Files
chanora/apps/chanora_flutter/ios/Runner/AppDelegate.swift
T
Edison Jwa a0ff17b935 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.
2026-06-08 06:02:12 +09:00

335 lines
13 KiB
Swift

import UIKit
import Flutter
import AVFoundation
@main
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
private var iosAudioLifecycleChannel: FlutterMethodChannel?
private var iosPlatformChannel: FlutterMethodChannel?
private var iosAudioSessionChannel: FlutterMethodChannel?
/// Tracks whether a voice channel is currently active.
///
/// The AVAudioSession is intentionally not configured for VoIP at
/// app launch — that would interrupt other apps' audio (Spotify,
/// Apple Music, podcasts) the moment the user opens Chanora, even
/// when they're just reading chat. Production VoIP apps (Telegram
/// group calls, Signal, Discord, Element) only switch the session
/// to `.playAndRecord` + `.voiceChat` when the user actually joins
/// a voice channel. See `docs/architecture/sad.md` and the
/// `chanora/ios_audio_session` MethodChannel contract.
///
/// This flag gates lifecycle handlers (interruption-ended,
/// media-services-reset) so we only rebuild the VoIP session if a
/// call is actually in progress. When false, those handlers leave
/// the session in the inactive `.ambient` baseline.
private var voiceSessionActive: Bool = false
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
DispatchQueue.global(qos: .utility).async {
ChanoraSileroSelfTest.run()
}
// AVAudioSession lifecycle policy (DEC-2026-06-08, supersedes
// the launch-time .playAndRecord setup):
//
// At launch we set the category to .ambient and leave the
// session INACTIVE — matching the Telegram / Signal / Discord /
// Element / Jitsi pattern and Apple's guidance that "a VoIP
// app's audio session should not be active" while idle.
// Configuring .playAndRecord + .voiceChat at launch stops other
// apps' music (Spotify, Apple Music, podcasts) the moment the
// user opens Chanora, even when they are just reading text chat.
//
// VoIP configuration is engaged on voice-channel join via the
// `chanora/ios_audio_session` MethodChannel, driven from Dart
// by the BridgeEvent::AudioStarted / AudioStopped lifecycle.
do {
try AVAudioSession.sharedInstance().setCategory(.ambient, mode: .default)
logAudioSessionState(context: "launch-ambient")
} catch {
NSLog("chanora_flutter: AVAudioSession .ambient baseline failed: \(error)")
}
NotificationCenter.default.addObserver(
self,
selector: #selector(handleRouteChange(_:)),
name: AVAudioSession.routeChangeNotification,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(handleInterruption(_:)),
name: AVAudioSession.interruptionNotification,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(handleMediaServicesReset(_:)),
name: AVAudioSession.mediaServicesWereResetNotification,
object: nil
)
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
/// 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
/// .playAndRecord + .voiceChat with .mixWithOthers so other apps
/// (Spotify, podcasts) can keep playing alongside the voice
/// channel — matching the Telegram group-call UX. Idempotent:
/// repeated calls while already active are a no-op.
private func activateVoiceSession() {
do {
let session = AVAudioSession.sharedInstance()
try session.setCategory(
.playAndRecord,
mode: .voiceChat,
options: [.defaultToSpeaker, .allowBluetoothHFP, .allowBluetoothA2DP, .mixWithOthers]
)
try session.setPreferredIOBufferDuration(0.02)
try session.setPreferredSampleRate(48000.0)
try session.setActive(true, options: [])
voiceSessionActive = true
logAudioSessionState(context: "activateVoiceSession")
let ins = session.currentRoute.inputs.map { $0.portType.rawValue }.joined(separator: ",")
NSLog(
"chanora_flutter: voice session active: " +
"sampleRate=\(session.sampleRate) " +
"ioBufferDuration=\(String(format: "%.4f", session.ioBufferDuration)) " +
"inputs=[\(ins)] outputVolume=\(session.outputVolume)"
)
} catch {
NSLog("chanora_flutter: activateVoiceSession failed: \(error)")
}
}
/// Deactivate the VoIP audio session and return to the idle
/// .ambient baseline. Called from Dart on `BridgeEvent::AudioStopped`
/// (intentional leave, disconnect, or connection lost).
/// `.notifyOthersOnDeactivation` lets other audio apps know they
/// can resume — best-effort: Apple Music / Podcasts resume
/// reliably, Spotify is not guaranteed.
private func deactivateVoiceSession() {
let session = AVAudioSession.sharedInstance()
do {
try session.setActive(false, options: [.notifyOthersOnDeactivation])
} catch {
NSLog("chanora_flutter: deactivateVoiceSession setActive(false) failed: \(error)")
}
do {
try session.setCategory(.ambient, mode: .default)
} catch {
NSLog("chanora_flutter: deactivateVoiceSession setCategory(.ambient) failed: \(error)")
}
voiceSessionActive = false
logAudioSessionState(context: "deactivateVoiceSession")
}
/// Reads back the actual AVAudioSession state and logs it for
/// SDD-098 compliance. Called after both setCategory and setActive
/// to verify that the session accepted the requested configuration.
private func logAudioSessionState(context: String) {
let s = AVAudioSession.sharedInstance()
NSLog("chanora_flutter: [\(context)] category=\(s.category.rawValue) mode=\(s.mode.rawValue) options=\(s.categoryOptions.rawValue) route outputs=\(s.currentRoute.outputs.map { "\($0.portType.rawValue)" })")
if s.sampleRate != 48000.0 {
NSLog("chanora_flutter: WARNING: actual sample rate \(s.sampleRate) != requested 48000")
}
if s.ioBufferDuration > 0.025 {
NSLog("chanora_flutter: WARNING: IO buffer duration \(s.ioBufferDuration) > 25ms, may cause latency")
}
}
@objc private func handleRouteChange(_ notification: Notification) {
guard let userInfo = notification.userInfo,
let reasonValue = userInfo[AVAudioSessionRouteChangeReasonKey] as? UInt,
let reason = AVAudioSession.RouteChangeReason(rawValue: reasonValue)
else {
return
}
let routeDescription = AVAudioSession.sharedInstance().currentRoute
let outputs = routeDescription.outputs.map { $0.portType.rawValue }.joined(separator: ",")
NSLog("chanora_flutter: route change reason=\(reason.rawValue) outputs=\(outputs)")
// P1: Send the detailed route class to Rust on every route change,
// not just device plug/unplug. This covers:
// - .newDeviceAvailable / .oldDeviceUnavailable (headset plug/unplug)
// - .override (speaker/earpiece toggle)
// - .categoryChange (session category changed)
// - .wakeFromSleep (device woke from sleep)
// - .routeConfigurationChange (BT HFP connect/disconnect)
// The Rust side uses the route class to recompute the processing
// policy (route_policy.rs) and reset AEC delay state if needed.
let routeClass = classifyAudioRoute(routeDescription)
NSLog("chanora_flutter: route class=\(routeClass) reason=\(reason.rawValue)")
iosAudioLifecycleChannel?.invokeMethod("handleRouteChange", arguments: routeClass)
}
@objc private func handleInterruption(_ notification: Notification) {
guard let userInfo = notification.userInfo,
let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt,
let type = AVAudioSession.InterruptionType(rawValue: typeValue)
else {
return
}
switch type {
case .began:
NSLog("chanora_flutter: audio interruption began")
iosAudioLifecycleChannel?.invokeMethod("handleInterruptionBegan", arguments: nil)
case .ended:
let shouldResume = (userInfo[AVAudioSessionInterruptionOptionKey] as? UInt)
.map { $0 & AVAudioSession.InterruptionOptions.shouldResume.rawValue != 0 }
?? false
NSLog("chanora_flutter: audio interruption ended shouldResume=\(shouldResume)")
iosAudioLifecycleChannel?.invokeMethod("handleInterruptionEnded", arguments: shouldResume)
@unknown default:
break
}
}
@objc private func handleMediaServicesReset(_ notification: Notification) {
NSLog("chanora_flutter: media services reset voiceActive=\(voiceSessionActive)")
if voiceSessionActive {
do {
let session = AVAudioSession.sharedInstance()
try session.setCategory(
.playAndRecord,
mode: .voiceChat,
options: [.defaultToSpeaker, .allowBluetoothHFP, .allowBluetoothA2DP, .mixWithOthers]
)
try session.setPreferredIOBufferDuration(0.02)
try session.setPreferredSampleRate(48000.0)
try session.setActive(true, options: [])
logAudioSessionState(context: "mediaServicesWereReset-voip")
} catch {
NSLog("chanora_flutter: AVAudioSession media-services reset rebuild failed: \(error)")
}
} else {
do {
try AVAudioSession.sharedInstance().setCategory(.ambient, mode: .default)
logAudioSessionState(context: "mediaServicesWereReset-ambient")
} catch {
NSLog("chanora_flutter: AVAudioSession media-services reset ambient restore failed: \(error)")
}
}
let routeClass = classifyAudioRoute(AVAudioSession.sharedInstance().currentRoute)
NSLog("chanora_flutter: media services reset complete, route=\(routeClass)")
iosAudioLifecycleChannel?.invokeMethod("handleMediaServicesReset", arguments: routeClass)
}
override func applicationWillResignActive(_ application: UIApplication) {
iosAudioLifecycleChannel?.invokeMethod("handleWillResignActive", arguments: nil)
}
override func applicationDidEnterBackground(_ application: UIApplication) {
iosAudioLifecycleChannel?.invokeMethod("handleDidEnterBackground", arguments: nil)
}
override func applicationWillEnterForeground(_ application: UIApplication) {
iosAudioLifecycleChannel?.invokeMethod("handleWillEnterForeground", arguments: nil)
}
override func applicationWillTerminate(_ application: UIApplication) {
iosAudioLifecycleChannel?.invokeMethod("handleWillTerminate", arguments: nil)
}
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
iosAudioLifecycleChannel = FlutterMethodChannel(
name: "chanora/ios_audio_lifecycle",
binaryMessenger: engineBridge.applicationRegistrar.messenger()
)
iosPlatformChannel = FlutterMethodChannel(
name: "chanora/ios_platform",
binaryMessenger: engineBridge.applicationRegistrar.messenger()
)
iosAudioSessionChannel = FlutterMethodChannel(
name: "chanora/ios_audio_session",
binaryMessenger: engineBridge.applicationRegistrar.messenger()
)
iosAudioSessionChannel?.setMethodCallHandler { [weak self] call, result in
guard let self = self else {
result(FlutterError(code: "delegate_gone", message: "AppDelegate deallocated", details: nil))
return
}
switch call.method {
case "activateVoiceSession":
self.activateVoiceSession()
result(nil)
case "deactivateVoiceSession":
self.deactivateVoiceSession()
result(nil)
default:
result(FlutterMethodNotImplemented)
}
}
iosPlatformChannel?.setMethodCallHandler { call, result in
switch call.method {
case "getMicrophonePermissionState":
result(self.microphonePermissionStateString())
case "requestMicrophonePermission":
AVAudioSession.sharedInstance().requestRecordPermission { granted in
DispatchQueue.main.async {
result(granted ? "Granted" : self.microphonePermissionStateString())
}
}
case "openAppSettings":
guard let url = URL(string: UIApplication.openSettingsURLString) else {
result(false)
return
}
UIApplication.shared.open(url, options: [:]) { opened in
result(opened)
}
default:
result(FlutterMethodNotImplemented)
}
}
}
private func classifyAudioRoute(_ route: AVAudioSessionRouteDescription) -> String {
for output in route.outputs {
switch output.portType {
case .builtInReceiver:
return "Earpiece"
case .builtInSpeaker:
return "Speaker"
case .headphones, .usbAudio:
return "WiredHeadset"
case .bluetoothHFP:
return "BluetoothHfp"
case .bluetoothA2DP:
return "BluetoothA2dp"
default:
break
}
}
return "Unknown"
}
private func microphonePermissionStateString() -> String {
switch AVAudioSession.sharedInstance().recordPermission {
case .granted:
return "Granted"
case .denied:
return "Denied"
case .undetermined:
return "NotDetermined"
@unknown default:
return "Unknown"
}
}
deinit {
NotificationCenter.default.removeObserver(self)
}
}