520 lines
22 KiB
Swift
520 lines
22 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 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 {
|
|
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<String>()
|
|
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
|
|
}
|
|
}
|