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 // 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") } 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 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: /// 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) } }