mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-10-31 16:24:11 +00:00
583 lines
26 KiB
Swift
583 lines
26 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
|
|
import ElementCall
|
|
@testable import ElementX
|
|
import PushKit
|
|
import Testing
|
|
|
|
@MainActor
|
|
final class ElementCallServiceTests {
|
|
private var appSettings: AppSettings!
|
|
private var callProvider: CXProviderMock!
|
|
private var callController: CXCallControllerMock!
|
|
private var currentDate: Date!
|
|
private var clock: ManualClock!
|
|
private var pushRegistry: PKPushRegistry!
|
|
private var service: ElementCallService!
|
|
private var userSession: UserSessionMock!
|
|
|
|
init() {
|
|
appSettings = AppSettings.volatile()
|
|
pushRegistry = PKPushRegistry(queue: nil)
|
|
callProvider = CXProviderMock(.init())
|
|
callController = CXCallControllerMock(.init())
|
|
currentDate = Date()
|
|
clock = ManualClock()
|
|
let dateProvider: () -> Date = {
|
|
self.currentDate
|
|
}
|
|
service = ElementCallService(appSettings: appSettings,
|
|
callProvider: callProvider,
|
|
callController: callController,
|
|
timeProvider: TimeProvider(clock: clock, now: dateProvider))
|
|
}
|
|
|
|
isolated deinit {
|
|
appSettings = nil
|
|
callProvider = nil
|
|
callController = nil
|
|
currentDate = nil
|
|
clock = nil
|
|
pushRegistry = nil
|
|
userSession = 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 throws {
|
|
// 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", isVideo: true)
|
|
|
|
let (endedCalls, endedCallsContinuation) = AsyncStream<CXCallEndedReason>.makeStream()
|
|
callProvider.reportCallWithEndedAtReasonClosure = { _, _, reason in
|
|
endedCallsContinuation.yield(reason)
|
|
}
|
|
let deferredUnansweredCall = deferFailure(endedCalls,
|
|
timeout: .milliseconds(100),
|
|
message: "endUnansweredCallTask should have been cancelled by setupCallSession") { $0 == .unanswered }
|
|
|
|
// Advance past what would have been the 60s unanswered timeout
|
|
clock.advance(by: .seconds(120))
|
|
|
|
try await deferredUnansweredCall.fulfill()
|
|
}
|
|
|
|
@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", isVideo: true)
|
|
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")
|
|
}
|
|
|
|
@Test
|
|
func webViewCallSessionIsNotTrackedByCallKit() async {
|
|
// CallKit tracking a call gives this process exclusive media access, which starves the web
|
|
// view. So a web view call is only ever tracked on the app side.
|
|
await service.setupCallSession(roomID: "!room:example.com", roomDisplayName: "welcome", isVideo: true)
|
|
service.reportCallSessionConnected(roomID: "!room:example.com")
|
|
|
|
#expect(service.ongoingCallRoomIDPublisher.value == "!room:example.com")
|
|
#expect(!callProvider.reportCallWithUpdatedCalled)
|
|
#expect(!callProvider.reportOutgoingCallWithConnectedAtCalled)
|
|
}
|
|
|
|
@Test
|
|
func endingAReplacedCallLeavesTheCallThatReplacedItUp() async throws {
|
|
enableNativeCalls()
|
|
|
|
// Leaving a call to start one in another room requests an end call action for the first,
|
|
// then starts the second before the system performs it.
|
|
await service.setupCallSession(roomID: "!first:example.com", roomDisplayName: "first", isVideo: true)
|
|
let firstCallID = try #require(callProvider.reportCallWithUpdatedReceivedArguments?.uuid)
|
|
|
|
service.tearDownCallSession(roomID: "!first:example.com")
|
|
await service.setupCallSession(roomID: "!second:example.com", roomDisplayName: "second", isVideo: true)
|
|
|
|
var endedRooms: [String] = []
|
|
let cancellable = service.actions.sink { action in
|
|
if case .endCall(let roomID) = action {
|
|
endedRooms.append(roomID)
|
|
}
|
|
}
|
|
defer { cancellable.cancel() }
|
|
|
|
service.endCall(withCallKitID: firstCallID)
|
|
|
|
#expect(endedRooms.isEmpty, "The first call's end action must not hang up the second call")
|
|
#expect(service.ongoingCallRoomIDPublisher.value == "!second:example.com")
|
|
|
|
// And the second call still ends when it's the one the system means.
|
|
let secondCallID = try #require(callProvider.reportCallWithUpdatedReceivedArguments?.uuid)
|
|
service.endCall(withCallKitID: secondCallID)
|
|
|
|
#expect(endedRooms == ["!second:example.com"])
|
|
#expect(service.ongoingCallRoomIDPublisher.value == nil)
|
|
}
|
|
|
|
/// A declined or unanswered call is the system's to log as such: reported with its reason, and
|
|
/// never as an end call action, which would log it as one we hung up.
|
|
@Test
|
|
func aCallThatEndedWithoutUsIsReportedRatherThanEnded() async throws {
|
|
enableNativeCalls()
|
|
await service.setupCallSession(roomID: "!room:example.com", roomDisplayName: "welcome", isVideo: true)
|
|
let callID = try #require(callProvider.reportCallWithUpdatedReceivedArguments?.uuid)
|
|
let requestsBeforeTheEnd = callController.requestCompletionCallsCount
|
|
|
|
service.reportCallSessionEnded(roomID: "!other:example.com", reason: .unanswered)
|
|
#expect(!callProvider.reportCallWithEndedAtReasonCalled, "Another room's end is not this call's")
|
|
|
|
service.reportCallSessionEnded(roomID: "!room:example.com", reason: .unanswered)
|
|
|
|
#expect(callProvider.reportCallWithEndedAtReasonReceivedArguments?.uuid == callID)
|
|
#expect(callProvider.reportCallWithEndedAtReasonReceivedArguments?.reason == .unanswered)
|
|
#expect(callController.requestCompletionCallsCount == requestsBeforeTheEnd)
|
|
#expect(service.ongoingCallRoomIDPublisher.value == nil)
|
|
}
|
|
|
|
@Test
|
|
func tearingDownAnotherRoomLeavesTheOngoingCallUp() async {
|
|
// A call screen that is dismissed after another room's call replaced it must not take the
|
|
// new call down with it.
|
|
await service.setupCallSession(roomID: "!room:example.com", roomDisplayName: "welcome", isVideo: true)
|
|
|
|
service.tearDownCallSession(roomID: "!other:example.com")
|
|
#expect(service.ongoingCallRoomIDPublisher.value == "!room:example.com")
|
|
|
|
service.tearDownCallSession(roomID: "!room:example.com")
|
|
#expect(service.ongoingCallRoomIDPublisher.value == nil)
|
|
}
|
|
|
|
// MARK: - Native calls
|
|
|
|
@Test
|
|
func nativeModeReportsVoiceCallsWithoutVideo() async {
|
|
appSettings.nativeCallEnabled = true
|
|
|
|
await reportIncomingPush(isVoiceCall: true)
|
|
|
|
#expect(callProvider.reportNewIncomingCallWithUpdateCompletionReceivedArguments?.update.hasVideo == false)
|
|
}
|
|
|
|
@Test
|
|
func nativeModeStillReportsVideoCallsWithVideo() async {
|
|
appSettings.nativeCallEnabled = true
|
|
|
|
await reportIncomingPush(isVoiceCall: false)
|
|
|
|
#expect(callProvider.reportNewIncomingCallWithUpdateCompletionReceivedArguments?.update.hasVideo == true)
|
|
}
|
|
|
|
@Test
|
|
func answeringInNativeModeKeepsTheCallUp() async throws {
|
|
appSettings.nativeCallEnabled = true
|
|
await reportIncomingPush(isVoiceCall: false)
|
|
await clock.waitForScheduledSleep()
|
|
|
|
try await answerReportedCall()
|
|
|
|
// The webview path ends the call a second after answering so that `setupCallSession` can
|
|
// start a new one. The native stack takes this one over instead, so it has to survive.
|
|
#expect(!callProvider.reportCallWithEndedAtReasonCalled)
|
|
}
|
|
|
|
@Test
|
|
func answeredNativeCallEndsWhenNothingTakesItOver() async throws {
|
|
appSettings.nativeCallEnabled = true
|
|
await reportIncomingPush(isVoiceCall: false)
|
|
await clock.waitForScheduledSleep()
|
|
|
|
let answeredUUID = try await answerReportedCall()
|
|
|
|
let (endedCalls, endedCallsContinuation) = AsyncStream<(UUID, CXCallEndedReason)>.makeStream()
|
|
callProvider.reportCallWithEndedAtReasonClosure = { uuid, _, reason in
|
|
endedCallsContinuation.yield((uuid, reason))
|
|
}
|
|
let deferredEndedCall = deferFulfillment(endedCalls) { _ in true }
|
|
|
|
// Answering swaps the unanswered timer for the watchdog that catches a hand-off that never
|
|
// happens — the room isn't joined, there's no transport, a call is already running.
|
|
#expect(await clock.waitForScheduledSleep() == .seconds(30))
|
|
clock.advance(by: .seconds(30))
|
|
|
|
let (uuid, reason) = try await deferredEndedCall.fulfill()
|
|
#expect(uuid == answeredUUID)
|
|
#expect(reason == .failed)
|
|
}
|
|
|
|
@Test
|
|
func nativeCallStackTakesOverTheAnsweredCall() async throws {
|
|
enableNativeCalls()
|
|
await reportIncomingPush(isVoiceCall: false)
|
|
await clock.waitForScheduledSleep()
|
|
|
|
try await answerReportedCall()
|
|
|
|
// Answering starts the watchdog, so wait for it before asserting that the hand-over stops it.
|
|
await clock.waitForScheduledSleep()
|
|
|
|
await service.setupCallSession(roomID: "!room:example.com", roomDisplayName: "welcome", isVideo: true)
|
|
|
|
// Taking the answered call over means keeping its identifier rather than reporting a new
|
|
// outgoing call, which is what the update below would be part of.
|
|
#expect(!callProvider.reportCallWithUpdatedCalled)
|
|
#expect(service.ongoingCallRoomIDPublisher.value == "!room:example.com")
|
|
|
|
// And the watchdog must be gone, otherwise it ends the call that was just taken over.
|
|
#expect(!clock.hasPendingSleep, "The watchdog must not outlive the hand-over")
|
|
}
|
|
|
|
@Test
|
|
func nativeCallStackForAnotherRoomReportsANewCall() async throws {
|
|
enableNativeCalls()
|
|
await reportIncomingPush(isVoiceCall: false)
|
|
await clock.waitForScheduledSleep()
|
|
|
|
let answeredUUID = try await answerReportedCall()
|
|
|
|
await service.setupCallSession(roomID: "!other:example.com", roomDisplayName: "other", isVideo: true)
|
|
|
|
let update = try #require(callProvider.reportCallWithUpdatedReceivedArguments)
|
|
#expect(update.uuid != answeredUUID)
|
|
#expect(update.update.localizedCallerName == "other")
|
|
#expect(service.ongoingCallRoomIDPublisher.value == "!other:example.com")
|
|
|
|
// Unlike a web view call, a native one is tracked by CallKit, so it gets the connected report too.
|
|
service.reportCallSessionConnected(roomID: "!other:example.com")
|
|
#expect(callProvider.reportOutgoingCallWithConnectedAtReceivedArguments?.uuid == update.uuid)
|
|
}
|
|
|
|
@Test
|
|
func nativeCallStackNeedsBothTheSettingAndASession() {
|
|
#expect(service.nativeCallController == nil)
|
|
|
|
appSettings.nativeCallEnabled = true
|
|
#expect(service.nativeCallController == nil, "The setting alone has no transport to run a call over")
|
|
|
|
service.setUserSession(nativeUserSession())
|
|
#expect(service.nativeCallController != nil)
|
|
|
|
service.setUserSession(nil)
|
|
#expect(service.nativeCallController == nil, "Signing out has to release the stack's to-device subscription")
|
|
}
|
|
|
|
@Test
|
|
func turningTheSettingOnMidSessionBuildsTheStack() {
|
|
service.setUserSession(nativeUserSession())
|
|
#expect(service.nativeCallController == nil)
|
|
|
|
// The transport is built on demand, so the setting takes effect without an app restart.
|
|
appSettings.nativeCallEnabled = true
|
|
#expect(service.nativeCallController != nil)
|
|
}
|
|
|
|
@Test
|
|
func aNewSessionGetsANewStack() {
|
|
appSettings.nativeCallEnabled = true
|
|
service.setUserSession(nativeUserSession())
|
|
let firstController = service.nativeCallController
|
|
|
|
// A soft logout or a cache clear brings a new session without the sign out that drops this
|
|
// one, and the stack is built against the client proxy it was given.
|
|
service.setUserSession(nativeUserSession())
|
|
|
|
#expect(service.nativeCallController !== firstController)
|
|
}
|
|
|
|
@Test
|
|
func callsFallBackToTheWebViewWithoutAStack() {
|
|
let roomProxy = JoinedRoomProxyMock(.init(id: "!room:example.com"))
|
|
|
|
#expect(!service.handleNativeCallRequest(roomProxy: roomProxy, isVoiceCall: false))
|
|
|
|
appSettings.nativeCallEnabled = true
|
|
service.setUserSession(nativeUserSession())
|
|
|
|
#expect(service.handleNativeCallRequest(roomProxy: roomProxy, isVoiceCall: false))
|
|
}
|
|
|
|
/// Builds a session whose client proxy can serve the native call stack, and holds on to it: the
|
|
/// service only keeps a weak reference, as the app owns the session.
|
|
private func nativeUserSession() -> UserSessionMock {
|
|
let clientProxy = ClientProxyMock(.init())
|
|
clientProxy.makeNativeCallTransportReturnValue = ElementCallFakeTransport()
|
|
let session = UserSessionMock(.init(clientProxy: clientProxy))
|
|
userSession = session
|
|
return session
|
|
}
|
|
|
|
/// Puts the service in native mode, with the stack the CallKit paths check for.
|
|
private func enableNativeCalls() {
|
|
appSettings.nativeCallEnabled = true
|
|
service.setUserSession(nativeUserSession())
|
|
}
|
|
|
|
/// Delivers a VoIP push and waits for it to be reported, returning once the call is ringing.
|
|
private func reportIncomingPush(isVoiceCall: Bool) async {
|
|
await waitForConfirmation { confirmation in
|
|
let payload = PKPushPayloadMock().updatingExpiration(currentDate, lifetime: 60)
|
|
.updateIsVoice(isVoiceCall)
|
|
|
|
service.pushRegistry(pushRegistry, didReceiveIncomingPushWith: payload, for: .voIP) {
|
|
confirmation()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Answers the call the provider last reported, returning its identifier.
|
|
@discardableResult
|
|
private func answerReportedCall() async throws -> UUID {
|
|
let uuid = try #require(callProvider.reportNewIncomingCallWithUpdateCompletionReceivedArguments?.uuid)
|
|
let provider = CXProvider(configuration: CXProviderConfiguration())
|
|
|
|
service.provider(provider, perform: CXAnswerCallAction(call: uuid))
|
|
|
|
return uuid
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|