Track state of camera permission

This commit is contained in:
Pierre-Yves Nicolas
2025-08-23 17:32:58 +02:00
committed by pynicolas
parent 2c64ebc972
commit eb1f3b64ed
5 changed files with 59 additions and 34 deletions

View File

@@ -22,27 +22,34 @@ import androidx.activity.compose.ManagedActivityResultLauncher
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
fun hasCameraPermission(context: Context): Boolean { private fun hasCameraPermission(context: Context): Boolean {
val camera = Manifest.permission.CAMERA val camera = Manifest.permission.CAMERA
return ContextCompat.checkSelfPermission(context, camera) == PackageManager.PERMISSION_GRANTED return ContextCompat.checkSelfPermission(context, camera) == PackageManager.PERMISSION_GRANTED
} }
@Composable @Stable
fun rememberCameraPermissionLauncher( class CameraPermissionState internal constructor(
onGranted: () -> Unit = {}, private val context: Context,
onDenied: () -> Unit = {} private val launcher: ManagedActivityResultLauncher<String, Boolean>
): ManagedActivityResultLauncher<String, Boolean> { ) {
val context = LocalContext.current var isGranted by mutableStateOf(hasCameraPermission(context))
return rememberLauncherForActivityResult ( private set
ActivityResultContracts.RequestPermission()
) { isGranted -> fun request() {
if (isGranted) { launcher.launch(Manifest.permission.CAMERA)
onGranted() }
} else {
onDenied() internal fun update(granted: Boolean) {
isGranted = granted
if (!granted) {
Toast.makeText( Toast.makeText(
context, context,
context.getString(R.string.camera_permission_denied), context.getString(R.string.camera_permission_denied),
@@ -51,3 +58,21 @@ fun rememberCameraPermissionLauncher(
} }
} }
} }
@Composable
fun rememberCameraPermissionState(): CameraPermissionState {
val context = LocalContext.current
lateinit var state: CameraPermissionState
val launcher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted ->
state.update(granted)
}
state = remember {
CameraPermissionState(context, launcher)
}
return state
}

View File

@@ -60,6 +60,7 @@ class MainActivity : ComponentActivity() {
val currentScreen by viewModel.currentScreen.collectAsStateWithLifecycle() val currentScreen by viewModel.currentScreen.collectAsStateWithLifecycle()
val liveAnalysisState by viewModel.liveAnalysisState.collectAsStateWithLifecycle() val liveAnalysisState by viewModel.liveAnalysisState.collectAsStateWithLifecycle()
val document by viewModel.documentUiModel.collectAsStateWithLifecycle() val document by viewModel.documentUiModel.collectAsStateWithLifecycle()
val cameraPermission = rememberCameraPermissionState()
MyScanTheme { MyScanTheme {
val navigation = Navigation( val navigation = Navigation(
toHomeScreen = { viewModel.navigateTo(Screen.Home) }, toHomeScreen = { viewModel.navigateTo(Screen.Home) },
@@ -72,7 +73,7 @@ class MainActivity : ComponentActivity() {
when (val screen = currentScreen) { when (val screen = currentScreen) {
is Screen.Home -> { is Screen.Home -> {
HomeScreen( HomeScreen(
hasCameraPermission = hasCameraPermission(this), cameraPermission = cameraPermission,
currentDocument = document, currentDocument = document,
navigation = navigation, navigation = navigation,
onStartNewScan = navigation.toCameraScreen, onStartNewScan = navigation.toCameraScreen,
@@ -85,6 +86,7 @@ class MainActivity : ComponentActivity() {
liveAnalysisState, liveAnalysisState,
onImageAnalyzed = { image -> viewModel.liveAnalysis(image) }, onImageAnalyzed = { image -> viewModel.liveAnalysis(image) },
onFinalizePressed = { viewModel.navigateTo(Screen.Document()) }, onFinalizePressed = { viewModel.navigateTo(Screen.Document()) },
cameraPermission = cameraPermission
) )
} }
is Screen.Document -> { is Screen.Document -> {

View File

@@ -52,10 +52,9 @@ import androidx.core.graphics.scale
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.LocalLifecycleOwner
import com.google.common.util.concurrent.ListenableFuture import com.google.common.util.concurrent.ListenableFuture
import org.mydomain.myscan.CameraPermissionState
import org.mydomain.myscan.LiveAnalysisState import org.mydomain.myscan.LiveAnalysisState
import org.mydomain.myscan.Point import org.mydomain.myscan.Point
import org.mydomain.myscan.hasCameraPermission
import org.mydomain.myscan.rememberCameraPermissionLauncher
import org.mydomain.myscan.scaledTo import org.mydomain.myscan.scaledTo
import java.util.concurrent.ExecutorService import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors import java.util.concurrent.Executors
@@ -66,13 +65,12 @@ fun CameraPreview(
onImageAnalyzed: (ImageProxy) -> Unit, onImageAnalyzed: (ImageProxy) -> Unit,
captureController: CameraCaptureController, captureController: CameraCaptureController,
onPreviewViewReady: (PreviewView) -> Unit, onPreviewViewReady: (PreviewView) -> Unit,
cameraPermission: CameraPermissionState,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val requestPermissionLauncher = rememberCameraPermissionLauncher(onGranted = {}, onDenied = {})
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
val camera = android.Manifest.permission.CAMERA if (!cameraPermission.isGranted) {
if (!hasCameraPermission(context)) { cameraPermission.request()
requestPermissionLauncher.launch(camera)
} }
} }

View File

@@ -75,6 +75,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import org.mydomain.myscan.CameraPermissionState
import org.mydomain.myscan.LiveAnalysisState import org.mydomain.myscan.LiveAnalysisState
import org.mydomain.myscan.MainViewModel import org.mydomain.myscan.MainViewModel
import org.mydomain.myscan.MainViewModel.CaptureState import org.mydomain.myscan.MainViewModel.CaptureState
@@ -102,6 +103,7 @@ fun CameraScreen(
liveAnalysisState: LiveAnalysisState, liveAnalysisState: LiveAnalysisState,
onImageAnalyzed: (ImageProxy) -> Unit, onImageAnalyzed: (ImageProxy) -> Unit,
onFinalizePressed: () -> Unit, onFinalizePressed: () -> Unit,
cameraPermission: CameraPermissionState,
) { ) {
var previewView by remember { mutableStateOf<PreviewView?>(null) } var previewView by remember { mutableStateOf<PreviewView?>(null) }
val document by viewModel.documentUiModel.collectAsStateWithLifecycle() val document by viewModel.documentUiModel.collectAsStateWithLifecycle()
@@ -145,7 +147,8 @@ fun CameraScreen(
CameraPreview( CameraPreview(
onImageAnalyzed = onImageAnalyzed, onImageAnalyzed = onImageAnalyzed,
captureController = captureController, captureController = captureController,
onPreviewViewReady = { view -> previewView = view } onPreviewViewReady = { view -> previewView = view },
cameraPermission = cameraPermission,
) )
}, },
pageListState = pageListState =

View File

@@ -14,7 +14,6 @@
*/ */
package org.mydomain.myscan.view package org.mydomain.myscan.view
import android.Manifest
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@@ -46,15 +45,16 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import org.mydomain.myscan.CameraPermissionState
import org.mydomain.myscan.Navigation import org.mydomain.myscan.Navigation
import org.mydomain.myscan.R import org.mydomain.myscan.R
import org.mydomain.myscan.rememberCameraPermissionLauncher import org.mydomain.myscan.rememberCameraPermissionState
import org.mydomain.myscan.ui.theme.MyScanTheme import org.mydomain.myscan.ui.theme.MyScanTheme
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun HomeScreen( fun HomeScreen(
hasCameraPermission: Boolean, cameraPermission: CameraPermissionState,
currentDocument: DocumentUiModel, currentDocument: DocumentUiModel,
navigation: Navigation, navigation: Navigation,
onStartNewScan: () -> Unit onStartNewScan: () -> Unit
@@ -95,8 +95,8 @@ fun HomeScreen(
.fillMaxSize() .fillMaxSize()
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
) { ) {
if (!hasCameraPermission) { if (!cameraPermission.isGranted) {
CameraPermissionRationale() CameraPermissionRationale(cameraPermission)
} }
if (!currentDocument.isEmpty()) { if (!currentDocument.isEmpty()) {
@@ -115,8 +115,7 @@ fun HomeScreen(
} }
@Composable @Composable
private fun CameraPermissionRationale() { private fun CameraPermissionRationale(cameraPermission: CameraPermissionState) {
val requestPermissionLauncher = rememberCameraPermissionLauncher(onGranted = {}, onDenied = {})
Card( Card(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -128,9 +127,7 @@ private fun CameraPermissionRationale() {
style = MaterialTheme.typography.bodyMedium style = MaterialTheme.typography.bodyMedium
) )
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Button(onClick = { Button(onClick = { cameraPermission.request() }) {
requestPermissionLauncher.launch(Manifest.permission.CAMERA)
}) {
Text(stringResource(R.string.grant_permission)) Text(stringResource(R.string.grant_permission))
} }
} }
@@ -183,7 +180,7 @@ private fun SectionTitle(text: String) {
fun HomeScreenPreviewOnFirstLaunch() { fun HomeScreenPreviewOnFirstLaunch() {
MyScanTheme { MyScanTheme {
HomeScreen( HomeScreen(
hasCameraPermission = false, cameraPermission = rememberCameraPermissionState(),
currentDocument = DocumentUiModel(listOf()) { _ -> null }, currentDocument = DocumentUiModel(listOf()) { _ -> null },
navigation = dummyNavigation(), navigation = dummyNavigation(),
onStartNewScan = {} onStartNewScan = {}
@@ -196,7 +193,7 @@ fun HomeScreenPreviewOnFirstLaunch() {
fun HomeScreenPreviewWithCurrentDocument() { fun HomeScreenPreviewWithCurrentDocument() {
MyScanTheme { MyScanTheme {
HomeScreen( HomeScreen(
hasCameraPermission = true, cameraPermission = rememberCameraPermissionState(),
currentDocument = fakeDocument( currentDocument = fakeDocument(
listOf("gallica.bnf.fr-bpt6k5530456s-1.jpg"), listOf("gallica.bnf.fr-bpt6k5530456s-1.jpg"),
LocalContext.current), LocalContext.current),