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 <daniel.anderson@toptal.com>
This commit is contained in:
Daniel Anderson
2026-05-27 10:09:11 -06:00
committed by Stefan Ceriu
parent 70152f9ba1
commit 434b3aab83
2 changed files with 96 additions and 25 deletions
@@ -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.")