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.
312 lines
13 KiB
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.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)")
|
|
}
|
|
}
|
|
}
|