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:
Valere Fedronic
2026-09-17 18:42:18 +02:00
committed by GitHub
parent fe18472556
commit fb49f5b83b
33 changed files with 1778 additions and 160 deletions
@@ -8,6 +8,7 @@
@preconcurrency import Combine
import CryptoKit
import ElementCall
import Foundation
import MatrixRustSDK
import OrderedCollections
@@ -374,6 +375,10 @@ class ClientProxy: ClientProxyProtocol {
}
}
func makeNativeCallTransport() -> ElementCallMatrixTransportProtocol? {
(client as? Client).flatMap { ElementCallSDKTransport(client: $0) }
}
var isLoginWithQRCodeSupported: Bool {
get async {
do {
@@ -7,6 +7,7 @@
//
import Combine
import ElementCall
import Foundation
import MatrixRustSDK
@@ -171,6 +172,9 @@ protocol ClientProxyProtocol: AnyObject {
var isReportRoomSupported: Bool { get async }
var isLiveKitRTCSupported: Bool { get async }
/// Builds a Matrix transport for the call package, `nil` when not backed by a real SDK client.
func makeNativeCallTransport() -> ElementCallMatrixTransportProtocol?
var isLoginWithQRCodeSupported: Bool { get async }
var maxMediaUploadSize: Result<UInt, ClientProxyError> { get async }
@@ -0,0 +1,15 @@
//
// 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 CallKit
// sourcery: AutoMockable
protocol CXCallControllerProtocol {
func request(_ transaction: CXTransaction, completion: @escaping @Sendable (Error?) -> Void)
}
extension CXCallController: CXCallControllerProtocol { }
@@ -11,6 +11,9 @@ import CallKit
protocol CXProviderProtocol {
func setDelegate(_ delegate: CXProviderDelegate?, queue: DispatchQueue?)
func reportNewIncomingCall(with uuid: UUID, update: CXCallUpdate, completion: @escaping @Sendable (Error?) -> Void)
func reportOutgoingCall(with uuid: UUID, startedConnectingAt: Date?)
func reportOutgoingCall(with uuid: UUID, connectedAt: Date?)
func reportCall(with uuid: UUID, updated update: CXCallUpdate)
func reportCall(with uuid: UUID, endedAt: Date?, reason: CXCallEndedReason)
}
@@ -9,6 +9,7 @@
import AVFoundation
import CallKit
import Combine
import ElementCall
import Foundation
import MatrixRustSDK
import PushKit
@@ -26,10 +27,12 @@ class ElementCallService: NSObject, ElementCallServiceProtocol, PKPushRegistryDe
let roomID: String
let rtcNotificationID: String?
let isVoiceCall: Bool
var isNative = false
}
private let appSettings: AppSettings
private let pushRegistry: PKPushRegistry
private let callController = CXCallController()
private let callController: CXCallControllerProtocol
private let callProvider: CXProviderProtocol
private let timeProvider: TimeProvider
@@ -51,6 +54,10 @@ class ElementCallService: NSObject, ElementCallServiceProtocol, PKPushRegistryDe
private var endUnansweredCallTask: Task<Void, Never>?
/// A call answered for the native stack, until the stack takes it over.
private var answeredNativeCallID: CallID?
private var endPendingCallTask: Task<Void, Never>?
private var ongoingCallID: CallID? {
didSet { ongoingCallRoomIDSubject.send(ongoingCallID?.roomID) }
}
@@ -67,7 +74,23 @@ class ElementCallService: NSObject, ElementCallServiceProtocol, PKPushRegistryDe
private var declineListenerHandle: TaskHandle?
init(callProvider: CXProviderProtocol? = nil, timeProvider: TimeProvider? = nil) {
/// The native call stack, for as long as there's a session running calls through it.
private var nativeCallStack: NativeCallStack?
private var nativeCallCancellable: AnyCancellable?
private var mediaProvider: MediaProviderProtocol?
var nativeCallController: ElementCallController? {
nativeCallStack?.controller
}
private var cancellables = Set<AnyCancellable>()
init(appSettings: AppSettings,
callProvider: CXProviderProtocol? = nil,
callController: CXCallControllerProtocol? = nil,
timeProvider: TimeProvider? = nil) {
self.appSettings = appSettings
self.callController = callController ?? CXCallController()
pushRegistry = PKPushRegistry(queue: nil)
self.timeProvider = timeProvider ?? TimeProvider(clock: ContinuousClock(), now: Date.init)
@@ -95,45 +118,130 @@ class ElementCallService: NSObject, ElementCallServiceProtocol, PKPushRegistryDe
pushRegistry.desiredPushTypes = [.voIP]
self.callProvider.setDelegate(self, queue: nil)
appSettings.nativeCallEnabledPublisher
.sink { [weak self] _ in
self?.updateNativeCallStack()
}
.store(in: &cancellables)
}
func setClientProxy(_ clientProxy: any ClientProxyProtocol) {
self.clientProxy = clientProxy
func setUserSession(_ userSession: UserSessionProtocol?) {
// A stack is built against one client proxy and can't be handed to another, which is what a
// soft logout or a cache clear brings: a new session without the sign out that drops this one.
if userSession?.clientProxy !== clientProxy {
stopNativeCallStack()
}
clientProxy = userSession?.clientProxy
mediaProvider = userSession?.mediaProvider
updateNativeCallStack()
}
func setupCallSession(roomID: String, roomDisplayName: String) async {
@discardableResult
func handleNativeCallRequest(roomProxy: JoinedRoomProxyProtocol, isVoiceCall: Bool) -> Bool {
guard let nativeCallStack else {
return false
}
nativeCallStack.handleCallRequest(roomProxy: roomProxy, isVoiceCall: isVoiceCall)
return true
}
func minimizeNativeCall() {
nativeCallStack?.minimize()
}
func restoreNativeCall() {
nativeCallStack?.restore()
}
func setupCallSession(roomID: String, roomDisplayName: String, isVideo: Bool) async {
// Unlike the push handling below, this always runs with a session, so the stack itself is
// the answer rather than the setting that would have built one.
let isNative = nativeCallStack != nil
MXLog.info("Setting up a call session, native: \(isNative), video: \(isVideo), replacing an ongoing call: \(ongoingCallID != nil)")
// Drop any ongoing calls when starting a new one
if ongoingCallID != nil {
tearDownCallSession()
tearDownCallSession(sendEndCallAction: true)
}
// If this starting from a ring reuse those identifiers
// Make sure the roomID matches
let callID = if let incomingCallID, incomingCallID.roomID == roomID {
var callID = if let incomingCallID, incomingCallID.roomID == roomID {
incomingCallID
} else {
CallID(callKitID: UUID(), roomID: roomID, rtcNotificationID: nil, isVoiceCall: false)
CallID(callKitID: UUID(), roomID: roomID, rtcNotificationID: nil, isVoiceCall: !isVideo)
}
callID.isNative = isNative
let isAnsweringIncomingCall = callID.rtcNotificationID != nil
clearIncomingCallState()
ongoingCallID = callID
// Don't bother starting another CallKit session as it won't work properly
// A web view call must not be tracked by CallKit, as that gives this process exclusive
// access to media and the web view runs in another (see `CXAnswerCallAction`).
// https://developer.apple.com/forums//thread/767949?answerId=812951022#812951022
guard isNative else {
return
}
// let handle = CXHandle(type: .generic, value: roomDisplayName)
// let startCallAction = CXStartCallAction(call: callID.callKitID, handle: handle)
// startCallAction.isVideo = true
// Answering the ringing call: it is already reported, keep it.
guard !isAnsweringIncomingCall else {
return
}
// do {
// try await callController.request(CXTransaction(action: startCallAction))
// } catch {
// MXLog.error("Failed requesting start call action with error: \(error)")
// }
let handle = CXHandle(type: .generic, value: roomID)
let startCallAction = CXStartCallAction(call: callID.callKitID, handle: handle)
startCallAction.contactIdentifier = roomDisplayName
startCallAction.isVideo = isVideo
// Awaited because our membership goes out straight after: a peer that sees it before the
// system knows about the call reads the call as answered elsewhere.
await requestTransaction(CXTransaction(action: startCallAction), describedAs: "start call")
// Without this the system call UI shows the handle, i.e. the room ID.
let update = CXCallUpdate()
update.localizedCallerName = roomDisplayName
update.remoteHandle = handle
update.hasVideo = isVideo
callProvider.reportCall(with: callID.callKitID, updated: update)
}
func tearDownCallSession() {
tearDownCallSession(sendEndCallAction: true)
func reportCallSessionConnected(roomID: String) {
guard let ongoingCallID, ongoingCallID.roomID == roomID else {
// The system ends an outgoing call that never reports connecting, so a silent guard
// here shows up much later as a call that hangs itself up.
MXLog.warning("Not reporting the call as connected, no matching ongoing call")
return
}
guard ongoingCallID.isNative else {
MXLog.info("Not reporting a web view call as connected, CallKit doesn't track it")
return
}
MXLog.info("Reporting the call as connected")
callProvider.reportOutgoingCall(with: ongoingCallID.callKitID, connectedAt: nil)
}
func tearDownCallSession(roomID: String) {
if ongoingCallID?.roomID == roomID {
tearDownCallSession(sendEndCallAction: true)
return
}
// Answered and handed over, but the stack couldn't take it. Ending it here rather than
// waiting for the watchdog takes the system call UI away straight away.
if answeredNativeCallID?.roomID == roomID {
MXLog.warning("Ending an answered call the native call stack didn't take over")
endPendingCall()
return
}
MXLog.info("Not tearing down the call session, no call for room \(roomID)")
}
func setAudioEnabled(_ enabled: Bool, roomID: String) {
@@ -155,6 +263,57 @@ class ElementCallService: NSObject, ElementCallServiceProtocol, PKPushRegistryDe
}
}
// MARK: - Native calls
/// Builds or releases the call stack to match the session and the setting.
private func updateNativeCallStack() {
guard appSettings.nativeCallEnabled, let clientProxy else {
stopNativeCallStack()
return
}
guard nativeCallStack == nil else { return }
guard let transport = clientProxy.makeNativeCallTransport() else {
MXLog.error("Cannot start the native call stack without a transport")
return
}
let style = ElementCallStyle(theme: NativeCallTheme(),
icons: NativeCallIcons(),
avatars: NativeCallAvatars(mediaProvider: mediaProvider),
strings: .init(you: L10n.commonYou,
error: L10n.commonError,
stop: L10n.actionStop,
back: L10n.actionBack))
// The stats overlay is raw RTP counters, so it follows whatever already reveals developer
// surface. Screen sharing stays off until the broadcast extension that makes it work lands.
let options = ElementCallOptions(isDeveloperModeEnabled: appSettings.developerOptionsEnabled)
let stack = NativeCallStack(transport: transport,
system: NativeCallSystem(service: self),
options: options,
style: style)
nativeCallStack = stack
nativeCallCancellable = stack.actions
.sink { [weak self] presentation in
self?.actionsSubject.send(.nativeCall(presentation))
}
Task { await stack.start() }
}
private func stopNativeCallStack() {
guard let nativeCallStack else { return }
self.nativeCallStack = nil
nativeCallCancellable = nil
nativeCallStack.stop()
actionsSubject.send(.nativeCall(.dismiss))
}
// MARK: - PKPushRegistryDelegate
func pushRegistry(_ registry: PKPushRegistry, didUpdate pushCredentials: PKPushCredentials, for type: PKPushType) { }
@@ -215,7 +374,7 @@ class ElementCallService: NSObject, ElementCallServiceProtocol, PKPushRegistryDe
// If not for audio call the app will not be put to foreground and the webview won't be able to handle the call...
// Consequence: The call will be presented to the user as a video call in CallKit UI,
// but once Element Call is launched it will correctly route to a voice-only call.
update.hasVideo = true
update.hasVideo = appSettings.nativeCallEnabled ? !isVoiceCall : true
update.localizedCallerName = roomDisplayName
// https://stackoverflow.com/a/41230020/730924
update.remoteHandle = .init(type: .generic, value: roomID)
@@ -253,10 +412,19 @@ class ElementCallService: NSObject, ElementCallServiceProtocol, PKPushRegistryDe
func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) {
MXLog.info("Call provider did activate audio session")
actionsSubject.send(.audioSessionActivated)
}
func provider(_ provider: CXProvider, didDeactivate audioSession: AVAudioSession) {
MXLog.info("Call provider did deactivate audio session")
actionsSubject.send(.audioSessionDeactivated)
}
func provider(_ provider: CXProvider, perform action: CXStartCallAction) {
// The native controller configured the audio session already; CallKit activates it and
// reports back through `didActivate`.
action.fulfill()
callProvider.reportOutgoingCall(with: action.callUUID, startedConnectingAt: nil)
}
func providerDidReset(_ provider: CXProvider) {
@@ -287,6 +455,15 @@ class ElementCallService: NSObject, ElementCallServiceProtocol, PKPushRegistryDe
// First fullfill the action
action.fulfill()
if appSettings.nativeCallEnabled {
// The native stack owns media in this process, so the CallKit call stays up: the
// controller attaches to it in `setupCallSession`.
endUnansweredCallTask?.cancel()
endCallIfLeftPending(incomingCallID)
actionsSubject.send(.startCall(roomID: incomingCallID.roomID, isVoiceCall: incomingCallID.isVoiceCall))
return
}
// And delay ending the call so that the app has enough time
// to get deeplinked into
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
@@ -302,7 +479,7 @@ class ElementCallService: NSObject, ElementCallServiceProtocol, PKPushRegistryDe
if let ongoingCallID {
actionsSubject.send(.setAudioEnabled(!action.isMuted, roomID: ongoingCallID.roomID))
} else {
MXLog.error("Failed muting/unmuting call, missing ongoingCallID")
MXLog.info("Ignoring a mute action without an ongoing call")
}
action.fulfill()
@@ -313,30 +490,56 @@ class ElementCallService: NSObject, ElementCallServiceProtocol, PKPushRegistryDe
// This gets called for no reason on simulators, where CallKit
// isn't even supported, ignore it.
#else
if let ongoingCallID {
actionsSubject.send(.endCall(roomID: ongoingCallID.roomID))
}
if let incomingCallID {
Task {
await sendDeclineCallEvent(incomingCallID)
}
}
if incomingCallID?.callKitID == action.callUUID {
clearIncomingCallState()
}
tearDownCallSession(sendEndCallAction: false)
endCall(withCallKitID: action.callUUID)
action.fulfill()
#endif
}
/// Ends the call the system identified, whether that's the ongoing one, the ringing one, or
/// neither because it has already been replaced.
///
/// Kept out of the delegate method so that it's reachable on the simulator, where the provider
/// performs end call actions of its own and the delegate has to ignore them.
func endCall(withCallKitID callKitID: UUID) {
MXLog.info("Ending the call for \(callKitID), ongoing: \(ongoingCallID != nil), incoming: \(incomingCallID != nil)")
// Both branches are gated on the identifier. Leaving a call to start one in another room
// requests an end call action for the old call and replaces `ongoingCallID` before the
// system gets round to performing it, so an ungated handler reads it as the new call ending
// and hangs up the call that was just started.
if let ongoingCallID, ongoingCallID.callKitID == callKitID {
actionsSubject.send(.endCall(roomID: ongoingCallID.roomID))
tearDownCallSession(sendEndCallAction: false)
}
if let incomingCallID, incomingCallID.callKitID == callKitID {
Task {
await sendDeclineCallEvent(incomingCallID)
}
clearIncomingCallState()
}
}
// MARK: - Private
/// Bridges the controller's completion handler, which is all ``CXCallControllerProtocol`` can
/// carry, back to an awaitable call.
private func requestTransaction(_ transaction: CXTransaction, describedAs description: String) async {
await withCheckedContinuation { continuation in
callController.request(transaction) { error in
if let error {
MXLog.error("Failed requesting \(description) action with error: \(error)")
}
continuation.resume()
}
}
}
private func tearDownCallSession(sendEndCallAction: Bool = true) {
if sendEndCallAction, let ongoingCallID {
MXLog.info("Requesting an end call action for the ongoing call")
let transaction = CXTransaction(action: CXEndCallAction(call: ongoingCallID.callKitID))
callController.request(transaction) { error in
if let error {
@@ -381,6 +584,32 @@ class ElementCallService: NSObject, ElementCallServiceProtocol, PKPushRegistryDe
}
}
/// Ends an answered call that the native stack never took over, which nothing else would: the
/// call is deliberately left up for the controller to attach to in `setupCallSession`.
private func endCallIfLeftPending(_ callID: CallID) {
answeredNativeCallID = callID
endPendingCallTask = Task { [weak self] in
try? await self?.timeProvider.clock.sleep(for: .seconds(30))
guard let self, !Task.isCancelled, answeredNativeCallID == callID else {
return
}
MXLog.error("The native call stack never took over the answered call, ending it")
endPendingCall()
}
}
private func endPendingCall() {
guard let answeredNativeCallID else {
return
}
callProvider.reportCall(with: answeredNativeCallID.callKitID, endedAt: nil, reason: .failed)
clearIncomingCallState()
}
private func sendDeclineCallEvent(_ incomingCallID: CallID) async {
guard let rtcNotificationID = incomingCallID.rtcNotificationID else {
MXLog.info("No rtc notification event to decline.")
@@ -482,6 +711,9 @@ class ElementCallService: NSObject, ElementCallServiceProtocol, PKPushRegistryDe
private func clearIncomingCallState() {
endUnansweredCallTask?.cancel()
endUnansweredCallTask = nil
endPendingCallTask?.cancel()
endPendingCallTask = nil
answeredNativeCallID = nil
declineListenerHandle?.cancel()
declineListenerHandle = nil
incomingCallRoomInfoCancellable = nil
@@ -7,25 +7,64 @@
//
import Combine
import ElementCall
/// What the host has to do about a native call, beyond running it.
enum NativeCallPresentation: Equatable {
case present
case restore
case minimize
case dismiss
}
enum ElementCallServiceAction {
case receivedIncomingCallRequest
case startCall(roomID: String, isVoiceCall: Bool)
case endCall(roomID: String)
case setAudioEnabled(_ enabled: Bool, roomID: String)
/// CallKit activated the audio session: a native call may start its audio engine now.
case audioSessionActivated
case audioSessionDeactivated
/// The native call's screen needs presenting, minimizing, restoring or dismissing.
case nativeCall(NativeCallPresentation)
}
// sourcery: AutoMockable
protocol ElementCallServiceProtocol {
protocol ElementCallServiceProtocol: AnyObject {
var actions: AnyPublisher<ElementCallServiceAction, Never> { get }
var ongoingCallRoomIDPublisher: CurrentValuePublisher<String?, Never> { get }
func setClientProxy(_ clientProxy: ClientProxyProtocol)
/// The controller for this session's native call stack, `nil` when native calls are unavailable.
var nativeCallController: ElementCallController? { get }
func setupCallSession(roomID: String, roomDisplayName: String) async
/// Builds the native call stack for the session, or releases it along with the to-device
/// subscription it holds open. Pass `nil` when signing out.
func setUserSession(_ userSession: UserSessionProtocol?)
func tearDownCallSession()
/// Starts, or returns to, a native call in the room, driving its screen through
/// ``ElementCallServiceAction/nativeCall(_:)``.
///
/// Returns `false` when there's no native call stack and the caller should present the Element
/// Call web view instead.
@discardableResult
func handleNativeCallRequest(roomProxy: JoinedRoomProxyProtocol, isVoiceCall: Bool) -> Bool
/// Asks the native call to minimize. The screen only comes down once a system window has
/// started, which arrives as ``ElementCallServiceAction/nativeCall(_:)``.
func minimizeNativeCall()
/// Brings the native call back out of its system window.
func restoreNativeCall()
/// Registers the call with CallKit, adopting the ringing incoming call for the room if there is one.
func setupCallSession(roomID: String, roomDisplayName: String, isVideo: Bool) async
/// Tells CallKit the call is connected. Does nothing for web view calls, which CallKit never tracks.
func reportCallSessionConnected(roomID: String)
/// Ends the call for the room, including an answered call the native stack never took over.
func tearDownCallSession(roomID: String)
func setAudioEnabled(_ enabled: Bool, roomID: String)
}
@@ -0,0 +1,58 @@
//
// 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
/// Serves the call package's room port from a room proxy.
///
/// Built on the proxy rather than in the package so that the call reuses its caching: current values
/// for room info and members, refreshed off the SDK's subscription, read from disk before the network.
final class NativeCallRoomContext: ElementCallRoomContextProtocol {
private let roomProxy: JoinedRoomProxyProtocol
init(roomProxy: JoinedRoomProxyProtocol) {
self.roomProxy = roomProxy
}
var roomID: String {
roomProxy.id
}
var displayName: String {
roomProxy.infoPublisher.value.displayNameOrID
}
var displayNamePublisher: AnyPublisher<String, Never> {
roomProxy.infoPublisher
.map(\.displayNameOrID)
.removeDuplicates()
.eraseToAnyPublisher()
}
var isDirect: Bool {
roomProxy.infoPublisher.value.isDirect
}
var isDirectPublisher: AnyPublisher<Bool, Never> {
roomProxy.infoPublisher.map(\.isDirect).removeDuplicates().eraseToAnyPublisher()
}
var memberProfiles: [String: ElementCallMemberProfile] {
Self.profiles(from: roomProxy.membersPublisher.value)
}
var memberProfilesPublisher: AnyPublisher<[String: ElementCallMemberProfile], Never> {
roomProxy.membersPublisher.map(Self.profiles).eraseToAnyPublisher()
}
private static func profiles(from members: [RoomMemberProxyProtocol]) -> [String: ElementCallMemberProfile] {
Dictionary(members.map { ($0.userID, ElementCallMemberProfile(userID: $0.userID,
displayName: $0.displayName,
avatarURL: $0.avatarURL)) }) { first, _ in first }
}
}
@@ -0,0 +1,168 @@
//
// 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
import Foundation
/// The call package's stack for one logged-in session, turning what the call does into the
/// navigation the host owes it.
///
/// Built with the session rather than with the first call: the core's to-device subscription has no
/// catch-up, so a stack that starts only once a call does can miss the keys sent while it was
/// joining.
@MainActor
final class NativeCallStack {
/// The call itself, for the screen that is built from it. Everything else asks this type.
var controller: ElementCallController {
stack.controller
}
var actions: AnyPublisher<NativeCallPresentation, Never> {
actionsSubject.eraseToAnyPublisher()
}
private let stack: ElementCallStack
private let actionsSubject = PassthroughSubject<NativeCallPresentation, Never>()
private var cancellables = Set<AnyCancellable>()
/// A call requested while another one was still running, started once that one has ended.
private var pendingCallRequest: (roomProxy: JoinedRoomProxyProtocol, isVoiceCall: Bool)?
init(transport: any ElementCallMatrixTransportProtocol,
system: any ElementCallSystemProvidingProtocol,
options: ElementCallOptions,
style: ElementCallStyle) {
stack = ElementCallStack(transport: transport,
system: system,
options: options,
style: style,
logger: Logger())
stack.controller.actions
.receive(on: DispatchQueue.main)
.sink { [weak self] action in
self?.handle(action)
}
.store(in: &cancellables)
}
func start() async {
Self.installRTCLogBridge()
await stack.start()
}
/// Releases the stack along with the to-device subscription it holds open for key delivery.
func stop() {
MXLog.info("Stopping the native call stack, in a call: \(controller.isInCall)")
// Best effort: the leave this starts may not finish before the core stops, but the
// alternative is walking away from the call without telling the room at all.
if controller.isInCall {
controller.hangUp()
}
stack.stop()
}
/// Starts, or returns to, the call in the room.
func handleCallRequest(roomProxy: JoinedRoomProxyProtocol, isVoiceCall: Bool) {
if controller.isInCall {
guard controller.room?.roomID != roomProxy.id else {
// Reached while the call is still joining, before the service has an ongoing call
// for its own room check to match against.
MXLog.info("Returning to the call already starting in this room.")
actionsSubject.send(.restore)
return
}
// The controller ignores a second call, so wait for the running one to leave its room.
MXLog.info("Leaving the ongoing call to start the one requested in another room.")
pendingCallRequest = (roomProxy, isVoiceCall)
controller.hangUp()
return
}
// Starting rings the room; joining one already running happens quietly.
let callData = ElementCallData(isAudioCall: isVoiceCall,
isStartingCall: !roomProxy.infoPublisher.value.hasRoomCall)
controller.startCall(callData, room: NativeCallRoomContext(roomProxy: roomProxy))
actionsSubject.send(.present)
}
/// Asks the call to minimize. The screen only comes down once the controller says a system
/// window has started, which it answers through ``actions``.
func minimize() {
controller.requestMinimize()
}
func restore() {
controller.restore()
}
// MARK: - Private
private func handle(_ action: ElementCallControllerAction) {
switch action {
case .minimizeRequested:
// Nothing to do until we know what the call is minimizing into: the controller follows
// this with either window or no window.
break
case .restoreRequested:
actionsSubject.send(.restore)
case .ended:
actionsSubject.send(.dismiss)
controller.reset()
startPendingCall()
case .pictureInPictureStarted:
// Also reached when the system started the window on backgrounding, so this isn't
// necessarily a minimize we asked for.
actionsSubject.send(.minimize)
case .pictureInPictureUnavailable:
// Hiding the screen without a window to minimize into would leave the call running with
// no way back to mute or hang up.
MXLog.info("Staying on the call screen: no system window is available.")
actionsSubject.send(.restore)
}
}
private func startPendingCall() {
guard let pendingCallRequest else { return }
self.pendingCallRequest = nil
handleCallRequest(roomProxy: pendingCallRequest.roomProxy, isVoiceCall: pendingCallRequest.isVoiceCall)
}
/// Sends the call package's log lines to `MXLog`, keeping the package's own file and line so
/// entries point at its source rather than at here.
private struct Logger: ElementCallLoggingProtocol {
func log(_ record: ElementCallLogRecord) {
switch record.level {
case .debug: MXLog.debug(record.message, file: record.file, line: record.line)
case .info: MXLog.info(record.message, file: record.file, line: record.line)
case .warning: MXLog.warning(record.message, file: record.file, line: record.line)
case .error: MXLog.error(record.message, file: record.file, line: record.line)
}
}
}
/// Feeds the Rust RTC core's own records into `MXLog` too, so they end up in rageshakes next to
/// the SDK's. Idempotent, and must run before anything else uses the call package.
private static func installRTCLogBridge() {
MatrixRTCLogging.install { record in
// The core's own position when it has one, its module path otherwise.
let file = record.file ?? record.target
let line = Int(record.line ?? 0)
switch record.level {
case .error: MXLog.error(record.message, file: file, line: line)
case .warning: MXLog.warning(record.message, file: file, line: line)
case .info: MXLog.info(record.message, file: file, line: line)
case .debug: MXLog.debug(record.message, file: file, line: line)
case .verbose: MXLog.verbose(record.message, file: file, line: line)
}
}
}
}
@@ -0,0 +1,152 @@
//
// 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 Compound
import ElementCall
import SwiftUI
// Colours, icons and avatars for the call, read from this app's Compound.
//
// Why the package takes a theme instead of linking Compound itself: `Color.compound` is a single
// shared instance that Element Pro re-brands at runtime, and a second copy inside the package would
// never see the override. Every member is computed so each read goes to the live instance.
struct NativeCallTheme: ElementCallThemeProtocol {
var bgCanvasDefault: Color {
.compound.bgCanvasDefault
}
var bgCanvasDefaultLevel: Color {
.compound.bgCanvasDefaultLevel1
}
var bgSubtleSecondary: Color {
.compound.bgSubtleSecondary
}
var bgAccentRest: Color {
.compound.bgAccentRest
}
var bgActionPrimaryRest: Color {
.compound.bgActionPrimaryRest
}
var bgCriticalPrimary: Color {
.compound.bgCriticalPrimary
}
var iconPrimary: Color {
.compound.iconPrimary
}
var iconQuaternary: Color {
.compound.iconQuaternary
}
var iconAccentPrimary: Color {
.compound.iconAccentPrimary
}
var iconCriticalPrimary: Color {
.compound.iconCriticalPrimary
}
var iconOnSolidPrimary: Color {
.compound.iconOnSolidPrimary
}
var textPrimary: Color {
.compound.textPrimary
}
var textSecondary: Color {
.compound.textSecondary
}
var textCriticalPrimary: Color {
.compound.textCriticalPrimary
}
var borderSuccessSubtle: Color {
.compound.borderSuccessSubtle
}
var borderInteractiveSecondary: Color {
.compound.borderInteractiveSecondary
}
var bodySM: Font {
.compound.bodySM
}
var bodySMSemibold: Font {
.compound.bodySMSemibold
}
var bodyLGSemibold: Font {
.compound.bodyLGSemibold
}
}
struct NativeCallIcons: ElementCallIconRenderingProtocol {
func icon(_ icon: ElementCallIcon, size: ElementCallIconSize, relativeTo textStyle: ElementCallTextStyle?) -> AnyView {
// The package asks for a text style rather than a point size so CompoundIcon can scale it.
AnyView(CompoundIcon(Self.keyPath(for: icon), size: Self.size(for: size), relativeTo: Self.font(for: textStyle)))
}
private static func keyPath(for icon: ElementCallIcon) -> KeyPath<CompoundIcons, Image> {
switch icon {
case .endCall: \.endCall
case .micOn: \.micOnSolid
case .micOff: \.micOffSolid
case .videoCall: \.videoCallSolid
case .videoCallOff: \.videoCallOffSolid
case .switchCamera: \.switchCameraSolid
case .shareScreen: \.shareScreenSolid
case .volumeOn: \.volumeOnSolid
case .volumeOff: \.volumeOffSolid
case .raisedHand: \.raisedHandSolid
case .userProfile: \.userProfileSolid
case .overflow: \.overflowHorizontal
case .collapse: \.collapse
}
}
private static func size(for size: ElementCallIconSize) -> CompoundIcon.Size {
switch size {
case .xSmall: .xSmall
case .small: .small
case .medium: .medium
}
}
// Not redundant despite the target's default isolation: a static member of a type conforming to
// a nonisolated, Sendable port is nonisolated, and `Font.compound` is main actor-isolated.
@MainActor
private static func font(for textStyle: ElementCallTextStyle?) -> Font {
switch textStyle {
case .bodySM: .compound.bodySM
case .bodySMSemibold: .compound.bodySMSemibold
case .bodyLGSemibold: .compound.bodyLGSemibold
case nil: .compound.bodyMD
}
}
}
/// Keeps avatars looking like the rest of the app, and keeps media loading out of the package.
struct NativeCallAvatars: ElementCallAvatarRenderingProtocol {
let mediaProvider: MediaProviderProtocol?
func avatar(userID: String, displayName: String?, avatarURL: URL?, size: ElementCallAvatarSize) -> AnyView {
AnyView(LoadableAvatarImage(url: avatarURL,
name: displayName,
contentID: userID,
avatarSize: size == .thumbnail ? .user(on: .roomDetails) : .user(on: .memberDetails),
mediaProvider: mediaProvider))
}
}
@@ -0,0 +1,65 @@
//
// 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
/// Serves the call package's system-call port from `ElementCallService`.
///
/// Stays app-side because the same `CXProvider` and VoIP push registry serve the web-view call path
/// too, so ownership can't be handed to the package.
final class NativeCallSystem: ElementCallSystemProvidingProtocol {
/// Weak because the service owns the call stack, which owns the controller holding this port.
private weak var service: ElementCallServiceProtocol?
/// Stored so the events keep flowing off the subject itself, without reaching back through the
/// weak reference on every subscription.
private let serviceActions: AnyPublisher<ElementCallServiceAction, Never>
init(service: ElementCallServiceProtocol) {
self.service = service
serviceActions = service.actions
}
var events: AnyPublisher<ElementCallSystemEvent, Never> {
serviceActions
.compactMap { action in
switch action {
case .audioSessionActivated:
.audioSessionActivated
case .audioSessionDeactivated:
.audioSessionDeactivated
case .setAudioEnabled(let enabled, _):
// The system reports what the microphone should be; the port speaks in mutes.
.microphoneMuteChanged(isMuted: !enabled)
case .endCall(let roomID):
.endCallRequested(roomID: roomID)
// All navigation, handled by the flow coordinator. The call reaches this port later,
// as an ordinary start.
case .startCall, .receivedIncomingCallRequest, .nativeCall:
nil
}
}
.eraseToAnyPublisher()
}
func startCall(roomID: String, displayName: String, isVideo: Bool) async {
await service?.setupCallSession(roomID: roomID, roomDisplayName: displayName, isVideo: isVideo)
}
func reportConnected(roomID: String) {
service?.reportCallSessionConnected(roomID: roomID)
}
func endCall(roomID: String) {
service?.tearDownCallSession(roomID: roomID)
}
func setMicrophoneEnabled(_ enabled: Bool, roomID: String) {
service?.setAudioEnabled(enabled, roomID: roomID)
}
}
@@ -123,6 +123,11 @@ extension RoomInfoProxyProtocol {
isDirect && activeMembersCount == 1
}
/// The room's name, falling back to its identifier when it doesn't have one.
var displayNameOrID: String {
displayName ?? id
}
/// Find the first alias that matches the given homeserver
/// - Parameters:
/// - serverName: the homserver in question