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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions app/src/main/java/com/google/jetpackcamera/di/CameraModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -49,18 +50,28 @@ internal object CameraModule {

@Provides
@ActivityRetainedScoped
fun providesCameraSystemRepository(
fun providesCameraXCameraSystemRepository(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

According to the repository style guide, we should use the most restrictive visibility modifier possible. Since CameraModule is an internal object, this provides method should be marked internal.

Suggested change
fun providesCameraXCameraSystemRepository(
internal fun providesCameraXCameraSystemRepository(
References
  1. Ensure all new functions, properties, and classes use the most restrictive visibility modifier possible (e.g., private, internal) while still allowing necessary access. Avoid public visibility unless explicitly required for external API exposure. (link)

cameraXCameraSystemProvider: Provider<CameraXCameraSystem>,
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
Comment on lines +65 to +73

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Following the repository style guide, these new provides methods should also be marked internal to restrict their visibility appropriately.

Suggested change
@Provides
fun providesCameraSystemRepository(
repository: CameraXCameraSystemRepository
): CameraSystemRepository = repository
@Provides
fun providesConstraintsRepository(
repository: CameraXCameraSystemRepository
): ConstraintsRepository = repository
@Provides
internal fun providesCameraSystemRepository(
repository: CameraXCameraSystemRepository
): CameraSystemRepository = repository
@Provides
internal fun providesConstraintsRepository(
repository: CameraXCameraSystemRepository
): ConstraintsRepository = repository
References
  1. Ensure all new functions, properties, and classes use the most restrictive visibility modifier possible (e.g., private, internal) while still allowing necessary access. Avoid public visibility unless explicitly required for external API exposure. (link)


@Provides
@ActivityRetainedScoped
fun providesCameraXCameraSystem(
Expand Down
33 changes: 27 additions & 6 deletions app/src/main/java/com/google/jetpackcamera/ui/JcaApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -86,6 +90,8 @@ private fun JetpackCameraNavHost(
onCaptureEvent: (CaptureEvent) -> Unit,
navController: NavHostController = rememberNavController()
) {
CameraPermissionGuard(navController)

NavHost(
navController = navController,
startDestination = PermissionsRoute.toString(),
Expand Down Expand Up @@ -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
)

Expand Down Expand Up @@ -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
}
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<CameraSystemConstraints?>

Expand All @@ -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.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<out CameraSystem>,
private val settingsRepository: SettingsRepository,
private val launchConfigProvider: CameraLaunchConfigProvider,
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
) : CameraSystemRepository {
) : CameraSystemRepository, ConstraintsRepository {

constructor(
cameraXCameraSystemProvider: Provider<out CameraSystem>,
Expand All @@ -69,6 +70,7 @@ class CameraXCameraSystemRepository(
}

override val systemConstraints: StateFlow<CameraSystemConstraints?> by lazy {
initializationDeferred.start()
cameraSystem.getSystemConstraints()
}

Expand All @@ -83,22 +85,27 @@ class CameraXCameraSystemRepository(
private val _cameraPropertiesJSON = MutableStateFlow<String?>(null)
override val cameraPropertiesJSON: StateFlow<String?> = _cameraPropertiesJSON.asStateFlow()

private val initializationDeferred: Deferred<Unit> =
private val initializationDeferred: Deferred<CameraAppSettings> =
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 {
initializationDeferred.await()
return cameraSystem
}

override suspend fun getInitialDefaultCameraAppSettings(): CameraAppSettings =
initializationDeferred.await()

override suspend fun getSupportedMimeTypes(): List<String> {
initializationDeferred.await()
val constraints = systemConstraints.filterNotNull().first()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -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<CameraSystemConstraints?>
}

interface SettableConstraintsRepository : ConstraintsRepository {
fun updateSystemConstraints(systemConstraints: CameraSystemConstraints)
}

class SettableConstraintsRepositoryImpl @Inject constructor() : SettableConstraintsRepository {

private val _systemConstraints = MutableStateFlow<CameraSystemConstraints?>(null)
override val systemConstraints: StateFlow<CameraSystemConstraints?>
get() = _systemConstraints.asStateFlow()

override fun updateSystemConstraints(systemConstraints: CameraSystemConstraints) {
_systemConstraints.value = systemConstraints
}
}
Original file line number Diff line number Diff line change
@@ -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<CameraSystemConstraints?> =
_systemConstraints.asStateFlow()

fun setSystemConstraints(systemConstraints: CameraSystemConstraints?) {
_systemConstraints.value = systemConstraints
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -79,6 +80,8 @@ fun NavGraphBuilder.permissionsScreen(
}
}

fun NavDestination.isPermissionsRoute(): Boolean = route?.substringBefore('?') == BASE_ROUTE_DEF

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

According to the repository style guide, all non-private members must have KDoc documentation. Please add a KDoc comment for isPermissionsRoute.

/**
 * Returns whether this [NavDestination] corresponds to the permissions route.
 */
fun NavDestination.isPermissionsRoute(): Boolean = route?.substringBefore('?') == BASE_ROUTE_DEF
References
  1. Document all non-private members: All non-private classes, functions, and composables must have KDoc documentation. (link)


fun NavOptionsBuilder.popUpToPermissions() {
popUpTo(BASE_ROUTE_DEF) {
inclusive = true
Expand Down
Loading
Loading