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(); }