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,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<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`. `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)
}
}
}
}
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.

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
}