// // Copyright 2025 New Vector Ltd. // // SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial // Please see LICENSE files in the repository root for full details. // import CallKit import Clocks @testable import ElementX import PushKit import Testing @MainActor final class ElementCallServiceTests { private var callProvider: CXProviderMock! private var currentDate: Date! private var testClock: TestClock! private var pushRegistry: PKPushRegistry! private var service: ElementCallService! init() { pushRegistry = PKPushRegistry(queue: nil) callProvider = CXProviderMock(.init()) currentDate = Date() testClock = TestClock() let dateProvider: () -> Date = { self.currentDate } service = ElementCallService(callProvider: callProvider, timeProvider: TimeProvider(clock: testClock, now: dateProvider)) } isolated deinit { callProvider = nil currentDate = nil testClock = nil pushRegistry = nil } @Test func incomingCall() async { #expect(!callProvider.reportNewIncomingCallWithUpdateCompletionCalled) await waitForConfirmation { confirmation in let pkPushPayloadMock = PKPushPayloadMock().updatingExpiration(currentDate, lifetime: 30) service.pushRegistry(pushRegistry, didReceiveIncomingPushWith: pkPushPayloadMock, for: .voIP) { confirmation() } } #expect(callProvider.reportNewIncomingCallWithUpdateCompletionCalled) // Verify the provider was called with a CXCallUpdate that has video enabled if let args = callProvider.reportNewIncomingCallWithUpdateCompletionReceivedArguments { #expect(args.update.hasVideo == true) } else { Issue.record("Expected reportNewIncomingCallWithUpdateCompletionReceivedArguments to be captured") } } @Test func incomingVoiceCall() async { #expect(!callProvider.reportNewIncomingCallWithUpdateCompletionCalled) await waitForConfirmation { confirmation in let pkPushPayloadMock = PKPushPayloadMock().updatingExpiration(currentDate, lifetime: 30) .updateIsVoice(true) service.pushRegistry(pushRegistry, didReceiveIncomingPushWith: pkPushPayloadMock, for: .voIP) { confirmation() } } #expect(callProvider.reportNewIncomingCallWithUpdateCompletionCalled) // Verify the provider was called with a CXCallUpdate that has video enabled if let args = callProvider.reportNewIncomingCallWithUpdateCompletionReceivedArguments { // Due to a limitation on Callkit and Webviews, we currently have to report voice calls as having video, // even if they are voice calls :/ If not the webview is not started and the call is not shown to the user. #expect(args.update.hasVideo == true) } else { Issue.record("Expected reportNewIncomingCallWithUpdateCompletionReceivedArguments to be captured") } } @Test(.disabled()) func callIsTimingOut() async { #expect(!callProvider.reportNewIncomingCallWithUpdateCompletionCalled) await confirmation { confirmation in let pushPayload = PKPushPayloadMock().updatingExpiration(currentDate, lifetime: 20) service.pushRegistry(pushRegistry, didReceiveIncomingPushWith: pushPayload, for: .voIP) { confirmation() } } await confirmation { confirmation in callProvider.reportCallWithEndedAtReasonClosure = { _, _, reason in if reason == .unanswered { confirmation() } else { Issue.record("Call should have ended as unanswered") } } // advance past the timeout await testClock.advance(by: .seconds(30)) } } @Test func lifetimeIsCapped() async { await confirmation { confirmation in callProvider.reportCallWithEndedAtReasonClosure = { _, _, reason in if reason == .unanswered { confirmation() } else { Issue.record("Call should have ended as unanswered") } } #expect(!callProvider.reportNewIncomingCallWithUpdateCompletionCalled) let pushPayload = PKPushPayloadMock().updatingExpiration(currentDate, lifetime: 300) service.pushRegistry(pushRegistry, didReceiveIncomingPushWith: pushPayload, for: .voIP) { } // Advance past the max timeout but below the 300 await testClock.advance(by: .seconds(100)) } } @Test func callIntentRawValues() { // Test to ensure that the implicit rawValue of the string enum matches the MSC values #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 waitForConfirmation { 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 waitForConfirmation { 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") } @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 waitForConfirmation { 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 nonisolated class PKPushPayloadMock: PKPushPayload { var dict: [AnyHashable: Any] = [:] override init() { dict[ElementCallServiceNotificationKey.roomID.rawValue] = "!room:example.com" dict[ElementCallServiceNotificationKey.roomDisplayName.rawValue] = "welcome" dict[ElementCallServiceNotificationKey.rtcNotifyEventID.rawValue] = "$000" dict[ElementCallServiceNotificationKey.expirationDate.rawValue] = Date(timeIntervalSince1970: 10) } override var dictionaryPayload: [AnyHashable: Any] { dict } func updatingExpiration(_ from: Date, lifetime: TimeInterval) -> Self { dict[ElementCallServiceNotificationKey.expirationDate.rawValue] = from.addingTimeInterval(lifetime) return self } func updateIsVoice(_ isVoice: Bool) -> Self { dict[ElementCallServiceNotificationKey.isVoiceCall.rawValue] = isVoice return self } }