From b494f8902daac1424dbc25974fc3b82f4667b7cf Mon Sep 17 00:00:00 2001 From: Edison Jwa Date: Sun, 24 May 2026 20:54:49 +0900 Subject: [PATCH] perf: short-circuit granted mic permission checks --- .../services/android_permissions_service.dart | 83 +++++++++++++++++-- .../lib/services/ios_permissions_service.dart | 3 + .../android_permissions_service_test.dart | 46 +++++++++- 3 files changed, 124 insertions(+), 8 deletions(-) diff --git a/apps/chanora_flutter/lib/services/android_permissions_service.dart b/apps/chanora_flutter/lib/services/android_permissions_service.dart index ae126f8..293b849 100644 --- a/apps/chanora_flutter/lib/services/android_permissions_service.dart +++ b/apps/chanora_flutter/lib/services/android_permissions_service.dart @@ -78,6 +78,8 @@ const String methodPermissionStateChanged = 'permissionStateChanged'; /// Trace: SDD-106 §1. @visibleForTesting const String methodRequestRecordAudio = 'requestRecordAudio'; +@visibleForTesting +const String methodRequestStartupPermissions = 'requestStartupPermissions'; /// Outbound (Dart → Kotlin) method that deep-links to the application's /// Android Settings page so the user can re-grant a permanently-denied @@ -109,6 +111,15 @@ enum AndroidRecordAudioPermissionState { unknown, } +enum AndroidPermissionKind { + recordAudio('android.permission.RECORD_AUDIO'), + bluetoothConnect('android.permission.BLUETOOTH_CONNECT'), + postNotifications('android.permission.POST_NOTIFICATIONS'); + + const AndroidPermissionKind(this.permission); + final String permission; +} + /// Maps the Kotlin-side `PermissionState` string into the Dart enum. AndroidRecordAudioPermissionState _parseState(String? raw) { switch (raw) { @@ -157,6 +168,16 @@ class AndroidPermissionsService { : AndroidRecordAudioPermissionState.granted, ); + final Map> + _extraStates = { + for (final kind in AndroidPermissionKind.values) + kind: ValueNotifier( + _isAndroid + ? AndroidRecordAudioPermissionState.unknown + : AndroidRecordAudioPermissionState.granted, + ), + }; + bool _started = false; /// Latest known [RECORD_AUDIO] state. Defaults to @@ -191,16 +212,29 @@ class AndroidPermissionsService { if (call.method != methodPermissionStateChanged) return null; final args = call.arguments; if (args is! Map) return null; - // We currently track RECORD_AUDIO only. POST_NOTIFICATIONS (SDD-107 - // §6) is on the same channel by design and will route through a - // sibling listenable when that work lands. final permission = args['permission']; - if (permission != 'android.permission.RECORD_AUDIO') return null; + if (permission is! String) return null; final state = _parseState(args['state'] as String?); - _state.value = state; + if (permission == AndroidPermissionKind.recordAudio.permission) { + _state.value = state; + } + for (final entry in _extraStates.entries) { + if (entry.key.permission == permission) { + entry.value.value = state; + } + } return null; } + ValueListenable permissionState( + AndroidPermissionKind kind, + ) { + if (kind == AndroidPermissionKind.recordAudio) { + return _state; + } + return _extraStates[kind]!; + } + /// Request the system permission. Invokes the Kotlin requester and /// then, if the platform synchronously resolves the request, returns /// the resolved state without parking for the listener; otherwise @@ -215,6 +249,9 @@ class AndroidPermissionsService { if (ch == null) { return AndroidRecordAudioPermissionState.granted; } + if (_state.value == AndroidRecordAudioPermissionState.granted) { + return AndroidRecordAudioPermissionState.granted; + } // Snapshot the pre-invocation state. New Android hosts return the // resolved Kotlin PermissionState string from requestRecordAudio; // older/test hosts may still return null and rely only on the @@ -272,6 +309,39 @@ class AndroidPermissionsService { } } + Future> + ensureStartupPermissions() async { + final ch = _channel; + if (ch == null) { + return { + for (final kind in AndroidPermissionKind.values) + kind: AndroidRecordAudioPermissionState.granted, + }; + } + Map? returned; + try { + returned = await ch.invokeMethod>( + methodRequestStartupPermissions, + ); + } catch (_) { + return { + for (final kind in AndroidPermissionKind.values) + kind: permissionState(kind).value, + }; + } + final resolved = {}; + for (final kind in AndroidPermissionKind.values) { + final parsed = _parseState(returned?[kind.permission] as String?); + if (kind == AndroidPermissionKind.recordAudio) { + _state.value = parsed; + } else { + _extraStates[kind]!.value = parsed; + } + resolved[kind] = parsed; + } + return resolved; + } + /// Deep-link to the system app settings page for the permanently /// denied case (SDD-106 §3). On non-Android, a no-op. Future openAppSettings() async { @@ -297,5 +367,8 @@ class AndroidPermissionsService { void dispose() { stop(); _state.dispose(); + for (final notifier in _extraStates.values) { + notifier.dispose(); + } } } diff --git a/apps/chanora_flutter/lib/services/ios_permissions_service.dart b/apps/chanora_flutter/lib/services/ios_permissions_service.dart index 555191a..fa77944 100644 --- a/apps/chanora_flutter/lib/services/ios_permissions_service.dart +++ b/apps/chanora_flutter/lib/services/ios_permissions_service.dart @@ -68,6 +68,9 @@ class IosPermissionsService { Future ensureRecordAudio() async { final ch = _channel; if (ch == null) return AndroidRecordAudioPermissionState.granted; + if (_state.value == AndroidRecordAudioPermissionState.granted) { + return AndroidRecordAudioPermissionState.granted; + } try { final state = _parseState( await ch.invokeMethod(methodRequestMicrophonePermission), diff --git a/apps/chanora_flutter/test/services/android_permissions_service_test.dart b/apps/chanora_flutter/test/services/android_permissions_service_test.dart index 7205b00..5439820 100644 --- a/apps/chanora_flutter/test/services/android_permissions_service_test.dart +++ b/apps/chanora_flutter/test/services/android_permissions_service_test.dart @@ -371,8 +371,8 @@ void main() { }); test( - 'SWE4-UV-041 / SDD-106 §1: ensureRecordAudio() resolves from the ' - 'platform return value even when the resolved state is unchanged', + 'SWE4-UV-041 / SDD-106 §1: ensureRecordAudio() short-circuits when ' + 'the cached state is already granted', () async { final svc = AndroidPermissionsService(channel: channel)..start(); @@ -392,7 +392,7 @@ void main() { expect(result, AndroidRecordAudioPermissionState.granted); expect( outgoingCalls.where((c) => c.method == methodRequestRecordAudio), - hasLength(1), + isEmpty, ); svc.dispose(); }, @@ -411,6 +411,46 @@ void main() { svc.dispose(); }); + test('startup permissions request emits outbound method and records ' + 'multiple returned states', () async { + final svc = AndroidPermissionsService(channel: channel)..start(); + + outgoingResponder = (call) async { + if (call.method == methodRequestStartupPermissions) { + return { + 'android.permission.RECORD_AUDIO': 'Granted', + 'android.permission.BLUETOOTH_CONNECT': 'Denied', + 'android.permission.POST_NOTIFICATIONS': 'PermanentlyDenied', + }; + } + return null; + }; + + final result = await svc.ensureStartupPermissions(); + + expect( + outgoingCalls.where((c) => c.method == methodRequestStartupPermissions), + hasLength(1), + ); + expect( + result[AndroidPermissionKind.recordAudio], + AndroidRecordAudioPermissionState.granted, + ); + expect( + result[AndroidPermissionKind.bluetoothConnect], + AndroidRecordAudioPermissionState.denied, + ); + expect( + result[AndroidPermissionKind.postNotifications], + AndroidRecordAudioPermissionState.permanentlyDenied, + ); + expect( + svc.permissionState(AndroidPermissionKind.bluetoothConnect).value, + AndroidRecordAudioPermissionState.denied, + ); + svc.dispose(); + }); + test('SWE4-UV-041 / SDD-106: non-Android short-circuit — when channel ' 'is null, recordAudioState seeds to granted and ensureRecordAudio ' 'resolves synchronously without touching any channel', () async {