From 941370337231196fe6afb3fa434126455cdbc84a Mon Sep 17 00:00:00 2001 From: EdisonJwa Date: Sat, 16 May 2026 21:32:41 +0800 Subject: [PATCH] fix(ios,audio): drop .defaultToSpeaker; speakerphone toggle now actually switches 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. --- .../ios/Runner/AppDelegate.swift | 42 +++++++++++++++---- .../lib/widgets/voice_compact.dart | 36 ++++++++++++++-- 2 files changed, 68 insertions(+), 10 deletions(-) diff --git a/apps/chanora_flutter/ios/Runner/AppDelegate.swift b/apps/chanora_flutter/ios/Runner/AppDelegate.swift index b02871d..6b464ac 100644 --- a/apps/chanora_flutter/ios/Runner/AppDelegate.swift +++ b/apps/chanora_flutter/ios/Runner/AppDelegate.swift @@ -32,13 +32,41 @@ import AVFoundation try session.setCategory( .playAndRecord, mode: .voiceChat, - // .allowBluetoothHFP is the iOS-26-renamed form of the - // historical .allowBluetooth flag — same semantics (permit - // HFP-profile Bluetooth headsets as input + output, so - // AirPods et al. route both directions). .allowBluetoothA2DP - // covers higher-quality output-only A2DP devices that lack - // a mic. Keeping both gives us the broadest BT support. - options: [.defaultToSpeaker, .allowBluetoothHFP, .allowBluetoothA2DP] + // 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 { diff --git a/apps/chanora_flutter/lib/widgets/voice_compact.dart b/apps/chanora_flutter/lib/widgets/voice_compact.dart index f687c42..4e04cda 100644 --- a/apps/chanora_flutter/lib/widgets/voice_compact.dart +++ b/apps/chanora_flutter/lib/widgets/voice_compact.dart @@ -768,9 +768,32 @@ class _AudioOutputPickerSheetState extends State<_AudioOutputPickerSheet> { Future _selectSpeaker() async { try { + // Apple docs: overrideOutputAudioPort requires the audio + // session's category to be .playAndRecord AND the session + // active. We set both in AppDelegate.swift on launch + + // foreground (commit 0466000 / 4ee2b38 / be160f5). If this + // setter throws, log the underlying error so the user can + // see what's wrong instead of a silent no-op picker. + // + // Also: setPreferredInput(builtInMic) AFTER the override so + // the input route matches the output we just forced. Without + // pairing input + output, the session can land in an + // inconsistent state where speakerphone is on but the mic + // is still routed to a previously-selected BT input \u2014 the + // user hears themselves through the speaker but the server + // hears nothing. await AVAudioSession() .overrideOutputAudioPort(AVAudioSessionPortOverride.speaker); - } catch (_) {/* ignore */} + final builtIn = _availableInputs + .where((p) => p.portType == AVAudioSessionPort.builtInMic) + .toList(); + if (builtIn.isNotEmpty) { + await AVAudioSession().setPreferredInput(builtIn.first); + } + debugPrint('chanora: audio output -> speakerphone (built-in mic input)'); + } catch (e, st) { + debugPrint('chanora: _selectSpeaker FAILED: $e\n$st'); + } if (!mounted) return; Navigator.of(context).pop(); } @@ -787,7 +810,10 @@ class _AudioOutputPickerSheetState extends State<_AudioOutputPickerSheet> { if (builtIn.isNotEmpty) { await AVAudioSession().setPreferredInput(builtIn.first); } - } catch (_) {/* ignore */} + debugPrint('chanora: audio output -> receiver/earpiece'); + } catch (e, st) { + debugPrint('chanora: _selectReceiver FAILED: $e\n$st'); + } if (!mounted) return; Navigator.of(context).pop(); } @@ -799,7 +825,11 @@ class _AudioOutputPickerSheetState extends State<_AudioOutputPickerSheet> { await AVAudioSession() .overrideOutputAudioPort(AVAudioSessionPortOverride.none); await AVAudioSession().setPreferredInput(port); - } catch (_) {/* ignore */} + debugPrint( + 'chanora: audio output -> ${port.portName} (${port.portType})'); + } catch (e, st) { + debugPrint('chanora: _selectInput FAILED: $e\n$st'); + } if (!mounted) return; Navigator.of(context).pop(); }