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+)*"#))
}
}