From 210a6a0e11310423b7382e948608988610042f38 Mon Sep 17 00:00:00 2001 From: Edison Jwa Date: Wed, 20 May 2026 08:17:31 +0900 Subject: [PATCH] fix(android): unblock mic permission startup --- .../chanora/chanora_flutter/MainActivity.kt | 7 +- apps/chanora_flutter/lib/main.dart | 27 ++++- .../services/android_permissions_service.dart | 42 ++++---- .../android_permissions_service_test.dart | 101 ++++++++++-------- .../chanora_audio/src/android_voice_unit.rs | 69 ++++++++++-- 5 files changed, 168 insertions(+), 78 deletions(-) diff --git a/apps/chanora_flutter/android/app/src/main/kotlin/app/chanora/chanora_flutter/MainActivity.kt b/apps/chanora_flutter/android/app/src/main/kotlin/app/chanora/chanora_flutter/MainActivity.kt index 91f2604..ee3cb88 100644 --- a/apps/chanora_flutter/android/app/src/main/kotlin/app/chanora/chanora_flutter/MainActivity.kt +++ b/apps/chanora_flutter/android/app/src/main/kotlin/app/chanora/chanora_flutter/MainActivity.kt @@ -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", diff --git a/apps/chanora_flutter/lib/main.dart b/apps/chanora_flutter/lib/main.dart index 4053d7c..58285f4 100644 --- a/apps/chanora_flutter/lib/main.dart +++ b/apps/chanora_flutter/lib/main.dart @@ -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), ), diff --git a/apps/chanora_flutter/lib/services/android_permissions_service.dart b/apps/chanora_flutter/lib/services/android_permissions_service.dart index 7311904..ae126f8 100644 --- a/apps/chanora_flutter/lib/services/android_permissions_service.dart +++ b/apps/chanora_flutter/lib/services/android_permissions_service.dart @@ -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 _state = ValueNotifier( - // 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(methodRequestRecordAudio); + returnedState = await ch.invokeMethod(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 diff --git a/apps/chanora_flutter/test/services/android_permissions_service_test.dart b/apps/chanora_flutter/test/services/android_permissions_service_test.dart index 364804c..7205b00 100644 --- a/apps/chanora_flutter/test/services/android_permissions_service_test.dart +++ b/apps/chanora_flutter/test/services/android_permissions_service_test.dart @@ -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, - { - 'permission': permission, - 'state': state, - }, - ), + MethodCall(methodPermissionStateChanged, { + '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); diff --git a/crates/chanora_audio/src/android_voice_unit.rs b/crates/chanora_audio/src/android_voice_unit.rs index 599800f..b0c8d7c 100644 --- a/crates/chanora_audio/src/android_voice_unit.rs +++ b/crates/chanora_audio/src/android_voice_unit.rs @@ -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> { + 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 + } + } +}