Files
mobile-ios/RosettaNotificationService/NotificationService.swift

105 lines
4.0 KiB
Swift

import UserNotifications
/// Notification Service Extension runs as a separate process even when the main app
/// is terminated. Intercepts push notifications with `mutable-content: 1` and:
/// 1. Adds `.default` sound for vibration (server payload has no sound)
/// 2. Increments the app icon badge from shared App Group storage
/// 3. Normalizes sender_public_key in userInfo (Android parity: multi-key fallback)
/// 4. Filters muted chats
final class NotificationService: UNNotificationServiceExtension {
private static let appGroupID = "group.com.rosetta.dev"
private static let badgeKey = "app_badge_count"
/// Android parity: multiple key names for sender public key extraction.
private static let senderKeyNames = [
"sender_public_key", "from_public_key", "fromPublicKey",
"public_key", "publicKey"
]
private static let senderNameKeyNames = [
"sender_name", "from_title", "sender", "title", "name"
]
private var contentHandler: ((UNNotificationContent) -> Void)?
private var bestAttemptContent: UNMutableNotificationContent?
override func didReceive(
_ request: UNNotificationRequest,
withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void
) {
self.contentHandler = contentHandler
bestAttemptContent = request.content.mutableCopy() as? UNMutableNotificationContent
guard let content = bestAttemptContent else {
contentHandler(request.content)
return
}
// 1. Add sound for vibration server APNs payload has no sound field.
content.sound = .default
// 2. Increment badge count from shared App Group storage.
if let shared = UserDefaults(suiteName: Self.appGroupID) {
let current = shared.integer(forKey: Self.badgeKey)
let newBadge = current + 1
shared.set(newBadge, forKey: Self.badgeKey)
content.badge = NSNumber(value: newBadge)
// 4. Filter muted chats.
let senderKey = Self.extractSenderKey(from: content.userInfo)
let mutedKeys = shared.stringArray(forKey: "muted_chats_keys") ?? []
if !senderKey.isEmpty, mutedKeys.contains(senderKey) {
// Muted: deliver silently (no sound, no alert)
content.sound = nil
content.title = ""
content.body = ""
contentHandler(content)
return
}
}
// 3. Normalize sender_public_key in userInfo for tap navigation.
// Server may send under different key names normalize to "sender_public_key".
let senderKey = Self.extractSenderKey(from: content.userInfo)
if !senderKey.isEmpty {
var updatedInfo = content.userInfo
updatedInfo["sender_public_key"] = senderKey
// Also normalize sender_name
if let name = Self.firstNonBlank(content.userInfo, keys: Self.senderNameKeyNames) {
updatedInfo["sender_name"] = name
}
content.userInfo = updatedInfo
}
// 5. Ensure notification category for CarPlay parity.
if content.categoryIdentifier.isEmpty {
content.categoryIdentifier = "message"
}
contentHandler(content)
}
override func serviceExtensionTimeWillExpire() {
if let handler = contentHandler, let content = bestAttemptContent {
content.sound = .default
handler(content)
}
}
// MARK: - Helpers
/// Android parity: extract sender key from multiple possible key names.
private static func extractSenderKey(from userInfo: [AnyHashable: Any]) -> String {
firstNonBlank(userInfo, keys: senderKeyNames) ?? ""
}
private static func firstNonBlank(_ dict: [AnyHashable: Any], keys: [String]) -> String? {
for key in keys {
if let value = dict[key] as? String, !value.trimmingCharacters(in: .whitespaces).isEmpty {
return value
}
}
return nil
}
}