From 434b3aab83a06f47b0932a410c445cde909ee771 Mon Sep 17 00:00:00 2001 From: Daniel Anderson Date: Wed, 27 May 2026 10:09:11 -0600 Subject: [PATCH] Report a phantom call to CallKit on PushKit early-return paths iOS kills the app if a VoIP push isn't followed by reportNewIncomingCall, so report an immediately ended call on every early-return path, named after the room. A duplicate of an ongoing call is reported as answered elsewhere and an expired push as a missed call that can be tapped to call back. Signed-off-by: Daniel Anderson --- .../ElementCall/ElementCallService.swift | 58 ++++++++++++++--- .../Sources/ElementCallServiceTests.swift | 63 ++++++++++++++----- 2 files changed, 96 insertions(+), 25 deletions(-) diff --git a/ElementX/Sources/Services/ElementCall/ElementCallService.swift b/ElementX/Sources/Services/ElementCall/ElementCallService.swift index b235d8729..98ec5c461 100644 --- a/ElementX/Sources/Services/ElementCall/ElementCallService.swift +++ b/ElementX/Sources/Services/ElementCall/ElementCallService.swift @@ -162,19 +162,25 @@ class ElementCallService: NSObject, ElementCallServiceProtocol, PKPushRegistryDe func pushRegistry(_ registry: PKPushRegistry, didReceiveIncomingPushWith payload: PKPushPayload, for type: PKPushType, completion: @escaping () -> Void) { guard let roomID = payload.dictionaryPayload[ElementCallServiceNotificationKey.roomID.rawValue] as? String else { MXLog.error("Something went wrong, missing room identifier for incoming voip call: \(payload)") - completion() + assertionFailure("Incoming voip push is missing the room identifier") + reportImmediatelyEndedCall(reason: .failed, completion: completion) return } guard let rtcNotificationID = payload.dictionaryPayload[ElementCallServiceNotificationKey.rtcNotifyEventID.rawValue] as? String else { MXLog.error("Something went wrong, missing rtc notification event identifier for incoming voip call: \(payload)") - completion() + assertionFailure("Incoming voip push is missing the rtc notification event identifier") + reportImmediatelyEndedCall(reason: .failed, completion: completion) return } + let roomDisplayName = payload.dictionaryPayload[ElementCallServiceNotificationKey.roomDisplayName.rawValue] as? String + guard ongoingCallID?.roomID != roomID else { - MXLog.warning("Call already ongoing for room \(roomID), ignoring incoming push") - completion() + MXLog.warning("Call already ongoing for room \(roomID), reporting the duplicate push as handled") + reportImmediatelyEndedCall(reason: .answeredElsewhere, + callerInfo: (roomID: roomID, roomDisplayName: roomDisplayName), + completion: completion) return } @@ -185,22 +191,25 @@ class ElementCallService: NSObject, ElementCallServiceProtocol, PKPushRegistryDe guard let expirationDate = (payload.dictionaryPayload[ElementCallServiceNotificationKey.expirationDate.rawValue] as? Date) else { MXLog.error("Something went wrong, missing expiration timestamp for incoming voip call: \(payload)") - completion() + assertionFailure("Incoming voip push is missing the expiration timestamp") + clearIncomingCallState() + reportImmediatelyEndedCall(reason: .failed, completion: completion) return } let nowDate = timeProvider.now() guard nowDate < expirationDate else { - MXLog.warning("Call expired for room \(roomID), ignoring incoming push") - completion() + MXLog.warning("Call expired for room \(roomID), reporting it as missed") + clearIncomingCallState() + reportImmediatelyEndedCall(reason: .unanswered, + callerInfo: (roomID: roomID, roomDisplayName: roomDisplayName), + completion: completion) return } let ringDuration: Duration = .seconds(min(expirationDate.timeIntervalSince1970 - nowDate.timeIntervalSince1970, 90)) - let roomDisplayName = payload.dictionaryPayload[ElementCallServiceNotificationKey.roomDisplayName.rawValue] as? String - let update = CXCallUpdate() // Work Around: Always set video to true! https://github.com/element-hq/element-x-ios/issues/5335 // If not for audio call the app will not be put to foreground and the webview won't be able to handle the call... @@ -334,6 +343,37 @@ class ElementCallService: NSObject, ElementCallServiceProtocol, PKPushRegistryDe ongoingCallID = nil } + /// Every VoIP push must be reported to CallKit via `reportNewIncomingCall`, otherwise iOS + /// terminates the app with `NSInternalInconsistencyException`. On early-return paths we report + /// a call and immediately end it. `callerInfo` names the call after the room so the brief + /// system UI and the Recents entry show the room rather than "Unknown", and the entry can be + /// tapped to call back. + private func reportImmediatelyEndedCall(reason: CXCallEndedReason, + callerInfo: (roomID: String, roomDisplayName: String?)? = nil, + completion: @escaping () -> Void) { + let provider = callProvider + + let callID = UUID() + let update = CXCallUpdate() + // Never answered through CallKit, so the hasVideo workaround for #5335 isn't needed. + update.hasVideo = false + + if let callerInfo { + update.localizedCallerName = callerInfo.roomDisplayName + update.remoteHandle = .init(type: .generic, value: callerInfo.roomID) + } + + provider.reportNewIncomingCall(with: callID, update: update) { error in + if let error { + MXLog.error("Failed reporting immediately ended call with error: \(error)") + } + + provider.reportCall(with: callID, endedAt: nil, reason: reason) + + completion() + } + } + private func sendDeclineCallEvent(_ incomingCallID: CallID) async { guard let rtcNotificationID = incomingCallID.rtcNotificationID else { MXLog.info("No rtc notification event to decline.") diff --git a/UnitTests/Sources/ElementCallServiceTests.swift b/UnitTests/Sources/ElementCallServiceTests.swift index 02d1c0240..c84ccfd67 100644 --- a/UnitTests/Sources/ElementCallServiceTests.swift +++ b/UnitTests/Sources/ElementCallServiceTests.swift @@ -5,6 +5,7 @@ // Please see LICENSE files in the repository root for full details. // +import CallKit import Clocks @testable import ElementX import PushKit @@ -109,22 +110,6 @@ final class ElementCallServiceTests { } } - @Test - func expiredRingLifetimeIsIgnored() { - #expect(!callProvider.reportNewIncomingCallWithUpdateCompletionCalled) - - let pushPayload = PKPushPayloadMock().updatingExpiration(currentDate, lifetime: 20) - - currentDate = currentDate.addingTimeInterval(60) - - service.pushRegistry(pushRegistry, - didReceiveIncomingPushWith: pushPayload, - for: .voIP) { } - sleep(20) - - #expect(!callProvider.reportNewIncomingCallWithUpdateCompletionCalled) - } - @Test func lifetimeIsCapped() async { await confirmation { confirmation in @@ -223,6 +208,52 @@ final class ElementCallServiceTests { #expect(!unansweredFired, "endUnansweredCallTask should have been cancelled by setupCallSession") } + + @Test + func expiredPushReportsMissedCall() async { + // An expired push is a real call we missed, so it should show up in Recents as one. + let pushPayload = PKPushPayloadMock().updatingExpiration(currentDate, lifetime: 20) + currentDate = currentDate.addingTimeInterval(60) + await expectImmediatelyEndedCallReported(forPayload: pushPayload, expectedReason: .unanswered) + + let update = callProvider.reportNewIncomingCallWithUpdateCompletionReceivedArguments?.update + #expect(update?.localizedCallerName == "welcome") + #expect(update?.remoteHandle?.value == "!room:example.com") + } + + @Test + func duplicateRoomPushReportsCallAsHandled() async { + // A duplicate push for an ongoing call is reported as handled, leaving the ongoing call alone. + await service.setupCallSession(roomID: "!room:example.com", roomDisplayName: "welcome") + let pushPayload = PKPushPayloadMock().updatingExpiration(currentDate, lifetime: 30) + await expectImmediatelyEndedCallReported(forPayload: pushPayload, expectedReason: .answeredElsewhere) + + // The call should be named so neither the brief system UI nor the Recents entry shows "Unknown". + let update = callProvider.reportNewIncomingCallWithUpdateCompletionReceivedArguments?.update + #expect(update?.localizedCallerName == "welcome") + + #expect(service.ongoingCallRoomIDPublisher.value == "!room:example.com") + } + + private func expectImmediatelyEndedCallReported(forPayload payload: PKPushPayloadMock, + expectedReason: CXCallEndedReason) async { + let baselineNewIncomingCount = callProvider.reportNewIncomingCallWithUpdateCompletionCallsCount + let baselineEndedCount = callProvider.reportCallWithEndedAtReasonCallsCount + + await confirmation { confirmation in + service.pushRegistry(pushRegistry, didReceiveIncomingPushWith: payload, for: .voIP) { + confirmation() + } + } + + #expect(callProvider.reportNewIncomingCallWithUpdateCompletionCallsCount == baselineNewIncomingCount + 1) + #expect(callProvider.reportCallWithEndedAtReasonCallsCount == baselineEndedCount + 1) + + let reportedCall = callProvider.reportNewIncomingCallWithUpdateCompletionReceivedArguments + let endedCall = callProvider.reportCallWithEndedAtReasonReceivedArguments + #expect(reportedCall?.uuid == endedCall?.uuid) + #expect(endedCall?.reason == expectedReason) + } } private class PKPushPayloadMock: PKPushPayload {