fix(android): unblock mic permission startup

This commit is contained in:
Edison Jwa
2026-05-20 14:52:34 +09:00
parent 7254f81c65
commit 210a6a0e11
5 changed files with 168 additions and 78 deletions
@@ -23,8 +23,6 @@
// test is actually exercised. The non-Android short-circuit (channel
// == null) is covered explicitly by the final test.
import 'dart:async';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
@@ -50,13 +48,10 @@ void main() {
}) async {
const codec = StandardMethodCodec();
final encoded = codec.encodeMethodCall(
MethodCall(
methodPermissionStateChanged,
<String, dynamic>{
'permission': permission,
'state': state,
},
),
MethodCall(methodPermissionStateChanged, <String, dynamic>{
'permission': permission,
'state': state,
}),
);
await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.handlePlatformMessage(channel.name, encoded, (_) {});
@@ -73,11 +68,11 @@ void main() {
// response set `outgoingResponder`.
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, (call) async {
outgoingCalls.add(call);
final r = outgoingResponder;
if (r != null) return r(call);
return null;
});
outgoingCalls.add(call);
final r = outgoingResponder;
if (r != null) return r(call);
return null;
});
});
tearDown(() {
@@ -85,8 +80,7 @@ void main() {
.setMockMethodCallHandler(channel, null);
});
test(
'SWE4-UV-041 / SDD-106: a fresh service exposes a deterministic '
test('SWE4-UV-041 / SDD-106: a fresh service exposes a deterministic '
'initial recordAudioState (granted on non-Android host, where these '
'unit tests run; the Android cold-launch case is unknown)', () {
// Trace: SDD-106 §5 state machine; non-Android short-circuit
@@ -103,8 +97,7 @@ void main() {
svc.dispose();
});
test(
'SWE4-UV-041 / SDD-106 §5: inbound permissionStateChanged with '
test('SWE4-UV-041 / SDD-106 §5: inbound permissionStateChanged with '
'state=Granted transitions recordAudioState to granted and notifies '
'listeners', () async {
final svc = AndroidPermissionsService(channel: channel)..start();
@@ -138,8 +131,7 @@ void main() {
svc.dispose();
});
test(
'SWE4-UV-041 / SDD-106 §5: inbound permissionStateChanged with '
test('SWE4-UV-041 / SDD-106 §5: inbound permissionStateChanged with '
'state=Denied transitions recordAudioState to denied', () async {
final svc = AndroidPermissionsService(channel: channel)..start();
@@ -155,8 +147,7 @@ void main() {
svc.dispose();
});
test(
'SWE4-UV-041 / SDD-106 §3: inbound permissionStateChanged with '
test('SWE4-UV-041 / SDD-106 §3: inbound permissionStateChanged with '
'state=PermanentlyDenied transitions recordAudioState to '
'permanentlyDenied (drives the "open app settings" UX)', () async {
final svc = AndroidPermissionsService(channel: channel)..start();
@@ -173,8 +164,7 @@ void main() {
svc.dispose();
});
test(
'SWE4-UV-041 / SDD-106 §5: a malformed state string parses to '
test('SWE4-UV-041 / SDD-106 §5: a malformed state string parses to '
'unknown (the _parseState default branch)', () async {
// Implementation contract: `_parseState` returns `unknown` for any
// string outside {Granted, Denied, PermanentlyDenied}.
@@ -202,11 +192,9 @@ void main() {
svc.dispose();
});
test(
'SWE4-UV-041 / SDD-106: inbound permissionStateChanged for a '
test('SWE4-UV-041 / SDD-106: inbound permissionStateChanged for a '
'permission other than RECORD_AUDIO is ignored (POST_NOTIFICATIONS '
'will route through a sibling listenable per the impl note)',
() async {
'will route through a sibling listenable per the impl note)', () async {
final svc = AndroidPermissionsService(channel: channel)..start();
// Seed a known baseline.
@@ -229,14 +217,12 @@ void main() {
expect(
svc.recordAudioState.value,
AndroidRecordAudioPermissionState.denied,
reason:
'POST_NOTIFICATIONS must not mutate the RECORD_AUDIO listenable',
reason: 'POST_NOTIFICATIONS must not mutate the RECORD_AUDIO listenable',
);
svc.dispose();
});
test(
'SWE4-UV-041 / SDD-106: start() is idempotent — calling it twice '
test('SWE4-UV-041 / SDD-106: start() is idempotent — calling it twice '
'does not double-register the handler and inbound messages still '
'fire exactly once', () async {
final svc = AndroidPermissionsService(channel: channel)
@@ -265,8 +251,7 @@ void main() {
svc.dispose();
});
test(
'SWE4-UV-041 / SDD-106: stop() removes the handler — subsequent '
test('SWE4-UV-041 / SDD-106: stop() removes the handler — subsequent '
'inbound messages have no effect on recordAudioState', () async {
final svc = AndroidPermissionsService(channel: channel)..start();
@@ -294,8 +279,7 @@ void main() {
);
});
test(
'SWE4-UV-041 / SDD-106 §1: ensureRecordAudio() emits an outbound '
test('SWE4-UV-041 / SDD-106 §1: ensureRecordAudio() emits an outbound '
'requestRecordAudio MethodCall on the channel; when the platform '
'stub raises (e.g. handler missing in a debug build) the impl '
'catches and resolves to the current cached state', () async {
@@ -324,8 +308,11 @@ void main() {
.skip(priorOutgoing)
.where((c) => c.method == methodRequestRecordAudio)
.toList();
expect(requests, hasLength(1),
reason: 'ensureRecordAudio() must invoke requestRecordAudio');
expect(
requests,
hasLength(1),
reason: 'ensureRecordAudio() must invoke requestRecordAudio',
);
expect(
result,
AndroidRecordAudioPermissionState.denied,
@@ -335,11 +322,9 @@ void main() {
svc.dispose();
});
test(
'SWE4-UV-041 / SDD-106 §1: ensureRecordAudio() resolves with the '
test('SWE4-UV-041 / SDD-106 §1: ensureRecordAudio() resolves with the '
'new state when an inbound permissionStateChanged is delivered '
'while the request is in-flight (the realistic Kotlin flow)',
() async {
'while the request is in-flight (the realistic Kotlin flow)', () async {
final svc = AndroidPermissionsService(channel: channel)..start();
// Seed away from the host default (granted on linux) so that the
@@ -386,7 +371,34 @@ void main() {
});
test(
'SWE4-UV-041 / SDD-106 §3: openAppSettings() emits an outbound '
'SWE4-UV-041 / SDD-106 §1: ensureRecordAudio() resolves from the '
'platform return value even when the resolved state is unchanged',
() async {
final svc = AndroidPermissionsService(channel: channel)..start();
await sendPermissionStateChanged(
permission: 'android.permission.RECORD_AUDIO',
state: 'Granted',
);
expect(
svc.recordAudioState.value,
AndroidRecordAudioPermissionState.granted,
);
outgoingResponder = (call) async => 'Granted';
final result = await svc.ensureRecordAudio();
expect(result, AndroidRecordAudioPermissionState.granted);
expect(
outgoingCalls.where((c) => c.method == methodRequestRecordAudio),
hasLength(1),
);
svc.dispose();
},
);
test('SWE4-UV-041 / SDD-106 §3: openAppSettings() emits an outbound '
'openAppSettings MethodCall on the channel', () async {
final svc = AndroidPermissionsService(channel: channel)..start();
@@ -399,8 +411,7 @@ void main() {
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 '
'resolves synchronously without touching any channel', () async {
final svc = AndroidPermissionsService(channel: null);