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

@@ -78,6 +78,7 @@ final class DialogRepository {
_sortedKeysCache = nil _sortedKeysCache = nil
updateAppBadge() updateAppBadge()
syncMutedKeysToDefaults() syncMutedKeysToDefaults()
syncContactNamesToDefaults()
} }
func reset(clearPersisted: Bool = false) { func reset(clearPersisted: Bool = false) {
@@ -224,6 +225,7 @@ final class DialogRepository {
guard changed else { return } guard changed else { return }
dialogs[opponentKey] = existing dialogs[opponentKey] = existing
persistDialog(existing) persistDialog(existing)
syncContactNamesToDefaults()
return return
} }
@@ -239,6 +241,7 @@ final class DialogRepository {
dialogs[opponentKey] = dialog dialogs[opponentKey] = dialog
_sortedKeysCache = nil _sortedKeysCache = nil
persistDialog(dialog) persistDialog(dialog)
syncContactNamesToDefaults()
} }
func updateOnlineState(publicKey: String, isOnline: Bool) { func updateOnlineState(publicKey: String, isOnline: Bool) {
@@ -258,8 +261,9 @@ final class DialogRepository {
func updateUserInfo(publicKey: String, title: String, username: String, verified: Int = 0, online: Int = -1) { func updateUserInfo(publicKey: String, title: String, username: String, verified: Int = 0, online: Int = -1) {
guard var dialog = dialogs[publicKey] else { return } guard var dialog = dialogs[publicKey] else { return }
var changed = false var changed = false
if !title.isEmpty, dialog.opponentTitle != title { dialog.opponentTitle = title; changed = true } var nameChanged = false
if !username.isEmpty, dialog.opponentUsername != username { dialog.opponentUsername = username; changed = true } if !title.isEmpty, dialog.opponentTitle != title { dialog.opponentTitle = title; changed = true; nameChanged = true }
if !username.isEmpty, dialog.opponentUsername != username { dialog.opponentUsername = username; changed = true; nameChanged = true }
if verified > 0, dialog.verified < verified { dialog.verified = verified; changed = true } if verified > 0, dialog.verified < verified { dialog.verified = verified; changed = true }
if online >= 0 { if online >= 0 {
let newOnline = online == 0 let newOnline = online == 0
@@ -272,6 +276,7 @@ final class DialogRepository {
guard changed else { return } guard changed else { return }
dialogs[publicKey] = dialog dialogs[publicKey] = dialog
persistDialog(dialog) persistDialog(dialog)
if nameChanged { syncContactNamesToDefaults() }
} }
/// Android parity: recalculate dialog from DB after marking messages as read. /// Android parity: recalculate dialog from DB after marking messages as read.
@@ -415,11 +420,40 @@ final class DialogRepository {
} }
private func syncMutedKeysToDefaults() { private func syncMutedKeysToDefaults() {
let mutedKeys = dialogs.values.filter(\.isMuted).map(\.opponentKey) var mutedKeys: [String] = []
for dialog in dialogs.values where dialog.isMuted {
mutedKeys.append(dialog.opponentKey)
// Server sends group ID without #group: prefix in push `from` field.
// Store stripped key so push mute check matches both formats.
if dialog.opponentKey.lowercased().hasPrefix("#group:") {
let stripped = String(dialog.opponentKey.dropFirst("#group:".count))
if !stripped.isEmpty { mutedKeys.append(stripped) }
}
}
UserDefaults.standard.set(mutedKeys, forKey: "muted_chats_keys") UserDefaults.standard.set(mutedKeys, forKey: "muted_chats_keys")
UserDefaults(suiteName: "group.com.rosetta.dev")?.set(mutedKeys, forKey: "muted_chats_keys") UserDefaults(suiteName: "group.com.rosetta.dev")?.set(mutedKeys, forKey: "muted_chats_keys")
} }
/// Sync contact display names to App Group for push notification name resolution.
/// Both NSE and AppDelegate read this in background to show sender name in notification title.
private func syncContactNamesToDefaults() {
var names: [String: String] = [:]
for dialog in dialogs.values {
let name = dialog.opponentTitle.isEmpty ? dialog.opponentUsername : dialog.opponentTitle
if !name.isEmpty {
names[dialog.opponentKey] = name
// Server sends group ID without #group: prefix in push `from` field.
// Store stripped key so push name lookup matches both formats.
if dialog.opponentKey.lowercased().hasPrefix("#group:") {
let stripped = String(dialog.opponentKey.dropFirst("#group:".count))
if !stripped.isEmpty { names[stripped] = name }
}
}
}
UserDefaults.standard.set(names, forKey: "contact_display_names")
UserDefaults(suiteName: "group.com.rosetta.dev")?.set(names, forKey: "contact_display_names")
}
private func normalizeTimestamp(_ raw: Int64) -> Int64 { private func normalizeTimestamp(_ raw: Int64) -> Int64 {
raw < 1_000_000_000_000 ? raw * 1000 : raw raw < 1_000_000_000_000 ? raw * 1000 : raw
} }

View File

@@ -68,15 +68,15 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent
Messaging.messaging().apnsToken = deviceToken Messaging.messaging().apnsToken = deviceToken
} }
// MARK: - Background Push (Badge + Local Notification with Sound) // MARK: - Data-Only Push (Server parity: type/from/dialog fields)
/// Called when a push notification arrives with `content-available: 1`. /// Server sends data-only push (`content-available: 1`) with custom fields:
/// Two scenarios: /// - `type`: `personal_message` | `group_message` | `read`
/// 1. Server sends data-only push (no alert) we create a local notification with sound. /// - `from`: sender public key (personal) or group ID (group)
/// 2. Server sends visible push + content-available NSE handles sound/badge, /// - `dialog`: filled only for `type=read` the dialog that was read on another device
/// we only sync the badge count here. ///
/// See `MessageDispatcher.java` in server for push payload construction.
/// Android parity: 10-second dedup window per sender. /// Android parity: 10-second dedup window per sender.
/// Prevents duplicate push notifications from rapid server retries.
private static var lastNotifTimestamps: [String: TimeInterval] = [:] private static var lastNotifTimestamps: [String: TimeInterval] = [:]
private static let dedupWindowSeconds: TimeInterval = 10 private static let dedupWindowSeconds: TimeInterval = 10
@@ -85,7 +85,16 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent
didReceiveRemoteNotification userInfo: [AnyHashable: Any], didReceiveRemoteNotification userInfo: [AnyHashable: Any],
fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void
) { ) {
// Foreground: WebSocket handles messages in real-time skip. let pushType = userInfo["type"] as? String ?? ""
// MARK: type=read clear notifications for dialog (read on another device).
// Handle even in foreground: if user reads on Desktop, phone clears its notifications.
if pushType == "read" {
handleReadPush(userInfo: userInfo, completionHandler: completionHandler)
return
}
// For message notifications, skip if foreground (WebSocket handles real-time).
guard application.applicationState != .active else { guard application.applicationState != .active else {
completionHandler(.noData) completionHandler(.noData)
return return
@@ -93,22 +102,15 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent
let shared = UserDefaults(suiteName: "group.com.rosetta.dev") let shared = UserDefaults(suiteName: "group.com.rosetta.dev")
// Background/inactive: increment badge from shared App Group storage. // MARK: Sender identification
let currentBadge = shared?.integer(forKey: "app_badge_count") ?? 0 // Server sends `from` = sender public key (personal_message) or group ID (group_message).
let newBadge = currentBadge + 1 let senderKey = userInfo["from"] as? String ?? Self.extractSenderKey(from: userInfo)
shared?.set(newBadge, forKey: "app_badge_count")
UserDefaults.standard.set(newBadge, forKey: "app_badge_count")
UNUserNotificationCenter.current().setBadgeCount(newBadge)
// Android parity: extract sender key with multi-key fallback. // Resolve sender display name from App Group cache (synced by DialogRepository).
// Server may send under different key names depending on version. let contactNames = shared?.dictionary(forKey: "contact_display_names") as? [String: String] ?? [:]
let senderKey = Self.extractSenderKey(from: userInfo) let senderName = contactNames[senderKey]
let senderName = Self.firstNonBlank(userInfo, keys: [ ?? Self.firstNonBlank(userInfo, keys: ["sender_name", "from_title", "sender", "title", "name"])
"sender_name", "from_title", "sender", "title", "name" ?? "Rosetta"
]) ?? "New message"
let messageText = Self.firstNonBlank(userInfo, keys: [
"message_preview", "message", "text", "body"
]) ?? "New message"
// Android parity: 10-second dedup per sender. // Android parity: 10-second dedup per sender.
let dedupKey = senderKey.isEmpty ? "__no_sender__" : senderKey let dedupKey = senderKey.isEmpty ? "__no_sender__" : senderKey
@@ -124,24 +126,42 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent
let hasVisibleAlert = aps?["alert"] != nil let hasVisibleAlert = aps?["alert"] != nil
// Don't notify for muted chats. // Don't notify for muted chats.
let isMuted: Bool = { let mutedKeys = shared?.stringArray(forKey: "muted_chats_keys")
let mutedSet = UserDefaults.standard.stringArray(forKey: "muted_chats_keys") ?? [] ?? UserDefaults.standard.stringArray(forKey: "muted_chats_keys") ?? []
return mutedSet.contains(senderKey) let isMuted = !senderKey.isEmpty && mutedKeys.contains(senderKey)
}()
// If server sent visible alert, NSE handles sound+badge. Just sync badge. // If server sent visible alert, NSE handles sound+badge. Just sync badge.
guard !hasVisibleAlert && !isMuted else { // If muted, wake app but don't show notification.
if hasVisibleAlert || isMuted {
if !isMuted {
// Increment badge only for non-muted visible alerts.
let currentBadge = shared?.integer(forKey: "app_badge_count") ?? 0
let newBadge = currentBadge + 1
shared?.set(newBadge, forKey: "app_badge_count")
UserDefaults.standard.set(newBadge, forKey: "app_badge_count")
UNUserNotificationCenter.current().setBadgeCount(newBadge)
}
completionHandler(.newData) completionHandler(.newData)
return return
} }
// MARK: Increment badge + create local notification
let currentBadge = shared?.integer(forKey: "app_badge_count") ?? 0
let newBadge = currentBadge + 1
shared?.set(newBadge, forKey: "app_badge_count")
UserDefaults.standard.set(newBadge, forKey: "app_badge_count")
UNUserNotificationCenter.current().setBadgeCount(newBadge)
let messageText = Self.firstNonBlank(userInfo, keys: [
"message_preview", "message", "text", "body"
]) ?? "New message"
let content = UNMutableNotificationContent() let content = UNMutableNotificationContent()
content.title = senderName content.title = senderName
content.body = messageText content.body = messageText
content.sound = .default content.sound = .default
content.badge = NSNumber(value: newBadge) content.badge = NSNumber(value: newBadge)
content.categoryIdentifier = "message" content.categoryIdentifier = "message"
// Always set sender_public_key in userInfo for notification tap navigation.
content.userInfo = ["sender_public_key": senderKey, "sender_name": senderName] content.userInfo = ["sender_public_key": senderKey, "sender_name": senderName]
// Communication Notification via INSendMessageIntent (CarPlay + Focus parity). // Communication Notification via INSendMessageIntent (CarPlay + Focus parity).
@@ -149,7 +169,7 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent
let sender = INPerson( let sender = INPerson(
personHandle: handle, personHandle: handle,
nameComponents: nil, nameComponents: nil,
displayName: senderName.isEmpty ? "Rosetta" : senderName, displayName: senderName,
image: nil, image: nil,
contactIdentifier: nil, contactIdentifier: nil,
customIdentifier: senderKey customIdentifier: senderKey
@@ -185,13 +205,62 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent
} }
} }
// MARK: - Read Push Handler
/// Handles `type=read` push: clears delivered notifications for the specified dialog.
/// Server sends this to the READER's other devices when they read a dialog on Desktop/Android.
/// `dialog` field = opponent public key (personal) or group ID (may have `#group:` prefix).
private func handleReadPush(
userInfo: [AnyHashable: Any],
completionHandler: @escaping (UIBackgroundFetchResult) -> Void
) {
guard var dialogKey = userInfo["dialog"] as? String, !dialogKey.isEmpty else {
completionHandler(.noData)
return
}
// Strip #group: prefix notification userInfo stores raw group ID.
if dialogKey.hasPrefix("#group:") {
dialogKey = String(dialogKey.dropFirst("#group:".count))
}
let center = UNUserNotificationCenter.current()
center.getDeliveredNotifications { delivered in
let idsToRemove = delivered
.filter { notification in
let key = notification.request.content.userInfo["sender_public_key"] as? String ?? ""
return key == dialogKey
}
.map { $0.request.identifier }
if !idsToRemove.isEmpty {
center.removeDeliveredNotifications(withIdentifiers: idsToRemove)
}
// Decrement badge by the number of cleared notifications.
let clearedCount = idsToRemove.count
if clearedCount > 0 {
let shared = UserDefaults(suiteName: "group.com.rosetta.dev")
let current = shared?.integer(forKey: "app_badge_count") ?? 0
let newBadge = max(current - clearedCount, 0)
shared?.set(newBadge, forKey: "app_badge_count")
UserDefaults.standard.set(newBadge, forKey: "app_badge_count")
UNUserNotificationCenter.current().setBadgeCount(newBadge)
}
completionHandler(.newData)
}
}
// MARK: - Push Payload Helpers (Android parity) // MARK: - Push Payload Helpers (Android parity)
/// Android parity: extract sender public key from multiple possible key names. /// Android parity: extract sender public key from multiple possible key names.
/// Server may use different key names across versions. /// Server may use different key names across versions.
/// Note: server currently sends `from` field checked first in didReceiveRemoteNotification,
/// this helper is a fallback for other contexts (notification tap, etc.).
private static func extractSenderKey(from userInfo: [AnyHashable: Any]) -> String { private static func extractSenderKey(from userInfo: [AnyHashable: Any]) -> String {
firstNonBlank(userInfo, keys: [ firstNonBlank(userInfo, keys: [
"sender_public_key", "from_public_key", "fromPublicKey", "from", "sender_public_key", "from_public_key", "fromPublicKey",
"public_key", "publicKey" "public_key", "publicKey"
]) ?? "" ]) ?? ""
} }

View File

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