From 89259b2a46af22a16293dde78b0d2914b9259404 Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Sun, 29 Mar 2026 23:16:38 +0500 Subject: [PATCH] =?UTF-8?q?=D0=A0=D0=B5=D0=BB=D0=B8=D0=B7=201.3.7:=20?= =?UTF-8?q?=D0=BD=D0=BE=D0=B2=D1=8B=D0=B9=20Stream,=20=D1=82=D1=80=D0=B0?= =?UTF-8?q?=D0=BD=D1=81=D0=BF=D0=BE=D1=80=D1=82=20=D0=B2=D0=BB=D0=BE=D0=B6?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=B8=20=D1=84=D0=B8=D0=BA=D1=81?= =?UTF-8?q?=D1=8B=20=D1=81=D0=BE=D0=B2=D0=BC=D0=B5=D1=81=D1=82=D0=B8=D0=BC?= =?UTF-8?q?=D0=BE=D1=81=D1=82=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle.kts | 4 +- .../rosetta/messenger/crypto/MessageCrypto.kt | 4 +- .../messenger/data/MessageRepository.kt | 10 + .../rosetta/messenger/data/ReleaseNotes.kt | 14 +- .../messenger/network/FileDownloadManager.kt | 13 +- .../messenger/network/PacketMessage.kt | 36 +- .../com/rosetta/messenger/network/Stream.kt | 397 ++++++++++++------ .../messenger/network/TransportManager.kt | 28 +- .../messenger/ui/chats/ChatViewModel.kt | 310 +++++++++++--- .../messenger/ui/chats/GroupInfoScreen.kt | 21 +- .../chats/components/AttachmentComponents.kt | 63 ++- .../ui/settings/OtherProfileScreen.kt | 21 +- 12 files changed, 685 insertions(+), 236 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c762985..c41286c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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 { diff --git a/app/src/main/java/com/rosetta/messenger/crypto/MessageCrypto.kt b/app/src/main/java/com/rosetta/messenger/crypto/MessageCrypto.kt index 2b66048..8e866b0 100644 --- a/app/src/main/java/com/rosetta/messenger/crypto/MessageCrypto.kt +++ b/app/src/main/java/com/rosetta/messenger/crypto/MessageCrypto.kt @@ -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 { - val candidates = LinkedHashSet(12) + val candidates = LinkedHashSet(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)) diff --git a/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt b/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt index 3868f60..69e19cd 100644 --- a/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt +++ b/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt @@ -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) diff --git a/app/src/main/java/com/rosetta/messenger/data/ReleaseNotes.kt b/app/src/main/java/com/rosetta/messenger/data/ReleaseNotes.kt index 7ab224a..9331692 100644 --- a/app/src/main/java/com/rosetta/messenger/data/ReleaseNotes.kt +++ b/app/src/main/java/com/rosetta/messenger/data/ReleaseNotes.kt @@ -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 = diff --git a/app/src/main/java/com/rosetta/messenger/network/FileDownloadManager.kt b/app/src/main/java/com/rosetta/messenger/network/FileDownloadManager.kt index 699a3f1..27822d8 100644 --- a/app/src/main/java/com/rosetta/messenger/network/FileDownloadManager.kt +++ b/app/src/main/java/com/rosetta/messenger/network/FileDownloadManager.kt @@ -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, diff --git a/app/src/main/java/com/rosetta/messenger/network/PacketMessage.kt b/app/src/main/java/com/rosetta/messenger/network/PacketMessage.kt index 7549405..8724fc4 100644 --- a/app/src/main/java/com/rosetta/messenger/network/PacketMessage.kt +++ b/app/src/main/java/com/rosetta/messenger/network/PacketMessage.kt @@ -31,15 +31,22 @@ 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 { - stream.setReadPointerBits(startPointer) - parseFromStream(stream, readExtendedAttachmentMeta = false) - ?: throw IllegalStateException("Failed to parse PacketMessage payload") - } + listOf(4, 2, 0) + .asSequence() + .mapNotNull { attachmentMetaFieldCount -> + stream.setReadPointerBits(startPointer) + parseFromStream(stream, attachmentMetaFieldCount) + ?.takeIf { !stream.hasRemainingBits() } + } + .firstOrNull() + ?: run { + stream.setReadPointerBits(startPointer) + parseFromStream(stream, 2) + ?: throw IllegalStateException( + "Failed to parse PacketMessage payload" + ) + } fromPublicKey = parsed.fromPublicKey toPublicKey = parsed.toPublicKey @@ -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 = "" } diff --git a/app/src/main/java/com/rosetta/messenger/network/Stream.kt b/app/src/main/java/com/rosetta/messenger/network/Stream.kt index 44dbcf0..7164bdb 100644 --- a/app/src/main/java/com/rosetta/messenger/network/Stream.kt +++ b/app/src/main/java/com/rosetta/messenger/network/Stream.kt @@ -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() - private var _readPointer = 0 - private var _writePointer = 0 - - init { - _stream = stream.map { it.toInt() and 0xFF }.toMutableList() - } - - fun getStream(): ByteArray { - return _stream.map { it.toByte() }.toByteArray() +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 + + fun getStream(): ByteArray = stream.copyOf(length()) + + fun setStream(value: ByteArray) { + stream = value.copyOf() + readPointer = 0 + writePointer = stream.size shl 3 } - fun getReadPointerBits(): Int = _readPointer + fun getBuffer(): ByteArray = getStream() + + 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 < writePointer - 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 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) + val lenLong = readUInt32() + if (lenLong == 0L) return byteArrayOf() + if (lenLong > Int.MAX_VALUE.toLong()) { + throw IllegalStateException("Byte array too large: $lenLong") + } + + 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) { - bytes[i] = readInt8().toByte() + out[i] = readUInt8().toByte() } - return bytes + return out } - - private fun ensureCapacity(index: Int) { - while (_stream.size <= index) { - _stream.add(0) + + 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) + } } diff --git a/app/src/main/java/com/rosetta/messenger/network/TransportManager.kt b/app/src/main/java/com/rosetta/messenger/network/TransportManager.kt index fc0a592..8791d71 100644 --- a/app/src/main/java/com/rosetta/messenger/network/TransportManager.kt +++ b/app/src/main/java/com/rosetta/messenger/network/TransportManager.kt @@ -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" ) diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt index fb089a7..25aea72 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt @@ -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>() + // Map: originalAttId -> updated attachment metadata. + val forwardedAttMap = mutableMapOf() if (isForwardToSend && replyMsgsToSend.isNotEmpty()) { val context = getApplication() 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> = emptyMap(), + forwardedIdMap: Map = 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>() + // Map: originalAttId -> updated attachment metadata. + val forwardedAttMap = mutableMapOf() 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) } ) } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/GroupInfoScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/GroupInfoScreen.kt index fa37fc7..dde24d0 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/GroupInfoScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/GroupInfoScreen.kt @@ -2293,6 +2293,23 @@ private fun parseAttachmentsForGroupInfo(attachmentsJson: String): List { 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(null) } var blurhashBitmap by remember { mutableStateOf(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 { val parts = preview.split("::") diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/OtherProfileScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/OtherProfileScreen.kt index 9023b43..8eb45c6 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/settings/OtherProfileScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/settings/OtherProfileScreen.kt @@ -1338,6 +1338,23 @@ private fun parseAttachmentsForOtherProfile(attachmentsJson: String): List