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.
This commit is contained in:
Edison Jwa
2026-06-09 19:28:54 +09:00
parent 703f44d731
commit c7e51c2e48
3 changed files with 84 additions and 1 deletions
+5
View File
@@ -1308,6 +1308,11 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
voiceJoin: rust.voiceJoin, voiceJoin: rust.voiceJoin,
activateIosAudioSession: iosAudioSessionController.activate, activateIosAudioSession: iosAudioSessionController.activate,
deactivateIosAudioSession: iosAudioSessionController.deactivate, 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; if (!mounted) return;
setState(() { setState(() {
@@ -6,17 +6,30 @@ typedef VoiceJoinCallback = Future<void> Function({
typedef IosVoiceSessionActivation = Future<void> Function(); typedef IosVoiceSessionActivation = Future<void> Function();
typedef IosVoiceSessionDeactivation = Future<void> Function(); typedef IosVoiceSessionDeactivation = Future<void> 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<void> joinVoiceChannelWithIosAudioSession({ Future<void> joinVoiceChannelWithIosAudioSession({
required BigInt channelId, required BigInt channelId,
required String password, required String password,
required VoiceJoinCallback voiceJoin, required VoiceJoinCallback voiceJoin,
required IosVoiceSessionActivation activateIosAudioSession, required IosVoiceSessionActivation activateIosAudioSession,
required IosVoiceSessionDeactivation deactivateIosAudioSession, required IosVoiceSessionDeactivation deactivateIosAudioSession,
VoiceJoinSuccessPredicate? isJoinSuccess,
}) async { }) async {
await activateIosAudioSession(); await activateIosAudioSession();
try { try {
await voiceJoin(channelId: channelId, password: password); await voiceJoin(channelId: channelId, password: password);
} catch (_) { } catch (e) {
if (isJoinSuccess != null && isJoinSuccess(e)) {
rethrow;
}
await deactivateIosAudioSession(); await deactivateIosAudioSession();
rethrow; rethrow;
} }
@@ -54,5 +54,70 @@ void main() {
'deactivateIosAudioSession', '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 = <String>[];
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 = <String>[];
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 {}