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/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/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/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) 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/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 ef6940968..1480a57c3 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 @@ -748,7 +748,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 @@ -762,7 +762,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 @@ -802,12 +802,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 d659a84e3..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 @@ -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 @@ -67,6 +68,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,26 +169,28 @@ 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 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 } } fun onLongPress() { - if (isLongPressing.value == false) { + 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 @@ -204,6 +209,7 @@ fun CaptureButton( } fun onPress(captureSource: CaptureSource) { + if (!captureButtonUiState.isEnabled) return if (firstKeyPressed.value == null) { firstKeyPressed.value = captureSource longPressJob = scope.launch { @@ -219,7 +225,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() @@ -228,7 +234,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() @@ -239,8 +245,8 @@ fun CaptureButton( } } - CaptureButtonUiState.Enabled.Recording.LockedRecording -> onStopRecording() - CaptureButtonUiState.Enabled.Recording.PressedRecording, + CaptureButtonUiState.Available.Recording.LockedRecording -> onStopRecording() + CaptureButtonUiState.Available.Recording.PressedRecording, CaptureButtonUiState.Unavailable -> { } } @@ -267,6 +273,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.isEnabled) + } + LaunchedEffect(captureButtonUiState) { + if (!captureButtonUiState.isEnabled) { + delay(delayMillis) + isVisuallyDisabled.value = true + } else { + isVisuallyDisabled.value = false + } + } + return isVisuallyDisabled +} + @Composable private fun CaptureButton( modifier: Modifier = Modifier, @@ -289,10 +326,23 @@ 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 isVisuallyDisabled by rememberDebouncedVisuallyDisabled( + captureButtonUiState = captureButtonUiState + ) + + 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 +373,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 (captureButtonUiState.isEnabled) { + Modifier .pointerInput(Unit) { detectTapGestures( // onLongPress cannot be null, otherwise it won't detect the release if the @@ -356,7 +402,7 @@ private fun CaptureButton( onDragCancel = {}, onDrag = { change, deltaOffset -> if (currentUiState.value == - CaptureButtonUiState.Enabled.Recording.PressedRecording + CaptureButtonUiState.Available.Recording.PressedRecording ) { val newPoint = change.position @@ -385,9 +431,24 @@ private fun CaptureButton( } } ) - }, + } + } else { + Modifier + } + CaptureButtonRing( + modifier = modifier + .onSizeChanged { + relativeCaptureButtonBounds = + Rect(0f, 0f, it.width.toFloat(), it.height.toFloat()) + } + .semantics { + if (!captureButtonUiState.isEnabled) { + disabled() + } + } + .then(gestureModifier), captureButtonSize = captureButtonSize, - color = currentColor + color = animatedColor ) { if (useLockSwitch) { LockSwitchCaptureButtonNucleus( @@ -462,7 +523,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 ) { @@ -494,7 +555,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 ) { @@ -558,13 +619,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 @@ -579,13 +640,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) @@ -603,7 +664,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 { @@ -616,7 +677,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() ) { @@ -633,12 +694,25 @@ private fun CaptureButtonNucleus( } } +@Preview +@Composable +private fun CaptureButtonUnavailablePreview() { + CaptureButton( + onImageCapture = {}, + onStartRecording = {}, + onStopRecording = {}, + onLockVideoRecording = {}, + onIncrementZoom = {}, + captureButtonUiState = CaptureButtonUiState.Unavailable + ) +} + @Preview @Composable 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 ) @@ -650,7 +724,64 @@ 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 + ) + } +} + +@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 ) @@ -662,7 +793,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 ) @@ -674,7 +805,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 ) @@ -686,7 +817,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 ) @@ -698,7 +829,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 ) @@ -713,7 +844,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 = {}, @@ -731,7 +862,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 = {}, @@ -749,7 +880,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..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,13 +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 { - data object Unavailable : CaptureButtonUiState - sealed interface Enabled : CaptureButtonUiState { - data class Idle(val captureMode: CaptureMode) : Enabled + /** + * 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 + } - sealed interface Recording : Enabled { + /** + * 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 } } 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..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 @@ -24,21 +24,26 @@ 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 + .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 - } 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.Available.Recording.LockedRecording + } else { + CaptureButtonUiState.Available.Recording.PressedRecording + } - is VideoRecordingState.Starting -> - CaptureButtonUiState - .Enabled.Idle(captureMode = cameraAppSettings.captureMode) + is VideoRecordingState.Starting -> + CaptureButtonUiState + .Available.Idle(captureMode = cameraAppSettings.captureMode) + } +} else { + CaptureButtonUiState + .Available.Idle(captureMode = cameraAppSettings.captureMode, isEnabled = false) } 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() + } +}