camera: requestAudioPermission — in-app prompt first, settings on permanent denial
Banner-tap entry point that shows the system mic prompt when the OS will still surface one, and deep-links to Settings only on permanent denial. Fixes the fresh-install trap where the mic entry isn't in the Privacy pane until requestAccess has fired at least once. Android tracks the first-asked state in SharedPreferences because shouldShowRequestPermissionRationale returns false in two observationally identical states (never asked vs permanently denied). The existing initialize() request path writes the flag too, so a banner tap after a record-then-deny correctly routes to Settings. Refactored Android pendingPermission into PendingPermission(primary, kind, cb) so audio-only requests check RECORD_AUDIO results instead of always checking CAMERA.
This commit is contained in:
@@ -31,6 +31,8 @@ class CameraPlugin :
|
||||
|
||||
companion object {
|
||||
private const val PERMISSION_REQUEST_CODE = 0xC2A0
|
||||
private const val PREFS_NAME = "ux.camera"
|
||||
private const val PREF_AUDIO_ASKED = "audio_asked"
|
||||
}
|
||||
|
||||
private val main = Handler(Looper.getMainLooper())
|
||||
@@ -43,8 +45,17 @@ class CameraPlugin :
|
||||
|
||||
private var activity: Activity? = null
|
||||
private var activityBinding: ActivityPluginBinding? = null
|
||||
private var pendingPermission: ((Boolean, String) -> Unit)? = null
|
||||
private var pendingPermissionKind: String = ""
|
||||
private var pending: PendingPermission? = null
|
||||
|
||||
/// Per-request bookkeeping. `primary` is the permission whose grant
|
||||
/// state gates the caller — camera for the session initialize flow,
|
||||
/// mic for the standalone request. `kind` is the human-readable
|
||||
/// label surfaced in the denial error code.
|
||||
private data class PendingPermission(
|
||||
val primary: String,
|
||||
val kind: String,
|
||||
val cb: (Boolean) -> Unit,
|
||||
)
|
||||
|
||||
private val instances = mutableMapOf<Int, CameraInstance>()
|
||||
private var nextHandle = 1
|
||||
@@ -88,16 +99,12 @@ class CameraPlugin :
|
||||
if (code != PERMISSION_REQUEST_CODE) {
|
||||
return@addRequestPermissionsResultListener false
|
||||
}
|
||||
// Camera grant gates the whole flow; mic is optional (we
|
||||
// tolerate a missing audio input gracefully). Match iOS.
|
||||
val cameraIndex = permissions.indexOf(Manifest.permission.CAMERA)
|
||||
val cameraGranted = cameraIndex >= 0 &&
|
||||
results.getOrNull(cameraIndex) == PackageManager.PERMISSION_GRANTED
|
||||
val cb = pendingPermission
|
||||
pendingPermission = null
|
||||
val kind = pendingPermissionKind
|
||||
pendingPermissionKind = ""
|
||||
cb?.invoke(cameraGranted, if (cameraGranted) "" else kind)
|
||||
val p = pending ?: return@addRequestPermissionsResultListener true
|
||||
pending = null
|
||||
val idx = permissions.indexOf(p.primary)
|
||||
val granted = idx >= 0 &&
|
||||
results.getOrNull(idx) == PackageManager.PERMISSION_GRANTED
|
||||
p.cb(granted)
|
||||
true
|
||||
}
|
||||
}
|
||||
@@ -107,9 +114,8 @@ class CameraPlugin :
|
||||
activityBinding = null
|
||||
// If a request is still pending when the activity tears down,
|
||||
// settle it as denied so the Dart Future doesn't hang.
|
||||
pendingPermission?.invoke(false, pendingPermissionKind)
|
||||
pendingPermission = null
|
||||
pendingPermissionKind = ""
|
||||
pending?.cb?.invoke(false)
|
||||
pending = null
|
||||
}
|
||||
|
||||
// MARK: - EventChannel
|
||||
@@ -138,6 +144,7 @@ class CameraPlugin :
|
||||
"startVideoRecording" -> handleStartVideo(call, result)
|
||||
"stopVideoRecording" -> handleStopVideo(call, result)
|
||||
"audioPermissionStatus" -> result.success(isAudioGranted())
|
||||
"requestAudioPermission" -> handleRequestAudioPermission(result)
|
||||
"openSettings" -> handleOpenSettings(result)
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
@@ -343,12 +350,16 @@ class CameraPlugin :
|
||||
private fun handleOpenSettings(result: MethodChannel.Result) {
|
||||
val act = activity
|
||||
?: return result.error("no_activity", "plugin not attached", null)
|
||||
openAppSettings(act)
|
||||
result.success(null)
|
||||
}
|
||||
|
||||
private fun openAppSettings(act: Activity) {
|
||||
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
|
||||
data = Uri.fromParts("package", act.packageName, null)
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
act.startActivity(intent)
|
||||
result.success(null)
|
||||
}
|
||||
|
||||
private fun isAudioGranted(): Boolean {
|
||||
@@ -358,6 +369,45 @@ class CameraPlugin :
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
/// Banner-tap entry point: show the in-app mic prompt when the OS
|
||||
/// will still surface one (fresh install, or first denial without
|
||||
/// "don't ask again"); otherwise deep-link into app Settings.
|
||||
///
|
||||
/// We track first-ever-asked in SharedPrefs because Android's
|
||||
/// `shouldShowRequestPermissionRationale` returns false in two
|
||||
/// observationally identical states: "never asked yet" and
|
||||
/// "permanently denied". Without the flag, the first banner tap on
|
||||
/// a fresh install would land in the settings fallback before the
|
||||
/// permission entry even exists in the Privacy pane.
|
||||
private fun handleRequestAudioPermission(result: MethodChannel.Result) {
|
||||
val act = activity
|
||||
?: return result.error("no_activity", "plugin not attached", null)
|
||||
if (isAudioGranted()) return result.success(true)
|
||||
val prefs = act.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
val askedBefore = prefs.getBoolean(PREF_AUDIO_ASKED, false)
|
||||
val rationale = ActivityCompat.shouldShowRequestPermissionRationale(
|
||||
act, Manifest.permission.RECORD_AUDIO,
|
||||
)
|
||||
if (askedBefore && !rationale) {
|
||||
openAppSettings(act)
|
||||
return result.success(false)
|
||||
}
|
||||
if (pending != null) {
|
||||
return result.error("in_progress", "another permission request is in flight", null)
|
||||
}
|
||||
prefs.edit().putBoolean(PREF_AUDIO_ASKED, true).apply()
|
||||
pending = PendingPermission(
|
||||
primary = Manifest.permission.RECORD_AUDIO,
|
||||
kind = "microphone",
|
||||
cb = { granted -> result.success(granted) },
|
||||
)
|
||||
ActivityCompat.requestPermissions(
|
||||
act,
|
||||
arrayOf(Manifest.permission.RECORD_AUDIO),
|
||||
PERMISSION_REQUEST_CODE,
|
||||
)
|
||||
}
|
||||
|
||||
private fun requestPermissions(
|
||||
perms: List<String>,
|
||||
cb: (Boolean, String) -> Unit,
|
||||
@@ -368,12 +418,25 @@ class CameraPlugin :
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
if (toRequest.isEmpty()) return cb(true, "")
|
||||
if (pendingPermission != null) {
|
||||
if (pending != null) {
|
||||
return cb(false, "camera") // serialize
|
||||
}
|
||||
pendingPermission = cb
|
||||
pendingPermissionKind = if (toRequest.contains(Manifest.permission.CAMERA))
|
||||
"camera" else "microphone"
|
||||
// Camera grant gates the session-init flow; mic is optional.
|
||||
val hasCamera = toRequest.contains(Manifest.permission.CAMERA)
|
||||
val primary = if (hasCamera) Manifest.permission.CAMERA else Manifest.permission.RECORD_AUDIO
|
||||
val kind = if (hasCamera) "camera" else "microphone"
|
||||
// Persist the audio-asked flag whenever a mic request leaves
|
||||
// the dispatch queue; the banner-tap path keys off this to tell
|
||||
// "never asked" apart from "permanently denied" later.
|
||||
if (toRequest.contains(Manifest.permission.RECORD_AUDIO)) {
|
||||
act.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
.edit().putBoolean(PREF_AUDIO_ASKED, true).apply()
|
||||
}
|
||||
pending = PendingPermission(
|
||||
primary = primary,
|
||||
kind = kind,
|
||||
cb = { granted -> cb(granted, if (granted) "" else kind) },
|
||||
)
|
||||
ActivityCompat.requestPermissions(
|
||||
act,
|
||||
toRequest.toTypedArray(),
|
||||
|
||||
Reference in New Issue
Block a user