User report: 'speaker change not work' \u2014 selecting Speaker or
iPhone receiver in the audio output picker had no audible effect.
Root cause:
AVAudioSession was configured with category options
[.defaultToSpeaker, .allowBluetoothHFP, .allowBluetoothA2DP] +
mode .voiceChat. The .defaultToSpeaker flag tells iOS 'this app's
baseline output route is the speakerphone, even though .voiceChat
mode would normally route to the receiver.'
When the user picked Speaker:
overrideOutputAudioPort(.speaker) <- already at speaker baseline; no-op
When the user picked iPhone receiver:
overrideOutputAudioPort(.none) <- removes speaker OVERRIDE,
restores baseline = .defaultToSpeaker
= speakerphone. Receiver
row silently mapped to speaker.
So both rows produced the same audible state. The picker UI changed
the selected radio but the route didn't actually move.
Fix:
1. AppDelegate.swift: drop .defaultToSpeaker from options. With
pure .voiceChat mode (no .defaultToSpeaker), the baseline is
the receiver/earpiece. overrideOutputAudioPort then works as
documented:
Default = receiver
overrideOutputAudioPort(.speaker) -> speakerphone
overrideOutputAudioPort(.none) -> back to receiver
BT/AirPods connected -> automatic
Wired headphones plugged in -> automatic
2. voice_compact.dart: replace 'catch (_) {/* ignore */}' silent
swallow with debugPrint logging of (a) the actual exception
and (b) which route was selected. So if iOS rejects an
override (e.g. wired headphones plugged in), we can see WHY
in the device log instead of a silent picker no-op.
3. _selectSpeaker also now sets preferredInput to the built-in
mic so input + output stay consistent. Previously the
speakerphone override could leave the mic still routed to a
previously-selected BT input \u2014 user hears self through
speaker but server hears nothing.
flutter build ios --release --no-codesign: 21.9 s, Runner.app
30.4 MB.
135 lines
6.3 KiB
Swift
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)
|
|
}
|
|
}
|