From 5c3dd70bba1ca183520b7db7535eae0f8e366cc7 Mon Sep 17 00:00:00 2001 From: Edison Jwa Date: Tue, 9 Jun 2026 19:58:19 +0900 Subject: [PATCH] fix(ios-audio): activate session before voice joins (#38) * fix(ios-audio): add voice join session coordinator * fix(ios-audio): activate session before voice joins * docs(ios-audio): align activation lifecycle comments * 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. * docs(security): regenerate license inventories Cargo inventory: pick up chanora_resolver bump from 0.1.0 to 0.2.0-beta.1 so it matches the workspace; also adds a trailing newline so 'cargo about generate' is idempotent in CI license-drift checks. Flutter inventory: pick up flutter_local_notifications (+ platform interfaces) and timezone pulled in by the prior notification permission work. --- .../ios/Runner/AppDelegate.swift | 7 +- apps/chanora_flutter/lib/main.dart | 14 +- .../ios_audio_session_controller.dart | 6 +- .../lib/services/voice_join_ordering.dart | 36 +++++ .../services/voice_join_ordering_test.dart | 123 ++++++++++++++++++ docs/security/license-inventory.md | 1 + 6 files changed, 180 insertions(+), 7 deletions(-) create mode 100644 apps/chanora_flutter/lib/services/voice_join_ordering.dart create mode 100644 apps/chanora_flutter/test/services/voice_join_ordering_test.dart diff --git a/apps/chanora_flutter/ios/Runner/AppDelegate.swift b/apps/chanora_flutter/ios/Runner/AppDelegate.swift index 6a8461e..53a9306 100644 --- a/apps/chanora_flutter/ios/Runner/AppDelegate.swift +++ b/apps/chanora_flutter/ios/Runner/AppDelegate.swift @@ -46,7 +46,8 @@ import AVFoundation // // VoIP configuration is engaged on voice-channel join via the // `chanora/ios_audio_session` MethodChannel, driven from Dart - // by the BridgeEvent::AudioStarted / AudioStopped lifecycle. + // before `voiceJoin` starts VoiceProcessingIO and again as an + // idempotent guard on the AudioStarted lifecycle. do { try AVAudioSession.sharedInstance().setCategory(.ambient, mode: .default) logAudioSessionState(context: "launch-ambient") @@ -79,8 +80,8 @@ import AVFoundation } /// Activate the VoIP audio session. Called from Dart via the - /// `chanora/ios_audio_session` channel when a voice channel join - /// reaches the `BridgeEvent::AudioStarted` stage. Configures + /// `chanora/ios_audio_session` channel before a voice channel join + /// starts VoiceProcessingIO. Configures /// .playAndRecord + .voiceChat with .mixWithOthers so other apps /// (Spotify, podcasts) can keep playing alongside the voice /// channel — matching the Telegram group-call UX. Idempotent: diff --git a/apps/chanora_flutter/lib/main.dart b/apps/chanora_flutter/lib/main.dart index f73d422..35bc003 100644 --- a/apps/chanora_flutter/lib/main.dart +++ b/apps/chanora_flutter/lib/main.dart @@ -34,6 +34,7 @@ import 'services/prefetch_debouncer.dart'; import 'services/snapshot_state_mapper.dart'; import 'services/ts3_server_link.dart'; import 'services/ui_preferences_service.dart'; +import 'services/voice_join_ordering.dart'; import 'src/rust/api.dart' as rust; import 'src/rust/frb_generated.dart'; import 'src/rust/lib.dart' as rust_err; @@ -1301,7 +1302,18 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { }); } } - await rust.voiceJoin(channelId: ch.id, password: password ?? ''); + await joinVoiceChannelWithIosAudioSession( + channelId: ch.id, + password: password ?? '', + 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(() { _currentVoiceChannelId = ch.id; 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 eb26bb2..648b171 100644 --- a/apps/chanora_flutter/lib/services/ios_audio_session_controller.dart +++ b/apps/chanora_flutter/lib/services/ios_audio_session_controller.dart @@ -10,9 +10,9 @@ const iosAudioSessionChannelName = 'chanora/ios_audio_session'; /// launch and leaves it inactive. The session is only switched to /// `.playAndRecord` + `.voiceChat` (with `.mixWithOthers`) while a /// voice channel is actually active. This controller is the Dart -/// side of that contract — call [activate] when the Rust engine -/// emits `BridgeEvent::AudioStarted` and [deactivate] on -/// `BridgeEvent::AudioStopped`. +/// side of that contract — call [activate] before the Rust engine +/// starts VoiceProcessingIO and [deactivate] on +/// `BridgeEvent::AudioStopped` or failed joins. /// /// On non-iOS platforms both methods are no-ops; the platforms /// handle their own session lifecycle elsewhere (Android via diff --git a/apps/chanora_flutter/lib/services/voice_join_ordering.dart b/apps/chanora_flutter/lib/services/voice_join_ordering.dart new file mode 100644 index 0000000..722db3b --- /dev/null +++ b/apps/chanora_flutter/lib/services/voice_join_ordering.dart @@ -0,0 +1,36 @@ +typedef VoiceJoinCallback = Future Function({ + required BigInt channelId, + required String password, +}); + +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 (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 new file mode 100644 index 0000000..52b4bfe --- /dev/null +++ b/apps/chanora_flutter/test/services/voice_join_ordering_test.dart @@ -0,0 +1,123 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:chanora_flutter/services/voice_join_ordering.dart'; + +void main() { + group('joinVoiceChannelWithIosAudioSession', () { + test('activates the iOS audio session before Rust voiceJoin', () async { + final calls = []; + + await joinVoiceChannelWithIosAudioSession( + channelId: BigInt.from(42), + password: 'secret', + activateIosAudioSession: () async { + calls.add('activateIosAudioSession'); + }, + deactivateIosAudioSession: () async { + calls.add('deactivateIosAudioSession'); + }, + voiceJoin: ({required channelId, required password}) async { + expect(channelId, BigInt.from(42)); + expect(password, 'secret'); + calls.add('voiceJoin'); + }, + ); + + expect(calls, ['activateIosAudioSession', 'voiceJoin']); + }); + + test('deactivates the iOS audio session when Rust voiceJoin fails', + () 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'); + }, + ), + throwsStateError, + ); + + expect(calls, [ + 'activateIosAudioSession', + 'voiceJoin', + '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 {} diff --git a/docs/security/license-inventory.md b/docs/security/license-inventory.md index c2e382e..4dda47a 100644 --- a/docs/security/license-inventory.md +++ b/docs/security/license-inventory.md @@ -10967,3 +10967,4 @@ cargo about generate --output-file docs/security/license-inventory.html about.hb This artefact supports the DEC-012 legal review handoff at `docs/governance/legal-review-readiness.md`. +