Files
letro-ios/ElementX/Sources/Screens/CallScreen/View/CallScreen.swift
Mauro Romito f8d1ca54dd EC: handle back navigation from the webview
EC: remove native header

updated element call to 0.13.0-rc1
2025-06-26 16:05:26 +02:00

351 lines
16 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 Combine
import EmbeddedElementCall
import SFSafeSymbols
import SwiftUI
import WebKit
struct CallScreen: View {
@ObservedObject var context: CallScreenViewModel.Context
var body: some View {
content
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.compound.bgCanvasDefault.ignoresSafeArea())
.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(edges: .bottom)
}
}
}
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
// 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)
}
}
// 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()
roomProxy.sendCallNotificationIfNeededReturnValue = .success(())
let widgetDriver = ElementCallWidgetDriverMock()
widgetDriver.underlyingMessagePublisher = .init()
widgetDriver.underlyingActions = PassthroughSubject<ElementCallWidgetDriverAction, Never>().eraseToAnyPublisher()
widgetDriver.startBaseURLClientIDColorSchemeRageshakeURLAnalyticsConfigurationReturnValue = .success(URL.userDirectory)
roomProxy.elementCallWidgetDriverDeviceIDReturnValue = widgetDriver
return CallScreenViewModel(elementCallService: ElementCallServiceMock(.init()),
configuration: .init(roomProxy: roomProxy,
clientProxy: clientProxy,
clientID: "io.element.elementx",
elementCallBaseURL: "https://call.element.io",
elementCallBaseURLOverride: nil,
colorScheme: .light,
notifyOtherParticipants: false),
allowPictureInPicture: false,
appHooks: AppHooks(),
appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics)
}()
static var previews: some View {
CallScreen(context: viewModel.context)
}
}