From dba2c2c1cb0db739135fac9feb57bfb4cb282ff0 Mon Sep 17 00:00:00 2001 From: Eli Gutovsky Date: Thu, 7 Dec 2023 09:48:19 +0200 Subject: [PATCH 1/6] feat: add push_notification decodable --- .../Sources/PushNotification.swift | 62 +++++++++++++++++++ .../Resources/notification-background.json | 20 ++++++ .../Tests/Resources/notification-badge.json | 20 ++++++ .../Tests/Resources/notification-buttons.json | 40 ++++++++++++ .../Resources/notification-deeplink.json | 24 +++++++ .../Tests/Resources/notification-image.json | 21 +++++++ .../Tests/Resources/notification-message.json | 18 ++++++ .../Tests/Sources/PushNotificationTests.swift | 59 ++++++++++++++++++ Package.swift | 10 ++- 9 files changed, 272 insertions(+), 2 deletions(-) create mode 100644 OptimoveNotificationServiceExtension/Sources/PushNotification.swift create mode 100644 OptimoveNotificationServiceExtension/Tests/Resources/notification-background.json create mode 100644 OptimoveNotificationServiceExtension/Tests/Resources/notification-badge.json create mode 100644 OptimoveNotificationServiceExtension/Tests/Resources/notification-buttons.json create mode 100644 OptimoveNotificationServiceExtension/Tests/Resources/notification-deeplink.json create mode 100644 OptimoveNotificationServiceExtension/Tests/Resources/notification-image.json create mode 100644 OptimoveNotificationServiceExtension/Tests/Resources/notification-message.json create mode 100644 OptimoveNotificationServiceExtension/Tests/Sources/PushNotificationTests.swift diff --git a/OptimoveNotificationServiceExtension/Sources/PushNotification.swift b/OptimoveNotificationServiceExtension/Sources/PushNotification.swift new file mode 100644 index 00000000..19e5cfd4 --- /dev/null +++ b/OptimoveNotificationServiceExtension/Sources/PushNotification.swift @@ -0,0 +1,62 @@ +// Copyright © 2023 Optimove. All rights reserved. + +/// Represents a push notification received from the server. +struct PushNotification: Decodable { + struct Button: Decodable { + struct Icon: Decodable { + enum IconType: String, Decodable { + case custom + case system + } + + let id: String + let type: IconType + } + + let id: String + let icon: Icon? + let text: String + } + + let id: Int + let badge: Int? + let buttons: [Button]? + let isBackground: Bool + let picturePath: String? + + private enum CodingKeys: String, CodingKey { + case a + case aps + case attachments + case badge = "badge_inc" + case buttons = "k.buttons" + case custom + case data + case deeplink = "k.deepLink" + case id + case isBackground = "content-available" + case message = "k.message" + case pictureUrl + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + let custom = try container.nestedContainer(keyedBy: CodingKeys.self, forKey: .custom) + self.badge = try custom.decodeIfPresent(Int.self, forKey: .badge) + + let a = try custom.nestedContainer(keyedBy: CodingKeys.self, forKey: .a) + self.buttons = try a.decodeIfPresent([Button].self, forKey: .buttons) + + let message = try a.nestedContainer(keyedBy: CodingKeys.self, forKey: .message) + let data = try message.nestedContainer(keyedBy: CodingKeys.self, forKey: .data) + self.id = try data.decode(Int.self, forKey: .id) + + let aps = try container.nestedContainer(keyedBy: CodingKeys.self, forKey: .aps) + let isBackground = try aps.decodeIfPresent(Int.self, forKey: .isBackground) + self.isBackground = isBackground == 1 ? true : false + + let attachments = try? container.nestedContainer(keyedBy: CodingKeys.self, forKey: .attachments) + self.picturePath = try attachments?.decodeIfPresent(String.self, forKey: .pictureUrl) + } +} diff --git a/OptimoveNotificationServiceExtension/Tests/Resources/notification-background.json b/OptimoveNotificationServiceExtension/Tests/Resources/notification-background.json new file mode 100644 index 00000000..451dc22a --- /dev/null +++ b/OptimoveNotificationServiceExtension/Tests/Resources/notification-background.json @@ -0,0 +1,20 @@ +{ + "aps": { + "alert": { + "title": "title", + "body": "body" + }, + "content-available": 1, + "mutable-content": 1 + }, + "custom": { + "a": { + "k.message": { + "type": 1, + "data": { + "id": 1 + } + } + } + } +} diff --git a/OptimoveNotificationServiceExtension/Tests/Resources/notification-badge.json b/OptimoveNotificationServiceExtension/Tests/Resources/notification-badge.json new file mode 100644 index 00000000..c60dbe8d --- /dev/null +++ b/OptimoveNotificationServiceExtension/Tests/Resources/notification-badge.json @@ -0,0 +1,20 @@ +{ + "aps": { + "alert": { + "title": "title", + "body": "body" + }, + "badge": 42 + }, + "custom": { + "badge_inc": 42, + "a": { + "k.message": { + "type": 1, + "data": { + "id": 1 + } + } + } + } +} diff --git a/OptimoveNotificationServiceExtension/Tests/Resources/notification-buttons.json b/OptimoveNotificationServiceExtension/Tests/Resources/notification-buttons.json new file mode 100644 index 00000000..4871228d --- /dev/null +++ b/OptimoveNotificationServiceExtension/Tests/Resources/notification-buttons.json @@ -0,0 +1,40 @@ +{ + "aps": { + "alert": { + "title": "title", + "body": "body" + } + }, + "custom": { + "a": { + "k.message": { + "type": 1, + "data": { + "id": 1 + } + }, + "k.buttons": [ + { + "id": "1", + "text": "action_1" + }, + { + "id": "2", + "text": "action_2", + "icon": { + "type": "system", + "id": "sys_icon_id" + } + }, + { + "id": "3", + "text": "action_3", + "icon": { + "type": "custom", + "id": "custom_icon_id" + } + } + ] + } + } +} diff --git a/OptimoveNotificationServiceExtension/Tests/Resources/notification-deeplink.json b/OptimoveNotificationServiceExtension/Tests/Resources/notification-deeplink.json new file mode 100644 index 00000000..dbdd2a5e --- /dev/null +++ b/OptimoveNotificationServiceExtension/Tests/Resources/notification-deeplink.json @@ -0,0 +1,24 @@ +{ + "aps": { + "alert": { + "title": "title", + "body": "body" + } + }, + "custom": { + "a": { + "k.message": { + "type": 1, + "data": { + "id": 1 + } + }, + "k.deepLink": { + "type": 2, + "data": { + "id": 1 + } + } + } + } +} diff --git a/OptimoveNotificationServiceExtension/Tests/Resources/notification-image.json b/OptimoveNotificationServiceExtension/Tests/Resources/notification-image.json new file mode 100644 index 00000000..5800a09e --- /dev/null +++ b/OptimoveNotificationServiceExtension/Tests/Resources/notification-image.json @@ -0,0 +1,21 @@ +{ + "aps": { + "alert": { + "title": "title", + "body": "body" + } + }, + "attachments": { + "pictureUrl": "B04wM4Y7/b2f69e254879d69b58c7418468213762.jpeg" + }, + "custom": { + "a": { + "k.message": { + "type": 1, + "data": { + "id": 1 + } + } + } + } +} diff --git a/OptimoveNotificationServiceExtension/Tests/Resources/notification-message.json b/OptimoveNotificationServiceExtension/Tests/Resources/notification-message.json new file mode 100644 index 00000000..1bc8d635 --- /dev/null +++ b/OptimoveNotificationServiceExtension/Tests/Resources/notification-message.json @@ -0,0 +1,18 @@ +{ + "aps": { + "alert": { + "title": "title", + "body": "body" + } + }, + "custom": { + "a": { + "k.message": { + "type": 1, + "data": { + "id": 1 + } + } + } + } +} diff --git a/OptimoveNotificationServiceExtension/Tests/Sources/PushNotificationTests.swift b/OptimoveNotificationServiceExtension/Tests/Sources/PushNotificationTests.swift new file mode 100644 index 00000000..829a83bc --- /dev/null +++ b/OptimoveNotificationServiceExtension/Tests/Sources/PushNotificationTests.swift @@ -0,0 +1,59 @@ +// Copyright © 2023 Optimove. All rights reserved. + +@testable import OptimoveNotificationServiceExtension +import OptimoveTest +import XCTest + +final class PushNotificationTests: XCTestCase, FileAccessible { + var fileName: String = "" + + func test_decode_id() throws { + fileName = "notification-message.json" + let decoder = JSONDecoder() + let notification = try decoder.decode(PushNotification.self, from: data) + XCTAssertEqual(notification.id, 1) + } + + func test_decode_badge() throws { + fileName = "notification-badge.json" + let decoder = JSONDecoder() + let notification = try decoder.decode(PushNotification.self, from: data) + XCTAssertEqual(notification.badge, 42) + } + + func test_decode_buttons() throws { + fileName = "notification-buttons.json" + let decoder = JSONDecoder() + let notification = try decoder.decode(PushNotification.self, from: data) + + XCTAssertEqual(notification.buttons?.count, 3) + XCTAssertEqual(notification.buttons?[0].id, "1") + XCTAssertEqual(notification.buttons?[0].text, "action_1") + + XCTAssertEqual(notification.buttons?[1].id, "2") + XCTAssertEqual(notification.buttons?[1].text, "action_2") + XCTAssertEqual(notification.buttons?[1].icon?.id, "sys_icon_id") + XCTAssertEqual(notification.buttons?[1].icon?.type, .system) + + XCTAssertEqual(notification.buttons?[2].id, "3") + XCTAssertEqual(notification.buttons?[2].text, "action_3") + XCTAssertEqual(notification.buttons?[2].icon?.id, "custom_icon_id") + XCTAssertEqual(notification.buttons?[2].icon?.type, .custom) + } + + func test_decode_image() throws { + fileName = "notification-image.json" + let decoder = JSONDecoder() + let notification = try decoder.decode(PushNotification.self, from: data) + + XCTAssertEqual(notification.picturePath, "B04wM4Y7/b2f69e254879d69b58c7418468213762.jpeg") + } + + func test_decode_background() throws { + fileName = "notification-background.json" + let decoder = JSONDecoder() + let notification = try decoder.decode(PushNotification.self, from: data) + + XCTAssertEqual(notification.isBackground, true) + } +} diff --git a/Package.swift b/Package.swift index 4342fc11..a4e642b1 100644 --- a/Package.swift +++ b/Package.swift @@ -78,8 +78,14 @@ let package = Package( ), .testTarget( name: "OptimoveNotificationServiceExtensionTests", - dependencies: ["OptimoveNotificationServiceExtension"], - path: "OptimoveNotificationServiceExtension/Tests" + dependencies: [ + "OptimoveNotificationServiceExtension", + "OptimoveTest", + ], + path: "OptimoveNotificationServiceExtension/Tests", + resources: [ + .process("Resources"), + ] ), ], swiftLanguageVersions: [.v5] From 04f8fe5bdc7303843a2924d0d443b94c9e822ac3 Mon Sep 17 00:00:00 2001 From: Eli Gutovsky Date: Thu, 7 Dec 2023 11:46:20 +0200 Subject: [PATCH 2/6] refactor: notification attachments --- OptimobileShared/MediaHelper.swift | 27 +- OptimobileShared/PendingNotification.swift | 6 + .../Sources/OptimoveNotificationService.swift | 246 ++++++------------ ...oveNotificationServiceExtensionTests.swift | 54 ++-- .../Classes/Optimobile/OptimoveInApp.swift | 5 +- Package.swift | 3 +- 6 files changed, 155 insertions(+), 186 deletions(-) diff --git a/OptimobileShared/MediaHelper.swift b/OptimobileShared/MediaHelper.swift index 066077b3..bbffa4d2 100644 --- a/OptimobileShared/MediaHelper.swift +++ b/OptimobileShared/MediaHelper.swift @@ -3,17 +3,28 @@ import Foundation enum MediaHelper { - /// Use ``Region.US`` as fallback region. - static let mediaResizerBaseUrl: String = "https://i-us-east-1.app.delivery" + enum Error: LocalizedError { + case noMediaUrlFound + case invalidPictureUrl(String) + } + + static func getCompletePictureUrl(pictureUrlString: String, width: UInt) throws -> URL { + if pictureUrlString.hasPrefix("https://") || pictureUrlString.hasPrefix("http://") { + guard let url = URL(string: pictureUrlString) else { + throw Error.invalidPictureUrl(pictureUrlString) + } + return url + } - static func getCompletePictureUrl(pictureUrl: String, width: UInt) -> URL? { - if pictureUrl.hasPrefix("https://") || pictureUrl.hasPrefix("http://") { - return URL(string: pictureUrl) + guard let mediaUrl = KeyValPersistenceHelper.object(forKey: OptimobileUserDefaultsKey.MEDIA_BASE_URL.rawValue) as? String else { + throw Error.noMediaUrlFound } - let baseUrl = KeyValPersistenceHelper.object(forKey: OptimobileUserDefaultsKey.MEDIA_BASE_URL.rawValue) as? String ?? mediaResizerBaseUrl + let urlString = "\(mediaUrl)/\(width)x/\(pictureUrlString)" + guard let url = URL(string: urlString) else { + throw Error.invalidPictureUrl(urlString) + } - let completeString = "\(baseUrl)/\(width)x/\(pictureUrl)" - return URL(string: completeString) + return url } } diff --git a/OptimobileShared/PendingNotification.swift b/OptimobileShared/PendingNotification.swift index b4003d74..ffe747e1 100644 --- a/OptimobileShared/PendingNotification.swift +++ b/OptimobileShared/PendingNotification.swift @@ -6,4 +6,10 @@ struct PendingNotification: Codable { let id: Int let deliveredAt: Date let identifier: String + + init(id: Int, deliveredAt: Date = .init(), identifier: String) { + self.id = id + self.deliveredAt = deliveredAt + self.identifier = identifier + } } diff --git a/OptimoveNotificationServiceExtension/Sources/OptimoveNotificationService.swift b/OptimoveNotificationServiceExtension/Sources/OptimoveNotificationService.swift index 22a29e03..36af5920 100644 --- a/OptimoveNotificationServiceExtension/Sources/OptimoveNotificationService.swift +++ b/OptimoveNotificationServiceExtension/Sources/OptimoveNotificationService.swift @@ -5,193 +5,128 @@ import UIKit import UserNotifications public class OptimoveNotificationService { - private static let syncBarrier = DispatchSemaphore(value: 0) + enum Error: String, LocalizedError { + case noBestAttemptContent + case userInfoNotValid + } - public class func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { - let bestAttemptContent = (request.content.mutableCopy() as! UNMutableNotificationContent) - let userInfo = request.content.userInfo + public class func didReceive( + _ request: UNNotificationRequest, + withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void + ) { + Task { + do { + let bestAttemptContent = try await didReceive(request) + contentHandler(bestAttemptContent) + } catch { + assertionFailure(error.localizedDescription) + contentHandler(request.content) + } + } + } - if !validateUserInfo(userInfo: userInfo) { - return + class func didReceive(_ request: UNNotificationRequest) async throws -> UNNotificationContent { + guard let bestAttemptContent = request.content.mutableCopy() as? UNMutableNotificationContent else { + throw Error.noBestAttemptContent } - let custom = userInfo["custom"] as! [AnyHashable: Any] - let data = custom["a"] as! [AnyHashable: Any] + let userInfo = request.content.userInfo + guard JSONSerialization.isValidJSONObject(userInfo) else { + throw Error.userInfoNotValid + } - let msg = data["k.message"] as! [AnyHashable: Any] - let msgData = msg["data"] as! [AnyHashable: Any] - let id = msgData["id"] as! Int + let data = try JSONSerialization.data(withJSONObject: userInfo) + let notification = try JSONDecoder().decode(PushNotification.self, from: data) if bestAttemptContent.categoryIdentifier == "" { - let actionButtons = getButtons(userInfo: userInfo, bestAttemptContent: bestAttemptContent) - - addCategory(bestAttemptContent: bestAttemptContent, actionArray: actionButtons, id: id) + bestAttemptContent.categoryIdentifier = buildCategorIdentifier(notification: notification) } - let dispatchGroup = DispatchGroup() - - maybeAddImageAttachment(dispatchGroup: dispatchGroup, userInfo: userInfo, bestAttemptContent: bestAttemptContent) - if AppGroupsHelper.isKumulosAppGroupDefined() { - maybeSetBadge(bestAttemptContent: bestAttemptContent, userInfo: userInfo) - PendingNotificationHelper.add(notification: PendingNotification(id: id, deliveredAt: Date(), identifier: request.identifier)) - } - - dispatchGroup.notify(queue: .main) { - contentHandler(bestAttemptContent) - } - } - - private class func validateUserInfo(userInfo: [AnyHashable: Any]) -> Bool { - var dict: [AnyHashable: Any] = userInfo - let keysInOrder = ["custom", "a", "k.message", "data"] - - for key in keysInOrder { - if dict[key] == nil { - return false + if let attachment = try await maybeGetAttachment(notification: notification) { + bestAttemptContent.attachments = [attachment] } - - dict = dict[key] as! [AnyHashable: Any] - } - - if dict["id"] == nil { - return false + maybeSetBadge(bestAttemptContent: bestAttemptContent, userInfo: userInfo) + PendingNotificationHelper.add( + notification: PendingNotification( + id: notification.id, + identifier: request.identifier + ) + ) } - return true + return bestAttemptContent } - private class func getButtons(userInfo: [AnyHashable: Any], bestAttemptContent _: UNMutableNotificationContent) -> NSMutableArray { - let actionArray = NSMutableArray() - - let custom = userInfo["custom"] as! [AnyHashable: Any] - let data = custom["a"] as! [AnyHashable: Any] - - let buttons = data["k.buttons"] as? NSArray - - if buttons == nil || buttons!.count == 0 { - return actionArray - } - - for button in buttons! { - let buttonDict = button as! [AnyHashable: Any] - - let id = buttonDict["id"] as! String - let text = buttonDict["text"] as! String - + class func buildActions(notification: PushNotification) -> [UNNotificationAction] { + return notification.buttons?.map { button in if #available(iOS 15.0, *) { - let icon = getButtonIcon(button: buttonDict) - let action = UNNotificationAction(identifier: id, title: text, options: .foreground, icon: icon) - actionArray.add(action) + return UNNotificationAction( + identifier: button.id, + title: button.text, + options: .foreground, + icon: buildIcon(button: button) + ) } else { - let action = UNNotificationAction(identifier: id, title: text, options: .foreground) - actionArray.add(action) + return UNNotificationAction( + identifier: button.id, + title: button.text, + options: .foreground + ) } - } - - return actionArray + } ?? [] } @available(iOS 15.0, *) - private class func getButtonIcon(button: [AnyHashable: Any]) -> UNNotificationActionIcon? { - guard let icon = button["icon"] as? [String: String], let iconType = icon["type"], let iconId = icon["id"] else { - return nil + class func buildIcon(button: PushNotification.Button) -> UNNotificationActionIcon? { + guard let icon = button.icon else { return nil } + switch icon.type { + case .custom: + return UNNotificationActionIcon(templateImageName: icon.id) + case .system: + return UNNotificationActionIcon(systemImageName: icon.id) } - - if iconType == "custom" { - // TODO: - What if this doesnt exist? Catch exception -> return nil? - return UNNotificationActionIcon(templateImageName: iconId) - } - - return UNNotificationActionIcon(systemImageName: iconId) } - private class func addCategory(bestAttemptContent: UNMutableNotificationContent, actionArray: NSMutableArray, id: Int) { - let categoryIdentifier = CategoryManager.getCategoryIdForMessageId(messageId: id) + class func buildCategorIdentifier(notification: PushNotification) -> String { + let categoryIdentifier = CategoryManager.getCategoryIdForMessageId(messageId: notification.id) - let category = UNNotificationCategory(identifier: categoryIdentifier, actions: actionArray as! [UNNotificationAction], intentIdentifiers: [], options: .customDismissAction) + let category = UNNotificationCategory( + identifier: categoryIdentifier, + actions: buildActions(notification: notification), + intentIdentifiers: [], + options: .customDismissAction + ) CategoryManager.registerCategory(category: category) - bestAttemptContent.categoryIdentifier = categoryIdentifier + return categoryIdentifier } - private class func maybeAddImageAttachment(dispatchGroup: DispatchGroup, userInfo: [AnyHashable: Any], bestAttemptContent: UNMutableNotificationContent) { - let attachments = userInfo["attachments"] as? [AnyHashable: Any] - let pictureUrl = attachments?["pictureUrl"] as? String - - guard let picUrlNonNull = pictureUrl else { return } - - let picExtension = getPictureExtension(picUrlNonNull) - let url = MediaHelper.getCompletePictureUrl(pictureUrl: picUrlNonNull as String, width: UInt(floor(UIScreen.main.bounds.size.width))) - - dispatchGroup.enter() - - loadAttachment(url!, withExtension: picExtension, completionHandler: { attachment in - if attachment != nil { - bestAttemptContent.attachments = [attachment!] - } - dispatchGroup.leave() - }) - } + class func maybeGetAttachment(notification: PushNotification) async throws -> UNNotificationAttachment? { + guard let picturePath = notification.picturePath else { return nil } - private class func getPictureExtension(_ pictureUrl: String?) -> String? { - if pictureUrl == nil { - return nil - } - let pictureExtension = URL(fileURLWithPath: pictureUrl!).pathExtension - if pictureExtension == "" { - return nil - } + let url = try await MediaHelper.getCompletePictureUrl( + pictureUrlString: picturePath, + width: UInt(floor(UIScreen.main.bounds.size.width)) + ) - return "." + pictureExtension + return try await downloadAttachment(url: url) } - private class func loadAttachment(_ url: URL, withExtension pictureExtension: String?, completionHandler: @escaping (UNNotificationAttachment?) -> Void) { - let session = URLSession(configuration: URLSessionConfiguration.default) - - (session.downloadTask(with: url, completionHandler: { temporaryFileLocation, response, error in - if error != nil { - print("NotificationServiceExtension: \(error!.localizedDescription)") - completionHandler(nil) - return - } - - var finalExt = pictureExtension - if finalExt == nil { - finalExt = self.getPictureExtension(response?.suggestedFilename) - if finalExt == nil { - completionHandler(nil) - return - } - } - - if temporaryFileLocation == nil { - completionHandler(nil) - return - } - - let fileManager = FileManager.default - let localURL = URL(fileURLWithPath: temporaryFileLocation!.path + finalExt!) - do { - try fileManager.moveItem(at: temporaryFileLocation!, to: localURL) - } catch { - completionHandler(nil) - return - } - - var attachment: UNNotificationAttachment? - do { - attachment = try UNNotificationAttachment(identifier: "", url: localURL, options: nil) - } catch { - print("NotificationServiceExtension: attachment error: \(error.localizedDescription)") - } - - completionHandler(attachment) - })).resume() + class func downloadAttachment(url: URL) async throws -> UNNotificationAttachment { + let (data, response) = try await URLSession.shared.data(from: url) + let tempURL = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent(UUID().uuidString) + .appendingPathExtension((response.url ?? url).pathExtension) + try data.write(to: tempURL) + return try UNNotificationAttachment( + identifier: "", + url: tempURL + ) } - private class func maybeSetBadge(bestAttemptContent: UNMutableNotificationContent, userInfo: [AnyHashable: Any]) { + class func maybeSetBadge(bestAttemptContent: UNMutableNotificationContent, userInfo: [AnyHashable: Any]) { let aps = userInfo["aps"] as! [AnyHashable: Any] if let contentAvailable = aps["content-available"] as? Int, contentAvailable == 1 { return @@ -205,13 +140,4 @@ public class OptimoveNotificationService { bestAttemptContent.badge = newBadge KeyValPersistenceHelper.set(newBadge, forKey: OptimobileUserDefaultsKey.BADGE_COUNT.rawValue) } - - private class func isBackgroundPush(userInfo: [AnyHashable: Any]) -> Bool { - let aps = userInfo["aps"] as! [AnyHashable: Any] - if let contentAvailable = aps["content-available"] as? Int, contentAvailable == 1 { - return true - } - - return false - } } diff --git a/OptimoveNotificationServiceExtension/Tests/Sources/OptimoveNotificationServiceExtensionTests.swift b/OptimoveNotificationServiceExtension/Tests/Sources/OptimoveNotificationServiceExtensionTests.swift index ca1d81cc..b9ee619d 100644 --- a/OptimoveNotificationServiceExtension/Tests/Sources/OptimoveNotificationServiceExtensionTests.swift +++ b/OptimoveNotificationServiceExtension/Tests/Sources/OptimoveNotificationServiceExtensionTests.swift @@ -1,28 +1,52 @@ // Copyright © 2023 Optimove. All rights reserved. +@testable import OptimoveNotificationServiceExtension import XCTest final class OptimoveNotificationServiceExtensionTests: XCTestCase { - override func setUpWithError() throws { - // Put setup code here. This method is called before the invocation of each test method in the class. + func test_download_attachment() async throws { + let url = URL(string: "https://picsum.photos/200")! + let attachment = try await OptimoveNotificationService.downloadAttachment(url: url) + XCTAssertNotNil(attachment) + XCTAssertTrue( + FileManager.default.fileExists(atPath: attachment.url.path), + "File should exist at path: \(attachment.url.path)" + ) } - override func tearDownWithError() throws { - // Put teardown code here. This method is called after the invocation of each test method in the class. + func test_download_attachment_in_the_process() async throws { + let url = URL(string: "https://picsum.photos/200")! + for _ in 0 ... 10 { + let attachment = try await OptimoveNotificationService.downloadAttachment(url: url) + XCTAssertNotNil(attachment) + XCTAssertTrue( + FileManager.default.fileExists(atPath: attachment.url.path), + "File should exist at path: \(attachment.url.path)" + ) + } } - func testExample() throws { - // This is an example of a functional test case. - // Use XCTAssert and related functions to verify your tests produce the correct results. - // Any test you write for XCTest can be annotated as throws and async. - // Mark your test throws to produce an unexpected failure when your test encounters an uncaught error. - // Mark your test async to allow awaiting for asynchronous code to complete. Check the results with assertions afterwards. + func test_download_attachment_withInvalidUrl() async throws { + let url = URL(string: "https://picsum.photos/invalid")! + do { + _ = try await OptimoveNotificationService.downloadAttachment(url: url) + XCTFail("Should throw an error") + } catch { + XCTAssertNotNil(error) + } } - func testPerformanceExample() throws { - // This is an example of a performance test case. - self.measure { - // Put the code you want to measure the time of here. - } + func test_getCompletePictureUrl() throws { + let pictureUrlString = "https://www.optimove.com/wp-content/uploads/2018/12/optimove-logo.png" + let url = try MediaHelper.getCompletePictureUrl(pictureUrlString: pictureUrlString, width: 100) + XCTAssertEqual(url.absoluteString, pictureUrlString) + } + + func test_getCompletePictureUrl_withMediaUrl() throws { + let pictureUrlString = "B04wM4Y7/b2f69e254879d69b58c7418468213762.jpeg" + let mediaUrl = "https://www.optimove.com" + KeyValPersistenceHelper.set(mediaUrl, forKey: OptimobileUserDefaultsKey.MEDIA_BASE_URL.rawValue) + let url = try MediaHelper.getCompletePictureUrl(pictureUrlString: pictureUrlString, width: 100) + XCTAssertEqual(url.absoluteString, "\(mediaUrl)/100x/\(pictureUrlString)") } } diff --git a/OptimoveSDK/Sources/Classes/Optimobile/OptimoveInApp.swift b/OptimoveSDK/Sources/Classes/Optimobile/OptimoveInApp.swift index 68f9f556..7c5cc9d3 100644 --- a/OptimoveSDK/Sources/Classes/Optimobile/OptimoveInApp.swift +++ b/OptimoveSDK/Sources/Classes/Optimobile/OptimoveInApp.swift @@ -60,7 +60,10 @@ public class InAppInboxItem { public func getImageUrl(width: UInt) -> URL? { if let imagePathNotNil = imagePath { - return MediaHelper.getCompletePictureUrl(pictureUrl: imagePathNotNil, width: width) + return try? MediaHelper.getCompletePictureUrl( + pictureUrlString: imagePathNotNil, + width: width + ) } return nil diff --git a/Package.swift b/Package.swift index a4e642b1..38d46b81 100644 --- a/Package.swift +++ b/Package.swift @@ -1,12 +1,11 @@ // swift-tools-version:5.3 -// The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription let package = Package( name: "Optimove", platforms: [ - .iOS(.v10), + .iOS(.v13), .macOS(.v10_14), ], products: [ From ae3dacaa9be56860f4d212bb148346117911c9ac Mon Sep 17 00:00:00 2001 From: Eli Gutovsky Date: Thu, 7 Dec 2023 13:10:16 +0200 Subject: [PATCH 3/6] chore: add mutable-content flag --- .../Tests/Resources/notification-buttons.json | 3 ++- .../Tests/Resources/notification-image.json | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/OptimoveNotificationServiceExtension/Tests/Resources/notification-buttons.json b/OptimoveNotificationServiceExtension/Tests/Resources/notification-buttons.json index 4871228d..0abe1aab 100644 --- a/OptimoveNotificationServiceExtension/Tests/Resources/notification-buttons.json +++ b/OptimoveNotificationServiceExtension/Tests/Resources/notification-buttons.json @@ -3,7 +3,8 @@ "alert": { "title": "title", "body": "body" - } + }, + "mutable-content": 1 }, "custom": { "a": { diff --git a/OptimoveNotificationServiceExtension/Tests/Resources/notification-image.json b/OptimoveNotificationServiceExtension/Tests/Resources/notification-image.json index 5800a09e..3f3f004c 100644 --- a/OptimoveNotificationServiceExtension/Tests/Resources/notification-image.json +++ b/OptimoveNotificationServiceExtension/Tests/Resources/notification-image.json @@ -3,7 +3,8 @@ "alert": { "title": "title", "body": "body" - } + }, + "mutable-content": 1 }, "attachments": { "pictureUrl": "B04wM4Y7/b2f69e254879d69b58c7418468213762.jpeg" From e6ea51b0224cf5e5d6e648b5d5fbea52b6f7914d Mon Sep 17 00:00:00 2001 From: Eli Gutovsky Date: Thu, 7 Dec 2023 13:24:02 +0200 Subject: [PATCH 4/6] refactor: use static instead of class --- .../Sources/OptimoveNotificationService.swift | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/OptimoveNotificationServiceExtension/Sources/OptimoveNotificationService.swift b/OptimoveNotificationServiceExtension/Sources/OptimoveNotificationService.swift index 36af5920..14f025d9 100644 --- a/OptimoveNotificationServiceExtension/Sources/OptimoveNotificationService.swift +++ b/OptimoveNotificationServiceExtension/Sources/OptimoveNotificationService.swift @@ -4,13 +4,13 @@ import Foundation import UIKit import UserNotifications -public class OptimoveNotificationService { +public enum OptimoveNotificationService { enum Error: String, LocalizedError { case noBestAttemptContent case userInfoNotValid } - public class func didReceive( + public static func didReceive( _ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void ) { @@ -25,7 +25,7 @@ public class OptimoveNotificationService { } } - class func didReceive(_ request: UNNotificationRequest) async throws -> UNNotificationContent { + static func didReceive(_ request: UNNotificationRequest) async throws -> UNNotificationContent { guard let bestAttemptContent = request.content.mutableCopy() as? UNMutableNotificationContent else { throw Error.noBestAttemptContent } @@ -58,7 +58,7 @@ public class OptimoveNotificationService { return bestAttemptContent } - class func buildActions(notification: PushNotification) -> [UNNotificationAction] { + static func buildActions(notification: PushNotification) -> [UNNotificationAction] { return notification.buttons?.map { button in if #available(iOS 15.0, *) { return UNNotificationAction( @@ -78,7 +78,7 @@ public class OptimoveNotificationService { } @available(iOS 15.0, *) - class func buildIcon(button: PushNotification.Button) -> UNNotificationActionIcon? { + static func buildIcon(button: PushNotification.Button) -> UNNotificationActionIcon? { guard let icon = button.icon else { return nil } switch icon.type { case .custom: @@ -88,7 +88,7 @@ public class OptimoveNotificationService { } } - class func buildCategorIdentifier(notification: PushNotification) -> String { + static func buildCategorIdentifier(notification: PushNotification) -> String { let categoryIdentifier = CategoryManager.getCategoryIdForMessageId(messageId: notification.id) let category = UNNotificationCategory( @@ -103,7 +103,7 @@ public class OptimoveNotificationService { return categoryIdentifier } - class func maybeGetAttachment(notification: PushNotification) async throws -> UNNotificationAttachment? { + static func maybeGetAttachment(notification: PushNotification) async throws -> UNNotificationAttachment? { guard let picturePath = notification.picturePath else { return nil } let url = try await MediaHelper.getCompletePictureUrl( @@ -114,11 +114,11 @@ public class OptimoveNotificationService { return try await downloadAttachment(url: url) } - class func downloadAttachment(url: URL) async throws -> UNNotificationAttachment { + static func downloadAttachment(url: URL) async throws -> UNNotificationAttachment { let (data, response) = try await URLSession.shared.data(from: url) let tempURL = URL(fileURLWithPath: NSTemporaryDirectory()) - .appendingPathComponent(UUID().uuidString) - .appendingPathExtension((response.url ?? url).pathExtension) + .appendingPathComponent(UUID().uuidString) + .appendingPathExtension((response.url ?? url).pathExtension) try data.write(to: tempURL) return try UNNotificationAttachment( identifier: "", @@ -126,7 +126,7 @@ public class OptimoveNotificationService { ) } - class func maybeSetBadge(bestAttemptContent: UNMutableNotificationContent, userInfo: [AnyHashable: Any]) { + static func maybeSetBadge(bestAttemptContent: UNMutableNotificationContent, userInfo: [AnyHashable: Any]) { let aps = userInfo["aps"] as! [AnyHashable: Any] if let contentAvailable = aps["content-available"] as? Int, contentAvailable == 1 { return From f8088bf1cc55295e957caa1d18fda639ee0e97c2 Mon Sep 17 00:00:00 2001 From: Eli Gutovsky Date: Thu, 7 Dec 2023 13:46:14 +0200 Subject: [PATCH 5/6] refactor: category manager --- .../Sources/CategoryManager.swift | 101 ++++++------------ .../Sources/OptimoveNotificationService.swift | 17 ++- .../Tests/Sources/CategoryManagerTests.swift | 65 +++++++++++ 3 files changed, 103 insertions(+), 80 deletions(-) create mode 100644 OptimoveNotificationServiceExtension/Tests/Sources/CategoryManagerTests.swift diff --git a/OptimoveNotificationServiceExtension/Sources/CategoryManager.swift b/OptimoveNotificationServiceExtension/Sources/CategoryManager.swift index def072a2..19db26c6 100644 --- a/OptimoveNotificationServiceExtension/Sources/CategoryManager.swift +++ b/OptimoveNotificationServiceExtension/Sources/CategoryManager.swift @@ -1,92 +1,55 @@ // Copyright © 2022 Optimove. All rights reserved. -import Foundation import UserNotifications -let MAX_DYNAMIC_CATEGORIES = 128 -let DYNAMIC_CATEGORY_IDENTIFIER = "__kumulos_category_%d__" - -@available(iOS 10.0, *) -class CategoryManager { - let categoryReadLock = DispatchSemaphore(value: 0) - let dynamicCategoryLock = DispatchSemaphore(value: 1) - - fileprivate static var instance: CategoryManager? - - static var sharedInstance: CategoryManager { - if instance == nil { - instance = CategoryManager() - } - - return instance! +enum CategoryManager { + enum Constants { + static let MAX_DYNAMIC_CATEGORIES = 128 + static let DYNAMIC_CATEGORY = OptimobileUserDefaultsKey.DYNAMIC_CATEGORY.rawValue } - static func getCategoryIdForMessageId(messageId: Int) -> String { - return String(format: DYNAMIC_CATEGORY_IDENTIFIER, messageId) + static func getCategoryId(messageId: Int) -> String { + return "__kumulos_category_\(messageId)__" } - static func registerCategory(category: UNNotificationCategory) { - var categorySet = sharedInstance.getExistingCategories() - var storedDynamicCategories = sharedInstance.getExistingDynamicCategoriesList() + static func registerCategory(_ category: UNNotificationCategory) async { + var systemCategories = await UNUserNotificationCenter.current().notificationCategories() + var storedCategoryIds = readCategoryIds() + + systemCategories.insert(category) + storedCategoryIds.insert(category.identifier) - categorySet.insert(category) - storedDynamicCategories.append(category.identifier) + let (categories, categoryIds) = maybePruneCategories(categories: systemCategories, categoryIds: storedCategoryIds) - sharedInstance.pruneCategoriesAndSave(categories: categorySet, dynamicCategories: storedDynamicCategories) + UNUserNotificationCenter.current().setNotificationCategories(categories) + writeCategoryIds(categoryIds) // Force a reload of the categories - _ = sharedInstance.getExistingCategories() + await UNUserNotificationCenter.current().notificationCategories() } - private func getExistingCategories() -> Set { - var returnedCategories = Set() - - UNUserNotificationCenter.current().getNotificationCategories { (categories: Set) in - returnedCategories = Set(categories) - - self.categoryReadLock.signal() - } - - _ = categoryReadLock.wait(timeout: DispatchTime.now() + DispatchTimeInterval.seconds(5)) - - return returnedCategories + static func readCategoryIds() -> Set { + let array = UserDefaults.standard.object(forKey: Constants.DYNAMIC_CATEGORY) as? [String] ?? [] + return Set(array) } - private func getExistingDynamicCategoriesList() -> [String] { - dynamicCategoryLock.wait() - defer { - dynamicCategoryLock.signal() - } - - if let existingArray = UserDefaults.standard.object(forKey: OptimobileUserDefaultsKey.DYNAMIC_CATEGORY.rawValue) { - return existingArray as! [String] - } - - let newArray = [String]() - - UserDefaults.standard.set(newArray, forKey: OptimobileUserDefaultsKey.DYNAMIC_CATEGORY.rawValue) - - return newArray + static func writeCategoryIds(_ ids: Set) { + UserDefaults.standard.set(Array(ids), forKey: Constants.DYNAMIC_CATEGORY) } - private func pruneCategoriesAndSave(categories: Set, dynamicCategories: [String]) { - if dynamicCategories.count <= MAX_DYNAMIC_CATEGORIES { - UNUserNotificationCenter.current().setNotificationCategories(categories) - UserDefaults.standard.set(dynamicCategories, forKey: OptimobileUserDefaultsKey.DYNAMIC_CATEGORY.rawValue) - return + static func maybePruneCategories( + categories: Set, + categoryIds: Set, + limit: Int = Constants.MAX_DYNAMIC_CATEGORIES + ) -> (categories: Set, categoryIds: Set) { + if categoryIds.count <= limit, categories.count <= limit { + return (categories: categories, categoryIds: categoryIds) } - let categoriesToRemove = dynamicCategories.prefix(dynamicCategories.count - MAX_DYNAMIC_CATEGORIES) - - let prunedCategories = categories.filter { category -> Bool in - categoriesToRemove.firstIndex(of: category.identifier) == nil - } - - let prunedDynamicCategories = dynamicCategories.filter { cat -> Bool in - categoriesToRemove.firstIndex(of: cat) == nil - } + let categoriesToRemove = categoryIds.prefix(categoryIds.count - limit) + let prunedCategories = categories.filter { !categoriesToRemove.contains($0.identifier) } + let prunedCategoryIds = categoryIds.subtracting(categoriesToRemove) - UNUserNotificationCenter.current().setNotificationCategories(prunedCategories) - UserDefaults.standard.set(prunedDynamicCategories, forKey: OptimobileUserDefaultsKey.DYNAMIC_CATEGORY.rawValue) + return (categories: prunedCategories, categoryIds: prunedCategoryIds) } } diff --git a/OptimoveNotificationServiceExtension/Sources/OptimoveNotificationService.swift b/OptimoveNotificationServiceExtension/Sources/OptimoveNotificationService.swift index 14f025d9..4235fcbd 100644 --- a/OptimoveNotificationServiceExtension/Sources/OptimoveNotificationService.swift +++ b/OptimoveNotificationServiceExtension/Sources/OptimoveNotificationService.swift @@ -29,19 +29,15 @@ public enum OptimoveNotificationService { guard let bestAttemptContent = request.content.mutableCopy() as? UNMutableNotificationContent else { throw Error.noBestAttemptContent } - let userInfo = request.content.userInfo guard JSONSerialization.isValidJSONObject(userInfo) else { throw Error.userInfoNotValid } - let data = try JSONSerialization.data(withJSONObject: userInfo) let notification = try JSONDecoder().decode(PushNotification.self, from: data) - - if bestAttemptContent.categoryIdentifier == "" { - bestAttemptContent.categoryIdentifier = buildCategorIdentifier(notification: notification) + if bestAttemptContent.categoryIdentifier.isEmpty { + bestAttemptContent.categoryIdentifier = await buildCategory(notification: notification) } - if AppGroupsHelper.isKumulosAppGroupDefined() { if let attachment = try await maybeGetAttachment(notification: notification) { bestAttemptContent.attachments = [attachment] @@ -88,17 +84,15 @@ public enum OptimoveNotificationService { } } - static func buildCategorIdentifier(notification: PushNotification) -> String { - let categoryIdentifier = CategoryManager.getCategoryIdForMessageId(messageId: notification.id) - + static func buildCategory(notification: PushNotification) async -> String { + let categoryIdentifier = CategoryManager.getCategoryId(messageId: notification.id) let category = UNNotificationCategory( identifier: categoryIdentifier, actions: buildActions(notification: notification), intentIdentifiers: [], options: .customDismissAction ) - - CategoryManager.registerCategory(category: category) + await CategoryManager.registerCategory(category) return categoryIdentifier } @@ -120,6 +114,7 @@ public enum OptimoveNotificationService { .appendingPathComponent(UUID().uuidString) .appendingPathExtension((response.url ?? url).pathExtension) try data.write(to: tempURL) + return try UNNotificationAttachment( identifier: "", url: tempURL diff --git a/OptimoveNotificationServiceExtension/Tests/Sources/CategoryManagerTests.swift b/OptimoveNotificationServiceExtension/Tests/Sources/CategoryManagerTests.swift new file mode 100644 index 00000000..6d7e543a --- /dev/null +++ b/OptimoveNotificationServiceExtension/Tests/Sources/CategoryManagerTests.swift @@ -0,0 +1,65 @@ +// Copyright © 2023 Optimove. All rights reserved. + +@testable import OptimoveNotificationServiceExtension +import XCTest + +final class CategoryManagerTests: XCTestCase { + override func tearDown() async throws { + UserDefaults.standard.removeObject(forKey: CategoryManager.Constants.DYNAMIC_CATEGORY) + } + + func test_category_id() { + let id = CategoryManager.getCategoryId(messageId: 123) + XCTAssertEqual(id, "__kumulos_category_123__") + } + + func test_read_dynamic_categories() async throws { + let categories = CategoryManager.readCategoryIds() + XCTAssertEqual(categories.count, 0) + } + + func test_write_dynamic_categories() async throws { + let categories = CategoryManager.readCategoryIds() + XCTAssertEqual(categories.count, 0) + + CategoryManager.writeCategoryIds(["category1", "category2"]) + + let newCategories = CategoryManager.readCategoryIds() + XCTAssertEqual(newCategories.count, 2) + } + + func test_filter_pruned_categories() async throws { + let categories: Set = [ + UNNotificationCategory(identifier: "category1", actions: [], intentIdentifiers: [], options: []), + UNNotificationCategory(identifier: "category2", actions: [], intentIdentifiers: [], options: []), + UNNotificationCategory(identifier: "category3", actions: [], intentIdentifiers: [], options: []), + ] + let categoryIds = Set(["category1", "category2", "category3"]) + + let (prunedCategories, prunedCategoryIds) = CategoryManager.maybePruneCategories( + categories: categories, + categoryIds: categoryIds + ) + + XCTAssertEqual(prunedCategories.count, 3) + XCTAssertEqual(prunedCategoryIds.count, 3) + } + + func test_filter_pruned_categories_with_limit() async throws { + let categories: Set = [ + UNNotificationCategory(identifier: "category1", actions: [], intentIdentifiers: [], options: []), + UNNotificationCategory(identifier: "category2", actions: [], intentIdentifiers: [], options: []), + UNNotificationCategory(identifier: "category3", actions: [], intentIdentifiers: [], options: []), + ] + let categoryIds = Set(["category1", "category2", "category3"]) + + let (prunedCategories, prunedCategoryIds) = CategoryManager.maybePruneCategories( + categories: categories, + categoryIds: categoryIds, + limit: 2 + ) + + XCTAssertEqual(prunedCategories.count, 2) + XCTAssertEqual(prunedCategoryIds.count, 2) + } +} From 20a8284ed59acf7e97f7c85817b181d8da8e7af2 Mon Sep 17 00:00:00 2001 From: Eli Gutovsky Date: Thu, 7 Dec 2023 14:16:45 +0200 Subject: [PATCH 6/6] chore: change notification fixtures --- .../Tests/Resources/notification-background.json | 3 +-- .../Tests/Resources/notification-badge.json | 3 ++- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/OptimoveNotificationServiceExtension/Tests/Resources/notification-background.json b/OptimoveNotificationServiceExtension/Tests/Resources/notification-background.json index 451dc22a..4c85cdf2 100644 --- a/OptimoveNotificationServiceExtension/Tests/Resources/notification-background.json +++ b/OptimoveNotificationServiceExtension/Tests/Resources/notification-background.json @@ -4,8 +4,7 @@ "title": "title", "body": "body" }, - "content-available": 1, - "mutable-content": 1 + "content-available": 1 }, "custom": { "a": { diff --git a/OptimoveNotificationServiceExtension/Tests/Resources/notification-badge.json b/OptimoveNotificationServiceExtension/Tests/Resources/notification-badge.json index c60dbe8d..f59e75df 100644 --- a/OptimoveNotificationServiceExtension/Tests/Resources/notification-badge.json +++ b/OptimoveNotificationServiceExtension/Tests/Resources/notification-badge.json @@ -4,7 +4,8 @@ "title": "title", "body": "body" }, - "badge": 42 + "badge": 42, + "mutable-content": 1 }, "custom": { "badge_inc": 42,