Фикс: бэкграунд звонки — аудио, имя на CallKit, deactivation order, UUID race

This commit is contained in:
2026-04-06 00:18:37 +05:00
parent d65624ad35
commit 55cb120db3
32 changed files with 1548 additions and 688 deletions

View File

@@ -16,6 +16,12 @@ 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
/// 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
/// reading on Desktop, so the phone should stay silent.
private static let recentlyReadKey = "nse_recently_read_dialogs"
private static let recentlyReadWindow: TimeInterval = 30
/// Android parity: multiple key names for sender public key extraction.
/// Server sends `dialog` field (was `from`). Both kept for backward compat.
@@ -53,6 +59,17 @@ final class NotificationService: UNNotificationServiceExtension {
dialogKey = String(dialogKey.dropFirst("#group:".count))
}
// Track this dialog as "recently read on another device" (Desktop parity).
// Next message push for this dialog within 30s will be suppressed.
if !dialogKey.isEmpty, let shared {
let now = Date().timeIntervalSince1970
var recentlyRead = shared.dictionary(forKey: Self.recentlyReadKey) as? [String: Double] ?? [:]
recentlyRead[dialogKey] = now
// Evict stale entries (> 60s) to prevent unbounded growth.
recentlyRead = recentlyRead.filter { now - $0.value < 60 }
shared.set(recentlyRead, forKey: Self.recentlyReadKey)
}
// Deliver silently no sound, no alert.
content.sound = nil
content.title = ""
@@ -136,6 +153,23 @@ final class NotificationService: UNNotificationServiceExtension {
return
}
// 3.1 Desktop-active suppression: if this dialog was read on another device
// (Desktop) within the last 30s, suppress the notification. The user is
// actively reading on Desktop no need to buzz the phone.
if !senderKey.isEmpty {
let recentlyRead = shared.dictionary(forKey: Self.recentlyReadKey) as? [String: Double] ?? [:]
if let lastReadTime = recentlyRead[senderKey] {
let elapsed = Date().timeIntervalSince1970 - lastReadTime
if elapsed < Self.recentlyReadWindow {
content.sound = nil
content.title = ""
content.body = ""
contentHandler(content)
return
}
}
}
// 3.5 Dedup: skip badge increment if we already processed this push.
// Protects against duplicate FCM delivery (rare, but server dedup window is ~10s).
let messageId = content.userInfo["message_id"] as? String
@@ -174,7 +208,7 @@ final class NotificationService: UNNotificationServiceExtension {
updatedInfo["sender_public_key"] = senderKey
}
// 6. Resolve sender name from App Group cache (synced by DialogRepository).
// 7. 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)
@@ -186,12 +220,12 @@ final class NotificationService: UNNotificationServiceExtension {
}
content.userInfo = updatedInfo
// 7. Ensure notification category for CarPlay parity.
// 8. Ensure notification category for CarPlay parity.
if content.categoryIdentifier.isEmpty {
content.categoryIdentifier = "message"
}
// 8. Create Communication Notification via INSendMessageIntent.
// 9. Create Communication Notification via INSendMessageIntent.
let senderName = resolvedName
?? Self.firstNonBlank(content.userInfo, keys: Self.senderNameKeyNames)
?? content.title