Skip to content

Commit

Permalink
fix: universal links deep links open host app (customerio#268)
Browse files Browse the repository at this point in the history
  • Loading branch information
levibostian committed Feb 15, 2023
1 parent de16dc7 commit 29c95b5
Show file tree
Hide file tree
Showing 9 changed files with 258 additions and 64 deletions.
53 changes: 53 additions & 0 deletions Sources/Common/Util/UIKitWrapper.swift
@@ -0,0 +1,53 @@
import Foundation
#if canImport(UIKit)
import UIKit
#endif

/*
Because our codebase uses `#if canImport(UIKit)` to avoid being tightly coupled to iOS, this utility class exists to
encapsulate all UIKit operations so our codebase doesn't need to have error-prone and boilerplate `#if ...UIKit...` code in many places.
*/
public protocol UIKitWrapper: AutoMockable {
func open(url: URL)
func continueNSUserActivity(webpageURL: URL) -> Bool
}

// sourcery: InjectRegister = "UIKitWrapper"
public class UIKitWrapperImpl: UIKitWrapper {
public func open(url: URL) {
#if canImport(UIKit)
UIApplication.shared.open(url: url)
#endif
}

public func continueNSUserActivity(webpageURL: URL) -> Bool {
#if canImport(UIKit)
guard isLinkValidNSUserActivityLink(webpageURL) else {
return false
}

let openLinkInHostAppActivity = NSUserActivity(activityType: NSUserActivityTypeBrowsingWeb)
openLinkInHostAppActivity.webpageURL = webpageURL

let didHostAppHandleLink = UIApplication.shared.delegate?.application?(UIApplication.shared, continue: openLinkInHostAppActivity, restorationHandler: { _ in }) ?? false

return didHostAppHandleLink
#else
return false
#endif
}
}

internal extension UIKitWrapperImpl {
// When using `NSUserActivity.webpageURL`, only certain URL schemes are allowed. An exception will be thrown otherwise which is why we have this function.
func isLinkValidNSUserActivityLink(_ url: URL) -> Bool {
guard let schemeOfUrl = url.scheme else {
return false
}

// All allowed schemes in docs: https://developer.apple.com/documentation/foundation/nsuseractivity/1418086-webpageurl
let allowedSchemes = ["http", "https"]

return allowedSchemes.contains(schemeOfUrl)
}
}
Expand Up @@ -115,6 +115,9 @@ extension DIGraph {
_ = dateUtil
countDependenciesResolved += 1

_ = uIKitWrapper
countDependenciesResolved += 1

_ = httpRequestRunner
countDependenciesResolved += 1

Expand Down Expand Up @@ -435,6 +438,18 @@ extension DIGraph {
SdkDateUtil()
}

// UIKitWrapper
public var uIKitWrapper: UIKitWrapper {
if let overridenDep = overrides[String(describing: UIKitWrapper.self)] {
return overridenDep as! UIKitWrapper
}
return newUIKitWrapper
}

private var newUIKitWrapper: UIKitWrapper {
UIKitWrapperImpl()
}

// HttpRequestRunner
internal var httpRequestRunner: HttpRequestRunner {
if let overridenDep = overrides[String(describing: HttpRequestRunner.self)] {
Expand Down
87 changes: 87 additions & 0 deletions Sources/Common/autogenerated/AutoMockable.generated.swift
Expand Up @@ -2584,6 +2584,93 @@ internal class SingleScheduleTimerMock: SingleScheduleTimer, Mock {
}
}

/**
Class to easily create a mocked version of the `UIKitWrapper` class.
This class is equipped with functions and properties ready for you to mock!
Note: This file is automatically generated. This means the mocks should always be up-to-date and has a consistent API.
See the SDK documentation to learn the basics behind using the mock classes in the SDK.
*/
public class UIKitWrapperMock: UIKitWrapper, Mock {
/// If *any* interactions done on mock. `true` if any method or property getter/setter called.
public var mockCalled: Bool = false //

public init() {
Mocks.shared.add(mock: self)
}

public func resetMock() {
openCallsCount = 0
openReceivedArguments = nil
openReceivedInvocations = []

mockCalled = false // do last as resetting properties above can make this true
continueNSUserActivityCallsCount = 0
continueNSUserActivityReceivedArguments = nil
continueNSUserActivityReceivedInvocations = []

mockCalled = false // do last as resetting properties above can make this true
}

// MARK: - open

/// Number of times the function was called.
public private(set) var openCallsCount = 0
/// `true` if the function was ever called.
public var openCalled: Bool {
openCallsCount > 0
}

/// The arguments from the *last* time the function was called.
public private(set) var openReceivedArguments: URL?
/// Arguments from *all* of the times that the function was called.
public private(set) var openReceivedInvocations: [URL] = []
/**
Set closure to get called when function gets called. Great way to test logic or return a value for the function.
*/
public var openClosure: ((URL) -> Void)?

/// Mocked function for `open(url: URL)`. Your opportunity to return a mocked value and check result of mock in test code.
public func open(url: URL) {
mockCalled = true
openCallsCount += 1
openReceivedArguments = url
openReceivedInvocations.append(url)
openClosure?(url)
}

// MARK: - continueNSUserActivity

/// Number of times the function was called.
public private(set) var continueNSUserActivityCallsCount = 0
/// `true` if the function was ever called.
public var continueNSUserActivityCalled: Bool {
continueNSUserActivityCallsCount > 0
}

/// The arguments from the *last* time the function was called.
public private(set) var continueNSUserActivityReceivedArguments: URL?
/// Arguments from *all* of the times that the function was called.
public private(set) var continueNSUserActivityReceivedInvocations: [URL] = []
/// Value to return from the mocked function.
public var continueNSUserActivityReturnValue: Bool!
/**
Set closure to get called when function gets called. Great way to test logic or return a value for the function.
The closure has first priority to return a value for the mocked function. If the closure returns `nil`,
then the mock will attempt to return the value for `continueNSUserActivityReturnValue`
*/
public var continueNSUserActivityClosure: ((URL) -> Bool)?

/// Mocked function for `continueNSUserActivity(webpageURL: URL)`. Your opportunity to return a mocked value and check result of mock in test code.
public func continueNSUserActivity(webpageURL: URL) -> Bool {
mockCalled = true
continueNSUserActivityCallsCount += 1
continueNSUserActivityReceivedArguments = webpageURL
continueNSUserActivityReceivedInvocations.append(webpageURL)
return continueNSUserActivityClosure.map { $0(webpageURL) } ?? continueNSUserActivityReturnValue
}
}

/**
Class to easily create a mocked version of the `UserAgentUtil` class.
This class is equipped with functions and properties ready for you to mock!
Expand Down
29 changes: 4 additions & 25 deletions Sources/MessagingPush/MessagingPush+AppDelegate.swift
@@ -1,11 +1,10 @@
import Common
import Foundation
#if canImport(UserNotifications) && canImport(UIKit)
import UIKit
#if canImport(UserNotifications)
import UserNotifications
#endif

#if canImport(UserNotifications) && canImport(UIKit)
#if canImport(UserNotifications)
@available(iOSApplicationExtension, unavailable)
public extension MessagingPush {
/**
Expand Down Expand Up @@ -64,28 +63,8 @@ extension MessagingPushImplementation {

switch response.actionIdentifier {
case UNNotificationDefaultActionIdentifier: // push notification was touched.
if let deepLinkurl = pushContent.deepLink {
// First, try to open the link inside of the host app. This is to keep compatability with Universal Links.
// Learn more of edge case: https://github.com/customerio/customerio-ios/issues/262
// Fallback to opening the URL system-wide if fail to open link in host app.
// Customers with Universal Links in their app will need to add this function to their `AppDelegate` which will get called with deep link:
// func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool

var didHostAppHandleLink = false
if deepLinkUtil.isLinkValidNSUserActivityLink(deepLinkurl) {
logger.debug("Found a deep link inside of the push notification. Attempting to open deep link in host app, first.")

let openLinkInHostAppActivity = NSUserActivity(activityType: NSUserActivityTypeBrowsingWeb)
openLinkInHostAppActivity.webpageURL = deepLinkurl

let didHostAppHandleLink = UIApplication.shared.delegate?.application?(UIApplication.shared, continue: openLinkInHostAppActivity, restorationHandler: { _ in }) ?? false
}

if !didHostAppHandleLink {
logger.debug("Host app didn't handle link yet. Opening the link through a system call.")
// fallback to open link, potentially in device browser
UIApplication.shared.open(url: deepLinkurl)
}
if let deepLinkUrl = pushContent.deepLink {
deepLinkUtil.handleDeepLink(deepLinkUrl)
}
default: break
}
Expand Down
45 changes: 36 additions & 9 deletions Sources/MessagingPush/Util/DeepLinkUtil.swift
@@ -1,21 +1,48 @@
import Common
import Foundation
#if canImport(UIKit)
import UIKit
#endif

protocol DeepLinkUtil: AutoMockable {
func isLinkValidNSUserActivityLink(_ url: URL) -> Bool
func handleDeepLink(_ deepLinkUrl: URL)
}

// sourcery: InjectRegister = "DeepLinkUtil"
class DeepLinkUtilImpl: DeepLinkUtil {
// When using `NSUserActivity.webpageURL`, only certain URL schemes are allowed. An exception will be thrown otherwise which is why we have this function.
func isLinkValidNSUserActivityLink(_ url: URL) -> Bool {
guard let schemeOfUrl = url.scheme else {
return false
}
private let logger: Logger
private let uiKit: UIKitWrapper

init(logger: Logger, uiKitWrapper: UIKitWrapper) {
self.logger = logger
self.uiKit = uiKitWrapper
}

func handleDeepLink(_ deepLinkUrl: URL) {
logger.info("Found a deep link inside of a push notification.")
logger.debug("deep link found in push: \(deepLinkUrl)")

// All allowed schemes in docs: https://developer.apple.com/documentation/foundation/nsuseractivity/1418086-webpageurl
let allowedSchemes = ["http", "https"]
/*
There are 2 types of deep links:
1. Universal Links which give URL format of a webpage using `http://` or `https://`
2. App scheme which give URL format using a prototol other then `http://` or `https://`.
return allowedSchemes.contains(schemeOfUrl)
First, try to open the link inside of the host app. This is to keep compatability with Universal Links.
Learn more of edge case: https://github.com/customerio/customerio-ios/issues/262
Fallback to opening the URL through a sytem call if:
1. deep link is an app scheme URL
2. Customer has not implemented the correct function in their host app to handle universal link:
```
func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool
```
3. Customer returned `false` from ^^^ function.
*/
let ifHandled = uiKit.continueNSUserActivity(webpageURL: deepLinkUrl)

if !ifHandled {
logger.debug("Opening deep link through system call. Deep link: \(deepLinkUrl)")
uiKit.open(url: deepLinkUrl)
}
}
}
Expand Up @@ -69,6 +69,6 @@ extension DIGraph {
}

private var newDeepLinkUtil: DeepLinkUtil {
DeepLinkUtilImpl()
DeepLinkUtilImpl(logger: logger, uiKitWrapper: uIKitWrapper)
}
}
36 changes: 16 additions & 20 deletions Sources/MessagingPush/autogenerated/AutoMockable.generated.swift
Expand Up @@ -97,42 +97,38 @@ internal class DeepLinkUtilMock: DeepLinkUtil, Mock {
}

