From c7e51c2e480342b41368b830f3a1c1666269b855 Mon Sep 17 00:00:00 2001 From: Edison Jwa Date: Tue, 9 Jun 2026 19:28:54 +0900 Subject: [PATCH] fix(ios-audio): keep session active when already-in-channel The 'already in channel' server response (code 0x0302) is treated as a successful join by _onJoinChannel: the user stays in the channel and local state is updated to reflect the joined target. But the underlying voiceJoin call still raises BridgeError_ServerRejected, which the joinVoiceChannelWithIosAudioSession helper used to interpret as a join failure and deactivate the iOS audio session. Result: the UI shows the user as joined while the audio session is dead and capture/playback remain silent. Add an isJoinSuccess predicate to the ordering helper. When the predicate matches, the helper rethrows (so the caller can still run its success-on-already-joined branch) without deactivating the session. Wire _onJoinChannel to pass _isAlreadyInChannel as the predicate so the 0x0302 path keeps the session active. Adds two regression tests covering the success-on-rethrow and the predicate-false-still-deactivates paths. --- apps/chanora_flutter/lib/main.dart | 5 ++ .../lib/services/voice_join_ordering.dart | 15 ++++- .../services/voice_join_ordering_test.dart | 65 +++++++++++++++++++ 3 files changed, 84 insertions(+), 1 deletion(-) diff --git a/apps/chanora_flutter/lib/main.dart b/apps/chanora_flutter/lib/main.dart index 8711560..35bc003 100644 --- a/apps/chanora_flutter/lib/main.dart +++ b/apps/chanora_flutter/lib/main.dart @@ -1308,6 +1308,11 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { voiceJoin: rust.voiceJoin, activateIosAudioSession: iosAudioSessionController.activate, deactivateIosAudioSession: iosAudioSessionController.deactivate, + // Server says we are already in the target channel: the user is + // still joined to a voice channel, so the iOS audio session must + // stay active. The catch below converts this rethrow into the + // success-on-already-joined branch. + isJoinSuccess: _isAlreadyInChannel, ); if (!mounted) return; setState(() { diff --git a/apps/chanora_flutter/lib/services/voice_join_ordering.dart b/apps/chanora_flutter/lib/services/voice_join_ordering.dart index 3122162..722db3b 100644 --- a/apps/chanora_flutter/lib/services/voice_join_ordering.dart +++ b/apps/chanora_flutter/lib/services/voice_join_ordering.dart @@ -6,17 +6,30 @@ typedef VoiceJoinCallback = Future Function({ typedef IosVoiceSessionActivation = Future Function(); typedef IosVoiceSessionDeactivation = Future Function(); +/// Predicate used to recognise `voiceJoin` errors that the caller treats as a +/// successful join outcome (e.g. the server replied "already in channel"). +/// +/// When this returns `true` for a thrown error, the iOS audio session is kept +/// active because the user is still considered joined to the channel. The +/// error is still rethrown so the caller can run its success-on-already-joined +/// branch and update local state. +typedef VoiceJoinSuccessPredicate = bool Function(Object error); + Future joinVoiceChannelWithIosAudioSession({ required BigInt channelId, required String password, required VoiceJoinCallback voiceJoin, required IosVoiceSessionActivation activateIosAudioSession, required IosVoiceSessionDeactivation deactivateIosAudioSession, + VoiceJoinSuccessPredicate? isJoinSuccess, }) async { await activateIosAudioSession(); try { await voiceJoin(channelId: channelId, password: password); - } catch (_) { + } catch (e) { + if (isJoinSuccess != null && isJoinSuccess(e)) { + rethrow; + } await deactivateIosAudioSession(); rethrow; } diff --git a/apps/chanora_flutter/test/services/voice_join_ordering_test.dart b/apps/chanora_flutter/test/services/voice_join_ordering_test.dart index e0ea51d..52b4bfe 100644 --- a/apps/chanora_flutter/test/services/voice_join_ordering_test.dart +++ b/apps/chanora_flutter/test/services/voice_join_ordering_test.dart @@ -54,5 +54,70 @@ void main() { 'deactivateIosAudioSession', ]); }); + + test( + 'keeps the iOS audio session active when voiceJoin throws but the ' + 'error is recognised as already-in-channel (treated as success); ' + 'still rethrows so the caller runs its success-on-already-joined branch', + () async { + final calls = []; + + await expectLater( + joinVoiceChannelWithIosAudioSession( + channelId: BigInt.from(42), + password: '', + activateIosAudioSession: () async { + calls.add('activateIosAudioSession'); + }, + deactivateIosAudioSession: () async { + calls.add('deactivateIosAudioSession'); + }, + voiceJoin: ({required channelId, required password}) async { + calls.add('voiceJoin'); + throw _FakeAlreadyInChannel(); + }, + isJoinSuccess: (error) => error is _FakeAlreadyInChannel, + ), + throwsA(isA<_FakeAlreadyInChannel>()), + ); + + expect(calls, ['activateIosAudioSession', 'voiceJoin']); + }, + ); + + test( + 'deactivates the iOS audio session when isJoinSuccess returns false ' + 'for a non-success error', + () async { + final calls = []; + + await expectLater( + joinVoiceChannelWithIosAudioSession( + channelId: BigInt.from(42), + password: '', + activateIosAudioSession: () async { + calls.add('activateIosAudioSession'); + }, + deactivateIosAudioSession: () async { + calls.add('deactivateIosAudioSession'); + }, + voiceJoin: ({required channelId, required password}) async { + calls.add('voiceJoin'); + throw StateError('join rejected'); + }, + isJoinSuccess: (error) => error is _FakeAlreadyInChannel, + ), + throwsStateError, + ); + + expect(calls, [ + 'activateIosAudioSession', + 'voiceJoin', + 'deactivateIosAudioSession', + ]); + }, + ); }); } + +class _FakeAlreadyInChannel implements Exception {}