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? /// Stores Communication Notification result so serviceExtensionTimeWillExpire can use it. private var communicationContent: UNNotificationContent? 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 isGroup = pushType == "group_message" let finalContent = Self.makeCommunicationNotification( content: content, senderName: senderName, senderKey: senderKey, isGroup: isGroup ) self.communicationContent = finalContent // 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 { // Prefer Communication Notification result (has avatar) over raw bestAttemptContent. if let comm = communicationContent { handler(comm) return } if 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, isGroup: Bool = false ) -> UNNotificationContent { let handle = INPersonHandle(value: senderKey, type: .unknown) let displayName = senderName.isEmpty ? "Rosetta" : senderName let avatarImage: INImage? = { if let cached = loadNotificationAvatar(for: senderKey) { return cached } if isGroup { return generateGroupAvatar(name: displayName, key: senderKey) } return 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: isGroup ? INSpeakableString(spokenPhrase: displayName) : 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: - Avatar Generation (Mantine v8 parity with main app) /// Mantine v8 avatar palette — exact copy from Colors.swift:135-147. /// tint = shade-6 (circle fill for groups, 15% overlay for personal) /// text = shade-3 (dark mode initials color) private static let avatarColors: [(tint: UInt32, text: UInt32)] = [ (0x228be6, 0x74c0fc), // blue (0x15aabf, 0x66d9e8), // cyan (0xbe4bdb, 0xe599f7), // grape (0x40c057, 0x8ce99a), // green (0x4c6ef5, 0x91a7ff), // indigo (0x82c91e, 0xc0eb75), // lime (0xfd7e14, 0xffc078), // orange (0xe64980, 0xfaa2c1), // pink (0xfa5252, 0xffa8a8), // red (0x12b886, 0x63e6be), // teal (0x7950f2, 0xb197fc), // violet ] /// Mantine dark body background (#1A1B1E) — matches AvatarView.swift. private static let mantineDarkBody: UInt32 = 0x1A1B1E /// Desktop parity: deterministic hash based on display name. /// Exact copy of RosettaColors.avatarColorIndex(for:publicKey:) from Colors.swift:151-164. private static func avatarColorIndex(for name: String, publicKey: String = "") -> Int { let trimmed = name.trimmingCharacters(in: .whitespaces) let input = trimmed.isEmpty ? String(publicKey.prefix(7)) : trimmed var hash: Int32 = 0 for char in input.unicodeScalars { hash = (hash &<< 5) &- hash &+ Int32(truncatingIfNeeded: char.value) } let count = Int32(avatarColors.count) var index = abs(hash) % count if index < 0 { index += count } return Int(index) } /// Single-letter initial for group avatars. /// Copy of RosettaColors.groupInitial(name:publicKey:) from Colors.swift. private static func groupInitial(name: String, publicKey: String) -> String { let trimmed = name.trimmingCharacters(in: .whitespaces) if let first = trimmed.first { return String(first).uppercased() } if !publicKey.isEmpty { return String(publicKey.prefix(1)).uppercased() } return "?" } /// Desktop parity: 2-letter initials from display name. /// Exact copy of RosettaColors.initials(name:publicKey:) from Colors.swift. private static func initials(name: String, publicKey: String) -> String { let words = name.trimmingCharacters(in: .whitespaces) .split(whereSeparator: { $0.isWhitespace }) .filter { !$0.isEmpty } switch words.count { case 0: return publicKey.isEmpty ? "??" : String(publicKey.prefix(2)).uppercased() case 1: return String(words[0].prefix(2)).uppercased() default: let first = words[0].first.map(String.init) ?? "" let second = words[1].first.map(String.init) ?? "" return (first + second).uppercased() } } private static func uiColor(hex: UInt32, alpha: CGFloat = 1) -> UIColor { UIColor( red: CGFloat((hex >> 16) & 0xFF) / 255, green: CGFloat((hex >> 8) & 0xFF) / 255, blue: CGFloat(hex & 0xFF) / 255, alpha: alpha ) } /// Generates a 50x50 Mantine "light" variant avatar for personal chats. /// Dark base (#1A1B1E) + 15% tint overlay + shade-3 text — matches AvatarView.swift:69-74. private static func generateLetterAvatar(name: String, key: String) -> INImage? { let size: CGFloat = 50 let colorIndex = avatarColorIndex(for: name, publicKey: key) let colors = avatarColors[colorIndex] let text = initials(name: name, publicKey: key) // Try 2.0 scale first; fallback to 1.0 if NSE memory is constrained. let image = renderLetterAvatar(size: size, colors: colors, text: text, scale: 2.0) ?? renderLetterAvatar(size: size, colors: colors, text: text, scale: 1.0) guard let pngData = image?.pngData() else { return nil } if let tempURL = storeTemporaryImage(data: pngData, key: "letter-\(key)", fileExtension: "png") { return INImage(url: tempURL) } return INImage(imageData: pngData) } /// Renders letter avatar at given scale. Returns nil if UIGraphics context can't be allocated. private static func renderLetterAvatar( size: CGFloat, colors: (tint: UInt32, text: UInt32), text: String, scale: CGFloat ) -> UIImage? { UIGraphicsBeginImageContextWithOptions(CGSize(width: size, height: size), false, scale) guard UIGraphicsGetCurrentContext() != nil else { return nil } let rect = CGRect(x: 0, y: 0, width: size, height: size) uiColor(hex: mantineDarkBody).setFill() UIBezierPath(ovalIn: rect).fill() uiColor(hex: colors.tint, alpha: 0.15).setFill() UIBezierPath(ovalIn: rect).fill() let textColor = uiColor(hex: colors.text) let font = UIFont.systemFont(ofSize: size * 0.38, weight: .bold) let attrs: [NSAttributedString.Key: Any] = [.font: font, .foregroundColor: textColor] let textSize = (text as NSString).size(withAttributes: attrs) let textRect = CGRect( x: (size - textSize.width) / 2, y: (size - textSize.height) / 2, width: textSize.width, height: textSize.height ) (text as NSString).draw(in: textRect, withAttributes: attrs) let image = UIGraphicsGetImageFromCurrentImageContext() UIGraphicsEndImageContext() return image } /// Generates a 50x50 group avatar with single-letter initial on Mantine light circle. private static func generateGroupAvatar(name: String, key: String) -> INImage? { let size: CGFloat = 50 let colorIndex = avatarColorIndex(for: name, publicKey: key) let colors = avatarColors[colorIndex] let text = groupInitial(name: name, publicKey: key) let image = renderLetterAvatar(size: size, colors: colors, text: text, scale: 2.0) ?? renderLetterAvatar(size: size, colors: colors, text: text, scale: 1.0) guard let pngData = image?.pngData() else { return nil } if let tempURL = storeTemporaryImage(data: pngData, key: "group-\(key)", fileExtension: "png") { return INImage(url: tempURL) } 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 { if let tempURL = storeTemporaryImage(data: data, key: "photo-\(normalized)", fileExtension: "jpg") { return INImage(url: tempURL) } 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() } /// Writes image data to NSTemporaryDirectory so INImage can reference it via file URL. /// Telegram approach: INImage(imageData:) is unreliable in NSE — file URL works. private static func storeTemporaryImage(data: Data, key: String, fileExtension: String) -> URL? { let imagesPath = NSTemporaryDirectory() + "aps-data" try? FileManager.default.createDirectory( at: URL(fileURLWithPath: imagesPath), withIntermediateDirectories: true ) let fileName = key.replacingOccurrences(of: "/", with: "_") let tempURL = URL(fileURLWithPath: imagesPath) .appendingPathComponent("\(fileName).\(fileExtension)") // Always overwrite — iOS cleans NSTemporaryDirectory() between notifications, // so a stale fileExists check would return a URL to a deleted file. do { try data.write(to: tempURL, options: [.atomic]) return tempURL } catch { return nil } } // 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 } }