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..28fdb6c639 --- /dev/null +++ b/packages/react-native-vision-camera/android/src/main/java/com/margelo/nitro/camera/extensions/PermissionRequestDispatcher.kt @@ -0,0 +1,67 @@ +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 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. + */ +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) + 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. + return@PermissionListener false + } + + /** + * 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..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 @@ -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,32 @@ 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) + 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 = grantResult == 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 }