Files
chanora/apps/chanora_flutter/ios/Runner/AppDelegate.swift
T

135 lines
6.3 KiB
Swift

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
)
// 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")
} catch {
NSLog("chanora_flutter: AVAudioSession setActive failed: \(error)")
}
}
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
}
}