From 557f056ab7e83bcda6cba407deabbf34ac45a38c Mon Sep 17 00:00:00 2001 From: Kimberly Crevecoeur Date: Tue, 7 Jul 2026 09:31:55 -0700 Subject: [PATCH 1/7] Refactor audio toggle to use Compose Semantics and add integration tests --- .../jetpackcamera/DebugHideComponentsTest.kt | 6 +- .../google/jetpackcamera/VideoAudioTest.kt | 104 ++++++++++++++++-- .../jetpackcamera/utils/ComposeTestRuleExt.kt | 24 +++- .../core/camera/CameraSession.kt | 15 +-- .../capture/CaptureScreenComponents.kt | 52 +++++---- .../ui/components/capture/TestTags.kt | 6 +- 6 files changed, 152 insertions(+), 55 deletions(-) diff --git a/app/src/androidTest/java/com/google/jetpackcamera/DebugHideComponentsTest.kt b/app/src/androidTest/java/com/google/jetpackcamera/DebugHideComponentsTest.kt index ef29f97d0..964a2ecf0 100644 --- a/app/src/androidTest/java/com/google/jetpackcamera/DebugHideComponentsTest.kt +++ b/app/src/androidTest/java/com/google/jetpackcamera/DebugHideComponentsTest.kt @@ -23,8 +23,7 @@ import androidx.test.platform.app.InstrumentationRegistry import androidx.test.rule.GrantPermissionRule import androidx.test.uiautomator.UiDevice import com.google.common.truth.Truth.assertThat -import com.google.jetpackcamera.ui.components.capture.AMPLITUDE_HOT_TAG -import com.google.jetpackcamera.ui.components.capture.AMPLITUDE_NONE_TAG +import com.google.jetpackcamera.ui.components.capture.AUDIO_INPUT_TOGGLE 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.ZOOM_BUTTON_ROW_TAG @@ -76,8 +75,7 @@ class DebugHideComponentsTest { composeTestRule.waitForNodeWithTagToDisappear(CAPTURE_BUTTON) composeTestRule.onNodeWithTag(ZOOM_BUTTON_ROW_TAG).assertDoesNotExist() composeTestRule.onNodeWithTag(FLIP_CAMERA_BUTTON).assertDoesNotExist() - composeTestRule.onNodeWithTag(AMPLITUDE_NONE_TAG).assertDoesNotExist() - composeTestRule.onNodeWithTag(AMPLITUDE_HOT_TAG).assertDoesNotExist() + composeTestRule.onNodeWithTag(AUDIO_INPUT_TOGGLE).assertDoesNotExist() composeTestRule.onNodeWithTag(DEBUG_OVERLAY_BUTTON).assertDoesNotExist() composeTestRule.onNodeWithTag(LOGICAL_CAMERA_ID_TAG).assertDoesNotExist() composeTestRule.onNodeWithTag(PHYSICAL_CAMERA_ID_TAG).assertDoesNotExist() diff --git a/app/src/androidTest/java/com/google/jetpackcamera/VideoAudioTest.kt b/app/src/androidTest/java/com/google/jetpackcamera/VideoAudioTest.kt index 3b02122b4..6fa182129 100644 --- a/app/src/androidTest/java/com/google/jetpackcamera/VideoAudioTest.kt +++ b/app/src/androidTest/java/com/google/jetpackcamera/VideoAudioTest.kt @@ -16,23 +16,25 @@ package com.google.jetpackcamera import androidx.compose.ui.test.junit4.createEmptyComposeRule -import androidx.compose.ui.test.longClick import androidx.compose.ui.test.onNodeWithTag -import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.performClick import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.RequiresDevice import androidx.test.platform.app.InstrumentationRegistry import androidx.test.rule.GrantPermissionRule -import androidx.test.uiautomator.By import androidx.test.uiautomator.UiDevice -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.AUDIO_INPUT_INCOMING_TAG +import com.google.jetpackcamera.ui.components.capture.AUDIO_INPUT_READY_TAG +import com.google.jetpackcamera.ui.components.capture.AUDIO_INPUT_OFF_TAG +import com.google.jetpackcamera.ui.components.capture.AUDIO_INPUT_TOGGLE import com.google.jetpackcamera.ui.components.capture.CAPTURE_BUTTON import com.google.jetpackcamera.utils.TEST_REQUIRED_PERMISSIONS import com.google.jetpackcamera.utils.debugExtra +import com.google.jetpackcamera.utils.pressAndDragToLockVideoRecording import com.google.jetpackcamera.utils.runMainActivityScenarioTest import com.google.jetpackcamera.utils.waitForCaptureButton +import com.google.jetpackcamera.utils.waitForNodeWithTag import org.junit.Before import org.junit.Rule import org.junit.Test @@ -62,15 +64,95 @@ class VideoAudioTest { // icon will only be unmuted if audio is nonzero composeTestRule.waitForCaptureButton() - // record video - composeTestRule.onNodeWithTag(CAPTURE_BUTTON) - .assertExists().performTouchInput { longClick(durationMillis = 5000) } + // Start video recording and lock it + composeTestRule.pressAndDragToLockVideoRecording() // assert hot amplitude tag visible - uiDevice.wait( - Until.findObject(By.res(AMPLITUDE_HOT_TAG)), - 5000 + composeTestRule.waitForNodeWithTag( + tag = AUDIO_INPUT_TOGGLE, + stateDescription = AUDIO_INPUT_READY_TAG ) } } + + @Test + fun muteAndUnmuteDuringRecording() { + runMainActivityScenarioTest(debugExtra) { + composeTestRule.waitForCaptureButton() + + // verify audio button is initially set to enable audio + composeTestRule.waitForNodeWithTag( + tag = AUDIO_INPUT_TOGGLE, + stateDescription = AUDIO_INPUT_READY_TAG + ) + + // Start video recording and lock it + composeTestRule.pressAndDragToLockVideoRecording() + + // Verify amplitude button is hot initially (audio is enabled by default) + composeTestRule.waitForNodeWithTag( + tag = AUDIO_INPUT_TOGGLE, + stateDescription = AUDIO_INPUT_INCOMING_TAG + ) + + // Tap the amplitude button to mute + composeTestRule.onNodeWithTag(AUDIO_INPUT_TOGGLE).performClick() + + // Verify amplitude button is now showing "none/muted" tag + composeTestRule.waitForNodeWithTag( + tag = AUDIO_INPUT_TOGGLE, + stateDescription = AUDIO_INPUT_OFF_TAG + ) + + // Tap the amplitude button to unmute + composeTestRule.onNodeWithTag(AUDIO_INPUT_TOGGLE).performClick() + + // Verify amplitude button is hot again + composeTestRule.waitForNodeWithTag( + tag = AUDIO_INPUT_TOGGLE, + stateDescription = AUDIO_INPUT_READY_TAG + ) + + // Stop recording + composeTestRule.onNodeWithTag(CAPTURE_BUTTON).performClick() + composeTestRule.onNodeWithTag(CAPTURE_BUTTON).performClick() + } + } + + @Test + fun startRecordingMuted() { + runMainActivityScenarioTest(debugExtra) { + composeTestRule.waitForCaptureButton() + + // Verify amplitude button is hot initially (audio is enabled by default) + composeTestRule.waitForNodeWithTag( + tag = AUDIO_INPUT_TOGGLE, + stateDescription = AUDIO_INPUT_READY_TAG + ) + + // Tap the amplitude button to mute + composeTestRule.onNodeWithTag(AUDIO_INPUT_TOGGLE).performClick() + + // Verify amplitude button is now showing "none/muted" tag + composeTestRule.waitForNodeWithTag( + tag = AUDIO_INPUT_TOGGLE, + stateDescription = AUDIO_INPUT_OFF_TAG + ) + + + // Start video recording + composeTestRule.pressAndDragToLockVideoRecording() + + // Verify it remains muted during recording + // Verify amplitude button is now showing "none/muted" tag + composeTestRule.waitForNodeWithTag( + tag = AUDIO_INPUT_TOGGLE, + stateDescription = AUDIO_INPUT_OFF_TAG + ) + + // Stop recording + composeTestRule.onNodeWithTag(CAPTURE_BUTTON).performClick() + composeTestRule.onNodeWithTag(CAPTURE_BUTTON).performClick() + } + } } 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 0b1b8a011..c3b79dfcc 100644 --- a/app/src/androidTest/java/com/google/jetpackcamera/utils/ComposeTestRuleExt.kt +++ b/app/src/androidTest/java/com/google/jetpackcamera/utils/ComposeTestRuleExt.kt @@ -14,6 +14,7 @@ * limitations under the License. */ package com.google.jetpackcamera.utils + import android.content.Context import android.util.Log import androidx.annotation.StringRes @@ -33,7 +34,6 @@ import androidx.compose.ui.test.isDisplayed import androidx.compose.ui.test.isEnabled import androidx.compose.ui.test.isNotDisplayed import androidx.compose.ui.test.junit4.ComposeTestRule -import androidx.compose.ui.test.onAllNodes import androidx.compose.ui.test.onAllNodesWithTag import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithTag @@ -52,12 +52,13 @@ import com.google.jetpackcamera.model.CaptureMode import com.google.jetpackcamera.model.ConcurrentCameraMode import com.google.jetpackcamera.model.FlashMode import com.google.jetpackcamera.model.LensFacing -import com.google.jetpackcamera.settings.R as SettingsR import com.google.jetpackcamera.settings.ui.BACK_BUTTON import com.google.jetpackcamera.settings.ui.BTN_SWITCH_SETTING_CONCURRENT_CAMERA_TAG import com.google.jetpackcamera.settings.ui.BTN_SWITCH_SETTING_LENS_FACING_TAG import com.google.jetpackcamera.settings.ui.CLOSE_BUTTON import com.google.jetpackcamera.settings.ui.SETTINGS_TITLE +import com.google.jetpackcamera.ui.components.capture.AUDIO_INPUT_OFF_TAG +import com.google.jetpackcamera.ui.components.capture.AUDIO_INPUT_TOGGLE import com.google.jetpackcamera.ui.components.capture.BTN_QUICK_SETTINGS_FOCUSED_CAPTURE_MODE_IMAGE_ONLY import com.google.jetpackcamera.ui.components.capture.BTN_QUICK_SETTINGS_FOCUSED_CAPTURE_MODE_OPTION_STANDARD import com.google.jetpackcamera.ui.components.capture.BTN_QUICK_SETTINGS_FOCUSED_CAPTURE_MODE_VIDEO_ONLY @@ -71,10 +72,11 @@ import com.google.jetpackcamera.ui.components.capture.QUICK_SETTINGS_FLASH_BUTTO import com.google.jetpackcamera.ui.components.capture.QUICK_SETTINGS_FLIP_CAMERA_BUTTON import com.google.jetpackcamera.ui.components.capture.QUICK_SETTINGS_HDR_BUTTON import com.google.jetpackcamera.ui.components.capture.QUICK_SETTINGS_SCROLL_CONTAINER -import com.google.jetpackcamera.ui.components.capture.R as CaptureR import com.google.jetpackcamera.ui.components.capture.SETTINGS_BUTTON import com.google.jetpackcamera.ui.components.capture.SNACKBAR_NODE_TAG import org.junit.AssumptionViolatedException +import com.google.jetpackcamera.settings.R as SettingsR +import com.google.jetpackcamera.ui.components.capture.R as CaptureR /** * Allows use of testRule.onNodeWithText that uses an integer string resource @@ -147,6 +149,7 @@ fun ComposeTestRule.wait(timeoutMillis: Long) { /* do nothing, we just want to time out*/ } } + fun ComposeTestRule.waitForCaptureButton(timeoutMillis: Long = APP_START_TIMEOUT_MILLIS) { // Wait for the capture button to be displayed and enabled waitUntil(timeoutMillis = timeoutMillis) { @@ -154,8 +157,19 @@ fun ComposeTestRule.waitForCaptureButton(timeoutMillis: Long = APP_START_TIMEOUT } } -fun ComposeTestRule.waitForNodeWithTag(tag: String, timeoutMillis: Long = DEFAULT_TIMEOUT_MILLIS) { - waitUntil(timeoutMillis = timeoutMillis) { onNodeWithTag(tag).isDisplayed() } +fun ComposeTestRule.waitForNodeWithTag( + tag: String, + timeoutMillis: Long = DEFAULT_TIMEOUT_MILLIS, + stateDescription: String? = null, +) { + waitUntil(timeoutMillis = timeoutMillis) { + var matcher = hasTestTag(tag) + + if (stateDescription != null) + matcher = matcher and hasStateDescription(stateDescription) + + onNode(matcher).isDisplayed() + } } fun ComposeTestRule.waitForNodeWithTagToDisappear( diff --git a/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraSession.kt b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraSession.kt index 2e23459c4..baa0e8c79 100644 --- a/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraSession.kt +++ b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraSession.kt @@ -1007,7 +1007,6 @@ private fun getPendingRecording( context(CameraSessionContext) @OptIn(ExperimentalPersistentRecording::class) private suspend fun startVideoRecordingInternal( - isInitialAudioEnabled: Boolean, context: Context, pendingRecord: PendingRecording, maxDurationMillis: Long, @@ -1031,7 +1030,7 @@ private suspend fun startVideoRecordingInternal( pendingRecord.apply { if (isAudioGranted) { - withAudioEnabled(isInitialAudioEnabled) + withAudioEnabled(initialMuted = !initialRecordingSettings.isAudioEnabled) } } .asPersistentRecording() @@ -1170,8 +1169,6 @@ private suspend fun startVideoRecordingInternal( } } } - }.apply { - mute(!isInitialAudioEnabled) } } @@ -1199,7 +1196,6 @@ private suspend fun runVideoRecording( onVideoRecord )?.let { startVideoRecordingInternal( - isInitialAudioEnabled = currentSettings.isAudioEnabled, context = context, pendingRecord = it, maxDurationMillis = maxDurationMillis, @@ -1216,13 +1212,10 @@ private suspend fun runVideoRecording( transientSettings.filterNotNull() .collectLatest { newTransientSettings -> if (currentSettings.isAudioEnabled != newTransientSettings.isAudioEnabled) { - recording.mute(newTransientSettings.isAudioEnabled) - } - if (currentSettings.isFlashModeOn() != - newTransientSettings.isFlashModeOn() - ) { - currentSettings = newTransientSettings + // audio mute state will be inverse of if audio is enabled. + recording.mute(!newTransientSettings.isAudioEnabled) } + currentSettings = newTransientSettings } } 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 e193cf8e6..829b30b6c 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 @@ -20,7 +20,6 @@ import android.content.pm.ActivityInfo import android.os.Build import android.util.Log import androidx.camera.compose.CameraXViewfinder -import androidx.camera.core.DynamicRange as CXDynamicRange import androidx.camera.core.SurfaceRequest import androidx.camera.viewfinder.compose.CoordinateTransformer import androidx.camera.viewfinder.compose.MutableCoordinateTransformer @@ -92,6 +91,8 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -116,12 +117,13 @@ import com.google.jetpackcamera.ui.uistate.capture.FlipLensUiState import com.google.jetpackcamera.ui.uistate.capture.FocusMeteringUiState import com.google.jetpackcamera.ui.uistate.capture.StabilizationUiState import com.google.jetpackcamera.ui.uistate.capture.compound.PreviewDisplayUiState -import kotlin.time.Duration.Companion.nanoseconds import kotlinx.coroutines.delay import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onCompletion +import kotlin.time.Duration.Companion.nanoseconds +import androidx.camera.core.DynamicRange as CXDynamicRange private const val TAG = "PreviewScreen" private const val BLINK_TIME = 100L @@ -236,12 +238,18 @@ fun AmplitudeToggleButton( FilledIconToggleButton( modifier = modifier .size(buttonSize) - .apply { - if (audioUiState is AudioUiState.Enabled.On) { - testTag(AMPLITUDE_HOT_TAG) - } else { - testTag(AMPLITUDE_NONE_TAG) - } + .testTag(AUDIO_INPUT_TOGGLE) + .semantics { + stateDescription = + when (audioUiState) { + is AudioUiState.Disabled, AudioUiState.Enabled.Mute -> AUDIO_INPUT_OFF_TAG + is AudioUiState.Enabled.On -> { + if (audioUiState.amplitude > 0.0) + AUDIO_INPUT_INCOMING_TAG + else + AUDIO_INPUT_READY_TAG + } + } } .drawBehind { if (audioUiState is AudioUiState.Enabled.On) { @@ -298,9 +306,9 @@ fun CaptureModeToggleButton( val enabled = uiState.isCaptureModeSelectable(CaptureMode.VIDEO_ONLY) && - uiState.isCaptureModeSelectable( - CaptureMode.IMAGE_ONLY - ) && uiState.selectedCaptureMode != CaptureMode.STANDARD + uiState.isCaptureModeSelectable( + CaptureMode.IMAGE_ONLY + ) && uiState.selectedCaptureMode != CaptureMode.STANDARD ToggleSwitch( modifier = modifier.testTag(CAPTURE_MODE_TOGGLE_BUTTON), @@ -312,13 +320,13 @@ fun CaptureModeToggleButton( onToggleWhenDisabled = { val disabledReason: DisableRationale? = ( - uiState.findSelectableStateFor(CaptureMode.VIDEO_ONLY) as? - SingleSelectableUiState.Disabled - )?.disabledReason + uiState.findSelectableStateFor(CaptureMode.VIDEO_ONLY) as? + SingleSelectableUiState.Disabled + )?.disabledReason ?: ( - uiState.findSelectableStateFor(CaptureMode.IMAGE_ONLY) - as? SingleSelectableUiState.Disabled - ) + uiState.findSelectableStateFor(CaptureMode.IMAGE_ONLY) + as? SingleSelectableUiState.Disabled + ) ?.disabledReason disabledReason?.let { snackBarController?.enqueueDisabledHdrToggleSnackBar(it) } }, @@ -565,7 +573,7 @@ fun PreviewDisplay( Log.d( "TAG", "onTapToFocus: " + - "input{$it} -> surface{$surfaceCoords}" + "input{$it} -> surface{$surfaceCoords}" ) onTapToFocus(surfaceCoords.x, surfaceCoords.y) } @@ -690,8 +698,8 @@ fun StabilizationIcon(stabilizationUiState: StabilizationUiState, modifier: Modi else -> TODO( "Cannot retrieve icon for unimplemented " + - "stabilization mode:" + - "${stabilizationUiState.stabilizationMode}" + "stabilization mode:" + + "${stabilizationUiState.stabilizationMode}" ) } @@ -706,8 +714,8 @@ fun StabilizationIcon(stabilizationUiState: StabilizationUiState, modifier: Modi else -> TODO( "Auto stabilization not yet implemented for " + - "${stabilizationUiState.stabilizationMode}, " + - "unable to retrieve icon." + "${stabilizationUiState.stabilizationMode}, " + + "unable to retrieve icon." ) } } diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/TestTags.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/TestTags.kt index 8779adc67..5cece49aa 100644 --- a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/TestTags.kt +++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/TestTags.kt @@ -24,8 +24,10 @@ const val IMAGE_WELL_TAG = "ImageWellTag" const val PREVIEW_DISPLAY = "PreviewDisplay" const val SCREEN_FLASH_OVERLAY = "ScreenFlashOverlay" -const val AMPLITUDE_NONE_TAG = "AmplitudeNoneTag" -const val AMPLITUDE_HOT_TAG = "AmplitudeHotTag" +const val AUDIO_INPUT_TOGGLE = "AudioInputToggle" +const val AUDIO_INPUT_OFF_TAG = "AudioInputOffTag" +const val AUDIO_INPUT_READY_TAG = "AudioInputOnTag" +const val AUDIO_INPUT_INCOMING_TAG = "AudioInputIncomingTag" const val FOCUS_METERING_INDICATOR_TAG = "FocusMeteringIndicatorTag" const val ZOOM_BUTTON_ROW_TAG = "ZoomButtonRowTag" From 4c716278de649822117c802168716b62abf60725 Mon Sep 17 00:00:00 2001 From: Kimberly Crevecoeur Date: Tue, 7 Jul 2026 10:28:33 -0700 Subject: [PATCH 2/7] Refactor audio test assertions to use custom semantics --- .../google/jetpackcamera/VideoAudioTest.kt | 40 +++++++++---------- .../jetpackcamera/utils/ComposeTestRuleExt.kt | 27 +++++++++---- .../capture/CaptureScreenComponents.kt | 18 ++++----- .../ui/components/capture/TestTags.kt | 14 +++++-- 4 files changed, 59 insertions(+), 40 deletions(-) diff --git a/app/src/androidTest/java/com/google/jetpackcamera/VideoAudioTest.kt b/app/src/androidTest/java/com/google/jetpackcamera/VideoAudioTest.kt index 6fa182129..7e8dc560c 100644 --- a/app/src/androidTest/java/com/google/jetpackcamera/VideoAudioTest.kt +++ b/app/src/androidTest/java/com/google/jetpackcamera/VideoAudioTest.kt @@ -24,9 +24,8 @@ import androidx.test.platform.app.InstrumentationRegistry import androidx.test.rule.GrantPermissionRule import androidx.test.uiautomator.UiDevice import com.google.common.truth.Truth.assertThat -import com.google.jetpackcamera.ui.components.capture.AUDIO_INPUT_INCOMING_TAG -import com.google.jetpackcamera.ui.components.capture.AUDIO_INPUT_READY_TAG -import com.google.jetpackcamera.ui.components.capture.AUDIO_INPUT_OFF_TAG +import com.google.jetpackcamera.ui.components.capture.AudioInputState +import com.google.jetpackcamera.ui.components.capture.AudioStateProperty import com.google.jetpackcamera.ui.components.capture.AUDIO_INPUT_TOGGLE import com.google.jetpackcamera.ui.components.capture.CAPTURE_BUTTON import com.google.jetpackcamera.utils.TEST_REQUIRED_PERMISSIONS @@ -34,7 +33,7 @@ import com.google.jetpackcamera.utils.debugExtra import com.google.jetpackcamera.utils.pressAndDragToLockVideoRecording import com.google.jetpackcamera.utils.runMainActivityScenarioTest import com.google.jetpackcamera.utils.waitForCaptureButton -import com.google.jetpackcamera.utils.waitForNodeWithTag +import com.google.jetpackcamera.utils.waitForNodeWithTagAndSemantics import org.junit.Before import org.junit.Rule import org.junit.Test @@ -68,9 +67,9 @@ class VideoAudioTest { composeTestRule.pressAndDragToLockVideoRecording() // assert hot amplitude tag visible - composeTestRule.waitForNodeWithTag( + composeTestRule.waitForNodeWithTagAndSemantics( tag = AUDIO_INPUT_TOGGLE, - stateDescription = AUDIO_INPUT_READY_TAG + semanticsProperty = AudioStateProperty to AudioInputState.INCOMING ) } } @@ -81,36 +80,36 @@ class VideoAudioTest { composeTestRule.waitForCaptureButton() // verify audio button is initially set to enable audio - composeTestRule.waitForNodeWithTag( + composeTestRule.waitForNodeWithTagAndSemantics( tag = AUDIO_INPUT_TOGGLE, - stateDescription = AUDIO_INPUT_READY_TAG + semanticsProperty = AudioStateProperty to AudioInputState.READY ) // Start video recording and lock it composeTestRule.pressAndDragToLockVideoRecording() // Verify amplitude button is hot initially (audio is enabled by default) - composeTestRule.waitForNodeWithTag( + composeTestRule.waitForNodeWithTagAndSemantics( tag = AUDIO_INPUT_TOGGLE, - stateDescription = AUDIO_INPUT_INCOMING_TAG + semanticsProperty = AudioStateProperty to AudioInputState.INCOMING ) // Tap the amplitude button to mute composeTestRule.onNodeWithTag(AUDIO_INPUT_TOGGLE).performClick() // Verify amplitude button is now showing "none/muted" tag - composeTestRule.waitForNodeWithTag( + composeTestRule.waitForNodeWithTagAndSemantics( tag = AUDIO_INPUT_TOGGLE, - stateDescription = AUDIO_INPUT_OFF_TAG + semanticsProperty = AudioStateProperty to AudioInputState.OFF ) // Tap the amplitude button to unmute composeTestRule.onNodeWithTag(AUDIO_INPUT_TOGGLE).performClick() // Verify amplitude button is hot again - composeTestRule.waitForNodeWithTag( + composeTestRule.waitForNodeWithTagAndSemantics( tag = AUDIO_INPUT_TOGGLE, - stateDescription = AUDIO_INPUT_READY_TAG + semanticsProperty = AudioStateProperty to AudioInputState.INCOMING ) // Stop recording @@ -125,29 +124,28 @@ class VideoAudioTest { composeTestRule.waitForCaptureButton() // Verify amplitude button is hot initially (audio is enabled by default) - composeTestRule.waitForNodeWithTag( + composeTestRule.waitForNodeWithTagAndSemantics( tag = AUDIO_INPUT_TOGGLE, - stateDescription = AUDIO_INPUT_READY_TAG + semanticsProperty = AudioStateProperty to AudioInputState.READY ) // Tap the amplitude button to mute composeTestRule.onNodeWithTag(AUDIO_INPUT_TOGGLE).performClick() // Verify amplitude button is now showing "none/muted" tag - composeTestRule.waitForNodeWithTag( + composeTestRule.waitForNodeWithTagAndSemantics( tag = AUDIO_INPUT_TOGGLE, - stateDescription = AUDIO_INPUT_OFF_TAG + semanticsProperty = AudioStateProperty to AudioInputState.OFF ) - // Start video recording composeTestRule.pressAndDragToLockVideoRecording() // Verify it remains muted during recording // Verify amplitude button is now showing "none/muted" tag - composeTestRule.waitForNodeWithTag( + composeTestRule.waitForNodeWithTagAndSemantics( tag = AUDIO_INPUT_TOGGLE, - stateDescription = AUDIO_INPUT_OFF_TAG + semanticsProperty = AudioStateProperty to AudioInputState.OFF ) // Stop recording 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 c3b79dfcc..8368f74e7 100644 --- a/app/src/androidTest/java/com/google/jetpackcamera/utils/ComposeTestRuleExt.kt +++ b/app/src/androidTest/java/com/google/jetpackcamera/utils/ComposeTestRuleExt.kt @@ -20,6 +20,7 @@ import android.util.Log import androidx.annotation.StringRes import androidx.compose.ui.geometry.Offset import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.semantics.SemanticsPropertyKey import androidx.compose.ui.semantics.getOrNull import androidx.compose.ui.state.ToggleableState import androidx.compose.ui.test.ComposeTimeoutException @@ -52,13 +53,12 @@ import com.google.jetpackcamera.model.CaptureMode import com.google.jetpackcamera.model.ConcurrentCameraMode import com.google.jetpackcamera.model.FlashMode import com.google.jetpackcamera.model.LensFacing +import com.google.jetpackcamera.settings.R as SettingsR import com.google.jetpackcamera.settings.ui.BACK_BUTTON import com.google.jetpackcamera.settings.ui.BTN_SWITCH_SETTING_CONCURRENT_CAMERA_TAG import com.google.jetpackcamera.settings.ui.BTN_SWITCH_SETTING_LENS_FACING_TAG import com.google.jetpackcamera.settings.ui.CLOSE_BUTTON import com.google.jetpackcamera.settings.ui.SETTINGS_TITLE -import com.google.jetpackcamera.ui.components.capture.AUDIO_INPUT_OFF_TAG -import com.google.jetpackcamera.ui.components.capture.AUDIO_INPUT_TOGGLE import com.google.jetpackcamera.ui.components.capture.BTN_QUICK_SETTINGS_FOCUSED_CAPTURE_MODE_IMAGE_ONLY import com.google.jetpackcamera.ui.components.capture.BTN_QUICK_SETTINGS_FOCUSED_CAPTURE_MODE_OPTION_STANDARD import com.google.jetpackcamera.ui.components.capture.BTN_QUICK_SETTINGS_FOCUSED_CAPTURE_MODE_VIDEO_ONLY @@ -72,11 +72,10 @@ import com.google.jetpackcamera.ui.components.capture.QUICK_SETTINGS_FLASH_BUTTO import com.google.jetpackcamera.ui.components.capture.QUICK_SETTINGS_FLIP_CAMERA_BUTTON import com.google.jetpackcamera.ui.components.capture.QUICK_SETTINGS_HDR_BUTTON import com.google.jetpackcamera.ui.components.capture.QUICK_SETTINGS_SCROLL_CONTAINER +import com.google.jetpackcamera.ui.components.capture.R as CaptureR import com.google.jetpackcamera.ui.components.capture.SETTINGS_BUTTON import com.google.jetpackcamera.ui.components.capture.SNACKBAR_NODE_TAG import org.junit.AssumptionViolatedException -import com.google.jetpackcamera.settings.R as SettingsR -import com.google.jetpackcamera.ui.components.capture.R as CaptureR /** * Allows use of testRule.onNodeWithText that uses an integer string resource @@ -157,17 +156,31 @@ fun ComposeTestRule.waitForCaptureButton(timeoutMillis: Long = APP_START_TIMEOUT } } + fun ComposeTestRule.waitForNodeWithTag( tag: String, timeoutMillis: Long = DEFAULT_TIMEOUT_MILLIS, - stateDescription: String? = null, + stateDescription: String? = null ) { waitUntil(timeoutMillis = timeoutMillis) { var matcher = hasTestTag(tag) - - if (stateDescription != null) + if (stateDescription != null) { matcher = matcher and hasStateDescription(stateDescription) + } + onNode(matcher).isDisplayed() + } +} +fun ComposeTestRule.waitForNodeWithTagAndSemantics( + tag: String, + timeoutMillis: Long = DEFAULT_TIMEOUT_MILLIS, + semanticsProperty: Pair, T> +) { + waitUntil(timeoutMillis = timeoutMillis) { + val matcher = hasTestTag(tag) and SemanticsMatcher.expectValue( + semanticsProperty.first, + semanticsProperty.second + ) onNode(matcher).isDisplayed() } } 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 829b30b6c..922dc5743 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 @@ -92,7 +92,6 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -240,16 +239,17 @@ fun AmplitudeToggleButton( .size(buttonSize) .testTag(AUDIO_INPUT_TOGGLE) .semantics { - stateDescription = - when (audioUiState) { - is AudioUiState.Disabled, AudioUiState.Enabled.Mute -> AUDIO_INPUT_OFF_TAG - is AudioUiState.Enabled.On -> { - if (audioUiState.amplitude > 0.0) - AUDIO_INPUT_INCOMING_TAG - else - AUDIO_INPUT_READY_TAG + audioState = when (audioUiState) { + is AudioUiState.Disabled, + AudioUiState.Enabled.Mute -> AudioInputState.OFF + is AudioUiState.Enabled.On -> { + if (audioUiState.amplitude > 0.0) { + AudioInputState.INCOMING + } else { + AudioInputState.READY } } + } } .drawBehind { if (audioUiState is AudioUiState.Enabled.On) { diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/TestTags.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/TestTags.kt index 5cece49aa..1829507a8 100644 --- a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/TestTags.kt +++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/TestTags.kt @@ -15,6 +15,8 @@ */ package com.google.jetpackcamera.ui.components.capture +import androidx.compose.ui.semantics.SemanticsPropertyKey +import androidx.compose.ui.semantics.SemanticsPropertyReceiver const val CAPTURE_BUTTON = "CaptureButton" const val CAPTURE_MODE_TOGGLE_BUTTON = "CaptureModeToggleButton" const val FLIP_CAMERA_BUTTON = "FlipCameraButton" @@ -25,11 +27,17 @@ const val IMAGE_WELL_TAG = "ImageWellTag" const val PREVIEW_DISPLAY = "PreviewDisplay" const val SCREEN_FLASH_OVERLAY = "ScreenFlashOverlay" const val AUDIO_INPUT_TOGGLE = "AudioInputToggle" -const val AUDIO_INPUT_OFF_TAG = "AudioInputOffTag" -const val AUDIO_INPUT_READY_TAG = "AudioInputOnTag" -const val AUDIO_INPUT_INCOMING_TAG = "AudioInputIncomingTag" const val FOCUS_METERING_INDICATOR_TAG = "FocusMeteringIndicatorTag" +enum class AudioInputState { + OFF, + READY, + INCOMING +} + +val AudioStateProperty = SemanticsPropertyKey("AudioState") +var SemanticsPropertyReceiver.audioState by AudioStateProperty + const val ZOOM_BUTTON_ROW_TAG = "ZoomButtonRowTag" const val ZOOM_BUTTON_MIN_TAG = "ZoomButtonMinTag" const val ZOOM_BUTTON_1_TAG = "ZoomButton1Tag" From 773101933ecf466e2deaac15ff995e9cee7b45f0 Mon Sep 17 00:00:00 2001 From: Kimberly Crevecoeur Date: Tue, 7 Jul 2026 10:38:03 -0700 Subject: [PATCH 3/7] spotless --- .../google/jetpackcamera/VideoAudioTest.kt | 2 +- .../jetpackcamera/utils/ComposeTestRuleExt.kt | 1 - .../capture/CaptureScreenComponents.kt | 32 +++++++++---------- 3 files changed, 17 insertions(+), 18 deletions(-) diff --git a/app/src/androidTest/java/com/google/jetpackcamera/VideoAudioTest.kt b/app/src/androidTest/java/com/google/jetpackcamera/VideoAudioTest.kt index 7e8dc560c..f4d2f87a7 100644 --- a/app/src/androidTest/java/com/google/jetpackcamera/VideoAudioTest.kt +++ b/app/src/androidTest/java/com/google/jetpackcamera/VideoAudioTest.kt @@ -24,9 +24,9 @@ import androidx.test.platform.app.InstrumentationRegistry import androidx.test.rule.GrantPermissionRule import androidx.test.uiautomator.UiDevice import com.google.common.truth.Truth.assertThat +import com.google.jetpackcamera.ui.components.capture.AUDIO_INPUT_TOGGLE import com.google.jetpackcamera.ui.components.capture.AudioInputState import com.google.jetpackcamera.ui.components.capture.AudioStateProperty -import com.google.jetpackcamera.ui.components.capture.AUDIO_INPUT_TOGGLE import com.google.jetpackcamera.ui.components.capture.CAPTURE_BUTTON import com.google.jetpackcamera.utils.TEST_REQUIRED_PERMISSIONS import com.google.jetpackcamera.utils.debugExtra 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 8368f74e7..8e68be8c9 100644 --- a/app/src/androidTest/java/com/google/jetpackcamera/utils/ComposeTestRuleExt.kt +++ b/app/src/androidTest/java/com/google/jetpackcamera/utils/ComposeTestRuleExt.kt @@ -156,7 +156,6 @@ fun ComposeTestRule.waitForCaptureButton(timeoutMillis: Long = APP_START_TIMEOUT } } - fun ComposeTestRule.waitForNodeWithTag( tag: String, timeoutMillis: Long = DEFAULT_TIMEOUT_MILLIS, 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 922dc5743..b62c67b01 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 @@ -20,6 +20,7 @@ import android.content.pm.ActivityInfo import android.os.Build import android.util.Log import androidx.camera.compose.CameraXViewfinder +import androidx.camera.core.DynamicRange as CXDynamicRange import androidx.camera.core.SurfaceRequest import androidx.camera.viewfinder.compose.CoordinateTransformer import androidx.camera.viewfinder.compose.MutableCoordinateTransformer @@ -116,13 +117,12 @@ import com.google.jetpackcamera.ui.uistate.capture.FlipLensUiState import com.google.jetpackcamera.ui.uistate.capture.FocusMeteringUiState import com.google.jetpackcamera.ui.uistate.capture.StabilizationUiState import com.google.jetpackcamera.ui.uistate.capture.compound.PreviewDisplayUiState +import kotlin.time.Duration.Companion.nanoseconds import kotlinx.coroutines.delay import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onCompletion -import kotlin.time.Duration.Companion.nanoseconds -import androidx.camera.core.DynamicRange as CXDynamicRange private const val TAG = "PreviewScreen" private const val BLINK_TIME = 100L @@ -306,9 +306,9 @@ fun CaptureModeToggleButton( val enabled = uiState.isCaptureModeSelectable(CaptureMode.VIDEO_ONLY) && - uiState.isCaptureModeSelectable( - CaptureMode.IMAGE_ONLY - ) && uiState.selectedCaptureMode != CaptureMode.STANDARD + uiState.isCaptureModeSelectable( + CaptureMode.IMAGE_ONLY + ) && uiState.selectedCaptureMode != CaptureMode.STANDARD ToggleSwitch( modifier = modifier.testTag(CAPTURE_MODE_TOGGLE_BUTTON), @@ -320,13 +320,13 @@ fun CaptureModeToggleButton( onToggleWhenDisabled = { val disabledReason: DisableRationale? = ( - uiState.findSelectableStateFor(CaptureMode.VIDEO_ONLY) as? - SingleSelectableUiState.Disabled - )?.disabledReason + uiState.findSelectableStateFor(CaptureMode.VIDEO_ONLY) as? + SingleSelectableUiState.Disabled + )?.disabledReason ?: ( - uiState.findSelectableStateFor(CaptureMode.IMAGE_ONLY) - as? SingleSelectableUiState.Disabled - ) + uiState.findSelectableStateFor(CaptureMode.IMAGE_ONLY) + as? SingleSelectableUiState.Disabled + ) ?.disabledReason disabledReason?.let { snackBarController?.enqueueDisabledHdrToggleSnackBar(it) } }, @@ -573,7 +573,7 @@ fun PreviewDisplay( Log.d( "TAG", "onTapToFocus: " + - "input{$it} -> surface{$surfaceCoords}" + "input{$it} -> surface{$surfaceCoords}" ) onTapToFocus(surfaceCoords.x, surfaceCoords.y) } @@ -698,8 +698,8 @@ fun StabilizationIcon(stabilizationUiState: StabilizationUiState, modifier: Modi else -> TODO( "Cannot retrieve icon for unimplemented " + - "stabilization mode:" + - "${stabilizationUiState.stabilizationMode}" + "stabilization mode:" + + "${stabilizationUiState.stabilizationMode}" ) } @@ -714,8 +714,8 @@ fun StabilizationIcon(stabilizationUiState: StabilizationUiState, modifier: Modi else -> TODO( "Auto stabilization not yet implemented for " + - "${stabilizationUiState.stabilizationMode}, " + - "unable to retrieve icon." + "${stabilizationUiState.stabilizationMode}, " + + "unable to retrieve icon." ) } } From 824128b47f242e2055ef4cc5470579da435e2dfd Mon Sep 17 00:00:00 2001 From: Kimberly Crevecoeur Date: Tue, 7 Jul 2026 13:27:23 -0700 Subject: [PATCH 4/7] Fix snackbar assertion regression in Compose tests by tagging SnackbarHost --- .../com/google/jetpackcamera/utils/ComposeTestRuleExt.kt | 4 ++-- .../feature/postcapture/PostCaptureScreen.kt | 4 +--- .../feature/postcapture/ui/PostCaptureLayout.kt | 9 ++++++++- .../jetpackcamera/feature/preview/PreviewScreen.kt | 3 +-- .../jetpackcamera/ui/components/capture/CaptureLayout.kt | 8 +++++++- 5 files changed, 19 insertions(+), 9 deletions(-) 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 0b1b8a011..3d2bbdab4 100644 --- a/app/src/androidTest/java/com/google/jetpackcamera/utils/ComposeTestRuleExt.kt +++ b/app/src/androidTest/java/com/google/jetpackcamera/utils/ComposeTestRuleExt.kt @@ -26,6 +26,7 @@ import androidx.compose.ui.test.SemanticsMatcher import androidx.compose.ui.test.SemanticsNodeInteraction import androidx.compose.ui.test.SemanticsNodeInteractionsProvider import androidx.compose.ui.test.assertIsNotDisplayed +import androidx.compose.ui.test.hasAnyAncestor import androidx.compose.ui.test.hasStateDescription import androidx.compose.ui.test.hasTestTag import androidx.compose.ui.test.hasText @@ -33,7 +34,6 @@ import androidx.compose.ui.test.isDisplayed import androidx.compose.ui.test.isEnabled import androidx.compose.ui.test.isNotDisplayed import androidx.compose.ui.test.junit4.ComposeTestRule -import androidx.compose.ui.test.onAllNodes import androidx.compose.ui.test.onAllNodesWithTag import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithTag @@ -182,7 +182,7 @@ fun ComposeTestRule.waitForSnackbarWithText( ) { val expectedText = getResString(textResId) waitUntil(timeoutMillis = timeoutMillis) { - onAllNodes(hasTestTag(SNACKBAR_NODE_TAG) and hasText(expectedText)) + onAllNodes(hasAnyAncestor(hasTestTag(SNACKBAR_NODE_TAG)) and hasText(expectedText)) .fetchSemanticsNodes().isNotEmpty() } } diff --git a/feature/postcapture/src/main/java/com/google/jetpackcamera/feature/postcapture/PostCaptureScreen.kt b/feature/postcapture/src/main/java/com/google/jetpackcamera/feature/postcapture/PostCaptureScreen.kt index d60cdfc32..519581824 100644 --- a/feature/postcapture/src/main/java/com/google/jetpackcamera/feature/postcapture/PostCaptureScreen.kt +++ b/feature/postcapture/src/main/java/com/google/jetpackcamera/feature/postcapture/PostCaptureScreen.kt @@ -23,7 +23,6 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.testTag import androidx.hilt.navigation.compose.hiltViewModel import androidx.media3.common.util.UnstableApi import com.google.jetpackcamera.feature.postcapture.PostCaptureViewModel.PostCaptureEvent @@ -34,7 +33,6 @@ import com.google.jetpackcamera.feature.postcapture.ui.PostCaptureLayout import com.google.jetpackcamera.feature.postcapture.ui.SaveCurrentMediaButton import com.google.jetpackcamera.feature.postcapture.ui.ShareCurrentMediaButton import com.google.jetpackcamera.feature.postcapture.utils.MediaSharing -import com.google.jetpackcamera.ui.components.capture.SNACKBAR_NODE_TAG import com.google.jetpackcamera.ui.components.capture.TestableSnackbar import com.google.jetpackcamera.ui.controller.SnackBarController import com.google.jetpackcamera.ui.uistate.SnackBarUiState @@ -143,7 +141,7 @@ fun PostCaptureComponent( if (snackBarData != null) { snackBarController?.let { TestableSnackbar( - modifier = modifier.testTag(SNACKBAR_NODE_TAG), + modifier = modifier, snackbarToShow = snackBarData, snackbarHostState = snackbarHostState, snackBarController = snackBarController diff --git a/feature/postcapture/src/main/java/com/google/jetpackcamera/feature/postcapture/ui/PostCaptureLayout.kt b/feature/postcapture/src/main/java/com/google/jetpackcamera/feature/postcapture/ui/PostCaptureLayout.kt index 7c7081953..d22cd27f4 100644 --- a/feature/postcapture/src/main/java/com/google/jetpackcamera/feature/postcapture/ui/PostCaptureLayout.kt +++ b/feature/postcapture/src/main/java/com/google/jetpackcamera/feature/postcapture/ui/PostCaptureLayout.kt @@ -30,6 +30,8 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import com.google.jetpackcamera.ui.components.capture.SNACKBAR_NODE_TAG @Composable fun PostCaptureLayout( @@ -45,7 +47,12 @@ fun PostCaptureLayout( Scaffold( modifier = Modifier.fillMaxSize(), - snackbarHost = { SnackbarHost(hostState = snackbarHostState) } + snackbarHost = { + SnackbarHost( + hostState = snackbarHostState, + modifier = Modifier.testTag(SNACKBAR_NODE_TAG) + ) + } ) { paddingValues -> Box( modifier = modifier 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 89686faba..eff65479a 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 @@ -84,7 +84,6 @@ import com.google.jetpackcamera.ui.components.capture.PauseResumeToggleButton import com.google.jetpackcamera.ui.components.capture.PreviewDisplay import com.google.jetpackcamera.ui.components.capture.PreviewLayout import com.google.jetpackcamera.ui.components.capture.R -import com.google.jetpackcamera.ui.components.capture.SNACKBAR_NODE_TAG import com.google.jetpackcamera.ui.components.capture.ScreenFlashScreen import com.google.jetpackcamera.ui.components.capture.StabilizationIcon import com.google.jetpackcamera.ui.components.capture.TestableSnackbar @@ -657,7 +656,7 @@ private fun ContentScreen( if (snackBarData != null) { snackBarController?.let { snackBarController -> TestableSnackbar( - modifier = modifier.testTag(SNACKBAR_NODE_TAG), + modifier = modifier, snackbarToShow = snackBarData, snackbarHostState = snackbarHostState, snackBarController = snackBarController diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureLayout.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureLayout.kt index 3084eec0c..064759b51 100644 --- a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureLayout.kt +++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureLayout.kt @@ -38,6 +38,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -81,7 +82,12 @@ fun PreviewLayout( val snackbarHostState = remember { SnackbarHostState() } Scaffold( modifier = Modifier.fillMaxSize(), - snackbarHost = { SnackbarHost(hostState = snackbarHostState) } + snackbarHost = { + SnackbarHost( + hostState = snackbarHostState, + modifier = Modifier.testTag(SNACKBAR_NODE_TAG) + ) + } ) { paddingValues -> Box(modifier = modifier.background(Color.Black)) { Column { From 0cef5885c36ebcd7ced48decf574fc4862b8f117 Mon Sep 17 00:00:00 2001 From: Kimberly Crevecoeur Date: Tue, 7 Jul 2026 13:33:59 -0700 Subject: [PATCH 5/7] Verify video capture success in VideoAudioTest and enable on emulators --- .../google/jetpackcamera/VideoAudioTest.kt | 24 +++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/app/src/androidTest/java/com/google/jetpackcamera/VideoAudioTest.kt b/app/src/androidTest/java/com/google/jetpackcamera/VideoAudioTest.kt index f4d2f87a7..2b9ab3d9f 100644 --- a/app/src/androidTest/java/com/google/jetpackcamera/VideoAudioTest.kt +++ b/app/src/androidTest/java/com/google/jetpackcamera/VideoAudioTest.kt @@ -19,7 +19,6 @@ import androidx.compose.ui.test.junit4.createEmptyComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performClick import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.filters.RequiresDevice import androidx.test.platform.app.InstrumentationRegistry import androidx.test.rule.GrantPermissionRule import androidx.test.uiautomator.UiDevice @@ -28,19 +27,21 @@ import com.google.jetpackcamera.ui.components.capture.AUDIO_INPUT_TOGGLE import com.google.jetpackcamera.ui.components.capture.AudioInputState import com.google.jetpackcamera.ui.components.capture.AudioStateProperty import com.google.jetpackcamera.ui.components.capture.CAPTURE_BUTTON +import com.google.jetpackcamera.ui.uistateadapter.capture.R import com.google.jetpackcamera.utils.TEST_REQUIRED_PERMISSIONS +import com.google.jetpackcamera.utils.VIDEO_CAPTURE_TIMEOUT_MILLIS import com.google.jetpackcamera.utils.debugExtra import com.google.jetpackcamera.utils.pressAndDragToLockVideoRecording import com.google.jetpackcamera.utils.runMainActivityScenarioTest import com.google.jetpackcamera.utils.waitForCaptureButton import com.google.jetpackcamera.utils.waitForNodeWithTagAndSemantics +import com.google.jetpackcamera.utils.waitForSnackbarWithText import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) -@RequiresDevice class VideoAudioTest { @get:Rule val permissionsRule: GrantPermissionRule = @@ -71,6 +72,14 @@ class VideoAudioTest { tag = AUDIO_INPUT_TOGGLE, semanticsProperty = AudioStateProperty to AudioInputState.INCOMING ) + + // Stop recording + composeTestRule.onNodeWithTag(CAPTURE_BUTTON).performClick() + + composeTestRule.waitForSnackbarWithText( + R.string.toast_video_capture_success, + VIDEO_CAPTURE_TIMEOUT_MILLIS + ) } } @@ -114,7 +123,11 @@ class VideoAudioTest { // Stop recording composeTestRule.onNodeWithTag(CAPTURE_BUTTON).performClick() - composeTestRule.onNodeWithTag(CAPTURE_BUTTON).performClick() + + composeTestRule.waitForSnackbarWithText( + R.string.toast_video_capture_success, + VIDEO_CAPTURE_TIMEOUT_MILLIS + ) } } @@ -150,7 +163,10 @@ class VideoAudioTest { // Stop recording composeTestRule.onNodeWithTag(CAPTURE_BUTTON).performClick() - composeTestRule.onNodeWithTag(CAPTURE_BUTTON).performClick() + composeTestRule.waitForSnackbarWithText( + R.string.toast_video_capture_success, + VIDEO_CAPTURE_TIMEOUT_MILLIS + ) } } } From d4a29734ee303dc12afcc8fded42fda393f3e0da Mon Sep 17 00:00:00 2001 From: Kimberly Crevecoeur Date: Wed, 15 Jul 2026 09:00:06 -0700 Subject: [PATCH 6/7] add statedescription --- .../ui/components/capture/CaptureScreenComponents.kt | 8 ++++++++ ui/components/capture/src/main/res/values/strings.xml | 2 ++ 2 files changed, 10 insertions(+) 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 b62c67b01..ddb9ec13b 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 @@ -93,6 +93,7 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -221,6 +222,8 @@ fun AmplitudeToggleButton( onToggleAudio: () -> Unit ) { val currentUiState = rememberUpdatedState(audioUiState) + val microphoneMutedDescription = stringResource(R.string.microphone_muted) + val microphoneOnDescription = stringResource(R.string.microphone_on) // Tweak the multiplier to amplitude to adjust the visualizer sensitivity val disableAnimations = LocalDisableAnimations.current @@ -239,6 +242,11 @@ fun AmplitudeToggleButton( .size(buttonSize) .testTag(AUDIO_INPUT_TOGGLE) .semantics { + stateDescription = if (audioUiState is AudioUiState.Enabled.On) { + microphoneOnDescription + } else { + microphoneMutedDescription + } audioState = when (audioUiState) { is AudioUiState.Disabled, AudioUiState.Enabled.Mute -> AudioInputState.OFF diff --git a/ui/components/capture/src/main/res/values/strings.xml b/ui/components/capture/src/main/res/values/strings.xml index 87f58b335..f73e4d3e4 100644 --- a/ui/components/capture/src/main/res/values/strings.xml +++ b/ui/components/capture/src/main/res/values/strings.xml @@ -29,6 +29,8 @@ Button to pause or resume video recording An icon of a microphone + Microphone Muted + Microphone On %1$.2fx From a1a2159880aced3c20d66aeca09aad38b791b2b8 Mon Sep 17 00:00:00 2001 From: Kimberly Crevecoeur Date: Wed, 29 Jul 2026 10:47:24 -0700 Subject: [PATCH 7/7] update minsdk --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 69f1d6c6d..a2317d598 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,7 +3,7 @@ compileSdk = "36" desugar_jdk_libs = "2.1.5" orchestrator = "1.6.1" -minSdk = "23" +minSdk = "24" targetSdk = "35" # Used below in dependency definitions