Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import androidx.test.core.app.ApplicationProvider
import androidx.test.espresso.action.ViewActions.swipeDown
import com.google.common.truth.Truth.assertThat
import com.google.errorprone.annotations.CanIgnoreReturnValue
import com.google.jetpackcamera.core.common.ignoreResult
import com.google.jetpackcamera.model.CaptureMode
import com.google.jetpackcamera.model.ConcurrentCameraMode
import com.google.jetpackcamera.model.FlashMode
Expand Down Expand Up @@ -187,24 +188,6 @@ fun ComposeTestRule.waitForSnackbarWithText(
}
}

private fun ComposeTestRule.idleForVideoDuration(
durationMillis: Long = VIDEO_DURATION_MILLIS,
earlyExitPredicate: () -> Boolean = {
// If the video capture fails, there is no point to continue the recording, so stop idling
onNodeWithText(
com.google.jetpackcamera.ui.uistateadapter.capture.R.string.toast_video_capture_failure
).isDisplayed()
}
) {
// TODO: replace with a check for the timestamp UI of the video duration
try {
waitUntil(timeoutMillis = durationMillis) {
earlyExitPredicate()
}
} catch (_: ComposeTimeoutException) {
}
}

fun ComposeTestRule.ensureTagNotAppears(
componentTag: String,
timeoutMillis: Long = DEFAULT_TIMEOUT_MILLIS
Expand Down Expand Up @@ -248,13 +231,9 @@ private fun ComposeTestRule.waitUntilVideoRecordingDurationAtLeast(
) {
waitUntil(timeoutMillis = ELAPSED_TIME_TEXT_TIMEOUT_MILLIS) {
checkWhileWaiting()
val text = onNodeWithTag(ELAPSED_TIME_TAG)
.fetchSemanticsNode()
.config.getOrNull(SemanticsProperties.Text)
?.firstOrNull()?.text

val text = onAllNodesWithTag(ELAPSED_TIME_TAG).fetchSemanticsNodes()
.firstOrNull()?.config?.getOrNull(SemanticsProperties.Text)?.firstOrNull()?.text
val duration = text?.let { parseMinSecToMillis(it) }

duration != null && duration >= durationMillis
}
}
Expand Down Expand Up @@ -333,12 +312,12 @@ fun ComposeTestRule.longClickForVideoRecording(durationMillis: Long = VIDEO_DURA
}
}

