fix(voice,ios): scope AVAudioSession VoiceChat to call lifetime (#33)
Adopt a call-scoped VoIP audio session lifecycle so other apps' audio is not stopped while Chanora is idle and the in-call session does not get clobbered by media-server resets unrelated to voice. AppDelegate.swift - Set .ambient + .mixWithOthers as the idle baseline so the app does not hold a VoiceChat session when no call is active. - Switch to .playAndRecord + .voiceChat + .mixWithOthers + .duckOthers on demand via the new chanora/ios_audio_session MethodChannel, and revert to .ambient on deactivate. - Gate the media-services-reset rebuild on voiceSessionActive so a stray reset during idle no longer reactivates VoiceChat. ios_audio_session_controller.dart (new) - Thin Dart wrapper around chanora/ios_audio_session with activate/ deactivate; no-op on non-iOS; swallows PlatformException to keep audio start/stop resilient to platform-side races. main.dart - Activate the iOS audio session on BridgeEvent_AudioStarted, deactivate on BridgeEvent_AudioStopped, fire-and-forget via unawaited(). ios_voice_unit.rs - Add the 10 local bindings required by the render-callback closure preamble (wav_recorder_for_render, render_recorder_active, render_ref_len, render_ref_accum, cb_count, last_num_frames, num_frames_changes, callbacks_with_audio, callbacks_with_silence) so the iOS target compiles cleanly with the new lifecycle wiring. Tests - 5 unit tests in test/services/ios_audio_session_controller_test.dart cover activate/deactivate on iOS, no-op on non-iOS, and graceful PlatformException handling. Docs - SRS SRS-110 expanded to cover the call-scoped lifecycle invariant. - SysDes mobile-voice row updated to reflect the MethodChannel and .ambient idle baseline. - implementation-status-2026-05-28 voiceChat row flipped to done. Verification - flutter analyze: No issues found (2.8s) - flutter test: 195 passed / 2 skipped / 0 failed - cargo build -p chanora_audio --target aarch64-apple-ios: clean - cargo build -p chanora_audio (macOS host): clean Device QA matrix (Spotify-keeps-playing-while-idle, mix-during-call, revert-on-call-end, media-services-reset-during-idle) remains pending on physical hardware.
This commit is contained in:
@@ -22,6 +22,7 @@ import 'l10n/generated/app_localizations.dart';
|
||||
import 'services/android_permissions_service.dart';
|
||||
import 'services/app_bootstrap.dart';
|
||||
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/ios_permissions_service.dart';
|
||||
@@ -765,8 +766,10 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
_resetConnectionUiState(phase: ConnectionPhase.disconnected);
|
||||
});
|
||||
case rust.BridgeEvent_AudioStarted():
|
||||
unawaited(iosAudioSessionController.activate());
|
||||
_ensureStatsTimer();
|
||||
case rust.BridgeEvent_AudioStopped():
|
||||
unawaited(iosAudioSessionController.deactivate());
|
||||
_statsTimer?.cancel();
|
||||
_statsTimer = null;
|
||||
case rust.BridgeEvent_PttCapability(
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
const iosAudioSessionChannelName = 'chanora/ios_audio_session';
|
||||
|
||||
/// Controls the iOS AVAudioSession VoIP lifecycle from Dart.
|
||||
///
|
||||
/// The Swift `AppDelegate` configures the session to `.ambient` at
|
||||
/// 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`.
|
||||
///
|
||||
/// On non-iOS platforms both methods are no-ops; the platforms
|
||||
/// handle their own session lifecycle elsewhere (Android via
|
||||
/// `AndroidAudioLifecycleController`, macOS via
|
||||
/// `MacOSAudioLifecycle`, desktop has no exclusive session).
|
||||
class IosAudioSessionController {
|
||||
IosAudioSessionController({
|
||||
MethodChannel? channel,
|
||||
bool? isIos,
|
||||
}) : _channel = channel ?? const MethodChannel(iosAudioSessionChannelName),
|
||||
_isIos = isIos ?? Platform.isIOS;
|
||||
|
||||
final MethodChannel _channel;
|
||||
final bool _isIos;
|
||||
|
||||
Future<void> activate() async {
|
||||
if (!_isIos) return;
|
||||
try {
|
||||
await _channel.invokeMethod<void>('activateVoiceSession');
|
||||
} on PlatformException {
|
||||
// Swift side logs the failure via NSLog; surfacing the
|
||||
// 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.
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> deactivate() async {
|
||||
if (!_isIos) return;
|
||||
try {
|
||||
await _channel.invokeMethod<void>('deactivateVoiceSession');
|
||||
} on PlatformException {
|
||||
// Same rationale as activate(): the Swift side logs.
|
||||
// Worst case the session stays in .playAndRecord until the
|
||||
// app is backgrounded — at which point iOS reclaims the
|
||||
// session automatically.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Default singleton used by [main.dart] event dispatch. Tests
|
||||
/// should construct their own [IosAudioSessionController] with a
|
||||
/// mocked channel rather than mutating this instance.
|
||||
final iosAudioSessionController = IosAudioSessionController();
|
||||
Reference in New Issue
Block a user