diff --git a/CallWaveKit/CallWaveClient.m b/CallWaveKit/CallWaveClient.m index 8e4a39f..dca0651 100644 --- a/CallWaveKit/CallWaveClient.m +++ b/CallWaveKit/CallWaveClient.m @@ -2198,6 +2198,15 @@ - (void)prepareIncomingCallWithUUID:(NSUUID *)uuid caller:(NSString *)caller { if (call.isCancelledBeforeInvite) { return; } + if (call.reportedToCallKit && call.state == CallWaveCallStateIncoming) { + // A duplicate push for a call that is already on the CallKit + // screen: reporting it again would re-publish `incoming`, restart + // the ring timeout and, in managed mode, risk a second CallKit + // call under the same UUID. + CWLogInfo(CallWaveLogCategoryPush, + @"duplicate push for call %@; already reported", uuid.UUIDString); + return; + } call.caller = resolved; call.displayName = [self displayNameForCaller:resolved]; call.reportedToCallKit = YES; @@ -2689,10 +2698,30 @@ - (CallWaveIncomingCallDescriptor *)descriptorForPushPayload:(NSDictionary *)pay NSString *uuidString = data[@"uuid"] ?: payload[@"uuid"]; NSUUID *uuid = uuidString.length > 0 ? [[NSUUID alloc] initWithUUIDString:uuidString] : nil; NSString *caller = data[@"callerID"] ?: data[@"caller"] ?: payload[@"caller_id"]; + if ([self payloadAnnouncesCancellation:payload data:data]) { + // The caller hung up before anyone answered; `caller` is irrelevant + // because nothing is ever shown for a cancellation. + return [CallWaveIncomingCallDescriptor cancellationDescriptorWithUUID:uuid ?: [NSUUID UUID]]; + } return [CallWaveIncomingCallDescriptor descriptorWithUUID:uuid ?: [NSUUID UUID] caller:caller]; } +/// The built-in cancellation marker: `type` (or `event`) equal to `cancel`, +/// `cancelled` or `cancellation`, read from `data` first and the top level +/// second. Hosts with a different marker shape set `pushPayloadParser` and +/// return a descriptor whose `cancellation` flag is set. +- (BOOL)payloadAnnouncesCancellation:(NSDictionary *)payload data:(nullable NSDictionary *)data { + NSString *type = data[@"type"] ?: data[@"event"] ?: payload[@"type"] ?: payload[@"event"]; + if (![type isKindOfClass:NSString.class]) { + return NO; + } + NSString *normalized = type.lowercaseString; + return [normalized isEqualToString:@"cancel"] + || [normalized isEqualToString:@"cancelled"] + || [normalized isEqualToString:@"cancellation"]; +} + - (void)handleVoIPPushPayload:(NSDictionary *)payload completion:(void (^)(void))completion { CallWaveIncomingCallDescriptor *descriptor = [self descriptorForPushPayload:payload]; @@ -2717,6 +2746,23 @@ - (void)handleVoIPPushPayload:(NSDictionary *)payload acknowledge(@"deadline"); }); + 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. + [self handleCancelledIncomingCallWithUUID:descriptor.uuid + reason:CXCallEndedReasonRemoteEnded + completion:^(NSError *error) { + dispatchMain(^{ + acknowledge(error == nil ? @"cancellation handled" + : @"cancellation handling failed"); + }); + }]; + return; + } + [self reportIncomingCallWithUUID:descriptor.uuid caller:descriptor.caller completion:^(NSError *error) { diff --git a/CallWaveKit/CallWaveIncomingCallDescriptor.m b/CallWaveKit/CallWaveIncomingCallDescriptor.m index 4ee33df..9a71d24 100644 --- a/CallWaveKit/CallWaveIncomingCallDescriptor.m +++ b/CallWaveKit/CallWaveIncomingCallDescriptor.m @@ -3,10 +3,17 @@ @implementation CallWaveIncomingCallDescriptor - (instancetype)initWithUUID:(NSUUID *)uuid caller:(NSString *)caller { + return [self initWithUUID:uuid caller:caller cancellation:NO]; +} + +- (instancetype)initWithUUID:(NSUUID *)uuid + caller:(NSString *)caller + cancellation:(BOOL)cancellation { self = [super init]; if (self) { _uuid = uuid; _caller = [caller copy]; + _cancellation = cancellation; } return self; } @@ -15,6 +22,10 @@ + (instancetype)descriptorWithUUID:(NSUUID *)uuid caller:(NSString *)caller { return [[self alloc] initWithUUID:uuid caller:caller]; } ++ (instancetype)cancellationDescriptorWithUUID:(NSUUID *)uuid { + return [[self alloc] initWithUUID:uuid caller:nil cancellation:YES]; +} + - (NSString *)description { return [NSString stringWithFormat:@"<%@: %@>", NSStringFromClass(self.class), self.uuid.UUIDString]; diff --git a/CallWaveKit/include/CallWaveClient.h b/CallWaveKit/include/CallWaveClient.h index 208cef5..be1830e 100644 --- a/CallWaveKit/include/CallWaveClient.h +++ b/CallWaveKit/include/CallWaveClient.h @@ -134,7 +134,9 @@ didChangeRegistrationState:(CallWaveRegistrationState)state @property (nonatomic, assign) CallWaveDTMFMethod dtmfMethod; /// Replaces the built-in parsing of `data.uuid` and `data.callerID` in -/// `-handleVoIPPushPayload:completion:`. +/// `-handleVoIPPushPayload:completion:`. To announce a remote hangup rather +/// than a new call, return a descriptor whose `cancellation` flag is set — +/// `+[CallWaveIncomingCallDescriptor cancellationDescriptorWithUUID:]`. @property (nonatomic, copy, nullable) CallWavePushPayloadParser pushPayloadParser; - (instancetype)init NS_UNAVAILABLE; @@ -334,6 +336,13 @@ forCallWithUUID:(nullable NSUUID *)uuid /// `completion` once CallKit has accepted the report — or after /// `pushCompletionTimeout`, whichever comes first. Failing to run the handler /// is what produces the `0xBAADCA11` termination and the VoIP push ban. +/// +/// A payload whose descriptor `isCancellation` (built-in markers: `type` or +/// `event` equal to `cancel`/`cancelled`/`cancellation` under `data` or at +/// the top level) is never reported to CallKit as an incoming call. It is +/// routed to `-handleCancelledIncomingCallWithUUID:reason:completion:` with +/// `CXCallEndedReasonRemoteEnded` instead, so the incoming-call screen comes +/// down and a late INVITE is answered `603`. - (void)handleVoIPPushPayload:(NSDictionary *)payload completion:(nullable void (^)(void))completion; diff --git a/CallWaveKit/include/CallWaveIncomingCallDescriptor.h b/CallWaveKit/include/CallWaveIncomingCallDescriptor.h index 5eb491a..257cd7e 100644 --- a/CallWaveKit/include/CallWaveIncomingCallDescriptor.h +++ b/CallWaveKit/include/CallWaveIncomingCallDescriptor.h @@ -15,11 +15,21 @@ NS_SWIFT_SENDABLE @property (nonatomic, strong, readonly) NSUUID *uuid; /// `nil` falls back to `CallWaveClient.defaultCallerName`. @property (nonatomic, copy, readonly, nullable) NSString *caller; +/// `YES` when the payload announces that the caller hung up before anyone +/// answered, not a new incoming call. A cancellation is never reported to +/// CallKit as an incoming call; it ends or suppresses the call it names. +@property (nonatomic, assign, readonly, getter=isCancellation) BOOL cancellation; - (instancetype)init NS_UNAVAILABLE; - (instancetype)initWithUUID:(NSUUID *)uuid - caller:(nullable NSString *)caller NS_DESIGNATED_INITIALIZER; + caller:(nullable NSString *)caller; +- (instancetype)initWithUUID:(NSUUID *)uuid + caller:(nullable NSString *)caller + cancellation:(BOOL)cancellation NS_DESIGNATED_INITIALIZER; + (instancetype)descriptorWithUUID:(NSUUID *)uuid caller:(nullable NSString *)caller; +/// The caller hung up before an answer: the incoming call screen for `uuid` +/// must come down, and the INVITE that may still arrive must be refused. ++ (instancetype)cancellationDescriptorWithUUID:(NSUUID *)uuid; @end diff --git a/Tests/CallWaveKitRegistryTests/CallWavePushCancelInviteChainTests.m b/Tests/CallWaveKitRegistryTests/CallWavePushCancelInviteChainTests.m new file mode 100644 index 0000000..529cdba --- /dev/null +++ b/Tests/CallWaveKitRegistryTests/CallWavePushCancelInviteChainTests.m @@ -0,0 +1,117 @@ +#import + +#import "CallWaveClient.h" +#import "CallWaveCallRegistry.h" + +// `takeCallCancelledBeforeInvite` is the exact decision point the PJSIP +// `on_incoming_call` callback consults before answering a late INVITE with +// `603 Decline`. It is private, so the chain tests drive it through a +// test-only category; the PJSIP side of the chain — a static C callback that +// needs a running stack — stays on the device/integration checklist. +@interface CallWaveClient (PushCancelInviteChainTests) +@property (nonatomic, strong) CallWaveCallRegistry *registry; +- (nullable CallWaveCall *)takeCallCancelledBeforeInvite; +@end + +@interface CallWavePushCancelInviteChainTests : XCTestCase +@property (nonatomic, strong) CallWaveClient *client; +@end + +@implementation CallWavePushCancelInviteChainTests + +- (void)setUp { + [super setUp]; + // Host-owned CallKit: no CXProvider and no PKPushRegistry in a test + // process, while the push/parsing/registry chain under test is identical. + self.client = [[CallWaveClient alloc] initWithConfiguration:nil + options:CallWaveIntegrationOptionNone + provider:nil + engineConfiguration:nil]; +} + +- (void)tearDown { + self.client = nil; + [super tearDown]; +} + +/// Pushes a payload through the managed-PushKit entry point and waits for its +/// completion, like PushKit would. +- (void)push:(NSDictionary *)payload { + XCTestExpectation *acknowledged = [self expectationWithDescription:@"push acknowledged"]; + [self.client handleVoIPPushPayload:payload completion:^{ + [acknowledged fulfill]; + }]; + [self waitForExpectations:@[acknowledged] timeout:2]; +} + +- (NSDictionary *)announcementForUUID:(NSUUID *)uuid { + return @{@"data": @{@"uuid": uuid.UUIDString, @"callerID": @"101"}}; +} + +- (NSDictionary *)cancellationForUUID:(NSUUID *)uuid { + return @{@"data": @{@"uuid": uuid.UUIDString, @"type": @"cancel"}}; +} + +- (void)testPushThenCancelThenLateInviteIsRefused { + NSUUID *uuid = [NSUUID UUID]; + [self push:[self announcementForUUID:uuid]]; + XCTAssertEqual(self.client.callState, CallWaveCallStateIncoming); + + [self push:[self cancellationForUUID:uuid]]; + XCTAssertEqual(self.client.callState, CallWaveCallStateEnded); + + // The late INVITE lands: `on_incoming_call` takes the cancellation and + // answers 603 instead of ringing. + CallWaveCall *cancelled = [self.client takeCallCancelledBeforeInvite]; + XCTAssertEqualObjects(cancelled.uuid, uuid); + + // The cancellation is consumed: a further, unrelated INVITE must not be + // refused by the same record. + XCTAssertNil([self.client takeCallCancelledBeforeInvite]); +} + +- (void)testCancelThatOvertakesTheAnnouncementStillRefusesTheInvite { + NSUUID *uuid = [NSUUID UUID]; + [self push:[self cancellationForUUID:uuid]]; + + // The announcement push that lost the race must not ring. + [self push:[self announcementForUUID:uuid]]; + XCTAssertEqual(self.client.callState, CallWaveCallStateEnded); + + // …and its INVITE must still be refused. + CallWaveCall *cancelled = [self.client takeCallCancelledBeforeInvite]; + XCTAssertEqualObjects(cancelled.uuid, uuid); +} + +- (void)testPendingInviteTakesPrecedenceOverAPendingCancellation { + NSUUID *first = [NSUUID UUID]; + NSUUID *second = [NSUUID UUID]; + [self push:[self announcementForUUID:first]]; + [self push:[self announcementForUUID:second]]; + [self push:[self cancellationForUUID:second]]; + + // `first` is still legitimately awaiting its INVITE, so an INVITE arriving + // now must be matched to it — not consumed by `second`'s cancellation. + XCTAssertNil([self.client takeCallCancelledBeforeInvite]); + + // `first`'s INVITE arrives and is bound by `handleIncomingSIPCall:caller:`; + // simulate that binding. + [self.client.registry bindCallId:7 toUUID:first]; + + // The next INVITE belongs to the cancelled call and is refused. + CallWaveCall *cancelled = [self.client takeCallCancelledBeforeInvite]; + XCTAssertEqualObjects(cancelled.uuid, second); +} + +- (void)testCancellationExpiresBeforeTheInvite { + NSUUID *uuid = [NSUUID UUID]; + [self push:[self announcementForUUID:uuid]]; + [self push:[self cancellationForUUID:uuid]]; + + // A cancellation older than the window is dead weight: it is dropped and + // cannot refuse an INVITE that arrives too late to belong to it. + XCTAssertNil([self.client.registry takeCallCancelledBeforeInviteWithin:0]); + XCTAssertNil([self.client.registry takeCallCancelledBeforeInviteWithin:60]); +} + +@end diff --git a/Tests/CallWaveKitTests/CallWavePushCancellationTests.swift b/Tests/CallWaveKitTests/CallWavePushCancellationTests.swift new file mode 100644 index 0000000..a8ad368 --- /dev/null +++ b/Tests/CallWaveKitTests/CallWavePushCancellationTests.swift @@ -0,0 +1,131 @@ +import XCTest + +@testable import CallWaveKit + +/// Managed-PushKit payload handling: incoming pushes, duplicate pushes and +/// remote cancellations, all in host-owned CallKit mode so the test process +/// never creates a `CXProvider` or a `PKPushRegistry`. +final class CallWavePushCancellationTests: XCTestCase { + + private var client: CallWaveClient! + private var events: [CallWaveEvent] = [] + private var observer: (NSCopying & NSObjectProtocol)? + + override func setUp() { + super.setUp() + events = [] + client = CallWaveClient( + configuration: nil, + options: [], + provider: nil, + engineConfiguration: nil + ) + observer = client.addEventObserver { [weak self] event in + self?.events.append(event) + } + } + + override func tearDown() { + if let observer { + client.removeEventObserver(observer) + } + client = nil + super.tearDown() + } + + private func push(_ payload: [String: Any], file: StaticString = #filePath, line: UInt = #line) { + let acknowledged = expectation(description: "push acknowledged") + client.handleVoIPPushPayload(payload) { + acknowledged.fulfill() + } + wait(for: [acknowledged], timeout: 2) + } + + private func incomingPayload(uuid: UUID) -> [String: Any] { + ["data": ["uuid": uuid.uuidString, "callerID": "101"]] + } + + private func states() -> [CallWaveCallState] { + events.compactMap { $0.type == .callStateChanged ? $0.callState : nil } + } + + func testCancellationPushAfterReportedCallEndsItBeforeInvite() { + let uuid = UUID() + push(incomingPayload(uuid: uuid)) + XCTAssertEqual(client.callState, .incoming) + + push(["data": ["uuid": uuid.uuidString, "type": "cancel"]]) + + XCTAssertEqual(client.callState, .ended) + XCTAssertEqual(states(), [.incoming, .ended]) + let ended = events.first { $0.type == .callEnded } + XCTAssertEqual(ended?.callUUID, uuid) + XCTAssertEqual(ended?.endedReason, .remoteEnded) + } + + func testCancellationMarkersAreRecognisedCaseInsensitively() { + for marker in ["cancel", "cancelled", "cancellation", "CANCEL"] { + let uuid = UUID() + push(incomingPayload(uuid: uuid)) + push(["data": ["uuid": uuid.uuidString, "event": marker]]) + XCTAssertEqual(client.callState, .ended, "marker \(marker) was not recognised") + } + } + + func testCancellationPushForUnknownCallLeavesATombstone() { + let uuid = UUID() + push(["data": ["uuid": uuid.uuidString, "type": "cancel"]]) + + // The cancellation overtook the announcement push: a tombstone is kept + // so neither that push nor a late INVITE can ring. + XCTAssertEqual(client.callState, .ended) + let ended = events.first { $0.type == .callEnded } + XCTAssertEqual(ended?.callUUID, uuid) + XCTAssertEqual(ended?.endedReason, .remoteEnded) + + events.removeAll() + push(incomingPayload(uuid: uuid)) + XCTAssertFalse(states().contains(.incoming), + "the announcement push that lost the race must not ring") + } + + func testCancellationPushIsNeverReportedAsANewIncomingCall() { + // A lone cancellation must not flash an incoming-call screen — this is + // the regression the managed-PushKit mode had: every payload was + // treated as a new call. + push(["data": ["uuid": UUID().uuidString, "type": "cancel"]]) + + XCTAssertFalse(states().contains(.incoming)) + } + + func testDuplicateIncomingPushReportsTheCallOnlyOnce() { + let uuid = UUID() + push(incomingPayload(uuid: uuid)) + push(incomingPayload(uuid: uuid)) + + XCTAssertEqual(client.callState, .incoming) + XCTAssertEqual(states().filter { $0 == .incoming }.count, 1) + } + + func testCustomParserCanMarkAPayloadAsCancellation() { + let uuid = UUID() + client.pushPayloadParser = { payload in + guard let id = (payload["call_id"] as? String).flatMap(UUID.init(uuidString:)) else { + return nil + } + if payload["hangup"] as? Bool == true { + return CallWaveIncomingCallDescriptor.cancellationDescriptor(with: id) + } + return CallWaveIncomingCallDescriptor(uuid: id, caller: nil) + } + + push(["call_id": uuid.uuidString]) + XCTAssertEqual(client.callState, .incoming) + + push(["call_id": uuid.uuidString, "hangup": true]) + XCTAssertEqual(client.callState, .ended) + let ended = events.first { $0.type == .callEnded } + XCTAssertEqual(ended?.callUUID, uuid) + XCTAssertEqual(ended?.endedReason, .remoteEnded) + } +}