diff --git a/apps/chanora_flutter/lib/main.dart b/apps/chanora_flutter/lib/main.dart index 58285f4..1953eae 100644 --- a/apps/chanora_flutter/lib/main.dart +++ b/apps/chanora_flutter/lib/main.dart @@ -251,6 +251,7 @@ class _BetaHomeState extends State<_BetaHome> { rust.BridgeTransmitMode _transmitMode = rust.BridgeTransmitMode.ptt; bool _hardMute = false; bool _hardMuteByPermission = false; + bool _permissionHardMuteClearInFlight = false; int _releaseTailMs = 200; BigInt? _currentVoiceChannelId; BigInt? _pendingVoiceChannelId; @@ -310,10 +311,38 @@ class _BetaHomeState extends State<_BetaHome> { // events as early as possible so the listen-only banner reflects // the system state on first frame. _androidPermissions.start(); + _androidPermissions.recordAudioState.addListener( + _onRecordAudioPermissionChanged, + ); unawaited(_reloadBookmarks()); unawaited(_hydratePttBinding()); } + void _onRecordAudioPermissionChanged() { + if (_androidPermissions.recordAudioState.value == + AndroidRecordAudioPermissionState.granted) { + unawaited(_clearPermissionHardMute()); + } + } + + Future _clearPermissionHardMute() async { + if (!_hardMuteByPermission || _permissionHardMuteClearInFlight) return; + _permissionHardMuteClearInFlight = true; + try { + await rust.setHardMute(muted: false); + if (!mounted || !_hardMuteByPermission) return; + setState(() { + _hardMute = false; + _hardMuteByPermission = false; + }); + } catch (e) { + if (!mounted) return; + setState(() => _error = e.toString()); + } finally { + _permissionHardMuteClearInFlight = false; + } + } + bool _handleFocusedPttKey(KeyEvent event) { final label = _pttDisplayLabelForKey(event.logicalKey); final isBoundKey = @@ -492,16 +521,17 @@ class _BetaHomeState extends State<_BetaHome> { // SDD-106 §5/§6 / SRS-209: defensive observer of the // authoritative Rust-side permission stream. The transmit // clamp is already applied inside `chanora_bridge` before - // this event is broadcast; here we merely surface the - // event so the UI stays consistent if the MethodChannel - // path is ever delayed. The existing `AndroidPermissionsService` - // remains the canonical Dart-side state holder (driven by - // the MethodChannel); a future revision may expose a - // setter so both paths converge on a single ValueNotifier. + // this event is broadcast. When permission later becomes + // granted, release only the permission-owned hard-mute; do + // not touch server-side input mute, which may be user-owned. case rust.BridgeEvent_PermissionState(:final permission, :final state): debugPrint( 'bridge permission_state: permission=$permission state=$state', ); + if (permission == 'android.permission.RECORD_AUDIO' && + state == rust.PermissionStateKind.granted) { + unawaited(_clearPermissionHardMute()); + } } } @@ -533,6 +563,9 @@ class _BetaHomeState extends State<_BetaHome> { _hostCtl.dispose(); _nickCtl.dispose(); _passwordCtl.dispose(); + _androidPermissions.recordAudioState.removeListener( + _onRecordAudioPermissionChanged, + ); // SDD-106: detach the Kotlin -> Dart MethodChannel handler so a // late invokeMethod from the platform side cannot land on this // disposed state.