diff --git a/Rosetta/Core/Crypto/MessageCrypto.swift b/Rosetta/Core/Crypto/MessageCrypto.swift index 9f5b6c3..3d7dd0f 100644 --- a/Rosetta/Core/Crypto/MessageCrypto.swift +++ b/Rosetta/Core/Crypto/MessageCrypto.swift @@ -176,10 +176,21 @@ enum MessageCrypto { var seen = Set() return candidates.filter { seen.insert($0).inserted } } - // Group key or plain password — try hex(utf8Bytes) first (Desktop parity: - // Desktop encrypts group attachments with Buffer.from(groupKey).toString('hex')). + // Group key or plain password — Desktop encrypts group attachments with + // Buffer.from(groupKey).toString('hex') (hex of UTF-8 bytes). + // If stored is already hex-encoded (128+ chars, all hex digits), use as-is + // to avoid generating a 256-char double-hex garbage candidate. + let isAlreadyHex = stored.count >= 128 && stored.allSatisfy { $0.isHexDigit } + if isAlreadyHex { + // Try hex as-is (Desktop) + un-hexed original (iOS/Android plain group key) + let decoded = Data(hexString: stored) + if let original = String(data: decoded, encoding: .utf8), !original.isEmpty { + return [stored, original] + } + return [stored] + } let hexVariant = Data(stored.utf8).map { String(format: "%02x", $0) }.joined() - return hexVariant == stored ? [stored] : [hexVariant, stored] + return [hexVariant, stored] } // MARK: - Android-Compatible UTF-8 Decoder diff --git a/Rosetta/Core/Services/SessionManager.swift b/Rosetta/Core/Services/SessionManager.swift index 32c21cc..1effb9b 100644 --- a/Rosetta/Core/Services/SessionManager.swift +++ b/Rosetta/Core/Services/SessionManager.swift @@ -1360,6 +1360,7 @@ final class SessionManager { self.syncRequestInFlight = false self.syncBatchInProgress = false self.pendingIncomingMessages.removeAll() + self.enqueuedMessageIds.removeAll() self.isProcessingIncomingMessages = false // Cancel stale retry timers from previous connection — @@ -1566,7 +1567,18 @@ final class SessionManager { DialogRepository.shared.updateDialogFromMessages(opponentKey: safeKey) } + /// Dedup set for the current processing queue — prevents the same messageId + /// from being enqueued twice (real-time + sync overlap). Cleared after queue drains. + private var enqueuedMessageIds: Set = [] + private func enqueueIncomingMessage(_ packet: PacketMessage) { + // Queue-level dedup: if the same messageId is already pending, skip. + // This catches the common real-time + sync overlap case without + // burning CPU on duplicate crypto + DB upsert. + let msgId = packet.messageId + if !msgId.isEmpty && enqueuedMessageIds.contains(msgId) { return } + if !msgId.isEmpty { enqueuedMessageIds.insert(msgId) } + pendingIncomingMessages.append(packet) guard !isProcessingIncomingMessages else { return } isProcessingIncomingMessages = true @@ -1595,6 +1607,7 @@ final class SessionManager { if batching { MessageRepository.shared.endBatchUpdates() } isProcessingIncomingMessages = false + enqueuedMessageIds.removeAll() signalQueueDrained() } @@ -1612,6 +1625,16 @@ final class SessionManager { let opponentKey = context.dialogKey let isGroupDialog = context.kind == .group let wasKnownBefore = MessageRepository.shared.hasMessage(packet.messageId) + + // Optimization: skip expensive crypto + upsert for incoming messages + // already stored in DB. Only outgoing messages need re-processing + // (sync may update delivery status from .waiting → .delivered). + // This also eliminates any race-condition window between real-time + // and sync delivery of the same messageId. + if wasKnownBefore && !fromMe { + return + } + let groupKey: String? = { guard isGroupDialog, let currentPrivateKeyHex else { return nil } return GroupRepository.shared.groupKey(