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