diff --git a/apps/chanora_flutter/lib/main.dart b/apps/chanora_flutter/lib/main.dart index 530edaa..d8f51ca 100644 --- a/apps/chanora_flutter/lib/main.dart +++ b/apps/chanora_flutter/lib/main.dart @@ -25,6 +25,7 @@ import 'services/audio_lifecycle_service.dart'; import 'services/ios_audio_session_controller.dart'; import 'services/channel_join_error_mapper.dart'; import 'services/connection_phase_state.dart'; +import 'services/hard_mute_owners.dart'; import 'services/ios_permissions_service.dart'; import 'services/macos_permissions_service.dart'; import 'services/prefetch_debouncer.dart'; @@ -339,8 +340,9 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { bool _inChannel = false; rust.BridgeTransmitMode _transmitMode = rust.BridgeTransmitMode.ptt; bool _hardMute = false; - bool _hardMuteByPermission = false; - bool _hardMuteByTalkPower = false; + HardMuteOwners _hardMuteOwners = const HardMuteOwners(); + bool get _hardMuteByPermission => _hardMuteOwners.permission; + bool get _hardMuteByTalkPower => _hardMuteOwners.talkPower; bool _permissionHardMuteClearInFlight = false; int _releaseTailMs = 200; BigInt? _currentVoiceChannelId; @@ -590,8 +592,8 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { await rust.setHardMute(muted: false); if (!mounted || !_hardMuteByPermission) return; setState(() { - _hardMute = false; - _hardMuteByPermission = false; + _hardMuteOwners = _hardMuteOwners.copyWith(permission: false); + _hardMute = _hardMuteOwners.effective; }); } catch (e) { if (!mounted) return; @@ -798,8 +800,8 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { _voiceStateInitialized = true; _inChannel = inChannel; _transmitMode = transmitMode; - _hardMute = mute; - if (!mute) _hardMuteByPermission = false; + _hardMuteOwners = _hardMuteOwners.withBridgeManualMute(mute); + _hardMute = _hardMuteOwners.effective; _releaseTailMs = releaseTailMs; _currentVoiceChannelId = currentChannelId; _pendingVoiceChannelId = pendingTargetChannelId; @@ -1271,8 +1273,8 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { await rust.setHardMute(muted: true); if (mounted) { setState(() { - _hardMute = true; - _hardMuteByPermission = true; + _hardMuteOwners = _hardMuteOwners.copyWith(permission: true); + _hardMute = _hardMuteOwners.effective; }); } } catch (_) { @@ -1287,8 +1289,8 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { await rust.setHardMute(muted: false); if (mounted) { setState(() { - _hardMute = false; - _hardMuteByPermission = false; + _hardMuteOwners = _hardMuteOwners.copyWith(permission: false); + _hardMute = _hardMuteOwners.effective; }); } } @@ -1356,13 +1358,14 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { final next = !_hardMute; final previousInputMuted = _inputMuted; final previousHardMute = _hardMute; - final previousPermissionMute = _hardMuteByPermission; + final previousHardMuteOwners = _hardMuteOwners; setState(() { _inputMuted = next; - _hardMute = next; - if (next) { - _hardMuteByPermission = false; - } + _hardMuteOwners = _hardMuteOwners.copyWith( + manual: next, + permission: next ? false : null, + ); + _hardMute = _hardMuteOwners.effective; }); try { // Hard-mute is two coordinated effects: @@ -1381,7 +1384,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { setState(() { _inputMuted = previousInputMuted; _hardMute = previousHardMute; - _hardMuteByPermission = previousPermissionMute; + _hardMuteOwners = previousHardMuteOwners; }); _showUiError('hard mute', e); } @@ -1651,8 +1654,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { _inputMuted = false; _outputMuted = false; _hardMute = false; - _hardMuteByPermission = false; - _hardMuteByTalkPower = false; + _hardMuteOwners = const HardMuteOwners(); _inChannel = false; _currentVoiceChannelId = null; _pendingVoiceChannelId = null; @@ -2083,8 +2085,8 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { if (!own.talkPowerOk && !_hardMuteByTalkPower) { final talkPowerEpoch = _connectionEpoch; - _hardMuteByTalkPower = true; - _hardMute = true; + _hardMuteOwners = _hardMuteOwners.copyWith(talkPower: true); + _hardMute = _hardMuteOwners.effective; WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted || _connectionEpoch != talkPowerEpoch || @@ -2097,15 +2099,14 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { }); } else if (own.talkPowerOk && _hardMuteByTalkPower) { final talkPowerEpoch = _connectionEpoch; - _hardMuteByTalkPower = false; - if (!_hardMuteByPermission) { - _hardMute = false; + _hardMuteOwners = _hardMuteOwners.copyWith(talkPower: false); + _hardMute = _hardMuteOwners.effective; + if (!_hardMuteOwners.effective) { WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted || _connectionEpoch != talkPowerEpoch || !_serverReachable || - _hardMuteByTalkPower || - _hardMuteByPermission) { + _hardMuteOwners.effective) { return; } unawaited(rust.setHardMute(muted: false)); diff --git a/apps/chanora_flutter/lib/services/hard_mute_owners.dart b/apps/chanora_flutter/lib/services/hard_mute_owners.dart new file mode 100644 index 0000000..7d071f6 --- /dev/null +++ b/apps/chanora_flutter/lib/services/hard_mute_owners.dart @@ -0,0 +1,31 @@ +class HardMuteOwners { + const HardMuteOwners({ + this.manual = false, + this.permission = false, + this.talkPower = false, + }); + + final bool manual; + final bool permission; + final bool talkPower; + + bool get effective => manual || permission || talkPower; + + HardMuteOwners withBridgeManualMute(bool muted) { + return copyWith( + manual: muted && (manual || !permission && !talkPower), + ); + } + + HardMuteOwners copyWith({ + bool? manual, + bool? permission, + bool? talkPower, + }) { + return HardMuteOwners( + manual: manual ?? this.manual, + permission: permission ?? this.permission, + talkPower: talkPower ?? this.talkPower, + ); + } +} diff --git a/apps/chanora_flutter/lib/services/ios_audio_session_controller.dart b/apps/chanora_flutter/lib/services/ios_audio_session_controller.dart index f9f0a7d..eb26bb2 100644 --- a/apps/chanora_flutter/lib/services/ios_audio_session_controller.dart +++ b/apps/chanora_flutter/lib/services/ios_audio_session_controller.dart @@ -37,6 +37,10 @@ class IosAudioSessionController { // exception to the event handler would be noise. The Rust // engine remains alive and will produce silence until the // next route change or a manual leave/rejoin. + } on MissingPluginException { + // Test hosts and mispackaged builds may not have registered + // the iOS channel. Keep event dispatch alive rather than + // surfacing an unhandled async error. } } @@ -49,6 +53,9 @@ class IosAudioSessionController { // Worst case the session stays in .playAndRecord until the // app is backgrounded — at which point iOS reclaims the // session automatically. + } on MissingPluginException { + // Same rationale as activate(): missing channel should not + // break bridge event handling. } } } diff --git a/apps/chanora_flutter/lib/widgets/voice_compact.dart b/apps/chanora_flutter/lib/widgets/voice_compact.dart index b0f25c6..8faeafb 100644 --- a/apps/chanora_flutter/lib/widgets/voice_compact.dart +++ b/apps/chanora_flutter/lib/widgets/voice_compact.dart @@ -318,6 +318,15 @@ class _VoicePttButtonState extends State { playVoicePttHaptic(held); } + @override + void dispose() { + if (_pressed) { + _pressed = false; + widget.onHeldChanged(false); + } + super.dispose(); + } + @override Widget build(BuildContext context) { final theme = Theme.of(context); diff --git a/apps/chanora_flutter/test/services/hard_mute_owners_test.dart b/apps/chanora_flutter/test/services/hard_mute_owners_test.dart new file mode 100644 index 0000000..d18bcdd --- /dev/null +++ b/apps/chanora_flutter/test/services/hard_mute_owners_test.dart @@ -0,0 +1,36 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:chanora_flutter/services/hard_mute_owners.dart'; + +void main() { + group('HardMuteOwners', () { + test('manual mute survives talk-power block and restore', () { + const owners = HardMuteOwners(manual: true); + + final blocked = owners.copyWith(talkPower: true); + expect(blocked.effective, isTrue); + + final restored = blocked.copyWith(talkPower: false); + expect(restored.manual, isTrue); + expect(restored.talkPower, isFalse); + expect(restored.effective, isTrue); + }); + + test('effective mute is the union of independent owners', () { + expect(const HardMuteOwners().effective, isFalse); + expect(const HardMuteOwners(manual: true).effective, isTrue); + expect(const HardMuteOwners(permission: true).effective, isTrue); + expect(const HardMuteOwners(talkPower: true).effective, isTrue); + }); + + test('bridge mute does not convert talk-power owner into manual owner', () { + const owners = HardMuteOwners(talkPower: true); + + final synced = owners.withBridgeManualMute(true); + + expect(synced.manual, isFalse); + expect(synced.talkPower, isTrue); + expect(synced.effective, isTrue); + }); + }); +} diff --git a/apps/chanora_flutter/test/services/ios_audio_session_controller_test.dart b/apps/chanora_flutter/test/services/ios_audio_session_controller_test.dart index b660d3b..9d6a567 100644 --- a/apps/chanora_flutter/test/services/ios_audio_session_controller_test.dart +++ b/apps/chanora_flutter/test/services/ios_audio_session_controller_test.dart @@ -84,5 +84,16 @@ void main() { await expectLater(controller.activate(), completes); await expectLater(controller.deactivate(), completes); }); + + test('activate swallows MissingPluginException when channel is absent', + () async { + final controller = IosAudioSessionController( + channel: channel, + isIos: true, + ); + + await expectLater(controller.activate(), completes); + await expectLater(controller.deactivate(), completes); + }); }); } diff --git a/apps/chanora_flutter/test/widgets/voice_compact_test.dart b/apps/chanora_flutter/test/widgets/voice_compact_test.dart new file mode 100644 index 0000000..88b68ef --- /dev/null +++ b/apps/chanora_flutter/test/widgets/voice_compact_test.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:chanora_flutter/l10n/generated/app_localizations.dart'; +import 'package:chanora_flutter/widgets/voice_compact.dart'; + +void main() { + testWidgets('touch PTT releases when disposed while held', (tester) async { + final heldChanges = []; + + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppL10n.localizationsDelegates, + supportedLocales: AppL10n.supportedLocales, + home: Scaffold( + body: VoicePttButton( + active: false, + onHeldChanged: heldChanges.add, + ), + ), + ), + ); + + final center = tester.getCenter(find.byType(VoicePttButton)); + final gesture = await tester.startGesture(center); + await tester.pump(); + + expect(heldChanges, [true]); + + await tester.pumpWidget(const MaterialApp(home: Scaffold())); + + expect(heldChanges, [true, false]); + + await gesture.cancel(); + + expect(heldChanges, [true, false]); + }); +}