diff --git a/Info.plist b/Info.plist
index d977eca..b56f24f 100644
--- a/Info.plist
+++ b/Info.plist
@@ -20,6 +20,17 @@
audio
voip
+ CFBundleURLTypes
+
+
+ CFBundleURLSchemes
+
+ rosetta
+
+ CFBundleURLName
+ com.rosetta.dev
+
+
NSUserActivityTypes
INSendMessageIntent
diff --git a/Rosetta/Core/Services/CallManager.swift b/Rosetta/Core/Services/CallManager.swift
index 2616dd3..2573095 100644
--- a/Rosetta/Core/Services/CallManager.swift
+++ b/Rosetta/Core/Services/CallManager.swift
@@ -336,18 +336,11 @@ final class CallManager: NSObject, ObservableObject {
print("[Call] LiveActivity DISABLED by user settings")
return
}
- // Create tiny avatar thumbnail (32x32) to embed directly in attributes (<4KB limit)
+ // Compress avatar to fit ActivityKit 4KB limit while maximizing quality
var avatarThumb: Data?
if let avatar = AvatarRepository.shared.loadAvatar(publicKey: uiState.peerPublicKey) {
- let thumbSize = CGSize(width: 32, height: 32)
- UIGraphicsBeginImageContextWithOptions(thumbSize, false, 1.0)
- avatar.draw(in: CGRect(origin: .zero, size: thumbSize))
- let tiny = UIGraphicsGetImageFromCurrentImageContext()
- UIGraphicsEndImageContext()
- if let jpeg = tiny?.jpegData(compressionQuality: 0.5) {
- avatarThumb = jpeg
- print("[Call] Avatar thumb: \(jpeg.count) bytes (32x32)")
- }
+ avatarThumb = Self.compressAvatarForActivity(avatar)
+ print("[Call] Avatar thumb: \(avatarThumb?.count ?? 0) bytes")
} else {
print("[Call] No avatar for peer")
}
@@ -434,6 +427,48 @@ final class CallManager: NSObject, ObservableObject {
break
}
}
+ // MARK: - Adaptive Avatar Compressor
+
+ /// Compresses avatar to fit ActivityKit's ~4KB attributes limit.
+ /// Uses adaptive resolution + quality: starts at 80x80 high quality,
+ /// progressively reduces until it fits. Avatar is ALWAYS included
+ /// if the source image exists — never falls back to initials.
+ static func compressAvatarForActivity(_ image: UIImage) -> Data? {
+ let maxBytes = 3000 // safe margin under 4KB limit (other fields use ~500B)
+
+ // Resolution/quality ladder — ordered from best to smallest.
+ // Each step: (pixel size, JPEG qualities to try from high to low)
+ let ladder: [(size: Int, qualities: [CGFloat])] = [
+ (80, [0.7, 0.5, 0.3]),
+ (64, [0.7, 0.5, 0.3]),
+ (48, [0.6, 0.4, 0.2]),
+ (36, [0.5, 0.3]),
+ ]
+
+ for step in ladder {
+ let sz = CGSize(width: step.size, height: step.size)
+ UIGraphicsBeginImageContextWithOptions(sz, false, 1.0)
+ image.draw(in: CGRect(origin: .zero, size: sz))
+ let resized = UIGraphicsGetImageFromCurrentImageContext()
+ UIGraphicsEndImageContext()
+
+ guard let resized else { continue }
+
+ for q in step.qualities {
+ if let data = resized.jpegData(compressionQuality: q), data.count <= maxBytes {
+ return data
+ }
+ }
+ }
+
+ // Ultimate fallback: 24x24 at minimum quality — guaranteed < 500 bytes
+ let tiny = CGSize(width: 24, height: 24)
+ UIGraphicsBeginImageContextWithOptions(tiny, false, 1.0)
+ image.draw(in: CGRect(origin: .zero, size: tiny))
+ let last = UIGraphicsGetImageFromCurrentImageContext()
+ UIGraphicsEndImageContext()
+ return last?.jpegData(compressionQuality: 0.1)
+ }
}
#if DEBUG
diff --git a/Rosetta/Core/Services/SessionManager.swift b/Rosetta/Core/Services/SessionManager.swift
index 5239530..60d4df9 100644
--- a/Rosetta/Core/Services/SessionManager.swift
+++ b/Rosetta/Core/Services/SessionManager.swift
@@ -325,9 +325,11 @@ final class SessionManager {
let timestamp = Int64(Date().timeIntervalSince1970 * 1000)
let attachmentId = String((0..<8).map { _ in "abcdefghijklmnopqrstuvwxyz0123456789".randomElement()! })
- // Generate ECDH keys + encrypt empty text (avatar messages can have empty text)
+ // Android/Desktop parity: avatar messages have empty text.
+ // Desktop shows "$a=Avatar" in chat list ONLY if decrypted text is empty.
+ // Sending " " (space) causes Desktop chat list to show nothing.
let encrypted = try MessageCrypto.encryptOutgoing(
- plaintext: " ",
+ plaintext: "",
recipientPublicKeyHex: toPublicKey
)
@@ -401,7 +403,7 @@ final class SessionManager {
// Optimistic UI — show message IMMEDIATELY (before upload)
MessageRepository.shared.upsertFromMessagePacket(
- packet, myPublicKey: currentPublicKey, decryptedText: " ",
+ packet, myPublicKey: currentPublicKey, decryptedText: "",
attachmentPassword: "rawkey:" + encrypted.plainKeyAndNonce.hexString,
fromSync: false
)
@@ -422,13 +424,15 @@ final class SessionManager {
throw error
}
- // Update preview with CDN tag (tag::blurhash)
- let preview = "\(upload.tag)::\(blurhash)"
+ // Desktop parity: preview = pure blurhash (no tag prefix).
+ // Desktop MessageAvatar.tsx passes preview directly to blurhash decoder —
+ // including the "tag::" prefix causes "blurhash length mismatch" errors.
+ // CDN tag is stored in transportTag for download.
packet.attachments = [
MessageAttachment(
id: attachmentId,
- preview: preview,
- blob: "", // Desktop parity: blob cleared after upload
+ preview: blurhash,
+ blob: "",
type: .avatar,
transportTag: upload.tag,
transportServer: upload.server
@@ -672,7 +676,11 @@ final class SessionManager {
for try await (index, tag, server) in group { uploads[index] = (tag, server) }
return encryptedAttachments.enumerated().map { index, item in
let upload = uploads[index] ?? (tag: "", server: "")
- let preview = AttachmentPreviewCodec.compose(downloadTag: upload.tag, payload: item.preview)
+ // Desktop parity: preview = payload only (no tag prefix).
+ // Desktop MessageFile.tsx does preview.split("::")[0] for filesize —
+ // embedding tag prefix makes it parse the UUID as filesize → shows wrong filename.
+ // CDN tag stored in transportTag for download.
+ let preview = item.preview
Self.logger.info("📤 Attachment uploaded: type=\(String(describing: item.original.type)), tag=\(upload.tag), server=\(upload.server)")
return MessageAttachment(
id: item.original.id, preview: preview, blob: "",
diff --git a/Rosetta/Features/Chats/ChatDetail/MessageAvatarView.swift b/Rosetta/Features/Chats/ChatDetail/MessageAvatarView.swift
index e938d51..0879365 100644
--- a/Rosetta/Features/Chats/ChatDetail/MessageAvatarView.swift
+++ b/Rosetta/Features/Chats/ChatDetail/MessageAvatarView.swift
@@ -235,11 +235,17 @@ struct MessageAvatarView: View {
let tag = attachment.effectiveDownloadTag
guard !tag.isEmpty else {
+ #if DEBUG
+ print("🖼️ AVATAR FAIL: empty tag for attachment \(attachment.id), preview=\(attachment.preview.prefix(60)), transportTag=\(attachment.transportTag)")
+ #endif
downloadError = true
return
}
guard let storedPassword = message.attachmentPassword, !storedPassword.isEmpty else {
+ #if DEBUG
+ print("🖼️ AVATAR FAIL: nil/empty attachmentPassword for msgId=\(message.id.prefix(8))… attId=\(attachment.id)")
+ #endif
downloadError = true
return
}
@@ -248,12 +254,23 @@ struct MessageAvatarView: View {
downloadError = false
let server = attachment.transportServer
+ #if DEBUG
+ print("🖼️ AVATAR START: tag=\(tag) server=\(server) storedPwd=\(storedPassword.prefix(30))… attId=\(attachment.id)")
+ #endif
Task {
do {
let encryptedData = try await TransportManager.shared.downloadFile(tag: tag, server: server)
let encryptedString = String(decoding: encryptedData, as: UTF8.self)
+ #if DEBUG
+ let hasColon = encryptedString.contains(":")
+ print("🖼️ AVATAR DOWNLOADED: \(encryptedData.count) bytes, hasColon=\(hasColon), first50=\(encryptedString.prefix(50))")
+ #endif
+
let passwords = MessageCrypto.attachmentPasswordCandidates(from: storedPassword)
+ #if DEBUG
+ print("🖼️ AVATAR CANDIDATES (\(passwords.count)): \(passwords.map { "\($0.prefix(20))…(\($0.count)ch)" })")
+ #endif
let downloadedImage = decryptAndParseImage(
encryptedString: encryptedString, passwords: passwords
)
@@ -268,12 +285,21 @@ struct MessageAvatarView: View {
let base64 = jpegData.base64EncodedString()
AvatarRepository.shared.saveAvatarFromBase64(base64, publicKey: senderKey)
}
+ #if DEBUG
+ print("🖼️ AVATAR DECRYPT OK: attId=\(attachment.id)")
+ #endif
} else {
+ #if DEBUG
+ print("🖼️ AVATAR DECRYPT FAIL: all \(passwords.count) candidates failed for attId=\(attachment.id)")
+ #endif
downloadError = true
}
isDownloading = false
}
} catch {
+ #if DEBUG
+ print("🖼️ AVATAR ERROR: \(error) for tag=\(tag)")
+ #endif
await MainActor.run {
downloadError = true
isDownloading = false
@@ -285,18 +311,42 @@ struct MessageAvatarView: View {
/// Tries each password candidate and validates the decrypted content is a real image.
private func decryptAndParseImage(encryptedString: String, passwords: [String]) -> UIImage? {
let crypto = CryptoManager.shared
- for password in passwords {
- guard let data = try? crypto.decryptWithPassword(
- encryptedString, password: password, requireCompression: true
- ) else { continue }
- if let img = parseImageData(data) { return img }
+ for (i, password) in passwords.enumerated() {
+ do {
+ let data = try crypto.decryptWithPassword(
+ encryptedString, password: password, requireCompression: true
+ )
+ #if DEBUG
+ print("🖼️ PASS1 candidate[\(i)] decrypted \(data.count) bytes")
+ #endif
+ if let img = parseImageData(data) { return img }
+ #if DEBUG
+ print("🖼️ PASS1 candidate[\(i)] parseImageData FAILED")
+ #endif
+ } catch {
+ #if DEBUG
+ print("🖼️ PASS1 candidate[\(i)] FAILED: \(error)")
+ #endif
+ }
}
// Fallback: try without requireCompression (legacy uncompressed payloads)
- for password in passwords {
- guard let data = try? crypto.decryptWithPassword(
- encryptedString, password: password
- ) else { continue }
- if let img = parseImageData(data) { return img }
+ for (i, password) in passwords.enumerated() {
+ do {
+ let data = try crypto.decryptWithPassword(
+ encryptedString, password: password
+ )
+ #if DEBUG
+ print("🖼️ PASS2 candidate[\(i)] decrypted \(data.count) bytes, prefix: \(data.prefix(20).map { String(format: "%02x", $0) }.joined())")
+ #endif
+ if let img = parseImageData(data) { return img }
+ #if DEBUG
+ print("🖼️ PASS2 candidate[\(i)] parseImageData FAILED")
+ #endif
+ } catch {
+ #if DEBUG
+ print("🖼️ PASS2 candidate[\(i)] FAILED: \(error)")
+ #endif
+ }
}
return nil
}
diff --git a/Rosetta/Features/Chats/ChatDetail/MessageFileView.swift b/Rosetta/Features/Chats/ChatDetail/MessageFileView.swift
index d7ddb8f..ee2a6ab 100644
--- a/Rosetta/Features/Chats/ChatDetail/MessageFileView.swift
+++ b/Rosetta/Features/Chats/ChatDetail/MessageFileView.swift
@@ -22,32 +22,39 @@ struct MessageFileView: View {
var body: some View {
HStack(spacing: 10) {
- // File icon circle
+ // File icon circle (Telegram parity: solid accent fill + white icon)
ZStack {
Circle()
- .fill(outgoing ? Color.white.opacity(0.2) : Color(hex: 0x008BFF).opacity(0.2))
+ .fill(downloadError
+ ? Color.red.opacity(0.15)
+ : (outgoing ? Color.white.opacity(0.2) : Color(hex: 0x008BFF)))
.frame(width: 44, height: 44)
if isDownloading {
ProgressView()
- .tint(outgoing ? .white : Color(hex: 0x008BFF))
+ .tint(.white)
.scaleEffect(0.8)
+ } else if downloadError {
+ Image(systemName: "exclamationmark.circle.fill")
+ .font(.system(size: 20))
+ .foregroundStyle(.red)
} else if isDownloaded {
Image(systemName: fileIcon)
.font(.system(size: 20))
- .foregroundStyle(outgoing ? .white : Color(hex: 0x008BFF))
+ .foregroundStyle(.white)
} else {
- Image(systemName: "arrow.down.circle.fill")
- .font(.system(size: 20))
- .foregroundStyle(outgoing ? .white.opacity(0.7) : Color(hex: 0x008BFF).opacity(0.7))
+ Image(systemName: "arrow.down")
+ .font(.system(size: 20, weight: .medium))
+ .foregroundStyle(.white)
}
}
// File metadata
VStack(alignment: .leading, spacing: 2) {
+ // Telegram parity: filename in accent blue (incoming) or white (outgoing)
Text(fileName)
.font(.system(size: 16, weight: .regular))
- .foregroundStyle(outgoing ? .white : RosettaColors.Adaptive.text)
+ .foregroundStyle(outgoing ? .white : Color(hex: 0x008BFF))
.lineLimit(1)
if isDownloading {
@@ -204,9 +211,16 @@ struct MessageFileView: View {
// MARK: - Share
- private func shareFile(_ url: URL) {
+ private func shareFile(_ cachedURL: URL) {
+ // Files are stored encrypted (.enc) on disk. Decrypt to temp dir for sharing.
+ guard let data = AttachmentCache.shared.loadFileData(
+ forAttachmentId: attachment.id, fileName: fileName
+ ) else { return }
+ let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(fileName)
+ try? data.write(to: tempURL, options: .atomic)
+
let activityVC = UIActivityViewController(
- activityItems: [url],
+ activityItems: [tempURL],
applicationActivities: nil
)
if let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
diff --git a/Rosetta/Features/Chats/ChatDetail/MessageImageView.swift b/Rosetta/Features/Chats/ChatDetail/MessageImageView.swift
index 09c2e5a..05dc360 100644
--- a/Rosetta/Features/Chats/ChatDetail/MessageImageView.swift
+++ b/Rosetta/Features/Chats/ChatDetail/MessageImageView.swift
@@ -253,11 +253,17 @@ struct MessageImageView: View {
let tag = attachment.effectiveDownloadTag
guard !tag.isEmpty else {
+ #if DEBUG
+ print("📸 DOWNLOAD FAIL: empty tag for attachment \(attachment.id), preview=\(attachment.preview.prefix(60)), transportTag=\(attachment.transportTag)")
+ #endif
downloadError = true
return
}
guard let storedPassword = message.attachmentPassword, !storedPassword.isEmpty else {
+ #if DEBUG
+ print("📸 DOWNLOAD FAIL: nil/empty attachmentPassword for msgId=\(message.id.prefix(8))… attId=\(attachment.id)")
+ #endif
downloadError = true
return
}
@@ -266,12 +272,24 @@ struct MessageImageView: View {
downloadError = false
let server = attachment.transportServer
+ #if DEBUG
+ print("📸 DOWNLOAD START: tag=\(tag) server=\(server) storedPwd=\(storedPassword.prefix(30))… attId=\(attachment.id)")
+ #endif
Task {
do {
let encryptedData = try await TransportManager.shared.downloadFile(tag: tag, server: server)
let encryptedString = String(decoding: encryptedData, as: UTF8.self)
+ #if DEBUG
+ let colonIdx = encryptedString.firstIndex(of: ":")
+ let hasColonSeparator = colonIdx != nil
+ print("📸 DOWNLOADED: \(encryptedData.count) bytes, hasColon=\(hasColonSeparator), first50=\(encryptedString.prefix(50))")
+ #endif
+
let passwords = MessageCrypto.attachmentPasswordCandidates(from: storedPassword)
+ #if DEBUG
+ print("📸 CANDIDATES (\(passwords.count)): \(passwords.map { "\($0.prefix(20))…(\($0.count)ch)" })")
+ #endif
let downloadedImage = decryptAndParseImage(
encryptedString: encryptedString, passwords: passwords
)
@@ -280,12 +298,21 @@ struct MessageImageView: View {
if let downloadedImage {
image = downloadedImage
AttachmentCache.shared.saveImage(downloadedImage, forAttachmentId: attachment.id)
+ #if DEBUG
+ print("📸 DECRYPT OK: attId=\(attachment.id) imageSize=\(downloadedImage.size)")
+ #endif
} else {
+ #if DEBUG
+ print("📸 DECRYPT FAIL: all \(passwords.count) candidates failed for attId=\(attachment.id)")
+ #endif
downloadError = true
}
isDownloading = false
}
} catch {
+ #if DEBUG
+ print("📸 DOWNLOAD ERROR: \(error) for tag=\(tag)")
+ #endif
await MainActor.run {
downloadError = true
isDownloading = false
@@ -296,17 +323,41 @@ struct MessageImageView: View {
private func decryptAndParseImage(encryptedString: String, passwords: [String]) -> UIImage? {
let crypto = CryptoManager.shared
- for password in passwords {
- guard let data = try? crypto.decryptWithPassword(
- encryptedString, password: password, requireCompression: true
- ) else { continue }
- if let img = parseImageData(data) { return img }
+ for (i, password) in passwords.enumerated() {
+ do {
+ let data = try crypto.decryptWithPassword(
+ encryptedString, password: password, requireCompression: true
+ )
+ #if DEBUG
+ print("📸 PASS1 candidate[\(i)] decrypted \(data.count) bytes")
+ #endif
+ if let img = parseImageData(data) { return img }
+ #if DEBUG
+ print("📸 PASS1 candidate[\(i)] parseImageData FAILED (data prefix: \(data.prefix(30).map { String(format: "%02x", $0) }.joined()))")
+ #endif
+ } catch {
+ #if DEBUG
+ print("📸 PASS1 candidate[\(i)] decrypt FAILED: \(error)")
+ #endif
+ }
}
- for password in passwords {
- guard let data = try? crypto.decryptWithPassword(
- encryptedString, password: password
- ) else { continue }
- if let img = parseImageData(data) { return img }
+ for (i, password) in passwords.enumerated() {
+ do {
+ let data = try crypto.decryptWithPassword(
+ encryptedString, password: password
+ )
+ #if DEBUG
+ print("📸 PASS2 candidate[\(i)] decrypted \(data.count) bytes, prefix: \(data.prefix(20).map { String(format: "%02x", $0) }.joined())")
+ #endif
+ if let img = parseImageData(data) { return img }
+ #if DEBUG
+ print("📸 PASS2 candidate[\(i)] parseImageData FAILED")
+ #endif
+ } catch {
+ #if DEBUG
+ print("📸 PASS2 candidate[\(i)] decrypt FAILED: \(error)")
+ #endif
+ }
}
return nil
}
diff --git a/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift b/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift
index 4f75b04..9819a27 100644
--- a/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift
+++ b/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift
@@ -563,12 +563,23 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
}
} else if let fileAtt = message.attachments.first(where: { $0.type == .file }) {
let parsed = AttachmentPreviewCodec.parseFilePreview(fileAtt.preview)
+ let isFileOutgoing = layout.isOutgoing
avatarImageView.isHidden = true
fileIconView.isHidden = false
- fileIconView.backgroundColor = UIColor.white.withAlphaComponent(0.2)
- fileIconSymbolView.image = UIImage(systemName: "doc.fill")
+ // Telegram parity: solid accent circle (incoming) or semi-transparent (outgoing)
+ fileIconView.backgroundColor = isFileOutgoing
+ ? UIColor.white.withAlphaComponent(0.2)
+ : UIColor(red: 0, green: 0x8B/255.0, blue: 1.0, alpha: 1)
+ let isCached = AttachmentCache.shared.fileURL(
+ forAttachmentId: fileAtt.id, fileName: parsed.fileName
+ ) != nil
+ let iconName = isCached ? Self.fileIcon(for: parsed.fileName) : "arrow.down"
+ fileIconSymbolView.image = UIImage(systemName: iconName)
+ fileIconSymbolView.tintColor = .white
fileNameLabel.font = Self.fileNameFont
fileNameLabel.text = parsed.fileName
+ // Telegram parity: accent blue filename (incoming) or white (outgoing)
+ fileNameLabel.textColor = isFileOutgoing ? .white : UIColor(red: 0, green: 0x8B/255.0, blue: 1.0, alpha: 1)
fileSizeLabel.text = Self.formattedFileSize(bytes: parsed.fileSize)
fileSizeLabel.textColor = UIColor.white.withAlphaComponent(0.6)
callArrowView.isHidden = true
@@ -891,6 +902,19 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
return String(format: "%d:%02d", minutes, secs)
}
+ /// Telegram parity: file-type-specific icon name (same mapping as MessageFileView.swift).
+ private static func fileIcon(for fileName: String) -> String {
+ let ext = (fileName as NSString).pathExtension.lowercased()
+ switch ext {
+ case "pdf": return "doc.fill"
+ case "zip", "rar", "7z": return "doc.zipper"
+ case "jpg", "jpeg", "png", "gif": return "photo.fill"
+ case "mp4", "mov", "avi": return "film.fill"
+ case "mp3", "wav", "aac": return "waveform"
+ default: return "doc.fill"
+ }
+ }
+
private static func formattedFileSize(bytes: Int) -> String {
if bytes < 1024 { return "\(bytes) B" }
if bytes < 1024 * 1024 { return String(format: "%.1f KB", Double(bytes) / 1024) }
@@ -1103,40 +1127,139 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
}
@objc private func handleAttachmentDownload(_ notif: Notification) {
- guard let id = notif.object as? String,
- let message,
- let avatarAtt = message.attachments.first(where: { $0.type == .avatar }),
- avatarAtt.id == id else { return }
- // Already downloaded?
- if AttachmentCache.shared.cachedImage(forAttachmentId: id) != nil { return }
- // Download from CDN
- let tag = avatarAtt.effectiveDownloadTag
- guard !tag.isEmpty else { return }
- guard let password = message.attachmentPassword, !password.isEmpty else { return }
+ guard let id = notif.object as? String, let message else { return }
- // Show loading state
- fileSizeLabel.text = "Downloading..."
+ // Avatar download
+ if let avatarAtt = message.attachments.first(where: { $0.type == .avatar }),
+ avatarAtt.id == id {
+ if AttachmentCache.shared.cachedImage(forAttachmentId: id) != nil { return }
+ let tag = avatarAtt.effectiveDownloadTag
+ guard !tag.isEmpty else { return }
+ guard let password = message.attachmentPassword, !password.isEmpty else { return }
- let messageId = message.id
- let senderKey = message.fromPublicKey
- let server = avatarAtt.transportServer
- Task.detached(priority: .userInitiated) {
- let downloaded = await Self.downloadAndCacheAvatar(
- tag: tag, attachmentId: id,
- storedPassword: password, senderKey: senderKey,
- server: server
- )
- await MainActor.run { [weak self] in
- guard let self, self.message?.id == messageId else { return }
- if let downloaded {
- self.avatarImageView.image = downloaded
- self.avatarImageView.isHidden = false
- self.fileIconView.isHidden = true
- self.fileSizeLabel.text = "Shared profile photo"
- } else {
- self.fileSizeLabel.text = "Tap to retry"
+ fileSizeLabel.text = "Downloading..."
+ let messageId = message.id
+ let senderKey = message.fromPublicKey
+ let server = avatarAtt.transportServer
+ Task.detached(priority: .userInitiated) {
+ let downloaded = await Self.downloadAndCacheAvatar(
+ tag: tag, attachmentId: id,
+ storedPassword: password, senderKey: senderKey,
+ server: server
+ )
+ await MainActor.run { [weak self] in
+ guard let self, self.message?.id == messageId else { return }
+ if let downloaded {
+ self.avatarImageView.image = downloaded
+ self.avatarImageView.isHidden = false
+ self.fileIconView.isHidden = true
+ self.fileSizeLabel.text = "Shared profile photo"
+ } else {
+ self.fileSizeLabel.text = "Tap to retry"
+ }
}
}
+ return
+ }
+
+ // File download (desktop parity: MessageFile.tsx download flow)
+ if let fileAtt = message.attachments.first(where: { $0.type == .file }),
+ fileAtt.id == id {
+ let parsed = AttachmentPreviewCodec.parseFilePreview(fileAtt.preview)
+ let fileName = parsed.fileName.isEmpty ? "file" : parsed.fileName
+
+ // Already cached? → share
+ if let url = AttachmentCache.shared.fileURL(forAttachmentId: id, fileName: fileName) {
+ shareFile(url)
+ return
+ }
+
+ let tag = fileAtt.effectiveDownloadTag
+ guard !tag.isEmpty else { return }
+ guard let storedPassword = message.attachmentPassword, !storedPassword.isEmpty else {
+ fileSizeLabel.text = "File expired"
+ return
+ }
+
+ fileSizeLabel.text = "Downloading..."
+ let messageId = message.id
+ let attId = fileAtt.id
+ let server = fileAtt.transportServer
+ Task.detached(priority: .userInitiated) {
+ do {
+ let encryptedData = try await TransportManager.shared.downloadFile(tag: tag, server: server)
+ let encryptedString = String(decoding: encryptedData, as: UTF8.self)
+ let passwords = MessageCrypto.attachmentPasswordCandidates(from: storedPassword)
+
+ // Try with compression first, then without (legacy)
+ var decrypted: Data?
+ for pw in passwords {
+ if let d = try? CryptoManager.shared.decryptWithPassword(encryptedString, password: pw, requireCompression: true) {
+ decrypted = d; break
+ }
+ }
+ if decrypted == nil {
+ for pw in passwords {
+ if let d = try? CryptoManager.shared.decryptWithPassword(encryptedString, password: pw) {
+ decrypted = d; break
+ }
+ }
+ }
+ guard let decrypted else { throw TransportError.invalidResponse }
+
+ // Parse data URI if present
+ let fileData: Data
+ if let str = String(data: decrypted, encoding: .utf8),
+ str.hasPrefix("data:"), let comma = str.firstIndex(of: ",") {
+ let b64 = String(str[str.index(after: comma)...])
+ fileData = Data(base64Encoded: b64) ?? decrypted
+ } else {
+ fileData = decrypted
+ }
+
+ let url = AttachmentCache.shared.saveFile(fileData, forAttachmentId: attId, fileName: fileName)
+ await MainActor.run { [weak self] in
+ guard let self, self.message?.id == messageId else { return }
+ self.fileSizeLabel.text = Self.formattedFileSize(bytes: fileData.count)
+ self.fileIconSymbolView.image = UIImage(systemName: "doc.fill")
+ self.shareFile(url)
+ }
+ } catch {
+ await MainActor.run { [weak self] in
+ guard let self, self.message?.id == messageId else { return }
+ self.fileSizeLabel.text = "File expired"
+ }
+ }
+ }
+ return
+ }
+ }
+
+ private func shareFile(_ cachedURL: URL) {
+ guard let message else { return }
+ // Files are stored encrypted (.enc) on disk. Decrypt to temp dir for sharing.
+ guard let fileAtt = message.attachments.first(where: { $0.type == .file }) else { return }
+ let parsed = AttachmentPreviewCodec.parseFilePreview(fileAtt.preview)
+ let fileName = parsed.fileName.isEmpty ? "file" : parsed.fileName
+
+ guard let data = AttachmentCache.shared.loadFileData(forAttachmentId: fileAtt.id, fileName: fileName) else {
+ fileSizeLabel.text = "File expired"
+ return
+ }
+ let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(fileName)
+ try? data.write(to: tempURL, options: .atomic)
+
+ let activityVC = UIActivityViewController(activityItems: [tempURL], applicationActivities: nil)
+ if let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
+ let window = scene.windows.first,
+ let rootVC = window.rootViewController {
+ var topVC = rootVC
+ while let presented = topVC.presentedViewController { topVC = presented }
+ if let popover = activityVC.popoverPresentationController {
+ popover.sourceView = topVC.view
+ popover.sourceRect = CGRect(x: topVC.view.bounds.midX, y: topVC.view.bounds.midY, width: 0, height: 0)
+ }
+ topVC.present(activityVC, animated: true)
}
}
diff --git a/Rosetta/RosettaApp.swift b/Rosetta/RosettaApp.swift
index 802e3e9..299899e 100644
--- a/Rosetta/RosettaApp.swift
+++ b/Rosetta/RosettaApp.swift
@@ -94,6 +94,19 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent
return
}
+ // MARK: type=call — incoming call wake-up (high priority, no badge).
+ // Server sends this when someone calls and the recipient's WebSocket is not connected.
+ // In foreground: skip (CallManager handles calls via WebSocket protocol).
+ // In background: show notification so user can tap to open app and receive the call.
+ if pushType == "call" {
+ guard application.applicationState != .active else {
+ completionHandler(.noData)
+ return
+ }
+ handleCallPush(userInfo: userInfo, completionHandler: completionHandler)
+ return
+ }
+
// For message notifications, skip if foreground (WebSocket handles real-time).
guard application.applicationState != .active else {
completionHandler(.noData)
@@ -252,6 +265,47 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent
}
}
+ // MARK: - Call Push Handler
+
+ /// Handles `type=call` push: shows incoming call notification when app is backgrounded.
+ /// Server sends this as a wake-up when the recipient's WebSocket is not connected.
+ /// No badge increment (calls don't affect unread count).
+ /// No dedup (calls are urgent — always show notification).
+ /// No mute check (Android parity: calls bypass mute).
+ private func handleCallPush(
+ userInfo: [AnyHashable: Any],
+ completionHandler: @escaping (UIBackgroundFetchResult) -> Void
+ ) {
+ let callerKey = userInfo["from"] as? String ?? Self.extractSenderKey(from: userInfo)
+ guard !callerKey.isEmpty else {
+ completionHandler(.noData)
+ return
+ }
+
+ let shared = UserDefaults(suiteName: "group.com.rosetta.dev")
+ let contactNames = shared?.dictionary(forKey: "contact_display_names") as? [String: String] ?? [:]
+ let callerName = contactNames[callerKey]
+ ?? Self.firstNonBlank(userInfo, keys: ["sender_name", "from_title", "sender", "title", "name"])
+ ?? "Rosetta"
+
+ let content = UNMutableNotificationContent()
+ content.title = callerName
+ content.body = "Incoming call"
+ content.sound = .default
+ content.categoryIdentifier = "call"
+ content.userInfo = ["sender_public_key": callerKey, "sender_name": callerName, "type": "call"]
+ content.interruptionLevel = .timeSensitive
+
+ let request = UNNotificationRequest(
+ identifier: "call_\(callerKey)_\(Int(Date().timeIntervalSince1970))",
+ content: content,
+ trigger: nil
+ )
+ UNUserNotificationCenter.current().add(request) { _ in
+ completionHandler(.newData)
+ }
+ }
+
// MARK: - Push Payload Helpers (Android parity)
/// Android parity: extract sender public key from multiple possible key names.
@@ -407,6 +461,9 @@ struct RosettaApp: App {
appState = initialState()
}
}
+ .onOpenURL { url in
+ handleDeepLink(url)
+ }
}
}
@@ -467,6 +524,13 @@ struct RosettaApp: App {
/// Fade-through-black transition: overlay fades in → swap content → overlay fades out.
/// Avoids UIKit-hosted views (Lottie, UIPageViewController) fighting SwiftUI transitions.
+ private func handleDeepLink(_ url: URL) {
+ guard url.scheme == "rosetta" else { return }
+ if url.host == "call" && url.path == "/end" {
+ CallManager.shared.endCall()
+ }
+ }
+
private func fadeTransition(to newState: AppState) {
guard !transitionOverlay else { return }
transitionOverlay = true
diff --git a/RosettaLiveActivityWidget/CallLiveActivity.swift b/RosettaLiveActivityWidget/CallLiveActivity.swift
index 192dd7f..8055572 100644
--- a/RosettaLiveActivityWidget/CallLiveActivity.swift
+++ b/RosettaLiveActivityWidget/CallLiveActivity.swift
@@ -66,13 +66,15 @@ struct CallLiveActivity: Widget {
}
}
Spacer()
- ZStack {
- Circle().fill(Color.red)
- Image(systemName: "phone.down.fill")
- .font(.system(size: 15, weight: .semibold))
- .foregroundColor(.white)
+ Link(destination: URL(string: "rosetta://call/end")!) {
+ ZStack {
+ Circle().fill(Color.red)
+ Image(systemName: "phone.down.fill")
+ .font(.system(size: 15, weight: .semibold))
+ .foregroundColor(.white)
+ }
+ .frame(width: 40, height: 40)
}
- .frame(width: 40, height: 40)
}
.padding(.horizontal, 20)
.padding(.vertical, 14)
diff --git a/RosettaNotificationService/NotificationService.swift b/RosettaNotificationService/NotificationService.swift
index 84895d0..38330f8 100644
--- a/RosettaNotificationService/NotificationService.swift
+++ b/RosettaNotificationService/NotificationService.swift
@@ -79,6 +79,38 @@ final class NotificationService: UNNotificationServiceExtension {
return
}
+ // MARK: type=call — incoming call notification (no badge increment).
+ // Server sends this when someone calls and the recipient's WebSocket is not connected.
+ // NSE adds sound for vibration and caller name; no badge (calls don't affect unread).
+ if pushType == "call" {
+ content.sound = .default
+ content.categoryIdentifier = "call"
+
+ let callerKey = content.userInfo["from"] as? String
+ ?? Self.extractSenderKey(from: content.userInfo)
+ let contactNames = shared?.dictionary(forKey: "contact_display_names") as? [String: String] ?? [:]
+ let callerName = contactNames[callerKey]
+ ?? Self.firstNonBlank(content.userInfo, keys: Self.senderNameKeyNames)
+
+ if let callerName, !callerName.isEmpty, content.title.isEmpty {
+ content.title = callerName
+ }
+ if content.body.isEmpty {
+ content.body = "Incoming call"
+ }
+
+ var updatedInfo = content.userInfo
+ if !callerKey.isEmpty {
+ updatedInfo["sender_public_key"] = callerKey
+ updatedInfo["type"] = "call"
+ }
+ content.userInfo = updatedInfo
+ content.interruptionLevel = .timeSensitive
+
+ contentHandler(content)
+ return
+ }
+
// MARK: Message types (personal_message / group_message)
// 1. Add sound for vibration — server APNs payload has no sound field.
diff --git a/RosettaTests/FileAttachmentTests.swift b/RosettaTests/FileAttachmentTests.swift
new file mode 100644
index 0000000..1186dab
--- /dev/null
+++ b/RosettaTests/FileAttachmentTests.swift
@@ -0,0 +1,433 @@
+import XCTest
+@testable import Rosetta
+
+/// Comprehensive tests for file attachment download, caching, preview parsing,
+/// icon mapping, and cross-platform parity.
+final class FileAttachmentTests: XCTestCase {
+
+ // =========================================================================
+ // MARK: - AttachmentPreviewCodec: File Preview Parsing
+ // =========================================================================
+
+ func testParseFilePreview_TagSizeFilename() {
+ let preview = "4e6712a0-31c0-4b0d-b17c-83170709ec02::12345::document.pdf"
+ let parsed = AttachmentPreviewCodec.parseFilePreview(preview)
+ XCTAssertEqual(parsed.downloadTag, "4e6712a0-31c0-4b0d-b17c-83170709ec02")
+ XCTAssertEqual(parsed.fileSize, 12345)
+ XCTAssertEqual(parsed.fileName, "document.pdf")
+ }
+
+ func testParseFilePreview_NoTag() {
+ let preview = "12345::document.pdf"
+ let parsed = AttachmentPreviewCodec.parseFilePreview(preview)
+ XCTAssertEqual(parsed.downloadTag, "")
+ XCTAssertEqual(parsed.fileSize, 12345)
+ XCTAssertEqual(parsed.fileName, "document.pdf")
+ }
+
+ func testParseFilePreview_PlaceholderBeforeUpload() {
+ let preview = "::500::notes.txt"
+ let parsed = AttachmentPreviewCodec.parseFilePreview(preview)
+ XCTAssertEqual(parsed.downloadTag, "")
+ XCTAssertEqual(parsed.fileSize, 500)
+ XCTAssertEqual(parsed.fileName, "notes.txt")
+ }
+
+ func testParseFilePreview_EmptyPreview() {
+ let parsed = AttachmentPreviewCodec.parseFilePreview("")
+ XCTAssertEqual(parsed.downloadTag, "")
+ XCTAssertEqual(parsed.fileName, "file") // fallback
+ XCTAssertEqual(parsed.fileSize, 0)
+ }
+
+ func testParseFilePreview_CustomFallbacks() {
+ let parsed = AttachmentPreviewCodec.parseFilePreview(
+ "", fallbackFileName: "unknown.bin", fallbackFileSize: 999
+ )
+ XCTAssertEqual(parsed.fileName, "unknown.bin")
+ XCTAssertEqual(parsed.fileSize, 999)
+ }
+
+ func testParseFilePreview_FilenameWithColons() {
+ // Edge case: filename containing "::" (unlikely but possible)
+ let preview = "4e6712a0-31c0-4b0d-b17c-83170709ec02::1024::file::with::colons.txt"
+ let parsed = AttachmentPreviewCodec.parseFilePreview(preview)
+ XCTAssertEqual(parsed.downloadTag, "4e6712a0-31c0-4b0d-b17c-83170709ec02")
+ XCTAssertEqual(parsed.fileSize, 1024)
+ XCTAssertEqual(parsed.fileName, "file::with::colons.txt")
+ }
+
+ // =========================================================================
+ // MARK: - AttachmentPreviewCodec: Download Tag Extraction
+ // =========================================================================
+
+ func testDownloadTag_ValidUUID() {
+ let preview = "4e6712a0-31c0-4b0d-b17c-83170709ec02::blurhash"
+ XCTAssertEqual(
+ AttachmentPreviewCodec.downloadTag(from: preview),
+ "4e6712a0-31c0-4b0d-b17c-83170709ec02"
+ )
+ }
+
+ func testDownloadTag_NoTag() {
+ let preview = "::blurhash"
+ XCTAssertEqual(AttachmentPreviewCodec.downloadTag(from: preview), "")
+ }
+
+ func testDownloadTag_EmptyPreview() {
+ XCTAssertEqual(AttachmentPreviewCodec.downloadTag(from: ""), "")
+ }
+
+ func testDownloadTag_JustBlurhash() {
+ let preview = "LVRv{GtRSXWB"
+ XCTAssertEqual(AttachmentPreviewCodec.downloadTag(from: preview), "")
+ }
+
+ // =========================================================================
+ // MARK: - AttachmentPreviewCodec: BlurHash Extraction
+ // =========================================================================
+
+ func testBlurHash_TagAndHash() {
+ let preview = "4e6712a0-31c0-4b0d-b17c-83170709ec02::LVRv{GtR"
+ XCTAssertEqual(AttachmentPreviewCodec.blurHash(from: preview), "LVRv{GtR")
+ }
+
+ func testBlurHash_WithDimensionSuffix() {
+ let preview = "4e6712a0-31c0-4b0d-b17c-83170709ec02::LVRv{GtR|640x480"
+ XCTAssertEqual(AttachmentPreviewCodec.blurHash(from: preview), "LVRv{GtR")
+ }
+
+ func testBlurHash_PlainHash() {
+ let preview = "LVRv{GtRSXWB"
+ XCTAssertEqual(AttachmentPreviewCodec.blurHash(from: preview), "LVRv{GtRSXWB")
+ }
+
+ func testBlurHash_PlaceholderFormat() {
+ let preview = "::LVRv{GtR"
+ XCTAssertEqual(AttachmentPreviewCodec.blurHash(from: preview), "LVRv{GtR")
+ }
+
+ // =========================================================================
+ // MARK: - AttachmentPreviewCodec: Image Dimensions
+ // =========================================================================
+
+ func testImageDimensions_Present() {
+ let preview = "tag::LVRv{GtR|640x480"
+ let dims = AttachmentPreviewCodec.imageDimensions(from: preview)
+ XCTAssertEqual(dims?.width, 640)
+ XCTAssertEqual(dims?.height, 480)
+ }
+
+ func testImageDimensions_Missing() {
+ let preview = "tag::LVRv{GtR"
+ XCTAssertNil(AttachmentPreviewCodec.imageDimensions(from: preview))
+ }
+
+ func testImageDimensions_TooSmall() {
+ let preview = "tag::LVRv{GtR|5x5"
+ XCTAssertNil(AttachmentPreviewCodec.imageDimensions(from: preview))
+ }
+
+ // =========================================================================
+ // MARK: - AttachmentPreviewCodec: Call Duration
+ // =========================================================================
+
+ func testCallDuration_PlainNumber() {
+ XCTAssertEqual(AttachmentPreviewCodec.parseCallDurationSeconds("45"), 45)
+ }
+
+ func testCallDuration_Zero() {
+ XCTAssertEqual(AttachmentPreviewCodec.parseCallDurationSeconds("0"), 0)
+ }
+
+ func testCallDuration_Empty() {
+ XCTAssertEqual(AttachmentPreviewCodec.parseCallDurationSeconds(""), 0)
+ }
+
+ func testCallDuration_WithTag() {
+ let preview = "4e6712a0-31c0-4b0d-b17c-83170709ec02::60"
+ XCTAssertEqual(AttachmentPreviewCodec.parseCallDurationSeconds(preview), 60)
+ }
+
+ // =========================================================================
+ // MARK: - AttachmentPreviewCodec: Compose
+ // =========================================================================
+
+ func testCompose_TagAndPayload() {
+ let result = AttachmentPreviewCodec.compose(
+ downloadTag: "4e6712a0-31c0-4b0d-b17c-83170709ec02",
+ payload: "LVRv{GtR"
+ )
+ XCTAssertEqual(result, "4e6712a0-31c0-4b0d-b17c-83170709ec02::LVRv{GtR")
+ }
+
+ func testCompose_EmptyTag() {
+ XCTAssertEqual(AttachmentPreviewCodec.compose(downloadTag: "", payload: "LVRv"), "LVRv")
+ }
+
+ // =========================================================================
+ // MARK: - MessageAttachment: effectiveDownloadTag
+ // =========================================================================
+
+ func testEffectiveTag_TransportTagPresent() {
+ let att = MessageAttachment(
+ id: "test", preview: "old-tag::hash", transportTag: "new-tag"
+ )
+ XCTAssertEqual(att.effectiveDownloadTag, "new-tag")
+ }
+
+ func testEffectiveTag_FallbackToPreview() {
+ let att = MessageAttachment(
+ id: "test",
+ preview: "4e6712a0-31c0-4b0d-b17c-83170709ec02::hash"
+ )
+ XCTAssertEqual(att.effectiveDownloadTag, "4e6712a0-31c0-4b0d-b17c-83170709ec02")
+ }
+
+ func testEffectiveTag_BothEmpty() {
+ let att = MessageAttachment(id: "test", preview: "::hash")
+ XCTAssertEqual(att.effectiveDownloadTag, "")
+ }
+
+ // =========================================================================
+ // MARK: - File Icon Mapping (Telegram parity)
+ // =========================================================================
+
+ /// Tests the file icon mapping used by MessageFileView (SwiftUI) and
+ /// NativeMessageCell.fileIcon(for:) (UIKit). Both must return same values.
+ private func fileIcon(for fileName: String) -> String {
+ let ext = (fileName as NSString).pathExtension.lowercased()
+ switch ext {
+ case "pdf": return "doc.fill"
+ case "zip", "rar", "7z": return "doc.zipper"
+ case "jpg", "jpeg", "png", "gif": return "photo.fill"
+ case "mp4", "mov", "avi": return "film.fill"
+ case "mp3", "wav", "aac": return "waveform"
+ default: return "doc.fill"
+ }
+ }
+
+ func testFileIcon_PDF() {
+ XCTAssertEqual(fileIcon(for: "report.pdf"), "doc.fill")
+ }
+
+ func testFileIcon_ZIP() {
+ XCTAssertEqual(fileIcon(for: "archive.zip"), "doc.zipper")
+ }
+
+ func testFileIcon_RAR() {
+ XCTAssertEqual(fileIcon(for: "data.rar"), "doc.zipper")
+ }
+
+ func testFileIcon_7Z() {
+ XCTAssertEqual(fileIcon(for: "backup.7z"), "doc.zipper")
+ }
+
+ func testFileIcon_JPG() {
+ XCTAssertEqual(fileIcon(for: "photo.jpg"), "photo.fill")
+ }
+
+ func testFileIcon_JPEG() {
+ XCTAssertEqual(fileIcon(for: "photo.jpeg"), "photo.fill")
+ }
+
+ func testFileIcon_PNG() {
+ XCTAssertEqual(fileIcon(for: "screenshot.png"), "photo.fill")
+ }
+
+ func testFileIcon_GIF() {
+ XCTAssertEqual(fileIcon(for: "animation.gif"), "photo.fill")
+ }
+
+ func testFileIcon_MP4() {
+ XCTAssertEqual(fileIcon(for: "video.mp4"), "film.fill")
+ }
+
+ func testFileIcon_MOV() {
+ XCTAssertEqual(fileIcon(for: "clip.mov"), "film.fill")
+ }
+
+ func testFileIcon_AVI() {
+ XCTAssertEqual(fileIcon(for: "movie.avi"), "film.fill")
+ }
+
+ func testFileIcon_MP3() {
+ XCTAssertEqual(fileIcon(for: "song.mp3"), "waveform")
+ }
+
+ func testFileIcon_WAV() {
+ XCTAssertEqual(fileIcon(for: "audio.wav"), "waveform")
+ }
+
+ func testFileIcon_AAC() {
+ XCTAssertEqual(fileIcon(for: "voice.aac"), "waveform")
+ }
+
+ func testFileIcon_Unknown() {
+ XCTAssertEqual(fileIcon(for: "data.bin"), "doc.fill")
+ }
+
+ func testFileIcon_NoExtension() {
+ XCTAssertEqual(fileIcon(for: "Makefile"), "doc.fill")
+ }
+
+ func testFileIcon_UpperCase() {
+ XCTAssertEqual(fileIcon(for: "IMAGE.PNG"), "photo.fill")
+ }
+
+ // =========================================================================
+ // MARK: - AttachmentCache: File Save/Load Round-Trip
+ // =========================================================================
+
+ func testFileSaveAndLoad_Plaintext() {
+ // Without private key, files are saved as plaintext
+ let cache = AttachmentCache.shared
+ let originalKey = cache.privateKey
+ cache.privateKey = nil
+ defer { cache.privateKey = originalKey }
+
+ let testData = "Hello, world!".data(using: .utf8)!
+ let attId = "test_plain_\(UUID().uuidString.prefix(8))"
+ let fileName = "test.txt"
+
+ let url = cache.saveFile(testData, forAttachmentId: attId, fileName: fileName)
+ XCTAssertTrue(FileManager.default.fileExists(atPath: url.path))
+
+ // fileURL should find it
+ let foundURL = cache.fileURL(forAttachmentId: attId, fileName: fileName)
+ XCTAssertNotNil(foundURL)
+
+ // loadFileData should return original data
+ let loaded = cache.loadFileData(forAttachmentId: attId, fileName: fileName)
+ XCTAssertEqual(loaded, testData)
+
+ // Cleanup
+ try? FileManager.default.removeItem(at: url)
+ }
+
+ func testFileSaveAndLoad_Encrypted() throws {
+ let cache = AttachmentCache.shared
+ let originalKey = cache.privateKey
+
+ // Use a test key
+ cache.privateKey = "test_private_key_for_encryption"
+ defer { cache.privateKey = originalKey }
+
+ let testData = "Encrypted file content 🔒".data(using: .utf8)!
+ let attId = "test_enc_\(UUID().uuidString.prefix(8))"
+ let fileName = "secret.pdf"
+
+ let url = cache.saveFile(testData, forAttachmentId: attId, fileName: fileName)
+ XCTAssertTrue(FileManager.default.fileExists(atPath: url.path))
+ XCTAssertTrue(url.lastPathComponent.hasSuffix(".enc"))
+
+ // Raw file should NOT be the original data (it's encrypted)
+ let rawData = try Data(contentsOf: url)
+ XCTAssertNotEqual(rawData, testData)
+
+ // loadFileData should decrypt and return original
+ let loaded = cache.loadFileData(forAttachmentId: attId, fileName: fileName)
+ XCTAssertEqual(loaded, testData)
+
+ // Cleanup
+ try? FileManager.default.removeItem(at: url)
+ }
+
+ func testFileURL_NotCached() {
+ let url = AttachmentCache.shared.fileURL(
+ forAttachmentId: "nonexistent_\(UUID())", fileName: "nope.txt"
+ )
+ XCTAssertNil(url)
+ }
+
+ func testLoadFileData_NotCached() {
+ let data = AttachmentCache.shared.loadFileData(
+ forAttachmentId: "nonexistent_\(UUID())", fileName: "nope.txt"
+ )
+ XCTAssertNil(data)
+ }
+
+ func testFileSave_SlashInFilename() {
+ let cache = AttachmentCache.shared
+ let originalKey = cache.privateKey
+ cache.privateKey = nil
+ defer { cache.privateKey = originalKey }
+
+ let testData = "data".data(using: .utf8)!
+ let attId = "test_slash_\(UUID().uuidString.prefix(8))"
+ // Slash should be replaced with underscore
+ let fileName = "path/to/file.txt"
+
+ let url = cache.saveFile(testData, forAttachmentId: attId, fileName: fileName)
+ XCTAssertFalse(url.lastPathComponent.contains("/"))
+
+ let loaded = cache.loadFileData(forAttachmentId: attId, fileName: fileName)
+ XCTAssertEqual(loaded, testData)
+
+ try? FileManager.default.removeItem(at: url)
+ }
+
+ // =========================================================================
+ // MARK: - AttachmentType JSON Encode/Decode (cross-platform parity)
+ // =========================================================================
+
+ func testAttachmentType_EncodeValues() throws {
+ // Verify wire format matches Desktop/Android (0,1,2,3,4)
+ let encoder = JSONEncoder()
+ let types: [AttachmentType] = [.image, .messages, .file, .avatar, .call]
+ for (idx, type) in types.enumerated() {
+ let data = try encoder.encode(type)
+ let str = String(data: data, encoding: .utf8)!
+ XCTAssertEqual(str, "\(idx)", "AttachmentType.\(type) should encode as \(idx)")
+ }
+ }
+
+ func testMessageAttachment_BackwardCompatDecode() throws {
+ // Old DB JSON without transportTag/transportServer should decode fine
+ let json = #"{"id":"abc","preview":"tag::hash","blob":"data","type":0}"#
+ let att = try JSONDecoder().decode(MessageAttachment.self, from: Data(json.utf8))
+ XCTAssertEqual(att.id, "abc")
+ XCTAssertEqual(att.type, .image)
+ XCTAssertEqual(att.transportTag, "") // default
+ XCTAssertEqual(att.transportServer, "") // default
+ }
+
+ func testMessageAttachment_NewFieldsDecode() throws {
+ let json = #"{"id":"abc","preview":"hash","blob":"","type":3,"transportTag":"cdn-tag","transportServer":"https://cdn.example.com"}"#
+ let att = try JSONDecoder().decode(MessageAttachment.self, from: Data(json.utf8))
+ XCTAssertEqual(att.type, .avatar)
+ XCTAssertEqual(att.transportTag, "cdn-tag")
+ XCTAssertEqual(att.transportServer, "https://cdn.example.com")
+ }
+
+ // =========================================================================
+ // MARK: - File Size Formatting
+ // =========================================================================
+
+ private func formattedFileSize(bytes: Int) -> String {
+ if bytes < 1024 { return "\(bytes) B" }
+ if bytes < 1024 * 1024 { return String(format: "%.1f KB", Double(bytes) / 1024) }
+ if bytes < 1024 * 1024 * 1024 { return String(format: "%.1f MB", Double(bytes) / (1024 * 1024)) }
+ return String(format: "%.1f GB", Double(bytes) / (1024 * 1024 * 1024))
+ }
+
+ func testFileSize_Bytes() {
+ XCTAssertEqual(formattedFileSize(bytes: 500), "500 B")
+ }
+
+ func testFileSize_KB() {
+ XCTAssertEqual(formattedFileSize(bytes: 2048), "2.0 KB")
+ }
+
+ func testFileSize_MB() {
+ XCTAssertEqual(formattedFileSize(bytes: 5_242_880), "5.0 MB")
+ }
+
+ func testFileSize_GB() {
+ XCTAssertEqual(formattedFileSize(bytes: 1_073_741_824), "1.0 GB")
+ }
+
+ func testFileSize_Zero() {
+ XCTAssertEqual(formattedFileSize(bytes: 0), "0 B")
+ }
+}