Fix push notification

This commit is contained in:
Thomas Ricouard 2024-08-13 09:55:51 +02:00
parent 864cc26e08
commit fca76849bc
4 changed files with 98 additions and 82 deletions

View file

@ -1203,7 +1203,7 @@
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 6.0;
TARGETED_DEVICE_FAMILY = "1,2,7"; TARGETED_DEVICE_FAMILY = "1,2,7";
}; };
name = Debug; name = Debug;
@ -1238,7 +1238,7 @@
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 6.0;
TARGETED_DEVICE_FAMILY = "1,2,7"; TARGETED_DEVICE_FAMILY = "1,2,7";
VALIDATE_PRODUCT = YES; VALIDATE_PRODUCT = YES;
}; };

View file

@ -56,8 +56,12 @@
debugServiceExtension = "internal" debugServiceExtension = "internal"
allowLocationSimulation = "YES" allowLocationSimulation = "YES"
launchAutomaticallySubstyle = "2"> launchAutomaticallySubstyle = "2">
<BuildableProductRunnable <RemoteRunnable
runnableDebuggingMode = "0"> runnableDebuggingMode = "1"
BundleIdentifier = "com.thomasricouard.IceCubesApp"
RemotePath = "/Users/dimillian/Library/Developer/CoreSimulator/Devices/8EF923D0-4CF1-49B6-B287-5F05AD5440C1/data/Containers/Bundle/Application/C447A1D1-9BC9-49C9-8FA5-130E8403972F/Ice Cubes.app">
</RemoteRunnable>
<MacroExpansion>
<BuildableReference <BuildableReference
BuildableIdentifier = "primary" BuildableIdentifier = "primary"
BlueprintIdentifier = "9FBFE638292A715500C250E9" BlueprintIdentifier = "9FBFE638292A715500C250E9"
@ -65,7 +69,7 @@
BlueprintName = "IceCubesApp" BlueprintName = "IceCubesApp"
ReferencedContainer = "container:IceCubesApp.xcodeproj"> ReferencedContainer = "container:IceCubesApp.xcodeproj">
</BuildableReference> </BuildableReference>
</BuildableProductRunnable> </MacroExpansion>
</LaunchAction> </LaunchAction>
<ProfileAction <ProfileAction
buildConfiguration = "Release" buildConfiguration = "Release"

View file

