Files
chanora/apps/chanora_flutter/ios/Runner/AppDelegate.swift
T

329 lines
14 KiB
Swift

import UIKit
import Flutter
import AVFoundation
@main
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
private var iosAudioLifecycleChannel: FlutterMethodChannel?
private var iosPlatformChannel: FlutterMethodChannel?
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
// Configure the iOS AVAudioSession **category + mode** at
// app-launch time, but DEFER setActive(true) until the scene
// is foregrounded. Calling setActive in didFinishLaunching is
// racy on iOS 17+ devices: if the user launches the app from a
// cold state, the UIApplication isn't yet `.active` and
// setActive returns `AVAudioSessionErrorCodeCannotStartPlaying`
// (561017449) — the iOS audio policy server refuses to grant
// the audio session because the app is not yet considered the
// foreground priority owner. Symptom in production builds:
// 'AVAudioSession setup failed: Error 561017449 "Session
// activation failed"' in NSLog, after which the audio engine
// is unusable until the user backgrounds + foregrounds the
// app.
//
// The category itself can be set whenever; only the active
// state needs to be deferred. We listen for
// didBecomeActiveNotification and activate then. Most
// production iOS voice apps (Discord, Zoom, FaceTime) follow
// this same shape.
do {
let session = AVAudioSession.sharedInstance()
try session.setCategory(
.playAndRecord,
mode: .voiceChat,
// Mode rationale (May 2026, .voiceChat reinstated):
//
// We previously used .default mode after discovering that
// .voiceChat routed output through iOS's in-call audio
// channel, which made speaker output barely audible. That
// bug was caused by cpal's RemoteIO unit binding to a stale
// physical transducer — after migrating to coreaudio-rs +
// kAudioUnitSubType_VoiceProcessingIO (see
// crates/chanora_audio/src/ios_voice_unit.rs) the route
// binding is correct under either mode because VPIO re-binds
// on overrideOutputAudioPort.
//
// .voiceChat advantages over .default:
// * Tells iOS this is a VoIP session — other apps' audio
// is properly ducked/paused instead of competing.
// * Enables correct Bluetooth HFP negotiation without
// manual workarounds.
// * iOS treats the audio session as a "call" for priority
// purposes (won't be interrupted by notification sounds).
// * System-level CallKit integration (lock-screen controls).
//
// .defaultToSpeaker ensures output goes to the main speaker
// (not the earpiece) by default when no headphones are
// connected, compensating for the in-call channel's tendency
// to route to the earpiece.
//
// References:
// * https://github.com/twilio/video-quickstart-ios/issues/522
// * https://stackoverflow.com/questions/79834998 (Daily.co)
//
// Options:
// .defaultToSpeaker : route output to the main speaker
// (not the earpiece) by default
// when no headphones are connected.
// .allowBluetoothHFP : permit Bluetooth Hands-Free
// Profile headsets as both input
// and output.
// .allowBluetoothA2DP : permit higher-quality A2DP
// output-only Bluetooth devices.
options: [.defaultToSpeaker, .allowBluetoothHFP, .allowBluetoothA2DP]
)
// Match VPIO / Opus frame cadence to reduce callback pressure.
try session.setPreferredIOBufferDuration(0.02)
try session.setPreferredSampleRate(48000.0)
logAudioSessionState(context: "setCategory")
} catch {
NSLog("chanora_flutter: AVAudioSession setCategory failed: \(error)")
}
// Activate the session once the app is actually foreground. The
// notification fires immediately after the cold-launch settles,
// and again on every resume-from-background — both safe
// moments to call setActive(true). Repeated activation while
// already-active is a no-op per the docs.
NotificationCenter.default.addObserver(
self,
selector: #selector(activateAudioSession),
name: UIApplication.didBecomeActiveNotification,
object: nil
)
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)
}
/// Called by `didBecomeActiveNotification` (cold-launch settle +
/// every resume-from-background). Activates the AVAudioSession.
/// Repeated activation is a no-op when the session is already
/// active so this is safe to call on every foreground.
@objc private func activateAudioSession() {
do {
try AVAudioSession.sharedInstance().setActive(true, options: [])
NSLog("chanora_flutter: AVAudioSession activated on foreground")
// Read back the ACTUAL session state. preferredSampleRate /
// preferredIOBufferDuration are hints; iOS may pick something
// else depending on hardware + currently-engaged effects.
// Without these we can't tell whether VPIO is running at
// 48 kHz mono (what our render callback assumes) or at e.g.
// 44.1 kHz (which would explain the user's broken playback
// \u2014 our render callback would be writing samples at the
// wrong rate, causing pitch + timing artifacts).
logAudioSessionState(context: "setActive")
let s = AVAudioSession.sharedInstance()
let ins = s.currentRoute.inputs.map { $0.portType.rawValue }.joined(separator: ",")
NSLog(
"chanora_flutter: AVAudioSession actual: " +
"sampleRate=\(s.sampleRate) " +
"ioBufferDuration=\(String(format: "%.4f", s.ioBufferDuration)) " +
"inputs=[\(ins)] " +
"outputVolume=\(s.outputVolume)"
)
} catch {
NSLog("chanora_flutter: AVAudioSession setActive failed: \(error)")
}
}
/// 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")
do {
let session = AVAudioSession.sharedInstance()
try session.setCategory(
.playAndRecord,
mode: .voiceChat,
options: [.defaultToSpeaker, .allowBluetoothHFP, .allowBluetoothA2DP]
)
try session.setPreferredIOBufferDuration(0.02)
try session.setPreferredSampleRate(48000.0)
try session.setActive(true, options: [])
logAudioSessionState(context: "mediaServicesWereReset")
} catch {
NSLog("chanora_flutter: AVAudioSession media-services reset rebuild failed: \(error)")
}
// P1: After rebuilding the session, send the current route class to
// Rust so it can recompute the processing policy and reset the
// AudioUnit. The Rust side handles this via ios_handle_media_services_reset
// which calls ios_restart_voice_unit.
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()
)
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)
}
}