mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-02 21:33:26 +00:00
Generic element call link support (#1643)
* Remove swift-url-routing and replace it with a custom implementation. It fails parsing the fragments in app.element.io links and it doesn't bring any value * Add handling for opening generic element call links * Add applinks support for call.element.io * Enable VoIP and Audio playback background modes
This commit is contained in:
@@ -138,6 +138,21 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationCoordinatorDelegate,
|
||||
func handleUniversalLink(_ url: URL) {
|
||||
// Parse into an AppRoute to redirect these in a type safe way.
|
||||
|
||||
if let route = AppRouteURLParser.route(from: url) {
|
||||
switch route {
|
||||
case .genericCallLink(let url):
|
||||
if let userSessionFlowCoordinator {
|
||||
userSessionFlowCoordinator.handleAppRoute(route, animated: true)
|
||||
} else {
|
||||
navigationRootCoordinator.setSheetCoordinator(GenericCallLinkCoordinator(parameters: .init(url: url)))
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Until we have an OIDC callback AppRoute, handle it manually.
|
||||
if url.absoluteString.starts(with: appSettings.oidcRedirectURL.absoluteString) {
|
||||
MXLog.error("OIDC callback through Universal Links not implemented.")
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
//
|
||||
// Copyright 2023 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 Foundation
|
||||
|
||||
import URLRouting
|
||||
|
||||
enum AppRoute {
|
||||
case roomList
|
||||
case room(roomID: String)
|
||||
case roomDetails(roomID: String)
|
||||
case invites
|
||||
}
|
||||
|
||||
struct AppRouterManager {
|
||||
private let deeplinkRouter = OneOf {
|
||||
Route(.case(AppRoute.room(roomID:))) {
|
||||
// Check with product if this is the expect path
|
||||
Path { "room" }
|
||||
Query {
|
||||
Field("id") { Parse(.string) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private let permalinkRouter = OneOf {
|
||||
Route(.case(AppRoute.room(roomID:))) {
|
||||
Host("matrix.to")
|
||||
Path {
|
||||
"#"
|
||||
Parse(.string)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func route(from url: URL) -> AppRoute? {
|
||||
var route: AppRoute?
|
||||
if let deeplinkRoute = try? deeplinkRouter.match(url: url) {
|
||||
route = deeplinkRoute
|
||||
} else if let permalinkRoute = try? permalinkRouter.match(url: url) {
|
||||
route = permalinkRoute
|
||||
}
|
||||
return route
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
//
|
||||
// Copyright 2023 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 Foundation
|
||||
|
||||
enum AppRoute: Equatable {
|
||||
case roomList
|
||||
case room(roomID: String)
|
||||
case roomDetails(roomID: String)
|
||||
case invites
|
||||
case genericCallLink(url: URL)
|
||||
}
|
||||
|
||||
enum AppRouteURLParser {
|
||||
private enum KnownHosts: String, CaseIterable {
|
||||
case elementIo = "element.io"
|
||||
case appElementIo = "app.element.io"
|
||||
case stagingElementIo = "staging.element.io"
|
||||
case developElementIo = "develop.element.io"
|
||||
case mobileElementIo = "mobile.element.io"
|
||||
case callElementIo = "call.element.io"
|
||||
}
|
||||
|
||||
static func route(from url: URL) -> AppRoute? {
|
||||
guard let urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: false),
|
||||
let host = urlComponents.host else {
|
||||
MXLog.error("Failed parsing URL: \(url)")
|
||||
return nil
|
||||
}
|
||||
|
||||
guard KnownHosts.allCases.map(\.rawValue).contains(host) else {
|
||||
return .genericCallLink(url: url)
|
||||
}
|
||||
|
||||
if host == KnownHosts.callElementIo.rawValue {
|
||||
return .genericCallLink(url: url)
|
||||
}
|
||||
|
||||
// Deep linking not supported at the moment
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,26 @@ class NavigationRootCoordinator: ObservableObject, CoordinatorProtocol, CustomSt
|
||||
rootModule?.coordinator
|
||||
}
|
||||
|
||||
@Published fileprivate var sheetModule: NavigationModule? {
|
||||
didSet {
|
||||
if let oldValue {
|
||||
logPresentationChange("Remove sheet", oldValue)
|
||||
oldValue.tearDown()
|
||||
}
|
||||
|
||||
if let sheetModule {
|
||||
logPresentationChange("Set sheet", sheetModule)
|
||||
sheetModule.coordinator?.start()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The currently presented sheet coordinator
|
||||
// Sheets will be presented through the NavigationSplitCoordinator if provided
|
||||
var sheetCoordinator: (any CoordinatorProtocol)? {
|
||||
sheetModule?.coordinator
|
||||
}
|
||||
|
||||
/// Sets or replaces the presented coordinator
|
||||
/// - Parameter coordinator: the coordinator to display
|
||||
func setRootCoordinator(_ coordinator: (any CoordinatorProtocol)?, dismissalCallback: (() -> Void)? = nil) {
|
||||
@@ -45,6 +65,25 @@ class NavigationRootCoordinator: ObservableObject, CoordinatorProtocol, CustomSt
|
||||
|
||||
rootModule = NavigationModule(coordinator, dismissalCallback: dismissalCallback)
|
||||
}
|
||||
|
||||
/// - dismissalCallback: called when the sheet has been dismissed, programatically or otherwise
|
||||
func setSheetCoordinator(_ coordinator: (any CoordinatorProtocol)?, animated: Bool = true, dismissalCallback: (() -> Void)? = nil) {
|
||||
guard let coordinator else {
|
||||
sheetModule = nil
|
||||
return
|
||||
}
|
||||
|
||||
if sheetModule?.coordinator === coordinator {
|
||||
fatalError("Cannot use the same coordinator more than once")
|
||||
}
|
||||
|
||||
var transaction = Transaction()
|
||||
transaction.disablesAnimations = !animated
|
||||
|
||||
withTransaction(transaction) {
|
||||
sheetModule = NavigationModule(coordinator, dismissalCallback: dismissalCallback)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CoordinatorProtocol
|
||||
|
||||
@@ -79,5 +118,8 @@ private struct NavigationRootCoordinatorView: View {
|
||||
rootCoordinator.rootModule?.coordinator?.toPresentable()
|
||||
}
|
||||
.animation(.elementDefault, value: rootCoordinator.rootModule)
|
||||
.sheet(item: $rootCoordinator.sheetModule) { module in
|
||||
module.coordinator?.toPresentable()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,6 +83,8 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
|
||||
stateMachine.tryEvent(.dismissRoom, userInfo: EventUserInfo(animated: animated))
|
||||
case .invites:
|
||||
break
|
||||
case .genericCallLink:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -105,27 +105,22 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
|
||||
}
|
||||
|
||||
// MARK: - FlowCoordinatorProtocol
|
||||
|
||||
|
||||
func handleAppRoute(_ appRoute: AppRoute, animated: Bool) {
|
||||
// Tidy up any state before applying the new route.
|
||||
switch stateMachine.state {
|
||||
case .initial, .migration:
|
||||
return // Not ready to handle a route.
|
||||
case .roomList:
|
||||
break // Nothing to tidy up on the home screen.
|
||||
case .feedbackScreen, .sessionVerificationScreen, .settingsScreen, .startChatScreen, .invitesScreen, .welcomeScreen:
|
||||
navigationSplitCoordinator.setSheetCoordinator(nil, animated: animated)
|
||||
}
|
||||
|
||||
// Apply the route.
|
||||
switch appRoute {
|
||||
case .room, .roomDetails, .roomList:
|
||||
roomFlowCoordinator.handleAppRoute(appRoute, animated: animated)
|
||||
case .invites:
|
||||
if UIDevice.current.isPhone {
|
||||
roomFlowCoordinator.clearRoute(animated: animated)
|
||||
clearPresentedSheets(animated: animated) { [weak self] in
|
||||
guard let self else { return }
|
||||
|
||||
switch appRoute {
|
||||
case .room, .roomDetails, .roomList:
|
||||
self.roomFlowCoordinator.handleAppRoute(appRoute, animated: animated)
|
||||
case .invites:
|
||||
if UIDevice.current.isPhone {
|
||||
self.roomFlowCoordinator.clearRoute(animated: animated)
|
||||
}
|
||||
self.stateMachine.processEvent(.showInvitesScreen, userInfo: .init(animated: animated))
|
||||
case .genericCallLink(let url):
|
||||
self.navigationSplitCoordinator.setSheetCoordinator(GenericCallLinkCoordinator(parameters: .init(url: url)), animated: animated)
|
||||
}
|
||||
stateMachine.processEvent(.showInvitesScreen, userInfo: .init(animated: animated))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,6 +130,20 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func clearPresentedSheets(animated: Bool, completion: @escaping () -> Void) {
|
||||
if navigationSplitCoordinator.sheetCoordinator == nil {
|
||||
completion()
|
||||
return
|
||||
}
|
||||
|
||||
navigationSplitCoordinator.setSheetCoordinator(nil, animated: animated)
|
||||
|
||||
// Prevents system crashes when presenting a sheet if another one was already shown
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
|
||||
completion()
|
||||
}
|
||||
}
|
||||
|
||||
private func setupStateMachine() {
|
||||
stateMachine.addTransitionHandler { [weak self] context in
|
||||
guard let self else { return }
|
||||
|
||||
@@ -198,7 +198,7 @@ struct CollapsibleReactionLayout: Layout {
|
||||
}
|
||||
var secondLastRow = rows[rows.count - 2]
|
||||
let collapseButton = secondLastRow.removeLast()
|
||||
lastRow.prepend(collapseButton)
|
||||
lastRow.insert(collapseButton, at: 0)
|
||||
rows[rows.count - 2] = secondLastRow
|
||||
rows[rows.count - 1] = lastRow
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
//
|
||||
// Copyright 2023 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
|
||||
import WebKit
|
||||
|
||||
struct GenericLinkCoordinatorParameters {
|
||||
let url: URL
|
||||
}
|
||||
|
||||
class GenericCallLinkCoordinator: CoordinatorProtocol {
|
||||
private let parameters: GenericLinkCoordinatorParameters
|
||||
|
||||
init(parameters: GenericLinkCoordinatorParameters) {
|
||||
self.parameters = parameters
|
||||
}
|
||||
|
||||
func toPresentable() -> AnyView {
|
||||
AnyView(
|
||||
WebView(url: parameters.url)
|
||||
.ignoresSafeArea(edges: .bottom)
|
||||
.presentationDragIndicator(.visible)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private struct WebView: UIViewRepresentable {
|
||||
let url: URL
|
||||
|
||||
func makeUIView(context: Context) -> WKWebView {
|
||||
context.coordinator.webView
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(initialURL: url)
|
||||
}
|
||||
|
||||
func updateUIView(_ webView: WKWebView, context: Context) {
|
||||
webView.load(URLRequest(url: url))
|
||||
}
|
||||
|
||||
@MainActor
|
||||
class Coordinator: NSObject, WKUIDelegate, WKNavigationDelegate {
|
||||
private let initialURL: URL
|
||||
private(set) var webView: WKWebView!
|
||||
|
||||
init(initialURL: URL) {
|
||||
self.initialURL = initialURL
|
||||
super.init()
|
||||
|
||||
let configuration = WKWebViewConfiguration()
|
||||
|
||||
configuration.allowsInlineMediaPlayback = true
|
||||
configuration.allowsPictureInPictureMediaPlayback = true
|
||||
|
||||
webView = WKWebView(frame: .zero, configuration: configuration)
|
||||
webView.uiDelegate = self
|
||||
}
|
||||
|
||||
// MARK: - WKUIDelegate
|
||||
|
||||
func webView(_ webView: WKWebView, decideMediaCapturePermissionsFor origin: WKSecurityOrigin, initiatedBy frame: WKFrameInfo, type: WKMediaCaptureType) async -> WKPermissionDecision {
|
||||
// Don't allow permissions for domains different than what the call was started on
|
||||
guard origin.host == initialURL.host else {
|
||||
return .deny
|
||||
}
|
||||
|
||||
return .grant
|
||||
}
|
||||
|
||||
// MARK: - WKNavigationDelegate
|
||||
|
||||
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction) async -> WKNavigationActionPolicy {
|
||||
// Allow any content from the main URL.
|
||||
if navigationAction.request.url?.host == initialURL.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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user