Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/simple-camera/__tests__/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
40 changes: 40 additions & 0 deletions apps/simple-camera/__tests__/visioncamera.permissions.harness.ts
Original file line number Diff line number Diff line change
@@ -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])
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
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<Int, CancellableContinuation<IntArray>>()
private var nextRequestCode = 3682

private val listener =
PermissionListener { requestCode: Int, _: Array<String>, grantResults: IntArray ->
val continuation = pendingRequests.remove(requestCode)
if (continuation != null && continuation.isActive) {
continuation.resume(grantResults)
}
// 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
}

/**
* 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)
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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<String>, 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)")
}
Comment thread
dennytosp marked this conversation as resolved.
Resolved

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
}