Clear incoming-call state on every path that ends a ringing call

Four end paths each cleaned up partially.

Signed-off-by: Daniel Anderson <daniel.anderson@toptal.com>
This commit is contained in:
Daniel Anderson
2026-05-27 10:36:48 -06:00
committed by Stefan Ceriu
parent 9bfc97ad59
commit 4c8becc99e
2 changed files with 87 additions and 3 deletions
@@ -115,7 +115,7 @@ class ElementCallService: NSObject, ElementCallServiceProtocol, PKPushRegistryDe
CallID(callKitID: UUID(), roomID: roomID, rtcNotificationID: nil, isVoiceCall: false)
}
incomingCallID = nil
clearIncomingCallState()
ongoingCallID = callID
// Don't bother starting another CallKit session as it won't work properly
@@ -230,6 +230,7 @@ class ElementCallService: NSObject, ElementCallServiceProtocol, PKPushRegistryDe
if let incomingCallID, incomingCallID.callKitID == callID.callKitID {
callProvider.reportCall(with: incomingCallID.callKitID, endedAt: nil, reason: .unanswered)
clearIncomingCallState()
}
}
}
@@ -308,6 +309,10 @@ class ElementCallService: NSObject, ElementCallServiceProtocol, PKPushRegistryDe
}
}
if incomingCallID?.callKitID == action.callUUID {
clearIncomingCallState()
}
tearDownCallSession(sendEndCallAction: false)
action.fulfill()
@@ -419,9 +424,20 @@ class ElementCallService: NSObject, ElementCallServiceProtocol, PKPushRegistryDe
}
private func reportEndedCall(incomingCallID: CallID, reason: CXCallEndedReason) {
callProvider.reportCall(with: incomingCallID.callKitID, endedAt: nil, reason: reason)
clearIncomingCallState()
}
/// Cancels every subscription and task tied to a ringing incoming call and nils `incomingCallID`.
/// Call this on every path that ends the incoming-call state (answered, declined, timed out,
/// cancelled by remote, answered/declined elsewhere) so that follow-up calls to the same room
/// start from a clean slate and the SDK decline-listener subscription doesn't leak.
private func clearIncomingCallState() {
endUnansweredCallTask?.cancel()
endUnansweredCallTask = nil
declineListenerHandle?.cancel()
declineListenerHandle = nil
endUnansweredCallTask?.cancel()
callProvider.reportCall(with: incomingCallID.callKitID, endedAt: nil, reason: reason)
incomingCallRoomInfoCancellable = nil
incomingCallID = nil
}
}
@@ -155,6 +155,74 @@ final class ElementCallServiceTests {
#expect(CallIntent.audio.rawValue == "audio")
#expect(CallIntent.video.rawValue == "video")
}
@Test
func timeoutClearsIncomingCallStateBeforeNextPush() async {
// Drive push #1 to its 60s unanswered timeout
await confirmation { confirmation in
callProvider.reportCallWithEndedAtReasonClosure = { _, _, reason in
if reason == .unanswered {
confirmation()
}
}
let firstPayload = PKPushPayloadMock().updatingExpiration(currentDate, lifetime: 60)
service.pushRegistry(pushRegistry, didReceiveIncomingPushWith: firstPayload, for: .voIP) { }
await testClock.advance(by: .seconds(70))
}
callProvider.reportCallWithEndedAtReasonClosure = nil
let firstCallUUID = callProvider.reportNewIncomingCallWithUpdateCompletionReceivedArguments?.uuid
// Send push #2 for the same room; the previous incoming state must be cleared,
// so the second push gets a fresh CallID.
await confirmation { confirmation in
let secondPayload = PKPushPayloadMock().updatingExpiration(currentDate, lifetime: 60)
service.pushRegistry(pushRegistry, didReceiveIncomingPushWith: secondPayload, for: .voIP) {
confirmation()
}
}
let secondCallUUID = callProvider.reportNewIncomingCallWithUpdateCompletionReceivedArguments?.uuid
#expect(firstCallUUID != nil)
#expect(secondCallUUID != nil)
#expect(firstCallUUID != secondCallUUID)
let unansweredCount = callProvider.reportCallWithEndedAtReasonReceivedInvocations.filter { $0.reason == .unanswered }.count
#expect(unansweredCount == 1)
}
@Test
func setupCallSessionCancelsPendingUnansweredTimeout() async {
// Schedule the 60s unanswered timer via an incoming push
await confirmation { confirmation in
let payload = PKPushPayloadMock().updatingExpiration(currentDate, lifetime: 60)
service.pushRegistry(pushRegistry, didReceiveIncomingPushWith: payload, for: .voIP) {
confirmation()
}
}
// Simulate the answer flow handing off to setupCallSession, which must cancel
// the pending endUnansweredCallTask as part of clearing the incoming state.
await service.setupCallSession(roomID: "!room:example.com", roomDisplayName: "welcome")
var unansweredFired = false
callProvider.reportCallWithEndedAtReasonClosure = { _, _, reason in
if reason == .unanswered {
unansweredFired = true
}
}
// Advance past what would have been the 60s unanswered timeout
await testClock.advance(by: .seconds(120))
for _ in 0..<3 {
await Task.yield()
}
#expect(!unansweredFired, "endUnansweredCallTask should have been cancelled by setupCallSession")
}
}
private class PKPushPayloadMock: PKPushPayload {