├── NavigationEngineDemo ├── Assets.xcassets │ ├── Contents.json │ └── AppIcon.appiconset │ │ └── Contents.json ├── NavigationEngineDemo.entitlements ├── NavigationEngine │ ├── Core │ │ ├── Protocol Extensions │ │ │ ├── NSUserActivity+ProtocolExtension.swift │ │ │ └── UIApplication+ProtocolExtension.swift │ │ ├── Protocols │ │ │ ├── NSUserActivityProtocol.swift │ │ │ ├── UserStatusProviding.swift │ │ │ ├── NavigationIntentHandling.swift │ │ │ ├── UIApplicationProtocol.swift │ │ │ ├── DeepLinkingSettingsProtocol.swift │ │ │ └── FlowControllers │ │ │ │ ├── SettingsFlowControllerProtocol.swift │ │ │ │ ├── OrdersFlowControllerProtocol.swift │ │ │ │ ├── AccountFlowControllerProtocol.swift │ │ │ │ ├── CheckoutFlowControllerProtocol.swift │ │ │ │ ├── RestaurantsFlowControllerProtocol.swift │ │ │ │ └── RootFlowControllerProtocol.swift │ │ ├── Utilities │ │ │ └── Endpoint.swift │ │ ├── DeepLinkingConstants.swift │ │ ├── Navigation │ │ │ ├── FlowControllerProvider.swift │ │ │ ├── NavigationIntentFactory.swift │ │ │ ├── StateMachine.swift │ │ │ ├── NavigationTransitioner.swift │ │ │ └── NavigationIntentHandler.swift │ │ ├── Parsing │ │ │ ├── SpotlightItemConverter.swift │ │ │ ├── ShortcutItemConverter.swift │ │ │ ├── URLGateway.swift │ │ │ ├── DeepLinkFactory.swift │ │ │ └── UniversalLinkConverter.swift │ │ └── DeepLinkingFacade.swift │ └── Tester │ │ └── DeepLinkingTesterViewController.swift ├── DemoApp │ ├── NavigationEngineIntegration │ │ ├── UserStatusProvider.swift │ │ ├── DeepLinkingSettings.swift │ │ ├── deeplinking_test_list.json │ │ └── FlowControllers │ │ │ ├── SettingsFlowController.swift │ │ │ ├── OrdersFlowController.swift │ │ │ ├── CheckoutFlowController.swift │ │ │ ├── AccountFlowController.swift │ │ │ ├── RestaurantsFlowController.swift │ │ │ └── RootFlowController.swift │ ├── ViewControllers │ │ ├── AccountViewController.swift │ │ ├── PrivacyPolicyViewController.swift │ │ ├── TabBarController.swift │ │ ├── ResetPasswordViewController.swift │ │ ├── SettingsViewController.swift │ │ ├── HomeViewController.swift │ │ ├── OrderHistoryViewController.swift │ │ ├── CheckoutViewController.swift │ │ ├── OrderDetailsViewController.swift │ │ ├── RestaurantViewController.swift │ │ ├── SERPViewController.swift │ │ ├── BasketViewController.swift │ │ └── PaymentViewController.swift │ ├── Extensions │ │ └── Storyboarding.swift │ ├── UserInputController.swift │ └── AppDelegate.swift ├── Base.lproj │ └── LaunchScreen.storyboard └── Info.plist ├── NavigationEngineDemo.xcodeproj ├── project.xcworkspace │ └── contents.xcworkspacedata ├── xcuserdata │ └── alberto.debortoli.xcuserdatad │ │ └── xcschemes │ │ └── xcschememanagement.plist └── xcshareddata │ └── xcschemes │ └── NavigationEngineDemo.xcscheme ├── Podfile ├── NavigationEngineDemo.xcworkspace ├── contents.xcworkspacedata └── xcshareddata │ └── WorkspaceSettings.xcsettings ├── Podfile.lock ├── NavigationEngineDemoTests ├── Utilities │ ├── MockUserStatusProvider.swift │ ├── MockNSUserActivity.swift │ ├── MockDeepLinkingSettings.swift │ ├── MockNavigationTransitionerDataSource.swift │ └── MockFlowControllers.swift ├── Info.plist ├── Navigation │ ├── FlowControllerProviderTests.swift │ ├── StateMachineTests.swift │ ├── NavigationIntentHandlerTests.swift │ └── NavigationIntentFactoryTests.swift ├── Parsing │ ├── SpotlightItemConverterTests.swift │ ├── ShortcutItemConverterTests.swift │ ├── URLGatewayTests.swift │ ├── DeepLinkFactoryTests.swift │ └── UniversalLinkConverterTests.swift └── DeepLinkingFacadeTests.swift ├── README.md ├── .gitignore └── LICENSE /NavigationEngineDemo/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } -------------------------------------------------------------------------------- /NavigationEngineDemo/NavigationEngineDemo.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /NavigationEngineDemo.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Podfile: -------------------------------------------------------------------------------- 1 | source 'https://github.com/CocoaPods/Specs.git' 2 | 3 | use_frameworks! 4 | platform :ios, '10.0' 5 | 6 | target 'NavigationEngineDemo' do 7 | pod 'Promis', '2.2.0' 8 | pod 'Stateful', '2.2.0' 9 | 10 | target 'NavigationEngineDemoTests' do 11 | inherit! :search_paths 12 | end 13 | 14 | end 15 | -------------------------------------------------------------------------------- /NavigationEngineDemo.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /NavigationEngineDemo/NavigationEngine/Core/Protocol Extensions/NSUserActivity+ProtocolExtension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NSUserActivity+ProtocolExtension.swift 3 | // NavigationEngine 4 | // 5 | // Created by Alberto De Bortoli on 11/02/2019. 6 | // 7 | 8 | import Foundation 9 | 10 | extension NSUserActivity: NSUserActivityProtocol { } 11 | -------------------------------------------------------------------------------- /NavigationEngineDemo.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | BuildSystemType 6 | Original 7 | 8 | 9 | -------------------------------------------------------------------------------- /NavigationEngineDemo/NavigationEngine/Core/Protocol Extensions/UIApplication+ProtocolExtension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIApplication+ProtocolExtension.swift 3 | // JUSTEAT 4 | // 5 | // Created by Alberto De Bortoli on 28/04/2018. 6 | // Copyright © 2018 Just Eat. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | extension UIApplication: UIApplicationProtocol {} 12 | -------------------------------------------------------------------------------- /NavigationEngineDemo/NavigationEngine/Core/Protocols/NSUserActivityProtocol.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NSUserActivityProtocol.swift 3 | // NavigationEngine 4 | // 5 | // Created by Alberto De Bortoli on 11/02/2019. 6 | // 7 | 8 | import Foundation 9 | 10 | public protocol NSUserActivityProtocol { 11 | 12 | var activityType: String { get } 13 | var userInfo: [AnyHashable: Any]? { get } 14 | } 15 | -------------------------------------------------------------------------------- /NavigationEngineDemo/NavigationEngine/Core/Protocols/UserStatusProviding.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UserStatusProviding.swift 3 | // NavigationEngineDemo 4 | // 5 | // Created by Alberto De Bortoli on 04/02/2019. 6 | // Copyright © 2019 Just Eat. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public protocol UserStatusProviding { 12 | 13 | var userStatus: UserStatus { get } 14 | } 15 | -------------------------------------------------------------------------------- /NavigationEngineDemo/DemoApp/NavigationEngineIntegration/UserStatusProvider.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UserStatusProvider.swift 3 | // NavigationEngineDemo 4 | // 5 | // Created by Alberto De Bortoli on 04/02/2019. 6 | // Copyright © 2019 Just Eat. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | class UserStatusProvider: UserStatusProviding { 12 | 13 | var userStatus: UserStatus = .loggedOut 14 | } 15 | -------------------------------------------------------------------------------- /NavigationEngineDemo/NavigationEngine/Core/Protocols/NavigationIntentHandling.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NavigationIntentHandling.swift 3 | // NavigationEngineDemo 4 | // 5 | // Created by Alberto De Bortoli on 28/12/2018. 6 | // Copyright © 2018 Just Eat. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Promis 11 | 12 | protocol NavigationIntentHandling { 13 | 14 | func handleIntent(_ intent: NavigationIntent) -> Future 15 | } 16 | -------------------------------------------------------------------------------- /NavigationEngineDemo/NavigationEngine/Core/Protocols/UIApplicationProtocol.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIApplicationProtocol.swift 3 | // Location 4 | // 5 | // Created by Alberto De Bortoli on 06/02/2018. 6 | // Copyright © 2018 Just Eat. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | protocol UIApplicationProtocol { 12 | 13 | func open(_ url: URL, options: [UIApplication.OpenExternalURLOptionsKey: Any], completionHandler completion: ((Bool) -> Void)?) 14 | } 15 | -------------------------------------------------------------------------------- /Podfile.lock: -------------------------------------------------------------------------------- 1 | PODS: 2 | - Promis (2.2.0) 3 | - Stateful (2.2.0) 4 | 5 | DEPENDENCIES: 6 | - Promis (= 2.2.0) 7 | - Stateful (= 2.2.0) 8 | 9 | SPEC REPOS: 10 | https://github.com/cocoapods/specs.git: 11 | - Promis 12 | - Stateful 13 | 14 | SPEC CHECKSUMS: 15 | Promis: 4b7c710108427d361e29a2be352dc0eff5315456 16 | Stateful: 92e01259d6e0b54af8ee4536ec50430b3d378063 17 | 18 | PODFILE CHECKSUM: e8dcc578ec330fe55ad76341a832a280f738e508 19 | 20 | COCOAPODS: 1.6.1 21 | -------------------------------------------------------------------------------- /NavigationEngineDemoTests/Utilities/MockUserStatusProvider.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MockUserStatusProvider.swift 3 | // NavigationEngine-Unit-Tests 4 | // 5 | // Created by Alberto De Bortoli on 12/02/2019. 6 | // 7 | 8 | import Foundation 9 | @testable import NavigationEngineDemo 10 | 11 | class MockUserStatusProvider: UserStatusProviding { 12 | 13 | var userStatus: UserStatus 14 | 15 | init(userStatus: UserStatus) { 16 | self.userStatus = userStatus 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # NavigationEngineDemo 2 | 3 | This is a sample project companion to the blog post "Deep Linking at Scale on iOS", that can be found at 4 | 5 | - [Just Eat Tech blog (WordPress)](https://tech.just-eat.com/2019/04/16/deep-linking-at-scale-on-ios/) 6 | - [Just Eat Engineering blog (Medium)](https://medium.com/just-eat-tech/deep-linking-at-scale-on-ios-1dd8789c389f) 7 | 8 | ## License 9 | 10 | NavigationEngineDemo is available under the Apache 2.0 license. See the LICENSE file for more info. 11 | 12 | - Just Eat iOS team 13 | -------------------------------------------------------------------------------- /NavigationEngineDemoTests/Utilities/MockNSUserActivity.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MockNSUserActivity.swift 3 | // NavigationEngine 4 | // 5 | // Created by Alberto De Bortoli on 11/02/2019. 6 | // 7 | 8 | import Foundation 9 | @testable import NavigationEngineDemo 10 | 11 | class MockNSUserActivity: NSUserActivityProtocol { 12 | 13 | var activityType: String 14 | var userInfo: [AnyHashable : Any]? 15 | 16 | init(activityType: String, userInfo: [AnyHashable : Any]?) { 17 | self.activityType = activityType 18 | self.userInfo = userInfo 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /NavigationEngineDemo/NavigationEngine/Core/Protocols/DeepLinkingSettingsProtocol.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DeepLinkingSettingsProtocol.swift 3 | // NavigationEngineDemo 4 | // 5 | // Created by Alberto De Bortoli on 04/02/2019. 6 | // Copyright © 2019 Just Eat. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public protocol DeepLinkingSettingsProtocol { 12 | 13 | var universalLinkSchemes: [String] { get } 14 | var universalLinkHosts: [String] { get } 15 | 16 | var internalDeepLinkSchemes: [String] { get } 17 | var internalDeepLinkHost: String { get } 18 | } 19 | -------------------------------------------------------------------------------- /NavigationEngineDemoTests/Utilities/MockDeepLinkingSettings.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MockDeepLinkingSettings.swift 3 | // Pods 4 | // 5 | // Created by Alberto De Bortoli on 10/02/2019. 6 | // 7 | 8 | import Foundation 9 | import NavigationEngineDemo 10 | 11 | class MockDeepLinkingSettings: DeepLinkingSettingsProtocol { 12 | 13 | var universalLinkSchemes = ["http", "https"] 14 | var universalLinkHosts = ["just-eat.co.uk", "www.just-eat.co.uk"] 15 | 16 | var internalDeepLinkSchemes = ["je-internal", "justeat", "just-eat", "just-eat-uk"] 17 | var internalDeepLinkHost = "je.com" 18 | } 19 | -------------------------------------------------------------------------------- /NavigationEngineDemo/DemoApp/ViewControllers/AccountViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LoginViewController.swift 3 | // NavigationEngineDemo 4 | // 5 | // Created by Alberto De Bortoli on 05/02/2019. 6 | // Copyright © 2019 Just Eat. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import UIKit 11 | 12 | class LoginViewController: UIViewController, Storyboarding { 13 | 14 | var flowController: AccountFlowController! 15 | 16 | override func viewDidLoad() { 17 | super.viewDidLoad() 18 | title = String(NSStringFromClass(type(of: self)).split(separator: ".").last!) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /NavigationEngineDemo/DemoApp/ViewControllers/PrivacyPolicyViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PrivacyPolicyViewController.swift 3 | // NavigationEngineDemo 4 | // 5 | // Created by Alberto De Bortoli on 23/11/2018. 6 | // Copyright © 2018 Just Eat. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class PrivacyPolicyViewController: UIViewController, Storyboarding { 12 | 13 | var flowController: SettingsFlowController! 14 | 15 | override func viewDidLoad() { 16 | super.viewDidLoad() 17 | title = String(NSStringFromClass(type(of: self)).split(separator: ".").last!) 18 | } 19 | } 20 | 21 | -------------------------------------------------------------------------------- /NavigationEngineDemo/DemoApp/ViewControllers/TabBarController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TabBarController.swift 3 | // NavigationEngineDemo 4 | // 5 | // Created by Alberto De Bortoli on 23/11/2018. 6 | // Copyright © 2018 Just Eat. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class TabBarController: UITabBarController, Storyboarding { 12 | 13 | weak var flowController: RootFlowController! 14 | 15 | override func viewDidLoad() { 16 | super.viewDidLoad() 17 | self.title = String(NSStringFromClass(type(of: self)).split(separator: ".").last!) 18 | flowController.setup() 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /NavigationEngineDemo/NavigationEngine/Core/Protocols/FlowControllers/SettingsFlowControllerProtocol.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SettingsFlowController.swift 3 | // NavigationEngineDemo 4 | // 5 | // Created by Alberto De Bortoli on 23/11/2018. 6 | // Copyright © 2018 Just Eat. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import Promis 11 | 12 | public protocol SettingsFlowControllerProtocol { 13 | 14 | var parentFlowController: RootFlowControllerProtocol! { get } 15 | 16 | @discardableResult func dismiss(animated: Bool) -> Future 17 | @discardableResult func goBackToRoot(animated: Bool) -> Future 18 | } 19 | -------------------------------------------------------------------------------- /NavigationEngineDemo/DemoApp/NavigationEngineIntegration/DeepLinkingSettings.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DeepLinkingSettings.swift 3 | // NavigationEngineDemo 4 | // 5 | // Created by Alberto De Bortoli on 04/02/2019. 6 | // Copyright © 2019 Just Eat. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | class DeepLinkingSettings: DeepLinkingSettingsProtocol { 12 | 13 | var universalLinkSchemes = ["http", "https"] 14 | var universalLinkHosts = ["just-eat.co.uk", "www.just-eat.co.uk"] 15 | 16 | var internalDeepLinkSchemes = ["je-internal", "justeat", "just-eat", "just-eat-uk"] 17 | var internalDeepLinkHost = "je.com" 18 | } 19 | -------------------------------------------------------------------------------- /NavigationEngineDemo/DemoApp/ViewControllers/ResetPasswordViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ResetPasswordViewController.swift 3 | // NavigationEngineDemo 4 | // 5 | // Created by Alberto De Bortoli on 05/02/2019. 6 | // Copyright © 2019 Just Eat. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import UIKit 11 | 12 | class ResetPasswordViewController: UIViewController, Storyboarding { 13 | 14 | var flowController: AccountFlowController! 15 | var token: ResetPasswordToken! 16 | 17 | override func viewDidLoad() { 18 | super.viewDidLoad() 19 | title = String(NSStringFromClass(type(of: self)).split(separator: ".").last!) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /NavigationEngineDemo/DemoApp/ViewControllers/SettingsViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SettingsViewController.swift 3 | // NavigationEngineDemo 4 | // 5 | // Created by Alberto De Bortoli on 23/11/2018. 6 | // Copyright © 2018 Just Eat. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class SettingsViewController: UIViewController { 12 | 13 | var flowController: SettingsFlowController! 14 | 15 | override func viewDidLoad() { 16 | super.viewDidLoad() 17 | title = String(NSStringFromClass(type(of: self)).split(separator: ".").last!) 18 | } 19 | 20 | @IBAction func privacyPolicySelected(_ sender: Any) { 21 | flowController.goToPrivacyPolicy() 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /NavigationEngineDemo/DemoApp/ViewControllers/HomeViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HomeViewController.swift 3 | // NavigationEngineDemo 4 | // 5 | // Created by Alberto De Bortoli on 23/11/2018. 6 | // Copyright © 2018 Just Eat. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class HomeViewController: UIViewController { 12 | 13 | var flowController: RestaurantsFlowController! 14 | 15 | override func viewDidLoad() { 16 | super.viewDidLoad() 17 | title = String(NSStringFromClass(type(of: self)).split(separator: ".").last!) 18 | } 19 | 20 | @IBAction func serpSelected(_ sender: Any) { 21 | flowController.goToSearchAnimated(postcode: "", cuisine: "", animated: true) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /NavigationEngineDemo/DemoApp/ViewControllers/OrderHistoryViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OrderHistoryViewController.swift 3 | // NavigationEngineDemo 4 | // 5 | // Created by Alberto De Bortoli on 23/11/2018. 6 | // Copyright © 2018 Just Eat. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class OrderHistoryViewController: UIViewController { 12 | 13 | var flowController: OrdersFlowController! 14 | 15 | override func viewDidLoad() { 16 | super.viewDidLoad() 17 | title = String(NSStringFromClass(type(of: self)).split(separator: ".").last!) 18 | } 19 | 20 | @IBAction func orderSelected(_ sender: Any) { 21 | flowController.goToOrder(orderId: "", animated: true) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /NavigationEngineDemo/NavigationEngine/Core/Utilities/Endpoint.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LoginViewController.swift 3 | // NavigationEngineDemo 4 | // 5 | // Created by Alberto De Bortoli on 05/02/2019. 6 | // Copyright © 2019 Just Eat. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct Endpoint { 12 | let scheme: String 13 | let host: String 14 | let path: String 15 | let queryItems: [URLQueryItem]? 16 | } 17 | 18 | extension Endpoint { 19 | var url: URL { 20 | var components = URLComponents() 21 | components.scheme = scheme 22 | components.host = host 23 | components.path = path 24 | components.queryItems = queryItems 25 | return components.url! 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /NavigationEngineDemo/DemoApp/ViewControllers/CheckoutViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CheckoutViewController.swift 3 | // NavigationEngineDemo 4 | // 5 | // Created by Alberto De Bortoli on 23/11/2018. 6 | // Copyright © 2018 Just Eat. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class CheckoutViewController: UIViewController, Storyboarding { 12 | 13 | var flowController: CheckoutFlowController! 14 | 15 | override func viewDidLoad() { 16 | super.viewDidLoad() 17 | title = String(NSStringFromClass(type(of: self)).split(separator: ".").last!) 18 | } 19 | 20 | @IBAction func proceedToPaymentButtonTapped(_ sender: Any) { 21 | flowController.proceedToPayment(animated: true) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /NavigationEngineDemo/DemoApp/Extensions/Storyboarding.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Storyboarding.swift 3 | // NavigationEngineDemo 4 | // 5 | // Created by Alberto De Bortoli on 04/01/2019. 6 | // Copyright © 2019 Just Eat. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | protocol Storyboarding { 12 | static func instantiate() -> Self 13 | } 14 | 15 | extension Storyboarding where Self: UIViewController { 16 | static func instantiate() -> Self { 17 | let fullName = NSStringFromClass(self) 18 | let className = fullName.components(separatedBy: ".")[1] 19 | let storyboard = UIStoryboard(name: "Main", bundle: Bundle.main) 20 | return storyboard.instantiateViewController(withIdentifier: className) as! Self 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /NavigationEngineDemo/DemoApp/ViewControllers/OrderDetailsViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OrderDetailsViewController.swift 3 | // NavigationEngineDemo 4 | // 5 | // Created by Alberto De Bortoli on 23/11/2018. 6 | // Copyright © 2018 Just Eat. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class OrderDetailsViewController: UIViewController, Storyboarding { 12 | 13 | var flowController: OrdersFlowController! 14 | 15 | override func viewDidLoad() { 16 | super.viewDidLoad() 17 | title = String(NSStringFromClass(type(of: self)).split(separator: ".").last!) 18 | } 19 | 20 | @IBAction func goToRestaurantButtonTapped(_ sender: Any) { 21 | flowController.goToRestaurant(restaurantId: "") 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /NavigationEngineDemo/DemoApp/ViewControllers/RestaurantViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RestaurantViewController.swift 3 | // NavigationEngineDemo 4 | // 5 | // Created by Alberto De Bortoli on 23/11/2018. 6 | // Copyright © 2018 Just Eat. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class RestaurantViewController: UIViewController, Storyboarding { 12 | 13 | var flowController: RestaurantsFlowController! 14 | 15 | override func viewDidLoad() { 16 | super.viewDidLoad() 17 | title = String(NSStringFromClass(type(of: self)).split(separator: ".").last!) 18 | } 19 | 20 | @IBAction func basketButtonSelected(_ sender: Any) { 21 | flowController.goToBasket(reorderId: "", animated: true) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /NavigationEngineDemo/DemoApp/ViewControllers/SERPViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SerpViewController.swift 3 | // NavigationEngineDemo 4 | // 5 | // Created by Alberto De Bortoli on 23/11/2018. 6 | // Copyright © 2018 Just Eat. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class SerpViewController: UIViewController, Storyboarding { 12 | 13 | var flowController: RestaurantsFlowController! 14 | 15 | override func viewDidLoad() { 16 | super.viewDidLoad() 17 | title = String(NSStringFromClass(type(of: self)).split(separator: ".").last!) 18 | } 19 | 20 | @IBAction func restaurantSelected(_ sender: Any) { 21 | flowController.goToRestaurant(restaurantId: "", postcode: nil, serviceType: nil, section: nil, animated: true) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /NavigationEngineDemo.xcodeproj/xcuserdata/alberto.debortoli.xcuserdatad/xcschemes/xcschememanagement.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SchemeUserState 6 | 7 | NavigationEngineDemo.xcscheme_^#shared#^_ 8 | 9 | orderHint 10 | 4 11 | 12 | 13 | SuppressBuildableAutocreation 14 | 15 | 4F4F65F5223B025000AE1D03 16 | 17 | primary 18 | 19 | 20 | 4F4F6609223B025200AE1D03 21 | 22 | primary 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /NavigationEngineDemo/DemoApp/ViewControllers/BasketViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BasketViewController.swift 3 | // NavigationEngineDemo 4 | // 5 | // Created by Alberto De Bortoli on 23/11/2018. 6 | // Copyright © 2018 Just Eat. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class BasketViewController: UIViewController, Storyboarding { 12 | 13 | var flowController: RestaurantsFlowController! 14 | private var checkoutFlowController: CheckoutFlowController! 15 | 16 | override func viewDidLoad() { 17 | super.viewDidLoad() 18 | title = String(NSStringFromClass(type(of: self)).split(separator: ".").last!) 19 | } 20 | 21 | @IBAction func checkoutButtonSelected(_ sender: Any) { 22 | flowController.goToCheckout(animated: true) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /NavigationEngineDemo/NavigationEngine/Core/Protocols/FlowControllers/OrdersFlowControllerProtocol.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OrdersFlowController.swift 3 | // NavigationEngineDemo 4 | // 5 | // Created by Alberto De Bortoli on 23/11/2018. 6 | // Copyright © 2018 Just Eat. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import Promis 11 | 12 | public protocol OrdersFlowControllerProtocol { 13 | 14 | var parentFlowController: RootFlowControllerProtocol! { get } 15 | 16 | @discardableResult func dismiss(animated: Bool) -> Future 17 | @discardableResult func goBackToRoot(animated: Bool) -> Future 18 | @discardableResult func goToOrder(orderId: OrderId, animated: Bool) -> Future 19 | @discardableResult func goToRestaurant(restaurantId: RestaurantId) -> Future 20 | } 21 | -------------------------------------------------------------------------------- /NavigationEngineDemoTests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | BNDL 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /NavigationEngineDemo/DemoApp/ViewControllers/PaymentViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PaymentViewController.swift 3 | // NavigationEngineDemo 4 | // 5 | // Created by Alberto De Bortoli on 23/11/2018. 6 | // Copyright © 2018 Just Eat. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class PaymentViewController: UIViewController, Storyboarding { 12 | 13 | var flowController: CheckoutFlowController! 14 | 15 | override func viewDidLoad() { 16 | super.viewDidLoad() 17 | title = String(NSStringFromClass(type(of: self)).split(separator: ".").last!) 18 | } 19 | 20 | @IBAction func payButtonTapped(_ sender: Any) { 21 | flowController.goToOrderConfirmation(orderId: "") 22 | } 23 | 24 | @IBAction func quitButtonTapped(_ sender: Any) { 25 | flowController.leaveFlowAndGoBackToSERP() 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /NavigationEngineDemo/NavigationEngine/Core/Protocols/FlowControllers/AccountFlowControllerProtocol.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AccountFlowControllerProtocol.swift 3 | // NavigationEngine 4 | // 5 | // Created by Alberto De Bortoli on 05/02/2019. 6 | // 7 | 8 | import Foundation 9 | import Promis 10 | 11 | public protocol AccountFlowControllerProtocol { 12 | 13 | var parentFlowController: RootFlowControllerProtocol! { get } 14 | 15 | @discardableResult func beginLoginFlow(from viewController: UIViewController, animated: Bool) -> Future 16 | @discardableResult func waitForUserToLogin(from viewController: UIViewController, animated: Bool) -> Future 17 | @discardableResult func beginResetPasswordFlow(from viewController: UIViewController, token: ResetPasswordToken, animated: Bool) -> Future 18 | @discardableResult func leaveFlow(animated: Bool) -> Future 19 | } 20 | -------------------------------------------------------------------------------- /NavigationEngineDemo/NavigationEngine/Core/Protocols/FlowControllers/CheckoutFlowControllerProtocol.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CheckoutFlowController.swift 3 | // NavigationEngineDemo 4 | // 5 | // Created by Alberto De Bortoli on 23/11/2018. 6 | // Copyright © 2018 Just Eat. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import Promis 11 | 12 | public protocol CheckoutFlowControllerProtocol { 13 | 14 | var parentFlowController: RestaurantsFlowControllerProtocol! { get } 15 | 16 | @discardableResult func beginFlow(from viewController: UIViewController, animated: Bool) -> Future 17 | @discardableResult func proceedToPayment(animated: Bool) -> Future 18 | @discardableResult func goToOrderConfirmation(orderId: OrderId) -> Future 19 | @discardableResult func leaveFlow(animated: Bool) -> Future 20 | @discardableResult func leaveFlowAndGoBackToSERP() -> Future 21 | } 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # OS X 2 | .DS_Store 3 | 4 | # Xcode 5 | build/ 6 | *.pbxuser 7 | !default.pbxuser 8 | *.mode1v3 9 | !default.mode1v3 10 | *.mode2v3 11 | !default.mode2v3 12 | *.perspectivev3 13 | !default.perspectivev3 14 | xcuserdata/ 15 | *.xccheckout 16 | profile 17 | *.moved-aside 18 | DerivedData 19 | *.hmap 20 | *.ipa 21 | *.dSYM.zip 22 | 23 | # Bundler 24 | .bundle 25 | fastlane/report.xml 26 | fastlane/test_output/ 27 | 28 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 29 | # Carthage/Checkouts 30 | 31 | Carthage/Build 32 | 33 | # We recommend against adding the Pods directory to your .gitignore. However 34 | # you should judge for yourself, the pros and cons are mentioned at: 35 | # http://guides.cocoapods.org/using/using-cocoapods.html#should-i-ignore-the-pods-directory-in-source-control 36 | # 37 | # Note: if you ignore the Pods directory, make sure to uncomment 38 | # `pod install` in .travis.yml 39 | # 40 | Pods/ 41 | -------------------------------------------------------------------------------- /NavigationEngineDemo/NavigationEngine/Core/DeepLinkingConstants.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DeepLinkingConstants.swift 3 | // NavigationEngineDemo 4 | // 5 | // Created by Alberto De Bortoli on 04/02/2019. 6 | // Copyright © 2019 Just Eat. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public typealias DeepLink = URL 12 | public typealias UniversalLink = URL 13 | 14 | public enum UserStatus: CaseIterable { 15 | case loggedIn 16 | case loggedOut 17 | } 18 | 19 | public enum RestaurantSection: String { 20 | case menu 21 | case reviews 22 | case info 23 | } 24 | 25 | public struct ReorderInfo { 26 | public let restaurantId: RestaurantId 27 | public let postcode: Postcode? 28 | public let serviceType: ServiceType? 29 | 30 | public init(restaurantId: RestaurantId, postcode: Postcode?, serviceType: ServiceType?) { 31 | self.restaurantId = restaurantId 32 | self.postcode = postcode 33 | self.serviceType = serviceType 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /NavigationEngineDemo/NavigationEngine/Core/Protocols/FlowControllers/RestaurantsFlowControllerProtocol.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RestaurantsFlowController.swift 3 | // NavigationEngineDemo 4 | // 5 | // Created by Alberto De Bortoli on 23/11/2018. 6 | // Copyright © 2018 Just Eat. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import Promis 11 | 12 | public protocol RestaurantsFlowControllerProtocol { 13 | 14 | var checkoutFlowController: CheckoutFlowControllerProtocol! { get } 15 | var parentFlowController: RootFlowControllerProtocol! { get } 16 | 17 | @discardableResult func dismiss(animated: Bool) -> Future 18 | @discardableResult func goBackToRoot(animated: Bool) -> Future 19 | @discardableResult func goToSearchAnimated(postcode: Postcode?, cuisine: Cuisine?, animated: Bool) -> Future 20 | @discardableResult func goToRestaurant(restaurantId: RestaurantId, postcode: Postcode?, serviceType: ServiceType?, section: RestaurantSection?, animated: Bool) -> Future 21 | @discardableResult func goToBasket(reorderId: ReorderId, animated: Bool) -> Future 22 | @discardableResult func goToCheckout(animated: Bool) -> Future 23 | } 24 | -------------------------------------------------------------------------------- /NavigationEngineDemo/NavigationEngine/Core/Navigation/FlowControllerProvider.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FlowControllerProvider.swift 3 | // NavigationEngineDemo 4 | // 5 | // Created by Alberto De Bortoli on 25/11/2018. 6 | // Copyright © 2018 Just Eat. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | public class FlowControllerProvider { 12 | 13 | public let rootFlowController: RootFlowControllerProtocol 14 | 15 | public var restaurantsFlowController: RestaurantsFlowControllerProtocol { 16 | return rootFlowController.restaurantsFlowController 17 | } 18 | 19 | public var ordersFlowController: OrdersFlowControllerProtocol { 20 | return rootFlowController.ordersFlowController 21 | } 22 | 23 | public var checkoutFlowController: CheckoutFlowControllerProtocol { 24 | return rootFlowController.restaurantsFlowController.checkoutFlowController 25 | } 26 | 27 | public var settingsFlowController: SettingsFlowControllerProtocol { 28 | return rootFlowController.settingsFlowController 29 | } 30 | 31 | public init(rootFlowController: RootFlowControllerProtocol) { 32 | self.rootFlowController = rootFlowController 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /NavigationEngineDemo/NavigationEngine/Core/Protocols/FlowControllers/RootFlowControllerProtocol.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RootFlowController.swift 3 | // NavigationEngineDemo 4 | // 5 | // Created by Alberto De Bortoli on 23/11/2018. 6 | // Copyright © 2018 Just Eat. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import Promis 11 | 12 | public protocol RootFlowControllerProtocol { 13 | 14 | var restaurantsFlowController: RestaurantsFlowControllerProtocol! { get } 15 | var ordersFlowController: OrdersFlowControllerProtocol! { get } 16 | var settingsFlowController: SettingsFlowControllerProtocol! { get } 17 | var accountFlowController: AccountFlowControllerProtocol! { get } 18 | 19 | func setup() 20 | 21 | @discardableResult func dismissAll(animated: Bool) -> Future 22 | @discardableResult func dismissAndPopToRootAll(animated: Bool) -> Future 23 | @discardableResult func goToLogin(animated: Bool) -> Future 24 | @discardableResult func goToResetPassword(token: ResetPasswordToken, animated: Bool) -> Future 25 | @discardableResult func goToRestaurantsSection() -> Future 26 | @discardableResult func goToOrdersSection() -> Future 27 | @discardableResult func goToSettingsSection() -> Future 28 | } 29 | -------------------------------------------------------------------------------- /NavigationEngineDemo/DemoApp/NavigationEngineIntegration/deeplinking_test_list.json: -------------------------------------------------------------------------------- 1 | { 2 | "universal_links": [ 3 | "https://just-eat.co.uk/", 4 | "https://just-eat.co.uk/home", 5 | "https://just-eat.co.uk/login", 6 | "https://just-eat.co.uk/area/EC4M7RF", 7 | "https://just-eat.co.uk/search?postcode=EC4M7RF", 8 | "https://just-eat.co.uk/search?postcode=EC4M7RF&cuisine=italian", 9 | "https://just-eat.co.uk/restaurant?restaurantId=123", 10 | "https://just-eat.co.uk/resetPassword?resetToken=123", 11 | "https://just-eat.co.uk/orders", 12 | "https://just-eat.co.uk/order/123", 13 | "https://just-eat.co.uk/reorder/123", 14 | "https://just-eat.co.uk/reorder?orderId=123", 15 | "https://just-eat.co.uk/account/update-password?token=123" 16 | ], 17 | "deep_links": [ 18 | "JUSTEAT://irrelev.ant/home", 19 | "justeat://irrelev.ant/login", 20 | "just-eat-uk://irrelev.ant/resetPassword?resetToken=xyz", 21 | "je-internal://irrelev.ant/search?postcode=EC4M7RF", 22 | "je-internal://irrelev.ant/search?postcode=EC4M7RF&cuisine=italian", 23 | "je-internal://irrelev.ant/restaurant?restaurantId=12345", 24 | "je-internal://irrelev.ant/order-history", 25 | "je-internal://irrelev.ant/order?orderId=98765", 26 | "je-internal://irrelev.ant/reorder?orderId=x8hawCy", 27 | "je-internal://irrelev.ant/settings", 28 | "je-internal://irrelev.ant/unknown" 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /NavigationEngineDemo/NavigationEngine/Core/Parsing/SpotlightItemConverter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SpotlightItemConverter.swift 3 | // NavigationEngine 4 | // 5 | // Created by Alberto De Bortoli on 05/02/2019. 6 | // 7 | 8 | import Foundation 9 | import CoreSpotlight 10 | 11 | class SpotlightItemConverter { 12 | 13 | private lazy var deepLinkFactory: DeepLinkFactory = { 14 | return DeepLinkFactory(scheme: settings.internalDeepLinkSchemes.first!, host: settings.internalDeepLinkHost) 15 | }() 16 | 17 | private let settings: DeepLinkingSettingsProtocol 18 | 19 | init(settings: DeepLinkingSettingsProtocol) { 20 | self.settings = settings 21 | } 22 | 23 | func deepLink(forSpotlightItem userActivity: NSUserActivityProtocol) -> DeepLink? { 24 | guard userActivity.activityType == CSSearchableItemActionType, let uniqueIdentifier = userActivity.userInfo?[CSSearchableItemActivityIdentifier] as? String else { return nil } 25 | 26 | let components = uniqueIdentifier.components(separatedBy: "/") 27 | 28 | switch components.count { 29 | case 3: 30 | switch (components[1], components[2]) { 31 | case ("restaurant", let restaurantId): 32 | return deepLinkFactory.restaurantURL(restaurantId: restaurantId, postcode: nil, serviceType: nil, section: nil) 33 | case ("orderDetails", let orderId): 34 | return deepLinkFactory.orderDetailsURL(orderId: orderId) 35 | default: 36 | return nil 37 | } 38 | 39 | default: 40 | return nil 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /NavigationEngineDemoTests/Utilities/MockNavigationTransitionerDataSource.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MockNavigationTransitionerDataSource.swift 3 | // NavigationEngine-Unit-Tests 4 | // 5 | // Created by Alberto De Bortoli on 11/02/2019. 6 | // 7 | 8 | import Foundation 9 | import Promis 10 | @testable import NavigationEngineDemo 11 | 12 | class MockNavigationTransitionerDataSource: NavigationTransitionerDataSource { 13 | 14 | private let shouldSucceed: Bool 15 | private let userStatusProvider: MockUserStatusProvider? 16 | 17 | init(shouldSucceed: Bool, userStatusProvider: MockUserStatusProvider? = nil) { 18 | self.shouldSucceed = shouldSucceed 19 | self.userStatusProvider = userStatusProvider 20 | } 21 | 22 | func navigationTransitionerDidRequestInputForReorder(orderId: OrderId) -> Future { 23 | if shouldSucceed { 24 | let reorderInfo = ReorderInfo(restaurantId: "", postcode: "", serviceType: .delivery) 25 | return Future.future(withResult: reorderInfo) 26 | } 27 | else { 28 | let error = NSError(domain: "", code: 0, userInfo: nil) 29 | return Future.future(withError: error) 30 | } 31 | } 32 | 33 | func navigationTransitionerDidRequestUserToLogin() -> Future { 34 | if shouldSucceed { 35 | userStatusProvider?.userStatus = .loggedIn 36 | return Future.future(withResult: true) 37 | } 38 | else { 39 | let error = NSError(domain: "", code: 0, userInfo: nil) 40 | return Future.future(withError: error) 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /NavigationEngineDemo/DemoApp/NavigationEngineIntegration/FlowControllers/SettingsFlowController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SettingsFlowController.swift 3 | // NavigationEngineDemo 4 | // 5 | // Created by Alberto De Bortoli on 23/11/2018. 6 | // Copyright © 2018 Just Eat. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import Promis 11 | 12 | class SettingsFlowController: SettingsFlowControllerProtocol { 13 | 14 | private let navigationController: UINavigationController 15 | public let parentFlowController: RootFlowControllerProtocol! 16 | 17 | init(with parentFlowController: RootFlowControllerProtocol, navigationController: UINavigationController) { 18 | self.parentFlowController = parentFlowController 19 | self.navigationController = navigationController 20 | } 21 | 22 | @discardableResult 23 | func dismiss(animated: Bool) -> Future { 24 | let promise = Promise() 25 | navigationController.dismiss(animated: animated) 26 | // cannot use the completion to fulfill the future since it might never get called 27 | promise.setResult(true) 28 | return promise.future 29 | } 30 | 31 | @discardableResult 32 | func goBackToRoot(animated: Bool) -> Future { 33 | navigationController.popToRootViewController(animated: animated) 34 | return Future.future(withResult: true) 35 | } 36 | 37 | @discardableResult 38 | func goToPrivacyPolicy() -> Future { 39 | let privacyPolicy = PrivacyPolicyViewController.instantiate() 40 | privacyPolicy.flowController = self 41 | navigationController.pushViewController(privacyPolicy, animated: true) 42 | return Future.future(withResult: true) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /NavigationEngineDemo/Base.lproj/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /NavigationEngineDemo/NavigationEngine/Core/Parsing/ShortcutItemConverter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ShortcutItemConverter.swift 3 | // NavigationEngine 4 | // 5 | // Created by Alberto De Bortoli on 05/02/2019. 6 | // 7 | 8 | import Foundation 9 | import UIKit 10 | 11 | class ShortcutItemConverter { 12 | 13 | private lazy var deepLinkFactory: DeepLinkFactory = { 14 | return DeepLinkFactory(scheme: settings.internalDeepLinkSchemes.first!, host: settings.internalDeepLinkHost) 15 | }() 16 | 17 | private let settings: DeepLinkingSettingsProtocol 18 | 19 | init(settings: DeepLinkingSettingsProtocol) { 20 | self.settings = settings 21 | } 22 | 23 | func deepLink(forShortcutItem item: UIApplicationShortcutItem) -> DeepLink? { 24 | 25 | let components = item.type.components(separatedBy: "/") 26 | 27 | switch components.count { 28 | case 3: 29 | switch (components[1], components[2]) { 30 | case ("reorder", let orderId): 31 | return deepLinkFactory.reorderURL(orderId: orderId) 32 | case ("search", let postcode): 33 | return deepLinkFactory.searchURL(postcode: postcode, cuisine: nil, location: nil) 34 | default: 35 | return nil 36 | } 37 | 38 | case 2: 39 | switch components[1] { 40 | case "search": 41 | return deepLinkFactory.searchURL(postcode: nil, cuisine: nil, location: nil) 42 | case "orderHistory": 43 | return deepLinkFactory.orderHistoryURL() 44 | default: 45 | return nil 46 | } 47 | 48 | default: 49 | return nil 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /NavigationEngineDemo/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | APPL 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | LSRequiresIPhoneOS 22 | 23 | UILaunchStoryboardName 24 | LaunchScreen 25 | UIMainStoryboardFile 26 | Main 27 | UIRequiredDeviceCapabilities 28 | 29 | armv7 30 | 31 | UISupportedInterfaceOrientations 32 | 33 | UIInterfaceOrientationPortrait 34 | UIInterfaceOrientationLandscapeLeft 35 | UIInterfaceOrientationLandscapeRight 36 | 37 | UISupportedInterfaceOrientations~ipad 38 | 39 | UIInterfaceOrientationPortrait 40 | UIInterfaceOrientationPortraitUpsideDown 41 | UIInterfaceOrientationLandscapeLeft 42 | UIInterfaceOrientationLandscapeRight 43 | 44 | CFBundleURLTypes 45 | 46 | 47 | CFBundleTypeRole 48 | Editor 49 | CFBundleURLSchemes 50 | 51 | je-internal 52 | justeat 53 | just-eat 54 | just-eat-uk 55 | 56 | 57 | 58 | 59 | 60 | -------------------------------------------------------------------------------- /NavigationEngineDemo/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "iphone", 5 | "size" : "20x20", 6 | "scale" : "2x" 7 | }, 8 | { 9 | "idiom" : "iphone", 10 | "size" : "20x20", 11 | "scale" : "3x" 12 | }, 13 | { 14 | "idiom" : "iphone", 15 | "size" : "29x29", 16 | "scale" : "2x" 17 | }, 18 | { 19 | "idiom" : "iphone", 20 | "size" : "29x29", 21 | "scale" : "3x" 22 | }, 23 | { 24 | "idiom" : "iphone", 25 | "size" : "40x40", 26 | "scale" : "2x" 27 | }, 28 | { 29 | "idiom" : "iphone", 30 | "size" : "40x40", 31 | "scale" : "3x" 32 | }, 33 | { 34 | "idiom" : "iphone", 35 | "size" : "60x60", 36 | "scale" : "2x" 37 | }, 38 | { 39 | "idiom" : "iphone", 40 | "size" : "60x60", 41 | "scale" : "3x" 42 | }, 43 | { 44 | "idiom" : "ipad", 45 | "size" : "20x20", 46 | "scale" : "1x" 47 | }, 48 | { 49 | "idiom" : "ipad", 50 | "size" : "20x20", 51 | "scale" : "2x" 52 | }, 53 | { 54 | "idiom" : "ipad", 55 | "size" : "29x29", 56 | "scale" : "1x" 57 | }, 58 | { 59 | "idiom" : "ipad", 60 | "size" : "29x29", 61 | "scale" : "2x" 62 | }, 63 | { 64 | "idiom" : "ipad", 65 | "size" : "40x40", 66 | "scale" : "1x" 67 | }, 68 | { 69 | "idiom" : "ipad", 70 | "size" : "40x40", 71 | "scale" : "2x" 72 | }, 73 | { 74 | "idiom" : "ipad", 75 | "size" : "76x76", 76 | "scale" : "1x" 77 | }, 78 | { 79 | "idiom" : "ipad", 80 | "size" : "76x76", 81 | "scale" : "2x" 82 | }, 83 | { 84 | "idiom" : "ipad", 85 | "size" : "83.5x83.5", 86 | "scale" : "2x" 87 | }, 88 | { 89 | "idiom" : "ios-marketing", 90 | "size" : "1024x1024", 91 | "scale" : "1x" 92 | } 93 | ], 94 | "info" : { 95 | "version" : 1, 96 | "author" : "xcode" 97 | } 98 | } -------------------------------------------------------------------------------- /NavigationEngineDemoTests/Navigation/FlowControllerProviderTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FlowControllerProviderTests.swift 3 | // NavigationEngine-Unit-Tests 4 | // 5 | // Created by Alberto De Bortoli on 11/02/2019. 6 | // 7 | 8 | import XCTest 9 | import Stateful 10 | @testable import NavigationEngineDemo 11 | 12 | class FlowControllerProviderTests: XCTestCase { 13 | 14 | private var flowControllerProvider: FlowControllerProvider! 15 | private let rootFlowController = MockRootFlowController() 16 | 17 | override func setUp() { 18 | super.setUp() 19 | rootFlowController.setup() 20 | flowControllerProvider = FlowControllerProvider(rootFlowController: rootFlowController) 21 | } 22 | 23 | func test_rootFlowController() { 24 | let fc1 = flowControllerProvider.rootFlowController as! MockRootFlowController 25 | let fc2 = rootFlowController 26 | XCTAssert(fc1 === fc2) 27 | } 28 | 29 | func test_restaurantsFlowController() { 30 | let fc1 = flowControllerProvider.restaurantsFlowController as! MockRestaurantsFlowController 31 | let fc2 = rootFlowController.restaurantsFlowController as! MockRestaurantsFlowController 32 | XCTAssert(fc1 === fc2) 33 | } 34 | 35 | func test_checkoutFlowController() { 36 | let fc1 = flowControllerProvider.checkoutFlowController as! MockCheckoutFlowController 37 | let fc2 = rootFlowController.restaurantsFlowController.checkoutFlowController as! MockCheckoutFlowController 38 | XCTAssert(fc1 === fc2) 39 | } 40 | 41 | func test_ordersFlowController() { 42 | let fc1 = flowControllerProvider.ordersFlowController as! MockOrdersFlowController 43 | let fc2 = rootFlowController.ordersFlowController as! MockOrdersFlowController 44 | XCTAssert(fc1 === fc2) 45 | } 46 | 47 | func test_settingsFlowController() { 48 | let fc1 = flowControllerProvider.settingsFlowController as! MockSettingsFlowController 49 | let fc2 = rootFlowController.settingsFlowController as! MockSettingsFlowController 50 | XCTAssert(fc1 === fc2) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /NavigationEngineDemoTests/Parsing/SpotlightItemConverterTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SpotlightItemConverterTests.swift 3 | // NavigationEngineTests 4 | // 5 | // Created by Alberto De Bortoli on 07/02/2019. 6 | // 7 | 8 | import XCTest 9 | import CoreSpotlight 10 | @testable import NavigationEngineDemo 11 | 12 | class SpotlightItemConverterTests: XCTestCase { 13 | 14 | private var spotlightItemConverter: SpotlightItemConverter! 15 | private var mockDeepLinkingSettings = MockDeepLinkingSettings() 16 | 17 | override func setUp() { 18 | super.setUp() 19 | spotlightItemConverter = SpotlightItemConverter(settings: mockDeepLinkingSettings) 20 | } 21 | 22 | func test_restaurantShortcutItem() { 23 | let target = DeepLink(string: "je-internal://je.com/restaurant?restaurantId=456") 24 | let spotlightItem = MockNSUserActivity(activityType: CSSearchableItemActionType, userInfo: [CSSearchableItemActivityIdentifier: "/restaurant/456"]) 25 | let test = spotlightItemConverter.deepLink(forSpotlightItem: spotlightItem) 26 | XCTAssertEqual(target, test) 27 | } 28 | 29 | func test_orderDetailsShortcutItem() { 30 | let target = DeepLink(string: "je-internal://je.com/order?orderId=123") 31 | let spotlightItem = MockNSUserActivity(activityType: CSSearchableItemActionType, userInfo: [CSSearchableItemActivityIdentifier: "/orderDetails/123"]) 32 | let test = spotlightItemConverter.deepLink(forSpotlightItem: spotlightItem) 33 | XCTAssertEqual(target, test) 34 | } 35 | 36 | func test_invalidShortcutItem() { 37 | var spotlightItem = MockNSUserActivity(activityType: CSSearchableItemActionType, userInfo: [CSSearchableItemActivityIdentifier: "/invalid"]) 38 | var test = spotlightItemConverter.deepLink(forSpotlightItem: spotlightItem) 39 | XCTAssertNil(test) 40 | 41 | spotlightItem = MockNSUserActivity(activityType: CSSearchableItemActionType, userInfo: [CSSearchableItemActivityIdentifier: "invalid"]) 42 | test = spotlightItemConverter.deepLink(forSpotlightItem: spotlightItem) 43 | XCTAssertNil(test) 44 | 45 | spotlightItem = MockNSUserActivity(activityType: CSSearchableItemActionType, userInfo: [CSSearchableItemActivityIdentifier: "/invalid/123"]) 46 | test = spotlightItemConverter.deepLink(forSpotlightItem: spotlightItem) 47 | XCTAssertNil(test) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /NavigationEngineDemo/DemoApp/NavigationEngineIntegration/FlowControllers/OrdersFlowController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OrdersFlowController.swift 3 | // NavigationEngineDemo 4 | // 5 | // Created by Alberto De Bortoli on 23/11/2018. 6 | // Copyright © 2018 Just Eat. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import Promis 11 | 12 | protocol OrdersFlowControllerDelegate: class { 13 | func ordersFlowController(_ flowController: OrdersFlowController, didRequestGoingToRestaurant restaurantId: String) -> Future 14 | } 15 | 16 | class OrdersFlowController: OrdersFlowControllerProtocol { 17 | 18 | weak var delegate: OrdersFlowControllerDelegate! 19 | private let navigationController: UINavigationController 20 | public let parentFlowController: RootFlowControllerProtocol! 21 | 22 | init(with parentFlowController: RootFlowControllerProtocol, navigationController: UINavigationController) { 23 | self.parentFlowController = parentFlowController 24 | self.navigationController = navigationController 25 | } 26 | 27 | @discardableResult 28 | func dismiss(animated: Bool) -> Future { 29 | let promise = Promise() 30 | navigationController.dismiss(animated: animated) 31 | // cannot use the completion to fulfill the future since it might never get called 32 | promise.setResult(true) 33 | return promise.future 34 | } 35 | 36 | @discardableResult 37 | func goBackToRoot(animated: Bool) -> Future { 38 | let promise = Promise() 39 | navigationController.popToRootViewController(animated: animated) 40 | promise.setResult(true) 41 | return promise.future 42 | } 43 | 44 | @discardableResult 45 | func goToOrder(orderId: OrderId, animated: Bool) -> Future { 46 | let promise = Promise() 47 | let orderDetails = OrderDetailsViewController.instantiate() 48 | orderDetails.flowController = self 49 | navigationController.pushViewController(orderDetails, animated: animated) 50 | promise.setResult(true) 51 | return promise.future 52 | } 53 | 54 | @discardableResult 55 | func goToRestaurant(restaurantId: String) -> Future { 56 | return delegate.ordersFlowController(self, didRequestGoingToRestaurant: restaurantId) 57 | } 58 | 59 | @objc func leaveFlow(_ sender: Any) { 60 | leaveFlow(animated: true) 61 | } 62 | 63 | @discardableResult 64 | func leaveFlow(animated: Bool) -> Future { 65 | let promise = Promise() 66 | navigationController.presentedViewController?.dismiss(animated: animated) { 67 | promise.setResult(true) 68 | } 69 | return promise.future 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /NavigationEngineDemoTests/Parsing/ShortcutItemConverterTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ShortcutItemConverterTests.swift 3 | // NavigationEngineTests 4 | // 5 | // Created by Alberto De Bortoli on 07/02/2019. 6 | // 7 | 8 | import XCTest 9 | @testable import NavigationEngineDemo 10 | 11 | class ShortcutItemConverterTests: XCTestCase { 12 | 13 | private var shortcutItemConverter: ShortcutItemConverter! 14 | private var mockDeepLinkingSettings = MockDeepLinkingSettings() 15 | 16 | override func setUp() { 17 | super.setUp() 18 | shortcutItemConverter = ShortcutItemConverter(settings: mockDeepLinkingSettings) 19 | } 20 | 21 | func test_serpShortcutItem() { 22 | let target = DeepLink(string: "je-internal://je.com/search") 23 | let shortcutItem = UIApplicationShortcutItem(type: "/search", localizedTitle: "Search") 24 | let test = shortcutItemConverter.deepLink(forShortcutItem: shortcutItem) 25 | XCTAssertEqual(target, test) 26 | } 27 | 28 | func test_serpWithPostcodeShortcutItem() { 29 | let target = DeepLink(string: "je-internal://je.com/search?postcode=EC4M7RF") 30 | let shortcutItem = UIApplicationShortcutItem(type: "/search/EC4M7RF", localizedTitle: "Search") 31 | let test = shortcutItemConverter.deepLink(forShortcutItem: shortcutItem) 32 | XCTAssertEqual(target, test) 33 | } 34 | 35 | func test_orderHistoryShortcutItem() { 36 | let target = DeepLink(string: "je-internal://je.com/orders") 37 | let shortcutItem = UIApplicationShortcutItem(type: "/orderHistory", localizedTitle: "Orders") 38 | let test = shortcutItemConverter.deepLink(forShortcutItem: shortcutItem) 39 | XCTAssertEqual(target, test) 40 | } 41 | 42 | func test_reorderShortcutItem() { 43 | let target = DeepLink(string: "je-internal://je.com/reorder?orderId=123") 44 | let shortcutItem = UIApplicationShortcutItem(type: "/reorder/123", localizedTitle: "Reorder") 45 | let test = shortcutItemConverter.deepLink(forShortcutItem: shortcutItem) 46 | XCTAssertEqual(target, test) 47 | } 48 | 49 | func test_invalidShortcutItem() { 50 | var shortcutItem = UIApplicationShortcutItem(type: "/invalid", localizedTitle: "Invalid") 51 | var test = shortcutItemConverter.deepLink(forShortcutItem: shortcutItem) 52 | XCTAssertNil(test) 53 | 54 | shortcutItem = UIApplicationShortcutItem(type: "invalid", localizedTitle: "Invalid") 55 | test = shortcutItemConverter.deepLink(forShortcutItem: shortcutItem) 56 | XCTAssertNil(test) 57 | 58 | shortcutItem = UIApplicationShortcutItem(type: "/invalid/123", localizedTitle: "Invalid") 59 | test = shortcutItemConverter.deepLink(forShortcutItem: shortcutItem) 60 | XCTAssertNil(test) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /NavigationEngineDemo/DemoApp/NavigationEngineIntegration/FlowControllers/CheckoutFlowController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CheckoutFlowController.swift 3 | // NavigationEngineDemo 4 | // 5 | // Created by Alberto De Bortoli on 23/11/2018. 6 | // Copyright © 2018 Just Eat. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import Promis 11 | 12 | protocol CheckoutFlowControllerDelegate: class { 13 | func checkoutFlowControllerDidRequestGoingBackToSERP(_ flowController: CheckoutFlowController) -> Future 14 | func checkoutFlowController(_ flowController: CheckoutFlowController, didRequestGoingToOrder orderId: OrderId) -> Future 15 | } 16 | 17 | class CheckoutFlowController: CheckoutFlowControllerProtocol { 18 | 19 | weak var delegate: CheckoutFlowControllerDelegate! 20 | private let navigationController: UINavigationController 21 | public let parentFlowController: RestaurantsFlowControllerProtocol! 22 | 23 | init(with parentFlowController: RestaurantsFlowControllerProtocol, navigationController: UINavigationController) { 24 | self.parentFlowController = parentFlowController 25 | self.navigationController = navigationController 26 | } 27 | 28 | @discardableResult 29 | func beginFlow(from viewController: UIViewController, animated: Bool) -> Future { 30 | let promise = Promise() 31 | let checkoutVC = CheckoutViewController.instantiate() 32 | checkoutVC.flowController = self 33 | navigationController.viewControllers = [checkoutVC] 34 | 35 | let leaveButton = UIBarButtonItem(title: "Leave", style: .plain, target: self, action: #selector(leaveFlow(_:))) 36 | checkoutVC.navigationItem.rightBarButtonItem = leaveButton 37 | 38 | viewController.present(navigationController, animated: animated) { 39 | promise.setResult(true) 40 | } 41 | return promise.future 42 | } 43 | 44 | @discardableResult 45 | func proceedToPayment(animated: Bool) -> Future { 46 | let paymentVC = PaymentViewController.instantiate() 47 | paymentVC.flowController = self 48 | navigationController.pushViewController(paymentVC, animated: animated) 49 | return Future.future(withResult: true) 50 | } 51 | 52 | @discardableResult 53 | func goToOrderConfirmation(orderId: OrderId) -> Future { 54 | return leaveFlow(animated: false).then { future in 55 | self.delegate.checkoutFlowController(self, didRequestGoingToOrder: orderId) 56 | } 57 | } 58 | 59 | @objc func leaveFlow(_ sender: Any) { 60 | leaveFlow(animated: true) 61 | } 62 | 63 | @discardableResult 64 | func leaveFlow(animated: Bool) -> Future { 65 | let promise = Promise() 66 | navigationController.presentingViewController?.dismiss(animated: animated) { 67 | promise.setResult(true) 68 | } 69 | return promise.future 70 | } 71 | 72 | @discardableResult 73 | func leaveFlowAndGoBackToSERP() -> Future { 74 | return leaveFlow(animated: false).then { future in 75 | self.delegate.checkoutFlowControllerDidRequestGoingBackToSERP(self) 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /NavigationEngineDemo/NavigationEngine/Core/Parsing/URLGateway.swift: -------------------------------------------------------------------------------- 1 | // 2 | // URLGateway.swift 3 | // Just Eat 4 | // 5 | // Created by Alberto De Bortoli on 27/04/2018. 6 | // Copyright © 2018 Just Eat. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import Promis 11 | 12 | class URLGateway { 13 | 14 | static let domain = "com.justeat.URLGateway" 15 | 16 | enum ErrorCode: Int { 17 | case missingURLScheme 18 | case unsupportedUniversalLink 19 | case generic 20 | } 21 | 22 | private let universalLinkConverter: UniversalLinkConverter 23 | private let settings: DeepLinkingSettingsProtocol 24 | 25 | init(settings: DeepLinkingSettingsProtocol) { 26 | self.settings = settings 27 | self.universalLinkConverter = UniversalLinkConverter(settings: settings) 28 | } 29 | 30 | func handleURL(_ url: URL) -> Future { 31 | let promise = Promise() 32 | 33 | guard let scheme = url.scheme?.lowercased() else { 34 | let error = NSError(domain: URLGateway.domain, code: ErrorCode.missingURLScheme.rawValue, userInfo: nil) 35 | promise.setError(error) 36 | return promise.future 37 | } 38 | 39 | // 1. check if universal link 40 | if settings.universalLinkSchemes.contains(scheme) { 41 | self.resultForUniversalLink(url).finally { future in 42 | promise.setResolution(of: future) 43 | } 44 | } 45 | 46 | // 2. check if deep link 47 | else if settings.internalDeepLinkSchemes.contains(scheme) { 48 | promise.setResult(url) 49 | } 50 | 51 | // 3. generic error 52 | else { 53 | let genericError = NSError(domain: URLGateway.domain, code: ErrorCode.generic.rawValue, userInfo: nil) 54 | promise.setError(genericError) 55 | } 56 | 57 | return promise.future 58 | } 59 | 60 | private func resultForUniversalLink(_ url: UniversalLink) -> Future { 61 | let promise = Promise() 62 | if let deepLink = universalLinkConverter.deepLink(forUniversalLink: url) { 63 | promise.setResult(deepLink) 64 | } 65 | else { 66 | let genericError = NSError(domain: URLGateway.domain, code: ErrorCode.unsupportedUniversalLink.rawValue, userInfo: nil) 67 | promise.setError(genericError) 68 | } 69 | return promise.future 70 | } 71 | } 72 | 73 | extension URL { 74 | 75 | func contains(subparts: [String]) -> Bool { 76 | return subparts.filter { absoluteString.contains($0) }.count > 0 77 | } 78 | 79 | var allQueryItems: [NSURLQueryItem] { 80 | let components = NSURLComponents(url: self as URL, resolvingAgainstBaseURL: false)! 81 | guard let allQueryItems = components.queryItems 82 | else { return [] } 83 | return allQueryItems as [NSURLQueryItem] 84 | } 85 | 86 | func queryItemValueFor(key: String) -> String? { 87 | let predicate = NSPredicate(format: "name=%@", key) 88 | let queryItem = (allQueryItems as NSArray).filtered(using: predicate).first as? NSURLQueryItem 89 | return queryItem?.value 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /NavigationEngineDemo/DemoApp/NavigationEngineIntegration/FlowControllers/AccountFlowController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AccountFlowController.swift 3 | // NavigationEngineDemo 4 | // 5 | // Created by Alberto De Bortoli on 05/02/2019. 6 | // Copyright © 2019 Just Eat. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Promis 11 | 12 | class AccountFlowController: AccountFlowControllerProtocol { 13 | 14 | private let navigationController: UINavigationController 15 | public let parentFlowController: RootFlowControllerProtocol! 16 | 17 | init(with parentFlowController: RootFlowControllerProtocol, navigationController: UINavigationController) { 18 | self.parentFlowController = parentFlowController 19 | self.navigationController = navigationController 20 | } 21 | 22 | @discardableResult 23 | func beginLoginFlow(from viewController: UIViewController, animated: Bool) -> Future { 24 | let promise = Promise() 25 | let loginVC = LoginViewController.instantiate() 26 | loginVC.flowController = self 27 | navigationController.viewControllers = [loginVC] 28 | 29 | let leaveButton = UIBarButtonItem(title: "Leave", style: .plain, target: self, action: #selector(leaveFlow(_:))) 30 | loginVC.navigationItem.rightBarButtonItem = leaveButton 31 | 32 | viewController.present(navigationController, animated: animated) 33 | 34 | promise.setResult(true) 35 | return promise.future 36 | } 37 | 38 | @discardableResult 39 | func waitForUserToLogin(from viewController: UIViewController, animated: Bool) -> Future { 40 | let promise = Promise() 41 | let loginVC = LoginViewController.instantiate() 42 | loginVC.flowController = self 43 | navigationController.viewControllers = [loginVC] 44 | 45 | let leaveButton = UIBarButtonItem(title: "Leave", style: .plain, target: self, action: #selector(leaveFlow(_:))) 46 | loginVC.navigationItem.rightBarButtonItem = leaveButton 47 | 48 | viewController.present(navigationController, animated: animated) { 49 | promise.setResult(true) 50 | } 51 | return promise.future 52 | } 53 | 54 | @discardableResult 55 | func beginResetPasswordFlow(from viewController: UIViewController, token: ResetPasswordToken, animated: Bool) -> Future { 56 | let promise = Promise() 57 | let resetPswVC = ResetPasswordViewController.instantiate() 58 | resetPswVC.flowController = self 59 | resetPswVC.token = token 60 | navigationController.viewControllers = [resetPswVC] 61 | 62 | let leaveButton = UIBarButtonItem(title: "Leave", style: .plain, target: self, action: #selector(leaveFlow(_:))) 63 | resetPswVC.navigationItem.rightBarButtonItem = leaveButton 64 | 65 | viewController.present(navigationController, animated: animated) { 66 | promise.setResult(true) 67 | } 68 | return promise.future 69 | } 70 | 71 | @objc func leaveFlow(_ sender: Any) { 72 | leaveFlow(animated: true) 73 | } 74 | 75 | @discardableResult 76 | func leaveFlow(animated: Bool) -> Future { 77 | let promise = Promise() 78 | navigationController.presentingViewController?.dismiss(animated: animated) { 79 | promise.setResult(true) 80 | } 81 | return promise.future 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /NavigationEngineDemoTests/Parsing/URLGatewayTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // URLGatewayTests.swift 3 | // NavigationEngineTests 4 | // 5 | // Created by Alberto De Bortoli on 07/02/2019. 6 | // 7 | 8 | import XCTest 9 | @testable import NavigationEngineDemo 10 | 11 | class URLGatewayTests: XCTestCase { 12 | 13 | private let timeout = 3.0 14 | private var urlGateway: URLGateway! 15 | 16 | func test_universalLink() { 17 | let expectation = self.expectation(description: #function) 18 | urlGateway = URLGateway(settings: MockDeepLinkingSettings()) 19 | let url = URL(string: "https://just-eat.co.uk/login")! 20 | urlGateway.handleURL(url).finally { future in 21 | XCTAssertTrue(future.hasResult()) 22 | let target = DeepLink(string: "je-internal://je.com/login") 23 | let test = future.result! 24 | XCTAssertEqual(test, target) 25 | expectation.fulfill() 26 | } 27 | wait(for: [expectation], timeout: timeout) 28 | } 29 | 30 | func test_universalLink_invalid() { 31 | let expectation = self.expectation(description: #function) 32 | urlGateway = URLGateway(settings: MockDeepLinkingSettings()) 33 | let url = URL(string: "https://just-eat.co.uk/invalid")! 34 | urlGateway.handleURL(url).finally { future in 35 | XCTAssertTrue(future.hasError()) 36 | let error = future.error! as NSError 37 | XCTAssertEqual(error.domain, URLGateway.domain) 38 | XCTAssertEqual(error.code, URLGateway.ErrorCode.unsupportedUniversalLink.rawValue) 39 | expectation.fulfill() 40 | } 41 | wait(for: [expectation], timeout: timeout) 42 | } 43 | 44 | func test_deepLink() { 45 | let expectation = self.expectation(description: #function) 46 | urlGateway = URLGateway(settings: MockDeepLinkingSettings()) 47 | let url = DeepLink(string: "je-internal://je.com/login")! 48 | urlGateway.handleURL(url).finally { future in 49 | XCTAssertTrue(future.hasResult()) 50 | let target = DeepLink(string: "je-internal://je.com/login") 51 | let test = future.result! 52 | XCTAssertEqual(test, target) 53 | expectation.fulfill() 54 | } 55 | wait(for: [expectation], timeout: timeout) 56 | } 57 | 58 | func test_deepLinkInvalid() { 59 | let expectation = self.expectation(description: #function) 60 | urlGateway = URLGateway(settings: MockDeepLinkingSettings()) 61 | let url = DeepLink(string: "je-internal://je.com/invalid")! 62 | urlGateway.handleURL(url).finally { future in 63 | XCTAssertTrue(future.hasResult()) 64 | let target = DeepLink(string: "je-internal://je.com/invalid") 65 | let test = future.result! 66 | XCTAssertEqual(test, target) 67 | expectation.fulfill() 68 | } 69 | wait(for: [expectation], timeout: timeout) 70 | } 71 | 72 | func test_invalidLink() { 73 | let expectation = self.expectation(description: #function) 74 | urlGateway = URLGateway(settings: MockDeepLinkingSettings()) 75 | let url = URL(string: "someotherscheme://je.com/login")! 76 | urlGateway.handleURL(url).finally { future in 77 | XCTAssertTrue(future.hasError()) 78 | let error = future.error! as NSError 79 | XCTAssertEqual(error.domain, URLGateway.domain) 80 | XCTAssertEqual(error.code, URLGateway.ErrorCode.generic.rawValue) 81 | expectation.fulfill() 82 | } 83 | wait(for: [expectation], timeout: timeout) 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /NavigationEngineDemo/DemoApp/NavigationEngineIntegration/FlowControllers/RestaurantsFlowController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RestaurantsFlowController.swift 3 | // NavigationEngineDemo 4 | // 5 | // Created by Alberto De Bortoli on 23/11/2018. 6 | // Copyright © 2018 Just Eat. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import Promis 11 | 12 | protocol RestaurantsFlowControllerDelegate: class { 13 | func restaurantsFlowController(_ flowController: RestaurantsFlowController, didRequestGoingToOrder orderId: OrderId) -> Future 14 | } 15 | 16 | class RestaurantsFlowController: RestaurantsFlowControllerProtocol { 17 | 18 | weak var delegate: RestaurantsFlowControllerDelegate! 19 | fileprivate let navigationController: UINavigationController 20 | let parentFlowController: RootFlowControllerProtocol! 21 | 22 | var checkoutFlowController: CheckoutFlowControllerProtocol! 23 | 24 | init(with parentFlowController: RootFlowControllerProtocol, navigationController: UINavigationController) { 25 | self.parentFlowController = parentFlowController 26 | self.navigationController = navigationController 27 | } 28 | 29 | @discardableResult 30 | func dismiss(animated: Bool) -> Future { 31 | let promise = Promise() 32 | navigationController.dismiss(animated: animated) 33 | // cannot use the completion to fulfill the future since it might never get called 34 | promise.setResult(true) 35 | return promise.future 36 | } 37 | 38 | @discardableResult 39 | func goBackToRoot(animated: Bool) -> Future { 40 | let promise = Promise() 41 | navigationController.popToRootViewController(animated: animated) 42 | promise.setResult(true) 43 | return promise.future 44 | } 45 | 46 | @discardableResult 47 | func goToSearchAnimated(postcode: Postcode?, cuisine: Cuisine?, animated: Bool) -> Future { 48 | let promise = Promise() 49 | let serp = SerpViewController.instantiate() 50 | serp.flowController = self 51 | navigationController.pushViewController(serp, animated: animated) 52 | promise.setResult(true) 53 | return promise.future 54 | } 55 | 56 | @discardableResult 57 | func goToRestaurant(restaurantId: RestaurantId, postcode: Postcode?, serviceType: ServiceType?, section: RestaurantSection?, animated: Bool) -> Future { 58 | let promise = Promise() 59 | let restaurantVC = RestaurantViewController.instantiate() 60 | restaurantVC.flowController = self 61 | navigationController.pushViewController(restaurantVC, animated: animated) 62 | promise.setResult(true) 63 | return promise.future 64 | } 65 | 66 | @discardableResult 67 | func goToBasket(reorderId: ReorderId, animated: Bool) -> Future { 68 | let promise = Promise() 69 | let basket = BasketViewController.instantiate() 70 | basket.flowController = self 71 | navigationController.pushViewController(basket, animated: animated) 72 | promise.setResult(true) 73 | return promise.future 74 | } 75 | 76 | @discardableResult 77 | func goToCheckout(animated: Bool) -> Future { 78 | let checkoutNC = UINavigationController() 79 | let flowController = CheckoutFlowController(with: self, navigationController: checkoutNC) 80 | flowController.delegate = self 81 | checkoutFlowController = flowController 82 | return checkoutFlowController.beginFlow(from: navigationController, animated: animated) 83 | } 84 | } 85 | 86 | extension RestaurantsFlowController: CheckoutFlowControllerDelegate { 87 | 88 | @discardableResult 89 | func checkoutFlowControllerDidRequestGoingBackToSERP(_ flowController: CheckoutFlowController) -> Future { 90 | navigationController.viewControllers = Array(navigationController.viewControllers[0...1]) 91 | return Future.future(withResult: true) 92 | } 93 | 94 | @discardableResult 95 | func checkoutFlowController(_ flowController: CheckoutFlowController, didRequestGoingToOrder orderId: OrderId) -> Future { 96 | return delegate.restaurantsFlowController(self, didRequestGoingToOrder: orderId) 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /NavigationEngineDemo/DemoApp/UserInputController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UserInputController.swift 3 | // NavigationEngineDemo 4 | // 5 | // Created by Alberto De Bortoli on 01/02/2019. 6 | // Copyright © 2019 Just Eat. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Promis 11 | 12 | class UserInputController { 13 | 14 | private let domain = "com.justeat.userInputController" 15 | 16 | private enum ErrorCode: Int { 17 | case abortUserInput 18 | case abortFakeLogin 19 | } 20 | 21 | private let userStatusProvider: UserStatusProvider 22 | 23 | init(userStatusProvider: UserStatusProvider) { 24 | self.userStatusProvider = userStatusProvider 25 | } 26 | 27 | func gatherReorderUserInput() -> Future { 28 | let promise = Promise() 29 | 30 | showReorderUserInputAlert { input in 31 | if let input = input { 32 | promise.setResult(input) 33 | } 34 | else { 35 | let error = NSError(domain: self.domain, code: ErrorCode.abortUserInput.rawValue, userInfo: nil) 36 | promise.setError(error) 37 | } 38 | } 39 | 40 | return promise.future 41 | } 42 | 43 | func promptUserForLogin() -> Future { 44 | let promise = Promise() 45 | 46 | showLoginUserAlert { success in 47 | if success { 48 | self.userStatusProvider.userStatus = .loggedIn 49 | promise.setResult(true) 50 | } 51 | else { 52 | let error = NSError(domain: self.domain, code: ErrorCode.abortFakeLogin.rawValue, userInfo: nil) 53 | promise.setError(error) 54 | } 55 | } 56 | 57 | return promise.future 58 | } 59 | 60 | private func showReorderUserInputAlert(handler: @escaping (ReorderInfo?) -> Void) { 61 | let alert = UIAlertController(title: "Reorder user input", message: "Please enter the reorder required values", preferredStyle: .alert) 62 | let action = UIAlertAction(title: "Confirm", style: .default) { _ in 63 | let postcode = (alert.textFields![0] as UITextField).text ?? "" 64 | let restaurantId = (alert.textFields![1] as UITextField).text ?? "" 65 | let serviceType = ServiceType(rawValue: (alert.textFields![2] as UITextField).text ?? "") ?? ServiceType.delivery 66 | let reorderInfo = ReorderInfo(restaurantId: restaurantId, postcode: postcode, serviceType: serviceType) 67 | handler(reorderInfo) 68 | } 69 | let action2 = UIAlertAction(title: "Cancel", style: .cancel) { _ in 70 | handler(nil) 71 | } 72 | alert.addAction(action) 73 | alert.addAction(action2) 74 | 75 | alert.addTextField { textField in 76 | textField.placeholder = "postcode" 77 | } 78 | alert.addTextField { textField in 79 | textField.placeholder = "seo name" 80 | } 81 | alert.addTextField { textField in 82 | textField.placeholder = "service type" 83 | } 84 | 85 | presentAlert(alert) 86 | } 87 | 88 | private func showLoginUserAlert(handler: @escaping (Bool) -> Void) { 89 | let alert = UIAlertController(title: "Fake login", message: "Proceed to faking a user login", preferredStyle: .alert) 90 | let action = UIAlertAction(title: "Yes", style: .default) { _ in 91 | handler(true) 92 | } 93 | let action2 = UIAlertAction(title: "No", style: .cancel) { _ in 94 | handler(false) 95 | } 96 | alert.addAction(action) 97 | alert.addAction(action2) 98 | presentAlert(alert) 99 | } 100 | 101 | private func presentAlert(_ alert: UIAlertController) { 102 | let presentingViewController = UIApplication.shared.delegate!.window!!.rootViewController! 103 | if let presentedViewController = presentingViewController.presentedViewController { 104 | presentedViewController.present(alert, animated: true) { } 105 | } 106 | else { 107 | presentingViewController.present(alert, animated: true) { } 108 | } 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /NavigationEngineDemo/NavigationEngine/Tester/DeepLinkingTesterViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DeepLinkingTesterViewController.swift 3 | // JUSTEAT 4 | // 5 | // Created by Shabeer Hussain on 22/11/2018. 6 | // Copyright © 2018 JUST EAT. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | public protocol DeepLinkingTesterViewControllerDelegate: class { 12 | func deepLinkingTesterViewController(_ deepLinkingTesterViewController: DeepLinkingTesterViewController, didSelect url: URL) 13 | } 14 | 15 | public class DeepLinkingTesterViewController: UIViewController { 16 | 17 | @IBOutlet weak var descriptionLabel: UILabel! 18 | @IBOutlet weak var quickLinksLabel: UILabel! 19 | @IBOutlet weak var urlTextField: UITextField! 20 | @IBOutlet weak var actionButton: UIButton! 21 | @IBOutlet weak var tableView: UITableView! 22 | @IBOutlet weak var linkTypeSelector: UISegmentedControl! 23 | 24 | public weak var delegate: DeepLinkingTesterViewControllerDelegate? 25 | 26 | public func loadTestLinks(atPath filePath: String) { 27 | let url = URL(fileURLWithPath: filePath) 28 | let fileContent = try! Data(contentsOf: url) 29 | let jsonObject = try! JSONSerialization.jsonObject(with: fileContent, options: []) as! [String: Any] 30 | universalLinks = jsonObject["universal_links"] as? [String] 31 | deepLinks = jsonObject["deep_links"] as? [String] 32 | } 33 | 34 | private var currentLinks: [String]! 35 | private var universalLinks: [String]! 36 | private var deepLinks: [String]! 37 | 38 | public static func instantiate() -> DeepLinkingTesterViewController { 39 | let fullName = NSStringFromClass(self) 40 | let className = fullName.components(separatedBy: ".")[1] 41 | let storyboard = UIStoryboard(name: "DeepLinkingTester", bundle: Bundle(for: self)) 42 | return storyboard.instantiateViewController(withIdentifier: className) as! DeepLinkingTesterViewController 43 | } 44 | 45 | override public func viewDidLoad() { 46 | super.viewDidLoad() 47 | setupTableView() 48 | applyLocalisation() 49 | currentLinks = universalLinks 50 | } 51 | 52 | func setupTableView() { 53 | tableView.delegate = self 54 | tableView.dataSource = self 55 | } 56 | 57 | func applyLocalisation() { 58 | title = "DeepLinking Tester" 59 | urlTextField.placeholder = "https://yourdomain.com/" 60 | descriptionLabel.text = "Enter a URL and test how the app handles it" 61 | } 62 | 63 | private func sanitisedUniversalLink(from universalLink: String?) -> URL? { 64 | guard 65 | let text = universalLink, 66 | text.count > 0, 67 | let url = URL(string: text) 68 | else { return nil } 69 | 70 | return url 71 | } 72 | } 73 | 74 | extension DeepLinkingTesterViewController { 75 | 76 | @IBAction func buttonTapped(_ sender: UIButton) { 77 | guard let url = sanitisedUniversalLink(from: urlTextField.text) else { return } 78 | delegate?.deepLinkingTesterViewController(self, didSelect: url) 79 | } 80 | 81 | @IBAction public func segmentedControlDidSelect(sender: UISegmentedControl) { 82 | let selectedIndex = linkTypeSelector.selectedSegmentIndex 83 | currentLinks = (selectedIndex == 0 ? universalLinks : deepLinks) 84 | urlTextField.placeholder = (selectedIndex == 0 ? "https://yourdomain.com/" : "your-scheme://") 85 | tableView.reloadData() 86 | } 87 | } 88 | 89 | extension DeepLinkingTesterViewController: UITableViewDataSource, UITableViewDelegate { 90 | 91 | public func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { 92 | let quickLink = currentLinks[indexPath.row] 93 | let cell = tableView.dequeueReusableCell(withIdentifier: "CellIdentifier", for: indexPath) 94 | cell.textLabel?.text = quickLink 95 | cell.selectionStyle = .none 96 | return cell 97 | } 98 | 99 | public func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 100 | return currentLinks.count 101 | } 102 | 103 | public func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { 104 | urlTextField.text = currentLinks[indexPath.row] 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /NavigationEngineDemo.xcodeproj/xcshareddata/xcschemes/NavigationEngineDemo.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 31 | 32 | 34 | 40 | 41 | 42 | 43 | 44 | 50 | 51 | 52 | 53 | 54 | 55 | 65 | 67 | 73 | 74 | 75 | 76 | 77 | 78 | 84 | 86 | 92 | 93 | 94 | 95 | 97 | 98 | 101 | 102 | 103 | -------------------------------------------------------------------------------- /NavigationEngineDemo/NavigationEngine/Core/DeepLinkingFacade.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DeepLinkingFacade.swift 3 | // NavigationEngineDemo 4 | // 5 | // Created by Alberto De Bortoli on 04/02/2019. 6 | // Copyright © 2019 Just Eat. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import UIKit 11 | import Promis 12 | 13 | public class DeepLinkingFacade { 14 | 15 | static let domain = "com.justeat.deepLinkingFacade" 16 | 17 | enum ErrorCode: Int { 18 | case couldNotHandleURL 19 | case couldNotHandleDeepLink 20 | case couldNotDeepLinkFromShortcutItem 21 | case couldNotDeepLinkFromSpotlightItem 22 | } 23 | 24 | private let flowControllerProvider: FlowControllerProvider 25 | private let navigationIntentHandler: NavigationIntentHandler 26 | private let urlGateway: URLGateway 27 | private let settings: DeepLinkingSettingsProtocol 28 | private let userStatusProvider: UserStatusProviding 29 | 30 | public init(flowControllerProvider: FlowControllerProvider, 31 | navigationTransitionerDataSource: NavigationTransitionerDataSource, 32 | settings: DeepLinkingSettingsProtocol, 33 | userStatusProvider: UserStatusProviding) { 34 | self.flowControllerProvider = flowControllerProvider 35 | self.navigationIntentHandler = NavigationIntentHandler(flowControllerProvider: flowControllerProvider, 36 | userStatusProvider: userStatusProvider, 37 | navigationTransitionerDataSource: navigationTransitionerDataSource) 38 | self.settings = settings 39 | self.userStatusProvider = userStatusProvider 40 | self.urlGateway = URLGateway(settings: settings) 41 | } 42 | 43 | @discardableResult 44 | public func handleURL(_ url: URL) -> Future { 45 | let promise = Promise() 46 | urlGateway.handleURL(url).finally { future in 47 | switch future.state { 48 | case .result(let deepLink): 49 | self.openDeepLink(deepLink).finally { future in 50 | promise.setResolution(of: future) 51 | } 52 | 53 | case .error(let error): 54 | let wrappedError = NSError(domain: DeepLinkingFacade.domain, code: ErrorCode.couldNotHandleURL.rawValue, userInfo: [NSUnderlyingErrorKey: error]) 55 | promise.setError(wrappedError) 56 | 57 | default: 58 | let error = NSError(domain: DeepLinkingFacade.domain, code: ErrorCode.couldNotHandleURL.rawValue, userInfo: nil) 59 | promise.setError(error) 60 | } 61 | } 62 | return promise.future 63 | } 64 | 65 | @discardableResult 66 | public func openDeepLink(_ deepLink: DeepLink) -> Future { 67 | let result = NavigationIntentFactory().intent(for: deepLink) 68 | switch result { 69 | case .result(let intent): 70 | return self.navigationIntentHandler.handleIntent(intent) 71 | case .error(let error): 72 | let wrappedError = NSError(domain: DeepLinkingFacade.domain, code: ErrorCode.couldNotHandleDeepLink.rawValue, userInfo: [NSUnderlyingErrorKey: error]) 73 | return Future.future(withError: wrappedError) 74 | } 75 | } 76 | 77 | @discardableResult 78 | public func openShortcutItem(_ item: UIApplicationShortcutItem) -> Future { 79 | let shortcutItemConverter = ShortcutItemConverter(settings: settings) 80 | if let deepLink = shortcutItemConverter.deepLink(forShortcutItem: item) { 81 | return openDeepLink(deepLink) 82 | } 83 | else { 84 | let error = NSError(domain: DeepLinkingFacade.domain, code: ErrorCode.couldNotDeepLinkFromShortcutItem.rawValue, userInfo: nil) 85 | return Future.future(withError: error) 86 | } 87 | } 88 | 89 | @discardableResult 90 | public func openSpotlightItem(_ userActivity: NSUserActivityProtocol) -> Future { 91 | let spotlightItemConverter = SpotlightItemConverter(settings: settings) 92 | if let deepLink = spotlightItemConverter.deepLink(forSpotlightItem: userActivity) { 93 | return openDeepLink(deepLink) 94 | } 95 | else { 96 | let error = NSError(domain: DeepLinkingFacade.domain, code: ErrorCode.couldNotDeepLinkFromSpotlightItem.rawValue, userInfo: nil) 97 | return Future.future(withError: error) 98 | } 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /NavigationEngineDemo/NavigationEngine/Core/Parsing/DeepLinkFactory.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DeepLinkFactory.swift 3 | // NavigationEngineDemo 4 | // 5 | // Created by Alberto De Bortoli on 04/02/2019. 6 | // Copyright © 2019 Just Eat. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import CoreLocation 11 | 12 | public class DeepLinkFactory { 13 | 14 | let scheme: String 15 | let host: String 16 | 17 | public init(scheme: String, host: String) { 18 | self.scheme = scheme 19 | self.host = host 20 | } 21 | } 22 | 23 | extension DeepLinkFactory { 24 | 25 | public func homeURL() -> DeepLink { 26 | let endpoint = Endpoint(scheme: scheme, 27 | host: host, 28 | path: "/home", 29 | queryItems: nil) 30 | return endpoint.url 31 | } 32 | 33 | public func loginURL() -> DeepLink { 34 | let endpoint = Endpoint(scheme: scheme, 35 | host: host, 36 | path: "/login", 37 | queryItems: nil) 38 | return endpoint.url 39 | } 40 | 41 | public func updatePasswordURL(token: ResetPasswordToken) -> DeepLink { 42 | let tokenQueryItem = URLQueryItem(name: "resetToken", value: token) 43 | let endpoint = Endpoint(scheme: scheme, 44 | host: host, 45 | path: "/resetPassword", 46 | queryItems: [tokenQueryItem]) 47 | return endpoint.url 48 | } 49 | } 50 | 51 | extension DeepLinkFactory { 52 | 53 | public func searchURL(postcode: Postcode?, cuisine: Cuisine?, location: CLLocationCoordinate2D?) -> DeepLink { 54 | let postcodeQueryItem = postcode != nil ? URLQueryItem(name: "postcode", value: postcode) : nil 55 | let cuisineQueryItem = cuisine != nil ? URLQueryItem(name: "cuisine", value: cuisine) : nil 56 | var queryItems = [postcodeQueryItem, cuisineQueryItem].compactMap { return $0 } 57 | if let location = location { 58 | let latitudeQueryItem = URLQueryItem(name: "lat", value: "\(location.latitude)") 59 | let longitudeQueryItem = URLQueryItem(name: "long", value: "\(location.longitude)") 60 | queryItems.append(latitudeQueryItem) 61 | queryItems.append(longitudeQueryItem) 62 | } 63 | let endpoint = Endpoint(scheme: scheme, 64 | host: host, 65 | path: "/search", 66 | queryItems: queryItems.count > 0 ? queryItems : nil) 67 | return endpoint.url 68 | } 69 | 70 | public func restaurantURL(restaurantId: RestaurantId, postcode: Postcode?, serviceType: ServiceType?, section: RestaurantSection?) -> DeepLink { 71 | let restaurantIdQueryItem = URLQueryItem(name: "restaurantId", value: restaurantId) 72 | let postcodeQueryItem = postcode != nil ? URLQueryItem(name: "postcode", value: postcode) : nil 73 | let serviceTypeQueryItem = serviceType != nil ? URLQueryItem(name: "serviceType", value: serviceType?.rawValue) : nil 74 | let sectionQueryItem = section != nil ? URLQueryItem(name: "section", value: section?.rawValue) : nil 75 | let queryItems = [restaurantIdQueryItem, postcodeQueryItem, serviceTypeQueryItem, sectionQueryItem].compactMap { return $0 } 76 | let endpoint = Endpoint(scheme: scheme, 77 | host: host, 78 | path: "/restaurant", 79 | queryItems: queryItems.count > 0 ? queryItems : nil) 80 | return endpoint.url 81 | } 82 | } 83 | 84 | extension DeepLinkFactory { 85 | 86 | public func orderHistoryURL() -> DeepLink { 87 | let endpoint = Endpoint(scheme: scheme, 88 | host: host, 89 | path: "/orders", 90 | queryItems: nil) 91 | return endpoint.url 92 | } 93 | 94 | public func orderDetailsURL(orderId: OrderId) -> DeepLink { 95 | let orderIdQueryItem = URLQueryItem(name: "orderId", value: orderId) 96 | let endpoint = Endpoint(scheme: scheme, 97 | host: host, 98 | path: "/order", 99 | queryItems: [orderIdQueryItem]) 100 | return endpoint.url 101 | } 102 | 103 | public func reorderURL(orderId: OrderId) -> DeepLink { 104 | let orderIdQueryItem = URLQueryItem(name: "orderId", value: orderId) 105 | let endpoint = Endpoint(scheme: scheme, 106 | host: host, 107 | path: "/reorder", 108 | queryItems: [orderIdQueryItem]) 109 | return endpoint.url 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /NavigationEngineDemo/NavigationEngine/Core/Navigation/NavigationIntentFactory.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NavigationIntentFactory.swift 3 | // NavigationEngineDemo 4 | // 5 | // Created by Alberto De Bortoli on 28/12/2018. 6 | // Copyright © 2018 Just Eat. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public typealias OrderId = String 12 | public typealias Postcode = String 13 | public typealias Cuisine = String 14 | public typealias ReorderId = String 15 | public typealias ResetPasswordToken = String 16 | public typealias RestaurantId = String 17 | 18 | public enum ServiceType: String { 19 | case delivery 20 | case collection 21 | } 22 | 23 | public enum NavigationIntent: Equatable { 24 | case goToHome 25 | case goToLogin 26 | case goToResetPassword(ResetPasswordToken) 27 | case goToSearch(Postcode?, Cuisine?) 28 | case goToRestaurant(RestaurantId, Postcode?, ServiceType?, RestaurantSection?) 29 | case goToReorder(ReorderId) 30 | case goToOrderHistory 31 | case goToOrderDetails(OrderId) 32 | case goToSettings 33 | } 34 | 35 | enum NavigationIntentFactoryResult { 36 | case result(NavigationIntent) 37 | case error(Error) 38 | } 39 | 40 | class NavigationIntentFactory { 41 | 42 | static let domain = "com.justeat.navigationIntentFactory" 43 | 44 | enum ErrorCode: Int { 45 | case malformedURL 46 | case unsupportedURL 47 | } 48 | 49 | func intent(for url: DeepLink) -> NavigationIntentFactoryResult { 50 | guard let components = URLComponents(url: url, resolvingAgainstBaseURL: true), 51 | let pathComponents = components.url?.pathComponents 52 | else { 53 | return .error(NSError(domain: NavigationIntentFactory.domain, code: ErrorCode.malformedURL.rawValue, userInfo: nil)) 54 | } 55 | 56 | let queryItems = components.queryItems 57 | 58 | switch pathComponents.count { 59 | case 3: 60 | switch (pathComponents[1], pathComponents[2]) { 61 | case ("account", "login"): 62 | return .result(.goToLogin) 63 | case ("order", let orderId): 64 | return .result(.goToOrderDetails(orderId)) 65 | case ("reorder", let orderId): 66 | return .result(.goToReorder(orderId)) 67 | default: break 68 | } 69 | 70 | case 2: 71 | switch pathComponents[1] { 72 | case "home": 73 | return .result(.goToHome) 74 | case "login": 75 | return .result(.goToLogin) 76 | case "search": 77 | let postcode = queryValue(queryItems, key: "postcode") 78 | let cuisine = queryValue(queryItems, key: "cuisine") 79 | return .result(.goToSearch(postcode, cuisine)) 80 | case ("resetPassword") where queryContainsKey(queryItems, key: "resetToken"): 81 | return .result(.goToResetPassword(queryValue(queryItems, key: "resetToken")!)) 82 | case ("restaurant") where queryContainsKey(queryItems, key: "restaurantId"): 83 | let restaurantId = queryValue(queryItems, key: "restaurantId")! 84 | let postcode = queryValue(queryItems, key: "postcode") 85 | let serviceType: ServiceType? = { 86 | guard let val = queryValue(queryItems, key: "serviceType") else { return nil } 87 | return ServiceType(rawValue: val) 88 | }() 89 | let section: RestaurantSection? = { 90 | guard let val = queryValue(queryItems, key: "section") else { return nil } 91 | return RestaurantSection(rawValue: val) 92 | }() 93 | return .result(.goToRestaurant(restaurantId, postcode, serviceType, section)) 94 | case ("orderHistory"): 95 | return .result(.goToOrderHistory) 96 | case ("order-history"): 97 | return .result(.goToOrderHistory) 98 | case ("orders"): 99 | return .result(.goToOrderHistory) 100 | case ("order") where queryContainsKey(queryItems, key: "orderId"): 101 | return .result(.goToOrderDetails(queryValue(queryItems, key: "orderId")!)) 102 | case ("reorder") where queryContainsKey(queryItems, key: "orderId"): 103 | return .result(.goToReorder(queryValue(queryItems, key: "orderId")!)) 104 | case ("settings"): 105 | return .result(.goToSettings) 106 | default: break 107 | } 108 | 109 | case 1: 110 | return .result(.goToHome) 111 | 112 | default: break 113 | } 114 | 115 | return .error(NSError(domain: NavigationIntentFactory.domain, code: ErrorCode.unsupportedURL.rawValue, userInfo: nil)) 116 | } 117 | 118 | private func queryContainsKey(_ items: [URLQueryItem]?, key: String) -> Bool { 119 | guard let queryItems = items else { return false } 120 | return queryItems.filter({ $0.name.caseInsensitiveCompare(key) == .orderedSame }).count > 0 121 | } 122 | 123 | private func queryValue(_ items: [URLQueryItem]?, key: String) -> String? { 124 | guard let queryItems = items else { return nil } 125 | return queryItems.filter({ $0.name.caseInsensitiveCompare(key) == .orderedSame }).first?.value 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /NavigationEngineDemoTests/Parsing/DeepLinkFactoryTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DeepLinkFactoryTests.swift 3 | // NavigationEngineTests 4 | // 5 | // Created by Alberto De Bortoli on 07/02/2019. 6 | // 7 | 8 | import XCTest 9 | import CoreLocation 10 | @testable import NavigationEngineDemo 11 | 12 | class DeepLinkFactoryTests: XCTestCase { 13 | 14 | private let scheme = "justeat" 15 | private let host = "je.com" 16 | 17 | private var deepLinkFactory: DeepLinkFactory! 18 | 19 | override func setUp() { 20 | super.setUp() 21 | deepLinkFactory = DeepLinkFactory(scheme: scheme, host: host) 22 | } 23 | 24 | func test_homeUrl() { 25 | let target = DeepLink(string: "\(scheme)://\(host)/home")! 26 | let test = deepLinkFactory.homeURL() 27 | XCTAssertEqual(target, test) 28 | } 29 | 30 | func test_loginUrl() { 31 | let target = DeepLink(string: "\(scheme)://\(host)/login")! 32 | let test = deepLinkFactory.loginURL() 33 | XCTAssertEqual(target, test) 34 | } 35 | 36 | func test_updatePasswordUrl() { 37 | let target = DeepLink(string: "\(scheme)://\(host)/resetPassword?resetToken=qwerty")! 38 | let test = deepLinkFactory.updatePasswordURL(token: "qwerty") 39 | XCTAssertEqual(target, test) 40 | } 41 | 42 | func test_serpSearchUrl() { 43 | let target = DeepLink(string: "\(scheme)://\(host)/search")! 44 | let test = deepLinkFactory.searchURL(postcode: nil, cuisine: nil, location: nil) 45 | XCTAssertEqual(target, test) 46 | } 47 | 48 | func test_serpSearchUrlWithPostcode() { 49 | let target = DeepLink(string: "\(scheme)://\(host)/search?postcode=EC4M7RF")! 50 | let test = deepLinkFactory.searchURL(postcode: "EC4M7RF", cuisine: nil, location: nil) 51 | XCTAssertEqual(target, test) 52 | } 53 | 54 | func test_serpSearchUrlWithPostcodeAndLocation() { 55 | let target = DeepLink(string: "\(scheme)://\(host)/search?postcode=EC4M7RF&lat=45.951342&long=12.497958")! 56 | let test = deepLinkFactory.searchURL(postcode: "EC4M7RF", cuisine: nil, location: CLLocationCoordinate2D(latitude: 45.951342, longitude: 12.497958)) 57 | XCTAssertEqual(target, test) 58 | } 59 | 60 | func test_serpSearchUrlWithPostcodeAndCuisine() { 61 | let target = DeepLink(string: "\(scheme)://\(host)/search?postcode=EC4M7RF&cuisine=italian")! 62 | let test = deepLinkFactory.searchURL(postcode: "EC4M7RF", cuisine: "italian", location: nil) 63 | XCTAssertEqual(target, test) 64 | } 65 | 66 | func test_serpSearchUrlWithPostcodeAndCuisineAndLocation() { 67 | let target = DeepLink(string: "\(scheme)://\(host)/search?postcode=EC4M7RF&cuisine=italian&lat=45.951342&long=12.497958")! 68 | let test = deepLinkFactory.searchURL(postcode: "EC4M7RF", cuisine: "italian", location: CLLocationCoordinate2D(latitude: 45.951342, longitude: 12.497958)) 69 | XCTAssertEqual(target, test) 70 | } 71 | 72 | func test_restaurantUrl() { 73 | let target = DeepLink(string: "\(scheme)://\(host)/restaurant?restaurantId=123")! 74 | let test = deepLinkFactory.restaurantURL(restaurantId: "123", postcode: nil, serviceType: nil, section: nil) 75 | XCTAssertEqual(target, test) 76 | } 77 | 78 | func test_restaurantUrlWithPostcode() { 79 | let target = DeepLink(string: "\(scheme)://\(host)/restaurant?restaurantId=123&postcode=EC4M7RF")! 80 | let test = deepLinkFactory.restaurantURL(restaurantId: "123", postcode: "EC4M7RF", serviceType: nil, section: nil) 81 | XCTAssertEqual(target, test) 82 | } 83 | 84 | func test_restaurantUrlWithPostcodeAndServiceType() { 85 | let target = DeepLink(string: "\(scheme)://\(host)/restaurant?restaurantId=123&postcode=EC4M7RF&serviceType=delivery")! 86 | let test = deepLinkFactory.restaurantURL(restaurantId: "123", postcode: "EC4M7RF", serviceType: .delivery, section: nil) 87 | XCTAssertEqual(target, test) 88 | } 89 | 90 | func test_restaurantUrlWithPostcodeAndServiceTypeAndMenuSection() { 91 | let target = DeepLink(string: "\(scheme)://\(host)/restaurant?restaurantId=123&postcode=EC4M7RF&serviceType=delivery§ion=menu")! 92 | let test = deepLinkFactory.restaurantURL(restaurantId: "123", postcode: "EC4M7RF", serviceType: .delivery, section: .menu) 93 | XCTAssertEqual(target, test) 94 | } 95 | 96 | func test_restaurantUrlWithPostcodeAndServiceTypeAndReviewsSection() { 97 | let target = DeepLink(string: "\(scheme)://\(host)/restaurant?restaurantId=123&postcode=EC4M7RF&serviceType=delivery§ion=reviews")! 98 | let test = deepLinkFactory.restaurantURL(restaurantId: "123", postcode: "EC4M7RF", serviceType: .delivery, section: .reviews) 99 | XCTAssertEqual(target, test) 100 | } 101 | 102 | func test_restaurantUrlWithPostcodeAndServiceTypeAndInfoSection() { 103 | let target = DeepLink(string: "\(scheme)://\(host)/restaurant?restaurantId=123&postcode=EC4M7RF&serviceType=delivery§ion=info")! 104 | let test = deepLinkFactory.restaurantURL(restaurantId: "123", postcode: "EC4M7RF", serviceType: .delivery, section: .info) 105 | XCTAssertEqual(target, test) 106 | } 107 | 108 | func test_orderHistoryUrl() { 109 | let target = DeepLink(string: "\(scheme)://\(host)/orders")! 110 | let test = deepLinkFactory.orderHistoryURL() 111 | XCTAssertEqual(target, test) 112 | } 113 | 114 | func test_orderDetailsUrl() { 115 | let target = DeepLink(string: "\(scheme)://\(host)/order?orderId=123")! 116 | let test = deepLinkFactory.orderDetailsURL(orderId: "123") 117 | XCTAssertEqual(target, test) 118 | } 119 | 120 | func test_reorderUrl() { 121 | let target = DeepLink(string: "\(scheme)://\(host)/reorder?orderId=123")! 122 | let test = deepLinkFactory.reorderURL(orderId: "123") 123 | XCTAssertEqual(target, test) 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /NavigationEngineDemoTests/DeepLinkingFacadeTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DeepLinkingFacadeTests.swift 3 | // NavigationEngineTests 4 | // 5 | // Created by Alberto De Bortoli on 07/02/2019. 6 | // 7 | 8 | import XCTest 9 | import CoreSpotlight 10 | @testable import NavigationEngineDemo 11 | 12 | class DeepLinkingFacadeTests: XCTestCase { 13 | 14 | private let timeout = 3.0 15 | private var deepLinkingFacade: DeepLinkingFacade! 16 | 17 | override func setUp() { 18 | let rootFlowController = MockRootFlowController() 19 | rootFlowController.setup() 20 | let flowControllerProvider = FlowControllerProvider(rootFlowController: rootFlowController) 21 | let navigationTransitionerDataSource = MockNavigationTransitionerDataSource(shouldSucceed: true) 22 | let settings = MockDeepLinkingSettings() 23 | let userStatusProvider = MockUserStatusProvider(userStatus: .loggedOut) 24 | deepLinkingFacade = DeepLinkingFacade(flowControllerProvider: flowControllerProvider, 25 | navigationTransitionerDataSource: navigationTransitionerDataSource, 26 | settings: settings, 27 | userStatusProvider: userStatusProvider) 28 | } 29 | 30 | func test_handleUniversalLink_success() { 31 | let expectation = self.expectation(description: #function) 32 | let url = URL(string: "http://just-eat.co.uk/home")! 33 | deepLinkingFacade.handleURL(url).finally { future in 34 | XCTAssertTrue(future.hasResult()) 35 | XCTAssertEqual(future.result!, true) 36 | expectation.fulfill() 37 | } 38 | wait(for: [expectation], timeout: timeout) 39 | } 40 | 41 | func test_handleUniversalLink_failure() { 42 | let expectation = self.expectation(description: #function) 43 | let url = URL(string: "http://just-eat.com/invalid")! 44 | deepLinkingFacade.handleURL(url).finally { future in 45 | XCTAssertTrue(future.hasError()) 46 | let error = future.error! as NSError 47 | XCTAssertEqual(error.domain, DeepLinkingFacade.domain) 48 | XCTAssertEqual(error.code, DeepLinkingFacade.ErrorCode.couldNotHandleURL.rawValue) 49 | expectation.fulfill() 50 | } 51 | wait(for: [expectation], timeout: timeout) 52 | } 53 | 54 | func test_openDeepLink_success() { 55 | let expectation = self.expectation(description: #function) 56 | let deepLink = DeepLink(string: "je-internal://just-eat.com/home")! 57 | deepLinkingFacade.openDeepLink(deepLink).finally { future in 58 | XCTAssertTrue(future.hasResult()) 59 | XCTAssertEqual(future.result!, true) 60 | expectation.fulfill() 61 | } 62 | wait(for: [expectation], timeout: timeout) 63 | } 64 | 65 | func test_openDeepLink_failure() { 66 | let expectation = self.expectation(description: #function) 67 | let deepLink = DeepLink(string: "je-internal://just-eat.com/invalid")! 68 | deepLinkingFacade.openDeepLink(deepLink).finally { future in 69 | XCTAssertTrue(future.hasError()) 70 | let error = future.error! as NSError 71 | XCTAssertEqual(error.domain, DeepLinkingFacade.domain) 72 | XCTAssertEqual(error.code, DeepLinkingFacade.ErrorCode.couldNotHandleDeepLink.rawValue) 73 | expectation.fulfill() 74 | } 75 | wait(for: [expectation], timeout: timeout) 76 | } 77 | 78 | func test_openSpotlightItem_success() { 79 | let expectation = self.expectation(description: #function) 80 | let spotlightItem = MockNSUserActivity(activityType: CSSearchableItemActionType, userInfo: [CSSearchableItemActivityIdentifier: "/restaurant/456"]) 81 | deepLinkingFacade.openSpotlightItem(spotlightItem).finally { future in 82 | XCTAssertTrue(future.hasResult()) 83 | XCTAssertEqual(future.result!, true) 84 | expectation.fulfill() 85 | } 86 | wait(for: [expectation], timeout: timeout) 87 | } 88 | 89 | func test_openSpotlightItem_failure() { 90 | let expectation = self.expectation(description: #function) 91 | let spotlightItem = MockNSUserActivity(activityType: CSSearchableItemActionType, userInfo: [CSSearchableItemActivityIdentifier: "/invalid"]) 92 | deepLinkingFacade.openSpotlightItem(spotlightItem).finally { future in 93 | XCTAssertTrue(future.hasError()) 94 | let error = future.error! as NSError 95 | XCTAssertEqual(error.domain, DeepLinkingFacade.domain) 96 | XCTAssertEqual(error.code, DeepLinkingFacade.ErrorCode.couldNotDeepLinkFromSpotlightItem.rawValue) 97 | expectation.fulfill() 98 | } 99 | wait(for: [expectation], timeout: timeout) 100 | } 101 | 102 | func test_openShortcutItem_success() { 103 | let expectation = self.expectation(description: #function) 104 | let shortcutItem = UIApplicationShortcutItem(type: "/search", localizedTitle: "Search") 105 | deepLinkingFacade.openShortcutItem(shortcutItem).finally { future in 106 | XCTAssertTrue(future.hasResult()) 107 | XCTAssertEqual(future.result!, true) 108 | expectation.fulfill() 109 | } 110 | wait(for: [expectation], timeout: timeout) 111 | } 112 | 113 | func test_openShortcutItem_failure() { 114 | let expectation = self.expectation(description: #function) 115 | let shortcutItem = UIApplicationShortcutItem(type: "/invalid", localizedTitle: "Invalid") 116 | deepLinkingFacade.openShortcutItem(shortcutItem).finally { future in 117 | XCTAssertTrue(future.hasError()) 118 | let error = future.error! as NSError 119 | XCTAssertEqual(error.domain, DeepLinkingFacade.domain) 120 | XCTAssertEqual(error.code, DeepLinkingFacade.ErrorCode.couldNotDeepLinkFromShortcutItem.rawValue) 121 | expectation.fulfill() 122 | } 123 | wait(for: [expectation], timeout: timeout) 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /NavigationEngineDemo/NavigationEngine/Core/Navigation/StateMachine.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StateMachine.swift 3 | // NavigationEngineDemo 4 | // 5 | // Created by Alberto De Bortoli on 25/12/2018. 6 | // Copyright © 2018 Just Eat. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Stateful 11 | 12 | struct TransitionOptions: OptionSet { 13 | let rawValue: Int 14 | 15 | static let basicTransitions = TransitionOptions(rawValue: 1 << 0) 16 | static let userLoggedIn = TransitionOptions(rawValue: 1 << 1) 17 | static let userLoggedOut = TransitionOptions(rawValue: 1 << 2) 18 | } 19 | 20 | enum EventType: Int, CaseIterable { 21 | case popEverything 22 | case goToHome 23 | case goToLogin 24 | case goToResetPassword 25 | case searchRestaurants 26 | case loadRestaurant 27 | case fillBasket 28 | case loadOrderHistory 29 | case goToOrderDetails 30 | case goToSettings 31 | } 32 | 33 | enum StateType: Int, CaseIterable { 34 | case allPoppedToRoot 35 | case home 36 | case login 37 | case resetPassword 38 | case search 39 | case basket 40 | case restaurant 41 | case orderHistory 42 | case orderDetails 43 | case settings 44 | } 45 | 46 | class StateMachine: Stateful.StateMachine { 47 | 48 | init(initialState: StateType, allowedTransitions: TransitionOptions) { 49 | super.init(initialState: initialState) 50 | 51 | if allowedTransitions.contains(.basicTransitions) { 52 | addBasicTransitions() 53 | } 54 | 55 | if allowedTransitions.contains(.userLoggedIn) { 56 | addUserLoggedInTransitions() 57 | } 58 | 59 | if allowedTransitions.contains(.userLoggedOut) { 60 | addUserLoggedOutTransitions() 61 | } 62 | } 63 | 64 | func addBasicTransitions() { 65 | add(transition: Transition(with: .goToHome, 66 | from: .allPoppedToRoot, 67 | to: .home)) 68 | add(transition: Transition(with: .searchRestaurants, 69 | from: .home, 70 | to: .search)) 71 | add(transition: Transition(with: .loadRestaurant, 72 | from: .home, 73 | to: .restaurant)) 74 | add(transition: Transition(with: .loadRestaurant, 75 | from: .search, 76 | to: .restaurant)) 77 | add(transition: Transition(with: .goToSettings, 78 | from: .allPoppedToRoot, 79 | to: .settings)) 80 | 81 | // make sure we can always go back to root 82 | add(transition: Transition(with: .popEverything, 83 | from: .allPoppedToRoot, 84 | to: .allPoppedToRoot)) 85 | add(transition: Transition(with: .popEverything, 86 | from: .home, 87 | to: .allPoppedToRoot)) 88 | add(transition: Transition(with: .popEverything, 89 | from: .resetPassword, 90 | to: .allPoppedToRoot)) 91 | add(transition: Transition(with: .popEverything, 92 | from: .search, 93 | to: .allPoppedToRoot)) 94 | add(transition: Transition(with: .popEverything, 95 | from: .restaurant, 96 | to: .allPoppedToRoot)) 97 | add(transition: Transition(with: .popEverything, 98 | from: .orderHistory, 99 | to: .allPoppedToRoot)) 100 | add(transition: Transition(with: .popEverything, 101 | from: .orderDetails, 102 | to: .allPoppedToRoot)) 103 | } 104 | 105 | func addUserLoggedInTransitions() { 106 | add(transition: Transition(with: .loadOrderHistory, 107 | from: .allPoppedToRoot, 108 | to: .orderHistory)) 109 | add(transition: Transition(with: .goToOrderDetails, 110 | from: .orderHistory, 111 | to: .orderDetails)) 112 | add(transition: Transition(with: .fillBasket, 113 | from: .restaurant, 114 | to: .basket)) 115 | } 116 | 117 | func addUserLoggedOutTransitions() { 118 | add(transition: Transition(with: .goToResetPassword, 119 | from: .allPoppedToRoot, 120 | to: .resetPassword)) 121 | add(transition: Transition(with: .goToLogin, 122 | from: .allPoppedToRoot, 123 | to: .login)) 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /NavigationEngineDemo/NavigationEngine/Core/Navigation/NavigationTransitioner.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NavigationTransitioner.swift 3 | // NavigationEngineDemo 4 | // 5 | // Created by Alberto De Bortoli on 26/12/2018. 6 | // Copyright © 2018 Just Eat. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Stateful 11 | import Promis 12 | 13 | public protocol NavigationTransitionerDataSource: class { 14 | 15 | func navigationTransitionerDidRequestInputForReorder(orderId: OrderId) -> Future 16 | func navigationTransitionerDidRequestUserToLogin() -> Future 17 | } 18 | 19 | class NavigationTransitioner { 20 | 21 | weak var dataSource: NavigationTransitionerDataSource! 22 | 23 | let flowControllerProvider: FlowControllerProvider 24 | let stateMachine: StateMachine 25 | 26 | static let domain = "com.justeat.navigationTransitioner" 27 | 28 | enum ErrorCode: Int { 29 | case failedPerformingTransition 30 | } 31 | 32 | init(flowControllerProvider: FlowControllerProvider, stateMachine: StateMachine) { 33 | self.flowControllerProvider = flowControllerProvider 34 | self.stateMachine = stateMachine 35 | } 36 | 37 | func goToRoot(animated: Bool) -> Future { 38 | return performTransition(forEvent: .popEverything, autoclosure: 39 | self.flowControllerProvider.rootFlowController.dismissAndPopToRootAll(animated: animated) 40 | ) 41 | } 42 | 43 | func goToHome(animated: Bool) -> Future { 44 | return performTransition(forEvent: .goToHome, autoclosure: 45 | self.flowControllerProvider.rootFlowController.goToRestaurantsSection() 46 | ) 47 | } 48 | 49 | func goToLogin(animated: Bool) -> Future { 50 | return performTransition(forEvent: .goToLogin, autoclosure: 51 | self.flowControllerProvider.rootFlowController.goToLogin(animated: animated) 52 | ) 53 | } 54 | 55 | func goToResetPassword(token: ResetPasswordToken, animated: Bool) -> Future { 56 | return performTransition(forEvent: .goToResetPassword, autoclosure: 57 | self.flowControllerProvider.rootFlowController.goToResetPassword(token: token, animated: animated) 58 | ) 59 | } 60 | 61 | func goFromHomeToSearch(postcode: Postcode?, cuisine: Cuisine?, animated: Bool) -> Future { 62 | return performTransition(forEvent: .searchRestaurants, autoclosure: 63 | self.flowControllerProvider.restaurantsFlowController.goToSearchAnimated(postcode: postcode, cuisine: cuisine, animated: animated) 64 | ) 65 | } 66 | 67 | func goFromHomeToRestaurant(restaurantId: RestaurantId, postcode: Postcode?, serviceType: ServiceType?, section: RestaurantSection?, animated: Bool) -> Future { 68 | return performTransition(forEvent: .loadRestaurant, autoclosure: 69 | self.flowControllerProvider.restaurantsFlowController.goToRestaurant(restaurantId: restaurantId, postcode: postcode, serviceType: serviceType, section: section, animated: animated) 70 | ) 71 | } 72 | 73 | func goFromSearchToRestaurant(restaurantId: RestaurantId, postcode: Postcode?, serviceType: ServiceType?, section: RestaurantSection?, animated: Bool) -> Future { 74 | return performTransition(forEvent: .loadRestaurant, autoclosure: 75 | self.flowControllerProvider.restaurantsFlowController.goToRestaurant(restaurantId: restaurantId, postcode: postcode, serviceType: serviceType, section: section, animated: animated) 76 | ) 77 | } 78 | 79 | func goFromRestaurantToBasket(reorderId: ReorderId, animated: Bool) -> Future { 80 | return performTransition(forEvent: .fillBasket, autoclosure: 81 | self.flowControllerProvider.restaurantsFlowController.goToBasket(reorderId: reorderId, animated: animated) 82 | ) 83 | } 84 | 85 | func goToOrderHistory(animated: Bool) -> Future { 86 | return performTransition(forEvent: .loadOrderHistory, autoclosure: 87 | self.flowControllerProvider.rootFlowController.goToOrdersSection() 88 | ) 89 | } 90 | 91 | func goFromOrderHistoryToOrderDetails(orderId: OrderId, animated: Bool) -> Future { 92 | return performTransition(forEvent: .goToOrderDetails, autoclosure: 93 | self.flowControllerProvider.ordersFlowController.goToOrder(orderId: orderId, animated: animated) 94 | ) 95 | } 96 | 97 | func goToSettings(animated: Bool) -> Future { 98 | return performTransition(forEvent: .goToSettings, autoclosure: 99 | self.flowControllerProvider.rootFlowController.goToSettingsSection() 100 | ) 101 | } 102 | 103 | func requestInputForReorder(orderId: OrderId) -> Future { 104 | return dataSource.navigationTransitionerDidRequestInputForReorder(orderId: orderId) 105 | } 106 | 107 | func requestUserToLogin() -> Future { 108 | return dataSource.navigationTransitionerDidRequestUserToLogin() 109 | } 110 | } 111 | 112 | extension NavigationTransitioner { 113 | 114 | fileprivate func performTransition(forEvent eventType: EventType, autoclosure: @autoclosure @escaping () -> Future) -> Future { 115 | let promise = Promise() 116 | stateMachine.process(event: eventType, execution: { 117 | autoclosure().finally { future in 118 | promise.setResolution(of: future) 119 | } 120 | }, callback: { result in 121 | /** 122 | * If result == .success (i.e. the transition was performed): 123 | - the callback block is called twice 124 | - the `execution` block is called 125 | * If result == .failure (i.e. the transition cannot be performed): 126 | - the callback block is called once 127 | - the `execution` block is not called 128 | * We cannot therefore resolve the promise in case of success otherwise we would do it twice causing a crash. 129 | */ 130 | if result == .failure { 131 | let error = NSError(domain: NavigationTransitioner.domain, 132 | code: ErrorCode.failedPerformingTransition.rawValue, 133 | userInfo: [NSLocalizedFailureReasonErrorKey: "Could not perform transition"]) 134 | promise.setError(error) 135 | } 136 | }) 137 | return promise.future 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /NavigationEngineDemo/DemoApp/NavigationEngineIntegration/FlowControllers/RootFlowController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RootFlowController.swift 3 | // NavigationEngineDemo 4 | // 5 | // Created by Alberto De Bortoli on 23/11/2018. 6 | // Copyright © 2018 Just Eat. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import Promis 11 | 12 | class RootFlowController: RootFlowControllerProtocol { 13 | 14 | private let tabBarController: TabBarController 15 | 16 | var restaurantsFlowController: RestaurantsFlowControllerProtocol! 17 | var ordersFlowController: OrdersFlowControllerProtocol! 18 | var settingsFlowController: SettingsFlowControllerProtocol! 19 | 20 | var accountFlowController: AccountFlowControllerProtocol! 21 | 22 | init(with tabBarController: TabBarController) { 23 | self.tabBarController = tabBarController 24 | } 25 | 26 | func setup() { 27 | let takeawayNavigationController = tabBarController.viewControllers![0] as! UINavigationController 28 | let restaurantsFlowController = RestaurantsFlowController(with: self, navigationController: takeawayNavigationController) 29 | restaurantsFlowController.delegate = self 30 | self.restaurantsFlowController = restaurantsFlowController 31 | let homeViewController = takeawayNavigationController.topViewController as! HomeViewController 32 | homeViewController.flowController = restaurantsFlowController 33 | 34 | let ordersNavigationController = tabBarController.viewControllers![1] as! UINavigationController 35 | let ordersFlowController = OrdersFlowController(with: self, navigationController: ordersNavigationController) 36 | ordersFlowController.delegate = self 37 | self.ordersFlowController = ordersFlowController 38 | let orderHistoryViewController = ordersNavigationController.topViewController as! OrderHistoryViewController 39 | orderHistoryViewController.flowController = ordersFlowController 40 | 41 | let settingsNavigationController = tabBarController.viewControllers![2] as! UINavigationController 42 | let settingsFlowController = SettingsFlowController(with: self, navigationController: settingsNavigationController) 43 | let settingsViewController = settingsNavigationController.topViewController as! SettingsViewController 44 | self.settingsFlowController = settingsFlowController 45 | settingsViewController.flowController = settingsFlowController 46 | } 47 | 48 | @discardableResult 49 | func dismissAll(animated: Bool) -> Future { 50 | // cannot use the completion to fulfill any future since it might never get called 51 | tabBarController.dismiss(animated: animated) 52 | let fut1 = restaurantsFlowController.dismiss(animated: animated) 53 | let fut2 = ordersFlowController.dismiss(animated: animated) 54 | let fut3 = settingsFlowController.dismiss(animated: animated) 55 | return Future.whenAll([fut1, fut2, fut3]).then { future in 56 | let erroredFuture = [fut1, fut2, fut3].filter { $0.hasError() } 57 | if let firstErroredFuture = erroredFuture.first { 58 | return Future.future(withError: firstErroredFuture.error!) 59 | } 60 | return Future.future(withResult: fut1.result! && fut2.result! && fut3.result!) 61 | } 62 | } 63 | 64 | @discardableResult 65 | func dismissAndPopToRootAll(animated: Bool) -> Future { 66 | let fut0 = dismissAll(animated: animated) 67 | let fut1 = restaurantsFlowController.goBackToRoot(animated: animated) 68 | let fut2 = ordersFlowController.goBackToRoot(animated: animated) 69 | let fut3 = settingsFlowController.goBackToRoot(animated: animated) 70 | return Future.whenAll([fut0, fut1, fut2, fut3]).then { future in 71 | let erroredFuture = [fut0, fut1, fut2, fut3].filter { $0.hasError() } 72 | if let firstErroredFuture = erroredFuture.first { 73 | return Future.future(withError: firstErroredFuture.error!) 74 | } 75 | return Future.future(withResult: fut0.result! && fut1.result! && fut2.result! && fut3.result!) 76 | } 77 | } 78 | 79 | @discardableResult 80 | func goToLogin(animated: Bool) -> Future { 81 | let accountNC = UINavigationController() 82 | accountFlowController = AccountFlowController(with: self, navigationController: accountNC) 83 | return accountFlowController.beginLoginFlow(from: tabBarController, animated: animated) 84 | } 85 | 86 | @discardableResult 87 | func goToResetPassword(token: ResetPasswordToken, animated: Bool) -> Future { 88 | let accountNC = UINavigationController() 89 | accountFlowController = AccountFlowController(with: self, navigationController: accountNC) 90 | return accountFlowController.beginResetPasswordFlow(from: tabBarController, token: token, animated: animated) 91 | } 92 | 93 | @discardableResult 94 | func goToRestaurantsSection() -> Future { 95 | tabBarController.selectedIndex = 0 96 | return Future.future(withResult: true) 97 | } 98 | 99 | @discardableResult 100 | func goToOrdersSection() -> Future { 101 | tabBarController.selectedIndex = 2 102 | return Future.future(withResult: true) 103 | } 104 | 105 | @discardableResult 106 | func goToSettingsSection() -> Future { 107 | tabBarController.selectedIndex = 3 108 | return Future.future(withResult: true) 109 | } 110 | } 111 | 112 | extension RootFlowController: OrdersFlowControllerDelegate { 113 | 114 | func ordersFlowController(_ flowController: OrdersFlowController, didRequestGoingToRestaurant restaurantId: String) -> Future { 115 | return goToRestaurantsSection().then { future in 116 | self.restaurantsFlowController.goBackToRoot(animated: false) 117 | }.then { future in 118 | self.restaurantsFlowController.goToRestaurant(restaurantId: restaurantId, postcode: nil, serviceType: nil, section: nil, animated: false) 119 | } 120 | } 121 | } 122 | 123 | extension RootFlowController: RestaurantsFlowControllerDelegate { 124 | 125 | func restaurantsFlowController(_ flowController: RestaurantsFlowController, didRequestGoingToOrder orderId: OrderId) -> Future { 126 | return dismissAndPopToRootAll(animated: false).then { future in 127 | return self.goToOrdersSection() 128 | }.then { future in 129 | self.ordersFlowController.goToOrder(orderId: orderId, animated: false) 130 | } 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /NavigationEngineDemo/NavigationEngine/Core/Navigation/NavigationIntentHandler.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IntentHandler.swift 3 | // NavigationEngineDemo 4 | // 5 | // Created by Alberto De Bortoli on 24/11/2018. 6 | // Copyright © 2018 Just Eat. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import Promis 11 | 12 | public class NavigationIntentHandler: NavigationIntentHandling { 13 | 14 | let flowControllerProvider: FlowControllerProvider 15 | let userStatusProvider: UserStatusProviding! 16 | private let navigationTransitionerDataSource: NavigationTransitionerDataSource 17 | var navigationTransitioner: NavigationTransitioner! 18 | 19 | init(flowControllerProvider: FlowControllerProvider, userStatusProvider: UserStatusProviding, navigationTransitionerDataSource: NavigationTransitionerDataSource) { 20 | self.flowControllerProvider = flowControllerProvider 21 | self.userStatusProvider = userStatusProvider 22 | self.navigationTransitionerDataSource = navigationTransitionerDataSource 23 | } 24 | 25 | func handleIntent(_ intent: NavigationIntent) -> Future { 26 | let allowedTransitions: TransitionOptions = { 27 | switch userStatusProvider.userStatus { 28 | case .loggedIn: 29 | return [.basicTransitions, .userLoggedIn] 30 | case .loggedOut: 31 | return [.basicTransitions, .userLoggedOut] 32 | } 33 | }() 34 | 35 | let stateMachine = StateMachine(initialState: StateType.allPoppedToRoot, allowedTransitions: allowedTransitions) 36 | navigationTransitioner = NavigationTransitioner(flowControllerProvider: flowControllerProvider, stateMachine: stateMachine) 37 | navigationTransitioner.dataSource = navigationTransitionerDataSource 38 | 39 | switch intent { 40 | case .goToHome: 41 | return navigationTransitioner.goToRoot(animated: false).thenWithResult { _ -> Future in 42 | self.navigationTransitioner.goToHome(animated: false) 43 | } 44 | case .goToLogin: 45 | return navigationTransitioner.goToRoot(animated: false).thenWithResult { _ -> Future in 46 | self.navigationTransitioner.goToLogin(animated: true) 47 | } 48 | case .goToResetPassword(let token): 49 | return navigationTransitioner.goToRoot(animated: false).thenWithResult { _ -> Future in 50 | self.navigationTransitioner.goToResetPassword(token: token, animated: true) 51 | } 52 | case .goToSearch(let postcode, let cuisine): 53 | return navigationTransitioner.goToRoot(animated: false).thenWithResult { _ -> Future in 54 | self.navigationTransitioner.goToHome(animated: false) 55 | }.thenWithResult { _ -> Future in 56 | self.navigationTransitioner.goFromHomeToSearch(postcode: postcode, cuisine: cuisine, animated: true) 57 | } 58 | case .goToRestaurant(let restaurantId, let postcode, let serviceType, let section): 59 | return navigationTransitioner.goToRoot(animated: false).thenWithResult { _ -> Future in 60 | self.navigationTransitioner.goToHome(animated: false) 61 | }.thenWithResult { _ -> Future in 62 | self.navigationTransitioner.goFromHomeToRestaurant(restaurantId: restaurantId, postcode: postcode, serviceType: serviceType, section: section, animated: true) 63 | } 64 | case .goToReorder(let reorderId): 65 | switch userStatusProvider.userStatus { 66 | case .loggedIn: 67 | return navigationTransitioner.goToRoot(animated: false).thenWithResult { _ -> Future in 68 | self.navigationTransitioner.goToHome(animated: false) 69 | }.thenWithResult { _ -> Future in 70 | self.navigationTransitioner.requestInputForReorder(orderId: reorderId) 71 | }.thenWithResult { reorderInfo -> Future in 72 | self.navigationTransitioner.goFromHomeToRestaurant(restaurantId: reorderInfo.restaurantId, 73 | postcode: reorderInfo.postcode, 74 | serviceType: reorderInfo.serviceType, 75 | section: .menu, 76 | animated: false) 77 | }.thenWithResult { _ -> Future in 78 | self.navigationTransitioner.goFromRestaurantToBasket(reorderId: reorderId, animated: true) 79 | } 80 | case .loggedOut: 81 | return navigationTransitioner.requestUserToLogin().then { future in 82 | switch future.state { 83 | case .result: 84 | return self.handleIntent(intent) // go recursive 85 | default: 86 | return Future.futureWithResolution(of: future) 87 | } 88 | } 89 | } 90 | case .goToOrderHistory: 91 | switch userStatusProvider.userStatus { 92 | case .loggedIn: 93 | return navigationTransitioner.goToRoot(animated: false).thenWithResult { _ -> Future in 94 | self.navigationTransitioner.goToOrderHistory(animated: true) 95 | } 96 | case .loggedOut: 97 | return navigationTransitioner.requestUserToLogin().then { future in 98 | switch future.state { 99 | case .result: 100 | return self.handleIntent(intent) // go recursive 101 | default: 102 | return Future.futureWithResolution(of: future) 103 | } 104 | } 105 | } 106 | case .goToOrderDetails(let orderId): 107 | switch userStatusProvider.userStatus { 108 | case .loggedIn: 109 | return navigationTransitioner.goToRoot(animated: false).thenWithResult { _ -> Future in 110 | self.navigationTransitioner.goToOrderHistory(animated: false) 111 | }.thenWithResult { _ -> Future in 112 | self.navigationTransitioner.goFromOrderHistoryToOrderDetails(orderId: orderId, animated: true) 113 | } 114 | case .loggedOut: 115 | return navigationTransitioner.requestUserToLogin().then { future in 116 | switch future.state { 117 | case .result: 118 | return self.handleIntent(intent) // go recursive 119 | default: 120 | return Future.futureWithResolution(of: future) 121 | } 122 | } 123 | } 124 | case .goToSettings: 125 | return navigationTransitioner.goToRoot(animated: false).thenWithResult { _ -> Future in 126 | self.navigationTransitioner.goToSettings(animated: false) 127 | } 128 | } 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /NavigationEngineDemoTests/Navigation/StateMachineTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StateMachineTests.swift 3 | // NavigationEngineTests 4 | // 5 | // Created by Alberto De Bortoli on 07/02/2019. 6 | // 7 | 8 | import XCTest 9 | import Stateful 10 | @testable import NavigationEngineDemo 11 | 12 | extension Transition: Equatable { 13 | 14 | public static func == (lhs: Transition, rhs: Transition) -> Bool { 15 | return lhs.source as! StateType == rhs.source as! StateType && 16 | lhs.event as! EventType == rhs.event as! EventType 17 | // don't do the equality on the destination since the ST only allows adding one event given a source state 18 | } 19 | } 20 | 21 | class StateMachineTests: XCTestCase { 22 | 23 | func test_onlyBasicTransitionsAreAllowed() { 24 | let options: TransitionOptions = [.basicTransitions] 25 | allowedTransitions(basicTransitions, options: options) 26 | } 27 | 28 | func test_onlyBasicAndUserLoggedInTransitionsAreAllowed() { 29 | let options: TransitionOptions = [.basicTransitions, .userLoggedIn] 30 | allowedTransitions(basicTransitions + userLoggedInTransitions, options: options) 31 | } 32 | 33 | func test_onlyBasicAndUserLoggedOutTransitionsAreAllowed() { 34 | let options: TransitionOptions = [.basicTransitions, .userLoggedOut] 35 | allowedTransitions(basicTransitions + userLoggedOutTransitions, options: options) 36 | } 37 | 38 | private func allowedTransitions(_ transitions: [Transition], options: TransitionOptions) { 39 | for s1 in 0..(with: e, from: state1, to: state2) 46 | let sm = StateMachine(initialState: state1, allowedTransitions: options) 47 | sm.process(event: e, execution: nil) { result in 48 | if transitions.contains(t) { 49 | XCTAssert(result == .success) 50 | } 51 | else { 52 | XCTAssert(result == .failure) 53 | } 54 | } 55 | } 56 | } 57 | } 58 | } 59 | 60 | lazy var basicTransitions: [Transition] = { 61 | var transitions = [Transition]() 62 | transitions.append(Transition(with: .goToHome, 63 | from: .allPoppedToRoot, 64 | to: .home)) 65 | transitions.append(Transition(with: .searchRestaurants, 66 | from: .home, 67 | to: .search)) 68 | transitions.append(Transition(with: .loadRestaurant, 69 | from: .home, 70 | to: .restaurant)) 71 | transitions.append(Transition(with: .loadRestaurant, 72 | from: .search, 73 | to: .restaurant)) 74 | transitions.append(Transition(with: .goToSettings, 75 | from: .allPoppedToRoot, 76 | to: .settings)) 77 | 78 | // make sure we can always go back to root 79 | transitions.append(Transition(with: .popEverything, 80 | from: .allPoppedToRoot, 81 | to: .allPoppedToRoot)) 82 | transitions.append(Transition(with: .popEverything, 83 | from: .home, 84 | to: .allPoppedToRoot)) 85 | transitions.append(Transition(with: .popEverything, 86 | from: .resetPassword, 87 | to: .allPoppedToRoot)) 88 | transitions.append(Transition(with: .popEverything, 89 | from: .search, 90 | to: .allPoppedToRoot)) 91 | transitions.append(Transition(with: .popEverything, 92 | from: .restaurant, 93 | to: .allPoppedToRoot)) 94 | transitions.append(Transition(with: .popEverything, 95 | from: .orderHistory, 96 | to: .allPoppedToRoot)) 97 | transitions.append(Transition(with: .popEverything, 98 | from: .orderDetails, 99 | to: .allPoppedToRoot)) 100 | return transitions 101 | }() 102 | 103 | lazy var userLoggedInTransitions: [Transition] = { 104 | var transitions = [Transition]() 105 | transitions.append(Transition(with: .loadOrderHistory, 106 | from: .allPoppedToRoot, 107 | to: .orderHistory)) 108 | transitions.append(Transition(with: .fillBasket, 109 | from: .restaurant, 110 | to: .basket)) 111 | transitions.append(Transition(with: .goToOrderDetails, 112 | from: .orderHistory, 113 | to: .orderDetails)) 114 | return transitions 115 | }() 116 | 117 | lazy var userLoggedOutTransitions: [Transition] = { 118 | var transitions = [Transition]() 119 | transitions.append(Transition(with: .goToResetPassword, 120 | from: .allPoppedToRoot, 121 | to: .resetPassword)) 122 | transitions.append(Transition(with: .goToLogin, 123 | from: .allPoppedToRoot, 124 | to: .login)) 125 | return transitions 126 | }() 127 | } 128 | -------------------------------------------------------------------------------- /NavigationEngineDemo/NavigationEngine/Core/Parsing/UniversalLinkConverter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GenericURIConverter.swift 3 | // JUSTEAT 4 | // 5 | // Created by Alberto De Bortoli on 13/10/2015. 6 | // Copyright © 2015 JUST EAT. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import CoreLocation 11 | 12 | class UniversalLinkConverter { 13 | 14 | private lazy var deepLinkFactory: DeepLinkFactory = { 15 | return DeepLinkFactory(scheme: settings.internalDeepLinkSchemes.first!, host: settings.internalDeepLinkHost) 16 | }() 17 | 18 | private let settings: DeepLinkingSettingsProtocol 19 | 20 | init(settings: DeepLinkingSettingsProtocol) { 21 | self.settings = settings 22 | } 23 | 24 | func deepLink(forUniversalLink url: UniversalLink) -> DeepLink? { 25 | guard 26 | let components = URLComponents(url: url, resolvingAgainstBaseURL: true), 27 | let pathComponents = components.url?.pathComponents, 28 | let scheme = components.scheme, 29 | let host = components.host, 30 | settings.universalLinkHosts.contains(host), 31 | settings.universalLinkSchemes.contains(scheme) else { return nil } 32 | 33 | let queryItems = components.queryItems 34 | 35 | switch pathComponents.count { 36 | case 4: 37 | switch (pathComponents[1], pathComponents[2], pathComponents[3]) { 38 | case ("area", let postcodeString, let cuisine): 39 | let postcode = self.postcodeFromString(postcodeString) 40 | let location: CLLocationCoordinate2D? = { 41 | if let latitude = queryValue(queryItems, key: "lat"), let longitude = queryValue(queryItems, key: "long"), 42 | let latitudeDoubleValue = Double(latitude), let longitudeDoubleValue = Double(longitude) { 43 | return CLLocationCoordinate2D(latitude: latitudeDoubleValue, longitude: longitudeDoubleValue) 44 | } 45 | return nil 46 | }() 47 | return deepLinkFactory.searchURL(postcode: postcode, cuisine: cuisine, location: location) 48 | case ("account", "order", let orderId): 49 | return deepLinkFactory.orderDetailsURL(orderId: orderId) 50 | default: 51 | return nil 52 | } 53 | case 3: 54 | switch (pathComponents[1], pathComponents[2]) { 55 | case ("pages", "orderstatus.aspx") where queryContainsKey(queryItems, key: "orderId"): 56 | let orderId = queryValue(queryItems, key: "orderId")! 57 | return deepLinkFactory.orderDetailsURL(orderId: orderId) 58 | case ("account", "update-password") where queryContainsKey(queryItems, key: "token"): 59 | let token = queryValue(queryItems, key: "token")! 60 | return deepLinkFactory.updatePasswordURL(token: token) 61 | case ("account", "login"): 62 | return deepLinkFactory.loginURL() 63 | case ("account", "order-history"): 64 | return deepLinkFactory.orderHistoryURL() 65 | case ("orders", let orderId): 66 | return deepLinkFactory.orderDetailsURL(orderId: orderId) 67 | case ("order", let orderId): 68 | return deepLinkFactory.orderDetailsURL(orderId: orderId) 69 | case ("reorder", let orderId): 70 | return deepLinkFactory.reorderURL(orderId: orderId) 71 | case ("area", let postcodeString): 72 | let postcode = self.postcodeFromString(postcodeString) 73 | let cuisine = queryValue(queryItems, key: "cuisine") 74 | let location: CLLocationCoordinate2D? = { 75 | if let latitude = queryValue(queryItems, key: "lat"), let longitude = queryValue(queryItems, key: "long"), 76 | let latitudeDoubleValue = Double(latitude), let longitudeDoubleValue = Double(longitude) { 77 | return CLLocationCoordinate2D(latitude: latitudeDoubleValue, longitude: longitudeDoubleValue) 78 | } 79 | return nil 80 | }() 81 | return deepLinkFactory.searchURL(postcode: postcode, cuisine: cuisine, location: location) 82 | default: 83 | return nil 84 | } 85 | 86 | case 2: 87 | switch pathComponents[1] { 88 | case ("home"): 89 | return deepLinkFactory.homeURL() 90 | case ("login"): 91 | return deepLinkFactory.loginURL() 92 | case ("search") where queryContainsKey(queryItems, key: "postcode"): 93 | let postcode = queryValue(queryItems, key: "postcode")! 94 | let cuisine = queryValue(queryItems, key: "cuisine") 95 | return deepLinkFactory.searchURL(postcode: postcode, cuisine: cuisine, location: nil) 96 | case ("search"): 97 | return deepLinkFactory.searchURL(postcode: nil, cuisine: nil, location: nil) 98 | case ("restaurant") where queryContainsKey(queryItems, key: "restaurantId"): 99 | let restaurantId = queryValue(queryItems, key: "restaurantId")! 100 | let postcode = queryValue(queryItems, key: "postcode") 101 | let serviceType: ServiceType? = { 102 | guard let val = queryValue(queryItems, key: "serviceType") else { return nil } 103 | return ServiceType(rawValue: val) 104 | }() 105 | let section: RestaurantSection? = { 106 | guard let val = queryValue(queryItems, key: "section") else { return nil } 107 | return RestaurantSection(rawValue: val) 108 | }() 109 | return deepLinkFactory.restaurantURL(restaurantId: restaurantId, postcode: postcode, serviceType: serviceType, section: section) 110 | case ("resetPassword") where queryContainsKey(queryItems, key: "resetToken"): 111 | let token = queryValue(queryItems, key: "resetToken")! 112 | return deepLinkFactory.updatePasswordURL(token: token) 113 | case ("orders") where queryContainsKey(queryItems, key: "orderId"): 114 | let orderId = queryValue(queryItems, key: "orderId")! 115 | return deepLinkFactory.orderDetailsURL(orderId: orderId) 116 | case ("orders"): 117 | return deepLinkFactory.orderHistoryURL() 118 | case ("order-history"): 119 | return deepLinkFactory.orderHistoryURL() 120 | case ("order") where queryContainsKey(queryItems, key: "orderId"): 121 | let orderId = queryValue(queryItems, key: "orderId")! 122 | return deepLinkFactory.orderDetailsURL(orderId: orderId) 123 | case ("reorder") where queryContainsKey(queryItems, key: "orderId"): 124 | let orderId = queryValue(queryItems, key: "orderId")! 125 | return deepLinkFactory.reorderURL(orderId: orderId) 126 | default: 127 | return nil 128 | } 129 | 130 | case 1: 131 | return deepLinkFactory.homeURL() 132 | 133 | default: 134 | return deepLinkFactory.homeURL() 135 | } 136 | } 137 | 138 | fileprivate func queryContainsKey(_ items: [URLQueryItem]?, key: String) -> Bool { 139 | guard let queryItems = items else { return false } 140 | return queryItems.filter({ $0.name.caseInsensitiveCompare(key) == .orderedSame }).count > 0 141 | } 142 | 143 | fileprivate func queryValue(_ items: [URLQueryItem]?, key: String) -> String? { 144 | guard let queryItems = items else { return nil } 145 | return queryItems.filter({ $0.name.caseInsensitiveCompare(key) == .orderedSame }).first?.value 146 | } 147 | 148 | fileprivate func postcodeFromString(_ string: String) -> String { 149 | let areaComponents = string.components(separatedBy: "-") 150 | 151 | if areaComponents.count > 0 { 152 | return areaComponents.first! 153 | } 154 | 155 | return string 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /NavigationEngineDemoTests/Navigation/NavigationIntentHandlerTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NavigationIntentHandlerTests.swift 3 | // NavigationEngineTests 4 | // 5 | // Created by Alberto De Bortoli on 07/02/2019. 6 | // 7 | 8 | import XCTest 9 | @testable import NavigationEngineDemo 10 | 11 | class NavigationIntentHandlerTests: XCTestCase { 12 | 13 | private let timeout = 3.0 14 | private var navigationIntentHandler: NavigationIntentHandler! 15 | 16 | func setUp(userStatus: UserStatus, dataSourceShouldSucceed: Bool = true) { 17 | let rootFlowController = MockRootFlowController() 18 | rootFlowController.setup() 19 | let flowControllerProvider = FlowControllerProvider(rootFlowController: rootFlowController) 20 | let userStatusProvider = MockUserStatusProvider(userStatus: userStatus) 21 | let navigationTransitionerDataSource = MockNavigationTransitionerDataSource(shouldSucceed: dataSourceShouldSucceed, userStatusProvider: userStatusProvider) 22 | navigationIntentHandler = NavigationIntentHandler(flowControllerProvider: flowControllerProvider, 23 | userStatusProvider: userStatusProvider, 24 | navigationTransitionerDataSource: navigationTransitionerDataSource) 25 | } 26 | 27 | func test_goToHome() { 28 | wait(for: [ 29 | goToHome(status: .loggedOut, shouldSucceed: true), 30 | goToHome(status: .loggedIn, shouldSucceed: true) 31 | ], timeout: timeout) 32 | } 33 | 34 | func test_goToLogin() { 35 | wait(for: [ 36 | goToLogin(status: .loggedOut, shouldSucceed: true), 37 | goToLogin(status: .loggedIn, shouldSucceed: false) 38 | ], timeout: timeout) 39 | } 40 | 41 | func test_goToResetPassword() { 42 | wait(for: [ 43 | goToResetPassword(status: .loggedOut, shouldSucceed: true), 44 | goToResetPassword(status: .loggedIn, shouldSucceed: false) 45 | ], timeout: timeout) 46 | } 47 | 48 | func test_goToSearch() { 49 | wait(for: [ 50 | goToSearch(status: .loggedOut, shouldSucceed: true), 51 | goToSearch(status: .loggedIn, shouldSucceed: true) 52 | ], timeout: timeout) 53 | } 54 | 55 | func test_goToRestaurant() { 56 | wait(for: [ 57 | goToRestaurant(status: .loggedOut, shouldSucceed: true), 58 | goToRestaurant(status: .loggedIn, shouldSucceed: true) 59 | ], timeout: timeout) 60 | } 61 | 62 | func test_goToOrderHistory() { 63 | wait(for: [ 64 | goToOrderHistory(status: .loggedOut, shouldSucceed: false, dataSourceShouldSucceed: false), 65 | goToOrderHistory(status: .loggedOut, shouldSucceed: true, dataSourceShouldSucceed: true), 66 | goToOrderHistory(status: .loggedIn, shouldSucceed: true, dataSourceShouldSucceed: true) 67 | ], timeout: timeout) 68 | } 69 | 70 | func test_goToOrderDetails() { 71 | wait(for: [ 72 | goToOrderDetails(status: .loggedOut, shouldSucceed: false, dataSourceShouldSucceed: false), 73 | goToOrderDetails(status: .loggedOut, shouldSucceed: true, dataSourceShouldSucceed: true), 74 | goToOrderDetails(status: .loggedIn, shouldSucceed: true, dataSourceShouldSucceed: true) 75 | ], timeout: timeout) 76 | } 77 | 78 | func test_goToSettings() { 79 | wait(for: [ 80 | goToSettings(status: .loggedOut, shouldSucceed: true), 81 | goToSettings(status: .loggedIn, shouldSucceed: true) 82 | ], timeout: timeout) 83 | } 84 | 85 | func test_goToReorder() { 86 | wait(for: [ 87 | goToReorder(status: .loggedOut, shouldSucceed: false, dataSourceShouldSucceed: false), 88 | goToReorder(status: .loggedOut, shouldSucceed: true, dataSourceShouldSucceed: true), 89 | goToReorder(status: .loggedIn, shouldSucceed: true, dataSourceShouldSucceed: true) 90 | ], timeout: timeout) 91 | } 92 | } 93 | 94 | extension NavigationIntentHandlerTests { 95 | 96 | fileprivate func goToHome(status: UserStatus, shouldSucceed: Bool) -> XCTestExpectation { 97 | setUp(userStatus: status) 98 | let expectation = self.expectation(description: #function) 99 | navigationIntentHandler.handleIntent(.goToHome).finally { future in 100 | XCTAssert(shouldSucceed ? future.hasResult() : future.hasError()) 101 | expectation.fulfill() 102 | } 103 | return expectation 104 | } 105 | 106 | fileprivate func goToLogin(status: UserStatus, shouldSucceed: Bool) -> XCTestExpectation { 107 | setUp(userStatus: status) 108 | let expectation = self.expectation(description: #function) 109 | navigationIntentHandler.handleIntent(.goToLogin).finally { future in 110 | XCTAssert(shouldSucceed ? future.hasResult() : future.hasError()) 111 | expectation.fulfill() 112 | } 113 | return expectation 114 | } 115 | 116 | fileprivate func goToResetPassword(status: UserStatus, shouldSucceed: Bool) -> XCTestExpectation { 117 | setUp(userStatus: status) 118 | let expectation = self.expectation(description: #function) 119 | navigationIntentHandler.handleIntent(.goToResetPassword("")).finally { future in 120 | XCTAssert(shouldSucceed ? future.hasResult() : future.hasError()) 121 | expectation.fulfill() 122 | } 123 | return expectation 124 | } 125 | 126 | fileprivate func goToSearch(status: UserStatus, shouldSucceed: Bool) -> XCTestExpectation { 127 | setUp(userStatus: status) 128 | let expectation = self.expectation(description: #function) 129 | navigationIntentHandler.handleIntent(.goToSearch(nil, nil)).finally { future in 130 | XCTAssert(shouldSucceed ? future.hasResult() : future.hasError()) 131 | expectation.fulfill() 132 | } 133 | return expectation 134 | } 135 | 136 | fileprivate func goToRestaurant(status: UserStatus, shouldSucceed: Bool) -> XCTestExpectation { 137 | setUp(userStatus: status) 138 | let expectation = self.expectation(description: #function) 139 | navigationIntentHandler.handleIntent(.goToRestaurant("", nil, nil, nil)).finally { future in 140 | XCTAssert(shouldSucceed ? future.hasResult() : future.hasError()) 141 | expectation.fulfill() 142 | } 143 | return expectation 144 | } 145 | 146 | fileprivate func goToReorder(status: UserStatus, shouldSucceed: Bool, dataSourceShouldSucceed: Bool) -> XCTestExpectation { 147 | setUp(userStatus: status, dataSourceShouldSucceed: dataSourceShouldSucceed) 148 | let expectation = self.expectation(description: #function) 149 | navigationIntentHandler.handleIntent(.goToReorder("")).finally { future in 150 | XCTAssert(shouldSucceed ? future.hasResult() : future.hasError()) 151 | expectation.fulfill() 152 | } 153 | return expectation 154 | } 155 | 156 | fileprivate func goToOrderHistory(status: UserStatus, shouldSucceed: Bool, dataSourceShouldSucceed: Bool) -> XCTestExpectation { 157 | setUp(userStatus: status, dataSourceShouldSucceed: dataSourceShouldSucceed) 158 | let expectation = self.expectation(description: #function) 159 | navigationIntentHandler.handleIntent(.goToOrderHistory).finally { future in 160 | XCTAssert(shouldSucceed ? future.hasResult() : future.hasError()) 161 | expectation.fulfill() 162 | } 163 | return expectation 164 | } 165 | 166 | fileprivate func goToOrderDetails(status: UserStatus, shouldSucceed: Bool, dataSourceShouldSucceed: Bool) -> XCTestExpectation { 167 | setUp(userStatus: status, dataSourceShouldSucceed: dataSourceShouldSucceed) 168 | let expectation = self.expectation(description: #function) 169 | navigationIntentHandler.handleIntent(.goToOrderDetails("")).finally { future in 170 | XCTAssert(shouldSucceed ? future.hasResult() : future.hasError()) 171 | expectation.fulfill() 172 | } 173 | return expectation 174 | } 175 | 176 | fileprivate func goToSettings(status: UserStatus, shouldSucceed: Bool) -> XCTestExpectation { 177 | setUp(userStatus: status) 178 | let expectation = self.expectation(description: #function) 179 | navigationIntentHandler.handleIntent(.goToSettings).finally { future in 180 | XCTAssert(shouldSucceed ? future.hasResult() : future.hasError()) 181 | expectation.fulfill() 182 | } 183 | return expectation 184 | } 185 | } 186 | -------------------------------------------------------------------------------- /NavigationEngineDemoTests/Utilities/MockFlowControllers.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MockFlowControllers.swift 3 | // NavigationEngine_Example 4 | // 5 | // Created by Alberto De Bortoli on 23/11/2018. 6 | // Copyright © 2018 Just Eat. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import NavigationEngineDemo 11 | import Promis 12 | 13 | class MockRestaurantsFlowController: RestaurantsFlowControllerProtocol { 14 | 15 | fileprivate let navigationController: UINavigationController 16 | 17 | var checkoutFlowController: CheckoutFlowControllerProtocol! 18 | var parentFlowController: RootFlowControllerProtocol! 19 | 20 | init(with parentFlowController: RootFlowControllerProtocol, navigationController: UINavigationController) { 21 | self.parentFlowController = parentFlowController 22 | self.navigationController = navigationController 23 | checkoutFlowController = MockCheckoutFlowController(with: self, navigationController: UINavigationController()) 24 | } 25 | 26 | @discardableResult 27 | func dismiss(animated: Bool) -> Future { 28 | return Future.future(withResult: true) 29 | } 30 | 31 | @discardableResult 32 | func goBackToRoot(animated: Bool) -> Future { 33 | return Future.future(withResult: true) 34 | } 35 | 36 | @discardableResult 37 | func goToSearchAnimated(postcode: Postcode?, cuisine: Cuisine?, animated: Bool) -> Future { 38 | return Future.future(withResult: true) 39 | } 40 | 41 | @discardableResult 42 | func goToRestaurant(restaurantId: RestaurantId, postcode: Postcode?, serviceType: ServiceType?, section: RestaurantSection?, animated: Bool) -> Future { 43 | return Future.future(withResult: true) 44 | } 45 | 46 | @discardableResult 47 | func goToBasket(reorderId: ReorderId, animated: Bool) -> Future { 48 | return Future.future(withResult: true) 49 | } 50 | 51 | @discardableResult 52 | func goToCheckout(animated: Bool) -> Future { 53 | return Future.future(withResult: true) 54 | } 55 | } 56 | 57 | class MockOrdersFlowController: OrdersFlowControllerProtocol { 58 | 59 | private let navigationController: UINavigationController 60 | var parentFlowController: RootFlowControllerProtocol! 61 | 62 | init(with parentFlowController: RootFlowControllerProtocol, navigationController: UINavigationController) { 63 | self.parentFlowController = parentFlowController 64 | self.navigationController = navigationController 65 | } 66 | 67 | @discardableResult 68 | func dismiss(animated: Bool) -> Future { 69 | return Future.future(withResult: true) 70 | } 71 | 72 | @discardableResult 73 | func goBackToRoot(animated: Bool) -> Future { 74 | return Future.future(withResult: true) 75 | } 76 | 77 | @discardableResult 78 | func goToOrder(orderId: OrderId, animated: Bool) -> Future { 79 | return Future.future(withResult: true) 80 | } 81 | 82 | @discardableResult 83 | func goToRestaurant(restaurantId: String) -> Future { 84 | return Future.future(withResult: true) 85 | } 86 | 87 | @discardableResult 88 | func leaveFlow(animated: Bool) -> Future { 89 | return Future.future(withResult: true) 90 | } 91 | } 92 | 93 | class MockCheckoutFlowController: CheckoutFlowControllerProtocol { 94 | 95 | private let navigationController: UINavigationController 96 | var parentFlowController: RestaurantsFlowControllerProtocol! 97 | 98 | init(with parentFlowController: RestaurantsFlowControllerProtocol, navigationController: UINavigationController) { 99 | self.parentFlowController = parentFlowController 100 | self.navigationController = navigationController 101 | } 102 | 103 | @discardableResult 104 | func beginFlow(from viewController: UIViewController, animated: Bool) -> Future { 105 | return Future.future(withResult: true) 106 | } 107 | 108 | @discardableResult 109 | func proceedToPayment(animated: Bool) -> Future { 110 | return Future.future(withResult: true) 111 | } 112 | 113 | @discardableResult 114 | func goToOrderConfirmation(orderId: String) -> Future { 115 | return Future.future(withResult: true) 116 | } 117 | 118 | @discardableResult 119 | func leaveFlow(animated: Bool) -> Future { 120 | return Future.future(withResult: true) 121 | } 122 | 123 | @discardableResult 124 | func leaveFlowAndGoBackToSERP() -> Future { 125 | return Future.future(withResult: true) 126 | } 127 | } 128 | 129 | class MockSettingsFlowController: SettingsFlowControllerProtocol { 130 | 131 | private let navigationController: UINavigationController 132 | var parentFlowController: RootFlowControllerProtocol! 133 | 134 | init(with parentFlowController: RootFlowControllerProtocol, navigationController: UINavigationController) { 135 | self.parentFlowController = parentFlowController 136 | self.navigationController = navigationController 137 | } 138 | 139 | @discardableResult 140 | func dismiss(animated: Bool) -> Future { 141 | return Future.future(withResult: true) 142 | } 143 | 144 | @discardableResult 145 | func goBackToRoot(animated: Bool) -> Future { 146 | return Future.future(withResult: true) 147 | } 148 | } 149 | 150 | class MockAccountFlowController: AccountFlowControllerProtocol { 151 | 152 | private let navigationController: UINavigationController 153 | var parentFlowController: RootFlowControllerProtocol! 154 | 155 | init(with parentFlowController: RootFlowControllerProtocol, navigationController: UINavigationController) { 156 | self.parentFlowController = parentFlowController 157 | self.navigationController = navigationController 158 | } 159 | 160 | @discardableResult 161 | func beginLoginFlow(from viewController: UIViewController, animated: Bool) -> Future { 162 | return Future.future(withResult: true) 163 | } 164 | 165 | @discardableResult 166 | func waitForUserToLogin(from viewController: UIViewController, animated: Bool) -> Future { 167 | return Future.future(withResult: true) 168 | } 169 | 170 | func beginResetPasswordFlow(from viewController: UIViewController, token: ResetPasswordToken, animated: Bool) -> Future { 171 | return Future.future(withResult: true) 172 | } 173 | 174 | @discardableResult 175 | func leaveFlow(animated: Bool) -> Future { 176 | return Future.future(withResult: true) 177 | } 178 | } 179 | 180 | class MockRootFlowController: RootFlowControllerProtocol { 181 | 182 | private let tabBarController = UITabBarController() 183 | 184 | var restaurantsFlowController: RestaurantsFlowControllerProtocol! 185 | var ordersFlowController: OrdersFlowControllerProtocol! 186 | var settingsFlowController: SettingsFlowControllerProtocol! 187 | 188 | var accountFlowController: AccountFlowControllerProtocol! 189 | 190 | func setup() { 191 | let takeawayNavigationController = UINavigationController() 192 | let restaurantsFlowController = MockRestaurantsFlowController(with: self, navigationController: takeawayNavigationController) 193 | self.restaurantsFlowController = restaurantsFlowController 194 | 195 | let ordersNavigationController = UINavigationController() 196 | let ordersFlowController = MockOrdersFlowController(with: self, navigationController: ordersNavigationController) 197 | self.ordersFlowController = ordersFlowController 198 | 199 | let settingsNavigationController = UINavigationController() 200 | let settingsFlowController = MockSettingsFlowController(with: self, navigationController: settingsNavigationController) 201 | self.settingsFlowController = settingsFlowController 202 | } 203 | 204 | func dismissAll(animated: Bool) -> Future { 205 | return Future.future(withResult: true) 206 | } 207 | 208 | func dismissAndPopToRootAll(animated: Bool) -> Future { 209 | return Future.future(withResult: true) 210 | } 211 | 212 | func goToLogin(animated: Bool) -> Future { 213 | return Future.future(withResult: true) 214 | } 215 | 216 | func goToResetPassword(token: ResetPasswordToken, animated: Bool) -> Future { 217 | return Future.future(withResult: true) 218 | } 219 | 220 | func goToRestaurantsSection() -> Future { 221 | return Future.future(withResult: true) 222 | } 223 | 224 | func goToOrdersSection() -> Future { 225 | return Future.future(withResult: true) 226 | } 227 | 228 | func goToSettingsSection() -> Future { 229 | return Future.future(withResult: true) 230 | } 231 | } 232 | -------------------------------------------------------------------------------- /NavigationEngineDemo/DemoApp/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // NavigationEngine 4 | // 5 | // Created by Alberto De Bortoli on 25/12/2018. 6 | // Copyright (c) 2018 Just Eat. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import Promis 11 | import CoreSpotlight 12 | import MobileCoreServices 13 | 14 | @UIApplicationMain 15 | class AppDelegate: UIResponder, UIApplicationDelegate { 16 | 17 | var window: UIWindow? 18 | var userInputController: UserInputController! 19 | var rootFlowController: RootFlowController! 20 | var deepLinkingFacade: DeepLinkingFacade! 21 | 22 | var userStatusProvider = UserStatusProvider() 23 | let deepLinkingSettings = DeepLinkingSettings() 24 | 25 | func applicationDidFinishLaunching(_ application: UIApplication) { 26 | 27 | // Init UI Stack 28 | let window = UIWindow(frame: UIScreen.main.bounds) 29 | let tabBarController = TabBarController.instantiate() 30 | 31 | // Root Flow Controller 32 | rootFlowController = RootFlowController(with: tabBarController) 33 | tabBarController.flowController = rootFlowController 34 | 35 | userStatusProvider.userStatus = .loggedOut 36 | 37 | // DeepLinking core 38 | let flowControllerProvider = FlowControllerProvider(rootFlowController: rootFlowController) 39 | deepLinkingFacade = DeepLinkingFacade(flowControllerProvider: flowControllerProvider, 40 | navigationTransitionerDataSource: self, 41 | settings: deepLinkingSettings, 42 | userStatusProvider: userStatusProvider) 43 | userInputController = UserInputController(userStatusProvider: userStatusProvider) 44 | 45 | updateShortcutItems() 46 | addSpotlightItem() 47 | 48 | // Complete UI Stack 49 | window.rootViewController = tabBarController 50 | window.makeKeyAndVisible() 51 | self.window = window 52 | 53 | setupDeepLinkingTesterViewController() 54 | } 55 | 56 | func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any] = [:]) -> Bool { 57 | // from internal deepLinks & TodayExtension 58 | deepLinkingFacade.openDeepLink(url).finally { future in 59 | self.handleFuture(future, originalInput: url.absoluteString) 60 | } 61 | return true 62 | } 63 | 64 | func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool { 65 | 66 | switch userActivity.activityType { 67 | // from Safari 68 | case NSUserActivityTypeBrowsingWeb: 69 | if let webpageURL = userActivity.webpageURL { 70 | self.deepLinkingFacade.handleURL(webpageURL).finally { future in 71 | self.handleFuture(future, originalInput: webpageURL.absoluteString) 72 | } 73 | return true 74 | } 75 | return false 76 | 77 | // from Spotlight 78 | case CSSearchableItemActionType: 79 | self.deepLinkingFacade.openSpotlightItem(userActivity).finally { future in 80 | let input = userActivity.userInfo![CSSearchableItemActivityIdentifier] as! String 81 | self.handleFuture(future, originalInput: input) 82 | } 83 | return true 84 | 85 | default: 86 | return false 87 | } 88 | } 89 | 90 | func application(_ application: UIApplication, performActionFor shortcutItem: UIApplicationShortcutItem, completionHandler: @escaping (Bool) -> Void) { 91 | // from shortcut items 92 | deepLinkingFacade.openShortcutItem(shortcutItem).finally { future in 93 | let input = shortcutItem.type 94 | self.handleFuture(future, originalInput: input) 95 | completionHandler(future.hasResult()) 96 | } 97 | } 98 | } 99 | 100 | extension AppDelegate { 101 | 102 | func handleFuture(_ future: Future, originalInput input: String) { 103 | switch future.state { 104 | case .result: 105 | print("[DeepLinkingFacade] ✅ Executed deepLinking with input: '\(input)'") 106 | self.showAlert(title: "✅ Navigation Engine", message: "Executed deepLinking with input:\n'\(input)'") 107 | case .error(let error): 108 | print("[DeepLinkingFacade] ❌ Failed deepLinking with input: '\(input)' with error:\n\(error)") 109 | self.showAlert(title: "❌ Navigation Engine", message: "Failed deepLinking with input:\n'\(input)'\nerror: \(error)") 110 | default: 111 | print("[DeepLinkingFacade] ❌ Failed deepLinking with input: '\(input)'") 112 | self.showAlert(title: "❌ Navigation Engine", message: "Failed deepLinking with input:\n'\(input)'") 113 | } 114 | } 115 | } 116 | 117 | extension AppDelegate: NavigationTransitionerDataSource { 118 | 119 | func navigationTransitionerDidRequestInputForReorder(orderId: OrderId) -> Future { 120 | return userInputController.gatherReorderUserInput() 121 | } 122 | 123 | func navigationTransitionerDidRequestUserToLogin() -> Future { 124 | return userInputController.promptUserForLogin() 125 | } 126 | } 127 | 128 | extension AppDelegate { 129 | 130 | func updateShortcutItems() { 131 | guard UIScreen.main.traitCollection.forceTouchCapability == .available else { return } 132 | 133 | UIApplication.shared.shortcutItems = { 134 | let item1 = UIMutableApplicationShortcutItem(type: "/search", localizedTitle: "Search") 135 | let item2 = UIMutableApplicationShortcutItem(type: "/orderHistory", localizedTitle: "Orders") 136 | let item3 = UIMutableApplicationShortcutItem(type: "/reorder/123", localizedTitle: "Reorder") 137 | let item4 = UIMutableApplicationShortcutItem(type: "unknown", localizedTitle: "Unknown") 138 | return [item1, item2, item3, item4] 139 | }() 140 | } 141 | 142 | func addSpotlightItem() { 143 | guard CSSearchableIndex.isIndexingAvailable() else { return } 144 | 145 | let domainIdentifier = "com.justeat.AppDelegate" 146 | 147 | // Order 148 | let attributeSet1 = CSSearchableItemAttributeSet(itemContentType: kUTTypeContent as String) 149 | attributeSet1.title = "Test order 123" 150 | 151 | attributeSet1.displayName = "Go to order details" 152 | attributeSet1.contentDescription = "Some description" 153 | attributeSet1.domainIdentifier = domainIdentifier 154 | 155 | attributeSet1.relatedUniqueIdentifier = "123" 156 | 157 | attributeSet1.path = "/orderDetails/123" 158 | attributeSet1.keywords = ["test order", "navigation engine"] 159 | let item1 = CSSearchableItem(uniqueIdentifier: "/orderDetails/123", domainIdentifier: domainIdentifier, attributeSet: attributeSet1) 160 | 161 | // Restaurant 162 | let attributeSet2 = CSSearchableItemAttributeSet(itemContentType: kUTTypeContent as String) 163 | attributeSet2.title = "Test restaurant 456" 164 | 165 | attributeSet2.displayName = "Go to restaurant details" 166 | attributeSet2.contentDescription = "Some description" 167 | attributeSet2.domainIdentifier = domainIdentifier 168 | 169 | attributeSet2.relatedUniqueIdentifier = "456" 170 | 171 | attributeSet2.path = "/restaurant/456" 172 | attributeSet2.keywords = ["test restaurant", "navigation engine"] 173 | let item2 = CSSearchableItem(uniqueIdentifier: "/restaurant/456", domainIdentifier: domainIdentifier, attributeSet: attributeSet2) 174 | 175 | // Invalid 176 | let attributeSet3 = CSSearchableItemAttributeSet(itemContentType: kUTTypeContent as String) 177 | attributeSet3.title = "Invalid" 178 | attributeSet3.path = "gibberish" 179 | attributeSet3.keywords = ["test order", "navigation engine"] 180 | attributeSet3.domainIdentifier = domainIdentifier 181 | let item3 = CSSearchableItem(uniqueIdentifier: "unknown", domainIdentifier: domainIdentifier, attributeSet: attributeSet3) 182 | 183 | CSSearchableIndex.default().indexSearchableItems([item1, item2, item3]) { _ in } 184 | } 185 | 186 | func showAlert(title: String, message: String) { 187 | let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) 188 | let okAction = UIAlertAction(title: "OK", style: .default, handler: nil) 189 | alert.addAction(okAction) 190 | window?.rootViewController?.show(alert, sender: nil) 191 | } 192 | } 193 | 194 | extension AppDelegate { 195 | 196 | func setupDeepLinkingTesterViewController() { 197 | let tabBarController = window!.rootViewController as! TabBarController 198 | let deepLinkingTesterViewController = tabBarController.viewControllers![3] as! DeepLinkingTesterViewController 199 | deepLinkingTesterViewController.delegate = self 200 | let path = Bundle.main.path(forResource: "deeplinking_test_list", ofType: "json")! 201 | deepLinkingTesterViewController.loadTestLinks(atPath: path) 202 | } 203 | } 204 | 205 | extension AppDelegate: DeepLinkingTesterViewControllerDelegate { 206 | 207 | func deepLinkingTesterViewController(_ deepLinkingTesterViewController: DeepLinkingTesterViewController, didSelect url: URL) { 208 | // to explicitly test the Universal Link appDelegate callback 209 | // this assumes the apple-app-site-association is reachable at the root of the website 210 | // and the app has configured associated domains in capabilities 211 | // if url.scheme! == "http" || url.scheme! == "https" { 212 | // UIApplication.shared.open(url, options: [:], completionHandler: nil) 213 | // } 214 | 215 | // DeepLinkg 216 | self.deepLinkingFacade.handleURL(url).finally { future in 217 | self.handleFuture(future, originalInput: url.absoluteString) 218 | } 219 | } 220 | } 221 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2019 Just Eat Holding Ltd 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /NavigationEngineDemoTests/Navigation/NavigationIntentFactoryTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NavigationIntentFactoryTests.swift 3 | // NavigationEngineTests 4 | // 5 | // Created by Alberto De Bortoli on 07/02/2019. 6 | // 7 | 8 | import XCTest 9 | @testable import NavigationEngineDemo 10 | 11 | class NavigationIntentFactoryTests: XCTestCase { 12 | 13 | private let navigationIntentFactory = NavigationIntentFactory() 14 | 15 | func test_home() { 16 | let deepLink = DeepLink(string: "je-internal://just-eat.com/home")! 17 | let result = navigationIntentFactory.intent(for: deepLink) 18 | switch result { 19 | case .result(let intent): 20 | let target = NavigationIntent.goToHome 21 | XCTAssertEqual(intent, target) 22 | default: 23 | XCTFail("Factory should have produced an intent") 24 | } 25 | } 26 | 27 | func test_login1() { 28 | let deepLink = DeepLink(string: "je-internal://just-eat.com/login")! 29 | let result = navigationIntentFactory.intent(for: deepLink) 30 | switch result { 31 | case .result(let intent): 32 | let target = NavigationIntent.goToLogin 33 | XCTAssertEqual(intent, target) 34 | default: 35 | XCTFail("Factory should have produced an intent") 36 | } 37 | } 38 | 39 | func test_login2() { 40 | let deepLink = DeepLink(string: "je-internal://just-eat.com/account/login")! 41 | let result = navigationIntentFactory.intent(for: deepLink) 42 | switch result { 43 | case .result(let intent): 44 | let target = NavigationIntent.goToLogin 45 | XCTAssertEqual(intent, target) 46 | default: 47 | XCTFail("Factory should have produced an intent") 48 | } 49 | } 50 | 51 | func test_resetPassword() { 52 | let deepLink = DeepLink(string: "je-internal://just-eat.com/resetPassword?resetToken=123")! 53 | let result = navigationIntentFactory.intent(for: deepLink) 54 | switch result { 55 | case .result(let intent): 56 | let target = NavigationIntent.goToResetPassword("123") 57 | XCTAssertEqual(intent, target) 58 | default: 59 | XCTFail("Factory should have produced an intent") 60 | } 61 | } 62 | 63 | func test_serp() { 64 | let deepLink = DeepLink(string: "je-internal://just-eat.com/search")! 65 | let result = navigationIntentFactory.intent(for: deepLink) 66 | switch result { 67 | case .result(let intent): 68 | let target = NavigationIntent.goToSearch(nil, nil) 69 | XCTAssertEqual(intent, target) 70 | default: 71 | XCTFail("Factory should have produced an intent") 72 | } 73 | } 74 | 75 | func test_serpWithPostcode() { 76 | let deepLink = DeepLink(string: "je-internal://just-eat.com/search?postcode=EC4M7RF")! 77 | let result = navigationIntentFactory.intent(for: deepLink) 78 | switch result { 79 | case .result(let intent): 80 | let target = NavigationIntent.goToSearch("EC4M7RF", nil) 81 | XCTAssertEqual(intent, target) 82 | default: 83 | XCTFail("Factory should have produced an intent") 84 | } 85 | } 86 | 87 | func test_serpWithPostcodeAndCuisine() { 88 | let deepLink = DeepLink(string: "je-internal://just-eat.com/search?postcode=EC4M7RF&cuisine=italian")! 89 | let result = navigationIntentFactory.intent(for: deepLink) 90 | switch result { 91 | case .result(let intent): 92 | let target = NavigationIntent.goToSearch("EC4M7RF", "italian") 93 | XCTAssertEqual(intent, target) 94 | default: 95 | XCTFail("Factory should have produced an intent") 96 | } 97 | } 98 | 99 | func test_restaurant() { 100 | let deepLink = DeepLink(string: "je-internal://just-eat.com/restaurant?restaurantId=123")! 101 | let result = navigationIntentFactory.intent(for: deepLink) 102 | switch result { 103 | case .result(let intent): 104 | let target = NavigationIntent.goToRestaurant("123", nil, nil, nil) 105 | XCTAssertEqual(intent, target) 106 | default: 107 | XCTFail("Factory should have produced an intent") 108 | } 109 | } 110 | 111 | func test_restaurantWithPostcode() { 112 | let deepLink = DeepLink(string: "je-internal://just-eat.com/restaurant?restaurantId=123&postcode=EC4M7RF")! 113 | let result = navigationIntentFactory.intent(for: deepLink) 114 | switch result { 115 | case .result(let intent): 116 | let target = NavigationIntent.goToRestaurant("123", "EC4M7RF", nil, nil) 117 | XCTAssertEqual(intent, target) 118 | default: 119 | XCTFail("Factory should have produced an intent") 120 | } 121 | } 122 | 123 | func test_restaurantWithPostcodeAndServiceType() { 124 | let deepLink = DeepLink(string: "je-internal://just-eat.com/restaurant?restaurantId=123&postcode=EC4M7RF&serviceType=delivery")! 125 | let result = navigationIntentFactory.intent(for: deepLink) 126 | switch result { 127 | case .result(let intent): 128 | let target = NavigationIntent.goToRestaurant("123", "EC4M7RF", .delivery, nil) 129 | XCTAssertEqual(intent, target) 130 | default: 131 | XCTFail("Factory should have produced an intent") 132 | } 133 | } 134 | 135 | func test_restaurantWithPostcodeAndServiceTypeAndSection() { 136 | let deepLink = DeepLink(string: "je-internal://just-eat.com/restaurant?restaurantId=123&postcode=EC4M7RF&serviceType=delivery§ion=menu")! 137 | let result = navigationIntentFactory.intent(for: deepLink) 138 | switch result { 139 | case .result(let intent): 140 | let target = NavigationIntent.goToRestaurant("123", "EC4M7RF", .delivery, .menu) 141 | XCTAssertEqual(intent, target) 142 | default: 143 | XCTFail("Factory should have produced an intent") 144 | } 145 | } 146 | 147 | func test_orderHistory1() { 148 | let deepLink = DeepLink(string: "je-internal://just-eat.com/orderHistory")! 149 | let result = navigationIntentFactory.intent(for: deepLink) 150 | switch result { 151 | case .result(let intent): 152 | let target = NavigationIntent.goToOrderHistory 153 | XCTAssertEqual(intent, target) 154 | default: 155 | XCTFail("Factory should have produced an intent") 156 | } 157 | } 158 | 159 | func test_orderHistory2() { 160 | let deepLink = DeepLink(string: "je-internal://just-eat.com/order-history")! 161 | let result = navigationIntentFactory.intent(for: deepLink) 162 | switch result { 163 | case .result(let intent): 164 | let target = NavigationIntent.goToOrderHistory 165 | XCTAssertEqual(intent, target) 166 | default: 167 | XCTFail("Factory should have produced an intent") 168 | } 169 | } 170 | 171 | func test_orderHistory3() { 172 | let deepLink = DeepLink(string: "je-internal://just-eat.com/orders")! 173 | let result = navigationIntentFactory.intent(for: deepLink) 174 | switch result { 175 | case .result(let intent): 176 | let target = NavigationIntent.goToOrderHistory 177 | XCTAssertEqual(intent, target) 178 | default: 179 | XCTFail("Factory should have produced an intent") 180 | } 181 | } 182 | 183 | func test_orderDetails1() { 184 | let deepLink = DeepLink(string: "je-internal://just-eat.com/order?orderId=123")! 185 | let result = navigationIntentFactory.intent(for: deepLink) 186 | switch result { 187 | case .result(let intent): 188 | let target = NavigationIntent.goToOrderDetails("123") 189 | XCTAssertEqual(intent, target) 190 | default: 191 | XCTFail("Factory should have produced an intent") 192 | } 193 | } 194 | 195 | func test_orderDetails2() { 196 | let deepLink = DeepLink(string: "je-internal://just-eat.com/order/123")! 197 | let result = navigationIntentFactory.intent(for: deepLink) 198 | switch result { 199 | case .result(let intent): 200 | let target = NavigationIntent.goToOrderDetails("123") 201 | XCTAssertEqual(intent, target) 202 | default: 203 | XCTFail("Factory should have produced an intent") 204 | } 205 | } 206 | 207 | func test_reorder1() { 208 | let deepLink = DeepLink(string: "je-internal://just-eat.com/reorder?orderId=123")! 209 | let result = navigationIntentFactory.intent(for: deepLink) 210 | switch result { 211 | case .result(let intent): 212 | let target = NavigationIntent.goToReorder("123") 213 | XCTAssertEqual(intent, target) 214 | default: 215 | XCTFail("Factory should have produced an intent") 216 | } 217 | } 218 | 219 | func test_reorder2() { 220 | let deepLink = DeepLink(string: "je-internal://just-eat.com/reorder/123")! 221 | let result = navigationIntentFactory.intent(for: deepLink) 222 | switch result { 223 | case .result(let intent): 224 | let target = NavigationIntent.goToReorder("123") 225 | XCTAssertEqual(intent, target) 226 | default: 227 | XCTFail("Factory should have produced an intent") 228 | } 229 | } 230 | 231 | func test_settings() { 232 | let deepLink = DeepLink(string: "je-internal://just-eat.com/settings")! 233 | let result = navigationIntentFactory.intent(for: deepLink) 234 | switch result { 235 | case .result(let intent): 236 | let target = NavigationIntent.goToSettings 237 | XCTAssertEqual(intent, target) 238 | default: 239 | XCTFail("Factory should have produced an intent") 240 | } 241 | } 242 | 243 | func test_unsupported1() { 244 | let deepLink = DeepLink(string: "je-internal://just-eat.com/invalid")! 245 | let result = navigationIntentFactory.intent(for: deepLink) 246 | switch result { 247 | case .result: 248 | XCTFail("Factory should have produced an error") 249 | case .error(let error as NSError): 250 | XCTAssertEqual(error.domain, NavigationIntentFactory.domain) 251 | XCTAssertEqual(error.code, NavigationIntentFactory.ErrorCode.unsupportedURL.rawValue) 252 | } 253 | } 254 | 255 | func test_unsupported2() { 256 | let deepLink = DeepLink(string: "je-internal://just-eat.com/invalid/kfc")! 257 | let result = navigationIntentFactory.intent(for: deepLink) 258 | switch result { 259 | case .result: 260 | XCTFail("Factory should have produced an error") 261 | case .error(let error as NSError): 262 | XCTAssertEqual(error.domain, NavigationIntentFactory.domain) 263 | XCTAssertEqual(error.code, NavigationIntentFactory.ErrorCode.unsupportedURL.rawValue) 264 | } 265 | } 266 | } 267 | -------------------------------------------------------------------------------- /NavigationEngineDemoTests/Parsing/UniversalLinkConverterTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UniversalLinkConverterTests.swift 3 | // NavigationEngineTests 4 | // 5 | // Created by Alberto De Bortoli on 07/02/2019. 6 | // 7 | 8 | import XCTest 9 | @testable import NavigationEngineDemo 10 | 11 | class UniversalLinkConverterTests: XCTestCase { 12 | 13 | private var universalLinkConverter: UniversalLinkConverter! 14 | private var deepLinkingSettings = MockDeepLinkingSettings() 15 | 16 | override func setUp() { 17 | super.setUp() 18 | universalLinkConverter = UniversalLinkConverter(settings: deepLinkingSettings) 19 | } 20 | 21 | func test_root() { 22 | let universalLink = UniversalLink(string: "https://just-eat.co.uk")! 23 | let target = DeepLink(string: "je-internal://je.com/home") 24 | let test = universalLinkConverter.deepLink(forUniversalLink: universalLink) 25 | XCTAssertEqual(target, test) 26 | } 27 | 28 | func test_root2() { 29 | let universalLink = UniversalLink(string: "https://just-eat.co.uk/")! 30 | let target = DeepLink(string: "je-internal://je.com/home") 31 | let test = universalLinkConverter.deepLink(forUniversalLink: universalLink) 32 | XCTAssertEqual(target, test) 33 | } 34 | 35 | func test_home() { 36 | let universalLink = UniversalLink(string: "https://just-eat.co.uk/home")! 37 | let target = DeepLink(string: "je-internal://je.com/home") 38 | let test = universalLinkConverter.deepLink(forUniversalLink: universalLink) 39 | XCTAssertEqual(target, test) 40 | } 41 | 42 | func test_login1() { 43 | let universalLink = UniversalLink(string: "https://just-eat.co.uk/login")! 44 | let target = DeepLink(string: "je-internal://je.com/login") 45 | let test = universalLinkConverter.deepLink(forUniversalLink: universalLink) 46 | XCTAssertEqual(target, test) 47 | } 48 | 49 | func test_login2() { 50 | let universalLink = UniversalLink(string: "https://just-eat.co.uk/account/login")! 51 | let target = DeepLink(string: "je-internal://je.com/login") 52 | let test = universalLinkConverter.deepLink(forUniversalLink: universalLink) 53 | XCTAssertEqual(target, test) 54 | } 55 | 56 | func test_areaWithPostcode1() { 57 | let universalLink = UniversalLink(string: "https://just-eat.co.uk/area/ECM7")! 58 | let target = DeepLink(string: "je-internal://je.com/search?postcode=ECM7") 59 | let test = universalLinkConverter.deepLink(forUniversalLink: universalLink) 60 | XCTAssertEqual(target, test) 61 | } 62 | 63 | func test_areaWithPostcode2() { 64 | let universalLink = UniversalLink(string: "https://just-eat.co.uk/area/ECM7-CityThameslink")! 65 | let target = DeepLink(string: "je-internal://je.com/search?postcode=ECM7") 66 | let test = universalLinkConverter.deepLink(forUniversalLink: universalLink) 67 | XCTAssertEqual(target, test) 68 | } 69 | 70 | func test_areaWithPostcodeAndLocation() { 71 | let universalLink = UniversalLink(string: "https://just-eat.co.uk/area/ECM7-CityThameslink?lat=45.951342&long=12.497958")! 72 | let target = DeepLink(string: "je-internal://je.com/search?postcode=ECM7&lat=45.951342&long=12.497958") 73 | let test = universalLinkConverter.deepLink(forUniversalLink: universalLink) 74 | XCTAssertEqual(target, test) 75 | } 76 | 77 | func test_areaWithPostcodeAndCuisine1() { 78 | let universalLink = UniversalLink(string: "https://just-eat.co.uk/area/ECM7-CityThameslink/italian")! 79 | let target = DeepLink(string: "je-internal://je.com/search?postcode=ECM7&cuisine=italian") 80 | let test = universalLinkConverter.deepLink(forUniversalLink: universalLink) 81 | XCTAssertEqual(target, test) 82 | } 83 | 84 | func test_areaWithPostcodeAndCuisine2() { 85 | let universalLink = UniversalLink(string: "https://just-eat.co.uk/area/ECM7-CityThameslink?cuisine=italian")! 86 | let target = DeepLink(string: "je-internal://je.com/search?postcode=ECM7&cuisine=italian") 87 | let test = universalLinkConverter.deepLink(forUniversalLink: universalLink) 88 | XCTAssertEqual(target, test) 89 | } 90 | 91 | func test_areaWithPostcodeAndCuisineAndLocation() { 92 | let universalLink = UniversalLink(string: "https://just-eat.co.uk/area/ECM7-CityThameslink?cuisine=italian&lat=45.951342&long=12.497958")! 93 | let target = DeepLink(string: "je-internal://je.com/search?postcode=ECM7&cuisine=italian&lat=45.951342&long=12.497958") 94 | let test = universalLinkConverter.deepLink(forUniversalLink: universalLink) 95 | XCTAssertEqual(target, test) 96 | } 97 | 98 | func test_serp() { 99 | let universalLink = UniversalLink(string: "https://just-eat.co.uk/search")! 100 | let target = DeepLink(string: "je-internal://je.com/search") 101 | let test = universalLinkConverter.deepLink(forUniversalLink: universalLink) 102 | XCTAssertEqual(target, test) 103 | } 104 | 105 | func test_serpWithPostcode() { 106 | let universalLink = UniversalLink(string: "https://just-eat.co.uk/search?postcode=EC4M7RF")! 107 | let target = DeepLink(string: "je-internal://je.com/search?postcode=EC4M7RF") 108 | let test = universalLinkConverter.deepLink(forUniversalLink: universalLink) 109 | XCTAssertEqual(target, test) 110 | } 111 | 112 | func test_serpWithPostcodeAndCuisine() { 113 | let universalLink = UniversalLink(string: "https://just-eat.co.uk/search?postcode=EC4M7RF&cuisine=italian")! 114 | let target = DeepLink(string: "je-internal://je.com/search?postcode=EC4M7RF&cuisine=italian") 115 | let test = universalLinkConverter.deepLink(forUniversalLink: universalLink) 116 | XCTAssertEqual(target, test) 117 | } 118 | 119 | func test_restaurant() { 120 | let universalLink = UniversalLink(string: "https://just-eat.co.uk/restaurant?restaurantId=123")! 121 | let target = DeepLink(string: "je-internal://je.com/restaurant?restaurantId=123") 122 | let test = universalLinkConverter.deepLink(forUniversalLink: universalLink) 123 | XCTAssertEqual(target, test) 124 | } 125 | 126 | func test_restaurantWithPostcode() { 127 | let universalLink = UniversalLink(string: "https://just-eat.co.uk/restaurant?restaurantId=123&postcode=EC4M7RF")! 128 | let target = DeepLink(string: "je-internal://je.com/restaurant?restaurantId=123&postcode=EC4M7RF") 129 | let test = universalLinkConverter.deepLink(forUniversalLink: universalLink) 130 | XCTAssertEqual(target, test) 131 | } 132 | 133 | func test_restaurantWithPostcodeAndServiceType() { 134 | let universalLink = UniversalLink(string: "https://just-eat.co.uk/restaurant?restaurantId=123&postcode=EC4M7RF&serviceType=delivery")! 135 | let target = DeepLink(string: "je-internal://je.com/restaurant?restaurantId=123&postcode=EC4M7RF&serviceType=delivery") 136 | let test = universalLinkConverter.deepLink(forUniversalLink: universalLink) 137 | XCTAssertEqual(target, test) 138 | } 139 | 140 | func test_restaurantWithMenuSection() { 141 | let universalLink = UniversalLink(string: "https://just-eat.co.uk/restaurant?restaurantId=123§ion=menu")! 142 | let target = DeepLink(string: "je-internal://je.com/restaurant?restaurantId=123§ion=menu") 143 | let test = universalLinkConverter.deepLink(forUniversalLink: universalLink) 144 | XCTAssertEqual(target, test) 145 | } 146 | 147 | func test_restaurantWithReviewSection() { 148 | let universalLink = UniversalLink(string: "https://just-eat.co.uk/restaurant?restaurantId=123§ion=reviews")! 149 | let target = DeepLink(string: "je-internal://je.com/restaurant?restaurantId=123§ion=reviews") 150 | let test = universalLinkConverter.deepLink(forUniversalLink: universalLink) 151 | XCTAssertEqual(target, test) 152 | } 153 | 154 | func test_restaurantWithInfoSection() { 155 | let universalLink = UniversalLink(string: "https://just-eat.co.uk/restaurant?restaurantId=123§ion=info")! 156 | let target = DeepLink(string: "je-internal://je.com/restaurant?restaurantId=123§ion=info") 157 | let test = universalLinkConverter.deepLink(forUniversalLink: universalLink) 158 | XCTAssertEqual(target, test) 159 | } 160 | 161 | func test_resetPassword() { 162 | let universalLink = UniversalLink(string: "https://just-eat.co.uk/resetPassword?resetToken=123")! 163 | let target = DeepLink(string: "je-internal://je.com/resetPassword?resetToken=123") 164 | let test = universalLinkConverter.deepLink(forUniversalLink: universalLink) 165 | XCTAssertEqual(target, test) 166 | } 167 | 168 | func test_orderHistory1() { 169 | let universalLink = UniversalLink(string: "https://just-eat.co.uk/orders")! 170 | let target = DeepLink(string: "je-internal://je.com/orders") 171 | let test = universalLinkConverter.deepLink(forUniversalLink: universalLink) 172 | XCTAssertEqual(target, test) 173 | } 174 | 175 | func test_orderHistory2() { 176 | let universalLink = UniversalLink(string: "https://just-eat.co.uk/order-history")! 177 | let target = DeepLink(string: "je-internal://je.com/orders") 178 | let test = universalLinkConverter.deepLink(forUniversalLink: universalLink) 179 | XCTAssertEqual(target, test) 180 | } 181 | 182 | func test_orderHistory3() { 183 | let universalLink = UniversalLink(string: "https://just-eat.co.uk/account/order-history")! 184 | let target = DeepLink(string: "je-internal://je.com/orders") 185 | let test = universalLinkConverter.deepLink(forUniversalLink: universalLink) 186 | XCTAssertEqual(target, test) 187 | } 188 | 189 | func test_orderDetails1() { 190 | let universalLink = UniversalLink(string: "https://just-eat.co.uk/order?orderId=123")! 191 | let target = DeepLink(string: "je-internal://je.com/order?orderId=123") 192 | let test = universalLinkConverter.deepLink(forUniversalLink: universalLink) 193 | XCTAssertEqual(target, test) 194 | } 195 | 196 | func test_orderDetails2() { 197 | let universalLink = UniversalLink(string: "https://just-eat.co.uk/orders?orderId=123")! 198 | let target = DeepLink(string: "je-internal://je.com/order?orderId=123") 199 | let test = universalLinkConverter.deepLink(forUniversalLink: universalLink) 200 | XCTAssertEqual(target, test) 201 | } 202 | 203 | func test_orderDetails3() { 204 | let universalLink = UniversalLink(string: "https://just-eat.co.uk/order/123")! 205 | let target = DeepLink(string: "je-internal://je.com/order?orderId=123") 206 | let test = universalLinkConverter.deepLink(forUniversalLink: universalLink) 207 | XCTAssertEqual(target, test) 208 | } 209 | 210 | func test_orderDetails4() { 211 | let universalLink = UniversalLink(string: "https://just-eat.co.uk/orders/123")! 212 | let target = DeepLink(string: "je-internal://je.com/order?orderId=123") 213 | let test = universalLinkConverter.deepLink(forUniversalLink: universalLink) 214 | XCTAssertEqual(target, test) 215 | } 216 | 217 | func test_orderDetails5() { 218 | let universalLink = UniversalLink(string: "https://just-eat.co.uk/pages/orderstatus.aspx?orderId=123")! 219 | let target = DeepLink(string: "je-internal://je.com/order?orderId=123") 220 | let test = universalLinkConverter.deepLink(forUniversalLink: universalLink) 221 | XCTAssertEqual(target, test) 222 | } 223 | 224 | func test_orderDetails6() { 225 | let universalLink = UniversalLink(string: "https://just-eat.co.uk/account/order/123")! 226 | let target = DeepLink(string: "je-internal://je.com/order?orderId=123") 227 | let test = universalLinkConverter.deepLink(forUniversalLink: universalLink) 228 | XCTAssertEqual(target, test) 229 | } 230 | 231 | func test_reorder1() { 232 | let universalLink = UniversalLink(string: "https://just-eat.co.uk/reorder?orderId=123")! 233 | let target = DeepLink(string: "je-internal://je.com/reorder?orderId=123") 234 | let test = universalLinkConverter.deepLink(forUniversalLink: universalLink) 235 | XCTAssertEqual(target, test) 236 | } 237 | 238 | func test_reorder2() { 239 | let universalLink = UniversalLink(string: "https://just-eat.co.uk/reorder/123")! 240 | let target = DeepLink(string: "je-internal://je.com/reorder?orderId=123") 241 | let test = universalLinkConverter.deepLink(forUniversalLink: universalLink) 242 | XCTAssertEqual(target, test) 243 | } 244 | 245 | func test_updatePassword() { 246 | let universalLink = UniversalLink(string: "https://just-eat.co.uk/account/update-password?token=123")! 247 | let target = DeepLink(string: "je-internal://je.com/resetPassword?resetToken=123") 248 | let test = universalLinkConverter.deepLink(forUniversalLink: universalLink) 249 | XCTAssertEqual(target, test) 250 | } 251 | 252 | func test_invalid1() { 253 | let universalLink = UniversalLink(string: "https://just-eat.co.uk/pretty/much/invalid")! 254 | let test = universalLinkConverter.deepLink(forUniversalLink: universalLink) 255 | XCTAssertNil(test) 256 | } 257 | 258 | func test_invalid2() { 259 | let universalLink = UniversalLink(string: "https://just-eat.co.uk/totally/invalid")! 260 | let test = universalLinkConverter.deepLink(forUniversalLink: universalLink) 261 | XCTAssertNil(test) 262 | } 263 | 264 | func test_invalid3() { 265 | let universalLink = UniversalLink(string: "https://just-eat.co.uk/invalid")! 266 | let test = universalLinkConverter.deepLink(forUniversalLink: universalLink) 267 | XCTAssertNil(test) 268 | } 269 | } 270 | --------------------------------------------------------------------------------