Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -488,13 +467,13 @@ fun ComposeTestRule.getCurrentFlashMode(): FlashMode = visitQuickSettings {
}

fun ComposeTestRule.getCurrentCaptureMode(): CaptureMode = visitQuickSettings {
waitUntil(timeoutMillis = 1000) {
waitUntil(timeoutMillis = DEFAULT_TIMEOUT_MILLIS) {
onNodeWithTag(BTN_QUICK_SETTINGS_FOCUS_CAPTURE_MODE).isDisplayed()
}
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 Expand Up @@ -775,15 +754,15 @@ fun ComposeTestRule.setHdrEnabled(enabled: Boolean) {
.assume(isEnabled()) { "Device does not support HDR." }
.performClick()
}
waitUntil(1000) { isHdrEnabled() == enabled }
waitUntil(DEFAULT_TIMEOUT_MILLIS) { isHdrEnabled() == enabled }
}
}

fun ComposeTestRule.setCaptureMode(captureMode: CaptureMode) {
visitQuickSettings {
searchForQuickSetting(BTN_QUICK_SETTINGS_FOCUS_CAPTURE_MODE)

waitUntil(timeoutMillis = 1000) {
waitUntil(timeoutMillis = DEFAULT_TIMEOUT_MILLIS) {
onNodeWithTag(BTN_QUICK_SETTINGS_FOCUS_CAPTURE_MODE).isDisplayed()
}
// check that current capture mode != given capture mode
Expand All @@ -798,7 +777,7 @@ fun ComposeTestRule.setCaptureMode(captureMode: CaptureMode) {
.assume(isEnabled())
.performClick()

waitUntil(timeoutMillis = 1_000) {
waitUntil(timeoutMillis = DEFAULT_TIMEOUT_MILLIS) {
onNodeWithTag(optionButtonTag).isDisplayed()
}

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