Skip to content

Commit e2f2823

Browse files
authored
🔀 Merge pull request #514 from vinceglb/codex/android-camera-permission-issue
Fix Android camera permission handling for camera picker
2 parents 25453e2 + d458f72 commit e2f2823

File tree

7 files changed

+289
-9
lines changed

7 files changed

+289
-9
lines changed

‎docs/dialogs/camera-picker.mdx‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,17 @@ Button(onClick = { launcher.launch() }) {
3030

3131
The captured media file is automatically saved to the specified location (or cache directory by default). If you need to keep the file permanently, make sure to copy it to a permanent storage location.
3232

33+
## Android camera permission behavior
34+
35+
On Android, camera capture is launched with `ACTION_IMAGE_CAPTURE`.
36+
If your app declares `android.permission.CAMERA`, FileKit checks runtime permission before launching the camera:
37+
38+
- If camera permission is already granted, FileKit opens the camera as usual.
39+
- If camera permission is not granted, FileKit requests it first.
40+
- If the user denies the permission, FileKit returns `null` (same as cancel) and does not crash.
41+
42+
If your app only launches the external camera app through this picker, you usually do not need to declare `android.permission.CAMERA`.
43+
3344
## Camera type
3445

3546
You can specify the type of media to capture using the `type` parameter:

‎filekit-dialogs-compose/src/androidHostTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/AndroidComposePickerReliabilityTest.kt‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,59 @@ class AndroidComposePickerReliabilityTest {
5454
assertNull(result)
5555
}
5656

57+
@Test
58+
fun CameraPermission_denied_returnsNullResult() {
59+
val resolution = resolveCameraPermissionResult(
60+
permissionGranted = false,
61+
pendingDestinationUri = "content://example.provider/camera/photo.jpg",
62+
)
63+
64+
assertIs<CameraPermissionResolution.ReturnNullResult>(resolution)
65+
}
66+
67+
@Test
68+
fun CameraPermission_grantedWithPendingUri_requestsCameraLaunch() {
69+
val resolution = resolveCameraPermissionResult(
70+
permissionGranted = true,
71+
pendingDestinationUri = "content://example.provider/camera/photo.jpg",
72+
)
73+
74+
val launch = assertIs<CameraPermissionResolution.LaunchCamera>(resolution)
75+
assertEquals("content://example.provider/camera/photo.jpg", launch.uri.toString())
76+
}
77+
78+
@Test
79+
fun CameraPermission_grantedWithoutPendingUri_returnsNoOp() {
80+
val resolution = resolveCameraPermissionResult(
81+
permissionGranted = true,
82+
pendingDestinationUri = null,
83+
)
84+
85+
assertIs<CameraPermissionResolution.NoOp>(resolution)
86+
}
87+
88+
@Test
89+
fun CameraLaunchSafely_whenSecurityException_returnsFalse() {
90+
val launched = launchCameraSafely(Uri.parse("content://example.provider/camera/photo.jpg")) {
91+
throw SecurityException("camera permission denied")
92+
}
93+
94+
assertFalse(launched)
95+
}
96+
97+
@Test
98+
fun CameraLaunchSafely_whenNoError_returnsTrue() {
99+
val expectedUri = Uri.parse("content://example.provider/camera/photo.jpg")
100+
var launchedUri: Uri? = null
101+
102+
val launched = launchCameraSafely(expectedUri) { uri ->
103+
launchedUri = uri
104+
}
105+
106+
assertTrue(launched)
107+
assertEquals(expectedUri, launchedUri)
108+
}
109+
57110
@Test
58111
fun PickerResult_singleModeWhenCancelled_emitsNullResult() {
59112
val consumed = mutableListOf<Any?>()

‎filekit-dialogs-compose/src/androidMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.android.kt‎

Lines changed: 93 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
package io.github.vinceglb.filekit.dialogs.compose
44

5+
import android.Manifest
56
import android.content.Context
67
import android.content.Intent
78
import android.net.Uri
@@ -21,11 +22,14 @@ import androidx.compose.runtime.remember
2122
import androidx.compose.runtime.rememberUpdatedState
2223
import androidx.compose.runtime.saveable.rememberSaveable
2324
import androidx.compose.runtime.setValue
25+
import androidx.compose.ui.platform.LocalContext
2426
import androidx.compose.ui.platform.LocalInspectionMode
2527
import androidx.core.net.toUri
2628
import io.github.vinceglb.filekit.FileKit
2729
import io.github.vinceglb.filekit.PlatformFile
30+
import io.github.vinceglb.filekit.dialogs.FileKitAndroidCameraPermissionInternal
2831
import io.github.vinceglb.filekit.dialogs.FileKitAndroidDialogsInternal
32+
import io.github.vinceglb.filekit.dialogs.FileKitCameraFacing
2933
import io.github.vinceglb.filekit.dialogs.FileKitDialogSettings
3034
import io.github.vinceglb.filekit.dialogs.FileKitMode
3135
import io.github.vinceglb.filekit.dialogs.FileKitOpenCameraSettings
@@ -280,6 +284,10 @@ public actual fun rememberCameraPickerLauncher(
280284
// Store the destination file URI string to survive process death.
281285
// If the user launches again before a callback, latest launch wins.
282286
var pendingDestinationUri by rememberSaveable { mutableStateOf<String?>(null) }
287+
var pendingCameraFacingName by rememberSaveable { mutableStateOf(FileKitCameraFacing.System.name) }
288+
var hasPendingPermissionRequest by rememberSaveable { mutableStateOf(false) }
289+
290+
val context = LocalContext.current
283291

284292
// Updated callback
285293
val currentOnResult by rememberUpdatedState(onResult)
@@ -294,22 +302,104 @@ public actual fun rememberCameraPickerLauncher(
294302
currentOnResult(resolveCameraResult(success, pendingUri))
295303
}
296304

305+
val permissionLauncher =
306+
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { permissionGranted ->
307+
if (!hasPendingPermissionRequest) return@rememberLauncherForActivityResult
308+
hasPendingPermissionRequest = false
309+
310+
when (
311+
val resolution = resolveCameraPermissionResult(
312+
permissionGranted = permissionGranted,
313+
pendingDestinationUri = pendingDestinationUri,
314+
)
315+
) {
316+
CameraPermissionResolution.NoOp -> {
317+
Unit
318+
}
319+
320+
CameraPermissionResolution.ReturnNullResult -> {
321+
pendingDestinationUri = null
322+
currentOnResult(null)
323+
}
324+
325+
is CameraPermissionResolution.LaunchCamera -> {
326+
val cameraFacing = runCatching {
327+
FileKitCameraFacing.valueOf(pendingCameraFacingName)
328+
}.getOrDefault(FileKitCameraFacing.System)
329+
330+
contract.setCameraFacing(cameraFacing)
331+
val isLaunched = launchCameraSafely(
332+
uri = resolution.uri,
333+
launch = launcher::launch,
334+
)
335+
if (!isLaunched) {
336+
pendingDestinationUri = null
337+
currentOnResult(null)
338+
}
339+
}
340+
}
341+
}
342+
297343
// Return the PhotoResultLauncher wrapper
298-
return remember(launcher, contract) {
299-
PhotoResultLauncher { type, cameraFacing, destinationFile ->
344+
return remember(launcher, permissionLauncher, contract, context) {
345+
PhotoResultLauncher { _, cameraFacing, destinationFile ->
300346
// Store the destination URI for retrieval after potential activity recreation
301347
val uri = destinationFile.toAndroidUri(openCameraSettings.authority)
302348
pendingDestinationUri = uri.toString()
349+
pendingCameraFacingName = cameraFacing.name
350+
351+
if (FileKitAndroidCameraPermissionInternal.needsRuntimeCameraPermission(context)) {
352+
hasPendingPermissionRequest = true
353+
permissionLauncher.launch(Manifest.permission.CAMERA)
354+
return@PhotoResultLauncher
355+
}
303356

304357
// Set the camera facing on the contract before launching
305358
contract.setCameraFacing(cameraFacing)
306359

307360
// Launch the camera
308-
launcher.launch(uri)
361+
val isLaunched = launchCameraSafely(
362+
uri = uri,
363+
launch = launcher::launch,
364+
)
365+
if (!isLaunched) {
366+
pendingDestinationUri = null
367+
currentOnResult(null)
368+
}
309369
}
310370
}
311371
}
312372

373+
internal sealed interface CameraPermissionResolution {
374+
data object NoOp : CameraPermissionResolution
375+
376+
data object ReturnNullResult : CameraPermissionResolution
377+
378+
data class LaunchCamera(
379+
val uri: Uri,
380+
) : CameraPermissionResolution
381+
}
382+
383+
internal fun resolveCameraPermissionResult(
384+
permissionGranted: Boolean,
385+
pendingDestinationUri: String?,
386+
): CameraPermissionResolution {
387+
if (!permissionGranted) return CameraPermissionResolution.ReturnNullResult
388+
389+
val pendingUri = pendingDestinationUri ?: return CameraPermissionResolution.NoOp
390+
return CameraPermissionResolution.LaunchCamera(pendingUri.toUri())
391+
}
392+
393+
internal fun launchCameraSafely(
394+
uri: Uri,
395+
launch: (Uri) -> Unit,
396+
): Boolean = try {
397+
launch(uri)
398+
true
399+
} catch (_: SecurityException) {
400+
false
401+
}
402+
313403
internal fun resolveCameraResult(
314404
success: Boolean,
315405
pendingDestinationUri: String?,

‎filekit-dialogs/build.gradle.kts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ kotlin {
2020
implementation(libs.androidx.activity.ktx)
2121
}
2222

23+
androidHostTest.dependencies {
24+
implementation(libs.test.android.robolectric)
25+
}
26+
2327
jvmMain.dependencies {
2428
implementation(libs.jna)
2529
implementation(libs.jna.platform)
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
@file:Suppress("ktlint:standard:function-naming", "TestFunctionName")
2+
@file:OptIn(io.github.vinceglb.filekit.dialogs.FileKitDialogsInternalApi::class)
3+
4+
package io.github.vinceglb.filekit.dialogs
5+
6+
import android.os.Build
7+
import kotlin.test.Test
8+
import kotlin.test.assertFalse
9+
import kotlin.test.assertTrue
10+
11+
class AndroidCameraPermissionTest {
12+
@Test
13+
fun CameraPermission_sdkBelowM_doesNotRequestRuntimePermission() {
14+
val shouldRequest = FileKitAndroidCameraPermissionInternal.shouldRequestRuntimeCameraPermission(
15+
apiLevel = Build.VERSION_CODES.LOLLIPOP_MR1,
16+
isCameraPermissionDeclared = true,
17+
isCameraPermissionGranted = false,
18+
)
19+
20+
assertFalse(shouldRequest)
21+
}
22+
23+
@Test
24+
fun CameraPermission_declaredAndDenied_requestsRuntimePermission() {
25+
val shouldRequest = FileKitAndroidCameraPermissionInternal.shouldRequestRuntimeCameraPermission(
26+
apiLevel = Build.VERSION_CODES.M,
27+
isCameraPermissionDeclared = true,
28+
isCameraPermissionGranted = false,
29+
)
30+
31+
assertTrue(shouldRequest)
32+
}
33+
34+
@Test
35+
fun CameraPermission_notDeclared_doesNotRequestRuntimePermission() {
36+
val shouldRequest = FileKitAndroidCameraPermissionInternal.shouldRequestRuntimeCameraPermission(
37+
apiLevel = Build.VERSION_CODES.UPSIDE_DOWN_CAKE,
38+
isCameraPermissionDeclared = false,
39+
isCameraPermissionGranted = false,
40+
)
41+
42+
assertFalse(shouldRequest)
43+
}
44+
45+
@Test
46+
fun CameraPermission_declaredAndGranted_doesNotRequestRuntimePermission() {
47+
val shouldRequest = FileKitAndroidCameraPermissionInternal.shouldRequestRuntimeCameraPermission(
48+
apiLevel = Build.VERSION_CODES.UPSIDE_DOWN_CAKE,
49+
isCameraPermissionDeclared = true,
50+
isCameraPermissionGranted = true,
51+
)
52+
53+
assertFalse(shouldRequest)
54+
}
55+
}

‎filekit-dialogs/src/androidMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.android.kt‎

Lines changed: 67 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,16 @@
22

33
package io.github.vinceglb.filekit.dialogs
44

5+
import android.Manifest
56
import android.content.ClipData
67
import android.content.Context
78
import android.content.Intent
89
import android.content.Intent.ACTION_VIEW
910
import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
1011
import android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION
12+
import android.content.pm.PackageManager
1113
import android.net.Uri
14+
import android.os.Build
1215
import androidx.activity.ComponentActivity
1316
import androidx.activity.result.ActivityResultLauncher
1417
import androidx.activity.result.ActivityResultRegistry
@@ -116,16 +119,75 @@ public actual suspend fun FileKit.openCameraPicker(
116119
openCameraSettings: FileKitOpenCameraSettings,
117120
): PlatformFile? {
118121
val registry = FileKit.registry
122+
if (!FileKitAndroidCameraPermissionInternal.requestCameraPermissionIfNeeded(registry, context)) {
123+
return null
124+
}
125+
119126
val contract = TakePictureWithCameraFacing(cameraFacing)
120127
val uri = destinationFile.toAndroidUri(openCameraSettings.authority)
121-
val isSaved = awaitActivityResult(
122-
registry = registry,
123-
contract = contract,
124-
input = uri,
125-
)
128+
val isSaved = try {
129+
awaitActivityResult(
130+
registry = registry,
131+
contract = contract,
132+
input = uri,
133+
)
134+
} catch (_: SecurityException) {
135+
return null
136+
}
126137
return if (isSaved) destinationFile else null
127138
}
128139

140+
@FileKitDialogsInternalApi
141+
public object FileKitAndroidCameraPermissionInternal {
142+
public fun isPermissionDeclared(
143+
context: Context,
144+
permission: String,
145+
): Boolean {
146+
val requestedPermissions = runCatching {
147+
@Suppress("DEPRECATION")
148+
context.packageManager
149+
.getPackageInfo(context.packageName, PackageManager.GET_PERMISSIONS)
150+
.requestedPermissions
151+
.orEmpty()
152+
}.getOrElse {
153+
return false
154+
}
155+
156+
return requestedPermissions.contains(permission)
157+
}
158+
159+
public fun needsRuntimeCameraPermission(context: Context): Boolean {
160+
val cameraPermissionGranted =
161+
context.checkSelfPermission(Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED
162+
return shouldRequestRuntimeCameraPermission(
163+
apiLevel = Build.VERSION.SDK_INT,
164+
isCameraPermissionDeclared = isPermissionDeclared(context, Manifest.permission.CAMERA),
165+
isCameraPermissionGranted = cameraPermissionGranted,
166+
)
167+
}
168+
169+
public fun shouldRequestRuntimeCameraPermission(
170+
apiLevel: Int,
171+
isCameraPermissionDeclared: Boolean,
172+
isCameraPermissionGranted: Boolean,
173+
): Boolean = apiLevel >= Build.VERSION_CODES.M && isCameraPermissionDeclared && !isCameraPermissionGranted
174+
175+
public suspend fun requestCameraPermissionIfNeeded(
176+
registry: ActivityResultRegistry,
177+
context: Context,
178+
): Boolean {
179+
if (!needsRuntimeCameraPermission(context)) {
180+
return true
181+
}
182+
183+
return awaitActivityResult(
184+
registry = registry,
185+
contract = ActivityResultContracts.RequestPermission(),
186+
input = Manifest.permission.CAMERA,
187+
)
188+
}
189+
}
190+
129191
/**
130192
* Contract for taking a picture with camera facing support.
131193
*

0 commit comments

Comments
 (0)