Форвард: Telegram-parity UI — правильный размер бабла, текст/таймстамп, аватарка с инициалами, отступы
This commit is contained in:
@@ -504,6 +504,12 @@ final class DialogRepository {
|
|||||||
// CHNK: chunked format
|
// CHNK: chunked format
|
||||||
if trimmed.hasPrefix("CHNK:") { return true }
|
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)
|
// Original check: all characters are garbage (U+FFFD, control chars, null bytes)
|
||||||
let validCharacters = trimmed.unicodeScalars.filter { scalar in
|
let validCharacters = trimmed.unicodeScalars.filter { scalar in
|
||||||
scalar.value != 0xFFFD &&
|
scalar.value != 0xFFFD &&
|
||||||
|
|||||||
@@ -408,7 +408,37 @@ final class MessageRepository: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} 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
|
// Debounced cache refresh — batch during sync
|
||||||
@@ -869,7 +899,21 @@ final class MessageRepository: ObservableObject {
|
|||||||
plainText = decrypted
|
plainText = decrypted
|
||||||
} else {
|
} else {
|
||||||
let fallback = Self.safePlainMessageFallback(record.text)
|
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 {
|
} else {
|
||||||
plainText = Self.safePlainMessageFallback(record.text)
|
plainText = Self.safePlainMessageFallback(record.text)
|
||||||
@@ -878,6 +922,68 @@ final class MessageRepository: ObservableObject {
|
|||||||
return record.toChatMessage(overrideText: plainText)
|
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
|
// MARK: - Android Parity: safePlainMessageFallback
|
||||||
|
|
||||||
/// Android parity: `safePlainMessageFallback()` in `ChatViewModel.kt` lines 1338-1349.
|
/// 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)
|
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 {
|
private static func isProbablyEncryptedPayload(_ value: String) -> Bool {
|
||||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
// Chunked format
|
// Chunked format
|
||||||
if trimmed.hasPrefix("CHNK:") { return true }
|
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: ":")
|
let parts = trimmed.components(separatedBy: ":")
|
||||||
guard parts.count == 2 else { return false }
|
if parts.count == 2 {
|
||||||
// Both parts should look like Base64 (alphanumeric + /+=)
|
let base64Chars = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "+/="))
|
||||||
let base64Chars = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "+/="))
|
if parts.allSatisfy({ part in
|
||||||
return parts.allSatisfy { part in
|
part.count >= 16 && part.unicodeScalars.allSatisfy { base64Chars.contains($0) }
|
||||||
!part.isEmpty && 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 {
|
private func normalizeTimestamp(_ raw: Int64) -> Int64 {
|
||||||
|
|||||||
@@ -175,6 +175,7 @@ extension MessageCellLayout {
|
|||||||
messageType = .text
|
messageType = .text
|
||||||
}
|
}
|
||||||
let isTextMessage = (messageType == .text || messageType == .textWithReply)
|
let isTextMessage = (messageType == .text || messageType == .textWithReply)
|
||||||
|
let isForwardWithCaption = messageType == .forward && !config.text.isEmpty
|
||||||
let textStatusLaneMetrics = TextStatusLaneMetrics.telegram(
|
let textStatusLaneMetrics = TextStatusLaneMetrics.telegram(
|
||||||
fontPointSize: font.pointSize,
|
fontPointSize: font.pointSize,
|
||||||
screenPixel: screenPixel
|
screenPixel: screenPixel
|
||||||
@@ -198,7 +199,7 @@ extension MessageCellLayout {
|
|||||||
// Outgoing: timestamp at 5pt from edge (checkmarks fill remaining space → rightPad-5=6pt compensation)
|
// 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
|
// Incoming: timestamp at rightPad (11pt) from edge, same as text → 0pt compensation
|
||||||
let statusTrailingCompensation: CGFloat
|
let statusTrailingCompensation: CGFloat
|
||||||
if isTextMessage && config.isOutgoing {
|
if (isTextMessage || isForwardWithCaption) && config.isOutgoing {
|
||||||
statusTrailingCompensation = max(0, rightPad - textStatusLaneMetrics.textStatusRightInset)
|
statusTrailingCompensation = max(0, rightPad - textStatusLaneMetrics.textStatusRightInset)
|
||||||
} else {
|
} else {
|
||||||
statusTrailingCompensation = 0
|
statusTrailingCompensation = 0
|
||||||
@@ -210,7 +211,7 @@ extension MessageCellLayout {
|
|||||||
|
|
||||||
let textMeasurement: TextMeasurement
|
let textMeasurement: TextMeasurement
|
||||||
var cachedTextLayout: CoreTextTextLayout?
|
var cachedTextLayout: CoreTextTextLayout?
|
||||||
let needsDetailedTextLayout = isTextMessage || messageType == .photoWithCaption
|
let needsDetailedTextLayout = isTextMessage || messageType == .photoWithCaption || (messageType == .forward && !config.text.isEmpty)
|
||||||
if !config.text.isEmpty && needsDetailedTextLayout {
|
if !config.text.isEmpty && needsDetailedTextLayout {
|
||||||
// CoreText (CTTypesetter) — returns per-line widths including lastLineWidth.
|
// CoreText (CTTypesetter) — returns per-line widths including lastLineWidth.
|
||||||
// Also captures CoreTextTextLayout for cell rendering (avoids double computation).
|
// Also captures CoreTextTextLayout for cell rendering (avoids double computation).
|
||||||
@@ -239,7 +240,7 @@ extension MessageCellLayout {
|
|||||||
let metadataWidth = tsSize.width + timeToCheckGap + checkW
|
let metadataWidth = tsSize.width + timeToCheckGap + checkW
|
||||||
|
|
||||||
let trailingWidthForStatus: CGFloat
|
let trailingWidthForStatus: CGFloat
|
||||||
if isTextMessage && !config.text.isEmpty {
|
if (isTextMessage || isForwardWithCaption) && !config.text.isEmpty {
|
||||||
if let cachedTextLayout {
|
if let cachedTextLayout {
|
||||||
if cachedTextLayout.lastLineHasRTL {
|
if cachedTextLayout.lastLineHasRTL {
|
||||||
trailingWidthForStatus = 10_000
|
trailingWidthForStatus = 10_000
|
||||||
@@ -277,7 +278,7 @@ extension MessageCellLayout {
|
|||||||
|
|
||||||
// ── STEP 3: Inline vs Wrapped determination ──
|
// ── STEP 3: Inline vs Wrapped determination ──
|
||||||
let timestampInline: Bool
|
let timestampInline: Bool
|
||||||
if isTextMessage && !config.text.isEmpty {
|
if (isTextMessage || isForwardWithCaption) && !config.text.isEmpty {
|
||||||
timestampInline = inlineStatusContentWidth <= maxTextWidth
|
timestampInline = inlineStatusContentWidth <= maxTextWidth
|
||||||
} else {
|
} else {
|
||||||
timestampInline = true
|
timestampInline = true
|
||||||
@@ -290,10 +291,10 @@ extension MessageCellLayout {
|
|||||||
// Telegram reply: 3pt top pad + 17pt name + 2pt spacing + 17pt text + 3pt bottom = 42pt container
|
// Telegram reply: 3pt top pad + 17pt name + 2pt spacing + 17pt text + 3pt bottom = 42pt container
|
||||||
let replyContainerH: CGFloat = 42
|
let replyContainerH: CGFloat = 42
|
||||||
let replyTopInset: CGFloat = 5
|
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
|
let replyH: CGFloat = config.hasReplyQuote ? (replyTopInset + replyContainerH + replyBottomGap - topPad) : 0
|
||||||
var photoH: CGFloat = 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
|
var fileH: CGFloat = CGFloat(config.fileCount) * 52
|
||||||
+ CGFloat(config.callCount) * 42
|
+ CGFloat(config.callCount) * 42
|
||||||
+ CGFloat(config.avatarCount) * 52
|
+ CGFloat(config.avatarCount) * 52
|
||||||
@@ -378,12 +379,25 @@ extension MessageCellLayout {
|
|||||||
bubbleW = leftPad + finalContentW + rightPad
|
bubbleW = leftPad + finalContentW + rightPad
|
||||||
bubbleW = max(minW, min(bubbleW, effectiveMaxBubbleWidth))
|
bubbleW = max(minW, min(bubbleW, effectiveMaxBubbleWidth))
|
||||||
if config.hasReplyQuote { bubbleW = max(bubbleW, 180) }
|
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 {
|
} else if !config.text.isEmpty {
|
||||||
// Non-text with caption (file, forward)
|
// Non-text with caption (file)
|
||||||
let finalContentW = max(textMeasurement.size.width, metadataWidth)
|
let finalContentW = max(textMeasurement.size.width, metadataWidth)
|
||||||
bubbleW = leftPad + finalContentW + rightPad
|
bubbleW = leftPad + finalContentW + rightPad
|
||||||
bubbleW = max(minW, min(bubbleW, effectiveMaxBubbleWidth))
|
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
|
let fileMinW: CGFloat = config.callCount > 0 ? 200 : 220
|
||||||
if fileH > 0 { bubbleW = max(bubbleW, min(fileMinW, effectiveMaxBubbleWidth)) }
|
if fileH > 0 { bubbleW = max(bubbleW, min(fileMinW, effectiveMaxBubbleWidth)) }
|
||||||
bubbleH += topPad + textMeasurement.size.height + bottomPad
|
bubbleH += topPad + textMeasurement.size.height + bottomPad
|
||||||
@@ -412,9 +426,24 @@ extension MessageCellLayout {
|
|||||||
if config.text.isEmpty && photoH == 0 && fileH == 0 && !config.isForward {
|
if config.text.isEmpty && photoH == 0 && fileH == 0 && !config.isForward {
|
||||||
bubbleH = max(bubbleH, 37)
|
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
|
// Stretchable bubble image min height
|
||||||
bubbleH = max(bubbleH, 37)
|
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.
|
// Date header adds height above the bubble.
|
||||||
let dateHeaderH: CGFloat = config.showsDateHeader ? 42 : 0
|
let dateHeaderH: CGFloat = config.showsDateHeader ? 42 : 0
|
||||||
|
|
||||||
@@ -437,7 +466,7 @@ extension MessageCellLayout {
|
|||||||
// Photo right = bubbleW - 2. Gap = 6pt → pill right = bubbleW - 8.
|
// Photo right = bubbleW - 2. Gap = 6pt → pill right = bubbleW - 8.
|
||||||
// → statusEndX = bubbleW - 15 → metadataRightInset = 15.
|
// → statusEndX = bubbleW - 15 → metadataRightInset = 15.
|
||||||
metadataRightInset = 15
|
metadataRightInset = 15
|
||||||
} else if isTextMessage || messageType == .photoWithCaption {
|
} else if isTextMessage || isForwardWithCaption || messageType == .photoWithCaption {
|
||||||
metadataRightInset = config.isOutgoing
|
metadataRightInset = config.isOutgoing
|
||||||
? textStatusLaneMetrics.textStatusRightInset
|
? textStatusLaneMetrics.textStatusRightInset
|
||||||
: rightPad
|
: rightPad
|
||||||
@@ -457,7 +486,7 @@ extension MessageCellLayout {
|
|||||||
}
|
}
|
||||||
let statusEndX = bubbleW - metadataRightInset
|
let statusEndX = bubbleW - metadataRightInset
|
||||||
let statusEndY = bubbleH - metadataBottomInset
|
let statusEndY = bubbleH - metadataBottomInset
|
||||||
let statusVerticalOffset: CGFloat = isTextMessage
|
let statusVerticalOffset: CGFloat = (isTextMessage || isForwardWithCaption)
|
||||||
? textStatusLaneMetrics.verticalOffset
|
? textStatusLaneMetrics.verticalOffset
|
||||||
: 0
|
: 0
|
||||||
|
|
||||||
@@ -484,7 +513,7 @@ extension MessageCellLayout {
|
|||||||
if hasStatusIcon {
|
if hasStatusIcon {
|
||||||
let checkImgW: CGFloat = floor(floor(font.pointSize * 11.0 / 17.0))
|
let checkImgW: CGFloat = floor(floor(font.pointSize * 11.0 / 17.0))
|
||||||
let checkImgH: CGFloat = floor(checkImgW * 9.0 / 11.0)
|
let checkImgH: CGFloat = floor(checkImgW * 9.0 / 11.0)
|
||||||
let checkOffset: CGFloat = isTextMessage
|
let checkOffset: CGFloat = (isTextMessage || isForwardWithCaption)
|
||||||
? textStatusLaneMetrics.checkOffset
|
? textStatusLaneMetrics.checkOffset
|
||||||
: floor(font.pointSize * 6.0 / 17.0)
|
: floor(font.pointSize * 6.0 / 17.0)
|
||||||
let checkReadX = statusEndX - checkImgW
|
let checkReadX = statusEndX - checkImgW
|
||||||
@@ -510,7 +539,7 @@ extension MessageCellLayout {
|
|||||||
// line breaks ("jagged first line"). The content area is always ≥ measured width.
|
// line breaks ("jagged first line"). The content area is always ≥ measured width.
|
||||||
var textY: CGFloat = topPad
|
var textY: CGFloat = topPad
|
||||||
if config.hasReplyQuote { textY = replyH + topPad }
|
if config.hasReplyQuote { textY = replyH + topPad }
|
||||||
if forwardHeaderH > 0 { textY = forwardHeaderH + topPad }
|
if forwardHeaderH > 0 { textY = forwardHeaderH + 2 }
|
||||||
if photoH > 0 {
|
if photoH > 0 {
|
||||||
// Photo has 2pt inset top + 2pt inset bottom, so text starts after photoH + 4 + gap
|
// Photo has 2pt inset top + 2pt inset bottom, so text starts after photoH + 4 + gap
|
||||||
textY = photoH + 4 + 6 + topPad
|
textY = photoH + 4 + 6 + topPad
|
||||||
@@ -544,9 +573,9 @@ extension MessageCellLayout {
|
|||||||
let photoFrame = CGRect(x: 2, y: photoY, width: bubbleW - 4, height: photoH)
|
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 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 fwdHeaderFrame = CGRect(x: 10, y: 7, width: bubbleW - 20, height: 17)
|
||||||
let fwdAvatarFrame = CGRect(x: 10, y: 23, width: 20, height: 20)
|
let fwdAvatarFrame = CGRect(x: 10, y: 24, width: 16, height: 16)
|
||||||
let fwdNameFrame = CGRect(x: 34, y: 24, width: bubbleW - 44, height: 17)
|
let fwdNameFrame = CGRect(x: 30, y: 24, width: bubbleW - 40, height: 17)
|
||||||
|
|
||||||
let layout = MessageCellLayout(
|
let layout = MessageCellLayout(
|
||||||
totalHeight: totalH,
|
totalHeight: totalH,
|
||||||
@@ -677,6 +706,9 @@ extension MessageCellLayout {
|
|||||||
}
|
}
|
||||||
if validChars.isEmpty { return true }
|
if validChars.isEmpty { return true }
|
||||||
|
|
||||||
|
// Chunked format
|
||||||
|
if trimmed.hasPrefix("CHNK:") { return true }
|
||||||
|
|
||||||
// Check for encrypted ciphertext: Base64:Base64 pattern
|
// Check for encrypted ciphertext: Base64:Base64 pattern
|
||||||
let parts = trimmed.components(separatedBy: ":")
|
let parts = trimmed.components(separatedBy: ":")
|
||||||
if parts.count == 2 && parts[0].count >= 16 && parts[1].count >= 16 {
|
if parts.count == 2 && parts[0].count >= 16 && parts[1].count >= 16 {
|
||||||
@@ -688,6 +720,12 @@ extension MessageCellLayout {
|
|||||||
if allBase64 { return true }
|
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
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -867,6 +905,23 @@ extension MessageCellLayout {
|
|||||||
let hasReply = message.attachments.contains { $0.type == .messages }
|
let hasReply = message.attachments.contains { $0.type == .messages }
|
||||||
let isForward = hasReply && displayText.isEmpty
|
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")
|
// Parse image dimensions from preview field (format: "tag::blurhash::WxH")
|
||||||
let imageDims: CGSize? = images.first.flatMap {
|
let imageDims: CGSize? = images.first.flatMap {
|
||||||
AttachmentPreviewCodec.imageDimensions(from: $0.preview)
|
AttachmentPreviewCodec.imageDimensions(from: $0.preview)
|
||||||
@@ -877,7 +932,7 @@ extension MessageCellLayout {
|
|||||||
isOutgoing: isOutgoing,
|
isOutgoing: isOutgoing,
|
||||||
position: position,
|
position: position,
|
||||||
deliveryStatus: message.deliveryStatus,
|
deliveryStatus: message.deliveryStatus,
|
||||||
text: displayText,
|
text: isForward ? (forwardCaption ?? "") : displayText,
|
||||||
timestampText: timestampText,
|
timestampText: timestampText,
|
||||||
hasReplyQuote: hasReply && !displayText.isEmpty,
|
hasReplyQuote: hasReply && !displayText.isEmpty,
|
||||||
replyName: nil,
|
replyName: nil,
|
||||||
@@ -888,9 +943,9 @@ extension MessageCellLayout {
|
|||||||
avatarCount: avatars.count,
|
avatarCount: avatars.count,
|
||||||
callCount: calls.count,
|
callCount: calls.count,
|
||||||
isForward: isForward,
|
isForward: isForward,
|
||||||
forwardImageCount: isForward ? images.count : 0,
|
forwardImageCount: forwardInnerImageCount,
|
||||||
forwardFileCount: isForward ? files.count : 0,
|
forwardFileCount: forwardInnerFileCount,
|
||||||
forwardCaption: nil,
|
forwardCaption: forwardCaption,
|
||||||
showsDateHeader: showsDateHeader,
|
showsDateHeader: showsDateHeader,
|
||||||
dateHeaderText: dateHeaderText
|
dateHeaderText: dateHeaderText
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -66,13 +66,21 @@ final class SessionManager {
|
|||||||
var attachmentFlowTransport: AttachmentFlowTransporting = LiveAttachmentFlowTransport()
|
var attachmentFlowTransport: AttachmentFlowTransporting = LiveAttachmentFlowTransport()
|
||||||
var packetFlowSender: PacketFlowSending = LivePacketFlowSender()
|
var packetFlowSender: PacketFlowSending = LivePacketFlowSender()
|
||||||
|
|
||||||
// MARK: - Foreground Detection (Android parity)
|
// MARK: - Foreground & Idle Detection (Desktop/Android parity)
|
||||||
|
|
||||||
private var foregroundObserverToken: NSObjectProtocol?
|
private var foregroundObserverToken: NSObjectProtocol?
|
||||||
private var backgroundObserverToken: NSObjectProtocol?
|
private var backgroundObserverToken: NSObjectProtocol?
|
||||||
/// Android parity: 5s debounce between foreground sync requests.
|
/// Android parity: 5s debounce between foreground sync requests.
|
||||||
private var lastForegroundSyncTime: TimeInterval = 0
|
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.
|
/// Whether the app is in the foreground.
|
||||||
private var isAppInForeground: Bool {
|
private var isAppInForeground: Bool {
|
||||||
UIApplication.shared.applicationState == .active
|
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.
|
/// 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() {
|
func markActiveDialogsAsRead() {
|
||||||
let activeKeys = MessageRepository.shared.activeDialogKeys
|
let activeKeys = MessageRepository.shared.activeDialogKeys
|
||||||
let myKey = currentPublicKey
|
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
|
// MARK: - Session Lifecycle
|
||||||
|
|
||||||
/// Called after password verification. Decrypts private key, connects WebSocket, and starts handshake.
|
/// Called after password verification. Decrypts private key, connects WebSocket, and starts handshake.
|
||||||
@@ -1488,7 +1522,19 @@ final class SessionManager {
|
|||||||
)
|
)
|
||||||
}()
|
}()
|
||||||
if isGroupDialog, groupKey == nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1506,9 +1552,27 @@ final class SessionManager {
|
|||||||
}.value
|
}.value
|
||||||
|
|
||||||
guard let cryptoResult else {
|
guard let cryptoResult else {
|
||||||
Self.logger.warning("processIncoming: decryptIncomingMessage returned nil for msgId=\(packet.messageId.prefix(8))…")
|
// Desktop/Android parity: NEVER drop a message on decrypt failure.
|
||||||
// Still recalculate dialog — cleans up stale ciphertext in lastMessage
|
// Desktop and Android store encrypted content and retry decryption
|
||||||
// that may persist from previous sessions or failed decryptions.
|
// 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)
|
DialogRepository.shared.updateDialogFromMessages(opponentKey: opponentKey)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1566,13 +1630,14 @@ final class SessionManager {
|
|||||||
// Sending 0x08 for every received message was causing a packet flood
|
// Sending 0x08 for every received message was causing a packet flood
|
||||||
// that triggered server RST disconnects.
|
// that triggered server RST disconnects.
|
||||||
|
|
||||||
// Android parity: mark as read if dialog is active AND app is in foreground.
|
// Desktop/Android parity: mark as read if dialog is active, read-eligible,
|
||||||
// Android has NO idle detection — only isDialogActive flag (ON_RESUME/ON_PAUSE).
|
// app is in foreground, AND user is not idle (20s timeout).
|
||||||
let dialogIsActive = MessageRepository.shared.isDialogActive(opponentKey)
|
let dialogIsActive = MessageRepository.shared.isDialogActive(opponentKey)
|
||||||
let dialogIsReadEligible = MessageRepository.shared.isDialogReadEligible(opponentKey)
|
let dialogIsReadEligible = MessageRepository.shared.isDialogReadEligible(opponentKey)
|
||||||
let isSystem = SystemAccounts.isSystemAccount(opponentKey)
|
let isSystem = SystemAccounts.isSystemAccount(opponentKey)
|
||||||
let fg = isAppInForeground
|
let fg = isAppInForeground
|
||||||
let shouldMarkRead = dialogIsActive && dialogIsReadEligible && fg && !isSystem
|
let idle = isUserIdle
|
||||||
|
let shouldMarkRead = dialogIsActive && dialogIsReadEligible && fg && !isSystem && !idle
|
||||||
|
|
||||||
if shouldMarkRead {
|
if shouldMarkRead {
|
||||||
DialogRepository.shared.markAsRead(opponentKey: opponentKey)
|
DialogRepository.shared.markAsRead(opponentKey: opponentKey)
|
||||||
|
|||||||
@@ -151,6 +151,12 @@ enum RosettaColors {
|
|||||||
return Int(index)
|
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 {
|
static func avatarText(publicKey: String) -> String {
|
||||||
String(publicKey.prefix(2)).uppercased()
|
String(publicKey.prefix(2)).uppercased()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -242,6 +242,7 @@ struct ChatDetailView: View {
|
|||||||
// setDialogActive only touches MessageRepository.activeDialogs (Set),
|
// setDialogActive only touches MessageRepository.activeDialogs (Set),
|
||||||
// does NOT mutate DialogRepository, so ForEach won't rebuild.
|
// does NOT mutate DialogRepository, so ForEach won't rebuild.
|
||||||
MessageRepository.shared.setDialogActive(route.publicKey, isActive: true)
|
MessageRepository.shared.setDialogActive(route.publicKey, isActive: true)
|
||||||
|
SessionManager.shared.resetIdleTimer()
|
||||||
updateReadEligibility()
|
updateReadEligibility()
|
||||||
clearDeliveredNotifications(for: route.publicKey)
|
clearDeliveredNotifications(for: route.publicKey)
|
||||||
// Telegram-like read policy: mark read only when dialog is truly readable
|
// Telegram-like read policy: mark read only when dialog is truly readable
|
||||||
@@ -275,6 +276,7 @@ struct ChatDetailView: View {
|
|||||||
isViewActive = false
|
isViewActive = false
|
||||||
updateReadEligibility()
|
updateReadEligibility()
|
||||||
MessageRepository.shared.setDialogActive(route.publicKey, isActive: false)
|
MessageRepository.shared.setDialogActive(route.publicKey, isActive: false)
|
||||||
|
SessionManager.shared.stopIdleTimer()
|
||||||
// Desktop parity: save draft text on chat close.
|
// Desktop parity: save draft text on chat close.
|
||||||
DraftManager.shared.saveDraft(for: route.publicKey, text: messageText)
|
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
|
// 600ms delay lets notification-tap navigation settle — if user tapped
|
||||||
// a notification for a DIFFERENT chat, isViewActive becomes false.
|
// a notification for a DIFFERENT chat, isViewActive becomes false.
|
||||||
guard isViewActive else { return }
|
guard isViewActive else { return }
|
||||||
|
SessionManager.shared.resetIdleTimer()
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
try? await Task.sleep(for: .milliseconds(600))
|
try? await Task.sleep(for: .milliseconds(600))
|
||||||
guard isViewActive else { return }
|
guard isViewActive else { return }
|
||||||
@@ -838,6 +841,7 @@ private extension ChatDetailView {
|
|||||||
scrollToBottomRequested: $scrollToBottomRequested,
|
scrollToBottomRequested: $scrollToBottomRequested,
|
||||||
onAtBottomChange: { atBottom in
|
onAtBottomChange: { atBottom in
|
||||||
isAtBottom = atBottom
|
isAtBottom = atBottom
|
||||||
|
SessionManager.shared.resetIdleTimer()
|
||||||
updateReadEligibility()
|
updateReadEligibility()
|
||||||
if atBottom {
|
if atBottom {
|
||||||
markDialogAsRead()
|
markDialogAsRead()
|
||||||
|
|||||||
@@ -172,25 +172,25 @@ struct MessageCellView: View, Equatable {
|
|||||||
|
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
Text("Forwarded from")
|
Text("Forwarded from")
|
||||||
.font(.system(size: 13, weight: .regular))
|
.font(.system(size: 14, weight: .regular))
|
||||||
.foregroundStyle(outgoing ? Color.white.opacity(0.5) : RosettaColors.Adaptive.textSecondary)
|
.foregroundStyle(outgoing ? Color.white : RosettaColors.figmaBlue)
|
||||||
.padding(.leading, 11)
|
.padding(.leading, 11)
|
||||||
.padding(.top, 6)
|
.padding(.top, 7)
|
||||||
|
|
||||||
HStack(spacing: 6) {
|
HStack(spacing: 4) {
|
||||||
AvatarView(
|
AvatarView(
|
||||||
initials: senderInitials,
|
initials: senderInitials,
|
||||||
colorIndex: senderColorIndex,
|
colorIndex: senderColorIndex,
|
||||||
size: 20,
|
size: 16,
|
||||||
image: senderAvatar
|
image: senderAvatar
|
||||||
)
|
)
|
||||||
Text(senderName)
|
Text(senderName)
|
||||||
.font(.system(size: 14, weight: .semibold))
|
.font(.system(size: 14, weight: .medium))
|
||||||
.foregroundStyle(outgoing ? Color.white : RosettaColors.figmaBlue)
|
.foregroundStyle(outgoing ? Color.white : RosettaColors.figmaBlue)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
}
|
}
|
||||||
.padding(.leading, 11)
|
.padding(.leading, 11)
|
||||||
.padding(.top, 3)
|
.padding(.top, 0)
|
||||||
|
|
||||||
if !imageAttachments.isEmpty {
|
if !imageAttachments.isEmpty {
|
||||||
ForwardedPhotoCollageView(
|
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 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 replyNameFont = UIFont.systemFont(ofSize: 14, weight: .semibold)
|
||||||
private static let replyTextFont = UIFont.systemFont(ofSize: 14, weight: .regular)
|
private static let replyTextFont = UIFont.systemFont(ofSize: 14, weight: .regular)
|
||||||
private static let forwardLabelFont = UIFont.systemFont(ofSize: 13, weight: .regular)
|
private static let forwardLabelFont = UIFont.systemFont(ofSize: 14, weight: .regular)
|
||||||
private static let forwardNameFont = UIFont.systemFont(ofSize: 14, weight: .semibold)
|
private static let forwardNameFont = UIFont.systemFont(ofSize: 14, weight: .medium)
|
||||||
private static let fileNameFont = UIFont.systemFont(ofSize: 16, weight: .regular)
|
private static let fileNameFont = UIFont.systemFont(ofSize: 16, weight: .regular)
|
||||||
private static let fileSizeFont = UIFont.monospacedDigitSystemFont(ofSize: 13, weight: .regular)
|
private static let fileSizeFont = UIFont.monospacedDigitSystemFont(ofSize: 13, weight: .regular)
|
||||||
private static let bubbleMetrics = BubbleMetrics.telegram()
|
private static let bubbleMetrics = BubbleMetrics.telegram()
|
||||||
@@ -136,6 +136,8 @@ final class NativeMessageCell: UICollectionViewCell {
|
|||||||
// Forward header
|
// Forward header
|
||||||
private let forwardLabel = UILabel()
|
private let forwardLabel = UILabel()
|
||||||
private let forwardAvatarView = UIView()
|
private let forwardAvatarView = UIView()
|
||||||
|
private let forwardAvatarInitialLabel = UILabel()
|
||||||
|
private let forwardAvatarImageView = UIImageView()
|
||||||
private let forwardNameLabel = UILabel()
|
private let forwardNameLabel = UILabel()
|
||||||
|
|
||||||
// Highlight overlay (scroll-to-message flash)
|
// Highlight overlay (scroll-to-message flash)
|
||||||
@@ -397,16 +399,24 @@ final class NativeMessageCell: UICollectionViewCell {
|
|||||||
|
|
||||||
// Forward header
|
// Forward header
|
||||||
forwardLabel.font = Self.forwardLabelFont
|
forwardLabel.font = Self.forwardLabelFont
|
||||||
forwardLabel.text = "Forwarded message"
|
forwardLabel.text = "Forwarded from"
|
||||||
forwardLabel.textColor = UIColor.white.withAlphaComponent(0.6)
|
|
||||||
bubbleView.addSubview(forwardLabel)
|
bubbleView.addSubview(forwardLabel)
|
||||||
|
|
||||||
forwardAvatarView.backgroundColor = UIColor.white.withAlphaComponent(0.3)
|
forwardAvatarView.layer.cornerRadius = 8
|
||||||
forwardAvatarView.layer.cornerRadius = 10
|
forwardAvatarView.clipsToBounds = true
|
||||||
bubbleView.addSubview(forwardAvatarView)
|
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.font = Self.forwardNameFont
|
||||||
forwardNameLabel.textColor = .white
|
forwardNameLabel.textColor = .white // default, overridden in configure()
|
||||||
bubbleView.addSubview(forwardNameLabel)
|
bubbleView.addSubview(forwardNameLabel)
|
||||||
|
|
||||||
// Highlight overlay — on top of all bubble content
|
// Highlight overlay — on top of all bubble content
|
||||||
@@ -457,7 +467,8 @@ final class NativeMessageCell: UICollectionViewCell {
|
|||||||
replyName: String? = nil,
|
replyName: String? = nil,
|
||||||
replyText: String? = nil,
|
replyText: String? = nil,
|
||||||
replyMessageId: String? = nil,
|
replyMessageId: String? = nil,
|
||||||
forwardSenderName: String? = nil
|
forwardSenderName: String? = nil,
|
||||||
|
forwardSenderKey: String? = nil
|
||||||
) {
|
) {
|
||||||
self.message = message
|
self.message = message
|
||||||
self.actions = actions
|
self.actions = actions
|
||||||
@@ -542,6 +553,31 @@ final class NativeMessageCell: UICollectionViewCell {
|
|||||||
forwardAvatarView.isHidden = false
|
forwardAvatarView.isHidden = false
|
||||||
forwardNameLabel.isHidden = false
|
forwardNameLabel.isHidden = false
|
||||||
forwardNameLabel.text = forwardSenderName
|
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 {
|
} else {
|
||||||
forwardLabel.isHidden = true
|
forwardLabel.isHidden = true
|
||||||
forwardAvatarView.isHidden = true
|
forwardAvatarView.isHidden = true
|
||||||
@@ -894,6 +930,9 @@ final class NativeMessageCell: UICollectionViewCell {
|
|||||||
if layout.isForward {
|
if layout.isForward {
|
||||||
forwardLabel.frame = layout.forwardHeaderFrame
|
forwardLabel.frame = layout.forwardHeaderFrame
|
||||||
forwardAvatarView.frame = layout.forwardAvatarFrame
|
forwardAvatarView.frame = layout.forwardAvatarFrame
|
||||||
|
let avatarBounds = forwardAvatarView.bounds
|
||||||
|
forwardAvatarInitialLabel.frame = avatarBounds
|
||||||
|
forwardAvatarImageView.frame = avatarBounds
|
||||||
forwardNameLabel.frame = layout.forwardNameFrame
|
forwardNameLabel.frame = layout.forwardNameFrame
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -271,6 +271,7 @@ final class NativeMessageListController: UIViewController {
|
|||||||
var replyText: String?
|
var replyText: String?
|
||||||
var replyMessageId: String?
|
var replyMessageId: String?
|
||||||
var forwardSenderName: String?
|
var forwardSenderName: String?
|
||||||
|
var forwardSenderKey: String?
|
||||||
|
|
||||||
if let att = replyAtt {
|
if let att = replyAtt {
|
||||||
if let data = att.blob.data(using: .utf8),
|
if let data = att.blob.data(using: .utf8),
|
||||||
@@ -293,6 +294,7 @@ final class NativeMessageListController: UIViewController {
|
|||||||
if displayText.isEmpty {
|
if displayText.isEmpty {
|
||||||
// Forward
|
// Forward
|
||||||
forwardSenderName = name
|
forwardSenderName = name
|
||||||
|
forwardSenderKey = senderKey
|
||||||
} else {
|
} else {
|
||||||
// Reply quote
|
// Reply quote
|
||||||
replyName = name
|
replyName = name
|
||||||
@@ -312,7 +314,8 @@ final class NativeMessageListController: UIViewController {
|
|||||||
replyName: replyName,
|
replyName: replyName,
|
||||||
replyText: replyText,
|
replyText: replyText,
|
||||||
replyMessageId: replyMessageId,
|
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))
|
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