diff --git a/Package.swift b/Package.swift index f4998b582..fff265ac6 100644 --- a/Package.swift +++ b/Package.swift @@ -11,7 +11,7 @@ import PackageDescription let package = Package( name: "Customer.io", platforms: [ - .iOS(.v9) + .iOS(.v13) ], products: [ // externally visible products for clients to install. // library name is the name given when installing the SDK. diff --git a/README.md b/README.md index 30ad5ac0f..5e1fedd8b 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [s-cioerrorparse]: https://github.com/customerio/RemoteHabits-iOS/blob/1.0.0/Remote%20Habits/Util/CustomerIOErrorUtil.swift ![min swift version is 5.3](https://img.shields.io/badge/min%20Swift%20version-5.3-orange) -![min ios version is 9](https://img.shields.io/badge/min%20iOS%20version-9-blue) +![min ios version is 13](https://img.shields.io/badge/min%20iOS%20version-13-blue) [![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-2.0-4baaaa.svg)](code_of_conduct.md) # Customer.io iOS SDK diff --git a/Sources/MessagingPush/MessagingPush+NotificationCenter.swift b/Sources/MessagingPush/MessagingPush+NotificationCenter.swift new file mode 100644 index 000000000..92cd39a22 --- /dev/null +++ b/Sources/MessagingPush/MessagingPush+NotificationCenter.swift @@ -0,0 +1,42 @@ +import Foundation +#if canImport(UserNotifications) && canImport(UIKit) +import CioTracking +import UIKit +import UserNotifications + +/** + For rich push handling. Methods to call when a rich push UI is interacted with. + */ +public extension MessagingPush { + /** + A push notification was interacted with. + + - returns: If the SDK called the completion handler for you indicating if the SDK took care of the request or not. + */ + func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) -> Bool { + guard let pushContent = PushContent.parse(notificationContent: response.notification.request.content, + jsonAdapter: DITracking.shared.jsonAdapter) + else { + return false + } + + switch response.actionIdentifier { + case UNNotificationDefaultActionIdentifier: // push notification was touched. + if let deepLinkurl = pushContent.deepLink { + UIApplication.shared.open(url: deepLinkurl) + + completionHandler() + + return true + } + default: break + } + + return false + } +} +#endif diff --git a/Sources/MessagingPush/PushContent.swift b/Sources/MessagingPush/PushContent.swift new file mode 100644 index 000000000..74a99a3cb --- /dev/null +++ b/Sources/MessagingPush/PushContent.swift @@ -0,0 +1,92 @@ +import CioTracking +import Foundation +#if canImport(UserNotifications) +import UserNotifications + +/** + The content of a push notification. Single source of truth for getting properties of a push notification. + */ +public class PushContent { + public var title: String { + get { + mutableNotificationContent.title + } + set { + mutableNotificationContent.title = newValue + } + } + + public var body: String { + get { + mutableNotificationContent.body + } + set { + mutableNotificationContent.body = newValue + } + } + + public var deepLink: URL? { + get { + cio.push.link?.url + } + set { + cioPush = cioPush.linkSet(newValue?.absoluteString) + } + } + + private var cio: CioPushPayload { + get { + // Disable swiftlint rule because this class can't initialize without this being valid + + // setter wont set unless a valid Object is created. + // swiftlint:disable:next force_cast + jsonAdapter.fromDictionary(mutableNotificationContent.userInfo["CIO"] as! [AnyHashable: Any])! + } + set { + // assert we have a valid payload before we set it as the new value + guard let newUserInfo = jsonAdapter.toDictionary(newValue) else { + return + } + mutableNotificationContent.userInfo["CIO"] = newUserInfo + } + } + + private var cioPush: CioPushPayload.Push { + get { + cio.push + } + set { + cio = CioPushPayload(push: newValue) + } + } + + private let jsonAdapter: JsonAdapter + public let mutableNotificationContent: UNMutableNotificationContent + + public static func parse(notificationContent: UNNotificationContent, jsonAdapter: JsonAdapter) -> PushContent? { + let raw = notificationContent.userInfo + + guard let cioUserInfo = raw["CIO"] as? [AnyHashable: Any], + let _: CioPushPayload = jsonAdapter.fromDictionary(cioUserInfo), + let mutableNotificationContent = notificationContent.mutableCopy() as? UNMutableNotificationContent + else { + return nil + } + + return PushContent(mutableNotificationContent: mutableNotificationContent, jsonAdapter: jsonAdapter) + } + + // Used when modifying push content before showing and for parsing after displaying. + private init(mutableNotificationContent: UNMutableNotificationContent, jsonAdapter: JsonAdapter) { + self.mutableNotificationContent = mutableNotificationContent + self.jsonAdapter = jsonAdapter + } +} +#endif + +struct CioPushPayload: Codable { + let push: Push + + struct Push: Codable, AutoLenses { + let link: String? + } +} diff --git a/Sources/MessagingPush/autogenerated/AutoLenses.generated.swift b/Sources/MessagingPush/autogenerated/AutoLenses.generated.swift index 8bebbb7e5..3a918a351 100644 --- a/Sources/MessagingPush/autogenerated/AutoLenses.generated.swift +++ b/Sources/MessagingPush/autogenerated/AutoLenses.generated.swift @@ -59,3 +59,15 @@ func |> (x: A, f: (A) -> B) -> B { func |> (f: @escaping (A) -> B, g: @escaping (B) -> C) -> (A) -> C { { g(f($0)) } } + +extension CioPushPayload.Push { + static let linkLens = Lens(get: { $0.link }, + set: { link, existing in + CioPushPayload.Push(link: link) + }) + + // Convenient set functions to edit a property of the immutable object + func linkSet(_ link: String?) -> CioPushPayload.Push { + CioPushPayload.Push(link: link) + } +} diff --git a/Sources/MessagingPush/autogenerated/Autogenerated.swift b/Sources/MessagingPush/autogenerated/Autogenerated.swift new file mode 100644 index 000000000..d3ec20db8 --- /dev/null +++ b/Sources/MessagingPush/autogenerated/Autogenerated.swift @@ -0,0 +1,11 @@ +import Foundation + +/** + View `AutoMockable.generated.swift` file to learn how to use this protocol. + */ +public protocol AutoMockable {} + +/** + View `AutoLenses.generated.swift` file to learn how to use this protocol. + */ +public protocol AutoLenses {} diff --git a/Sources/MessagingPushAPN/RichPush/MessagingPush+NotificationServiceExtension.swift b/Sources/MessagingPushAPN/RichPush/MessagingPush+NotificationServiceExtension.swift new file mode 100644 index 000000000..e2c2cb37f --- /dev/null +++ b/Sources/MessagingPushAPN/RichPush/MessagingPush+NotificationServiceExtension.swift @@ -0,0 +1,41 @@ +import CioMessagingPush +import CioTracking +import Foundation +#if canImport(UserNotifications) +import UserNotifications + +/** + Functions called in app's Notification Service target. + */ +public extension MessagingPush { + /** + - returns: + Bool indicating if this push notification is one handled by Customer.io SDK or not. + If function returns `false`, `contentHandler` will *not* be called by the SDK. + */ + @discardableResult + func didReceive( + _ request: UNNotificationRequest, + withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void + ) -> Bool { + guard let pushContent = PushContent.parse(notificationContent: request.content, + jsonAdapter: DITracking.shared.jsonAdapter) + else { + // push is not sent by CIO. Therefore, end early. + return false + } + + RichPushRequestHandler.shared.startRequest(request, content: pushContent, completionHandler: contentHandler) + + return true + } + + /** + iOS OS telling the notification service to hurry up and stop modifying the push notifications. + Stop all network requests and modifying and show the push for what it looks like now. + */ + func serviceExtensionTimeWillExpire() { + RichPushRequestHandler.shared.stopAll() + } +} +#endif diff --git a/Sources/MessagingPushAPN/RichPush/RichPushRequest.swift b/Sources/MessagingPushAPN/RichPush/RichPushRequest.swift new file mode 100644 index 000000000..88cc31148 --- /dev/null +++ b/Sources/MessagingPushAPN/RichPush/RichPushRequest.swift @@ -0,0 +1,31 @@ +import CioMessagingPush +import Foundation +#if canImport(UserNotifications) +import UserNotifications + +internal class RichPushRequest { + private let completionHandler: (UNNotificationContent) -> Void + private let pushContent: PushContent + + init( + pushContent: PushContent, + request: UNNotificationRequest, + completionHandler: @escaping (UNNotificationContent) -> Void + ) { + self.completionHandler = completionHandler + self.pushContent = pushContent + } + + func start() { + // no async operations or modifications to the notification to do. Therefore, let's just finish. + + finishImmediately() + } + + func finishImmediately() { + // XXX: stop async operations and finish the rich push request. + + completionHandler(pushContent.mutableNotificationContent) + } +} +#endif diff --git a/Sources/MessagingPushAPN/RichPush/RichPushRequestHandler.swift b/Sources/MessagingPushAPN/RichPush/RichPushRequestHandler.swift new file mode 100644 index 000000000..9832d7cff --- /dev/null +++ b/Sources/MessagingPushAPN/RichPush/RichPushRequestHandler.swift @@ -0,0 +1,38 @@ +import CioMessagingPush +import CioTracking +import Foundation +#if canImport(UserNotifications) +import UserNotifications + +internal class RichPushRequestHandler { + static let shared = RichPushRequestHandler() + + @Atomic private var requests: [String: RichPushRequest] = [:] + + private init() {} + + func startRequest( + _ request: UNNotificationRequest, + content: PushContent, + completionHandler: @escaping (UNNotificationContent) -> Void + ) { + let requestId = request.identifier + + let existingRequest = requests[requestId] + if existingRequest != nil { return } + + let newRequest = RichPushRequest(pushContent: content, request: request, completionHandler: completionHandler) + requests[requestId] = newRequest + + newRequest.start() + } + + func stopAll() { + requests.forEach { + $0.value.finishImmediately() + } + + requests = [:] + } +} +#endif diff --git a/Sources/MessagingPushAPN/autogenerated/Autogenerated.swift b/Sources/MessagingPushAPN/autogenerated/Autogenerated.swift new file mode 100644 index 000000000..d3ec20db8 --- /dev/null +++ b/Sources/MessagingPushAPN/autogenerated/Autogenerated.swift @@ -0,0 +1,11 @@ +import Foundation + +/** + View `AutoMockable.generated.swift` file to learn how to use this protocol. + */ +public protocol AutoMockable {} + +/** + View `AutoLenses.generated.swift` file to learn how to use this protocol. + */ +public protocol AutoLenses {} diff --git a/Sources/MessagingPushFCM/autogenerated/Autogenerated.swift b/Sources/MessagingPushFCM/autogenerated/Autogenerated.swift new file mode 100644 index 000000000..d3ec20db8 --- /dev/null +++ b/Sources/MessagingPushFCM/autogenerated/Autogenerated.swift @@ -0,0 +1,11 @@ +import Foundation + +/** + View `AutoMockable.generated.swift` file to learn how to use this protocol. + */ +public protocol AutoMockable {} + +/** + View `AutoLenses.generated.swift` file to learn how to use this protocol. + */ +public protocol AutoLenses {} diff --git a/Sources/Tracking/Extensions/StringExtensions.swift b/Sources/Tracking/Extensions/StringExtensions.swift index 693b1781c..3cd69e74c 100644 --- a/Sources/Tracking/Extensions/StringExtensions.swift +++ b/Sources/Tracking/Extensions/StringExtensions.swift @@ -11,6 +11,10 @@ public extension String { data(using: .utf8) } + var url: URL? { + URL(string: self) + } + static var abcLetters: String { "abcdefghijklmnopqrstuvwxyz" } diff --git a/Sources/Tracking/Extensions/UIApplicationExtensions.swift b/Sources/Tracking/Extensions/UIApplicationExtensions.swift new file mode 100644 index 000000000..5fa701771 --- /dev/null +++ b/Sources/Tracking/Extensions/UIApplicationExtensions.swift @@ -0,0 +1,10 @@ +import Foundation +#if canImport(UIKit) +import UIKit + +public extension UIApplication { + func open(url: URL) { + open(url, options: [:]) { _ in } + } +} +#endif diff --git a/Sources/Tracking/Repository/Event.swift b/Sources/Tracking/Repository/Event.swift deleted file mode 100644 index 8b1378917..000000000 --- a/Sources/Tracking/Repository/Event.swift +++ /dev/null @@ -1 +0,0 @@ - diff --git a/Sources/Tracking/Util/JsonAdapter.swift b/Sources/Tracking/Util/JsonAdapter.swift index ef9542013..f4b63d3cb 100644 --- a/Sources/Tracking/Util/JsonAdapter.swift +++ b/Sources/Tracking/Util/JsonAdapter.swift @@ -48,6 +48,33 @@ public class JsonAdapter { self.log = log } + public func fromDictionary(_ dictionary: [AnyHashable: Any], + decoder override: JSONDecoder? = nil) -> T? { + do { + let jsonData = try JSONSerialization.data(withJSONObject: dictionary) + + return fromJson(jsonData, decoder: decoder) + } catch { + log.error("\(error.localizedDescription), dictionary: \(dictionary)") + } + + return nil + } + + public func toDictionary(_ obj: T, encoder override: JSONEncoder? = nil) -> [AnyHashable: Any]? { + guard let data = toJson(obj, encoder: encoder) else { + return nil + } + + do { + return try JSONSerialization.jsonObject(with: data, options: []) as? [AnyHashable: Any] + } catch { + log.error("\(error.localizedDescription), object: \(obj)") + } + + return nil + } + /** Returns optional to be more convenient then try/catch all over the code base. @@ -87,7 +114,7 @@ public class JsonAdapter { """ } catch { errorStringToLog = """ - Generic decide error. \(error.localizedDescription), json: \(json.string ?? "(error getting json string)") + Generic decode error. \(error.localizedDescription), json: \(json.string ?? "(error getting json string)") """ } diff --git a/Tests/MessagingPush/PlaceholderTest.swift b/Tests/MessagingPush/PlaceholderTest.swift deleted file mode 100644 index e69de29bb..000000000 diff --git a/Tests/MessagingPush/PushContentTest.swift b/Tests/MessagingPush/PushContentTest.swift new file mode 100644 index 000000000..c621947fc --- /dev/null +++ b/Tests/MessagingPush/PushContentTest.swift @@ -0,0 +1,93 @@ +@testable import CioMessagingPush +@testable import CioTracking +import Foundation +import SharedTests +import XCTest +#if canImport(UserNotifications) +import UserNotifications + +class PushContentTest: UnitTest { + var validCioPushContent: [AnyHashable: Any] = ["CIO": [ + "push": [ + "link": String.random + ] + ]] + + // MARK: parse + + func test_parse_givenPushWithoutCioContent_expectNil() { + let givenContent = UNMutableNotificationContent() + givenContent.userInfo = ["aps": ["mutable-content": 1]] + + XCTAssertNil(PushContent.parse(notificationContent: givenContent, jsonAdapter: jsonAdapter)) + } + + func test_parse_givenPushNotContainingValidCioContent_expectNil() { + let givenContent = UNMutableNotificationContent() + givenContent.userInfo = ["CIO": [ + "not-push": [ + "link": "cio://foo" + ] + ]] + + XCTAssertNil(PushContent.parse(notificationContent: givenContent, jsonAdapter: jsonAdapter)) + } + + func test_parse_givenCioPushContent_expectObject() { + let givenLink = "cio://\(String.random)" + let givenContent = UNMutableNotificationContent() + givenContent.userInfo = ["CIO": [ + "push": [ + "link": givenLink + ] + ]] + + let actual = PushContent.parse(notificationContent: givenContent, jsonAdapter: jsonAdapter)! + + XCTAssertEqual(actual.deepLink!, givenLink.url!) + } + + // MARK: property setters/getters + + func test_title_givenSet_expectGetSameValue() { + let given = String.random + let content = UNMutableNotificationContent() + content.title = "foo" + content.userInfo = validCioPushContent + let pushContent = PushContent.parse(notificationContent: content, jsonAdapter: jsonAdapter)! + + XCTAssertNotEqual(given, pushContent.title) + + pushContent.title = given + + XCTAssertEqual(given, pushContent.title) + } + + func test_body_givenSet_expectGetSameValue() { + let given = String.random + let content = UNMutableNotificationContent() + content.body = "foo" + content.userInfo = validCioPushContent + let pushContent = PushContent.parse(notificationContent: content, jsonAdapter: jsonAdapter)! + + XCTAssertNotEqual(given, pushContent.body) + + pushContent.body = given + + XCTAssertEqual(given, pushContent.body) + } + + func test_deepLink_givenSet_expectGetSameValue() { + let given = "cio://\(String.random)".url + let content = UNMutableNotificationContent() + content.userInfo = validCioPushContent + let pushContent = PushContent.parse(notificationContent: content, jsonAdapter: jsonAdapter)! + + XCTAssertNotEqual(given, pushContent.deepLink) + + pushContent.deepLink = given + + XCTAssertEqual(given, pushContent.deepLink) + } +} +#endif diff --git a/Tests/Tracking/Util/JsonAdapterTest.swift b/Tests/Tracking/Util/JsonAdapterTest.swift index fe9c5a577..f7ab90c65 100644 --- a/Tests/Tracking/Util/JsonAdapterTest.swift +++ b/Tests/Tracking/Util/JsonAdapterTest.swift @@ -42,4 +42,81 @@ class JsonAdapterTest: UnitTest { XCTAssertNil(actual) } + + // MARK: fromDictionary + + func test_fromDictionary_givenDictionaryMatchingObject_expectObject() { + struct Person: Codable, Equatable { + let firstName: String + } + + let given = ["firstName": "Dana"] + let expected = Person(firstName: "Dana") + + let actual: Person = jsonAdapter.fromDictionary(given)! + + XCTAssertEqual(expected, actual) + } + + func test_fromDictionary_givenDictionaryNotMatchingObject_expectNil() { + struct Person: Codable, Equatable { + let firstName: String + } + + let given = ["lastName": "Dana", "email": "you@you.com"] + + let actual: Person? = jsonAdapter.fromDictionary(given) + + XCTAssertNil(actual) + } + + func test_fromDictionary_givenDictionaryWithNesting_expectObject() { + struct Person: Codable, Equatable { + let name: Name + + struct Name: Codable, Equatable { + let first: String + let last: String + } + } + + let given = ["name": ["first": "Dana", "last": "Green"]] + let expected = Person(name: Person.Name(first: "Dana", last: "Green")) + + let actual: Person = jsonAdapter.fromDictionary(given)! + + XCTAssertEqual(expected, actual) + } + + // MARK: toDictionary + + func test_toDictionary_givenObjectWithNesting_expectDictionary() { + struct Person: Codable, Equatable { + let name: Name + + struct Name: Codable, Equatable { + let first: String + } + } + + let given = Person(name: Person.Name(first: "Dana")) + let expected: [String: [String: String]] = ["name": ["first": "Dana"]] + + let actual = jsonAdapter.toDictionary(given) as? [String: [String: String]] + + XCTAssertEqual(expected, actual) + } + + func test_toDictionary_givenObject_expectDictionary() { + struct Person: Codable, Equatable { + let name: String + } + + let given = Person(name: "Dana") + let expected: [String: String] = ["name": "Dana"] + + let actual = jsonAdapter.toDictionary(given) as? [String: String] + + XCTAssertEqual(expected, actual) + } } diff --git a/Tests/Tracking/VersionTest.swift b/Tests/Tracking/VersionTest.swift index 2624f2a45..1c1d27b55 100644 --- a/Tests/Tracking/VersionTest.swift +++ b/Tests/Tracking/VersionTest.swift @@ -5,7 +5,7 @@ import XCTest class VersionTest: XCTestCase { func test_versionValidSemanticVersion() { - // regex: https://regexr.com/65mto - XCTAssertTrue(SdkVersion.version.matches(regex: #"(\d+)\.(\d+)\.(\d+)(-alpha|-beta)*(\.\d+)*"#)) + // regex: https://regexr.com/662d5 + XCTAssertTrue(SdkVersion.version.matches(regex: #"(\d+)\.(\d+)\.(\d+)((-alpha|-beta)\.\d+)*"#)) } }