Repository navigation
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 margelo#3834
664202e to
ede0d0e
Compare
mrousavy
left a comment
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.
|
Thanks, all four fixed:
ktlint and |
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.
|
@mrousavy thanks for the review — all four are answered. Three were already handled in 1. The listener race — you were right, and it is worse than "an assumption"I went and read React Native's side rather than reasoning about it. 43: private @Nullable PermissionListener mPermissionListener;
246: mPermissionListener = listener; // unconditional overwrite, no map
254: if (mPermissionListener != null
255: && mPermissionListener.onRequestPermissionsResult(...)) {
257: mPermissionListener = null; // `true` nulls the single slotOne global, non-volatile slot — no keying by request code anywhere. So It is also reachable rather than theoretical, because registration and callback are on different threads. The same reading is what proves the original bug, for the record: pre-PR the code created a new listener per request and returned What is in the PR: one long-lived shared listener, per-request state in What // com/facebook/react/modules/permissions/PermissionsModule.kt
27: public class PermissionsModule(reactContext: ReactApplicationContext?) :
28: NativePermissionsAndroidSpec(reactContext), PermissionListener {
30: private val callbacks: SparseArray<Callback> = SparseArray<Callback>()
93: activity.requestPermissions(arrayOf(permission), requestCode, this)
163: callbacks.size() == 0One long-lived listener, per-request state keyed by request code, and 2. "Maybe we can find a better solution"Agreed on rejecting the There is a structural version of it that I think is better, and I would rather you decide than push it: don't resume on main, hand the request to Android on main. withContext(Dispatchers.Main) {
suspendCancellableCoroutine { continuation -> ... }
}The reasoning is that registration is currently the odd one out. The callback is always on main; the delegate's The honest caveat: RN's own module calls 3.
|
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