From 6a49302998ed96e21dbbab7c76a3201db5fc22d0 Mon Sep 17 00:00:00 2001 From: PeterSthuka Date: Tue, 8 Sep 2026 09:24:30 +0300 Subject: [PATCH] Fix speaker disable and headset routing --- CHANGELOG.md | 17 ++++++++++++ CallWaveKit/CallWaveAudioSessionCoordinator.m | 25 +++++++++++++----- FIELD-TESTING.md | 26 +++++++++++++++++++ 3 files changed, 62 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e9e6b69..5a11806 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,23 @@ bump may contain breaking changes, and each one is listed below. ## [Unreleased] +### Fixed + +- Disabling speakerphone now removes `defaultToSpeaker` before clearing the + output override, allowing audio to return to the receiver. Failed speaker + changes restore the previous preference; an override failure also attempts + to restore the category configuration. +- Connecting headphones during a call no longer forces audio back to the + speaker. A saved speaker preference is restored on `oldDeviceUnavailable`, + rather than on every route change. + +### Changed + +- The audio category includes `defaultToSpeaker` only while speakerphone is + requested. Hosts that never call `setSpeakerEnabled` no longer receive this + category option. CallKit controls activation and initial routing; the normal + built-in route remains the receiver when no external device is selected. + ## [0.7.0] - 2026-09-07 ### Security diff --git a/CallWaveKit/CallWaveAudioSessionCoordinator.m b/CallWaveKit/CallWaveAudioSessionCoordinator.m index 5c6abf9..f97cf0c 100644 --- a/CallWaveKit/CallWaveAudioSessionCoordinator.m +++ b/CallWaveKit/CallWaveAudioSessionCoordinator.m @@ -104,12 +104,14 @@ - (BOOL)configureAudioSessionWithError:(NSError **)error { NSError *categoryError = nil; AVAudioSessionCategoryOptions options = #if defined(__IPHONE_26_0) && __IPHONE_OS_VERSION_MAX_ALLOWED >= __IPHONE_26_0 - AVAudioSessionCategoryOptionAllowBluetoothHFP | + AVAudioSessionCategoryOptionAllowBluetoothHFP; #else // Renamed to …AllowBluetoothHFP in the iOS 26 SDK; same raw value. - AVAudioSessionCategoryOptionAllowBluetooth | + AVAudioSessionCategoryOptionAllowBluetooth; #endif - AVAudioSessionCategoryOptionDefaultToSpeaker; + if (self.desiredSpeakerEnabled) { + options |= AVAudioSessionCategoryOptionDefaultToSpeaker; + } if ([session setCategory:AVAudioSessionCategoryPlayAndRecord mode:AVAudioSessionModeVoiceChat options:options @@ -188,13 +190,21 @@ - (void)audioSessionDidDeactivate:(AVAudioSession *)audioSession { #pragma mark - Speaker - (BOOL)setSpeakerEnabled:(BOOL)enabled error:(NSError **)error { - [self configureAudioSessionWithError:NULL]; + BOOL previousSpeakerEnabled = self.desiredSpeakerEnabled; + self.desiredSpeakerEnabled = enabled; + if (![self configureAudioSessionWithError:error]) { + self.desiredSpeakerEnabled = previousSpeakerEnabled; + return NO; + } NSError *routeError = nil; BOOL changed = [AVAudioSession.sharedInstance overrideOutputAudioPort:enabled ? AVAudioSessionPortOverrideSpeaker : AVAudioSessionPortOverrideNone error:&routeError]; if (!changed) { + self.desiredSpeakerEnabled = previousSpeakerEnabled; + // Restore the category default as well as the preference after failure. + [self configureAudioSessionWithError:NULL]; CWLogError(CallWaveLogCategoryAudio, @"route change failed: %@", routeError); if (error != NULL) { *error = routeError ?: CallWaveMakeError(CallWaveErrorCallActionFailed, @@ -202,7 +212,6 @@ - (BOOL)setSpeakerEnabled:(BOOL)enabled error:(NSError **)error { } } if (changed) { - self.desiredSpeakerEnabled = enabled; [self publishCurrentAudioRoute]; } return changed; @@ -211,9 +220,13 @@ - (BOOL)setSpeakerEnabled:(BOOL)enabled error:(NSError **)error { #pragma mark - AVAudioSession notifications - (void)handleRouteChangeNotification:(NSNotification *)notification { + AVAudioSessionRouteChangeReason reason = + [notification.userInfo[AVAudioSessionRouteChangeReasonKey] unsignedIntegerValue]; CallWaveAudioRoute *route = [CallWaveAudioRoute routeForAudioSession:AVAudioSession.sharedInstance]; - if (self.desiredSpeakerEnabled && !route.isSpeakerActive) { + // Let newly connected headphones take over; restore the preference on removal. + if (reason == AVAudioSessionRouteChangeReasonOldDeviceUnavailable && + self.desiredSpeakerEnabled && !route.isSpeakerActive) { NSError *error = nil; [AVAudioSession.sharedInstance overrideOutputAudioPort:AVAudioSessionPortOverrideSpeaker error:&error]; diff --git a/FIELD-TESTING.md b/FIELD-TESTING.md index 41a066f..0afdb59 100644 --- a/FIELD-TESTING.md +++ b/FIELD-TESTING.md @@ -361,6 +361,32 @@ so the exchange happens on a timescale a human can sit through, leave call; if the Majordom PBX cannot do session timers, record that here and keep `.optional` as the shipped default. +### 18. Speaker off and headset routing + +Added with the speaker-routing fix after 0.7.0. **Not yet verified on-device**; +the 0.7.0 pass above does not cover this scenario. + +Use Majordom in host-owned mode (`options: []`, with the host's `CXProvider` +and `PKPushRegistry`) and answer a real intercom call on a physical iPhone. + +1. With no headset connected, enable speakerphone and confirm sound comes + from the loudspeaker and `currentAudioRoute.isSpeakerActive` becomes `true`. +2. Call `setSpeakerEnabled(false)`. Confirm it succeeds, sound moves to the + receiver at the ear, and `currentAudioRoute.isSpeakerActive` becomes `false` + after the route update. Check the host's button follows that reported route. +3. Enable speakerphone again and confirm both sound and the reported route. +4. Connect a Bluetooth headset during the call. Audio must stay in the headset, + and the reported speaker state must become `false`, without the library + forcing the speaker back on. +5. Disconnect the headset. The saved speaker preference must restore the + loudspeaker and the reported speaker state must become `true`. +6. Disable speakerphone again, then connect and disconnect the headset. + Audio must return to the receiver with the speaker state `false`. + +Repeat the headset steps with wired headphones. Also repeat scenario 15 with +speakerphone disabled before the interruption: reactivation must preserve that +preference. Record audible output and route updates, not just method success. + ## Reporting a field failure Attach the console log for the whole call, from the push to the end, and state: