Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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 @@ -96,6 +97,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 @@ -48,6 +48,7 @@ import kotlinx.coroutines.withContext
*
* This is useful for tests that require a valid surface provider but do not need to display the output.
*/
@android.annotation.SuppressLint("Recycle")
suspend fun SurfaceRequest.provideUpdatingSurface() {
var isReleased = false
val executor = Executors.newFixedThreadPool(1)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -456,6 +456,7 @@ private fun ContentScreen(
) {
@Composable { modifier: Modifier ->
PreviewDisplay(
modifier = modifier,
previewDisplayUiState = previewDisplayState.value,
onFlipCamera = onFlipCamera,
onTapToFocus = onTapToFocusLambda,
Expand Down
1 change: 1 addition & 0 deletions gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -44,5 +44,6 @@ 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.experimental.enableScreenshotTest=true
# Enabled parallel sync for Gradle 9.4+
org.gradle.tooling.parallel=true
3 changes: 3 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"
# See https://developer.android.com/jetpack/androidx/releases/compose-kotlin
kotlinPlugin = "2.2.0"
androidGradlePlugin = "9.3.1"
composeScreenshot = "0.0.1-alpha14"

accessibilityTestFramework = "4.1.1"

Expand Down Expand Up @@ -97,6 +98,7 @@ compose-accessibility = { module = "androidx.compose.ui:ui-test-junit4-accessibi
accessibility-test-framework = { module = "com.google.android.apps.common.testing.accessibility.framework:accessibility-test-framework", version.ref = "accessibilityTestFramework" }
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 @@ -132,4 +134,5 @@ dagger-hilt-android = { id = "com.google.dagger.hilt.android", version.ref = "hi

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" }
android-legacy-kapt = { id = "com.android.legacy-kapt", version.ref = "androidGradlePlugin" }
5 changes: 5 additions & 0 deletions ui/components/capture/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.android.legacy.kapt)
alias(libs.plugins.compose.compiler)
alias(libs.plugins.compose.screenshot)
}

android {
Expand All @@ -28,6 +29,8 @@ android {
}
}

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

defaultConfig {
minSdk = libs.versions.minSdk.get().toInt()
testOptions.targetSdk = libs.versions.targetSdk.get().toInt()
Expand Down Expand Up @@ -97,6 +100,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 Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
/*
* 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.SemanticsActions
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.performSemanticsAction
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.common.truth.Truth.assertThat
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() {
var imageCaptured = false
var recordingStarted = false
var recordingLocked = false
composeTestRule.setContent {
CaptureButton(
modifier = Modifier.testTag(CAPTURE_BUTTON),
onImageCapture = { imageCaptured = true },
onStartRecording = { recordingStarted = true },
onStopRecording = {},
onLockVideoRecording = { recordingLocked = it },
onIncrementZoom = {},
captureButtonUiState = CaptureButtonUiState.Enabled.Idle(CaptureMode.STANDARD)
)
}

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

composeTestRule.onNodeWithTag(CAPTURE_BUTTON)
.performSemanticsAction(SemanticsActions.OnClick)
assertThat(imageCaptured).isTrue()

composeTestRule.onNodeWithTag(CAPTURE_BUTTON)
.performSemanticsAction(SemanticsActions.OnLongClick)
assertThat(recordingLocked).isTrue()
assertThat(recordingStarted).isTrue()
}

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

@Test
fun captureButton_videoOnly_exists() {
var recordingStarted = false
var recordingLocked = false
composeTestRule.setContent {
CaptureButton(
modifier = Modifier.testTag(CAPTURE_BUTTON),
onImageCapture = {},
onStartRecording = { recordingStarted = true },
onStopRecording = {},
onLockVideoRecording = { recordingLocked = it },
onIncrementZoom = {},
captureButtonUiState = CaptureButtonUiState.Enabled.Idle(CaptureMode.VIDEO_ONLY)
)
}
composeTestRule.onRoot().tryPerformAccessibilityChecks()
composeTestRule.onNodeWithTag(CAPTURE_BUTTON).assertExists()
composeTestRule.onNodeWithTag(
CAPTURE_BUTTON
).assertContentDescriptionEquals("Start Video Recording")
composeTestRule.onNodeWithTag(CAPTURE_BUTTON, useUnmergedTree = true)
.assert(SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.Button))
composeTestRule.onNodeWithTag(CAPTURE_BUTTON)
.performSemanticsAction(SemanticsActions.OnClick)
assertThat(recordingLocked).isTrue()
assertThat(recordingStarted).isTrue()
}

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

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

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