Skip to content

Commit ede0d0e

Browse files
committed
fix(android): Resolve every permission request when they run in parallel
React Native's `PermissionAwareActivity` only remembers a single `PermissionListener`, and Android itself refuses a second `requestPermissions(...)` while one is still in flight. Overlapping permission requests therefore lost their results and left their callers - and with them the JS Promises - suspended forever, surfacing as "Timeouted: JPromise was destroyed!". Route every request through a new `PermissionRequestDispatcher` that registers one shared listener, keys the pending continuations by request code, and serializes requests with a `Mutex` so Android only ever sees one at a time. Fixes #3834
1 parent b8c89ea commit ede0d0e

4 files changed

Lines changed: 125 additions & 31 deletions

File tree

apps/simple-camera/__tests__/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ Tests are split by domain. Each file tests one slice of the imperative `VisionCa
2020
| File | Covers |
2121
|------|--------|
2222
| [visioncamera.devices.harness.ts](visioncamera.devices.harness.ts) | `VisionCamera.createDeviceFactory`, device enumeration, per-device capabilities, `getCameraForId`, `addOnCameraDevicesChangedListener`, `getSupportedExtensions`, `userPreferredCamera` |
23+
| [visioncamera.permissions.harness.ts](visioncamera.permissions.harness.ts) | `VisionCamera.requestCameraPermission` / `requestMicrophonePermission`, including requests that overlap |
2324
| [visioncamera.session.harness.ts](visioncamera.session.harness.ts) | `createCameraSession`, `configure`, `start`, `stop`, `addOnStartedListener` / `addOnStoppedListener` / `addOnErrorListener` / interruption listeners, reconfigure-while-running, multi-cam |
2425
| [visioncamera.photo.harness.ts](visioncamera.photo.harness.ts) | `createPhotoOutput`, `capturePhoto` / `capturePhotoToFile`, container formats (JPEG, HEIC, DNG), flash / mirror / quality / resolution options, capture lifecycle callbacks, preview images |
2526
| [visioncamera.video.harness.ts](visioncamera.video.harness.ts) | `createVideoOutput`, `Recorder` lifecycle, audio, `maxDuration` / `maxFileSize` stops, pause / resume / cancel, persistent recorder, higher-resolution codecs |
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { describe, expect, it } from 'react-native-harness'
2+
import { VisionCamera } from 'react-native-vision-camera'
3+
import { withTimeout } from './test-utils'
4+
5+
describe('VisionCamera - Permissions', () => {
6+
it('resolves camera and microphone requests that are started in parallel', async () => {
7+
expect(VisionCamera.cameraPermissionStatus).toBe('authorized')
8+
expect(VisionCamera.microphonePermissionStatus).toBe('authorized')
9+
10+
const parallelRequests = Promise.all([
11+
VisionCamera.requestCameraPermission(),
12+
VisionCamera.requestMicrophonePermission(),
13+
])
14+
const [hasCameraPermission, hasMicrophonePermission] = await withTimeout(
15+
parallelRequests,
16+
10_000,
17+
'parallel camera + microphone permission requests',
18+
)
19+
20+
expect(hasCameraPermission).toBe(true)
21+
expect(hasMicrophonePermission).toBe(true)
22+
})
23+
24+
it('resolves every request when the same permission is requested multiple times at once', async () => {
25+
expect(VisionCamera.cameraPermissionStatus).toBe('authorized')
26+
27+
const parallelRequests = Promise.all([
28+
VisionCamera.requestCameraPermission(),
29+
VisionCamera.requestCameraPermission(),
30+
VisionCamera.requestCameraPermission(),
31+
])
32+
const results = await withTimeout(
33+
parallelRequests,
34+
10_000,
35+
'parallel camera permission requests',
36+
)
37+
38+
expect(results).toEqual([true, true, true])
39+
})
40+
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
package com.margelo.nitro.camera.extensions
2+
3+
import com.facebook.react.modules.core.PermissionAwareActivity
4+
import com.facebook.react.modules.core.PermissionListener
5+
import kotlinx.coroutines.CancellableContinuation
6+
import kotlinx.coroutines.suspendCancellableCoroutine
7+
import kotlinx.coroutines.sync.Mutex
8+
import kotlinx.coroutines.sync.withLock
9+
import java.util.concurrent.ConcurrentHashMap
10+
import kotlin.coroutines.resume
11+
import kotlin.coroutines.resumeWithException
12+
13+
/**
14+
* Runs Android runtime permission requests one at a time and routes each result back to the caller that started it.
15+
*
16+
* Both React Native and Android only keep track of a single permission request at a time, so requests that overlap lose
17+
* their results and leave their callers suspended forever:
18+
* - A [PermissionAwareActivity] only remembers the [PermissionListener] of the most recent request, so a listener created
19+
* per request is overwritten before its result arrives. This dispatcher registers one shared listener instead and keeps
20+
* the per-request state here, keyed by request code.
21+
* - `Activity.requestPermissions(...)` refuses a request while another one is still in flight ("Can request only one set of
22+
* permissions at a time") and cancels it with empty grant results, which would look like a denial for a permission the
23+
* user was never asked about. The [mutex] makes sure Android only ever sees one request at a time.
24+
*/
25+
internal object PermissionRequestDispatcher {
26+
private val mutex = Mutex()
27+
private val pendingRequests = ConcurrentHashMap<Int, CancellableContinuation<IntArray>>()
28+
private var nextRequestCode = 3682
29+
30+
private val listener =
31+
PermissionListener { requestCode: Int, _: Array<String>, grantResults: IntArray ->
32+
val continuation = pendingRequests.remove(requestCode) ?: return@PermissionListener false
33+
if (continuation.isActive) {
34+
continuation.resume(grantResults)
35+
}
36+
// Returning `true` makes React Native drop the shared listener, so only give the slot up once
37+
// there is no request left that still needs its result delivered.
38+
return@PermissionListener pendingRequests.isEmpty()
39+
}
40+
41+
/**
42+
* Requests the given [permission] and suspends until Android reported a result for it.
43+
* @return The grant results as reported by Android - empty if the request has been cancelled.
44+
*/
45+
suspend fun request(
46+
activity: PermissionAwareActivity,
47+
permission: String,
48+
): IntArray =
49+
mutex.withLock {
50+
suspendCancellableCoroutine { continuation ->
51+
val requestCode = nextRequestCode++
52+
pendingRequests[requestCode] = continuation
53+
continuation.invokeOnCancellation { pendingRequests.remove(requestCode) }
54+
55+
try {
56+
activity.requestPermissions(arrayOf(permission), requestCode, listener)
57+
} catch (error: Throwable) {
58+
// Android never received the request, so no result will ever arrive for it.
59+
pendingRequests.remove(requestCode)?.resumeWithException(error)
60+
}
61+
}
62+
}
63+
}

packages/react-native-vision-camera/android/src/main/java/com/margelo/nitro/camera/extensions/ReactApplicationContext+permissions.kt

Lines changed: 21 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,7 @@ import androidx.core.app.ActivityCompat
66
import androidx.core.content.ContextCompat
77
import com.facebook.react.bridge.ReactApplicationContext
88
import com.facebook.react.modules.core.PermissionAwareActivity
9-
import com.facebook.react.modules.core.PermissionListener
109
import com.margelo.nitro.camera.PermissionStatus
11-
import kotlin.coroutines.resume
12-
import kotlin.coroutines.suspendCoroutine
1310

1411
fun ReactApplicationContext.getPermissionStatus(permission: String): PermissionStatus {
1512
val status = ContextCompat.checkSelfPermission(this, permission)
@@ -43,34 +40,27 @@ fun ReactApplicationContext.getPermissionStatus(permission: String): PermissionS
4340
}
4441
}
4542

46-
private var permissionRequestCode: Int = 3682
47-
4843
suspend fun ReactApplicationContext.requestPermission(permission: String): Boolean {
49-
return suspendCoroutine { continuation ->
50-
val activity = currentActivity ?: throw Error("No Activity!")
51-
if (activity is PermissionAwareActivity) {
52-
PermissionStateStore.setHasRequestedPermission(this, permission, true)
53-
val currentRequestCode = permissionRequestCode++
54-
val listener =
55-
PermissionListener { requestCode: Int, _: Array<String>, grantResults: IntArray ->
56-
if (requestCode == currentRequestCode) {
57-
val permissionStatus = grantResults.firstOrNull() ?: PackageManager.PERMISSION_DENIED
58-
val hasPermission = permissionStatus == PackageManager.PERMISSION_GRANTED
59-
if (hasPermission) {
60-
PermissionStateStore.setHasRequestedPermission(this, permission, false)
61-
PermissionStateStore.setPermissionPermanentlyDenied(this, permission, false)
62-
} else {
63-
val canRequestAgain = ActivityCompat.shouldShowRequestPermissionRationale(activity, permission)
64-
PermissionStateStore.setPermissionPermanentlyDenied(this, permission, !canRequestAgain)
65-
}
66-
continuation.resume(hasPermission)
67-
return@PermissionListener true
68-
}
69-
return@PermissionListener false
70-
}
71-
activity.requestPermissions(arrayOf(permission), currentRequestCode, listener)
72-
} else {
73-
throw Error("Activity is not a PermissionAwareActivity!")
74-
}
44+
val activity = currentActivity ?: throw Error("No Activity!")
45+
if (activity !is PermissionAwareActivity) {
46+
throw Error("Activity is not a PermissionAwareActivity!")
47+
}
48+
49+
PermissionStateStore.setHasRequestedPermission(this, permission, true)
50+
val grantResults = PermissionRequestDispatcher.request(activity, permission)
51+
if (grantResults.isEmpty()) {
52+
// Android cancelled the request without ever asking the user, so we did not learn anything
53+
// new about this permission - don't remember it as denied.
54+
return false
55+
}
56+
57+
val hasPermission = grantResults.first() == PackageManager.PERMISSION_GRANTED
58+
if (hasPermission) {
59+
PermissionStateStore.setHasRequestedPermission(this, permission, false)
60+
PermissionStateStore.setPermissionPermanentlyDenied(this, permission, false)
61+
} else {
62+
val canRequestAgain = ActivityCompat.shouldShowRequestPermissionRationale(activity, permission)
63+
PermissionStateStore.setPermissionPermanentlyDenied(this, permission, !canRequestAgain)
7564
}
65+
return hasPermission
7666
}

0 commit comments

Comments
 (0)