diff --git a/apps/chanora_flutter/ios/Runner/AppDelegate.swift b/apps/chanora_flutter/ios/Runner/AppDelegate.swift index 101b0ed..c0c1ee0 100644 --- a/apps/chanora_flutter/ios/Runner/AppDelegate.swift +++ b/apps/chanora_flutter/ios/Runner/AppDelegate.swift @@ -8,20 +8,25 @@ import AVFoundation _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { - // Configure the iOS AVAudioSession for voice chat BEFORE Flutter - // starts the audio engine. The category + mode combination tells - // iOS to: - // * route via the receiver/speaker like a phone call - // (`.playAndRecord` + `.voiceChat`) - // * engage hardware AEC / NS where the device supports it - // * default the speaker output (so the user doesn't have to - // hold the phone to their ear) - // * permit Bluetooth headsets (so AirPods et al. just work) + // 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. // - // SRS-197 covers the iOS audio routing contract; this is the - // matching iOS-side implementation. Failures are logged but do - // not block app launch — the audio engine will still come up, - // just at the iOS default playback route. + // 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( @@ -29,12 +34,23 @@ import AVFoundation mode: .voiceChat, options: [.defaultToSpeaker, .allowBluetooth, .allowBluetoothA2DP] ) - try session.setActive(true, options: []) - NSLog("chanora_flutter: AVAudioSession configured (playAndRecord/voiceChat)") + NSLog("chanora_flutter: AVAudioSession category set (playAndRecord/voiceChat)") } catch { - NSLog("chanora_flutter: AVAudioSession setup failed: \(error)") + 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 + ) + // Request microphone access on first launch rather than waiting // for the user's first voice-channel join. The latter is // surprising: the user has only tapped "connect to server" and @@ -65,6 +81,19 @@ import AVFoundation 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") + } catch { + NSLog("chanora_flutter: AVAudioSession setActive failed: \(error)") + } + } + func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) }