Files
letro-ios/ElementX/Sources/Screens/CallScreen/CallScreenViewModel.swift
Timo 8cc035e5af Add isSpeaker to available devices list.
This is required so that EC "thinks" there is a speaker and an earpiece. If this condition is not met it will not show the toggle button.

So this fixes showing the toggle button on EX for earpiece speaker toggle.
2025-06-26 18:27:06 +02:00

312 lines
13 KiB
Swift

//
// Copyright 2022-2024 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<CallScreenViewState, CallScreenViewAction>
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<CallScreenViewModelAction, Never> = .init()
var actions: AnyPublisher<CallScreenViewModelAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
@CancellableTask
private var timeoutTask: Task<Void, Never>?
/// 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
switch configuration.kind {
case .genericCallLink(let url):
widgetDriver = GenericCallLinkWidgetDriver(url: url)
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,
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()
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
}
}
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.hasJoined {
// 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 elementCallBaseURL, let elementCallBaseURLOverride, let colorScheme, let notifyOtherParticipants):
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 = analyticsService.isEnabled ? ElementCallAnalyticsConfiguration(posthogAPIHost: appSettings.elementCallPosthogAPIHost,
posthogAPIKey: appSettings.elementCallPosthogAPIKey,
sentryDSN: appSettings.elementCallPosthogSentryDSN) : nil
switch await widgetDriver.start(baseURL: baseURL,
clientID: clientID,
colorScheme: colorScheme,
rageshakeURL: appSettings.bugReportServiceBaseURL?.absoluteString,
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)
if notifyOtherParticipants {
_ = await roomProxy.sendCallNotificationIfNeeded()
}
}
}
}
// 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("[handleOutputDeviceSelected] 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)")
}
}
}