Форвард: Telegram-parity UI — правильный размер бабла, текст/таймстамп, аватарка с инициалами, отступы
This commit is contained in:
@@ -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 &&
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
624
RosettaTests/CryptoParityTests.swift
Normal file
624
RosettaTests/CryptoParityTests.swift
Normal 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-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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user