Data-only пуши: обработка type/from/dialog, очистка по read, мут-проверка групп и имя отправителя
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
]) ?? ""
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user