diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e3f8580..31231b2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f77e1c..5a581e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CallWaveKit.podspec b/CallWaveKit.podspec index f3127ef..c6023ad 100644 --- a/CallWaveKit.podspec +++ b/CallWaveKit.podspec @@ -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, diff --git a/CallWaveKit/CallWaveCallRegistry.h b/CallWaveKit/CallWaveCallRegistry.h index ea2bda9..d46adee 100644 --- a/CallWaveKit/CallWaveCallRegistry.h +++ b/CallWaveKit/CallWaveCallRegistry.h @@ -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; @@ -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. @@ -61,6 +69,24 @@ FOUNDATION_EXPORT const CallWaveSIPCallId CallWaveSIPCallIdInvalid; - (void)removeCallWithCallId:(CallWaveSIPCallId)callId; - (NSArray *)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; diff --git a/CallWaveKit/CallWaveCallRegistry.m b/CallWaveKit/CallWaveCallRegistry.m index 5617a54..60f6615 100644 --- a/CallWaveKit/CallWaveCallRegistry.m +++ b/CallWaveKit/CallWaveCallRegistry.m @@ -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 { @@ -15,6 +21,8 @@ - (instancetype)initWithUUID:(NSUUID *)uuid { _displayName = @""; _state = CallWaveCallStateIncoming; _createdAt = [NSDate date]; + _cancelledBeforeInvite = NO; + _cancelledAt = nil; } return self; } @@ -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) { @@ -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 *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 *)removeAllCalls { os_unfair_lock_lock(&_lock); NSArray *calls = _callsByUUID.allValues; diff --git a/CallWaveKit/CallWaveClient.m b/CallWaveKit/CallWaveClient.m index 8d6690f..66ec781 100644 --- a/CallWaveKit/CallWaveClient.m +++ b/CallWaveKit/CallWaveClient.m @@ -203,6 +203,7 @@ @interface CallWaveClient () - (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 @@ -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 @@ -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, @@ -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; @@ -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; diff --git a/FIELD-TESTING.md b/FIELD-TESTING.md new file mode 100644 index 0000000..837a526 --- /dev/null +++ b/FIELD-TESTING.md @@ -0,0 +1,217 @@ +# Field testing CallWaveKit + +`Scripts/run-package-tests.sh` covers URI construction, configuration equality, +caller-name formatting, DTMF normalization and the client's property contracts. +It cannot cover the part that actually breaks: a real PBX, a real VoIP push, a +real lock screen and a real audio session. Everything in this file has to be +done by hand, on a device, against an intercom. + +Run it before tagging a release, and after any change to the answer path, the +push path, the audio session or the registration lifecycle. + +## Setup + +- A physical iPhone. The Simulator has no PushKit and no usable audio route. +- An intercom (or PBX) that calls the account, and a door the DTMF code opens. +- A host application build with the library's logging opened up: + +```swift +CallWaveLog.level = .debug +CallWaveLog.redactsIdentifiers = false // never in a build you ship +``` + + Redaction is on by default, so without that second line every UUID, caller and + registrar reads `` and the scenarios below cannot be followed. At + `.debug` the PJSIP protocol trace includes `Authorization` headers — this is a + debugging build, not a TestFlight build. + +- Console output, filtered on the library's subsystem: + +```sh +log stream --predicate 'subsystem == "com.callwave.kit"' --info --debug +``` + + Categories are `sip`, `call`, `audio`, `push`, `network` and `pjsip`, all + lower-case. Narrow with `--predicate 'subsystem == "com.callwave.kit" AND + category == "call"'`. + +Markers below are written as `[category] message`, matching what `log stream` +prints for the subsystem and category. Only the message text is emitted by the +library; the bracket is shorthand for the category column. + +Record the log for every scenario. A failure with no log is a failure that has +to be reproduced from scratch. + +## Scenarios + +Each one lists what to do, what must happen, and the marker that proves it. + +### 1. Cold start, locked screen + +The single most fragile path: the application is not running, the phone is +locked, the call is answered from the lock screen. + +Force-quit the app, lock the phone, place the call, answer from the lock screen. + +- CallKit shows the branded name and icon. +- Two-way audio within a second of answering — **check both directions**, the + common failure is one-way. +- `[call] INVITE for call … observed after N ms, settle delay 500 ms`, then + `[call] answering call … after a 500 ms settle delay`, then + `[call] 200 OK sent for call 0`. +- Note the `N`. If it creeps toward `answerTimeout`, the PBX or the push path + got slower and the timeout needs revisiting. +- `[audio] CallKit did not activate the session, activating manually` is a + warning, not a failure — but if it appears on every call, CallKit is not + activating the session at all and that deserves an investigation of its own. + +### 2. Foreground and background + +Repeat scenario 1 with the app in the foreground, then backgrounded but running. +All three must behave identically; they exercise different wake-up paths. + +### 3. Opening the door + +Answer, then send the DTMF code. + +- The door opens. +- The call ends only **after** the digits have gone out — hanging up inside the + DTMF completion is the contract; ending the call earlier truncates the RTP + telephone-events and the door stays shut. +- If the PBX does not negotiate `telephone-event`, the log shows the RFC 2833 + attempt failing and the SIP INFO fallback being used: + `[call] RFC 2833 DTMF failed (…), retrying with SIP INFO`. Both are + acceptable; silently no door is not. + +### 4. Declining + +Three separate cases, all of which must leave the intercom silent: + +1. Decline **after** the INVITE has arrived (roughly a second after the phone + starts ringing). +2. Decline **before** the INVITE has arrived — press it the instant CallKit + appears. The INVITE lands after the decline, and the intercom must still stop + ringing. There must be **no second** `call state … incoming` with a different + UUID afterwards, and no call left ringing on the PBX. +3. Ignore the call entirely and let CallKit time it out. + +Case 2 is a regression test: it failed in 0.3.0 and earlier, where the decline +was dropped because the SIP call did not exist yet, and the late INVITE then rang +as a second call. Fixed in 0.3.1 — the marker that proves the fix ran is +`[call] INVITE for call … arrived after the user rejected it; answered 603`. + +### 5. The intercom hangs up + +Answer, then hang up on the intercom side. + +- The CallKit screen disappears on its own; no stuck call in the UI. +- `didEndCallWithUUID:reason:` fires once, with the UUID of the call that + actually ended. + +### 6. Nobody answers + +Let the phone ring past `incomingCallTimeout` (60 s by default). + +- The call is rejected with `480`, `[call] call … rang for 60s without an answer` + appears, and CallKit clears. +- The next call still arrives — the timeout must not leave the account in a bad + state. + +### 7. Several calls in a row + +Place five calls, answering some and declining others, with the app left running +between them. + +- Every call arrives. A call that does not arrive after a successful earlier one + means the registration was not restored — check `unregister()` / `login()` + ordering in the host. +- If the host receives credentials in the push, use an account whose credentials + actually change between calls, so the account swap is exercised rather than + the "identical configuration, just re-register" shortcut. +- Watch for `[sip] registration started for … via …` per call and a `200` in + `[sip] registration 200 …`. + +### 8. Unregistering between calls + +A host that releases the account between calls does it through `unregister()`, +and the state that follows has to be believable: a client that claims to be +registered when it is not makes the host skip the re-registration, and the next +call simply never arrives. + +After a finished call, call `unregister()` and read the state back. + +- `registrationState` is `.stopped` and `isRegistered` is `false`. Reporting + `.registered` here is the 0.3.0 bug — PJSIP leaves `expires` at + `PJSIP_EXPIRES_NOT_SPECIFIED` rather than at zero, with the status still 200. +- `unregister()` a second time still succeeds instead of reporting an error. +- `refreshRegistration()` or `login(configuration:)` brings it back, and the + next call arrives. + +### 9. Two calls at once + +With `maximumCalls == 1` (the default), have a second intercom call while the +first is up: the second must be rejected `486 Busy Here` and the first must +survive untouched. + +With `maximumCalls > 1`, the second call is reported to CallKit, hold works, and +audio follows the active call. With both calls up, end **one** of them: the +other must survive with its audio intact. Ending the wrong one is the 0.3.0 bug, +where a disconnect was resolved through `currentCallUUID` instead of the call id +PJSIP actually reported. + +### 10. Network handover mid-call + +Answer, then switch Wi-Fi off during the conversation. + +- Audio recovers, or the call ends cleanly. What must not happen is a call that + looks alive with no audio in either direction. +- `[network] network path changed (0x… -> 0x…), rebuilding transports` — this + is the line that proves `pjsua_handle_ip_change()` ran. A failure logs + `[network] IP change handling failed (…)`. + +### 11. Push survival + +Twenty calls over a session, some answered, some declined, some ignored. + +- The process is never killed with `0xBAADCA11`. +- Pushes still arrive at call twenty. iOS stops delivering VoIP pushes to an + application that fails to run the PushKit completion handler, and the + punishment is delayed — which is exactly why this needs twenty calls and not + two. +- `[push] acknowledging VoIP push (…)` appears once per push. + +### 12. Audio details + +During an answered call: + +- Mute, and confirm the **other side stops hearing you** — CallWaveKit mutes at + the RTP capture connection, so a mute that only changes the button is a bug. +- Speaker on and off. +- Plug in wired headphones, then a Bluetooth headset, mid-call. +- `statistics(forCallWithUUID:)` reports a sane codec, non-zero packet counts + and plausible loss and jitter. + +### 13. TLS, if the deployment uses it + +Certificate verification is on by default since 0.3.0. Register against the +intercom over TLS and confirm it succeeds; if the intercom carries a self-signed +certificate, confirm that the host's explicit opt-out is what makes it work, and +that removing the opt-out fails the registration rather than silently accepting +the certificate. + +## Reporting a field failure + +Attach the console log for the whole call, from the push to the end, and state: + +- which scenario number, and which of the three app states (cold, foreground, + background); +- the intercom or PBX model and the transport; +- the `N` from `INVITE … observed after N ms`; +- what the user saw, separately from what the log says. + +## Keeping this file honest + +Every bug found in the field becomes a numbered scenario here, in the same +release as the fix. A bug that is fixed without a scenario added is a bug that +comes back — the decline-before-INVITE case in scenario 4 is exactly that, and +it reached production because nothing on this list would have caught it. diff --git a/Package.swift b/Package.swift index a0ecb9b..c72a3e2 100644 --- a/Package.swift +++ b/Package.swift @@ -56,6 +56,18 @@ let package = Package( name: "CallWaveKitTests", dependencies: ["CallWaveKit", "CallWaveKitAsync"], path: "Tests/CallWaveKitTests" + ), + // CallWaveCallRegistry is an implementation detail behind a private + // header, so its tests are Objective-C and reach it through a header + // search path rather than through the module. Keeping them out of the + // Swift target is what avoids widening the public API for testing. + .testTarget( + name: "CallWaveKitRegistryTests", + dependencies: ["CallWaveKit"], + path: "Tests/CallWaveKitRegistryTests", + cSettings: [ + .headerSearchPath("../../CallWaveKit") + ] ) ] ) diff --git a/README.md b/README.md index 87da66f..a7481a7 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ To track the repository directly instead of the published pod — an unreleased fix, say — point at the tag: ```ruby -pod 'CallWaveKit', git: 'https://github.com/PetrShtuka/CallWaveKit.git', tag: '0.3.0' +pod 'CallWaveKit', git: 'https://github.com/PetrShtuka/CallWaveKit.git', tag: '0.3.1' ``` ## Host application settings @@ -120,7 +120,10 @@ settings, call waiting and hold, DTMF, call statistics, the audio-session hooks, logging, the Swift concurrency layer and the threading contract. [CHANGELOG.md](CHANGELOG.md) records every release and every breaking change. -[RELEASING.md](RELEASING.md) is the maintainer's checklist for cutting one. +[RELEASING.md](RELEASING.md) is the maintainer's checklist for cutting one, and +[FIELD-TESTING.md](FIELD-TESTING.md) is the pass that has to be done by hand on a +device against a real intercom — the unit tests cover none of the answer path, +the push path or the audio session. ## What it does @@ -184,19 +187,25 @@ The checked-in binary can be reproduced with: The script builds PJSIP 2.17 for iOS 15.0 or later. Override `PJSIP_VERSION`, `MIN_IOS_VERSION` or `BUILD_JOBS` when necessary. -The checked-in `PJSIP.xcframework` was built with a minimum of iOS 16.0. It -links into an application targeting iOS 15.0, but every object file produces a -`built for newer 'iOS' version` linker warning. Rebuild it to silence them: +The checked-in `PJSIP.xcframework` matches the package's own floor: every slice +reports `minos 15.0`, so an application with a 15.x deployment target links it +without `built for newer 'iOS' version` warnings. If you rebuild it, keep the +floor in step with `Package.swift` and the podspec, and check the result on +every slice rather than the first one: ```sh -MIN_IOS_VERSION=15.0 ./Scripts/build-pjsip-xcframework.sh +for a in Vendor/PJSIP.xcframework/*/libPJSIP.a; do + for arch in $(lipo -archs "$a"); do + echo "$a $arch $(otool -arch "$arch" -l "$a" | grep -A3 LC_BUILD_VERSION | grep minos | sort -u)" + done +done ``` The XCFramework is 21 MB and every clone pays for it. For a tagged release, attach the zip instead and point the binary target at its URL: ```sh -./Scripts/package-pjsip-release.sh 0.3.0 +./Scripts/package-pjsip-release.sh 0.3.1 ``` The script prints the archive's checksum and the `.binaryTarget(url:checksum:)` diff --git a/RELEASING.md b/RELEASING.md index 2b764c1..81677e5 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -24,6 +24,23 @@ shows who currently owns the pod name. ## Per release +### 0. Verify the behaviour + +Mechanics come later; this is the step that decides whether the release is +shippable at all. + +```sh +./Scripts/run-package-tests.sh +``` + +Then work through [FIELD-TESTING.md](FIELD-TESTING.md) on a device against a real +intercom. The unit tests cover none of the answer path, the push path, the audio +session or the registration lifecycle — those only break in the field, and +`pod spec lint` in step 4 checks packaging, not behaviour. + +For a release that only touches documentation or packaging, the field pass can be +skipped — say so in the release notes rather than leaving it ambiguous. + ### 1. Bump the version The version appears in five places; `spec.version` is the one that matters, the @@ -69,9 +86,10 @@ than from the working directory: pod spec lint CallWaveKit.podspec --allow-warnings --skip-tests --platforms=ios ``` -`--allow-warnings` is required: the bundled PJSIP binary is built for iOS 16 -while the pod declares 15, so every object file emits a -`built for newer 'iOS' version` linker warning. +`--allow-warnings` is kept as a safety net rather than to paper over a known +warning: since 0.3.1 the lint passes clean, because the PJSIP binary's `minos` +matches the platform the pod declares. Drop the flag if you would rather a new +warning fail the release. ### 5. Publish to CocoaPods diff --git a/Tests/CallWaveKitRegistryTests/CallWaveCallRegistryTests.m b/Tests/CallWaveKitRegistryTests/CallWaveCallRegistryTests.m new file mode 100644 index 0000000..f4020ee --- /dev/null +++ b/Tests/CallWaveKitRegistryTests/CallWaveCallRegistryTests.m @@ -0,0 +1,173 @@ +#import + +#import "CallWaveCallRegistry.h" + +/// `CallWaveCallRegistry` is a private header, so these live in an +/// Objective-C target that reaches it through a header search path rather than +/// through the module. Nothing here is public API. +@interface CallWaveCallRegistryCancellationTests : XCTestCase +@end + +@implementation CallWaveCallRegistryCancellationTests { + CallWaveCallRegistry *_registry; +} + +- (void)setUp { + [super setUp]; + _registry = [[CallWaveCallRegistry alloc] init]; +} + +/// A call announced by a push, with no INVITE yet — the state the bug happens in. +- (CallWaveCall *)pendingCall { + return [_registry registerCallWithUUID:[NSUUID UUID]]; +} + +#pragma mark - Marking a cancellation + +- (void)testCancellingAPendingCallKeepsTheRecord { + CallWaveCall *call = [self pendingCall]; + + XCTAssertTrue([_registry markCallCancelledBeforeInvite:call.uuid]); + + // Removing it is what used to let the late INVITE look like a fresh call. + XCTAssertNotNil([_registry callForUUID:call.uuid]); + XCTAssertEqual(_registry.count, (NSUInteger)1); + XCTAssertTrue([_registry callForUUID:call.uuid].isCancelledBeforeInvite); + XCTAssertNotNil([_registry callForUUID:call.uuid].cancelledAt); +} + +- (void)testCancellingAnUnknownCallChangesNothing { + XCTAssertFalse([_registry markCallCancelledBeforeInvite:[NSUUID UUID]]); + XCTAssertFalse([_registry markCallCancelledBeforeInvite:nil]); + XCTAssertEqual(_registry.count, (NSUInteger)0); +} + +- (void)testACallWithAnInviteIsNotMarked { + CallWaveCall *call = [self pendingCall]; + [_registry bindCallId:7 toUUID:call.uuid]; + + // There is a real INVITE to reject through PJSUA, so nothing is deferred. + XCTAssertFalse([_registry markCallCancelledBeforeInvite:call.uuid]); + XCTAssertFalse([_registry callForUUID:call.uuid].isCancelledBeforeInvite); +} + +- (void)testMarkingTwiceDoesNotExtendTheDeadline { + CallWaveCall *call = [self pendingCall]; + + XCTAssertTrue([_registry markCallCancelledBeforeInvite:call.uuid]); + NSDate *first = [_registry callForUUID:call.uuid].cancelledAt; + + XCTAssertFalse([_registry markCallCancelledBeforeInvite:call.uuid]); + XCTAssertEqualObjects([_registry callForUUID:call.uuid].cancelledAt, first); +} + +#pragma mark - A cancelled call is not awaiting an INVITE + +- (void)testCancelledCallIsNotReturnedAsAwaitingInvite { + CallWaveCall *call = [self pendingCall]; + XCTAssertEqualObjects([_registry callAwaitingInvite].uuid, call.uuid); + + [_registry markCallCancelledBeforeInvite:call.uuid]; + + // Matching an INVITE to it would ring a call the user already rejected. + XCTAssertNil([_registry callAwaitingInvite]); +} + +- (void)testCancellingOneCallLeavesAnotherAwaitingInvite { + CallWaveCall *cancelled = [self pendingCall]; + CallWaveCall *live = [self pendingCall]; + + [_registry markCallCancelledBeforeInvite:cancelled.uuid]; + + XCTAssertEqualObjects([_registry callAwaitingInvite].uuid, live.uuid); +} + +#pragma mark - Consuming a cancellation + +- (void)testTakingACancellationReturnsItAndRemovesTheRecord { + CallWaveCall *call = [self pendingCall]; + [_registry markCallCancelledBeforeInvite:call.uuid]; + + CallWaveCall *taken = [_registry takeCallCancelledBeforeInviteWithin:60]; + + XCTAssertEqualObjects(taken.uuid, call.uuid); + XCTAssertNil([_registry callForUUID:call.uuid]); + XCTAssertEqual(_registry.count, (NSUInteger)0); + // Once consumed it must not reject a second INVITE. + XCTAssertNil([_registry takeCallCancelledBeforeInviteWithin:60]); +} + +- (void)testNothingIsTakenWhenNoCallWasCancelled { + [self pendingCall]; + + XCTAssertNil([_registry takeCallCancelledBeforeInviteWithin:60]); + XCTAssertEqual(_registry.count, (NSUInteger)1); +} + +- (void)testALiveCallAwaitingItsInviteTakesPrecedence { + CallWaveCall *cancelled = [self pendingCall]; + CallWaveCall *live = [self pendingCall]; + [_registry markCallCancelledBeforeInvite:cancelled.uuid]; + + // The arriving INVITE may belong to `live`; refusing it would drop a call + // the user never rejected. + XCTAssertNil([_registry takeCallCancelledBeforeInviteWithin:60]); + XCTAssertNotNil([_registry callForUUID:cancelled.uuid]); + XCTAssertNotNil([_registry callForUUID:live.uuid]); +} + +- (void)testTheOldestCancellationIsTakenFirst { + CallWaveCall *first = [self pendingCall]; + [_registry markCallCancelledBeforeInvite:first.uuid]; + usleep(20000); + CallWaveCall *second = [self pendingCall]; + [_registry markCallCancelledBeforeInvite:second.uuid]; + + XCTAssertEqualObjects([_registry takeCallCancelledBeforeInviteWithin:60].uuid, first.uuid); + XCTAssertEqualObjects([_registry takeCallCancelledBeforeInviteWithin:60].uuid, second.uuid); +} + +#pragma mark - Expiry + +- (void)testAnExpiredCancellationIsEquivalentToNoRecord { + CallWaveCall *call = [self pendingCall]; + [_registry markCallCancelledBeforeInvite:call.uuid]; + usleep(20000); + + XCTAssertNil([_registry takeCallCancelledBeforeInviteWithin:0.01]); + // And it is dropped, so it cannot reject some later, unrelated call. + XCTAssertNil([_registry callForUUID:call.uuid]); + XCTAssertEqual(_registry.count, (NSUInteger)0); +} + +- (void)testAFreshCancellationSurvivesTheSameWindow { + CallWaveCall *call = [self pendingCall]; + [_registry markCallCancelledBeforeInvite:call.uuid]; + + XCTAssertEqualObjects([_registry takeCallCancelledBeforeInviteWithin:60].uuid, call.uuid); +} + +- (void)testExpiryDoesNotDisturbACallStillAwaitingItsInvite { + CallWaveCall *cancelled = [self pendingCall]; + [_registry markCallCancelledBeforeInvite:cancelled.uuid]; + usleep(20000); + CallWaveCall *live = [self pendingCall]; + + XCTAssertNil([_registry takeCallCancelledBeforeInviteWithin:0.01]); + XCTAssertNil([_registry callForUUID:cancelled.uuid]); + XCTAssertNotNil([_registry callForUUID:live.uuid]); + XCTAssertEqualObjects([_registry callAwaitingInvite].uuid, live.uuid); +} + +#pragma mark - Calls with a SIP id are untouched + +- (void)testAnAnsweredCallIsNeverConsumedAsACancellation { + CallWaveCall *answered = [self pendingCall]; + [_registry bindCallId:3 toUUID:answered.uuid]; + answered.state = CallWaveCallStateActive; + + XCTAssertNil([_registry takeCallCancelledBeforeInviteWithin:60]); + XCTAssertEqualObjects([_registry callForCallId:3].uuid, answered.uuid); +} + +@end diff --git a/Vendor/PJSIP.xcframework/ios-arm64/libPJSIP.a b/Vendor/PJSIP.xcframework/ios-arm64/libPJSIP.a index fe5e328..e462547 100644 Binary files a/Vendor/PJSIP.xcframework/ios-arm64/libPJSIP.a and b/Vendor/PJSIP.xcframework/ios-arm64/libPJSIP.a differ diff --git a/Vendor/PJSIP.xcframework/ios-arm64_x86_64-simulator/libPJSIP.a b/Vendor/PJSIP.xcframework/ios-arm64_x86_64-simulator/libPJSIP.a index a4d0f95..7282979 100644 Binary files a/Vendor/PJSIP.xcframework/ios-arm64_x86_64-simulator/libPJSIP.a and b/Vendor/PJSIP.xcframework/ios-arm64_x86_64-simulator/libPJSIP.a differ