From fca76849bc40c0cafda73dce226660f634b6237b Mon Sep 17 00:00:00 2001 From: Thomas Ricouard Date: Tue, 13 Aug 2024 09:55:51 +0200 Subject: [PATCH] Fix push notification --- IceCubesApp.xcodeproj/project.pbxproj | 4 +- .../xcschemes/IceCubesNotifications.xcscheme | 10 +- .../NotificationService.swift | 158 ++++++++++-------- .../Env/PushNotificationsService.swift | 8 - 4 files changed, 98 insertions(+), 82 deletions(-) diff --git a/IceCubesApp.xcodeproj/project.pbxproj b/IceCubesApp.xcodeproj/project.pbxproj index 3a3d2b3a..512ce8a8 100644 --- a/IceCubesApp.xcodeproj/project.pbxproj +++ b/IceCubesApp.xcodeproj/project.pbxproj @@ -1203,7 +1203,7 @@ SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2,7"; }; name = Debug; @@ -1238,7 +1238,7 @@ SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2,7"; VALIDATE_PRODUCT = YES; }; diff --git a/IceCubesApp.xcodeproj/xcshareddata/xcschemes/IceCubesNotifications.xcscheme b/IceCubesApp.xcodeproj/xcshareddata/xcschemes/IceCubesNotifications.xcscheme index c53de172..06441acb 100644 --- a/IceCubesApp.xcodeproj/xcshareddata/xcschemes/IceCubesNotifications.xcscheme +++ b/IceCubesApp.xcodeproj/xcshareddata/xcschemes/IceCubesNotifications.xcscheme @@ -56,8 +56,12 @@ debugServiceExtension = "internal" allowLocationSimulation = "YES" launchAutomaticallySubstyle = "2"> - + + + - + Void)? + override func didReceive(_ request: UNNotificationRequest, + withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { + + let bestAttemptContent = request.content.mutableCopy() as? UNMutableNotificationContent + let provider = NotificationServiceContentProvider(bestAttemptContent: bestAttemptContent) + let casted = unsafeBitCast(contentHandler, + to: (@Sendable (UNNotificationContent) -> Void).self) + Task { + if let content = await provider.buildContent() { + casted(content) + } + } + } +} + +actor NotificationServiceContentProvider { var bestAttemptContent: UNMutableNotificationContent? private let pushKeys = PushKeys() - - @MainActor - override func didReceive(_ request: UNNotificationRequest, - withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { - self.contentHandler = contentHandler - bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent) - + private let keychainAccounts = AppAccount.retrieveAll() + + init(bestAttemptContent: UNMutableNotificationContent? = nil) { + self.bestAttemptContent = bestAttemptContent + } + + func buildContent() async -> UNMutableNotificationContent? { if var bestAttemptContent { let privateKey = pushKeys.notificationsPrivateKeyAsKey let auth = pushKeys.notificationsAuthKeyAsKey - + guard let encodedPayload = bestAttemptContent.userInfo["m"] as? String, let payload = Data(base64Encoded: encodedPayload.URLSafeBase64ToBase64()) else { - contentHandler(bestAttemptContent) - return + return bestAttemptContent } - + guard let encodedPublicKey = bestAttemptContent.userInfo["k"] as? String, let publicKeyData = Data(base64Encoded: encodedPublicKey.URLSafeBase64ToBase64()), let publicKey = try? P256.KeyAgreement.PublicKey(x963Representation: publicKeyData) else { - contentHandler(bestAttemptContent) - return + return bestAttemptContent } - + guard let encodedSalt = bestAttemptContent.userInfo["s"] as? String, let salt = Data(base64Encoded: encodedSalt.URLSafeBase64ToBase64()) else { - contentHandler(bestAttemptContent) - return + return bestAttemptContent } - + guard let plaintextData = NotificationService.decrypt(payload: payload, salt: salt, auth: auth, privateKey: privateKey, publicKey: publicKey), - let notification = try? JSONDecoder().decode(MastodonPushNotification.self, from: plaintextData) + let notification = try? JSONDecoder().decode(MastodonPushNotification.self, from: plaintextData) else { - contentHandler(bestAttemptContent) - return + return bestAttemptContent } - + bestAttemptContent.title = notification.title - if AppAccountsManager.shared.availableAccounts.count > 1 { + if keychainAccounts.count > 1 { bestAttemptContent.subtitle = bestAttemptContent.userInfo["i"] as? String ?? "" } bestAttemptContent.body = notification.body.escape() bestAttemptContent.userInfo["plaintext"] = plaintextData bestAttemptContent.sound = UNNotificationSound(named: UNNotificationSoundName(rawValue: "glass.caf")) - - let preferences = UserPreferences.shared - let tokens = AppAccountsManager.shared.pushAccounts.map(\.token) - preferences.reloadNotificationsCount(tokens: tokens) - - if let token = AppAccountsManager.shared.availableAccounts.first(where: { $0.oauthToken?.accessToken == notification.accessToken })?.oauthToken { - var currentCount = preferences.notificationsCount[token] ?? 0 - currentCount += 1 - preferences.notificationsCount[token] = currentCount - } - - bestAttemptContent.badge = .init(integerLiteral: preferences.totalNotificationsCount) - + let badgeCount = await updateBadgeCoung(notification: notification) + bestAttemptContent.badge = .init(integerLiteral: badgeCount) + if let urlString = notification.icon, - let url = URL(string: urlString) - { + let url = URL(string: urlString) { let temporaryDirectoryURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("notification-attachments") try? FileManager.default.createDirectory(at: temporaryDirectoryURL, withIntermediateDirectories: true, attributes: nil) let filename = url.lastPathComponent let fileURL = temporaryDirectoryURL.appendingPathComponent(filename) - - Task { - // Warning: Non-sendable type '(any URLSessionTaskDelegate)?' exiting main actor-isolated - // context in call to non-isolated instance method 'data(for:delegate:)' cannot cross actor - // boundary. - // This is on the defaulted-to-nil second parameter of `.data(from:delegate:)`. - // There is a Radar tracking this & others like it. - if let (data, _) = try? await URLSession.shared.data(for: .init(url: url)) { - if let image = UIImage(data: data) { - try? image.pngData()?.write(to: fileURL) - - if let remoteNotification = await toRemoteNotification(localNotification: notification), - let type = remoteNotification.supportedType - { - let intent = buildMessageIntent(remoteNotification: remoteNotification, - currentUser: bestAttemptContent.userInfo["i"] as? String ?? "", - avatarURL: fileURL) + + // Warning: Non-sendable type '(any URLSessionTaskDelegate)?' exiting main actor-isolated + // context in call to non-isolated instance method 'data(for:delegate:)' cannot cross actor + // boundary. + // This is on the defaulted-to-nil second parameter of `.data(from:delegate:)`. + // There is a Radar tracking this & others like it. + if let (data, _) = try? await URLSession.shared.data(for: .init(url: url)) { + if let image = UIImage(data: data) { + try? image.pngData()?.write(to: fileURL) + + if let remoteNotification = await toRemoteNotification(localNotification: notification), + let type = remoteNotification.supportedType + { + let intent = buildMessageIntent(remoteNotification: remoteNotification, + currentUser: bestAttemptContent.userInfo["i"] as? String ?? "", + avatarURL: fileURL) + do { bestAttemptContent = try bestAttemptContent.updating(from: intent) as! UNMutableNotificationContent bestAttemptContent.threadIdentifier = remoteNotification.type if type == .mention { @@ -110,27 +112,32 @@ class NotificationService: UNNotificationServiceExtension { let newBody = "\(NSLocalizedString(type.notificationKey(), bundle: .main, comment: ""))\(notification.body.escape())" bestAttemptContent.body = newBody } - } else { - if let attachment = try? UNNotificationAttachment(identifier: filename, url: fileURL, options: nil) { - bestAttemptContent.attachments = [attachment] - } + return bestAttemptContent + } catch { + return bestAttemptContent + } + } else { + if let attachment = try? UNNotificationAttachment(identifier: filename, + url: fileURL, + options: nil) { + bestAttemptContent.attachments = [attachment] } } - contentHandler(bestAttemptContent) - } else { - contentHandler(bestAttemptContent) } + } else { + return bestAttemptContent } } else { - contentHandler(bestAttemptContent) + return bestAttemptContent } } + return nil } - - @MainActor + + private func toRemoteNotification(localNotification: MastodonPushNotification) async -> Models.Notification? { do { - if let account = AppAccountsManager.shared.availableAccounts.first(where: { $0.oauthToken?.accessToken == localNotification.accessToken }) { + if let account = keychainAccounts.first(where: { $0.oauthToken?.accessToken == localNotification.accessToken }) { let client = Client(server: account.server, oauthToken: account.oauthToken) let remoteNotification: Models.Notification = try await client.get(endpoint: Notifications.notification(id: String(localNotification.notificationID))) return remoteNotification @@ -140,8 +147,7 @@ class NotificationService: UNNotificationServiceExtension { } return nil } - - @MainActor + private func buildMessageIntent(remoteNotification: Models.Notification, currentUser: String, avatarURL: URL) -> INSendMessageIntent @@ -156,7 +162,7 @@ class NotificationService: UNNotificationServiceExtension { customIdentifier: nil) var recipents: [INPerson]? var groupName: INSpeakableString? - if AppAccountsManager.shared.availableAccounts.count > 1 { + if keychainAccounts.count > 1 { let me = INPerson(personHandle: .init(value: currentUser, type: .unknown), nameComponents: nil, displayName: currentUser, @@ -179,4 +185,18 @@ class NotificationService: UNNotificationServiceExtension { } return intent } + + @MainActor + private func updateBadgeCoung(notification: MastodonPushNotification) -> Int { + let preferences = UserPreferences.shared + let tokens = AppAccountsManager.shared.pushAccounts.map(\.token) + preferences.reloadNotificationsCount(tokens: tokens) + + if let token = keychainAccounts.first(where: { $0.oauthToken?.accessToken == notification.accessToken })?.oauthToken { + var currentCount = preferences.notificationsCount[token] ?? 0 + currentCount += 1 + preferences.notificationsCount[token] = currentCount + } + return preferences.totalNotificationsCount + } } diff --git a/Packages/Env/Sources/Env/PushNotificationsService.swift b/Packages/Env/Sources/Env/PushNotificationsService.swift index 066d1684..54ce9bf5 100644 --- a/Packages/Env/Sources/Env/PushNotificationsService.swift +++ b/Packages/Env/Sources/Env/PushNotificationsService.swift @@ -111,14 +111,6 @@ public struct HandledNotification: Equatable { UNUserNotificationCenter.current().delegate = self } - private var keychain: KeychainSwift { - let keychain = KeychainSwift() - #if !DEBUG && !targetEnvironment(simulator) - keychain.accessGroup = AppInfo.keychainGroup - #endif - return keychain - } - public func requestPushNotifications() { UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { @Sendable _, _ in DispatchQueue.main.async {