import UIKit 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" private static let processedIdsKey = "nse_processed_message_ids" /// Max dedup entries kept in App Group — NSE has tight memory limits. private static let maxProcessedIds = 100 /// Android parity: suppress duplicate notifications from the same sender /// within a short window to avoid FCM burst duplicates. private static let recentSenderNotificationTimestampsKey = "nse_recent_sender_notif_timestamps" private static let senderDedupWindowSeconds: TimeInterval = 10 /// Tracks dialogs recently read on another device (e.g. Desktop). /// When a READ push arrives, we store {dialogKey: timestamp}. Subsequent message /// pushes for the same dialog within the window are suppressed — the user is actively /// reading on Desktop, so the phone should stay silent. private static let recentlyReadKey = "nse_recently_read_dialogs" private static let recentlyReadWindow: TimeInterval = 30 /// Android parity: multiple key names for sender public key extraction. /// Server sends `dialog` field (was `from`). Both kept for backward compat. private static let senderKeyNames = [ "dialog", "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)) } // Track this dialog as "recently read on another device" (Desktop parity). // Next message push for this dialog within 30s will be suppressed. if !dialogKey.isEmpty, let shared { let now = Date().timeIntervalSince1970 var recentlyRead = shared.dictionary(forKey: Self.recentlyReadKey) as? [String: Double] ?? [:] recentlyRead[dialogKey] = now // Evict stale entries (> 60s) to prevent unbounded growth. recentlyRead = recentlyRead.filter { now - $0.value < 60 } shared.set(recentlyRead, forKey: Self.recentlyReadKey) } // 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: type=call — incoming call notification (no badge increment). // Server sends this when someone calls and the recipient's WebSocket is not connected. // NSE adds sound for vibration and caller name; no badge (calls don't affect unread). if pushType == "call" { content.sound = .default content.categoryIdentifier = "call" let callerKey = content.userInfo["dialog"] as? String ?? Self.extractSenderKey(from: content.userInfo) let contactNames = shared?.dictionary(forKey: "contact_display_names") as? [String: String] ?? [:] let callerName = contactNames[callerKey] ?? Self.firstNonBlank(content.userInfo, keys: Self.senderNameKeyNames) if let callerName, !callerName.isEmpty, content.title.isEmpty { content.title = callerName } if content.body.isEmpty { content.body = "Incoming call" } var updatedInfo = content.userInfo if !callerKey.isEmpty { updatedInfo["sender_public_key"] = callerKey updatedInfo["type"] = "call" } content.userInfo = updatedInfo content.interruptionLevel = .timeSensitive 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 `dialog` field (was `from`). let senderKey = content.userInfo["dialog"] as? String ?? Self.extractSenderKey(from: content.userInfo) var shouldCollapseDuplicateBurst = false // 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 } // 3.1 Desktop-active suppression: if this dialog was read on another device // (Desktop) within the last 30s, suppress the notification. The user is // actively reading on Desktop — no need to buzz the phone. if !senderKey.isEmpty { let recentlyRead = shared.dictionary(forKey: Self.recentlyReadKey) as? [String: Double] ?? [:] if let lastReadTime = recentlyRead[senderKey] { let elapsed = Date().timeIntervalSince1970 - lastReadTime if elapsed < Self.recentlyReadWindow { content.sound = nil content.title = "" content.body = "" contentHandler(content) return } } } // 3.5 Dedup: // - messageId duplicate: keep badge unchanged (exact duplicate delivery) // - sender-window duplicate: collapse UI to latest notification, but keep // badge increment so unread-message count is preserved. let messageId = content.userInfo["message_id"] as? String ?? content.userInfo["messageId"] as? String ?? request.identifier var processedIds = shared.stringArray(forKey: Self.processedIdsKey) ?? [] let isSenderWindowDuplicate = Self.isSenderWindowDuplicate(senderKey: senderKey, shared: shared) let isMessageIdDuplicate = processedIds.contains(messageId) shouldCollapseDuplicateBurst = isSenderWindowDuplicate || isMessageIdDuplicate if isMessageIdDuplicate { // Already counted — show notification but don't inflate badge. let currentBadge = shared.integer(forKey: Self.badgeKey) content.badge = NSNumber(value: currentBadge) } else { // 4. Increment badge count — only for non-muted, non-duplicate chats. let current = shared.integer(forKey: Self.badgeKey) let newBadge = current + 1 shared.set(newBadge, forKey: Self.badgeKey) content.badge = NSNumber(value: newBadge) // Track this message ID. Evict oldest if over limit. processedIds.append(messageId) if processedIds.count > Self.maxProcessedIds { processedIds = Array(processedIds.suffix(Self.maxProcessedIds)) } shared.set(processedIds, forKey: Self.processedIdsKey) } } // 5. Group notifications by conversation (Telegram parity). // iOS stacks notifications from the same chat together. if !senderKey.isEmpty { content.threadIdentifier = senderKey } // 6. Normalize sender_public_key in userInfo for tap navigation. var updatedInfo = content.userInfo if !senderKey.isEmpty { updatedInfo["sender_public_key"] = senderKey } // 7. 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 // Always prefer local name — server sends title at push time, // but user may have a custom contact name in App Group cache. content.title = resolvedName } content.userInfo = updatedInfo // 8. Ensure notification category for CarPlay parity. if content.categoryIdentifier.isEmpty { content.categoryIdentifier = "message" } // 9. 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 ) // Android parity: for duplicate bursts, keep only the latest notification // for this sender instead of stacking multiple identical entries. if !senderKey.isEmpty, shouldCollapseDuplicateBurst { Self.replaceDeliveredNotifications(for: senderKey) { contentHandler(finalContent) } return } contentHandler(finalContent) } override func serviceExtensionTimeWillExpire() { if let handler = contentHandler, let content = bestAttemptContent { // Read pushes must stay silent even on timeout — no sound, no alert. let pushType = content.userInfo["type"] as? String ?? "" if pushType == "read" { content.sound = nil content.title = "" content.body = "" } else { content.sound = .default } handler(content) } } // MARK: - Sender Dedup Helpers /// Returns true if the sender has a recent notification in the dedup window. /// When `shouldRecord` is true, records current timestamp for non-duplicates. private static func isSenderWindowDuplicate( senderKey: String, shared: UserDefaults, shouldRecord: Bool = true ) -> Bool { let dedupKey = senderKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "__no_sender__" : senderKey.trimmingCharacters(in: .whitespacesAndNewlines) let now = Date().timeIntervalSince1970 var timestamps = shared.dictionary(forKey: recentSenderNotificationTimestampsKey) as? [String: Double] ?? [:] // Keep the map compact under NSE memory constraints. timestamps = timestamps.filter { now - $0.value < 120 } if let last = timestamps[dedupKey], now - last < senderDedupWindowSeconds { shared.set(timestamps, forKey: recentSenderNotificationTimestampsKey) return true } if shouldRecord { timestamps[dedupKey] = now shared.set(timestamps, forKey: recentSenderNotificationTimestampsKey) } else { shared.set(timestamps, forKey: recentSenderNotificationTimestampsKey) } return false } /// Removes delivered notifications for this sender so duplicate bursts collapse /// into a single latest entry (Android parity). private static func replaceDeliveredNotifications( for senderKey: String, then completion: @escaping () -> Void ) { let center = UNUserNotificationCenter.current() center.getDeliveredNotifications { delivered in let idsToRemove = delivered .filter { notification in let info = notification.request.content.userInfo let infoSender = info["sender_public_key"] as? String ?? info["dialog"] as? String ?? "" return infoSender == senderKey || notification.request.content.threadIdentifier == senderKey } .map { $0.request.identifier } if !idsToRemove.isEmpty { center.removeDeliveredNotifications(withIdentifiers: idsToRemove) } completion() } } // 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 displayName = senderName.isEmpty ? "Rosetta" : senderName let avatarImage = loadNotificationAvatar(for: senderKey) ?? generateLetterAvatar(name: displayName, key: senderKey) let sender = INPerson( personHandle: handle, nameComponents: nil, displayName: displayName, image: avatarImage, contactIdentifier: nil, customIdentifier: senderKey ) let intent = INSendMessageIntent( recipients: nil, outgoingMessageType: .outgoingMessageText, content: content.body, speakableGroupName: nil, conversationIdentifier: senderKey, serviceName: "Rosetta", sender: sender, attachments: nil ) // Set avatar on sender parameter (prefer real avatar from App Group, fallback to letter avatar). if let avatarImage { intent.setImage(avatarImage, forParameterNamed: \.sender) } // 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: - Letter Avatar (Telegram parity: colored circle with initials) /// Mantine avatar color palette — matches AvatarView in main app. private static let avatarColors: [(bg: UInt32, text: UInt32)] = [ (0x4C6EF5, 0xDBE4FF), // indigo (0x7950F2, 0xE5DBFF), // violet (0xF06595, 0xFFDEEB), // pink (0xFF6B6B, 0xFFE3E3), // red (0xFD7E14, 0xFFE8CC), // orange (0xFAB005, 0xFFF3BF), // yellow (0x40C057, 0xD3F9D8), // green (0x12B886, 0xC3FAE8), // teal (0x15AABF, 0xC5F6FA), // cyan (0x228BE6, 0xD0EBFF), // blue (0xBE4BDB, 0xF3D9FA), // grape ] /// Generates a 50x50 circular letter avatar as INImage for notification display. private static func generateLetterAvatar(name: String, key: String) -> INImage? { let size: CGFloat = 50 let colorIndex = abs(key.hashValue) % avatarColors.count let colors = avatarColors[colorIndex] let initial = String(name.prefix(1)).uppercased() UIGraphicsBeginImageContextWithOptions(CGSize(width: size, height: size), false, 2.0) guard let ctx = UIGraphicsGetCurrentContext() else { return nil } // Background circle. let bgColor = UIColor( red: CGFloat((colors.bg >> 16) & 0xFF) / 255, green: CGFloat((colors.bg >> 8) & 0xFF) / 255, blue: CGFloat(colors.bg & 0xFF) / 255, alpha: 1 ) ctx.setFillColor(bgColor.cgColor) ctx.fillEllipse(in: CGRect(x: 0, y: 0, width: size, height: size)) // Initial letter. let textColor = UIColor( red: CGFloat((colors.text >> 16) & 0xFF) / 255, green: CGFloat((colors.text >> 8) & 0xFF) / 255, blue: CGFloat(colors.text & 0xFF) / 255, alpha: 1 ) let font = UIFont.systemFont(ofSize: size * 0.38, weight: .bold) let attrs: [NSAttributedString.Key: Any] = [.font: font, .foregroundColor: textColor] let textSize = (initial as NSString).size(withAttributes: attrs) let textRect = CGRect( x: (size - textSize.width) / 2, y: (size - textSize.height) / 2, width: textSize.width, height: textSize.height ) (initial as NSString).draw(in: textRect, withAttributes: attrs) let image = UIGraphicsGetImageFromCurrentImageContext() UIGraphicsEndImageContext() guard let pngData = image?.pngData() else { return nil } return INImage(imageData: pngData) } /// Loads sender avatar from shared App Group cache written by the main app. /// Falls back to letter avatar when no real image is available. private static func loadNotificationAvatar(for senderKey: String) -> INImage? { guard let appGroupURL = FileManager.default.containerURL( forSecurityApplicationGroupIdentifier: Self.appGroupID ) else { return nil } let avatarsDir = appGroupURL.appendingPathComponent("NotificationAvatars", isDirectory: true) for candidate in avatarKeyCandidates(for: senderKey) { let normalized = normalizedAvatarKey(candidate) guard !normalized.isEmpty else { continue } let avatarURL = avatarsDir.appendingPathComponent("\(normalized).jpg") if let data = try? Data(contentsOf: avatarURL), !data.isEmpty { return INImage(imageData: data) } } return nil } /// Server may send group dialog key in different forms (`raw`, `#group:raw`, `group:raw`). /// Probe all variants so NSE can find avatar mirrored by the main app. private static func avatarKeyCandidates(for senderKey: String) -> [String] { let trimmed = senderKey.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return [] } var candidates: [String] = [trimmed] let lower = trimmed.lowercased() if lower.hasPrefix("#group:") { let raw = String(trimmed.dropFirst("#group:".count)).trimmingCharacters(in: .whitespacesAndNewlines) if !raw.isEmpty { candidates.append(raw) candidates.append("group:\(raw)") } } else if lower.hasPrefix("group:") { let raw = String(trimmed.dropFirst("group:".count)).trimmingCharacters(in: .whitespacesAndNewlines) if !raw.isEmpty { candidates.append(raw) candidates.append("#group:\(raw)") } } else if !trimmed.isEmpty { candidates.append("#group:\(trimmed)") candidates.append("group:\(trimmed)") } // Keep first-seen order and drop duplicates. var seen = Set() return candidates.filter { seen.insert($0).inserted } } private static func normalizedAvatarKey(_ key: String) -> String { key .trimmingCharacters(in: .whitespacesAndNewlines) .replacingOccurrences(of: "0x", with: "") .lowercased() } // 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 } }