From d7556cd39f77df5ad00616ff1daabcb165ffa711 Mon Sep 17 00:00:00 2001 From: Edison Jwa Date: Mon, 25 May 2026 01:10:10 +0900 Subject: [PATCH] feat: request Android startup permissions --- .../AndroidPermissionRequester.kt | 122 ++++++++++++++++++ .../chanora/chanora_flutter/MainActivity.kt | 28 ++++ apps/chanora_flutter/lib/main.dart | 12 +- 3 files changed, 156 insertions(+), 6 deletions(-) diff --git a/apps/chanora_flutter/android/app/src/main/kotlin/app/chanora/chanora_flutter/AndroidPermissionRequester.kt b/apps/chanora_flutter/android/app/src/main/kotlin/app/chanora/chanora_flutter/AndroidPermissionRequester.kt index 36770b5..ae18db9 100644 --- a/apps/chanora_flutter/android/app/src/main/kotlin/app/chanora/chanora_flutter/AndroidPermissionRequester.kt +++ b/apps/chanora_flutter/android/app/src/main/kotlin/app/chanora/chanora_flutter/AndroidPermissionRequester.kt @@ -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) -> 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) -> Unit, + ) { + val required = mutableListOf() + val resolved = mutableMapOf() + + 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, grantResults: IntArray, ): Boolean { + if (requestCode == REQ_STARTUP_PERMISSIONS) { + val cb = pendingStartupCallback + pendingStartupCallback = null + val resolved = mutableMapOf() + 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 + } + } } diff --git a/apps/chanora_flutter/android/app/src/main/kotlin/app/chanora/chanora_flutter/MainActivity.kt b/apps/chanora_flutter/android/app/src/main/kotlin/app/chanora/chanora_flutter/MainActivity.kt index 1fca899..e290043 100644 --- a/apps/chanora_flutter/android/app/src/main/kotlin/app/chanora/chanora_flutter/MainActivity.kt +++ b/apps/chanora_flutter/android/app/src/main/kotlin/app/chanora/chanora_flutter/MainActivity.kt @@ -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() diff --git a/apps/chanora_flutter/lib/main.dart b/apps/chanora_flutter/lib/main.dart index bf3d374..7d5f1c3 100644 --- a/apps/chanora_flutter/lib/main.dart +++ b/apps/chanora_flutter/lib/main.dart @@ -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 (_) {}