From dfb50e4e39f1edb5d5c25b324571715dd6eef4a0 Mon Sep 17 00:00:00 2001 From: Trevor McGuire Date: Wed, 14 Jan 2026 11:36:20 -0800 Subject: [PATCH 01/12] Increase test timeouts for API 28 emulators --- .../java/com/google/jetpackcamera/utils/UiTestUtil.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/src/androidTest/java/com/google/jetpackcamera/utils/UiTestUtil.kt b/app/src/androidTest/java/com/google/jetpackcamera/utils/UiTestUtil.kt index c80b971c7..f6721e203 100644 --- a/app/src/androidTest/java/com/google/jetpackcamera/utils/UiTestUtil.kt +++ b/app/src/androidTest/java/com/google/jetpackcamera/utils/UiTestUtil.kt @@ -74,13 +74,13 @@ val compatMainActivityExtras: Bundle? val debugExtra: Bundle = Bundle().apply { putBoolean("KEY_DEBUG_MODE", true) } val cacheExtra: Bundle = Bundle().apply { putBoolean("KEY_REVIEW_AFTER_CAPTURE", true) } -const val DEFAULT_TIMEOUT_MILLIS = 1_000L -const val APP_START_TIMEOUT_MILLIS = 10_000L +const val DEFAULT_TIMEOUT_MILLIS = 5_000L +const val APP_START_TIMEOUT_MILLIS = 20_000L const val ELAPSED_TIME_TEXT_TIMEOUT_MILLIS = 45_000L const val SCREEN_FLASH_OVERLAY_TIMEOUT_MILLIS = 5_000L const val IMAGE_CAPTURE_TIMEOUT_MILLIS = 45_000L -const val VIDEO_CAPTURE_TIMEOUT_MILLIS = 5_000L -const val SAVE_MEDIA_TIMEOUT_MILLIS = 5_000L +const val VIDEO_CAPTURE_TIMEOUT_MILLIS = 15_000L +const val SAVE_MEDIA_TIMEOUT_MILLIS = 15_000L const val IMAGE_WELL_LOAD_TIMEOUT_MILLIS = 10_000L const val VIDEO_DURATION_MILLIS = 3_000L From 3494bdffdfa06b3765ccbf041c30bba4ef2e5e99 Mon Sep 17 00:00:00 2001 From: Trevor McGuire Date: Wed, 14 Jan 2026 13:49:35 -0800 Subject: [PATCH 02/12] Update tests to use common timeouts rather than `waitUntil` directly --- .../CachedImageCaptureDeviceTest.kt | 19 +-- .../CachedVideoRecordingDeviceTest.kt | 21 ++-- .../jetpackcamera/CaptureModeSettingsTest.kt | 40 +++--- .../jetpackcamera/ConcurrentCameraTest.kt | 13 +- .../jetpackcamera/DebugHideComponentsTest.kt | 12 +- .../google/jetpackcamera/FlashDeviceTest.kt | 26 ++-- .../google/jetpackcamera/FocusMeteringTest.kt | 17 ++- .../jetpackcamera/ImageCaptureDeviceTest.kt | 20 ++- .../google/jetpackcamera/NavigationTest.kt | 18 ++- .../google/jetpackcamera/PermissionsTest.kt | 114 +++++++++--------- .../google/jetpackcamera/PostCaptureTest.kt | 98 ++++++--------- .../google/jetpackcamera/SwitchCameraTest.kt | 6 +- .../jetpackcamera/VideoRecordingDeviceTest.kt | 19 +-- .../jetpackcamera/utils/ComposeTestRuleExt.kt | 27 +++++ 14 files changed, 220 insertions(+), 230 deletions(-) diff --git a/app/src/androidTest/java/com/google/jetpackcamera/CachedImageCaptureDeviceTest.kt b/app/src/androidTest/java/com/google/jetpackcamera/CachedImageCaptureDeviceTest.kt index 13cdc1145..20bd7076b 100644 --- a/app/src/androidTest/java/com/google/jetpackcamera/CachedImageCaptureDeviceTest.kt +++ b/app/src/androidTest/java/com/google/jetpackcamera/CachedImageCaptureDeviceTest.kt @@ -51,6 +51,7 @@ import com.google.jetpackcamera.utils.getTestUri import com.google.jetpackcamera.utils.runMainActivityScenarioTest import com.google.jetpackcamera.utils.runMainActivityScenarioTestForResult import com.google.jetpackcamera.utils.waitForCaptureButton +import com.google.jetpackcamera.utils.waitForNodeWithTag import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -79,12 +80,11 @@ class CachedImageCaptureDeviceTest { .performClick() // navigate to postcapture screen - composeTestRule.waitUntil(timeoutMillis = VIDEO_CAPTURE_TIMEOUT_MILLIS) { - composeTestRule.onNodeWithTag(VIEWER_POST_CAPTURE_IMAGE).isDisplayed() - } - composeTestRule.waitUntil { - composeTestRule.onNodeWithTag(BUTTON_POST_CAPTURE_EXIT).isDisplayed() - } + composeTestRule.waitForNodeWithTag( + VIEWER_POST_CAPTURE_IMAGE, + timeoutMillis = VIDEO_CAPTURE_TIMEOUT_MILLIS + ) + composeTestRule.waitForNodeWithTag(BUTTON_POST_CAPTURE_EXIT) composeTestRule.onNodeWithTag(BUTTON_POST_CAPTURE_EXIT).performClick() composeTestRule.waitForCaptureButton() @@ -135,9 +135,10 @@ class CachedImageCaptureDeviceTest { .assertExists() .performClick() - composeTestRule.waitUntil(timeoutMillis = IMAGE_CAPTURE_TIMEOUT_MILLIS) { - composeTestRule.onNodeWithTag(IMAGE_CAPTURE_FAILURE_TAG).isDisplayed() - } + composeTestRule.waitForNodeWithTag( + IMAGE_CAPTURE_FAILURE_TAG, + timeoutMillis = IMAGE_CAPTURE_TIMEOUT_MILLIS + ) uiDevice.pressBack() } Truth.assertThat(result.resultCode).isEqualTo(Activity.RESULT_CANCELED) diff --git a/app/src/androidTest/java/com/google/jetpackcamera/CachedVideoRecordingDeviceTest.kt b/app/src/androidTest/java/com/google/jetpackcamera/CachedVideoRecordingDeviceTest.kt index fe00e076a..4728de0f6 100644 --- a/app/src/androidTest/java/com/google/jetpackcamera/CachedVideoRecordingDeviceTest.kt +++ b/app/src/androidTest/java/com/google/jetpackcamera/CachedVideoRecordingDeviceTest.kt @@ -46,6 +46,7 @@ import com.google.jetpackcamera.utils.longClickForVideoRecordingCheckingElapsedT import com.google.jetpackcamera.utils.runMainActivityScenarioTest import com.google.jetpackcamera.utils.runScenarioTestForResult import com.google.jetpackcamera.utils.waitForCaptureButton +import com.google.jetpackcamera.utils.waitForNodeWithTag import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -70,14 +71,11 @@ class CachedVideoRecordingDeviceTest { composeTestRule.longClickForVideoRecordingCheckingElapsedTime() // navigate to postcapture screen - composeTestRule.waitUntil(timeoutMillis = VIDEO_CAPTURE_TIMEOUT_MILLIS) { - composeTestRule.onNodeWithTag(VIEWER_POST_CAPTURE_VIDEO).isDisplayed() - } - composeTestRule.waitUntil { - composeTestRule.onNodeWithTag( - BUTTON_POST_CAPTURE_EXIT - ).isDisplayed() - } + composeTestRule.waitForNodeWithTag( + VIEWER_POST_CAPTURE_VIDEO, + VIDEO_CAPTURE_TIMEOUT_MILLIS + ) + composeTestRule.waitForNodeWithTag(BUTTON_POST_CAPTURE_EXIT) composeTestRule.onNodeWithTag(BUTTON_POST_CAPTURE_EXIT).performClick() composeTestRule.waitForCaptureButton() @@ -116,9 +114,10 @@ class CachedVideoRecordingDeviceTest { composeTestRule.onNodeWithTag(CAPTURE_BUTTON).isDisplayed() } composeTestRule.longClickForVideoRecording() - composeTestRule.waitUntil(timeoutMillis = VIDEO_CAPTURE_TIMEOUT_MILLIS) { - composeTestRule.onNodeWithTag(VIDEO_CAPTURE_FAILURE_TAG).isDisplayed() - } + composeTestRule.waitForNodeWithTag( + VIDEO_CAPTURE_FAILURE_TAG, + VIDEO_CAPTURE_TIMEOUT_MILLIS + ) uiDevice.pressBack() } Truth.assertThat(result.resultCode).isEqualTo(Activity.RESULT_CANCELED) diff --git a/app/src/androidTest/java/com/google/jetpackcamera/CaptureModeSettingsTest.kt b/app/src/androidTest/java/com/google/jetpackcamera/CaptureModeSettingsTest.kt index 3ece418b0..473e3e58c 100644 --- a/app/src/androidTest/java/com/google/jetpackcamera/CaptureModeSettingsTest.kt +++ b/app/src/androidTest/java/com/google/jetpackcamera/CaptureModeSettingsTest.kt @@ -20,8 +20,6 @@ import android.provider.MediaStore import androidx.compose.ui.geometry.Offset import androidx.compose.ui.test.assertIsEnabled import androidx.compose.ui.test.assertIsNotEnabled -import androidx.compose.ui.test.isDisplayed -import androidx.compose.ui.test.isNotDisplayed import androidx.compose.ui.test.junit4.ComposeTestRule import androidx.compose.ui.test.junit4.createEmptyComposeRule import androidx.compose.ui.test.onNodeWithTag @@ -63,7 +61,9 @@ import com.google.jetpackcamera.utils.unFocusQuickSetting import com.google.jetpackcamera.utils.visitQuickSettings import com.google.jetpackcamera.utils.wait import com.google.jetpackcamera.utils.waitForCaptureButton +import com.google.jetpackcamera.utils.waitForCaptureModeToggleState import com.google.jetpackcamera.utils.waitForNodeWithTag +import com.google.jetpackcamera.utils.waitForNodeWithTagToDisappear import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -108,9 +108,7 @@ internal class CaptureModeSettingsTest { setCaptureMode(captureMode) } - waitUntil(DEFAULT_TIMEOUT_MILLIS) { - onNodeWithTag(CAPTURE_MODE_TOGGLE_BUTTON).isDisplayed() - } + waitForNodeWithTag(CAPTURE_MODE_TOGGLE_BUTTON, DEFAULT_TIMEOUT_MILLIS) onNodeWithTag(CAPTURE_MODE_TOGGLE_BUTTON).assertExists() } @@ -393,18 +391,19 @@ internal class CaptureModeSettingsTest { composeTestRule.initializeCaptureSwitch() val initialCaptureMode = composeTestRule.getCaptureModeToggleState() + val targetCaptureMode = if (initialCaptureMode == CaptureMode.IMAGE_ONLY) { + CaptureMode.VIDEO_ONLY + } else { + CaptureMode.IMAGE_ONLY + } // should be different from initial capture mode composeTestRule.onNodeWithTag(CAPTURE_MODE_TOGGLE_BUTTON).performClick() - composeTestRule.waitUntil { - composeTestRule.getCaptureModeToggleState() != initialCaptureMode - } + composeTestRule.waitForCaptureModeToggleState(targetCaptureMode) // should now be she same as the initial capture mode. composeTestRule.onNodeWithTag(CAPTURE_MODE_TOGGLE_BUTTON).performClick() - composeTestRule.waitUntil { - composeTestRule.getCaptureModeToggleState() == initialCaptureMode - } + composeTestRule.waitForCaptureModeToggleState(initialCaptureMode) } @Test @@ -413,6 +412,11 @@ internal class CaptureModeSettingsTest { composeTestRule.waitForCaptureButton() composeTestRule.initializeCaptureSwitch() val initialCaptureMode = composeTestRule.getCaptureModeToggleState() + val targetCaptureMode = if (initialCaptureMode == CaptureMode.IMAGE_ONLY) { + CaptureMode.VIDEO_ONLY + } else { + CaptureMode.IMAGE_ONLY + } val captureToggleNode = composeTestRule.onNodeWithTag(CAPTURE_MODE_TOGGLE_BUTTON) val toggleNodeWidth = captureToggleNode.fetchSemanticsNode().size.width.toFloat() val offsetToSwitch = when (initialCaptureMode) { @@ -442,9 +446,7 @@ internal class CaptureModeSettingsTest { captureToggleNode.performTouchInput { up() } - composeTestRule.waitUntil { - initialCaptureMode != composeTestRule.getCaptureModeToggleState() - } + composeTestRule.waitForCaptureModeToggleState(targetCaptureMode) } @Test @@ -459,17 +461,13 @@ internal class CaptureModeSettingsTest { // start recording composeTestRule.tapStartLockedVideoRecording() // check that recording - composeTestRule.waitUntil { - composeTestRule.onNodeWithTag(CAPTURE_MODE_TOGGLE_BUTTON).isNotDisplayed() - } + composeTestRule.waitForNodeWithTagToDisappear(CAPTURE_MODE_TOGGLE_BUTTON) // stop recording composeTestRule.onNodeWithTag(CAPTURE_BUTTON).assertExists().performClick() - composeTestRule.waitUntil { - composeTestRule.onNodeWithTag(CAPTURE_MODE_TOGGLE_BUTTON).isDisplayed() && - composeTestRule.getCaptureModeToggleState() == CaptureMode.VIDEO_ONLY - } + composeTestRule.waitForNodeWithTag(CAPTURE_MODE_TOGGLE_BUTTON) + composeTestRule.waitForCaptureModeToggleState(CaptureMode.VIDEO_ONLY) deleteFilesInDirAfterTimestamp(MOVIES_DIR_PATH, instrumentation, timeStamp) } diff --git a/app/src/androidTest/java/com/google/jetpackcamera/ConcurrentCameraTest.kt b/app/src/androidTest/java/com/google/jetpackcamera/ConcurrentCameraTest.kt index 2658934f8..53d2292dc 100644 --- a/app/src/androidTest/java/com/google/jetpackcamera/ConcurrentCameraTest.kt +++ b/app/src/androidTest/java/com/google/jetpackcamera/ConcurrentCameraTest.kt @@ -20,7 +20,6 @@ import androidx.compose.ui.semantics.SemanticsProperties import androidx.compose.ui.test.SemanticsNodeInteraction import androidx.compose.ui.test.assert import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.isDisplayed import androidx.compose.ui.test.isEnabled import androidx.compose.ui.test.isNotEnabled import androidx.compose.ui.test.junit4.createEmptyComposeRule @@ -33,7 +32,6 @@ import com.google.common.truth.Truth.assertThat import com.google.jetpackcamera.MainActivity import com.google.jetpackcamera.model.ConcurrentCameraMode import com.google.jetpackcamera.ui.components.capture.BTN_QUICK_SETTINGS_FOCUS_CAPTURE_MODE -import com.google.jetpackcamera.ui.components.capture.CAPTURE_BUTTON import com.google.jetpackcamera.ui.components.capture.FLIP_CAMERA_BUTTON import com.google.jetpackcamera.ui.components.capture.QUICK_SETTINGS_CONCURRENT_CAMERA_MODE_BUTTON import com.google.jetpackcamera.ui.components.capture.QUICK_SETTINGS_DROP_DOWN @@ -44,7 +42,6 @@ import com.google.jetpackcamera.ui.components.capture.QUICK_SETTINGS_RATIO_BUTTO import com.google.jetpackcamera.ui.components.capture.QUICK_SETTINGS_STREAM_CONFIG_BUTTON import com.google.jetpackcamera.ui.components.capture.R import com.google.jetpackcamera.ui.components.capture.VIDEO_CAPTURE_SUCCESS_TAG -import com.google.jetpackcamera.utils.APP_START_TIMEOUT_MILLIS import com.google.jetpackcamera.utils.TEST_REQUIRED_PERMISSIONS import com.google.jetpackcamera.utils.VIDEO_CAPTURE_TIMEOUT_MILLIS import com.google.jetpackcamera.utils.assume @@ -53,6 +50,8 @@ import com.google.jetpackcamera.utils.longClickForVideoRecordingCheckingElapsedT import com.google.jetpackcamera.utils.runMainActivityMediaStoreAutoDeleteScenarioTest import com.google.jetpackcamera.utils.runMainActivityScenarioTest import com.google.jetpackcamera.utils.stateDescriptionMatches +import com.google.jetpackcamera.utils.waitForCaptureButton +import com.google.jetpackcamera.utils.waitForNodeWithTag import kotlinx.coroutines.runBlocking import org.junit.Rule import org.junit.Test @@ -280,9 +279,7 @@ class ConcurrentCameraTest { longClickForVideoRecordingCheckingElapsedTime() - waitUntil(timeoutMillis = VIDEO_CAPTURE_TIMEOUT_MILLIS) { - composeTestRule.onNodeWithTag(VIDEO_CAPTURE_SUCCESS_TAG).isDisplayed() - } + waitForNodeWithTag(VIDEO_CAPTURE_SUCCESS_TAG, VIDEO_CAPTURE_TIMEOUT_MILLIS) } } @@ -296,9 +293,7 @@ class ConcurrentCameraTest { ) { val wrappedBlock: ActivityScenario.() -> Unit = { // Wait for the capture button to be displayed - composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) { - composeTestRule.onNodeWithTag(CAPTURE_BUTTON).isDisplayed() - } + composeTestRule.waitForCaptureButton() // /////////////////////////////////////////////////// // Check that the device supports concurrent camera // diff --git a/app/src/androidTest/java/com/google/jetpackcamera/DebugHideComponentsTest.kt b/app/src/androidTest/java/com/google/jetpackcamera/DebugHideComponentsTest.kt index 8b9bc1688..d908b2f56 100644 --- a/app/src/androidTest/java/com/google/jetpackcamera/DebugHideComponentsTest.kt +++ b/app/src/androidTest/java/com/google/jetpackcamera/DebugHideComponentsTest.kt @@ -15,8 +15,6 @@ */ package com.google.jetpackcamera -import androidx.compose.ui.test.isDisplayed -import androidx.compose.ui.test.isNotDisplayed import androidx.compose.ui.test.junit4.createEmptyComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performClick @@ -39,6 +37,8 @@ import com.google.jetpackcamera.utils.TEST_REQUIRED_PERMISSIONS import com.google.jetpackcamera.utils.debugExtra import com.google.jetpackcamera.utils.runMainActivityScenarioTest import com.google.jetpackcamera.utils.waitForCaptureButton +import com.google.jetpackcamera.utils.waitForNodeWithTag +import com.google.jetpackcamera.utils.waitForNodeWithTagToDisappear import org.junit.Before import org.junit.Rule import org.junit.Test @@ -73,9 +73,7 @@ class DebugHideComponentsTest { composeTestRule.onNodeWithTag(BTN_DEBUG_HIDE_COMPONENTS_TAG).performClick() - composeTestRule.waitUntil { - composeTestRule.onNodeWithTag(CAPTURE_BUTTON).isNotDisplayed() - } + composeTestRule.waitForNodeWithTagToDisappear(CAPTURE_BUTTON) composeTestRule.onNodeWithTag(ZOOM_BUTTON_ROW_TAG).assertDoesNotExist() composeTestRule.onNodeWithTag(FLIP_CAMERA_BUTTON).assertDoesNotExist() composeTestRule.onNodeWithTag(AMPLITUDE_NONE_TAG).assertDoesNotExist() @@ -87,9 +85,7 @@ class DebugHideComponentsTest { composeTestRule.onNodeWithTag(BTN_DEBUG_HIDE_COMPONENTS_TAG).performClick() - composeTestRule.waitUntil { - composeTestRule.onNodeWithTag(CAPTURE_BUTTON).isDisplayed() - } + composeTestRule.waitForNodeWithTag(CAPTURE_BUTTON) composeTestRule.onNodeWithTag(FLIP_CAMERA_BUTTON).assertExists() composeTestRule.onNodeWithTag(DEBUG_OVERLAY_BUTTON).assertExists() composeTestRule.onNodeWithTag(LOGICAL_CAMERA_ID_TAG).assertExists() diff --git a/app/src/androidTest/java/com/google/jetpackcamera/FlashDeviceTest.kt b/app/src/androidTest/java/com/google/jetpackcamera/FlashDeviceTest.kt index e418a23f3..90758b0a8 100644 --- a/app/src/androidTest/java/com/google/jetpackcamera/FlashDeviceTest.kt +++ b/app/src/androidTest/java/com/google/jetpackcamera/FlashDeviceTest.kt @@ -46,6 +46,7 @@ import com.google.jetpackcamera.utils.longClickForVideoRecordingCheckingElapsedT import com.google.jetpackcamera.utils.runMainActivityMediaStoreAutoDeleteScenarioTest import com.google.jetpackcamera.utils.runMainActivityScenarioTest import com.google.jetpackcamera.utils.setFlashMode +import com.google.jetpackcamera.utils.waitForNodeWithTag import org.junit.Before import org.junit.Rule import org.junit.Test @@ -141,9 +142,7 @@ internal class FlashDeviceTest { .assertExists() .performClick() - composeTestRule.waitUntil(timeoutMillis = IMAGE_CAPTURE_TIMEOUT_MILLIS) { - composeTestRule.onNodeWithTag(IMAGE_CAPTURE_SUCCESS_TAG).isDisplayed() - } + composeTestRule.waitForNodeWithTag(IMAGE_CAPTURE_SUCCESS_TAG, IMAGE_CAPTURE_TIMEOUT_MILLIS) } @Test @@ -172,13 +171,15 @@ internal class FlashDeviceTest { .assertExists() .performClick() - composeTestRule.waitUntil(timeoutMillis = SCREEN_FLASH_OVERLAY_TIMEOUT_MILLIS) { - composeTestRule.onNodeWithTag(SCREEN_FLASH_OVERLAY).isDisplayed() - } + composeTestRule.waitForNodeWithTag( + SCREEN_FLASH_OVERLAY, + SCREEN_FLASH_OVERLAY_TIMEOUT_MILLIS + ) - composeTestRule.waitUntil(timeoutMillis = IMAGE_CAPTURE_TIMEOUT_MILLIS) { - composeTestRule.onNodeWithTag(IMAGE_CAPTURE_SUCCESS_TAG).isDisplayed() - } + composeTestRule.waitForNodeWithTag( + IMAGE_CAPTURE_SUCCESS_TAG, + IMAGE_CAPTURE_TIMEOUT_MILLIS + ) } @Test @@ -209,8 +210,9 @@ internal class FlashDeviceTest { composeTestRule.setFlashMode(FlashMode.ON) composeTestRule.longClickForVideoRecordingCheckingElapsedTime() - composeTestRule.waitUntil(timeoutMillis = VIDEO_CAPTURE_TIMEOUT_MILLIS) { - composeTestRule.onNodeWithTag(VIDEO_CAPTURE_SUCCESS_TAG).isDisplayed() - } + composeTestRule.waitForNodeWithTag( + VIDEO_CAPTURE_SUCCESS_TAG, + VIDEO_CAPTURE_TIMEOUT_MILLIS + ) } } diff --git a/app/src/androidTest/java/com/google/jetpackcamera/FocusMeteringTest.kt b/app/src/androidTest/java/com/google/jetpackcamera/FocusMeteringTest.kt index f55e341ca..f72ebbd16 100644 --- a/app/src/androidTest/java/com/google/jetpackcamera/FocusMeteringTest.kt +++ b/app/src/androidTest/java/com/google/jetpackcamera/FocusMeteringTest.kt @@ -18,8 +18,6 @@ package com.google.jetpackcamera import androidx.compose.ui.geometry.Offset import androidx.compose.ui.platform.ViewConfiguration import androidx.compose.ui.test.click -import androidx.compose.ui.test.isDisplayed -import androidx.compose.ui.test.isNotDisplayed import androidx.compose.ui.test.junit4.createEmptyComposeRule import androidx.compose.ui.test.onAllNodesWithTag import androidx.compose.ui.test.onNodeWithTag @@ -37,6 +35,8 @@ import com.google.jetpackcamera.utils.debugExtra import com.google.jetpackcamera.utils.runMainActivityScenarioTest import com.google.jetpackcamera.utils.wait import com.google.jetpackcamera.utils.waitForCaptureButton +import com.google.jetpackcamera.utils.waitForNodeWithTag +import com.google.jetpackcamera.utils.waitForNodeWithTagToDisappear import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -58,9 +58,7 @@ class FocusMeteringTest { // Hide all components so we don't accidentally tap on them composeTestRule.onNodeWithTag(BTN_DEBUG_HIDE_COMPONENTS_TAG).performClick() - composeTestRule.waitUntil { - composeTestRule.onNodeWithTag(CAPTURE_BUTTON).isNotDisplayed() - } + composeTestRule.waitForNodeWithTagToDisappear(CAPTURE_BUTTON) // Define the four quadrants of the screen val quadrants = listOf( @@ -79,11 +77,10 @@ class FocusMeteringTest { performTouchInput { click(position = percentOffset(x, y)) } // Wait for the focus metering indicator to be visible - composeTestRule.waitUntil(FOCUS_METERING_INDICATOR_TIMEOUT_MILLIS) { - composeTestRule.onNodeWithTag( - FOCUS_METERING_INDICATOR_TAG - ).isDisplayed() - } + composeTestRule.waitForNodeWithTag( + FOCUS_METERING_INDICATOR_TAG, + FOCUS_METERING_INDICATOR_TIMEOUT_MILLIS + ) composeTestRule.waitUntil(FOCUS_METERING_INDICATOR_TIMEOUT_MILLIS) { composeTestRule.onAllNodesWithTag(FOCUS_METERING_INDICATOR_TAG).run { diff --git a/app/src/androidTest/java/com/google/jetpackcamera/ImageCaptureDeviceTest.kt b/app/src/androidTest/java/com/google/jetpackcamera/ImageCaptureDeviceTest.kt index cf9b896d7..0139e3f8c 100644 --- a/app/src/androidTest/java/com/google/jetpackcamera/ImageCaptureDeviceTest.kt +++ b/app/src/androidTest/java/com/google/jetpackcamera/ImageCaptureDeviceTest.kt @@ -53,6 +53,7 @@ import com.google.jetpackcamera.utils.getTestUri import com.google.jetpackcamera.utils.longClickForVideoRecording import com.google.jetpackcamera.utils.runMainActivityMediaStoreAutoDeleteScenarioTest import com.google.jetpackcamera.utils.runMainActivityScenarioTestForResult +import com.google.jetpackcamera.utils.waitForNodeWithTag import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -84,9 +85,7 @@ internal class ImageCaptureDeviceTest { composeTestRule.onNodeWithTag(CAPTURE_BUTTON) .assertExists() .performClick() - composeTestRule.waitUntil(timeoutMillis = IMAGE_CAPTURE_TIMEOUT_MILLIS) { - composeTestRule.onNodeWithTag(IMAGE_CAPTURE_SUCCESS_TAG).isDisplayed() - } + composeTestRule.waitForNodeWithTag(IMAGE_CAPTURE_SUCCESS_TAG, IMAGE_CAPTURE_TIMEOUT_MILLIS) } @Test @@ -101,9 +100,7 @@ internal class ImageCaptureDeviceTest { uiDevice.pressKeyCode(KeyEvent.KEYCODE_VOLUME_UP) - composeTestRule.waitUntil(timeoutMillis = IMAGE_CAPTURE_TIMEOUT_MILLIS) { - composeTestRule.onNodeWithTag(IMAGE_CAPTURE_SUCCESS_TAG).isDisplayed() - } + composeTestRule.waitForNodeWithTag(IMAGE_CAPTURE_SUCCESS_TAG, IMAGE_CAPTURE_TIMEOUT_MILLIS) } @Test @@ -117,9 +114,7 @@ internal class ImageCaptureDeviceTest { } uiDevice.pressKeyCode(KeyEvent.KEYCODE_VOLUME_DOWN) - composeTestRule.waitUntil(timeoutMillis = IMAGE_CAPTURE_TIMEOUT_MILLIS) { - composeTestRule.onNodeWithTag(IMAGE_CAPTURE_SUCCESS_TAG).isDisplayed() - } + composeTestRule.waitForNodeWithTag(IMAGE_CAPTURE_SUCCESS_TAG, IMAGE_CAPTURE_TIMEOUT_MILLIS) } @Test @@ -164,9 +159,10 @@ internal class ImageCaptureDeviceTest { .assertExists() .performClick() - composeTestRule.waitUntil(timeoutMillis = IMAGE_CAPTURE_TIMEOUT_MILLIS) { - composeTestRule.onNodeWithTag(IMAGE_CAPTURE_FAILURE_TAG).isDisplayed() - } + composeTestRule.waitForNodeWithTag( + IMAGE_CAPTURE_FAILURE_TAG, + IMAGE_CAPTURE_TIMEOUT_MILLIS + ) uiDevice.pressBack() } Truth.assertThat(result.resultCode).isEqualTo(Activity.RESULT_CANCELED) diff --git a/app/src/androidTest/java/com/google/jetpackcamera/NavigationTest.kt b/app/src/androidTest/java/com/google/jetpackcamera/NavigationTest.kt index 4ec476485..db57373c0 100644 --- a/app/src/androidTest/java/com/google/jetpackcamera/NavigationTest.kt +++ b/app/src/androidTest/java/com/google/jetpackcamera/NavigationTest.kt @@ -17,7 +17,6 @@ package com.google.jetpackcamera import androidx.compose.ui.test.isDisplayed import androidx.compose.ui.test.isEnabled -import androidx.compose.ui.test.isNotDisplayed import androidx.compose.ui.test.junit4.createEmptyComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performClick @@ -41,6 +40,8 @@ import com.google.jetpackcamera.utils.assume import com.google.jetpackcamera.utils.onNodeWithText import com.google.jetpackcamera.utils.runMainActivityScenarioTest import com.google.jetpackcamera.utils.searchForQuickSetting +import com.google.jetpackcamera.utils.waitForNodeWithTag +import com.google.jetpackcamera.utils.waitForNodeWithTagToDisappear import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -141,9 +142,7 @@ class NavigationTest { .performClick() // Wait for the quick settings to be displayed - composeTestRule.waitUntil { - composeTestRule.onNodeWithTag(QUICK_SETTINGS_RATIO_BUTTON).isDisplayed() - } + composeTestRule.waitForNodeWithTag(QUICK_SETTINGS_RATIO_BUTTON) // Press the device's back button uiDevice.pressBack() @@ -172,16 +171,15 @@ class NavigationTest { .performClick() // Wait for the 1:1 ratio button to be displayed - composeTestRule.waitUntil { - composeTestRule.onNodeWithTag(QUICK_SETTINGS_RATIO_1_1_BUTTON).isDisplayed() - } + composeTestRule.waitForNodeWithTag(QUICK_SETTINGS_RATIO_1_1_BUTTON) // Press the device's back button uiDevice.pressBack() // Assert bottom sheet closed - composeTestRule.waitUntil(DEFAULT_TIMEOUT_MILLIS) { - composeTestRule.onNodeWithTag(QUICK_SETTINGS_BOTTOM_SHEET).isNotDisplayed() - } + composeTestRule.waitForNodeWithTagToDisappear( + QUICK_SETTINGS_BOTTOM_SHEET, + DEFAULT_TIMEOUT_MILLIS + ) } } diff --git a/app/src/androidTest/java/com/google/jetpackcamera/PermissionsTest.kt b/app/src/androidTest/java/com/google/jetpackcamera/PermissionsTest.kt index a79828813..e829e1d8e 100644 --- a/app/src/androidTest/java/com/google/jetpackcamera/PermissionsTest.kt +++ b/app/src/androidTest/java/com/google/jetpackcamera/PermissionsTest.kt @@ -18,7 +18,6 @@ package com.google.jetpackcamera import android.Manifest.permission.CAMERA import android.Manifest.permission.RECORD_AUDIO import androidx.compose.ui.test.isDisplayed -import androidx.compose.ui.test.isNotDisplayed import androidx.compose.ui.test.junit4.createEmptyComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performClick @@ -47,6 +46,9 @@ import com.google.jetpackcamera.utils.grantPermissionDialog import com.google.jetpackcamera.utils.onNodeWithText import com.google.jetpackcamera.utils.runMainActivityScenarioTest import com.google.jetpackcamera.utils.waitForCaptureButton +import com.google.jetpackcamera.utils.waitForNodeWithTag +import com.google.jetpackcamera.utils.waitForNodeWithTagToDisappear +import com.google.jetpackcamera.utils.waitForNodeWithText import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -94,9 +96,10 @@ class PermissionsTest { @Test fun cameraPermission_granted_closesPage() = runMainActivityScenarioTest { // Wait for the camera permission screen to be displayed - composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) { - composeTestRule.onNodeWithTag(CAMERA_PERMISSION_BUTTON).isDisplayed() - } + composeTestRule.waitForNodeWithTag( + CAMERA_PERMISSION_BUTTON, + timeoutMillis = APP_START_TIMEOUT_MILLIS + ) // Click button to request permission composeTestRule.onNodeWithTag(REQUEST_PERMISSION_BUTTON) @@ -117,9 +120,10 @@ class PermissionsTest { uiDevice.waitForIdle() runMainActivityScenarioTest { // Wait for the camera permission screen to be displayed - composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) { - composeTestRule.onNodeWithTag(CAMERA_PERMISSION_BUTTON).isDisplayed() - } + composeTestRule.waitForNodeWithTag( + CAMERA_PERMISSION_BUTTON, + timeoutMillis = APP_START_TIMEOUT_MILLIS + ) // Click button to request permission composeTestRule.onNodeWithTag(REQUEST_PERMISSION_BUTTON) @@ -139,9 +143,10 @@ class PermissionsTest { // required permissions should persist on screen // Wait for the permission screen to be displayed runMainActivityScenarioTest { - composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) { - composeTestRule.onNodeWithTag(CAMERA_PERMISSION_BUTTON).isDisplayed() - } + composeTestRule.waitForNodeWithTag( + CAMERA_PERMISSION_BUTTON, + timeoutMillis = APP_START_TIMEOUT_MILLIS + ) // Click button to request permission composeTestRule.onNodeWithTag(REQUEST_PERMISSION_BUTTON) @@ -157,13 +162,11 @@ class PermissionsTest { composeTestRule.onNodeWithTag(CAMERA_PERMISSION_BUTTON).isDisplayed() // text changed after permission denied - composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) { - composeTestRule.onNodeWithText( - com.google.jetpackcamera.permissions.R.string - .camera_permission_declined_rationale - ) - .isDisplayed() - } + composeTestRule.waitForNodeWithText( + com.google.jetpackcamera.permissions.R.string + .camera_permission_declined_rationale, + timeoutMillis = APP_START_TIMEOUT_MILLIS + ) // request permissions button should now say to navigate to settings composeTestRule.onNodeWithText( com.google.jetpackcamera.permissions @@ -176,9 +179,10 @@ class PermissionsTest { fun recordAudioPermission_granted_closesPage() { // optional permissions should close the screen after declining runMainActivityScenarioTest { - composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) { - composeTestRule.onNodeWithTag(RECORD_AUDIO_PERMISSION_BUTTON).isDisplayed() - } + composeTestRule.waitForNodeWithTag( + RECORD_AUDIO_PERMISSION_BUTTON, + timeoutMillis = APP_START_TIMEOUT_MILLIS + ) // Click button to request permission composeTestRule.onNodeWithTag(REQUEST_PERMISSION_BUTTON) @@ -190,9 +194,10 @@ class PermissionsTest { uiDevice.waitForIdle() // Assert we're on a different page - composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) { - composeTestRule.onNodeWithTag(RECORD_AUDIO_PERMISSION_BUTTON).isNotDisplayed() - } + composeTestRule.waitForNodeWithTagToDisappear( + RECORD_AUDIO_PERMISSION_BUTTON, + timeoutMillis = APP_START_TIMEOUT_MILLIS + ) } } @@ -200,9 +205,10 @@ class PermissionsTest { fun recordAudioPermission_denied_closesPage() { // optional permissions should close the screen after declining runMainActivityScenarioTest { - composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) { - composeTestRule.onNodeWithTag(RECORD_AUDIO_PERMISSION_BUTTON).isDisplayed() - } + composeTestRule.waitForNodeWithTag( + RECORD_AUDIO_PERMISSION_BUTTON, + timeoutMillis = APP_START_TIMEOUT_MILLIS + ) // Click button to request permission composeTestRule.onNodeWithTag(REQUEST_PERMISSION_BUTTON) @@ -214,9 +220,10 @@ class PermissionsTest { uiDevice.waitForIdle() // Assert we're on a different page - composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) { - composeTestRule.onNodeWithTag(RECORD_AUDIO_PERMISSION_BUTTON).isNotDisplayed() - } + composeTestRule.waitForNodeWithTagToDisappear( + RECORD_AUDIO_PERMISSION_BUTTON, + timeoutMillis = APP_START_TIMEOUT_MILLIS + ) } } @@ -229,11 +236,10 @@ class PermissionsTest { val timeStamp = System.currentTimeMillis() runMainActivityScenarioTest { // Wait for the camera permission screen to be displayed - composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) { - composeTestRule.onNodeWithTag( - WRITE_EXTERNAL_STORAGE_PERMISSION_BUTTON - ).isDisplayed() - } + composeTestRule.waitForNodeWithTag( + WRITE_EXTERNAL_STORAGE_PERMISSION_BUTTON, + timeoutMillis = APP_START_TIMEOUT_MILLIS + ) // Click button to request permission composeTestRule.onNodeWithTag(REQUEST_PERMISSION_BUTTON) @@ -244,11 +250,10 @@ class PermissionsTest { uiDevice.grantPermissionDialog() // permission screen should close - composeTestRule.waitUntil(timeoutMillis = 5_000) { - composeTestRule - .onNodeWithTag(WRITE_EXTERNAL_STORAGE_PERMISSION_BUTTON) - .isNotDisplayed() - } + composeTestRule.waitForNodeWithTagToDisappear( + WRITE_EXTERNAL_STORAGE_PERMISSION_BUTTON, + timeoutMillis = 5_000 + ) composeTestRule.waitForCaptureButton() @@ -258,9 +263,10 @@ class PermissionsTest { composeTestRule.onNodeWithTag(CAPTURE_BUTTON) .assertExists() .performClick() - composeTestRule.waitUntil(timeoutMillis = IMAGE_CAPTURE_TIMEOUT_MILLIS) { - composeTestRule.onNodeWithTag(IMAGE_CAPTURE_SUCCESS_TAG).isDisplayed() - } + composeTestRule.waitForNodeWithTag( + IMAGE_CAPTURE_SUCCESS_TAG, + timeoutMillis = IMAGE_CAPTURE_TIMEOUT_MILLIS + ) } deleteFilesInDirAfterTimestamp(PICTURES_DIR_PATH, instrumentation, timeStamp) @@ -272,11 +278,10 @@ class PermissionsTest { uiDevice.waitForIdle() runMainActivityScenarioTest { // Wait for the camera permission screen to be displayed - composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) { - composeTestRule.onNodeWithTag( - WRITE_EXTERNAL_STORAGE_PERMISSION_BUTTON - ).isDisplayed() - } + composeTestRule.waitForNodeWithTag( + WRITE_EXTERNAL_STORAGE_PERMISSION_BUTTON, + timeoutMillis = APP_START_TIMEOUT_MILLIS + ) // Click button to request permission composeTestRule.onNodeWithTag(REQUEST_PERMISSION_BUTTON) @@ -287,20 +292,19 @@ class PermissionsTest { uiDevice.denyPermissionDialog() // storage permission is optional and the screen should close - composeTestRule.waitUntil { - composeTestRule - .onNodeWithTag(WRITE_EXTERNAL_STORAGE_PERMISSION_BUTTON) - .isNotDisplayed() - } + composeTestRule.waitForNodeWithTagToDisappear( + WRITE_EXTERNAL_STORAGE_PERMISSION_BUTTON + ) composeTestRule.waitForCaptureButton() // check for image capture failure composeTestRule.onNodeWithTag(CAPTURE_BUTTON).assertExists().performClick() - composeTestRule.waitUntil(timeoutMillis = IMAGE_CAPTURE_TIMEOUT_MILLIS) { - composeTestRule.onNodeWithTag(IMAGE_CAPTURE_FAILURE_TAG).isDisplayed() - } + composeTestRule.waitForNodeWithTag( + IMAGE_CAPTURE_FAILURE_TAG, + timeoutMillis = IMAGE_CAPTURE_TIMEOUT_MILLIS + ) // imageWell shouldn't appear composeTestRule.ensureTagNotAppears(IMAGE_WELL_TAG) } diff --git a/app/src/androidTest/java/com/google/jetpackcamera/PostCaptureTest.kt b/app/src/androidTest/java/com/google/jetpackcamera/PostCaptureTest.kt index 0ea61cbf3..a80529f3a 100644 --- a/app/src/androidTest/java/com/google/jetpackcamera/PostCaptureTest.kt +++ b/app/src/androidTest/java/com/google/jetpackcamera/PostCaptureTest.kt @@ -16,7 +16,6 @@ package com.google.jetpackcamera import android.provider.MediaStore -import androidx.compose.ui.test.isDisplayed import androidx.compose.ui.test.junit4.createEmptyComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performClick @@ -50,6 +49,7 @@ import com.google.jetpackcamera.utils.mediaStoreEntryExistsAfterTimestamp import com.google.jetpackcamera.utils.runMainActivityScenarioTest import com.google.jetpackcamera.utils.wait import com.google.jetpackcamera.utils.waitForCaptureButton +import com.google.jetpackcamera.utils.waitForNodeWithTag import org.junit.After import org.junit.Before import org.junit.Rule @@ -96,20 +96,14 @@ class PostCaptureTest { private fun enterImageWellAndDelete(recentCaptureViewerTag: String) { // enter postcapture via imagewell - composeTestRule.waitUntil(IMAGE_WELL_LOAD_TIMEOUT_MILLIS) { - composeTestRule.onNodeWithTag(IMAGE_WELL_TAG).isDisplayed() - } + composeTestRule.waitForNodeWithTag(IMAGE_WELL_TAG, IMAGE_WELL_LOAD_TIMEOUT_MILLIS) composeTestRule.onNodeWithTag(IMAGE_WELL_TAG).assertExists().performClick() // most recent capture tag - composeTestRule.waitUntil(timeoutMillis = VIDEO_CAPTURE_TIMEOUT_MILLIS) { - composeTestRule.onNodeWithTag(recentCaptureViewerTag).isDisplayed() - } + composeTestRule.waitForNodeWithTag(recentCaptureViewerTag, VIDEO_CAPTURE_TIMEOUT_MILLIS) // delete most recent capture - composeTestRule.waitUntil { - composeTestRule.onNodeWithTag(BUTTON_POST_CAPTURE_DELETE).isDisplayed() - } + composeTestRule.waitForNodeWithTag(BUTTON_POST_CAPTURE_DELETE) composeTestRule.onNodeWithTag(BUTTON_POST_CAPTURE_DELETE).assertExists().performClick() // wait for capture button after automatically exiting post capture @@ -118,20 +112,14 @@ class PostCaptureTest { private fun enterImageWellAndSave(recentCaptureViewerTag: String) { // enter postcapture via imagewell - composeTestRule.waitUntil(IMAGE_WELL_LOAD_TIMEOUT_MILLIS) { - composeTestRule.onNodeWithTag(IMAGE_WELL_TAG).isDisplayed() - } + composeTestRule.waitForNodeWithTag(IMAGE_WELL_TAG, IMAGE_WELL_LOAD_TIMEOUT_MILLIS) composeTestRule.onNodeWithTag(IMAGE_WELL_TAG).assertExists().performClick() // most recent capture tag - composeTestRule.waitUntil(timeoutMillis = VIDEO_CAPTURE_TIMEOUT_MILLIS) { - composeTestRule.onNodeWithTag(recentCaptureViewerTag).isDisplayed() - } + composeTestRule.waitForNodeWithTag(recentCaptureViewerTag, VIDEO_CAPTURE_TIMEOUT_MILLIS) // delete most recent capture - composeTestRule.waitUntil { - composeTestRule.onNodeWithTag(BUTTON_POST_CAPTURE_DELETE).isDisplayed() - } + composeTestRule.waitForNodeWithTag(BUTTON_POST_CAPTURE_DELETE) composeTestRule.onNodeWithTag(BUTTON_POST_CAPTURE_SAVE).assertExists().performClick() } @@ -146,20 +134,19 @@ class PostCaptureTest { composeTestRule.onNodeWithTag(CAPTURE_BUTTON).assertExists().performClick() // navigate to postcapture screen - composeTestRule.waitUntil(timeoutMillis = VIDEO_CAPTURE_TIMEOUT_MILLIS) { - composeTestRule.onNodeWithTag(VIEWER_POST_CAPTURE_IMAGE).isDisplayed() - } + composeTestRule.waitForNodeWithTag( + VIEWER_POST_CAPTURE_IMAGE, + VIDEO_CAPTURE_TIMEOUT_MILLIS + ) - composeTestRule.waitUntil { - composeTestRule.onNodeWithTag(BUTTON_POST_CAPTURE_SAVE).isDisplayed() - } + composeTestRule.waitForNodeWithTag(BUTTON_POST_CAPTURE_SAVE) composeTestRule.onNodeWithTag(BUTTON_POST_CAPTURE_SAVE).performClick() // Wait for image save success message - composeTestRule.waitUntil(timeoutMillis = SAVE_MEDIA_TIMEOUT_MILLIS) { - composeTestRule.onNodeWithTag(SNACKBAR_POST_CAPTURE_IMAGE_SAVE_SUCCESS) - .isDisplayed() - } + composeTestRule.waitForNodeWithTag( + SNACKBAR_POST_CAPTURE_IMAGE_SAVE_SUCCESS, + SAVE_MEDIA_TIMEOUT_MILLIS + ) assertThat(newImageMediaExists()).isTrue() } @@ -172,25 +159,22 @@ class PostCaptureTest { composeTestRule.longClickForVideoRecordingCheckingElapsedTime() // navigate to postcapture screen - composeTestRule.waitUntil(timeoutMillis = VIDEO_CAPTURE_TIMEOUT_MILLIS) { - composeTestRule.onNodeWithTag(VIEWER_POST_CAPTURE_VIDEO).isDisplayed() - } - composeTestRule.waitUntil { - composeTestRule.onNodeWithTag(BUTTON_POST_CAPTURE_EXIT).isDisplayed() - } + composeTestRule.waitForNodeWithTag( + VIEWER_POST_CAPTURE_VIDEO, + VIDEO_CAPTURE_TIMEOUT_MILLIS + ) + composeTestRule.waitForNodeWithTag(BUTTON_POST_CAPTURE_EXIT) assertThat(newVideoMediaExists()).isFalse() // save video - composeTestRule.waitUntil { - composeTestRule.onNodeWithTag(BUTTON_POST_CAPTURE_SAVE).isDisplayed() - } + composeTestRule.waitForNodeWithTag(BUTTON_POST_CAPTURE_SAVE) composeTestRule.onNodeWithTag(BUTTON_POST_CAPTURE_SAVE).performClick() // Wait for video save success message - composeTestRule.waitUntil(timeoutMillis = SAVE_MEDIA_TIMEOUT_MILLIS) { - composeTestRule.onNodeWithTag(SNACKBAR_POST_CAPTURE_VIDEO_SAVE_SUCCESS) - .isDisplayed() - } + composeTestRule.waitForNodeWithTag( + SNACKBAR_POST_CAPTURE_VIDEO_SAVE_SUCCESS, + SAVE_MEDIA_TIMEOUT_MILLIS + ) assertThat(newVideoMediaExists()).isTrue() composeTestRule.onNodeWithTag(BUTTON_POST_CAPTURE_EXIT).performClick() @@ -203,9 +187,7 @@ class PostCaptureTest { composeTestRule.waitForCaptureButton() assertThat(newImageMediaExists()).isFalse() composeTestRule.onNodeWithTag(CAPTURE_BUTTON).assertExists().performClick() - composeTestRule.waitUntil(IMAGE_CAPTURE_TIMEOUT_MILLIS) { - composeTestRule.onNodeWithTag(IMAGE_CAPTURE_SUCCESS_TAG).isDisplayed() - } + composeTestRule.waitForNodeWithTag(IMAGE_CAPTURE_SUCCESS_TAG, IMAGE_CAPTURE_TIMEOUT_MILLIS) assertThat(newImageMediaExists()).isTrue() // enter postcapture via imagewell and delete recent capture enterImageWellAndDelete(VIEWER_POST_CAPTURE_IMAGE) @@ -222,9 +204,7 @@ class PostCaptureTest { assertThat(newVideoMediaExists()).isFalse() composeTestRule.longClickForVideoRecordingCheckingElapsedTime() - composeTestRule.waitUntil(VIDEO_CAPTURE_TIMEOUT_MILLIS) { - composeTestRule.onNodeWithTag(VIDEO_CAPTURE_SUCCESS_TAG).isDisplayed() - } + composeTestRule.waitForNodeWithTag(VIDEO_CAPTURE_SUCCESS_TAG, VIDEO_CAPTURE_TIMEOUT_MILLIS) assertThat(newVideoMediaExists()).isTrue() // enter postcapture via imagewell and delete recent capture enterImageWellAndDelete(VIEWER_POST_CAPTURE_VIDEO) @@ -239,17 +219,16 @@ class PostCaptureTest { composeTestRule.waitForCaptureButton() assertThat(newImageMediaExists()).isFalse() composeTestRule.onNodeWithTag(CAPTURE_BUTTON).assertExists().performClick() - composeTestRule.waitUntil(IMAGE_CAPTURE_TIMEOUT_MILLIS) { - composeTestRule.onNodeWithTag(IMAGE_CAPTURE_SUCCESS_TAG).isDisplayed() - } + composeTestRule.waitForNodeWithTag(IMAGE_CAPTURE_SUCCESS_TAG, IMAGE_CAPTURE_TIMEOUT_MILLIS) assertThat(newImageMediaExists()).isTrue() // enter postcapture via imagewell and save recent capture val newTimestamp = System.currentTimeMillis() enterImageWellAndSave(VIEWER_POST_CAPTURE_IMAGE) - composeTestRule.waitUntil(SAVE_MEDIA_TIMEOUT_MILLIS) { - composeTestRule.onNodeWithTag(SNACKBAR_POST_CAPTURE_IMAGE_SAVE_SUCCESS).isDisplayed() - } + composeTestRule.waitForNodeWithTag( + SNACKBAR_POST_CAPTURE_IMAGE_SAVE_SUCCESS, + SAVE_MEDIA_TIMEOUT_MILLIS + ) composeTestRule.waitUntil(timeoutMillis = SAVE_MEDIA_TIMEOUT_MILLIS) { newImageMediaExists(newTimestamp) } @@ -262,16 +241,15 @@ class PostCaptureTest { assertThat(newVideoMediaExists()).isFalse() composeTestRule.longClickForVideoRecordingCheckingElapsedTime() - composeTestRule.waitUntil(VIDEO_CAPTURE_TIMEOUT_MILLIS) { - composeTestRule.onNodeWithTag(VIDEO_CAPTURE_SUCCESS_TAG).isDisplayed() - } + composeTestRule.waitForNodeWithTag(VIDEO_CAPTURE_SUCCESS_TAG, VIDEO_CAPTURE_TIMEOUT_MILLIS) assertThat(newVideoMediaExists()).isTrue() // enter postcapture via imagewell and save recent capture val newTimestamp = System.currentTimeMillis() enterImageWellAndSave(VIEWER_POST_CAPTURE_VIDEO) - composeTestRule.waitUntil(SAVE_MEDIA_TIMEOUT_MILLIS) { - composeTestRule.onNodeWithTag(SNACKBAR_POST_CAPTURE_VIDEO_SAVE_SUCCESS).isDisplayed() - } + composeTestRule.waitForNodeWithTag( + SNACKBAR_POST_CAPTURE_VIDEO_SAVE_SUCCESS, + SAVE_MEDIA_TIMEOUT_MILLIS + ) composeTestRule.waitUntil(timeoutMillis = VIDEO_CAPTURE_TIMEOUT_MILLIS) { newVideoMediaExists(newTimestamp) } diff --git a/app/src/androidTest/java/com/google/jetpackcamera/SwitchCameraTest.kt b/app/src/androidTest/java/com/google/jetpackcamera/SwitchCameraTest.kt index f66e4fda5..f56d5e80d 100644 --- a/app/src/androidTest/java/com/google/jetpackcamera/SwitchCameraTest.kt +++ b/app/src/androidTest/java/com/google/jetpackcamera/SwitchCameraTest.kt @@ -16,7 +16,6 @@ package com.google.jetpackcamera import androidx.compose.ui.test.doubleClick -import androidx.compose.ui.test.isDisplayed import androidx.compose.ui.test.isEnabled import androidx.compose.ui.test.junit4.ComposeTestRule import androidx.compose.ui.test.junit4.createEmptyComposeRule @@ -37,6 +36,7 @@ import com.google.jetpackcamera.utils.TEST_REQUIRED_PERMISSIONS import com.google.jetpackcamera.utils.assume import com.google.jetpackcamera.utils.getCurrentLensFacing import com.google.jetpackcamera.utils.runMainActivityScenarioTest +import com.google.jetpackcamera.utils.waitForNodeWithTag import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -141,9 +141,7 @@ inline fun runFlipCameraTest( crossinline block: ActivityScenario.() -> Unit ) = runMainActivityScenarioTest { // Wait for the preview display to be visible - composeTestRule.waitUntil(APP_START_TIMEOUT_MILLIS) { - composeTestRule.onNodeWithTag(PREVIEW_DISPLAY).isDisplayed() - } + composeTestRule.waitForNodeWithTag(PREVIEW_DISPLAY, APP_START_TIMEOUT_MILLIS) // If flipping the camera is available, flip it. Otherwise skip test. composeTestRule.onNodeWithTag(FLIP_CAMERA_BUTTON).assume(isEnabled()) { diff --git a/app/src/androidTest/java/com/google/jetpackcamera/VideoRecordingDeviceTest.kt b/app/src/androidTest/java/com/google/jetpackcamera/VideoRecordingDeviceTest.kt index a37e62623..bd5557934 100644 --- a/app/src/androidTest/java/com/google/jetpackcamera/VideoRecordingDeviceTest.kt +++ b/app/src/androidTest/java/com/google/jetpackcamera/VideoRecordingDeviceTest.kt @@ -46,6 +46,7 @@ import com.google.jetpackcamera.utils.pressAndDragToLockVideoRecording import com.google.jetpackcamera.utils.runMainActivityMediaStoreAutoDeleteScenarioTest import com.google.jetpackcamera.utils.runScenarioTestForResult import com.google.jetpackcamera.utils.tapStartLockedVideoRecording +import com.google.jetpackcamera.utils.waitForNodeWithTag import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -72,9 +73,7 @@ internal class VideoRecordingDeviceTest { composeTestRule.onNodeWithTag(CAPTURE_BUTTON).isDisplayed() } composeTestRule.longClickForVideoRecordingCheckingElapsedTime() - composeTestRule.waitUntil(timeoutMillis = VIDEO_CAPTURE_TIMEOUT_MILLIS) { - composeTestRule.onNodeWithTag(VIDEO_CAPTURE_SUCCESS_TAG).isDisplayed() - } + composeTestRule.waitForNodeWithTag(VIDEO_CAPTURE_SUCCESS_TAG, VIDEO_CAPTURE_TIMEOUT_MILLIS) deleteFilesInDirAfterTimestamp(MOVIES_DIR_PATH, instrumentation, timeStamp) } @@ -95,9 +94,10 @@ internal class VideoRecordingDeviceTest { composeTestRule.onNodeWithTag(CAPTURE_BUTTON).assertExists().performClick() composeTestRule.onNodeWithTag(CAPTURE_BUTTON).assertExists().performClick() - composeTestRule.waitUntil(timeoutMillis = VIDEO_CAPTURE_TIMEOUT_MILLIS) { - composeTestRule.onNodeWithTag(VIDEO_CAPTURE_SUCCESS_TAG).isDisplayed() - } + composeTestRule.waitForNodeWithTag( + VIDEO_CAPTURE_SUCCESS_TAG, + VIDEO_CAPTURE_TIMEOUT_MILLIS + ) deleteFilesInDirAfterTimestamp(MOVIES_DIR_PATH, instrumentation, timeStamp) } @@ -157,9 +157,10 @@ internal class VideoRecordingDeviceTest { composeTestRule.onNodeWithTag(CAPTURE_BUTTON).isDisplayed() } composeTestRule.longClickForVideoRecording() - composeTestRule.waitUntil(timeoutMillis = VIDEO_CAPTURE_TIMEOUT_MILLIS) { - composeTestRule.onNodeWithTag(VIDEO_CAPTURE_FAILURE_TAG).isDisplayed() - } + composeTestRule.waitForNodeWithTag( + VIDEO_CAPTURE_FAILURE_TAG, + VIDEO_CAPTURE_TIMEOUT_MILLIS + ) uiDevice.pressBack() } Truth.assertThat(result.resultCode).isEqualTo(Activity.RESULT_CANCELED) diff --git a/app/src/androidTest/java/com/google/jetpackcamera/utils/ComposeTestRuleExt.kt b/app/src/androidTest/java/com/google/jetpackcamera/utils/ComposeTestRuleExt.kt index b6f6501bf..4a9e56f72 100644 --- a/app/src/androidTest/java/com/google/jetpackcamera/utils/ComposeTestRuleExt.kt +++ b/app/src/androidTest/java/com/google/jetpackcamera/utils/ComposeTestRuleExt.kt @@ -154,6 +154,24 @@ fun ComposeTestRule.waitForNodeWithTag(tag: String, timeoutMillis: Long = DEFAUL waitUntil(timeoutMillis = timeoutMillis) { onNodeWithTag(tag).isDisplayed() } } +fun ComposeTestRule.waitForNodeWithTagToDisappear( + tag: String, + timeoutMillis: Long = DEFAULT_TIMEOUT_MILLIS +) { + waitUntil(timeoutMillis = timeoutMillis) { + onNodeWithTag(tag).isNotDisplayed() + } +} + +fun ComposeTestRule.waitForNodeWithText( + @StringRes textResId: Int, + timeoutMillis: Long = DEFAULT_TIMEOUT_MILLIS +) { + waitUntil(timeoutMillis = timeoutMillis) { + onNodeWithText(textResId).isDisplayed() + } +} + private fun ComposeTestRule.idleForVideoDuration( durationMillis: Long = VIDEO_DURATION_MILLIS, earlyExitPredicate: () -> Boolean = { @@ -342,6 +360,15 @@ fun ComposeTestRule.getCaptureModeToggleState(): CaptureMode = } } +fun ComposeTestRule.waitForCaptureModeToggleState( + targetState: CaptureMode, + timeoutMillis: Long = DEFAULT_TIMEOUT_MILLIS +) { + waitUntil(timeoutMillis = timeoutMillis) { + getCaptureModeToggleState() == targetState + } +} + // ////////////////////// // // check current quick settings state From 0090aed4e226306f01bee11524d9ed70cc2187ee Mon Sep 17 00:00:00 2001 From: Trevor McGuire Date: Wed, 14 Jan 2026 14:23:05 -0800 Subject: [PATCH 03/12] Update style guide with some guidance on timeouts in tests --- .gemini/styleguide.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gemini/styleguide.md b/.gemini/styleguide.md index a8dbf4c01..6b70feefa 100644 --- a/.gemini/styleguide.md +++ b/.gemini/styleguide.md @@ -43,6 +43,9 @@ When reviewing a pull request, focus on the following key areas: * **Descriptive Test Names:** Test function names must be clear, descriptive, and follow a consistent pattern. * **Use Truth Assertions:** Always prefer [Truth](https://truth.dev/) assertions (`assertThat(...)`) over JUnit assertions (`assertEquals`, `assertTrue`, etc.). Truth provides more readable assertion chains and more informative failure messages. Avoid functions from `org.junit.Assert` such as `assertEquals`, `assertTrue`, `assertFalse`, `assertNull`, and `assertNotNull`. * **Explicit Test Runners:** All test classes must be annotated with `@RunWith(...)` to explicitly declare which test runner should be used (e.g., `@RunWith(AndroidJUnit4::class)`, `@RunWith(RobolectricTestRunner::class)`, or `@RunWith(JUnit4::class)` for host tests with no Android dependencies). + * **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. 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. From 0787e1af5bb3aa7009aaf9c666c7c124b62bb94f Mon Sep 17 00:00:00 2001 From: Trevor McGuire Date: Mon, 10 Nov 2025 18:30:34 -0800 Subject: [PATCH 04/12] Add isCameraRunning state Introduces an `isCameraRunning` flag to the `CameraState` to accurately reflect the underlying camera's status. This state is derived from the CameraX `CameraState` and is used to disable the capture button when the camera is not open or available. This commit also renames `torchEnabled` to `isTorchEnabled` for clarity and consistency, and ensures the `ZoomState` is being correctly updated in the `CameraState`. The capture button's UI has been improved to prevent flickering during brief transitional states (e.g. switching cameras). This is achieved by visually debouncing the disabled state. While the button's semantics and pointer input are disabled immediately, the visual change to a disabled appearance is delayed by one second. If the button becomes enabled again within this period, the distracting flicker is avoided. Color transitions are animated for a smoother user experience. Finally, the test suite has been made more robust by updating the tests to wait for the capture button to be both displayed and enabled before interaction. --- .../jetpackcamera/BackgroundDeviceTest.kt | 19 ++--- .../jetpackcamera/ImageCaptureDeviceTest.kt | 42 +++------- .../google/jetpackcamera/NavigationTest.kt | 19 ++--- .../google/jetpackcamera/PermissionsTest.kt | 4 +- .../jetpackcamera/VideoRecordingDeviceTest.kt | 25 ++---- .../jetpackcamera/utils/ComposeTestRuleExt.kt | 4 +- .../capture/CaptureButtonComponents.kt | 77 ++++++++++++++++--- .../capture/CaptureButtonUiStateAdapter.kt | 34 ++++---- 8 files changed, 115 insertions(+), 109 deletions(-) diff --git a/app/src/androidTest/java/com/google/jetpackcamera/BackgroundDeviceTest.kt b/app/src/androidTest/java/com/google/jetpackcamera/BackgroundDeviceTest.kt index 742c2ae3c..57f0e4ca6 100644 --- a/app/src/androidTest/java/com/google/jetpackcamera/BackgroundDeviceTest.kt +++ b/app/src/androidTest/java/com/google/jetpackcamera/BackgroundDeviceTest.kt @@ -16,7 +16,6 @@ package com.google.jetpackcamera import android.os.Build -import androidx.compose.ui.test.isDisplayed import androidx.compose.ui.test.junit4.createEmptyComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performClick @@ -28,7 +27,6 @@ import androidx.test.uiautomator.UiDevice import androidx.test.uiautomator.Until import com.google.common.truth.Truth.assertThat import com.google.common.truth.TruthJUnit.assume -import com.google.jetpackcamera.ui.components.capture.CAPTURE_BUTTON import com.google.jetpackcamera.ui.components.capture.QUICK_SETTINGS_DROP_DOWN import com.google.jetpackcamera.ui.components.capture.QUICK_SETTINGS_FLIP_CAMERA_BUTTON import com.google.jetpackcamera.ui.components.capture.QUICK_SETTINGS_RATIO_1_1_BUTTON @@ -37,6 +35,7 @@ import com.google.jetpackcamera.ui.components.capture.QUICK_SETTINGS_STREAM_CONF import com.google.jetpackcamera.utils.APP_START_TIMEOUT_MILLIS import com.google.jetpackcamera.utils.TEST_REQUIRED_PERMISSIONS import com.google.jetpackcamera.utils.runMainActivityScenarioTest +import com.google.jetpackcamera.utils.waitForCaptureButton import org.junit.Before import org.junit.Rule import org.junit.Test @@ -74,9 +73,7 @@ class BackgroundDeviceTest { @Test fun background_foreground() = runMainActivityScenarioTest { // Wait for the capture button to be displayed - composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) { - composeTestRule.onNodeWithTag(CAPTURE_BUTTON).isDisplayed() - } + composeTestRule.waitForCaptureButton() backgroundThenForegroundApp() } @@ -84,9 +81,7 @@ class BackgroundDeviceTest { @Test fun flipCamera_then_background_foreground() = runMainActivityScenarioTest { // Wait for the capture button to be displayed - composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) { - composeTestRule.onNodeWithTag(CAPTURE_BUTTON).isDisplayed() - } + composeTestRule.waitForCaptureButton() // Navigate to quick settings composeTestRule.onNodeWithTag(QUICK_SETTINGS_DROP_DOWN) @@ -109,9 +104,7 @@ class BackgroundDeviceTest { @Test fun setAspectRatio_then_background_foreground() = runMainActivityScenarioTest { // Wait for the capture button to be displayed - composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) { - composeTestRule.onNodeWithTag(CAPTURE_BUTTON).isDisplayed() - } + composeTestRule.waitForCaptureButton() // Navigate to quick settings composeTestRule.onNodeWithTag(QUICK_SETTINGS_DROP_DOWN) @@ -147,9 +140,7 @@ class BackgroundDeviceTest { assumeSupportsSingleStream() // Wait for the capture button to be displayed - composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) { - composeTestRule.onNodeWithTag(CAPTURE_BUTTON).isDisplayed() - } + composeTestRule.waitForCaptureButton() // Navigate to quick settings composeTestRule.onNodeWithTag(QUICK_SETTINGS_DROP_DOWN) diff --git a/app/src/androidTest/java/com/google/jetpackcamera/ImageCaptureDeviceTest.kt b/app/src/androidTest/java/com/google/jetpackcamera/ImageCaptureDeviceTest.kt index 0139e3f8c..becb2c3da 100644 --- a/app/src/androidTest/java/com/google/jetpackcamera/ImageCaptureDeviceTest.kt +++ b/app/src/androidTest/java/com/google/jetpackcamera/ImageCaptureDeviceTest.kt @@ -34,7 +34,6 @@ import com.google.jetpackcamera.ui.components.capture.CAPTURE_BUTTON import com.google.jetpackcamera.ui.components.capture.IMAGE_CAPTURE_FAILURE_TAG import com.google.jetpackcamera.ui.components.capture.IMAGE_CAPTURE_SUCCESS_TAG import com.google.jetpackcamera.ui.components.capture.VIDEO_CAPTURE_EXTERNAL_UNSUPPORTED_TAG -import com.google.jetpackcamera.utils.APP_START_TIMEOUT_MILLIS import com.google.jetpackcamera.utils.FILE_PREFIX import com.google.jetpackcamera.utils.IMAGE_CAPTURE_TIMEOUT_MILLIS import com.google.jetpackcamera.utils.IMAGE_PREFIX @@ -53,6 +52,7 @@ import com.google.jetpackcamera.utils.getTestUri import com.google.jetpackcamera.utils.longClickForVideoRecording import com.google.jetpackcamera.utils.runMainActivityMediaStoreAutoDeleteScenarioTest import com.google.jetpackcamera.utils.runMainActivityScenarioTestForResult +import com.google.jetpackcamera.utils.waitForCaptureButton import com.google.jetpackcamera.utils.waitForNodeWithTag import org.junit.Rule import org.junit.Test @@ -78,9 +78,7 @@ internal class ImageCaptureDeviceTest { filePrefix = FILE_PREFIX ) { // Wait for the capture button to be displayed - composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) { - composeTestRule.onNodeWithTag(CAPTURE_BUTTON).isDisplayed() - } + composeTestRule.waitForCaptureButton() composeTestRule.onNodeWithTag(CAPTURE_BUTTON) .assertExists() @@ -94,9 +92,7 @@ internal class ImageCaptureDeviceTest { filePrefix = FILE_PREFIX ) { // Wait for the capture button to be displayed - composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) { - composeTestRule.onNodeWithTag(CAPTURE_BUTTON).isDisplayed() - } + composeTestRule.waitForCaptureButton() uiDevice.pressKeyCode(KeyEvent.KEYCODE_VOLUME_UP) @@ -109,9 +105,7 @@ internal class ImageCaptureDeviceTest { filePrefix = FILE_PREFIX ) { // Wait for the capture button to be displayed - composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) { - composeTestRule.onNodeWithTag(CAPTURE_BUTTON).isDisplayed() - } + composeTestRule.waitForCaptureButton() uiDevice.pressKeyCode(KeyEvent.KEYCODE_VOLUME_DOWN) composeTestRule.waitForNodeWithTag(IMAGE_CAPTURE_SUCCESS_TAG, IMAGE_CAPTURE_TIMEOUT_MILLIS) @@ -126,9 +120,7 @@ internal class ImageCaptureDeviceTest { getSingleImageCaptureIntent(uri, MediaStore.ACTION_IMAGE_CAPTURE) ) { // Wait for the capture button to be displayed - composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) { - composeTestRule.onNodeWithTag(CAPTURE_BUTTON).isDisplayed() - } + composeTestRule.waitForCaptureButton() composeTestRule.onNodeWithTag(CAPTURE_BUTTON) .assertExists() @@ -151,9 +143,7 @@ internal class ImageCaptureDeviceTest { getSingleImageCaptureIntent(uri, MediaStore.ACTION_IMAGE_CAPTURE) ) { // Wait for the capture button to be displayed - composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) { - composeTestRule.onNodeWithTag(CAPTURE_BUTTON).isDisplayed() - } + composeTestRule.waitForCaptureButton() composeTestRule.onNodeWithTag(CAPTURE_BUTTON) .assertExists() @@ -178,9 +168,7 @@ internal class ImageCaptureDeviceTest { getSingleImageCaptureIntent(uri, MediaStore.ACTION_IMAGE_CAPTURE) ) { // Wait for the capture button to be displayed - composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) { - composeTestRule.onNodeWithTag(CAPTURE_BUTTON).isDisplayed() - } + composeTestRule.waitForCaptureButton() composeTestRule.longClickForVideoRecording() @@ -211,9 +199,7 @@ internal class ImageCaptureDeviceTest { ) ) { // Wait for the capture button to be displayed - composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) { - composeTestRule.onNodeWithTag(CAPTURE_BUTTON).isDisplayed() - } + composeTestRule.waitForCaptureButton() repeat(2) { clickCaptureAndWaitUntilMessageDisappears( IMAGE_CAPTURE_TIMEOUT_MILLIS, @@ -239,9 +225,7 @@ internal class ImageCaptureDeviceTest { getMultipleImageCaptureIntent(null, MediaStore.INTENT_ACTION_STILL_IMAGE_CAMERA) ) { // Wait for the capture button to be displayed - composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) { - composeTestRule.onNodeWithTag(CAPTURE_BUTTON).isDisplayed() - } + composeTestRule.waitForCaptureButton() repeat(2) { clickCaptureAndWaitUntilMessageDisappears( IMAGE_CAPTURE_TIMEOUT_MILLIS, @@ -263,9 +247,7 @@ internal class ImageCaptureDeviceTest { getMultipleImageCaptureIntent(null, MediaStore.INTENT_ACTION_STILL_IMAGE_CAMERA) ) { // Wait for the capture button to be displayed - composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) { - composeTestRule.onNodeWithTag(CAPTURE_BUTTON).isDisplayed() - } + composeTestRule.waitForCaptureButton() uiDevice.pressBack() } Truth.assertThat(result.resultCode).isEqualTo(Activity.RESULT_CANCELED) @@ -285,9 +267,7 @@ internal class ImageCaptureDeviceTest { ) ) { // Wait for the capture button to be displayed - composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) { - composeTestRule.onNodeWithTag(CAPTURE_BUTTON).isDisplayed() - } + composeTestRule.waitForCaptureButton() clickCaptureAndWaitUntilMessageDisappears( IMAGE_CAPTURE_TIMEOUT_MILLIS, IMAGE_CAPTURE_FAILURE_TAG diff --git a/app/src/androidTest/java/com/google/jetpackcamera/NavigationTest.kt b/app/src/androidTest/java/com/google/jetpackcamera/NavigationTest.kt index db57373c0..cdaa1cac0 100644 --- a/app/src/androidTest/java/com/google/jetpackcamera/NavigationTest.kt +++ b/app/src/androidTest/java/com/google/jetpackcamera/NavigationTest.kt @@ -15,7 +15,6 @@ */ package com.google.jetpackcamera -import androidx.compose.ui.test.isDisplayed import androidx.compose.ui.test.isEnabled import androidx.compose.ui.test.junit4.createEmptyComposeRule import androidx.compose.ui.test.onNodeWithTag @@ -33,13 +32,13 @@ import com.google.jetpackcamera.ui.components.capture.QUICK_SETTINGS_DROP_DOWN import com.google.jetpackcamera.ui.components.capture.QUICK_SETTINGS_RATIO_1_1_BUTTON import com.google.jetpackcamera.ui.components.capture.QUICK_SETTINGS_RATIO_BUTTON import com.google.jetpackcamera.ui.components.capture.SETTINGS_BUTTON -import com.google.jetpackcamera.utils.APP_START_TIMEOUT_MILLIS import com.google.jetpackcamera.utils.DEFAULT_TIMEOUT_MILLIS import com.google.jetpackcamera.utils.TEST_REQUIRED_PERMISSIONS import com.google.jetpackcamera.utils.assume import com.google.jetpackcamera.utils.onNodeWithText import com.google.jetpackcamera.utils.runMainActivityScenarioTest import com.google.jetpackcamera.utils.searchForQuickSetting +import com.google.jetpackcamera.utils.waitForCaptureButton import com.google.jetpackcamera.utils.waitForNodeWithTag import com.google.jetpackcamera.utils.waitForNodeWithTagToDisappear import org.junit.Rule @@ -61,9 +60,7 @@ class NavigationTest { @Test fun backAfterReturnFromSettings_doesNotReturnToSettings() = runMainActivityScenarioTest { // Wait for the capture button to be displayed - composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) { - composeTestRule.onNodeWithTag(CAPTURE_BUTTON).isDisplayed() - } + composeTestRule.waitForCaptureButton() // open quick settings composeTestRule.onNodeWithTag(QUICK_SETTINGS_DROP_DOWN).assertExists().performClick() @@ -97,9 +94,7 @@ class NavigationTest { @Test fun returnFromSettings_afterFlipCamera_returnsToPreview() = runMainActivityScenarioTest { // Wait for the capture button to be displayed - composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) { - composeTestRule.onNodeWithTag(CAPTURE_BUTTON).isDisplayed() - } + composeTestRule.waitForCaptureButton() // If flipping the camera is available, flip it. Otherwise skip test. composeTestRule.onNodeWithTag(FLIP_CAMERA_BUTTON) @@ -132,9 +127,7 @@ class NavigationTest { @Test fun backFromQuickSettings_returnToPreview() = runMainActivityScenarioTest { // Wait for the capture button to be displayed - composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) { - composeTestRule.onNodeWithTag(CAPTURE_BUTTON).isDisplayed() - } + composeTestRule.waitForCaptureButton() // Navigate to the quick settings screen composeTestRule.onNodeWithTag(QUICK_SETTINGS_DROP_DOWN) @@ -154,9 +147,7 @@ class NavigationTest { @Test fun backFromQuickSettingsExpended_returnToQuickSettings() = runMainActivityScenarioTest { // Wait for the capture button to be displayed - composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) { - composeTestRule.onNodeWithTag(CAPTURE_BUTTON).isDisplayed() - } + composeTestRule.waitForCaptureButton() // Navigate to the quick settings screen composeTestRule.onNodeWithTag(QUICK_SETTINGS_DROP_DOWN) diff --git a/app/src/androidTest/java/com/google/jetpackcamera/PermissionsTest.kt b/app/src/androidTest/java/com/google/jetpackcamera/PermissionsTest.kt index e829e1d8e..297eb3ca7 100644 --- a/app/src/androidTest/java/com/google/jetpackcamera/PermissionsTest.kt +++ b/app/src/androidTest/java/com/google/jetpackcamera/PermissionsTest.kt @@ -87,9 +87,7 @@ class PermissionsTest { @Test fun allPermissions_alreadyGranted_screenNotShown() { runMainActivityScenarioTest { - composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) { - composeTestRule.onNodeWithTag(CAPTURE_BUTTON).isDisplayed() - } + composeTestRule.waitForCaptureButton() } } diff --git a/app/src/androidTest/java/com/google/jetpackcamera/VideoRecordingDeviceTest.kt b/app/src/androidTest/java/com/google/jetpackcamera/VideoRecordingDeviceTest.kt index bd5557934..24d31c89f 100644 --- a/app/src/androidTest/java/com/google/jetpackcamera/VideoRecordingDeviceTest.kt +++ b/app/src/androidTest/java/com/google/jetpackcamera/VideoRecordingDeviceTest.kt @@ -18,7 +18,6 @@ package com.google.jetpackcamera import android.app.Activity import android.net.Uri import android.provider.MediaStore -import androidx.compose.ui.test.isDisplayed import androidx.compose.ui.test.junit4.createEmptyComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performClick @@ -30,7 +29,6 @@ import com.google.common.truth.Truth import com.google.jetpackcamera.ui.components.capture.CAPTURE_BUTTON import com.google.jetpackcamera.ui.components.capture.VIDEO_CAPTURE_FAILURE_TAG import com.google.jetpackcamera.ui.components.capture.VIDEO_CAPTURE_SUCCESS_TAG -import com.google.jetpackcamera.utils.APP_START_TIMEOUT_MILLIS import com.google.jetpackcamera.utils.IMAGE_PREFIX import com.google.jetpackcamera.utils.MOVIES_DIR_PATH import com.google.jetpackcamera.utils.TEST_REQUIRED_PERMISSIONS @@ -46,6 +44,7 @@ import com.google.jetpackcamera.utils.pressAndDragToLockVideoRecording import com.google.jetpackcamera.utils.runMainActivityMediaStoreAutoDeleteScenarioTest import com.google.jetpackcamera.utils.runScenarioTestForResult import com.google.jetpackcamera.utils.tapStartLockedVideoRecording +import com.google.jetpackcamera.utils.waitForCaptureButton import com.google.jetpackcamera.utils.waitForNodeWithTag import org.junit.Rule import org.junit.Test @@ -69,9 +68,7 @@ internal class VideoRecordingDeviceTest { ) { val timeStamp = System.currentTimeMillis() // Wait for the capture button to be displayed - composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) { - composeTestRule.onNodeWithTag(CAPTURE_BUTTON).isDisplayed() - } + composeTestRule.waitForCaptureButton() composeTestRule.longClickForVideoRecordingCheckingElapsedTime() composeTestRule.waitForNodeWithTag(VIDEO_CAPTURE_SUCCESS_TAG, VIDEO_CAPTURE_TIMEOUT_MILLIS) deleteFilesInDirAfterTimestamp(MOVIES_DIR_PATH, instrumentation, timeStamp) @@ -84,13 +81,11 @@ internal class VideoRecordingDeviceTest { ) { val timeStamp = System.currentTimeMillis() // Wait for the capture button to be displayed - composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) { - composeTestRule.onNodeWithTag(CAPTURE_BUTTON).isDisplayed() - } + composeTestRule.waitForCaptureButton() composeTestRule.pressAndDragToLockVideoRecording() // stop recording - // fixme: this shouldnt need two clicks + // fixme: this shouldn't need two clicks composeTestRule.onNodeWithTag(CAPTURE_BUTTON).assertExists().performClick() composeTestRule.onNodeWithTag(CAPTURE_BUTTON).assertExists().performClick() @@ -111,9 +106,7 @@ internal class VideoRecordingDeviceTest { getSingleImageCaptureIntent(uri, MediaStore.ACTION_VIDEO_CAPTURE) ) { // Wait for the capture button to be displayed - composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) { - composeTestRule.onNodeWithTag(CAPTURE_BUTTON).isDisplayed() - } + composeTestRule.waitForCaptureButton() composeTestRule.longClickForVideoRecordingCheckingElapsedTime() } Truth.assertThat(result.resultCode).isEqualTo(Activity.RESULT_OK) @@ -130,9 +123,7 @@ internal class VideoRecordingDeviceTest { getSingleImageCaptureIntent(uri, MediaStore.ACTION_VIDEO_CAPTURE) ) { // Wait for the capture button to be displayed - composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) { - composeTestRule.onNodeWithTag(CAPTURE_BUTTON).isDisplayed() - } + composeTestRule.waitForCaptureButton() // start recording composeTestRule.tapStartLockedVideoRecording() @@ -153,9 +144,7 @@ internal class VideoRecordingDeviceTest { getSingleImageCaptureIntent(uri, MediaStore.ACTION_VIDEO_CAPTURE) ) { // Wait for the capture button to be displayed - composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) { - composeTestRule.onNodeWithTag(CAPTURE_BUTTON).isDisplayed() - } + composeTestRule.waitForCaptureButton() composeTestRule.longClickForVideoRecording() composeTestRule.waitForNodeWithTag( VIDEO_CAPTURE_FAILURE_TAG, diff --git a/app/src/androidTest/java/com/google/jetpackcamera/utils/ComposeTestRuleExt.kt b/app/src/androidTest/java/com/google/jetpackcamera/utils/ComposeTestRuleExt.kt index 4a9e56f72..09c6f0472 100644 --- a/app/src/androidTest/java/com/google/jetpackcamera/utils/ComposeTestRuleExt.kt +++ b/app/src/androidTest/java/com/google/jetpackcamera/utils/ComposeTestRuleExt.kt @@ -144,9 +144,9 @@ fun ComposeTestRule.wait(timeoutMillis: Long) { } } fun ComposeTestRule.waitForCaptureButton(timeoutMillis: Long = APP_START_TIMEOUT_MILLIS) { - // Wait for the capture button to be displayed + // Wait for the capture button to be displayed and enabled waitUntil(timeoutMillis = timeoutMillis) { - onNodeWithTag(CAPTURE_BUTTON).isDisplayed() + onNode(hasTestTag(CAPTURE_BUTTON) and isEnabled()).isDisplayed() } } diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureButtonComponents.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureButtonComponents.kt index d659a84e3..abec033db 100644 --- a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureButtonComponents.kt +++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureButtonComponents.kt @@ -67,6 +67,8 @@ import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalViewConfiguration +import androidx.compose.ui.semantics.disabled +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -166,9 +168,9 @@ fun CaptureButton( captureButtonUiState: CaptureButtonUiState, captureButtonSize: Float = DEFAULT_CAPTURE_BUTTON_SIZE ) { - var currentUiState = rememberUpdatedState(captureButtonUiState) + val currentUiState = rememberUpdatedState(captureButtonUiState) val firstKeyPressed = remember { mutableStateOf(null) } - val isLongPressing = remember { mutableStateOf(false) } + val isLongPressing = remember { mutableStateOf(false) } var longPressJob by remember { mutableStateOf(null) } val scope = rememberCoroutineScope() val longPressTimeout = LocalViewConfiguration.current.longPressTimeoutMillis @@ -183,7 +185,7 @@ fun CaptureButton( } } fun onLongPress() { - if (isLongPressing.value == false) { + if (!isLongPressing.value) { when (val current = currentUiState.value) { is CaptureButtonUiState.Enabled.Idle -> when (current.captureMode) { CaptureMode.STANDARD, @@ -289,10 +291,37 @@ private fun CaptureButton( val currentUiState = rememberUpdatedState(captureButtonUiState) val switchWidth = (captureButtonSize * LOCK_SWITCH_WIDTH_SCALE) - val currentColor = LocalContentColor.current var relativeCaptureButtonBounds by remember { mutableStateOf(null) } + val isEnabled = captureButtonUiState !is CaptureButtonUiState.Unavailable + + var isVisuallyDisabled by remember { + mutableStateOf(captureButtonUiState is CaptureButtonUiState.Unavailable) + } + val currentCaptureButtonUiState = rememberUpdatedState(captureButtonUiState) + + LaunchedEffect(captureButtonUiState) { + if (currentCaptureButtonUiState.value is CaptureButtonUiState.Unavailable) { + delay(1000) + if (currentCaptureButtonUiState.value is CaptureButtonUiState.Unavailable) { + isVisuallyDisabled = true + } + } else { + isVisuallyDisabled = false + } + } + + val animatedColor by animateColorAsState( + targetValue = if (isVisuallyDisabled) { + LocalContentColor.current.copy(alpha = 0.38f) + } else { + LocalContentColor.current + }, + animationSpec = tween(durationMillis = if (isVisuallyDisabled) 1000 else 300), + label = "Capture Button Color" + ) + fun shouldBeLocked(): Boolean = switchPosition > MINIMUM_LOCK_THRESHOLD fun setLockSwitchPosition(positionX: Float, offsetX: Float) { @@ -323,12 +352,8 @@ private fun CaptureButton( LOCK_SWITCH_POSITION_ON } } - CaptureButtonRing( - modifier = modifier - .onSizeChanged { - relativeCaptureButtonBounds = - Rect(0f, 0f, it.width.toFloat(), it.height.toFloat()) - } + val gestureModifier = if (isEnabled) { + Modifier .pointerInput(Unit) { detectTapGestures( // onLongPress cannot be null, otherwise it won't detect the release if the @@ -385,9 +410,24 @@ private fun CaptureButton( } } ) - }, + } + } else { + Modifier + } + CaptureButtonRing( + modifier = modifier + .onSizeChanged { + relativeCaptureButtonBounds = + Rect(0f, 0f, it.width.toFloat(), it.height.toFloat()) + } + .semantics { + if (!isEnabled) { + disabled() + } + } + .then(gestureModifier), captureButtonSize = captureButtonSize, - color = currentColor + color = animatedColor ) { if (useLockSwitch) { LockSwitchCaptureButtonNucleus( @@ -633,6 +673,19 @@ private fun CaptureButtonNucleus( } } +@Preview +@Composable +private fun CaptureButtonUnavailablePreview() { + CaptureButton( + onImageCapture = {}, + onStartRecording = {}, + onStopRecording = {}, + onLockVideoRecording = {}, + onIncrementZoom = {}, + captureButtonUiState = CaptureButtonUiState.Unavailable + ) +} + @Preview @Composable private fun IdleStandardCaptureButtonPreview() { diff --git a/ui/uistateadapter/capture/src/main/java/com/google/jetpackcamera/ui/uistateadapter/capture/CaptureButtonUiStateAdapter.kt b/ui/uistateadapter/capture/src/main/java/com/google/jetpackcamera/ui/uistateadapter/capture/CaptureButtonUiStateAdapter.kt index e8e11a7ab..724abc0a1 100644 --- a/ui/uistateadapter/capture/src/main/java/com/google/jetpackcamera/ui/uistateadapter/capture/CaptureButtonUiStateAdapter.kt +++ b/ui/uistateadapter/capture/src/main/java/com/google/jetpackcamera/ui/uistateadapter/capture/CaptureButtonUiStateAdapter.kt @@ -24,21 +24,25 @@ fun CaptureButtonUiState.Companion.from( cameraAppSettings: CameraAppSettings, cameraState: CameraState, lockedState: Boolean -): CaptureButtonUiState = when (cameraState.videoRecordingState) { - // if not currently recording, check capturemode to determine idle capture button UI - is VideoRecordingState.Inactive -> - CaptureButtonUiState - .Enabled.Idle(captureMode = cameraAppSettings.captureMode) +): CaptureButtonUiState = if (cameraState.isCameraRunning) { + when (cameraState.videoRecordingState) { + // if not currently recording, check capturemode to determine idle capture button UI + is VideoRecordingState.Inactive -> + CaptureButtonUiState + .Enabled.Idle(captureMode = cameraAppSettings.captureMode) - // display different capture button UI depending on if recording is pressed or locked - is VideoRecordingState.Active.Recording, is VideoRecordingState.Active.Paused -> - if (lockedState) { - CaptureButtonUiState.Enabled.Recording.LockedRecording - } else { - CaptureButtonUiState.Enabled.Recording.PressedRecording - } + // display different capture button UI depending on if recording is pressed or locked + is VideoRecordingState.Active.Recording, is VideoRecordingState.Active.Paused -> + if (lockedState) { + CaptureButtonUiState.Enabled.Recording.LockedRecording + } else { + CaptureButtonUiState.Enabled.Recording.PressedRecording + } - is VideoRecordingState.Starting -> - CaptureButtonUiState - .Enabled.Idle(captureMode = cameraAppSettings.captureMode) + is VideoRecordingState.Starting -> + CaptureButtonUiState + .Enabled.Idle(captureMode = cameraAppSettings.captureMode) + } +} else { + CaptureButtonUiState.Unavailable } From 0e9aa1bfef883d4b79aff5c505a38d2e9c9a55bd Mon Sep 17 00:00:00 2001 From: Trevor McGuire Date: Tue, 13 Jan 2026 16:39:52 -0800 Subject: [PATCH 05/12] Update more tests to use `waitForCaptureButton` --- .../CachedImageCaptureDeviceTest.kt | 25 ++++----------- .../CachedVideoRecordingDeviceTest.kt | 11 ++----- .../google/jetpackcamera/FlashDeviceTest.kt | 31 +++++-------------- .../jetpackcamera/SettingsDeviceTest.kt | 7 ++--- .../google/jetpackcamera/VideoAudioTest.kt | 7 ++--- 5 files changed, 20 insertions(+), 61 deletions(-) diff --git a/app/src/androidTest/java/com/google/jetpackcamera/CachedImageCaptureDeviceTest.kt b/app/src/androidTest/java/com/google/jetpackcamera/CachedImageCaptureDeviceTest.kt index 20bd7076b..51c7e1261 100644 --- a/app/src/androidTest/java/com/google/jetpackcamera/CachedImageCaptureDeviceTest.kt +++ b/app/src/androidTest/java/com/google/jetpackcamera/CachedImageCaptureDeviceTest.kt @@ -34,7 +34,6 @@ import com.google.jetpackcamera.feature.postcapture.ui.VIEWER_POST_CAPTURE_IMAGE import com.google.jetpackcamera.ui.components.capture.CAPTURE_BUTTON import com.google.jetpackcamera.ui.components.capture.IMAGE_CAPTURE_FAILURE_TAG import com.google.jetpackcamera.ui.components.capture.IMAGE_CAPTURE_SUCCESS_TAG -import com.google.jetpackcamera.utils.APP_START_TIMEOUT_MILLIS import com.google.jetpackcamera.utils.IMAGE_CAPTURE_TIMEOUT_MILLIS import com.google.jetpackcamera.utils.IMAGE_PREFIX import com.google.jetpackcamera.utils.MESSAGE_DISAPPEAR_TIMEOUT_MILLIS @@ -101,9 +100,7 @@ class CachedImageCaptureDeviceTest { cacheExtra ) { // Wait for the capture button to be displayed - composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) { - composeTestRule.onNodeWithTag(CAPTURE_BUTTON).isDisplayed() - } + composeTestRule.waitForCaptureButton() composeTestRule.onNodeWithTag(CAPTURE_BUTTON) .assertExists() @@ -127,9 +124,7 @@ class CachedImageCaptureDeviceTest { cacheExtra ) { // Wait for the capture button to be displayed - composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) { - composeTestRule.onNodeWithTag(CAPTURE_BUTTON).isDisplayed() - } + composeTestRule.waitForCaptureButton() composeTestRule.onNodeWithTag(CAPTURE_BUTTON) .assertExists() @@ -162,9 +157,7 @@ class CachedImageCaptureDeviceTest { cacheExtra ) { // Wait for the capture button to be displayed - composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) { - composeTestRule.onNodeWithTag(CAPTURE_BUTTON).isDisplayed() - } + composeTestRule.waitForCaptureButton() repeat(2) { clickCaptureAndWaitUntilMessageDisappears( IMAGE_CAPTURE_TIMEOUT_MILLIS, @@ -191,9 +184,7 @@ class CachedImageCaptureDeviceTest { cacheExtra ) { // Wait for the capture button to be displayed - composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) { - composeTestRule.onNodeWithTag(CAPTURE_BUTTON).isDisplayed() - } + composeTestRule.waitForCaptureButton() repeat(2) { clickCaptureAndWaitUntilMessageDisappears( IMAGE_CAPTURE_TIMEOUT_MILLIS, @@ -219,9 +210,7 @@ class CachedImageCaptureDeviceTest { cacheExtra ) { // Wait for the capture button to be displayed - composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) { - composeTestRule.onNodeWithTag(CAPTURE_BUTTON).isDisplayed() - } + composeTestRule.waitForCaptureButton() uiDevice.pressBack() } Truth.assertThat(result.resultCode).isEqualTo(Activity.RESULT_CANCELED) @@ -242,9 +231,7 @@ class CachedImageCaptureDeviceTest { cacheExtra ) { // Wait for the capture button to be displayed - composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) { - composeTestRule.onNodeWithTag(CAPTURE_BUTTON).isDisplayed() - } + composeTestRule.waitForCaptureButton() clickCaptureAndWaitUntilMessageDisappears( IMAGE_CAPTURE_TIMEOUT_MILLIS, IMAGE_CAPTURE_FAILURE_TAG diff --git a/app/src/androidTest/java/com/google/jetpackcamera/CachedVideoRecordingDeviceTest.kt b/app/src/androidTest/java/com/google/jetpackcamera/CachedVideoRecordingDeviceTest.kt index 4728de0f6..4c419d85e 100644 --- a/app/src/androidTest/java/com/google/jetpackcamera/CachedVideoRecordingDeviceTest.kt +++ b/app/src/androidTest/java/com/google/jetpackcamera/CachedVideoRecordingDeviceTest.kt @@ -18,7 +18,6 @@ package com.google.jetpackcamera import android.app.Activity import android.net.Uri import android.provider.MediaStore -import androidx.compose.ui.test.isDisplayed import androidx.compose.ui.test.junit4.createEmptyComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performClick @@ -29,9 +28,7 @@ import androidx.test.uiautomator.UiDevice import com.google.common.truth.Truth import com.google.jetpackcamera.feature.postcapture.ui.BUTTON_POST_CAPTURE_EXIT import com.google.jetpackcamera.feature.postcapture.ui.VIEWER_POST_CAPTURE_VIDEO -import com.google.jetpackcamera.ui.components.capture.CAPTURE_BUTTON import com.google.jetpackcamera.ui.components.capture.VIDEO_CAPTURE_FAILURE_TAG -import com.google.jetpackcamera.utils.APP_START_TIMEOUT_MILLIS import com.google.jetpackcamera.utils.MOVIES_DIR_PATH import com.google.jetpackcamera.utils.TEST_REQUIRED_PERMISSIONS import com.google.jetpackcamera.utils.VIDEO_CAPTURE_TIMEOUT_MILLIS @@ -92,9 +89,7 @@ class CachedVideoRecordingDeviceTest { cacheExtra ) { // Wait for the capture button to be displayed - composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) { - composeTestRule.onNodeWithTag(CAPTURE_BUTTON).isDisplayed() - } + composeTestRule.waitForCaptureButton() composeTestRule.longClickForVideoRecordingCheckingElapsedTime() } Truth.assertThat(result.resultCode).isEqualTo(Activity.RESULT_OK) @@ -110,9 +105,7 @@ class CachedVideoRecordingDeviceTest { getSingleImageCaptureIntent(uri, MediaStore.ACTION_VIDEO_CAPTURE) ) { // Wait for the capture button to be displayed - composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) { - composeTestRule.onNodeWithTag(CAPTURE_BUTTON).isDisplayed() - } + composeTestRule.waitForCaptureButton() composeTestRule.longClickForVideoRecording() composeTestRule.waitForNodeWithTag( VIDEO_CAPTURE_FAILURE_TAG, diff --git a/app/src/androidTest/java/com/google/jetpackcamera/FlashDeviceTest.kt b/app/src/androidTest/java/com/google/jetpackcamera/FlashDeviceTest.kt index 90758b0a8..6649d27c5 100644 --- a/app/src/androidTest/java/com/google/jetpackcamera/FlashDeviceTest.kt +++ b/app/src/androidTest/java/com/google/jetpackcamera/FlashDeviceTest.kt @@ -17,7 +17,6 @@ package com.google.jetpackcamera import android.os.Build import android.provider.MediaStore -import androidx.compose.ui.test.isDisplayed import androidx.compose.ui.test.isEnabled import androidx.compose.ui.test.junit4.createEmptyComposeRule import androidx.compose.ui.test.onNodeWithTag @@ -35,7 +34,6 @@ import com.google.jetpackcamera.ui.components.capture.FLIP_CAMERA_BUTTON import com.google.jetpackcamera.ui.components.capture.IMAGE_CAPTURE_SUCCESS_TAG import com.google.jetpackcamera.ui.components.capture.SCREEN_FLASH_OVERLAY import com.google.jetpackcamera.ui.components.capture.VIDEO_CAPTURE_SUCCESS_TAG -import com.google.jetpackcamera.utils.APP_START_TIMEOUT_MILLIS import com.google.jetpackcamera.utils.IMAGE_CAPTURE_TIMEOUT_MILLIS import com.google.jetpackcamera.utils.SCREEN_FLASH_OVERLAY_TIMEOUT_MILLIS import com.google.jetpackcamera.utils.TEST_REQUIRED_PERMISSIONS @@ -46,6 +44,7 @@ import com.google.jetpackcamera.utils.longClickForVideoRecordingCheckingElapsedT import com.google.jetpackcamera.utils.runMainActivityMediaStoreAutoDeleteScenarioTest import com.google.jetpackcamera.utils.runMainActivityScenarioTest import com.google.jetpackcamera.utils.setFlashMode +import com.google.jetpackcamera.utils.waitForCaptureButton import com.google.jetpackcamera.utils.waitForNodeWithTag import org.junit.Before import org.junit.Rule @@ -72,9 +71,7 @@ internal class FlashDeviceTest { @Test fun set_flash_on() = runMainActivityScenarioTest { // Wait for the capture button to be displayed - composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) { - composeTestRule.onNodeWithTag(CAPTURE_BUTTON).isDisplayed() - } + composeTestRule.waitForCaptureButton() composeTestRule.setFlashMode(FlashMode.ON) } @@ -82,9 +79,7 @@ internal class FlashDeviceTest { @Test fun set_flash_auto() = runMainActivityScenarioTest { // Wait for the capture button to be displayed - composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) { - composeTestRule.onNodeWithTag(CAPTURE_BUTTON).isDisplayed() - } + composeTestRule.waitForCaptureButton() composeTestRule.setFlashMode(FlashMode.AUTO) } @@ -92,9 +87,7 @@ internal class FlashDeviceTest { @Test fun set_flash_off() = runMainActivityScenarioTest { // Wait for the capture button to be displayed - composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) { - composeTestRule.onNodeWithTag(CAPTURE_BUTTON).isDisplayed() - } + composeTestRule.waitForCaptureButton() composeTestRule.setFlashMode(FlashMode.OFF) } @@ -102,9 +95,7 @@ internal class FlashDeviceTest { @Test fun set_flash_low_light_boost() = runMainActivityScenarioTest { // Wait for the capture button to be displayed - composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) { - composeTestRule.onNodeWithTag(CAPTURE_BUTTON).isDisplayed() - } + composeTestRule.waitForCaptureButton() composeTestRule.setFlashMode(FlashMode.LOW_LIGHT_BOOST) } @@ -124,9 +115,7 @@ internal class FlashDeviceTest { assumeHalStableOnImageCapture() // Wait for the capture button to be displayed - composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) { - composeTestRule.onNodeWithTag(CAPTURE_BUTTON).isDisplayed() - } + composeTestRule.waitForCaptureButton() // Ensure camera has a back camera and flip to it val lensFacing = composeTestRule.getCurrentLensFacing() @@ -152,9 +141,7 @@ internal class FlashDeviceTest { filePrefix = "JCA" ) { // Wait for the capture button to be displayed - composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) { - composeTestRule.onNodeWithTag(CAPTURE_BUTTON).isDisplayed() - } + composeTestRule.waitForCaptureButton() // Ensure camera has a front camera and flip to it val lensFacing = composeTestRule.getCurrentLensFacing() @@ -195,9 +182,7 @@ internal class FlashDeviceTest { mediaUri = MediaStore.Video.Media.EXTERNAL_CONTENT_URI ) { // Wait for the capture button to be displayed - composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) { - composeTestRule.onNodeWithTag(CAPTURE_BUTTON).isDisplayed() - } + composeTestRule.waitForCaptureButton() // Ensure camera has the target lens facing camera and flip to it val lensFacing = composeTestRule.getCurrentLensFacing() diff --git a/app/src/androidTest/java/com/google/jetpackcamera/SettingsDeviceTest.kt b/app/src/androidTest/java/com/google/jetpackcamera/SettingsDeviceTest.kt index d3b9a7837..a66587cdc 100644 --- a/app/src/androidTest/java/com/google/jetpackcamera/SettingsDeviceTest.kt +++ b/app/src/androidTest/java/com/google/jetpackcamera/SettingsDeviceTest.kt @@ -68,14 +68,13 @@ import com.google.jetpackcamera.settings.ui.BTN_OPEN_DIALOG_SETTING_STREAM_CONFI import com.google.jetpackcamera.settings.ui.BTN_OPEN_DIALOG_SETTING_VIDEO_DURATION_TAG import com.google.jetpackcamera.settings.ui.BTN_OPEN_DIALOG_SETTING_VIDEO_QUALITY_TAG import com.google.jetpackcamera.settings.ui.BTN_OPEN_DIALOG_SETTING_VIDEO_STABILIZATION_TAG -import com.google.jetpackcamera.ui.components.capture.CAPTURE_BUTTON -import com.google.jetpackcamera.utils.APP_START_TIMEOUT_MILLIS import com.google.jetpackcamera.utils.DEFAULT_TIMEOUT_MILLIS import com.google.jetpackcamera.utils.TEST_REQUIRED_PERMISSIONS import com.google.jetpackcamera.utils.runMainActivityScenarioTest import com.google.jetpackcamera.utils.selectLensFacing import com.google.jetpackcamera.utils.visitSettingDialog import com.google.jetpackcamera.utils.visitSettingsScreen +import com.google.jetpackcamera.utils.waitForCaptureButton import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -105,9 +104,7 @@ class SettingsDeviceTest(private val lensFacing: LensFacing) { action: ComposeTestRule.() -> Unit ): Unit = runMainActivityScenarioTest { // Wait for the capture button to be displayed - composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) { - composeTestRule.onNodeWithTag(CAPTURE_BUTTON).isDisplayed() - } + composeTestRule.waitForCaptureButton() composeTestRule.visitSettingsScreen { // Ensure appropriate lens facing is selected diff --git a/app/src/androidTest/java/com/google/jetpackcamera/VideoAudioTest.kt b/app/src/androidTest/java/com/google/jetpackcamera/VideoAudioTest.kt index 626249b9f..3b02122b4 100644 --- a/app/src/androidTest/java/com/google/jetpackcamera/VideoAudioTest.kt +++ b/app/src/androidTest/java/com/google/jetpackcamera/VideoAudioTest.kt @@ -15,7 +15,6 @@ */ package com.google.jetpackcamera -import androidx.compose.ui.test.isDisplayed import androidx.compose.ui.test.junit4.createEmptyComposeRule import androidx.compose.ui.test.longClick import androidx.compose.ui.test.onNodeWithTag @@ -30,10 +29,10 @@ import androidx.test.uiautomator.Until import com.google.common.truth.Truth.assertThat import com.google.jetpackcamera.ui.components.capture.AMPLITUDE_HOT_TAG import com.google.jetpackcamera.ui.components.capture.CAPTURE_BUTTON -import com.google.jetpackcamera.utils.APP_START_TIMEOUT_MILLIS import com.google.jetpackcamera.utils.TEST_REQUIRED_PERMISSIONS import com.google.jetpackcamera.utils.debugExtra import com.google.jetpackcamera.utils.runMainActivityScenarioTest +import com.google.jetpackcamera.utils.waitForCaptureButton import org.junit.Before import org.junit.Rule import org.junit.Test @@ -61,9 +60,7 @@ class VideoAudioTest { runMainActivityScenarioTest(debugExtra) { // check audio visualizer composable for muted/unmuted icon. // icon will only be unmuted if audio is nonzero - composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) { - composeTestRule.onNodeWithTag(CAPTURE_BUTTON).isDisplayed() - } + composeTestRule.waitForCaptureButton() // record video composeTestRule.onNodeWithTag(CAPTURE_BUTTON) From 74fd57266b62d384f28c67f4090cab55bc32b20e Mon Sep 17 00:00:00 2001 From: Trevor McGuire Date: Mon, 10 Nov 2025 21:55:50 -0800 Subject: [PATCH 06/12] Move visually debounced button state into its own `remember` hook --- .../capture/CaptureButtonComponents.kt | 50 +++++++++++++------ 1 file changed, 35 insertions(+), 15 deletions(-) diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureButtonComponents.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureButtonComponents.kt index abec033db..4eb0295f7 100644 --- a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureButtonComponents.kt +++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureButtonComponents.kt @@ -47,6 +47,7 @@ import androidx.compose.material3.LocalContentColor import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf @@ -269,6 +270,37 @@ fun CaptureButton( ) } +/** + * A composable that returns a debounced boolean state for whether the capture button should be + * visually disabled. + * + * While the button's semantics and pointer input are disabled immediately, the visual change + * to a disabled appearance is delayed. If the button becomes enabled again within this period, + * the distracting flicker is avoided. + * + * @param captureButtonUiState The current UI state of the capture button. + * @param delayMillis The duration to wait before visually disabling the button. + * @return A [State] holding `true` if the button should be visually disabled, `false` otherwise. + */ +@Composable +private fun rememberDebouncedVisuallyDisabled( + captureButtonUiState: CaptureButtonUiState, + delayMillis: Long = 1000L +): State { + val isVisuallyDisabled = remember { + mutableStateOf(captureButtonUiState is CaptureButtonUiState.Unavailable) + } + LaunchedEffect(captureButtonUiState) { + if (captureButtonUiState is CaptureButtonUiState.Unavailable) { + delay(delayMillis) + isVisuallyDisabled.value = true + } else { + isVisuallyDisabled.value = false + } + } + return isVisuallyDisabled +} + @Composable private fun CaptureButton( modifier: Modifier = Modifier, @@ -296,21 +328,9 @@ private fun CaptureButton( val isEnabled = captureButtonUiState !is CaptureButtonUiState.Unavailable - var isVisuallyDisabled by remember { - mutableStateOf(captureButtonUiState is CaptureButtonUiState.Unavailable) - } - val currentCaptureButtonUiState = rememberUpdatedState(captureButtonUiState) - - LaunchedEffect(captureButtonUiState) { - if (currentCaptureButtonUiState.value is CaptureButtonUiState.Unavailable) { - delay(1000) - if (currentCaptureButtonUiState.value is CaptureButtonUiState.Unavailable) { - isVisuallyDisabled = true - } - } else { - isVisuallyDisabled = false - } - } + val isVisuallyDisabled by rememberDebouncedVisuallyDisabled( + captureButtonUiState = captureButtonUiState + ) val animatedColor by animateColorAsState( targetValue = if (isVisuallyDisabled) { From c1db907ff01731a89bea113a1281960ba6581046 Mon Sep 17 00:00:00 2001 From: Trevor McGuire Date: Mon, 12 Jan 2026 15:13:11 -0800 Subject: [PATCH 07/12] Rename CaptureButtonUiState.Enabled to "Available" This is the opposite of "Unavailable". --- .../feature/preview/PreviewScreen.kt | 8 +-- .../capture/CaptureButtonComponents.kt | 54 ++++++++++--------- .../capture/CaptureScreenComponents.kt | 4 +- .../uistate/capture/CaptureButtonUiState.kt | 6 +-- .../capture/CaptureButtonUiStateAdapter.kt | 8 +-- 5 files changed, 41 insertions(+), 39 deletions(-) diff --git a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt index 17095ca55..bb5625efc 100644 --- a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt +++ b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt @@ -739,7 +739,7 @@ private fun ContentScreen_ImageOnly_Idle() { MaterialTheme(colorScheme = darkColorScheme()) { ContentScreen( captureUiState = FAKE_PREVIEW_UI_STATE_READY.copy( - captureButtonUiState = CaptureButtonUiState.Enabled.Idle(CaptureMode.IMAGE_ONLY) + captureButtonUiState = CaptureButtonUiState.Available.Idle(CaptureMode.IMAGE_ONLY) ), screenFlashUiState = ScreenFlashUiState(), surfaceRequest = null @@ -753,7 +753,7 @@ private fun ContentScreen_VideoOnly_Idle() { MaterialTheme(colorScheme = darkColorScheme()) { ContentScreen( captureUiState = FAKE_PREVIEW_UI_STATE_READY.copy( - captureButtonUiState = CaptureButtonUiState.Enabled.Idle(CaptureMode.VIDEO_ONLY) + captureButtonUiState = CaptureButtonUiState.Available.Idle(CaptureMode.VIDEO_ONLY) ), screenFlashUiState = ScreenFlashUiState(), surfaceRequest = null @@ -793,12 +793,12 @@ private val FAKE_PREVIEW_UI_STATE_READY = CaptureUiState.Ready( private val FAKE_PREVIEW_UI_STATE_PRESSED_RECORDING = FAKE_PREVIEW_UI_STATE_READY.copy( videoRecordingState = VideoRecordingState.Active.Recording(0, 0.0, 0), - captureButtonUiState = CaptureButtonUiState.Enabled.Recording.PressedRecording, + captureButtonUiState = CaptureButtonUiState.Available.Recording.PressedRecording, audioUiState = AudioUiState.Enabled.On(1.0) ) private val FAKE_PREVIEW_UI_STATE_LOCKED_RECORDING = FAKE_PREVIEW_UI_STATE_READY.copy( videoRecordingState = VideoRecordingState.Active.Recording(0, 0.0, 0), - captureButtonUiState = CaptureButtonUiState.Enabled.Recording.LockedRecording, + captureButtonUiState = CaptureButtonUiState.Available.Recording.LockedRecording, audioUiState = AudioUiState.Enabled.On(1.0) ) diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureButtonComponents.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureButtonComponents.kt index 4eb0295f7..2d5cddaa2 100644 --- a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureButtonComponents.kt +++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureButtonComponents.kt @@ -177,9 +177,11 @@ fun CaptureButton( val longPressTimeout = LocalViewConfiguration.current.longPressTimeoutMillis LaunchedEffect(captureButtonUiState) { - if (captureButtonUiState is CaptureButtonUiState.Enabled.Idle) { + if (captureButtonUiState is CaptureButtonUiState.Available.Idle) { onLockVideoRecording(false) - } else if (captureButtonUiState is CaptureButtonUiState.Enabled.Recording.LockedRecording) { + } else if (captureButtonUiState + is CaptureButtonUiState.Available.Recording.LockedRecording + ) { longPressJob = null isLongPressing.value = false firstKeyPressed.value = null @@ -188,7 +190,7 @@ fun CaptureButton( fun onLongPress() { if (!isLongPressing.value) { when (val current = currentUiState.value) { - is CaptureButtonUiState.Enabled.Idle -> when (current.captureMode) { + is CaptureButtonUiState.Available.Idle -> when (current.captureMode) { CaptureMode.STANDARD, CaptureMode.VIDEO_ONLY -> { isLongPressing.value = true @@ -222,7 +224,7 @@ fun CaptureButton( if (isLongPressing.value) { if (!isLocked && currentUiState.value is - CaptureButtonUiState.Enabled.Recording.PressedRecording + CaptureButtonUiState.Available.Recording.PressedRecording ) { Log.d(TAG, "Stopping recording") onStopRecording() @@ -231,7 +233,7 @@ fun CaptureButton( // on click else { when (val current = currentUiState.value) { - is CaptureButtonUiState.Enabled.Idle -> when (current.captureMode) { + is CaptureButtonUiState.Available.Idle -> when (current.captureMode) { CaptureMode.STANDARD, CaptureMode.IMAGE_ONLY -> onImageCapture() @@ -242,8 +244,8 @@ fun CaptureButton( } } - CaptureButtonUiState.Enabled.Recording.LockedRecording -> onStopRecording() - CaptureButtonUiState.Enabled.Recording.PressedRecording, + CaptureButtonUiState.Available.Recording.LockedRecording -> onStopRecording() + CaptureButtonUiState.Available.Recording.PressedRecording, CaptureButtonUiState.Unavailable -> { } } @@ -401,7 +403,7 @@ private fun CaptureButton( onDragCancel = {}, onDrag = { change, deltaOffset -> if (currentUiState.value == - CaptureButtonUiState.Enabled.Recording.PressedRecording + CaptureButtonUiState.Available.Recording.PressedRecording ) { val newPoint = change.position @@ -522,7 +524,7 @@ private fun LockSwitchCaptureButtonNucleus( // grey cylinder offset to the left and fades in when pressed recording AnimatedVisibility( visible = captureButtonUiState == - CaptureButtonUiState.Enabled.Recording.PressedRecording, + CaptureButtonUiState.Available.Recording.PressedRecording, enter = fadeIn(), exit = ExitTransition.None ) { @@ -554,7 +556,7 @@ private fun LockSwitchCaptureButtonNucleus( // locked icon, matches cylinder offset AnimatedVisibility( visible = captureButtonUiState == - CaptureButtonUiState.Enabled.Recording.PressedRecording, + CaptureButtonUiState.Available.Recording.PressedRecording, enter = fadeIn(), exit = ExitTransition.None ) { @@ -618,13 +620,13 @@ private fun CaptureButtonNucleus( val centerShapeSize by animateDpAsState( targetValue = when (val uiState = currentUiState.value) { // inner circle fills white ring when locked - CaptureButtonUiState.Enabled.Recording.LockedRecording -> captureButtonSize.dp + CaptureButtonUiState.Available.Recording.LockedRecording -> captureButtonSize.dp - CaptureButtonUiState.Enabled.Recording.PressedRecording -> + CaptureButtonUiState.Available.Recording.PressedRecording -> (captureButtonSize * pressedVideoCaptureScale).dp CaptureButtonUiState.Unavailable -> 0.dp - is CaptureButtonUiState.Enabled.Idle -> when (uiState.captureMode) { + is CaptureButtonUiState.Available.Idle -> when (uiState.captureMode) { // no inner circle will be visible on STANDARD CaptureMode.STANDARD -> 0.dp // large white circle will be visible on IMAGE_ONLY @@ -639,13 +641,13 @@ private fun CaptureButtonNucleus( // used to fade between red/white in the center of the capture button val animatedColor by animateColorAsState( targetValue = when (val uiState = currentUiState.value) { - is CaptureButtonUiState.Enabled.Idle -> when (uiState.captureMode) { + is CaptureButtonUiState.Available.Idle -> when (uiState.captureMode) { CaptureMode.STANDARD -> imageCaptureModeColor CaptureMode.IMAGE_ONLY -> imageCaptureModeColor CaptureMode.VIDEO_ONLY -> recordingColor } - is CaptureButtonUiState.Enabled.Recording -> recordingColor + is CaptureButtonUiState.Available.Recording -> recordingColor is CaptureButtonUiState.Unavailable -> Color.Transparent }, animationSpec = tween(durationMillis = 500) @@ -663,7 +665,7 @@ private fun CaptureButtonNucleus( .alpha( if (isPressed && currentUiState.value == - CaptureButtonUiState.Enabled.Idle(CaptureMode.IMAGE_ONLY) + CaptureButtonUiState.Available.Idle(CaptureMode.IMAGE_ONLY) ) { .5f // transparency to indicate click ONLY on IMAGE_ONLY } else { @@ -676,7 +678,7 @@ private fun CaptureButtonNucleus( // central "square" stop icon AnimatedVisibility( visible = currentUiState.value is - CaptureButtonUiState.Enabled.Recording.LockedRecording, + CaptureButtonUiState.Available.Recording.LockedRecording, enter = scaleIn(initialScale = .5f) + fadeIn(), exit = fadeOut() ) { @@ -711,7 +713,7 @@ private fun CaptureButtonUnavailablePreview() { private fun IdleStandardCaptureButtonPreview() { CaptureButtonRing(captureButtonSize = DEFAULT_CAPTURE_BUTTON_SIZE, color = Color.White) { CaptureButtonNucleus( - captureButtonUiState = CaptureButtonUiState.Enabled.Idle(CaptureMode.STANDARD), + captureButtonUiState = CaptureButtonUiState.Available.Idle(CaptureMode.STANDARD), isPressed = false, captureButtonSize = DEFAULT_CAPTURE_BUTTON_SIZE ) @@ -723,7 +725,7 @@ private fun IdleStandardCaptureButtonPreview() { private fun IdleImageCaptureButtonPreview() { CaptureButtonRing(captureButtonSize = DEFAULT_CAPTURE_BUTTON_SIZE, color = Color.White) { CaptureButtonNucleus( - captureButtonUiState = CaptureButtonUiState.Enabled.Idle(CaptureMode.IMAGE_ONLY), + captureButtonUiState = CaptureButtonUiState.Available.Idle(CaptureMode.IMAGE_ONLY), isPressed = false, captureButtonSize = DEFAULT_CAPTURE_BUTTON_SIZE ) @@ -735,7 +737,7 @@ private fun IdleImageCaptureButtonPreview() { private fun PressedImageCaptureButtonPreview() { CaptureButtonRing(captureButtonSize = DEFAULT_CAPTURE_BUTTON_SIZE, color = Color.White) { CaptureButtonNucleus( - captureButtonUiState = CaptureButtonUiState.Enabled.Idle(CaptureMode.IMAGE_ONLY), + captureButtonUiState = CaptureButtonUiState.Available.Idle(CaptureMode.IMAGE_ONLY), isPressed = true, captureButtonSize = DEFAULT_CAPTURE_BUTTON_SIZE ) @@ -747,7 +749,7 @@ private fun PressedImageCaptureButtonPreview() { private fun IdleRecordingCaptureButtonPreview() { CaptureButtonRing(captureButtonSize = DEFAULT_CAPTURE_BUTTON_SIZE, color = Color.White) { CaptureButtonNucleus( - captureButtonUiState = CaptureButtonUiState.Enabled.Idle(CaptureMode.VIDEO_ONLY), + captureButtonUiState = CaptureButtonUiState.Available.Idle(CaptureMode.VIDEO_ONLY), isPressed = false, captureButtonSize = DEFAULT_CAPTURE_BUTTON_SIZE ) @@ -759,7 +761,7 @@ private fun IdleRecordingCaptureButtonPreview() { private fun SimpleNucleusPressedRecordingPreview() { CaptureButtonRing(captureButtonSize = DEFAULT_CAPTURE_BUTTON_SIZE, color = Color.White) { CaptureButtonNucleus( - captureButtonUiState = CaptureButtonUiState.Enabled.Recording.PressedRecording, + captureButtonUiState = CaptureButtonUiState.Available.Recording.PressedRecording, isPressed = true, captureButtonSize = DEFAULT_CAPTURE_BUTTON_SIZE ) @@ -771,7 +773,7 @@ private fun SimpleNucleusPressedRecordingPreview() { private fun LockedRecordingPreview() { CaptureButtonRing(captureButtonSize = DEFAULT_CAPTURE_BUTTON_SIZE, color = Color.White) { CaptureButtonNucleus( - captureButtonUiState = CaptureButtonUiState.Enabled.Recording.LockedRecording, + captureButtonUiState = CaptureButtonUiState.Available.Recording.LockedRecording, isPressed = false, captureButtonSize = DEFAULT_CAPTURE_BUTTON_SIZE ) @@ -786,7 +788,7 @@ private fun LockSwitchUnlockedPressedRecordingPreview() { CaptureButtonRing(captureButtonSize = DEFAULT_CAPTURE_BUTTON_SIZE, color = Color.White) { LockSwitchCaptureButtonNucleus( captureButtonSize = DEFAULT_CAPTURE_BUTTON_SIZE, - captureButtonUiState = CaptureButtonUiState.Enabled.Recording.PressedRecording, + captureButtonUiState = CaptureButtonUiState.Available.Recording.PressedRecording, switchWidth = (DEFAULT_CAPTURE_BUTTON_SIZE * LOCK_SWITCH_WIDTH_SCALE).dp, switchPosition = 0f, onToggleSwitchPosition = {}, @@ -804,7 +806,7 @@ private fun LockSwitchLockedAtThresholdPressedRecordingPreview() { CaptureButtonRing(captureButtonSize = DEFAULT_CAPTURE_BUTTON_SIZE, color = Color.White) { LockSwitchCaptureButtonNucleus( captureButtonSize = DEFAULT_CAPTURE_BUTTON_SIZE, - captureButtonUiState = CaptureButtonUiState.Enabled.Recording.PressedRecording, + captureButtonUiState = CaptureButtonUiState.Available.Recording.PressedRecording, switchWidth = (DEFAULT_CAPTURE_BUTTON_SIZE * LOCK_SWITCH_WIDTH_SCALE).dp, switchPosition = MINIMUM_LOCK_THRESHOLD, onToggleSwitchPosition = {}, @@ -822,7 +824,7 @@ private fun LockSwitchLockedPressedRecordingPreview() { CaptureButtonRing(captureButtonSize = DEFAULT_CAPTURE_BUTTON_SIZE, color = Color.White) { LockSwitchCaptureButtonNucleus( captureButtonSize = DEFAULT_CAPTURE_BUTTON_SIZE, - captureButtonUiState = CaptureButtonUiState.Enabled.Recording.PressedRecording, + captureButtonUiState = CaptureButtonUiState.Available.Recording.PressedRecording, switchWidth = (DEFAULT_CAPTURE_BUTTON_SIZE * LOCK_SWITCH_WIDTH_SCALE).dp, switchPosition = 1f, onToggleSwitchPosition = {}, diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureScreenComponents.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureScreenComponents.kt index 7a06a0826..a86bed710 100644 --- a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureScreenComponents.kt +++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureScreenComponents.kt @@ -529,7 +529,7 @@ fun CaptureButton( modifier = modifier.testTag(CAPTURE_BUTTON), onIncrementZoom = onIncrementZoom, onImageCapture = { - if (captureButtonUiState is CaptureButtonUiState.Enabled) { + if (captureButtonUiState is CaptureButtonUiState.Available) { multipleEventsCutter.processEvent { onCaptureImage(context.contentResolver) } @@ -539,7 +539,7 @@ fun CaptureButton( } }, onStartRecording = { - if (captureButtonUiState is CaptureButtonUiState.Enabled) { + if (captureButtonUiState is CaptureButtonUiState.Available) { onStartVideoRecording() } }, diff --git a/ui/uistate/capture/src/main/java/com/google/jetpackcamera/ui/uistate/capture/CaptureButtonUiState.kt b/ui/uistate/capture/src/main/java/com/google/jetpackcamera/ui/uistate/capture/CaptureButtonUiState.kt index 7ab930379..962659160 100644 --- a/ui/uistate/capture/src/main/java/com/google/jetpackcamera/ui/uistate/capture/CaptureButtonUiState.kt +++ b/ui/uistate/capture/src/main/java/com/google/jetpackcamera/ui/uistate/capture/CaptureButtonUiState.kt @@ -19,10 +19,10 @@ import com.google.jetpackcamera.model.CaptureMode sealed interface CaptureButtonUiState { data object Unavailable : CaptureButtonUiState - sealed interface Enabled : CaptureButtonUiState { - data class Idle(val captureMode: CaptureMode) : Enabled + sealed interface Available : CaptureButtonUiState { + data class Idle(val captureMode: CaptureMode) : Available - sealed interface Recording : Enabled { + sealed interface Recording : Available { data object PressedRecording : Recording data object LockedRecording : Recording } diff --git a/ui/uistateadapter/capture/src/main/java/com/google/jetpackcamera/ui/uistateadapter/capture/CaptureButtonUiStateAdapter.kt b/ui/uistateadapter/capture/src/main/java/com/google/jetpackcamera/ui/uistateadapter/capture/CaptureButtonUiStateAdapter.kt index 724abc0a1..4f13c540f 100644 --- a/ui/uistateadapter/capture/src/main/java/com/google/jetpackcamera/ui/uistateadapter/capture/CaptureButtonUiStateAdapter.kt +++ b/ui/uistateadapter/capture/src/main/java/com/google/jetpackcamera/ui/uistateadapter/capture/CaptureButtonUiStateAdapter.kt @@ -29,19 +29,19 @@ fun CaptureButtonUiState.Companion.from( // if not currently recording, check capturemode to determine idle capture button UI is VideoRecordingState.Inactive -> CaptureButtonUiState - .Enabled.Idle(captureMode = cameraAppSettings.captureMode) + .Available.Idle(captureMode = cameraAppSettings.captureMode) // display different capture button UI depending on if recording is pressed or locked is VideoRecordingState.Active.Recording, is VideoRecordingState.Active.Paused -> if (lockedState) { - CaptureButtonUiState.Enabled.Recording.LockedRecording + CaptureButtonUiState.Available.Recording.LockedRecording } else { - CaptureButtonUiState.Enabled.Recording.PressedRecording + CaptureButtonUiState.Available.Recording.PressedRecording } is VideoRecordingState.Starting -> CaptureButtonUiState - .Enabled.Idle(captureMode = cameraAppSettings.captureMode) + .Available.Idle(captureMode = cameraAppSettings.captureMode) } } else { CaptureButtonUiState.Unavailable From 4833dac6b263134f8d01cf80c01b08fd6c8d88f4 Mon Sep 17 00:00:00 2001 From: Trevor McGuire Date: Mon, 12 Jan 2026 15:45:45 -0800 Subject: [PATCH 08/12] Add isEnabled property to CaptureButtonUiState to control button interactivity --- .../components/capture/CaptureButtonComponents.kt | 11 +++++------ .../ui/uistate/capture/CaptureButtonUiState.kt | 14 ++++++++++++-- .../capture/CaptureButtonUiStateAdapter.kt | 3 ++- 3 files changed, 19 insertions(+), 9 deletions(-) diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureButtonComponents.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureButtonComponents.kt index 2d5cddaa2..cc7073f7b 100644 --- a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureButtonComponents.kt +++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureButtonComponents.kt @@ -209,6 +209,7 @@ fun CaptureButton( } fun onPress(captureSource: CaptureSource) { + if (!captureButtonUiState.isEnabled) return if (firstKeyPressed.value == null) { firstKeyPressed.value = captureSource longPressJob = scope.launch { @@ -290,10 +291,10 @@ private fun rememberDebouncedVisuallyDisabled( delayMillis: Long = 1000L ): State { val isVisuallyDisabled = remember { - mutableStateOf(captureButtonUiState is CaptureButtonUiState.Unavailable) + mutableStateOf(!captureButtonUiState.isEnabled) } LaunchedEffect(captureButtonUiState) { - if (captureButtonUiState is CaptureButtonUiState.Unavailable) { + if (!captureButtonUiState.isEnabled) { delay(delayMillis) isVisuallyDisabled.value = true } else { @@ -328,8 +329,6 @@ private fun CaptureButton( var relativeCaptureButtonBounds by remember { mutableStateOf(null) } - val isEnabled = captureButtonUiState !is CaptureButtonUiState.Unavailable - val isVisuallyDisabled by rememberDebouncedVisuallyDisabled( captureButtonUiState = captureButtonUiState ) @@ -374,7 +373,7 @@ private fun CaptureButton( LOCK_SWITCH_POSITION_ON } } - val gestureModifier = if (isEnabled) { + val gestureModifier = if (captureButtonUiState.isEnabled) { Modifier .pointerInput(Unit) { detectTapGestures( @@ -443,7 +442,7 @@ private fun CaptureButton( Rect(0f, 0f, it.width.toFloat(), it.height.toFloat()) } .semantics { - if (!isEnabled) { + if (!captureButtonUiState.isEnabled) { disabled() } } diff --git a/ui/uistate/capture/src/main/java/com/google/jetpackcamera/ui/uistate/capture/CaptureButtonUiState.kt b/ui/uistate/capture/src/main/java/com/google/jetpackcamera/ui/uistate/capture/CaptureButtonUiState.kt index 962659160..771771e42 100644 --- a/ui/uistate/capture/src/main/java/com/google/jetpackcamera/ui/uistate/capture/CaptureButtonUiState.kt +++ b/ui/uistate/capture/src/main/java/com/google/jetpackcamera/ui/uistate/capture/CaptureButtonUiState.kt @@ -18,11 +18,21 @@ package com.google.jetpackcamera.ui.uistate.capture import com.google.jetpackcamera.model.CaptureMode sealed interface CaptureButtonUiState { - data object Unavailable : CaptureButtonUiState + val isEnabled: Boolean + + data object Unavailable : CaptureButtonUiState { + override val isEnabled: Boolean = false + } + sealed interface Available : CaptureButtonUiState { - data class Idle(val captureMode: CaptureMode) : Available + data class Idle( + val captureMode: CaptureMode, + override val isEnabled: Boolean = true + ) : Available sealed interface Recording : Available { + override val isEnabled: Boolean get() = true + data object PressedRecording : Recording data object LockedRecording : Recording } diff --git a/ui/uistateadapter/capture/src/main/java/com/google/jetpackcamera/ui/uistateadapter/capture/CaptureButtonUiStateAdapter.kt b/ui/uistateadapter/capture/src/main/java/com/google/jetpackcamera/ui/uistateadapter/capture/CaptureButtonUiStateAdapter.kt index 4f13c540f..5642261c4 100644 --- a/ui/uistateadapter/capture/src/main/java/com/google/jetpackcamera/ui/uistateadapter/capture/CaptureButtonUiStateAdapter.kt +++ b/ui/uistateadapter/capture/src/main/java/com/google/jetpackcamera/ui/uistateadapter/capture/CaptureButtonUiStateAdapter.kt @@ -44,5 +44,6 @@ fun CaptureButtonUiState.Companion.from( .Available.Idle(captureMode = cameraAppSettings.captureMode) } } else { - CaptureButtonUiState.Unavailable + CaptureButtonUiState + .Available.Idle(captureMode = cameraAppSettings.captureMode, isEnabled = false) } From db5fdbf4a54569381543a35cd9ad0e0325f356ef Mon Sep 17 00:00:00 2001 From: Trevor McGuire Date: Mon, 12 Jan 2026 15:47:24 -0800 Subject: [PATCH 09/12] Add documentation to CaptureButtonUiState --- .../uistate/capture/CaptureButtonUiState.kt | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/ui/uistate/capture/src/main/java/com/google/jetpackcamera/ui/uistate/capture/CaptureButtonUiState.kt b/ui/uistate/capture/src/main/java/com/google/jetpackcamera/ui/uistate/capture/CaptureButtonUiState.kt index 771771e42..d5e7459d8 100644 --- a/ui/uistate/capture/src/main/java/com/google/jetpackcamera/ui/uistate/capture/CaptureButtonUiState.kt +++ b/ui/uistate/capture/src/main/java/com/google/jetpackcamera/ui/uistate/capture/CaptureButtonUiState.kt @@ -17,23 +17,51 @@ package com.google.jetpackcamera.ui.uistate.capture import com.google.jetpackcamera.model.CaptureMode +/** + * Defines the UI state for the capture button. + */ sealed interface CaptureButtonUiState { + /** + * Whether the capture button is enabled and can be interacted with. + */ val isEnabled: Boolean + /** + * The capture button is unavailable and should not be shown or interacted with. + */ data object Unavailable : CaptureButtonUiState { override val isEnabled: Boolean = false } + /** + * The capture button is available to be shown. + */ sealed interface Available : CaptureButtonUiState { + /** + * The capture button is idle and ready to capture. + * + * @property captureMode The current capture mode. + * @property isEnabled Whether the button is enabled for interaction. + */ data class Idle( val captureMode: CaptureMode, override val isEnabled: Boolean = true ) : Available + /** + * The capture button is currently recording video. + */ sealed interface Recording : Available { override val isEnabled: Boolean get() = true + /** + * The user is actively pressing the capture button to record. + */ data object PressedRecording : Recording + + /** + * The recording is locked and continues without user interaction. + */ data object LockedRecording : Recording } } From 728a5cf27f34d6b752b058541013019477bbd3bc Mon Sep 17 00:00:00 2001 From: Trevor McGuire Date: Mon, 12 Jan 2026 16:00:53 -0800 Subject: [PATCH 10/12] Add new test for CaptureButtonUiStateAdapter --- .../CaptureButtonUiStateAdapterTest.kt | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 ui/uistateadapter/capture/src/test/java/com/google/jetpackcamera/ui/uistateadapter/capture/CaptureButtonUiStateAdapterTest.kt diff --git a/ui/uistateadapter/capture/src/test/java/com/google/jetpackcamera/ui/uistateadapter/capture/CaptureButtonUiStateAdapterTest.kt b/ui/uistateadapter/capture/src/test/java/com/google/jetpackcamera/ui/uistateadapter/capture/CaptureButtonUiStateAdapterTest.kt new file mode 100644 index 000000000..a046caeb0 --- /dev/null +++ b/ui/uistateadapter/capture/src/test/java/com/google/jetpackcamera/ui/uistateadapter/capture/CaptureButtonUiStateAdapterTest.kt @@ -0,0 +1,111 @@ +/* + * 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.uistateadapter.capture + +import com.google.common.truth.Truth.assertThat +import com.google.jetpackcamera.core.camera.CameraState +import com.google.jetpackcamera.core.camera.VideoRecordingState +import com.google.jetpackcamera.model.CaptureMode +import com.google.jetpackcamera.settings.model.CameraAppSettings +import com.google.jetpackcamera.ui.uistate.capture.CaptureButtonUiState +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 + +@RunWith(JUnit4::class) +class CaptureButtonUiStateAdapterTest { + private val defaultCameraAppSettings = CameraAppSettings(captureMode = CaptureMode.STANDARD) + private val defaultCameraState = CameraState(isCameraRunning = true) + + @Test + fun from_cameraNotRunning_returnsIdleAndDisabled() { + val cameraState = CameraState(isCameraRunning = false) + val uiState = CaptureButtonUiState.from( + defaultCameraAppSettings, + cameraState, + lockedState = false + ) + + assertThat(uiState).isInstanceOf(CaptureButtonUiState.Available.Idle::class.java) + assertThat(uiState.isEnabled).isFalse() + assertThat((uiState as CaptureButtonUiState.Available.Idle).captureMode) + .isEqualTo(CaptureMode.STANDARD) + } + + @Test + fun from_cameraRunning_recordingInactive_returnsIdleAndEnabled() { + val cameraState = defaultCameraState.copy( + videoRecordingState = VideoRecordingState.Inactive() + ) + val uiState = CaptureButtonUiState.from( + defaultCameraAppSettings, + cameraState, + lockedState = false + ) + + assertThat(uiState).isInstanceOf(CaptureButtonUiState.Available.Idle::class.java) + assertThat(uiState.isEnabled).isTrue() + assertThat((uiState as CaptureButtonUiState.Available.Idle).captureMode) + .isEqualTo(CaptureMode.STANDARD) + } + + @Test + fun from_cameraRunning_recordingPressed_returnsPressedRecording() { + val cameraState = defaultCameraState.copy( + videoRecordingState = VideoRecordingState.Active.Recording(0L, 0.0, 0L) + ) + val uiState = CaptureButtonUiState.from( + defaultCameraAppSettings, + cameraState, + lockedState = false + ) + + assertThat(uiState) + .isInstanceOf(CaptureButtonUiState.Available.Recording.PressedRecording::class.java) + assertThat(uiState.isEnabled).isTrue() + } + + @Test + fun from_cameraRunning_recordingLocked_returnsLockedRecording() { + val cameraState = defaultCameraState.copy( + videoRecordingState = VideoRecordingState.Active.Recording(0L, 0.0, 0L) + ) + val uiState = CaptureButtonUiState.from( + defaultCameraAppSettings, + cameraState, + lockedState = true + ) + + assertThat(uiState) + .isInstanceOf(CaptureButtonUiState.Available.Recording.LockedRecording::class.java) + assertThat(uiState.isEnabled).isTrue() + } + + @Test + fun from_cameraRunning_recordingStarting_returnsIdleAndEnabled() { + val cameraState = defaultCameraState.copy( + videoRecordingState = VideoRecordingState.Starting(null) + ) + val uiState = CaptureButtonUiState.from( + defaultCameraAppSettings, + cameraState, + lockedState = false + ) + + assertThat(uiState).isInstanceOf(CaptureButtonUiState.Available.Idle::class.java) + assertThat(uiState.isEnabled).isTrue() + } +} From 1f5bccd1377d4c24f72523197492151eb61b1c59 Mon Sep 17 00:00:00 2001 From: Trevor McGuire Date: Mon, 12 Jan 2026 16:01:17 -0800 Subject: [PATCH 11/12] Add new Compose Previews for disabled capture button states --- .../capture/CaptureButtonComponents.kt | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureButtonComponents.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureButtonComponents.kt index cc7073f7b..0cc314aab 100644 --- a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureButtonComponents.kt +++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureButtonComponents.kt @@ -731,6 +731,63 @@ private fun IdleImageCaptureButtonPreview() { } } +@Preview +@Composable +private fun IdleVideoOnlyCaptureButtonPreview() { + CaptureButtonRing(captureButtonSize = DEFAULT_CAPTURE_BUTTON_SIZE, color = Color.White) { + CaptureButtonNucleus( + captureButtonUiState = CaptureButtonUiState.Available.Idle(CaptureMode.VIDEO_ONLY), + isPressed = false, + captureButtonSize = DEFAULT_CAPTURE_BUTTON_SIZE + ) + } +} + +@Preview +@Composable +private fun IdleStandardCaptureButtonDisabledPreview() { + CaptureButtonRing(captureButtonSize = DEFAULT_CAPTURE_BUTTON_SIZE, color = Color.Gray) { + CaptureButtonNucleus( + captureButtonUiState = CaptureButtonUiState.Available.Idle( + CaptureMode.STANDARD, + isEnabled = false + ), + isPressed = false, + captureButtonSize = DEFAULT_CAPTURE_BUTTON_SIZE + ) + } +} + +@Preview +@Composable +private fun IdleImageCaptureButtonDisabledPreview() { + CaptureButtonRing(captureButtonSize = DEFAULT_CAPTURE_BUTTON_SIZE, color = Color.Gray) { + CaptureButtonNucleus( + captureButtonUiState = CaptureButtonUiState.Available.Idle( + CaptureMode.IMAGE_ONLY, + isEnabled = false + ), + isPressed = false, + captureButtonSize = DEFAULT_CAPTURE_BUTTON_SIZE + ) + } +} + +@Preview +@Composable +private fun IdleVideoOnlyCaptureButtonDisabledPreview() { + CaptureButtonRing(captureButtonSize = DEFAULT_CAPTURE_BUTTON_SIZE, color = Color.Gray) { + CaptureButtonNucleus( + captureButtonUiState = CaptureButtonUiState.Available.Idle( + CaptureMode.VIDEO_ONLY, + isEnabled = false + ), + isPressed = false, + captureButtonSize = DEFAULT_CAPTURE_BUTTON_SIZE + ) + } +} + @Preview @Composable private fun PressedImageCaptureButtonPreview() { From a4dea88de2dac628613bc502e1a56d1f3807f596 Mon Sep 17 00:00:00 2001 From: Trevor McGuire Date: Wed, 14 Jan 2026 18:20:51 -0800 Subject: [PATCH 12/12] Remove repeated logic from `CaptureModeSettingsTest` --- .../jetpackcamera/CaptureModeSettingsTest.kt | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/app/src/androidTest/java/com/google/jetpackcamera/CaptureModeSettingsTest.kt b/app/src/androidTest/java/com/google/jetpackcamera/CaptureModeSettingsTest.kt index 473e3e58c..e8599e37a 100644 --- a/app/src/androidTest/java/com/google/jetpackcamera/CaptureModeSettingsTest.kt +++ b/app/src/androidTest/java/com/google/jetpackcamera/CaptureModeSettingsTest.kt @@ -113,6 +113,15 @@ internal class CaptureModeSettingsTest { onNodeWithTag(CAPTURE_MODE_TOGGLE_BUTTON).assertExists() } + private fun flip(mode: CaptureMode): CaptureMode { + require(mode == CaptureMode.IMAGE_ONLY || mode == CaptureMode.VIDEO_ONLY) + return if (mode == CaptureMode.IMAGE_ONLY) { + CaptureMode.VIDEO_ONLY + } else { + CaptureMode.IMAGE_ONLY + } + } + @Test fun can_set_capture_mode_in_quick_settings() { runMainActivityScenarioTest { @@ -391,11 +400,7 @@ internal class CaptureModeSettingsTest { composeTestRule.initializeCaptureSwitch() val initialCaptureMode = composeTestRule.getCaptureModeToggleState() - val targetCaptureMode = if (initialCaptureMode == CaptureMode.IMAGE_ONLY) { - CaptureMode.VIDEO_ONLY - } else { - CaptureMode.IMAGE_ONLY - } + val targetCaptureMode = flip(initialCaptureMode) // should be different from initial capture mode composeTestRule.onNodeWithTag(CAPTURE_MODE_TOGGLE_BUTTON).performClick() @@ -412,11 +417,7 @@ internal class CaptureModeSettingsTest { composeTestRule.waitForCaptureButton() composeTestRule.initializeCaptureSwitch() val initialCaptureMode = composeTestRule.getCaptureModeToggleState() - val targetCaptureMode = if (initialCaptureMode == CaptureMode.IMAGE_ONLY) { - CaptureMode.VIDEO_ONLY - } else { - CaptureMode.IMAGE_ONLY - } + val targetCaptureMode = flip(initialCaptureMode) val captureToggleNode = composeTestRule.onNodeWithTag(CAPTURE_MODE_TOGGLE_BUTTON) val toggleNodeWidth = captureToggleNode.fetchSemanticsNode().size.width.toFloat() val offsetToSwitch = when (initialCaptureMode) {