perf: short-circuit granted mic permission checks

This commit is contained in:
Edison Jwa
2026-05-24 20:54:49 +09:00
parent 9b8d812f9c
commit b494f8902d
3 changed files with 124 additions and 8 deletions
@@ -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<AndroidPermissionKind, ValueNotifier<AndroidRecordAudioPermissionState>>
_extraStates = {
for (final kind in AndroidPermissionKind.values)
kind: ValueNotifier<AndroidRecordAudioPermissionState>(
_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<AndroidRecordAudioPermissionState> 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<Map<AndroidPermissionKind, AndroidRecordAudioPermissionState>>
ensureStartupPermissions() async {
final ch = _channel;
if (ch == null) {
return {
for (final kind in AndroidPermissionKind.values)
kind: AndroidRecordAudioPermissionState.granted,
};
}
Map<dynamic, dynamic>? returned;
try {
returned = await ch.invokeMethod<Map<dynamic, dynamic>>(
methodRequestStartupPermissions,
);
} catch (_) {
return {
for (final kind in AndroidPermissionKind.values)
kind: permissionState(kind).value,
};
}
final resolved = <AndroidPermissionKind, AndroidRecordAudioPermissionState>{};
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<void> openAppSettings() async {
@@ -297,5 +367,8 @@ class AndroidPermissionsService {
void dispose() {
stop();
_state.dispose();
for (final notifier in _extraStates.values) {
notifier.dispose();
}
}
}
@@ -68,6 +68,9 @@ class IosPermissionsService {
Future<AndroidRecordAudioPermissionState> 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<String>(methodRequestMicrophonePermission),