From 304070e8cceeba58ab3928c80854edd281e10747 Mon Sep 17 00:00:00 2001 From: Doug <6060466+pixlwave@users.noreply.github.com> Date: Wed, 21 Aug 2024 17:48:57 +0100 Subject: [PATCH] Use native video call picture in picture! (#3190) * Use native video call picture in picture! * Handle isSupported, isPossible and web view errors. * Use new canEnterPip method. * Hide a room's Call button when already joined to the call. * Tweak feature flag description. * PR comments. --- ElementX.xcodeproj/project.pbxproj | 4 + .../Sources/Application/AppSettings.swift | 5 +- .../RoomFlowCoordinator.swift | 5 + .../UserSessionFlowCoordinator.swift | 3 +- .../Mocks/ElementCallServiceMock.swift | 5 +- .../Mocks/Generated/GeneratedMocks.swift | 6 +- .../Sources/Other/Extensions/UIView.swift | 30 +++++ .../CallScreen/CallScreenCoordinator.swift | 2 +- .../Screens/CallScreen/CallScreenModels.swift | 9 +- .../CallScreen/CallScreenViewModel.swift | 54 +++------ .../Screens/CallScreen/View/CallScreen.swift | 103 +++++++++++++++--- .../RoomScreen/RoomScreenCoordinator.swift | 2 + .../Screens/RoomScreen/RoomScreenModels.swift | 1 + .../RoomScreen/RoomScreenViewModel.swift | 14 ++- .../Screens/RoomScreen/View/RoomScreen.swift | 6 +- .../DeveloperOptionsScreenModels.swift | 1 + .../View/DeveloperOptionsScreen.swift | 5 + .../ElementCallConfiguration.swift | 2 +- .../ElementCall/ElementCallService.swift | 9 +- .../ElementCallServiceProtocol.swift | 2 +- .../UITests/UITestsAppCoordinator.swift | 13 +++ .../Sources/RoomFlowCoordinatorTests.swift | 1 + .../Sources/RoomScreenViewModelTests.swift | 40 +++++++ 23 files changed, 256 insertions(+), 66 deletions(-) create mode 100644 ElementX/Sources/Other/Extensions/UIView.swift diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index 493e0b5cf..1f502a0c3 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -665,6 +665,7 @@ 95690DDD9D547D3D842ACBE3 /* AnalyticsSettingsScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BD371B60E07A5324B9507EF /* AnalyticsSettingsScreenCoordinator.swift */; }; 9586E90A447C4896C0CA3A8E /* TimelineItemReplyDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE89A8BD65CCE3FCC925CA14 /* TimelineItemReplyDetails.swift */; }; 95E7B236F7116CACE05A6BC9 /* BlockedUsersScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A16D0F226B1819D017531647 /* BlockedUsersScreenCoordinator.swift */; }; + 9603EEF6DE980BB1D15D4707 /* UIView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05A3E8741D199CD1A37F4CBF /* UIView.swift */; }; 962A4F8AD6312804E2C6BB6E /* PhotoLibraryPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = A232D9156D225BD9FD1D0C43 /* PhotoLibraryPicker.swift */; }; 964B9D2EC38C488C360CE0C9 /* HomeScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = B902EA6CD3296B0E10EE432B /* HomeScreen.swift */; }; 968A5B890004526AB58A217C /* AvatarSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = E24B88AD3D1599E8CB1376E0 /* AvatarSize.swift */; }; @@ -1203,6 +1204,7 @@ 054F469E433864CC6FE6EE8E /* ServerSelectionUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerSelectionUITests.swift; sourceTree = ""; }; 05512FB13987D221B7205DE0 /* HomeScreenRecoveryKeyConfirmationBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeScreenRecoveryKeyConfirmationBanner.swift; sourceTree = ""; }; 05596E4A11A8C9346E9E54AE /* SoftLogoutScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoftLogoutScreenCoordinator.swift; sourceTree = ""; }; + 05A3E8741D199CD1A37F4CBF /* UIView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIView.swift; sourceTree = ""; }; 05AF58372CA884A789EB9C5A /* AppMediatorProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppMediatorProtocol.swift; sourceTree = ""; }; 05F598B1B346DAF223651C91 /* LoginScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginScreenCoordinator.swift; sourceTree = ""; }; 0685156EB62D7E243F097CFC /* ServerSelectionScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerSelectionScreenViewModelProtocol.swift; sourceTree = ""; }; @@ -3194,6 +3196,7 @@ 40B21E611DADDEF00307E7AC /* String.swift */, A40C19719687984FD9478FBE /* Task.swift */, 287FC98AF2664EAD79C0D902 /* UIDevice.swift */, + 05A3E8741D199CD1A37F4CBF /* UIView.swift */, BE148A4FFEE853C5A281500C /* UNNotificationContent.swift */, 227AC5D71A4CE43512062243 /* URL.swift */, 2BFDCA5A09EE70BC17F2EFA7 /* URLComponents.swift */, @@ -6809,6 +6812,7 @@ 384D6B9A7DFD7260139D6852 /* UITestsNotificationCenter.swift in Sources */, 22882C710BC99EC34A5024A0 /* UITestsScreenIdentifier.swift in Sources */, 706289B086B0A6B0C211763F /* UITestsSignalling.swift in Sources */, + 9603EEF6DE980BB1D15D4707 /* UIView.swift in Sources */, D02AA6208C7ACB9BE6332394 /* UNNotificationContent.swift in Sources */, 071A017E415AD378F2961B11 /* URL.swift in Sources */, 6FD8053301C5FEFA82D2F246 /* URLComponents.swift in Sources */, diff --git a/ElementX/Sources/Application/AppSettings.swift b/ElementX/Sources/Application/AppSettings.swift index 8ff6de726..7adb5f05d 100644 --- a/ElementX/Sources/Application/AppSettings.swift +++ b/ElementX/Sources/Application/AppSettings.swift @@ -47,6 +47,7 @@ final class AppSettings { case publicSearchEnabled case fuzzyRoomListSearchEnabled case pinningEnabled + case elementCallPictureInPictureEnabled } private static var suiteName: String = InfoPlistReader.main.appGroupIdentifier @@ -285,8 +286,8 @@ final class AppSettings { @UserPreference(key: UserDefaultsKeys.pinningEnabled, defaultValue: false, storageType: .userDefaults(store)) var pinningEnabled - // Not user configurable as it depends on work in EC too. - let elementCallPictureInPictureEnabled = false + @UserPreference(key: UserDefaultsKeys.elementCallPictureInPictureEnabled, defaultValue: false, storageType: .userDefaults(store)) + var elementCallPictureInPictureEnabled #endif diff --git a/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift index c024238cc..7f8ffab52 100644 --- a/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift @@ -58,6 +58,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { private let roomTimelineControllerFactory: RoomTimelineControllerFactoryProtocol private let navigationStackCoordinator: NavigationStackCoordinator private let emojiProvider: EmojiProviderProtocol + private let ongoingCallRoomIDPublisher: CurrentValuePublisher private let appMediator: AppMediatorProtocol private let appSettings: AppSettings private let analytics: AnalyticsService @@ -90,6 +91,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { roomTimelineControllerFactory: RoomTimelineControllerFactoryProtocol, navigationStackCoordinator: NavigationStackCoordinator, emojiProvider: EmojiProviderProtocol, + ongoingCallRoomIDPublisher: CurrentValuePublisher, appMediator: AppMediatorProtocol, appSettings: AppSettings, analytics: AnalyticsService, @@ -100,6 +102,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { self.roomTimelineControllerFactory = roomTimelineControllerFactory self.navigationStackCoordinator = navigationStackCoordinator self.emojiProvider = emojiProvider + self.ongoingCallRoomIDPublisher = ongoingCallRoomIDPublisher self.appMediator = appMediator self.appSettings = appSettings self.analytics = analytics @@ -580,6 +583,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { voiceMessageMediaManager: userSession.voiceMessageMediaManager, emojiProvider: emojiProvider, completionSuggestionService: completionSuggestionService, + ongoingCallRoomIDPublisher: ongoingCallRoomIDPublisher, appMediator: appMediator, appSettings: appSettings, composerDraftService: composerDraftService) @@ -1350,6 +1354,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { roomTimelineControllerFactory: roomTimelineControllerFactory, navigationStackCoordinator: navigationStackCoordinator, emojiProvider: emojiProvider, + ongoingCallRoomIDPublisher: ongoingCallRoomIDPublisher, appMediator: appMediator, appSettings: appSettings, analytics: analytics, diff --git a/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift index 9842b3ff6..f459b5e17 100644 --- a/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift @@ -477,6 +477,7 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol { roomTimelineControllerFactory: roomTimelineControllerFactory, navigationStackCoordinator: detailNavigationStackCoordinator, emojiProvider: EmojiProvider(), + ongoingCallRoomIDPublisher: elementCallService.ongoingCallRoomIDPublisher, appMediator: appMediator, appSettings: appSettings, analytics: analytics, @@ -580,7 +581,7 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol { private var callScreenPictureInPictureController: AVPictureInPictureController? private func presentCallScreen(configuration: ElementCallConfiguration) { - guard elementCallService.ongoingCallRoomID != configuration.callID else { + guard elementCallService.ongoingCallRoomIDPublisher.value != configuration.callRoomID else { MXLog.info("Returning to existing call.") callScreenPictureInPictureController?.stopPictureInPicture() return diff --git a/ElementX/Sources/Mocks/ElementCallServiceMock.swift b/ElementX/Sources/Mocks/ElementCallServiceMock.swift index 27b2e8125..bfbf30263 100644 --- a/ElementX/Sources/Mocks/ElementCallServiceMock.swift +++ b/ElementX/Sources/Mocks/ElementCallServiceMock.swift @@ -17,12 +17,15 @@ import Combine import Foundation -struct ElementCallServiceMockConfiguration { } +struct ElementCallServiceMockConfiguration { + var ongoingCallRoomID: String? +} extension ElementCallServiceMock { convenience init(_ configuration: ElementCallServiceMockConfiguration) { self.init() underlyingActions = PassthroughSubject().eraseToAnyPublisher() + underlyingOngoingCallRoomIDPublisher = .init(.init(configuration.ongoingCallRoomID)) } } diff --git a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift index e1f88901a..e9bf04f3d 100644 --- a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift +++ b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift @@ -4894,7 +4894,11 @@ class ElementCallServiceMock: ElementCallServiceProtocol { set(value) { underlyingActions = value } } var underlyingActions: AnyPublisher! - var ongoingCallRoomID: String? + var ongoingCallRoomIDPublisher: CurrentValuePublisher { + get { return underlyingOngoingCallRoomIDPublisher } + set(value) { underlyingOngoingCallRoomIDPublisher = value } + } + var underlyingOngoingCallRoomIDPublisher: CurrentValuePublisher! //MARK: - setClientProxy diff --git a/ElementX/Sources/Other/Extensions/UIView.swift b/ElementX/Sources/Other/Extensions/UIView.swift new file mode 100644 index 000000000..f75ba254a --- /dev/null +++ b/ElementX/Sources/Other/Extensions/UIView.swift @@ -0,0 +1,30 @@ +// +// Copyright 2024 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI + +extension UIView { + func addMatchedSubview(_ subview: UIView) { + addSubview(subview) + subview.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + subview.topAnchor.constraint(equalTo: topAnchor), + subview.leadingAnchor.constraint(equalTo: leadingAnchor), + subview.trailingAnchor.constraint(equalTo: trailingAnchor), + subview.bottomAnchor.constraint(equalTo: bottomAnchor) + ]) + } +} diff --git a/ElementX/Sources/Screens/CallScreen/CallScreenCoordinator.swift b/ElementX/Sources/Screens/CallScreen/CallScreenCoordinator.swift index 401fab4c0..da5f45f4e 100644 --- a/ElementX/Sources/Screens/CallScreen/CallScreenCoordinator.swift +++ b/ElementX/Sources/Screens/CallScreen/CallScreenCoordinator.swift @@ -27,7 +27,7 @@ struct CallScreenCoordinatorParameters { enum CallScreenCoordinatorAction { /// The call is still ongoing but the user wishes to navigate around the app. - case pictureInPictureStarted(AVPictureInPictureController?) + case pictureInPictureStarted(AVPictureInPictureController) /// The call is hidden and the user wishes to return to it. case pictureInPictureStopped /// The call is finished and the screen is done with. diff --git a/ElementX/Sources/Screens/CallScreen/CallScreenModels.swift b/ElementX/Sources/Screens/CallScreen/CallScreenModels.swift index 2007ee04e..8570066c5 100644 --- a/ElementX/Sources/Screens/CallScreen/CallScreenModels.swift +++ b/ElementX/Sources/Screens/CallScreen/CallScreenModels.swift @@ -18,7 +18,7 @@ import AVKit import Foundation enum CallScreenViewModelAction { - case pictureInPictureStarted(AVPictureInPictureController?) + case pictureInPictureStarted(AVPictureInPictureController) case pictureInPictureStopped case dismiss } @@ -36,6 +36,7 @@ struct CallScreenViewState: BindableState { struct Bindings { var javaScriptMessageHandler: ((Any) -> Void)? var javaScriptEvaluator: ((String) async throws -> Any)? + var requestPictureInPictureHandler: (() async -> Result)? var alertInfo: AlertInfo? } @@ -43,4 +44,10 @@ struct Bindings { enum CallScreenViewAction { case urlChanged(URL?) case navigateBack + case pictureInPictureWillStop + case endCall +} + +enum CallScreenError: Error { + case pictureInPictureNotAvailable } diff --git a/ElementX/Sources/Screens/CallScreen/CallScreenViewModel.swift b/ElementX/Sources/Screens/CallScreen/CallScreenViewModel.swift index 2534c490e..c4dc8dd63 100644 --- a/ElementX/Sources/Screens/CallScreen/CallScreenViewModel.swift +++ b/ElementX/Sources/Screens/CallScreen/CallScreenViewModel.swift @@ -73,8 +73,8 @@ class CallScreenViewModel: CallScreenViewModelType, CallScreenViewModelProtocol switch action { case let .setAudioEnabled(enabled, roomID): - guard roomID == configuration.callID else { - MXLog.error("Received mute request for a different room: \(roomID) != \(configuration.callID)") + guard roomID == configuration.callRoomID else { + MXLog.error("Received mute request for a different room: \(roomID) != \(configuration.callRoomID)") return } @@ -107,28 +107,11 @@ class CallScreenViewModel: CallScreenViewModelType, CallScreenViewModelProtocol case .callEnded: actionsSubject.send(.dismiss) case .mediaStateChanged(let audioEnabled, _): - elementCallService.setAudioEnabled(audioEnabled, roomID: configuration.callID) + elementCallService.setAudioEnabled(audioEnabled, roomID: configuration.callRoomID) } } .store(in: &cancellables) - // Use did start otherwise there's a black box left on the screen during the pip controller animation. - NotificationCenter.default.publisher(for: .init("AVPictureInPictureControllerDidStartNotification")) - .sink { [weak self] notification in - guard let self else { return } - let controller = notification.object as? AVPictureInPictureController - actionsSubject.send(.pictureInPictureStarted(controller)) - } - .store(in: &cancellables) - - NotificationCenter.default.publisher(for: .init("AVPictureInPictureControllerWillStopNotification")) - .sink { [weak self] _ in - guard let self else { return } - actionsSubject.send(.pictureInPictureStopped) - Task { try await self.state.bindings.javaScriptEvaluator?("controls.disableCompatPip()") } - } - .store(in: &cancellables) - setupCall() } @@ -138,7 +121,11 @@ class CallScreenViewModel: CallScreenViewModelType, CallScreenViewModelProtocol guard let url else { return } MXLog.info("URL changed to: \(url)") case .navigateBack: - handleBackwardsNavigation() + Task { await handleBackwardsNavigation() } + case .pictureInPictureWillStop: + actionsSubject.send(.pictureInPictureStopped) + case .endCall: + actionsSubject.send(.dismiss) } } @@ -200,26 +187,19 @@ class CallScreenViewModel: CallScreenViewModelType, CallScreenViewModelProtocol } } - private func handleBackwardsNavigation() { - #if targetEnvironment(simulator) - if UIDevice.current.isPhone { - MXLog.warning("The iPhone simulator doesn't support PiP.") - actionsSubject.send(.dismiss) - return - } - #endif - - guard isPictureInPictureEnabled, state.url != nil else { + private func handleBackwardsNavigation() async { + guard state.url != nil, + isPictureInPictureEnabled, + let requestPictureInPictureHandler = state.bindings.requestPictureInPictureHandler else { actionsSubject.send(.dismiss) return } - Task { - try await state.bindings.javaScriptEvaluator?("controls.enableCompatPip()") - // Enable this check when implemented on web. - // if result as? Bool != true { - // actionsSubject.send(.dismiss) - // } + switch await requestPictureInPictureHandler() { + case .success(let controller): + actionsSubject.send(.pictureInPictureStarted(controller)) + case .failure: + actionsSubject.send(.dismiss) } } diff --git a/ElementX/Sources/Screens/CallScreen/View/CallScreen.swift b/ElementX/Sources/Screens/CallScreen/View/CallScreen.swift index 026ace55f..cb2a3fc4a 100644 --- a/ElementX/Sources/Screens/CallScreen/View/CallScreen.swift +++ b/ElementX/Sources/Screens/CallScreen/View/CallScreen.swift @@ -14,6 +14,7 @@ // limitations under the License. // +import AVKit import Combine import SFSafeSymbols import SwiftUI @@ -46,7 +47,7 @@ struct CallScreen: View { if context.viewState.url == nil { ProgressView() } else { - WebView(url: context.viewState.url, viewModelContext: context) + 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) @@ -54,44 +55,52 @@ struct CallScreen: View { } } -private struct WebView: UIViewRepresentable { +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) -> WKWebView { - context.coordinator.webView + func makeUIView(context: Context) -> WebViewWrapper { + context.coordinator.webViewWrapper } func makeCoordinator() -> Coordinator { Coordinator(viewModelContext: viewModelContext) } - func updateUIView(_ webView: WKWebView, context: Context) { + func updateUIView(_ callWebView: WebViewWrapper, context: Context) { if let url { - context.coordinator.url = url - let request = URLRequest(url: url) - webView.load(request) + context.coordinator.load(url) } } @MainActor - class Coordinator: NSObject, WKUIDelegate, WKNavigationDelegate { + class Coordinator: NSObject, WKUIDelegate, WKNavigationDelegate, AVPictureInPictureControllerDelegate { private weak var viewModelContext: CallScreenViewModel.Context? private let certificateValidator: CertificateValidatorHookProtocol - private(set) var webView: WKWebView! + private var webView: WKWebView! + private var pictureInPictureController: AVPictureInPictureController? + private let pictureInPictureViewController: AVPictureInPictureVideoCallViewController - var url: URL! + /// 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(_:) + DispatchQueue.main.async { // Avoid `Publishing changes from within view update warnings` + viewModelContext.javaScriptEvaluator = self.evaluateJavaScript + viewModelContext.requestPictureInPictureHandler = self.requestPictureInPicture } let configuration = WKWebViewConfiguration() @@ -120,6 +129,21 @@ private struct WebView: UIViewRepresentable { webView.isOpaque = false webView.backgroundColor = .compound.bgCanvasDefault webView.scrollView.backgroundColor = .compound.bgCanvasDefault + + webViewWrapper.addMatchedSubview(webView) + + if AVPictureInPictureController.isPictureInPictureSupported() { + let pictureInPictureController = AVPictureInPictureController(contentSource: .init(activeVideoCallSourceView: webViewWrapper, + contentViewController: pictureInPictureViewController)) + pictureInPictureController.delegate = self + self.pictureInPictureController = pictureInPictureController + } + } + + func load(_ url: URL) { + self.url = url + let request = URLRequest(url: url) + webView.load(request) } func evaluateJavaScript(_ script: String) async throws -> Any? { @@ -181,6 +205,57 @@ private struct WebView: UIViewRepresentable { viewModelContext?.send(viewAction: .urlChanged(webView.url)) } } + + // MARK: - Picture in Picture + + func requestPictureInPicture() async -> Result { + guard let pictureInPictureController, + pictureInPictureController.isPictureInPicturePossible, + case .success(true) = await webViewCanEnterPictureInPicture() else { + return .failure(.pictureInPictureNotAvailable) + } + + pictureInPictureController.startPictureInPicture() + return .success(pictureInPictureController) + } + + 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 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 { + 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 diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift index cea9b0c85..54a562fb0 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift @@ -28,6 +28,7 @@ struct RoomScreenCoordinatorParameters { let voiceMessageMediaManager: VoiceMessageMediaManagerProtocol let emojiProvider: EmojiProviderProtocol let completionSuggestionService: CompletionSuggestionServiceProtocol + let ongoingCallRoomIDPublisher: CurrentValuePublisher let appMediator: AppMediatorProtocol let appSettings: AppSettings let composerDraftService: ComposerDraftServiceProtocol @@ -64,6 +65,7 @@ final class RoomScreenCoordinator: CoordinatorProtocol { init(parameters: RoomScreenCoordinatorParameters) { roomViewModel = RoomScreenViewModel(roomProxy: parameters.roomProxy, mediaProvider: parameters.mediaProvider, + ongoingCallRoomIDPublisher: parameters.ongoingCallRoomIDPublisher, appMediator: parameters.appMediator, appSettings: ServiceLocator.shared.settings, analyticsService: ServiceLocator.shared.analytics) diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift index 866d1252e..95a41b30c 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift @@ -46,6 +46,7 @@ struct RoomScreenViewState: BindableState { var canJoinCall = false var hasOngoingCall: Bool + var shouldShowCallButton = true var bindings: RoomScreenViewStateBindings } diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift index b95005655..9236dd861 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift @@ -53,6 +53,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol init(roomProxy: JoinedRoomProxyProtocol, mediaProvider: MediaProviderProtocol, + ongoingCallRoomIDPublisher: CurrentValuePublisher, appMediator: AppMediatorProtocol, appSettings: AppSettings, analyticsService: AnalyticsService) { @@ -68,7 +69,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol bindings: .init()), mediaProvider: mediaProvider) - setupSubscriptions() + setupSubscriptions(ongoingCallRoomIDPublisher: ongoingCallRoomIDPublisher) } override func process(viewAction: RoomScreenViewAction) { @@ -93,7 +94,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol state.lastScrollDirection = direction } - private func setupSubscriptions() { + private func setupSubscriptions(ongoingCallRoomIDPublisher: CurrentValuePublisher) { let roomInfoSubscription = roomProxy .actionsPublisher .filter { $0 == .roomInfoUpdate } @@ -135,6 +136,14 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol self?.setupPinnedEventsTimelineProviderIfNeeded() } .store(in: &cancellables) + + ongoingCallRoomIDPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] ongoingCallRoomID in + guard let self else { return } + state.shouldShowCallButton = ongoingCallRoomID != roomProxy.id + } + .store(in: &cancellables) } private func buildPinnedEventContents(timelineItems: [TimelineItemProxy]) { @@ -186,6 +195,7 @@ extension RoomScreenViewModel { static func mock(roomProxyMock: JoinedRoomProxyMock) -> RoomScreenViewModel { RoomScreenViewModel(roomProxy: roomProxyMock, mediaProvider: MockMediaProvider(), + ongoingCallRoomIDPublisher: .init(.init(nil)), appMediator: AppMediatorMock.default, appSettings: ServiceLocator.shared.settings, analyticsService: ServiceLocator.shared.analytics) diff --git a/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift b/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift index 9f2dae515..68caa2a39 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift @@ -175,8 +175,10 @@ struct RoomScreen: View { if !ProcessInfo.processInfo.isiOSAppOnMac { ToolbarItem(placement: .primaryAction) { - callButton - .disabled(roomContext.viewState.canJoinCall == false) + if roomContext.viewState.shouldShowCallButton { + callButton + .disabled(!roomContext.viewState.canJoinCall) + } } } } diff --git a/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/DeveloperOptionsScreenModels.swift b/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/DeveloperOptionsScreenModels.swift index ba618ad29..61a90be87 100644 --- a/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/DeveloperOptionsScreenModels.swift +++ b/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/DeveloperOptionsScreenModels.swift @@ -53,6 +53,7 @@ protocol DeveloperOptionsProtocol: AnyObject { var elementCallBaseURLOverride: URL? { get set } var fuzzyRoomListSearchEnabled: Bool { get set } var pinningEnabled: Bool { get set } + var elementCallPictureInPictureEnabled: Bool { get set } } extension AppSettings: DeveloperOptionsProtocol { } diff --git a/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/View/DeveloperOptionsScreen.swift b/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/View/DeveloperOptionsScreen.swift index 990fb8470..1e1224e1b 100644 --- a/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/View/DeveloperOptionsScreen.swift +++ b/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/View/DeveloperOptionsScreen.swift @@ -61,6 +61,11 @@ struct DeveloperOptionsScreen: View { .autocorrectionDisabled(true) .autocapitalization(.none) .foregroundColor(URL(string: elementCallBaseURLString) == nil ? .red : .primary) + + Toggle(isOn: $context.elementCallPictureInPictureEnabled) { + Text("Picture in Picture support") + Text("Requires an Element Call deployment with support for signalling PiP availability.") + } } Section { diff --git a/ElementX/Sources/Services/ElementCall/ElementCallConfiguration.swift b/ElementX/Sources/Services/ElementCall/ElementCallConfiguration.swift index b9be9fc0d..be9b9615d 100644 --- a/ElementX/Sources/Services/ElementCall/ElementCallConfiguration.swift +++ b/ElementX/Sources/Services/ElementCall/ElementCallConfiguration.swift @@ -77,7 +77,7 @@ struct ElementCallConfiguration { } /// A string representing the call being configured. - var callID: String { + var callRoomID: String { switch kind { case .genericCallLink(let url): url.absoluteString diff --git a/ElementX/Sources/Services/ElementCall/ElementCallService.swift b/ElementX/Sources/Services/ElementCall/ElementCallService.swift index db0316152..1f8b5b145 100644 --- a/ElementX/Sources/Services/ElementCall/ElementCallService.swift +++ b/ElementX/Sources/Services/ElementCall/ElementCallService.swift @@ -57,9 +57,14 @@ class ElementCallService: NSObject, ElementCallServiceProtocol, PKPushRegistryDe private var endUnansweredCallTask: Task? - private var ongoingCallID: CallID? + private var ongoingCallID: CallID? { + didSet { ongoingCallRoomIDSubject.send(ongoingCallID?.roomID) } + } - var ongoingCallRoomID: String? { ongoingCallID?.roomID } + let ongoingCallRoomIDSubject = CurrentValueSubject(nil) + var ongoingCallRoomIDPublisher: CurrentValuePublisher { + ongoingCallRoomIDSubject.asCurrentValuePublisher() + } private let actionsSubject: PassthroughSubject = .init() var actions: AnyPublisher { diff --git a/ElementX/Sources/Services/ElementCall/ElementCallServiceProtocol.swift b/ElementX/Sources/Services/ElementCall/ElementCallServiceProtocol.swift index c58a68394..4cc57aa8b 100644 --- a/ElementX/Sources/Services/ElementCall/ElementCallServiceProtocol.swift +++ b/ElementX/Sources/Services/ElementCall/ElementCallServiceProtocol.swift @@ -26,7 +26,7 @@ enum ElementCallServiceAction { protocol ElementCallServiceProtocol { var actions: AnyPublisher { get } - var ongoingCallRoomID: String? { get } + var ongoingCallRoomIDPublisher: CurrentValuePublisher { get } func setClientProxy(_ clientProxy: ClientProxyProtocol) diff --git a/ElementX/Sources/UITests/UITestsAppCoordinator.swift b/ElementX/Sources/UITests/UITestsAppCoordinator.swift index 9234c0bf3..428bdcbf3 100644 --- a/ElementX/Sources/UITests/UITestsAppCoordinator.swift +++ b/ElementX/Sources/UITests/UITestsAppCoordinator.swift @@ -255,6 +255,7 @@ class MockScreen: Identifiable { voiceMessageMediaManager: VoiceMessageMediaManagerMock(), emojiProvider: EmojiProvider(), completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()), + ongoingCallRoomIDPublisher: .init(.init(nil)), appMediator: AppMediatorMock.default, appSettings: ServiceLocator.shared.settings, composerDraftService: ComposerDraftServiceMock(.init())) @@ -272,6 +273,7 @@ class MockScreen: Identifiable { voiceMessageMediaManager: VoiceMessageMediaManagerMock(), emojiProvider: EmojiProvider(), completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()), + ongoingCallRoomIDPublisher: .init(.init(nil)), appMediator: AppMediatorMock.default, appSettings: ServiceLocator.shared.settings, composerDraftService: ComposerDraftServiceMock(.init())) @@ -289,6 +291,7 @@ class MockScreen: Identifiable { voiceMessageMediaManager: VoiceMessageMediaManagerMock(), emojiProvider: EmojiProvider(), completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()), + ongoingCallRoomIDPublisher: .init(.init(nil)), appMediator: AppMediatorMock.default, appSettings: ServiceLocator.shared.settings, composerDraftService: ComposerDraftServiceMock(.init())) @@ -306,6 +309,7 @@ class MockScreen: Identifiable { voiceMessageMediaManager: VoiceMessageMediaManagerMock(), emojiProvider: EmojiProvider(), completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()), + ongoingCallRoomIDPublisher: .init(.init(nil)), appMediator: AppMediatorMock.default, appSettings: ServiceLocator.shared.settings, composerDraftService: ComposerDraftServiceMock(.init())) @@ -326,6 +330,7 @@ class MockScreen: Identifiable { voiceMessageMediaManager: VoiceMessageMediaManagerMock(), emojiProvider: EmojiProvider(), completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()), + ongoingCallRoomIDPublisher: .init(.init(nil)), appMediator: AppMediatorMock.default, appSettings: ServiceLocator.shared.settings, composerDraftService: ComposerDraftServiceMock(.init())) @@ -346,6 +351,7 @@ class MockScreen: Identifiable { voiceMessageMediaManager: VoiceMessageMediaManagerMock(), emojiProvider: EmojiProvider(), completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()), + ongoingCallRoomIDPublisher: .init(.init(nil)), appMediator: AppMediatorMock.default, appSettings: ServiceLocator.shared.settings, composerDraftService: ComposerDraftServiceMock(.init())) @@ -366,6 +372,7 @@ class MockScreen: Identifiable { voiceMessageMediaManager: VoiceMessageMediaManagerMock(), emojiProvider: EmojiProvider(), completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()), + ongoingCallRoomIDPublisher: .init(.init(nil)), appMediator: AppMediatorMock.default, appSettings: ServiceLocator.shared.settings, composerDraftService: ComposerDraftServiceMock(.init())) @@ -387,6 +394,7 @@ class MockScreen: Identifiable { voiceMessageMediaManager: VoiceMessageMediaManagerMock(), emojiProvider: EmojiProvider(), completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()), + ongoingCallRoomIDPublisher: .init(.init(nil)), appMediator: AppMediatorMock.default, appSettings: ServiceLocator.shared.settings, composerDraftService: ComposerDraftServiceMock(.init())) @@ -407,6 +415,7 @@ class MockScreen: Identifiable { voiceMessageMediaManager: VoiceMessageMediaManagerMock(), emojiProvider: EmojiProvider(), completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()), + ongoingCallRoomIDPublisher: .init(.init(nil)), appMediator: AppMediatorMock.default, appSettings: ServiceLocator.shared.settings, composerDraftService: ComposerDraftServiceMock(.init())) @@ -426,6 +435,7 @@ class MockScreen: Identifiable { voiceMessageMediaManager: VoiceMessageMediaManagerMock(), emojiProvider: EmojiProvider(), completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()), + ongoingCallRoomIDPublisher: .init(.init(nil)), appMediator: AppMediatorMock.default, appSettings: ServiceLocator.shared.settings, composerDraftService: ComposerDraftServiceMock(.init())) @@ -459,6 +469,7 @@ class MockScreen: Identifiable { voiceMessageMediaManager: VoiceMessageMediaManagerMock(), emojiProvider: EmojiProvider(), completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()), + ongoingCallRoomIDPublisher: .init(.init(nil)), appMediator: AppMediatorMock.default, appSettings: ServiceLocator.shared.settings, composerDraftService: ComposerDraftServiceMock(.init())) @@ -479,6 +490,7 @@ class MockScreen: Identifiable { voiceMessageMediaManager: VoiceMessageMediaManagerMock(), emojiProvider: EmojiProvider(), completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()), + ongoingCallRoomIDPublisher: .init(.init(nil)), appMediator: AppMediatorMock.default, appSettings: ServiceLocator.shared.settings, composerDraftService: ComposerDraftServiceMock(.init())) @@ -499,6 +511,7 @@ class MockScreen: Identifiable { voiceMessageMediaManager: VoiceMessageMediaManagerMock(), emojiProvider: EmojiProvider(), completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()), + ongoingCallRoomIDPublisher: .init(.init(nil)), appMediator: AppMediatorMock.default, appSettings: ServiceLocator.shared.settings, composerDraftService: ComposerDraftServiceMock(.init())) diff --git a/UnitTests/Sources/RoomFlowCoordinatorTests.swift b/UnitTests/Sources/RoomFlowCoordinatorTests.swift index 0bbbfefff..5c94307d2 100644 --- a/UnitTests/Sources/RoomFlowCoordinatorTests.swift +++ b/UnitTests/Sources/RoomFlowCoordinatorTests.swift @@ -308,6 +308,7 @@ class RoomFlowCoordinatorTests: XCTestCase { roomTimelineControllerFactory: timelineControllerFactory, navigationStackCoordinator: navigationStackCoordinator, emojiProvider: EmojiProvider(), + ongoingCallRoomIDPublisher: .init(.init(nil)), appMediator: AppMediatorMock.default, appSettings: ServiceLocator.shared.settings, analytics: ServiceLocator.shared.analytics, diff --git a/UnitTests/Sources/RoomScreenViewModelTests.swift b/UnitTests/Sources/RoomScreenViewModelTests.swift index aaa944f7c..07570b025 100644 --- a/UnitTests/Sources/RoomScreenViewModelTests.swift +++ b/UnitTests/Sources/RoomScreenViewModelTests.swift @@ -44,6 +44,7 @@ class RoomScreenViewModelTests: XCTestCase { roomProxyMock.underlyingActionsPublisher = updateSubject.eraseToAnyPublisher() let viewModel = RoomScreenViewModel(roomProxy: roomProxyMock, mediaProvider: MockMediaProvider(), + ongoingCallRoomIDPublisher: .init(.init(nil)), appMediator: AppMediatorMock.default, appSettings: ServiceLocator.shared.settings, analyticsService: ServiceLocator.shared.analytics) @@ -112,6 +113,7 @@ class RoomScreenViewModelTests: XCTestCase { roomProxyMock.underlyingActionsPublisher = updateSubject.eraseToAnyPublisher() let viewModel = RoomScreenViewModel(roomProxy: roomProxyMock, mediaProvider: MockMediaProvider(), + ongoingCallRoomIDPublisher: .init(.init(nil)), appMediator: AppMediatorMock.default, appSettings: ServiceLocator.shared.settings, analyticsService: ServiceLocator.shared.analytics) @@ -140,4 +142,42 @@ class RoomScreenViewModelTests: XCTestCase { updateSubject.send(.roomInfoUpdate) try await deferred.fulfill() } + + func testCallButtonVisibility() async throws { + // Given a room screen with no ongoing call. + let ongoingCallRoomIDSubject = CurrentValueSubject(nil) + let roomProxyMock = JoinedRoomProxyMock(.init(id: "MyRoomID")) + let viewModel = RoomScreenViewModel(roomProxy: roomProxyMock, + mediaProvider: MockMediaProvider(), + ongoingCallRoomIDPublisher: ongoingCallRoomIDSubject.asCurrentValuePublisher(), + appMediator: AppMediatorMock.default, + appSettings: ServiceLocator.shared.settings, + analyticsService: ServiceLocator.shared.analytics) + self.viewModel = viewModel + XCTAssertTrue(viewModel.state.shouldShowCallButton) + + // When a call starts in this room. + var deferred = deferFulfillment(viewModel.context.$viewState) { !$0.shouldShowCallButton } + ongoingCallRoomIDSubject.send("MyRoomID") + try await deferred.fulfill() + + // Then the call button should be hidden. + XCTAssertFalse(viewModel.state.shouldShowCallButton) + + // When a call starts in a different room. + deferred = deferFulfillment(viewModel.context.$viewState) { $0.shouldShowCallButton } + ongoingCallRoomIDSubject.send("OtherRoomID") + try await deferred.fulfill() + + // Then the call button should be shown again. + XCTAssertTrue(viewModel.state.shouldShowCallButton) + + // When the call from the other room finishes. + let deferredFailure = deferFailure(viewModel.context.$viewState, timeout: 1) { !$0.shouldShowCallButton } + ongoingCallRoomIDSubject.send(nil) + try await deferredFailure.fulfill() + + // Then the call button should remain visible shown. + XCTAssertTrue(viewModel.state.shouldShowCallButton) + } }