Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
dfb50e4
Increase test timeouts for API 28 emulators
temcguir Jan 14, 2026
3494bdf
Update tests to use common timeouts rather than `waitUntil` directly
temcguir Jan 14, 2026
0090aed
Update style guide with some guidance on timeouts in tests
temcguir Jan 14, 2026
0787e1a
Add isCameraRunning state
temcguir Nov 11, 2025
0e9aa1b
Update more tests to use `waitForCaptureButton`
temcguir Jan 14, 2026
74fd572
Move visually debounced button state into its own `remember` hook
temcguir Nov 11, 2025
c1db907
Rename CaptureButtonUiState.Enabled to "Available"
temcguir Jan 12, 2026
4833dac
Add isEnabled property to CaptureButtonUiState to control button inte…
temcguir Jan 12, 2026
db5fdbf
Add documentation to CaptureButtonUiState
temcguir Jan 12, 2026
728a5cf
Add new test for CaptureButtonUiStateAdapter
temcguir Jan 13, 2026
1f5bccd
Add new Compose Previews for disabled capture button states
temcguir Jan 13, 2026
a4dea88
Remove repeated logic from `CaptureModeSettingsTest`
temcguir Jan 15, 2026
781b62e
Merge branch 'temcguir/improve_test_flakiness' into temcguir/capture_…
temcguir Jan 15, 2026
e448e80
Update CaptureButton Compose Previews to use real logic
temcguir Jan 13, 2026
cc0ca6b
Simplify Preview logic for CaptureButtonComponent
temcguir Jan 13, 2026
89b9ddb
Ensure pressed state for IMAGE_ONLY is right color
temcguir Jan 13, 2026
4737c83
Make capture button nucles correct color and size during capture
temcguir Jan 13, 2026
539a226
Ensure capture animation works with volume buttons
temcguir Jan 13, 2026
828c02e
Apply Spotless
temcguir Jan 14, 2026
940a090
Adjust size of capture button, ring stroke, and nucleus to match mocks
temcguir Jan 14, 2026
098cc25
Make capture button nucleus white when in idle VIDEO_ONLY mode
temcguir Jan 14, 2026
6e8f7c3
Add 50% black background to capture button to match mocks
temcguir Jan 14, 2026
647522b
Update compose previews to use a gradient background for higher vis
temcguir Jan 14, 2026
3553cd4
merge: resolve conflicts with main and keep PR 461 visuals
temcguir May 14, 2026
9d3a498
Setup build environment and dependencies for testing and screenshots
temcguir May 19, 2026
0c6620f
Improve accessibility of CaptureButton with content descriptions and …
temcguir May 19, 2026
b8e750a
Update CaptureButton visuals and animations to match spec
temcguir May 19, 2026
ef6f3ca
Implement accurate overlap detection for dynamic background
temcguir May 19, 2026
afe1a4e
Add screenshot tests and previews for all permutations
temcguir May 19, 2026
e3a84ad
Add generated reference screenshots for all permutations
temcguir May 19, 2026
8b4eee5
Extract magic numbers to constants and update styleguide
temcguir May 19, 2026
cbfcbe9
Add tests for PressedRecording and Disabled states of CaptureButton
temcguir May 19, 2026
08496f4
Enable automated accessibility checks and make CaptureButton focusable
temcguir May 19, 2026
74fc3fc
Update styleguide with explicit focusability rule for custom components
temcguir May 19, 2026
0d89719
Fix accessibility issues in CaptureButton:
temcguir May 20, 2026
29c4a18
Fix ring visibility in previews in CaptureButtonComponents.kt
temcguir May 20, 2026
27b6820
Consolidate previews and apply style guide fixes in CaptureButtonScre…
temcguir May 20, 2026
409ef19
Refactor capture button previews to use CompositionLocal for pressed …
temcguir May 20, 2026
837e4e2
Use string resources for CaptureButton content descriptions
temcguir May 20, 2026
5ba312f
Address review comments: move alpha values and durations to constants…
temcguir May 20, 2026
b4e3938
Merge branch 'main' into temcguir/update_capture_button_appearance
temcguir May 20, 2026
38f01e4
Clean up unnecessary changes and debug logs
temcguir May 22, 2026
68cc381
Apply spotless formatting to CaptureLayout.kt
temcguir May 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .gemini/styleguide.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ When reviewing a pull request, focus on the following key areas:
* **Remove Unused Imports:** Check for and remove any unused import statements to maintain code cleanliness.
* Look for potential null-safety issues, improper error handling, or resource leaks.
* **Promote Reusability (DRY Principle):** Identify duplicated or highly similar blocks of code. If a pattern of logic is repeated—even with minor variations—suggest extracting it into a reusable function, composable, or helper class.
* **Avoid Magic Numbers:** Avoid scattering literal dimension values or scales directly in the layout code. Instead, group them into a `private object Tokens` at the top of the file if file-scoped, or in a separate `Dimensions.kt` or `Tokens.kt` file if shared across features. Use semantic naming (e.g., `SmallPadding`) rather than value-based naming (e.g., `Dp16`).

