Кроссплатформенный аудит: reply-бар, файлы, аватар, blurhash — 10 фиксов Desktop/Android-parity

This commit is contained in:
2026-03-30 01:21:07 +05:00
parent 406ac421a3
commit 2b25c87a6a
11 changed files with 909 additions and 86 deletions

View File

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

View File

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

View File

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

View File

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