Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 19 additions & 6 deletions CallWaveKit/CallWaveAudioSessionCoordinator.m
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -188,21 +190,28 @@ - (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,
@"Audio route could not be changed.");
}
}
if (changed) {
self.desiredSpeakerEnabled = enabled;
[self publishCurrentAudioRoute];
}
return changed;
Expand All @@ -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];
Expand Down
26 changes: 26 additions & 0 deletions FIELD-TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down