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:
agra
2026-05-21 08:50:39 +03:00
parent 1a7ce1ac1b
commit a508aca2bb
7 changed files with 369 additions and 242 deletions

View File

@@ -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(),