From b6ea17c535e8916d52598477897e7ef425383204 Mon Sep 17 00:00:00 2001 From: Stefan Ceriu Date: Mon, 11 Sep 2023 12:31:31 +0300 Subject: [PATCH] Top level deeplink handling (#1660) * Handle link opening on the top most levels and prepare for percolating them throughout the app * Add support for a custom app scheme * Add specific AppRoute parsers. * Integrate custom scheme in the AppRouteURLParser * Switch to `element://call` and cleanup route parsing --- ElementX.xcodeproj/project.pbxproj | 2 + .../Sources/Application/AppCoordinator.swift | 10 +- .../Application/AppCoordinatorProtocol.swift | 2 +- .../Sources/Application/Application.swift | 9 +- .../Application/Navigation/AppRoutes.swift | 100 ++++++++++++++---- .../RoomFlowCoordinator.swift | 2 +- .../UserSessionFlowCoordinator.swift | 2 + ElementX/Sources/Other/InfoPlistReader.swift | 16 +++ .../Screens/RoomScreen/RoomScreenModels.swift | 1 - .../RoomScreen/RoomScreenViewModel.swift | 2 - .../NotificationSettingsScreenModels.swift | 1 - .../NotificationSettingsScreenViewModel.swift | 2 - .../View/NotificationSettingsScreen.swift | 4 - .../TimelineItems/RoomTimelineItemView.swift | 4 - .../UITests/UITestsAppCoordinator.swift | 2 +- .../UnitTests/UnitTestsAppCoordinator.swift | 2 +- ElementX/SupportingFiles/Info.plist | 13 +++ ElementX/SupportingFiles/target.yml | 9 ++ project.yml | 1 + 19 files changed, 140 insertions(+), 44 deletions(-) diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index 749c45e2e..3e2a5c2b4 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -5196,6 +5196,7 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; + APP_CUSTOM_SCHEME = element; APP_DISPLAY_NAME = "Element X"; APP_GROUP_IDENTIFIER = "group.$(BASE_APP_GROUP_IDENTIFIER)"; APP_NAME = ElementX; @@ -5265,6 +5266,7 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; + APP_CUSTOM_SCHEME = element; APP_DISPLAY_NAME = "Element X"; APP_GROUP_IDENTIFIER = "group.$(BASE_APP_GROUP_IDENTIFIER)"; APP_NAME = ElementX; diff --git a/ElementX/Sources/Application/AppCoordinator.swift b/ElementX/Sources/Application/AppCoordinator.swift index 1301734ed..462abd031 100644 --- a/ElementX/Sources/Application/AppCoordinator.swift +++ b/ElementX/Sources/Application/AppCoordinator.swift @@ -55,6 +55,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationCoordinatorDelegate, let notificationManager: NotificationManagerProtocol + private let appRouteURLParser: AppRouteURLParser @Consumable private var storedAppRoute: AppRoute? init() { @@ -75,6 +76,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationCoordinatorDelegate, } self.appSettings = appSettings + appRouteURLParser = AppRouteURLParser(appSettings: appSettings) navigationRootCoordinator = NavigationRootCoordinator() @@ -137,10 +139,10 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationCoordinatorDelegate, ) } - func handleUniversalLink(_ url: URL) { + func handleDeepLink(_ url: URL) -> Bool { // Parse into an AppRoute to redirect these in a type safe way. - if let route = AppRouteURLParser.route(from: url) { + if let route = appRouteURLParser.route(from: url) { switch route { case .genericCallLink(let url): if let userSessionFlowCoordinator { @@ -152,13 +154,15 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationCoordinatorDelegate, break } - return + return true } // 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.") } + + return false } // MARK: - AuthenticationCoordinatorDelegate diff --git a/ElementX/Sources/Application/AppCoordinatorProtocol.swift b/ElementX/Sources/Application/AppCoordinatorProtocol.swift index 0915c220e..375681f4e 100644 --- a/ElementX/Sources/Application/AppCoordinatorProtocol.swift +++ b/ElementX/Sources/Application/AppCoordinatorProtocol.swift @@ -18,5 +18,5 @@ import Foundation protocol AppCoordinatorProtocol: CoordinatorProtocol { var notificationManager: NotificationManagerProtocol { get } - func handleUniversalLink(_ url: URL) + @discardableResult func handleDeepLink(_ url: URL) -> Bool } diff --git a/ElementX/Sources/Application/Application.swift b/ElementX/Sources/Application/Application.swift index ea98ace65..2a35c3a3a 100644 --- a/ElementX/Sources/Application/Application.swift +++ b/ElementX/Sources/Application/Application.swift @@ -35,7 +35,14 @@ struct Application: App { WindowGroup { appCoordinator.toPresentable() .statusBarHidden(shouldHideStatusBar) - .onOpenURL { appCoordinator.handleUniversalLink($0) } + .environment(\.openURL, OpenURLAction { url in + if appCoordinator.handleDeepLink(url) { + return .handled + } + + return .systemAction + }) + .onOpenURL { appCoordinator.handleDeepLink($0) } .introspect(.window, on: .iOS(.v16)) { window in // Workaround for SwiftUI not consistently applying the tint colour to Alerts/Confirmation Dialogs. window.tintColor = .compound.textActionPrimary diff --git a/ElementX/Sources/Application/Navigation/AppRoutes.swift b/ElementX/Sources/Application/Navigation/AppRoutes.swift index 4093e5e0b..4595a13e1 100644 --- a/ElementX/Sources/Application/Navigation/AppRoutes.swift +++ b/ElementX/Sources/Application/Navigation/AppRoutes.swift @@ -17,6 +17,7 @@ import Foundation enum AppRoute: Equatable { + case oidcCallback(url: URL) case roomList case room(roomID: String) case roomDetails(roomID: String) @@ -24,32 +25,87 @@ enum AppRoute: Equatable { 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" +struct AppRouteURLParser { + let urlParsers: [URLParser] + + init(appSettings: AppSettings) { + urlParsers = [ + ElementAppURLParser(appSettings: appSettings), + ElementCallURLParser() + ] } - 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 + func route(from url: URL) -> AppRoute? { + for parser in urlParsers { + if let appRoute = parser.route(from: url) { + return appRoute + } } - 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 } } + +/// Represents a type that can parse a `URL` into an `AppRoute`. +/// +/// The following Universal Links are missing parsers. +/// - app.element.io +/// - mobile.element.io +protocol URLParser { + func route(from url: URL) -> AppRoute? +} + +/// The parser for the main Element website. +struct ElementAppURLParser: URLParser { + private let knownHosts = ["app.element.io", "staging.element.io", "develop.element.io"] + + let appSettings: AppSettings + + func route(from url: URL) -> AppRoute? { + guard let host = url.host, knownHosts.contains(host) else { + return nil + } + + let pathComponents = url.pathComponents + + // OIDC callback URL. + if pathComponents.count == 3, pathComponents[0] == "mobile", pathComponents[1] == "oidc" { + return .oidcCallback(url: url) + } + + return nil + } +} + +/// The parser for Element Call links. This always returns a `.genericCallLink` +struct ElementCallURLParser: URLParser { + private let knownHosts = ["call.element.io"] + private let customSchemeHost = "call" + private let customSchemeURLQueryParameterName = "url" + + func route(from url: URL) -> AppRoute? { + // First try processing URLs with custom schemes + if let scheme = url.scheme, + scheme == InfoPlistReader.app.appScheme { + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false), + components.host == customSchemeHost else { + return nil + } + + guard let encodedURLString = components.queryItems?.first(where: { $0.name == customSchemeURLQueryParameterName })?.value, + let callURL = URL(string: encodedURLString) else { + MXLog.error("Invalid custom scheme call parameters: \(url)") + return nil + } + + return .genericCallLink(url: callURL) + } + + // Otherwise try to interpret it as an universal link + guard let host = url.host, knownHosts.contains(host) else { + return nil + } + + return .genericCallLink(url: url) + } +} diff --git a/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift index 59971dc71..5e7196f3f 100644 --- a/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift @@ -83,7 +83,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { stateMachine.tryEvent(.dismissRoom, userInfo: EventUserInfo(animated: animated)) case .invites: break - case .genericCallLink: + case .genericCallLink, .oidcCallback: break } } diff --git a/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift index dce5ec2cf..46a635a00 100644 --- a/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift @@ -121,6 +121,8 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol { self.stateMachine.processEvent(.showInvitesScreen, userInfo: .init(animated: animated)) case .genericCallLink(let url): self.navigationSplitCoordinator.setSheetCoordinator(GenericCallLinkCoordinator(parameters: .init(url: url)), animated: animated) + case .oidcCallback: + break } } } diff --git a/ElementX/Sources/Other/InfoPlistReader.swift b/ElementX/Sources/Other/InfoPlistReader.swift index fcdb99804..a16a2b467 100644 --- a/ElementX/Sources/Other/InfoPlistReader.swift +++ b/ElementX/Sources/Other/InfoPlistReader.swift @@ -31,6 +31,9 @@ struct InfoPlistReader { static let otlpTracingURL = "otlpTracingURL" static let otlpTracingUsername = "otlpTracingUsername" static let otlpTracingPassword = "otlpTracingPassword" + + static let bundleURLTypes = "CFBundleURLTypes" + static let bundleURLSchemes = "CFBundleURLSchemes" } private enum Values { @@ -111,6 +114,19 @@ struct InfoPlistReader { infoPlistValue(forKey: Keys.otlpTracingPassword) } + // MARK: - Custom App Scheme + + var appScheme: String { + let urlTypes: [[String: Any]] = infoPlistValue(forKey: Keys.bundleURLTypes) + + guard let urlSchemes = urlTypes.first?[Keys.bundleURLSchemes] as? [String], + let scheme = urlSchemes.first else { + fatalError("Invalid custon application scheme configuration") + } + + return scheme + } + // MARK: - Mention Pills /// Mention Pills UTType diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift index 370dfa9c4..727220541 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift @@ -56,7 +56,6 @@ enum RoomScreenViewAction { case itemAppeared(itemID: TimelineItemIdentifier) case itemDisappeared(itemID: TimelineItemIdentifier) case itemTapped(itemID: TimelineItemIdentifier) - case linkClicked(url: URL) case toggleReaction(key: String, itemID: TimelineItemIdentifier) case sendReadReceiptIfNeeded(TimelineItemIdentifier) case paginateBackwards diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift index 20a8bf6cf..770a176ae 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift @@ -104,8 +104,6 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol Task { await timelineController.processItemDisappearance(id) } case .itemTapped(let id): Task { await itemTapped(with: id) } - case .linkClicked(let url): - MXLog.warning("Link clicked: \(url)") case .toggleReaction(let emoji, let itemId): Task { await timelineController.toggleReaction(emoji, to: itemId) } case .sendReadReceiptIfNeeded(let lastVisibleItemID): diff --git a/ElementX/Sources/Screens/Settings/NotificationSettingsScreen/NotificationSettingsScreenModels.swift b/ElementX/Sources/Screens/Settings/NotificationSettingsScreen/NotificationSettingsScreenModels.swift index 0da202ee8..c7af88e31 100644 --- a/ElementX/Sources/Screens/Settings/NotificationSettingsScreen/NotificationSettingsScreenModels.swift +++ b/ElementX/Sources/Screens/Settings/NotificationSettingsScreen/NotificationSettingsScreenModels.swift @@ -86,7 +86,6 @@ struct NotificationSettingsScreenStrings { } enum NotificationSettingsScreenViewAction { - case linkClicked(url: URL) case changedEnableNotifications case groupChatsTapped case directChatsTapped diff --git a/ElementX/Sources/Screens/Settings/NotificationSettingsScreen/NotificationSettingsScreenViewModel.swift b/ElementX/Sources/Screens/Settings/NotificationSettingsScreen/NotificationSettingsScreenViewModel.swift index 66c9168b9..19bac63ba 100644 --- a/ElementX/Sources/Screens/Settings/NotificationSettingsScreen/NotificationSettingsScreenViewModel.swift +++ b/ElementX/Sources/Screens/Settings/NotificationSettingsScreen/NotificationSettingsScreenViewModel.swift @@ -57,8 +57,6 @@ class NotificationSettingsScreenViewModel: NotificationSettingsScreenViewModelTy override func process(viewAction: NotificationSettingsScreenViewAction) { switch viewAction { - case .linkClicked(let url): - MXLog.warning("Link clicked: \(url)") case .changedEnableNotifications: toggleNotifications() case .groupChatsTapped: diff --git a/ElementX/Sources/Screens/Settings/NotificationSettingsScreen/View/NotificationSettingsScreen.swift b/ElementX/Sources/Screens/Settings/NotificationSettingsScreen/View/NotificationSettingsScreen.swift index c91ee8de8..b7286a630 100644 --- a/ElementX/Sources/Screens/Settings/NotificationSettingsScreen/View/NotificationSettingsScreen.swift +++ b/ElementX/Sources/Screens/Settings/NotificationSettingsScreen/View/NotificationSettingsScreen.swift @@ -79,10 +79,6 @@ struct NotificationSettingsScreen: View { } .padding(.horizontal, ListRowPadding.horizontal) .padding(.vertical, 8) - .environment(\.openURL, OpenURLAction { url in - context.send(viewAction: .linkClicked(url: url)) - return .systemAction - }) }) } } diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemView.swift b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemView.swift index 291a1b210..69f4fd700 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemView.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemView.swift @@ -30,10 +30,6 @@ struct RoomTimelineItemView: View { .onDisappear { context.send(viewAction: .itemDisappeared(itemID: viewState.identifier)) } - .environment(\.openURL, OpenURLAction { url in - context.send(viewAction: .linkClicked(url: url)) - return .systemAction - }) } @ViewBuilder private var timelineView: some View { diff --git a/ElementX/Sources/UITests/UITestsAppCoordinator.swift b/ElementX/Sources/UITests/UITestsAppCoordinator.swift index f639c062e..0abf40858 100644 --- a/ElementX/Sources/UITests/UITestsAppCoordinator.swift +++ b/ElementX/Sources/UITests/UITestsAppCoordinator.swift @@ -61,7 +61,7 @@ class UITestsAppCoordinator: AppCoordinatorProtocol { navigationRootCoordinator.toPresentable() } - func handleUniversalLink(_ url: URL) { + func handleDeepLink(_ url: URL) -> Bool { fatalError("Not implemented.") } } diff --git a/ElementX/Sources/UnitTests/UnitTestsAppCoordinator.swift b/ElementX/Sources/UnitTests/UnitTestsAppCoordinator.swift index 1191f2f6c..d903b8e6f 100644 --- a/ElementX/Sources/UnitTests/UnitTestsAppCoordinator.swift +++ b/ElementX/Sources/UnitTests/UnitTestsAppCoordinator.swift @@ -37,7 +37,7 @@ class UnitTestsAppCoordinator: AppCoordinatorProtocol { AnyView(ProgressView("Running Unit Tests")) } - func handleUniversalLink(_ url: URL) { + func handleDeepLink(_ url: URL) -> Bool { fatalError("Not implemented.") } } diff --git a/ElementX/SupportingFiles/Info.plist b/ElementX/SupportingFiles/Info.plist index b3c3819fe..50eefe27d 100644 --- a/ElementX/SupportingFiles/Info.plist +++ b/ElementX/SupportingFiles/Info.plist @@ -35,6 +35,19 @@ APPL CFBundleShortVersionString $(MARKETING_VERSION) + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLName + $(BASE_BUNDLE_IDENTIFIER) + CFBundleURLSchemes + + $(APP_CUSTOM_SCHEME) + + + CFBundleVersion $(CURRENT_PROJECT_VERSION) ITSAppUsesNonExemptEncryption diff --git a/ElementX/SupportingFiles/target.yml b/ElementX/SupportingFiles/target.yml index 2f2c893f5..03f405308 100644 --- a/ElementX/SupportingFiles/target.yml +++ b/ElementX/SupportingFiles/target.yml @@ -54,6 +54,15 @@ targets: CFBundleDisplayName: $(APP_DISPLAY_NAME) CFBundleShortVersionString: $(MARKETING_VERSION) CFBundleVersion: $(CURRENT_PROJECT_VERSION) + CFBundleURLTypes: [ + { + CFBundleTypeRole: Editor, + CFBundleURLName: $(BASE_BUNDLE_IDENTIFIER), + CFBundleURLSchemes: [ + $(APP_CUSTOM_SCHEME) + ] + } + ] UISupportedInterfaceOrientations: [ UIInterfaceOrientationPortrait, UIInterfaceOrientationPortraitUpsideDown, diff --git a/project.yml b/project.yml index 44b29dc52..f7781297d 100644 --- a/project.yml +++ b/project.yml @@ -28,6 +28,7 @@ settings: BASE_BUNDLE_IDENTIFIER: io.element.elementx APP_NAME: ElementX APP_DISPLAY_NAME: Element X + APP_CUSTOM_SCHEME: element KEYCHAIN_ACCESS_GROUP_IDENTIFIER: $(AppIdentifierPrefix)$(BASE_BUNDLE_IDENTIFIER) MARKETING_VERSION: 1.2.9 CURRENT_PROJECT_VERSION: 1