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 {}