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
This commit is contained in:
Stefan Ceriu
2023-09-11 12:31:31 +03:00
committed by GitHub
parent 0813e13325
commit ee4040141d
19 changed files with 140 additions and 44 deletions
+2
View File
@@ -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;
@@ -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
@@ -18,5 +18,5 @@ import Foundation
protocol AppCoordinatorProtocol: CoordinatorProtocol {
var notificationManager: NotificationManagerProtocol { get }
func handleUniversalLink(_ url: URL)
@discardableResult func handleDeepLink(_ url: URL) -> Bool
}
@@ -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
@@ -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)
}
}
@@ -83,7 +83,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
stateMachine.tryEvent(.dismissRoom, userInfo: EventUserInfo(animated: animated))
case .invites:
break
case .genericCallLink:
case .genericCallLink, .oidcCallback:
break
}
}
@@ -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
}
}
}
@@ -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
@@ -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
@@ -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):
@@ -86,7 +86,6 @@ struct NotificationSettingsScreenStrings {
}
enum NotificationSettingsScreenViewAction {
case linkClicked(url: URL)
case changedEnableNotifications
case groupChatsTapped
case directChatsTapped
@@ -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:
@@ -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
})
})
}
}
@@ -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 {
@@ -61,7 +61,7 @@ class UITestsAppCoordinator: AppCoordinatorProtocol {
navigationRootCoordinator.toPresentable()
}
func handleUniversalLink(_ url: URL) {
func handleDeepLink(_ url: URL) -> Bool {
fatalError("Not implemented.")
}
}
@@ -37,7 +37,7 @@ class UnitTestsAppCoordinator: AppCoordinatorProtocol {
AnyView(ProgressView("Running Unit Tests"))
}
func handleUniversalLink(_ url: URL) {
func handleDeepLink(_ url: URL) -> Bool {
fatalError("Not implemented.")
}
}
+13
View File
@@ -35,6 +35,19 @@
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLName</key>
<string>$(BASE_BUNDLE_IDENTIFIER)</string>
<key>CFBundleURLSchemes</key>
<array>
<string>$(APP_CUSTOM_SCHEME)</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>ITSAppUsesNonExemptEncryption</key>
+9
View File
@@ -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,
+1
View File
@@ -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