Skip to content

fix(android): Resolve every permission request when they run in parallel - #4168

Open
dennytosp wants to merge 2 commits into
mrousavy:mainfrom
dennytosp:fix/android-parallel-permission-requests
Open

fix(android): Resolve every permission request when they run in parallel#4168
dennytosp wants to merge 2 commits into
mrousavy:mainfrom
dennytosp:fix/android-parallel-permission-requests

Conversation

@dennytosp

Copy link
Copy Markdown

Fixes #3834

What

Requesting more than one permission at a time on Android — e.g. camera, microphone and location from the same useEffect — leaves every requestPermission() Promise hanging. Nitro eventually destroys them, and the app sees:

ERROR  [Error: Uncaught (in promise, id: 0) Error: java.lang.RuntimeException: Timeouted: JPromise was destroyed!]

The dialogs themselves work and hasPermission / status end up correct — only the Promises never settle.

Root cause

Two single-slot fields, one in React Native and one in Android itself. Routing results by request code alone does not fix it — the requests also have to actually run one at a time.

1. PermissionAwareActivity only remembers one PermissionListener.

ReactActivityDelegate.requestPermissions overwrites the field on every call:

public void requestPermissions(String[] permissions, int requestCode, @Nullable PermissionListener listener) {
    mPermissionListener = listener;
    getPlainActivity().requestPermissions(permissions, requestCode);
}

VisionCamera created a fresh PermissionListener per request, so listener A was replaced by B and B by C. Only the last one ever ran; the earlier continuations stayed suspended forever.

The same delegate also drops the listener as soon as one returns true, and buffers the result in a single mPermissionsCallback field while a dialog is up — so an overlapping request can lose a result there too.

2. Activity.requestPermissions(...) refuses concurrent requests.

AOSP Activity.java:

if (mHasCurrentPermissionsRequest) {
    Log.w(TAG, "Can request only one set of permissions at a time");
    // Dispatch the callback with empty arrays which means a cancellation.
    onRequestPermissionsResult(requestCode, new String[0], new int[0], deviceId);
    return;
}

The second request is cancelled with empty grant results before the user ever sees it. The old code mapped that empty array to "denied" — and since shouldShowRequestPermissionRationale(...) is false for a permission that was never asked, PermissionStateStore would then persist it as permanently denied.

Fix

A new PermissionRequestDispatcher (android/.../camera/extensions/PermissionRequestDispatcher.kt) owns all of the plumbing:

  • one shared PermissionListener, with the per-request state kept here in a ConcurrentHashMap<Int, CancellableContinuation<IntArray>> keyed by request code, so no request can clobber another one's listener,
  • a Mutex, so Android only ever sees one in-flight request and never hits the "only one set of permissions at a time" cancellation,
  • the listener only returns true — which makes React Native drop the shared listener again — once nothing is in flight anymore,
  • cancelling the coroutine removes its pending entry, and a requestPermissions(...) that throws resumes its caller with that error instead of leaking it.

ReactApplicationContext.requestPermission(...) keeps the exact same signature and still owns the PermissionStateStore bookkeeping, so HybridCameraFactory and HybridLocationManager are untouched — none of this leaks into surface-level code.

One small behavior change worth calling out: an empty (cancelled) grant result no longer records a permanently denied state, since nothing was actually asked. Happy to drop that if you'd rather keep the diff strictly to the hang.

Tests

New apps/simple-camera/__tests__/visioncamera.permissions.harness.ts, plus its row in the __tests__ README layout table:

  • resolves camera and microphone requests that are started in parallel
  • resolves every request when the same permission is requested multiple times at once

Both are shared tests with no platform guard — the behavior should hold everywhere, and iOS passes them today. On Android before this change they hang and fail on withTimeout(...). The harness grants permissions on install (permissions: true in rn-harness.config.mjs), so both requests are expected to resolve true, same as the existing expect(cameraPermissionStatus).toBe('authorized') in the hooks suite.

No JUnit tests / no new Gradle test dependencies, per the Harness-only convention.

Verification

  • bun run lint-kotlin — clean, no reformatting
  • biome check — clean on the new test file
  • tsc --noEmit — no errors in the new test file
  • ./gradlew :react-native-vision-camera:compileDebugKotlin — compiles
  • No Android device on hand for a local Harness run, so the on-device pass is up to CI.

@vercel

vercel Bot commented Aug 21, 2026

Copy link
Copy Markdown

@dennytosp is attempting to deploy a commit to the Margelo Team on Vercel.

A member of the Team first needs to authorize it.

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 mrousavy#3834
@dennytosp
dennytosp force-pushed the fix/android-parallel-permission-requests branch from 664202e to ede0d0e Compare August 21, 2026 09:39

@mrousavy mrousavy left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for attempting to fix this! A few nit picks before we can merge

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.
@dennytosp

Copy link
Copy Markdown
Author

Thanks, all four fixed:

  • Listener always returns false now — true could drop the listener that the next queued request had just registered.
  • Skipped the mainHandler idea, agreed it only narrows the window.
  • Rolled back setHasRequestedPermission on a cancelled request. Good catch, that was reporting DENIED for a permission the user was never asked.
  • singleOrNull() in, throws on null.

ktlint and compileDebugKotlin green locally.

@dennytosp
dennytosp requested a review from mrousavy August 22, 2026 12:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Android: parallel requestPermission calls leak coroutines and surface as 'JPromise was destroyed'

2 participants