From c4ef5400310eb19e50600334d9a791c4e077a103 Mon Sep 17 00:00:00 2001 From: Trevor McGuire Date: Thu, 12 Sep 2024 14:27:11 -0700 Subject: [PATCH 1/7] Fix tests which don't specify explicit URI When running JCA without an explicit intent mode, the files are saved to MediaStore. This installs a watch on the MediaStore to delete any files that are created during tests. --- .../jetpackcamera/BackgroundDeviceTest.kt | 4 +- .../google/jetpackcamera/FlashDeviceTest.kt | 4 +- .../jetpackcamera/ImageCaptureDeviceTest.kt | 14 +-- .../google/jetpackcamera/NavigationTest.kt | 4 +- .../google/jetpackcamera/SwitchCameraTest.kt | 4 +- .../google/jetpackcamera/VideoAudioTest.kt | 4 +- .../jetpackcamera/VideoRecordingDeviceTest.kt | 13 +- .../google/jetpackcamera/utils/AppTestUtil.kt | 114 +++++++++++++++++- .../google/jetpackcamera/utils/UiTestUtil.kt | 83 ++++++++++++- app/src/debug/AndroidManifest.xml | 30 +++++ gradle/libs.versions.toml | 2 +- 11 files changed, 244 insertions(+), 32 deletions(-) create mode 100644 app/src/debug/AndroidManifest.xml diff --git a/app/src/androidTest/java/com/google/jetpackcamera/BackgroundDeviceTest.kt b/app/src/androidTest/java/com/google/jetpackcamera/BackgroundDeviceTest.kt index 747650f07..7ae4498bf 100644 --- a/app/src/androidTest/java/com/google/jetpackcamera/BackgroundDeviceTest.kt +++ b/app/src/androidTest/java/com/google/jetpackcamera/BackgroundDeviceTest.kt @@ -34,8 +34,8 @@ import com.google.jetpackcamera.feature.preview.quicksettings.ui.QUICK_SETTINGS_ 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.utils.APP_REQUIRED_PERMISSIONS import com.google.jetpackcamera.utils.APP_START_TIMEOUT_MILLIS +import com.google.jetpackcamera.utils.TEST_REQUIRED_PERMISSIONS import com.google.jetpackcamera.utils.runScenarioTest import org.junit.Before import org.junit.Rule @@ -46,7 +46,7 @@ import org.junit.runner.RunWith class BackgroundDeviceTest { @get:Rule val permissionsRule: GrantPermissionRule = - GrantPermissionRule.grant(*(APP_REQUIRED_PERMISSIONS).toTypedArray()) + GrantPermissionRule.grant(*(TEST_REQUIRED_PERMISSIONS).toTypedArray()) @get:Rule val composeTestRule = createEmptyComposeRule() diff --git a/app/src/androidTest/java/com/google/jetpackcamera/FlashDeviceTest.kt b/app/src/androidTest/java/com/google/jetpackcamera/FlashDeviceTest.kt index 0e57a00cb..e65a77cad 100644 --- a/app/src/androidTest/java/com/google/jetpackcamera/FlashDeviceTest.kt +++ b/app/src/androidTest/java/com/google/jetpackcamera/FlashDeviceTest.kt @@ -35,9 +35,9 @@ import com.google.jetpackcamera.feature.preview.ui.FLIP_CAMERA_BUTTON import com.google.jetpackcamera.feature.preview.ui.IMAGE_CAPTURE_SUCCESS_TAG import com.google.jetpackcamera.feature.preview.ui.SCREEN_FLASH_OVERLAY import com.google.jetpackcamera.settings.model.LensFacing -import com.google.jetpackcamera.utils.APP_REQUIRED_PERMISSIONS 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.assume import com.google.jetpackcamera.utils.getCurrentLensFacing import com.google.jetpackcamera.utils.onNodeWithContentDescription @@ -52,7 +52,7 @@ internal class FlashDeviceTest { @get:Rule val permissionsRule: GrantPermissionRule = - GrantPermissionRule.grant(*(APP_REQUIRED_PERMISSIONS).toTypedArray()) + GrantPermissionRule.grant(*(TEST_REQUIRED_PERMISSIONS).toTypedArray()) @get:Rule val composeTestRule = createEmptyComposeRule() diff --git a/app/src/androidTest/java/com/google/jetpackcamera/ImageCaptureDeviceTest.kt b/app/src/androidTest/java/com/google/jetpackcamera/ImageCaptureDeviceTest.kt index 7edae9a32..289f479a2 100644 --- a/app/src/androidTest/java/com/google/jetpackcamera/ImageCaptureDeviceTest.kt +++ b/app/src/androidTest/java/com/google/jetpackcamera/ImageCaptureDeviceTest.kt @@ -34,17 +34,16 @@ import com.google.jetpackcamera.feature.preview.ui.CAPTURE_BUTTON import com.google.jetpackcamera.feature.preview.ui.IMAGE_CAPTURE_FAILURE_TAG import com.google.jetpackcamera.feature.preview.ui.IMAGE_CAPTURE_SUCCESS_TAG import com.google.jetpackcamera.feature.preview.ui.VIDEO_CAPTURE_EXTERNAL_UNSUPPORTED_TAG -import com.google.jetpackcamera.utils.APP_REQUIRED_PERMISSIONS 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.deleteFilesInDirAfterTimestamp import com.google.jetpackcamera.utils.doesImageFileExist import com.google.jetpackcamera.utils.getIntent import com.google.jetpackcamera.utils.getTestUri -import com.google.jetpackcamera.utils.runScenarioTest +import com.google.jetpackcamera.utils.runMediaStoreAutoDeleteScenarioTest import com.google.jetpackcamera.utils.runScenarioTestForResult -import java.io.File import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -55,7 +54,7 @@ internal class ImageCaptureDeviceTest { @get:Rule val permissionsRule: GrantPermissionRule = - GrantPermissionRule.grant(*(APP_REQUIRED_PERMISSIONS).toTypedArray()) + GrantPermissionRule.grant(*(TEST_REQUIRED_PERMISSIONS).toTypedArray()) @get:Rule val composeTestRule = createEmptyComposeRule() @@ -64,8 +63,9 @@ internal class ImageCaptureDeviceTest { private val uiDevice = UiDevice.getInstance(instrumentation) @Test - fun image_capture() = runScenarioTest { - val timeStamp = System.currentTimeMillis() + fun image_capture() = runMediaStoreAutoDeleteScenarioTest( + mediaUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI + ) { // Wait for the capture button to be displayed composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) { composeTestRule.onNodeWithTag(CAPTURE_BUTTON).isDisplayed() @@ -77,8 +77,6 @@ internal class ImageCaptureDeviceTest { composeTestRule.waitUntil(timeoutMillis = IMAGE_CAPTURE_TIMEOUT_MILLIS) { composeTestRule.onNodeWithTag(IMAGE_CAPTURE_SUCCESS_TAG).isDisplayed() } - Truth.assertThat(File(DIR_PATH).lastModified() > timeStamp).isTrue() - deleteFilesInDirAfterTimestamp(DIR_PATH, instrumentation, timeStamp) } @Test diff --git a/app/src/androidTest/java/com/google/jetpackcamera/NavigationTest.kt b/app/src/androidTest/java/com/google/jetpackcamera/NavigationTest.kt index b3e82ab50..f85f24404 100644 --- a/app/src/androidTest/java/com/google/jetpackcamera/NavigationTest.kt +++ b/app/src/androidTest/java/com/google/jetpackcamera/NavigationTest.kt @@ -32,8 +32,8 @@ import com.google.jetpackcamera.feature.preview.ui.FLIP_CAMERA_BUTTON import com.google.jetpackcamera.feature.preview.ui.SETTINGS_BUTTON import com.google.jetpackcamera.settings.R import com.google.jetpackcamera.settings.ui.BACK_BUTTON -import com.google.jetpackcamera.utils.APP_REQUIRED_PERMISSIONS import com.google.jetpackcamera.utils.APP_START_TIMEOUT_MILLIS +import com.google.jetpackcamera.utils.TEST_REQUIRED_PERMISSIONS import com.google.jetpackcamera.utils.assume import com.google.jetpackcamera.utils.onNodeWithText import com.google.jetpackcamera.utils.runScenarioTest @@ -45,7 +45,7 @@ import org.junit.runner.RunWith class NavigationTest { @get:Rule val permissionsRule: GrantPermissionRule = - GrantPermissionRule.grant(*(APP_REQUIRED_PERMISSIONS).toTypedArray()) + GrantPermissionRule.grant(*(TEST_REQUIRED_PERMISSIONS).toTypedArray()) @get:Rule val composeTestRule = createEmptyComposeRule() diff --git a/app/src/androidTest/java/com/google/jetpackcamera/SwitchCameraTest.kt b/app/src/androidTest/java/com/google/jetpackcamera/SwitchCameraTest.kt index 5d732a9ec..9be463cbc 100644 --- a/app/src/androidTest/java/com/google/jetpackcamera/SwitchCameraTest.kt +++ b/app/src/androidTest/java/com/google/jetpackcamera/SwitchCameraTest.kt @@ -32,7 +32,7 @@ import com.google.jetpackcamera.feature.preview.quicksettings.ui.QUICK_SETTINGS_ import com.google.jetpackcamera.feature.preview.ui.FLIP_CAMERA_BUTTON import com.google.jetpackcamera.feature.preview.ui.PREVIEW_DISPLAY import com.google.jetpackcamera.settings.model.LensFacing -import com.google.jetpackcamera.utils.APP_REQUIRED_PERMISSIONS +import com.google.jetpackcamera.utils.TEST_REQUIRED_PERMISSIONS import com.google.jetpackcamera.utils.assume import com.google.jetpackcamera.utils.getCurrentLensFacing import com.google.jetpackcamera.utils.runScenarioTest @@ -44,7 +44,7 @@ import org.junit.runner.RunWith class SwitchCameraTest { @get:Rule val permissionsRule: GrantPermissionRule = - GrantPermissionRule.grant(*(APP_REQUIRED_PERMISSIONS).toTypedArray()) + GrantPermissionRule.grant(*(TEST_REQUIRED_PERMISSIONS).toTypedArray()) @get:Rule val composeTestRule = createEmptyComposeRule() diff --git a/app/src/androidTest/java/com/google/jetpackcamera/VideoAudioTest.kt b/app/src/androidTest/java/com/google/jetpackcamera/VideoAudioTest.kt index 0cfbd731d..3af4d125f 100644 --- a/app/src/androidTest/java/com/google/jetpackcamera/VideoAudioTest.kt +++ b/app/src/androidTest/java/com/google/jetpackcamera/VideoAudioTest.kt @@ -30,8 +30,8 @@ import androidx.test.uiautomator.Until import com.google.common.truth.Truth.assertThat import com.google.jetpackcamera.feature.preview.ui.AMPLITUDE_HOT_TAG import com.google.jetpackcamera.feature.preview.ui.CAPTURE_BUTTON -import com.google.jetpackcamera.utils.APP_REQUIRED_PERMISSIONS import com.google.jetpackcamera.utils.APP_START_TIMEOUT_MILLIS +import com.google.jetpackcamera.utils.TEST_REQUIRED_PERMISSIONS import com.google.jetpackcamera.utils.runScenarioTest import org.junit.Before import org.junit.Rule @@ -43,7 +43,7 @@ import org.junit.runner.RunWith class VideoAudioTest { @get:Rule val permissionsRule: GrantPermissionRule = - GrantPermissionRule.grant(*(APP_REQUIRED_PERMISSIONS).toTypedArray()) + GrantPermissionRule.grant(*(TEST_REQUIRED_PERMISSIONS).toTypedArray()) @get:Rule val composeTestRule = createEmptyComposeRule() diff --git a/app/src/androidTest/java/com/google/jetpackcamera/VideoRecordingDeviceTest.kt b/app/src/androidTest/java/com/google/jetpackcamera/VideoRecordingDeviceTest.kt index 545b406fd..094c43f74 100644 --- a/app/src/androidTest/java/com/google/jetpackcamera/VideoRecordingDeviceTest.kt +++ b/app/src/androidTest/java/com/google/jetpackcamera/VideoRecordingDeviceTest.kt @@ -34,18 +34,17 @@ import com.google.jetpackcamera.feature.preview.ui.CAPTURE_BUTTON import com.google.jetpackcamera.feature.preview.ui.IMAGE_CAPTURE_EXTERNAL_UNSUPPORTED_TAG import com.google.jetpackcamera.feature.preview.ui.VIDEO_CAPTURE_FAILURE_TAG import com.google.jetpackcamera.feature.preview.ui.VIDEO_CAPTURE_SUCCESS_TAG -import com.google.jetpackcamera.utils.APP_REQUIRED_PERMISSIONS 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.runScenarioTest +import com.google.jetpackcamera.utils.runMediaStoreAutoDeleteScenarioTest import com.google.jetpackcamera.utils.runScenarioTestForResult -import java.io.File import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -54,7 +53,7 @@ import org.junit.runner.RunWith internal class VideoRecordingDeviceTest { @get:Rule val permissionsRule: GrantPermissionRule = - GrantPermissionRule.grant(*(APP_REQUIRED_PERMISSIONS).toTypedArray()) + GrantPermissionRule.grant(*(TEST_REQUIRED_PERMISSIONS).toTypedArray()) @get:Rule val composeTestRule = createEmptyComposeRule() @@ -63,7 +62,9 @@ internal class VideoRecordingDeviceTest { private val uiDevice = UiDevice.getInstance(instrumentation) @Test - fun video_capture() = runScenarioTest { + fun video_capture(): Unit = runMediaStoreAutoDeleteScenarioTest( + mediaUri = MediaStore.Video.Media.EXTERNAL_CONTENT_URI + ) { val timeStamp = System.currentTimeMillis() // Wait for the capture button to be displayed composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) { @@ -73,8 +74,6 @@ internal class VideoRecordingDeviceTest { composeTestRule.waitUntil(timeoutMillis = VIDEO_CAPTURE_TIMEOUT_MILLIS) { composeTestRule.onNodeWithTag(VIDEO_CAPTURE_SUCCESS_TAG).isDisplayed() } - Truth.assertThat(File(DIR_PATH).lastModified() > timeStamp).isTrue() - deleteFilesInDirAfterTimestamp(DIR_PATH, instrumentation, timeStamp) } @Test diff --git a/app/src/androidTest/java/com/google/jetpackcamera/utils/AppTestUtil.kt b/app/src/androidTest/java/com/google/jetpackcamera/utils/AppTestUtil.kt index 0d1e8d6ac..c6cd707ca 100644 --- a/app/src/androidTest/java/com/google/jetpackcamera/utils/AppTestUtil.kt +++ b/app/src/androidTest/java/com/google/jetpackcamera/utils/AppTestUtil.kt @@ -15,12 +15,124 @@ */ package com.google.jetpackcamera.utils +import android.app.Instrumentation +import android.database.ContentObserver +import android.database.Cursor +import android.net.Uri import android.os.Build +import android.provider.BaseColumns +import android.provider.MediaStore +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.transform -val APP_REQUIRED_PERMISSIONS: List = buildList { +private val APP_REQUIRED_PERMISSIONS: List = buildList { add(android.Manifest.permission.CAMERA) add(android.Manifest.permission.RECORD_AUDIO) if (Build.VERSION.SDK_INT <= 28) { add(android.Manifest.permission.WRITE_EXTERNAL_STORAGE) } } + +val TEST_REQUIRED_PERMISSIONS: List = buildList { + addAll(APP_REQUIRED_PERMISSIONS) + if (Build.VERSION.SDK_INT >= 33) { + add(android.Manifest.permission.READ_MEDIA_IMAGES) + add(android.Manifest.permission.READ_MEDIA_VIDEO) + } +} + +fun mediaStoreInsertedFlow( + mediaUri: Uri, + instrumentation: Instrumentation, + filePrefix: String = "" +): Flow> = with(instrumentation.targetContext.contentResolver) { + // Creates a map of the display names and corresponding URIs for all files contained within + // the URI argument. If the URI is a single file, the map will contain a single file. + // On API 29+, this will also only return files that are not "pending". Pending files + // have not yet been fully written. + fun queryWrittenFiles(uri: Uri): Map { + return buildMap { + query( + uri, + buildList { + add(BaseColumns._ID) + add(MediaStore.MediaColumns.DISPLAY_NAME) + if (Build.VERSION.SDK_INT >= 29) { + add(MediaStore.MediaColumns.IS_PENDING) + } + }.toTypedArray(), + null, + null, + null + )?.use { cursor: Cursor -> + cursor.moveToFirst() + val idCol = cursor.getColumnIndex(BaseColumns._ID) + val displayNameCol = cursor.getColumnIndex(MediaStore.MediaColumns.DISPLAY_NAME) + + while (!cursor.isAfterLast) { + val id = cursor.getLong(idCol) + val displayName = cursor.getString(displayNameCol) + val isPending = if (Build.VERSION.SDK_INT >= 29) { + cursor.getInt(cursor.getColumnIndex(MediaStore.MediaColumns.IS_PENDING)) + } else { + // On devices pre-API 29, we don't have an is_pending column, so never + // say that the file is pending + 0 + } + if (isPending == 0 && + (filePrefix.isEmpty() || displayName.startsWith(filePrefix)) + ) { + // Construct URI for a single file + val outputUri = if (uri.lastPathSegment?.equals("$id") == false) { + uri.buildUpon().appendPath("$id").build() + } else { + uri + } + put(displayName, outputUri) + } + cursor.moveToNext() + } + } + } + } + + // Get the full list of initially written files. We'll append files to this as we + // publish them. + val existingFiles = queryWrittenFiles(mediaUri).toMutableMap() + return callbackFlow { + val observer = object : ContentObserver(null) { + override fun onChange(selfChange: Boolean) { + onChange(selfChange, null) + } + + override fun onChange(selfChange: Boolean, uri: Uri?) { + onChange(selfChange, uri, 0) + } + + override fun onChange(selfChange: Boolean, uri: Uri?, flags: Int) { + onChange(selfChange, uri?.let { setOf(it) } ?: emptySet(), flags) + } + + override fun onChange(selfChange: Boolean, uris: Collection, flags: Int) { + uris.forEach { uri -> + queryWrittenFiles(uri).forEach { + trySend(it) + } + } + } + } + + registerContentObserver(mediaUri, true, observer) + + awaitClose { + unregisterContentObserver(observer) + } + }.transform { + if (!existingFiles.containsKey(it.key)) { + existingFiles[it.key] = it.value + emit(it.toPair()) + } + } +} 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 782ede2e9..aa81f1b40 100644 --- a/app/src/androidTest/java/com/google/jetpackcamera/utils/UiTestUtil.kt +++ b/app/src/androidTest/java/com/google/jetpackcamera/utils/UiTestUtil.kt @@ -21,24 +21,97 @@ import android.content.ComponentName import android.content.Intent 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.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 kotlin.coroutines.CoroutineContext +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull const val APP_START_TIMEOUT_MILLIS = 10_000L const val IMAGE_CAPTURE_TIMEOUT_MILLIS = 5_000L const val VIDEO_CAPTURE_TIMEOUT_MILLIS = 5_000L const val VIDEO_DURATION_MILLIS = 2_000L +@OptIn(ExperimentalCoroutinesApi::class) +inline fun runMediaStoreAutoDeleteScenarioTest( + mediaUri: Uri, + filePrefix: String = "JCA", + expectedNumFiles: Int = 1, + fileWaitTimeoutMs: Duration = 10.seconds, + crossinline block: ActivityScenario.() -> Unit +) = runBlocking { + val debugTag = "MediaStoreAutoDelete" + val instrumentation = InstrumentationRegistry.getInstrumentation() + val insertedMediaStoreEntries = mutableMapOf() + val fileObserverContext: CoroutineContext = Dispatchers.IO.limitedParallelism(1) + val observeFilesJob = launch(fileObserverContext) { + mediaStoreInsertedFlow( + mediaUri = mediaUri, + instrumentation = instrumentation, + filePrefix = filePrefix + ).take(expectedNumFiles) + .collect { + Log.d(debugTag, "Discovered new media store file: ${it.first}") + insertedMediaStoreEntries[it.first] = it.second + } + } + + try { + runScenarioTest(block = block) + } finally { + withContext(NonCancellable) { + withTimeoutOrNull(fileWaitTimeoutMs) { + // Wait for normal completion with timeout + observeFilesJob.join() + } ?: run { + // If timed out, cancel file observer and ensure job is complete + observeFilesJob.cancelAndJoin() + } + + val detectedNumFiles = insertedMediaStoreEntries.size + // Delete all inserted files that we know about at this point + insertedMediaStoreEntries.forEach { + Log.d(debugTag, "Deleting media store file: $it") + val deletedRows = instrumentation.targetContext.contentResolver.delete( + it.value, + null, + null + ) + if (deletedRows > 0) { + Log.d(debugTag, "Deleted $deletedRows files") + } else { + Log.e(debugTag, "Failed to delete ${it.key}") + } + } + + assertWithMessage("Expected number of saved files does not match detected number") + .that(detectedNumFiles).isEqualTo(expectedNumFiles) + } + } +} + inline fun runScenarioTest( crossinline block: ActivityScenario.() -> Unit ) { @@ -112,13 +185,13 @@ fun deleteFilesInDirAfterTimestamp( timeStamp: Long ): Boolean { var hasDeletedFile = false - for (file in File(directoryPath).listFiles()) { + for (file in File(directoryPath).listFiles() ?: emptyArray()) { if (file.lastModified() >= timeStamp) { file.delete() if (file.exists()) { - file.getCanonicalFile().delete() + file.canonicalFile.delete() if (file.exists()) { - instrumentation.targetContext.applicationContext.deleteFile(file.getName()) + instrumentation.targetContext.applicationContext.deleteFile(file.name) } } hasDeletedFile = true @@ -128,8 +201,8 @@ fun deleteFilesInDirAfterTimestamp( } fun doesImageFileExist(uri: Uri, prefix: String): Boolean { - val file = File(uri.path) - if (file.exists()) { + val file = uri.path?.let { File(it) } + if (file?.exists() == true) { val mimeType = URLConnection.guessContentTypeFromName(uri.path) return mimeType != null && mimeType.startsWith(prefix) } diff --git a/app/src/debug/AndroidManifest.xml b/app/src/debug/AndroidManifest.xml new file mode 100644 index 000000000..658a6d24b --- /dev/null +++ b/app/src/debug/AndroidManifest.xml @@ -0,0 +1,30 @@ + + + + + + + + \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 36ea1328b..157b308e6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -34,7 +34,7 @@ androidxProfileinstaller = "1.3.1" androidxTestEspresso = "3.5.1" androidxTestJunit = "1.1.5" androidxTestMonitor = "1.6.1" -androidxTestRules = "1.5.0" +androidxTestRules = "1.6.1" androidxTestUiautomator = "2.3.0" androidxTracing = "1.2.0" cmake = "3.22.1" From 6026d5092c0b4626dad4386132cdd84f57187877 Mon Sep 17 00:00:00 2001 From: Trevor McGuire Date: Thu, 12 Sep 2024 15:48:54 -0700 Subject: [PATCH 2/7] Remove "JCA" file prefix check for video capture Video capture seems to be saving a 3gp file without the filename specified. Remove the "JCA" prefix check for now in order to ensure this file gets deleted in tests. --- .../java/com/google/jetpackcamera/ImageCaptureDeviceTest.kt | 3 ++- .../java/com/google/jetpackcamera/utils/UiTestUtil.kt | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/app/src/androidTest/java/com/google/jetpackcamera/ImageCaptureDeviceTest.kt b/app/src/androidTest/java/com/google/jetpackcamera/ImageCaptureDeviceTest.kt index 7c6b931db..7cbfbd4e2 100644 --- a/app/src/androidTest/java/com/google/jetpackcamera/ImageCaptureDeviceTest.kt +++ b/app/src/androidTest/java/com/google/jetpackcamera/ImageCaptureDeviceTest.kt @@ -64,7 +64,8 @@ internal class ImageCaptureDeviceTest { @Test fun image_capture() = runMediaStoreAutoDeleteScenarioTest( - mediaUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI + mediaUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI, + filePrefix = "JCA" ) { // Wait for the capture button to be displayed composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) { 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 436a8558c..7a2524470 100644 --- a/app/src/androidTest/java/com/google/jetpackcamera/utils/UiTestUtil.kt +++ b/app/src/androidTest/java/com/google/jetpackcamera/utils/UiTestUtil.kt @@ -60,7 +60,7 @@ const val VIDEO_DURATION_MILLIS = 2_000L @OptIn(ExperimentalCoroutinesApi::class) inline fun runMediaStoreAutoDeleteScenarioTest( mediaUri: Uri, - filePrefix: String = "JCA", + filePrefix: String = "", expectedNumFiles: Int = 1, fileWaitTimeoutMs: Duration = 10.seconds, crossinline block: ActivityScenario.() -> Unit From 704b13567a16fe4d9c91d295b7836b6f9b142dd9 Mon Sep 17 00:00:00 2001 From: Trevor McGuire Date: Mon, 16 Sep 2024 17:10:51 -0700 Subject: [PATCH 3/7] Remove unnecessary limitedParalellism and only assert for missing files when test succeeds --- .../google/jetpackcamera/utils/UiTestUtil.kt | 26 +++++++++++-------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/app/src/androidTest/java/com/google/jetpackcamera/utils/UiTestUtil.kt b/app/src/androidTest/java/com/google/jetpackcamera/utils/UiTestUtil.kt index 7a2524470..c5f973b6b 100644 --- a/app/src/androidTest/java/com/google/jetpackcamera/utils/UiTestUtil.kt +++ b/app/src/androidTest/java/com/google/jetpackcamera/utils/UiTestUtil.kt @@ -42,7 +42,6 @@ import kotlin.coroutines.CoroutineContext import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.delay @@ -56,19 +55,17 @@ const val APP_START_TIMEOUT_MILLIS = 10_000L const val IMAGE_CAPTURE_TIMEOUT_MILLIS = 5_000L const val VIDEO_CAPTURE_TIMEOUT_MILLIS = 5_000L const val VIDEO_DURATION_MILLIS = 2_000L - -@OptIn(ExperimentalCoroutinesApi::class) inline fun runMediaStoreAutoDeleteScenarioTest( mediaUri: Uri, filePrefix: String = "", expectedNumFiles: Int = 1, fileWaitTimeoutMs: Duration = 10.seconds, + fileObserverContext: CoroutineContext = Dispatchers.IO, crossinline block: ActivityScenario.() -> Unit ) = runBlocking { val debugTag = "MediaStoreAutoDelete" val instrumentation = InstrumentationRegistry.getInstrumentation() val insertedMediaStoreEntries = mutableMapOf() - val fileObserverContext: CoroutineContext = Dispatchers.IO.limitedParallelism(1) val observeFilesJob = launch(fileObserverContext) { mediaStoreInsertedFlow( mediaUri = mediaUri, @@ -81,15 +78,20 @@ inline fun runMediaStoreAutoDeleteScenarioTest( } } + var succeeded = false try { runScenarioTest(block = block) + succeeded = true } finally { withContext(NonCancellable) { - withTimeoutOrNull(fileWaitTimeoutMs) { - // Wait for normal completion with timeout - observeFilesJob.join() - } ?: run { - // If timed out, cancel file observer and ensure job is complete + if (!succeeded || + withTimeoutOrNull(fileWaitTimeoutMs) { + // Wait for normal completion with timeout + observeFilesJob.join() + } == null + ) { + // If the test didn't succeed, or we've timed out waiting for files, + // cancel file observer and ensure job is complete observeFilesJob.cancelAndJoin() } @@ -109,8 +111,10 @@ inline fun runMediaStoreAutoDeleteScenarioTest( } } - assertWithMessage("Expected number of saved files does not match detected number") - .that(detectedNumFiles).isEqualTo(expectedNumFiles) + if (succeeded) { + assertWithMessage("Expected number of saved files does not match detected number") + .that(detectedNumFiles).isEqualTo(expectedNumFiles) + } } } } From f5e6c63c3d64885636ad1cb728419c3c3af1157e Mon Sep 17 00:00:00 2001 From: Trevor McGuire Date: Mon, 16 Sep 2024 17:12:54 -0700 Subject: [PATCH 4/7] Make CaptureModeToggleButton more testable Adds test tag to CaptureModeToggleButton Adds state descriptions to ToggleButton Removes unnecessary coroutine scope from ToggleButton --- .../preview/ui/CameraControlsOverlay.kt | 22 +++++++---- .../preview/ui/PreviewScreenComponents.kt | 39 +++++++++++-------- .../feature/preview/ui/TestTags.kt | 1 + .../preview/src/main/res/values/strings.xml | 4 ++ 4 files changed, 41 insertions(+), 25 deletions(-) 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 3631b360b..91d817eb6 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 f5d8139b1..8382af48b 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 077a971f7..dcea4ec69 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 77d80e033..10b96b849 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 From 6a18f096095cf072f1fdc91c564cf6a45c30b01b Mon Sep 17 00:00:00 2001 From: Trevor McGuire Date: Mon, 16 Sep 2024 17:14:43 -0700 Subject: [PATCH 5/7] Add Concurrent Camera tests Adds tests to ensure Concurrent Camera can be enabled without crashing the app, and appropriate features are disabled when concurrent camera mode is enabled. Also ensures that recordings can be made in concurrent camera mode. --- .../jetpackcamera/ConcurrentCameraTest.kt | 368 ++++++++++++++++++ .../jetpackcamera/VideoRecordingDeviceTest.kt | 34 +- .../jetpackcamera/utils/ComposeTestRuleExt.kt | 77 ++++ .../google/jetpackcamera/utils/UiTestUtil.kt | 54 +-- 4 files changed, 455 insertions(+), 78 deletions(-) create mode 100644 app/src/androidTest/java/com/google/jetpackcamera/ConcurrentCameraTest.kt 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 000000000..b4b1556b4 --- /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 41a910e76..a537bb829 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 e3be80e95..b6e0388d7 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 c5f973b6b..bf5e8e4f0 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) +} From 269358d974e57e580fae633b3b86b4fcc2662a80 Mon Sep 17 00:00:00 2001 From: Trevor McGuire Date: Wed, 18 Sep 2024 10:22:19 -0700 Subject: [PATCH 6/7] Update VideoAudioTest to auto-delete saved videos This will run VideoAudioTest with `runMediaStoreAutoDeleteScenarioTest` to ensure that any recorded videos are deleted when the test finishes. It also changes the way we detect the hot mic. Instead of changing the test tag, we change the semantic state description when the mic indicates we're recording audio. This allows ComposeTest to wait for this state where before it was unable to detect test tag changes. We also will no longer use longClick since it does not seem to work well on emulators. We implicitly send the down and up events instead. This seems to allow us to run this test on emulators. --- .../google/jetpackcamera/VideoAudioTest.kt | 68 ++++++++++++------- .../jetpackcamera/utils/ComposeTestRuleExt.kt | 13 ++++ .../google/jetpackcamera/utils/UiTestUtil.kt | 1 + .../preview/ui/PreviewScreenComponents.kt | 19 +++--- .../feature/preview/ui/TestTags.kt | 3 +- .../preview/src/main/res/values/strings.xml | 4 +- 6 files changed, 73 insertions(+), 35 deletions(-) diff --git a/app/src/androidTest/java/com/google/jetpackcamera/VideoAudioTest.kt b/app/src/androidTest/java/com/google/jetpackcamera/VideoAudioTest.kt index 3af4d125f..9cfd6312b 100644 --- a/app/src/androidTest/java/com/google/jetpackcamera/VideoAudioTest.kt +++ b/app/src/androidTest/java/com/google/jetpackcamera/VideoAudioTest.kt @@ -15,31 +15,31 @@ */ package com.google.jetpackcamera +import android.provider.MediaStore import androidx.compose.ui.test.isDisplayed import androidx.compose.ui.test.junit4.createEmptyComposeRule -import androidx.compose.ui.test.longClick import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performTouchInput 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.feature.preview.ui.AMPLITUDE_HOT_TAG +import com.google.jetpackcamera.feature.preview.R import com.google.jetpackcamera.feature.preview.ui.CAPTURE_BUTTON +import com.google.jetpackcamera.feature.preview.ui.VIDEO_CAPTURE_SUCCESS_TAG import com.google.jetpackcamera.utils.APP_START_TIMEOUT_MILLIS import com.google.jetpackcamera.utils.TEST_REQUIRED_PERMISSIONS -import com.google.jetpackcamera.utils.runScenarioTest +import com.google.jetpackcamera.utils.VIDEO_CAPTURE_TIMEOUT_MILLIS +import com.google.jetpackcamera.utils.VIDEO_RECORDING_START_TIMEOUT_MILLIS +import com.google.jetpackcamera.utils.onNodeWithStateDescription +import com.google.jetpackcamera.utils.runMediaStoreAutoDeleteScenarioTest 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 = @@ -48,31 +48,51 @@ class VideoAudioTest { @get:Rule val composeTestRule = createEmptyComposeRule() - private val uiDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) - @Before fun setUp() { - assertThat(uiDevice.isScreenOn).isTrue() + with(UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())) { + assertThat(isScreenOn).isTrue() + } } @Test - fun audioIncomingWhenEnabled() { - runScenarioTest { - // check audio visualizer composable for muted/unmuted icon. - // icon will only be unmuted if audio is nonzero - composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) { - composeTestRule.onNodeWithTag(CAPTURE_BUTTON).isDisplayed() + fun audioIncomingWhenEnabled() = runMediaStoreAutoDeleteScenarioTest( + mediaUri = MediaStore.Video.Media.EXTERNAL_CONTENT_URI + ) { + // check audio visualizer composable for muted/unmuted icon. + // icon will only be unmuted if audio is nonzero + with(composeTestRule) { + waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) { + onNodeWithTag(CAPTURE_BUTTON).isDisplayed() } - // record video - composeTestRule.onNodeWithTag(CAPTURE_BUTTON) - .assertExists().performTouchInput { longClick(durationMillis = 5000) } + // start recording video + onNodeWithTag(CAPTURE_BUTTON) + .assertExists() + .performTouchInput { + down(center) + } - // assert hot amplitude tag visible - uiDevice.wait( - Until.findObject(By.res(AMPLITUDE_HOT_TAG)), - 5000 - ) + try { + // assert hot amplitude tag visible + waitUntil(timeoutMillis = VIDEO_RECORDING_START_TIMEOUT_MILLIS) { + onNodeWithStateDescription( + R.string.audio_visualizer_recording_state_description + ).isDisplayed() + } + } finally { + // finish recording video + onNodeWithTag(CAPTURE_BUTTON) + .assertExists() + .performTouchInput { + up() + } + + // Wait for recording to finish + waitUntil(timeoutMillis = VIDEO_CAPTURE_TIMEOUT_MILLIS) { + onNodeWithTag(VIDEO_CAPTURE_SUCCESS_TAG).isDisplayed() + } + } } } } 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 b6e0388d7..217310a0f 100644 --- a/app/src/androidTest/java/com/google/jetpackcamera/utils/ComposeTestRuleExt.kt +++ b/app/src/androidTest/java/com/google/jetpackcamera/utils/ComposeTestRuleExt.kt @@ -59,6 +59,19 @@ fun SemanticsNodeInteractionsProvider.onNodeWithContentDescription( label = getResString(strRes) ) +/** + * Allows searching for a node by [SemanticsProperties.StateDescription] using an integer string + * resource. + */ +fun SemanticsNodeInteractionsProvider.onNodeWithStateDescription( + @StringRes strRes: Int +): SemanticsNodeInteraction = onNode( + SemanticsMatcher.expectValue( + SemanticsProperties.StateDescription, + expectedValue = getResString(strRes) + ) +) + /** * Fetch a string resources from a [SemanticsNodeInteractionsProvider] context. */ 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 bf5e8e4f0..635243f2b 100644 --- a/app/src/androidTest/java/com/google/jetpackcamera/utils/UiTestUtil.kt +++ b/app/src/androidTest/java/com/google/jetpackcamera/utils/UiTestUtil.kt @@ -47,6 +47,7 @@ import kotlinx.coroutines.withTimeoutOrNull const val APP_START_TIMEOUT_MILLIS = 10_000L const val IMAGE_CAPTURE_TIMEOUT_MILLIS = 5_000L const val VIDEO_CAPTURE_TIMEOUT_MILLIS = 5_000L +const val VIDEO_RECORDING_START_TIMEOUT_MILLIS = 2_000L const val VIDEO_DURATION_MILLIS = 2_000L inline fun runMediaStoreAutoDeleteScenarioTest( mediaUri: Uri, 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 8382af48b..6245e8a98 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 @@ -151,16 +151,18 @@ fun AmplitudeVisualizer( } ) + val audioVisualizerStateDescription = if (audioAmplitude != 0.0) { + stringResource(id = R.string.audio_visualizer_recording_state_description) + } else { + stringResource(id = R.string.audio_visualizer_not_recording_state_description) + } Icon( modifier = Modifier .align(Alignment.Center) .size((0.5 * size).dp) - .apply { - if (audioAmplitude != 0.0) { - testTag(AMPLITUDE_HOT_TAG) - } else { - testTag(AMPLITUDE_NONE_TAG) - } + .testTag(AUDIO_VISUALIZER_TAG) + .semantics { + stateDescription = audioVisualizerStateDescription }, tint = Color.Black, imageVector = if (audioAmplitude != 0.0) { @@ -168,7 +170,7 @@ fun AmplitudeVisualizer( } else { Icons.Filled.MicOff }, - contentDescription = stringResource(id = R.string.audio_visualizer_icon) + contentDescription = stringResource(id = R.string.audio_visualizer_content_description) ) } } @@ -625,7 +627,8 @@ fun ToggleButton( onToggleWhenDisabled() } } - ).semantics { + ) + .semantics { stateDescription = when (toggleState) { ToggleState.Left -> leftIconDescription ToggleState.Right -> rightIconDescription 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 dcea4ec69..67173ec43 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 @@ -29,8 +29,7 @@ const val VIDEO_CAPTURE_FAILURE_TAG = "VideoCaptureFailureTag" const val PREVIEW_DISPLAY = "PreviewDisplay" const val SCREEN_FLASH_OVERLAY = "ScreenFlashOverlay" const val SETTINGS_BUTTON = "SettingsButton" -const val AMPLITUDE_NONE_TAG = "AmplitudeNoneTag" -const val AMPLITUDE_HOT_TAG = "AmplitudeHotTag" +const val AUDIO_VISUALIZER_TAG = "AudioVisualizerTag" const val HDR_IMAGE_UNSUPPORTED_ON_DEVICE_TAG = "HdrImageUnsupportedOnDeviceTag" const val HDR_IMAGE_UNSUPPORTED_ON_LENS_TAG = "HdrImageUnsupportedOnLensTag" const val HDR_IMAGE_UNSUPPORTED_ON_SINGLE_STREAM_TAG = "HdrImageUnsupportedOnSingleStreamTag" diff --git a/feature/preview/src/main/res/values/strings.xml b/feature/preview/src/main/res/values/strings.xml index 10b96b849..5bb603c85 100644 --- a/feature/preview/src/main/res/values/strings.xml +++ b/feature/preview/src/main/res/values/strings.xml @@ -23,7 +23,9 @@ Settings Flip Camera - An icon of a microphone + An icon of a microphone + Audio recording in progress + Audio recording not in progress %1$.2fx Physical ID: From 6e6b0dd4bed9f85f01a7a7dd023eccd451b7098d Mon Sep 17 00:00:00 2001 From: Trevor McGuire Date: Wed, 18 Sep 2024 10:59:04 -0700 Subject: [PATCH 7/7] Ensure recording is long enough to create successful recording --- .../java/com/google/jetpackcamera/VideoAudioTest.kt | 4 ++++ .../com/google/jetpackcamera/utils/ComposeTestRuleExt.kt | 8 +++----- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/app/src/androidTest/java/com/google/jetpackcamera/VideoAudioTest.kt b/app/src/androidTest/java/com/google/jetpackcamera/VideoAudioTest.kt index 9cfd6312b..2511d113f 100644 --- a/app/src/androidTest/java/com/google/jetpackcamera/VideoAudioTest.kt +++ b/app/src/androidTest/java/com/google/jetpackcamera/VideoAudioTest.kt @@ -32,6 +32,7 @@ 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.VIDEO_RECORDING_START_TIMEOUT_MILLIS +import com.google.jetpackcamera.utils.idleForVideoDuration import com.google.jetpackcamera.utils.onNodeWithStateDescription import com.google.jetpackcamera.utils.runMediaStoreAutoDeleteScenarioTest import org.junit.Before @@ -80,6 +81,9 @@ class VideoAudioTest { R.string.audio_visualizer_recording_state_description ).isDisplayed() } + + // Ensure we record long enough to create a successful recording + idleForVideoDuration() } finally { // finish recording video onNodeWithTag(CAPTURE_BUTTON) 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 217310a0f..d4588bb3f 100644 --- a/app/src/androidTest/java/com/google/jetpackcamera/utils/ComposeTestRuleExt.kt +++ b/app/src/androidTest/java/com/google/jetpackcamera/utils/ComposeTestRuleExt.kt @@ -124,13 +124,11 @@ fun ComposeTestRule.longClickForVideoRecording() { } } -private fun ComposeTestRule.idleForVideoDuration() { +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) { + waitUntil(timeoutMillis = VIDEO_DURATION_MILLIS, condition = { false }) + } catch (_: ComposeTimeoutException) { } }