mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-10-31 16:24:11 +00:00
ff5dcb040d
Removes the ServiceLocator singleton and instead directly injects the dependencies it was holding for more control over instantiation of the various component subtrees and better visibility over how they're being used.
388 lines
17 KiB
Swift
388 lines
17 KiB
Swift
//
|
|
// 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 Combine
|
|
import EmbeddedElementCall
|
|
import SFSafeSymbols
|
|
import SwiftUI
|
|
import WebKit
|
|
|
|
struct CallScreen: View {
|
|
@ObservedObject var context: CallScreenViewModel.Context
|
|
|
|
var body: some View {
|
|
ElementNavigationStack {
|
|
content
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
.background(Color.compound.bgCanvasDefault.ignoresSafeArea())
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar(.hidden, for: .navigationBar)
|
|
.toolbar { toolbar }
|
|
}
|
|
.alert(item: $context.alertInfo)
|
|
}
|
|
|
|
@ViewBuilder
|
|
var content: some View {
|
|
if context.viewState.url == nil {
|
|
ProgressView()
|
|
} else {
|
|
CallView(url: context.viewState.url, viewModelContext: context)
|
|
// This URL is stable, forces view reloads if this representable is ever reused for another url
|
|
.id(context.viewState.url)
|
|
.ignoresSafeArea()
|
|
}
|
|
}
|
|
|
|
var toolbar: some ToolbarContent {
|
|
ToolbarItem(placement: .cancellationAction) {
|
|
Button { context.send(viewAction: .navigateBack) } label: {
|
|
Image(systemSymbol: .chevronBackward)
|
|
.fontWeight(.semibold)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct CallView: UIViewRepresentable {
|
|
/// The top-level view this representable displays. It wraps the web view when picture in picture isn't running.
|
|
typealias WebViewWrapper = UIView
|
|
|
|
let url: URL?
|
|
let viewModelContext: CallScreenViewModel.Context
|
|
|
|
func makeUIView(context: Context) -> WebViewWrapper {
|
|
context.coordinator.webViewWrapper
|
|
}
|
|
|
|
func makeCoordinator() -> Coordinator {
|
|
Coordinator(viewModelContext: viewModelContext)
|
|
}
|
|
|
|
func updateUIView(_ callWebView: WebViewWrapper, context: Context) {
|
|
if let url {
|
|
context.coordinator.load(url)
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
class Coordinator: NSObject, WKUIDelegate, WKNavigationDelegate, AVPictureInPictureControllerDelegate {
|
|
private weak var viewModelContext: CallScreenViewModel.Context?
|
|
private let certificateValidator: CertificateValidatorHookProtocol
|
|
|
|
private var webView: WKWebView!
|
|
private var pictureInPictureController: AVPictureInPictureController?
|
|
private let pictureInPictureViewController: AVPictureInPictureVideoCallViewController
|
|
private var routePickerView: AVRoutePickerView!
|
|
|
|
/// The view to be shown in the app. This will contain the web view when picture in picture isn't running.
|
|
let webViewWrapper = WebViewWrapper(frame: .zero)
|
|
|
|
private var url: URL!
|
|
|
|
init(viewModelContext: CallScreenViewModel.Context) {
|
|
self.viewModelContext = viewModelContext
|
|
certificateValidator = viewModelContext.viewState.certificateValidator
|
|
pictureInPictureViewController = AVPictureInPictureVideoCallViewController()
|
|
pictureInPictureViewController.preferredContentSize = CGSize(width: 1920, height: 1080)
|
|
|
|
super.init()
|
|
|
|
DispatchQueue.main.async { // Avoid `Publishing changes from within view update` warnings
|
|
viewModelContext.javaScriptEvaluator = self.evaluateJavaScript
|
|
viewModelContext.requestPictureInPictureHandler = self.requestPictureInPicture
|
|
}
|
|
|
|
let configuration = WKWebViewConfiguration()
|
|
|
|
let userContentController = WKUserContentController()
|
|
CallScreenJavaScriptMessageName.allCases.forEach {
|
|
userContentController.add(WKScriptMessageHandlerWrapper(self), name: $0.rawValue)
|
|
}
|
|
|
|
// Required to allow a webview that uses file URL to load its own assets
|
|
configuration.preferences.setValue(true, forKey: "allowFileAccessFromFileURLs")
|
|
configuration.userContentController = userContentController
|
|
configuration.allowsInlineMediaPlayback = true
|
|
configuration.allowsPictureInPictureMediaPlayback = true
|
|
|
|
if let script = viewModelContext.viewState.script {
|
|
let userScript = WKUserScript(source: script, injectionTime: .atDocumentEnd, forMainFrameOnly: false)
|
|
configuration.userContentController.addUserScript(userScript)
|
|
}
|
|
|
|
webView = WKWebView(frame: .zero, configuration: configuration)
|
|
webView.uiDelegate = self
|
|
webView.navigationDelegate = self
|
|
webView.isInspectable = true
|
|
webView.scrollView.contentInsetAdjustmentBehavior = .never // Let Element Call manage the safe areas within the web view.
|
|
|
|
webView.customUserAgent = UserAgentBuilder.makeASCIIUserAgent()
|
|
|
|
// https://stackoverflow.com/a/77963877/730924
|
|
webView.allowsLinkPreview = true
|
|
|
|
// Try matching Element Call colors
|
|
webView.isOpaque = false
|
|
webView.backgroundColor = .compound.bgCanvasDefault
|
|
webView.scrollView.backgroundColor = .compound.bgCanvasDefault
|
|
|
|
// This button is always hidden and is only used to be programmaticaly tapped
|
|
routePickerView = AVRoutePickerView(frame: .zero)
|
|
routePickerView.isHidden = true
|
|
routePickerView.isUserInteractionEnabled = false
|
|
webView.addSubview(routePickerView)
|
|
|
|
webViewWrapper.addMatchedSubview(webView)
|
|
|
|
if AVPictureInPictureController.isPictureInPictureSupported() {
|
|
let pictureInPictureController = AVPictureInPictureController(contentSource: .init(activeVideoCallSourceView: webViewWrapper,
|
|
contentViewController: pictureInPictureViewController))
|
|
pictureInPictureController.delegate = self
|
|
self.pictureInPictureController = pictureInPictureController
|
|
viewModelContext.send(viewAction: .pictureInPictureIsAvailable(pictureInPictureController))
|
|
}
|
|
}
|
|
|
|
func load(_ url: URL) {
|
|
self.url = url
|
|
// The only file URL we allow is the one coming from our own local ElementCall bundle, so it's okay to allow read permission only to our local EC bundle
|
|
if url.isFileURL {
|
|
webView.loadFileURL(url, allowingReadAccessTo: EmbeddedElementCall.bundle.bundleURL)
|
|
} else {
|
|
let request = URLRequest(url: url)
|
|
webView.load(request)
|
|
}
|
|
}
|
|
|
|
func evaluateJavaScript(_ script: String) async throws -> Any? {
|
|
// After testing different scenarios it seems that when using async/await version of these
|
|
// methods wkwebView expects JavaScript to return with a value (something other than Void),
|
|
// if there is no value returning from the JavaScript that you evaluate you will have a crash.
|
|
try await withCheckedThrowingContinuation { [weak self] continuaton in
|
|
self?.webView.evaluateJavaScript(script) { result, error in
|
|
if let error {
|
|
continuaton.resume(throwing: error)
|
|
} else {
|
|
continuaton.resume(returning: result)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
|
|
guard let handlerID = CallScreenJavaScriptMessageName(rawValue: message.name) else {
|
|
return
|
|
}
|
|
|
|
switch handlerID {
|
|
case .widgetAction:
|
|
guard let message = message.body as? String else { return }
|
|
viewModelContext?.send(viewAction: .widgetAction(message: message))
|
|
case .showNativeOutputDevicePicker:
|
|
DispatchQueue.main.async {
|
|
self.tapRoutePickerView()
|
|
}
|
|
case .onOutputDeviceSelect:
|
|
guard let deviceID = message.body as? String else { return }
|
|
viewModelContext?.send(viewAction: .outputDeviceSelected(deviceID: deviceID))
|
|
case .onBackButtonPressed:
|
|
viewModelContext?.send(viewAction: .navigateBack)
|
|
case .forwardLogs:
|
|
guard let body = message.body as? [String: String],
|
|
let level = body["level"],
|
|
let logMessage = body["message"] else { return }
|
|
|
|
switch level {
|
|
case "log", "debug":
|
|
MXLog.debug("[ElementCall]: \(logMessage)")
|
|
case "info":
|
|
MXLog.info("[ElementCall]: \(logMessage)")
|
|
case "warn":
|
|
MXLog.warning("[ElementCall]: \(logMessage)")
|
|
case "error":
|
|
MXLog.error("[ElementCall]: \(logMessage)")
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
/// This function is called by the webview output routing button
|
|
/// it allows to open the OS output selector using the hidden button.
|
|
private func tapRoutePickerView() {
|
|
guard let button = routePickerView.subviews.first(where: { $0 is UIButton }) as? UIButton else {
|
|
return
|
|
}
|
|
|
|
button.sendActions(for: .touchUpInside)
|
|
}
|
|
|
|
// MARK: - WKUIDelegate
|
|
|
|
func webView(_ webView: WKWebView, decideMediaCapturePermissionsFor origin: WKSecurityOrigin, initiatedBy frame: WKFrameInfo, type: WKMediaCaptureType) async -> WKPermissionDecision {
|
|
// Allow if the origin is local, otherwise don't allow permissions for domains different than what the call was started on
|
|
guard origin.protocol == "file" || origin.host == url.host else {
|
|
return .deny
|
|
}
|
|
|
|
viewModelContext?.send(viewAction: .mediaCapturePermissionGranted)
|
|
return .grant
|
|
}
|
|
|
|
// MARK: - WKNavigationDelegate
|
|
|
|
func webView(_ webView: WKWebView, respondTo challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) {
|
|
await certificateValidator.respondTo(challenge)
|
|
}
|
|
|
|
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction) async -> WKNavigationActionPolicy {
|
|
if let navigationURL = navigationAction.request.url {
|
|
// Do not allow navigation to a different URL scheme.
|
|
if navigationURL.scheme != url.scheme {
|
|
return .cancel
|
|
}
|
|
|
|
// Allow any content from the main URL.
|
|
if navigationURL.host == url.host {
|
|
return .allow
|
|
}
|
|
}
|
|
|
|
// Additionally allow any embedded content such as captchas.
|
|
if let targetFrame = navigationAction.targetFrame, !targetFrame.isMainFrame {
|
|
return .allow
|
|
}
|
|
|
|
// Otherwise the request is invalid.
|
|
return .cancel
|
|
}
|
|
|
|
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
|
viewModelContext?.send(viewAction: .urlChanged(webView.url))
|
|
}
|
|
|
|
// MARK: - Picture in Picture
|
|
|
|
func requestPictureInPicture() async -> Result<Void, CallScreenError> {
|
|
guard let pictureInPictureController,
|
|
pictureInPictureController.isPictureInPicturePossible,
|
|
case .success(true) = await webViewCanEnterPictureInPicture() else {
|
|
return .failure(.pictureInPictureNotAvailable)
|
|
}
|
|
|
|
pictureInPictureController.startPictureInPicture()
|
|
return .success(())
|
|
}
|
|
|
|
func stopPictureInPicture() {
|
|
pictureInPictureController?.stopPictureInPicture()
|
|
}
|
|
|
|
nonisolated func pictureInPictureControllerWillStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
|
|
Task { @MainActor in
|
|
// We move the view via the delegate so it works when you background the app without calling requestPictureInPicture
|
|
pictureInPictureViewController.view.addMatchedSubview(webView)
|
|
_ = try? await evaluateJavaScript("controls.enablePip()")
|
|
}
|
|
}
|
|
|
|
nonisolated func pictureInPictureControllerDidStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
|
|
Task { @MainActor in
|
|
// Double check that the controller is definitely showing a page that supports picture in picture.
|
|
// This is necessary as it doesn't get checked when backgrounding the app or tapping a notification.
|
|
guard case .success(true) = await webViewCanEnterPictureInPicture() else {
|
|
MXLog.error("Picture in picture started on a webpage that doesn't support it. Ending the call.")
|
|
viewModelContext?.send(viewAction: .endCall)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
nonisolated func pictureInPictureControllerWillStopPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
|
|
Task { await viewModelContext?.send(viewAction: .pictureInPictureWillStop) }
|
|
}
|
|
|
|
nonisolated func pictureInPictureControllerDidStopPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
|
|
Task { @MainActor in
|
|
webViewWrapper.addMatchedSubview(webView)
|
|
_ = try? await evaluateJavaScript("controls.disablePip()")
|
|
}
|
|
}
|
|
|
|
/// Whether the web view can do picture in picture or not (e.g. it is showing an error or the page didn't load).
|
|
private func webViewCanEnterPictureInPicture() async -> Result<Bool, CallScreenError> {
|
|
do {
|
|
guard let canEnterPictureInPicture = try await evaluateJavaScript("controls.canEnterPip()") as? Bool else {
|
|
MXLog.error("canEnterPip returned an unexpected value, skipping picture in picture.")
|
|
return .failure(.pictureInPictureNotAvailable)
|
|
}
|
|
MXLog.info("canEnterPip returned \(canEnterPictureInPicture)")
|
|
return .success(canEnterPictureInPicture)
|
|
} catch {
|
|
MXLog.error("Error checking canEnterPip: \(error)")
|
|
return .failure(.pictureInPictureNotAvailable)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Avoids retain loops between the configuration and webView coordinator
|
|
private class WKScriptMessageHandlerWrapper: NSObject, WKScriptMessageHandler {
|
|
private weak var coordinator: Coordinator?
|
|
|
|
init(_ coordinator: Coordinator) {
|
|
self.coordinator = coordinator
|
|
}
|
|
|
|
// MARK: - WKScriptMessageHandler
|
|
|
|
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
|
|
coordinator?.userContentController(userContentController, didReceive: message)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Previews
|
|
|
|
struct CallScreen_Previews: PreviewProvider {
|
|
static let viewModel = {
|
|
let clientProxy = ClientProxyMock()
|
|
clientProxy.deviceID = "call-device-id"
|
|
|
|
let roomProxy = JoinedRoomProxyMock()
|
|
|
|
let widgetDriver = ElementCallWidgetDriverMock()
|
|
widgetDriver.underlyingMessagePublisher = .init()
|
|
widgetDriver.underlyingActions = PassthroughSubject<ElementCallWidgetDriverAction, Never>().eraseToAnyPublisher()
|
|
widgetDriver.startBaseURLClientIDColorSchemeVoiceOnlyRageshakeURLAnalyticsConfigurationReturnValue = .success(URL.userDirectory)
|
|
|
|
roomProxy.elementCallWidgetDriverDeviceIDReturnValue = widgetDriver
|
|
|
|
let appSettings = AppSettings()
|
|
let analytics = AnalyticsService.mock(settings: appSettings)
|
|
|
|
return CallScreenViewModel(elementCallService: ElementCallServiceMock(.init()),
|
|
configuration: .init(roomProxy: roomProxy,
|
|
clientProxy: clientProxy,
|
|
clientID: "io.element.elementx",
|
|
elementCallBaseURL: "https://call.element.io",
|
|
elementCallBaseURLOverride: nil,
|
|
voiceOnly: false,
|
|
colorScheme: .light),
|
|
allowPictureInPicture: false,
|
|
appHooks: AppHooks(),
|
|
appSettings: appSettings,
|
|
analyticsService: analytics)
|
|
}()
|
|
|
|
static var previews: some View {
|
|
CallScreen(context: viewModel.context)
|
|
}
|
|
}
|