diff --git a/app/src/androidTest/java/com/google/jetpackcamera/ConcurrentCameraTest.kt b/app/src/androidTest/java/com/google/jetpackcamera/ConcurrentCameraTest.kt new file mode 100644 index 00000000..b4b1556b --- /dev/null +++ b/app/src/androidTest/java/com/google/jetpackcamera/ConcurrentCameraTest.kt @@ -0,0 +1,368 @@ +/* + * Copyright (C) 2024 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. + */ +import android.app.Activity +import android.net.Uri +import android.provider.MediaStore +import androidx.compose.ui.semantics.SemanticsNode +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.SemanticsNodeInteraction +import androidx.compose.ui.test.SemanticsNodeInteractionsProvider +import androidx.compose.ui.test.assert +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.isDisplayed +import androidx.compose.ui.test.isEnabled +import androidx.compose.ui.test.isNotEnabled +import androidx.compose.ui.test.junit4.createEmptyComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.test.core.app.ActivityScenario +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.rule.GrantPermissionRule +import com.google.common.truth.Truth.assertThat +import com.google.jetpackcamera.MainActivity +import com.google.jetpackcamera.feature.preview.R +import com.google.jetpackcamera.feature.preview.quicksettings.ui.QUICK_SETTINGS_CAPTURE_MODE_BUTTON +import com.google.jetpackcamera.feature.preview.quicksettings.ui.QUICK_SETTINGS_CONCURRENT_CAMERA_MODE_BUTTON +import com.google.jetpackcamera.feature.preview.quicksettings.ui.QUICK_SETTINGS_DROP_DOWN +import com.google.jetpackcamera.feature.preview.quicksettings.ui.QUICK_SETTINGS_FLIP_CAMERA_BUTTON +import com.google.jetpackcamera.feature.preview.quicksettings.ui.QUICK_SETTINGS_HDR_BUTTON +import com.google.jetpackcamera.feature.preview.quicksettings.ui.QUICK_SETTINGS_RATIO_1_1_BUTTON +import com.google.jetpackcamera.feature.preview.quicksettings.ui.QUICK_SETTINGS_RATIO_BUTTON +import com.google.jetpackcamera.feature.preview.ui.CAPTURE_BUTTON +import com.google.jetpackcamera.feature.preview.ui.CAPTURE_MODE_TOGGLE_BUTTON +import com.google.jetpackcamera.feature.preview.ui.FLIP_CAMERA_BUTTON +import com.google.jetpackcamera.feature.preview.ui.IMAGE_CAPTURE_UNSUPPORTED_CONCURRENT_CAMERA_TAG +import com.google.jetpackcamera.feature.preview.ui.VIDEO_CAPTURE_SUCCESS_TAG +import com.google.jetpackcamera.settings.model.ConcurrentCameraMode +import com.google.jetpackcamera.utils.APP_START_TIMEOUT_MILLIS +import com.google.jetpackcamera.utils.TEST_REQUIRED_PERMISSIONS +import com.google.jetpackcamera.utils.VIDEO_CAPTURE_TIMEOUT_MILLIS +import com.google.jetpackcamera.utils.assume +import com.google.jetpackcamera.utils.getResString +import com.google.jetpackcamera.utils.longClickForVideoRecording +import com.google.jetpackcamera.utils.runMediaStoreAutoDeleteScenarioTest +import com.google.jetpackcamera.utils.runScenarioTest +import com.google.jetpackcamera.utils.stateDescriptionMatches +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ConcurrentCameraTest { + @get:Rule + val permissionsRule: GrantPermissionRule = + GrantPermissionRule.grant(*(TEST_REQUIRED_PERMISSIONS).toTypedArray()) + + @get:Rule + val composeTestRule = createEmptyComposeRule() + + @Test + fun concurrentCameraMode_canBeEnabled() = runConcurrentCameraScenarioTest { + val concurrentCameraModes = mutableListOf() + with(composeTestRule) { + onNodeWithTag(QUICK_SETTINGS_CONCURRENT_CAMERA_MODE_BUTTON) + .assertExists().apply { + // Check the original mode + fetchSemanticsNode().let { node -> + concurrentCameraModes.add(node.fetchConcurrentCameraMode()) + } + } + // Enable concurrent camera + .performClick().apply { + // Check the mode has changed + fetchSemanticsNode().let { node -> + concurrentCameraModes.add(node.fetchConcurrentCameraMode()) + } + } + + // Exit quick settings + onNodeWithTag(QUICK_SETTINGS_DROP_DOWN) + .assertExists() + .performClick() + + // Assert that the flip camera button is visible + onNodeWithTag(FLIP_CAMERA_BUTTON) + .assertIsDisplayed() + } + + assertThat(concurrentCameraModes).containsExactly( + ConcurrentCameraMode.OFF, + ConcurrentCameraMode.DUAL + ).inOrder() + } + + @Test + fun concurrentCameraMode_whenEnabled_canBeDisabled() = + runConcurrentCameraScenarioTest { + val concurrentCameraModes = mutableListOf() + with(composeTestRule) { + onNodeWithTag(QUICK_SETTINGS_CONCURRENT_CAMERA_MODE_BUTTON) + .assertExists().apply { + // Check the original mode + fetchSemanticsNode().let { node -> + concurrentCameraModes.add(node.fetchConcurrentCameraMode()) + } + } + // Enable concurrent camera + .performClick().apply { + // Check the mode has changed + fetchSemanticsNode().let { node -> + concurrentCameraModes.add(node.fetchConcurrentCameraMode()) + } + } + + // Exit quick settings + onNodeWithTag(QUICK_SETTINGS_DROP_DOWN) + .assertExists() + .performClick() + + // Assert that the flip camera button is visible + onNodeWithTag(FLIP_CAMERA_BUTTON) + .assertIsDisplayed() + + // Enter quick settings + onNodeWithTag(QUICK_SETTINGS_DROP_DOWN) + .assertExists() + .performClick() + + onNodeWithTag(QUICK_SETTINGS_CONCURRENT_CAMERA_MODE_BUTTON) + .assertExists() + // Disable concurrent camera + .performClick().apply { + // Check the mode is back to OFF + fetchSemanticsNode().let { node -> + concurrentCameraModes.add(node.fetchConcurrentCameraMode()) + } + } + + // Exit quick settings + onNodeWithTag(QUICK_SETTINGS_DROP_DOWN) + .assertExists() + .performClick() + + // Assert that the flip camera button is visible + onNodeWithTag(FLIP_CAMERA_BUTTON) + .assertIsDisplayed() + } + + assertThat(concurrentCameraModes).containsExactly( + ConcurrentCameraMode.OFF, + ConcurrentCameraMode.DUAL, + ConcurrentCameraMode.OFF + ).inOrder() + } + + @Test + fun concurrentCameraMode_whenEnabled_canFlipCamera() = + runConcurrentCameraScenarioTest { + with(composeTestRule) { + // Check device has multiple cameras + onNodeWithTag(QUICK_SETTINGS_FLIP_CAMERA_BUTTON) + .assertExists() + .assume(isEnabled()) { + "Device does not have multiple cameras." + } + + onNodeWithTag(QUICK_SETTINGS_CONCURRENT_CAMERA_MODE_BUTTON) + .assertExists() + .assertConcurrentCameraMode(ConcurrentCameraMode.OFF) + // Enable concurrent camera + .performClick() + .assertConcurrentCameraMode(ConcurrentCameraMode.DUAL) + + onNodeWithTag(QUICK_SETTINGS_FLIP_CAMERA_BUTTON) + .assertExists() + .performClick() + + // Exit quick settings + onNodeWithTag(QUICK_SETTINGS_DROP_DOWN) + .assertExists() + .performClick() + + // Assert that the flip camera button is visible + onNodeWithTag(FLIP_CAMERA_BUTTON) + .assertIsDisplayed() + } + } + + @Test + fun concurrentCameraMode_whenEnabled_canSwitchAspectRatio() = + runConcurrentCameraScenarioTest { + with(composeTestRule) { + onNodeWithTag(QUICK_SETTINGS_CONCURRENT_CAMERA_MODE_BUTTON) + .assertExists() + .assertConcurrentCameraMode(ConcurrentCameraMode.OFF) + // Enable concurrent camera + .performClick() + .assertConcurrentCameraMode(ConcurrentCameraMode.DUAL) + + // Click the ratio button + composeTestRule.onNodeWithTag(QUICK_SETTINGS_RATIO_BUTTON) + .assertExists() + .performClick() + + // Click the 1:1 ratio button + composeTestRule.onNodeWithTag(QUICK_SETTINGS_RATIO_1_1_BUTTON) + .assertExists() + .performClick() + + // Exit quick settings + onNodeWithTag(QUICK_SETTINGS_DROP_DOWN) + .assertExists() + .performClick() + + // Assert that the flip camera button is visible + onNodeWithTag(FLIP_CAMERA_BUTTON) + .assertIsDisplayed() + } + } + + @Test + fun concurrentCameraMode_whenEnabled_disablesOtherSettings() = + runConcurrentCameraScenarioTest { + with(composeTestRule) { + onNodeWithTag(QUICK_SETTINGS_CONCURRENT_CAMERA_MODE_BUTTON) + .assertExists() + .assertConcurrentCameraMode(ConcurrentCameraMode.OFF) + // Enable concurrent camera + .performClick() + .assertConcurrentCameraMode(ConcurrentCameraMode.DUAL) + + // Assert the capture mode button is disabled + onNodeWithTag(QUICK_SETTINGS_CAPTURE_MODE_BUTTON) + .assertExists() + .assert(isNotEnabled()) + + // Assert the HDR button is disabled + onNodeWithTag(QUICK_SETTINGS_HDR_BUTTON) + .assertExists() + .assert(isNotEnabled()) + + // Exit quick settings + onNodeWithTag(QUICK_SETTINGS_DROP_DOWN) + .assertExists() + .performClick() + + onNodeWithTag(CAPTURE_MODE_TOGGLE_BUTTON) + .assertExists() + .assert( + stateDescriptionMatches( + getResString(R.string.capture_mode_video_recording_content_description) + ) + ).performClick() + + waitUntil { + onNodeWithTag(IMAGE_CAPTURE_UNSUPPORTED_CONCURRENT_CAMERA_TAG).isDisplayed() + } + } + } + + @Test + fun concurrentCameraMode_canRecordVideo() = runConcurrentCameraScenarioTest( + mediaUriForSavedFiles = MediaStore.Video.Media.EXTERNAL_CONTENT_URI + ) { + with(composeTestRule) { + onNodeWithTag(QUICK_SETTINGS_CONCURRENT_CAMERA_MODE_BUTTON) + .assertExists() + .assertConcurrentCameraMode(ConcurrentCameraMode.OFF) + // Enable concurrent camera + .performClick() + .assertConcurrentCameraMode(ConcurrentCameraMode.DUAL) + + // Exit quick settings + onNodeWithTag(QUICK_SETTINGS_DROP_DOWN) + .assertExists() + .performClick() + + longClickForVideoRecording() + + waitUntil(timeoutMillis = VIDEO_CAPTURE_TIMEOUT_MILLIS) { + composeTestRule.onNodeWithTag(VIDEO_CAPTURE_SUCCESS_TAG).isDisplayed() + } + } + } + + // Ensures the app has launched and checks that the device supports concurrent camera before + // running the test. + // This test will start with quick settings visible + private inline fun runConcurrentCameraScenarioTest( + mediaUriForSavedFiles: Uri? = null, + expectedMediaFiles: Int = 1, + crossinline block: ActivityScenario.() -> Unit + ) { + val wrappedBlock: ActivityScenario.() -> Unit = { + // Wait for the capture button to be displayed + composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) { + composeTestRule.onNodeWithTag(CAPTURE_BUTTON).isDisplayed() + } + + // /////////////////////////////////////////////////// + // Check that the device supports concurrent camera // + // /////////////////////////////////////////////////// + // Navigate to quick settings + composeTestRule.onNodeWithTag(QUICK_SETTINGS_DROP_DOWN) + .assertExists() + .performClick() + + // Check that the concurrent camera button is enabled + composeTestRule.onNodeWithTag(QUICK_SETTINGS_CONCURRENT_CAMERA_MODE_BUTTON) + .assertExists() + .assume(isEnabled()) { + "Device does not support concurrent camera." + } + + // /////////////////////////////////////////////////// + // Run the actual test // + // /////////////////////////////////////////////////// + block() + } + + if (mediaUriForSavedFiles != null) { + runMediaStoreAutoDeleteScenarioTest( + mediaUri = mediaUriForSavedFiles, + expectedNumFiles = expectedMediaFiles, + block = wrappedBlock + ) + } else { + runScenarioTest(wrappedBlock) + } + } + + context(SemanticsNodeInteractionsProvider) + private fun SemanticsNode.fetchConcurrentCameraMode(): ConcurrentCameraMode { + config[SemanticsProperties.ContentDescription].any { description -> + when (description) { + getResString(R.string.quick_settings_concurrent_camera_off_description) -> + return ConcurrentCameraMode.OFF + + getResString(R.string.quick_settings_concurrent_camera_dual_description) -> + return ConcurrentCameraMode.DUAL + + else -> false + } + } + throw AssertionError("Unable to determine concurrent camera mode from quick settings") + } + + context(SemanticsNodeInteractionsProvider) + private fun SemanticsNodeInteraction.assertConcurrentCameraMode( + mode: ConcurrentCameraMode + ): SemanticsNodeInteraction { + assertThat(fetchSemanticsNode().fetchConcurrentCameraMode()) + .isEqualTo(mode) + return this + } +} diff --git a/app/src/androidTest/java/com/google/jetpackcamera/VideoRecordingDeviceTest.kt b/app/src/androidTest/java/com/google/jetpackcamera/VideoRecordingDeviceTest.kt index 41a910e7..a537bb82 100644 --- a/app/src/androidTest/java/com/google/jetpackcamera/VideoRecordingDeviceTest.kt +++ b/app/src/androidTest/java/com/google/jetpackcamera/VideoRecordingDeviceTest.kt @@ -19,12 +19,10 @@ import android.app.Activity import android.net.Uri import android.os.Environment import android.provider.MediaStore -import androidx.compose.ui.test.ComposeTimeoutException 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 -import androidx.compose.ui.test.performTouchInput import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import androidx.test.rule.GrantPermissionRule @@ -38,11 +36,11 @@ import com.google.jetpackcamera.utils.APP_START_TIMEOUT_MILLIS import com.google.jetpackcamera.utils.IMAGE_CAPTURE_TIMEOUT_MILLIS import com.google.jetpackcamera.utils.TEST_REQUIRED_PERMISSIONS import com.google.jetpackcamera.utils.VIDEO_CAPTURE_TIMEOUT_MILLIS -import com.google.jetpackcamera.utils.VIDEO_DURATION_MILLIS import com.google.jetpackcamera.utils.deleteFilesInDirAfterTimestamp import com.google.jetpackcamera.utils.doesImageFileExist import com.google.jetpackcamera.utils.getIntent import com.google.jetpackcamera.utils.getTestUri +import com.google.jetpackcamera.utils.longClickForVideoRecording import com.google.jetpackcamera.utils.runMediaStoreAutoDeleteScenarioTest import com.google.jetpackcamera.utils.runScenarioTestForResult import org.junit.Rule @@ -70,7 +68,7 @@ internal class VideoRecordingDeviceTest { composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) { composeTestRule.onNodeWithTag(CAPTURE_BUTTON).isDisplayed() } - longClickForVideoRecording() + composeTestRule.longClickForVideoRecording() composeTestRule.waitUntil(timeoutMillis = VIDEO_CAPTURE_TIMEOUT_MILLIS) { composeTestRule.onNodeWithTag(VIDEO_CAPTURE_SUCCESS_TAG).isDisplayed() } @@ -88,7 +86,7 @@ internal class VideoRecordingDeviceTest { composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) { composeTestRule.onNodeWithTag(CAPTURE_BUTTON).isDisplayed() } - longClickForVideoRecording() + composeTestRule.longClickForVideoRecording() } Truth.assertThat(result.resultCode).isEqualTo(Activity.RESULT_OK) Truth.assertThat(doesImageFileExist(uri, "video")).isTrue() @@ -106,7 +104,7 @@ internal class VideoRecordingDeviceTest { composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) { composeTestRule.onNodeWithTag(CAPTURE_BUTTON).isDisplayed() } - longClickForVideoRecording() + composeTestRule.longClickForVideoRecording() composeTestRule.waitUntil(timeoutMillis = VIDEO_CAPTURE_TIMEOUT_MILLIS) { composeTestRule.onNodeWithTag(VIDEO_CAPTURE_FAILURE_TAG).isDisplayed() } @@ -143,30 +141,6 @@ internal class VideoRecordingDeviceTest { Truth.assertThat(doesImageFileExist(uri, "image")).isFalse() } - private fun longClickForVideoRecording() { - composeTestRule.onNodeWithTag(CAPTURE_BUTTON) - .assertExists() - .performTouchInput { - down(center) - } - idleForVideoDuration() - composeTestRule.onNodeWithTag(CAPTURE_BUTTON) - .assertExists() - .performTouchInput { - up() - } - } - - private fun idleForVideoDuration() { - // TODO: replace with a check for the timestamp UI of the video duration - try { - composeTestRule.waitUntil(timeoutMillis = VIDEO_DURATION_MILLIS) { - composeTestRule.onNodeWithTag("dummyTagForLongPress").isDisplayed() - } - } catch (e: ComposeTimeoutException) { - } - } - companion object { val DIR_PATH: String = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MOVIES).path 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 e3be80e9..b6e0388d 100644 --- a/app/src/androidTest/java/com/google/jetpackcamera/utils/ComposeTestRuleExt.kt +++ b/app/src/androidTest/java/com/google/jetpackcamera/utils/ComposeTestRuleExt.kt @@ -17,13 +17,26 @@ package com.google.jetpackcamera.utils import android.content.Context import androidx.annotation.StringRes +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.ComposeTimeoutException import androidx.compose.ui.test.SemanticsMatcher import androidx.compose.ui.test.SemanticsNodeInteraction import androidx.compose.ui.test.SemanticsNodeInteractionsProvider +import androidx.compose.ui.test.isDisplayed +import androidx.compose.ui.test.junit4.ComposeTestRule import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTouchInput import androidx.compose.ui.test.printToString +import androidx.test.core.app.ActivityScenario import androidx.test.core.app.ApplicationProvider +import com.google.jetpackcamera.MainActivity +import com.google.jetpackcamera.feature.preview.R +import com.google.jetpackcamera.feature.preview.quicksettings.ui.QUICK_SETTINGS_FLIP_CAMERA_BUTTON +import com.google.jetpackcamera.feature.preview.ui.CAPTURE_BUTTON +import com.google.jetpackcamera.settings.model.LensFacing import org.junit.AssumptionViolatedException /** @@ -84,6 +97,70 @@ fun SemanticsNodeInteraction.assume( return this } +fun ComposeTestRule.longClickForVideoRecording() { + onNodeWithTag(CAPTURE_BUTTON) + .assertExists() + .performTouchInput { + down(center) + } + idleForVideoDuration() + onNodeWithTag(CAPTURE_BUTTON) + .assertExists() + .performTouchInput { + up() + } +} + +private fun ComposeTestRule.idleForVideoDuration() { + // TODO: replace with a check for the timestamp UI of the video duration + try { + waitUntil(timeoutMillis = VIDEO_DURATION_MILLIS) { + onNodeWithTag("dummyTagForLongPress").isDisplayed() + } + } catch (e: ComposeTimeoutException) { + } +} + +context(ActivityScenario) +fun ComposeTestRule.getCurrentLensFacing(): LensFacing { + var needReturnFromQuickSettings = false + onNodeWithContentDescription(R.string.quick_settings_dropdown_closed_description).apply { + if (isDisplayed()) { + performClick() + needReturnFromQuickSettings = true + } + } + + onNodeWithContentDescription(R.string.quick_settings_dropdown_open_description).assertExists( + "LensFacing can only be retrieved from PreviewScreen or QuickSettings screen" + ) + + try { + return onNodeWithTag(QUICK_SETTINGS_FLIP_CAMERA_BUTTON).fetchSemanticsNode( + "Flip camera button is not visible when expected." + ).let { node -> + node.config[SemanticsProperties.ContentDescription].any { description -> + when (description) { + getResString(R.string.quick_settings_front_camera_description) -> + return@let LensFacing.FRONT + + getResString(R.string.quick_settings_back_camera_description) -> + return@let LensFacing.BACK + + else -> false + } + } + throw AssertionError("Unable to determine lens facing from quick settings") + } + } finally { + if (needReturnFromQuickSettings) { + onNodeWithContentDescription(R.string.quick_settings_dropdown_open_description) + .assertExists() + .performClick() + } + } +} + internal fun buildGeneralErrorMessage( errorMessage: String, nodeInteraction: SemanticsNodeInteraction diff --git a/app/src/androidTest/java/com/google/jetpackcamera/utils/UiTestUtil.kt b/app/src/androidTest/java/com/google/jetpackcamera/utils/UiTestUtil.kt index c5f973b6..bf5e8e4f 100644 --- a/app/src/androidTest/java/com/google/jetpackcamera/utils/UiTestUtil.kt +++ b/app/src/androidTest/java/com/google/jetpackcamera/utils/UiTestUtil.kt @@ -23,18 +23,11 @@ import android.net.Uri import android.provider.MediaStore import android.util.Log import androidx.compose.ui.semantics.SemanticsProperties -import androidx.compose.ui.test.isDisplayed -import androidx.compose.ui.test.junit4.ComposeTestRule -import androidx.compose.ui.test.onNodeWithTag -import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.SemanticsMatcher import androidx.lifecycle.Lifecycle import androidx.test.core.app.ActivityScenario import androidx.test.platform.app.InstrumentationRegistry import com.google.common.truth.Truth.assertWithMessage -import com.google.jetpackcamera.MainActivity -import com.google.jetpackcamera.feature.preview.R -import com.google.jetpackcamera.feature.preview.quicksettings.ui.QUICK_SETTINGS_FLIP_CAMERA_BUTTON -import com.google.jetpackcamera.settings.model.LensFacing import java.io.File import java.net.URLConnection import java.util.concurrent.TimeoutException @@ -154,46 +147,6 @@ suspend inline fun ActivityScenario.pollResult( ) } -context(ActivityScenario) -fun ComposeTestRule.getCurrentLensFacing(): LensFacing { - var needReturnFromQuickSettings = false - onNodeWithContentDescription(R.string.quick_settings_dropdown_closed_description).apply { - if (isDisplayed()) { - performClick() - needReturnFromQuickSettings = true - } - } - - onNodeWithContentDescription(R.string.quick_settings_dropdown_open_description).assertExists( - "LensFacing can only be retrieved from PreviewScreen or QuickSettings screen" - ) - - try { - return onNodeWithTag(QUICK_SETTINGS_FLIP_CAMERA_BUTTON).fetchSemanticsNode( - "Flip camera button is not visible when expected." - ).let { node -> - node.config[SemanticsProperties.ContentDescription].any { description -> - when (description) { - getResString(R.string.quick_settings_front_camera_description) -> - return@let LensFacing.FRONT - - getResString(R.string.quick_settings_back_camera_description) -> - return@let LensFacing.BACK - - else -> false - } - } - throw AssertionError("Unable to determine lens facing from quick settings") - } - } finally { - if (needReturnFromQuickSettings) { - onNodeWithContentDescription(R.string.quick_settings_dropdown_open_description) - .assertExists() - .performClick() - } - } -} - fun getTestUri(directoryPath: String, timeStamp: Long, suffix: String): Uri { return Uri.fromFile( File( @@ -244,3 +197,8 @@ fun getIntent(uri: Uri, action: String): Intent { intent.putExtra(MediaStore.EXTRA_OUTPUT, uri) return intent } + +fun stateDescriptionMatches(expected: String?) = SemanticsMatcher("stateDescription is $expected") { + SemanticsProperties.StateDescription in it.config && + (it.config[SemanticsProperties.StateDescription] == expected) +} diff --git a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/CameraControlsOverlay.kt b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/CameraControlsOverlay.kt index 3631b360..91d817eb 100644 --- a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/CameraControlsOverlay.kt +++ b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/CameraControlsOverlay.kt @@ -15,7 +15,6 @@ */ package com.google.jetpackcamera.feature.preview.ui -import android.annotation.SuppressLint import android.content.ContentResolver import android.net.Uri import androidx.compose.foundation.layout.Arrangement @@ -47,15 +46,16 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.core.util.Preconditions import com.google.jetpackcamera.feature.preview.CaptureModeToggleUiState import com.google.jetpackcamera.feature.preview.MultipleEventsCutter import com.google.jetpackcamera.feature.preview.PreviewMode import com.google.jetpackcamera.feature.preview.PreviewUiState import com.google.jetpackcamera.feature.preview.PreviewViewModel +import com.google.jetpackcamera.feature.preview.R import com.google.jetpackcamera.feature.preview.VideoRecordingState import com.google.jetpackcamera.feature.preview.quicksettings.ui.QuickSettingsIndicators import com.google.jetpackcamera.feature.preview.quicksettings.ui.ToggleQuickSettingsButton @@ -292,7 +292,8 @@ private fun ControlsBottom( CaptureModeToggleButton( uiState = previewUiState.captureModeToggleUiState, onChangeImageFormat = onChangeImageFormat, - onToggleWhenDisabled = onToggleWhenDisabled + onToggleWhenDisabled = onToggleWhenDisabled, + modifier = Modifier.testTag(CAPTURE_MODE_TOGGLE_BUTTON) ) } } @@ -390,12 +391,12 @@ private fun CaptureButton( ) } -@SuppressLint("RestrictedApi") @Composable private fun CaptureModeToggleButton( uiState: CaptureModeToggleUiState.Visible, onChangeImageFormat: (ImageOutputFormat) -> Unit, - onToggleWhenDisabled: (CaptureModeToggleUiState.DisabledReason) -> Unit + onToggleWhenDisabled: (CaptureModeToggleUiState.DisabledReason) -> Unit, + modifier: Modifier = Modifier ) { // Captures hdr image (left) when output format is UltraHdr, else captures hdr video (right). val initialState = @@ -427,10 +428,15 @@ private fun CaptureModeToggleButton( onChangeImageFormat(imageFormat) }, onToggleWhenDisabled = { - Preconditions.checkArgument(uiState is CaptureModeToggleUiState.Disabled) - onToggleWhenDisabled((uiState as CaptureModeToggleUiState.Disabled).disabledReason) + check(uiState is CaptureModeToggleUiState.Disabled) + onToggleWhenDisabled(uiState.disabledReason) }, - enabled = uiState is CaptureModeToggleUiState.Enabled + enabled = uiState is CaptureModeToggleUiState.Enabled, + leftIconDescription = + stringResource(id = R.string.capture_mode_image_capture_content_description), + rightIconDescription = + stringResource(id = R.string.capture_mode_video_recording_content_description), + modifier = modifier ) } diff --git a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/PreviewScreenComponents.kt b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/PreviewScreenComponents.kt index f5d8139b..8382af48 100644 --- a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/PreviewScreenComponents.kt +++ b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/PreviewScreenComponents.kt @@ -75,7 +75,6 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow @@ -91,6 +90,9 @@ import androidx.compose.ui.layout.layout import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -106,7 +108,6 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onCompletion -import kotlinx.coroutines.launch private const val TAG = "PreviewScreen" private const val BLINK_TIME = 100L @@ -583,9 +584,7 @@ enum class ToggleState { fun ToggleButton( leftIcon: Painter, rightIcon: Painter, - modifier: Modifier = Modifier - .width(64.dp) - .height(32.dp), + modifier: Modifier = Modifier, initialState: ToggleState = ToggleState.Left, onToggleStateChanged: (newState: ToggleState) -> Unit = {}, onToggleWhenDisabled: () -> Unit = {}, @@ -608,26 +607,32 @@ fun ToggleButton( }, label = "togglePosition" ) - val scope = rememberCoroutineScope() Surface( modifier = modifier .clip(shape = RoundedCornerShape(50)) .then( - Modifier.clickable { - scope.launch { - if (enabled) { - toggleState = when (toggleState) { - ToggleState.Left -> ToggleState.Right - ToggleState.Right -> ToggleState.Left - } - onToggleStateChanged(toggleState) - } else { - onToggleWhenDisabled() + Modifier.clickable( + role = Role.Switch + ) { + if (enabled) { + toggleState = when (toggleState) { + ToggleState.Left -> ToggleState.Right + ToggleState.Right -> ToggleState.Left } + onToggleStateChanged(toggleState) + } else { + onToggleWhenDisabled() } } - ), + ).semantics { + stateDescription = when (toggleState) { + ToggleState.Left -> leftIconDescription + ToggleState.Right -> rightIconDescription + } + } + .width(64.dp) + .height(32.dp), color = backgroundColor ) { Box { diff --git a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/TestTags.kt b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/TestTags.kt index 077a971f..dcea4ec6 100644 --- a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/TestTags.kt +++ b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/ui/TestTags.kt @@ -16,6 +16,7 @@ package com.google.jetpackcamera.feature.preview.ui const val CAPTURE_BUTTON = "CaptureButton" +const val CAPTURE_MODE_TOGGLE_BUTTON = "CaptureModeToggleButton" const val FLIP_CAMERA_BUTTON = "FlipCameraButton" const val IMAGE_CAPTURE_SUCCESS_TAG = "ImageCaptureSuccessTag" const val IMAGE_CAPTURE_FAILURE_TAG = "ImageCaptureFailureTag" diff --git a/feature/preview/src/main/res/values/strings.xml b/feature/preview/src/main/res/values/strings.xml index 77d80e03..10b96b84 100644 --- a/feature/preview/src/main/res/values/strings.xml +++ b/feature/preview/src/main/res/values/strings.xml @@ -16,6 +16,10 @@ --> Camera Loading… + + Image capture mode + Video recording mode + Settings Flip Camera