mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-10-31 16:24:11 +00:00
174 lines
7.2 KiB
Swift
174 lines
7.2 KiB
Swift
//
|
|
// 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()
|
|
}
|
|
|
|
/// Prefix for native call related logs
|
|
nonisolated static let logPrefix = "[NativeCall]"
|
|
|
|
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("\(Self.logPrefix) 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("\(Self.logPrefix) 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("\(Self.logPrefix) 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("\(Self.logPrefix) 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) {
|
|
let message = "\(NativeCallStack.logPrefix) \(record.message)"
|
|
switch record.level {
|
|
case .debug: MXLog.debug(message, file: record.file, line: record.line)
|
|
case .info: MXLog.info(message, file: record.file, line: record.line)
|
|
case .warning: MXLog.warning(message, file: record.file, line: record.line)
|
|
case .error: MXLog.error(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)
|
|
let message = "\(logPrefix) \(record.target): \(record.message)"
|
|
switch record.level {
|
|
case .error: MXLog.error(message, file: file, line: line)
|
|
case .warning: MXLog.warning(message, file: file, line: line)
|
|
case .info: MXLog.info(message, file: file, line: line)
|
|
case .debug: MXLog.debug(message, file: file, line: line)
|
|
case .verbose: MXLog.verbose(message, file: file, line: line)
|
|
}
|
|
}
|
|
}
|
|
}
|