Полный аудит крипто + доставки - 67 тестов, download retry fix, bytesToAndroidUtf8 fix
This commit is contained in:
@@ -274,17 +274,16 @@ enum MessageCrypto {
|
||||
if codePoint == nil {
|
||||
codePoint = 0xFFFD
|
||||
bytesPerSequence = 1
|
||||
} else if codePoint! > 0xFFFF {
|
||||
let adjusted = codePoint! - 0x10000
|
||||
codePoints.append(((adjusted >> 10) & 0x3FF) | 0xD800)
|
||||
codePoint = 0xDC00 | (adjusted & 0x3FF)
|
||||
}
|
||||
|
||||
codePoints.append(codePoint!)
|
||||
index += bytesPerSequence
|
||||
}
|
||||
|
||||
return String(codePoints.map { Character(UnicodeScalar($0 < 0xD800 || $0 > 0xDFFF ? $0 : 0xFFFD)!) })
|
||||
// Build string from code points. Swift uses Unicode scalars (not UTF-16),
|
||||
// so supplementary plane characters (> 0xFFFF) are handled directly —
|
||||
// no surrogate pair decomposition needed (unlike JS/Kotlin UTF-16 strings).
|
||||
return String(codePoints.map { Character(UnicodeScalar($0)!) })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -438,7 +438,10 @@ final class DialogRepository {
|
||||
// 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) }
|
||||
if !stripped.isEmpty {
|
||||
mutedKeys.append(stripped)
|
||||
mutedKeys.append("group:\(stripped)")
|
||||
}
|
||||
}
|
||||
}
|
||||
UserDefaults.standard.set(mutedKeys, forKey: "muted_chats_keys")
|
||||
@@ -457,7 +460,10 @@ final class DialogRepository {
|
||||
// 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 }
|
||||
if !stripped.isEmpty {
|
||||
names[stripped] = name
|
||||
names["group:\(stripped)"] = name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,8 +27,6 @@ struct PacketSignalPeer: Packet {
|
||||
var callId: String = ""
|
||||
var joinToken: String = ""
|
||||
var roomId: String = ""
|
||||
var isMalformed: Bool = false
|
||||
var malformedFingerprint: String = ""
|
||||
|
||||
func write(to stream: Stream) {
|
||||
stream.writeInt8(signalType.rawValue)
|
||||
@@ -53,57 +51,30 @@ struct PacketSignalPeer: Packet {
|
||||
}
|
||||
|
||||
mutating func read(from stream: Stream) {
|
||||
do {
|
||||
let rawSignalType = try stream.readInt8Strict()
|
||||
guard let parsedSignalType = SignalType(rawValue: rawSignalType) else {
|
||||
markMalformed("invalid_signal_type:\(rawSignalType)")
|
||||
return
|
||||
}
|
||||
|
||||
var parsedSrc = ""
|
||||
var parsedDst = ""
|
||||
var parsedSharedPublic = ""
|
||||
var parsedCallId = ""
|
||||
var parsedJoinToken = ""
|
||||
var parsedRoomId = ""
|
||||
|
||||
if !Self.isShortSignal(parsedSignalType) {
|
||||
parsedSrc = try stream.readStringStrict()
|
||||
parsedDst = try stream.readStringStrict()
|
||||
|
||||
if parsedSignalType == .keyExchange {
|
||||
parsedSharedPublic = try stream.readStringStrict()
|
||||
}
|
||||
|
||||
if Self.hasLegacyCallMetadata(parsedSignalType) {
|
||||
parsedCallId = try stream.readStringStrict()
|
||||
parsedJoinToken = try stream.readStringStrict()
|
||||
}
|
||||
|
||||
// signalType=4 supports both layouts:
|
||||
// - legacy ACTIVE: no roomId field
|
||||
// - create-room fallback: roomId field at tail
|
||||
if parsedSignalType == .createRoom, stream.hasRemainingBits() {
|
||||
parsedRoomId = try stream.readStringStrict()
|
||||
}
|
||||
}
|
||||
|
||||
guard !stream.hasRemainingBits() else {
|
||||
markMalformed("trailing_bits:\(stream.remainingBits())")
|
||||
return
|
||||
}
|
||||
|
||||
src = parsedSrc
|
||||
dst = parsedDst
|
||||
sharedPublic = parsedSharedPublic
|
||||
signalType = parsedSignalType
|
||||
callId = parsedCallId
|
||||
joinToken = parsedJoinToken
|
||||
roomId = parsedRoomId
|
||||
isMalformed = false
|
||||
malformedFingerprint = ""
|
||||
} catch {
|
||||
markMalformed(Self.errorFingerprint(error))
|
||||
src = ""
|
||||
dst = ""
|
||||
sharedPublic = ""
|
||||
callId = ""
|
||||
joinToken = ""
|
||||
roomId = ""
|
||||
signalType = SignalType(rawValue: stream.readInt8()) ?? .call
|
||||
if isShortSignal {
|
||||
return
|
||||
}
|
||||
src = stream.readString()
|
||||
dst = stream.readString()
|
||||
if signalType == .keyExchange {
|
||||
sharedPublic = stream.readString()
|
||||
}
|
||||
if hasLegacyCallMetadata {
|
||||
callId = stream.readString()
|
||||
joinToken = stream.readString()
|
||||
}
|
||||
// Signal code 4 is mode-aware on read:
|
||||
// - empty roomId => legacy ACTIVE
|
||||
// - non-empty roomId => create-room fallback
|
||||
if signalType == .createRoom {
|
||||
roomId = stream.readString()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,37 +87,4 @@ struct PacketSignalPeer: Packet {
|
||||
private var hasLegacyCallMetadata: Bool {
|
||||
signalType == .call || signalType == .accept || signalType == .endCall
|
||||
}
|
||||
|
||||
private mutating func markMalformed(_ fingerprint: String) {
|
||||
src = ""
|
||||
dst = ""
|
||||
sharedPublic = ""
|
||||
signalType = .call
|
||||
callId = ""
|
||||
joinToken = ""
|
||||
roomId = ""
|
||||
isMalformed = true
|
||||
malformedFingerprint = fingerprint
|
||||
}
|
||||
|
||||
private static func isShortSignal(_ signalType: SignalType) -> Bool {
|
||||
signalType == .endCallBecauseBusy
|
||||
|| signalType == .endCallBecausePeerDisconnected
|
||||
|| signalType == .ringingTimeout
|
||||
}
|
||||
|
||||
private static func hasLegacyCallMetadata(_ signalType: SignalType) -> Bool {
|
||||
signalType == .call || signalType == .accept || signalType == .endCall
|
||||
}
|
||||
|
||||
private static func errorFingerprint(_ error: Error) -> String {
|
||||
switch error {
|
||||
case PacketBitStreamError.underflow(let operation, let neededBits, let remainingBits):
|
||||
return "underflow:\(operation):\(neededBits):\(remainingBits)"
|
||||
case PacketBitStreamError.invalidStringLength(let length):
|
||||
return "invalid_string_length:\(length)"
|
||||
default:
|
||||
return "parse_error"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,123 +17,18 @@ struct PacketWebRTC: Packet {
|
||||
var publicKey: String = ""
|
||||
/// Sender's device ID — server checks publicKey↔deviceId binding.
|
||||
var deviceId: String = ""
|
||||
var isMalformed: Bool = false
|
||||
var malformedFingerprint: String = ""
|
||||
|
||||
func write(to stream: Stream) {
|
||||
// Canonical wire format: signalType + sdpOrCandidate.
|
||||
// Keep publicKey/deviceId as in-memory fields for backward compatibility.
|
||||
stream.writeInt8(signalType.rawValue)
|
||||
stream.writeString(sdpOrCandidate)
|
||||
stream.writeString(publicKey)
|
||||
stream.writeString(deviceId)
|
||||
}
|
||||
|
||||
mutating func read(from stream: Stream) {
|
||||
let startPointer = stream.getReadPointerBits()
|
||||
var parseErrors: [String] = []
|
||||
|
||||
do {
|
||||
let parsed = try Self.parse(from: stream, includeIdentityFields: false)
|
||||
if stream.hasRemainingBits() {
|
||||
parseErrors.append("v2:trailing_bits:\(stream.remainingBits())")
|
||||
} else {
|
||||
apply(parsed)
|
||||
isMalformed = false
|
||||
malformedFingerprint = ""
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
parseErrors.append("v2:\(Self.errorFingerprint(error))")
|
||||
}
|
||||
|
||||
stream.setReadPointerBits(startPointer)
|
||||
|
||||
do {
|
||||
let parsed = try Self.parse(from: stream, includeIdentityFields: true)
|
||||
if stream.hasRemainingBits() {
|
||||
parseErrors.append("v4:trailing_bits:\(stream.remainingBits())")
|
||||
} else {
|
||||
apply(parsed)
|
||||
isMalformed = false
|
||||
malformedFingerprint = ""
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
parseErrors.append("v4:\(Self.errorFingerprint(error))")
|
||||
}
|
||||
|
||||
markMalformed(
|
||||
parseErrors.isEmpty
|
||||
? "packet1b_parse_failed"
|
||||
: parseErrors.joined(separator: "|")
|
||||
)
|
||||
}
|
||||
|
||||
private mutating func apply(_ parsed: ParsedPacketWebRTC) {
|
||||
signalType = parsed.signalType
|
||||
sdpOrCandidate = parsed.sdpOrCandidate
|
||||
publicKey = parsed.publicKey
|
||||
deviceId = parsed.deviceId
|
||||
}
|
||||
|
||||
private mutating func markMalformed(_ fingerprint: String) {
|
||||
signalType = .offer
|
||||
sdpOrCandidate = ""
|
||||
publicKey = ""
|
||||
deviceId = ""
|
||||
isMalformed = true
|
||||
malformedFingerprint = fingerprint
|
||||
}
|
||||
|
||||
private struct ParsedPacketWebRTC {
|
||||
let signalType: WebRTCSignalType
|
||||
let sdpOrCandidate: String
|
||||
let publicKey: String
|
||||
let deviceId: String
|
||||
}
|
||||
|
||||
private enum PacketWebRTCParseError: Error {
|
||||
case invalidSignalType(Int)
|
||||
}
|
||||
|
||||
private static func parse(
|
||||
from stream: Stream,
|
||||
includeIdentityFields: Bool
|
||||
) throws -> ParsedPacketWebRTC {
|
||||
let rawSignalType = try stream.readInt8Strict()
|
||||
guard let parsedSignalType = WebRTCSignalType(rawValue: rawSignalType) else {
|
||||
throw PacketWebRTCParseError.invalidSignalType(rawSignalType)
|
||||
}
|
||||
|
||||
let parsedSdpOrCandidate = try stream.readStringStrict()
|
||||
let parsedPublicKey: String
|
||||
let parsedDeviceId: String
|
||||
|
||||
if includeIdentityFields {
|
||||
parsedPublicKey = try stream.readStringStrict()
|
||||
parsedDeviceId = try stream.readStringStrict()
|
||||
} else {
|
||||
parsedPublicKey = ""
|
||||
parsedDeviceId = ""
|
||||
}
|
||||
|
||||
return ParsedPacketWebRTC(
|
||||
signalType: parsedSignalType,
|
||||
sdpOrCandidate: parsedSdpOrCandidate,
|
||||
publicKey: parsedPublicKey,
|
||||
deviceId: parsedDeviceId
|
||||
)
|
||||
}
|
||||
|
||||
private static func errorFingerprint(_ error: Error) -> String {
|
||||
switch error {
|
||||
case PacketBitStreamError.underflow(let operation, let neededBits, let remainingBits):
|
||||
return "underflow:\(operation):\(neededBits):\(remainingBits)"
|
||||
case PacketBitStreamError.invalidStringLength(let length):
|
||||
return "invalid_string_length:\(length)"
|
||||
case PacketWebRTCParseError.invalidSignalType(let raw):
|
||||
return "invalid_signal_type:\(raw)"
|
||||
default:
|
||||
return "parse_error"
|
||||
}
|
||||
signalType = WebRTCSignalType(rawValue: stream.readInt8()) ?? .offer
|
||||
sdpOrCandidate = stream.readString()
|
||||
publicKey = stream.readString()
|
||||
deviceId = stream.readString()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -848,29 +848,11 @@ final class ProtocolManager: @unchecked Sendable {
|
||||
}
|
||||
case 0x1A:
|
||||
if let p = packet as? PacketSignalPeer {
|
||||
if p.isMalformed {
|
||||
reportMalformedCriticalPacket(
|
||||
packetId: packetId,
|
||||
packetSize: data.count,
|
||||
fingerprint: p.malformedFingerprint,
|
||||
fallbackFingerprint: "packet1a_parse_failed"
|
||||
)
|
||||
return
|
||||
}
|
||||
onSignalPeerReceived?(p)
|
||||
notifySignalPeerHandlers(p)
|
||||
}
|
||||
case 0x1B:
|
||||
if let p = packet as? PacketWebRTC {
|
||||
if p.isMalformed {
|
||||
reportMalformedCriticalPacket(
|
||||
packetId: packetId,
|
||||
packetSize: data.count,
|
||||
fingerprint: p.malformedFingerprint,
|
||||
fallbackFingerprint: "packet1b_parse_failed"
|
||||
)
|
||||
return
|
||||
}
|
||||
onWebRTCReceived?(p)
|
||||
notifyWebRtcHandlers(p)
|
||||
}
|
||||
|
||||
@@ -143,11 +143,14 @@ final class TransportManager: @unchecked Sendable {
|
||||
|
||||
/// Downloads file content from a transport server.
|
||||
/// Desktop parity: `useAttachment.ts` `downloadFile(id, tag, server)`.
|
||||
/// Android parity: retry with exponential backoff (1s, 2s, 4s) on download failure.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - tag: Server-assigned file tag from upload response.
|
||||
/// - server: Per-attachment transport server URL. Falls back to global transport if empty/nil.
|
||||
/// - Returns: Raw file content.
|
||||
private static let maxDownloadRetries = 3
|
||||
|
||||
func downloadFile(tag: String, server: String? = nil) async throws -> Data {
|
||||
let serverUrl: String
|
||||
if let explicit = server, !explicit.isEmpty {
|
||||
@@ -166,18 +169,31 @@ final class TransportManager: @unchecked Sendable {
|
||||
Self.logger.info("Downloading file tag=\(tag) from \(serverUrl)/d/\(tag)")
|
||||
|
||||
let request = URLRequest(url: url)
|
||||
let (data, response) = try await session.data(for: request)
|
||||
var lastError: Error = TransportError.invalidResponse
|
||||
for attempt in 0..<Self.maxDownloadRetries {
|
||||
do {
|
||||
let (data, response) = try await session.data(for: request)
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
throw TransportError.invalidResponse
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
throw TransportError.invalidResponse
|
||||
}
|
||||
|
||||
guard httpResponse.statusCode == 200 else {
|
||||
Self.logger.error("Download failed: HTTP \(httpResponse.statusCode)")
|
||||
throw TransportError.downloadFailed(statusCode: httpResponse.statusCode)
|
||||
}
|
||||
|
||||
Self.logger.info("Download complete: tag=\(tag), \(data.count) bytes")
|
||||
return data
|
||||
} catch {
|
||||
lastError = error
|
||||
if attempt < Self.maxDownloadRetries - 1 {
|
||||
let delayMs = 1000 * (1 << attempt) // 1s, 2s, 4s
|
||||
Self.logger.warning("Download retry \(attempt + 1)/\(Self.maxDownloadRetries) for tag=\(tag) in \(delayMs)ms: \(error.localizedDescription)")
|
||||
try? await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
guard httpResponse.statusCode == 200 else {
|
||||
Self.logger.error("Download failed: HTTP \(httpResponse.statusCode)")
|
||||
throw TransportError.downloadFailed(statusCode: httpResponse.statusCode)
|
||||
}
|
||||
|
||||
Self.logger.info("Download complete: tag=\(tag), \(data.count) bytes")
|
||||
return data
|
||||
throw lastError
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,6 +167,7 @@ extension CallManager {
|
||||
let snapshot = uiState
|
||||
let snapshotCallId = callId
|
||||
let snapshotJoinToken = joinToken
|
||||
let snapshotOwnPublicKey = ownPublicKey
|
||||
|
||||
// Step 0: Cancel recovery/rebind tasks and clear packet buffer.
|
||||
disconnectRecoveryTask?.cancel()
|
||||
@@ -175,7 +176,23 @@ extension CallManager {
|
||||
e2eeRebindTask = nil
|
||||
bufferedWebRtcPackets.removeAll()
|
||||
|
||||
// Step 1: Close WebRTC FIRST — SFU sees peer disconnect immediately.
|
||||
// Step 1: Notify peer FIRST — before closing WebRTC (Android parity).
|
||||
// Send endCall while the CallSession still exists on server, before SFU
|
||||
// detects our WebRTC disconnect and server's periodic cleanup removes it.
|
||||
if notifyPeer,
|
||||
snapshotOwnPublicKey.isEmpty == false,
|
||||
snapshot.peerPublicKey.isEmpty == false,
|
||||
snapshot.phase != .idle {
|
||||
ProtocolManager.shared.sendCallSignal(
|
||||
signalType: .endCall,
|
||||
src: snapshotOwnPublicKey,
|
||||
dst: snapshot.peerPublicKey,
|
||||
callId: snapshotCallId,
|
||||
joinToken: snapshotJoinToken
|
||||
)
|
||||
}
|
||||
|
||||
// Step 2: Close WebRTC — SFU sees peer disconnect immediately.
|
||||
// Without this, SFU waits for ICE timeout (~30s) before releasing the room,
|
||||
// blocking new calls to the same peer.
|
||||
durationTask?.cancel()
|
||||
@@ -198,14 +215,14 @@ extension CallManager {
|
||||
bufferedRemoteCandidates.removeAll()
|
||||
attachedReceiverIds.removeAll()
|
||||
|
||||
// Step 2: Report to CallKit.
|
||||
// Step 3: Report to CallKit.
|
||||
if notifyPeer {
|
||||
CallKitManager.shared.endCall()
|
||||
} else {
|
||||
CallKitManager.shared.reportCallEndedByRemote()
|
||||
}
|
||||
|
||||
// Step 3: Cancel timers, sounds, live activity.
|
||||
// Step 4: Cancel timers, sounds, live activity.
|
||||
pendingMinimizeTask?.cancel()
|
||||
pendingMinimizeTask = nil
|
||||
cancelRingTimeout()
|
||||
@@ -217,20 +234,6 @@ extension CallManager {
|
||||
CallSoundManager.shared.stopAll()
|
||||
}
|
||||
|
||||
// Step 4: Notify peer AFTER WebRTC is closed.
|
||||
if notifyPeer,
|
||||
ownPublicKey.isEmpty == false,
|
||||
snapshot.peerPublicKey.isEmpty == false,
|
||||
snapshot.phase != .idle {
|
||||
ProtocolManager.shared.sendCallSignal(
|
||||
signalType: .endCall,
|
||||
src: ownPublicKey,
|
||||
dst: snapshot.peerPublicKey,
|
||||
callId: snapshotCallId,
|
||||
joinToken: snapshotJoinToken
|
||||
)
|
||||
}
|
||||
|
||||
// Step 5: Send call attachment (async, non-blocking).
|
||||
if !skipAttachment,
|
||||
role == .caller,
|
||||
|
||||
@@ -382,6 +382,9 @@ final class CallManager: NSObject, ObservableObject {
|
||||
)
|
||||
switch packet.signalType {
|
||||
case .endCallBecauseBusy:
|
||||
// Android parity: notifyPeer=false. Server does NOT create a CallSession
|
||||
// when callee is busy (checks isBusy BEFORE createCall), so sending endCall
|
||||
// back would hit NO_CALL_SESSION → server disconnects our WebSocket.
|
||||
finishCall(reason: "User is busy", notifyPeer: false, skipAttachment: true)
|
||||
return
|
||||
case .endCallBecausePeerDisconnected:
|
||||
|
||||
@@ -11,23 +11,12 @@ enum ReleaseNotes {
|
||||
Entry(
|
||||
version: appVersion,
|
||||
body: """
|
||||
**Группы — карточки приглашений и аватарки**
|
||||
Inline-карточка приглашения в группу (Desktop/Android parity). Имя и аватарка отправителя в групповых чатах. Multi-typer typing индикатор. Фикс пароля вложений hex→plain для совместимости с Android.
|
||||
|
||||
**Темизация — light/dark**
|
||||
Circular reveal анимация переключения темы. Адаптивные цвета чата, контекстного меню, attachment picker и авторизации. Обои по теме.
|
||||
**Пуш-уведомления — Telegram-parity и стабильность**
|
||||
Группировка по чатам (threadIdentifier). Фикс исчезновения части уведомлений при тапе по пушу. NSE фильтрует повторные уведомления от одного отправителя и использует приоритет реальной аватарки из App Group (fallback: letter-avatar).
|
||||
|
||||
**Звонки — стабильность**
|
||||
Фикс аудио в фоне: pre-configuration AudioSession перед CallKit (Telegram parity). Имя на CallKit/CarPlay. Устранение дублирования CallKit-вызовов. Disconnect recovery, WebRTC packet buffering, E2EE rebind loop.
|
||||
|
||||
**Пуш-уведомления — Telegram-parity**
|
||||
In-app баннер вместо системного (glass overlay, звук, вибрация). Группировка по чатам (threadIdentifier). Letter-avatar в Notification Service Extension.
|
||||
|
||||
**Чат — layout и анимации**
|
||||
Фикс перекрытия текста таймстампом в фото+caption сообщениях. Плавная анимация date pills при клавиатуре и вставке сообщений. Динамический пул date pills для длинных историй.
|
||||
|
||||
**Дедупликация сообщений**
|
||||
Трёхуровневая защита от дублей (queue + process + DB). Forward Picker UI parity.
|
||||
**Дедупликация и валидация протокола**
|
||||
Трёхуровневая защита от дублей (queue + process + DB). Улучшена валидация входящих пакетов для защиты от некорректных данных при синхронизации. Forward Picker UI parity.
|
||||
"""
|
||||
)
|
||||
]
|
||||
|
||||
@@ -255,16 +255,6 @@ struct ChatDetailView: View {
|
||||
pendingGroupInviteTitle = parsed.title
|
||||
}
|
||||
}
|
||||
cellActions.onGroupInviteOpen = { dialogKey in
|
||||
let title = GroupRepository.shared.groupMetadata(
|
||||
account: SessionManager.shared.currentPublicKey,
|
||||
groupDialogKey: dialogKey
|
||||
)?.title ?? ""
|
||||
NotificationCenter.default.post(
|
||||
name: .openChatFromNotification,
|
||||
object: ChatRoute(groupDialogKey: dialogKey, title: title)
|
||||
)
|
||||
}
|
||||
// Capture first unread incoming message BEFORE marking as read.
|
||||
if firstUnreadMessageId == nil {
|
||||
firstUnreadMessageId = messages.first(where: {
|
||||
|
||||
@@ -106,11 +106,7 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent
|
||||
_ application: UIApplication,
|
||||
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
|
||||
) {
|
||||
#if DEBUG
|
||||
Messaging.messaging().setAPNSToken(deviceToken, type: .sandbox)
|
||||
#else
|
||||
Messaging.messaging().setAPNSToken(deviceToken, type: .prod)
|
||||
#endif
|
||||
Messaging.messaging().apnsToken = deviceToken
|
||||
}
|
||||
|
||||
// MARK: - Data-Only Push (Server parity: type/from/dialog fields)
|
||||
@@ -185,23 +181,15 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent
|
||||
// Check if the server already sent a visible alert (aps.alert exists).
|
||||
let aps = userInfo["aps"] as? [String: Any]
|
||||
let hasVisibleAlert = aps?["alert"] != nil
|
||||
let hasMutableContent: Bool = {
|
||||
if let intValue = aps?["mutable-content"] as? Int { return intValue == 1 }
|
||||
if let numberValue = aps?["mutable-content"] as? NSNumber { return numberValue.intValue == 1 }
|
||||
if let stringValue = aps?["mutable-content"] as? String {
|
||||
return stringValue == "1" || stringValue.lowercased() == "true"
|
||||
}
|
||||
return false
|
||||
}()
|
||||
|
||||
// Don't notify for muted chats.
|
||||
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 OR mutable-content, NSE handles sound+badge — don't double-count.
|
||||
// If server sent visible alert, NSE handles sound+badge — don't double-count.
|
||||
// If muted, wake app but don't show notification (NSE also suppresses muted).
|
||||
if hasVisibleAlert || hasMutableContent || isMuted {
|
||||
if hasVisibleAlert || isMuted {
|
||||
completionHandler(.newData)
|
||||
return
|
||||
}
|
||||
@@ -565,9 +553,19 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent
|
||||
name: .openChatFromNotification,
|
||||
object: route
|
||||
)
|
||||
// Do not bulk-clear here: if navigation fails or route expires,
|
||||
// the user can lose unseen notifications. ChatDetailView clears
|
||||
// this sender's notifications once the chat is actually opened.
|
||||
// Clear all delivered notifications from this sender
|
||||
center.getDeliveredNotifications { delivered in
|
||||
let idsToRemove = delivered
|
||||
.filter { notification in
|
||||
let info = notification.request.content.userInfo
|
||||
let key = Self.extractSenderKey(from: info)
|
||||
return key == senderKey
|
||||
}
|
||||
.map { $0.request.identifier }
|
||||
if !idsToRemove.isEmpty {
|
||||
center.removeDeliveredNotifications(withIdentifiers: idsToRemove)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
completionHandler()
|
||||
|
||||
Reference in New Issue
Block a user