From ede0d0e5074b84e3420ba7b519a2d4c6f962fee9 Mon Sep 17 00:00:00 2001 From: Mad Dinh Date: Fri, 21 Aug 2026 10:55:32 +0700 Subject: [PATCH 1/3] 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/3] 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) From 8bba64752bdd3c01497e554536432a578b32568a Mon Sep 17 00:00:00 2001 From: Mad Dinh Date: Thu, 22 Oct 2026 01:56:51 +0700 Subject: [PATCH 3/3] fix(android): Justify never dropping the shared permission listener The comment argued from first principles that a shared listener is never "done". React Native's own `PermissionsModule` is a stronger argument: it implements `PermissionListener` once, keeps per-request state in a `SparseArray` keyed by request code, and reports itself droppable only when `callbacks.size() == 0` -- the same architecture, never an unconditional `true`. It also names why this goes one step further and never reports droppable at all: `mPermissionListener` is a plain non-volatile field, written here from `Dispatchers.Default` and nulled on the main thread, so the next queued request can register the listener while the main thread is still deciding whether to drop it. No behaviour change. --- .../extensions/PermissionRequestDispatcher.kt | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 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 28fdb6c639..281dfb2c89 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 @@ -34,11 +34,19 @@ internal object PermissionRequestDispatcher { if (continuation != null && continuation.isActive) { continuation.resume(grantResults) } - // 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. + // This never returns `true`. React Native keeps a single listener slot + // (`ReactActivityDelegate.mPermissionListener`) and nulls it whenever a listener returns `true`, so + // `true` means "you may drop me". React Native's own `PermissionsModule` is built the same way this + // object is - it implements `PermissionListener` once and keeps per-request state in a `SparseArray` + // keyed by request code - and it reports itself droppable only once its own bucket is empty, never + // unconditionally. + // + // We do not report droppable at all. `Promise.async { ... }` runs callers on `Dispatchers.Default`, + // while this listener is invoked on the main thread, so the next queued request can register this + // very listener from another thread while the main thread is still deciding whether to drop it - + // and `mPermissionListener` is a plain, non-volatile field written from both. Staying registered is + // free: this is a process-wide singleton that captures no Activity, it ignores request codes it does + // not know, and every new request re-registers it anyway. return@PermissionListener false }