diff --git a/core/camera/build.gradle.kts b/core/camera/build.gradle.kts index 0fe482a3b..b03977297 100644 --- a/core/camera/build.gradle.kts +++ b/core/camera/build.gradle.kts @@ -126,6 +126,7 @@ dependencies { // CameraX implementation(libs.camera.core) implementation(libs.camera.camera2) + implementation(libs.camera.viewfinder.core) implementation(libs.camera.lifecycle) implementation(libs.camera.video) diff --git a/core/camera/src/androidTest/java/com/google/jetpackcamera/core/camera/CameraXCameraSystemTest.kt b/core/camera/src/androidTest/java/com/google/jetpackcamera/core/camera/CameraXCameraSystemTest.kt index e90320509..a523c0fca 100644 --- a/core/camera/src/androidTest/java/com/google/jetpackcamera/core/camera/CameraXCameraSystemTest.kt +++ b/core/camera/src/androidTest/java/com/google/jetpackcamera/core/camera/CameraXCameraSystemTest.kt @@ -409,7 +409,9 @@ class CameraXCameraSystemTest { private fun CameraXCameraSystem.providePreviewSurface() { cameraSystemScope.launch { getSurfaceRequest().filterNotNull().collect { - it.provideUpdatingSurface() + if (it is PreviewSurfaceRequest.CameraX) { + it.surfaceRequest.provideUpdatingSurface() + } } } } diff --git a/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraSession.kt b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraSession.kt index 2e23459c4..ac16b2477 100644 --- a/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraSession.kt +++ b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraSession.kt @@ -773,7 +773,7 @@ private fun createPreviewUseCase( }.build() .apply { setSurfaceProvider { surfaceRequest -> - surfaceRequests.update { surfaceRequest } + surfaceRequests.update { PreviewSurfaceRequest.CameraX(surfaceRequest) } } } diff --git a/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraSessionContext.kt b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraSessionContext.kt index bb9aefc42..2c7225a64 100644 --- a/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraSessionContext.kt +++ b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraSessionContext.kt @@ -16,7 +16,6 @@ package com.google.jetpackcamera.core.camera import android.content.Context -import androidx.camera.core.SurfaceRequest import androidx.camera.lifecycle.ProcessCameraProvider import com.google.jetpackcamera.core.camera.lowlight.LowLightBoostEffectProvider import com.google.jetpackcamera.core.common.FilePathGenerator @@ -41,7 +40,7 @@ internal data class CameraSessionContext( val focusMeteringEvents: Channel, val videoCaptureControlEvents: Channel, val currentCameraState: MutableStateFlow, - val surfaceRequests: MutableStateFlow, + val surfaceRequests: MutableStateFlow, val transientSettings: StateFlow, val lowLightBoostEffectProvider: LowLightBoostEffectProvider? = null ) diff --git a/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraSystem.kt b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraSystem.kt index b6821300e..6e10b6982 100644 --- a/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraSystem.kt +++ b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraSystem.kt @@ -91,7 +91,12 @@ interface CameraSystem { fun getCurrentCameraState(): StateFlow - fun getSurfaceRequest(): StateFlow + /** + * Returns the current [PreviewSurfaceRequest]. + * + * This will be null if no surface is currently requested. + */ + fun getSurfaceRequest(): StateFlow fun getScreenFlashEvents(): ReceiveChannel diff --git a/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraXCameraSystem.kt b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraXCameraSystem.kt index ca4f4f0c5..67418a097 100644 --- a/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraXCameraSystem.kt +++ b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraXCameraSystem.kt @@ -31,7 +31,6 @@ import androidx.camera.core.CameraXConfig import androidx.camera.core.DynamicRange as CXDynamicRange import androidx.camera.core.ImageCapture import androidx.camera.core.ImageCapture.OutputFileOptions -import androidx.camera.core.SurfaceRequest import androidx.camera.core.takePicture import androidx.camera.lifecycle.ExperimentalCameraProviderConfiguration import androidx.camera.lifecycle.ProcessCameraProvider @@ -135,9 +134,10 @@ constructor( private var currentCameraState = MutableStateFlow(CameraState()) override fun getCurrentCameraState(): StateFlow = currentCameraState.asStateFlow() - private val _surfaceRequest = MutableStateFlow(null) + private val _surfaceRequest = MutableStateFlow(null) - override fun getSurfaceRequest(): StateFlow = _surfaceRequest.asStateFlow() + override fun getSurfaceRequest(): StateFlow = + _surfaceRequest.asStateFlow() private val lowLightBoostAvailabilityChecker: LowLightBoostAvailabilityChecker? private val lowLightBoostEffectProvider: LowLightBoostEffectProvider? diff --git a/core/camera/src/main/java/com/google/jetpackcamera/core/camera/FocusMetering.kt b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/FocusMetering.kt index 9253b8609..16ced2ec6 100644 --- a/core/camera/src/main/java/com/google/jetpackcamera/core/camera/FocusMetering.kt +++ b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/FocusMetering.kt @@ -44,7 +44,9 @@ internal suspend fun CameraSessionContext.processFocusMeteringEvents( cameraInfo: CameraInfo, cameraControl: CameraControl ) { - surfaceRequests.flatMapLatest { surfaceRequest -> + surfaceRequests.flatMapLatest { previewSurfaceRequest -> + val surfaceRequest = + (previewSurfaceRequest as? PreviewSurfaceRequest.CameraX)?.surfaceRequest surfaceRequest?.let { request -> Log.d( TAG, diff --git a/core/camera/src/main/java/com/google/jetpackcamera/core/camera/PreviewSurfaceRequest.kt b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/PreviewSurfaceRequest.kt new file mode 100644 index 000000000..7d510f67c --- /dev/null +++ b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/PreviewSurfaceRequest.kt @@ -0,0 +1,46 @@ +/* + * 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.camera + +import android.view.Surface +import androidx.camera.core.SurfaceRequest +import androidx.camera.viewfinder.core.ViewfinderSurfaceRequest +import kotlinx.coroutines.CompletableDeferred + +/** + * A sealed interface representing a request for a preview surface, abstracting the specific + * viewfinder implementation required. + */ +sealed interface PreviewSurfaceRequest { + /** + * Wraps a CameraX [SurfaceRequest] for the production CameraXViewfinder. + * + * @property surfaceRequest The CameraX [SurfaceRequest]. + */ + data class CameraX(val surfaceRequest: SurfaceRequest) : PreviewSurfaceRequest + + /** + * Wraps a [ViewfinderSurfaceRequest] for the standalone Viewfinder composable. + * + * @property surfaceRequest The standalone [ViewfinderSurfaceRequest]. + * @property surfaceDeferred A [CompletableDeferred] that will be completed with the [Surface] + * once provided by the UI. + */ + data class Viewfinder( + val surfaceRequest: ViewfinderSurfaceRequest, + val surfaceDeferred: CompletableDeferred = CompletableDeferred() + ) : PreviewSurfaceRequest +} diff --git a/core/camera/testing/build.gradle.kts b/core/camera/testing/build.gradle.kts index 80153a553..10ae51ce8 100644 --- a/core/camera/testing/build.gradle.kts +++ b/core/camera/testing/build.gradle.kts @@ -52,10 +52,10 @@ dependencies { implementation(project(":core:camera")) implementation(project(":core:model")) implementation(project(":data:settings")) - + implementation(libs.camera.core) + implementation(libs.camera.viewfinder.core) implementation(libs.kotlinx.coroutines.core) - // Testing testImplementation(libs.junit) testImplementation(libs.truth) diff --git a/core/camera/testing/src/main/java/com/google/jetpackcamera/core/camera/testing/FakeCameraSystem.kt b/core/camera/testing/src/main/java/com/google/jetpackcamera/core/camera/testing/FakeCameraSystem.kt index 5b19520ce..1afd392c8 100644 --- a/core/camera/testing/src/main/java/com/google/jetpackcamera/core/camera/testing/FakeCameraSystem.kt +++ b/core/camera/testing/src/main/java/com/google/jetpackcamera/core/camera/testing/FakeCameraSystem.kt @@ -17,11 +17,14 @@ package com.google.jetpackcamera.core.camera.testing import android.annotation.SuppressLint import android.content.ContentResolver +import android.view.Surface import androidx.camera.core.ImageCapture -import androidx.camera.core.SurfaceRequest +import androidx.camera.viewfinder.core.ImplementationMode +import androidx.camera.viewfinder.core.ViewfinderSurfaceRequest import com.google.jetpackcamera.core.camera.CameraState import com.google.jetpackcamera.core.camera.CameraSystem import com.google.jetpackcamera.core.camera.OnVideoRecordEvent +import com.google.jetpackcamera.core.camera.PreviewSurfaceRequest import com.google.jetpackcamera.model.AspectRatio import com.google.jetpackcamera.model.CameraZoomRatio import com.google.jetpackcamera.model.CaptureMode @@ -38,6 +41,7 @@ import com.google.jetpackcamera.model.StreamConfig import com.google.jetpackcamera.model.TestPattern import com.google.jetpackcamera.model.VideoQuality import com.google.jetpackcamera.settings.model.CameraAppSettings +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED import kotlinx.coroutines.flow.MutableStateFlow @@ -53,12 +57,19 @@ class FakeCameraSystem(defaultCameraSettings: CameraAppSettings = CameraAppSetti private var initialized = false private var useCasesBinded = false + /** Whether the preview has started. */ var previewStarted = false + + /** Number of pictures taken. */ var numPicturesTaken = 0 + /** Whether a recording is in progress. */ var recordingInProgress = false + + /** Whether the current recording is paused. */ var isRecordingPaused = false + /** Whether the lens facing is front. */ var isLensFacingFront = false private var isScreenFlash = true @@ -85,9 +96,11 @@ class FakeCameraSystem(defaultCameraSettings: CameraAppSettings = CameraAppSetti currentSettings .onCompletion { + _surfaceRequest.value = null useCasesBinded = false previewStarted = false recordingInProgress = false + _currentCameraState.update { CameraState() } }.collectLatest { useCasesBinded = true previewStarted = true @@ -96,6 +109,22 @@ class FakeCameraSystem(defaultCameraSettings: CameraAppSettings = CameraAppSetti isScreenFlash = isLensFacingFront && (it.flashMode == FlashMode.AUTO || it.flashMode == FlashMode.ON) + + val aspectRatio = it.aspectRatio + val request = ViewfinderSurfaceRequest( + 1080 * aspectRatio.denominator / aspectRatio.numerator, + 1080, + ImplementationMode.EXTERNAL + ) + val deferred = CompletableDeferred() + _currentCameraState.update { state -> + state.copy(isCameraRunning = false) + } + _surfaceRequest.value = PreviewSurfaceRequest.Viewfinder(request, deferred) + deferred.await() + _currentCameraState.update { state -> + state.copy(isCameraRunning = true) + } } } @@ -163,8 +192,9 @@ class FakeCameraSystem(defaultCameraSettings: CameraAppSettings = CameraAppSetti override fun getCurrentCameraState(): StateFlow = _currentCameraState.asStateFlow() - private val _surfaceRequest = MutableStateFlow(null) - override fun getSurfaceRequest(): StateFlow = _surfaceRequest.asStateFlow() + private val _surfaceRequest = MutableStateFlow(null) + override fun getSurfaceRequest(): StateFlow = + _surfaceRequest.asStateFlow() override fun getScreenFlashEvents() = screenFlashEvents override fun getCurrentSettings(): StateFlow = currentSettings.asStateFlow() diff --git a/core/camera/testing/src/test/java/com/google/jetpackcamera/core/camera/testing/FakeCameraSystemTest.kt b/core/camera/testing/src/test/java/com/google/jetpackcamera/core/camera/testing/FakeCameraSystemTest.kt index 9dfd6955b..39df8d487 100644 --- a/core/camera/testing/src/test/java/com/google/jetpackcamera/core/camera/testing/FakeCameraSystemTest.kt +++ b/core/camera/testing/src/test/java/com/google/jetpackcamera/core/camera/testing/FakeCameraSystemTest.kt @@ -15,14 +15,19 @@ */ package com.google.jetpackcamera.core.camera.testing -import com.google.common.truth.Truth +import android.graphics.SurfaceTexture +import android.view.Surface +import com.google.common.truth.Truth.assertThat import com.google.jetpackcamera.core.camera.CameraSystem +import com.google.jetpackcamera.core.camera.PreviewSurfaceRequest import com.google.jetpackcamera.model.FlashMode import com.google.jetpackcamera.model.LensFacing import com.google.jetpackcamera.settings.model.DEFAULT_CAMERA_APP_SETTINGS import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.consumeAsFlow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.toList import kotlinx.coroutines.launch import kotlinx.coroutines.test.StandardTestDispatcher @@ -36,10 +41,10 @@ import org.junit.After import org.junit.Before import org.junit.Test import org.junit.runner.RunWith -import org.junit.runners.JUnit4 +import org.robolectric.RobolectricTestRunner @OptIn(ExperimentalCoroutinesApi::class) -@RunWith(JUnit4::class) +@RunWith(RobolectricTestRunner::class) class FakeCameraSystemTest { private val testScope = TestScope() private val testDispatcher = StandardTestDispatcher(testScope.testScheduler) @@ -47,7 +52,7 @@ class FakeCameraSystemTest { private val cameraSystem = FakeCameraSystem() @Before - fun setup() { + fun setUp() { Dispatchers.setMain(testDispatcher) } @@ -56,50 +61,71 @@ class FakeCameraSystemTest { Dispatchers.resetMain() } - @Test - fun canInitialize() = runTest(testDispatcher) { - cameraSystem.initialize( - cameraAppSettings = DEFAULT_CAMERA_APP_SETTINGS - ) {} - } - @Test fun canRunCamera() = runTest(testDispatcher) { - initAndRunCamera() - Truth.assertThat(cameraSystem.isPreviewStarted()).isTrue() + cameraSystem.initialize(DEFAULT_CAMERA_APP_SETTINGS) {} + val job = backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + cameraSystem.runCamera() + } + advanceUntilIdle() + + // Fulfill surface request to let runCamera continue + val request = cameraSystem.getSurfaceRequest().filterNotNull().first() + (request as PreviewSurfaceRequest.Viewfinder).surfaceDeferred.complete( + Surface(SurfaceTexture(1)) + ) + + advanceUntilIdle() + assertThat(cameraSystem.isPreviewStarted()).isTrue() + job.cancel() } @Test - fun screenFlashDisabled_whenFlashModeOffAndFrontCamera() = runTest(testDispatcher) { - initAndRunCamera() + fun surfaceRequest_emitsAndViewfinderFulfills() = runTest(testDispatcher) { + cameraSystem.initialize(DEFAULT_CAMERA_APP_SETTINGS) {} + val job = backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + cameraSystem.runCamera() + } + advanceUntilIdle() + + val request = cameraSystem.getSurfaceRequest().filterNotNull().first() + assertThat(request).isInstanceOf(PreviewSurfaceRequest.Viewfinder::class.java) + val viewfinderRequest = request as PreviewSurfaceRequest.Viewfinder + + // Camera should not be running yet + assertThat(cameraSystem.getCurrentCameraState().value.isCameraRunning).isFalse() + + // Provide surface + val surface = Surface(SurfaceTexture(1)) + viewfinderRequest.surfaceDeferred.complete(surface) - cameraSystem.setLensFacing(lensFacing = LensFacing.FRONT) - cameraSystem.setFlashMode(flashMode = FlashMode.OFF) advanceUntilIdle() - Truth.assertThat(cameraSystem.isScreenFlashEnabled()).isFalse() + // Now camera should be running + assertThat(cameraSystem.getCurrentCameraState().value.isCameraRunning).isTrue() + job.cancel() } @Test - fun screenFlashDisabled_whenFlashModeOnAndNotFrontCamera() = runTest(testDispatcher) { + fun canSetLensFacing() = runTest(testDispatcher) { initAndRunCamera() - cameraSystem.setLensFacing(lensFacing = LensFacing.BACK) - cameraSystem.setFlashMode(flashMode = FlashMode.ON) + cameraSystem.setLensFacing(lensFacing = LensFacing.FRONT) advanceUntilIdle() - Truth.assertThat(cameraSystem.isScreenFlashEnabled()).isFalse() + assertThat(cameraSystem.getCurrentSettings().value?.cameraLensFacing) + .isEqualTo(LensFacing.FRONT) } @Test - fun screenFlashDisabled_whenFlashModeAutoAndNotFrontCamera() = runTest(testDispatcher) { + fun canSetFlashMode() = runTest(testDispatcher) { initAndRunCamera() - cameraSystem.setLensFacing(lensFacing = LensFacing.BACK) - cameraSystem.setFlashMode(flashMode = FlashMode.AUTO) + cameraSystem.setFlashMode(flashMode = FlashMode.ON) advanceUntilIdle() - Truth.assertThat(cameraSystem.isScreenFlashEnabled()).isFalse() + assertThat(cameraSystem.getCurrentSettings().value?.flashMode) + .isEqualTo(FlashMode.ON) } @Test @@ -110,51 +136,51 @@ class FakeCameraSystemTest { cameraSystem.setFlashMode(flashMode = FlashMode.ON) advanceUntilIdle() - Truth.assertThat(cameraSystem.isScreenFlashEnabled()).isTrue() + val events = mutableListOf() + val job = backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + cameraSystem.getScreenFlashEvents().consumeAsFlow().toList(events) + } + + cameraSystem.takePicture {} + advanceUntilIdle() + + assertThat(events.map { it.type }).contains(CameraSystem.ScreenFlashEvent.Type.APPLY_UI) + job.cancel() } @Test - fun screenFlashEnabled_whenFlashModeAutoAndFrontCamera() = runTest(testDispatcher) { + fun screenFlashDisabled_whenFlashModeOffAndFrontCamera() = runTest(testDispatcher) { initAndRunCamera() cameraSystem.setLensFacing(lensFacing = LensFacing.FRONT) - cameraSystem.setFlashMode(flashMode = FlashMode.AUTO) + cameraSystem.setFlashMode(flashMode = FlashMode.OFF) advanceUntilIdle() - Truth.assertThat(cameraSystem.isScreenFlashEnabled()).isTrue() - } - - @Test - fun captureScreenFlashImage_screenFlashEventsEmittedInCorrectSequence() = runTest( - testDispatcher - ) { - initAndRunCamera() val events = mutableListOf() - backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + val job = backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { cameraSystem.getScreenFlashEvents().consumeAsFlow().toList(events) } - // FlashMode.ON in front facing camera automatically enables screen flash - cameraSystem.setLensFacing(lensFacing = LensFacing.FRONT) - cameraSystem.setFlashMode(FlashMode.ON) + cameraSystem.takePicture {} advanceUntilIdle() - cameraSystem.takePicture() - advanceUntilIdle() - Truth.assertThat(events.map { it.type }).containsExactlyElementsIn( - listOf( - CameraSystem.ScreenFlashEvent.Type.APPLY_UI, - CameraSystem.ScreenFlashEvent.Type.CLEAR_UI - ) - ).inOrder() + assertThat(events.map { it.type }) + .doesNotContain(CameraSystem.ScreenFlashEvent.Type.APPLY_UI) + job.cancel() } - private fun TestScope.initAndRunCamera() { + private suspend fun TestScope.initAndRunCamera() { + cameraSystem.initialize( + cameraAppSettings = DEFAULT_CAMERA_APP_SETTINGS + ) {} backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { - cameraSystem.initialize( - cameraAppSettings = DEFAULT_CAMERA_APP_SETTINGS - ) {} cameraSystem.runCamera() } + advanceUntilIdle() + val request = cameraSystem.getSurfaceRequest().filterNotNull().first() + (request as PreviewSurfaceRequest.Viewfinder).surfaceDeferred.complete( + Surface(SurfaceTexture(1)) + ) + advanceUntilIdle() } } diff --git a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt index 947873c5b..3ad5ad23a 100644 --- a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt +++ b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt @@ -19,7 +19,6 @@ import android.Manifest import android.os.Build import android.util.Log import android.util.Range -import androidx.camera.core.SurfaceRequest import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn @@ -142,9 +141,6 @@ fun PreviewScreen( val screenFlashUiState: ScreenFlashUiState by viewModel.screenFlash.screenFlashUiState.collectAsState() - val surfaceRequest: SurfaceRequest? - by viewModel.surfaceRequest.collectAsState() - LifecycleStartEffect(Unit) { viewModel.cameraController.startCamera() onStopOrDispose { @@ -275,7 +271,6 @@ fun PreviewScreen( modifier = modifier, captureUiState = currentUiState, screenFlashUiState = screenFlashUiState, - surfaceRequest = surfaceRequest, onNavigateToSettings = onNavigateToSettings, onClearUiScreenBrightness = viewModel.screenFlash::setClearUiScreenBrightness, onAbsoluteZoom = { zoomRatio: Float, lensToZoom: LensToZoom -> @@ -341,7 +336,6 @@ fun PreviewScreen( private fun ContentScreen( captureUiState: CaptureUiState.Ready, screenFlashUiState: ScreenFlashUiState, - surfaceRequest: SurfaceRequest?, modifier: Modifier = Modifier, onNavigateToSettings: () -> Unit = {}, onClearUiScreenBrightness: (Float) -> Unit = {}, @@ -408,8 +402,8 @@ private fun ContentScreen( onFlipCamera = onFlipCamera, onTapToFocus = cameraController?.let { it::tapToFocus } ?: { _, _ -> }, onScaleZoom = { onScaleZoom(it, LensToZoom.PRIMARY) }, - surfaceRequest = surfaceRequest, onRequestWindowColorMode = onRequestWindowColorMode, + surfaceRequest = captureUiState.previewDisplayUiState.surfaceRequest, focusMeteringUiState = captureUiState.focusMeteringUiState ) }, @@ -687,8 +681,7 @@ private fun ContentScreenPreview() { MaterialTheme { ContentScreen( captureUiState = FAKE_PREVIEW_UI_STATE_READY, - screenFlashUiState = ScreenFlashUiState(), - surfaceRequest = null + screenFlashUiState = ScreenFlashUiState() ) } } @@ -699,8 +692,7 @@ private fun ContentScreen_Standard_Idle() { MaterialTheme(colorScheme = darkColorScheme()) { ContentScreen( captureUiState = FAKE_PREVIEW_UI_STATE_READY.copy(), - screenFlashUiState = ScreenFlashUiState(), - surfaceRequest = null + screenFlashUiState = ScreenFlashUiState() ) } } @@ -713,8 +705,7 @@ private fun ContentScreen_ImageOnly_Idle() { captureUiState = FAKE_PREVIEW_UI_STATE_READY.copy( captureButtonUiState = CaptureButtonUiState.Enabled.Idle(CaptureMode.IMAGE_ONLY) ), - screenFlashUiState = ScreenFlashUiState(), - surfaceRequest = null + screenFlashUiState = ScreenFlashUiState() ) } } @@ -727,8 +718,7 @@ private fun ContentScreen_VideoOnly_Idle() { captureUiState = FAKE_PREVIEW_UI_STATE_READY.copy( captureButtonUiState = CaptureButtonUiState.Enabled.Idle(CaptureMode.VIDEO_ONLY) ), - screenFlashUiState = ScreenFlashUiState(), - surfaceRequest = null + screenFlashUiState = ScreenFlashUiState() ) } } @@ -739,8 +729,7 @@ private fun ContentScreen_Standard_Recording() { MaterialTheme(colorScheme = darkColorScheme()) { ContentScreen( captureUiState = FAKE_PREVIEW_UI_STATE_PRESSED_RECORDING, - screenFlashUiState = ScreenFlashUiState(), - surfaceRequest = null + screenFlashUiState = ScreenFlashUiState() ) } } @@ -751,8 +740,7 @@ private fun ContentScreen_Locked_Recording() { MaterialTheme(colorScheme = darkColorScheme()) { ContentScreen( captureUiState = FAKE_PREVIEW_UI_STATE_LOCKED_RECORDING, - screenFlashUiState = ScreenFlashUiState(), - surfaceRequest = null + screenFlashUiState = ScreenFlashUiState() ) } } diff --git a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewViewModel.kt b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewViewModel.kt index 3cf0b7004..4b27217f5 100644 --- a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewViewModel.kt +++ b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewViewModel.kt @@ -17,7 +17,6 @@ package com.google.jetpackcamera.feature.preview import android.net.Uri import android.util.Log -import androidx.camera.core.SurfaceRequest import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -103,8 +102,6 @@ class PreviewViewModel @Inject constructor( val snackBarUiState: StateFlow = _snackBarUiState.asStateFlow() - val surfaceRequest: StateFlow = cameraSystem.getSurfaceRequest() - private val _captureEvents = Channel() val captureEvents: ReceiveChannel = _captureEvents diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9e76a6caf..78264c490 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -31,8 +31,8 @@ androidxLifecycle = "2.9.2" androidxMedia3 = "1.9.0-alpha01" androidxNavigationCompose = "2.9.2" androidxProfileinstaller = "1.4.1" -androidxTestCore = "1.5.0" -androidxTestEspresso = "3.6.1" +androidxTestCore = "1.6.1" +androidxTestEspresso = "3.7.0" androidxTestJunit = "1.2.1" androidxTestMonitor = "1.7.2" androidxTestRules = "1.6.1" @@ -81,9 +81,11 @@ androidx-tracing = { module = "androidx.tracing:tracing-ktx", version.ref = "and androidx-uiautomator = { module = "androidx.test.uiautomator:uiautomator", version.ref = "androidxTestUiautomator" } camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "androidxCamera" } camera-core = { module = "androidx.camera:camera-core", version.ref = "androidxCamera" } +camera-compose = { module = "androidx.camera:camera-compose", version.ref = "androidxCamera" } camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "androidxCamera" } camera-video = { module = "androidx.camera:camera-video", version.ref = "androidxCamera" } -camera-compose = { module = "androidx.camera:camera-compose", version.ref = "androidxCamera" } +camera-viewfinder-core = { module = "androidx.camera.viewfinder:viewfinder-core", version.ref = "androidxCamera" } +camera-viewfinder-compose = { module = "androidx.camera.viewfinder:viewfinder-compose", version.ref = "androidxCamera" } compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" } compose-junit = { module = "androidx.compose.ui:ui-test-junit4" } compose-material3 = { module = "androidx.compose.material3:material3", version.ref = "composeMaterial" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 89bd10d46..34e816d8e 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -62,3 +62,4 @@ include(":ui:uistateadapter:postcapture") include(":core:camera:postprocess") include(":ui:controller") include(":ui:controller:impl") +include(":ui:controller:testing") diff --git a/ui/components/capture/build.gradle.kts b/ui/components/capture/build.gradle.kts index 70395892c..bb3332087 100644 --- a/ui/components/capture/build.gradle.kts +++ b/ui/components/capture/build.gradle.kts @@ -80,6 +80,8 @@ dependencies { // CameraX implementation(libs.camera.core) implementation(libs.camera.compose) + implementation(libs.camera.viewfinder.core) + implementation(libs.camera.viewfinder.compose) // Compose - Testing androidTestImplementation(libs.compose.junit) diff --git a/ui/components/capture/src/androidTest/java/com/google/jetpackcamera/ui/components/capture/PreviewDisplayTest.kt b/ui/components/capture/src/androidTest/java/com/google/jetpackcamera/ui/components/capture/PreviewDisplayTest.kt new file mode 100644 index 000000000..68917e63a --- /dev/null +++ b/ui/components/capture/src/androidTest/java/com/google/jetpackcamera/ui/components/capture/PreviewDisplayTest.kt @@ -0,0 +1,77 @@ +/* + * 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.ui.components.capture + +import androidx.camera.viewfinder.core.ImplementationMode +import androidx.camera.viewfinder.core.ViewfinderSurfaceRequest +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.jetpackcamera.core.camera.PreviewSurfaceRequest +import com.google.jetpackcamera.model.AspectRatio +import com.google.jetpackcamera.ui.uistate.SingleSelectableUiState +import com.google.jetpackcamera.ui.uistate.capture.AspectRatioUiState +import com.google.jetpackcamera.ui.uistate.capture.FocusMeteringUiState +import com.google.jetpackcamera.ui.uistate.capture.compound.PreviewDisplayUiState +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Instrumented tests for the [PreviewDisplay] composable. + */ +@RunWith(AndroidJUnit4::class) +class PreviewDisplayTest { + @get:Rule + val composeTestRule = createComposeRule() + + /** + * Verifies that the [PreviewDisplay] composable correctly renders when provided with a + * [PreviewSurfaceRequest.Viewfinder] and fulfills the surface request. + */ + @Test + fun previewDisplay_withViewfinderRequest_rendersWithoutError() { + val viewfinderRequest = ViewfinderSurfaceRequest( + 1920, + 1080, + ImplementationMode.EXTERNAL + ) + val surfaceRequest = PreviewSurfaceRequest.Viewfinder(viewfinderRequest) + + composeTestRule.setContent { + PreviewDisplay( + previewDisplayUiState = PreviewDisplayUiState( + aspectRatioUiState = AspectRatioUiState.Available( + availableAspectRatios = listOf( + SingleSelectableUiState.SelectableUi( + AspectRatio.THREE_FOUR + ) + ), + selectedAspectRatio = AspectRatio.THREE_FOUR + ), + surfaceRequest = surfaceRequest + ), + onTapToFocus = { _, _ -> }, + onFlipCamera = { }, + onScaleZoom = { }, + onRequestWindowColorMode = { }, + focusMeteringUiState = FocusMeteringUiState.Unspecified, + surfaceRequest = surfaceRequest + ) + } + + composeTestRule.waitForIdle() + } +} diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureScreenComponents.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureScreenComponents.kt index dda1439b2..f4e6e282a 100644 --- a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureScreenComponents.kt +++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureScreenComponents.kt @@ -24,6 +24,7 @@ import androidx.camera.core.DynamicRange as CXDynamicRange import androidx.camera.core.SurfaceRequest import androidx.camera.viewfinder.compose.CoordinateTransformer import androidx.camera.viewfinder.compose.MutableCoordinateTransformer +import androidx.camera.viewfinder.compose.Viewfinder import androidx.camera.viewfinder.core.ImplementationMode import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.Animatable @@ -92,6 +93,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.round +import com.google.jetpackcamera.core.camera.PreviewSurfaceRequest import com.google.jetpackcamera.core.camera.VideoRecordingState import com.google.jetpackcamera.model.CaptureMode import com.google.jetpackcamera.model.StabilizationMode @@ -113,6 +115,7 @@ import com.google.jetpackcamera.ui.uistate.capture.FocusMeteringUiState import com.google.jetpackcamera.ui.uistate.capture.StabilizationUiState import com.google.jetpackcamera.ui.uistate.capture.compound.PreviewDisplayUiState import kotlin.time.Duration.Companion.nanoseconds +import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.delay import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged @@ -391,12 +394,12 @@ fun TestableSnackbar( @Composable private fun DetectWindowColorModeChanges( - surfaceRequest: SurfaceRequest, + surfaceRequest: SurfaceRequest?, implementationMode: ImplementationMode, onRequestWindowColorMode: (Int) -> Unit ) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val currentSurfaceRequest: SurfaceRequest by rememberUpdatedState(surfaceRequest) + val currentSurfaceRequest: SurfaceRequest? by rememberUpdatedState(surfaceRequest) val currentImplementationMode: ImplementationMode by rememberUpdatedState( implementationMode ) @@ -404,11 +407,14 @@ private fun DetectWindowColorModeChanges( onRequestWindowColorMode ) - LaunchedEffect(Unit) { + LaunchedEffect(currentSurfaceRequest) { + if (currentSurfaceRequest == null) { + return@LaunchedEffect + } val colorModeSnapshotFlow = snapshotFlow { Pair( - currentSurfaceRequest.dynamicRange, + currentSurfaceRequest!!.dynamicRange, currentImplementationMode ) } @@ -452,7 +458,7 @@ private fun DetectWindowColorModeChanges( * @param onFlipCamera the callback for flipping the camera. * @param onScaleZoom the callback for scaling the zoom. * @param onRequestWindowColorMode the callback for requesting a window color mode. - * @param surfaceRequest the [SurfaceRequest] for the preview. + * @param surfaceRequest the [PreviewSurfaceRequest] for the preview. * @param focusMeteringUiState the [FocusMeteringUiState] for this component. * @param modifier the modifier for this component. */ @@ -463,7 +469,7 @@ fun PreviewDisplay( onFlipCamera: () -> Unit, onScaleZoom: (Float) -> Unit, onRequestWindowColorMode: (Int) -> Unit, - surfaceRequest: SurfaceRequest?, + surfaceRequest: PreviewSurfaceRequest?, focusMeteringUiState: FocusMeteringUiState, modifier: Modifier = Modifier ) { @@ -526,39 +532,48 @@ fun PreviewDisplay( } DetectWindowColorModeChanges( - surfaceRequest = surfaceRequest, + surfaceRequest = (surfaceRequest as? PreviewSurfaceRequest.CameraX) + ?.surfaceRequest, implementationMode = implementationMode, onRequestWindowColorMode = onRequestWindowColorMode ) val coordinateTransformer = remember { MutableCoordinateTransformer() } - CameraXViewfinder( - modifier = Modifier - .fillMaxSize() - .pointerInput(onFlipCamera) { - detectTapGestures( - onDoubleTap = { offset -> - // double tap to flip camera - Log.d(TAG, "onDoubleTap $offset") - onFlipCamera() - }, - onTap = { - with(coordinateTransformer) { - val surfaceCoords = it.transform() - Log.d( - "TAG", - "onTapToFocus: " + - "input{$it} -> surface{$surfaceCoords}" - ) - onTapToFocus(surfaceCoords.x, surfaceCoords.y) - } - } - ) - }, - surfaceRequest = it, - implementationMode = implementationMode, - coordinateTransformer = coordinateTransformer - ) + when (surfaceRequest) { + is PreviewSurfaceRequest.CameraX -> { + CameraXViewfinder( + modifier = Modifier + .fillMaxSize() + .previewGestures( + onFlipCamera = onFlipCamera, + onTapToFocus = onTapToFocus, + coordinateTransformer = coordinateTransformer + ), + surfaceRequest = surfaceRequest.surfaceRequest, + implementationMode = implementationMode, + coordinateTransformer = coordinateTransformer + ) + } + + is PreviewSurfaceRequest.Viewfinder -> { + Viewfinder( + surfaceRequest = surfaceRequest.surfaceRequest, + modifier = Modifier + .fillMaxSize() + .previewGestures( + onFlipCamera = onFlipCamera, + onTapToFocus = onTapToFocus, + coordinateTransformer = coordinateTransformer + ), + coordinateTransformer = coordinateTransformer + ) { + onSurfaceSession { + surfaceRequest.surfaceDeferred.complete(surface) + awaitCancellation() + } + } + } + } FocusMeteringIndicator( focusMeteringUiState = focusMeteringUiState, coordinateTransformer = coordinateTransformer @@ -910,3 +925,28 @@ private fun FocusMeteringIndicator( } } } + +private fun Modifier.previewGestures( + onFlipCamera: () -> Unit, + onTapToFocus: (x: Float, y: Float) -> Unit, + coordinateTransformer: CoordinateTransformer +): Modifier = pointerInput(onFlipCamera) { + detectTapGestures( + onDoubleTap = { offset -> + // double tap to flip camera + Log.d(TAG, "onDoubleTap $offset") + onFlipCamera() + }, + onTap = { + with(coordinateTransformer) { + val surfaceCoords = it.transform() + Log.d( + TAG, + "onTapToFocus: " + + "input{$it} -> surface{$surfaceCoords}" + ) + onTapToFocus(surfaceCoords.x, surfaceCoords.y) + } + } + ) +} diff --git a/ui/controller/testing/build.gradle.kts b/ui/controller/testing/build.gradle.kts new file mode 100644 index 000000000..402ca3715 --- /dev/null +++ b/ui/controller/testing/build.gradle.kts @@ -0,0 +1,65 @@ +/* + * 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. + */ + +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) +} + +android { + namespace = "com.google.jetpackcamera.ui.controller.testing" + compileSdk = libs.versions.compileSdk.get().toInt() + + defaultConfig { + minSdk = libs.versions.minSdk.get().toInt() + testOptions.targetSdk = libs.versions.targetSdk.get().toInt() + lint.targetSdk = libs.versions.targetSdk.get().toInt() + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + flavorDimensions += "flavor" + productFlavors { + create("stable") { + dimension = "flavor" + isDefault = true + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlin { + jvmToolchain(17) + } +} + +dependencies { + implementation(project(":ui:controller")) + implementation(project(":core:model")) + implementation(project(":data:media")) + implementation(project(":ui:uistate")) + implementation(project(":ui:uistate:capture")) + implementation(libs.kotlinx.coroutines.core) + + // Testing + testImplementation(libs.junit) + testImplementation(libs.truth) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.robolectric) + testImplementation(libs.androidx.test.core) +} diff --git a/ui/controller/testing/src/main/java/com/google/jetpackcamera/ui/controller/testing/FakeCameraController.kt b/ui/controller/testing/src/main/java/com/google/jetpackcamera/ui/controller/testing/FakeCameraController.kt new file mode 100644 index 000000000..3f437f25f --- /dev/null +++ b/ui/controller/testing/src/main/java/com/google/jetpackcamera/ui/controller/testing/FakeCameraController.kt @@ -0,0 +1,50 @@ +/* + * 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.ui.controller.testing + +import com.google.jetpackcamera.model.DeviceRotation +import com.google.jetpackcamera.ui.controller.CameraController + +/** + * A fake implementation of [CameraController] that allows for configuring actions for its methods. + * + * @param startCameraAction The action to perform when [startCamera] is called. + * @param stopCameraAction The action to perform when [stopCamera] is called. + * @param tapToFocusAction The action to perform when [tapToFocus] is called. + * @param setDisplayRotationAction The action to perform when [setDisplayRotation] is called. + */ +class FakeCameraController( + var startCameraAction: () -> Unit = {}, + var stopCameraAction: () -> Unit = {}, + var tapToFocusAction: (x: Float, y: Float) -> Unit = { _, _ -> }, + var setDisplayRotationAction: (DeviceRotation) -> Unit = {} +) : CameraController { + override fun startCamera() { + startCameraAction() + } + + override fun stopCamera() { + stopCameraAction() + } + + override fun tapToFocus(x: Float, y: Float) { + tapToFocusAction(x, y) + } + + override fun setDisplayRotation(deviceRotation: DeviceRotation) { + setDisplayRotationAction(deviceRotation) + } +} diff --git a/ui/controller/testing/src/main/java/com/google/jetpackcamera/ui/controller/testing/FakeCaptureController.kt b/ui/controller/testing/src/main/java/com/google/jetpackcamera/ui/controller/testing/FakeCaptureController.kt new file mode 100644 index 000000000..99933ed5e --- /dev/null +++ b/ui/controller/testing/src/main/java/com/google/jetpackcamera/ui/controller/testing/FakeCaptureController.kt @@ -0,0 +1,76 @@ +/* + * 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.ui.controller.testing + +import android.content.ContentResolver +import com.google.jetpackcamera.model.CaptureEvent +import com.google.jetpackcamera.ui.controller.CaptureController +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.ReceiveChannel + +/** + * A fake implementation of [CaptureController] that allows for configuring actions for its methods. + * + * @param captureEvents The [ReceiveChannel] for [CaptureEvent]s. + * @param captureImageAction The action to perform when [captureImage] is called. + * @param startVideoRecordingAction The action to perform when [startVideoRecording] is called. + * @param stopVideoRecordingAction The action to perform when [stopVideoRecording] is called. + * @param setLockedRecordingAction The action to perform when [setLockedRecording] is called. + * @param setPausedAction The action to perform when [setPaused] is called. + * @param setAudioEnabledAction The action to perform when [setAudioEnabled] is called. + */ +class FakeCaptureController( + override val captureEvents: ReceiveChannel = Channel(Channel.UNLIMITED), + var captureImageAction: (ContentResolver) -> Unit = {}, + var startVideoRecordingAction: () -> Unit = {}, + var stopVideoRecordingAction: () -> Unit = {}, + var setLockedRecordingAction: (Boolean) -> Unit = {}, + var setPausedAction: (Boolean) -> Unit = {}, + var setAudioEnabledAction: (Boolean) -> Unit = {} +) : CaptureController { + /** + * Simulates a [CaptureEvent] being emitted by the controller. + * This relies on the [captureEvents] instance being a [Channel]. + */ + fun simulateCaptureEvent(event: CaptureEvent) { + (captureEvents as? Channel)?.trySend(event) + ?: throw IllegalStateException("captureEvents is not a Channel") + } + + override fun captureImage(contentResolver: ContentResolver) { + captureImageAction(contentResolver) + } + + override fun startVideoRecording() { + startVideoRecordingAction() + } + + override fun stopVideoRecording() { + stopVideoRecordingAction() + } + + override fun setLockedRecording(isLocked: Boolean) { + setLockedRecordingAction(isLocked) + } + + override fun setPaused(shouldBePaused: Boolean) { + setPausedAction(shouldBePaused) + } + + override fun setAudioEnabled(shouldEnableAudio: Boolean) { + setAudioEnabledAction(shouldEnableAudio) + } +} diff --git a/ui/controller/testing/src/main/java/com/google/jetpackcamera/ui/controller/testing/FakeDebugController.kt b/ui/controller/testing/src/main/java/com/google/jetpackcamera/ui/controller/testing/FakeDebugController.kt new file mode 100644 index 000000000..4d8b427a5 --- /dev/null +++ b/ui/controller/testing/src/main/java/com/google/jetpackcamera/ui/controller/testing/FakeDebugController.kt @@ -0,0 +1,44 @@ +/* + * 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.ui.controller.testing + +import com.google.jetpackcamera.model.TestPattern +import com.google.jetpackcamera.ui.controller.debug.DebugController + +/** + * A fake implementation of [DebugController] that allows for configuring actions for its methods. + * + * @param toggleDebugHidingComponentsAction The action to perform when [toggleDebugHidingComponents] is called. + * @param toggleDebugOverlayAction The action to perform when [toggleDebugOverlay] is called. + * @param setTestPatternAction The action to perform when [setTestPattern] is called. + */ +class FakeDebugController( + var toggleDebugHidingComponentsAction: () -> Unit = {}, + var toggleDebugOverlayAction: () -> Unit = {}, + var setTestPatternAction: (TestPattern) -> Unit = {} +) : DebugController { + override fun toggleDebugHidingComponents() { + toggleDebugHidingComponentsAction() + } + + override fun toggleDebugOverlay() { + toggleDebugOverlayAction() + } + + override fun setTestPattern(testPattern: TestPattern) { + setTestPatternAction(testPattern) + } +} diff --git a/ui/controller/testing/src/main/java/com/google/jetpackcamera/ui/controller/testing/FakeImageWellController.kt b/ui/controller/testing/src/main/java/com/google/jetpackcamera/ui/controller/testing/FakeImageWellController.kt new file mode 100644 index 000000000..e10c8e4b4 --- /dev/null +++ b/ui/controller/testing/src/main/java/com/google/jetpackcamera/ui/controller/testing/FakeImageWellController.kt @@ -0,0 +1,38 @@ +/* + * 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.ui.controller.testing + +import com.google.jetpackcamera.data.media.MediaDescriptor +import com.google.jetpackcamera.ui.controller.ImageWellController + +/** + * A fake implementation of [ImageWellController] that allows for configuring actions for its methods. + * + * @param imageWellToRepositoryAction The action to perform when [imageWellToRepository] is called. + * @param updateLastCapturedMediaAction The action to perform when [updateLastCapturedMedia] is called. + */ +class FakeImageWellController( + var imageWellToRepositoryAction: (MediaDescriptor) -> Unit = {}, + var updateLastCapturedMediaAction: () -> Unit = {} +) : ImageWellController { + override fun imageWellToRepository(mediaDescriptor: MediaDescriptor) { + imageWellToRepositoryAction(mediaDescriptor) + } + + override fun updateLastCapturedMedia() { + updateLastCapturedMediaAction() + } +} diff --git a/ui/controller/testing/src/main/java/com/google/jetpackcamera/ui/controller/testing/FakeQuickSettingsController.kt b/ui/controller/testing/src/main/java/com/google/jetpackcamera/ui/controller/testing/FakeQuickSettingsController.kt new file mode 100644 index 000000000..a53ac0a3f --- /dev/null +++ b/ui/controller/testing/src/main/java/com/google/jetpackcamera/ui/controller/testing/FakeQuickSettingsController.kt @@ -0,0 +1,94 @@ +/* + * 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.ui.controller.testing + +import com.google.jetpackcamera.model.AspectRatio +import com.google.jetpackcamera.model.CaptureMode +import com.google.jetpackcamera.model.ConcurrentCameraMode +import com.google.jetpackcamera.model.DynamicRange +import com.google.jetpackcamera.model.FlashMode +import com.google.jetpackcamera.model.ImageOutputFormat +import com.google.jetpackcamera.model.LensFacing +import com.google.jetpackcamera.model.StreamConfig +import com.google.jetpackcamera.ui.controller.quicksettings.QuickSettingsController +import com.google.jetpackcamera.ui.uistate.capture.compound.FocusedQuickSetting + +/** + * A fake implementation of [QuickSettingsController] that allows for configuring actions for its methods. + * + * @param toggleQuickSettingsAction The action to perform when [toggleQuickSettings] is called. + * @param setFocusedSettingAction The action to perform when [setFocusedSetting] is called. + * @param setLensFacingAction The action to perform when [setLensFacing] is called. + * @param setFlashAction The action to perform when [setFlash] is called. + * @param setAspectRatioAction The action to perform when [setAspectRatio] is called. + * @param setStreamConfigAction The action to perform when [setStreamConfig] is called. + * @param setDynamicRangeAction The action to perform when [setDynamicRange] is called. + * @param setImageFormatAction The action to perform when [setImageFormat] is called. + * @param setConcurrentCameraModeAction The action to perform when [setConcurrentCameraMode] is called. + * @param setCaptureModeAction The action to perform when [setCaptureMode] is called. + */ +class FakeQuickSettingsController( + var toggleQuickSettingsAction: () -> Unit = {}, + var setFocusedSettingAction: (FocusedQuickSetting) -> Unit = {}, + var setLensFacingAction: (LensFacing) -> Unit = {}, + var setFlashAction: (FlashMode) -> Unit = {}, + var setAspectRatioAction: (AspectRatio) -> Unit = {}, + var setStreamConfigAction: (StreamConfig) -> Unit = {}, + var setDynamicRangeAction: (DynamicRange) -> Unit = {}, + var setImageFormatAction: (ImageOutputFormat) -> Unit = {}, + var setConcurrentCameraModeAction: (ConcurrentCameraMode) -> Unit = {}, + var setCaptureModeAction: (CaptureMode) -> Unit = {} +) : QuickSettingsController { + override fun toggleQuickSettings() { + toggleQuickSettingsAction() + } + + override fun setFocusedSetting(focusedQuickSetting: FocusedQuickSetting) { + setFocusedSettingAction(focusedQuickSetting) + } + + override fun setLensFacing(lensFace: LensFacing) { + setLensFacingAction(lensFace) + } + + override fun setFlash(flashMode: FlashMode) { + setFlashAction(flashMode) + } + + override fun setAspectRatio(aspectRatio: AspectRatio) { + setAspectRatioAction(aspectRatio) + } + + override fun setStreamConfig(streamConfig: StreamConfig) { + setStreamConfigAction(streamConfig) + } + + override fun setDynamicRange(dynamicRange: DynamicRange) { + setDynamicRangeAction(dynamicRange) + } + + override fun setImageFormat(imageOutputFormat: ImageOutputFormat) { + setImageFormatAction(imageOutputFormat) + } + + override fun setConcurrentCameraMode(concurrentCameraMode: ConcurrentCameraMode) { + setConcurrentCameraModeAction(concurrentCameraMode) + } + + override fun setCaptureMode(captureMode: CaptureMode) { + setCaptureModeAction(captureMode) + } +} diff --git a/ui/controller/testing/src/main/java/com/google/jetpackcamera/ui/controller/testing/FakeSnackBarController.kt b/ui/controller/testing/src/main/java/com/google/jetpackcamera/ui/controller/testing/FakeSnackBarController.kt new file mode 100644 index 000000000..2c73edb0f --- /dev/null +++ b/ui/controller/testing/src/main/java/com/google/jetpackcamera/ui/controller/testing/FakeSnackBarController.kt @@ -0,0 +1,61 @@ +/* + * 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.ui.controller.testing + +import com.google.jetpackcamera.ui.controller.SnackBarController +import com.google.jetpackcamera.ui.uistate.DisableRationale +import com.google.jetpackcamera.ui.uistate.SnackbarData +import java.util.concurrent.atomic.AtomicInteger + +/** + * A fake implementation of [SnackBarController] that allows for configuring actions for its methods. + * + * @param onSnackBarResultAction The action to perform when [onSnackBarResult] is called. + * @param incrementAndGetSnackBarCountAction The action to perform when [incrementAndGetSnackBarCount] is called. + * @param addSnackBarDataAction The action to perform when [addSnackBarData] is called. + */ +class FakeSnackBarController( + var onSnackBarResultAction: (String) -> Unit = {}, + var incrementAndGetSnackBarCountAction: (() -> Int)? = null, + var addSnackBarDataAction: (SnackbarData) -> Unit = {} +) : SnackBarController { + private val snackBarCount by lazy { AtomicInteger(0) } + + override fun enqueueDisabledHdrToggleSnackBar(disabledReason: DisableRationale) { + val cookieInt = incrementAndGetSnackBarCount() + val cookie = "DisabledHdrToggle-$cookieInt" + addSnackBarData( + SnackbarData( + cookie = cookie, + stringResource = disabledReason.reasonTextResId, + withDismissAction = true, + testTag = disabledReason.testTag + ) + ) + } + + override fun onSnackBarResult(cookie: String) { + onSnackBarResultAction(cookie) + } + + override fun incrementAndGetSnackBarCount(): Int { + return incrementAndGetSnackBarCountAction?.invoke() ?: snackBarCount.incrementAndGet() + } + + override fun addSnackBarData(snackBarData: SnackbarData) { + addSnackBarDataAction(snackBarData) + } +} diff --git a/ui/controller/testing/src/main/java/com/google/jetpackcamera/ui/controller/testing/FakeZoomController.kt b/ui/controller/testing/src/main/java/com/google/jetpackcamera/ui/controller/testing/FakeZoomController.kt new file mode 100644 index 000000000..db5d9b2a4 --- /dev/null +++ b/ui/controller/testing/src/main/java/com/google/jetpackcamera/ui/controller/testing/FakeZoomController.kt @@ -0,0 +1,38 @@ +/* + * 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.ui.controller.testing + +import com.google.jetpackcamera.model.CameraZoomRatio +import com.google.jetpackcamera.ui.controller.ZoomController + +/** + * A fake implementation of [ZoomController] that allows for configuring actions for its methods. + * + * @param setZoomRatioAction The action to perform when [setZoomRatio] is called. + * @param setZoomAnimationStateAction The action to perform when [setZoomAnimationState] is called. + */ +class FakeZoomController( + var setZoomRatioAction: (CameraZoomRatio) -> Unit = {}, + var setZoomAnimationStateAction: (Float?) -> Unit = {} +) : ZoomController { + override fun setZoomRatio(zoomRatio: CameraZoomRatio) { + setZoomRatioAction(zoomRatio) + } + + override fun setZoomAnimationState(targetValue: Float?) { + setZoomAnimationStateAction(targetValue) + } +} diff --git a/ui/controller/testing/src/test/java/com/google/jetpackcamera/ui/controller/testing/FakeCameraControllerTest.kt b/ui/controller/testing/src/test/java/com/google/jetpackcamera/ui/controller/testing/FakeCameraControllerTest.kt new file mode 100644 index 000000000..14cad2012 --- /dev/null +++ b/ui/controller/testing/src/test/java/com/google/jetpackcamera/ui/controller/testing/FakeCameraControllerTest.kt @@ -0,0 +1,59 @@ +/* + * 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.ui.controller.testing + +import com.google.common.truth.Truth.assertThat +import com.google.jetpackcamera.model.DeviceRotation +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 + +@RunWith(JUnit4::class) +class FakeCameraControllerTest { + @Test + fun startCamera_invokesAction() { + var called = false + val controller = FakeCameraController(startCameraAction = { called = true }) + controller.startCamera() + assertThat(called).isTrue() + } + + @Test + fun stopCamera_invokesAction() { + var called = false + val controller = FakeCameraController(stopCameraAction = { called = true }) + controller.stopCamera() + assertThat(called).isTrue() + } + + @Test + fun tapToFocus_invokesAction() { + var calledCoords: Pair? = null + val controller = FakeCameraController(tapToFocusAction = { x, y -> calledCoords = x to y }) + controller.tapToFocus(1f, 2f) + assertThat(calledCoords).isEqualTo(1f to 2f) + } + + @Test + fun setDisplayRotation_invokesAction() { + var calledRotation: DeviceRotation? = null + val controller = FakeCameraController( + setDisplayRotationAction = { calledRotation = it } + ) + controller.setDisplayRotation(DeviceRotation.Rotated90) + assertThat(calledRotation).isEqualTo(DeviceRotation.Rotated90) + } +} diff --git a/ui/controller/testing/src/test/java/com/google/jetpackcamera/ui/controller/testing/FakeCaptureControllerTest.kt b/ui/controller/testing/src/test/java/com/google/jetpackcamera/ui/controller/testing/FakeCaptureControllerTest.kt new file mode 100644 index 000000000..218366dc8 --- /dev/null +++ b/ui/controller/testing/src/test/java/com/google/jetpackcamera/ui/controller/testing/FakeCaptureControllerTest.kt @@ -0,0 +1,96 @@ +/* + * 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.ui.controller.testing + +import android.content.ContentResolver +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.google.common.truth.Truth.assertThat +import com.google.jetpackcamera.model.CaptureEvent +import com.google.jetpackcamera.model.ImageCaptureEvent +import kotlinx.coroutines.channels.Channel +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class FakeCaptureControllerTest { + @Test + fun captureEvents_returnsProvidedChannel() { + val channel = Channel() + val controller = FakeCaptureController(captureEvents = channel) + assertThat(controller.captureEvents).isEqualTo(channel) + } + + @Test + fun simulateCaptureEvent_emitsToCaptureEventsChannel() { + val controller = FakeCaptureController() + val event = ImageCaptureEvent.SingleImageSaved() + controller.simulateCaptureEvent(event) + + val receivedEvent = controller.captureEvents.tryReceive().getOrNull() + assertThat(receivedEvent).isEqualTo(event) + } + + @Test + fun captureImage_invokesAction() { + var calledResolver: ContentResolver? = null + val controller = FakeCaptureController(captureImageAction = { calledResolver = it }) + val resolver = ApplicationProvider.getApplicationContext().contentResolver + controller.captureImage(resolver) + assertThat(calledResolver).isEqualTo(resolver) + } + + @Test + fun startVideoRecording_invokesAction() { + var called = false + val controller = FakeCaptureController(startVideoRecordingAction = { called = true }) + controller.startVideoRecording() + assertThat(called).isTrue() + } + + @Test + fun stopVideoRecording_invokesAction() { + var called = false + val controller = FakeCaptureController(stopVideoRecordingAction = { called = true }) + controller.stopVideoRecording() + assertThat(called).isTrue() + } + + @Test + fun setLockedRecording_invokesAction() { + var calledValue: Boolean? = null + val controller = FakeCaptureController(setLockedRecordingAction = { calledValue = it }) + controller.setLockedRecording(true) + assertThat(calledValue).isTrue() + } + + @Test + fun setPaused_invokesAction() { + var calledValue: Boolean? = null + val controller = FakeCaptureController(setPausedAction = { calledValue = it }) + controller.setPaused(true) + assertThat(calledValue).isTrue() + } + + @Test + fun setAudioEnabled_invokesAction() { + var calledValue: Boolean? = null + val controller = FakeCaptureController(setAudioEnabledAction = { calledValue = it }) + controller.setAudioEnabled(true) + assertThat(calledValue).isTrue() + } +} diff --git a/ui/controller/testing/src/test/java/com/google/jetpackcamera/ui/controller/testing/FakeDebugControllerTest.kt b/ui/controller/testing/src/test/java/com/google/jetpackcamera/ui/controller/testing/FakeDebugControllerTest.kt new file mode 100644 index 000000000..6f7aeded3 --- /dev/null +++ b/ui/controller/testing/src/test/java/com/google/jetpackcamera/ui/controller/testing/FakeDebugControllerTest.kt @@ -0,0 +1,49 @@ +/* + * 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.ui.controller.testing + +import com.google.common.truth.Truth.assertThat +import com.google.jetpackcamera.model.TestPattern +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 + +@RunWith(JUnit4::class) +class FakeDebugControllerTest { + @Test + fun toggleDebugHidingComponents_invokesAction() { + var called = false + val controller = FakeDebugController(toggleDebugHidingComponentsAction = { called = true }) + controller.toggleDebugHidingComponents() + assertThat(called).isTrue() + } + + @Test + fun toggleDebugOverlay_invokesAction() { + var called = false + val controller = FakeDebugController(toggleDebugOverlayAction = { called = true }) + controller.toggleDebugOverlay() + assertThat(called).isTrue() + } + + @Test + fun setTestPattern_invokesAction() { + var calledPattern: TestPattern? = null + val controller = FakeDebugController(setTestPatternAction = { calledPattern = it }) + controller.setTestPattern(TestPattern.ColorBars) + assertThat(calledPattern).isEqualTo(TestPattern.ColorBars) + } +} diff --git a/ui/controller/testing/src/test/java/com/google/jetpackcamera/ui/controller/testing/FakeImageWellControllerTest.kt b/ui/controller/testing/src/test/java/com/google/jetpackcamera/ui/controller/testing/FakeImageWellControllerTest.kt new file mode 100644 index 000000000..08e8bcbee --- /dev/null +++ b/ui/controller/testing/src/test/java/com/google/jetpackcamera/ui/controller/testing/FakeImageWellControllerTest.kt @@ -0,0 +1,45 @@ +/* + * 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.ui.controller.testing + +import android.net.Uri +import com.google.common.truth.Truth.assertThat +import com.google.jetpackcamera.data.media.MediaDescriptor +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class FakeImageWellControllerTest { + @Test + fun imageWellToRepository_invokesAction() { + var calledDescriptor: MediaDescriptor? = null + val controller = FakeImageWellController( + imageWellToRepositoryAction = { calledDescriptor = it } + ) + val descriptor = MediaDescriptor.Content.Image(Uri.EMPTY, null) + controller.imageWellToRepository(descriptor) + assertThat(calledDescriptor).isEqualTo(descriptor) + } + + @Test + fun updateLastCapturedMedia_invokesAction() { + var called = false + val controller = FakeImageWellController(updateLastCapturedMediaAction = { called = true }) + controller.updateLastCapturedMedia() + assertThat(called).isTrue() + } +} diff --git a/ui/controller/testing/src/test/java/com/google/jetpackcamera/ui/controller/testing/FakeQuickSettingsControllerTest.kt b/ui/controller/testing/src/test/java/com/google/jetpackcamera/ui/controller/testing/FakeQuickSettingsControllerTest.kt new file mode 100644 index 000000000..5a4af8ac0 --- /dev/null +++ b/ui/controller/testing/src/test/java/com/google/jetpackcamera/ui/controller/testing/FakeQuickSettingsControllerTest.kt @@ -0,0 +1,115 @@ +/* + * 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.ui.controller.testing + +import com.google.common.truth.Truth.assertThat +import com.google.jetpackcamera.model.AspectRatio +import com.google.jetpackcamera.model.CaptureMode +import com.google.jetpackcamera.model.ConcurrentCameraMode +import com.google.jetpackcamera.model.DynamicRange +import com.google.jetpackcamera.model.FlashMode +import com.google.jetpackcamera.model.ImageOutputFormat +import com.google.jetpackcamera.model.LensFacing +import com.google.jetpackcamera.model.StreamConfig +import com.google.jetpackcamera.ui.uistate.capture.compound.FocusedQuickSetting +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 + +@RunWith(JUnit4::class) +class FakeQuickSettingsControllerTest { + @Test + fun toggleQuickSettings_invokesAction() { + var called = false + val controller = FakeQuickSettingsController(toggleQuickSettingsAction = { called = true }) + controller.toggleQuickSettings() + assertThat(called).isTrue() + } + + @Test + fun setFocusedSetting_invokesAction() { + var calledValue: FocusedQuickSetting? = null + val controller = FakeQuickSettingsController(setFocusedSettingAction = { calledValue = it }) + controller.setFocusedSetting(FocusedQuickSetting.ASPECT_RATIO) + assertThat(calledValue).isEqualTo(FocusedQuickSetting.ASPECT_RATIO) + } + + @Test + fun setLensFacing_invokesAction() { + var calledValue: LensFacing? = null + val controller = FakeQuickSettingsController(setLensFacingAction = { calledValue = it }) + controller.setLensFacing(LensFacing.FRONT) + assertThat(calledValue).isEqualTo(LensFacing.FRONT) + } + + @Test + fun setFlash_invokesAction() { + var calledValue: FlashMode? = null + val controller = FakeQuickSettingsController(setFlashAction = { calledValue = it }) + controller.setFlash(FlashMode.ON) + assertThat(calledValue).isEqualTo(FlashMode.ON) + } + + @Test + fun setAspectRatio_invokesAction() { + var calledValue: AspectRatio? = null + val controller = FakeQuickSettingsController(setAspectRatioAction = { calledValue = it }) + controller.setAspectRatio(AspectRatio.THREE_FOUR) + assertThat(calledValue).isEqualTo(AspectRatio.THREE_FOUR) + } + + @Test + fun setStreamConfig_invokesAction() { + var calledValue: StreamConfig? = null + val controller = FakeQuickSettingsController(setStreamConfigAction = { calledValue = it }) + controller.setStreamConfig(StreamConfig.SINGLE_STREAM) + assertThat(calledValue).isEqualTo(StreamConfig.SINGLE_STREAM) + } + + @Test + fun setDynamicRange_invokesAction() { + var calledValue: DynamicRange? = null + val controller = FakeQuickSettingsController(setDynamicRangeAction = { calledValue = it }) + controller.setDynamicRange(DynamicRange.HLG10) + assertThat(calledValue).isEqualTo(DynamicRange.HLG10) + } + + @Test + fun setImageFormat_invokesAction() { + var calledValue: ImageOutputFormat? = null + val controller = FakeQuickSettingsController(setImageFormatAction = { calledValue = it }) + controller.setImageFormat(ImageOutputFormat.JPEG) + assertThat(calledValue).isEqualTo(ImageOutputFormat.JPEG) + } + + @Test + fun setConcurrentCameraMode_invokesAction() { + var calledValue: ConcurrentCameraMode? = null + val controller = FakeQuickSettingsController( + setConcurrentCameraModeAction = { calledValue = it } + ) + controller.setConcurrentCameraMode(ConcurrentCameraMode.DUAL) + assertThat(calledValue).isEqualTo(ConcurrentCameraMode.DUAL) + } + + @Test + fun setCaptureMode_invokesAction() { + var calledValue: CaptureMode? = null + val controller = FakeQuickSettingsController(setCaptureModeAction = { calledValue = it }) + controller.setCaptureMode(CaptureMode.STANDARD) + assertThat(calledValue).isEqualTo(CaptureMode.STANDARD) + } +} diff --git a/ui/controller/testing/src/test/java/com/google/jetpackcamera/ui/controller/testing/FakeSnackBarControllerTest.kt b/ui/controller/testing/src/test/java/com/google/jetpackcamera/ui/controller/testing/FakeSnackBarControllerTest.kt new file mode 100644 index 000000000..2dc1acbc2 --- /dev/null +++ b/ui/controller/testing/src/test/java/com/google/jetpackcamera/ui/controller/testing/FakeSnackBarControllerTest.kt @@ -0,0 +1,75 @@ +/* + * 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.ui.controller.testing + +import com.google.common.truth.Truth.assertThat +import com.google.jetpackcamera.ui.uistate.DisableRationale +import com.google.jetpackcamera.ui.uistate.SnackbarData +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 + +@RunWith(JUnit4::class) +class FakeSnackBarControllerTest { + private val testDisableRationale = object : DisableRationale { + override val testTag: String = "test-tag" + override val reasonTextResId: Int = 123 + } + + @Test + fun enqueueDisabledHdrToggleSnackBar_invokesAddSnackBarData() { + var calledValue: SnackbarData? = null + val controller = FakeSnackBarController( + addSnackBarDataAction = { calledValue = it } + ) + controller.enqueueDisabledHdrToggleSnackBar(testDisableRationale) + + assertThat(calledValue).isNotNull() + assertThat(calledValue?.cookie).isEqualTo("DisabledHdrToggle-1") + assertThat(calledValue?.stringResource).isEqualTo(testDisableRationale.reasonTextResId) + assertThat(calledValue?.testTag).isEqualTo(testDisableRationale.testTag) + } + + @Test + fun onSnackBarResult_invokesAction() { + var calledValue: String? = null + val controller = FakeSnackBarController(onSnackBarResultAction = { calledValue = it }) + controller.onSnackBarResult("test-cookie") + assertThat(calledValue).isEqualTo("test-cookie") + } + + @Test + fun incrementAndGetSnackBarCount_usesInternalCounterByDefault() { + val controller = FakeSnackBarController() + assertThat(controller.incrementAndGetSnackBarCount()).isEqualTo(1) + assertThat(controller.incrementAndGetSnackBarCount()).isEqualTo(2) + } + + @Test + fun incrementAndGetSnackBarCount_invokesActionIfProvided() { + val controller = FakeSnackBarController(incrementAndGetSnackBarCountAction = { 42 }) + assertThat(controller.incrementAndGetSnackBarCount()).isEqualTo(42) + } + + @Test + fun addSnackBarData_invokesAction() { + var calledValue: SnackbarData? = null + val controller = FakeSnackBarController(addSnackBarDataAction = { calledValue = it }) + val data = SnackbarData(cookie = "test-cookie", stringResource = 123) + controller.addSnackBarData(data) + assertThat(calledValue).isEqualTo(data) + } +} diff --git a/ui/controller/testing/src/test/java/com/google/jetpackcamera/ui/controller/testing/FakeZoomControllerTest.kt b/ui/controller/testing/src/test/java/com/google/jetpackcamera/ui/controller/testing/FakeZoomControllerTest.kt new file mode 100644 index 000000000..05ba5b0e0 --- /dev/null +++ b/ui/controller/testing/src/test/java/com/google/jetpackcamera/ui/controller/testing/FakeZoomControllerTest.kt @@ -0,0 +1,43 @@ +/* + * 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.ui.controller.testing + +import com.google.common.truth.Truth.assertThat +import com.google.jetpackcamera.model.CameraZoomRatio +import com.google.jetpackcamera.model.ZoomStrategy +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 + +@RunWith(JUnit4::class) +class FakeZoomControllerTest { + @Test + fun setZoomRatio_invokesAction() { + var calledValue: CameraZoomRatio? = null + val controller = FakeZoomController(setZoomRatioAction = { calledValue = it }) + val ratio = CameraZoomRatio(ZoomStrategy.Absolute(1f)) + controller.setZoomRatio(ratio) + assertThat(calledValue).isEqualTo(ratio) + } + + @Test + fun setZoomAnimationState_invokesAction() { + var calledValue: Float? = null + val controller = FakeZoomController(setZoomAnimationStateAction = { calledValue = it }) + controller.setZoomAnimationState(2.5f) + assertThat(calledValue).isEqualTo(2.5f) + } +} diff --git a/ui/uistate/capture/src/main/java/com/google/jetpackcamera/ui/uistate/capture/compound/PreviewDisplayUiState.kt b/ui/uistate/capture/src/main/java/com/google/jetpackcamera/ui/uistate/capture/compound/PreviewDisplayUiState.kt index d6ed953c4..fe7364e11 100644 --- a/ui/uistate/capture/src/main/java/com/google/jetpackcamera/ui/uistate/capture/compound/PreviewDisplayUiState.kt +++ b/ui/uistate/capture/src/main/java/com/google/jetpackcamera/ui/uistate/capture/compound/PreviewDisplayUiState.kt @@ -15,6 +15,7 @@ */ package com.google.jetpackcamera.ui.uistate.capture.compound +import com.google.jetpackcamera.core.camera.PreviewSurfaceRequest import com.google.jetpackcamera.ui.uistate.capture.AspectRatioUiState /** @@ -22,8 +23,10 @@ import com.google.jetpackcamera.ui.uistate.capture.AspectRatioUiState * * @param lastBlinkTimeStamp The timestamp of the most recent capture blink animation. * @param aspectRatioUiState The UI state for the aspect ratio of the preview. + * @param surfaceRequest The current surface request for the preview display. */ data class PreviewDisplayUiState( val lastBlinkTimeStamp: Long = 0, - val aspectRatioUiState: AspectRatioUiState + val aspectRatioUiState: AspectRatioUiState, + val surfaceRequest: PreviewSurfaceRequest? = null ) diff --git a/ui/uistate/postcapture/build.gradle.kts b/ui/uistate/postcapture/build.gradle.kts index 1779bfab2..3c6fb311b 100644 --- a/ui/uistate/postcapture/build.gradle.kts +++ b/ui/uistate/postcapture/build.gradle.kts @@ -42,8 +42,8 @@ android { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } - kotlinOptions { - jvmTarget = "17" + kotlin { + jvmToolchain(17) } } diff --git a/ui/uistateadapter/capture/src/main/java/com/google/jetpackcamera/ui/uistateadapter/capture/compound/CaptureUiStateAdapter.kt b/ui/uistateadapter/capture/src/main/java/com/google/jetpackcamera/ui/uistateadapter/capture/compound/CaptureUiStateAdapter.kt index eabc94353..ae4fe34b8 100644 --- a/ui/uistateadapter/capture/src/main/java/com/google/jetpackcamera/ui/uistateadapter/capture/compound/CaptureUiStateAdapter.kt +++ b/ui/uistateadapter/capture/src/main/java/com/google/jetpackcamera/ui/uistateadapter/capture/compound/CaptureUiStateAdapter.kt @@ -72,8 +72,9 @@ fun captureUiState( cameraSystem.getCurrentSettings().filterNotNull(), constraintsRepository.systemConstraints.filterNotNull(), cameraSystem.getCurrentCameraState(), + cameraSystem.getSurfaceRequest(), trackedCaptureUiState - ) { cameraAppSettings, systemConstraints, cameraState, trackedUiState -> + ) { cameraAppSettings, systemConstraints, cameraState, surfaceRequest, trackedUiState -> val captureModeUiState = CaptureModeUiState.from( systemConstraints, cameraAppSettings, @@ -112,7 +113,8 @@ fun captureUiState( aspectRatioUiState = aspectRatioUiState, previewDisplayUiState = PreviewDisplayUiState( trackedUiState.lastBlinkTimeStamp, - aspectRatioUiState + aspectRatioUiState, + surfaceRequest ), // TODO: add updateFrom() for all ui states to prevent re-updating if // values are the same diff --git a/ui/uistateadapter/postcapture/build.gradle.kts b/ui/uistateadapter/postcapture/build.gradle.kts index cb480e455..47a383364 100644 --- a/ui/uistateadapter/postcapture/build.gradle.kts +++ b/ui/uistateadapter/postcapture/build.gradle.kts @@ -42,8 +42,8 @@ android { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } - kotlinOptions { - jvmTarget = "17" + kotlin { + jvmToolchain(17) } }