Кроссплатформенный аудит: reply-бар, файлы, аватар, blurhash — 10 фиксов Desktop/Android-parity
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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: "",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user