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
21 changes: 13 additions & 8 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,27 @@ bump may contain breaking changes, and each one is listed below.

## [Unreleased]

## [0.7.1] - 2026-09-08

### Fixed

- Disabling speakerphone now removes `defaultToSpeaker` before clearing the
output override, allowing audio to return to the receiver. Failed speaker
- Speakerphone uses a temporary output override, without `defaultToSpeaker`,
allowing both the host button and CallKit to return audio 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.
speaker. An explicit system route selection updates the saved speaker preference.
Recovery is limited to `oldDeviceUnavailable` and never overrides an external output.

### 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.
- The audio category no longer includes `defaultToSpeaker`. Hosts should use
`setSpeakerEnabled` for the initial speaker route; CallKit controls activation.
- Added regression coverage for CallKit speaker/receiver selections, headset
selection and receiver-compatible category configuration.

Physical-device verification of 0.7.1 is pending; simulator audio tests cannot
confirm actual earpiece/speaker playback on an iPhone.

## [0.7.0] - 2026-09-07

Expand Down
2 changes: 1 addition & 1 deletion CallWaveKit.podspec
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
Pod::Spec.new do |spec|
spec.name = 'CallWaveKit'
spec.version = '0.7.0'
spec.version = '0.7.1'
spec.summary = 'Incoming SIP SDK for iOS intercoms.'
spec.description = <<-DESC
CallWaveKit owns a PJSUA runtime, SIP registration, incoming audio calls,
Expand Down
2 changes: 1 addition & 1 deletion CallWaveKit/CallWaveAudioSessionCoordinator.h
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ NS_ASSUME_NONNULL_BEGIN
@property (nonatomic, assign) BOOL audioSessionActive;

/// The user's speaker choice. Survives route losses and session reactivation;
/// only `-setSpeakerEnabled:error:` changes it.
/// updated by the API and by system route selections (including CallKit).
@property (nonatomic, assign) BOOL desiredSpeakerEnabled;

