diff --git a/app/src/androidTest/java/com/google/jetpackcamera/DebugOverlayTest.kt b/app/src/androidTest/java/com/google/jetpackcamera/DebugOverlayTest.kt index 835dffb4f..56ec67e2d 100644 --- a/app/src/androidTest/java/com/google/jetpackcamera/DebugOverlayTest.kt +++ b/app/src/androidTest/java/com/google/jetpackcamera/DebugOverlayTest.kt @@ -24,8 +24,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 @@ -80,8 +79,7 @@ class DebugOverlayTest { 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..2b9ab3d9f 100644 --- a/app/src/androidTest/java/com/google/jetpackcamera/VideoAudioTest.kt +++ b/app/src/androidTest/java/com/google/jetpackcamera/VideoAudioTest.kt @@ -16,30 +16,32 @@ 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_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 = @@ -62,14 +64,108 @@ 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.waitForNodeWithTagAndSemantics( + 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 + ) + } + } + + @Test + fun muteAndUnmuteDuringRecording() { + runMainActivityScenarioTest(debugExtra) { + composeTestRule.waitForCaptureButton() + + // verify audio button is initially set to enable audio + composeTestRule.waitForNodeWithTagAndSemantics( + tag = AUDIO_INPUT_TOGGLE, + 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.waitForNodeWithTagAndSemantics( + tag = AUDIO_INPUT_TOGGLE, + 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.waitForNodeWithTagAndSemantics( + tag = AUDIO_INPUT_TOGGLE, + semanticsProperty = AudioStateProperty to AudioInputState.OFF + ) + + // Tap the amplitude button to unmute + composeTestRule.onNodeWithTag(AUDIO_INPUT_TOGGLE).performClick() + + // Verify amplitude button is hot again + composeTestRule.waitForNodeWithTagAndSemantics( + 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 + ) + } + } + + @Test + fun startRecordingMuted() { + runMainActivityScenarioTest(debugExtra) { + composeTestRule.waitForCaptureButton() + + // Verify amplitude button is hot initially (audio is enabled by default) + composeTestRule.waitForNodeWithTagAndSemantics( + tag = AUDIO_INPUT_TOGGLE, + 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.waitForNodeWithTagAndSemantics( + tag = AUDIO_INPUT_TOGGLE, + 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.waitForNodeWithTagAndSemantics( + tag = AUDIO_INPUT_TOGGLE, + semanticsProperty = AudioStateProperty to AudioInputState.OFF + ) + + // Stop recording + composeTestRule.onNodeWithTag(CAPTURE_BUTTON).performClick() + composeTestRule.waitForSnackbarWithText( + R.string.toast_video_capture_success, + VIDEO_CAPTURE_TIMEOUT_MILLIS ) } } 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 3d2bbdab4..34245006a 100644 --- a/app/src/androidTest/java/com/google/jetpackcamera/utils/ComposeTestRuleExt.kt +++ b/app/src/androidTest/java/com/google/jetpackcamera/utils/ComposeTestRuleExt.kt @@ -14,11 +14,13 @@ * limitations under the License. */ package com.google.jetpackcamera.utils + import android.content.Context 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 @@ -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,32 @@ 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.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() + } } 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 8dd60c4a7..74e6abfa3 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 @@ -1002,7 +1002,6 @@ private fun getPendingRecording( context(CameraSessionContext) @OptIn(ExperimentalPersistentRecording::class) private suspend fun startVideoRecordingInternal( - isInitialAudioEnabled: Boolean, context: Context, pendingRecord: PendingRecording, maxDurationMillis: Long, @@ -1026,7 +1025,7 @@ private suspend fun startVideoRecordingInternal( pendingRecord.apply { if (isAudioGranted) { - withAudioEnabled(isInitialAudioEnabled) + withAudioEnabled(initialMuted = !initialRecordingSettings.isAudioEnabled) } } .asPersistentRecording() @@ -1165,8 +1164,6 @@ private suspend fun startVideoRecordingInternal( } } } - }.apply { - mute(!isInitialAudioEnabled) } } @@ -1194,7 +1191,6 @@ private suspend fun runVideoRecording( onVideoRecord )?.let { startVideoRecordingInternal( - isInitialAudioEnabled = currentSettings.isAudioEnabled, context = context, pendingRecord = it, maxDurationMillis = maxDurationMillis, @@ -1211,13 +1207,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..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 @@ -92,6 +92,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 @@ -220,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 @@ -236,11 +240,23 @@ fun AmplitudeToggleButton( FilledIconToggleButton( modifier = modifier .size(buttonSize) - .apply { - if (audioUiState is AudioUiState.Enabled.On) { - testTag(AMPLITUDE_HOT_TAG) + .testTag(AUDIO_INPUT_TOGGLE) + .semantics { + stateDescription = if (audioUiState is AudioUiState.Enabled.On) { + microphoneOnDescription } else { - testTag(AMPLITUDE_NONE_TAG) + microphoneMutedDescription + } + 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 { 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..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" @@ -24,10 +26,18 @@ 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 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" 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