fix(android): Resolve every permission request when they run in parallel - #4168
Open
dennytosp wants to merge 2 commits into
Open
fix(android): Resolve every permission request when they run in parallel#4168dennytosp wants to merge 2 commits into
dennytosp wants to merge 2 commits into
Conversation
|
@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
force-pushed
the
fix/android-parallel-permission-requests
branch
from
August 21, 2026 09:39
664202e to
ede0d0e
Compare
mrousavy
requested changes
Aug 21, 2026
mrousavy
left a comment
Owner
There was a problem hiding this comment.
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.
Author
|
Thanks, all four fixed:
ktlint and |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #3834
What
Requesting more than one permission at a time on Android — e.g. camera, microphone and location from the same
useEffect— leaves everyrequestPermission()Promise hanging. Nitro eventually destroys them, and the app sees:The dialogs themselves work and
hasPermission/statusend 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.
PermissionAwareActivityonly remembers onePermissionListener.ReactActivityDelegate.requestPermissionsoverwrites the field on every call:VisionCamera created a fresh
PermissionListenerper 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 singlemPermissionsCallbackfield while a dialog is up — so an overlapping request can lose a result there too.2.
Activity.requestPermissions(...)refuses concurrent requests.AOSP
Activity.java: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(...)isfalsefor a permission that was never asked,PermissionStateStorewould then persist it as permanently denied.Fix
A new
PermissionRequestDispatcher(android/.../camera/extensions/PermissionRequestDispatcher.kt) owns all of the plumbing:PermissionListener, with the per-request state kept here in aConcurrentHashMap<Int, CancellableContinuation<IntArray>>keyed by request code, so no request can clobber another one's listener,Mutex, so Android only ever sees one in-flight request and never hits the "only one set of permissions at a time" cancellation,true— which makes React Native drop the shared listener again — once nothing is in flight anymore,requestPermissions(...)that throws resumes its caller with that error instead of leaking it.ReactApplicationContext.requestPermission(...)keeps the exact same signature and still owns thePermissionStateStorebookkeeping, soHybridCameraFactoryandHybridLocationManagerare 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 parallelresolves every request when the same permission is requested multiple times at onceBoth 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: trueinrn-harness.config.mjs), so both requests are expected to resolvetrue, same as the existingexpect(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 reformattingbiome check— clean on the new test filetsc --noEmit— no errors in the new test file./gradlew :react-native-vision-camera:compileDebugKotlin— compiles