diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ea675b..8185a06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CallWaveKit/CallWaveClient.m b/CallWaveKit/CallWaveClient.m index dfa103a..8f088f2 100644 --- a/CallWaveKit/CallWaveClient.m +++ b/CallWaveKit/CallWaveClient.m @@ -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); @@ -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]; + } }); } diff --git a/CallWaveKit/README.md b/CallWaveKit/README.md index 79f70f6..ad1e29d 100644 --- a/CallWaveKit/README.md +++ b/CallWaveKit/README.md @@ -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 diff --git a/Tests/CallWaveKitRegistryTests/CallWavePushWakeRegistrationTests.m b/Tests/CallWaveKitRegistryTests/CallWavePushWakeRegistrationTests.m new file mode 100644 index 0000000..a3ef100 --- /dev/null +++ b/Tests/CallWaveKitRegistryTests/CallWavePushWakeRegistrationTests.m @@ -0,0 +1,126 @@ +#import + +#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 +@property (nonatomic, strong) NSMutableArray *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