@ -9,18 +9,35 @@ import Notifications
import UIKit import UIKit
import UserNotifications import UserNotifications
extension UNMutableNotificationContent: @unchecked @retroactive Sendable { }
class NotificationService: UNNotificationServiceExtension { class NotificationService: UNNotificationServiceExtension {
var contentHandler: ((UNNotificationContent) -> 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? var bestAttemptContent: UNMutableNotificationContent?
private let pushKeys = PushKeys() private let pushKeys = PushKeys()
private let keychainAccounts = AppAccount.retrieveAll()
@MainActor init(bestAttemptContent: UNMutableNotificationContent? = nil) {
override func didReceive(_ request: UNNotificationRequest, self.bestAttemptContent = bestAttemptContent
withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { }
self.contentHandler = contentHandler
bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent)
func buildContent() async -> UNMutableNotificationContent? {
if var bestAttemptContent { if var bestAttemptContent {
let privateKey = pushKeys.notificationsPrivateKeyAsKey let privateKey = pushKeys.notificationsPrivateKeyAsKey
let auth = pushKeys.notificationsAuthKeyAsKey let auth = pushKeys.notificationsAuthKeyAsKey
@ -28,23 +45,20 @@ class NotificationService: UNNotificationServiceExtension {
guard let encodedPayload = bestAttemptContent.userInfo["m"] as? String, guard let encodedPayload = bestAttemptContent.userInfo["m"] as? String,
let payload = Data(base64Encoded: encodedPayload.URLSafeBase64ToBase64()) let payload = Data(base64Encoded: encodedPayload.URLSafeBase64ToBase64())
else { else {
contentHandler(bestAttemptContent) return bestAttemptContent
return
} }
guard let encodedPublicKey = bestAttemptContent.userInfo["k"] as? String, guard let encodedPublicKey = bestAttemptContent.userInfo["k"] as? String,
let publicKeyData = Data(base64Encoded: encodedPublicKey.URLSafeBase64ToBase64()), let publicKeyData = Data(base64Encoded: encodedPublicKey.URLSafeBase64ToBase64()),
let publicKey = try? P256.KeyAgreement.PublicKey(x963Representation: publicKeyData) let publicKey = try? P256.KeyAgreement.PublicKey(x963Representation: publicKeyData)
else { else {
contentHandler(bestAttemptContent) return bestAttemptContent
return
} }
guard let encodedSalt = bestAttemptContent.userInfo["s"] as? String, guard let encodedSalt = bestAttemptContent.userInfo["s"] as? String,
let salt = Data(base64Encoded: encodedSalt.URLSafeBase64ToBase64()) let salt = Data(base64Encoded: encodedSalt.URLSafeBase64ToBase64())
else { else {
contentHandler(bestAttemptContent) return bestAttemptContent
return
} }
guard let plaintextData = NotificationService.decrypt(payload: payload, guard let plaintextData = NotificationService.decrypt(payload: payload,
@ -54,39 +68,26 @@ class NotificationService: UNNotificationServiceExtension {
publicKey: publicKey), publicKey: publicKey),
let notification = try? JSONDecoder().decode(MastodonPushNotification.self, from: plaintextData) let notification = try? JSONDecoder().decode(MastodonPushNotification.self, from: plaintextData)
else { else {
contentHandler(bestAttemptContent) return bestAttemptContent
return
} }
bestAttemptContent.title = notification.title bestAttemptContent.title = notification.title
if AppAccountsManager.shared.availableAccounts.count > 1 { if keychainAccounts.count > 1 {
bestAttemptContent.subtitle = bestAttemptContent.userInfo["i"] as? String ?? "" bestAttemptContent.subtitle = bestAttemptContent.userInfo["i"] as? String ?? ""
} }
bestAttemptContent.body = notification.body.escape() bestAttemptContent.body = notification.body.escape()
bestAttemptContent.userInfo["plaintext"] = plaintextData bestAttemptContent.userInfo["plaintext"] = plaintextData
bestAttemptContent.sound = UNNotificationSound(named: UNNotificationSoundName(rawValue: "glass.caf")) bestAttemptContent.sound = UNNotificationSound(named: UNNotificationSoundName(rawValue: "glass.caf"))
let badgeCount = await updateBadgeCoung(notification: notification)
let preferences = UserPreferences.shared bestAttemptContent.badge = .init(integerLiteral: badgeCount)
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)
if let urlString = notification.icon, if let urlString = notification.icon,
let url = URL(string: urlString) let url = URL(string: urlString) {
{
let temporaryDirectoryURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("notification-attachments") let temporaryDirectoryURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("notification-attachments")
try? FileManager.default.createDirectory(at: temporaryDirectoryURL, withIntermediateDirectories: true, attributes: nil) try? FileManager.default.createDirectory(at: temporaryDirectoryURL, withIntermediateDirectories: true, attributes: nil)
let filename = url.lastPathComponent let filename = url.lastPathComponent
let fileURL = temporaryDirectoryURL.appendingPathComponent(filename) let fileURL = temporaryDirectoryURL.appendingPathComponent(filename)
Task {
// Warning: Non-sendable type '(any URLSessionTaskDelegate)?' exiting main actor-isolated // Warning: Non-sendable type '(any URLSessionTaskDelegate)?' exiting main actor-isolated
// context in call to non-isolated instance method 'data(for:delegate:)' cannot cross actor // context in call to non-isolated instance method 'data(for:delegate:)' cannot cross actor
// boundary. // boundary.
@ -102,6 +103,7 @@ class NotificationService: UNNotificationServiceExtension {
let intent = buildMessageIntent(remoteNotification: remoteNotification, let intent = buildMessageIntent(remoteNotification: remoteNotification,
currentUser: bestAttemptContent.userInfo["i"] as? String ?? "", currentUser: bestAttemptContent.userInfo["i"] as? String ?? "",
avatarURL: fileURL) avatarURL: fileURL)
do {
bestAttemptContent = try bestAttemptContent.updating(from: intent) as! UNMutableNotificationContent bestAttemptContent = try bestAttemptContent.updating(from: intent) as! UNMutableNotificationContent
bestAttemptContent.threadIdentifier = remoteNotification.type bestAttemptContent.threadIdentifier = remoteNotification.type
if type == .mention { if type == .mention {
@ -110,27 +112,32 @@ class NotificationService: UNNotificationServiceExtension {
let newBody = "\(NSLocalizedString(type.notificationKey(), bundle: .main, comment: ""))\(notification.body.escape())" let newBody = "\(NSLocalizedString(type.notificationKey(), bundle: .main, comment: ""))\(notification.body.escape())"
bestAttemptContent.body = newBody bestAttemptContent.body = newBody
} }
return bestAttemptContent
} catch {
return bestAttemptContent
}
} else { } else {
if let attachment = try? UNNotificationAttachment(identifier: filename, url: fileURL, options: nil) { if let attachment = try? UNNotificationAttachment(identifier: filename,
url: fileURL,
options: nil) {
bestAttemptContent.attachments = [attachment] bestAttemptContent.attachments = [attachment]
} }
} }
} }
contentHandler(bestAttemptContent)
} else { } else {
contentHandler(bestAttemptContent) return bestAttemptContent
}
} }
} else { } else {
contentHandler(bestAttemptContent) return bestAttemptContent
} }
} }
return nil
} }
@MainActor
private func toRemoteNotification(localNotification: MastodonPushNotification) async -> Models.Notification? { private func toRemoteNotification(localNotification: MastodonPushNotification) async -> Models.Notification? {
do { 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 client = Client(server: account.server, oauthToken: account.oauthToken)
let remoteNotification: Models.Notification = try await client.get(endpoint: Notifications.notification(id: String(localNotification.notificationID))) let remoteNotification: Models.Notification = try await client.get(endpoint: Notifications.notification(id: String(localNotification.notificationID)))
return remoteNotification return remoteNotification
@ -141,7 +148,6 @@ class NotificationService: UNNotificationServiceExtension {
return nil return nil
} }
@MainActor
private func buildMessageIntent(remoteNotification: Models.Notification, private func buildMessageIntent(remoteNotification: Models.Notification,
currentUser: String, currentUser: String,
avatarURL: URL) -> INSendMessageIntent avatarURL: URL) -> INSendMessageIntent
@ -156,7 +162,7 @@ class NotificationService: UNNotificationServiceExtension {
customIdentifier: nil) customIdentifier: nil)
var recipents: [INPerson]? var recipents: [INPerson]?
var groupName: INSpeakableString? var groupName: INSpeakableString?
if AppAccountsManager.shared.availableAccounts.count > 1 { if keychainAccounts.count > 1 {
let me = INPerson(personHandle: .init(value: currentUser, type: .unknown), let me = INPerson(personHandle: .init(value: currentUser, type: .unknown),
nameComponents: nil, nameComponents: nil,
displayName: currentUser, displayName: currentUser,
@ -179,4 +185,18 @@ class NotificationService: UNNotificationServiceExtension {
} }
return intent 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
}
} }

View file

@ -111,14 +111,6 @@ public struct HandledNotification: Equatable {
UNUserNotificationCenter.current().delegate = self 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() { public func requestPushNotifications() {
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { @Sendable _, _ in UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { @Sendable _, _ in
DispatchQueue.main.async { DispatchQueue.main.async {