mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-01 18:23:03 +00:00
a520d94760
* Enable Swift 6.2 with default main actor isolation for the app target Turn on SWIFT_VERSION 6.2, SWIFT_APPROACHABLE_CONCURRENCY and SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor for the ElementX target, matching the NSE and Share Extension (which already use 6.2 without the main actor default). # Conflicts: # ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved * Update the code generation templates for Swift 6 Emit nonisolated/@unchecked Sendable mocks and nonisolated string enums, and mark async mock methods @concurrent so they keep suspending like the real implementations (otherwise intermediate states are unobservable in tests). Includes the regenerated mocks and strings. # Conflicts: # ElementX/Sources/Mocks/Generated/GeneratedMocks.swift * Make the data layer and services nonisolated and Sendable Annotate the value-type models, Rust SDK proxy wrappers, timeline item graph, settings, logging and NSE-shared types as nonisolated, and conform the service protocols to Sendable so their existentials can cross isolation boundaries. These types run off the main actor and no longer inherit the target's default main actor isolation. * Adopt the new isolation across call sites, mocks and signatures Update call sites, closure signatures and mock configuration helpers to match the nonisolated/Sendable data layer, including the main actor isolated mock inits and the @Sendable filter on recentlyVisitedRooms. ^ Conflicts: ^ ElementX/Sources/Services/SecureBackup/SecureBackupController.swift * Remove redundant @MainActor annotations With the app target defaulting to main actor isolation, the explicit @MainActor on view models, coordinators, views and their protocols is no longer required. * Fix concurrency hotspots surfaced by the migration Resolve the room summary main-thread deadlock by moving diff processing onto AsyncStreams, add the SDKListener.onMainActor FIFO bridge for SDK callbacks, hop KVO/timer/UIKit callbacks to the main actor via assumeIsolated, add isolated deinits, and apply the few justified nonisolated(unsafe) escapes for queue-confined Apple APIs. * Fix the unit tests for Swift 6 concurrency Hop the waitForConfirmation body onto the caller's actor (Swift Testing runs confirmation closures off-actor), and adjust the affected suites and async signatures. * offload diffs on concurrent threads instead of main actor * make room info protocols sendable * use a task group for fetching data so that everything runs concurrently in the TextRoomTimelineView when getting the preview. * use the generic onMainActor implementation, which already makes a stream and send the continuation on the main actor. * small pr comments issues being addressed * update enterprise * Update StaticCode to 1.1.0 and specify the required Swift isolation. * fix ElementCallServiceTests * update enterprise * re-generate mocks * update enterprise * enterprise new code conformed to swift 6 --------- Co-authored-by: Doug <douglase@element.io>
406 lines
18 KiB
Swift
406 lines
18 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)
|
|
}
|
|
}
|
|
|
|
class Coordinator: NSObject, WKUIDelegate, WKNavigationDelegate, AVPictureInPictureControllerDelegate {
|
|
private weak var viewModelContext: CallScreenViewModel.Context?
|
|
|
|
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
|
|
pictureInPictureViewController = AVPictureInPictureVideoCallViewController()
|
|
pictureInPictureViewController.preferredContentSize = PiPSize.portrait.size
|
|
|
|
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 {
|
|
// The completion is called on the main thread which the continuation
|
|
// also resumes on, so the result never actually crosses threads.
|
|
nonisolated(unsafe) let result = result
|
|
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 .onPipMediaOrientationUpdate:
|
|
guard let orientation = message.body as? String else { return }
|
|
switch orientation {
|
|
case "portrait":
|
|
pictureInPictureViewController.preferredContentSize = PiPSize.portrait.size
|
|
case "landscape":
|
|
pictureInPictureViewController.preferredContentSize = PiPSize.landscape.size
|
|
default:
|
|
break
|
|
}
|
|
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, 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)
|
|
}
|
|
}
|
|
|
|
private enum PiPSize {
|
|
case portrait
|
|
case landscape
|
|
|
|
var size: CGSize {
|
|
switch self {
|
|
case .portrait:
|
|
.init(width: 1080, height: 1920)
|
|
case .landscape:
|
|
.init(width: 1920, height: 1080)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Previews
|
|
|
|
struct CallScreen_Previews: PreviewProvider {
|
|
static let viewModel = makeViewModel()
|
|
|
|
static var previews: some View {
|
|
CallScreen(context: viewModel.context)
|
|
}
|
|
|
|
static func makeViewModel() -> CallScreenViewModel {
|
|
let clientProxy = ClientProxyMock()
|
|
clientProxy.deviceID = "call-device-id"
|
|
|
|
let roomProxy = JoinedRoomProxyMock()
|
|
|
|
let widgetDriver = ElementCallWidgetDriverMock()
|
|
widgetDriver.messagePublisher = .init()
|
|
widgetDriver.actions = PassthroughSubject<ElementCallWidgetDriverAction, Never>().eraseToAnyPublisher()
|
|
widgetDriver.startBaseURLClientIDColorSchemeVoiceOnlyRageshakeURLAnalyticsConfigurationReturnValue = .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,
|
|
voiceOnly: false,
|
|
colorScheme: .light),
|
|
allowPictureInPicture: false,
|
|
appSettings: .volatile(),
|
|
analyticsService: AnalyticsServiceMock(.init()))
|
|
}
|
|
}
|