fun ComposeTestRule.tapStartLockedVideoRecording() {
fun ComposeTestRule.tapStartLockedVideoRecording(durationMillis: Long = VIDEO_DURATION_MILLIS) {
assertThat(getCurrentCaptureMode()).isEqualTo(CaptureMode.VIDEO_ONLY)
onNodeWithTag(CAPTURE_BUTTON)
.assertExists()
.performClick()
idleForVideoDuration()
waitUntilVideoRecordingDurationAtLeast(durationMillis)
}

// //////////////////////
Expand Down Expand Up @@ -409,7 +388,7 @@ inline fun <reified T> ComposeTestRule.checkComponentContentDescriptionState(
waitForNodeWithTag(nodeTag)
onNodeWithTag(nodeTag).assume(isEnabled()) { "$nodeTag is not enabled" }
.fetchSemanticsNode().let { node ->
node.config[SemanticsProperties.ContentDescription].forEach { description ->
for (description in node.config[SemanticsProperties.ContentDescription]) {
val result = block(description)
if (result != null) return result
}
Expand Down Expand Up @@ -451,7 +430,7 @@ fun ComposeTestRule.getCurrentLensFacing(): LensFacing = visitQuickSettings {
onNodeWithTag(QUICK_SETTINGS_FLIP_CAMERA_BUTTON).fetchSemanticsNode(
"Flip camera button is not visible when expected."
).let { node ->
node.config[SemanticsProperties.ContentDescription].forEach { description ->
for (description in node.config[SemanticsProperties.ContentDescription]) {
when (description) {
getResString(CaptureR.string.quick_settings_front_camera_description) ->
return@let LensFacing.FRONT
Expand All @@ -468,7 +447,7 @@ fun ComposeTestRule.getCurrentFlashMode(): FlashMode = visitQuickSettings {
onNodeWithTag(QUICK_SETTINGS_FLASH_BUTTON).fetchSemanticsNode(
"Flash button is not visible when expected."
).let { node ->
node.config[SemanticsProperties.ContentDescription].forEach { description ->
for (description in node.config[SemanticsProperties.ContentDescription]) {
when (description) {
getResString(CaptureR.string.quick_settings_flash_off_description) ->
return@let FlashMode.OFF
Expand All @@ -494,7 +473,7 @@ fun ComposeTestRule.getCurrentCaptureMode(): CaptureMode = visitQuickSettings {
onNodeWithTag(BTN_QUICK_SETTINGS_FOCUS_CAPTURE_MODE).fetchSemanticsNode(
"Capture mode button is not visible when expected."
).let { node ->
node.config[SemanticsProperties.ContentDescription].forEach { description ->
for (description in node.config[SemanticsProperties.ContentDescription]) {
// check description is one of the capture modes
when (description) {
getResString(CaptureR.string.quick_settings_description_capture_mode_standard) ->
Expand Down Expand Up @@ -682,7 +661,7 @@ inline fun <T> ComposeTestRule.visitQuickSettings(
// It's visible, so perform the swipe down
bottomSheetNode.performTouchInput {
down(center)
swipeDown()
swipeDown().ignoreResult()
up()
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ fun mergeWithCompatExtras(extras: Bundle?): Bundle {
val debugExtra: Bundle = Bundle().apply { putBoolean("KEY_DEBUG_MODE", true) }
val cacheExtra: Bundle = Bundle().apply { putBoolean("KEY_REVIEW_AFTER_CAPTURE", true) }

const val DEFAULT_TIMEOUT_MILLIS = 5_000L
const val DEFAULT_TIMEOUT_MILLIS = 15_000L
const val APP_START_TIMEOUT_MILLIS = 20_000L
const val ELAPSED_TIME_TEXT_TIMEOUT_MILLIS = 45_000L
const val SCREEN_FLASH_OVERLAY_TIMEOUT_MILLIS = 5_000L
Expand All @@ -101,7 +101,7 @@ const val VIDEO_CAPTURE_TIMEOUT_MILLIS = 15_000L
const val SAVE_MEDIA_TIMEOUT_MILLIS = 15_000L
const val IMAGE_WELL_LOAD_TIMEOUT_MILLIS = 10_000L

const val VIDEO_DURATION_MILLIS = 3_000L
const val VIDEO_DURATION_MILLIS = 1_000L
const val MESSAGE_DISAPPEAR_TIMEOUT_MILLIS = 15_000L
const val FOCUS_METERING_INDICATOR_TIMEOUT_MILLIS = 10_000L
const val FILE_PREFIX = "JCA"
Expand All @@ -111,6 +111,7 @@ const val COMPONENT_PACKAGE_NAME = "com.google.jetpackcamera"
const val COMPONENT_CLASS = "com.google.jetpackcamera.MainActivity"
private const val TAG = "UiTestUtil"

@Suppress("ImmutableEnum")
internal enum class CacheParam(val extras: Bundle?) {
NO_CACHE(null),
WITH_CACHE(cacheExtra)
Expand Down Expand Up @@ -168,17 +169,17 @@ inline fun runMainActivityMediaStoreAutoDeleteScenarioTest(

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")
for (entry in insertedMediaStoreEntries) {
Log.d(debugTag, "Deleting media store file: $entry")
val deletedRows = instrumentation.targetContext.contentResolver.delete(
it.value,
entry.value,
null,
null
)
if (deletedRows > 0) {
Log.d(debugTag, "Deleted $deletedRows files")
} else {
Log.e(debugTag, "Failed to delete ${it.key}")
Log.e(debugTag, "Failed to delete ${entry.key}")
}
}

Expand Down Expand Up @@ -253,9 +254,9 @@ inline fun <reified T : Activity> runScenarioTestForResult(
crossinline block: ActivityScenario<T>.() -> Unit
): Instrumentation.ActivityResult {
activityExtras?.let { intent.putExtras(it) }
ActivityScenario.launchActivityForResult<T>(intent).use { scenario ->
return ActivityScenario.launchActivityForResult<T>(intent).use { scenario ->
scenario.apply(block)
return runBlocking { scenario.pollResult() }
runBlocking { scenario.pollResult() }
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import com.google.jetpackcamera.core.camera.postprocess.ImagePostProcessorFeatur
import com.google.jetpackcamera.core.camera.postprocess.PostProcessModule.Companion.provideImagePostProcessorMap
import com.google.jetpackcamera.core.camera.utils.APP_REQUIRED_PERMISSIONS
import com.google.jetpackcamera.core.camera.utils.provideUpdatingSurface
import com.google.jetpackcamera.core.common.ignoreResult
import com.google.jetpackcamera.core.common.testing.FakeFilePathGenerator
import com.google.jetpackcamera.model.CaptureMode
import com.google.jetpackcamera.model.DynamicRange
Expand Down Expand Up @@ -87,7 +88,7 @@ class CameraXCameraSystemTest {
GrantPermissionRule.grant(*(APP_REQUIRED_PERMISSIONS).toTypedArray())

private val instrumentation = InstrumentationRegistry.getInstrumentation()
private val context = instrumentation.context
private val context = instrumentation.targetContext
private val application = context.applicationContext as Application
private val filesToDelete = mutableSetOf<Uri>()
private lateinit var cameraSystemScope: CoroutineScope
Expand Down Expand Up @@ -136,7 +137,7 @@ class CameraXCameraSystemTest {
cameraSystem.startCameraAndWaitUntilRunning()

// Act.
cameraSystem.takePicture(context.contentResolver, SaveLocation.Default) {}
cameraSystem.takePicture(context.contentResolver, SaveLocation.Default) {}.ignoreResult()

// Assert.
assertThat(imagePostProcessor.postProcessImageCalled).isTrue()
Expand All @@ -155,7 +156,7 @@ class CameraXCameraSystemTest {
cameraSystem.takePicture(
context.contentResolver,
SaveLocation.Explicit(Uri.parse("asdfasdf"))
) {}
) {}.ignoreResult()
} catch (e: Exception) {}

// Assert.
Expand All @@ -173,7 +174,10 @@ class CameraXCameraSystemTest {

// Act.
try {
cameraSystem.takePicture(context.contentResolver, SaveLocation.Default) {}
cameraSystem.takePicture(
context.contentResolver,
SaveLocation.Default
) {}.ignoreResult()
} catch (e: RuntimeException) {
// Assert.
assertThat(imagePostProcessor.postProcessImageCalled).isTrue()
Expand All @@ -195,7 +199,7 @@ class CameraXCameraSystemTest {
cameraSystem.startCameraAndWaitUntilRunning()

// Act.
cameraSystem.takePicture(context.contentResolver, SaveLocation.Default) {}
cameraSystem.takePicture(context.contentResolver, SaveLocation.Default) {}.ignoreResult()

// Assert.
assertThat(imagePostProcessor.postProcessImageCalled).isFalse()
Expand Down Expand Up @@ -550,9 +554,9 @@ class CameraXCameraSystemTest {
assume().withMessage("Ultra HDR not supported on $lensFacing, skip the test.")
.that(
cameraConstraints != null &&
cameraConstraints.supportedImageFormatsMap[false]?.contains(
ImageOutputFormat.JPEG_ULTRA_HDR
) == true
cameraConstraints.supportedImageFormatsMap[
DEFAULT_CAMERA_APP_SETTINGS.streamConfig
]?.contains(ImageOutputFormat.JPEG_ULTRA_HDR) == true
).isTrue()

cameraSystem.setLensFacing(lensFacing)
Expand Down Expand Up @@ -645,9 +649,9 @@ class CameraXCameraSystemTest {
assume().withMessage("Ultra HDR not supported on $lensFacing, skip the test.")
.that(
cameraConstraints != null &&
cameraConstraints.supportedImageFormatsMap[false]?.contains(
ImageOutputFormat.JPEG_ULTRA_HDR
) == true
cameraConstraints.supportedImageFormatsMap[
DEFAULT_CAMERA_APP_SETTINGS.streamConfig
]?.contains(ImageOutputFormat.JPEG_ULTRA_HDR) == true
).isTrue()

// Configure the camera to use the target lens and enable Ultra HDR
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/*
* Copyright (C) 2026 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.jetpackcamera.core.common

/**
* Explicitly ignores return results, particularly handy to suppress ErrorProne warnings
* on @CheckResult methods.
*/
fun <T> T.ignoreResult() = Unit
Original file line number Diff line number Diff line change
Expand Up @@ -110,8 +110,9 @@ class PreviewViewModel @Inject constructor(
val surfaceRequest: StateFlow<SurfaceRequest?> =
cameraSystemRepository.cameraSystem.getSurfaceRequest()

private val _captureEvents = Channel<CaptureEvent>()
val captureEvents: ReceiveChannel<CaptureEvent> = _captureEvents
private val outgoingCaptureEvents = Channel<CaptureEvent>(capacity = Channel.UNLIMITED)
val captureEvents: ReceiveChannel<CaptureEvent> = outgoingCaptureEvents
private val incomingCaptureEvents = Channel<CaptureEvent>(capacity = Channel.UNLIMITED)

private val externalCaptureMode: ExternalCaptureMode = savedStateHandle.getExternalCaptureMode()
private val externalUris: List<Uri> = savedStateHandle.getCaptureUris()
Expand Down Expand Up @@ -223,7 +224,7 @@ class PreviewViewModel @Inject constructor(
Pair(SaveLocation.Default, null)
}
},
captureEvents = _captureEvents,
captureEvents = incomingCaptureEvents,
imageWellController = imageWellController,
coroutineContext = viewModelScope.coroutineContext
)
Expand Down Expand Up @@ -271,7 +272,7 @@ class PreviewViewModel @Inject constructor(
launch {
for (event in captureController.captureEvents) {
showSnackbarForCaptureEvent(event)
_captureEvents.send(event)
outgoingCaptureEvents.send(event)
}
}
}
Expand Down
Loading