Форвард: Telegram-parity UI — правильный размер бабла, текст/таймстамп, аватарка с инициалами, отступы

This commit is contained in:
2026-03-31 16:36:58 +05:00
parent e5179b11ea
commit 464fae37a9
11 changed files with 1037 additions and 53 deletions

View File

@@ -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 &&

View File

@@ -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 {

View File

@@ -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
)

View File

@@ -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)

View File

@@ -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()
}

View File

@@ -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()

View File

@@ -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(

View File

@@ -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
}

View File

@@ -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
)
}
}

View File

@@ -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-1UTF-8), both decoders match
// This is the ACTUAL scenario for DesktopiOS 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)
}
}

View File

@@ -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")
}
}