feat: request Android startup permissions
This commit is contained in:
+122
@@ -94,6 +94,7 @@ class AndroidPermissionRequester {
|
||||
* [handleRequestPermissionsResult] matches the request.
|
||||
*/
|
||||
private const val REQ_RECORD_AUDIO = 0x52454341 // "RECA"
|
||||
private const val REQ_STARTUP_PERMISSIONS = 0x53544152 // "STAR"
|
||||
|
||||
/**
|
||||
* SharedPreferences file backing the cross-process / cross-launch
|
||||
@@ -124,6 +125,8 @@ class AndroidPermissionRequester {
|
||||
* 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_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.
|
||||
*/
|
||||
private var pendingCallback: ((PermissionState) -> Unit)? = null
|
||||
private var pendingStartupCallback: ((Map<String, PermissionState>) -> Unit)? = null
|
||||
|
||||
/**
|
||||
* Optional listener invoked on every resolved state change. The
|
||||
@@ -143,6 +147,56 @@ class AndroidPermissionRequester {
|
||||
*/
|
||||
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.
|
||||
*
|
||||
@@ -198,6 +252,24 @@ class AndroidPermissionRequester {
|
||||
permissions: Array<out String>,
|
||||
grantResults: IntArray,
|
||||
): 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
|
||||
val cb = pendingCallback
|
||||
pendingCallback = null
|
||||
@@ -298,6 +370,13 @@ class AndroidPermissionRequester {
|
||||
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
|
||||
* user can re-grant a permanently-denied permission.
|
||||
@@ -345,6 +424,14 @@ class AndroidPermissionRequester {
|
||||
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
|
||||
* least once. Idempotent. Uses `apply` (async, lossless across
|
||||
@@ -362,4 +449,39 @@ class AndroidPermissionRequester {
|
||||
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" -> {
|
||||
val r = permissionRequester
|
||||
if (r != null) {
|
||||
@@ -235,6 +251,18 @@ class MainActivity : FlutterActivity() {
|
||||
if (requester != null) {
|
||||
// SDD-106: state already emitted by AndroidPermissionRequester via stateChangeListener; do NOT double-emit (M-4 fix)
|
||||
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.
|
||||
audioLifecycleController?.onResume()
|
||||
|
||||
@@ -263,11 +263,11 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
await showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Microphone Access'),
|
||||
title: const Text('Permissions'),
|
||||
content: const Text(
|
||||
'Chanora needs microphone access to transmit your voice in '
|
||||
'TeamSpeak channels. Without this permission, you can only '
|
||||
'listen to others.',
|
||||
'Chanora requests microphone, Bluetooth headset, and '
|
||||
'notification permissions at startup so voice, headset '
|
||||
'routing, and the foreground session work correctly.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
@@ -277,7 +277,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(ctx);
|
||||
_androidPermissions.ensureRecordAudio();
|
||||
_androidPermissions.ensureStartupPermissions();
|
||||
},
|
||||
child: const Text('Allow'),
|
||||
),
|
||||
@@ -286,7 +286,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
);
|
||||
await _uiPreferences.markPermissionsExplained();
|
||||
} else {
|
||||
await _androidPermissions.ensureRecordAudio();
|
||||
await _androidPermissions.ensureStartupPermissions();
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
Reference in New Issue
Block a user