From 406ac421a32a7bb06b914854b4222808f54b35eb Mon Sep 17 00:00:00 2001 From: senseiGai Date: Sun, 29 Mar 2026 22:36:38 +0500 Subject: [PATCH] =?UTF-8?q?Data-only=20=D0=BF=D1=83=D1=88=D0=B8:=20=D0=BE?= =?UTF-8?q?=D0=B1=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D0=BA=D0=B0=20type/from/di?= =?UTF-8?q?alog,=20=D0=BE=D1=87=D0=B8=D1=81=D1=82=D0=BA=D0=B0=20=D0=BF?= =?UTF-8?q?=D0=BE=20read,=20=D0=BC=D1=83=D1=82-=D0=BF=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D0=BA=D0=B0=20=D0=B3=D1=80=D1=83=D0=BF=D0=BF=20?= =?UTF-8?q?=D0=B8=20=D0=B8=D0=BC=D1=8F=20=D0=BE=D1=82=D0=BF=D1=80=D0=B0?= =?UTF-8?q?=D0=B2=D0=B8=D1=82=D0=B5=D0=BB=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Data/Repositories/DialogRepository.swift | 40 +++++- Rosetta/RosettaApp.swift | 131 +++++++++++++----- .../NotificationService.swift | 99 +++++++++---- 3 files changed, 212 insertions(+), 58 deletions(-) diff --git a/Rosetta/Core/Data/Repositories/DialogRepository.swift b/Rosetta/Core/Data/Repositories/DialogRepository.swift index fd658c6..e30ecb8 100644 --- a/Rosetta/Core/Data/Repositories/DialogRepository.swift +++ b/Rosetta/Core/Data/Repositories/DialogRepository.swift @@ -78,6 +78,7 @@ final class DialogRepository { _sortedKeysCache = nil updateAppBadge() syncMutedKeysToDefaults() + syncContactNamesToDefaults() } func reset(clearPersisted: Bool = false) { @@ -224,6 +225,7 @@ final class DialogRepository { guard changed else { return } dialogs[opponentKey] = existing persistDialog(existing) + syncContactNamesToDefaults() return } @@ -239,6 +241,7 @@ final class DialogRepository { dialogs[opponentKey] = dialog _sortedKeysCache = nil persistDialog(dialog) + syncContactNamesToDefaults() } 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) { guard var dialog = dialogs[publicKey] else { return } var changed = false - if !title.isEmpty, dialog.opponentTitle != title { dialog.opponentTitle = title; changed = true } - if !username.isEmpty, dialog.opponentUsername != username { dialog.opponentUsername = username; changed = true } + var nameChanged = false + 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 online >= 0 { let newOnline = online == 0 @@ -272,6 +276,7 @@ final class DialogRepository { guard changed else { return } dialogs[publicKey] = dialog persistDialog(dialog) + if nameChanged { syncContactNamesToDefaults() } } /// Android parity: recalculate dialog from DB after marking messages as read. @@ -415,11 +420,40 @@ final class DialogRepository { } 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(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 { raw < 1_000_000_000_000 ? raw * 1000 : raw } diff --git a/Rosetta/RosettaApp.swift b/Rosetta/RosettaApp.swift index 7cf29af..802e3e9 100644 --- a/Rosetta/RosettaApp.swift +++ b/Rosetta/RosettaApp.swift @@ -68,15 +68,15 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent 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`. - /// Two scenarios: - /// 1. Server sends data-only push (no alert) → we create a local notification with sound. - /// 2. Server sends visible push + content-available → NSE handles sound/badge, - /// we only sync the badge count here. + /// Server sends data-only push (`content-available: 1`) with custom fields: + /// - `type`: `personal_message` | `group_message` | `read` + /// - `from`: sender public key (personal) or group ID (group) + /// - `dialog`: filled only for `type=read` — the dialog that was read on another device + /// + /// See `MessageDispatcher.java` in server for push payload construction. /// Android parity: 10-second dedup window per sender. - /// Prevents duplicate push notifications from rapid server retries. private static var lastNotifTimestamps: [String: TimeInterval] = [:] private static let dedupWindowSeconds: TimeInterval = 10 @@ -85,7 +85,16 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent didReceiveRemoteNotification userInfo: [AnyHashable: Any], 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 { completionHandler(.noData) return @@ -93,22 +102,15 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent let shared = UserDefaults(suiteName: "group.com.rosetta.dev") - // Background/inactive: increment badge from shared App Group storage. - 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) + // MARK: Sender identification + // Server sends `from` = sender public key (personal_message) or group ID (group_message). + let senderKey = userInfo["from"] as? String ?? Self.extractSenderKey(from: userInfo) - // Android parity: extract sender key with multi-key fallback. - // Server may send under different key names depending on version. - let senderKey = Self.extractSenderKey(from: userInfo) - let senderName = Self.firstNonBlank(userInfo, keys: [ - "sender_name", "from_title", "sender", "title", "name" - ]) ?? "New message" - let messageText = Self.firstNonBlank(userInfo, keys: [ - "message_preview", "message", "text", "body" - ]) ?? "New message" + // Resolve sender display name from App Group cache (synced by DialogRepository). + let contactNames = shared?.dictionary(forKey: "contact_display_names") as? [String: String] ?? [:] + let senderName = contactNames[senderKey] + ?? Self.firstNonBlank(userInfo, keys: ["sender_name", "from_title", "sender", "title", "name"]) + ?? "Rosetta" // Android parity: 10-second dedup per sender. let dedupKey = senderKey.isEmpty ? "__no_sender__" : senderKey @@ -124,24 +126,42 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent let hasVisibleAlert = aps?["alert"] != nil // Don't notify for muted chats. - let isMuted: Bool = { - let mutedSet = UserDefaults.standard.stringArray(forKey: "muted_chats_keys") ?? [] - return mutedSet.contains(senderKey) - }() + let mutedKeys = shared?.stringArray(forKey: "muted_chats_keys") + ?? UserDefaults.standard.stringArray(forKey: "muted_chats_keys") ?? [] + let isMuted = !senderKey.isEmpty && mutedKeys.contains(senderKey) // 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) 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() content.title = senderName content.body = messageText content.sound = .default content.badge = NSNumber(value: newBadge) content.categoryIdentifier = "message" - // Always set sender_public_key in userInfo for notification tap navigation. content.userInfo = ["sender_public_key": senderKey, "sender_name": senderName] // Communication Notification via INSendMessageIntent (CarPlay + Focus parity). @@ -149,7 +169,7 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent let sender = INPerson( personHandle: handle, nameComponents: nil, - displayName: senderName.isEmpty ? "Rosetta" : senderName, + displayName: senderName, image: nil, contactIdentifier: nil, 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) /// Android parity: extract sender public key from multiple possible key names. /// 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 { firstNonBlank(userInfo, keys: [ - "sender_public_key", "from_public_key", "fromPublicKey", + "from", "sender_public_key", "from_public_key", "fromPublicKey", "public_key", "publicKey" ]) ?? "" } diff --git a/RosettaNotificationService/NotificationService.swift b/RosettaNotificationService/NotificationService.swift index cd648ca..84895d0 100644 --- a/RosettaNotificationService/NotificationService.swift +++ b/RosettaNotificationService/NotificationService.swift @@ -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,