diff --git a/CHANGELOG.md b/CHANGELOG.md index 2eefbb3..4360c53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,16 @@ bump may contain breaking changes, and each one is listed below. ### Fixed +- Runtime ownership now uses a separate identity token that survives ARC weak + zeroing during deallocation, so releasing a client also destroys its PJSUA + runtime and releases an initialization-stage claim. +- Registration checks, refresh, unregister, logout and account updates validate + ownership and operate in the same SIP queue turn as stop. Queue identity is + specific to each client, and account state is published before leaving it. +- Managed CallKit handling reports cancellation, duplicate and stale VoIP + payloads before completing them. Cancelled calls are immediately ended; + duplicate reports reuse the UUID. Host-owned CallKit handling is unchanged. + - PJSUA runtime ownership is claimed for the whole initialization window, not only after `isRunning` becomes true, so two clients cannot initialize the process-global stack concurrently on different queues. Start/stop state is diff --git a/CallWaveKit/CallWaveClient.m b/CallWaveKit/CallWaveClient.m index 2fe807b..dfa103a 100644 --- a/CallWaveKit/CallWaveClient.m +++ b/CallWaveKit/CallWaveClient.m @@ -59,6 +59,9 @@ static pj_pool_t *gAccountHeaderPool = NULL; static NSUInteger gCreatedTransports = 0; static __weak CallWaveClient *gActiveClient = nil; +// Unlike the weak callback target, this identity survives the owner's dealloc. +// Access only while synchronized on CallWaveClient.class. +static NSUUID *gRuntimeOwnerToken = nil; /// One outgoing non-2xx final response to an INVITE, from the moment it is /// handed to the transport until the peer ACKs it. Keyed by Call-ID and CSeq @@ -628,6 +631,7 @@ + (void)destroyRuntimeOnQueue:(dispatch_queue_t)queue; @end @implementation CallWaveClient { + NSUUID *_runtimeOwnerToken; /// Guards the published state below. Those properties are written on the /// main queue but read from the SIP queue, from PJSIP's own callback /// threads and from whatever thread the host calls a public method on, so @@ -727,6 +731,7 @@ - (instancetype)initWithConfiguration:(CallWaveConfiguration *)configuration // Before anything that goes through a locked accessor, which -setupCallKit // below does. _stateLock = OS_UNFAIR_LOCK_INIT; + _runtimeOwnerToken = [NSUUID UUID]; _configuration = [configuration copy]; _engineConfiguration = [engineConfiguration copy] ?: [CallWaveEngineConfiguration defaultConfiguration]; @@ -747,7 +752,7 @@ - (instancetype)initWithConfiguration:(CallWaveConfiguration *)configuration _dtmfMethod = CallWaveDTMFMethodAuto; _networkPathSummary = @"unknown"; _sipQueue = dispatch_queue_create("com.callwave.pjsip", DISPATCH_QUEUE_SERIAL); - dispatch_queue_set_specific(_sipQueue, kCallWaveSIPQueueKey, kCallWaveSIPQueueKey, NULL); + dispatch_queue_set_specific(_sipQueue, kCallWaveSIPQueueKey, (__bridge void *)_sipQueue, NULL); _callController = [[CXCallController alloc] init]; if (options & CallWaveIntegrationOptionManagesCallKit) { [self setupCallKit]; @@ -770,10 +775,10 @@ - (void)dealloc { if (_pathMonitor != nil) { nw_path_monitor_cancel(_pathMonitor); } - if (_running) { + { BOOL ownsRuntime = NO; @synchronized (CallWaveClient.class) { - ownsRuntime = gActiveClient == self; + ownsRuntime = gRuntimeOwnerToken == _runtimeOwnerToken; } if (ownsRuntime) { // Deliberately not `-stop`: a block that captures `self` during @@ -782,8 +787,9 @@ - (void)dealloc { // Ownership must cover destruction. Releasing it first lets a new // client initialize PJSUA while this dealloc is tearing it down. @synchronized (CallWaveClient.class) { - if (gActiveClient == self) { + if (gRuntimeOwnerToken == _runtimeOwnerToken) { gActiveClient = nil; + gRuntimeOwnerToken = nil; } } } @@ -794,7 +800,7 @@ - (void)dealloc { /// Runs `block` on `sipQueue` and waits. Safe to call from `sipQueue` itself. - (void)performSIPSync:(NS_NOESCAPE dispatch_block_t)block { - if (dispatch_get_specific(kCallWaveSIPQueueKey) != NULL) { + if (dispatch_get_specific(kCallWaveSIPQueueKey) == (__bridge void *)self.sipQueue) { block(); return; } @@ -898,13 +904,14 @@ - (BOOL)claimRuntimeWithError:(NSError **)error { // Ownership begins before PJSUA starts. Looking only at `isRunning` // leaves the whole initialization window unprotected and lets two // clients mutate the process-global runtime from different queues. - if (gActiveClient != nil && gActiveClient != self) { + if (gRuntimeOwnerToken != nil && gRuntimeOwnerToken != _runtimeOwnerToken) { if (error != NULL) { *error = CallWaveMakeError(CallWaveErrorEngineAlreadyRunning, @"Another CallWaveClient owns the PJSUA runtime."); } return NO; } + gRuntimeOwnerToken = _runtimeOwnerToken; gActiveClient = self; } return YES; @@ -914,7 +921,7 @@ - (BOOL)startEngineWithError:(NSError **)error { if (self.isRunning) { BOOL ownsRuntime = NO; @synchronized (CallWaveClient.class) { - ownsRuntime = gActiveClient == self; + ownsRuntime = gRuntimeOwnerToken == _runtimeOwnerToken; } if (ownsRuntime) { return YES; @@ -936,7 +943,7 @@ - (BOOL)startEngineWithError:(NSError **)error { __block BOOL ownershipLost = NO; [self performSIPSync:^{ @synchronized (CallWaveClient.class) { - ownershipLost = gActiveClient != self; + ownershipLost = gRuntimeOwnerToken != _runtimeOwnerToken; } if (ownershipLost) { return; @@ -955,8 +962,9 @@ - (BOOL)startEngineWithError:(NSError **)error { // queued stop/start transition can run. [CallWaveClient destroyRuntimeOnQueue:self.sipQueue]; @synchronized (CallWaveClient.class) { - if (gActiveClient == self) { + if (gRuntimeOwnerToken == _runtimeOwnerToken) { gActiveClient = nil; + gRuntimeOwnerToken = nil; } } } @@ -1018,29 +1026,35 @@ - (BOOL)updateConfiguration:(CallWaveConfiguration *)configuration } CallWaveConfiguration *copy = [configuration copy]; - self.registrationState = CallWaveRegistrationStateRegistering; - - __block pj_status_t status = PJ_SUCCESS; + __block NSError *failure = nil; + __block BOOL recentsChanged = NO; [self performSIPSync:^{ - status = [self applyConfigurationLocked:copy]; - }]; - - if (status != PJ_SUCCESS) { - NSError *failure = CallWaveMakeSIPError(status, @"SIP account setup"); - self.registrationState = CallWaveRegistrationStateFailed; - self.registrationError = failure; - if (error != NULL) { - *error = failure; + BOOL ownsRuntime; + @synchronized (CallWaveClient.class) { + ownsRuntime = gRuntimeOwnerToken == _runtimeOwnerToken; } + if (!ownsRuntime || !self.isRunning) { + failure = CallWaveMakeError(CallWaveErrorEngineNotRunning, + @"Account update was superseded by stop()."); + return; + } + self.registrationState = CallWaveRegistrationStateRegistering; + pj_status_t status = [self applyConfigurationLocked:copy]; + if (status != PJ_SUCCESS) { + failure = CallWaveMakeSIPError(status, @"SIP account setup"); + self.registrationState = CallWaveRegistrationStateFailed; + self.registrationError = failure; + return; + } + recentsChanged = self.configuration.includesCallsInRecents != copy.includesCallsInRecents; + self.configuration = copy; + }]; + if (failure != nil) { + if (error != NULL) *error = failure; return NO; } - - BOOL recentsChanged = self.configuration.includesCallsInRecents != copy.includesCallsInRecents; - self.configuration = copy; if (recentsChanged && self.managesCallKit) { - dispatchMain(^{ - [self refreshProviderConfiguration]; - }); + dispatchMain(^{ [self refreshProviderConfiguration]; }); } return YES; } @@ -1256,6 +1270,9 @@ - (void)configureSessionTimersForAccount:(pjsua_acc_config *)account /// Swaps the SIP account in place. The PJSUA runtime is never destroyed, so /// this is safe to run for every incoming call. Must run on `sipQueue`. - (pj_status_t)applyConfigurationLocked:(CallWaveConfiguration *)configuration { + @synchronized (CallWaveClient.class) { + if (gRuntimeOwnerToken != _runtimeOwnerToken) return PJ_EINVALIDOP; + } if (!gPJSUAStarted) { return PJ_EINVALIDOP; } @@ -1368,16 +1385,9 @@ - (pj_status_t)applyConfigurationLocked:(CallWaveConfiguration *)configuration { } - (BOOL)isRegistered { - if (!gPJSUAStarted || gAccountId == PJSUA_INVALID_ID) { - return NO; - } - __block BOOL registered = NO; [self performSIPSync:^{ - ensurePJThreadRegistered("CallWaveRegistrationCheck"); - if (!pjsua_acc_is_valid(gAccountId)) { - return; - } + if (![self validateAccountWithError:NULL]) return; pjsua_acc_info info; if (pjsua_acc_get_info(gAccountId, &info) == PJ_SUCCESS) { registered = registrationIsActive(&info); @@ -1386,15 +1396,21 @@ - (BOOL)isRegistered { return registered; } +/// Must be called on this client's SIP queue, in the same turn as the operation. - (BOOL)validateAccountWithError:(NSError **)error { - if (!self.isRunning) { + BOOL ownsRuntime; + @synchronized (CallWaveClient.class) { + ownsRuntime = gRuntimeOwnerToken == _runtimeOwnerToken; + } + if (!ownsRuntime || !self.isRunning) { if (error != NULL) { *error = CallWaveMakeError(CallWaveErrorEngineNotRunning, @"CallWaveClient must be started first."); } return NO; } - if (!gPJSUAStarted || gAccountId == PJSUA_INVALID_ID || !pjsua_acc_is_valid(gAccountId)) { + if (!gPJSUAStarted || !ensurePJThreadRegistered("CallWaveAccountCheck") || + gAccountId == PJSUA_INVALID_ID || !pjsua_acc_is_valid(gAccountId)) { if (error != NULL) { *error = CallWaveMakeError(CallWaveErrorSIPFailure, @"The SIP account is not available."); @@ -1407,22 +1423,14 @@ - (BOOL)validateAccountWithError:(NSError **)error { - (BOOL)setRegistrationEnabled:(BOOL)enabled context:(NSString *)context error:(NSError **)error { - if (![self validateAccountWithError:error]) { - return NO; - } - - __block pj_status_t status = PJ_EUNKNOWN; + __block NSError *failure = nil; [self performSIPSync:^{ - ensurePJThreadRegistered("CallWaveRegistration"); - status = pjsua_acc_set_registration(gAccountId, enabled ? PJ_TRUE : PJ_FALSE); + if (![self validateAccountWithError:&failure]) return; + pj_status_t status = pjsua_acc_set_registration(gAccountId, enabled ? PJ_TRUE : PJ_FALSE); + if (status != PJ_SUCCESS) failure = CallWaveMakeSIPError(status, context); }]; - if (status != PJ_SUCCESS) { - if (error != NULL) { - *error = CallWaveMakeSIPError(status, context); - } - return NO; - } - return YES; + if (failure != nil && error != NULL) *error = failure; + return failure == nil; } - (BOOL)refreshRegistrationWithError:(NSError **)error { @@ -1432,46 +1440,40 @@ - (BOOL)refreshRegistrationWithError:(NSError **)error { /// Sends `REGISTER` with `Expires: 0` and keeps the account, so a later /// `-refreshRegistrationWithError:` re-registers without rebuilding anything. - (BOOL)unregisterWithError:(NSError **)error { - if (![self validateAccountWithError:error]) { - return NO; - } - - __block BOOL hasSession = NO; + __block NSError *failure = nil; [self performSIPSync:^{ - ensurePJThreadRegistered("CallWaveUnregisterCheck"); + if (![self validateAccountWithError:&failure]) return; pjsua_acc_info info; - if (pjsua_acc_get_info(gAccountId, &info) == PJ_SUCCESS) { - hasSession = info.expires != PJSIP_EXPIRES_NOT_SPECIFIED && info.expires > 0; + pj_status_t status = pjsua_acc_get_info(gAccountId, &info); + if (status == PJ_SUCCESS) { + if (info.expires == PJSIP_EXPIRES_NOT_SPECIFIED || info.expires == 0) { + self.registrationState = CallWaveRegistrationStateStopped; + return; + } + status = pjsua_acc_set_registration(gAccountId, PJ_FALSE); } + if (status != PJ_SUCCESS) failure = CallWaveMakeSIPError(status, @"Unregister"); }]; - if (!hasSession) { - // PJSUA answers PJ_EINVALIDOP when there is no session to close, which - // would turn an unregister-after-every-call into a spurious error. - self.registrationState = CallWaveRegistrationStateStopped; - return YES; - } - - return [self setRegistrationEnabled:NO context:@"Unregister" error:error]; + if (failure != nil && error != NULL) *error = failure; + return failure == nil; } - (BOOL)logoutWithError:(NSError **)error { - if (![self validateAccountWithError:error]) { - return NO; - } - + __block NSError *failure = nil; [self performSIPSync:^{ - ensurePJThreadRegistered("CallWaveLogout"); + if (![self validateAccountWithError:&failure]) return; pjsua_acc_set_registration(gAccountId, PJ_FALSE); deleteAccountLocked(); if (gAccountHeaderPool != NULL) { pj_pool_release(gAccountHeaderPool); gAccountHeaderPool = NULL; } + self.configuration = nil; + self.registrationState = CallWaveRegistrationStateStopped; + self.registrationError = nil; }]; - self.configuration = nil; - self.registrationState = CallWaveRegistrationStateStopped; - self.registrationError = nil; - return YES; + if (failure != nil && error != NULL) *error = failure; + return failure == nil; } + (void)destroyRuntimeOnQueue:(dispatch_queue_t)queue { @@ -1499,7 +1501,7 @@ + (void)destroyRuntimeOnQueue:(dispatch_queue_t)queue { gCreatedTransports = 0; }; - if (dispatch_get_specific(kCallWaveSIPQueueKey) != NULL) { + if (dispatch_get_specific(kCallWaveSIPQueueKey) == (__bridge void *)queue) { teardown(); } else { dispatch_sync(queue, teardown); @@ -1510,7 +1512,7 @@ - (void)stop { BOOL shouldStop = self.isRunning; if (!shouldStop) { @synchronized (CallWaveClient.class) { - shouldStop = gActiveClient == self; + shouldStop = gRuntimeOwnerToken == _runtimeOwnerToken; } } if (!shouldStop) { @@ -1522,7 +1524,7 @@ - (void)stop { [self performSIPSync:^{ BOOL ownsRuntime = NO; @synchronized (CallWaveClient.class) { - ownsRuntime = gActiveClient == self; + ownsRuntime = gRuntimeOwnerToken == _runtimeOwnerToken; } if (!self.isRunning && !ownsRuntime) { return; @@ -1551,8 +1553,9 @@ - (void)stop { self.registrationState = CallWaveRegistrationStateStopped; self.registrationError = nil; @synchronized (CallWaveClient.class) { - if (gActiveClient == self) { + if (gRuntimeOwnerToken == _runtimeOwnerToken) { gActiveClient = nil; + gRuntimeOwnerToken = nil; } } }]; @@ -2995,7 +2998,7 @@ - (void)handleCancelledIncomingCallWithUUID:(NSUUID *)uuid }]; } CWLogInfo(CallWaveLogCategoryPush, @"incoming call %@ was retracted by the server", - uuid.UUIDString); + CWRedact(uuid.UUIDString)); [self reportCallEndedWithUUID:uuid reason:reason]; [self publishCallState:CallWaveCallStateEnded forUUID:uuid]; if (call.callId == CallWaveSIPCallIdInvalid) { @@ -3305,7 +3308,7 @@ - (void)wakeRegistration { // Keep the validity check and the operation it guards in the same // serialized queue turn as start/stop. Otherwise stop() can destroy // PJSUA after the check but before registration is refreshed. - if (!gPJSUAStarted || gAccountId == PJSUA_INVALID_ID || !pjsua_acc_is_valid(gAccountId)) { + if (![self validateAccountWithError:NULL]) { NSError *error = nil; if (![self startWithError:&error] && error != nil) { CWLogError(CallWaveLogCategoryPush, @"start after VoIP push failed: %@", error); @@ -3392,19 +3395,40 @@ - (void)handleVoIPPushPayload:(NSDictionary *)payload acknowledge(@"deadline"); }); + CallWaveCall *existing = [self.registry callForUUID:descriptor.uuid]; + BOOL cancelled = descriptor.isCancellation || existing.isCancelledBeforeInvite; + if (self.managesCallKit && (cancelled || existing.reportedToCallKit)) { + // Even a duplicate or stale VoIP push must reach CallKit. Reusing + // the UUID lets CallKit reject a duplicate without a second call. + // Cancellation is applied only after submitting the required report. + CXCallUpdate *update = [[CXCallUpdate alloc] init]; + update.remoteHandle = [[CXHandle alloc] initWithType:CXHandleTypeGeneric + value:descriptor.caller.length > 0 ? descriptor.caller : self.defaultCallerName]; + CXProvider *provider = self.provider; + [provider reportNewIncomingCallWithUUID:descriptor.uuid update:update + completion:^(NSError *error) { + dispatchMain(^{ + if (cancelled) { + [provider reportCallWithUUID:descriptor.uuid endedAtDate:nil + reason:CXCallEndedReasonRemoteEnded]; + [self handleCancelledIncomingCallWithUUID:descriptor.uuid + reason:CXCallEndedReasonRemoteEnded completion:^(NSError *cancelError) { + acknowledge(@"cancelled call reported to CallKit"); + }]; + } else { + acknowledge(@"duplicate reported to CallKit"); + } + }); + }]; + return; + } if (descriptor.isCancellation) { - // A cancellation is not an incoming call: reporting it to CallKit - // would flash an incoming-call screen for a call that no longer - // exists. It ends or suppresses the call it names instead — - // including the tombstone case, where the cancellation overtook - // the announcement push. + // In host-owned mode the host owns CallKit reporting and PushKit + // completion; this only updates the SDK's cancellation state. [self handleCancelledIncomingCallWithUUID:descriptor.uuid reason:CXCallEndedReasonRemoteEnded completion:^(NSError *error) { - dispatchMain(^{ - acknowledge(error == nil ? @"cancellation handled" - : @"cancellation handling failed"); - }); + dispatchMain(^{ acknowledge(@"cancellation handled"); }); }]; return; } diff --git a/CallWaveKit/README.md b/CallWaveKit/README.md index 51523d8..79f70f6 100644 --- a/CallWaveKit/README.md +++ b/CallWaveKit/README.md @@ -309,7 +309,9 @@ application. If CallKit has not called back within `pushCompletionTimeout` calls.handleVoIPPushPayload(payload.dictionaryPayload, completion: completion) ``` -If the server sends a second push to retract the call, use the same UUID: +Deliver remote cancellation over the existing signalling connection or a +regular remote notification, rather than sending another VoIP push. Use the +same UUID: ```swift try await calls.handleCancelledIncomingCall(uuid: callUUID, reason: .remoteEnded) @@ -317,10 +319,18 @@ try await calls.handleCancelledIncomingCall(uuid: callUUID, reason: .remoteEnded The method dismisses the pending CallKit call and records the cancellation. A late INVITE with that UUID receives `603 Decline` instead of ringing again. +In managed CallKit mode, an actual VoIP payload marked as cancellation (or a +late announcement for a cancelled call) is still reported to CallKit and then +immediately ended. Duplicate announcements are reported with the same UUID so +CallKit can reject the duplicate without creating a second call. A transient +system UI may appear for a cancelled call; use the signalling cancellation API +to avoid it. In host-owned CallKit mode, the host remains responsible for +reporting every VoIP push and completing its PushKit handler. + `callWaveClientDidInvalidateVoIPPushToken(_:)` tells the host to remove the token from its backend. -That method parses `data.uuid` and `data.callerID`. For a different payload +`handleVoIPPushPayload` parses `data.uuid` and `data.callerID`. For a different payload shape, install a parser rather than reimplementing the reporting sequence: ```swift diff --git a/Tests/CallWaveKitRegistryTests/CallWaveProductionLifecycleTests.m b/Tests/CallWaveKitRegistryTests/CallWaveProductionLifecycleTests.m new file mode 100644 index 0000000..60af87a --- /dev/null +++ b/Tests/CallWaveKitRegistryTests/CallWaveProductionLifecycleTests.m @@ -0,0 +1,139 @@ +#import +#import "CallWaveClient.h" +#if __has_include() +#import +#else +#import +#endif + +@interface CallWaveClient (ProductionTests) +- (void)performSIPSync:(NS_NOESCAPE dispatch_block_t)block; +- (BOOL)claimRuntimeWithError:(NSError **)error; +- (void)setupCallKit; +- (void)setProvider:(CXProvider *)provider; +@end + +// Forces the interleaving that used to happen between validation and use. +@interface CallWaveStopBeforeOperationClient : CallWaveClient +@property BOOL stopBeforeNextOperation; +@end +@implementation CallWaveStopBeforeOperationClient +- (void)performSIPSync:(NS_NOESCAPE dispatch_block_t)block { + if (self.stopBeforeNextOperation) { + self.stopBeforeNextOperation = NO; + [self stop]; + } + [super performSIPSync:block]; +} +@end + +// A provider-shaped double avoids invoking the OS call UI in unit tests. +@interface CallWavePushReportProbe : NSObject +@property NSMutableArray *reports; +@property NSMutableArray *ended; +@property (copy) void (^pendingCompletion)(NSError *); +@end +@implementation CallWavePushReportProbe +- (instancetype)init { + if ((self = [super init])) { _reports = [NSMutableArray array]; _ended = [NSMutableArray array]; } + return self; +} +- (void)reportNewIncomingCallWithUUID:(NSUUID *)uuid update:(CXCallUpdate *)update + completion:(void (^)(NSError *))completion { + [self.reports addObject:uuid]; + self.pendingCompletion = completion; +} +- (void)reportCallWithUUID:(NSUUID *)uuid endedAtDate:(NSDate *)date reason:(CXCallEndedReason)reason { + [self.ended addObject:uuid]; +} +@end +@interface CallWaveManagedPushTestClient : CallWaveClient +@end +@implementation CallWaveManagedPushTestClient +- (void)setupCallKit { + if (self.provider == nil) [self setProvider:(CXProvider *)[CallWavePushReportProbe new]]; +} +@end + +@interface CallWaveProductionLifecycleTests : XCTestCase +@end +@implementation CallWaveProductionLifecycleTests +- (CallWaveEngineConfiguration *)engine { + CallWaveEngineConfiguration *engine = [CallWaveEngineConfiguration defaultConfiguration]; + engine.handlesNetworkChanges = NO; + return engine; +} +- (void)testDroppingRunningClientDestroysNativeRuntime { + __weak CallWaveClient *weakClient; + @autoreleasepool { + CallWaveClient *client = [[CallWaveClient alloc] initWithConfiguration:nil options:0 + provider:nil engineConfiguration:[self engine]]; + XCTAssertTrue([client startEngineWithError:NULL]); + XCTAssertEqual(pjsua_get_state(), PJSUA_STATE_RUNNING); + weakClient = client; + } + XCTAssertNil(weakClient); + XCTAssertEqual(pjsua_get_state(), PJSUA_STATE_NULL); +} +- (void)testDroppingInitializationOwnerReleasesClaim { + @autoreleasepool { + CallWaveClient *client = [[CallWaveClient alloc] initWithConfiguration:nil options:0 + provider:nil engineConfiguration:[self engine]]; + XCTAssertTrue([client claimRuntimeWithError:NULL]); + } + CallWaveClient *next = [[CallWaveClient alloc] initWithConfiguration:nil options:0 + provider:nil engineConfiguration:[self engine]]; + XCTAssertTrue([next startEngineWithError:NULL]); + [next stop]; +} +- (void)testRegistrationOperationsRejectStopBetweenEntryAndQueueTurn { + for (NSString *operation in @[@"refresh", @"unregister", @"logout", @"registered", @"update"]) { + CallWaveConfiguration *config = [[CallWaveConfiguration alloc] initWithHost:@"127.0.0.1" + port:5099 transport:CallWaveTransportUDP username:@"test" password:@"test" + includesCallsInRecents:NO]; + CallWaveStopBeforeOperationClient *client = [[CallWaveStopBeforeOperationClient alloc] + initWithConfiguration:config options:0 provider:nil engineConfiguration:[self engine]]; + XCTAssertTrue([client startWithError:NULL]); + client.stopBeforeNextOperation = YES; + NSError *error = nil; + BOOL result; + if ([operation isEqual:@"refresh"]) result = [client refreshRegistrationWithError:&error]; + else if ([operation isEqual:@"unregister"]) result = [client unregisterWithError:&error]; + else if ([operation isEqual:@"logout"]) result = [client logoutWithError:&error]; + else if ([operation isEqual:@"update"]) result = [client updateConfiguration:config error:&error]; + else result = client.isRegistered; + XCTAssertFalse(result, @"%@", operation); + if (![operation isEqual:@"registered"]) XCTAssertEqual(error.code, CallWaveErrorEngineNotRunning); + XCTAssertFalse(client.isRunning); + XCTAssertEqual(client.registrationState, CallWaveRegistrationStateStopped); + [client stop]; + } +} +- (void)testManagedCancellationAndLateAnnouncementReportBeforeCompletion { + CallWaveManagedPushTestClient *client = [[CallWaveManagedPushTestClient alloc] + initWithConfiguration:nil options:CallWaveIntegrationOptionManagesCallKit + provider:nil engineConfiguration:[self engine]]; + CallWavePushReportProbe *probe = (id)client.provider; + NSUUID *uuid = [NSUUID UUID]; + for (NSString *type in @[@"cancel", @"incoming"]) { + __block BOOL completed = NO; + XCTestExpectation *reported = [self expectationWithDescription:@"report submitted"]; + [client handleVoIPPushPayload:@{@"uuid": uuid.UUIDString, @"type": type} + completion:^{ completed = YES; }]; + dispatch_async(dispatch_get_main_queue(), ^{ [reported fulfill]; }); + [self waitForExpectations:@[reported] timeout:2]; + XCTAssertEqualObjects(probe.reports.lastObject, uuid); + XCTAssertFalse(completed); + void (^callback)(NSError *) = probe.pendingCompletion; + probe.pendingCompletion = nil; + XCTAssertNotNil(callback); + if (callback) callback(nil); + XCTestExpectation *drained = [self expectationWithDescription:@"completion"]; + dispatch_async(dispatch_get_main_queue(), ^{ [drained fulfill]; }); + [self waitForExpectations:@[drained] timeout:2]; + XCTAssertTrue(completed); + XCTAssertTrue([probe.ended containsObject:uuid]); + } + XCTAssertEqual(probe.reports.count, 2u); +} +@end