3. **Performance and Efficiency**
* Scan for inefficient operations, especially within Composable functions (e.g., expensive calculations, improper state management leading to excessive recompositions).
Expand Down Expand Up @@ -87,6 +88,11 @@ When reviewing a pull request, focus on the following key areas:
* **Apply Proper Semantics:** When building custom UI components from the ground up (e.g., a custom button made of an `Icon` and a `Text`), apply the correct semantics to ensure they are accessible.
* Use `semantics { role = Role.Button }` (or `Role.Checkbox`, etc.) to define the component's logical purpose for screen readers.
* For components made of multiple parts that should be read as a single, coherent unit, use `semantics { mergeDescendants = true }`. This prevents screen readers from announcing inner elements (like an icon and its text label) as separate, unrelated items.
* **Explicit Focusability:** When building custom components that handle input manually via low-level gestures (e.g., using `pointerInput` or `detectTapGestures`) rather than `Modifier.clickable()`, they may not automatically become focusable. In such cases, explicitly add `Modifier.focusable()` to ensure they are reachable via keyboard navigation and analyzed by automated accessibility checks.
* **Content vs State Descriptions:**
* Use `contentDescription` to describe the **identity** or **action** of the component (e.g., "Capture Photo", "Start Video Recording").
* Use `stateDescription` to describe the **current state** of the component (e.g., "Locked", "Selected").
* **Avoid Redundancy:** Do not include state information or control type in `contentDescription` (e.g., avoid "Locked Video Button" or "Shutter Button"). Let the system announce role and state automatically.

## Rules for Providing Feedback
* **Be Constructive:** Frame feedback as suggestions, not commands. Explain the reasoning ("why") behind each comment.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -404,8 +404,9 @@ private fun ContentScreen(
)
},

