diff --git a/Rosetta/Core/Data/Repositories/DialogRepository.swift b/Rosetta/Core/Data/Repositories/DialogRepository.swift index dedaa8b..d786db6 100644 --- a/Rosetta/Core/Data/Repositories/DialogRepository.swift +++ b/Rosetta/Core/Data/Repositories/DialogRepository.swift @@ -504,6 +504,12 @@ final class DialogRepository { // CHNK: chunked format if trimmed.hasPrefix("CHNK:") { return true } + // Pure hex string (≥40 chars) — XChaCha20 wire format + if trimmed.count >= 40 { + let hexChars = CharacterSet(charactersIn: "0123456789abcdefABCDEF") + if trimmed.unicodeScalars.allSatisfy({ hexChars.contains($0) }) { return true } + } + // Original check: all characters are garbage (U+FFFD, control chars, null bytes) let validCharacters = trimmed.unicodeScalars.filter { scalar in scalar.value != 0xFFFD && diff --git a/Rosetta/Core/Data/Repositories/MessageRepository.swift b/Rosetta/Core/Data/Repositories/MessageRepository.swift index e077f2c..7357b3e 100644 --- a/Rosetta/Core/Data/Repositories/MessageRepository.swift +++ b/Rosetta/Core/Data/Repositories/MessageRepository.swift @@ -408,7 +408,37 @@ final class MessageRepository: ObservableObject { } } } catch { - print("[DB] upsert error: \(error)") + // Retry once — transient GRDB errors (SQLITE_BUSY, lock contention) + // should not permanently lose a message. + print("[DB] upsert error (will retry): \(error)") + do { + try db.writeSync { db in + var record = MessageRecord( + id: nil, + account: myPublicKey, + fromPublicKey: packet.fromPublicKey, + toPublicKey: packet.toPublicKey, + content: packet.content, + chachaKey: packet.chachaKey, + text: storedText, + plainMessage: storedText, + timestamp: timestamp, + isRead: incomingRead ? 1 : 0, + readAlias: incomingRead ? 1 : 0, + fromMe: fromMe ? 1 : 0, + deliveryStatus: outgoingStatus.rawValue, + deliveredAlias: outgoingStatus.rawValue, + messageId: messageId, + replyToMessageId: nil, + dialogKey: dialogKey, + attachments: attachmentsJSON, + attachmentPassword: attachmentPassword + ) + try record.insert(db, onConflict: .replace) + } + } catch { + print("[DB] upsert retry FAILED — message may be lost: \(error)") + } } // Debounced cache refresh — batch during sync @@ -869,7 +899,21 @@ final class MessageRepository: ObservableObject { plainText = decrypted } else { let fallback = Self.safePlainMessageFallback(record.text) - plainText = fallback + + // Desktop/Android parity: lazy re-decrypt from encrypted wire content. + // When a message was stored with decrypt failure (empty text but content + // and chachaKey present), try ECDH/group decryption with current keys. + if fallback.isEmpty, !record.content.isEmpty { + if let retried = Self.tryReDecrypt(record: record, privateKeyHex: privateKey) { + plainText = retried + // Persist successful re-decrypt to DB so we don't retry every load + persistReDecryptedText(retried, forMessageId: record.messageId, privateKey: privateKey) + } else { + plainText = fallback + } + } else { + plainText = fallback + } } } else { plainText = Self.safePlainMessageFallback(record.text) @@ -878,6 +922,68 @@ final class MessageRepository: ObservableObject { return record.toChatMessage(overrideText: plainText) } + // MARK: - Lazy Re-Decryption (Desktop/Android Parity) + + /// Attempts to decrypt a message from its stored encrypted wire content. + /// Tries ECDH path (direct messages) and group key path (group messages). + /// Called when primary decryption (from `text` column) fails but raw content exists. + private static func tryReDecrypt(record: MessageRecord, privateKeyHex: String) -> String? { + guard !record.content.isEmpty else { return nil } + + // Path 1: ECDH path — chachaKey + privateKey → XChaCha20 decrypt + if !record.chachaKey.isEmpty { + do { + let (text, _) = try MessageCrypto.decryptIncomingFull( + ciphertext: record.content, + encryptedKey: record.chachaKey, + myPrivateKeyHex: privateKeyHex + ) + return text + } catch { + // Fall through to group path + } + } + + // Path 2: Group messages — try group key if dialog is a group. + // Group dialogKeys are stored as-is (e.g., "#group:xxx"), not "account:opponent". + if DatabaseManager.isGroupDialogKey(record.dialogKey) { + if let groupKey = GroupRepository.shared.groupKey( + account: record.account, + privateKeyHex: privateKeyHex, + groupDialogKey: record.dialogKey + ) { + if let data = try? CryptoManager.shared.decryptWithPassword( + record.content, password: groupKey + ), let text = String(data: data, encoding: .utf8) { + return text + } + } + } + + return nil + } + + /// Persists successfully re-decrypted text back to DB. + private func persistReDecryptedText(_ text: String, forMessageId messageId: String, privateKey: String) { + guard !currentAccount.isEmpty else { return } + let storedText: String + if let enc = try? CryptoManager.shared.encryptWithPassword(Data(text.utf8), password: privateKey) { + storedText = enc + } else { + storedText = text + } + do { + try db.writeSync { db in + try db.execute( + sql: "UPDATE messages SET text = ?, plain_message = ? WHERE account = ? AND message_id = ?", + arguments: [storedText, storedText, currentAccount, messageId] + ) + } + } catch { + // Non-critical — message will retry on next load + } + } + // MARK: - Android Parity: safePlainMessageFallback /// Android parity: `safePlainMessageFallback()` in `ChatViewModel.kt` lines 1338-1349. @@ -888,19 +994,28 @@ final class MessageRepository: ObservableObject { return raw // Looks like real plaintext (legacy unencrypted record) } - /// Android parity: `isProbablyEncryptedPayload()` — detects `ivBase64:ctBase64` format. + /// Android parity: `isProbablyEncryptedPayload()` — detects encrypted formats. private static func isProbablyEncryptedPayload(_ value: String) -> Bool { let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) // Chunked format if trimmed.hasPrefix("CHNK:") { return true } - // ivBase64:ctBase64 format + // ivBase64:ctBase64 format — both parts must be ≥16 chars to avoid + // false positives on short strings like "hello:world". + // Aligned with MessageCellLayout.isGarbageOrEncrypted() and + // DialogRepository.isGarbageText(). let parts = trimmed.components(separatedBy: ":") - guard parts.count == 2 else { return false } - // Both parts should look like Base64 (alphanumeric + /+=) - let base64Chars = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "+/=")) - return parts.allSatisfy { part in - !part.isEmpty && part.unicodeScalars.allSatisfy { base64Chars.contains($0) } + if parts.count == 2 { + let base64Chars = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "+/=")) + if parts.allSatisfy({ part in + part.count >= 16 && part.unicodeScalars.allSatisfy { base64Chars.contains($0) } + }) { return true } } + // Pure hex string (≥40 chars) — XChaCha20 wire format + if trimmed.count >= 40 { + let hexChars = CharacterSet(charactersIn: "0123456789abcdefABCDEF") + if trimmed.unicodeScalars.allSatisfy({ hexChars.contains($0) }) { return true } + } + return false } private func normalizeTimestamp(_ raw: Int64) -> Int64 { diff --git a/Rosetta/Core/Layout/MessageCellLayout.swift b/Rosetta/Core/Layout/MessageCellLayout.swift index a0e43c7..337c099 100644 --- a/Rosetta/Core/Layout/MessageCellLayout.swift +++ b/Rosetta/Core/Layout/MessageCellLayout.swift @@ -175,6 +175,7 @@ extension MessageCellLayout { messageType = .text } let isTextMessage = (messageType == .text || messageType == .textWithReply) + let isForwardWithCaption = messageType == .forward && !config.text.isEmpty let textStatusLaneMetrics = TextStatusLaneMetrics.telegram( fontPointSize: font.pointSize, screenPixel: screenPixel @@ -198,7 +199,7 @@ extension MessageCellLayout { // Outgoing: timestamp at 5pt from edge (checkmarks fill remaining space → rightPad-5=6pt compensation) // Incoming: timestamp at rightPad (11pt) from edge, same as text → 0pt compensation let statusTrailingCompensation: CGFloat - if isTextMessage && config.isOutgoing { + if (isTextMessage || isForwardWithCaption) && config.isOutgoing { statusTrailingCompensation = max(0, rightPad - textStatusLaneMetrics.textStatusRightInset) } else { statusTrailingCompensation = 0 @@ -210,7 +211,7 @@ extension MessageCellLayout { let textMeasurement: TextMeasurement var cachedTextLayout: CoreTextTextLayout? - let needsDetailedTextLayout = isTextMessage || messageType == .photoWithCaption + let needsDetailedTextLayout = isTextMessage || messageType == .photoWithCaption || (messageType == .forward && !config.text.isEmpty) if !config.text.isEmpty && needsDetailedTextLayout { // CoreText (CTTypesetter) — returns per-line widths including lastLineWidth. // Also captures CoreTextTextLayout for cell rendering (avoids double computation). @@ -239,7 +240,7 @@ extension MessageCellLayout { let metadataWidth = tsSize.width + timeToCheckGap + checkW let trailingWidthForStatus: CGFloat - if isTextMessage && !config.text.isEmpty { + if (isTextMessage || isForwardWithCaption) && !config.text.isEmpty { if let cachedTextLayout { if cachedTextLayout.lastLineHasRTL { trailingWidthForStatus = 10_000 @@ -277,7 +278,7 @@ extension MessageCellLayout { // ── STEP 3: Inline vs Wrapped determination ── let timestampInline: Bool - if isTextMessage && !config.text.isEmpty { + if (isTextMessage || isForwardWithCaption) && !config.text.isEmpty { timestampInline = inlineStatusContentWidth <= maxTextWidth } else { timestampInline = true @@ -290,10 +291,10 @@ extension MessageCellLayout { // Telegram reply: 3pt top pad + 17pt name + 2pt spacing + 17pt text + 3pt bottom = 42pt container let replyContainerH: CGFloat = 42 let replyTopInset: CGFloat = 5 - let replyBottomGap: CGFloat = 7 // Telegram: 7pt from reply container bottom to message text + let replyBottomGap: CGFloat = 3 let replyH: CGFloat = config.hasReplyQuote ? (replyTopInset + replyContainerH + replyBottomGap - topPad) : 0 var photoH: CGFloat = 0 - let forwardHeaderH: CGFloat = config.isForward ? 40 : 0 + let forwardHeaderH: CGFloat = config.isForward ? 41 : 0 var fileH: CGFloat = CGFloat(config.fileCount) * 52 + CGFloat(config.callCount) * 42 + CGFloat(config.avatarCount) * 52 @@ -378,12 +379,25 @@ extension MessageCellLayout { bubbleW = leftPad + finalContentW + rightPad bubbleW = max(minW, min(bubbleW, effectiveMaxBubbleWidth)) if config.hasReplyQuote { bubbleW = max(bubbleW, 180) } + } else if isForwardWithCaption { + // Forward with caption — SAME sizing as text messages (Telegram parity) + // 2pt gap from forward header to text (Telegram: spacing after forward = 2pt) + let actualTextW = textMeasurement.size.width + let finalContentW: CGFloat + if timestampInline { + finalContentW = max(actualTextW, inlineStatusContentWidth) + bubbleH += 2 + textMeasurement.size.height + bottomPad + } else { + finalContentW = max(actualTextW, wrappedStatusContentWidth) + bubbleH += 2 + textMeasurement.size.height + 15 + bottomPad + } + bubbleW = leftPad + finalContentW + rightPad + bubbleW = max(minW, min(bubbleW, effectiveMaxBubbleWidth)) } else if !config.text.isEmpty { - // Non-text with caption (file, forward) + // Non-text with caption (file) let finalContentW = max(textMeasurement.size.width, metadataWidth) bubbleW = leftPad + finalContentW + rightPad bubbleW = max(minW, min(bubbleW, effectiveMaxBubbleWidth)) - // File/call/avatar with text: ensure min width for icon+text layout let fileMinW: CGFloat = config.callCount > 0 ? 200 : 220 if fileH > 0 { bubbleW = max(bubbleW, min(fileMinW, effectiveMaxBubbleWidth)) } bubbleH += topPad + textMeasurement.size.height + bottomPad @@ -412,9 +426,24 @@ extension MessageCellLayout { if config.text.isEmpty && photoH == 0 && fileH == 0 && !config.isForward { bubbleH = max(bubbleH, 37) } + // Forward header needs minimum width for "Forwarded from" + avatar + name + if config.isForward { + bubbleW = max(bubbleW, min(200, effectiveMaxBubbleWidth)) + } // Stretchable bubble image min height bubbleH = max(bubbleH, 37) + // Forward/reply text area must match regular text bubble min-height behavior. + // Without this, text and timestamp share the same bottom edge (look "centered"). + // With this, the text area gets the same ~5pt extra padding as regular text bubbles. + if (isForwardWithCaption || (config.hasReplyQuote && !config.text.isEmpty)) { + let headerH = forwardHeaderH + replyH + let textAreaH = bubbleH - headerH + if textAreaH < 37 { + bubbleH = headerH + 37 + } + } + // Date header adds height above the bubble. let dateHeaderH: CGFloat = config.showsDateHeader ? 42 : 0 @@ -437,7 +466,7 @@ extension MessageCellLayout { // Photo right = bubbleW - 2. Gap = 6pt → pill right = bubbleW - 8. // → statusEndX = bubbleW - 15 → metadataRightInset = 15. metadataRightInset = 15 - } else if isTextMessage || messageType == .photoWithCaption { + } else if isTextMessage || isForwardWithCaption || messageType == .photoWithCaption { metadataRightInset = config.isOutgoing ? textStatusLaneMetrics.textStatusRightInset : rightPad @@ -457,7 +486,7 @@ extension MessageCellLayout { } let statusEndX = bubbleW - metadataRightInset let statusEndY = bubbleH - metadataBottomInset - let statusVerticalOffset: CGFloat = isTextMessage + let statusVerticalOffset: CGFloat = (isTextMessage || isForwardWithCaption) ? textStatusLaneMetrics.verticalOffset : 0 @@ -484,7 +513,7 @@ extension MessageCellLayout { if hasStatusIcon { let checkImgW: CGFloat = floor(floor(font.pointSize * 11.0 / 17.0)) let checkImgH: CGFloat = floor(checkImgW * 9.0 / 11.0) - let checkOffset: CGFloat = isTextMessage + let checkOffset: CGFloat = (isTextMessage || isForwardWithCaption) ? textStatusLaneMetrics.checkOffset : floor(font.pointSize * 6.0 / 17.0) let checkReadX = statusEndX - checkImgW @@ -510,7 +539,7 @@ extension MessageCellLayout { // line breaks ("jagged first line"). The content area is always ≥ measured width. var textY: CGFloat = topPad if config.hasReplyQuote { textY = replyH + topPad } - if forwardHeaderH > 0 { textY = forwardHeaderH + topPad } + if forwardHeaderH > 0 { textY = forwardHeaderH + 2 } if photoH > 0 { // Photo has 2pt inset top + 2pt inset bottom, so text starts after photoH + 4 + gap textY = photoH + 4 + 6 + topPad @@ -544,9 +573,9 @@ extension MessageCellLayout { let photoFrame = CGRect(x: 2, y: photoY, width: bubbleW - 4, height: photoH) let fileFrame = CGRect(x: 0, y: config.hasReplyQuote ? replyH : 0, width: bubbleW, height: fileH) - let fwdHeaderFrame = CGRect(x: 10, y: 6, width: bubbleW - 20, height: 14) - let fwdAvatarFrame = CGRect(x: 10, y: 23, width: 20, height: 20) - let fwdNameFrame = CGRect(x: 34, y: 24, width: bubbleW - 44, height: 17) + let fwdHeaderFrame = CGRect(x: 10, y: 7, width: bubbleW - 20, height: 17) + let fwdAvatarFrame = CGRect(x: 10, y: 24, width: 16, height: 16) + let fwdNameFrame = CGRect(x: 30, y: 24, width: bubbleW - 40, height: 17) let layout = MessageCellLayout( totalHeight: totalH, @@ -677,6 +706,9 @@ extension MessageCellLayout { } if validChars.isEmpty { return true } + // Chunked format + if trimmed.hasPrefix("CHNK:") { return true } + // Check for encrypted ciphertext: Base64:Base64 pattern let parts = trimmed.components(separatedBy: ":") if parts.count == 2 && parts[0].count >= 16 && parts[1].count >= 16 { @@ -688,6 +720,12 @@ extension MessageCellLayout { if allBase64 { return true } } + // Pure hex string (≥40 chars) — XChaCha20 wire format + if trimmed.count >= 40 { + let hexChars = CharacterSet(charactersIn: "0123456789abcdefABCDEF") + if trimmed.unicodeScalars.allSatisfy({ hexChars.contains($0) }) { return true } + } + return false } @@ -867,6 +905,23 @@ extension MessageCellLayout { let hasReply = message.attachments.contains { $0.type == .messages } let isForward = hasReply && displayText.isEmpty + // Parse forward content from .messages blob + var forwardCaption: String? + var forwardInnerImageCount = 0 + var forwardInnerFileCount = 0 + if isForward, + let att = message.attachments.first(where: { $0.type == .messages }), + let data = att.blob.data(using: .utf8), + let replies = try? JSONDecoder().decode([ReplyMessageData].self, from: data), + let first = replies.first { + let fwdText = first.message.trimmingCharacters(in: .whitespacesAndNewlines) + if !fwdText.isEmpty && !isGarbageOrEncrypted(fwdText) { + forwardCaption = fwdText + } + forwardInnerImageCount = first.attachments.filter { $0.type == 0 }.count + forwardInnerFileCount = first.attachments.filter { $0.type == 2 }.count + } + // Parse image dimensions from preview field (format: "tag::blurhash::WxH") let imageDims: CGSize? = images.first.flatMap { AttachmentPreviewCodec.imageDimensions(from: $0.preview) @@ -877,7 +932,7 @@ extension MessageCellLayout { isOutgoing: isOutgoing, position: position, deliveryStatus: message.deliveryStatus, - text: displayText, + text: isForward ? (forwardCaption ?? "") : displayText, timestampText: timestampText, hasReplyQuote: hasReply && !displayText.isEmpty, replyName: nil, @@ -888,9 +943,9 @@ extension MessageCellLayout { avatarCount: avatars.count, callCount: calls.count, isForward: isForward, - forwardImageCount: isForward ? images.count : 0, - forwardFileCount: isForward ? files.count : 0, - forwardCaption: nil, + forwardImageCount: forwardInnerImageCount, + forwardFileCount: forwardInnerFileCount, + forwardCaption: forwardCaption, showsDateHeader: showsDateHeader, dateHeaderText: dateHeaderText ) diff --git a/Rosetta/Core/Services/SessionManager.swift b/Rosetta/Core/Services/SessionManager.swift index 5a4fa4e..1af2554 100644 --- a/Rosetta/Core/Services/SessionManager.swift +++ b/Rosetta/Core/Services/SessionManager.swift @@ -66,13 +66,21 @@ final class SessionManager { var attachmentFlowTransport: AttachmentFlowTransporting = LiveAttachmentFlowTransport() var packetFlowSender: PacketFlowSending = LivePacketFlowSender() - // MARK: - Foreground Detection (Android parity) + // MARK: - Foreground & Idle Detection (Desktop/Android parity) private var foregroundObserverToken: NSObjectProtocol? private var backgroundObserverToken: NSObjectProtocol? /// Android parity: 5s debounce between foreground sync requests. private var lastForegroundSyncTime: TimeInterval = 0 + /// Desktop/Android parity: 20s idle timeout clears read eligibility. + /// Desktop: `useIdle(TIME_TO_INACTIVE_FOR_MESSAGES_UNREAD * 1000)` (20s). + /// Android: `useIdle(20000)`. + /// Reset by ChatDetailView on scroll/tap via `resetIdleTimer()`. + private static let idleTimeoutSeconds: TimeInterval = 20 + private var idleTimer: Timer? + private(set) var isUserIdle = false + /// Whether the app is in the foreground. private var isAppInForeground: Bool { UIApplication.shared.applicationState == .active @@ -121,7 +129,7 @@ final class SessionManager { } /// Android parity (ON_RESUME): re-mark active dialogs as read and send read receipts. - /// Called on foreground resume. Android has no idle detection — just re-marks on resume. + /// Called on foreground resume. Safe after background clear: readEligibleDialogs is empty. func markActiveDialogsAsRead() { let activeKeys = MessageRepository.shared.activeDialogKeys let myKey = currentPublicKey @@ -136,6 +144,32 @@ final class SessionManager { } } + // MARK: - Idle Detection (Desktop/Android parity: 20s) + + /// Reset the idle timer. Called by ChatDetailView on user interaction + /// (scroll, tap, message send). Restores read eligibility if was idle. + func resetIdleTimer() { + idleTimer?.invalidate() + if isUserIdle { + isUserIdle = false + // ChatDetailView will call updateReadEligibility() on its own + } + idleTimer = Timer.scheduledTimer(withTimeInterval: Self.idleTimeoutSeconds, repeats: false) { [weak self] _ in + Task { @MainActor [weak self] in + guard let self, !self.isUserIdle else { return } + self.isUserIdle = true + MessageRepository.shared.clearAllReadEligibility() + } + } + } + + /// Stop the idle timer (e.g., on session end or leaving chat). + func stopIdleTimer() { + idleTimer?.invalidate() + idleTimer = nil + isUserIdle = false + } + // MARK: - Session Lifecycle /// Called after password verification. Decrypts private key, connects WebSocket, and starts handshake. @@ -1488,7 +1522,19 @@ final class SessionManager { ) }() if isGroupDialog, groupKey == nil { - Self.logger.warning("processIncoming: group key not found for \(opponentKey)") + // Don't drop the message — store with encrypted content for retry. + // Group key may arrive later (join confirmation, sync). + Self.logger.warning("processIncoming: group key not found for \(opponentKey) — storing fallback") + let effectiveFromSync = syncBatchInProgress || ProtocolManager.shared.isSyncBatchActive + || packet.fromPublicKey == myKey + MessageRepository.shared.upsertFromMessagePacket( + packet, + myPublicKey: myKey, + decryptedText: "", + fromSync: effectiveFromSync, + dialogIdentityOverride: opponentKey + ) + DialogRepository.shared.updateDialogFromMessages(opponentKey: opponentKey) return } @@ -1506,9 +1552,27 @@ final class SessionManager { }.value guard let cryptoResult else { - Self.logger.warning("processIncoming: decryptIncomingMessage returned nil for msgId=\(packet.messageId.prefix(8))…") - // Still recalculate dialog — cleans up stale ciphertext in lastMessage - // that may persist from previous sessions or failed decryptions. + // Desktop/Android parity: NEVER drop a message on decrypt failure. + // Desktop and Android store encrypted content and retry decryption + // on load. iOS was the only platform that lost messages permanently. + Self.logger.warning(""" + processIncoming: decrypt FAILED — storing fallback \ + msgId=\(packet.messageId.prefix(8))… \ + from=\(packet.fromPublicKey.prefix(8))… \ + hasChachaKey=\(!packet.chachaKey.isEmpty) \ + hasAesChachaKey=\(!packet.aesChachaKey.isEmpty) \ + contentLen=\(packet.content.count) \ + isOwnMessage=\(fromMe) + """) + + let effectiveFromSync = syncBatchInProgress || ProtocolManager.shared.isSyncBatchActive || fromMe + MessageRepository.shared.upsertFromMessagePacket( + packet, + myPublicKey: myKey, + decryptedText: "", + fromSync: effectiveFromSync, + dialogIdentityOverride: opponentKey + ) DialogRepository.shared.updateDialogFromMessages(opponentKey: opponentKey) return } @@ -1566,13 +1630,14 @@ final class SessionManager { // Sending 0x08 for every received message was causing a packet flood // that triggered server RST disconnects. - // Android parity: mark as read if dialog is active AND app is in foreground. - // Android has NO idle detection — only isDialogActive flag (ON_RESUME/ON_PAUSE). + // Desktop/Android parity: mark as read if dialog is active, read-eligible, + // app is in foreground, AND user is not idle (20s timeout). let dialogIsActive = MessageRepository.shared.isDialogActive(opponentKey) let dialogIsReadEligible = MessageRepository.shared.isDialogReadEligible(opponentKey) let isSystem = SystemAccounts.isSystemAccount(opponentKey) let fg = isAppInForeground - let shouldMarkRead = dialogIsActive && dialogIsReadEligible && fg && !isSystem + let idle = isUserIdle + let shouldMarkRead = dialogIsActive && dialogIsReadEligible && fg && !isSystem && !idle if shouldMarkRead { DialogRepository.shared.markAsRead(opponentKey: opponentKey) diff --git a/Rosetta/DesignSystem/Colors.swift b/Rosetta/DesignSystem/Colors.swift index 2c9d6d1..ad97aed 100644 --- a/Rosetta/DesignSystem/Colors.swift +++ b/Rosetta/DesignSystem/Colors.swift @@ -151,6 +151,12 @@ enum RosettaColors { return Int(index) } + /// Returns UIColor for a given avatar color index (for UIKit contexts). + static func avatarColor(for index: Int) -> UIColor { + let safeIndex = index % avatarColors.count + return UIColor(avatarColors[safeIndex].tint) + } + static func avatarText(publicKey: String) -> String { String(publicKey.prefix(2)).uppercased() } diff --git a/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift b/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift index 35716f5..93851d5 100644 --- a/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift +++ b/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift @@ -242,6 +242,7 @@ struct ChatDetailView: View { // setDialogActive only touches MessageRepository.activeDialogs (Set), // does NOT mutate DialogRepository, so ForEach won't rebuild. MessageRepository.shared.setDialogActive(route.publicKey, isActive: true) + SessionManager.shared.resetIdleTimer() updateReadEligibility() clearDeliveredNotifications(for: route.publicKey) // Telegram-like read policy: mark read only when dialog is truly readable @@ -275,6 +276,7 @@ struct ChatDetailView: View { isViewActive = false updateReadEligibility() MessageRepository.shared.setDialogActive(route.publicKey, isActive: false) + SessionManager.shared.stopIdleTimer() // Desktop parity: save draft text on chat close. DraftManager.shared.saveDraft(for: route.publicKey, text: messageText) } @@ -285,6 +287,7 @@ struct ChatDetailView: View { // 600ms delay lets notification-tap navigation settle — if user tapped // a notification for a DIFFERENT chat, isViewActive becomes false. guard isViewActive else { return } + SessionManager.shared.resetIdleTimer() Task { @MainActor in try? await Task.sleep(for: .milliseconds(600)) guard isViewActive else { return } @@ -838,6 +841,7 @@ private extension ChatDetailView { scrollToBottomRequested: $scrollToBottomRequested, onAtBottomChange: { atBottom in isAtBottom = atBottom + SessionManager.shared.resetIdleTimer() updateReadEligibility() if atBottom { markDialogAsRead() diff --git a/Rosetta/Features/Chats/ChatDetail/MessageCellView.swift b/Rosetta/Features/Chats/ChatDetail/MessageCellView.swift index 12a9037..70029ae 100644 --- a/Rosetta/Features/Chats/ChatDetail/MessageCellView.swift +++ b/Rosetta/Features/Chats/ChatDetail/MessageCellView.swift @@ -172,25 +172,25 @@ struct MessageCellView: View, Equatable { VStack(alignment: .leading, spacing: 0) { Text("Forwarded from") - .font(.system(size: 13, weight: .regular)) - .foregroundStyle(outgoing ? Color.white.opacity(0.5) : RosettaColors.Adaptive.textSecondary) + .font(.system(size: 14, weight: .regular)) + .foregroundStyle(outgoing ? Color.white : RosettaColors.figmaBlue) .padding(.leading, 11) - .padding(.top, 6) + .padding(.top, 7) - HStack(spacing: 6) { + HStack(spacing: 4) { AvatarView( initials: senderInitials, colorIndex: senderColorIndex, - size: 20, + size: 16, image: senderAvatar ) Text(senderName) - .font(.system(size: 14, weight: .semibold)) + .font(.system(size: 14, weight: .medium)) .foregroundStyle(outgoing ? Color.white : RosettaColors.figmaBlue) .lineLimit(1) } .padding(.leading, 11) - .padding(.top, 3) + .padding(.top, 0) if !imageAttachments.isEmpty { ForwardedPhotoCollageView( diff --git a/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift b/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift index 9b25fb1..8cfe2e9 100644 --- a/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift +++ b/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift @@ -19,8 +19,8 @@ final class NativeMessageCell: UICollectionViewCell { private static let timestampFont = UIFont.systemFont(ofSize: floor(textFont.pointSize * 11.0 / 17.0), weight: .regular) private static let replyNameFont = UIFont.systemFont(ofSize: 14, weight: .semibold) private static let replyTextFont = UIFont.systemFont(ofSize: 14, weight: .regular) - private static let forwardLabelFont = UIFont.systemFont(ofSize: 13, weight: .regular) - private static let forwardNameFont = UIFont.systemFont(ofSize: 14, weight: .semibold) + private static let forwardLabelFont = UIFont.systemFont(ofSize: 14, weight: .regular) + private static let forwardNameFont = UIFont.systemFont(ofSize: 14, weight: .medium) private static let fileNameFont = UIFont.systemFont(ofSize: 16, weight: .regular) private static let fileSizeFont = UIFont.monospacedDigitSystemFont(ofSize: 13, weight: .regular) private static let bubbleMetrics = BubbleMetrics.telegram() @@ -136,6 +136,8 @@ final class NativeMessageCell: UICollectionViewCell { // Forward header private let forwardLabel = UILabel() private let forwardAvatarView = UIView() + private let forwardAvatarInitialLabel = UILabel() + private let forwardAvatarImageView = UIImageView() private let forwardNameLabel = UILabel() // Highlight overlay (scroll-to-message flash) @@ -397,16 +399,24 @@ final class NativeMessageCell: UICollectionViewCell { // Forward header forwardLabel.font = Self.forwardLabelFont - forwardLabel.text = "Forwarded message" - forwardLabel.textColor = UIColor.white.withAlphaComponent(0.6) + forwardLabel.text = "Forwarded from" bubbleView.addSubview(forwardLabel) - forwardAvatarView.backgroundColor = UIColor.white.withAlphaComponent(0.3) - forwardAvatarView.layer.cornerRadius = 10 + forwardAvatarView.layer.cornerRadius = 8 + forwardAvatarView.clipsToBounds = true bubbleView.addSubview(forwardAvatarView) + forwardAvatarInitialLabel.font = .systemFont(ofSize: 8, weight: .medium) + forwardAvatarInitialLabel.textColor = .white + forwardAvatarInitialLabel.textAlignment = .center + forwardAvatarView.addSubview(forwardAvatarInitialLabel) + + forwardAvatarImageView.contentMode = .scaleAspectFill + forwardAvatarImageView.clipsToBounds = true + forwardAvatarView.addSubview(forwardAvatarImageView) + forwardNameLabel.font = Self.forwardNameFont - forwardNameLabel.textColor = .white + forwardNameLabel.textColor = .white // default, overridden in configure() bubbleView.addSubview(forwardNameLabel) // Highlight overlay — on top of all bubble content @@ -457,7 +467,8 @@ final class NativeMessageCell: UICollectionViewCell { replyName: String? = nil, replyText: String? = nil, replyMessageId: String? = nil, - forwardSenderName: String? = nil + forwardSenderName: String? = nil, + forwardSenderKey: String? = nil ) { self.message = message self.actions = actions @@ -542,6 +553,31 @@ final class NativeMessageCell: UICollectionViewCell { forwardAvatarView.isHidden = false forwardNameLabel.isHidden = false forwardNameLabel.text = forwardSenderName + // Telegram: same accentTextColor for both title and name + let accent: UIColor = isOutgoing ? .white : Self.outgoingColor + forwardLabel.textColor = accent + forwardNameLabel.textColor = accent + // Avatar: real photo if available, otherwise initial + color + if let key = forwardSenderKey, let avatarImage = AvatarRepository.shared.loadAvatar(publicKey: key) { + forwardAvatarImageView.image = avatarImage + forwardAvatarImageView.isHidden = false + forwardAvatarInitialLabel.isHidden = true + forwardAvatarView.backgroundColor = .clear + } else { + forwardAvatarImageView.image = nil + forwardAvatarImageView.isHidden = true + forwardAvatarInitialLabel.isHidden = false + let initial = String(forwardSenderName.prefix(1)).uppercased() + forwardAvatarInitialLabel.text = initial + let colorIndex = RosettaColors.avatarColorIndex(for: forwardSenderName, publicKey: forwardSenderKey ?? "") + let hexes: [UInt32] = [0x228be6, 0x15aabf, 0xbe4bdb, 0x40c057, 0x4c6ef5, 0x82c91e, 0xfd7e14, 0xe64980, 0xfa5252, 0x12b886, 0x7950f2] + let hex = hexes[colorIndex % hexes.count] + forwardAvatarView.backgroundColor = UIColor( + red: CGFloat((hex >> 16) & 0xFF) / 255, + green: CGFloat((hex >> 8) & 0xFF) / 255, + blue: CGFloat(hex & 0xFF) / 255, alpha: 1 + ) + } } else { forwardLabel.isHidden = true forwardAvatarView.isHidden = true @@ -894,6 +930,9 @@ final class NativeMessageCell: UICollectionViewCell { if layout.isForward { forwardLabel.frame = layout.forwardHeaderFrame forwardAvatarView.frame = layout.forwardAvatarFrame + let avatarBounds = forwardAvatarView.bounds + forwardAvatarInitialLabel.frame = avatarBounds + forwardAvatarImageView.frame = avatarBounds forwardNameLabel.frame = layout.forwardNameFrame } diff --git a/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift b/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift index fe170d6..ce59a01 100644 --- a/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift +++ b/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift @@ -271,6 +271,7 @@ final class NativeMessageListController: UIViewController { var replyText: String? var replyMessageId: String? var forwardSenderName: String? + var forwardSenderKey: String? if let att = replyAtt { if let data = att.blob.data(using: .utf8), @@ -293,6 +294,7 @@ final class NativeMessageListController: UIViewController { if displayText.isEmpty { // Forward forwardSenderName = name + forwardSenderKey = senderKey } else { // Reply quote replyName = name @@ -312,7 +314,8 @@ final class NativeMessageListController: UIViewController { replyName: replyName, replyText: replyText, replyMessageId: replyMessageId, - forwardSenderName: forwardSenderName + forwardSenderName: forwardSenderName, + forwardSenderKey: forwardSenderKey ) } } diff --git a/RosettaTests/CryptoParityTests.swift b/RosettaTests/CryptoParityTests.swift new file mode 100644 index 0000000..fccae45 --- /dev/null +++ b/RosettaTests/CryptoParityTests.swift @@ -0,0 +1,624 @@ +import XCTest +@testable import Rosetta + +/// Cross-platform crypto parity tests: iOS ↔ Desktop ↔ Android. +/// Verifies that all crypto operations produce compatible output +/// and that messages encrypted on any platform can be decrypted on iOS. +final class CryptoParityTests: XCTestCase { + + // MARK: - XChaCha20-Poly1305 Round-Trip + + func testXChaCha20Poly1305_encryptDecryptRoundTrip() throws { + let plaintext = "Привет, мир! Hello, world! 🔐" + let key = try CryptoPrimitives.randomBytes(count: 32) + let nonce = try CryptoPrimitives.randomBytes(count: 24) + let keyAndNonce = key + nonce + + let ciphertextWithTag = try XChaCha20Engine.encrypt( + plaintext: Data(plaintext.utf8), key: key, nonce: nonce + ) + + let decrypted = try MessageCrypto.decryptIncomingWithPlainKey( + ciphertext: ciphertextWithTag.hexString, + plainKeyAndNonce: keyAndNonce + ) + + XCTAssertEqual(decrypted, plaintext, "Round-trip must preserve plaintext") + } + + func testXChaCha20Poly1305_wrongKeyFails() throws { + let plaintext = "secret message" + let key = try CryptoPrimitives.randomBytes(count: 32) + let nonce = try CryptoPrimitives.randomBytes(count: 24) + let wrongKey = try CryptoPrimitives.randomBytes(count: 32) + + let ciphertext = try XChaCha20Engine.encrypt( + plaintext: Data(plaintext.utf8), key: key, nonce: nonce + ) + + XCTAssertThrowsError( + try XChaCha20Engine.decrypt( + ciphertextWithTag: ciphertext, key: wrongKey, nonce: nonce + ), + "Decryption with wrong key must fail (Poly1305 tag mismatch)" + ) + } + + func testXChaCha20Poly1305_emptyPlaintext() throws { + let key = try CryptoPrimitives.randomBytes(count: 32) + let nonce = try CryptoPrimitives.randomBytes(count: 24) + + let ciphertext = try XChaCha20Engine.encrypt( + plaintext: Data(), key: key, nonce: nonce + ) + // Ciphertext should be exactly 16 bytes (Poly1305 tag only) + XCTAssertEqual(ciphertext.count, 16, "Empty plaintext produces 16-byte tag only") + + let decrypted = try XChaCha20Engine.decrypt( + ciphertextWithTag: ciphertext, key: key, nonce: nonce + ) + XCTAssertEqual(decrypted.count, 0, "Decrypted empty plaintext must be empty") + } + + func testXChaCha20Poly1305_largePlaintext() throws { + let plaintext = Data(repeating: 0x42, count: 100_000) + let key = try CryptoPrimitives.randomBytes(count: 32) + let nonce = try CryptoPrimitives.randomBytes(count: 24) + + let ciphertext = try XChaCha20Engine.encrypt( + plaintext: plaintext, key: key, nonce: nonce + ) + XCTAssertEqual(ciphertext.count, plaintext.count + 16) + + let decrypted = try XChaCha20Engine.decrypt( + ciphertextWithTag: ciphertext, key: key, nonce: nonce + ) + XCTAssertEqual(decrypted, plaintext) + } + + // MARK: - ECDH Encrypt → Decrypt Round-Trip + + func testECDH_encryptDecryptRoundTrip() throws { + let plaintext = "Test ECDH round-trip" + + // Generate recipient key pair + let recipientPrivKey = try P256K.KeyAgreement.PrivateKey() + let recipientPubKeyHex = recipientPrivKey.publicKey.dataRepresentation.hexString + + // Encrypt + let encrypted = try MessageCrypto.encryptOutgoing( + plaintext: plaintext, + recipientPublicKeyHex: recipientPubKeyHex + ) + + XCTAssertFalse(encrypted.content.isEmpty, "Content must not be empty") + XCTAssertFalse(encrypted.chachaKey.isEmpty, "chachaKey must not be empty") + XCTAssertEqual(encrypted.plainKeyAndNonce.count, 56, "Key+nonce must be 56 bytes") + + // Decrypt + let decrypted = try MessageCrypto.decryptIncoming( + ciphertext: encrypted.content, + encryptedKey: encrypted.chachaKey, + myPrivateKeyHex: recipientPrivKey.rawRepresentation.hexString + ) + + XCTAssertEqual(decrypted, plaintext, "ECDH round-trip must preserve plaintext") + } + + func testECDH_decryptReturnsCorrectKeyAndNonce() throws { + let plaintext = "Key verification" + let recipientPrivKey = try P256K.KeyAgreement.PrivateKey() + let recipientPubKeyHex = recipientPrivKey.publicKey.dataRepresentation.hexString + + let encrypted = try MessageCrypto.encryptOutgoing( + plaintext: plaintext, + recipientPublicKeyHex: recipientPubKeyHex + ) + + let (decryptedText, recoveredKeyAndNonce) = try MessageCrypto.decryptIncomingFull( + ciphertext: encrypted.content, + encryptedKey: encrypted.chachaKey, + myPrivateKeyHex: recipientPrivKey.rawRepresentation.hexString + ) + + XCTAssertEqual(decryptedText, plaintext) + XCTAssertEqual(recoveredKeyAndNonce, encrypted.plainKeyAndNonce, + "Recovered key+nonce must match original") + } + + func testECDH_wrongPrivateKeyFails() throws { + let recipientPrivKey = try P256K.KeyAgreement.PrivateKey() + let wrongPrivKey = try P256K.KeyAgreement.PrivateKey() + let recipientPubKeyHex = recipientPrivKey.publicKey.dataRepresentation.hexString + + let encrypted = try MessageCrypto.encryptOutgoing( + plaintext: "secret", + recipientPublicKeyHex: recipientPubKeyHex + ) + + XCTAssertThrowsError( + try MessageCrypto.decryptIncoming( + ciphertext: encrypted.content, + encryptedKey: encrypted.chachaKey, + myPrivateKeyHex: wrongPrivKey.rawRepresentation.hexString + ), + "Decryption with wrong private key must fail" + ) + } + + func testECDH_multipleEncryptions_differentCiphertext() throws { + let plaintext = "same message" + let recipientPrivKey = try P256K.KeyAgreement.PrivateKey() + let recipientPubKeyHex = recipientPrivKey.publicKey.dataRepresentation.hexString + + let enc1 = try MessageCrypto.encryptOutgoing(plaintext: plaintext, recipientPublicKeyHex: recipientPubKeyHex) + let enc2 = try MessageCrypto.encryptOutgoing(plaintext: plaintext, recipientPublicKeyHex: recipientPubKeyHex) + + XCTAssertNotEqual(enc1.content, enc2.content, "Each encryption must use different random key") + XCTAssertNotEqual(enc1.chachaKey, enc2.chachaKey, "Each encryption must use different ephemeral key") + + // Both must decrypt correctly + let dec1 = try MessageCrypto.decryptIncoming(ciphertext: enc1.content, encryptedKey: enc1.chachaKey, myPrivateKeyHex: recipientPrivKey.rawRepresentation.hexString) + let dec2 = try MessageCrypto.decryptIncoming(ciphertext: enc2.content, encryptedKey: enc2.chachaKey, myPrivateKeyHex: recipientPrivKey.rawRepresentation.hexString) + XCTAssertEqual(dec1, plaintext) + XCTAssertEqual(dec2, plaintext) + } + + // MARK: - aesChachaKey (Sync Path) Round-Trip + + func testAesChachaKey_encryptDecryptRoundTrip() throws { + let plaintext = "Sync path message" + let privateKeyHex = try P256K.KeyAgreement.PrivateKey().rawRepresentation.hexString + + // Encrypt with XChaCha20 + let recipientPrivKey = try P256K.KeyAgreement.PrivateKey() + let encrypted = try MessageCrypto.encryptOutgoing( + plaintext: plaintext, + recipientPublicKeyHex: recipientPrivKey.publicKey.dataRepresentation.hexString + ) + + // Build aesChachaKey (same logic as makeOutgoingPacket) + guard let latin1String = String(data: encrypted.plainKeyAndNonce, encoding: .isoLatin1) else { + XCTFail("Latin-1 encoding must succeed for any 56-byte sequence") + return + } + let aesChachaPayload = Data(latin1String.utf8) + let aesChachaKey = try CryptoManager.shared.encryptWithPasswordDesktopCompat( + aesChachaPayload, + password: privateKeyHex + ) + + // Decrypt aesChachaKey (same logic as decryptIncomingMessage sync path) + let decryptedPayload = try CryptoManager.shared.decryptWithPassword( + aesChachaKey, + password: privateKeyHex + ) + let recoveredKeyAndNonce = MessageCrypto.androidUtf8BytesToLatin1Bytes(decryptedPayload) + + XCTAssertEqual(recoveredKeyAndNonce.count, 56, "Recovered key+nonce must be 56 bytes") + + // Decrypt message content with recovered key + let decryptedText = try MessageCrypto.decryptIncomingWithPlainKey( + ciphertext: encrypted.content, + plainKeyAndNonce: recoveredKeyAndNonce + ) + XCTAssertEqual(decryptedText, plaintext, "aesChachaKey round-trip must recover original text") + } + + func testAesChachaKey_latin1Utf8RoundTrip_allByteValues() throws { + // Verify that Latin-1 → UTF-8 → Latin-1 round-trip preserves ALL byte values 0-255. + // This is critical: key bytes can be ANY value. + var allBytes = Data(count: 256) + for i in 0..<256 { allBytes[i] = UInt8(i) } + + guard let latin1String = String(data: allBytes, encoding: .isoLatin1) else { + XCTFail("Latin-1 must encode all 256 byte values") + return + } + let utf8Bytes = Data(latin1String.utf8) + let recovered = MessageCrypto.androidUtf8BytesToLatin1Bytes(utf8Bytes) + + XCTAssertEqual(recovered, allBytes, + "Latin-1 → UTF-8 → Latin-1 must be lossless for all byte values 0-255") + } + + func testAesChachaKey_wrongPasswordFails() throws { + let privateKeyHex = try P256K.KeyAgreement.PrivateKey().rawRepresentation.hexString + let wrongKeyHex = try P256K.KeyAgreement.PrivateKey().rawRepresentation.hexString + + let data = try CryptoPrimitives.randomBytes(count: 56) + guard let latin1 = String(data: data, encoding: .isoLatin1) else { return } + let encrypted = try CryptoManager.shared.encryptWithPasswordDesktopCompat( + Data(latin1.utf8), password: privateKeyHex + ) + + XCTAssertThrowsError( + try CryptoManager.shared.decryptWithPassword( + encrypted, password: wrongKeyHex, requireCompression: true + ), + "Decryption with wrong password must fail" + ) + } + + // MARK: - Attachment Password Candidates + + func testAttachmentPasswordCandidates_rawkeyFormat() { + // 56 random bytes as hex + let keyData = Data([ + 0x00, 0x01, 0x7F, 0x80, 0xFF, 0xFE, 0xAB, 0xCD, + 0x00, 0x01, 0x7F, 0x80, 0xFF, 0xFE, 0xAB, 0xCD, + 0x00, 0x01, 0x7F, 0x80, 0xFF, 0xFE, 0xAB, 0xCD, + 0x00, 0x01, 0x7F, 0x80, 0xFF, 0xFE, 0xAB, 0xCD, + 0x00, 0x01, 0x7F, 0x80, 0xFF, 0xFE, 0xAB, 0xCD, + 0x00, 0x01, 0x7F, 0x80, 0xFF, 0xFE, 0xAB, 0xCD, + 0x00, 0x01, 0x7F, 0x80, 0xFF, 0xFE, 0xAB, 0xCD, + ]) + let stored = "rawkey:" + keyData.hexString + + let candidates = MessageCrypto.attachmentPasswordCandidates(from: stored) + + // Must have at least HEX + Android + WHATWG + XCTAssertGreaterThanOrEqual(candidates.count, 3, "Must generate ≥3 candidates") + + // HEX must be first (Desktop commit 61e83bd parity) + XCTAssertEqual(candidates[0], keyData.hexString, + "First candidate must be HEX (Desktop parity)") + + // All candidates must be unique + XCTAssertEqual(candidates.count, Set(candidates).count, + "All candidates must be unique (deduplicated)") + } + + func testAttachmentPasswordCandidates_legacyFormat() { + let stored = "some_legacy_password_string" + let candidates = MessageCrypto.attachmentPasswordCandidates(from: stored) + + XCTAssertEqual(candidates.count, 1, "Legacy format returns single candidate") + XCTAssertEqual(candidates[0], stored, "Legacy candidate is the stored value itself") + } + + func testAttachmentPasswordCandidates_hexMatchesDesktop() { + // Desktop: key.toString('hex') = lowercase hex of raw 56 bytes + let keyBytes = Data([ + 0xDE, 0xAD, 0xBE, 0xEF, 0xCA, 0xFE, 0xBA, 0xBE, + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, + 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, 0x10, + 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, + 0x19, 0x1A, 0x1B, 0x1C, 0x1D, 0x1E, 0x1F, 0x20, + 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, + 0x29, 0x2A, 0x2B, 0x2C, 0x2D, 0x2E, 0x2F, 0x30, + ]) + let stored = "rawkey:" + keyBytes.hexString + let candidates = MessageCrypto.attachmentPasswordCandidates(from: stored) + + // Desktop: Buffer.from(keyBytes).toString('hex') + let expectedDesktopPassword = "deadbeefcafebabe01020304050607080910111213141516171819202122232425262728292a2b2c2d2e2f30" + + // Verify hex format matches (lowercase, no separators) + XCTAssertTrue(candidates[0].allSatisfy { "0123456789abcdef".contains($0) }, + "HEX candidate must be lowercase hex") + + // Verify exact match with expected Desktop output + // Note: the hex is based on keyBytes.hexString which should be lowercase + XCTAssertEqual(candidates[0], keyBytes.hexString) + } + + // MARK: - PBKDF2 Parity + + func testPBKDF2_consistentOutput() throws { + let password = "test_password_hex_string" + let key1 = CryptoPrimitives.pbkdf2( + password: password, salt: "rosetta", iterations: 1000, + keyLength: 32, prf: CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256) + ) + let key2 = CryptoPrimitives.pbkdf2( + password: password, salt: "rosetta", iterations: 1000, + keyLength: 32, prf: CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256) + ) + + XCTAssertNotNil(key1) + XCTAssertNotNil(key2) + XCTAssertEqual(key1, key2, "PBKDF2 must be deterministic") + XCTAssertEqual(key1!.count, 32, "PBKDF2 key must be 32 bytes") + } + + func testPBKDF2_differentPasswordsDifferentKeys() throws { + let key1 = CryptoPrimitives.pbkdf2( + password: "password_a", salt: "rosetta", iterations: 1000, + keyLength: 32, prf: CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256) + ) + let key2 = CryptoPrimitives.pbkdf2( + password: "password_b", salt: "rosetta", iterations: 1000, + keyLength: 32, prf: CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256) + ) + + XCTAssertNotEqual(key1, key2, "Different passwords must produce different keys") + } + + // MARK: - encryptWithPassword / decryptWithPassword Parity + + func testEncryptWithPasswordDesktopCompat_roundTrip() throws { + let plaintext = "Cross-platform encrypted message content" + let password = "my_private_key_hex" + + let encrypted = try CryptoManager.shared.encryptWithPasswordDesktopCompat( + Data(plaintext.utf8), password: password + ) + + // Must be ivBase64:ctBase64 format + let parts = encrypted.components(separatedBy: ":") + XCTAssertEqual(parts.count, 2, "Format must be ivBase64:ctBase64") + + let decrypted = try CryptoManager.shared.decryptWithPassword( + encrypted, password: password, requireCompression: true + ) + let decryptedText = String(data: decrypted, encoding: .utf8) + + XCTAssertEqual(decryptedText, plaintext, "Desktop-compat round-trip must preserve plaintext") + } + + func testEncryptWithPassword_iOSOnly_roundTrip() throws { + let plaintext = "iOS-only storage encryption" + let password = "local_private_key" + + let encrypted = try CryptoManager.shared.encryptWithPassword( + Data(plaintext.utf8), password: password + ) + + let decrypted = try CryptoManager.shared.decryptWithPassword( + encrypted, password: password, requireCompression: true + ) + let decryptedText = String(data: decrypted, encoding: .utf8) + + XCTAssertEqual(decryptedText, plaintext, "iOS-only round-trip must preserve plaintext") + } + + func testDecryptWithPassword_wrongPassword_withCompression_fails() throws { + let encrypted = try CryptoManager.shared.encryptWithPasswordDesktopCompat( + Data("secret".utf8), password: "correct_password" + ) + + XCTAssertThrowsError( + try CryptoManager.shared.decryptWithPassword( + encrypted, password: "wrong_password", requireCompression: true + ), + "Wrong password with requireCompression must fail" + ) + } + + // MARK: - UTF-8 Decoder Parity (Android ↔ iOS) + + func testAndroidUtf8Decoder_validAscii() { + let bytes = Data("Hello".utf8) + let result = MessageCrypto.bytesToAndroidUtf8String(bytes) + XCTAssertEqual(result, "Hello", "ASCII must decode identically") + } + + func testAndroidUtf8Decoder_validMultibyte() { + let bytes = Data("Привет 🔐".utf8) + let result = MessageCrypto.bytesToAndroidUtf8String(bytes) + XCTAssertEqual(result, "Привет 🔐", "Valid UTF-8 must decode identically") + } + + func testAndroidUtf8Decoder_matchesWhatWG_onValidUtf8() { + // For valid UTF-8, both decoders must produce identical results + for _ in 0..<100 { + let randomBytes = (0..<56).map { _ in UInt8.random(in: 0...127) } + let data = Data(randomBytes) + + let android = MessageCrypto.bytesToAndroidUtf8String(data) + let whatwg = String(decoding: data, as: UTF8.self) + + XCTAssertEqual(android, whatwg, + "For ASCII bytes, Android and WHATWG decoders must match") + } + } + + func testLatin1RoundTrip_withBothDecoders_onValidUtf8() { + // When input is valid UTF-8 (from CryptoJS Latin-1→UTF-8), both decoders match + // This is the ACTUAL scenario for Desktop→iOS messages + var allLatin1 = Data(count: 256) + for i in 0..<256 { allLatin1[i] = UInt8(i) } + + // Simulate Desktop: Latin-1 string → UTF-8 bytes + guard let latin1String = String(data: allLatin1, encoding: .isoLatin1) else { + XCTFail("Latin-1 encoding must work") + return + } + let utf8Bytes = Data(latin1String.utf8) + + // iOS recovery path 1: WHATWG + let recoveredWhatWG = MessageCrypto.androidUtf8BytesToLatin1Bytes(utf8Bytes) + // iOS recovery path 2: Android polyfill + let recoveredAndroid = MessageCrypto.androidUtf8BytesToLatin1BytesAlt(utf8Bytes) + + XCTAssertEqual(recoveredWhatWG, allLatin1, + "WHATWG decoder must recover all 256 Latin-1 bytes from valid UTF-8") + XCTAssertEqual(recoveredAndroid, allLatin1, + "Android decoder must recover all 256 Latin-1 bytes from valid UTF-8") + XCTAssertEqual(recoveredWhatWG, recoveredAndroid, + "Both decoders must produce identical results for valid UTF-8 input") + } + + // MARK: - Defense Layers + + func testIsProbablyEncryptedPayload_base64Colon() { + // ivBase64:ctBase64 (both ≥16 chars) + let encrypted = "YWJjZGVmZ2hpamtsbQ==:eHl6MTIzNDU2Nzg5MA==" + XCTAssertTrue(MessageRepository.testIsProbablyEncrypted(encrypted), + "ivBase64:ctBase64 must be detected") + } + + func testIsProbablyEncryptedPayload_hexString() { + // Pure hex ≥40 chars + let hex = String(repeating: "ab", count: 30) // 60 chars + XCTAssertTrue(MessageRepository.testIsProbablyEncrypted(hex), + "Pure hex ≥40 chars must be detected") + } + + func testIsProbablyEncryptedPayload_chunked() { + XCTAssertTrue(MessageRepository.testIsProbablyEncrypted("CHNK:data:here"), + "CHNK: format must be detected") + } + + func testIsProbablyEncryptedPayload_normalText() { + XCTAssertFalse(MessageRepository.testIsProbablyEncrypted("Hello, world!"), + "Normal text must NOT be flagged") + XCTAssertFalse(MessageRepository.testIsProbablyEncrypted("Привет"), + "Cyrillic text must NOT be flagged") + } + + func testIsProbablyEncryptedPayload_shortHex() { + // Short hex (<40 chars) must NOT be flagged (could be normal text) + XCTAssertFalse(MessageRepository.testIsProbablyEncrypted("abcdef1234"), + "Short hex must NOT be flagged") + } + + func testIsProbablyEncryptedPayload_shortBase64Parts() { + // Both parts <16 chars must NOT be flagged + XCTAssertFalse(MessageRepository.testIsProbablyEncrypted("abc:def"), + "Short base64:base64 must NOT be flagged") + } + + func testIsProbablyEncryptedPayload_emptyString() { + // Empty string is not encrypted + XCTAssertFalse(MessageRepository.testIsProbablyEncrypted(""), + "Empty string must NOT be flagged as encrypted (it's just empty)") + } + + // MARK: - Compression Parity (zlib) + + func testZlibDeflate_inflate_roundTrip() throws { + let original = Data("Test compression for cross-platform parity".utf8) + + let compressed = try CryptoPrimitives.zlibDeflate(original) + XCTAssertTrue(compressed.count > 0, "Compressed data must not be empty") + XCTAssertTrue(compressed[0] == 0x78, "zlib deflate must start with 0x78 header") + + let decompressed = try CryptoPrimitives.rawInflate(compressed) + XCTAssertEqual(decompressed, original, "Inflate must recover original data") + } + + func testRawDeflate_inflate_roundTrip() throws { + let original = Data("Raw deflate for iOS-only storage".utf8) + + let compressed = try CryptoPrimitives.rawDeflate(original) + XCTAssertTrue(compressed.count > 0) + + let decompressed = try CryptoPrimitives.rawInflate(compressed) + XCTAssertEqual(decompressed, original, "Raw inflate must recover original data") + } + + // MARK: - Full Message Flow (Simulate Desktop → iOS) + + func testFullMessageFlow_desktopToiOS() throws { + // Simulate: Desktop sends message to iOS user + let senderPrivKey = try P256K.KeyAgreement.PrivateKey() + let recipientPrivKey = try P256K.KeyAgreement.PrivateKey() + let recipientPubKeyHex = recipientPrivKey.publicKey.dataRepresentation.hexString + + let originalMessage = "Message from Desktop to iOS 🚀" + + // Step 1: Desktop encrypts (simulated on iOS since crypto is identical) + let encrypted = try MessageCrypto.encryptOutgoing( + plaintext: originalMessage, + recipientPublicKeyHex: recipientPubKeyHex + ) + + // Step 2: Build aesChachaKey for sync (Desktop logic) + let senderPrivKeyHex = senderPrivKey.rawRepresentation.hexString + guard let latin1 = String(data: encrypted.plainKeyAndNonce, encoding: .isoLatin1) else { + XCTFail("Latin-1 encoding failed") + return + } + let aesChachaKey = try CryptoManager.shared.encryptWithPasswordDesktopCompat( + Data(latin1.utf8), password: senderPrivKeyHex + ) + + // Step 3: iOS receives and decrypts via ECDH path + let (ecdhText, ecdhKeyAndNonce) = try MessageCrypto.decryptIncomingFull( + ciphertext: encrypted.content, + encryptedKey: encrypted.chachaKey, + myPrivateKeyHex: recipientPrivKey.rawRepresentation.hexString + ) + XCTAssertEqual(ecdhText, originalMessage, "ECDH path must decrypt correctly") + + // Step 4: iOS receives own message via aesChachaKey sync path + let syncDecrypted = try CryptoManager.shared.decryptWithPassword( + aesChachaKey, password: senderPrivKeyHex + ) + let syncKeyAndNonce = MessageCrypto.androidUtf8BytesToLatin1Bytes(syncDecrypted) + let syncText = try MessageCrypto.decryptIncomingWithPlainKey( + ciphertext: encrypted.content, + plainKeyAndNonce: syncKeyAndNonce + ) + XCTAssertEqual(syncText, originalMessage, "aesChachaKey sync path must decrypt correctly") + + // Step 5: Verify key+nonce matches across both paths + XCTAssertEqual(ecdhKeyAndNonce, syncKeyAndNonce, + "ECDH and sync paths must recover the same key+nonce") + + // Step 6: Verify attachment password derivation matches + let ecdhPassword = "rawkey:" + ecdhKeyAndNonce.hexString + let syncPassword = "rawkey:" + syncKeyAndNonce.hexString + let ecdhCandidates = MessageCrypto.attachmentPasswordCandidates(from: ecdhPassword) + let syncCandidates = MessageCrypto.attachmentPasswordCandidates(from: syncPassword) + XCTAssertEqual(ecdhCandidates, syncCandidates, + "Attachment password candidates must be identical across both paths") + } + + // MARK: - Stress Test: Random Key Bytes + + func testECDH_100RandomKeys_allDecryptSuccessfully() throws { + for i in 0..<100 { + let recipientPrivKey = try P256K.KeyAgreement.PrivateKey() + let recipientPubKeyHex = recipientPrivKey.publicKey.dataRepresentation.hexString + + let encrypted = try MessageCrypto.encryptOutgoing( + plaintext: "Message #\(i)", + recipientPublicKeyHex: recipientPubKeyHex + ) + + let decrypted = try MessageCrypto.decryptIncoming( + ciphertext: encrypted.content, + encryptedKey: encrypted.chachaKey, + myPrivateKeyHex: recipientPrivKey.rawRepresentation.hexString + ) + + XCTAssertEqual(decrypted, "Message #\(i)", "Message #\(i) must decrypt correctly") + } + } + + func testAesChachaKey_100RandomKeys_allRoundTrip() throws { + for i in 0..<100 { + let keyAndNonce = try CryptoPrimitives.randomBytes(count: 56) + let password = try P256K.KeyAgreement.PrivateKey().rawRepresentation.hexString + + guard let latin1 = String(data: keyAndNonce, encoding: .isoLatin1) else { + XCTFail("Latin-1 must work for key #\(i)") + continue + } + + let encrypted = try CryptoManager.shared.encryptWithPasswordDesktopCompat( + Data(latin1.utf8), password: password + ) + + let decrypted = try CryptoManager.shared.decryptWithPassword( + encrypted, password: password + ) + let recovered = MessageCrypto.androidUtf8BytesToLatin1Bytes(decrypted) + + XCTAssertEqual(recovered, keyAndNonce, + "aesChachaKey round-trip #\(i) must recover original 56 bytes") + } + } +} + +// MARK: - Test Helpers + +extension MessageRepository { + /// Exposes isProbablyEncryptedPayload for testing. + static func testIsProbablyEncrypted(_ value: String) -> Bool { + isProbablyEncryptedPayload(value) + } +} diff --git a/RosettaTests/ReadEligibilityTests.swift b/RosettaTests/ReadEligibilityTests.swift index 9215496..62cd6cc 100644 --- a/RosettaTests/ReadEligibilityTests.swift +++ b/RosettaTests/ReadEligibilityTests.swift @@ -301,4 +301,71 @@ final class ReadEligibilityTests: XCTestCase { XCTAssertFalse(MessageRepository.shared.isDialogReadEligible(peer)) } + + // MARK: - Idle detection (Desktop/Android parity: 20s) + + func testIdleTimer_clearsEligibilityAfterTimeout() async throws { + try await ctx.bootstrap() + + MessageRepository.shared.setDialogActive(peer, isActive: true) + MessageRepository.shared.setDialogReadEligible(peer, isEligible: true) + XCTAssertTrue(MessageRepository.shared.isDialogReadEligible(peer)) + + // Simulate idle timeout (call clearAllReadEligibility directly, + // since we can't wait 20s in a unit test) + SessionManager.shared.resetIdleTimer() + XCTAssertFalse(SessionManager.shared.isUserIdle, "User should not be idle right after reset") + + // Simulate what the idle timer callback does + MessageRepository.shared.clearAllReadEligibility() + + XCTAssertFalse(MessageRepository.shared.isDialogReadEligible(peer), + "After idle timeout, eligibility must be cleared") + + // Messages arriving during idle should be unread + try await ctx.runScenario(FixtureScenario(name: "idle msg", events: [ + .incoming(opponent: peer, messageId: "idle-1", timestamp: 7000, text: "idle msg"), + ])) + + let snapshot = try ctx.normalizedSnapshot() + let msg = snapshot.messages.first(where: { $0.messageId == "idle-1" }) + XCTAssertEqual(msg?.read, false, "Message during idle must be unread") + + let dialog = snapshot.dialogs.first(where: { $0.opponentKey == peer }) + XCTAssertEqual(dialog?.unreadCount, 1, "Unread count must be 1 during idle") + + // Cleanup + SessionManager.shared.stopIdleTimer() + } + + func testIdleTimer_resetRestoresEligibility() async throws { + try await ctx.bootstrap() + + MessageRepository.shared.setDialogActive(peer, isActive: true) + MessageRepository.shared.setDialogReadEligible(peer, isEligible: true) + + // Simulate idle → clear eligibility + MessageRepository.shared.clearAllReadEligibility() + XCTAssertFalse(MessageRepository.shared.isDialogReadEligible(peer)) + + // Simulate user interaction → reset timer + re-enable eligibility + SessionManager.shared.resetIdleTimer() + MessageRepository.shared.setDialogReadEligible(peer, isEligible: true) + + XCTAssertTrue(MessageRepository.shared.isDialogReadEligible(peer), + "After user interaction, eligibility must be restored") + + // Cleanup + SessionManager.shared.stopIdleTimer() + } + + func testStopIdleTimer_resetsIdleState() async throws { + try await ctx.bootstrap() + + SessionManager.shared.resetIdleTimer() + XCTAssertFalse(SessionManager.shared.isUserIdle) + + SessionManager.shared.stopIdleTimer() + XCTAssertFalse(SessionManager.shared.isUserIdle, "stopIdleTimer must clear idle state") + } }