diff --git a/app/src/main/java/com/google/jetpackcamera/di/CameraModule.kt b/app/src/main/java/com/google/jetpackcamera/di/CameraModule.kt index 66b68cf426..040824581e 100644 --- a/app/src/main/java/com/google/jetpackcamera/di/CameraModule.kt +++ b/app/src/main/java/com/google/jetpackcamera/di/CameraModule.kt @@ -29,6 +29,7 @@ import com.google.jetpackcamera.core.common.FilePathGenerator import com.google.jetpackcamera.data.camera.CameraLaunchConfigProvider import com.google.jetpackcamera.data.camera.CameraSystemRepository import com.google.jetpackcamera.data.camera.CameraXCameraSystemRepository +import com.google.jetpackcamera.settings.ConstraintsRepository import com.google.jetpackcamera.settings.SettingsRepository import dagger.Module import dagger.Provides @@ -49,18 +50,28 @@ internal object CameraModule { @Provides @ActivityRetainedScoped - fun providesCameraSystemRepository( + fun providesCameraXCameraSystemRepository( cameraXCameraSystemProvider: Provider, settingsRepository: SettingsRepository, launchConfigProvider: CameraLaunchConfigProvider, @DefaultCoroutineScope scope: CoroutineScope - ): CameraSystemRepository = CameraXCameraSystemRepository( + ): CameraXCameraSystemRepository = CameraXCameraSystemRepository( cameraXCameraSystemProvider = cameraXCameraSystemProvider, settingsRepository = settingsRepository, launchConfigProvider = launchConfigProvider, scope = scope ) + @Provides + fun providesCameraSystemRepository( + repository: CameraXCameraSystemRepository + ): CameraSystemRepository = repository + + @Provides + fun providesConstraintsRepository( + repository: CameraXCameraSystemRepository + ): ConstraintsRepository = repository + @Provides @ActivityRetainedScoped fun providesCameraXCameraSystem( diff --git a/app/src/main/java/com/google/jetpackcamera/ui/JcaApp.kt b/app/src/main/java/com/google/jetpackcamera/ui/JcaApp.kt index 99f79a6d46..8622bb6ebe 100644 --- a/app/src/main/java/com/google/jetpackcamera/ui/JcaApp.kt +++ b/app/src/main/java/com/google/jetpackcamera/ui/JcaApp.kt @@ -24,21 +24,25 @@ import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable +import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.isGranted +import com.google.accompanist.permissions.rememberPermissionState import com.google.jetpackcamera.BuildConfig import com.google.jetpackcamera.feature.postcapture.PostCaptureScreen import com.google.jetpackcamera.feature.preview.navigation.navigateToPreview -import com.google.jetpackcamera.feature.preview.navigation.popUpToPreview import com.google.jetpackcamera.feature.preview.navigation.previewScreen import com.google.jetpackcamera.model.CaptureEvent import com.google.jetpackcamera.model.DebugSettings import com.google.jetpackcamera.model.ExternalCaptureMode import com.google.jetpackcamera.permissions.navigation.PermissionsRoute +import com.google.jetpackcamera.permissions.navigation.isPermissionsRoute import com.google.jetpackcamera.permissions.navigation.navigateToPermissions import com.google.jetpackcamera.permissions.navigation.permissionsScreen import com.google.jetpackcamera.permissions.navigation.popUpToPermissions @@ -86,6 +90,8 @@ private fun JetpackCameraNavHost( onCaptureEvent: (CaptureEvent) -> Unit, navController: NavHostController = rememberNavController() ) { + CameraPermissionGuard(navController) + NavHost( navController = navController, startDestination = PermissionsRoute.toString(), @@ -119,11 +125,6 @@ private fun JetpackCameraNavHost( onFirstFrameCaptureCompleted = onFirstFrameCaptureCompleted, onNavigateToSettings = { navController.navigate(SETTINGS_ROUTE) }, onNavigateToPostCapture = { navController.navigate(POST_CAPTURE_ROUTE) }, - onNavigateToPermissions = { - navController.navigateToPermissions { - popUpToPreview() - } - }, onCaptureEvent = onCaptureEvent ) @@ -160,3 +161,23 @@ private fun JetpackCameraNavHost( } } } + +@OptIn(ExperimentalPermissionsApi::class) +@Composable +private fun CameraPermissionGuard(navController: NavHostController) { + val cameraPermissionState = rememberPermissionState(android.Manifest.permission.CAMERA) + val currentDestination = navController.currentBackStackEntryAsState().value?.destination + + // Automatically navigate to permissions screen when camera permission revoked + LaunchedEffect(cameraPermissionState.status, currentDestination) { + if (currentDestination?.isPermissionsRoute() == false && + !cameraPermissionState.status.isGranted + ) { + navController.navigateToPermissions { + popUpTo(navController.graph.id) { + inclusive = true + } + } + } + } +} diff --git a/data/camera/src/main/java/com/google/jetpackcamera/data/camera/CameraSystemRepository.kt b/data/camera/src/main/java/com/google/jetpackcamera/data/camera/CameraSystemRepository.kt index 9aca13f731..2f80b0d0cd 100644 --- a/data/camera/src/main/java/com/google/jetpackcamera/data/camera/CameraSystemRepository.kt +++ b/data/camera/src/main/java/com/google/jetpackcamera/data/camera/CameraSystemRepository.kt @@ -34,6 +34,7 @@ interface CameraSystemRepository { /** * A [StateFlow] emitting the current [CameraSystemConstraints] supported by the device. + * Accessing it starts initialization if it hasn't started yet. */ val systemConstraints: StateFlow @@ -57,6 +58,12 @@ interface CameraSystemRepository { */ suspend fun getCameraSystem(): CameraSystem + /** + * Returns the default [CameraAppSettings] read when the camera system was initialized, + * suspending until initialization completes. + */ + suspend fun getInitialDefaultCameraAppSettings(): CameraAppSettings + /** * Returns supported MIME types once initialized. */ diff --git a/data/camera/src/main/java/com/google/jetpackcamera/data/camera/CameraXCameraSystemRepository.kt b/data/camera/src/main/java/com/google/jetpackcamera/data/camera/CameraXCameraSystemRepository.kt index eaf89f526b..7292314c6b 100644 --- a/data/camera/src/main/java/com/google/jetpackcamera/data/camera/CameraXCameraSystemRepository.kt +++ b/data/camera/src/main/java/com/google/jetpackcamera/data/camera/CameraXCameraSystemRepository.kt @@ -19,6 +19,7 @@ import androidx.camera.core.SurfaceRequest import com.google.jetpackcamera.core.camera.CameraState import com.google.jetpackcamera.core.camera.CameraSystem import com.google.jetpackcamera.core.camera.CameraXCameraSystem +import com.google.jetpackcamera.settings.ConstraintsRepository import com.google.jetpackcamera.settings.SettingsRepository import com.google.jetpackcamera.settings.model.CameraAppSettings import com.google.jetpackcamera.settings.model.CameraSystemConstraints @@ -38,15 +39,15 @@ import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first /** - * Implementation of [CameraSystemRepository] that manages [CameraXCameraSystem] initialization - * and exposes camera streams. + * Implementation of [CameraSystemRepository] and [ConstraintsRepository] that manages + * [CameraXCameraSystem] initialization and exposes camera streams. */ class CameraXCameraSystemRepository( private val cameraXCameraSystemProvider: Provider, private val settingsRepository: SettingsRepository, private val launchConfigProvider: CameraLaunchConfigProvider, private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) -) : CameraSystemRepository { +) : CameraSystemRepository, ConstraintsRepository { constructor( cameraXCameraSystemProvider: Provider, @@ -69,6 +70,7 @@ class CameraXCameraSystemRepository( } override val systemConstraints: StateFlow by lazy { + initializationDeferred.start() cameraSystem.getSystemConstraints() } @@ -83,15 +85,17 @@ class CameraXCameraSystemRepository( private val _cameraPropertiesJSON = MutableStateFlow(null) override val cameraPropertiesJSON: StateFlow = _cameraPropertiesJSON.asStateFlow() - private val initializationDeferred: Deferred = + private val initializationDeferred: Deferred = scope.async(start = CoroutineStart.LAZY) { val launchConfig = launchConfigProvider.config.value - val initialSettings = settingsRepository.getCurrentDefaultCameraAppSettings() + val defaultSettings = settingsRepository.getCurrentDefaultCameraAppSettings() + val initialSettings = defaultSettings .applyExternalCaptureMode(launchConfig.externalCaptureMode) .copy(debugSettings = launchConfig.debugSettings) cameraSystem.initialize(initialSettings) { properties -> _cameraPropertiesJSON.value = properties } + defaultSettings } override suspend fun getCameraSystem(): CameraSystem { @@ -99,6 +103,9 @@ class CameraXCameraSystemRepository( return cameraSystem } + override suspend fun getInitialDefaultCameraAppSettings(): CameraAppSettings = + initializationDeferred.await() + override suspend fun getSupportedMimeTypes(): List { initializationDeferred.await() val constraints = systemConstraints.filterNotNull().first() diff --git a/data/camera/src/test/java/com/google/jetpackcamera/data/camera/CameraXCameraSystemRepositoryTest.kt b/data/camera/src/test/java/com/google/jetpackcamera/data/camera/CameraXCameraSystemRepositoryTest.kt index 9cacd70610..14eec3b1d8 100644 --- a/data/camera/src/test/java/com/google/jetpackcamera/data/camera/CameraXCameraSystemRepositoryTest.kt +++ b/data/camera/src/test/java/com/google/jetpackcamera/data/camera/CameraXCameraSystemRepositoryTest.kt @@ -26,6 +26,8 @@ import com.google.jetpackcamera.settings.model.CameraSystemConstraints import com.google.jetpackcamera.settings.testing.FakeSettingsRepository import javax.inject.Provider import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest @@ -94,6 +96,43 @@ class CameraXCameraSystemRepositoryTest { assertThat(repository.cameraPropertiesJSON.value).isEqualTo("{\"test\": true}") } + @Test + fun systemConstraints_lazilyInitializesCameraSystem() = testScope.runTest { + val testCamera = TestCameraSystem() + + val repository = CameraXCameraSystemRepository( + cameraXCameraSystemProvider = Provider { testCamera }, + settingsRepository = FakeSettingsRepository(), + launchConfig = CameraLaunchConfig(), + scope = testScope + ) + + assertThat(testCamera.initializedSettings).isNull() + + val constraints = repository.systemConstraints.filterNotNull().first() + assertThat(testCamera.initializedSettings).isNotNull() + assertThat(constraints).isEqualTo(testCamera.getSystemConstraints().value) + } + + @Test + fun getInitialDefaultCameraAppSettings_returnsDefaultSettings() = testScope.runTest { + val testCamera = TestCameraSystem() + val settingsRepository = FakeSettingsRepository() + + val repository = CameraXCameraSystemRepository( + cameraXCameraSystemProvider = Provider { testCamera }, + settingsRepository = settingsRepository, + launchConfig = CameraLaunchConfig( + externalCaptureMode = ExternalCaptureMode.ImageCapture + ), + scope = testScope + ) + + assertThat(repository.getInitialDefaultCameraAppSettings()) + .isEqualTo(settingsRepository.getCurrentDefaultCameraAppSettings()) + assertThat(testCamera.initializedSettings?.captureMode).isEqualTo(CaptureMode.IMAGE_ONLY) + } + @Test fun getSupportedMimeTypes_initializesAndReturnsMimeTypes() = testScope.runTest { val testCamera = TestCameraSystem() diff --git a/data/settings/src/main/java/com/google/jetpackcamera/settings/ConstraintsModule.kt b/data/settings/src/main/java/com/google/jetpackcamera/settings/ConstraintsModule.kt deleted file mode 100644 index 9886c58428..0000000000 --- a/data/settings/src/main/java/com/google/jetpackcamera/settings/ConstraintsModule.kt +++ /dev/null @@ -1,48 +0,0 @@ -/* - * 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.settings - -import dagger.Binds -import dagger.Module -import dagger.hilt.InstallIn -import dagger.hilt.android.components.ActivityRetainedComponent -import dagger.hilt.android.scopes.ActivityRetainedScoped - -/** - * Dagger [Module] for constraints data layer. - */ -@Module -@InstallIn(ActivityRetainedComponent::class) -interface ConstraintsModule { - - @Binds - @ActivityRetainedScoped - fun bindsSettableConstraintsRepository( - settableConstraintsRepository: SettableConstraintsRepositoryImpl - ): SettableConstraintsRepository - - /** - * ConstraintsRepository without setter. - * - * This is the same instance as the activity-retained - * SettableConstraintsRepository, but does not - * have the ability to update the constraints. - */ - @Binds - fun bindsConstraintsRepository( - constraintsRepository: SettableConstraintsRepository - ): ConstraintsRepository -} diff --git a/data/settings/src/main/java/com/google/jetpackcamera/settings/ConstraintsRepository.kt b/data/settings/src/main/java/com/google/jetpackcamera/settings/ConstraintsRepository.kt index f02b13e2ef..6be63807db 100644 --- a/data/settings/src/main/java/com/google/jetpackcamera/settings/ConstraintsRepository.kt +++ b/data/settings/src/main/java/com/google/jetpackcamera/settings/ConstraintsRepository.kt @@ -16,26 +16,8 @@ package com.google.jetpackcamera.settings import com.google.jetpackcamera.settings.model.CameraSystemConstraints -import javax.inject.Inject -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow interface ConstraintsRepository { val systemConstraints: StateFlow } - -interface SettableConstraintsRepository : ConstraintsRepository { - fun updateSystemConstraints(systemConstraints: CameraSystemConstraints) -} - -class SettableConstraintsRepositoryImpl @Inject constructor() : SettableConstraintsRepository { - - private val _systemConstraints = MutableStateFlow(null) - override val systemConstraints: StateFlow - get() = _systemConstraints.asStateFlow() - - override fun updateSystemConstraints(systemConstraints: CameraSystemConstraints) { - _systemConstraints.value = systemConstraints - } -} diff --git a/data/settings/testing/src/main/java/com/google/jetpackcamera/settings/testing/FakeConstraintsRepository.kt b/data/settings/testing/src/main/java/com/google/jetpackcamera/settings/testing/FakeConstraintsRepository.kt new file mode 100644 index 0000000000..c78cf5258e --- /dev/null +++ b/data/settings/testing/src/main/java/com/google/jetpackcamera/settings/testing/FakeConstraintsRepository.kt @@ -0,0 +1,34 @@ +/* + * 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.settings.testing + +import com.google.jetpackcamera.settings.ConstraintsRepository +import com.google.jetpackcamera.settings.model.CameraSystemConstraints +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +class FakeConstraintsRepository( + initialConstraints: CameraSystemConstraints? = null +) : ConstraintsRepository { + private val _systemConstraints = MutableStateFlow(initialConstraints) + override val systemConstraints: StateFlow = + _systemConstraints.asStateFlow() + + fun setSystemConstraints(systemConstraints: CameraSystemConstraints?) { + _systemConstraints.value = systemConstraints + } +} diff --git a/feature/permissions/src/main/java/com/google/jetpackcamera/permissions/navigation/PermissionsNavigation.kt b/feature/permissions/src/main/java/com/google/jetpackcamera/permissions/navigation/PermissionsNavigation.kt index 3f9d9923ec..2dac50b6bf 100644 --- a/feature/permissions/src/main/java/com/google/jetpackcamera/permissions/navigation/PermissionsNavigation.kt +++ b/feature/permissions/src/main/java/com/google/jetpackcamera/permissions/navigation/PermissionsNavigation.kt @@ -17,6 +17,7 @@ package com.google.jetpackcamera.permissions.navigation import androidx.lifecycle.SavedStateHandle import androidx.navigation.NavController +import androidx.navigation.NavDestination import androidx.navigation.NavGraphBuilder import androidx.navigation.NavOptionsBuilder import androidx.navigation.NavType @@ -79,6 +80,8 @@ fun NavGraphBuilder.permissionsScreen( } } +fun NavDestination.isPermissionsRoute(): Boolean = route?.substringBefore('?') == BASE_ROUTE_DEF + fun NavOptionsBuilder.popUpToPermissions() { popUpTo(BASE_ROUTE_DEF) { inclusive = true 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 7126cb9bfd..107da95c17 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 @@ -38,7 +38,6 @@ import com.google.jetpackcamera.model.LowLightBoostState import com.google.jetpackcamera.model.SaveLocation import com.google.jetpackcamera.model.SaveMode import com.google.jetpackcamera.model.VideoCaptureEvent -import com.google.jetpackcamera.settings.SettableConstraintsRepository import com.google.jetpackcamera.settings.SettingsRepository import com.google.jetpackcamera.settings.model.CameraAppSettings import com.google.jetpackcamera.ui.components.capture.R @@ -75,7 +74,6 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update @@ -92,7 +90,6 @@ class PreviewViewModel @Inject constructor( private val savedStateHandle: SavedStateHandle, private val defaultSaveMode: SaveMode, private val settingsRepository: SettingsRepository, - private val constraintsRepository: SettableConstraintsRepository, private val mediaRepository: MediaRepository ) : ViewModel() { private val saveMode: SaveMode = savedStateHandle.getRequestedSaveMode() ?: defaultSaveMode @@ -124,7 +121,7 @@ class PreviewViewModel @Inject constructor( val captureUiState: StateFlow = captureUiState( currentSettings = cameraSystemRepository.currentSettings, - systemConstraints = constraintsRepository.systemConstraints, + systemConstraints = cameraSystemRepository.systemConstraints, currentCameraState = cameraSystemRepository.currentCameraState, trackedCaptureUiState = trackedCaptureUiState, externalCaptureMode = externalCaptureMode @@ -136,7 +133,7 @@ class PreviewViewModel @Inject constructor( ) val debugUiState: StateFlow = debugUiState( currentSettings = cameraSystemRepository.currentSettings, - systemConstraints = constraintsRepository.systemConstraints, + systemConstraints = cameraSystemRepository.systemConstraints, currentCameraState = cameraSystemRepository.currentCameraState, debugSettings = debugSettings, cameraPropertiesJSON = cameraSystemRepository.cameraPropertiesJSON, @@ -242,20 +239,14 @@ class PreviewViewModel @Inject constructor( init { viewModelScope.launch { launch { - cameraSystemRepository.systemConstraints - .filterNotNull() - .collect { constraints -> - constraintsRepository.updateSystemConstraints(constraints) - } - } - - launch { - var oldCameraAppSettings: CameraAppSettings? = null + var oldCameraAppSettings: CameraAppSettings = + cameraSystemRepository.getInitialDefaultCameraAppSettings() settingsRepository.defaultCameraAppSettings .collect { new -> - oldCameraAppSettings?.apply { - applyDiffs(new, cameraSystemRepository.getCameraSystem()) - } + oldCameraAppSettings.applyDiffs( + new, + cameraSystemRepository.getCameraSystem() + ) oldCameraAppSettings = new } } diff --git a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/navigation/PreviewNavigation.kt b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/navigation/PreviewNavigation.kt index 1021d6e685..1026bcc83f 100644 --- a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/navigation/PreviewNavigation.kt +++ b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/navigation/PreviewNavigation.kt @@ -15,11 +15,8 @@ */ package com.google.jetpackcamera.feature.preview.navigation -import android.Manifest import android.net.Uri -import android.os.Build import androidx.compose.animation.fadeIn -import androidx.compose.runtime.LaunchedEffect import androidx.core.net.toUri import androidx.lifecycle.SavedStateHandle import androidx.navigation.NavController @@ -28,9 +25,6 @@ import androidx.navigation.NavOptionsBuilder import androidx.navigation.NavType import androidx.navigation.compose.composable import androidx.navigation.navArgument -import com.google.accompanist.permissions.ExperimentalPermissionsApi -import com.google.accompanist.permissions.isGranted -import com.google.accompanist.permissions.rememberMultiplePermissionsState import com.google.jetpackcamera.feature.preview.PreviewScreen import com.google.jetpackcamera.feature.preview.navigation.PreviewRoute.ARG_CAPTURE_URIS import com.google.jetpackcamera.feature.preview.navigation.PreviewRoute.ARG_DEBUG_SETTINGS @@ -102,7 +96,6 @@ fun NavController.navigateToPreview( this.navigate(route, builder) } -@OptIn(ExperimentalPermissionsApi::class) fun NavGraphBuilder.previewScreen( externalCaptureMode: ExternalCaptureMode, shouldCacheReview: Boolean, @@ -112,7 +105,6 @@ fun NavGraphBuilder.previewScreen( onFirstFrameCaptureCompleted: () -> Unit, onNavigateToSettings: () -> Unit, onNavigateToPostCapture: () -> Unit, - onNavigateToPermissions: () -> Unit, onCaptureEvent: (CaptureEvent) -> Unit ) { composable( @@ -137,23 +129,6 @@ fun NavGraphBuilder.previewScreen( ), enterTransition = { fadeIn() } ) { - val permissionStates = rememberMultiplePermissionsState( - permissions = - buildList { - add(Manifest.permission.CAMERA) - add(Manifest.permission.RECORD_AUDIO) - if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) { - add(Manifest.permission.WRITE_EXTERNAL_STORAGE) - add(Manifest.permission.READ_EXTERNAL_STORAGE) - } - } - ) - // Automatically navigate to permissions screen when camera permission revoked - LaunchedEffect(key1 = permissionStates.permissions[0].status) { - if (!permissionStates.permissions[0].status.isGranted) { - onNavigateToPermissions() - } - } PreviewScreen( onNavigateToSettings = onNavigateToSettings, onNavigateToPostCapture = onNavigateToPostCapture, @@ -164,12 +139,6 @@ fun NavGraphBuilder.previewScreen( } } -fun NavOptionsBuilder.popUpToPreview() { - popUpTo(BASE_ROUTE_DEF) { - inclusive = true - } -} - internal fun SavedStateHandle.getRequestedSaveMode(): SaveMode? { val requestedCaptureReview = get(ARG_REVIEW_AFTER_CAPTURE) ?: false return if (requestedCaptureReview) { diff --git a/feature/preview/src/test/java/com/google/jetpackcamera/feature/preview/PreviewViewModelTest.kt b/feature/preview/src/test/java/com/google/jetpackcamera/feature/preview/PreviewViewModelTest.kt index f404486bae..e4b76bae3e 100644 --- a/feature/preview/src/test/java/com/google/jetpackcamera/feature/preview/PreviewViewModelTest.kt +++ b/feature/preview/src/test/java/com/google/jetpackcamera/feature/preview/PreviewViewModelTest.kt @@ -27,7 +27,6 @@ import com.google.jetpackcamera.data.media.testing.FakeMediaRepository import com.google.jetpackcamera.model.FlashMode import com.google.jetpackcamera.model.LensFacing import com.google.jetpackcamera.model.SaveMode -import com.google.jetpackcamera.settings.SettableConstraintsRepositoryImpl import com.google.jetpackcamera.settings.model.CameraAppSettings import com.google.jetpackcamera.settings.model.TYPICAL_SYSTEM_CONSTRAINTS import com.google.jetpackcamera.settings.testing.FakeSettingsRepository @@ -52,7 +51,9 @@ import org.robolectric.RobolectricTestRunner @RunWith(RobolectricTestRunner::class) class PreviewViewModelTest { - private val cameraSystem = FakeCameraSystem() + private val cameraSystem = FakeCameraSystem().apply { + setSystemConstraints(TYPICAL_SYSTEM_CONSTRAINTS) + } private val cameraSystemRepository = object : CameraSystemRepository { override val surfaceRequest = cameraSystem.getSurfaceRequest() override val systemConstraints = cameraSystem.getSystemConstraints() @@ -64,11 +65,10 @@ class PreviewViewModelTest { cameraSystem.initialize(CameraAppSettings()) {} return cameraSystem } + override suspend fun getInitialDefaultCameraAppSettings(): CameraAppSettings = + CameraAppSettings() override suspend fun getSupportedMimeTypes(): List = emptyList() } - private val constraintsRepository = SettableConstraintsRepositoryImpl().apply { - updateSystemConstraints(TYPICAL_SYSTEM_CONSTRAINTS) - } private lateinit var previewViewModel: PreviewViewModel @Before @@ -76,7 +76,6 @@ class PreviewViewModelTest { Dispatchers.setMain(StandardTestDispatcher()) previewViewModel = PreviewViewModel( cameraSystemRepository = cameraSystemRepository, - constraintsRepository = constraintsRepository, settingsRepository = FakeSettingsRepository(), mediaRepository = FakeMediaRepository(), savedStateHandle = SavedStateHandle(), @@ -183,6 +182,23 @@ class PreviewViewModelTest { assertThat(cameraSystem.isLensFacingFront).isTrue() } + @Test + fun defaultSettingsChangedBeforeCreation_propagatesToCameraSystem() = + runTest(StandardTestDispatcher()) { + previewViewModel = PreviewViewModel( + cameraSystemRepository = cameraSystemRepository, + settingsRepository = FakeSettingsRepository( + CameraAppSettings(cameraLensFacing = LensFacing.FRONT) + ), + mediaRepository = FakeMediaRepository(), + savedStateHandle = SavedStateHandle(), + defaultSaveMode = SaveMode.Immediate + ) + startCameraUntilRunning() + + assertThat(cameraSystem.isLensFacingFront).isTrue() + } + private fun TestScope.startCameraUntilRunning() { previewViewModel.cameraController.startCamera() advanceUntilIdle() diff --git a/feature/settings/build.gradle.kts b/feature/settings/build.gradle.kts index 181538597c..2c21ffaa08 100644 --- a/feature/settings/build.gradle.kts +++ b/feature/settings/build.gradle.kts @@ -104,6 +104,7 @@ dependencies { testImplementation(libs.robolectric) testImplementation(project(":core:settings:datastore-prefs")) testImplementation(project(":core:settings:datastore-prefs:testing")) + testImplementation(project(":data:settings:testing")) testImplementation(libs.androidx.datastore.preferences) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) diff --git a/feature/settings/src/main/java/com/google/jetpackcamera/settings/SettingsScreen.kt b/feature/settings/src/main/java/com/google/jetpackcamera/settings/SettingsScreen.kt index 89108f5cb5..2e437cc2cc 100644 --- a/feature/settings/src/main/java/com/google/jetpackcamera/settings/SettingsScreen.kt +++ b/feature/settings/src/main/java/com/google/jetpackcamera/settings/SettingsScreen.kt @@ -18,10 +18,14 @@ package com.google.jetpackcamera.settings import android.Manifest import android.content.res.Configuration import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold @@ -30,11 +34,13 @@ import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.rememberMultiplePermissionsState @@ -141,13 +147,23 @@ private fun SettingsScreen( ) } ) { innerPadding -> - Column( - modifier = Modifier - .padding(innerPadding) - .verticalScroll(rememberScrollState()) - .background(color = MaterialTheme.colorScheme.background) - ) { - if (uiState is SettingsUiState.Enabled) { + when (uiState) { + SettingsUiState.Loading -> Box( + modifier = Modifier + .padding(innerPadding) + .fillMaxSize() + .background(color = MaterialTheme.colorScheme.background), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(modifier = Modifier.size(50.dp)) + } + + is SettingsUiState.Enabled -> Column( + modifier = Modifier + .padding(innerPadding) + .verticalScroll(rememberScrollState()) + .background(color = MaterialTheme.colorScheme.background) + ) { SettingsList( uiState = uiState, versionInfo = versionInfo, diff --git a/feature/settings/src/main/java/com/google/jetpackcamera/settings/SettingsUiState.kt b/feature/settings/src/main/java/com/google/jetpackcamera/settings/SettingsUiState.kt index 2baa12c972..2f7bfdca2e 100644 --- a/feature/settings/src/main/java/com/google/jetpackcamera/settings/SettingsUiState.kt +++ b/feature/settings/src/main/java/com/google/jetpackcamera/settings/SettingsUiState.kt @@ -45,7 +45,7 @@ internal const val SIXTY_SECONDS_DURATION = 60_000L * Defines the current state of the [SettingsScreen]. */ sealed interface SettingsUiState { - data object Disabled : SettingsUiState + data object Loading : SettingsUiState data class Enabled( val aspectRatioUiState: AspectRatioUiState, val cameraEffectUiState: CameraEffectUiState, diff --git a/feature/settings/src/main/java/com/google/jetpackcamera/settings/SettingsViewModel.kt b/feature/settings/src/main/java/com/google/jetpackcamera/settings/SettingsViewModel.kt index 6fd896dd56..51c1d5ad46 100644 --- a/feature/settings/src/main/java/com/google/jetpackcamera/settings/SettingsViewModel.kt +++ b/feature/settings/src/main/java/com/google/jetpackcamera/settings/SettingsViewModel.kt @@ -105,7 +105,7 @@ class SettingsViewModel @Inject constructor( }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5_000), - initialValue = SettingsUiState.Disabled + initialValue = SettingsUiState.Loading ) // //////////////////////////////////////////////////////////// diff --git a/feature/settings/src/test/java/com/google/jetpackcamera/settings/CameraAppSettingsViewModelTest.kt b/feature/settings/src/test/java/com/google/jetpackcamera/settings/CameraAppSettingsViewModelTest.kt index 6be7a89e7d..e852557323 100644 --- a/feature/settings/src/test/java/com/google/jetpackcamera/settings/CameraAppSettingsViewModelTest.kt +++ b/feature/settings/src/test/java/com/google/jetpackcamera/settings/CameraAppSettingsViewModelTest.kt @@ -35,6 +35,8 @@ import com.google.jetpackcamera.model.LensFacing import com.google.jetpackcamera.model.StabilizationMode import com.google.jetpackcamera.settings.model.CameraSystemConstraints import com.google.jetpackcamera.settings.model.TYPICAL_SYSTEM_CONSTRAINTS +import com.google.jetpackcamera.settings.testing.FakeConstraintsRepository +import com.google.jetpackcamera.settings.testing.FakeSettingsRepository import java.io.File import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -42,6 +44,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest @@ -115,9 +118,7 @@ internal class CameraAppSettingsViewModelTest { val settingsRepository = LocalSettingsRepository( settingsDataSource = settingsDataSource ) - val constraintsRepository = SettableConstraintsRepositoryImpl().apply { - updateSystemConstraints(TYPICAL_SYSTEM_CONSTRAINTS) - } + val constraintsRepository = FakeConstraintsRepository(TYPICAL_SYSTEM_CONSTRAINTS) settingsViewModel = SettingsViewModel( settingsRepository, constraintsRepository @@ -144,6 +145,26 @@ internal class CameraAppSettingsViewModelTest { ) } + /** + * Verifies that the settings UI state stays loading, rather than showing an empty screen, + * until the camera system constraints become available. + */ + @Test + fun settingsUiState_whenConstraintsUnavailable_isLoading() = runTest(StandardTestDispatcher()) { + val constraintsRepository = FakeConstraintsRepository() + val customViewModel = SettingsViewModel(FakeSettingsRepository(), constraintsRepository) + backgroundScope.launch { customViewModel.settingsUiState.collect {} } + advanceUntilIdle() + + assertThat(customViewModel.settingsUiState.value).isEqualTo(SettingsUiState.Loading) + + constraintsRepository.setSystemConstraints(TYPICAL_SYSTEM_CONSTRAINTS) + advanceUntilIdle() + + assertThat(customViewModel.settingsUiState.value) + .isInstanceOf(SettingsUiState.Enabled::class.java) + } + @Test fun setMute_permission_granted() = runTest(StandardTestDispatcher()) { // permission must be granted or the setting will be disabled @@ -250,9 +271,7 @@ internal class CameraAppSettingsViewModelTest { val settingsRepository = LocalSettingsRepository( settingsDataSource = settingsDataSource ) - val constraintsRepository = SettableConstraintsRepositoryImpl().apply { - updateSystemConstraints(systemConstraints) - } + val constraintsRepository = FakeConstraintsRepository(systemConstraints) return SettingsViewModel(settingsRepository, constraintsRepository).apply { setGrantedPermissions(mutableSetOf(Manifest.permission.RECORD_AUDIO)) } diff --git a/ui/uistateadapter/capture/src/test/java/com/google/jetpackcamera/ui/uistateadapter/capture/CaptureUiStateAdapterTest.kt b/ui/uistateadapter/capture/src/test/java/com/google/jetpackcamera/ui/uistateadapter/capture/CaptureUiStateAdapterTest.kt index d99d448a24..a17b84903e 100644 --- a/ui/uistateadapter/capture/src/test/java/com/google/jetpackcamera/ui/uistateadapter/capture/CaptureUiStateAdapterTest.kt +++ b/ui/uistateadapter/capture/src/test/java/com/google/jetpackcamera/ui/uistateadapter/capture/CaptureUiStateAdapterTest.kt @@ -24,7 +24,6 @@ import com.google.jetpackcamera.model.ExternalCaptureMode import com.google.jetpackcamera.model.FlashMode import com.google.jetpackcamera.model.Illuminant import com.google.jetpackcamera.model.LensFacing -import com.google.jetpackcamera.settings.SettableConstraintsRepositoryImpl import com.google.jetpackcamera.settings.model.CameraConstraints import com.google.jetpackcamera.settings.model.CameraSystemConstraints import com.google.jetpackcamera.settings.model.TYPICAL_SYSTEM_CONSTRAINTS @@ -49,16 +48,15 @@ import org.robolectric.RobolectricTestRunner @RunWith(RobolectricTestRunner::class) internal class CaptureUiStateAdapterTest { - private val cameraSystem = FakeCameraSystem() - private val constraintsRepository = SettableConstraintsRepositoryImpl().apply { - updateSystemConstraints(TYPICAL_SYSTEM_CONSTRAINTS) + private val cameraSystem = FakeCameraSystem().apply { + setSystemConstraints(TYPICAL_SYSTEM_CONSTRAINTS) } private val trackedCaptureUiState = MutableStateFlow(TrackedCaptureUiState()) private val externalCaptureMode = ExternalCaptureMode.Standard private fun createCaptureUiStateFlow() = captureUiState( currentSettings = cameraSystem.getCurrentSettings(), - systemConstraints = constraintsRepository.systemConstraints, + systemConstraints = cameraSystem.getSystemConstraints(), currentCameraState = cameraSystem.getCurrentCameraState(), trackedCaptureUiState = trackedCaptureUiState, externalCaptureMode = externalCaptureMode @@ -144,7 +142,7 @@ internal class CaptureUiStateAdapterTest { @Test fun captureUiState_flashModeUpdate_emitsUpdatedState() = runTest { - constraintsRepository.updateSystemConstraints( + cameraSystem.setSystemConstraints( CameraSystemConstraints( availableLenses = listOf(LensFacing.BACK), perLensConstraints = mapOf(