mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-03 16:10:20 +00:00
Add experimental native calls behind a developer flag (#6159)
Add the element-call-ios dependency and a developer option for native calls The package carries the call UI and the matrix-rust-rtc media stack, pinned by exact version the way the SDK is, with the local path kept commented out for working against a checkout. Its media xcframework has no Intel simulator slice and a package cannot say so for the projects that consume it, hence the excluded architecture at the project level. The developer option is off by default and nothing reads it yet. It is placed next to the Element Call URL override, which only applies to the web view that native calls replace.
This commit is contained in:
@@ -6,34 +6,46 @@
|
||||
//
|
||||
|
||||
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(callProvider: callProvider, timeProvider: TimeProvider(clock: clock, now: dateProvider))
|
||||
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
|
||||
@@ -182,7 +194,7 @@ final class ElementCallServiceTests {
|
||||
}
|
||||
|
||||
@Test
|
||||
func setupCallSessionCancelsPendingUnansweredTimeout() async {
|
||||
func setupCallSessionCancelsPendingUnansweredTimeout() async throws {
|
||||
// Schedule the 60s unanswered timer via an incoming push
|
||||
await waitForConfirmation { confirmation in
|
||||
let payload = PKPushPayloadMock().updatingExpiration(currentDate, lifetime: 60)
|
||||
@@ -196,22 +208,20 @@ final class ElementCallServiceTests {
|
||||
|
||||
// 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")
|
||||
await service.setupCallSession(roomID: "!room:example.com", roomDisplayName: "welcome", isVideo: true)
|
||||
|
||||
var unansweredFired = false
|
||||
let (endedCalls, endedCallsContinuation) = AsyncStream<CXCallEndedReason>.makeStream()
|
||||
callProvider.reportCallWithEndedAtReasonClosure = { _, _, reason in
|
||||
if reason == .unanswered {
|
||||
unansweredFired = true
|
||||
}
|
||||
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))
|
||||
for _ in 0..<3 {
|
||||
await Task.yield()
|
||||
}
|
||||
|
||||
#expect(!unansweredFired, "endUnansweredCallTask should have been cancelled by setupCallSession")
|
||||
try await deferredUnansweredCall.fulfill()
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -229,7 +239,7 @@ final class ElementCallServiceTests {
|
||||
@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")
|
||||
await service.setupCallSession(roomID: "!room:example.com", roomDisplayName: "welcome", isVideo: true)
|
||||
let pushPayload = PKPushPayloadMock().updatingExpiration(currentDate, lifetime: 30)
|
||||
await expectImmediatelyEndedCallReported(forPayload: pushPayload, expectedReason: .answeredElsewhere)
|
||||
|
||||
@@ -240,6 +250,251 @@ final class ElementCallServiceTests {
|
||||
#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)
|
||||
}
|
||||
|
||||
@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
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
//
|
||||
// Copyright 2026 Element Creations Ltd.
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
// Please see LICENSE files in the repository root for full details.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import ElementCall
|
||||
@testable import ElementX
|
||||
import Testing
|
||||
|
||||
@MainActor
|
||||
final class NativeCallStackTests {
|
||||
private let stack: NativeCallStack
|
||||
private var presentations: [NativeCallPresentation] = []
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
init() {
|
||||
stack = NativeCallStack(transport: ElementCallFakeTransport(),
|
||||
system: ElementCallFakeSystem(),
|
||||
options: ElementCallOptions(),
|
||||
style: .stock)
|
||||
|
||||
stack.actions
|
||||
.sink { [weak self] presentation in
|
||||
self?.presentations.append(presentation)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
@Test
|
||||
func startingACallAsksForItsScreen() {
|
||||
stack.handleCallRequest(roomProxy: roomProxy(id: "!room:example.com"), isVoiceCall: false)
|
||||
|
||||
#expect(presentations == [.present])
|
||||
#expect(stack.controller.room?.roomID == "!room:example.com")
|
||||
}
|
||||
|
||||
@Test
|
||||
func aCallStartsARoomWithoutOneAndJoinsARoomWithOne() {
|
||||
stack.handleCallRequest(roomProxy: roomProxy(id: "!empty:example.com", hasOngoingCall: false), isVoiceCall: true)
|
||||
// Starting rings the room; joining one already running happens quietly.
|
||||
#expect(stack.controller.callData?.isStartingCall == true)
|
||||
#expect(stack.controller.callData?.isAudioCall == true)
|
||||
|
||||
stack.stop()
|
||||
|
||||
let joining = NativeCallStack(transport: ElementCallFakeTransport(),
|
||||
system: ElementCallFakeSystem(),
|
||||
options: ElementCallOptions(),
|
||||
style: .stock)
|
||||
joining.handleCallRequest(roomProxy: roomProxy(id: "!busy:example.com", hasOngoingCall: true), isVoiceCall: true)
|
||||
#expect(joining.controller.callData?.isStartingCall == false)
|
||||
}
|
||||
|
||||
@Test
|
||||
func askingForTheCallYouAreAlreadyInRestoresItsScreen() {
|
||||
let roomProxy = roomProxy(id: "!room:example.com")
|
||||
stack.handleCallRequest(roomProxy: roomProxy, isVoiceCall: false)
|
||||
presentations.removeAll()
|
||||
|
||||
// Reached while the call is still joining, before the service has an ongoing call of its own.
|
||||
stack.handleCallRequest(roomProxy: roomProxy, isVoiceCall: false)
|
||||
|
||||
#expect(presentations == [.restore])
|
||||
}
|
||||
|
||||
@Test
|
||||
func aCallInAnotherRoomWaitsForTheRunningOneToEnd() async throws {
|
||||
stack.handleCallRequest(roomProxy: roomProxy(id: "!first:example.com"), isVoiceCall: false)
|
||||
presentations.removeAll()
|
||||
|
||||
// The controller ignores a second call, so this one is queued behind a hang up.
|
||||
stack.handleCallRequest(roomProxy: roomProxy(id: "!second:example.com"), isVoiceCall: false)
|
||||
#expect(stack.controller.room?.roomID == "!first:example.com")
|
||||
|
||||
try await waitForPresentations([.dismiss, .present])
|
||||
#expect(stack.controller.room?.roomID == "!second:example.com")
|
||||
}
|
||||
|
||||
@Test
|
||||
func endingACallDismissesItsScreen() async throws {
|
||||
stack.handleCallRequest(roomProxy: roomProxy(id: "!room:example.com"), isVoiceCall: false)
|
||||
presentations.removeAll()
|
||||
|
||||
stack.controller.hangUp()
|
||||
|
||||
try await waitForPresentations([.dismiss])
|
||||
#expect(stack.controller.room == nil, "The controller is reset so the next call starts clean")
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func roomProxy(id: String, hasOngoingCall: Bool = false) -> JoinedRoomProxyMock {
|
||||
JoinedRoomProxyMock(.init(id: id, hasOngoingCall: hasOngoingCall))
|
||||
}
|
||||
|
||||
private func waitForPresentations(_ expected: [NativeCallPresentation]) async throws {
|
||||
let deferred = deferFulfillment(stack.actions) { [weak self] _ in
|
||||
self?.presentations == expected
|
||||
}
|
||||
|
||||
if presentations == expected {
|
||||
return
|
||||
}
|
||||
|
||||
try await deferred.fulfill()
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,12 @@ final nonisolated class ManualClock: Clock {
|
||||
state.withLock { $0.now }
|
||||
}
|
||||
|
||||
/// Whether a sleep is waiting on ``advance(by:)``, which is how a test asserts that a timer has
|
||||
/// been cancelled rather than advancing the clock and watching for what it doesn't do.
|
||||
var hasPendingSleep: Bool {
|
||||
state.withLock { $0.sleep != nil }
|
||||
}
|
||||
|
||||
/// Suspends until ``advance(by:)`` moves the clock to `deadline`, or the calling task is
|
||||
/// cancelled, in which case this throws a `CancellationError`. Deadlines that have already
|
||||
/// passed return without suspending. `tolerance` is ignored.
|
||||
|
||||
Reference in New Issue
Block a user