Files
mobile-ios/RosettaNotificationService/NotificationService.swift

643 lines
28 KiB
Swift

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 isGroup = pushType == "group_message"
let finalContent = Self.makeCommunicationNotification(
content: content,
senderName: senderName,
senderKey: senderKey,
isGroup: isGroup
)
// 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,
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)
}
/// Desktop parity: 2-letter initials from display name.
/// Exact copy of RosettaColors.initials(name:publicKey:) from Colors.swift:209-223.
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)
UIGraphicsBeginImageContextWithOptions(CGSize(width: size, height: size), false, 2.0)
guard let ctx = UIGraphicsGetCurrentContext() else { return nil }
// Mantine "light" variant: dark base + semi-transparent tint overlay.
ctx.setFillColor(uiColor(hex: mantineDarkBody).cgColor)
ctx.fillEllipse(in: CGRect(x: 0, y: 0, width: size, height: size))
ctx.setFillColor(uiColor(hex: colors.tint, alpha: 0.15).cgColor)
ctx.fillEllipse(in: CGRect(x: 0, y: 0, width: size, height: size))
// Initials in shade-3 text color.
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()
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)
}
/// Generates a 50x50 group avatar with person.2.fill icon on solid tint circle.
/// Matches ChatRowView.swift:99-106 (group avatar without photo).
private static func generateGroupAvatar(name: String, key: String) -> INImage? {
let size: CGFloat = 50
let colorIndex = avatarColorIndex(for: name, publicKey: key)
let colors = avatarColors[colorIndex]
UIGraphicsBeginImageContextWithOptions(CGSize(width: size, height: size), false, 2.0)
guard let ctx = UIGraphicsGetCurrentContext() else { return nil }
// Solid tint color circle (ChatRowView parity).
ctx.setFillColor(uiColor(hex: colors.tint).cgColor)
ctx.fillEllipse(in: CGRect(x: 0, y: 0, width: size, height: size))
// person.2.fill SF Symbol centered, white 90% opacity.
let config = UIImage.SymbolConfiguration(pointSize: 20, weight: .medium)
if let symbol = UIImage(systemName: "person.2.fill", withConfiguration: config)?
.withTintColor(.white.withAlphaComponent(0.9), renderingMode: .alwaysOriginal) {
let symbolSize = symbol.size
let symbolRect = CGRect(
x: (size - symbolSize.width) / 2,
y: (size - symbolSize.height) / 2,
width: symbolSize.width,
height: symbolSize.height
)
symbol.draw(in: symbolRect)
}
let image = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
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<String>()
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)")
if FileManager.default.fileExists(atPath: tempURL.path) {
return tempURL
}
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
}
}