Skip to content
Draft
1 change: 1 addition & 0 deletions .gemini/styleguide.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ When reviewing a pull request, focus on the following key areas:
* **Test Stability & Timeouts:**
* **Explicit Timeouts:** Avoid using `waitUntil` (or similar synchronization) without explicitly defining a `timeoutMillis`. Default timeouts are often too short for slower emulators (like API 28) or low-end devices, leading to flakiness.
* **Helper Functions for Waits:** If a wait condition is repeated (e.g., waiting for a specific UI element), extract it into a helper function (e.g., `waitForNodeWithTag`). This consolidates the logic and allows the timeout duration to be tuned centrally for that specific scenario.
* **Animation Bypassing for Tests:** Any new animation added to the UI **must** respect `LocalDisableAnimations` and snap to its end state or use a fixed state when animations are disabled, to prevent Espresso timeouts on slow emulators. [Introduced in PR #519]

6. **Documentation Sync**
* **Check for necessary updates:** Analyze if the PR's changes (e.g., adding a new feature, changing build logic, deprecating functionality) require updates to `README.md` or other documentation files.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,12 @@ internal val compatMainActivityExtras: Bundle?
extras.putString(MainActivity.KEY_DEBUG_SINGLE_LENS_MODE, "back")
}

val resolver = InstrumentationRegistry.getInstrumentation().targetContext.contentResolver
val args = TestStorage(resolver).getInputArgs()
val disableAnimations = args["disable_animations"]?.toBoolean() ?: false
if (disableAnimations) {
extras.putBoolean(MainActivity.KEY_DISABLE_ANIMATIONS, true)
}
return extras.takeIf { !it.isEmpty() }
}

Expand Down
102 changes: 55 additions & 47 deletions app/src/main/java/com/google/jetpackcamera/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
Expand Down Expand Up @@ -68,6 +69,7 @@ import com.google.jetpackcamera.model.ImageCaptureEvent
import com.google.jetpackcamera.model.LensFacing
import com.google.jetpackcamera.model.VideoCaptureEvent
import com.google.jetpackcamera.ui.JcaApp
import com.google.jetpackcamera.ui.components.capture.LocalDisableAnimations
import com.google.jetpackcamera.ui.theme.JetpackCameraTheme
import dagger.hilt.android.AndroidEntryPoint
import kotlin.collections.emptyList
Expand Down Expand Up @@ -113,57 +115,62 @@ class MainActivity : ComponentActivity() {
}
}

setContent {
when (uiState) {
Loading -> {
Column(
modifier = Modifier
.fillMaxSize()
.background(Color.Black),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
CircularProgressIndicator(modifier = Modifier.size(50.dp))
Text(text = stringResource(R.string.jca_loading), color = Color.White)
}
}
val disableAnimations = intent?.getBooleanExtra(KEY_DISABLE_ANIMATIONS, false) ?: false
Log.d(TAG, "LocalDisableAnimations: $disableAnimations")

is Success -> {
// TODO(kimblebee@): add app setting to enable/disable dynamic color
JetpackCameraTheme(
darkTheme = isInDarkMode(uiState = uiState),
dynamicColor = false
) {
Surface(
setContent {
CompositionLocalProvider(LocalDisableAnimations provides disableAnimations) {
when (uiState) {
Loading -> {
Column(
modifier = Modifier
.fillMaxSize()
.semantics {
testTagsAsResourceId = true
},
color = MaterialTheme.colorScheme.background
.background(Color.Black),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
JcaApp(
externalCaptureMode = externalCaptureMode,
shouldReviewAfterCapture = shouldReviewAfterCapture,
captureUris = captureUris,
debugSettings = debugSettings,
openAppSettings = ::openAppSettings,
onRequestWindowColorMode = { colorMode ->
// Window color mode APIs require API level 26+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Log.d(
TAG,
"Setting window color mode to:" +
" ${colorMode.toColorModeString()}"
)
window?.colorMode = colorMode
}
},
onFirstFrameCaptureCompleted = {
firstFrameComplete?.complete(Unit)
},
onCaptureEvent = captureEventCallback
)
CircularProgressIndicator(modifier = Modifier.size(50.dp))
Text(text = stringResource(R.string.jca_loading), color = Color.White)
}
}

is Success -> {
// TODO(kimblebee@): add app setting to enable/disable dynamic color
JetpackCameraTheme(
darkTheme = isInDarkMode(uiState = uiState),
dynamicColor = false
) {
Surface(
modifier = Modifier
.fillMaxSize()
.semantics {
testTagsAsResourceId = true
},
color = MaterialTheme.colorScheme.background
) {
JcaApp(
externalCaptureMode = externalCaptureMode,
shouldReviewAfterCapture = shouldReviewAfterCapture,
captureUris = captureUris,
debugSettings = debugSettings,
openAppSettings = ::openAppSettings,
onRequestWindowColorMode = { colorMode ->
// Window color mode APIs require API level 26+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Log.d(
TAG,
"Setting window color mode to:" +
" ${colorMode.toColorModeString()}"
)
window?.colorMode = colorMode
}
},
onFirstFrameCaptureCompleted = {
firstFrameComplete?.complete(Unit)
},
onCaptureEvent = captureEventCallback
)
}
}
}
}
Expand Down Expand Up @@ -310,6 +317,7 @@ class MainActivity : ComponentActivity() {

private const val KEY_DEBUG_MODE = "KEY_DEBUG_MODE"
const val KEY_DEBUG_SINGLE_LENS_MODE = "KEY_DEBUG_SINGLE_LENS_MODE"
const val KEY_DISABLE_ANIMATIONS = "KEY_DISABLE_ANIMATIONS"
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -550,10 +550,15 @@ private fun ContentScreen(
captureUiStateProvider()?.videoRecordingState is VideoRecordingState.Active
}
}
val disableAnimations = LocalDisableAnimations.current
AnimatedVisibility(
visible = isVisible.value,
enter = fadeIn(),
exit = fadeOut(animationSpec = tween(delayMillis = 1_500))
enter = if (disableAnimations) fadeIn(animationSpec = snap()) else fadeIn(),
exit = if (disableAnimations) {
fadeOut(animationSpec = snap())
} else {
fadeOut(animationSpec = tween(delayMillis = 1_500))
}
) {
val elapsedTimeModifier = remember(modifier) {
modifier.testTag(ELAPSED_TIME_TAG)
Expand All @@ -573,10 +578,15 @@ private fun ContentScreen(
@Composable { modifier: Modifier ->
val readyState = captureUiStateProvider()
if (readyState != null) {
val disableAnimations = LocalDisableAnimations.current
AnimatedVisibility(
visible = (readyState.videoRecordingState !is VideoRecordingState.Active),
enter = fadeIn(),
exit = fadeOut(animationSpec = tween(delayMillis = 1_500))
enter = if (disableAnimations) fadeIn(animationSpec = snap()) else fadeIn(),
exit = if (disableAnimations) {
fadeOut(animationSpec = snap())
} else {
fadeOut(animationSpec = tween(delayMillis = 1_500))
}
) {
quickSettingsController?.let { quickSettingsController ->
ToggleQuickSettingsButton(
Expand Down
Loading
Loading