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
@@ -108,7 +108,7 @@ class MainActivity : FlutterActivity() {
permissionsChannel = channel
val requester = AndroidPermissionRequester()
requester.stateChangeListener = { permission, state ->
val stateName = state.toString()
val stateName = state.javaClass.simpleName
channel.invokeMethod(
MethodChannels.METHOD_PERMISSION_STATE_CHANGED,
mapOf(
@@ -148,8 +148,9 @@ class MainActivity : FlutterActivity() {
"requestRecordAudio" -> {
val r = permissionRequester
if (r != null) {
r.ensureRecordAudioPermission(this) { _ -> }
result.success(null)
r.ensureRecordAudioPermission(this) { state ->
result.success(state.javaClass.simpleName)
}
} else {
result.error(
"no_requester",
+24 -3
View File
@@ -250,6 +250,7 @@ class _BetaHomeState extends State<_BetaHome> {
bool _inChannel = false;
rust.BridgeTransmitMode _transmitMode = rust.BridgeTransmitMode.ptt;
bool _hardMute = false;
bool _hardMuteByPermission = false;
int _releaseTailMs = 200;
BigInt? _currentVoiceChannelId;
BigInt? _pendingVoiceChannelId;
@@ -436,6 +437,7 @@ class _BetaHomeState extends State<_BetaHome> {
_inChannel = inChannel;
_transmitMode = transmitMode;
_hardMute = mute;
if (!mute) _hardMuteByPermission = false;
_releaseTailMs = releaseTailMs;
_audioStarted = inChannel;
_currentVoiceChannelId = currentChannelId;
@@ -687,7 +689,12 @@ class _BetaHomeState extends State<_BetaHome> {
// time via the Grant / Open Settings action.
try {
await rust.setHardMute(muted: true);
if (mounted) setState(() => _hardMute = true);
if (mounted) {
setState(() {
_hardMute = true;
_hardMuteByPermission = true;
});
}
} catch (_) {
// Best-effort clamp; if the bridge isn't ready we still
// proceed. The capture path also self-clamps on Android
@@ -696,6 +703,14 @@ class _BetaHomeState extends State<_BetaHome> {
// AtomicU8 clamp); this Dart setHardMute is the
// defence-in-depth path.
}
} else if (_hardMuteByPermission) {
await rust.setHardMute(muted: false);
if (mounted) {
setState(() {
_hardMute = false;
_hardMuteByPermission = false;
});
}
}
await rust.voiceJoin(channelId: ch.id, password: password ?? '');
if (!mounted) return;
@@ -787,7 +802,10 @@ class _BetaHomeState extends State<_BetaHome> {
await rust.setHardMute(muted: next);
await rust.setInputMuted(muted: next);
if (!mounted) return;
setState(() => _inputMuted = next);
setState(() {
_inputMuted = next;
_hardMuteByPermission = false;
});
} catch (e) {
if (!mounted) return;
setState(() => _error = e.toString());
@@ -2107,7 +2125,10 @@ class _SnapshotView extends StatelessWidget {
: () => onJoinChannel(ch),
),
selected: ch.id == currentVoiceChannelId,
onTap: hasJoinPending || !canJoinVoiceChannel || ch.id == currentVoiceChannelId
onTap:
hasJoinPending ||
!canJoinVoiceChannel ||
ch.id == currentVoiceChannelId
? null
: () => onJoinChannel(ch),
),
@@ -131,10 +131,11 @@ class AndroidPermissionsService {
/// production code uses the default channel keyed on
/// [androidPermissionsChannelName].
AndroidPermissionsService({MethodChannel? channel})
: _channel = channel ??
(_isAndroid
? const MethodChannel(androidPermissionsChannelName)
: null);
: _channel =
channel ??
(_isAndroid
? const MethodChannel(androidPermissionsChannelName)
: null);
/// Platform-detection seam. Web counts as non-Android.
static bool get _isAndroid {
@@ -148,13 +149,13 @@ class AndroidPermissionsService {
final ValueNotifier<AndroidRecordAudioPermissionState> _state =
ValueNotifier<AndroidRecordAudioPermissionState>(
// On non-Android, present as granted so the voice-join gate is a
// no-op (desktop / iOS have separate audio-permission paths owned
// elsewhere; see SDD-101 for iOS).
_isAndroid
? AndroidRecordAudioPermissionState.unknown
: AndroidRecordAudioPermissionState.granted,
);
// On non-Android, present as granted so the voice-join gate is a
// no-op (desktop / iOS have separate audio-permission paths owned
// elsewhere; see SDD-101 for iOS).
_isAndroid
? AndroidRecordAudioPermissionState.unknown
: AndroidRecordAudioPermissionState.granted,
);
bool _started = false;
@@ -214,16 +215,14 @@ class AndroidPermissionsService {
if (ch == null) {
return AndroidRecordAudioPermissionState.granted;
}
// Snapshot the pre-invocation state. If the platform synchronously
// resolves the request to a NEW state during invokeMethod (the
// platform may emit permissionStateChanged before invokeMethod
// returns), we can return immediately. If the state is unchanged
// (typical: user is being re-prompted after a previous denial; the
// platform always re-prompts and the resolution arrives
// asynchronously), we must wait for the next listener fire.
// 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
// permissionStateChanged callback below.
final preInvokeState = _state.value;
String? returnedState;
try {
await ch.invokeMethod<void>(methodRequestRecordAudio);
returnedState = await ch.invokeMethod<String>(methodRequestRecordAudio);
} catch (_) {
// Channel-side failure (e.g. missing handler in a debug build).
// Fall back to whatever state we currently hold; if still
@@ -232,6 +231,11 @@ class AndroidPermissionsService {
// "proceed in listen-only".
return _state.value;
}
final parsedReturnedState = _parseState(returnedState);
if (parsedReturnedState != AndroidRecordAudioPermissionState.unknown) {
_state.value = parsedReturnedState;
return parsedReturnedState;
}
// M1 fix (corrected): only short-circuit if the platform changed
// the state synchronously to a resolved value. A still-unknown
// state means we must wait. A still-equal-to-pre-invoke state
@@ -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);
+61 -8
View File
@@ -1119,17 +1119,13 @@ fn call_voice_service_static(method: &str) -> bool {
return false;
}
};
let class = match env.find_class(ANDROID_VOICE_FG_SERVICE_FQCN) {
Ok(c) => c,
Err(e) => {
let _ = env.exception_clear();
warn!(target: "chanora_audio", error = %e, method, "android: find_class failed");
return false;
}
};
// SAFETY: ndk_context::context() is the application Context
// jobject; valid global ref for process lifetime.
let context_obj = unsafe { JObject::from_raw(ctx.context() as jni::sys::jobject) };
let class = match load_app_class(&mut env, &context_obj, ANDROID_VOICE_FG_SERVICE_FQCN) {
Some(c) => c,
None => return false,
};
match env.call_static_method(
&class,
method,
@@ -1147,3 +1143,60 @@ fn call_voice_service_static(method: &str) -> bool {
}
}
}
fn load_app_class<'local>(
env: &mut jni::JNIEnv<'local>,
context_obj: &jni::objects::JObject<'local>,
slash_name: &str,
) -> Option<jni::objects::JClass<'local>> {
match env.find_class(slash_name) {
Ok(c) => return Some(c),
Err(e) => {
let _ = env.exception_clear();
warn!(target: "chanora_audio", error = %e, class = slash_name, "android: find_class failed; retrying with app ClassLoader");
}
}
let loader = match env
.call_method(
context_obj,
"getClassLoader",
"()Ljava/lang/ClassLoader;",
&[],
)
.and_then(|v| v.l())
{
Ok(loader) => loader,
Err(e) => {
let _ = env.exception_clear();
warn!(target: "chanora_audio", error = %e, "android: Context.getClassLoader failed");
return None;
}
};
let dotted_name = slash_name.replace('/', ".");
let class_name = match env.new_string(&dotted_name) {
Ok(s) => s,
Err(e) => {
let _ = env.exception_clear();
warn!(target: "chanora_audio", error = %e, class = %dotted_name, "android: class-name string allocation failed");
return None;
}
};
let class_name_obj = jni::objects::JObject::from(class_name);
match env
.call_method(
&loader,
"loadClass",
"(Ljava/lang/String;)Ljava/lang/Class;",
&[jni::objects::JValue::Object(&class_name_obj)],
)
.and_then(|v| v.l())
{
Ok(class_obj) => Some(jni::objects::JClass::from(class_obj)),
Err(e) => {
let _ = env.exception_clear();
warn!(target: "chanora_audio", error = %e, class = %dotted_name, "android: ClassLoader.loadClass failed");
None
}
}
}