Data-only пуши: обработка type/from/dialog, очистка по read, мут-проверка групп и имя отправителя

This commit is contained in:
2026-03-29 22:36:38 +05:00
parent 4e17c9b188
commit 406ac421a3
3 changed files with 212 additions and 58 deletions

View File

@@ -14,8 +14,9 @@ final class NotificationService: UNNotificationServiceExtension {
private static let badgeKey = "app_badge_count"
/// Android parity: multiple key names for sender public key extraction.
/// Server currently sends `from` field in data-only push.
private static let senderKeyNames = [
"sender_public_key", "from_public_key", "fromPublicKey",
"from", "sender_public_key", "from_public_key", "fromPublicKey",
"public_key", "publicKey"
]
private static let senderNameKeyNames = [
@@ -37,51 +38,101 @@ final class NotificationService: UNNotificationServiceExtension {
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))
}
// 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: Message types (personal_message / group_message)
// 1. Add sound for vibration server APNs payload has no sound field.
content.sound = .default
// 2. Increment badge count from shared App Group storage.
if let shared = UserDefaults(suiteName: Self.appGroupID) {
let current = shared.integer(forKey: Self.badgeKey)
let newBadge = current + 1
shared.set(newBadge, forKey: Self.badgeKey)
content.badge = NSNumber(value: newBadge)
// 2. Extract sender key server sends `from` field.
let senderKey = content.userInfo["from"] as? String
?? Self.extractSenderKey(from: content.userInfo)
// 4. Filter muted chats.
let senderKey = Self.extractSenderKey(from: content.userInfo)
// 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)
// Muted: deliver silently (no sound, no alert, no badge increment).
content.sound = nil
content.title = ""
content.body = ""
contentHandler(content)
return
}
// 4. Increment badge count only for non-muted chats.
let current = shared.integer(forKey: Self.badgeKey)
let newBadge = current + 1
shared.set(newBadge, forKey: Self.badgeKey)
content.badge = NSNumber(value: newBadge)
}
// 3. Normalize sender_public_key in userInfo for tap navigation.
// Server may send under different key names normalize to "sender_public_key".
let senderKey = Self.extractSenderKey(from: content.userInfo)
// 5. Normalize sender_public_key in userInfo for tap navigation.
var updatedInfo = content.userInfo
if !senderKey.isEmpty {
var updatedInfo = content.userInfo
updatedInfo["sender_public_key"] = senderKey
// Also normalize sender_name
if let name = Self.firstNonBlank(content.userInfo, keys: Self.senderNameKeyNames) {
updatedInfo["sender_name"] = name
}
content.userInfo = updatedInfo
}
// 5. Ensure notification category for CarPlay parity.
// 6. 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
if content.title.isEmpty {
content.title = resolvedName
}
}
content.userInfo = updatedInfo
// 7. Ensure notification category for CarPlay parity.
if content.categoryIdentifier.isEmpty {
content.categoryIdentifier = "message"
}
// 6. Create Communication Notification via INSendMessageIntent.
// This makes the notification appear on CarPlay and work with Focus filters.
// Apple requires INSendMessageIntent for messaging notifications on CarPlay (iOS 15+).
let senderName = Self.firstNonBlank(content.userInfo, keys: Self.senderNameKeyNames)
// 8. Create Communication Notification via INSendMessageIntent.
let senderName = resolvedName
?? Self.firstNonBlank(content.userInfo, keys: Self.senderNameKeyNames)
?? content.title
let finalContent = Self.makeCommunicationNotification(
content: content,