mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-03 06:55:59 +00:00
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:
committed by
Stefan Ceriu
parent
70152f9ba1
commit
434b3aab83
@@ -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.")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user