From 2b25c87a6af374a3ceedaeda177f72a823fdb14e Mon Sep 17 00:00:00 2001 From: senseiGai Date: Mon, 30 Mar 2026 01:21:07 +0500 Subject: [PATCH] =?UTF-8?q?=D0=9A=D1=80=D0=BE=D1=81=D1=81=D0=BF=D0=BB?= =?UTF-8?q?=D0=B0=D1=82=D1=84=D0=BE=D1=80=D0=BC=D0=B5=D0=BD=D0=BD=D1=8B?= =?UTF-8?q?=D0=B9=20=D0=B0=D1=83=D0=B4=D0=B8=D1=82:=20reply-=D0=B1=D0=B0?= =?UTF-8?q?=D1=80,=20=D1=84=D0=B0=D0=B9=D0=BB=D1=8B,=20=D0=B0=D0=B2=D0=B0?= =?UTF-8?q?=D1=82=D0=B0=D1=80,=20blurhash=20=E2=80=94=2010=20=D1=84=D0=B8?= =?UTF-8?q?=D0=BA=D1=81=D0=BE=D0=B2=20Desktop/Android-parity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Info.plist | 11 + Rosetta/Core/Services/CallManager.swift | 55 ++- Rosetta/Core/Services/SessionManager.swift | 24 +- .../Chats/ChatDetail/MessageAvatarView.swift | 70 ++- .../Chats/ChatDetail/MessageFileView.swift | 34 +- .../Chats/ChatDetail/MessageImageView.swift | 71 ++- .../Chats/ChatDetail/NativeMessageCell.swift | 187 ++++++-- Rosetta/RosettaApp.swift | 64 +++ .../CallLiveActivity.swift | 14 +- .../NotificationService.swift | 32 ++ RosettaTests/FileAttachmentTests.swift | 433 ++++++++++++++++++ 11 files changed, 909 insertions(+), 86 deletions(-) create mode 100644 RosettaTests/FileAttachmentTests.swift 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") + } +}