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
33 changes: 33 additions & 0 deletions app/src/main/java/com/google/jetpackcamera/di/PermissionsModule.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/*
* 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.di

import com.google.jetpackcamera.permissions.data.DataStorePermissionsRepository
import com.google.jetpackcamera.permissions.data.PermissionsRepository
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent

/**
* Dagger [Module] for permission onboarding dependencies.
*/
@Module
@InstallIn(SingletonComponent::class)
internal interface PermissionsModule {
@Binds
fun bindPermissionsRepository(impl: DataStorePermissionsRepository): PermissionsRepository
}
3 changes: 3 additions & 0 deletions app/src/main/java/com/google/jetpackcamera/ui/JcaApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,8 @@ private fun JetpackCameraNavHost(
add(android.Manifest.permission.CAMERA)
if (externalCaptureMode == ExternalCaptureMode.Standard) {
add(android.Manifest.permission.RECORD_AUDIO)
add(android.Manifest.permission.ACCESS_FINE_LOCATION)
add(android.Manifest.permission.ACCESS_COARSE_LOCATION)
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) {
add(android.Manifest.permission.WRITE_EXTERNAL_STORAGE)
}
Expand Down Expand Up @@ -152,6 +154,7 @@ private fun JetpackCameraNavHost(
buildType = BuildConfig.BUILD_TYPE
),
onNavigateBack = { navController.popBackStack() },
onOpenAppSettings = onOpenAppSettings,
cameraSettingsSlot = {
DefaultCameraSettings(
customEffectSlot = {
Expand Down
16 changes: 16 additions & 0 deletions feature/permissions/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ android {

defaultConfig {
minSdk = libs.versions.minSdk.get().toInt()
testOptions.targetSdk = libs.versions.targetSdk.get().toInt()

testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles("consumer-rules.pro")
Expand All @@ -48,6 +49,12 @@ android {
buildConfig = true
compose = true
}
testOptions {
unitTests {
isReturnDefaultValues = true
isIncludeAndroidResources = true
}
}
}

dependencies {
Expand All @@ -62,13 +69,15 @@ dependencies {
// Compose - Android Studio Preview support
implementation(libs.compose.ui.tooling.preview)
debugImplementation(libs.compose.ui.tooling)
debugImplementation(libs.compose.test.manifest)

// Compose - Integration with ViewModels with Navigation and Hilt
implementation(libs.androidx.navigation.compose)
implementation(libs.hilt.navigation.compose)


// Compose - Testing
testImplementation(libs.compose.junit)
androidTestImplementation(libs.compose.junit)

// Accompanist - Permissions
Expand All @@ -81,7 +90,14 @@ dependencies {

implementation(libs.androidx.core.ktx)
implementation(libs.androidx.appcompat)
implementation(libs.androidx.datastore.preferences)
implementation(project(":data:settings"))
testImplementation(project(":core:settings"))
testImplementation(project(":data:settings:testing"))
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.junit)
testImplementation(libs.robolectric)
testImplementation(libs.truth)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.res.painterResource
import com.google.jetpackcamera.permissions.ui.CAMERA_PERMISSION_BUTTON
import com.google.jetpackcamera.permissions.ui.LOCATION_PERMISSION_BUTTON
import com.google.jetpackcamera.permissions.ui.RECORD_AUDIO_PERMISSION_BUTTON
import com.google.jetpackcamera.permissions.ui.WRITE_EXTERNAL_STORAGE_PERMISSION_BUTTON

Expand All @@ -37,6 +38,18 @@ sealed interface PermissionInfoProvider {
*/
fun getPermission(): String

/**
* Returns the complete list of system runtime permissions associated with this entry.
*
* Supports composite permission requests (e.g., coarse and fine location).
*/
fun getPermissions(): List<String> = listOf(getPermission())

/**
* Whether this permission is optional during onboarding.
*
* Optional permissions can be declined or skipped without blocking app navigation.
*/
fun isOptional(): Boolean

fun getTestTag(): String
Expand Down Expand Up @@ -122,11 +135,42 @@ enum class PermissionEnum : PermissionInfoProvider {

override fun getIconAccessibilityTextResId(): Int =
R.string.write_storage_permission_accessibility_text
},

/**
* Location permission entry requesting both [Manifest.permission.ACCESS_FINE_LOCATION]
* and [Manifest.permission.ACCESS_COARSE_LOCATION].
*
* Marked as optional so users can opt out during initial onboarding.
*/
LOCATION {
override fun getPermission(): String = Manifest.permission.ACCESS_FINE_LOCATION

override fun getPermissions(): List<String> = listOf(
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION
)

override fun isOptional(): Boolean = true

override fun getTestTag(): String = LOCATION_PERMISSION_BUTTON

override fun getDrawableResId(): Int = R.drawable.ic_location_on

override fun getPermissionTitleResId(): Int = R.string.location_permission_screen_title

override fun getPermissionBodyTextResId(): Int =
R.string.location_permission_required_rationale

override fun getRationaleBodyTextResId(): Int? = null

override fun getIconAccessibilityTextResId(): Int =
R.string.location_permission_accessibility_text
};

companion object {
fun fromString(permission: String): PermissionEnum =
entries.firstOrNull { it.getPermission() == permission }
entries.firstOrNull { it.getPermissions().contains(permission) }
?: throw IllegalArgumentException("Unknown permission: $permission")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.key
import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel
import com.google.accompanist.permissions.ExperimentalPermissionsApi
Expand Down Expand Up @@ -51,20 +51,23 @@ fun PermissionsScreen(
}
}

LaunchedEffect(permissionStates) {
LaunchedEffect(permissionStates.permissions.map { it.status }) {
viewModel.updatePermissionStates(permissionStates)
}

if (permissionsUiState is PermissionsUiState.PermissionsNeeded) {
val permissionEnum =
(permissionsUiState as PermissionsUiState.PermissionsNeeded).currentPermission

val currentPermissionStates by rememberUpdatedState(permissionStates)
PermissionTemplate(
modifier = modifier,
permissionEnum = permissionEnum,
onDismissPermission = { viewModel.updatePermissionStates(currentPermissionStates) },
onOpenAppSettings = onOpenAppSettings
)
key(permissionEnum) {
PermissionTemplate(
modifier = modifier,
permissionEnum = permissionEnum,
onDismissPermission = {
viewModel.dismissPermission(permissionEnum)
},
onOpenAppSettings = onOpenAppSettings
)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.MultiplePermissionsState
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.shouldShowRationale
import com.google.jetpackcamera.permissions.data.PermissionsRepository
import com.google.jetpackcamera.permissions.navigation.getRequestablePermissions
import com.google.jetpackcamera.settings.SettingsRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
Expand All @@ -31,14 +33,17 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch

/**
* A [ViewModel] for [PermissionsScreen]]
*/
@OptIn(ExperimentalPermissionsApi::class)
@HiltViewModel()
class PermissionsViewModel @Inject constructor(
savedStateHandle: SavedStateHandle
savedStateHandle: SavedStateHandle,
private val settingsRepository: SettingsRepository,
private val permissionsRepository: PermissionsRepository
) : ViewModel() {

// Initialize required permissions from savedStateHandle. Assume all permissions are not yet
Expand All @@ -58,9 +63,62 @@ class PermissionsViewModel @Inject constructor(
PermissionsUiState.PermissionsNeeded(first())
}

private var lastLocationGranted: Boolean? = null
private val dismissedPermissions = mutableSetOf<PermissionEnum>()
private var requestedPermissions = emptySet<String>()
private var latestPermissionsState: MultiplePermissionsState? = null

init {
viewModelScope.launch {
permissionsRepository.requestedPermissions.collect { requested ->
requestedPermissions = requested
latestPermissionsState?.let { state ->
recomputeQueue(state)
}
}
}
}

/**
* Dismisses [permission] without granting it.
*
* The permission is removed from the current queue, excluded for the rest of this session,
* and recorded as requested in [PermissionsRepository] so it is not shown again.
*
* @param permission The permission the user chose to skip.
*/
internal fun dismissPermission(permission: PermissionEnum) {
dismissedPermissions.add(permission)
viewModelScope.launch {
permissionsRepository.markPermissionRequested(permission.name)
}
permissionQueue.update { queue ->
queue.filter { it != permission }
}
}

fun updatePermissionStates(multiplePermissionsState: MultiplePermissionsState) {
latestPermissionsState = multiplePermissionsState
val isLocationGranted = multiplePermissionsState.permissions.any {
it.permission in PermissionEnum.LOCATION.getPermissions() && it.status.isGranted
}

if (lastLocationGranted == false && isLocationGranted) {
viewModelScope.launch {
settingsRepository.updateLocationEnabled(true)
}
}
lastLocationGranted = isLocationGranted

recomputeQueue(multiplePermissionsState)
}

private fun recomputeQueue(multiplePermissionsState: MultiplePermissionsState) {
permissionQueue.update {
getRequestablePermissions(multiplePermissionsState)
getRequestablePermissions(
multiplePermissionsState,
requestedPermissions
).filter { it !in dismissedPermissions }
}
}
}
Expand All @@ -73,14 +131,25 @@ class PermissionsViewModel @Inject constructor(
* - optional permissions that have not yet been denied by the user
*/
@OptIn(ExperimentalPermissionsApi::class)
fun getRequestablePermissions(permissionStates: MultiplePermissionsState): List<PermissionEnum> =
buildList {
permissionStates.permissions.forEach { permissionState ->
val permission = PermissionEnum.fromString(permissionState.permission)
if (!permissionState.status.isGranted) {
if (!permission.isOptional() || !permissionState.status.shouldShowRationale) {
add(permission)
}
fun getRequestablePermissions(
permissionStates: MultiplePermissionsState,
requestedPermissions: Set<String> = emptySet()
): List<PermissionEnum> = permissionStates.permissions
.map { PermissionEnum.fromString(it.permission) }
.distinct()
.filter { permission ->
val componentStates = permissionStates.permissions.filter {
it.permission in permission.getPermissions()
}
val isAnyGranted = componentStates.any { it.status.isGranted }
when {
isAnyGranted -> false
!permission.isOptional() -> true
else -> {
val wasPreviouslyHandled =
permission.name in requestedPermissions ||
componentStates.any { it.status.shouldShowRationale }
!wasPreviouslyHandled
}
}
}
Loading
Loading