viewfinder = {
viewfinder = { modifier ->
PreviewDisplay(
modifier = modifier,
previewDisplayUiState = captureUiState.previewDisplayUiState,
onFlipCamera = onFlipCamera,
onTapToFocus = cameraController?.let { it::tapToFocus } ?: { _, _ -> },
Expand All @@ -415,7 +416,7 @@ private fun ContentScreen(
focusMeteringUiState = captureUiState.focusMeteringUiState
)
},
captureButton = {
captureButton = { modifier ->
fun runCaptureAction(action: () -> Unit) {
if ((captureUiState.quickSettingsUiState as? QuickSettingsUiState.Available)
?.quickSettingsIsOpen == true
Expand All @@ -425,6 +426,7 @@ private fun ContentScreen(
action()
}
CaptureButton(
modifier = modifier,
captureButtonUiState = captureUiState.captureButtonUiState,
isQuickSettingsOpen = (
captureUiState.quickSettingsUiState as?
Expand Down
3 changes: 2 additions & 1 deletion gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,5 @@ android.nonFinalResIds=false
android.experimental.testOptions.managedDevices.maxConcurrentDevices=1
android.experimental.testOptions.managedDevices.setupTimeoutMinutes=180
# Ensure we can run managed devices on servers that don't support hardware rendering
android.testoptions.manageddevices.emulator.gpu=swiftshader_indirect
android.testoptions.manageddevices.emulator.gpu=swiftshader_indirect
android.experimental.enableScreenshotTest=true
6 changes: 6 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ accompanist = "0.37.3"
kotlinPlugin = "2.2.0"
androidGradlePlugin = "8.10.1"
protobufPlugin = "0.9.5"
composeScreenshot = "0.0.1-alpha14"

androidxActivityCompose = "1.10.1"
androidxAppCompat = "1.7.1"
Expand Down Expand Up @@ -64,6 +65,7 @@ androidx-benchmark-macro-junit4 = { module = "androidx.benchmark:benchmark-macro
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidxCoreKtx" }
androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "androidxDatastore" }
androidx-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "androidxTestEspresso" }
androidx-espresso-accessibility = { module = "androidx.test.espresso:espresso-accessibility", version.ref = "androidxTestEspresso" }
androidx-graphics-core = { module = "androidx.graphics:graphics-core", version.ref = "androidxGraphicsCore" }
androidx-junit = { module = "androidx.test.ext:junit", version.ref = "androidxTestJunit" }
androidx-lifecycle-livedata = { module = "androidx.lifecycle:lifecycle-livedata-ktx", version.ref = "androidxLifecycle" }
Expand All @@ -86,8 +88,11 @@ camera-video = { module = "androidx.camera:camera-video", version.ref = "android
camera-compose = { module = "androidx.camera:camera-compose", version.ref = "androidxCamera" }
compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" }
compose-junit = { module = "androidx.compose.ui:ui-test-junit4" }
compose-accessibility = { module = "androidx.compose.ui:ui-test-junit4-accessibility", version = "1.8.0-beta03" }
accessibility-test-framework = { module = "com.google.android.apps.common.testing.accessibility.framework:accessibility-test-framework", version = "4.1.1" }
compose-material3 = { module = "androidx.compose.material3:material3", version.ref = "composeMaterial" }
compose-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" }
screenshot-validation-api = { module = "com.android.tools.screenshot:screenshot-validation-api", version.ref = "composeScreenshot" }
compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
dagger-hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" }
Expand Down Expand Up @@ -121,3 +126,4 @@ dagger-hilt-android = { id = "com.google.dagger.hilt.android", version.ref = "hi
google-protobuf = { id = "com.google.protobuf", version.ref = "protobufPlugin" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlinPlugin" }
kotlin-kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlinPlugin" }
compose-screenshot = { id = "com.android.compose.screenshot", version.ref = "composeScreenshot" }
13 changes: 13 additions & 0 deletions ui/components/capture/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,15 @@ plugins {
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.kapt)
alias(libs.plugins.compose.compiler)
alias(libs.plugins.compose.screenshot)
}

android {
namespace = "com.google.jetpackcamera.ui.components.capture"
compileSdk = libs.versions.compileSdk.get().toInt()

experimentalProperties["android.experimental.enableScreenshotTest"] = true

defaultConfig {
minSdk = libs.versions.minSdk.get().toInt()
testOptions.targetSdk = libs.versions.targetSdk.get().toInt()
Expand Down Expand Up @@ -87,6 +90,8 @@ dependencies {
// noinspection TestManifestGradleConfiguration: required for release build unit tests
testImplementation(libs.compose.test.manifest)
testImplementation(libs.compose.junit)
screenshotTestImplementation(libs.screenshot.validation.api)
screenshotTestImplementation(libs.compose.ui.tooling)

// Testing
testImplementation(libs.junit)
Expand All @@ -98,6 +103,9 @@ dependencies {
implementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(libs.androidx.espresso.accessibility)
androidTestImplementation(libs.compose.accessibility)
androidTestImplementation(libs.accessibility.test.framework)

implementation(project(":ui:uistate"))
implementation(project(":ui:uistate:capture"))
Expand All @@ -116,3 +124,8 @@ dependencies {
kapt {
correctErrorTypes = true
}
configurations.all {
resolutionStrategy {
exclude(group = "com.google.protobuf", module = "protobuf-lite")
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
/*
* Copyright (C) 2026 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.jetpackcamera.ui.components.capture

import androidx.activity.ComponentActivity
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.test.SemanticsMatcher
import androidx.compose.ui.test.assert
import androidx.compose.ui.test.assertContentDescriptionEquals
import androidx.compose.ui.test.isNotEnabled
import androidx.compose.ui.test.junit4.accessibility.enableAccessibilityChecks
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.tryPerformAccessibilityChecks
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.android.apps.common.testing.accessibility.framework.AccessibilityCheckResult.AccessibilityCheckResultType
import com.google.android.apps.common.testing.accessibility.framework.integrations.espresso.AccessibilityValidator
import com.google.jetpackcamera.model.CaptureMode
import com.google.jetpackcamera.ui.uistate.capture.CaptureButtonUiState
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
class CaptureButtonTest {
@get:Rule
val composeTestRule = createAndroidComposeRule<ComponentActivity>()

@Before
fun setUp() {
composeTestRule.enableAccessibilityChecks(
AccessibilityValidator().setRunChecksFromRootView(true).also {
it.setThrowExceptionFor(AccessibilityCheckResultType.ERROR)
}
)
}

@Test
fun captureButton_standard_exists() {
composeTestRule.setContent {
CaptureButton(
modifier = Modifier.testTag("CaptureButtonTestTag"),
onImageCapture = {},
onStartRecording = {},
onStopRecording = {},
onLockVideoRecording = {},
onIncrementZoom = {},
captureButtonUiState = CaptureButtonUiState.Enabled.Idle(CaptureMode.STANDARD)
)
}

composeTestRule.onNodeWithTag("CaptureButtonTestTag").assertExists()
composeTestRule.onNodeWithTag(
"CaptureButtonTestTag"
).assertContentDescriptionEquals("Capture Photo")
composeTestRule.onNodeWithTag("CaptureButtonTestTag", useUnmergedTree = true)
.assert(SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.Button))
composeTestRule.onRoot().tryPerformAccessibilityChecks()
}

@Test
fun captureButton_imageOnly_exists() {
composeTestRule.setContent {
CaptureButton(
modifier = Modifier.testTag("CaptureButtonImageOnly"),
onImageCapture = {},
onStartRecording = {},
onStopRecording = {},
onLockVideoRecording = {},
onIncrementZoom = {},
captureButtonUiState = CaptureButtonUiState.Enabled.Idle(CaptureMode.IMAGE_ONLY)
)
}
composeTestRule.onRoot().tryPerformAccessibilityChecks()
composeTestRule.onNodeWithTag("CaptureButtonImageOnly").assertExists()
composeTestRule.onNodeWithTag(
"CaptureButtonImageOnly"
).assertContentDescriptionEquals("Capture Photo")
composeTestRule.onNodeWithTag("CaptureButtonImageOnly", useUnmergedTree = true)
.assert(SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.Button))
}

@Test
fun captureButton_videoOnly_exists() {
composeTestRule.setContent {
CaptureButton(
modifier = Modifier.testTag("CaptureButtonVideoOnly"),
onImageCapture = {},
onStartRecording = {},
onStopRecording = {},
onLockVideoRecording = {},
onIncrementZoom = {},
captureButtonUiState = CaptureButtonUiState.Enabled.Idle(CaptureMode.VIDEO_ONLY)
)
}
composeTestRule.onRoot().tryPerformAccessibilityChecks()
composeTestRule.onNodeWithTag("CaptureButtonVideoOnly").assertExists()
composeTestRule.onNodeWithTag(
"CaptureButtonVideoOnly"
).assertContentDescriptionEquals("Start Video Recording")
composeTestRule.onNodeWithTag("CaptureButtonVideoOnly", useUnmergedTree = true)
.assert(SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.Button))
}

@Test
fun captureButton_lockedRecording_exists() {
composeTestRule.setContent {
CaptureButton(
modifier = Modifier.testTag("CaptureButtonLocked"),
onImageCapture = {},
onStartRecording = {},
onStopRecording = {},
onLockVideoRecording = {},
onIncrementZoom = {},
captureButtonUiState = CaptureButtonUiState.Enabled.Recording.LockedRecording
)
}
composeTestRule.onRoot().tryPerformAccessibilityChecks()
composeTestRule.onNodeWithTag("CaptureButtonLocked").assertExists()
composeTestRule.onNodeWithTag(
"CaptureButtonLocked"
).assertContentDescriptionEquals("Stop Video Recording")
composeTestRule.onNodeWithTag("CaptureButtonLocked", useUnmergedTree = true)
.assert(SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.Button))
}

@Test
fun captureButton_pressedRecording_exists() {
composeTestRule.setContent {
CaptureButton(
modifier = Modifier.testTag("CaptureButtonPressedRecording"),
onImageCapture = {},
onStartRecording = {},
onStopRecording = {},
onLockVideoRecording = {},
onIncrementZoom = {},
captureButtonUiState = CaptureButtonUiState.Enabled.Recording.PressedRecording
)
}
composeTestRule.onRoot().tryPerformAccessibilityChecks()
composeTestRule.onNodeWithTag("CaptureButtonPressedRecording").assertExists()
composeTestRule.onNodeWithTag(
"CaptureButtonPressedRecording"
).assertContentDescriptionEquals("Recording Video")
composeTestRule.onNodeWithTag("CaptureButtonPressedRecording", useUnmergedTree = true)
.assert(SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.Button))
}

@Test
fun captureButton_disabled_exists() {
composeTestRule.setContent {
CaptureButton(
modifier = Modifier.testTag("CaptureButtonDisabled"),
onImageCapture = {},
onStartRecording = {},
onStopRecording = {},
onLockVideoRecording = {},
onIncrementZoom = {},
captureButtonUiState = CaptureButtonUiState.Enabled.Idle(
CaptureMode.STANDARD,
isEnabled = false
)
)
}
composeTestRule.onRoot().tryPerformAccessibilityChecks()
composeTestRule.onNodeWithTag("CaptureButtonDisabled").assertExists()
composeTestRule.onNodeWithTag(
"CaptureButtonDisabled"
).assert(androidx.compose.ui.test.isNotEnabled())
}
}
Loading
Loading