Files
letro-ios/ElementX/Sources/Screens/CallScreen/CallScreenViewModel.swift

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.bugReportRageshakeURL?.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)")
}
}
}