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

## [Unreleased]

### Fixed

- A VoIP push that arrives before the host has called
`loginWithConfiguration:` no longer runs the full start path and fails with
`CallWaveErrorNotConfigured`. With no credentials there is nothing to
register, and the login that follows the push starts the stack itself, so the
registration nudge now logs one informational line instead of an error on
every call.
- A push handled in host-owned CallKit mode nudges the registration once.
`-handleVoIPPushPayload:completion:` used to add a second nudge on top of the
one `-prepareIncomingCallWithUUID:caller:` already performs, sending two
`REGISTER`s per call.

## [0.7.1] - 2026-09-08

### Fixed
Expand Down
17 changes: 16 additions & 1 deletion CallWaveKit/CallWaveClient.m
Original file line number Diff line number Diff line change
Expand Up @@ -3309,6 +3309,16 @@ - (void)wakeRegistration {
// serialized queue turn as start/stop. Otherwise stop() can destroy
// PJSUA after the check but before registration is refreshed.
if (![self validateAccountWithError:NULL]) {
if (self.configuration == nil) {
// Credentials-on-first-push hosts have not called
// -loginWithConfiguration: yet: the push is what makes them
// fetch the account. There is nothing to register here, and
// that login starts the stack on its own, so waking is a no-op
// rather than a failed start.
CWLogInfo(CallWaveLogCategoryPush,
@"no SIP account yet; waiting for -loginWithConfiguration:");
return;
}
NSError *error = nil;
if (![self startWithError:&error] && error != nil) {
CWLogError(CallWaveLogCategoryPush, @"start after VoIP push failed: %@", error);
Expand Down Expand Up @@ -3440,7 +3450,12 @@ - (void)handleVoIPPushPayload:(NSDictionary *)payload
acknowledge(error == nil ? @"CallKit accepted the call" : @"CallKit refused the call");
});
}];
[self wakeRegistration];
if (self.managesCallKit) {
// In host-owned mode the report above went through
// -prepareIncomingCallWithUUID:caller:, which already woke the
// registration; a second nudge would REGISTER twice per push.
[self wakeRegistration];
}
});
}

Expand Down
4 changes: 4 additions & 0 deletions CallWaveKit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,10 @@ from a VoIP push on every call — unlike a `stop()`/`start()` cycle, which mean
`pjsua_destroy()`/`pjsua_create()` in the background. An identical configuration
only refreshes the registration.

A push that arrives before the first `login(configuration:)` finds no account
to refresh. The library logs one informational line and waits for that login,
which starts the stack itself.

Between calls, release the account without tearing the stack down:

```swift
Expand Down
126 changes: 126 additions & 0 deletions Tests/CallWaveKitRegistryTests/CallWavePushWakeRegistrationTests.m
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
#import <XCTest/XCTest.h>

#import "CallWaveClient.h"
#import "CallWaveLogInternal.h"

// A push for a host that only learns its credentials from that push used to
// run the full -startWithError: path, which has no configuration to register
// and therefore failed with CallWaveErrorNotConfigured on every single call.
// The call itself survived — the host's -loginWithConfiguration: landed a
// moment later — but every call logged an error for a state the library
// documents as supported.

@interface CallWaveClient (PushWakeTests)
- (void)performSIPSync:(NS_NOESCAPE dispatch_block_t)block;
- (void)wakeRegistration;
@end

@interface CallWavePushWakeProbe : NSObject <CallWaveLogger>
@property (nonatomic, strong) NSMutableArray<NSString *> *messages;
@end

@implementation CallWavePushWakeProbe

- (instancetype)init {
self = [super init];
if (self) {
_messages = [NSMutableArray array];
}
return self;
}

- (void)callWaveDidLogMessage:(NSString *)message
level:(CallWaveLogLevel)level
category:(NSString *)category {
@synchronized (self) {
[self.messages addObject:message];
}
}

- (BOOL)sawMessageContaining:(NSString *)needle {
@synchronized (self) {
for (NSString *message in self.messages) {
if ([message containsString:needle]) {
return YES;
}
}
}
return NO;
}

@end

// Counts the nudges a single push produces.
@interface CallWaveWakeCountingClient : CallWaveClient
@property (nonatomic, assign) NSUInteger wakeCount;
@end

@implementation CallWaveWakeCountingClient
- (void)wakeRegistration {
self.wakeCount += 1;
[super wakeRegistration];
}
@end

@interface CallWavePushWakeRegistrationTests : XCTestCase
@property (nonatomic, strong) CallWavePushWakeProbe *probe;
@property (nonatomic, assign) CallWaveLogLevel previousLevel;
@end

@implementation CallWavePushWakeRegistrationTests

- (void)setUp {
[super setUp];
self.probe = [[CallWavePushWakeProbe alloc] init];
self.previousLevel = CallWaveLog.level;
CallWaveLog.level = CallWaveLogLevelInfo;
CallWaveLog.logger = self.probe;
}

- (void)tearDown {
CallWaveLog.logger = nil;
CallWaveLog.level = self.previousLevel;
self.probe = nil;
[super tearDown];
}

- (CallWaveEngineConfiguration *)engine {
CallWaveEngineConfiguration *engine = [CallWaveEngineConfiguration defaultConfiguration];
engine.handlesNetworkChanges = NO;
engine.logLevel = CallWaveLogLevelInfo;
return engine;
}

- (void)testPushBeforeLoginDoesNotReportMissingConfiguration {
CallWaveClient *client = [[CallWaveClient alloc] initWithConfiguration:nil
options:CallWaveIntegrationOptionNone
provider:nil
engineConfiguration:[self engine]];

[client prepareIncomingCallWithUUID:[NSUUID UUID] caller:@"1001"];
// The wake is queued on the SIP queue before this returns, so a synchronous
// turn behind it is enough to observe whatever it logged.
[client performSIPSync:^{}];

XCTAssertFalse([self.probe sawMessageContaining:@"No configuration"]);
XCTAssertTrue([self.probe sawMessageContaining:@"no SIP account yet"]);
XCTAssertFalse(client.isRunning);
}

- (void)testHostOwnedPushNudgesRegistrationOnce {
CallWaveWakeCountingClient *client =
[[CallWaveWakeCountingClient alloc] initWithConfiguration:nil
options:CallWaveIntegrationOptionNone
provider:nil
engineConfiguration:[self engine]];

XCTestExpectation *handled = [self expectationWithDescription:@"push handled"];
[client handleVoIPPushPayload:@{@"uuid": [NSUUID UUID].UUIDString, @"caller_id": @"1001"}
completion:nil];
dispatch_async(dispatch_get_main_queue(), ^{ [handled fulfill]; });
[self waitForExpectations:@[handled] timeout:2];

XCTAssertEqual(client.wakeCount, 1u);
}

@end