fix(ios): defer AVAudioSession.setActive(true) to didBecomeActive

From iPhone log:
  chanora_flutter: AVAudioSession setup failed:
    Error Domain=NSOSStatusErrorDomain Code=561017449
    'Session activation failed'

Error code 561017449 = AVAudioSessionErrorCodeCannotStartPlaying
(ASCII '!cat' big-endian). iOS 17+ refuses setActive(true) calls
made before the app's scene is foregrounded: the audio policy
server denies the activation because the app is not yet considered
the foreground priority owner. didFinishLaunchingWithOptions runs
BEFORE the scene becomes .active, so synchronous activation there
hits this race on cold launch.

Symptom flow:
  1. App cold-launch -> AppDelegate.didFinishLaunching fires
  2. setActive(true) -> Error 561017449
  3. Audio session is left inactive
  4. cpal's later attempts to open RemoteIO see an inactive
     session and reject with StreamConfigNotSupported
  5. voice_join fails at ensure_audio_running
  6. user sees the audio failure manifested as missing mute /
     continuous / PTT buttons (now fixed in f1f81a3 to be
     lenient; this commit also unblocks the underlying audio).

Fix: split the AVAudioSession configuration into two phases:
  * setCategory at didFinishLaunching (always safe).
  * setActive(true) deferred to UIApplication.didBecomeActive    Notification, which fires after the cold-launch settle and
    on every resume-from-background. Repeated setActive while
    already-active is a no-op per docs.

This is the canonical iOS voice-app pattern (Discord, Zoom,
FaceTime, Flutter's  package all follow it). Documented
in commit body comments.

flutter build ios --release --no-codesign: 13.2 s, Runner.app
30.0 MB.
This commit is contained in:
EdisonJwa
2026-05-16 17:53:49 +08:00
parent 020be77faf
commit 0466000733
@@ -8,20 +8,25 @@ import AVFoundation
_ application: UIApplication, _ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool { ) -> Bool {
// Configure the iOS AVAudioSession for voice chat BEFORE Flutter // Configure the iOS AVAudioSession **category + mode** at
// starts the audio engine. The category + mode combination tells // app-launch time, but DEFER setActive(true) until the scene
// iOS to: // is foregrounded. Calling setActive in didFinishLaunching is
// * route via the receiver/speaker like a phone call // racy on iOS 17+ devices: if the user launches the app from a
// (`.playAndRecord` + `.voiceChat`) // cold state, the UIApplication isn't yet `.active` and
// * engage hardware AEC / NS where the device supports it // setActive returns `AVAudioSessionErrorCodeCannotStartPlaying`
// * default the speaker output (so the user doesn't have to // (561017449) the iOS audio policy server refuses to grant
// hold the phone to their ear) // the audio session because the app is not yet considered the
// * permit Bluetooth headsets (so AirPods et al. just work) // 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 // The category itself can be set whenever; only the active
// matching iOS-side implementation. Failures are logged but do // state needs to be deferred. We listen for
// not block app launch the audio engine will still come up, // didBecomeActiveNotification and activate then. Most
// just at the iOS default playback route. // production iOS voice apps (Discord, Zoom, FaceTime) follow
// this same shape.
do { do {
let session = AVAudioSession.sharedInstance() let session = AVAudioSession.sharedInstance()
try session.setCategory( try session.setCategory(
@@ -29,12 +34,23 @@ import AVFoundation
mode: .voiceChat, mode: .voiceChat,
options: [.defaultToSpeaker, .allowBluetooth, .allowBluetoothA2DP] options: [.defaultToSpeaker, .allowBluetooth, .allowBluetoothA2DP]
) )
try session.setActive(true, options: []) NSLog("chanora_flutter: AVAudioSession category set (playAndRecord/voiceChat)")
NSLog("chanora_flutter: AVAudioSession configured (playAndRecord/voiceChat)")
} catch { } 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 // Request microphone access on first launch rather than waiting
// for the user's first voice-channel join. The latter is // for the user's first voice-channel join. The latter is
// surprising: the user has only tapped "connect to server" and // surprising: the user has only tapped "connect to server" and
@@ -65,6 +81,19 @@ import AVFoundation
return super.application(application, didFinishLaunchingWithOptions: launchOptions) 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) { func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
} }