* 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.
336 lines
13 KiB
Swift
336 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
|
|
// 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)
|
|
}
|
|
}
|