diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a11806..1ea675b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CallWaveKit.podspec b/CallWaveKit.podspec index a5e1342..e350d5c 100644 --- a/CallWaveKit.podspec +++ b/CallWaveKit.podspec @@ -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, diff --git a/CallWaveKit/CallWaveAudioSessionCoordinator.h b/CallWaveKit/CallWaveAudioSessionCoordinator.h index 6b6258b..356fcc2 100644 --- a/CallWaveKit/CallWaveAudioSessionCoordinator.h +++ b/CallWaveKit/CallWaveAudioSessionCoordinator.h @@ -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; diff --git a/CallWaveKit/CallWaveAudioSessionCoordinator.m b/CallWaveKit/CallWaveAudioSessionCoordinator.m index f97cf0c..2e40606 100644 --- a/CallWaveKit/CallWaveAudioSessionCoordinator.m +++ b/CallWaveKit/CallWaveAudioSessionCoordinator.m @@ -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 @@ -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); diff --git a/FIELD-TESTING.md b/FIELD-TESTING.md index 0afdb59..c712071 100644 --- a/FIELD-TESTING.md +++ b/FIELD-TESTING.md @@ -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 diff --git a/README.md b/README.md index 1dc1a46..4546d6b 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 @@ -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:)` diff --git a/RELEASING.md b/RELEASING.md index 040f22f..060e031 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -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" diff --git a/Scripts/package-pjsip-release.sh b/Scripts/package-pjsip-release.sh index 63884d5..8683116 100755 --- a/Scripts/package-pjsip-release.sh +++ b/Scripts/package-pjsip-release.sh @@ -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 diff --git a/Tests/CallWaveKitRegistryTests/CallWaveAudioReliabilityTests.m b/Tests/CallWaveKitRegistryTests/CallWaveAudioReliabilityTests.m index c00e61e..2769bc8 100644 --- a/Tests/CallWaveKitRegistryTests/CallWaveAudioReliabilityTests.m +++ b/Tests/CallWaveKitRegistryTests/CallWaveAudioReliabilityTests.m @@ -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`, @@ -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