feat: request Android startup permissions

This commit is contained in:
Edison Jwa
2026-05-25 01:10:10 +09:00
parent 11d4e7b7da
commit d7556cd39f
3 changed files with 156 additions and 6 deletions
@@ -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
}
}
}
@@ -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()
+6 -6
View File
@@ -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 (_) {}