@property (nonatomic, strong, readonly) CallWaveAudioRoute *currentAudioRoute;
Expand Down
36 changes: 26 additions & 10 deletions CallWaveKit/CallWaveAudioSessionCoordinator.m
Original file line number Diff line number Diff line change
Expand Up @@ -109,9 +109,7 @@ - (BOOL)configureAudioSessionWithError:(NSError **)error {
// Renamed to …AllowBluetoothHFP in the iOS 26 SDK; same raw value.
AVAudioSessionCategoryOptionAllowBluetooth;
#endif
if (self.desiredSpeakerEnabled) {
options |= AVAudioSessionCategoryOptionDefaultToSpeaker;
}

if ([session setCategory:AVAudioSessionCategoryPlayAndRecord
mode:AVAudioSessionModeVoiceChat
options:options
Expand Down Expand Up @@ -219,17 +217,35 @@ - (BOOL)setSpeakerEnabled:(BOOL)enabled error:(NSError **)error {

#pragma mark - AVAudioSession notifications

- (CallWaveAudioRoute *)readCurrentAudioRoute {
return [CallWaveAudioRoute routeForAudioSession:AVAudioSession.sharedInstance];
}

- (BOOL)restoreSpeakerWithError:(NSError **)error {
return [AVAudioSession.sharedInstance overrideOutputAudioPort:AVAudioSessionPortOverrideSpeaker
error:error];
}

- (void)handleRouteChangeNotification:(NSNotification *)notification {
CallWaveAudioRoute *route = [self readCurrentAudioRoute];
AVAudioSessionRouteChangeReason reason =
[notification.userInfo[AVAudioSessionRouteChangeReasonKey] unsignedIntegerValue];
CallWaveAudioRoute *route =
[CallWaveAudioRoute routeForAudioSession:AVAudioSession.sharedInstance];
// Let newly connected headphones take over; restore the preference on removal.
if (reason == AVAudioSessionRouteChangeReasonOldDeviceUnavailable &&
self.desiredSpeakerEnabled && !route.isSpeakerActive) {
BOOL externalOutput = NO;
for (NSString *port in route.outputPortTypes) {
if (![port isEqualToString:AVAudioSessionPortBuiltInSpeaker] &&
![port isEqualToString:AVAudioSessionPortBuiltInReceiver]) {
externalOutput = YES;
}
}
// CallKit's speaker button changes AVAudioSession directly. Treat that
// override (and a newly selected accessory) as the user's new preference.
// Restoring our old preference here would immediately undo their action.
if (reason == AVAudioSessionRouteChangeReasonOverride || externalOutput) {
self.desiredSpeakerEnabled = route.isSpeakerActive;
} else if (self.desiredSpeakerEnabled && !route.isSpeakerActive &&
reason == AVAudioSessionRouteChangeReasonOldDeviceUnavailable) {
NSError *error = nil;
[AVAudioSession.sharedInstance overrideOutputAudioPort:AVAudioSessionPortOverrideSpeaker
error:&error];
[self restoreSpeakerWithError:&error];
if (error != nil) {
CWLogWarning(CallWaveLogCategoryAudio,
@"could not restore the speaker after a route change: %@", error);
Expand Down
9 changes: 9 additions & 0 deletions FIELD-TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,15 @@ done by hand, on a device, against an intercom.
Run it before tagging a release, and after any change to the answer path, the
push path, the audio session or the registration lifecycle.

## 0.7.1 run record

Physical-device pass: **not run**. The release is authorized to deliver the
speaker-routing fix for another on-device QA run. Automated audio regression
coverage includes system speaker/receiver selection and headset selection.
Verify CallKit Speaker on/off repeatedly, repeat from the app, then interrupt
and resume the call and connect/disconnect a headset. Confirm actual playback,
not only the selected button.

## 0.4.0 release record

On 2026-08-04 the maintainer confirmed registration and a real incoming call
Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,13 @@ not grant or transfer a proprietary PJSIP licence.
### Swift Package Manager

In Xcode: **File → Add Package Dependencies**, enter
`https://github.com/PetrShtuka/CallWaveKit.git`, pick version `0.7.0` or later,
`https://github.com/PetrShtuka/CallWaveKit.git`, pick version `0.7.1` or later,
and add the `CallWaveKit` product to your application target.

Or in a `Package.swift`:

```swift
.package(url: "https://github.com/PetrShtuka/CallWaveKit.git", from: "0.7.0")
.package(url: "https://github.com/PetrShtuka/CallWaveKit.git", from: "0.7.1")
```

The product vends two modules: `CallWaveKit` (the Objective-C API) and
Expand All @@ -63,7 +63,7 @@ To track the repository directly instead of the published pod — an unreleased
fix, say — point at the tag:

```ruby
pod 'CallWaveKit', git: 'https://github.com/PetrShtuka/CallWaveKit.git', tag: '0.7.0'
pod 'CallWaveKit', git: 'https://github.com/PetrShtuka/CallWaveKit.git', tag: '0.7.1'
```

## Host application settings
Expand Down Expand Up @@ -215,7 +215,7 @@ SwiftPM releases use an immutable binary asset so package consumers do not
download the 21 MB framework through Git history. To package a rebuilt binary:

```sh
./Scripts/package-pjsip-release.sh 0.7.0
./Scripts/package-pjsip-release.sh 0.7.1
```

The script prints the archive checksum and the `.binaryTarget(url:checksum:)`
Expand Down
2 changes: 1 addition & 1 deletion RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ CocoaPods fail with a version mismatch, because `spec.source` resolves
### 3. Tag

```sh
RELEASE_VERSION=0.7.0
RELEASE_VERSION=0.7.1
git checkout main
git pull
git tag -a "$RELEASE_VERSION" -m "CallWaveKit $RELEASE_VERSION"
Expand Down
4 changes: 2 additions & 2 deletions Scripts/package-pjsip-release.sh
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
# checkout pays for it, so a tagged release should attach the zip instead and
# Package.swift should point at that URL:
#
# ./Scripts/package-pjsip-release.sh 0.7.0
# gh release upload 0.7.0 build/PJSIP.xcframework.zip
# ./Scripts/package-pjsip-release.sh 0.7.1
# gh release upload 0.7.1 build/PJSIP.xcframework.zip
#
# Then replace the `path:` binary target in Package.swift with the printed
# `url:`/`checksum:` pair. Keep the local `path:` variant on a branch you
Expand Down
61 changes: 61 additions & 0 deletions Tests/CallWaveKitRegistryTests/CallWaveAudioReliabilityTests.m
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
#import "CallWaveClient.h"
#import "CallWaveCallRegistry.h"
#import "CallWaveAudioRouteInternal.h"
#import "CallWaveAudioSessionCoordinator.h"

// Private entry points are redeclared here so the tests can drive the audio
// coordinator paths without changing the public API. `registry`,
Expand All @@ -19,11 +20,71 @@ - (void)audioSessionRouteDidChange:(NSNotification *)notification;
- (void)audioMediaServicesWereReset:(NSNotification *)notification;
@end


@interface CallWaveTestAudioCoordinator : CallWaveAudioSessionCoordinator
@property (nonatomic, strong) CallWaveAudioRoute *testRoute;
@property (nonatomic, assign) NSUInteger restoreCount;
@end
@implementation CallWaveTestAudioCoordinator
- (CallWaveAudioRoute *)readCurrentAudioRoute { return self.testRoute; }
- (BOOL)restoreSpeakerWithError:(NSError **)error {
self.restoreCount += 1;
return YES;
}
@end

@interface CallWaveAudioReliabilityTests : XCTestCase
@end

@implementation CallWaveAudioReliabilityTests


- (void)testCallKitReceiverSelectionDoesNotRestoreSpeaker {
CallWaveTestAudioCoordinator *audio = [CallWaveTestAudioCoordinator new];
audio.desiredSpeakerEnabled = YES;
audio.testRoute = [[CallWaveAudioRoute alloc]
initWithInputPortTypes:@[AVAudioSessionPortBuiltInMic]
outputPortTypes:@[AVAudioSessionPortBuiltInReceiver]];
[audio handleRouteChangeNotification:[NSNotification
notificationWithName:AVAudioSessionRouteChangeNotification object:nil
userInfo:@{AVAudioSessionRouteChangeReasonKey: @(AVAudioSessionRouteChangeReasonOverride)}]];
XCTAssertFalse(audio.desiredSpeakerEnabled);
XCTAssertEqual(audio.restoreCount, 0u);
}

- (void)testCallKitSpeakerSelectionUpdatesPreference {
CallWaveTestAudioCoordinator *audio = [CallWaveTestAudioCoordinator new];
audio.testRoute = [[CallWaveAudioRoute alloc]
initWithInputPortTypes:@[AVAudioSessionPortBuiltInMic]
outputPortTypes:@[AVAudioSessionPortBuiltInSpeaker]];
[audio handleRouteChangeNotification:[NSNotification
notificationWithName:AVAudioSessionRouteChangeNotification object:nil
userInfo:@{AVAudioSessionRouteChangeReasonKey: @(AVAudioSessionRouteChangeReasonOverride)}]];
XCTAssertTrue(audio.desiredSpeakerEnabled);
XCTAssertEqual(audio.restoreCount, 0u);
}

- (void)testSelectingHeadsetDoesNotRestoreSpeaker {
CallWaveTestAudioCoordinator *audio = [CallWaveTestAudioCoordinator new];
audio.desiredSpeakerEnabled = YES;
audio.testRoute = [[CallWaveAudioRoute alloc]
initWithInputPortTypes:@[AVAudioSessionPortBluetoothHFP]
outputPortTypes:@[AVAudioSessionPortBluetoothHFP]];
[audio handleRouteChangeNotification:[NSNotification
notificationWithName:AVAudioSessionRouteChangeNotification object:nil
userInfo:@{AVAudioSessionRouteChangeReasonKey: @(AVAudioSessionRouteChangeReasonNewDeviceAvailable)}]];
XCTAssertFalse(audio.desiredSpeakerEnabled);
XCTAssertEqual(audio.restoreCount, 0u);
}

- (void)testConfigurationAllowsReturningToReceiver {
CallWaveAudioSessionCoordinator *audio = [CallWaveAudioSessionCoordinator new];
NSError *error = nil;
XCTAssertTrue([audio configureAudioSessionWithError:&error], @"%@", error);
XCTAssertEqual(AVAudioSession.sharedInstance.categoryOptions &
AVAudioSessionCategoryOptionDefaultToSpeaker, 0u);
}

- (CallWaveClient *)makeClient {
return [[CallWaveClient alloc] initWithConfiguration:nil
options:CallWaveIntegrationOptionNone
Expand Down