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 } }