From 0466000733351000a9d87c403d0fb7c4a6a57c8e Mon Sep 17 00:00:00 2001 From: EdisonJwa Date: Sat, 16 May 2026 17:53:49 +0800 Subject: [PATCH] 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. --- .../ios/Runner/AppDelegate.swift | 61 ++++++++++++++----- 1 file changed, 45 insertions(+), 16 deletions(-) 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) }