import UIKit import Flutter import AVFoundation @main @objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { 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, // Category options rationale: // // .allowBluetoothHFP : permit Bluetooth Hands-Free // Profile headsets as both input // and output. This is the protocol // AirPods et al. use for two-way // voice. Renamed from .allowBluetooth // in iOS 26. // .allowBluetoothA2DP : permit higher-quality A2DP // output-only Bluetooth devices // (no mic). Keeping both gives the // broadest BT support. // // .defaultToSpeaker was REMOVED from this options set after // the user-reported "speaker change not work" bug. With // .voiceChat mode, the framework default output route is // the receiver/earpiece (matches a phone-call UX). Setting // .defaultToSpeaker overrides that to speakerphone by // default \u2014 but then overrideOutputAudioPort(.none) (which // we use when the user picks "iPhone receiver") cannot // restore the receiver because .none simply removes the // speaker OVERRIDE, leaving us back at the .defaultToSpeaker // baseline which is speakerphone. So the "Receiver" picker // option silently no-op'd. // // Without .defaultToSpeaker: // * Default = receiver/earpiece (matches phone UX) // * overrideOutputAudioPort(.speaker) -> speakerphone // * overrideOutputAudioPort(.none) -> back to receiver // * BT/AirPods connected -> route follows BT // * Wired headphones -> route follows wire // // Net: every row in our audio output picker now has a // route-change effect that matches its label. options: [.allowBluetoothHFP, .allowBluetoothA2DP] ) NSLog("chanora_flutter: AVAudioSession category set (playAndRecord/voiceChat)") } 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 ) // Diagnostic: subscribe to every AVAudioSession route change so // we can correlate user picker actions with what iOS actually // does to the route. The reason printed here ("override", // "newDeviceAvailable", "categoryChange", etc.) tells us // whether our Dart-side override was honoured or silently // reverted by another component (e.g. cpal's RemoteIO unit // reacting to its own configuration change). NotificationCenter.default.addObserver( self, selector: #selector(handleRouteChange(_:)), name: AVAudioSession.routeChangeNotification, 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 // suddenly iOS pops the permission prompt because joining a // text channel happens to trigger audio engine startup. Asking // up-front matches user expectations for a voice-chat client. // // The request is asynchronous and non-blocking. If the user // denies, voice_join will surface a clearer error later when // the audio engine fails to open the input device. The // permission state is cached by iOS so subsequent launches // skip the prompt. // // Deferred ~1 s so iOS finishes initialising the keyboard / // text-input subsystem before the permission alert appears. // Firing the alert too early steals focus from the not-yet- // ready text-input layer, with the symptom that the first tap // on a TextField does nothing (the second tap works because // by then iOS has caught up). DispatchQueue.main.asyncAfter // keeps everything on the main thread; the permission API // itself must be called there too. DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { AVAudioSession.sharedInstance().requestRecordPermission { granted in NSLog("chanora_flutter: microphone permission granted=\(granted)") } } 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") logSessionState(tag: "didBecomeActive") } catch { NSLog("chanora_flutter: AVAudioSession setActive failed: \(error)") } } /// Log the full AVAudioSession state with a diagnostic tag. Used /// after every state transition (setActive, route change) so we /// can correlate user-perceived audio bugs with what iOS thinks /// the session looks like. Output is parseable by grep /// `chanora.session\[`. private func logSessionState(tag: String) { let s = AVAudioSession.sharedInstance() let route = s.currentRoute let outs = route.outputs.map { "\($0.portType.rawValue)/\($0.portName)" }.joined(separator: ",") let ins = route.inputs.map { "\($0.portType.rawValue)/\($0.portName)" }.joined(separator: ",") let preferredInput = s.preferredInput?.portName ?? "" NSLog(""" chanora.session[\(tag)] \ cat=\(s.category.rawValue) \ mode=\(s.mode.rawValue) \ sr=\(s.sampleRate) \ ioBuf=\(String(format: "%.4f", s.ioBufferDuration)) \ out=[\(outs)] in=[\(ins)] \ preferredInput=\(preferredInput) """) } /// Called by `routeChangeNotification`. iOS posts this whenever /// the route is reconfigured for any reason — user toggles /// Bluetooth, plugs in headphones, our `overrideOutputAudioPort` /// call, OR (the suspected bug here) when cpal's RemoteIO /// AudioUnit reacts to its own format change and resets the /// route. The `reason` field tells us which case it is: /// /// * `.override` : our Dart-side override took effect. /// * `.routeConfigurationChange` : something else (cpal?) /// triggered an internal reconfig. /// * `.newDeviceAvailable` / `.oldDeviceUnavailable` : /// user hardware change. /// * `.categoryChange` : someone (us or another app) set a /// new category. @objc private func handleRouteChange(_ note: Notification) { guard let reasonValue = note.userInfo?[AVAudioSessionRouteChangeReasonKey] as? UInt, let reason = AVAudioSession.RouteChangeReason(rawValue: reasonValue) else { NSLog("chanora.routeChange[unknown-reason]") return } let reasonName: String switch reason { case .unknown: reasonName = "unknown" case .newDeviceAvailable: reasonName = "newDeviceAvailable" case .oldDeviceUnavailable: reasonName = "oldDeviceUnavailable" case .categoryChange: reasonName = "categoryChange" case .override: reasonName = "override" case .wakeFromSleep: reasonName = "wakeFromSleep" case .noSuitableRouteForCategory: reasonName = "noSuitableRouteForCategory" case .routeConfigurationChange: reasonName = "routeConfigurationChange" @unknown default: reasonName = "default(\(reasonValue))" } logSessionState(tag: "routeChange.\(reasonName)") } func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) } }