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:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user