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; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO; ALWAYS_SEARCH_USER_PATHS = NO;
APP_CUSTOM_SCHEME = element;
APP_DISPLAY_NAME = "Element X"; APP_DISPLAY_NAME = "Element X";
APP_GROUP_IDENTIFIER = "group.$(BASE_APP_GROUP_IDENTIFIER)"; APP_GROUP_IDENTIFIER = "group.$(BASE_APP_GROUP_IDENTIFIER)";
APP_NAME = ElementX; APP_NAME = ElementX;
@@ -5265,6 +5266,7 @@
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO; ALWAYS_SEARCH_USER_PATHS = NO;
APP_CUSTOM_SCHEME = element;
APP_DISPLAY_NAME = "Element X"; APP_DISPLAY_NAME = "Element X";
APP_GROUP_IDENTIFIER = "group.$(BASE_APP_GROUP_IDENTIFIER)"; APP_GROUP_IDENTIFIER = "group.$(BASE_APP_GROUP_IDENTIFIER)";
APP_NAME = ElementX; APP_NAME = ElementX;
@@ -55,6 +55,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationCoordinatorDelegate,
let notificationManager: NotificationManagerProtocol let notificationManager: NotificationManagerProtocol
private let appRouteURLParser: AppRouteURLParser
@Consumable private var storedAppRoute: AppRoute? @Consumable private var storedAppRoute: AppRoute?
init() { init() {
@@ -75,6 +76,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationCoordinatorDelegate,
} }
self.appSettings = appSettings self.appSettings = appSettings
appRouteURLParser = AppRouteURLParser(appSettings: appSettings)
navigationRootCoordinator = NavigationRootCoordinator() 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. // 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 { switch route {
case .genericCallLink(let url): case .genericCallLink(let url):
if let userSessionFlowCoordinator { if let userSessionFlowCoordinator {
@@ -152,13 +154,15 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationCoordinatorDelegate,
break break
} }
return return true
} }
// Until we have an OIDC callback AppRoute, handle it manually. // Until we have an OIDC callback AppRoute, handle it manually.
if url.absoluteString.starts(with: appSettings.oidcRedirectURL.absoluteString) { if url.absoluteString.starts(with: appSettings.oidcRedirectURL.absoluteString) {
MXLog.error("OIDC callback through Universal Links not implemented.") MXLog.error("OIDC callback through Universal Links not implemented.")
} }
return false
} }
// MARK: - AuthenticationCoordinatorDelegate // MARK: - AuthenticationCoordinatorDelegate
@@ -18,5 +18,5 @@ import Foundation
protocol AppCoordinatorProtocol: CoordinatorProtocol { protocol AppCoordinatorProtocol: CoordinatorProtocol {
var notificationManager: NotificationManagerProtocol { get } var notificationManager: NotificationManagerProtocol { get }
func handleUniversalLink(_ url: URL) @discardableResult func handleDeepLink(_ url: URL) -> Bool
} }
@@ -35,7 +35,14 @@ struct Application: App {
WindowGroup { WindowGroup {
appCoordinator.toPresentable() appCoordinator.toPresentable()
.statusBarHidden(shouldHideStatusBar) .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 .introspect(.window, on: .iOS(.v16)) { window in
// Workaround for SwiftUI not consistently applying the tint colour to Alerts/Confirmation Dialogs. // Workaround for SwiftUI not consistently applying the tint colour to Alerts/Confirmation Dialogs.
window.tintColor = .compound.textActionPrimary window.tintColor = .compound.textActionPrimary
@@ -17,6 +17,7 @@
import Foundation import Foundation
enum AppRoute: Equatable { enum AppRoute: Equatable {
case oidcCallback(url: URL)
case roomList case roomList
case room(roomID: String) case room(roomID: String)
case roomDetails(roomID: String) case roomDetails(roomID: String)
@@ -24,32 +25,87 @@ enum AppRoute: Equatable {
case genericCallLink(url: URL) case genericCallLink(url: URL)
} }
enum AppRouteURLParser { struct AppRouteURLParser {
private enum KnownHosts: String, CaseIterable { let urlParsers: [URLParser]
case elementIo = "element.io"
case appElementIo = "app.element.io" init(appSettings: AppSettings) {
case stagingElementIo = "staging.element.io" urlParsers = [
case developElementIo = "develop.element.io" ElementAppURLParser(appSettings: appSettings),
case mobileElementIo = "mobile.element.io" ElementCallURLParser()
case callElementIo = "call.element.io" ]
} }
static func route(from url: URL) -> AppRoute? { func route(from url: URL) -> AppRoute? {
guard let urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: false), for parser in urlParsers {
let host = urlComponents.host else { if let appRoute = parser.route(from: url) {
MXLog.error("Failed parsing URL: \(url)") return appRoute
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 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)) stateMachine.tryEvent(.dismissRoom, userInfo: EventUserInfo(animated: animated))
case .invites: case .invites:
break break
case .genericCallLink: case .genericCallLink, .oidcCallback:
break break
} }
} }
@@ -121,6 +121,8 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
self.stateMachine.processEvent(.showInvitesScreen, userInfo: .init(animated: animated)) self.stateMachine.processEvent(.showInvitesScreen, userInfo: .init(animated: animated))
case .genericCallLink(let url): case .genericCallLink(let url):
self.navigationSplitCoordinator.setSheetCoordinator(GenericCallLinkCoordinator(parameters: .init(url: url)), animated: animated) 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 otlpTracingURL = "otlpTracingURL"
static let otlpTracingUsername = "otlpTracingUsername" static let otlpTracingUsername = "otlpTracingUsername"
static let otlpTracingPassword = "otlpTracingPassword" static let otlpTracingPassword = "otlpTracingPassword"
static let bundleURLTypes = "CFBundleURLTypes"
static let bundleURLSchemes = "CFBundleURLSchemes"
} }
private enum Values { private enum Values {
@@ -111,6 +114,19 @@ struct InfoPlistReader {
infoPlistValue(forKey: Keys.otlpTracingPassword) 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 // MARK: - Mention Pills
/// Mention Pills UTType /// Mention Pills UTType
@@ -56,7 +56,6 @@ enum RoomScreenViewAction {
case itemAppeared(itemID: TimelineItemIdentifier) case itemAppeared(itemID: TimelineItemIdentifier)
case itemDisappeared(itemID: TimelineItemIdentifier) case itemDisappeared(itemID: TimelineItemIdentifier)
case itemTapped(itemID: TimelineItemIdentifier) case itemTapped(itemID: TimelineItemIdentifier)
case linkClicked(url: URL)
case toggleReaction(key: String, itemID: TimelineItemIdentifier) case toggleReaction(key: String, itemID: TimelineItemIdentifier)
case sendReadReceiptIfNeeded(TimelineItemIdentifier) case sendReadReceiptIfNeeded(TimelineItemIdentifier)
case paginateBackwards case paginateBackwards
@@ -104,8 +104,6 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
Task { await timelineController.processItemDisappearance(id) } Task { await timelineController.processItemDisappearance(id) }
case .itemTapped(let id): case .itemTapped(let id):
Task { await itemTapped(with: id) } Task { await itemTapped(with: id) }
case .linkClicked(let url):
MXLog.warning("Link clicked: \(url)")
case .toggleReaction(let emoji, let itemId): case .toggleReaction(let emoji, let itemId):
Task { await timelineController.toggleReaction(emoji, to: itemId) } Task { await timelineController.toggleReaction(emoji, to: itemId) }
case .sendReadReceiptIfNeeded(let lastVisibleItemID): case .sendReadReceiptIfNeeded(let lastVisibleItemID):
@@ -86,7 +86,6 @@ struct NotificationSettingsScreenStrings {
} }
enum NotificationSettingsScreenViewAction { enum NotificationSettingsScreenViewAction {
case linkClicked(url: URL)
case changedEnableNotifications case changedEnableNotifications
case groupChatsTapped case groupChatsTapped
case directChatsTapped case directChatsTapped
@@ -57,8 +57,6 @@ class NotificationSettingsScreenViewModel: NotificationSettingsScreenViewModelTy
override func process(viewAction: NotificationSettingsScreenViewAction) { override func process(viewAction: NotificationSettingsScreenViewAction) {
switch viewAction { switch viewAction {
case .linkClicked(let url):
MXLog.warning("Link clicked: \(url)")
case .changedEnableNotifications: case .changedEnableNotifications:
toggleNotifications() toggleNotifications()
case .groupChatsTapped: case .groupChatsTapped:
@@ -79,10 +79,6 @@ struct NotificationSettingsScreen: View {
} }
.padding(.horizontal, ListRowPadding.horizontal) .padding(.horizontal, ListRowPadding.horizontal)
.padding(.vertical, 8) .padding(.vertical, 8)
.environment(\.openURL, OpenURLAction { url in
context.send(viewAction: .linkClicked(url: url))
return .systemAction
})
}) })
} }
} }
@@ -30,10 +30,6 @@ struct RoomTimelineItemView: View {
.onDisappear { .onDisappear {
context.send(viewAction: .itemDisappeared(itemID: viewState.identifier)) 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 { @ViewBuilder private var timelineView: some View {
@@ -61,7 +61,7 @@ class UITestsAppCoordinator: AppCoordinatorProtocol {
navigationRootCoordinator.toPresentable() navigationRootCoordinator.toPresentable()
} }
func handleUniversalLink(_ url: URL) { func handleDeepLink(_ url: URL) -> Bool {
fatalError("Not implemented.") fatalError("Not implemented.")
} }
} }
@@ -37,7 +37,7 @@ class UnitTestsAppCoordinator: AppCoordinatorProtocol {
AnyView(ProgressView("Running Unit Tests")) AnyView(ProgressView("Running Unit Tests"))
} }
func handleUniversalLink(_ url: URL) { func handleDeepLink(_ url: URL) -> Bool {
fatalError("Not implemented.") fatalError("Not implemented.")
} }
} }
+13
View File
@@ -35,6 +35,19 @@
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string> <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> <key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string> <string>$(CURRENT_PROJECT_VERSION)</string>
<key>ITSAppUsesNonExemptEncryption</key> <key>ITSAppUsesNonExemptEncryption</key>
+9
View File
@@ -54,6 +54,15 @@ targets:
CFBundleDisplayName: $(APP_DISPLAY_NAME) CFBundleDisplayName: $(APP_DISPLAY_NAME)
CFBundleShortVersionString: $(MARKETING_VERSION) CFBundleShortVersionString: $(MARKETING_VERSION)
CFBundleVersion: $(CURRENT_PROJECT_VERSION) CFBundleVersion: $(CURRENT_PROJECT_VERSION)
CFBundleURLTypes: [
{
CFBundleTypeRole: Editor,
CFBundleURLName: $(BASE_BUNDLE_IDENTIFIER),
CFBundleURLSchemes: [
$(APP_CUSTOM_SCHEME)
]
}
]
UISupportedInterfaceOrientations: [ UISupportedInterfaceOrientations: [
UIInterfaceOrientationPortrait, UIInterfaceOrientationPortrait,
UIInterfaceOrientationPortraitUpsideDown, UIInterfaceOrientationPortraitUpsideDown,
+1
View File
@@ -28,6 +28,7 @@ settings:
BASE_BUNDLE_IDENTIFIER: io.element.elementx BASE_BUNDLE_IDENTIFIER: io.element.elementx
APP_NAME: ElementX APP_NAME: ElementX
APP_DISPLAY_NAME: Element X APP_DISPLAY_NAME: Element X
APP_CUSTOM_SCHEME: element
KEYCHAIN_ACCESS_GROUP_IDENTIFIER: $(AppIdentifierPrefix)$(BASE_BUNDLE_IDENTIFIER) KEYCHAIN_ACCESS_GROUP_IDENTIFIER: $(AppIdentifierPrefix)$(BASE_BUNDLE_IDENTIFIER)
MARKETING_VERSION: 1.2.9 MARKETING_VERSION: 1.2.9
CURRENT_PROJECT_VERSION: 1 CURRENT_PROJECT_VERSION: 1