diff --git a/ElementX/Sources/Services/ElementCall/ElementCallService.swift b/ElementX/Sources/Services/ElementCall/ElementCallService.swift index 381986809..b235d8729 100644 --- a/ElementX/Sources/Services/ElementCall/ElementCallService.swift +++ b/ElementX/Sources/Services/ElementCall/ElementCallService.swift @@ -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 } } diff --git a/UnitTests/Sources/ElementCallServiceTests.swift b/UnitTests/Sources/ElementCallServiceTests.swift index 34e8c21d7..02d1c0240 100644 --- a/UnitTests/Sources/ElementCallServiceTests.swift +++ b/UnitTests/Sources/ElementCallServiceTests.swift @@ -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 {