Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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,63 @@
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 shared listener instead and keeps
* the per-request state here, keyed by request code.
* - `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) ?: return@PermissionListener false
if (continuation.isActive) {
continuation.resume(grantResults)
}
// Returning `true` makes React Native drop the shared listener, so only give the slot up once
// there is no request left that still needs its result delivered.
return@PermissionListener pendingRequests.isEmpty()
Comment thread
dennytosp marked this conversation as resolved.
Resolved
}

/**
* 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,27 @@ 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)
if (grantResults.isEmpty()) {
// Android cancelled the request without ever asking the user, so we did not learn anything
// new about this permission - don't remember it as denied.
return false
}
Comment thread
dennytosp marked this conversation as resolved.
Resolved

val hasPermission = grantResults.first() == PackageManager.PERMISSION_GRANTED
Comment thread
dennytosp marked this conversation as resolved.
Resolved
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
}