mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-10-31 08:15:24 +00:00
570c7534c3
CallKit rejects the NSE hand-off without the filtering entitlement.
310 lines
13 KiB
Swift
310 lines
13 KiB
Swift
//
|
|
// 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
|
|
@testable import ElementX
|
|
import PushKit
|
|
import Testing
|
|
|
|
@MainActor
|
|
final class ElementCallServiceTests {
|
|
private var callProvider: CXProviderMock!
|
|
private var currentDate: Date!
|
|
private var clock: ManualClock!
|
|
private var pushRegistry: PKPushRegistry!
|
|
private var service: ElementCallService!
|
|
|
|
init() {
|
|
pushRegistry = PKPushRegistry(queue: nil)
|
|
callProvider = CXProviderMock(.init())
|
|
currentDate = Date()
|
|
clock = ManualClock()
|
|
let dateProvider: () -> Date = {
|
|
self.currentDate
|
|
}
|
|
service = ElementCallService(callProvider: callProvider,
|
|
timeProvider: TimeProvider(clock: clock, now: dateProvider),
|
|
appSettings: AppSettings.volatile())
|
|
}
|
|
|
|
isolated deinit {
|
|
callProvider = nil
|
|
currentDate = nil
|
|
clock = 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")
|
|
}
|
|
}
|
|
|
|
/// HealthChat: a VoIP push requested from the push gateway carries the expiration as JSON seconds.
|
|
@Test
|
|
func incomingCallFromPushGatewayVoIPPush() async {
|
|
await waitForConfirmation { confirmation in
|
|
let pkPushPayloadMock = PKPushPayloadMock().updatingExpiration(secondsSince1970: currentDate.timeIntervalSince1970 + 30)
|
|
|
|
service.pushRegistry(pushRegistry, didReceiveIncomingPushWith: pkPushPayloadMock, for: .voIP) {
|
|
confirmation()
|
|
}
|
|
}
|
|
|
|
#expect(callProvider.reportNewIncomingCallWithUpdateCompletionCalled)
|
|
#expect(!callProvider.reportCallWithEndedAtReasonCalled)
|
|
}
|
|
|
|
@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
|
|
func callIsTimingOut() async throws {
|
|
#expect(!callProvider.reportNewIncomingCallWithUpdateCompletionCalled)
|
|
|
|
let (endedCalls, endedCallsContinuation) = AsyncStream<CXCallEndedReason>.makeStream()
|
|
callProvider.reportCallWithEndedAtReasonClosure = { _, _, reason in
|
|
endedCallsContinuation.yield(reason)
|
|
}
|
|
let deferredEndedCall = deferFulfillment(endedCalls) { _ in true }
|
|
|
|
await waitForConfirmation { confirmation in
|
|
let pushPayload = PKPushPayloadMock().updatingExpiration(currentDate, lifetime: 20)
|
|
|
|
service.pushRegistry(pushRegistry,
|
|
didReceiveIncomingPushWith: pushPayload,
|
|
for: .voIP) {
|
|
confirmation()
|
|
}
|
|
}
|
|
|
|
await clock.waitForScheduledSleep()
|
|
|
|
// advance past the timeout
|
|
clock.advance(by: .seconds(30))
|
|
|
|
let reason = try await deferredEndedCall.fulfill()
|
|
#expect(reason == .unanswered, "Call should have ended as unanswered")
|
|
}
|
|
|
|
@Test
|
|
func lifetimeIsCapped() async throws {
|
|
#expect(!callProvider.reportNewIncomingCallWithUpdateCompletionCalled)
|
|
|
|
let (endedCalls, endedCallsContinuation) = AsyncStream<CXCallEndedReason>.makeStream()
|
|
callProvider.reportCallWithEndedAtReasonClosure = { _, _, reason in
|
|
endedCallsContinuation.yield(reason)
|
|
}
|
|
let deferredEndedCall = deferFulfillment(endedCalls, timeout: .seconds(30)) { _ in true }
|
|
|
|
let pushPayload = PKPushPayloadMock().updatingExpiration(currentDate, lifetime: 300)
|
|
|
|
service.pushRegistry(pushRegistry,
|
|
didReceiveIncomingPushWith: pushPayload,
|
|
for: .voIP) { }
|
|
|
|
// The ring duration is capped, no matter how far away the push's expiration is.
|
|
#expect(await clock.waitForScheduledSleep() == .seconds(90))
|
|
|
|
// Advance past the max timeout but below the 300
|
|
clock.advance(by: .seconds(100))
|
|
|
|
let reason = try await deferredEndedCall.fulfill()
|
|
#expect(reason == .unanswered, "Call should have ended as unanswered")
|
|
}
|
|
|
|
@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 throws {
|
|
// Drive push #1 to its 60s unanswered timeout
|
|
let (endedCalls, endedCallsContinuation) = AsyncStream<CXCallEndedReason>.makeStream()
|
|
callProvider.reportCallWithEndedAtReasonClosure = { _, _, reason in
|
|
endedCallsContinuation.yield(reason)
|
|
}
|
|
let deferredEndedCall = deferFulfillment(endedCalls, timeout: .seconds(30)) { $0 == .unanswered }
|
|
|
|
let firstPayload = PKPushPayloadMock().updatingExpiration(currentDate, lifetime: 60)
|
|
service.pushRegistry(pushRegistry, didReceiveIncomingPushWith: firstPayload, for: .voIP) { }
|
|
|
|
await clock.waitForScheduledSleep()
|
|
clock.advance(by: .seconds(70))
|
|
try await deferredEndedCall.fulfill()
|
|
|
|
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()
|
|
}
|
|
}
|
|
|
|
// Make sure the cancellation below exercises a scheduled timer rather than one that never started.
|
|
await clock.waitForScheduledSleep()
|
|
|
|
// 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
|
|
clock.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 updatingExpiration(secondsSince1970: TimeInterval) -> Self {
|
|
dict[ElementCallServiceNotificationKey.expirationDate.rawValue] = secondsSince1970
|
|
return self
|
|
}
|
|
|
|
func updateIsVoice(_ isVoice: Bool) -> Self {
|
|
dict[ElementCallServiceNotificationKey.isVoiceCall.rawValue] = isVoice
|
|
return self
|
|
}
|
|
}
|