import UserNotifications import Intents /// 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 /// 5. Creates Communication Notification via INSendMessageIntent (CarPlay + Focus parity) 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. /// Server currently sends `from` field in data-only push. private static let senderKeyNames = [ "from", "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 } let shared = UserDefaults(suiteName: Self.appGroupID) let pushType = content.userInfo["type"] as? String ?? "" // MARK: type=read — clear notifications for dialog, deliver silently. // Server sends read push when user reads a dialog on another device. if pushType == "read" { var dialogKey = content.userInfo["dialog"] as? String ?? "" if dialogKey.hasPrefix("#group:") { dialogKey = String(dialogKey.dropFirst("#group:".count)) } // Deliver silently — no sound, no alert. content.sound = nil content.title = "" content.body = "" guard !dialogKey.isEmpty else { contentHandler(content) return } // Clear notifications for the dialog BEFORE calling contentHandler. // NSE process can be suspended immediately after contentHandler returns, // so the async callback must complete first. let center = UNUserNotificationCenter.current() center.getDeliveredNotifications { delivered in let idsToRemove = delivered .filter { $0.request.content.userInfo["sender_public_key"] as? String == dialogKey } .map { $0.request.identifier } if !idsToRemove.isEmpty { center.removeDeliveredNotifications(withIdentifiers: idsToRemove) let current = shared?.integer(forKey: Self.badgeKey) ?? 0 let newBadge = max(current - idsToRemove.count, 0) shared?.set(newBadge, forKey: Self.badgeKey) UNUserNotificationCenter.current().setBadgeCount(newBadge) } contentHandler(content) } return } // MARK: Message types (personal_message / group_message) // 1. Add sound for vibration — server APNs payload has no sound field. content.sound = .default // 2. Extract sender key — server sends `from` field. let senderKey = content.userInfo["from"] as? String ?? Self.extractSenderKey(from: content.userInfo) // 3. Filter muted chats BEFORE badge increment — muted chats must not inflate badge. if let shared { let mutedKeys = shared.stringArray(forKey: "muted_chats_keys") ?? [] if !senderKey.isEmpty, mutedKeys.contains(senderKey) { // Muted: deliver silently (no sound, no alert, no badge increment). content.sound = nil content.title = "" content.body = "" contentHandler(content) return } // 4. Increment badge count — only for non-muted chats. let current = shared.integer(forKey: Self.badgeKey) let newBadge = current + 1 shared.set(newBadge, forKey: Self.badgeKey) content.badge = NSNumber(value: newBadge) } // 5. Normalize sender_public_key in userInfo for tap navigation. var updatedInfo = content.userInfo if !senderKey.isEmpty { updatedInfo["sender_public_key"] = senderKey } // 6. Resolve sender name from App Group cache (synced by DialogRepository). let contactNames = shared?.dictionary(forKey: "contact_display_names") as? [String: String] ?? [:] let resolvedName = contactNames[senderKey] ?? Self.firstNonBlank(content.userInfo, keys: Self.senderNameKeyNames) if let resolvedName, !resolvedName.isEmpty { updatedInfo["sender_name"] = resolvedName if content.title.isEmpty { content.title = resolvedName } } content.userInfo = updatedInfo // 7. Ensure notification category for CarPlay parity. if content.categoryIdentifier.isEmpty { content.categoryIdentifier = "message" } // 8. Create Communication Notification via INSendMessageIntent. let senderName = resolvedName ?? Self.firstNonBlank(content.userInfo, keys: Self.senderNameKeyNames) ?? content.title let finalContent = Self.makeCommunicationNotification( content: content, senderName: senderName, senderKey: senderKey ) contentHandler(finalContent) } override func serviceExtensionTimeWillExpire() { if let handler = contentHandler, let content = bestAttemptContent { content.sound = .default handler(content) } } // MARK: - Communication Notification (CarPlay + Focus) /// Wraps the notification content with an INSendMessageIntent so iOS treats it /// as a Communication Notification. This enables: /// - Display on CarPlay /// - Proper grouping in Focus modes /// - Sender name/avatar in notification UI private static func makeCommunicationNotification( content: UNMutableNotificationContent, senderName: String, senderKey: String ) -> UNNotificationContent { let handle = INPersonHandle(value: senderKey, type: .unknown) let sender = INPerson( personHandle: handle, nameComponents: nil, displayName: senderName.isEmpty ? "Rosetta" : senderName, image: nil, contactIdentifier: nil, customIdentifier: senderKey ) let intent = INSendMessageIntent( recipients: nil, outgoingMessageType: .outgoingMessageText, content: content.body, speakableGroupName: nil, conversationIdentifier: senderKey, serviceName: "Rosetta", sender: sender, attachments: nil ) // Donate the intent so Siri can learn communication patterns. let interaction = INInteraction(intent: intent, response: nil) interaction.direction = .incoming interaction.donate(completion: nil) // Update the notification content with the intent. // This returns a new content object that iOS recognizes as a Communication Notification. do { let updatedContent = try content.updating(from: intent) return updatedContent } catch { // If updating fails, return original content — notification still works, // just without CarPlay / Communication Notification features. return 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 } }