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
5 changes: 3 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ jobs:
- uses: actions/checkout@v4
# Builds the pod into a synthetic application, which is what keeps the
# CocoaPods integration honest now that the repository has no demo app.
# The bundled PJSIP binary is built for iOS 16 while the pod declares 15,
# which is a documented linker warning rather than a defect.
# --allow-warnings is a safety net, not a workaround: the lint passes
# clean since 0.3.1 realigned the PJSIP binary's minos with the declared
# platform. Drop the flag to make a new warning fail the build.
- name: pod lib lint
run: pod lib lint CallWaveKit.podspec --allow-warnings --skip-tests --platforms=ios
40 changes: 40 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,46 @@ All notable changes to CallWaveKit are recorded here. The project follows
[Semantic Versioning](https://semver.org/spec/v2.0.0.html); until 1.0 a minor
bump may contain breaking changes, and each one is listed below.

## [0.3.1] — 2026-08-02

### Fixed

- **Rejecting a call before its INVITE arrives now stops the caller ringing.**
A VoIP push routinely beats the INVITE by a second or more, and the reject
path — unlike the answer path, which polls for the call until `answerTimeout`
— sent nothing to SIP when there was no call id yet. It deleted the pending
record and returned `CallWaveErrorNoActiveCall`, so no `603`, `486` or `480`
ever left the device. The INVITE then arrived to an empty registry, was
answered `180 Ringing`, and became a *second* incoming call under a fresh
UUID: the intercom kept ringing until `incomingCallTimeout`, and the host saw
`.incoming` again after `.ended`.

`-endCallWithUUID:completion:` and `-declineCallWithUUID:completion:` now
keep the record and mark it cancelled instead of deleting it, report
`CallWaveCallStateEnded` and complete without an error — the user's intent
succeeded, so it is not a failure. `on_incoming_call` checks for such a
cancellation before it rings, and answers `603 Decline` instead. Being the
callee, it answers the INVITE; it does not send CANCEL.

A cancellation expires after `answerTimeout`, so one whose INVITE never
arrived cannot reject an unrelated later call, and a call still legitimately
waiting for its INVITE always takes precedence over a pending cancellation.
Nothing is reported to CallKit from this path: in host-owned mode the
application owns the provider and ends the call from the state stream.

- **The bundled PJSIP binary is built for iOS 15.0 again.** It carried
`minos 16.0` while the package declares iOS 15.0, so every application with a
15.x deployment target linked it with a `built for newer 'iOS' version`
warning per object file — 199 of them in one real consumer. The build script
had already been lowered to 15.0; the XCFramework simply had not been rebuilt
since. Every slice now reports `minos 15.0`: `ios-arm64` (arm64) and
`ios-arm64_x86_64-simulator` (arm64 and x86_64).

The rebuild is PJSIP 2.17 with the same options as before. The exported
symbols are unchanged — 2241 before and after, with no additions or
removals — the headers are untouched, and the XCFramework's `Info.plist` is
byte-identical. The public API of CallWaveKit did not change.

## [0.3.0] — 2026-08-02

0.2.0 was staged during this work but never tagged or published, so it does
Expand Down
2 changes: 1 addition & 1 deletion CallWaveKit.podspec
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
Pod::Spec.new do |spec|
spec.name = 'CallWaveKit'
spec.version = '0.3.0'
spec.version = '0.3.1'
spec.summary = 'Instance-owned incoming SIP calling for iOS with CallKit.'
spec.description = <<-DESC
CallWaveKit owns a PJSUA runtime, SIP registration, incoming audio calls,
Expand Down
28 changes: 27 additions & 1 deletion CallWaveKit/CallWaveCallRegistry.h
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@ FOUNDATION_EXPORT const CallWaveSIPCallId CallWaveSIPCallIdInvalid;
@property (nonatomic, assign) BOOL microphoneMuted;
@property (nonatomic, strong, readonly) NSDate *createdAt;

/// Set when the user rejected the call before its INVITE arrived. The record is
/// kept rather than removed, so the INVITE that follows can be answered with a
/// final rejection instead of being rung as a fresh call.
@property (nonatomic, assign, readonly, getter=isCancelledBeforeInvite) BOOL cancelledBeforeInvite;
/// When the cancellation was recorded, for expiring it.
@property (nonatomic, strong, readonly, nullable) NSDate *cancelledAt;

- (instancetype)init NS_UNAVAILABLE;
- (instancetype)initWithUUID:(NSUUID *)uuid NS_DESIGNATED_INITIALIZER;

Expand All @@ -46,7 +53,8 @@ FOUNDATION_EXPORT const CallWaveSIPCallId CallWaveSIPCallIdInvalid;
- (nullable CallWaveCall *)callForUUID:(nullable NSUUID *)uuid;
- (nullable CallWaveCall *)callForCallId:(CallWaveSIPCallId)callId;
/// The oldest call that was announced by a push but whose INVITE has not
/// arrived yet, so a fresh INVITE can be matched to it.
/// arrived yet, so a fresh INVITE can be matched to it. A call the user already
/// cancelled is never returned here — it is not waiting to be answered.
- (nullable CallWaveCall *)callAwaitingInvite;
/// The call `currentCallUUID` should point at: the most recently created call
/// that has not ended.
Expand All @@ -61,6 +69,24 @@ FOUNDATION_EXPORT const CallWaveSIPCallId CallWaveSIPCallIdInvalid;
- (void)removeCallWithCallId:(CallWaveSIPCallId)callId;
- (NSArray<CallWaveCall *> *)removeAllCalls;

/// Records that the user rejected `uuid` before its INVITE arrived, keeping the
/// record so the late INVITE can still be refused.
///
/// Returns NO — and changes nothing — when there is no such call, or when the
/// call already has a SIP call id, because then there is a real INVITE to
/// reject through PJSUA and no reason to remember anything.
- (BOOL)markCallCancelledBeforeInvite:(nullable NSUUID *)uuid;

/// Removes and returns the call whose late INVITE should be refused, or `nil`
/// when there is none.
///
/// A cancellation older than `window` is dropped and not returned, so a
/// cancelled call whose INVITE never arrived cannot reject an unrelated later
/// one. A call that is still legitimately awaiting its INVITE takes precedence:
/// while one exists this returns `nil`, so the INVITE is matched to it rather
/// than consumed by a cancellation that may belong to a different call.
- (nullable CallWaveCall *)takeCallCancelledBeforeInviteWithin:(NSTimeInterval)window;

/// Runs `block` with the lock held, for a read-modify-write that has to be
/// atomic. Do not call back into the registry from inside it.
- (void)performLocked:(NS_NOESCAPE dispatch_block_t)block;
Expand Down
74 changes: 73 additions & 1 deletion CallWaveKit/CallWaveCallRegistry.m
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@

const CallWaveSIPCallId CallWaveSIPCallIdInvalid = -1;

/// Only the registry may record a cancellation, and only under its lock.
@interface CallWaveCall ()
@property (nonatomic, assign, readwrite, getter=isCancelledBeforeInvite) BOOL cancelledBeforeInvite;
@property (nonatomic, strong, readwrite, nullable) NSDate *cancelledAt;
@end

@implementation CallWaveCall

- (instancetype)initWithUUID:(NSUUID *)uuid {
Expand All @@ -15,6 +21,8 @@ - (instancetype)initWithUUID:(NSUUID *)uuid {
_displayName = @"";
_state = CallWaveCallStateIncoming;
_createdAt = [NSDate date];
_cancelledBeforeInvite = NO;
_cancelledAt = nil;
}
return self;
}
Expand Down Expand Up @@ -76,7 +84,9 @@ - (CallWaveCall *)callAwaitingInvite {
os_unfair_lock_lock(&_lock);
CallWaveCall *oldest = nil;
for (CallWaveCall *call in _callsByUUID.objectEnumerator) {
if (call.callId != CallWaveSIPCallIdInvalid || call.state == CallWaveCallStateEnded) {
if (call.callId != CallWaveSIPCallIdInvalid ||
call.state == CallWaveCallStateEnded ||
call.isCancelledBeforeInvite) {
continue;
}
if (oldest == nil || [call.createdAt compare:oldest.createdAt] == NSOrderedAscending) {
Expand Down Expand Up @@ -172,6 +182,68 @@ - (void)removeCallWithCallId:(CallWaveSIPCallId)callId {
os_unfair_lock_unlock(&_lock);
}

- (BOOL)markCallCancelledBeforeInvite:(NSUUID *)uuid {
if (uuid == nil) {
return NO;
}
os_unfair_lock_lock(&_lock);
CallWaveCall *call = _callsByUUID[uuid];
// A call that already has a SIP id has a real INVITE to answer, so there is
// nothing to defer; and re-marking must not extend an existing deadline.
BOOL marked = call != nil &&
call.callId == CallWaveSIPCallIdInvalid &&
!call.isCancelledBeforeInvite;
if (marked) {
call.cancelledBeforeInvite = YES;
call.cancelledAt = [NSDate date];
}
os_unfair_lock_unlock(&_lock);
return marked;
}

- (CallWaveCall *)takeCallCancelledBeforeInviteWithin:(NSTimeInterval)window {
os_unfair_lock_lock(&_lock);

NSDate *now = [NSDate date];
CallWaveCall *oldestCancelled = nil;
BOOL someoneIsStillWaiting = NO;
NSMutableArray<CallWaveCall *> *expired = [NSMutableArray array];

for (CallWaveCall *call in _callsByUUID.objectEnumerator) {
if (call.callId != CallWaveSIPCallIdInvalid) {
continue;
}
if (!call.isCancelledBeforeInvite) {
if (call.state != CallWaveCallStateEnded) {
someoneIsStillWaiting = YES;
}
continue;
}
if (call.cancelledAt == nil ||
[now timeIntervalSinceDate:call.cancelledAt] > MAX(window, 0)) {
[expired addObject:call];
continue;
}
if (oldestCancelled == nil ||
[call.cancelledAt compare:oldestCancelled.cancelledAt] == NSOrderedAscending) {
oldestCancelled = call;
}
}

// A cancellation whose INVITE never came is dead weight; drop it here so it
// cannot reject an unrelated call later.
for (CallWaveCall *call in expired) {
[_callsByUUID removeObjectForKey:call.uuid];
}

CallWaveCall *result = someoneIsStillWaiting ? nil : oldestCancelled;
if (result != nil) {
[_callsByUUID removeObjectForKey:result.uuid];
}
os_unfair_lock_unlock(&_lock);
return result;
}

- (NSArray<CallWaveCall *> *)removeAllCalls {
os_unfair_lock_lock(&_lock);
NSArray<CallWaveCall *> *calls = _callsByUUID.allValues;
Expand Down
55 changes: 50 additions & 5 deletions CallWaveKit/CallWaveClient.m
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,7 @@ @interface CallWaveClient () <CXProviderDelegate, PKPushRegistryDelegate>
- (BOOL)managesCallKit;
- (NSString *)displayNameForCaller:(nullable NSString *)caller;
- (BOOL)canAcceptAnotherIncomingCall;
- (nullable CallWaveCall *)takeCallCancelledBeforeInvite;
- (void)handleIncomingSIPCall:(pjsua_call_id)callId caller:(NSString *)caller;
- (void)handleSIPCallConfirmed:(pjsua_call_id)callId;
- (void)handleSIPCallDisconnected:(pjsua_call_id)callId
Expand Down Expand Up @@ -1064,12 +1065,20 @@ - (void)clearCallWithUUID:(nullable NSUUID *)uuid {
return;
}
[self.registry removeCallWithUUID:uuid];
if ([uuid isEqual:self.currentCallUUID]) {
CallWaveCall *next = self.registry.mostRecentCall;
self.currentCallUUID = next.uuid;
self.currentCaller = next.displayName;
self.microphoneMuted = next != nil ? next.microphoneMuted : NO;
[self detachCurrentCallIfItIs:uuid];
}

/// Moves `currentCallUUID` off `uuid` without touching the registry, for a call
/// whose record has to outlive the user's decision — a cancellation waiting for
/// its late INVITE. Must run on the main queue.
- (void)detachCurrentCallIfItIs:(nullable NSUUID *)uuid {
if (uuid == nil || ![uuid isEqual:self.currentCallUUID]) {
return;
}
CallWaveCall *next = self.registry.mostRecentCall;
self.currentCallUUID = next.uuid;
self.currentCaller = next.displayName;
self.microphoneMuted = next != nil ? next.microphoneMuted : NO;
}

#pragma mark - Incoming-only calling
Expand Down Expand Up @@ -1362,6 +1371,19 @@ - (void)terminateCallWithUUID:(NSUUID *)uuid
NSUUID *target = call.uuid ?: uuid;
pjsua_call_id callId = call != nil ? call.callId : CallWaveSIPCallIdInvalid;
if (callId == CallWaveSIPCallIdInvalid) {
// A call the push announced but whose INVITE has not arrived yet.
// There is nothing to reject through PJSUA, so the rejection is
// remembered instead and applied to the INVITE when it lands. The
// user's intent succeeded; this is not an error.
if ([self.registry markCallCancelledBeforeInvite:target]) {
CWLogInfo(CallWaveLogCategoryCall,
@"call %@ %@ before its INVITE arrived; the INVITE will be refused",
target.UUIDString, declining ? @"declined" : @"ended");
[self publishCallState:CallWaveCallStateEnded forUUID:target];
[self detachCurrentCallIfItIs:target];
[self complete:completion error:nil];
return;
}
[self clearCallWithUUID:target];
[self complete:completion
error:CallWaveMakeError(CallWaveErrorNoActiveCall,
Expand Down Expand Up @@ -1865,6 +1887,16 @@ - (void)refreshProviderConfiguration {
self.provider.configuration = [self makeProviderConfiguration];
}

/// Called from a PJSIP callback thread, like `-canAcceptAnotherIncomingCall`:
/// the registry has its own lock, and a `180`/`603` cannot afford a queue hop.
///
/// The cancellation is honoured for `answerTimeout` — the same budget the
/// client gives an INVITE to arrive — so it cannot outlive the call it belongs
/// to and reject a later, unrelated one.
- (CallWaveCall *)takeCallCancelledBeforeInvite {
return [self.registry takeCallCancelledBeforeInviteWithin:self.answerTimeout];
}

- (BOOL)canAcceptAnotherIncomingCall {
if ([self.registry callAwaitingInvite] != nil) {
return YES;
Expand Down Expand Up @@ -2431,6 +2463,19 @@ static void onIncomingCall(pjsua_acc_id accId, pjsua_call_id callId, pjsip_rx_da
return;
}

// The user may have rejected this call from the CallKit screen before its
// INVITE arrived — the push routinely beats the INVITE by a second or more.
// The peer is still waiting for a final response, so answer one now instead
// of ringing: `603` here, never CANCEL, because this side is the callee.
CallWaveCall *cancelled = [client takeCallCancelledBeforeInvite];
if (cancelled != nil) {
pjsua_call_answer(callId, PJSIP_SC_DECLINE, NULL, NULL);
CWLogInfo(CallWaveLogCategoryCall,
@"INVITE for call %@ arrived after the user rejected it; answered 603",
cancelled.uuid.UUIDString);
return;
}

if (![client canAcceptAnotherIncomingCall]) {
pjsua_call_answer(callId, PJSIP_SC_BUSY_HERE, NULL, NULL);
return;
Expand Down
Loading
Loading