Релиз 1.3.7: новый Stream, транспорт вложений и фиксы совместимости
All checks were successful
Android Kernel Build / build (push) Successful in 19m57s

This commit is contained in:
2026-03-29 23:16:38 +05:00
parent ce6bc985be
commit 89259b2a46
12 changed files with 685 additions and 236 deletions

View File

@@ -23,8 +23,8 @@ val gitShortSha = safeGitOutput("rev-parse", "--short", "HEAD") ?: "unknown"
// ═══════════════════════════════════════════════════════════
// Rosetta versioning — bump here on each release
// ═══════════════════════════════════════════════════════════
val rosettaVersionName = "1.3.6"
val rosettaVersionCode = 38 // Increment on each release
val rosettaVersionName = "1.3.7"
val rosettaVersionCode = 39 // Increment on each release
val customWebRtcAar = file("libs/libwebrtc-custom.aar")
android {

View File

@@ -1327,15 +1327,17 @@ object MessageCrypto {
/**
* Собираем пароль-кандидаты для полной desktop совместимости:
* - full key+nonce (56 bytes) и legacy key-only (32 bytes)
* - hex password (актуальный desktop формат для attachments)
* - Buffer polyfill UTF-8 decode (desktop runtime parity: window.Buffer from "buffer")
* - WHATWG/Node UTF-8 decode
* - JVM UTF-8 / Latin1 fallback
*/
private fun buildAttachmentPasswordCandidates(chachaKeyPlain: ByteArray): List<String> {
val candidates = LinkedHashSet<String>(12)
val candidates = LinkedHashSet<String>(16)
fun addVariants(bytes: ByteArray) {
if (bytes.isEmpty()) return
candidates.add(bytes.joinToString("") { "%02x".format(it) })
candidates.add(bytesToBufferPolyfillUtf8String(bytes))
candidates.add(bytesToJsUtf8String(bytes))
candidates.add(String(bytes, Charsets.UTF_8))

View File

@@ -1695,6 +1695,8 @@ class MessageRepository private constructor(private val context: Context) {
put("preview", attachment.preview)
put("width", attachment.width)
put("height", attachment.height)
put("transportTag", attachment.transportTag)
put("transportServer", attachment.transportServer)
}
jsonArray.put(jsonObj)
}
@@ -1937,6 +1939,8 @@ class MessageRepository private constructor(private val context: Context) {
jsonObj.put("preview", attachment.preview)
jsonObj.put("width", attachment.width)
jsonObj.put("height", attachment.height)
jsonObj.put("transportTag", attachment.transportTag)
jsonObj.put("transportServer", attachment.transportServer)
} else {
// Fallback - пустой blob для IMAGE/FILE
jsonObj.put("id", attachment.id)
@@ -1945,6 +1949,8 @@ class MessageRepository private constructor(private val context: Context) {
jsonObj.put("preview", attachment.preview)
jsonObj.put("width", attachment.width)
jsonObj.put("height", attachment.height)
jsonObj.put("transportTag", attachment.transportTag)
jsonObj.put("transportServer", attachment.transportServer)
}
} catch (e: Exception) {
// Fallback - пустой blob
@@ -1954,6 +1960,8 @@ class MessageRepository private constructor(private val context: Context) {
jsonObj.put("preview", attachment.preview)
jsonObj.put("width", attachment.width)
jsonObj.put("height", attachment.height)
jsonObj.put("transportTag", attachment.transportTag)
jsonObj.put("transportServer", attachment.transportServer)
}
} else {
// Для IMAGE/FILE - НЕ сохраняем blob (пустой)
@@ -1963,6 +1971,8 @@ class MessageRepository private constructor(private val context: Context) {
jsonObj.put("preview", attachment.preview)
jsonObj.put("width", attachment.width)
jsonObj.put("height", attachment.height)
jsonObj.put("transportTag", attachment.transportTag)
jsonObj.put("transportServer", attachment.transportServer)
}
jsonArray.put(jsonObj)

View File

@@ -17,13 +17,15 @@ object ReleaseNotes {
val RELEASE_NOTICE = """
Update v$VERSION_PLACEHOLDER
Hotfix звонков
- Исправлен регресс качества аудио в E2EE звонках после 1.3.5
- Возвращена стабильная схема обработки состояния звонка, как в 1.3.3
- Нативный C++ шифратор (XChaCha20/HSalsa20) оставлен без изменений
Протокол и вложения
- Обновлен Stream под новый серверный формат сериализации
- Добавлена поддержка transportServer/transportTag во вложениях
- Исправлена совместимость шифрования вложений Android -> Desktop
- Улучшена обработка call-аттачментов и рендер карточек звонков
UI
- Сохранено принудительное скрытие клавиатуры на экране звонка
Push-уведомления
- Пуши теперь учитывают mute-чаты корректно
- Заголовок уведомления берет имя отправителя из payload сервера
""".trimIndent()
fun getNotice(version: String): String =

View File

@@ -45,6 +45,7 @@ object FileDownloadManager {
private data class DownloadRequest(
val attachmentId: String,
val downloadTag: String,
val transportServer: String,
val chachaKey: String,
val privateKey: String,
val accountPublicKey: String,
@@ -112,6 +113,7 @@ object FileDownloadManager {
fun download(
attachmentId: String,
downloadTag: String,
transportServer: String = "",
chachaKey: String,
privateKey: String,
accountPublicKey: String,
@@ -121,6 +123,7 @@ object FileDownloadManager {
val request = DownloadRequest(
attachmentId = attachmentId,
downloadTag = downloadTag,
transportServer = transportServer.trim(),
chachaKey = chachaKey,
privateKey = privateKey,
accountPublicKey = accountPublicKey.trim(),
@@ -238,6 +241,7 @@ object FileDownloadManager {
downloadGroupFile(
attachmentId = attachmentId,
downloadTag = request.downloadTag,
transportServer = request.transportServer,
chachaKey = request.chachaKey,
privateKey = request.privateKey,
fileName = request.fileName,
@@ -250,6 +254,7 @@ object FileDownloadManager {
downloadDirectFile(
attachmentId = attachmentId,
downloadTag = request.downloadTag,
transportServer = request.transportServer,
chachaKey = request.chachaKey,
privateKey = request.privateKey,
fileName = request.fileName,
@@ -351,6 +356,7 @@ object FileDownloadManager {
private suspend fun downloadGroupFile(
attachmentId: String,
downloadTag: String,
transportServer: String,
chachaKey: String,
privateKey: String,
fileName: String,
@@ -365,7 +371,8 @@ object FileDownloadManager {
id = attachmentId,
tag = downloadTag,
targetFile = encryptedPartFile,
resumeFromBytes = resumeBytes
resumeFromBytes = resumeBytes,
transportServer = transportServer
)
val encryptedContent = withContext(Dispatchers.IO) {
encryptedFile.readText(Charsets.UTF_8)
@@ -420,6 +427,7 @@ object FileDownloadManager {
private suspend fun downloadDirectFile(
attachmentId: String,
downloadTag: String,
transportServer: String,
chachaKey: String,
privateKey: String,
fileName: String,
@@ -434,7 +442,8 @@ object FileDownloadManager {
id = attachmentId,
tag = downloadTag,
targetFile = encryptedPartFile,
resumeFromBytes = resumeBytes
resumeFromBytes = resumeBytes,
transportServer = transportServer
)
update(
attachmentId,

View File

@@ -31,14 +31,21 @@ class PacketMessage : Packet() {
override fun receive(stream: Stream) {
val startPointer = stream.getReadPointerBits()
val extended = parseFromStream(stream, readExtendedAttachmentMeta = true)
val parsed =
if (extended != null && !stream.hasRemainingBits()) {
extended
} else {
listOf(4, 2, 0)
.asSequence()
.mapNotNull { attachmentMetaFieldCount ->
stream.setReadPointerBits(startPointer)
parseFromStream(stream, readExtendedAttachmentMeta = false)
?: throw IllegalStateException("Failed to parse PacketMessage payload")
parseFromStream(stream, attachmentMetaFieldCount)
?.takeIf { !stream.hasRemainingBits() }
}
.firstOrNull()
?: run {
stream.setReadPointerBits(startPointer)
parseFromStream(stream, 2)
?: throw IllegalStateException(
"Failed to parse PacketMessage payload"
)
}
fromPublicKey = parsed.fromPublicKey
@@ -69,6 +76,8 @@ class PacketMessage : Packet() {
stream.writeString(attachment.preview)
stream.writeString(attachment.blob)
stream.writeInt8(attachment.type.value)
stream.writeString(attachment.transportTag)
stream.writeString(attachment.transportServer)
}
stream.writeString(aesChachaKey)
@@ -77,7 +86,7 @@ class PacketMessage : Packet() {
private fun parseFromStream(
parser: Stream,
readExtendedAttachmentMeta: Boolean
attachmentMetaFieldCount: Int
): ParsedPacketMessage? {
return runCatching {
val parsedFromPublicKey = parser.readString()
@@ -100,14 +109,17 @@ class PacketMessage : Packet() {
val transportServer: String
val encodedFor: String
val encoder: String
if (readExtendedAttachmentMeta) {
if (attachmentMetaFieldCount >= 2) {
transportTag = parser.readString()
transportServer = parser.readString()
encodedFor = parser.readString()
encoder = parser.readString()
} else {
transportTag = ""
transportServer = ""
}
if (attachmentMetaFieldCount >= 4) {
encodedFor = parser.readString()
encoder = parser.readString()
} else {
encodedFor = ""
encoder = ""
}

View File

@@ -1,167 +1,318 @@
package com.rosetta.messenger.network
/**
* Binary stream for protocol packets
* Matches the React Native implementation exactly
* Binary stream for protocol packets.
*
* Parity with desktop/server:
* - signed: Int8/16/32/64 (two's complement)
* - unsigned: UInt8/16/32/64
* - String: length(UInt32) + chars(UInt16)
* - byte[]: length(UInt32) + raw bytes
*/
class Stream(stream: ByteArray = ByteArray(0)) {
private var _stream = mutableListOf<Int>()
private var _readPointer = 0
private var _writePointer = 0
class Stream(initial: ByteArray = byteArrayOf()) {
private var stream: ByteArray = initial.copyOf()
private var readPointer: Int = 0 // bits
private var writePointer: Int = stream.size shl 3 // bits
init {
_stream = stream.map { it.toInt() and 0xFF }.toMutableList()
fun getStream(): ByteArray = stream.copyOf(length())
fun setStream(value: ByteArray) {
stream = value.copyOf()
readPointer = 0
writePointer = stream.size shl 3
}
fun getStream(): ByteArray {
return _stream.map { it.toByte() }.toByteArray()
}
fun getBuffer(): ByteArray = getStream()
fun getReadPointerBits(): Int = _readPointer
fun isEmpty(): Boolean = writePointer == 0
fun length(): Int = (writePointer + 7) shr 3
fun getReadPointerBits(): Int = readPointer
fun setReadPointerBits(bits: Int) {
_readPointer = bits.coerceIn(0, getTotalBits())
readPointer = bits.coerceIn(0, writePointer)
}
fun getTotalBits(): Int = _stream.size * 8
fun getTotalBits(): Int = writePointer
fun getRemainingBits(): Int = getTotalBits() - _readPointer
fun getRemainingBits(): Int = (writePointer - readPointer).coerceAtLeast(0)
fun hasRemainingBits(): Boolean = _readPointer < getTotalBits()
fun setStream(stream: ByteArray) {
_stream = stream.map { it.toInt() and 0xFF }.toMutableList()
_readPointer = 0
}
fun writeInt8(value: Int) {
val negationBit = if (value < 0) 1 else 0
val int8Value = Math.abs(value) and 0xFF
ensureCapacity(_writePointer shr 3)
_stream[_writePointer shr 3] = _stream[_writePointer shr 3] or (negationBit shl (7 - (_writePointer and 7)))
_writePointer++
for (i in 0 until 8) {
val bit = (int8Value shr (7 - i)) and 1
ensureCapacity(_writePointer shr 3)
_stream[_writePointer shr 3] = _stream[_writePointer shr 3] or (bit shl (7 - (_writePointer and 7)))
_writePointer++
}
}
fun readInt8(): Int {
var value = 0
val negationBit = (_stream[_readPointer shr 3] shr (7 - (_readPointer and 7))) and 1
_readPointer++
for (i in 0 until 8) {
val bit = (_stream[_readPointer shr 3] shr (7 - (_readPointer and 7))) and 1
value = value or (bit shl (7 - i))
_readPointer++
}
return if (negationBit == 1) -value else value
}
fun hasRemainingBits(): Boolean = readPointer < writePointer
fun writeBit(value: Int) {
val bit = value and 1
ensureCapacity(_writePointer shr 3)
_stream[_writePointer shr 3] = _stream[_writePointer shr 3] or (bit shl (7 - (_writePointer and 7)))
_writePointer++
writeBits((value and 1).toLong(), 1)
}
fun readBit(): Int {
val bit = (_stream[_readPointer shr 3] shr (7 - (_readPointer and 7))) and 1
_readPointer++
return bit
}
fun readBit(): Int = readBits(1).toInt()
fun writeBoolean(value: Boolean) {
writeBit(if (value) 1 else 0)
}
fun readBoolean(): Boolean {
return readBit() == 1
fun readBoolean(): Boolean = readBit() == 1
fun writeByte(value: Byte) {
writeUInt8(value.toInt() and 0xFF)
}
fun readByte(): Byte = readUInt8().toByte()
fun writeUInt8(value: Int) {
val v = value and 0xFF
// Fast path when byte-aligned.
if ((writePointer and 7) == 0) {
reserveBits(8)
stream[writePointer shr 3] = v.toByte()
writePointer += 8
return
}
writeBits(v.toLong(), 8)
}
fun readUInt8(): Int {
if (remainingBits() < 8L) {
throw IllegalStateException("Not enough bits to read UInt8")
}
// Fast path when byte-aligned.
if ((readPointer and 7) == 0) {
val value = stream[readPointer shr 3].toInt() and 0xFF
readPointer += 8
return value
}
return readBits(8).toInt() and 0xFF
}
fun writeInt8(value: Int) {
writeUInt8(value)
}
fun readInt8(): Int = readUInt8().toByte().toInt()
fun writeUInt16(value: Int) {
val v = value and 0xFFFF
writeUInt8((v ushr 8) and 0xFF)
writeUInt8(v and 0xFF)
}
fun readUInt16(): Int {
val hi = readUInt8()
val lo = readUInt8()
return (hi shl 8) or lo
}
fun writeInt16(value: Int) {
writeInt8(value shr 8)
writeInt8(value and 0xFF)
writeUInt16(value)
}
fun readInt16(): Int {
val high = readInt8() shl 8
return high or readInt8()
fun readInt16(): Int = readUInt16().toShort().toInt()
fun writeUInt32(value: Long) {
if (value < 0L || value > 0xFFFF_FFFFL) {
throw IllegalArgumentException("UInt32 out of range: $value")
}
writeUInt8(((value ushr 24) and 0xFF).toInt())
writeUInt8(((value ushr 16) and 0xFF).toInt())
writeUInt8(((value ushr 8) and 0xFF).toInt())
writeUInt8((value and 0xFF).toInt())
}
fun readUInt32(): Long {
val b1 = readUInt8().toLong() and 0xFFL
val b2 = readUInt8().toLong() and 0xFFL
val b3 = readUInt8().toLong() and 0xFFL
val b4 = readUInt8().toLong() and 0xFFL
return (b1 shl 24) or (b2 shl 16) or (b3 shl 8) or b4
}
fun writeInt32(value: Int) {
writeInt16(value shr 16)
writeInt16(value and 0xFFFF)
writeUInt32(value.toLong() and 0xFFFF_FFFFL)
}
fun readInt32(): Int {
val high = readInt16() shl 16
return high or readInt16()
fun readInt32(): Int = readUInt32().toInt()
/** Writes raw 64-bit pattern (UInt64 bit-pattern in Long). */
fun writeUInt64(value: Long) {
writeUInt8(((value ushr 56) and 0xFF).toInt())
writeUInt8(((value ushr 48) and 0xFF).toInt())
writeUInt8(((value ushr 40) and 0xFF).toInt())
writeUInt8(((value ushr 32) and 0xFF).toInt())
writeUInt8(((value ushr 24) and 0xFF).toInt())
writeUInt8(((value ushr 16) and 0xFF).toInt())
writeUInt8(((value ushr 8) and 0xFF).toInt())
writeUInt8((value and 0xFF).toInt())
}
fun writeInt64(value: Long) {
val high = (value shr 32).toInt()
val low = (value and 0xFFFFFFFF).toInt()
writeInt32(high)
writeInt32(low)
}
fun readInt64(): Long {
val high = readInt32().toLong()
val low = (readInt32().toLong() and 0xFFFFFFFFL)
/** Reads raw 64-bit pattern (UInt64 bit-pattern in Long). */
fun readUInt64(): Long {
val high = readUInt32() and 0xFFFF_FFFFL
val low = readUInt32() and 0xFFFF_FFFFL
return (high shl 32) or low
}
fun writeInt64(value: Long) {
writeUInt64(value)
}
fun readInt64(): Long = readUInt64()
fun writeFloat32(value: Float) {
writeInt32(java.lang.Float.floatToIntBits(value))
}
fun readFloat32(): Float = java.lang.Float.intBitsToFloat(readInt32())
/** String: length(UInt32) + chars(UInt16). */
fun writeString(value: String) {
writeInt32(value.length)
for (char in value) {
writeInt16(char.code)
writeUInt32(value.length.toLong())
if (value.isEmpty()) return
reserveBits(value.length.toLong() * 16L)
for (i in value.indices) {
writeUInt16(value[i].code and 0xFFFF)
}
}
fun readString(): String {
val length = readInt32()
// Desktop parity + safety: don't trust malformed string length.
val bytesAvailable = _stream.size - (_readPointer shr 3)
if (length < 0 || (length.toLong() * 2L) > bytesAvailable.toLong()) {
android.util.Log.w(
"RosettaStream",
"readString invalid length=$length, bytesAvailable=$bytesAvailable, readPointer=$_readPointer"
)
return ""
val lenLong = readUInt32()
if (lenLong > Int.MAX_VALUE.toLong()) {
throw IllegalStateException("String length too large: $lenLong")
}
val sb = StringBuilder()
for (i in 0 until length) {
sb.append(readInt16().toChar())
val length = lenLong.toInt()
val requiredBits = length.toLong() * 16L
if (requiredBits > remainingBits()) {
throw IllegalStateException("Not enough bits to read string")
}
val sb = StringBuilder(length)
repeat(length) {
sb.append(readUInt16().toChar())
}
return sb.toString()
}
/** byte[]: length(UInt32) + payload. */
fun writeBytes(value: ByteArray) {
writeInt32(value.size)
for (byte in value) {
writeInt8(byte.toInt())
writeUInt32(value.size.toLong())
if (value.isEmpty()) return
reserveBits(value.size.toLong() * 8L)
// Fast path when byte-aligned.
if ((writePointer and 7) == 0) {
val byteIndex = writePointer shr 3
ensureCapacity(byteIndex + value.size - 1)
System.arraycopy(value, 0, stream, byteIndex, value.size)
writePointer += value.size shl 3
return
}
value.forEach { writeUInt8(it.toInt() and 0xFF) }
}
fun readBytes(): ByteArray {
val length = readInt32()
val bytes = ByteArray(length)
for (i in 0 until length) {
bytes[i] = readInt8().toByte()
}
return bytes
val lenLong = readUInt32()
if (lenLong == 0L) return byteArrayOf()
if (lenLong > Int.MAX_VALUE.toLong()) {
throw IllegalStateException("Byte array too large: $lenLong")
}
private fun ensureCapacity(index: Int) {
while (_stream.size <= index) {
_stream.add(0)
val length = lenLong.toInt()
val requiredBits = length.toLong() * 8L
if (requiredBits > remainingBits()) {
return byteArrayOf()
}
val out = ByteArray(length)
// Fast path when byte-aligned.
if ((readPointer and 7) == 0) {
val byteIndex = readPointer shr 3
System.arraycopy(stream, byteIndex, out, 0, length)
readPointer += length shl 3
return out
}
for (i in 0 until length) {
out[i] = readUInt8().toByte()
}
return out
}
private fun remainingBits(): Long = (writePointer - readPointer).toLong()
private fun writeBits(value: Long, bits: Int) {
if (bits <= 0) return
reserveBits(bits.toLong())
for (i in bits - 1 downTo 0) {
val bit = ((value ushr i) and 1L).toInt()
val byteIndex = writePointer shr 3
val shift = 7 - (writePointer and 7)
stream[byteIndex] =
if (bit == 1) {
(stream[byteIndex].toInt() or (1 shl shift)).toByte()
} else {
(stream[byteIndex].toInt() and (1 shl shift).inv()).toByte()
}
writePointer++
}
}
private fun readBits(bits: Int): Long {
if (bits <= 0) return 0L
if (remainingBits() < bits.toLong()) {
throw IllegalStateException("Not enough bits to read")
}
var value = 0L
repeat(bits) {
val bit =
(stream[readPointer shr 3].toInt() ushr
(7 - (readPointer and 7))) and
1
value = (value shl 1) or bit.toLong()
readPointer++
}
return value
}
private fun reserveBits(bitsToWrite: Long) {
if (bitsToWrite <= 0L) return
val lastBitIndex = writePointer.toLong() + bitsToWrite - 1L
if (lastBitIndex < 0L) {
throw IllegalStateException("Bit index overflow")
}
val byteIndex = lastBitIndex ushr 3
if (byteIndex > Int.MAX_VALUE.toLong()) {
throw IllegalStateException("Stream too large")
}
ensureCapacity(byteIndex.toInt())
}
private fun ensureCapacity(byteIndex: Int) {
val requiredSize = byteIndex + 1
if (requiredSize <= stream.size) return
var newSize = if (stream.isEmpty()) 32 else stream.size
while (newSize < requiredSize) {
newSize = if (newSize <= Int.MAX_VALUE / 2) newSize shl 1 else requiredSize
}
stream = stream.copyOf(newSize)
}
}

View File

@@ -79,7 +79,11 @@ object TransportManager {
* Получить активный сервер для скачивания/загрузки.
* Desktop parity: ждём сервер из PacketRequestTransport (0x0F), а не используем hardcoded CDN.
*/
private suspend fun getActiveServer(): String {
private suspend fun getActiveServer(serverOverride: String? = null): String {
val normalizedOverride = serverOverride?.trim()?.trimEnd('/').orEmpty()
if (normalizedOverride.isNotEmpty()) {
return normalizedOverride
}
transportServer?.let { return it }
requestTransportServer()
repeat(40) { // 10s total
@@ -269,8 +273,12 @@ object TransportManager {
* @param tag Tag файла на сервере
* @return Содержимое файла
*/
suspend fun downloadFile(id: String, tag: String): String = withContext(Dispatchers.IO) {
val server = getActiveServer()
suspend fun downloadFile(
id: String,
tag: String,
transportServer: String? = null
): String = withContext(Dispatchers.IO) {
val server = getActiveServer(transportServer)
ProtocolManager.addLog("📥 Download start: id=${id.take(8)}, tag=${tag.take(10)}, server=$server")
// Добавляем в список скачиваний
@@ -385,7 +393,11 @@ object TransportManager {
* @param tag Tag файла на сервере
* @return Временный файл с зашифрованным содержимым
*/
suspend fun downloadFileRaw(id: String, tag: String): File = withContext(Dispatchers.IO) {
suspend fun downloadFileRaw(
id: String,
tag: String,
transportServer: String? = null
): File = withContext(Dispatchers.IO) {
val cacheDir = appContext?.cacheDir ?: throw IOException("No app context")
val tempFile = File(cacheDir, "dlr_${id.take(16)}_${System.currentTimeMillis()}.tmp")
try {
@@ -393,7 +405,8 @@ object TransportManager {
id = id,
tag = tag,
targetFile = tempFile,
resumeFromBytes = 0L
resumeFromBytes = 0L,
transportServer = transportServer
)
} catch (e: Exception) {
tempFile.delete()
@@ -410,9 +423,10 @@ object TransportManager {
id: String,
tag: String,
targetFile: File,
resumeFromBytes: Long = 0L
resumeFromBytes: Long = 0L,
transportServer: String? = null
): File = withContext(Dispatchers.IO) {
val server = getActiveServer()
val server = getActiveServer(transportServer)
ProtocolManager.addLog(
"📥 Download raw(resume) start: id=${id.take(8)}, tag=${tag.take(10)}, server=$server, resume=$resumeFromBytes"
)

View File

@@ -1544,6 +1544,23 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val preview = first.optString("preview", "")
if (!isLikelyCallAttachmentPreview(preview)) return null
val transportObj = first.optJSONObject("transport")
val transportTag =
first.optString(
"transportTag",
first.optString(
"transport_tag",
transportObj?.optString("transport_tag", "") ?: ""
)
)
val transportServer =
first.optString(
"transportServer",
first.optString(
"transport_server",
transportObj?.optString("transport_server", "") ?: ""
)
)
return MessageAttachment(
id = first.optString("id", "").ifBlank { "call-$fallbackId" },
@@ -1551,7 +1568,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
type = AttachmentType.CALL,
preview = preview,
width = 0,
height = 0
height = 0,
transportTag = transportTag,
transportServer = transportServer
)
}
@@ -1618,6 +1637,23 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
var blob = attachment.optString("blob", "")
val attachmentId = attachment.optString("id", "")
val transportObj = attachment.optJSONObject("transport")
val transportTag =
attachment.optString(
"transportTag",
attachment.optString(
"transport_tag",
transportObj?.optString("transport_tag", "") ?: ""
)
)
val transportServer =
attachment.optString(
"transportServer",
attachment.optString(
"transport_server",
transportObj?.optString("transport_server", "") ?: ""
)
)
// 💾 Для IMAGE и AVATAR - пробуем загрузить blob из файла если пустой
if ((effectiveType == AttachmentType.IMAGE ||
@@ -1649,7 +1685,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
attachment.optString(
"localUri",
""
) // 🔥 Поддержка localUri из БД
), // 🔥 Поддержка localUri из БД
transportTag = transportTag,
transportServer = transportServer
)
)
}
@@ -1870,11 +1908,38 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val attWidth = attJson.optInt("width", 0)
val attHeight = attJson.optInt("height", 0)
val attLocalUri = attJson.optString("localUri", "")
val transportObj = attJson.optJSONObject("transport")
val attTransportTag =
attJson.optString(
"transportTag",
attJson.optString(
"transport_tag",
transportObj?.optString(
"transport_tag",
""
)
?: ""
)
)
val attTransportServer =
attJson.optString(
"transportServer",
attJson.optString(
"transport_server",
transportObj?.optString(
"transport_server",
""
)
?: ""
)
)
if (attId.isNotEmpty()) {
fwdAttachments.add(MessageAttachment(
id = attId, type = attType, preview = attPreview,
blob = attBlob, width = attWidth, height = attHeight,
localUri = attLocalUri
localUri = attLocalUri,
transportTag = attTransportTag,
transportServer = attTransportServer
))
}
}
@@ -1952,6 +2017,31 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val attWidth = attJson.optInt("width", 0)
val attHeight = attJson.optInt("height", 0)
val attLocalUri = attJson.optString("localUri", "")
val transportObj = attJson.optJSONObject("transport")
val attTransportTag =
attJson.optString(
"transportTag",
attJson.optString(
"transport_tag",
transportObj?.optString(
"transport_tag",
""
)
?: ""
)
)
val attTransportServer =
attJson.optString(
"transportServer",
attJson.optString(
"transport_server",
transportObj?.optString(
"transport_server",
""
)
?: ""
)
)
if (attId.isNotEmpty()) {
replyAttachmentsFromJson.add(
@@ -1962,7 +2052,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
blob = attBlob,
width = attWidth,
height = attHeight,
localUri = attLocalUri
localUri = attLocalUri,
transportTag = attTransportTag,
transportServer = attTransportServer
)
)
}
@@ -2180,21 +2272,16 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
encryptedKey = encryptResult.encryptedKey,
aesChachaKey = encryptAesChachaKey(encryptResult.plainKeyAndNonce, privateKey),
plainKeyAndNonce = encryptResult.plainKeyAndNonce,
attachmentPassword = String(encryptResult.plainKeyAndNonce, Charsets.ISO_8859_1),
// Desktop parity: attachments are encrypted with key+nonce HEX password.
attachmentPassword =
encryptResult.plainKeyAndNonce.joinToString("") { "%02x".format(it) },
isGroup = false
)
}
}
private fun encryptAttachmentPayload(payload: String, context: OutgoingEncryptionContext): String {
return if (context.isGroup) {
CryptoManager.encryptWithPassword(payload, context.attachmentPassword)
} else {
val plainKeyAndNonce =
context.plainKeyAndNonce
?: throw IllegalStateException("Missing key+nonce for direct message")
MessageCrypto.encryptReplyBlob(payload, plainKeyAndNonce)
}
return CryptoManager.encryptWithPassword(payload, context.attachmentPassword)
}
/** Обновить текст ввода */
@@ -2720,8 +2807,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
var replyBlobForDatabase = "" // Зашифрованный blob для БД (приватным ключом)
// 📸 Forward: сначала загружаем IMAGE на CDN, чтобы обновить ссылки в MESSAGES blob
// Map: originalAttId → (newAttId, newPreview) — для подстановки в reply JSON
val forwardedAttMap = mutableMapOf<String, Pair<String, String>>()
// Map: originalAttId -> updated attachment metadata.
val forwardedAttMap = mutableMapOf<String, MessageAttachment>()
if (isForwardToSend && replyMsgsToSend.isNotEmpty()) {
val context = getApplication<Application>()
val isSaved = (sender == recipient)
@@ -2746,10 +2833,22 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
uploadTag = TransportManager.uploadFile(newAttId, encryptedBlob)
}
val blurhash = att.preview.substringAfter("::", "")
val newPreview = if (uploadTag.isNotEmpty()) "$uploadTag::$blurhash" else blurhash
val blurhash = att.preview.substringAfter("::", att.preview)
val transportServer =
if (uploadTag.isNotEmpty()) {
TransportManager.getTransportServer().orEmpty()
} else {
""
}
forwardedAttMap[att.id] = Pair(newAttId, newPreview)
forwardedAttMap[att.id] =
att.copy(
id = newAttId,
preview = blurhash,
blob = "",
transportTag = uploadTag,
transportServer = transportServer
)
// Сохраняем локально с новым ID
// publicKey = msg.publicKey чтобы совпадал с JSON для parseReplyFromAttachments
@@ -2774,10 +2873,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
replyMsgsToSend.forEach { msg ->
val attachmentsArray = JSONArray()
msg.attachments.forEach { att ->
// Для forward IMAGE: подставляем НОВЫЙ id и preview (CDN tag)
// Для forward IMAGE: подставляем НОВЫЙ id/preview/transport.
val fwdInfo = forwardedAttMap[att.id]
val attId = fwdInfo?.first ?: att.id
val attPreview = fwdInfo?.second ?: att.preview
val attId = fwdInfo?.id ?: att.id
val attPreview = fwdInfo?.preview ?: att.preview
val attTransportTag = fwdInfo?.transportTag ?: att.transportTag
val attTransportServer = fwdInfo?.transportServer ?: att.transportServer
attachmentsArray.put(
JSONObject().apply {
@@ -2791,6 +2892,15 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
if (att.type == AttachmentType.MESSAGES) att.blob
else ""
)
put("transportTag", attTransportTag)
put("transportServer", attTransportServer)
put(
"transport",
JSONObject().apply {
put("transport_tag", attTransportTag)
put("transport_server", attTransportServer)
}
)
}
)
}
@@ -2876,6 +2986,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
put("preview", att.preview)
put("width", att.width)
put("height", att.height)
put("transportTag", att.transportTag)
put("transportServer", att.transportServer)
// Только для MESSAGES сохраняем blob (reply
// data небольшие)
// Для IMAGE/FILE - пустой blob
@@ -2975,7 +3087,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val replyAttachmentId = "reply_${timestamp}"
fun buildForwardReplyJson(
forwardedIdMap: Map<String, Pair<String, String>> = emptyMap(),
forwardedIdMap: Map<String, MessageAttachment> = emptyMap(),
includeLocalUri: Boolean
): JSONArray {
val replyJsonArray = JSONArray()
@@ -2983,8 +3095,10 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val attachmentsArray = JSONArray()
fm.attachments.forEach { att ->
val fwdInfo = forwardedIdMap[att.id]
val attId = fwdInfo?.first ?: att.id
val attPreview = fwdInfo?.second ?: att.preview
val attId = fwdInfo?.id ?: att.id
val attPreview = fwdInfo?.preview ?: att.preview
val attTransportTag = fwdInfo?.transportTag ?: att.transportTag
val attTransportServer = fwdInfo?.transportServer ?: att.transportServer
attachmentsArray.put(
JSONObject().apply {
@@ -2994,6 +3108,15 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
put("width", att.width)
put("height", att.height)
put("blob", if (att.type == AttachmentType.MESSAGES) att.blob else "")
put("transportTag", attTransportTag)
put("transportServer", attTransportServer)
put(
"transport",
JSONObject().apply {
put("transport_tag", attTransportTag)
put("transport_server", attTransportServer)
}
)
if (includeLocalUri && att.localUri.isNotEmpty()) {
put("localUri", att.localUri)
}
@@ -3099,8 +3222,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
}
// 📸 Forward: загружаем IMAGE на CDN и пересобираем MESSAGES blob с новыми ID/tag
// Map: originalAttId → (newAttId, newPreview)
val forwardedAttMap = mutableMapOf<String, Pair<String, String>>()
// Map: originalAttId -> updated attachment metadata.
val forwardedAttMap = mutableMapOf<String, MessageAttachment>()
var fwdIdx = 0
for (fm in forwardMessages) {
for (att in fm.attachments) {
@@ -3121,10 +3244,21 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
uploadTag = TransportManager.uploadFile(newAttId, encryptedBlob)
}
val blurhash = att.preview.substringAfter("::", "")
val newPreview = if (uploadTag.isNotEmpty()) "$uploadTag::$blurhash" else blurhash
forwardedAttMap[att.id] = Pair(newAttId, newPreview)
val blurhash = att.preview.substringAfter("::", att.preview)
val transportServer =
if (uploadTag.isNotEmpty()) {
TransportManager.getTransportServer().orEmpty()
} else {
""
}
forwardedAttMap[att.id] =
att.copy(
id = newAttId,
preview = blurhash,
blob = "",
transportTag = uploadTag,
transportServer = transportServer
)
// Сохраняем локально с новым ID
// publicKey = fm.senderPublicKey чтобы совпадал с JSON для parseReplyFromAttachments
@@ -3492,17 +3626,24 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
logPhotoPipeline(messageId, "saved-messages mode: upload skipped")
}
// Preview содержит tag::blurhash
val previewWithTag = if (uploadTag.isNotEmpty()) "$uploadTag::$blurhash" else blurhash
val attachmentTransportServer =
if (uploadTag.isNotEmpty()) {
TransportManager.getTransportServer().orEmpty()
} else {
""
}
val previewValue = blurhash
val imageAttachment =
MessageAttachment(
id = attachmentId,
blob = "",
type = AttachmentType.IMAGE,
preview = previewWithTag,
preview = previewValue,
width = width,
height = height
height = height,
transportTag = uploadTag,
transportServer = attachmentTransportServer
)
val packet =
@@ -3545,10 +3686,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
JSONObject().apply {
put("id", attachmentId)
put("type", AttachmentType.IMAGE.value)
put("preview", previewWithTag)
put("preview", previewValue)
put("blob", "")
put("width", width)
put("height", height)
put("transportTag", uploadTag)
put("transportServer", attachmentTransportServer)
}
)
}
@@ -3675,18 +3818,24 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
uploadTag = TransportManager.uploadFile(attachmentId, encryptedImageBlob)
}
// Preview содержит tag::blurhash (как в desktop)
val previewWithTag =
if (uploadTag.isNotEmpty()) "$uploadTag::$blurhash" else blurhash
val attachmentTransportServer =
if (uploadTag.isNotEmpty()) {
TransportManager.getTransportServer().orEmpty()
} else {
""
}
val previewValue = blurhash
val imageAttachment =
MessageAttachment(
id = attachmentId,
blob = "", // 🔥 Пустой blob - файл на Transport Server!
type = AttachmentType.IMAGE,
preview = previewWithTag,
preview = previewValue,
width = width,
height = height
height = height,
transportTag = uploadTag,
transportServer = attachmentTransportServer
)
val packet =
@@ -3725,10 +3874,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
JSONObject().apply {
put("id", attachmentId)
put("type", AttachmentType.IMAGE.value)
put("preview", previewWithTag)
put("preview", previewValue)
put("blob", "") // Пустой blob - не сохраняем в БД!
put("width", width)
put("height", height)
put("transportTag", uploadTag)
put("transportServer", attachmentTransportServer)
}
)
}
@@ -3969,9 +4120,13 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
} else {
""
}
val previewWithTag =
if (uploadTag.isNotEmpty()) "$uploadTag::${imageData.blurhash}"
else imageData.blurhash
val attachmentTransportServer =
if (uploadTag.isNotEmpty()) {
TransportManager.getTransportServer().orEmpty()
} else {
""
}
val previewValue = imageData.blurhash
AttachmentFileManager.saveAttachment(
context = context,
@@ -3986,10 +4141,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
id = attachmentId,
blob = if (uploadTag.isNotEmpty()) "" else encryptedImageBlob,
type = AttachmentType.IMAGE,
preview = previewWithTag,
preview = previewValue,
width = imageData.width,
height = imageData.height,
localUri = ""
localUri = "",
transportTag = uploadTag,
transportServer = attachmentTransportServer
)
networkAttachments.add(finalAttachment)
finalAttachmentsById[attachmentId] = finalAttachment
@@ -3998,10 +4155,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
JSONObject().apply {
put("id", attachmentId)
put("type", AttachmentType.IMAGE.value)
put("preview", previewWithTag)
put("preview", previewValue)
put("blob", "")
put("width", imageData.width)
put("height", imageData.height)
put("transportTag", uploadTag)
put("transportServer", attachmentTransportServer)
}
)
}
@@ -4175,12 +4334,13 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// Загружаем на Transport Server
val uploadTag = TransportManager.uploadFile(attachmentId, encryptedImageBlob)
val previewWithTag =
if (uploadTag != null) {
"$uploadTag::${imageData.blurhash}"
val attachmentTransportServer =
if (uploadTag.isNotEmpty()) {
TransportManager.getTransportServer().orEmpty()
} else {
imageData.blurhash
""
}
val previewValue = imageData.blurhash
logPhotoPipeline(
messageId,
"group item#$index upload done: tag=${shortPhotoId(uploadTag, 12)}"
@@ -4199,11 +4359,13 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
networkAttachments.add(
MessageAttachment(
id = attachmentId,
blob = if (uploadTag != null) "" else encryptedImageBlob,
blob = if (uploadTag.isNotEmpty()) "" else encryptedImageBlob,
type = AttachmentType.IMAGE,
preview = previewWithTag,
preview = previewValue,
width = imageData.width,
height = imageData.height
height = imageData.height,
transportTag = uploadTag,
transportServer = attachmentTransportServer
)
)
@@ -4212,10 +4374,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
JSONObject().apply {
put("id", attachmentId)
put("type", AttachmentType.IMAGE.value)
put("preview", previewWithTag)
put("preview", previewValue)
put("blob", "") // Пустой blob - изображения в файловой системе
put("width", imageData.width)
put("height", imageData.height)
put("transportTag", uploadTag)
put("transportServer", attachmentTransportServer)
}
)
}
@@ -4384,15 +4548,22 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
uploadTag = TransportManager.uploadFile(attachmentId, encryptedFileBlob)
}
// Preview содержит tag::size::name (как в desktop)
val previewWithTag = if (uploadTag.isNotEmpty()) "$uploadTag::$preview" else preview
val attachmentTransportServer =
if (uploadTag.isNotEmpty()) {
TransportManager.getTransportServer().orEmpty()
} else {
""
}
val previewValue = preview
val fileAttachment =
MessageAttachment(
id = attachmentId,
blob = "", // 🔥 Пустой blob - файл на Transport Server!
type = AttachmentType.FILE,
preview = previewWithTag
preview = previewValue,
transportTag = uploadTag,
transportServer = attachmentTransportServer
)
val packet =
@@ -4422,8 +4593,10 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
JSONObject().apply {
put("id", attachmentId)
put("type", AttachmentType.FILE.value)
put("preview", previewWithTag)
put("preview", previewValue)
put("blob", "") // Пустой blob - не сохраняем в БД!
put("transportTag", uploadTag)
put("transportServer", attachmentTransportServer)
}
)
}
@@ -4607,17 +4780,22 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
}
}
// Preview содержит tag::blurhash (как в desktop)
val previewWithTag =
if (uploadTag.isNotEmpty()) "$uploadTag::$avatarBlurhash"
else avatarBlurhash
val attachmentTransportServer =
if (uploadTag.isNotEmpty()) {
TransportManager.getTransportServer().orEmpty()
} else {
""
}
val previewValue = avatarBlurhash
val avatarAttachment =
MessageAttachment(
id = avatarAttachmentId,
blob = "", // 🔥 ПУСТОЙ blob - файл на Transport Server!
type = AttachmentType.AVATAR,
preview = previewWithTag
preview = previewValue,
transportTag = uploadTag,
transportServer = attachmentTransportServer
)
// 3. Отправляем пакет (с ПУСТЫМ blob!)
@@ -4655,8 +4833,10 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
JSONObject().apply {
put("id", avatarAttachmentId)
put("type", AttachmentType.AVATAR.value)
put("preview", previewWithTag) // tag::blurhash
put("preview", previewValue)
put("blob", "") // Пустой blob - не сохраняем в БД!
put("transportTag", uploadTag)
put("transportServer", attachmentTransportServer)
}
)
}

View File

@@ -2293,6 +2293,23 @@ private fun parseAttachmentsForGroupInfo(attachmentsJson: String): List<MessageA
val attachment = attachments.getJSONObject(i)
val type = AttachmentType.fromInt(attachment.optInt("type", 0))
if (type == AttachmentType.MESSAGES) continue
val transportObj = attachment.optJSONObject("transport")
val transportTag =
attachment.optString(
"transportTag",
attachment.optString(
"transport_tag",
transportObj?.optString("transport_tag", "") ?: ""
)
)
val transportServer =
attachment.optString(
"transportServer",
attachment.optString(
"transport_server",
transportObj?.optString("transport_server", "") ?: ""
)
)
add(
MessageAttachment(
@@ -2302,7 +2319,9 @@ private fun parseAttachmentsForGroupInfo(attachmentsJson: String): List<MessageA
preview = attachment.optString("preview", ""),
width = attachment.optInt("width", 0),
height = attachment.optInt("height", 0),
localUri = attachment.optString("localUri", "")
localUri = attachment.optString("localUri", ""),
transportTag = transportTag,
transportServer = transportServer
)
)
}

View File

@@ -927,8 +927,8 @@ fun ImageAttachment(
var downloadProgress by remember(attachment.id) { mutableStateOf(0f) }
var errorLabel by remember(attachment.id) { mutableStateOf("Error") }
val preview = getPreview(attachment.preview)
val downloadTag = getDownloadTag(attachment.preview)
val preview = getPreview(attachment)
val downloadTag = getDownloadTag(attachment)
// Анимация прогресса
val animatedProgress by
@@ -965,8 +965,8 @@ fun ImageAttachment(
attachment.blob.isNotEmpty() -> {
DownloadStatus.DOWNLOADED
}
// 2. Если preview НЕ содержит UUID → это наш локальный файл → DOWNLOADED
!isDownloadTag(attachment.preview) -> {
// 2. Нет transport tag → это локальный файл → DOWNLOADED
downloadTag.isBlank() -> {
DownloadStatus.DOWNLOADED
}
// 3. Есть UUID (download tag) → проверяем файловую систему
@@ -989,7 +989,7 @@ fun ImageAttachment(
}
// Декодируем blurhash для placeholder (если есть)
if (preview.isNotEmpty() && !isDownloadTag(preview)) {
if (preview.isNotEmpty()) {
withContext(Dispatchers.IO) {
try {
blurhashBitmap = BlurHash.decode(preview, 200, 200)
@@ -1117,7 +1117,7 @@ fun ImageAttachment(
scope.launch {
val idShort = shortDebugId(attachment.id)
val tagShort = shortDebugId(downloadTag)
val server = TransportManager.getTransportServer() ?: "unset"
val server = attachment.transportServer.ifBlank { TransportManager.getTransportServer() ?: "unset" }
logPhotoDebug("Start image download: id=$idShort, tag=$tagShort, server=$server")
try {
downloadStatus = DownloadStatus.DOWNLOADING
@@ -1126,13 +1126,23 @@ fun ImageAttachment(
val startTime = System.currentTimeMillis()
val encryptedContent: String
try {
encryptedContent = TransportManager.downloadFile(attachment.id, downloadTag)
encryptedContent =
TransportManager.downloadFile(
attachment.id,
downloadTag,
attachment.transportServer
)
} catch (e: Exception) {
// Один авто-ретрай через 1с
logPhotoDebug("CDN download failed, retrying: id=$idShort, reason=${e.message}")
kotlinx.coroutines.delay(1000)
try {
val retryResult = TransportManager.downloadFile(attachment.id, downloadTag)
val retryResult =
TransportManager.downloadFile(
attachment.id,
downloadTag,
attachment.transportServer
)
@Suppress("NAME_SHADOWING")
val encryptedContent = retryResult
logPhotoDebug("CDN retry OK: id=$idShort")
@@ -1715,8 +1725,8 @@ fun FileAttachment(
)
}
val preview = attachment.preview
val downloadTag = getDownloadTag(preview)
val preview = getPreview(attachment)
val downloadTag = getDownloadTag(attachment)
val (fileSize, fileName) = parseFilePreview(preview)
// Анимация прогресса
@@ -1779,7 +1789,7 @@ fun FileAttachment(
isPaused = false
return@LaunchedEffect
}
downloadStatus = if (isDownloadTag(preview)) {
downloadStatus = if (downloadTag.isNotBlank()) {
// Проверяем, был ли файл уже скачан ранее
if (savedFile.exists()) DownloadStatus.DOWNLOADED
else DownloadStatus.NOT_DOWNLOADED
@@ -1828,6 +1838,7 @@ fun FileAttachment(
com.rosetta.messenger.network.FileDownloadManager.download(
attachmentId = attachment.id,
downloadTag = downloadTag,
transportServer = attachment.transportServer,
chachaKey = chachaKey,
privateKey = privateKey,
accountPublicKey = currentUserPublicKey,
@@ -2087,8 +2098,8 @@ fun AvatarAttachment(
var avatarBitmap by remember { mutableStateOf<Bitmap?>(null) }
var blurhashBitmap by remember { mutableStateOf<Bitmap?>(null) }
val preview = getPreview(attachment.preview)
val downloadTag = getDownloadTag(attachment.preview)
val preview = getPreview(attachment)
val downloadTag = getDownloadTag(attachment)
val timeFormat = remember { java.text.SimpleDateFormat("HH:mm", java.util.Locale.getDefault()) }
@@ -2113,8 +2124,8 @@ fun AvatarAttachment(
attachment.blob.isNotEmpty() -> {
DownloadStatus.DOWNLOADED
}
// 2. Если preview НЕ содержит UUID → локальный файл → DOWNLOADED
!isDownloadTag(attachment.preview) -> {
// 2. Нет transport tag → локальный файл → DOWNLOADED
downloadTag.isBlank() -> {
DownloadStatus.DOWNLOADED
}
// 3. Есть UUID (download tag) → проверяем файловую систему
@@ -2176,7 +2187,12 @@ fun AvatarAttachment(
downloadStatus = DownloadStatus.DOWNLOADING
val startTime = System.currentTimeMillis()
val encryptedContent = TransportManager.downloadFile(attachment.id, downloadTag)
val encryptedContent =
TransportManager.downloadFile(
attachment.id,
downloadTag,
attachment.transportServer
)
val downloadTime = System.currentTimeMillis() - startTime
downloadStatus = DownloadStatus.DECRYPTING
@@ -2520,6 +2536,12 @@ internal fun getDownloadTag(preview: String): String {
return ""
}
/** Получить download tag из attachment (new format first, legacy preview fallback). */
internal fun getDownloadTag(attachment: MessageAttachment): String {
if (attachment.transportTag.isNotBlank()) return attachment.transportTag
return getDownloadTag(attachment.preview)
}
/** Получить preview без download tag Как в desktop: preview.split("::").splice(1).join("::") */
internal fun getPreview(preview: String): String {
val parts = preview.split("::")
@@ -2529,6 +2551,15 @@ internal fun getPreview(preview: String): String {
return preview
}
/** Получить attachment preview (new format raw preview, legacy preview fallback). */
internal fun getPreview(attachment: MessageAttachment): String {
return if (attachment.transportTag.isNotBlank()) {
attachment.preview
} else {
getPreview(attachment.preview)
}
}
/** Парсинг preview для файлов Формат: "UUID::filesize::filename" или "filesize::filename" */
private fun parseFilePreview(preview: String): Pair<Long, String> {
val parts = preview.split("::")

View File

@@ -1338,6 +1338,23 @@ private fun parseAttachmentsForOtherProfile(attachmentsJson: String): List<Messa
val id = attachment.optString("id", "")
val blob = attachment.optString("blob", "")
val transportObj = attachment.optJSONObject("transport")
val transportTag =
attachment.optString(
"transportTag",
attachment.optString(
"transport_tag",
transportObj?.optString("transport_tag", "") ?: ""
)
)
val transportServer =
attachment.optString(
"transportServer",
attachment.optString(
"transport_server",
transportObj?.optString("transport_server", "") ?: ""
)
)
add(
MessageAttachment(
@@ -1347,7 +1364,9 @@ private fun parseAttachmentsForOtherProfile(attachmentsJson: String): List<Messa
preview = attachment.optString("preview", ""),
width = attachment.optInt("width", 0),
height = attachment.optInt("height", 0),
localUri = attachment.optString("localUri", "")
localUri = attachment.optString("localUri", ""),
transportTag = transportTag,
transportServer = transportServer
)
)
}