feat: request Android startup permissions
This commit is contained in:
+122
@@ -94,6 +94,7 @@ class AndroidPermissionRequester {
|
|||||||
* [handleRequestPermissionsResult] matches the request.
|
* [handleRequestPermissionsResult] matches the request.
|
||||||
*/
|
*/
|
||||||
private const val REQ_RECORD_AUDIO = 0x52454341 // "RECA"
|
private const val REQ_RECORD_AUDIO = 0x52454341 // "RECA"
|
||||||
|
private const val REQ_STARTUP_PERMISSIONS = 0x53544152 // "STAR"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SharedPreferences file backing the cross-process / cross-launch
|
* SharedPreferences file backing the cross-process / cross-launch
|
||||||
@@ -124,6 +125,8 @@ class AndroidPermissionRequester {
|
|||||||
* and restarted the process during a settings round-trip.
|
* and restarted the process during a settings round-trip.
|
||||||
*/
|
*/
|
||||||
private const val KEY_RECORD_AUDIO_HAS_REQUESTED = "record_audio_has_requested"
|
private const val KEY_RECORD_AUDIO_HAS_REQUESTED = "record_audio_has_requested"
|
||||||
|
private const val KEY_BLUETOOTH_CONNECT_HAS_REQUESTED = "bluetooth_connect_has_requested"
|
||||||
|
private const val KEY_POST_NOTIFICATIONS_HAS_REQUESTED = "post_notifications_has_requested"
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -133,6 +136,7 @@ class AndroidPermissionRequester {
|
|||||||
* `voice_join` coalescing (SDD-106 §8) is owned by the Rust side.
|
* `voice_join` coalescing (SDD-106 §8) is owned by the Rust side.
|
||||||
*/
|
*/
|
||||||
private var pendingCallback: ((PermissionState) -> Unit)? = null
|
private var pendingCallback: ((PermissionState) -> Unit)? = null
|
||||||
|
private var pendingStartupCallback: ((Map<String, PermissionState>) -> Unit)? = null
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Optional listener invoked on every resolved state change. The
|
* Optional listener invoked on every resolved state change. The
|
||||||
@@ -143,6 +147,56 @@ class AndroidPermissionRequester {
|
|||||||
*/
|
*/
|
||||||
var stateChangeListener: ((permission: String, state: PermissionState) -> Unit)? = null
|
var stateChangeListener: ((permission: String, state: PermissionState) -> Unit)? = null
|
||||||
|
|
||||||
|
fun ensureStartupPermissions(
|
||||||
|
activity: Activity,
|
||||||
|
callback: (Map<String, PermissionState>) -> Unit,
|
||||||
|
) {
|
||||||
|
val required = mutableListOf<String>()
|
||||||
|
val resolved = mutableMapOf<String, PermissionState>()
|
||||||
|
|
||||||
|
fun resolveNow(permission: String) {
|
||||||
|
resolved[permission] = currentState(activity, permission)
|
||||||
|
}
|
||||||
|
|
||||||
|
val startupPermissions = buildList {
|
||||||
|
add(Manifest.permission.RECORD_AUDIO)
|
||||||
|
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) {
|
||||||
|
add(Manifest.permission.BLUETOOTH_CONNECT)
|
||||||
|
}
|
||||||
|
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
add(Manifest.permission.POST_NOTIFICATIONS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (permission in startupPermissions) {
|
||||||
|
val granted = ContextCompat.checkSelfPermission(activity, permission) ==
|
||||||
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
if (granted) {
|
||||||
|
resolveNow(permission)
|
||||||
|
} else {
|
||||||
|
markRequested(activity, permission)
|
||||||
|
required.add(permission)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (required.isEmpty()) {
|
||||||
|
for (permission in startupPermissions) {
|
||||||
|
emit(permission, resolved[permission] ?: currentState(activity, permission), null)
|
||||||
|
}
|
||||||
|
callback(
|
||||||
|
startupPermissions.associateWith { resolved[it] ?: currentState(activity, it) }
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingStartupCallback = callback
|
||||||
|
ActivityCompat.requestPermissions(
|
||||||
|
activity,
|
||||||
|
required.toTypedArray(),
|
||||||
|
REQ_STARTUP_PERMISSIONS,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensure `RECORD_AUDIO` is granted, prompting the user if not.
|
* Ensure `RECORD_AUDIO` is granted, prompting the user if not.
|
||||||
*
|
*
|
||||||
@@ -198,6 +252,24 @@ class AndroidPermissionRequester {
|
|||||||
permissions: Array<out String>,
|
permissions: Array<out String>,
|
||||||
grantResults: IntArray,
|
grantResults: IntArray,
|
||||||
): Boolean {
|
): Boolean {
|
||||||
|
if (requestCode == REQ_STARTUP_PERMISSIONS) {
|
||||||
|
val cb = pendingStartupCallback
|
||||||
|
pendingStartupCallback = null
|
||||||
|
val resolved = mutableMapOf<String, PermissionState>()
|
||||||
|
permissions.forEachIndexed { index, permission ->
|
||||||
|
val state = if (index < grantResults.size &&
|
||||||
|
grantResults[index] == PackageManager.PERMISSION_GRANTED
|
||||||
|
) {
|
||||||
|
PermissionState.Granted
|
||||||
|
} else {
|
||||||
|
currentDeniedState(activity, permission)
|
||||||
|
}
|
||||||
|
resolved[permission] = state
|
||||||
|
emit(permission, state, null)
|
||||||
|
}
|
||||||
|
cb?.invoke(resolved)
|
||||||
|
return true
|
||||||
|
}
|
||||||
if (requestCode != REQ_RECORD_AUDIO) return false
|
if (requestCode != REQ_RECORD_AUDIO) return false
|
||||||
val cb = pendingCallback
|
val cb = pendingCallback
|
||||||
pendingCallback = null
|
pendingCallback = null
|
||||||
@@ -298,6 +370,13 @@ class AndroidPermissionRequester {
|
|||||||
emit(permission, state, callback)
|
emit(permission, state, callback)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun currentState(activity: Activity, permission: String): PermissionState {
|
||||||
|
val granted = ContextCompat.checkSelfPermission(activity, permission) ==
|
||||||
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
if (granted) return PermissionState.Granted
|
||||||
|
return currentDeniedState(activity, permission)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deep-link to the application's Settings → App info page so the
|
* Deep-link to the application's Settings → App info page so the
|
||||||
* user can re-grant a permanently-denied permission.
|
* user can re-grant a permanently-denied permission.
|
||||||
@@ -345,6 +424,14 @@ class AndroidPermissionRequester {
|
|||||||
return prefs.getBoolean(KEY_RECORD_AUDIO_HAS_REQUESTED, false)
|
return prefs.getBoolean(KEY_RECORD_AUDIO_HAS_REQUESTED, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun hasEverRequested(activity: Activity, permission: String): Boolean {
|
||||||
|
val prefs = activity.applicationContext.getSharedPreferences(
|
||||||
|
PREFS_FILE,
|
||||||
|
Context.MODE_PRIVATE,
|
||||||
|
)
|
||||||
|
return prefs.getBoolean(requestedKey(permission), false)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Persist that the user has now been prompted for RECORD_AUDIO at
|
* Persist that the user has now been prompted for RECORD_AUDIO at
|
||||||
* least once. Idempotent. Uses `apply` (async, lossless across
|
* least once. Idempotent. Uses `apply` (async, lossless across
|
||||||
@@ -362,4 +449,39 @@ class AndroidPermissionRequester {
|
|||||||
prefs.edit().putBoolean(KEY_RECORD_AUDIO_HAS_REQUESTED, true).apply()
|
prefs.edit().putBoolean(KEY_RECORD_AUDIO_HAS_REQUESTED, true).apply()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun markRequested(activity: Activity, permission: String) {
|
||||||
|
val prefs = activity.applicationContext.getSharedPreferences(
|
||||||
|
PREFS_FILE,
|
||||||
|
Context.MODE_PRIVATE,
|
||||||
|
)
|
||||||
|
val key = requestedKey(permission)
|
||||||
|
if (!prefs.getBoolean(key, false)) {
|
||||||
|
prefs.edit().putBoolean(key, true).apply()
|
||||||
|
}
|
||||||
|
if (permission == Manifest.permission.RECORD_AUDIO) {
|
||||||
|
markRecordAudioRequested(activity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requestedKey(permission: String): String = when (permission) {
|
||||||
|
Manifest.permission.RECORD_AUDIO -> KEY_RECORD_AUDIO_HAS_REQUESTED
|
||||||
|
Manifest.permission.BLUETOOTH_CONNECT -> KEY_BLUETOOTH_CONNECT_HAS_REQUESTED
|
||||||
|
Manifest.permission.POST_NOTIFICATIONS -> KEY_POST_NOTIFICATIONS_HAS_REQUESTED
|
||||||
|
else -> "requested_${permission.replace('.', '_')}"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun currentDeniedState(activity: Activity, permission: String): PermissionState {
|
||||||
|
val hasEverRequested = hasEverRequested(activity, permission)
|
||||||
|
if (!hasEverRequested) return PermissionState.Denied
|
||||||
|
val shouldRationale = ActivityCompat.shouldShowRequestPermissionRationale(
|
||||||
|
activity,
|
||||||
|
permission,
|
||||||
|
)
|
||||||
|
return if (shouldRationale) {
|
||||||
|
PermissionState.Denied
|
||||||
|
} else {
|
||||||
|
PermissionState.PermanentlyDenied
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+28
@@ -184,6 +184,22 @@ class MainActivity : FlutterActivity() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
"requestStartupPermissions" -> {
|
||||||
|
val r = permissionRequester
|
||||||
|
if (r != null) {
|
||||||
|
r.ensureStartupPermissions(this) { states ->
|
||||||
|
result.success(
|
||||||
|
states.mapValues { (_, state) -> permissionStateWireName(state) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result.error(
|
||||||
|
"no_requester",
|
||||||
|
"AndroidPermissionRequester not bound",
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
"openAppSettings" -> {
|
"openAppSettings" -> {
|
||||||
val r = permissionRequester
|
val r = permissionRequester
|
||||||
if (r != null) {
|
if (r != null) {
|
||||||
@@ -235,6 +251,18 @@ class MainActivity : FlutterActivity() {
|
|||||||
if (requester != null) {
|
if (requester != null) {
|
||||||
// SDD-106: state already emitted by AndroidPermissionRequester via stateChangeListener; do NOT double-emit (M-4 fix)
|
// SDD-106: state already emitted by AndroidPermissionRequester via stateChangeListener; do NOT double-emit (M-4 fix)
|
||||||
requester.onResume(this) { _ -> }
|
requester.onResume(this) { _ -> }
|
||||||
|
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) {
|
||||||
|
requester.stateChangeListener?.invoke(
|
||||||
|
android.Manifest.permission.BLUETOOTH_CONNECT,
|
||||||
|
requester.currentState(this, android.Manifest.permission.BLUETOOTH_CONNECT),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
requester.stateChangeListener?.invoke(
|
||||||
|
android.Manifest.permission.POST_NOTIFICATIONS,
|
||||||
|
requester.currentState(this, android.Manifest.permission.POST_NOTIFICATIONS),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// SDD-111: re-evaluate the current audio route on resume.
|
// SDD-111: re-evaluate the current audio route on resume.
|
||||||
audioLifecycleController?.onResume()
|
audioLifecycleController?.onResume()
|
||||||
|
|||||||
@@ -263,11 +263,11 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
|||||||
await showDialog(
|
await showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => AlertDialog(
|
builder: (ctx) => AlertDialog(
|
||||||
title: const Text('Microphone Access'),
|
title: const Text('Permissions'),
|
||||||
content: const Text(
|
content: const Text(
|
||||||
'Chanora needs microphone access to transmit your voice in '
|
'Chanora requests microphone, Bluetooth headset, and '
|
||||||
'TeamSpeak channels. Without this permission, you can only '
|
'notification permissions at startup so voice, headset '
|
||||||
'listen to others.',
|
'routing, and the foreground session work correctly.',
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
@@ -277,7 +277,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
|||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
Navigator.pop(ctx);
|
Navigator.pop(ctx);
|
||||||
_androidPermissions.ensureRecordAudio();
|
_androidPermissions.ensureStartupPermissions();
|
||||||
},
|
},
|
||||||
child: const Text('Allow'),
|
child: const Text('Allow'),
|
||||||
),
|
),
|
||||||
@@ -286,7 +286,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
|||||||
);
|
);
|
||||||
await _uiPreferences.markPermissionsExplained();
|
await _uiPreferences.markPermissionsExplained();
|
||||||
} else {
|
} else {
|
||||||
await _androidPermissions.ensureRecordAudio();
|
await _androidPermissions.ensureStartupPermissions();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
|||||||
Reference in New Issue
Block a user