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
46 changes: 46 additions & 0 deletions CallWaveKit/CallWaveClient.m
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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];
Expand All @@ -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) {
Expand Down
11 changes: 11 additions & 0 deletions CallWaveKit/CallWaveIncomingCallDescriptor.m
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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];
Expand Down
11 changes: 10 additions & 1 deletion CallWaveKit/include/CallWaveClient.h
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;

Expand Down
12 changes: 11 additions & 1 deletion CallWaveKit/include/CallWaveIncomingCallDescriptor.h
Original file line number Diff line number Diff line change
Expand Up @@ -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

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

#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
131 changes: 131 additions & 0 deletions Tests/CallWaveKitTests/CallWavePushCancellationTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading