diff --git a/Sources/Common/Util/UIKitWrapper.swift b/Sources/Common/Util/UIKitWrapper.swift new file mode 100644 index 000000000..5f5b052a6 --- /dev/null +++ b/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) + } +} diff --git a/Sources/Common/autogenerated/AutoDependencyInjection.generated.swift b/Sources/Common/autogenerated/AutoDependencyInjection.generated.swift index fafa97efa..a4eb66167 100644 --- a/Sources/Common/autogenerated/AutoDependencyInjection.generated.swift +++ b/Sources/Common/autogenerated/AutoDependencyInjection.generated.swift @@ -115,6 +115,9 @@ extension DIGraph { _ = dateUtil countDependenciesResolved += 1 + _ = uIKitWrapper + countDependenciesResolved += 1 + _ = httpRequestRunner countDependenciesResolved += 1 @@ -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)] { diff --git a/Sources/Common/autogenerated/AutoMockable.generated.swift b/Sources/Common/autogenerated/AutoMockable.generated.swift index c42adefe0..63b17cb2f 100644 --- a/Sources/Common/autogenerated/AutoMockable.generated.swift +++ b/Sources/Common/autogenerated/AutoMockable.generated.swift @@ -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! diff --git a/Sources/MessagingPush/MessagingPush+AppDelegate.swift b/Sources/MessagingPush/MessagingPush+AppDelegate.swift index 1668bc56b..db1c307b5 100644 --- a/Sources/MessagingPush/MessagingPush+AppDelegate.swift +++ b/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 { /** @@ -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 } diff --git a/Sources/MessagingPush/Util/DeepLinkUtil.swift b/Sources/MessagingPush/Util/DeepLinkUtil.swift index 03d28a9a2..75e021a73 100644 --- a/Sources/MessagingPush/Util/DeepLinkUtil.swift +++ b/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) + } } } diff --git a/Sources/MessagingPush/autogenerated/AutoDependencyInjection.generated.swift b/Sources/MessagingPush/autogenerated/AutoDependencyInjection.generated.swift index d92fc519b..3d0f266d9 100644 --- a/Sources/MessagingPush/autogenerated/AutoDependencyInjection.generated.swift +++ b/Sources/MessagingPush/autogenerated/AutoDependencyInjection.generated.swift @@ -69,6 +69,6 @@ extension DIGraph { } private var newDeepLinkUtil: DeepLinkUtil { - DeepLinkUtilImpl() + DeepLinkUtilImpl(logger: logger, uiKitWrapper: uIKitWrapper) } } diff --git a/Sources/MessagingPush/autogenerated/AutoMockable.generated.swift b/Sources/MessagingPush/autogenerated/AutoMockable.generated.swift index 8e69757bb..9766c16db 100644 --- a/Sources/MessagingPush/autogenerated/AutoMockable.generated.swift +++ b/Sources/MessagingPush/autogenerated/AutoMockable.generated.swift @@ -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) } } diff --git a/Tests/Common/Util/UIKitWrapperTest.swift b/Tests/Common/Util/UIKitWrapperTest.swift new file mode 100644 index 000000000..91be64641 --- /dev/null +++ b/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)) + } +} diff --git a/Tests/MessagingPush/Util/DeepLinkUtilTest.swift b/Tests/MessagingPush/Util/DeepLinkUtilTest.swift index 0399c2012..0eda63d47 100644 --- a/Tests/MessagingPush/Util/DeepLinkUtilTest.swift +++ b/Tests/MessagingPush/Util/DeepLinkUtilTest.swift @@ -1,28 +1,37 @@ @testable import CioMessagingPush +@testable import Common import Foundation import SharedTests import XCTest class DeepLinkUtilTest: UnitTest { - private var deepLinkUtil: DeepLinkUtil! + private var deepLinkUtil: DeepLinkUtilImpl! + + private let uiKitMock = UIKitWrapperMock() override func setUp() { super.setUp() - deepLinkUtil = DeepLinkUtilImpl() + deepLinkUtil = DeepLinkUtilImpl(logger: log, uiKitWrapper: uiKitMock) } - // MARK: isLinkValidNSUserActivityLink + // MARK: handleDeepLink + + func test_handleDeepLink_givenHostAppDoesNotHandleLink_expectOpenLinkSystemCall() { + uiKitMock.continueNSUserActivityReturnValue = false - func test_isLinkValidNSUserActivityLink_givenAppSchemeUrl_expectFalse() { - let given = URL(string: "remote-habits://switch_workspace?site_id=AAA&api_key=BBB")! + deepLinkUtil.handleDeepLink(URL(string: "https://customer.io")!) - XCTAssertFalse(deepLinkUtil.isLinkValidNSUserActivityLink(given)) + XCTAssertEqual(uiKitMock.continueNSUserActivityCallsCount, 1) + XCTAssertEqual(uiKitMock.openCallsCount, 1) } - func test_isLinkValidNSUserActivityLink_givenUniversalLinkUrl_expectTrue() { - let given = URL(string: "https://remotehabits.com/switch_workspace?site_id=AAA&api_key=BBB")! + func test_handleDeepLink_givenHostAppHandlesLink_expectDoNotOpenLinkSystemCall() { + uiKitMock.continueNSUserActivityReturnValue = true + + deepLinkUtil.handleDeepLink(URL(string: "https://customer.io")!) - XCTAssertTrue(deepLinkUtil.isLinkValidNSUserActivityLink(given)) + XCTAssertEqual(uiKitMock.continueNSUserActivityCallsCount, 1) + XCTAssertFalse(uiKitMock.openCalled) } }