perf: short-circuit granted mic permission checks
This commit is contained in:
@@ -78,6 +78,8 @@ const String methodPermissionStateChanged = 'permissionStateChanged';
|
|||||||
/// Trace: SDD-106 §1.
|
/// Trace: SDD-106 §1.
|
||||||
@visibleForTesting
|
@visibleForTesting
|
||||||
const String methodRequestRecordAudio = 'requestRecordAudio';
|
const String methodRequestRecordAudio = 'requestRecordAudio';
|
||||||
|
@visibleForTesting
|
||||||
|
const String methodRequestStartupPermissions = 'requestStartupPermissions';
|
||||||
|
|
||||||
/// Outbound (Dart → Kotlin) method that deep-links to the application's
|
/// Outbound (Dart → Kotlin) method that deep-links to the application's
|
||||||
/// Android Settings page so the user can re-grant a permanently-denied
|
/// Android Settings page so the user can re-grant a permanently-denied
|
||||||
@@ -109,6 +111,15 @@ enum AndroidRecordAudioPermissionState {
|
|||||||
unknown,
|
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.
|
/// Maps the Kotlin-side `PermissionState` string into the Dart enum.
|
||||||
AndroidRecordAudioPermissionState _parseState(String? raw) {
|
AndroidRecordAudioPermissionState _parseState(String? raw) {
|
||||||
switch (raw) {
|
switch (raw) {
|
||||||
@@ -157,6 +168,16 @@ class AndroidPermissionsService {
|
|||||||
: AndroidRecordAudioPermissionState.granted,
|
: 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;
|
bool _started = false;
|
||||||
|
|
||||||
/// Latest known [RECORD_AUDIO] state. Defaults to
|
/// Latest known [RECORD_AUDIO] state. Defaults to
|
||||||
@@ -191,16 +212,29 @@ class AndroidPermissionsService {
|
|||||||
if (call.method != methodPermissionStateChanged) return null;
|
if (call.method != methodPermissionStateChanged) return null;
|
||||||
final args = call.arguments;
|
final args = call.arguments;
|
||||||
if (args is! Map) return null;
|
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'];
|
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?);
|
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;
|
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
|
/// Request the system permission. Invokes the Kotlin requester and
|
||||||
/// then, if the platform synchronously resolves the request, returns
|
/// then, if the platform synchronously resolves the request, returns
|
||||||
/// the resolved state without parking for the listener; otherwise
|
/// the resolved state without parking for the listener; otherwise
|
||||||
@@ -215,6 +249,9 @@ class AndroidPermissionsService {
|
|||||||
if (ch == null) {
|
if (ch == null) {
|
||||||
return AndroidRecordAudioPermissionState.granted;
|
return AndroidRecordAudioPermissionState.granted;
|
||||||
}
|
}
|
||||||
|
if (_state.value == AndroidRecordAudioPermissionState.granted) {
|
||||||
|
return AndroidRecordAudioPermissionState.granted;
|
||||||
|
}
|
||||||
// Snapshot the pre-invocation state. New Android hosts return the
|
// Snapshot the pre-invocation state. New Android hosts return the
|
||||||
// resolved Kotlin PermissionState string from requestRecordAudio;
|
// resolved Kotlin PermissionState string from requestRecordAudio;
|
||||||
// older/test hosts may still return null and rely only on the
|
// 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
|
/// Deep-link to the system app settings page for the permanently
|
||||||
/// denied case (SDD-106 §3). On non-Android, a no-op.
|
/// denied case (SDD-106 §3). On non-Android, a no-op.
|
||||||
Future<void> openAppSettings() async {
|
Future<void> openAppSettings() async {
|
||||||
@@ -297,5 +367,8 @@ class AndroidPermissionsService {
|
|||||||
void dispose() {
|
void dispose() {
|
||||||
stop();
|
stop();
|
||||||
_state.dispose();
|
_state.dispose();
|
||||||
|
for (final notifier in _extraStates.values) {
|
||||||
|
notifier.dispose();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,6 +68,9 @@ class IosPermissionsService {
|
|||||||
Future<AndroidRecordAudioPermissionState> ensureRecordAudio() async {
|
Future<AndroidRecordAudioPermissionState> ensureRecordAudio() async {
|
||||||
final ch = _channel;
|
final ch = _channel;
|
||||||
if (ch == null) return AndroidRecordAudioPermissionState.granted;
|
if (ch == null) return AndroidRecordAudioPermissionState.granted;
|
||||||
|
if (_state.value == AndroidRecordAudioPermissionState.granted) {
|
||||||
|
return AndroidRecordAudioPermissionState.granted;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
final state = _parseState(
|
final state = _parseState(
|
||||||
await ch.invokeMethod<String>(methodRequestMicrophonePermission),
|
await ch.invokeMethod<String>(methodRequestMicrophonePermission),
|
||||||
|
|||||||
@@ -371,8 +371,8 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test(
|
test(
|
||||||
'SWE4-UV-041 / SDD-106 §1: ensureRecordAudio() resolves from the '
|
'SWE4-UV-041 / SDD-106 §1: ensureRecordAudio() short-circuits when '
|
||||||
'platform return value even when the resolved state is unchanged',
|
'the cached state is already granted',
|
||||||
() async {
|
() async {
|
||||||
final svc = AndroidPermissionsService(channel: channel)..start();
|
final svc = AndroidPermissionsService(channel: channel)..start();
|
||||||
|
|
||||||
@@ -392,7 +392,7 @@ void main() {
|
|||||||
expect(result, AndroidRecordAudioPermissionState.granted);
|
expect(result, AndroidRecordAudioPermissionState.granted);
|
||||||
expect(
|
expect(
|
||||||
outgoingCalls.where((c) => c.method == methodRequestRecordAudio),
|
outgoingCalls.where((c) => c.method == methodRequestRecordAudio),
|
||||||
hasLength(1),
|
isEmpty,
|
||||||
);
|
);
|
||||||
svc.dispose();
|
svc.dispose();
|
||||||
},
|
},
|
||||||
@@ -411,6 +411,46 @@ void main() {
|
|||||||
svc.dispose();
|
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 '
|
test('SWE4-UV-041 / SDD-106: non-Android short-circuit — when channel '
|
||||||
'is null, recordAudioState seeds to granted and ensureRecordAudio '
|
'is null, recordAudioState seeds to granted and ensureRecordAudio '
|
||||||
'resolves synchronously without touching any channel', () async {
|
'resolves synchronously without touching any channel', () async {
|
||||||
|
|||||||
Reference in New Issue
Block a user