From ede0d0e5074b84e3420ba7b519a2d4c6f962fee9 Mon Sep 17 00:00:00 2001 From: Mad Dinh Date: Fri, 21 Aug 2026 10:55:32 +0700 Subject: [PATCH 1/2] 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 --- apps/simple-camera/__tests__/README.md | 1 + .../visioncamera.permissions.harness.ts | 40 ++++++++++++ .../extensions/PermissionRequestDispatcher.kt | 63 +++++++++++++++++++ .../ReactApplicationContext+permissions.kt | 52 +++++++-------- 4 files changed, 125 insertions(+), 31 deletions(-) create mode 100644 apps/simple-camera/__tests__/visioncamera.permissions.harness.ts create mode 100644 packages/react-native-vision-camera/android/src/main/java/com/margelo/nitro/camera/extensions/PermissionRequestDispatcher.kt diff --git a/apps/simple-camera/__tests__/README.md b/apps/simple-camera/__tests__/README.md index ddcc20cb5e..d8f275a8b1 100644 --- a/apps/simple-camera/__tests__/README.md +++ b/apps/simple-camera/__tests__/README.md @@ -20,6 +20,7 @@ Tests are split by domain. Each file tests one slice of the imperative `VisionCa | File | Covers | |------|--------| | [visioncamera.devices.harness.ts](visioncamera.devices.harness.ts) | `VisionCamera.createDeviceFactory`, device enumeration, per-device capabilities, `getCameraForId`, `addOnCameraDevicesChangedListener`, `getSupportedExtensions`, `userPreferredCamera` | +| [visioncamera.permissions.harness.ts](visioncamera.permissions.harness.ts) | `VisionCamera.requestCameraPermission` / `requestMicrophonePermission`, including requests that overlap | | [visioncamera.session.harness.ts](visioncamera.session.harness.ts) | `createCameraSession`, `configure`, `start`, `stop`, `addOnStartedListener` / `addOnStoppedListener` / `addOnErrorListener` / interruption listeners, reconfigure-while-running, multi-cam | | [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 | | [visioncamera.video.harness.ts](visioncamera.video.harness.ts) | `createVideoOutput`, `Recorder` lifecycle, audio, `maxDuration` / `maxFileSize` stops, pause / resume / cancel, persistent recorder, higher-resolution codecs | diff --git a/apps/simple-camera/__tests__/visioncamera.permissions.harness.ts b/apps/simple-camera/__tests__/visioncamera.permissions.harness.ts new file mode 100644 index 0000000000..e57d1249ed --- /dev/null +++ b/apps/simple-camera/__tests__/visioncamera.permissions.harness.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'react-native-harness' +import { VisionCamera } from 'react-native-vision-camera' +import { withTimeout } from './test-utils' + +describe('VisionCamera - Permissions', () => { + it('resolves camera and microphone requests that are started in parallel', async () => { + expect(VisionCamera.cameraPermissionStatus).toBe('authorized') + expect(VisionCamera.microphonePermissionStatus).toBe('authorized') + + const parallelRequests = Promise.all([ + VisionCamera.requestCameraPermission(), + VisionCamera.requestMicrophonePermission(), + ]) + const [hasCameraPermission, hasMicrophonePermission] = await withTimeout( + parallelRequests, + 10_000, + 'parallel camera + microphone permission requests', + ) + + expect(hasCameraPermission).toBe(true) + expect(hasMicrophonePermission).toBe(true) + }) + + it('resolves every request when the same permission is requested multiple times at once', async () => { + expect(VisionCamera.cameraPermissionStatus).toBe('authorized') + + const parallelRequests = Promise.all([ + VisionCamera.requestCameraPermission(), + VisionCamera.requestCameraPermission(), + VisionCamera.requestCameraPermission(), + ]) + const results = await withTimeout( + parallelRequests, + 10_000, + 'parallel camera permission requests', + ) + + expect(results).toEqual([true, true, true]) + }) +}) diff --git a/packages/react-native-vision-camera/android/src/main/java/com/margelo/nitro/camera/extensions/PermissionRequestDispatcher.kt b/packages/react-native-vision-camera/android/src/main/java/com/margelo/nitro/camera/extensions/PermissionRequestDispatcher.kt new file mode 100644 index 0000000000..ec8d6feaa4 --- /dev/null +++ b/packages/react-native-vision-camera/android/src/main/java/com/margelo/nitro/camera/extensions/PermissionRequestDispatcher.kt @@ -0,0 +1,63 @@ +package com.margelo.nitro.camera.extensions + +import com.facebook.react.modules.core.PermissionAwareActivity +import com.facebook.react.modules.core.PermissionListener +import kotlinx.coroutines.CancellableContinuation +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import java.util.concurrent.ConcurrentHashMap +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +/** + * Runs Android runtime permission requests one at a time and routes each result back to the caller that started it. + * + * Both React Native and Android only keep track of a single permission request at a time, so requests that overlap lose + * their results and leave their callers suspended forever: + * - A [PermissionAwareActivity] only remembers the [PermissionListener] of the most recent request, so a listener created + * per request is overwritten before its result arrives. This dispatcher registers one shared listener instead and keeps + * the per-request state here, keyed by request code. + * - `Activity.requestPermissions(...)` refuses a request while another one is still in flight ("Can request only one set of + * permissions at a time") and cancels it with empty grant results, which would look like a denial for a permission the + * user was never asked about. The [mutex] makes sure Android only ever sees one request at a time. + */ +internal object PermissionRequestDispatcher { + private val mutex = Mutex() + private val pendingRequests = ConcurrentHashMap>() + private var nextRequestCode = 3682 + + private val listener = + PermissionListener { requestCode: Int, _: Array, grantResults: IntArray -> + val continuation = pendingRequests.remove(requestCode) ?: return@PermissionListener false + if (continuation.isActive) { + continuation.resume(grantResults) + } + // Returning `true` makes React Native drop the shared listener, so only give the slot up once + // there is no request left that still needs its result delivered. + return@PermissionListener pendingRequests.isEmpty() + } + + /** + * Requests the given [permission] and suspends until Android reported a result for it. + * @return The grant results as reported by Android - empty if the request has been cancelled. + */ + suspend fun request( + activity: PermissionAwareActivity, + permission: String, + ): IntArray = + mutex.withLock { + suspendCancellableCoroutine { continuation -> + val requestCode = nextRequestCode++ + pendingRequests[requestCode] = continuation + continuation.invokeOnCancellation { pendingRequests.remove(requestCode) } + + try { + activity.requestPermissions(arrayOf(permission), requestCode, listener) + } catch (error: Throwable) { + // Android never received the request, so no result will ever arrive for it. + pendingRequests.remove(requestCode)?.resumeWithException(error) + } + } + } +} diff --git a/packages/react-native-vision-camera/android/src/main/java/com/margelo/nitro/camera/extensions/ReactApplicationContext+permissions.kt b/packages/react-native-vision-camera/android/src/main/java/com/margelo/nitro/camera/extensions/ReactApplicationContext+permissions.kt index 04d23a7904..76478e1afe 100644 --- a/packages/react-native-vision-camera/android/src/main/java/com/margelo/nitro/camera/extensions/ReactApplicationContext+permissions.kt +++ b/packages/react-native-vision-camera/android/src/main/java/com/margelo/nitro/camera/extensions/ReactApplicationContext+permissions.kt @@ -6,10 +6,7 @@ import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.modules.core.PermissionAwareActivity -import com.facebook.react.modules.core.PermissionListener import com.margelo.nitro.camera.PermissionStatus -import kotlin.coroutines.resume -import kotlin.coroutines.suspendCoroutine fun ReactApplicationContext.getPermissionStatus(permission: String): PermissionStatus { val status = ContextCompat.checkSelfPermission(this, permission) @@ -43,34 +40,27 @@ fun ReactApplicationContext.getPermissionStatus(permission: String): PermissionS } } -private var permissionRequestCode: Int = 3682 - suspend fun ReactApplicationContext.requestPermission(permission: String): Boolean { - return suspendCoroutine { continuation -> - val activity = currentActivity ?: throw Error("No Activity!") - if (activity is PermissionAwareActivity) { - PermissionStateStore.setHasRequestedPermission(this, permission, true) - val currentRequestCode = permissionRequestCode++ - val listener = - PermissionListener { requestCode: Int, _: Array, grantResults: IntArray -> - if (requestCode == currentRequestCode) { - val permissionStatus = grantResults.firstOrNull() ?: PackageManager.PERMISSION_DENIED - val hasPermission = permissionStatus == PackageManager.PERMISSION_GRANTED - if (hasPermission) { - PermissionStateStore.setHasRequestedPermission(this, permission, false) - PermissionStateStore.setPermissionPermanentlyDenied(this, permission, false) - } else { - val canRequestAgain = ActivityCompat.shouldShowRequestPermissionRationale(activity, permission) - PermissionStateStore.setPermissionPermanentlyDenied(this, permission, !canRequestAgain) - } - continuation.resume(hasPermission) - return@PermissionListener true - } - return@PermissionListener false - } - activity.requestPermissions(arrayOf(permission), currentRequestCode, listener) - } else { - throw Error("Activity is not a PermissionAwareActivity!") - } + val activity = currentActivity ?: throw Error("No Activity!") + if (activity !is PermissionAwareActivity) { + throw Error("Activity is not a PermissionAwareActivity!") + } + + PermissionStateStore.setHasRequestedPermission(this, permission, true) + val grantResults = PermissionRequestDispatcher.request(activity, permission) + if (grantResults.isEmpty()) { + // Android cancelled the request without ever asking the user, so we did not learn anything + // new about this permission - don't remember it as denied. + return false + } + + val hasPermission = grantResults.first() == PackageManager.PERMISSION_GRANTED + if (hasPermission) { + PermissionStateStore.setHasRequestedPermission(this, permission, false) + PermissionStateStore.setPermissionPermanentlyDenied(this, permission, false) + } else { + val canRequestAgain = ActivityCompat.shouldShowRequestPermissionRationale(activity, permission) + PermissionStateStore.setPermissionPermanentlyDenied(this, permission, !canRequestAgain) } + return hasPermission } From 1a9671e736a5e5d0c7921abf1ad4f8977d99fd60 Mon Sep 17 00:00:00 2001 From: Mad Dinh Date: Fri, 21 Aug 2026 23:36:14 +0700 Subject: [PATCH 2/2] fix(android): Address review - never drop the shared permission listener Returning `true` from the shared `PermissionListener` tells React Native to drop it again. Resuming the caller can already have let the next queued request register that very listener before the callback returns, so dropping it afterwards would swallow that request's result. A long-lived shared listener is never done, so it now always returns `false`. Also roll `setHasRequestedPermission(...)` back when Android cancels a request without showing it: leaving the marker set made `getPermissionStatus(...)` report `DENIED` for a permission the user was never asked about, because `shouldShowRequestPermissionRationale(...)` is `false` for a permission that was never presented. Use `singleOrNull()` to detect that case, which also guards against Android ever reporting more than the one requested result. --- .../extensions/PermissionRequestDispatcher.kt | 18 +++++++++++------- .../ReactApplicationContext+permissions.kt | 15 ++++++++++----- 2 files changed, 21 insertions(+), 12 deletions(-) diff --git a/packages/react-native-vision-camera/android/src/main/java/com/margelo/nitro/camera/extensions/PermissionRequestDispatcher.kt b/packages/react-native-vision-camera/android/src/main/java/com/margelo/nitro/camera/extensions/PermissionRequestDispatcher.kt index ec8d6feaa4..28fdb6c639 100644 --- a/packages/react-native-vision-camera/android/src/main/java/com/margelo/nitro/camera/extensions/PermissionRequestDispatcher.kt +++ b/packages/react-native-vision-camera/android/src/main/java/com/margelo/nitro/camera/extensions/PermissionRequestDispatcher.kt @@ -16,8 +16,9 @@ import kotlin.coroutines.resumeWithException * Both React Native and Android only keep track of a single permission request at a time, so requests that overlap lose * their results and leave their callers suspended forever: * - A [PermissionAwareActivity] only remembers the [PermissionListener] of the most recent request, so a listener created - * per request is overwritten before its result arrives. This dispatcher registers one shared listener instead and keeps - * the per-request state here, keyed by request code. + * per request is overwritten before its result arrives. This dispatcher registers one long-lived shared listener instead + * and keeps the per-request state here, keyed by request code. Because of the [mutex] there is at most one entry in + * [pendingRequests] at a time - the map is what claims and hands over that entry atomically. * - `Activity.requestPermissions(...)` refuses a request while another one is still in flight ("Can request only one set of * permissions at a time") and cancels it with empty grant results, which would look like a denial for a permission the * user was never asked about. The [mutex] makes sure Android only ever sees one request at a time. @@ -29,13 +30,16 @@ internal object PermissionRequestDispatcher { private val listener = PermissionListener { requestCode: Int, _: Array, grantResults: IntArray -> - val continuation = pendingRequests.remove(requestCode) ?: return@PermissionListener false - if (continuation.isActive) { + val continuation = pendingRequests.remove(requestCode) + if (continuation != null && continuation.isActive) { continuation.resume(grantResults) } - // Returning `true` makes React Native drop the shared listener, so only give the slot up once - // there is no request left that still needs its result delivered. - return@PermissionListener pendingRequests.isEmpty() + // This never returns `true`. `true` tells React Native to drop the listener again, and resuming + // the continuation above may already have let the next queued request register this very listener + // - dropping it afterwards would swallow that request's result and bring the hang back. A shared + // listener is never "done" anyway: it stays valid for every future request, ignores request codes + // it does not know, and is replaced as usual once other code registers a listener of its own. + return@PermissionListener false } /** diff --git a/packages/react-native-vision-camera/android/src/main/java/com/margelo/nitro/camera/extensions/ReactApplicationContext+permissions.kt b/packages/react-native-vision-camera/android/src/main/java/com/margelo/nitro/camera/extensions/ReactApplicationContext+permissions.kt index 76478e1afe..0e75855b5f 100644 --- a/packages/react-native-vision-camera/android/src/main/java/com/margelo/nitro/camera/extensions/ReactApplicationContext+permissions.kt +++ b/packages/react-native-vision-camera/android/src/main/java/com/margelo/nitro/camera/extensions/ReactApplicationContext+permissions.kt @@ -48,13 +48,18 @@ suspend fun ReactApplicationContext.requestPermission(permission: String): Boole PermissionStateStore.setHasRequestedPermission(this, permission, true) val grantResults = PermissionRequestDispatcher.request(activity, permission) - if (grantResults.isEmpty()) { - // Android cancelled the request without ever asking the user, so we did not learn anything - // new about this permission - don't remember it as denied. - return false + val grantResult = grantResults.singleOrNull() + if (grantResult == null) { + // We asked for exactly one permission, so anything but exactly one result means the request never + // reached the user - Android reports a cancellation as an empty array. Roll the "has requested" + // marker back before bailing out: otherwise `getPermissionStatus(...)` sees a permission that has + // been requested, is not permanently denied, and has no rationale to show (because it was never + // presented), and reports it as `DENIED` instead of `NOT_DETERMINED`. + PermissionStateStore.setHasRequestedPermission(this, permission, false) + throw Error("Permission request for \"$permission\" was cancelled by Android! (got ${grantResults.size} results)") } - val hasPermission = grantResults.first() == PackageManager.PERMISSION_GRANTED + val hasPermission = grantResult == PackageManager.PERMISSION_GRANTED if (hasPermission) { PermissionStateStore.setHasRequestedPermission(this, permission, false) PermissionStateStore.setPermissionPermanentlyDenied(this, permission, false)