// // Copyright 2025 Element Creations Ltd. // Copyright 2022-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 AVKit import CallKit import Combine import SwiftUI typealias CallScreenViewModelType = StateStoreViewModel class CallScreenViewModel: CallScreenViewModelType, CallScreenViewModelProtocol { private let elementCallService: ElementCallServiceProtocol private let configuration: ElementCallConfiguration private let isPictureInPictureAllowed: Bool private let appSettings: AppSettings private let analyticsService: AnalyticsService private let widgetDriver: ElementCallWidgetDriverProtocol private let actionsSubject: PassthroughSubject = .init() var actions: AnyPublisher { actionsSubject.eraseToAnyPublisher() } @CancellableTask private var timeoutTask: Task? /// Designated initialiser /// - Parameters: /// - elementCallService: service responsible for setting up CallKit /// - roomProxy: The room in which the call should be created /// - callBaseURL: Which Element Call instance should be used /// - clientID: Something to identify the current client on the Element Call side init(elementCallService: ElementCallServiceProtocol, configuration: ElementCallConfiguration, allowPictureInPicture: Bool, appHooks: AppHooks, appSettings: AppSettings, analyticsService: AnalyticsService) { self.elementCallService = elementCallService self.configuration = configuration self.appSettings = appSettings self.analyticsService = analyticsService isPictureInPictureAllowed = allowPictureInPicture var isGenericCallLink = false switch configuration.kind { case .genericCallLink(let url): widgetDriver = GenericCallLinkWidgetDriver(url: url) isGenericCallLink = true case .roomCall(let roomProxy, let clientProxy, _, _, _, _, _): guard let deviceID = clientProxy.deviceID else { fatalError("Missing device ID for the call.") } widgetDriver = roomProxy.elementCallWidgetDriver(deviceID: deviceID) } super.init(initialViewState: CallScreenViewState(script: CallScreenJavaScriptMessageName.allCasesInjectionScript, isGenericCallLink: isGenericCallLink, certificateValidator: appHooks.certificateValidatorHook)) elementCallService.actions .receive(on: DispatchQueue.main) .sink { [weak self] action in guard let self else { return } switch action { case let .setAudioEnabled(enabled, roomID): guard roomID == configuration.callRoomID else { MXLog.error("Received mute request for a different room: \(roomID) != \(configuration.callRoomID)") return } Task { await self.setAudioEnabled(enabled) } default: break } } .store(in: &cancellables) widgetDriver.messagePublisher .receive(on: DispatchQueue.main) .sink { [weak self] receivedMessage in guard let self else { return } Task { await self.postJSONToWidget(receivedMessage) } } .store(in: &cancellables) widgetDriver.actions .receive(on: DispatchQueue.main) .sink { [weak self] action in guard let self else { return } switch action { case .callEnded: actionsSubject.send(.dismiss) case .mediaStateChanged(let audioEnabled, _): elementCallService.setAudioEnabled(audioEnabled, roomID: configuration.callRoomID) } } .store(in: &cancellables) NotificationCenter.default .publisher(for: AVAudioSession.routeChangeNotification) .sink { [weak self] _ in Task { await self?.updateOutputsListOnWeb() } } .store(in: &cancellables) setupCall() } override func process(viewAction: CallScreenViewAction) { switch viewAction { case .urlChanged(let url): guard let url else { return } MXLog.info("URL changed to: \(url)") case .pictureInPictureIsAvailable(let controller): actionsSubject.send(.pictureInPictureIsAvailable(controller)) case .navigateBack: Task { await handleBackwardsNavigation() } case .pictureInPictureWillStop: actionsSubject.send(.pictureInPictureStopped) case .endCall: actionsSubject.send(.dismiss) case .mediaCapturePermissionGranted: Task { await updateOutputsListOnWeb() } case .outputDeviceSelected(deviceID: let deviceID): handleOutputDeviceSelected(deviceID: deviceID) case .widgetAction(let message): Task { await handleWidgetAction(message: message) } } } func stop() { Task { await hangup() } elementCallService.tearDownCallSession() UIDevice.current.isProximityMonitoringEnabled = false } // MARK: - Private private func handleWidgetAction(message: String) async { if timeoutTask != nil, let decodedMessage = try? DecodedWidgetMessage.decode(message: message), decodedMessage.hasLoaded { // This means that the call room was joined succesfully, we can stop the timeout task timeoutTask = nil } await widgetDriver.handleMessage(message) } private func setupCall() { switch configuration.kind { case .genericCallLink(let url): state.url = url // We need widget messaging to work before enabling CallKit, otherwise mute, hangup etc do nothing. case .roomCall(let roomProxy, _, let clientID, let voiceOnly, let elementCallBaseURL, let elementCallBaseURLOverride, let colorScheme): Task { [weak self] in guard let self else { return } let baseURL = if let elementCallBaseURLOverride { elementCallBaseURLOverride } else { elementCallBaseURL } // We only set the analytics configuration if analytics are enabled let analyticsConfiguration: ElementCallAnalyticsConfiguration? = if analyticsService.isEnabled { .init(posthogAPIHost: appSettings.elementCallPosthogAPIHost, posthogAPIKey: appSettings.elementCallPosthogAPIKey, sentryDSN: appSettings.elementCallPosthogSentryDSN) } else { nil } let rageshakeURL: String? = if case let .url(baseURL) = appSettings.bugReportRageshakeURL.publisher.value { baseURL.absoluteString } else { nil } switch await widgetDriver.start(baseURL: baseURL, clientID: clientID, colorScheme: colorScheme, voiceOnly: voiceOnly, rageshakeURL: rageshakeURL, analyticsConfiguration: analyticsConfiguration) { case .success(let url): state.url = url case .failure(let error): MXLog.error("Failed starting ElementCall Widget Driver with error: \(error)") state.bindings.alertInfo = .init(id: UUID(), title: L10n.errorUnknown, primaryButton: .init(title: L10n.actionOk) { self.actionsSubject.send(.dismiss) }) return } await elementCallService.setupCallSession(roomID: roomProxy.id, roomDisplayName: roomProxy.infoPublisher.value.displayName ?? roomProxy.id) } timeoutTask = Task { [weak self] in try? await Task.sleep(for: .seconds(10)) guard !Task.isCancelled, let self else { return } MXLog.error("Failed to join Element Call: Timeout") state.bindings.alertInfo = .init(id: UUID(), title: L10n.commonError, message: L10n.errorUnknown, primaryButton: .init(title: L10n.actionDismiss) { [weak self] in self?.actionsSubject.send(.dismiss) }) timeoutTask = nil } } } /// This should always match the web app value private static let earpieceID = "earpiece-id" private func handleOutputDeviceSelected(deviceID: String) { let isEarpiece = deviceID == Self.earpieceID MXLog.info("Is earpiece: \(isEarpiece)") UIDevice.current.isProximityMonitoringEnabled = isEarpiece } private func handleBackwardsNavigation() async { guard state.url != nil, isPictureInPictureAllowed, let requestPictureInPictureHandler = state.bindings.requestPictureInPictureHandler else { actionsSubject.send(.dismiss) return } switch await requestPictureInPictureHandler() { case .success: actionsSubject.send(.pictureInPictureStarted) case .failure: actionsSubject.send(.dismiss) } } private func setAudioEnabled(_ enabled: Bool) async { let message = ElementCallWidgetMessage(direction: .toWidget, action: .mediaState, data: .init(audioEnabled: enabled), widgetId: widgetDriver.widgetID) await postMessageToWidget(message) } func hangup() async { let message = ElementCallWidgetMessage(direction: .fromWidget, action: .hangup, widgetId: widgetDriver.widgetID) await postMessageToWidget(message) } private func postMessageToWidget(_ message: ElementCallWidgetMessage) async { let data: Data do { data = try JSONEncoder().encode(message) } catch { MXLog.error("Failed encoding widget message with error: \(error)") return } guard let json = String(data: data, encoding: .utf8) else { MXLog.error("Invalid data for widget message") return } await postJSONToWidget(json) } private func postJSONToWidget(_ json: String) async { do { let message = "postMessage(\(json), '*')" let result = try await state.bindings.javaScriptEvaluator?(message) MXLog.debug("Evaluated javascript: \(json) with result: \(String(describing: result))") } catch { MXLog.error("Received javascript evaluation error: \(error)") } } /// This function updates the list of available audio outputs on the web side /// however since we actually handle switching the audio output through the OS, /// this is only used to inform the webview when the speaker is selected, /// so that the option to use the earpiece can be displayed. private func updateOutputsListOnWeb() async { guard let currentOutput = AVAudioSession.sharedInstance().currentRoute.outputs.first else { return } let deviceList = if currentOutput.portType == .builtInSpeaker { // This allows the webview to display the earpiece option "{id: '\(currentOutput.uid)', name: '\(currentOutput.portName)', forEarpiece: true, isSpeaker: true}" } else { // Doesn't matter because the switch is handled through the OS "{id: 'dummy', name: 'dummy'}" } let javaScript = "window.controls.setAvailableOutputDevices([\(deviceList)])" do { let result = try await state.bindings.javaScriptEvaluator?(javaScript) MXLog.debug("Evaluated with result: \(String(describing: result))") } catch { MXLog.error("Received javascript evaluation error: \(error)") } } }