public func resetMock() {
isLinkValidNSUserActivityLinkCallsCount = 0
isLinkValidNSUserActivityLinkReceivedArguments = nil
isLinkValidNSUserActivityLinkReceivedInvocations = []
handleDeepLinkCallsCount = 0
handleDeepLinkReceivedArguments = nil
handleDeepLinkReceivedInvocations = []

mockCalled = false // do last as resetting properties above can make this true
}

// MARK: - isLinkValidNSUserActivityLink
// MARK: - handleDeepLink

/// Number of times the function was called.
internal private(set) var isLinkValidNSUserActivityLinkCallsCount = 0
internal private(set) var handleDeepLinkCallsCount = 0
/// `true` if the function was ever called.
internal var isLinkValidNSUserActivityLinkCalled: Bool {
isLinkValidNSUserActivityLinkCallsCount > 0
internal var handleDeepLinkCalled: Bool {
handleDeepLinkCallsCount > 0
}

/// The arguments from the *last* time the function was called.
internal private(set) var isLinkValidNSUserActivityLinkReceivedArguments: URL?
internal private(set) var handleDeepLinkReceivedArguments: URL?
/// Arguments from *all* of the times that the function was called.
internal private(set) var isLinkValidNSUserActivityLinkReceivedInvocations: [URL] = []
/// Value to return from the mocked function.
internal var isLinkValidNSUserActivityLinkReturnValue: Bool!
internal private(set) var handleDeepLinkReceivedInvocations: [URL] = []
/**
Set closure to get called when function gets called. Great way to test logic or return a value for the function.
The closure has first priority to return a value for the mocked function. If the closure returns `nil`,
then the mock will attempt to return the value for `isLinkValidNSUserActivityLinkReturnValue`
*/
internal var isLinkValidNSUserActivityLinkClosure: ((URL) -> Bool)?
internal var handleDeepLinkClosure: ((URL) -> Void)?

/// Mocked function for `isLinkValidNSUserActivityLink(_ url: URL)`. Your opportunity to return a mocked value and check result of mock in test code.
internal func isLinkValidNSUserActivityLink(_ url: URL) -> Bool {
/// Mocked function for `handleDeepLink(_ deepLinkUrl: URL)`. Your opportunity to return a mocked value and check result of mock in test code.
internal func handleDeepLink(_ deepLinkUrl: URL) {
mockCalled = true
isLinkValidNSUserActivityLinkCallsCount += 1
isLinkValidNSUserActivityLinkReceivedArguments = url
isLinkValidNSUserActivityLinkReceivedInvocations.append(url)
return isLinkValidNSUserActivityLinkClosure.map { $0(url) } ?? isLinkValidNSUserActivityLinkReturnValue
handleDeepLinkCallsCount += 1
handleDeepLinkReceivedArguments = deepLinkUrl
handleDeepLinkReceivedInvocations.append(deepLinkUrl)
handleDeepLinkClosure?(deepLinkUrl)
}
}

Expand Down
28 changes: 28 additions & 0 deletions Tests/Common/Util/UIKitWrapperTest.swift
@@ -0,0 +1,28 @@
@testable import Common
import Foundation
import SharedTests
import XCTest

class UIKitWrpperTest: UnitTest {
private var uiKit: UIKitWrapperImpl!

override func setUp() {
super.setUp()

uiKit = UIKitWrapperImpl()
}

// MARK: continueNSUserActivity

func test_continueNSUserActivity_givenAppSchemeUrl_expectFalse() {
let given = URL(string: "remote-habits://switch_workspace?site_id=AAA&api_key=BBB")!

XCTAssertFalse(uiKit.isLinkValidNSUserActivityLink(given))
}

func test_continueNSUserActivity_givenUniversalLinkUrl_expectTrue() {
let given = URL(string: "https://remotehabits.com/switch_workspace?site_id=AAA&api_key=BBB")!

XCTAssertTrue(uiKit.isLinkValidNSUserActivityLink(given))
}
}

0 comments on commit 29c95b5

Please sign in to comment.