Skip to content

Commit 3eed6ed

Browse files
authored
Merge pull request #7555 from woocommerce/issue/7536-universal-links-infra
[Engage] Universal Links base infrastructure
2 parents f4acfaf + 3ef6382 commit 3eed6ed

File tree

10 files changed

+322
-0
lines changed

10 files changed

+322
-0
lines changed

WooCommerce/Classes/AppDelegate.swift

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
4949
///
5050
private lazy var appleIDCredentialChecker = AppleIDCredentialChecker()
5151

52+
private let universalLinkRouter = UniversalLinkRouter.defaultUniversalLinkRouter()
53+
5254
// MARK: - AppDelegate Methods
5355

5456
func application(_ application: UIApplication, willFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
@@ -175,6 +177,16 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
175177
DDLogVerbose("👀 Application terminating...")
176178
NotificationCenter.default.post(name: .applicationTerminating, object: nil)
177179
}
180+
181+
func application(_ application: UIApplication,
182+
continue userActivity: NSUserActivity,
183+
restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
184+
if userActivity.activityType == NSUserActivityTypeBrowsingWeb {
185+
handleWebActivity(userActivity)
186+
}
187+
188+
return true
189+
}
178190
}
179191

180192

@@ -428,3 +440,15 @@ extension AppDelegate: UNUserNotificationCenterDelegate {
428440
await ServiceLocator.pushNotesManager.handleNotificationInTheForeground(notification)
429441
}
430442
}
443+
444+
// MARK: - Universal Links
445+
446+
private extension AppDelegate {
447+
func handleWebActivity(_ activity: NSUserActivity) {
448+
guard let linkURL = activity.webpageURL else {
449+
return
450+
}
451+
452+
universalLinkRouter.handle(url: linkURL)
453+
}
454+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import Foundation
2+
import UIKit
3+
4+
protocol URLOpener {
5+
func open(_ url: URL)
6+
}
7+
8+
/// Uses the UIApplication API to open the url
9+
///
10+
struct ApplicationURLOpener: URLOpener {
11+
func open(_ url: URL) {
12+
UIApplication.shared.open(url, options: [:], completionHandler: nil)
13+
}
14+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import Foundation
2+
3+
/// This struct enriches a route with the parameters that came along with the URL,
4+
/// making it possible to perform the route action
5+
///
6+
struct MatchedRoute {
7+
let route: Route
8+
let parameters: [String: String]
9+
10+
func performAction() {
11+
route.perform(with: parameters)
12+
}
13+
}
14+
15+
/// RouterMatcher finds URL routes with paths that match the path of a specified URL,
16+
/// and extracts parameters from the URL.
17+
///
18+
class RouteMatcher {
19+
let routes: [Route]
20+
21+
/// - parameter routes: A collection of routes to match against.
22+
init(routes: [Route]) {
23+
self.routes = routes
24+
}
25+
26+
func firstRouteMatching(_ url: URL) -> MatchedRoute? {
27+
guard let components = URLComponents(string: url.absoluteString),
28+
let firstRoute = routes.first(where: { $0.path == components.path }) else {
29+
return nil
30+
}
31+
32+
guard let queryItems = components.queryItems else {
33+
return MatchedRoute(route: firstRoute, parameters: [:])
34+
}
35+
36+
return MatchedRoute(route: firstRoute, parameters: parameters(from: queryItems))
37+
}
38+
}
39+
40+
private extension RouteMatcher {
41+
func parameters(from queryItems: [URLQueryItem]) -> [String: String] {
42+
var parameters: [String: String] = [:]
43+
for queryItem in queryItems {
44+
guard let value = queryItem.value else {
45+
continue
46+
}
47+
48+
parameters[queryItem.name] = value
49+
}
50+
51+
return parameters
52+
}
53+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import Foundation
2+
3+
/// Shows order details from a given universal link that matches the right path
4+
///
5+
struct OrderDetailsRoute: Route {
6+
let path = "/orders/details"
7+
8+
func perform(with parameters: [String: String]) {
9+
DDLogInfo("We received an order details universal link with parameters: \(parameters)")
10+
}
11+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import UIKit
2+
import Foundation
3+
4+
/// A universal link route, used to encapsulate a URL path and action
5+
///
6+
protocol Route {
7+
var path: String { get }
8+
9+
func perform(with parameters: [String: String])
10+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import Foundation
2+
import UIKit
3+
4+
/// Keeps a list of possible URL routes that are exposed
5+
/// via universal links, and handles incoming links to trigger the appropriate route.
6+
///
7+
struct UniversalLinkRouter {
8+
private let matcher: RouteMatcher
9+
private let bouncingURLOpener: URLOpener
10+
11+
/// The order of the passed Route array matters, as given two routes that handle a path only the first
12+
/// will be called to perform its action. If no route matches the path it uses the `bouncingURLOpener` to
13+
/// open it e.g to be opened in web when the app cannot handle the link
14+
///
15+
init(routes: [Route], bouncingURLOpener: URLOpener = ApplicationURLOpener()) {
16+
matcher = RouteMatcher(routes: routes)
17+
self.bouncingURLOpener = bouncingURLOpener
18+
}
19+
20+
static func defaultUniversalLinkRouter() -> UniversalLinkRouter {
21+
UniversalLinkRouter(routes: UniversalLinkRouter.defaultRoutes)
22+
}
23+
24+
/// Add your route here if you want it to be considered when matching for an incoming universal link.
25+
/// As we only perform one action to avoid conflicts, order matters (only the first matched route will be called to perform its action)
26+
///
27+
private static let defaultRoutes: [Route] = [
28+
OrderDetailsRoute()
29+
]
30+
31+
func handle(url: URL) {
32+
guard let matchedRoute = matcher.firstRouteMatching(url) else {
33+
return bouncingURLOpener.open(url)
34+
}
35+
36+
matchedRoute.performAction()
37+
}
38+
}

WooCommerce/WooCommerce.xcodeproj/project.pbxproj

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1273,6 +1273,14 @@
12731273
B92FF9AE27FC7217005C34E3 /* OrderListViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = B92FF9AD27FC7217005C34E3 /* OrderListViewController.xib */; };
12741274
B92FF9B027FC7821005C34E3 /* ProductsViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = B92FF9AF27FC7821005C34E3 /* ProductsViewController.xib */; };
12751275
B94403C9289ABB4D00323FC2 /* SimplePaymentsAmountFlowOpener.swift in Sources */ = {isa = PBXBuildFile; fileRef = B94403C8289ABB4D00323FC2 /* SimplePaymentsAmountFlowOpener.swift */; };
1276+
B958A7C728B3D44A00823EEF /* UniversalLinkRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B958A7C628B3D44A00823EEF /* UniversalLinkRouter.swift */; };
1277+
B958A7C928B3D47B00823EEF /* Route.swift in Sources */ = {isa = PBXBuildFile; fileRef = B958A7C828B3D47B00823EEF /* Route.swift */; };
1278+
B958A7CB28B3D4A100823EEF /* RouteMatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = B958A7CA28B3D4A100823EEF /* RouteMatcher.swift */; };
1279+
B958A7CD28B3DD9100823EEF /* OrderDetailsRoute.swift in Sources */ = {isa = PBXBuildFile; fileRef = B958A7CC28B3DD9100823EEF /* OrderDetailsRoute.swift */; };
1280+
B958A7D128B5281800823EEF /* UniversalLinkRouterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B958A7D028B5281800823EEF /* UniversalLinkRouterTests.swift */; };
1281+
B958A7D328B52A2300823EEF /* MockRoute.swift in Sources */ = {isa = PBXBuildFile; fileRef = B958A7D228B52A2300823EEF /* MockRoute.swift */; };
1282+
B958A7D628B5310100823EEF /* URLOpener.swift in Sources */ = {isa = PBXBuildFile; fileRef = B958A7D428B5302500823EEF /* URLOpener.swift */; };
1283+
B958A7D828B5316A00823EEF /* MockURLOpener.swift in Sources */ = {isa = PBXBuildFile; fileRef = B958A7D728B5316A00823EEF /* MockURLOpener.swift */; };
12761284
B96B536B2816ECFC00F753E6 /* CardPresentPluginsDataProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B96B536A2816ECFC00F753E6 /* CardPresentPluginsDataProviderTests.swift */; };
12771285
B979A9BA282D62A500EBB383 /* InPersonPaymentsDeactivateStripeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B979A9B9282D62A500EBB383 /* InPersonPaymentsDeactivateStripeView.swift */; };
12781286
B9B0391628A6824200DC1C83 /* PermanentNoticePresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9B0391528A6824200DC1C83 /* PermanentNoticePresenter.swift */; };
@@ -3117,6 +3125,14 @@
31173125
B92FF9AD27FC7217005C34E3 /* OrderListViewController.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = OrderListViewController.xib; sourceTree = "<group>"; };
31183126
B92FF9AF27FC7821005C34E3 /* ProductsViewController.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = ProductsViewController.xib; sourceTree = "<group>"; };
31193127
B94403C8289ABB4D00323FC2 /* SimplePaymentsAmountFlowOpener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimplePaymentsAmountFlowOpener.swift; sourceTree = "<group>"; };
3128+
B958A7C628B3D44A00823EEF /* UniversalLinkRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UniversalLinkRouter.swift; sourceTree = "<group>"; };
3129+
B958A7C828B3D47B00823EEF /* Route.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Route.swift; sourceTree = "<group>"; };
3130+
B958A7CA28B3D4A100823EEF /* RouteMatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouteMatcher.swift; sourceTree = "<group>"; };
3131+
B958A7CC28B3DD9100823EEF /* OrderDetailsRoute.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrderDetailsRoute.swift; sourceTree = "<group>"; };
3132+
B958A7D028B5281800823EEF /* UniversalLinkRouterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UniversalLinkRouterTests.swift; sourceTree = "<group>"; };
3133+
B958A7D228B52A2300823EEF /* MockRoute.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockRoute.swift; sourceTree = "<group>"; };
3134+
B958A7D428B5302500823EEF /* URLOpener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLOpener.swift; sourceTree = "<group>"; };
3135+
B958A7D728B5316A00823EEF /* MockURLOpener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockURLOpener.swift; sourceTree = "<group>"; };
31203136
B96B536A2816ECFC00F753E6 /* CardPresentPluginsDataProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardPresentPluginsDataProviderTests.swift; sourceTree = "<group>"; };
31213137
B979A9B9282D62A500EBB383 /* InPersonPaymentsDeactivateStripeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InPersonPaymentsDeactivateStripeView.swift; sourceTree = "<group>"; };
31223138
B9B0391528A6824200DC1C83 /* PermanentNoticePresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermanentNoticePresenter.swift; sourceTree = "<group>"; };
@@ -6054,6 +6070,8 @@
60546070
0247F50D286E6CCD009C177E /* MockProductImageActionHandler.swift */,
60556071
0248042C2887C92A00991319 /* MockLoggedOutAppSettings.swift */,
60566072
02829BA9288FA8B300951E1E /* MockUserNotification.swift */,
6073+
B958A7D228B52A2300823EEF /* MockRoute.swift */,
6074+
B958A7D728B5316A00823EEF /* MockURLOpener.swift */,
60576075
);
60586076
path = Mocks;
60596077
sourceTree = "<group>";
@@ -6486,6 +6504,7 @@
64866504
31579027273EE2B1008CA3AF /* VersionHelpers.swift */,
64876505
174CA86D27DBFD2D00126524 /* ShareAppTextItemActivitySource.swift */,
64886506
B9C4AB2427FDE4B6007008B8 /* CardPresentPluginsDataProvider.swift */,
6507+
B958A7D428B5302500823EEF /* URLOpener.swift */,
64896508
);
64906509
path = Tools;
64916510
sourceTree = "<group>";
@@ -6529,6 +6548,7 @@
65296548
B56DB3E02049BFAA00D4AA8E /* WooCommerceTests */ = {
65306549
isa = PBXGroup;
65316550
children = (
6551+
B958A7CF28B527FB00823EEF /* Universal Links */,
65326552
D8F01DD125DEDC0100CE70BE /* Stripe Integration Tests */,
65336553
5791FB4024EC833200117FD6 /* ViewModels */,
65346554
57C2F6E324C27B0C00131012 /* Authentication */,
@@ -6593,6 +6613,7 @@
65936613
B56DB3F12049C0B800D4AA8E /* Classes */ = {
65946614
isa = PBXGroup;
65956615
children = (
6616+
B958A7C528B3D42000823EEF /* Universal Links */,
65966617
B57B67882107545B00AF8905 /* Model */,
65976618
D8D15F81230A178100D48B3F /* ServiceLocator */,
65986619
747AA0872107CE270047A89B /* Analytics */,
@@ -6897,6 +6918,33 @@
68976918
path = CustomerNoteSection;
68986919
sourceTree = "<group>";
68996920
};
6921+
B958A7C528B3D42000823EEF /* Universal Links */ = {
6922+
isa = PBXGroup;
6923+
children = (
6924+
B958A7CE28B50FAE00823EEF /* Routes */,
6925+
B958A7C628B3D44A00823EEF /* UniversalLinkRouter.swift */,
6926+
B958A7CA28B3D4A100823EEF /* RouteMatcher.swift */,
6927+
);
6928+
path = "Universal Links";
6929+
sourceTree = "<group>";
6930+
};
6931+
B958A7CE28B50FAE00823EEF /* Routes */ = {
6932+
isa = PBXGroup;
6933+
children = (
6934+
B958A7CC28B3DD9100823EEF /* OrderDetailsRoute.swift */,
6935+
B958A7C828B3D47B00823EEF /* Route.swift */,
6936+
);
6937+
path = Routes;
6938+
sourceTree = "<group>";
6939+
};
6940+
B958A7CF28B527FB00823EEF /* Universal Links */ = {
6941+
isa = PBXGroup;
6942+
children = (
6943+
B958A7D028B5281800823EEF /* UniversalLinkRouterTests.swift */,
6944+
);
6945+
path = "Universal Links";
6946+
sourceTree = "<group>";
6947+
};
69006948
B9B0391B28A690DA00DC1C83 /* PermanentNotice */ = {
69016949
isa = PBXGroup;
69026950
children = (
@@ -9556,6 +9604,7 @@
95569604
45FDDD65267784AD00ADACE8 /* ShippingLabelSummaryTableViewCell.swift in Sources */,
95579605
45DB70662614CE3F0064A6CF /* Decimal+Helpers.swift in Sources */,
95589606
0379C51727BFCE9800A7E284 /* WCPayCardBrand+icons.swift in Sources */,
9607+
B958A7C728B3D44A00823EEF /* UniversalLinkRouter.swift in Sources */,
95599608
02E8B17C23E2C78A00A43403 /* ProductImageStatus.swift in Sources */,
95609609
0259D5FF2581F3FA003B1CD6 /* ShippingLabelPaperSizeOptionsViewController.swift in Sources */,
95619610
CCEC256A27B581E800EF9FA3 /* ProductVariationFormatter.swift in Sources */,
@@ -9574,6 +9623,7 @@
95749623
7441E1D221503F77004E6ECE /* IntrinsicTableView.swift in Sources */,
95759624
B517EA1D218B41F200730EC4 /* String+Woo.swift in Sources */,
95769625
26B3D8A0252235C50054C319 /* RefundShippingDetailsViewModel.swift in Sources */,
9626+
B958A7CD28B3DD9100823EEF /* OrderDetailsRoute.swift in Sources */,
95779627
45DB7040261209B10064A6CF /* ItemToFulfillRow.swift in Sources */,
95789628
02564A8C246CE38E00D6DB2A /* SwappableSubviewContainerView.swift in Sources */,
95799629
023453F22579DA1A00A6BB20 /* ShippingLabelPrintingInstructionsViewController.swift in Sources */,
@@ -9717,6 +9767,7 @@
97179767
0211252825773F220075AD2A /* Models+Copiable.generated.swift in Sources */,
97189768
4596853F2540669900D17B90 /* DownloadableFileSource.swift in Sources */,
97199769
021DD44D286A3A8D004F0468 /* UIViewController+Navigation.swift in Sources */,
9770+
B958A7CB28B3D4A100823EEF /* RouteMatcher.swift in Sources */,
97209771
0279F0E4252DC9670098D7DE /* ProductVariationLoadUseCase.swift in Sources */,
97219772
CCF87BC02790582500461C43 /* ProductVariationSelector.swift in Sources */,
97229773
02CA63DC23D1ADD100BBF148 /* DeviceMediaLibraryPicker.swift in Sources */,
@@ -9758,6 +9809,7 @@
97589809
DE74F2A527E41D740002FE59 /* EnableAnalyticsView.swift in Sources */,
97599810
D85A3C5626C1911600C0E026 /* InPersonPaymentsPluginNotInstalledView.swift in Sources */,
97609811
B59D1EDF219072CC009D1978 /* ProductReviewTableViewCell.swift in Sources */,
9812+
B958A7C928B3D47B00823EEF /* Route.swift in Sources */,
97619813
CC53FB3A275697B000C4CA4F /* ProductRowViewModel.swift in Sources */,
97629814
AEE1D4F525D14F88006A490B /* AttributeOptionListSelectorCommand.swift in Sources */,
97639815
020DD48A23229495005822B1 /* ProductsTabProductTableViewCell.swift in Sources */,
@@ -9770,6 +9822,7 @@
97709822
02396251239948470096F34C /* UIImage+TintColor.swift in Sources */,
97719823
DEE6437826D8DAD900888A75 /* InProgressView.swift in Sources */,
97729824
0290E275238E4F8100B5C466 /* PaginatedListSelectorViewController.swift in Sources */,
9825+
B958A7D628B5310100823EEF /* URLOpener.swift in Sources */,
97739826
020DD48F232392C9005822B1 /* UIViewController+AppReview.swift in Sources */,
97749827
2687165524D21BC80042F6AE /* SurveySubmittedViewController.swift in Sources */,
97759828
CE263DE8206ACE3E0015A693 /* MainTabBarController.swift in Sources */,
@@ -10132,6 +10185,7 @@
1013210185
02CE43092769953D0006EAEF /* MockCaptureDevicePermissionChecker.swift in Sources */,
1013310186
7E6A01A32726C5D3001668D5 /* MockProductCategoryStoresManager.swift in Sources */,
1013410187
45F5A3C323DF31D2007D40E5 /* ShippingInputFormatterTests.swift in Sources */,
10188+
B958A7D328B52A2300823EEF /* MockRoute.swift in Sources */,
1013510189
02153211242376B5003F2BBD /* ProductPriceSettingsViewModelTests.swift in Sources */,
1013610190
45C8B25D231529410002FA77 /* CustomerInfoTableViewCellTests.swift in Sources */,
1013710191
023EC2E624DAB1270021DA91 /* EditableProductVariationModelTests.swift in Sources */,
@@ -10209,6 +10263,7 @@
1020910263
0375799D2822F9040083F2E1 /* MockCardPresentPaymentsOnboardingPresenter.swift in Sources */,
1021010264
455800CC24C6F83F00A8D117 /* ProductSettingsSectionsTests.swift in Sources */,
1021110265
D85B833F2230F268002168F3 /* SummaryTableViewCellTests.swift in Sources */,
10266+
B958A7D828B5316A00823EEF /* MockURLOpener.swift in Sources */,
1021210267
02645D8227BA20A30065DC68 /* InboxViewModelTests.swift in Sources */,
1021310268
57ABE36824EB048A00A64F49 /* MockSwitchStoreUseCase.swift in Sources */,
1021410269
311F827626CD8AB100DF5BAD /* MockCardReaderSettingsAlerts.swift in Sources */,
@@ -10454,6 +10509,7 @@
1045410509
571FDDAE24C768DC00D486A5 /* MockZendeskManager.swift in Sources */,
1045510510
45FBDF3C238D4EA800127F77 /* ExtendedAddProductImageCollectionViewCellTests.swift in Sources */,
1045610511
02279590237A5DC900787C63 /* AztecUnorderedListFormatBarCommandTests.swift in Sources */,
10512+
B958A7D128B5281800823EEF /* UniversalLinkRouterTests.swift in Sources */,
1045710513
B5F571AB21BEECB60010D1B8 /* NoteWooTests.swift in Sources */,
1045810514
D802546B2655180A001B2CC1 /* CardPresentModalReaderIsReadyTests.swift in Sources */,
1045910515
45DB706C26161F970064A6CF /* DecimalWooTests.swift in Sources */,
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
@testable import WooCommerce
2+
3+
final class MockRoute: Route {
4+
let path: String
5+
let performAction: ([String: String]) -> ()
6+
7+
init(path: String, performAction: @escaping ([String: String]) -> ()) {
8+
self.path = path
9+
self.performAction = performAction
10+
}
11+
12+
func perform(with parameters: [String: String]) {
13+
performAction(parameters)
14+
}
15+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import Foundation
2+
@testable import WooCommerce
3+
4+
struct MockURLOpener: URLOpener {
5+
let open: (URL) -> Void
6+
7+
func open(_ url: URL) {
8+
open(url)
9+
}
10+
}

0 commit comments

Comments
 (0)