perf: short-circuit granted mic permission checks
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user