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),
@@ -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 {