mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-10-31 08:15:24 +00:00
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:
@@ -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):
|
||||
|
||||
-1
@@ -86,7 +86,6 @@ struct NotificationSettingsScreenStrings {
|
||||
}
|
||||
|
||||
enum NotificationSettingsScreenViewAction {
|
||||
case linkClicked(url: URL)
|
||||
case changedEnableNotifications
|
||||
case groupChatsTapped
|
||||
case directChatsTapped
|
||||
|
||||
-2
@@ -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:
|
||||
|
||||
-4
@@ -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.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user