Фикс: исправлено исчезновение части уведомлений при открытии пуша
This commit is contained in:
@@ -16,6 +16,10 @@ final class NotificationService: UNNotificationServiceExtension {
|
||||
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
|
||||
@@ -140,6 +144,7 @@ final class NotificationService: UNNotificationServiceExtension {
|
||||
// 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 {
|
||||
@@ -170,13 +175,19 @@ final class NotificationService: UNNotificationServiceExtension {
|
||||
}
|
||||
}
|
||||
|
||||
// 3.5 Dedup: skip badge increment if we already processed this push.
|
||||
// Protects against duplicate FCM delivery (rare, but server dedup window is ~10s).
|
||||
// 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) ?? []
|
||||
if processedIds.contains(messageId) {
|
||||
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)
|
||||
@@ -235,6 +246,15 @@ final class NotificationService: UNNotificationServiceExtension {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -245,6 +265,64 @@ final class NotificationService: UNNotificationServiceExtension {
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -259,7 +337,8 @@ final class NotificationService: UNNotificationServiceExtension {
|
||||
) -> UNNotificationContent {
|
||||
let handle = INPersonHandle(value: senderKey, type: .unknown)
|
||||
let displayName = senderName.isEmpty ? "Rosetta" : senderName
|
||||
let avatarImage = generateLetterAvatar(name: displayName, key: senderKey)
|
||||
let avatarImage = loadNotificationAvatar(for: senderKey)
|
||||
?? generateLetterAvatar(name: displayName, key: senderKey)
|
||||
let sender = INPerson(
|
||||
personHandle: handle,
|
||||
nameComponents: nil,
|
||||
@@ -280,7 +359,7 @@ final class NotificationService: UNNotificationServiceExtension {
|
||||
attachments: nil
|
||||
)
|
||||
|
||||
// Set avatar on sender parameter (Telegram parity: 50x50 letter avatar).
|
||||
// Set avatar on sender parameter (prefer real avatar from App Group, fallback to letter avatar).
|
||||
if let avatarImage {
|
||||
intent.setImage(avatarImage, forParameterNamed: \.sender)
|
||||
}
|
||||
@@ -364,6 +443,64 @@ final class NotificationService: UNNotificationServiceExtension {
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user