Релиз 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 // Rosetta versioning — bump here on each release
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
val rosettaVersionName = "1.3.6" val rosettaVersionName = "1.3.7"
val rosettaVersionCode = 38 // Increment on each release val rosettaVersionCode = 39 // Increment on each release
val customWebRtcAar = file("libs/libwebrtc-custom.aar") val customWebRtcAar = file("libs/libwebrtc-custom.aar")
android { android {

View File

@@ -1327,15 +1327,17 @@ object MessageCrypto {
/** /**
* Собираем пароль-кандидаты для полной desktop совместимости: * Собираем пароль-кандидаты для полной desktop совместимости:
* - full key+nonce (56 bytes) и legacy key-only (32 bytes) * - 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") * - Buffer polyfill UTF-8 decode (desktop runtime parity: window.Buffer from "buffer")
* - WHATWG/Node UTF-8 decode * - WHATWG/Node UTF-8 decode
* - JVM UTF-8 / Latin1 fallback * - JVM UTF-8 / Latin1 fallback
*/ */
private fun buildAttachmentPasswordCandidates(chachaKeyPlain: ByteArray): List<String> { private fun buildAttachmentPasswordCandidates(chachaKeyPlain: ByteArray): List<String> {
val candidates = LinkedHashSet<String>(12) val candidates = LinkedHashSet<String>(16)
fun addVariants(bytes: ByteArray) { fun addVariants(bytes: ByteArray) {
if (bytes.isEmpty()) return if (bytes.isEmpty()) return
candidates.add(bytes.joinToString("") { "%02x".format(it) })
candidates.add(bytesToBufferPolyfillUtf8String(bytes)) candidates.add(bytesToBufferPolyfillUtf8String(bytes))
candidates.add(bytesToJsUtf8String(bytes)) candidates.add(bytesToJsUtf8String(bytes))
candidates.add(String(bytes, Charsets.UTF_8)) 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("preview", attachment.preview)
put("width", attachment.width) put("width", attachment.width)
put("height", attachment.height) put("height", attachment.height)
put("transportTag", attachment.transportTag)
put("transportServer", attachment.transportServer)
} }
jsonArray.put(jsonObj) jsonArray.put(jsonObj)
} }
@@ -1937,6 +1939,8 @@ class MessageRepository private constructor(private val context: Context) {
jsonObj.put("preview", attachment.preview) jsonObj.put("preview", attachment.preview)
jsonObj.put("width", attachment.width) jsonObj.put("width", attachment.width)
jsonObj.put("height", attachment.height) jsonObj.put("height", attachment.height)
jsonObj.put("transportTag", attachment.transportTag)
jsonObj.put("transportServer", attachment.transportServer)
} else { } else {
// Fallback - пустой blob для IMAGE/FILE // Fallback - пустой blob для IMAGE/FILE
jsonObj.put("id", attachment.id) jsonObj.put("id", attachment.id)
@@ -1945,6 +1949,8 @@ class MessageRepository private constructor(private val context: Context) {
jsonObj.put("preview", attachment.preview) jsonObj.put("preview", attachment.preview)
jsonObj.put("width", attachment.width) jsonObj.put("width", attachment.width)
jsonObj.put("height", attachment.height) jsonObj.put("height", attachment.height)
jsonObj.put("transportTag", attachment.transportTag)
jsonObj.put("transportServer", attachment.transportServer)
} }
} catch (e: Exception) { } catch (e: Exception) {
// Fallback - пустой blob // Fallback - пустой blob
@@ -1954,6 +1960,8 @@ class MessageRepository private constructor(private val context: Context) {
jsonObj.put("preview", attachment.preview) jsonObj.put("preview", attachment.preview)
jsonObj.put("width", attachment.width) jsonObj.put("width", attachment.width)
jsonObj.put("height", attachment.height) jsonObj.put("height", attachment.height)
jsonObj.put("transportTag", attachment.transportTag)
jsonObj.put("transportServer", attachment.transportServer)
} }
} else { } else {
// Для IMAGE/FILE - НЕ сохраняем blob (пустой) // Для IMAGE/FILE - НЕ сохраняем blob (пустой)
@@ -1963,6 +1971,8 @@ class MessageRepository private constructor(private val context: Context) {
jsonObj.put("preview", attachment.preview) jsonObj.put("preview", attachment.preview)
jsonObj.put("width", attachment.width) jsonObj.put("width", attachment.width)
jsonObj.put("height", attachment.height) jsonObj.put("height", attachment.height)
jsonObj.put("transportTag", attachment.transportTag)
jsonObj.put("transportServer", attachment.transportServer)
} }
jsonArray.put(jsonObj) jsonArray.put(jsonObj)

View File

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

View File

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

View File

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

View File

@@ -1,167 +1,318 @@
package com.rosetta.messenger.network package com.rosetta.messenger.network
/** /**
* Binary stream for protocol packets * Binary stream for protocol packets.
* Matches the React Native implementation exactly *
* 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)) { class Stream(initial: ByteArray = byteArrayOf()) {
private var _stream = mutableListOf<Int>() private var stream: ByteArray = initial.copyOf()
private var _readPointer = 0 private var readPointer: Int = 0 // bits
private var _writePointer = 0 private var writePointer: Int = stream.size shl 3 // bits
init { fun getStream(): ByteArray = stream.copyOf(length())
_stream = stream.map { it.toInt() and 0xFF }.toMutableList()
fun setStream(value: ByteArray) {
stream = value.copyOf()
readPointer = 0
writePointer = stream.size shl 3
} }
fun getStream(): ByteArray { fun getBuffer(): ByteArray = getStream()
return _stream.map { it.toByte() }.toByteArray()
}
fun getReadPointerBits(): Int = _readPointer fun isEmpty(): Boolean = writePointer == 0
fun length(): Int = (writePointer + 7) shr 3
fun getReadPointerBits(): Int = readPointer
fun setReadPointerBits(bits: Int) { 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 hasRemainingBits(): Boolean = readPointer < writePointer
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) { fun writeBit(value: Int) {
val bit = value and 1 writeBits((value and 1).toLong(), 1)
ensureCapacity(_writePointer shr 3)
_stream[_writePointer shr 3] = _stream[_writePointer shr 3] or (bit shl (7 - (_writePointer and 7)))
_writePointer++
} }
fun readBit(): Int { fun readBit(): Int = readBits(1).toInt()
val bit = (_stream[_readPointer shr 3] shr (7 - (_readPointer and 7))) and 1
_readPointer++
return bit
}
fun writeBoolean(value: Boolean) { fun writeBoolean(value: Boolean) {
writeBit(if (value) 1 else 0) writeBit(if (value) 1 else 0)
} }
fun readBoolean(): Boolean { fun readBoolean(): Boolean = readBit() == 1
return 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) { fun writeInt16(value: Int) {
writeInt8(value shr 8) writeUInt16(value)
writeInt8(value and 0xFF)
} }
fun readInt16(): Int { fun readInt16(): Int = readUInt16().toShort().toInt()
val high = readInt8() shl 8
return high or readInt8() 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) { fun writeInt32(value: Int) {
writeInt16(value shr 16) writeUInt32(value.toLong() and 0xFFFF_FFFFL)
writeInt16(value and 0xFFFF)
} }
fun readInt32(): Int { fun readInt32(): Int = readUInt32().toInt()
val high = readInt16() shl 16
return high or readInt16() /** 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) { /** Reads raw 64-bit pattern (UInt64 bit-pattern in Long). */
val high = (value shr 32).toInt() fun readUInt64(): Long {
val low = (value and 0xFFFFFFFF).toInt() val high = readUInt32() and 0xFFFF_FFFFL
writeInt32(high) val low = readUInt32() and 0xFFFF_FFFFL
writeInt32(low)
}
fun readInt64(): Long {
val high = readInt32().toLong()
val low = (readInt32().toLong() and 0xFFFFFFFFL)
return (high shl 32) or low 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) { fun writeString(value: String) {
writeInt32(value.length) writeUInt32(value.length.toLong())
for (char in value) {
writeInt16(char.code) if (value.isEmpty()) return
reserveBits(value.length.toLong() * 16L)
for (i in value.indices) {
writeUInt16(value[i].code and 0xFFFF)
} }
} }
fun readString(): String { fun readString(): String {
val length = readInt32() val lenLong = readUInt32()
// Desktop parity + safety: don't trust malformed string length. if (lenLong > Int.MAX_VALUE.toLong()) {
val bytesAvailable = _stream.size - (_readPointer shr 3) throw IllegalStateException("String length too large: $lenLong")
if (length < 0 || (length.toLong() * 2L) > bytesAvailable.toLong()) {
android.util.Log.w(
"RosettaStream",
"readString invalid length=$length, bytesAvailable=$bytesAvailable, readPointer=$_readPointer"
)
return ""
} }
val sb = StringBuilder()
for (i in 0 until length) { val length = lenLong.toInt()
sb.append(readInt16().toChar()) 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() return sb.toString()
} }
/** byte[]: length(UInt32) + payload. */
fun writeBytes(value: ByteArray) { fun writeBytes(value: ByteArray) {
writeInt32(value.size) writeUInt32(value.size.toLong())
for (byte in value) {
writeInt8(byte.toInt()) 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 { fun readBytes(): ByteArray {
val length = readInt32() val lenLong = readUInt32()
val bytes = ByteArray(length) if (lenLong == 0L) return byteArrayOf()
for (i in 0 until length) { if (lenLong > Int.MAX_VALUE.toLong()) {
bytes[i] = readInt8().toByte() throw IllegalStateException("Byte array too large: $lenLong")
}
return bytes
} }
private fun ensureCapacity(index: Int) { val length = lenLong.toInt()
while (_stream.size <= index) { val requiredBits = length.toLong() * 8L
_stream.add(0) 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. * 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 } transportServer?.let { return it }
requestTransportServer() requestTransportServer()
repeat(40) { // 10s total repeat(40) { // 10s total
@@ -269,8 +273,12 @@ object TransportManager {
* @param tag Tag файла на сервере * @param tag Tag файла на сервере
* @return Содержимое файла * @return Содержимое файла
*/ */
suspend fun downloadFile(id: String, tag: String): String = withContext(Dispatchers.IO) { suspend fun downloadFile(
val server = getActiveServer() 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") ProtocolManager.addLog("📥 Download start: id=${id.take(8)}, tag=${tag.take(10)}, server=$server")
// Добавляем в список скачиваний // Добавляем в список скачиваний
@@ -385,7 +393,11 @@ object TransportManager {
* @param tag Tag файла на сервере * @param tag Tag файла на сервере
* @return Временный файл с зашифрованным содержимым * @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 cacheDir = appContext?.cacheDir ?: throw IOException("No app context")
val tempFile = File(cacheDir, "dlr_${id.take(16)}_${System.currentTimeMillis()}.tmp") val tempFile = File(cacheDir, "dlr_${id.take(16)}_${System.currentTimeMillis()}.tmp")
try { try {
@@ -393,7 +405,8 @@ object TransportManager {
id = id, id = id,
tag = tag, tag = tag,
targetFile = tempFile, targetFile = tempFile,
resumeFromBytes = 0L resumeFromBytes = 0L,
transportServer = transportServer
) )
} catch (e: Exception) { } catch (e: Exception) {
tempFile.delete() tempFile.delete()
@@ -410,9 +423,10 @@ object TransportManager {
id: String, id: String,
tag: String, tag: String,
targetFile: File, targetFile: File,
resumeFromBytes: Long = 0L resumeFromBytes: Long = 0L,
transportServer: String? = null
): File = withContext(Dispatchers.IO) { ): File = withContext(Dispatchers.IO) {
val server = getActiveServer() val server = getActiveServer(transportServer)
ProtocolManager.addLog( ProtocolManager.addLog(
"📥 Download raw(resume) start: id=${id.take(8)}, tag=${tag.take(10)}, server=$server, resume=$resumeFromBytes" "📥 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", "") val preview = first.optString("preview", "")
if (!isLikelyCallAttachmentPreview(preview)) return null 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( return MessageAttachment(
id = first.optString("id", "").ifBlank { "call-$fallbackId" }, id = first.optString("id", "").ifBlank { "call-$fallbackId" },
@@ -1551,7 +1568,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
type = AttachmentType.CALL, type = AttachmentType.CALL,
preview = preview, preview = preview,
width = 0, 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", "") var blob = attachment.optString("blob", "")
val attachmentId = attachment.optString("id", "") 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 из файла если пустой // 💾 Для IMAGE и AVATAR - пробуем загрузить blob из файла если пустой
if ((effectiveType == AttachmentType.IMAGE || if ((effectiveType == AttachmentType.IMAGE ||
@@ -1649,7 +1685,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
attachment.optString( attachment.optString(
"localUri", "localUri",
"" ""
) // 🔥 Поддержка localUri из БД ), // 🔥 Поддержка localUri из БД
transportTag = transportTag,
transportServer = transportServer
) )
) )
} }
@@ -1870,11 +1908,38 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val attWidth = attJson.optInt("width", 0) val attWidth = attJson.optInt("width", 0)
val attHeight = attJson.optInt("height", 0) val attHeight = attJson.optInt("height", 0)
val attLocalUri = attJson.optString("localUri", "") 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()) { if (attId.isNotEmpty()) {
fwdAttachments.add(MessageAttachment( fwdAttachments.add(MessageAttachment(
id = attId, type = attType, preview = attPreview, id = attId, type = attType, preview = attPreview,
blob = attBlob, width = attWidth, height = attHeight, 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 attWidth = attJson.optInt("width", 0)
val attHeight = attJson.optInt("height", 0) val attHeight = attJson.optInt("height", 0)
val attLocalUri = attJson.optString("localUri", "") 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()) { if (attId.isNotEmpty()) {
replyAttachmentsFromJson.add( replyAttachmentsFromJson.add(
@@ -1962,7 +2052,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
blob = attBlob, blob = attBlob,
width = attWidth, width = attWidth,
height = attHeight, height = attHeight,
localUri = attLocalUri localUri = attLocalUri,
transportTag = attTransportTag,
transportServer = attTransportServer
) )
) )
} }
@@ -2180,21 +2272,16 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
encryptedKey = encryptResult.encryptedKey, encryptedKey = encryptResult.encryptedKey,
aesChachaKey = encryptAesChachaKey(encryptResult.plainKeyAndNonce, privateKey), aesChachaKey = encryptAesChachaKey(encryptResult.plainKeyAndNonce, privateKey),
plainKeyAndNonce = encryptResult.plainKeyAndNonce, 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 isGroup = false
) )
} }
} }
private fun encryptAttachmentPayload(payload: String, context: OutgoingEncryptionContext): String { private fun encryptAttachmentPayload(payload: String, context: OutgoingEncryptionContext): String {
return if (context.isGroup) { return CryptoManager.encryptWithPassword(payload, context.attachmentPassword)
CryptoManager.encryptWithPassword(payload, context.attachmentPassword)
} else {
val plainKeyAndNonce =
context.plainKeyAndNonce
?: throw IllegalStateException("Missing key+nonce for direct message")
MessageCrypto.encryptReplyBlob(payload, plainKeyAndNonce)
}
} }
/** Обновить текст ввода */ /** Обновить текст ввода */
@@ -2720,8 +2807,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
var replyBlobForDatabase = "" // Зашифрованный blob для БД (приватным ключом) var replyBlobForDatabase = "" // Зашифрованный blob для БД (приватным ключом)
// 📸 Forward: сначала загружаем IMAGE на CDN, чтобы обновить ссылки в MESSAGES blob // 📸 Forward: сначала загружаем IMAGE на CDN, чтобы обновить ссылки в MESSAGES blob
// Map: originalAttId → (newAttId, newPreview) — для подстановки в reply JSON // Map: originalAttId -> updated attachment metadata.
val forwardedAttMap = mutableMapOf<String, Pair<String, String>>() val forwardedAttMap = mutableMapOf<String, MessageAttachment>()
if (isForwardToSend && replyMsgsToSend.isNotEmpty()) { if (isForwardToSend && replyMsgsToSend.isNotEmpty()) {
val context = getApplication<Application>() val context = getApplication<Application>()
val isSaved = (sender == recipient) val isSaved = (sender == recipient)
@@ -2746,10 +2833,22 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
uploadTag = TransportManager.uploadFile(newAttId, encryptedBlob) uploadTag = TransportManager.uploadFile(newAttId, encryptedBlob)
} }
val blurhash = att.preview.substringAfter("::", "") val blurhash = att.preview.substringAfter("::", att.preview)
val newPreview = if (uploadTag.isNotEmpty()) "$uploadTag::$blurhash" else blurhash 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 // Сохраняем локально с новым ID
// publicKey = msg.publicKey чтобы совпадал с JSON для parseReplyFromAttachments // publicKey = msg.publicKey чтобы совпадал с JSON для parseReplyFromAttachments
@@ -2774,10 +2873,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
replyMsgsToSend.forEach { msg -> replyMsgsToSend.forEach { msg ->
val attachmentsArray = JSONArray() val attachmentsArray = JSONArray()
msg.attachments.forEach { att -> msg.attachments.forEach { att ->
// Для forward IMAGE: подставляем НОВЫЙ id и preview (CDN tag) // Для forward IMAGE: подставляем НОВЫЙ id/preview/transport.
val fwdInfo = forwardedAttMap[att.id] val fwdInfo = forwardedAttMap[att.id]
val attId = fwdInfo?.first ?: att.id val attId = fwdInfo?.id ?: att.id
val attPreview = fwdInfo?.second ?: att.preview val attPreview = fwdInfo?.preview ?: att.preview
val attTransportTag = fwdInfo?.transportTag ?: att.transportTag
val attTransportServer = fwdInfo?.transportServer ?: att.transportServer
attachmentsArray.put( attachmentsArray.put(
JSONObject().apply { JSONObject().apply {
@@ -2791,6 +2892,15 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
if (att.type == AttachmentType.MESSAGES) att.blob if (att.type == AttachmentType.MESSAGES) att.blob
else "" 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("preview", att.preview)
put("width", att.width) put("width", att.width)
put("height", att.height) put("height", att.height)
put("transportTag", att.transportTag)
put("transportServer", att.transportServer)
// Только для MESSAGES сохраняем blob (reply // Только для MESSAGES сохраняем blob (reply
// data небольшие) // data небольшие)
// Для IMAGE/FILE - пустой blob // Для IMAGE/FILE - пустой blob
@@ -2975,7 +3087,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val replyAttachmentId = "reply_${timestamp}" val replyAttachmentId = "reply_${timestamp}"
fun buildForwardReplyJson( fun buildForwardReplyJson(
forwardedIdMap: Map<String, Pair<String, String>> = emptyMap(), forwardedIdMap: Map<String, MessageAttachment> = emptyMap(),
includeLocalUri: Boolean includeLocalUri: Boolean
): JSONArray { ): JSONArray {
val replyJsonArray = JSONArray() val replyJsonArray = JSONArray()
@@ -2983,8 +3095,10 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val attachmentsArray = JSONArray() val attachmentsArray = JSONArray()
fm.attachments.forEach { att -> fm.attachments.forEach { att ->
val fwdInfo = forwardedIdMap[att.id] val fwdInfo = forwardedIdMap[att.id]
val attId = fwdInfo?.first ?: att.id val attId = fwdInfo?.id ?: att.id
val attPreview = fwdInfo?.second ?: att.preview val attPreview = fwdInfo?.preview ?: att.preview
val attTransportTag = fwdInfo?.transportTag ?: att.transportTag
val attTransportServer = fwdInfo?.transportServer ?: att.transportServer
attachmentsArray.put( attachmentsArray.put(
JSONObject().apply { JSONObject().apply {
@@ -2994,6 +3108,15 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
put("width", att.width) put("width", att.width)
put("height", att.height) put("height", att.height)
put("blob", if (att.type == AttachmentType.MESSAGES) att.blob else "") 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()) { if (includeLocalUri && att.localUri.isNotEmpty()) {
put("localUri", att.localUri) put("localUri", att.localUri)
} }
@@ -3099,8 +3222,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
} }
// 📸 Forward: загружаем IMAGE на CDN и пересобираем MESSAGES blob с новыми ID/tag // 📸 Forward: загружаем IMAGE на CDN и пересобираем MESSAGES blob с новыми ID/tag
// Map: originalAttId → (newAttId, newPreview) // Map: originalAttId -> updated attachment metadata.
val forwardedAttMap = mutableMapOf<String, Pair<String, String>>() val forwardedAttMap = mutableMapOf<String, MessageAttachment>()
var fwdIdx = 0 var fwdIdx = 0
for (fm in forwardMessages) { for (fm in forwardMessages) {
for (att in fm.attachments) { for (att in fm.attachments) {
@@ -3121,10 +3244,21 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
uploadTag = TransportManager.uploadFile(newAttId, encryptedBlob) uploadTag = TransportManager.uploadFile(newAttId, encryptedBlob)
} }
val blurhash = att.preview.substringAfter("::", "") val blurhash = att.preview.substringAfter("::", att.preview)
val newPreview = if (uploadTag.isNotEmpty()) "$uploadTag::$blurhash" else blurhash val transportServer =
if (uploadTag.isNotEmpty()) {
forwardedAttMap[att.id] = Pair(newAttId, newPreview) TransportManager.getTransportServer().orEmpty()
} else {
""
}
forwardedAttMap[att.id] =
att.copy(
id = newAttId,
preview = blurhash,
blob = "",
transportTag = uploadTag,
transportServer = transportServer
)
// Сохраняем локально с новым ID // Сохраняем локально с новым ID
// publicKey = fm.senderPublicKey чтобы совпадал с JSON для parseReplyFromAttachments // publicKey = fm.senderPublicKey чтобы совпадал с JSON для parseReplyFromAttachments
@@ -3492,17 +3626,24 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
logPhotoPipeline(messageId, "saved-messages mode: upload skipped") logPhotoPipeline(messageId, "saved-messages mode: upload skipped")
} }
// Preview содержит tag::blurhash val attachmentTransportServer =
val previewWithTag = if (uploadTag.isNotEmpty()) "$uploadTag::$blurhash" else blurhash if (uploadTag.isNotEmpty()) {
TransportManager.getTransportServer().orEmpty()
} else {
""
}
val previewValue = blurhash
val imageAttachment = val imageAttachment =
MessageAttachment( MessageAttachment(
id = attachmentId, id = attachmentId,
blob = "", blob = "",
type = AttachmentType.IMAGE, type = AttachmentType.IMAGE,
preview = previewWithTag, preview = previewValue,
width = width, width = width,
height = height height = height,
transportTag = uploadTag,
transportServer = attachmentTransportServer
) )
val packet = val packet =
@@ -3545,10 +3686,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
JSONObject().apply { JSONObject().apply {
put("id", attachmentId) put("id", attachmentId)
put("type", AttachmentType.IMAGE.value) put("type", AttachmentType.IMAGE.value)
put("preview", previewWithTag) put("preview", previewValue)
put("blob", "") put("blob", "")
put("width", width) put("width", width)
put("height", height) put("height", height)
put("transportTag", uploadTag)
put("transportServer", attachmentTransportServer)
} }
) )
} }
@@ -3675,18 +3818,24 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
uploadTag = TransportManager.uploadFile(attachmentId, encryptedImageBlob) uploadTag = TransportManager.uploadFile(attachmentId, encryptedImageBlob)
} }
// Preview содержит tag::blurhash (как в desktop) val attachmentTransportServer =
val previewWithTag = if (uploadTag.isNotEmpty()) {
if (uploadTag.isNotEmpty()) "$uploadTag::$blurhash" else blurhash TransportManager.getTransportServer().orEmpty()
} else {
""
}
val previewValue = blurhash
val imageAttachment = val imageAttachment =
MessageAttachment( MessageAttachment(
id = attachmentId, id = attachmentId,
blob = "", // 🔥 Пустой blob - файл на Transport Server! blob = "", // 🔥 Пустой blob - файл на Transport Server!
type = AttachmentType.IMAGE, type = AttachmentType.IMAGE,
preview = previewWithTag, preview = previewValue,
width = width, width = width,
height = height height = height,
transportTag = uploadTag,
transportServer = attachmentTransportServer
) )
val packet = val packet =
@@ -3725,10 +3874,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
JSONObject().apply { JSONObject().apply {
put("id", attachmentId) put("id", attachmentId)
put("type", AttachmentType.IMAGE.value) put("type", AttachmentType.IMAGE.value)
put("preview", previewWithTag) put("preview", previewValue)
put("blob", "") // Пустой blob - не сохраняем в БД! put("blob", "") // Пустой blob - не сохраняем в БД!
put("width", width) put("width", width)
put("height", height) put("height", height)
put("transportTag", uploadTag)
put("transportServer", attachmentTransportServer)
} }
) )
} }
@@ -3969,9 +4120,13 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
} else { } else {
"" ""
} }
val previewWithTag = val attachmentTransportServer =
if (uploadTag.isNotEmpty()) "$uploadTag::${imageData.blurhash}" if (uploadTag.isNotEmpty()) {
else imageData.blurhash TransportManager.getTransportServer().orEmpty()
} else {
""
}
val previewValue = imageData.blurhash
AttachmentFileManager.saveAttachment( AttachmentFileManager.saveAttachment(
context = context, context = context,
@@ -3986,10 +4141,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
id = attachmentId, id = attachmentId,
blob = if (uploadTag.isNotEmpty()) "" else encryptedImageBlob, blob = if (uploadTag.isNotEmpty()) "" else encryptedImageBlob,
type = AttachmentType.IMAGE, type = AttachmentType.IMAGE,
preview = previewWithTag, preview = previewValue,
width = imageData.width, width = imageData.width,
height = imageData.height, height = imageData.height,
localUri = "" localUri = "",
transportTag = uploadTag,
transportServer = attachmentTransportServer
) )
networkAttachments.add(finalAttachment) networkAttachments.add(finalAttachment)
finalAttachmentsById[attachmentId] = finalAttachment finalAttachmentsById[attachmentId] = finalAttachment
@@ -3998,10 +4155,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
JSONObject().apply { JSONObject().apply {
put("id", attachmentId) put("id", attachmentId)
put("type", AttachmentType.IMAGE.value) put("type", AttachmentType.IMAGE.value)
put("preview", previewWithTag) put("preview", previewValue)
put("blob", "") put("blob", "")
put("width", imageData.width) put("width", imageData.width)
put("height", imageData.height) put("height", imageData.height)
put("transportTag", uploadTag)
put("transportServer", attachmentTransportServer)
} }
) )
} }
@@ -4175,12 +4334,13 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// Загружаем на Transport Server // Загружаем на Transport Server
val uploadTag = TransportManager.uploadFile(attachmentId, encryptedImageBlob) val uploadTag = TransportManager.uploadFile(attachmentId, encryptedImageBlob)
val previewWithTag = val attachmentTransportServer =
if (uploadTag != null) { if (uploadTag.isNotEmpty()) {
"$uploadTag::${imageData.blurhash}" TransportManager.getTransportServer().orEmpty()
} else { } else {
imageData.blurhash ""
} }
val previewValue = imageData.blurhash
logPhotoPipeline( logPhotoPipeline(
messageId, messageId,
"group item#$index upload done: tag=${shortPhotoId(uploadTag, 12)}" "group item#$index upload done: tag=${shortPhotoId(uploadTag, 12)}"
@@ -4199,11 +4359,13 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
networkAttachments.add( networkAttachments.add(
MessageAttachment( MessageAttachment(
id = attachmentId, id = attachmentId,
blob = if (uploadTag != null) "" else encryptedImageBlob, blob = if (uploadTag.isNotEmpty()) "" else encryptedImageBlob,
type = AttachmentType.IMAGE, type = AttachmentType.IMAGE,
preview = previewWithTag, preview = previewValue,
width = imageData.width, 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 { JSONObject().apply {
put("id", attachmentId) put("id", attachmentId)
put("type", AttachmentType.IMAGE.value) put("type", AttachmentType.IMAGE.value)
put("preview", previewWithTag) put("preview", previewValue)
put("blob", "") // Пустой blob - изображения в файловой системе put("blob", "") // Пустой blob - изображения в файловой системе
put("width", imageData.width) put("width", imageData.width)
put("height", imageData.height) 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) uploadTag = TransportManager.uploadFile(attachmentId, encryptedFileBlob)
} }
// Preview содержит tag::size::name (как в desktop) val attachmentTransportServer =
val previewWithTag = if (uploadTag.isNotEmpty()) "$uploadTag::$preview" else preview if (uploadTag.isNotEmpty()) {
TransportManager.getTransportServer().orEmpty()
} else {
""
}
val previewValue = preview
val fileAttachment = val fileAttachment =
MessageAttachment( MessageAttachment(
id = attachmentId, id = attachmentId,
blob = "", // 🔥 Пустой blob - файл на Transport Server! blob = "", // 🔥 Пустой blob - файл на Transport Server!
type = AttachmentType.FILE, type = AttachmentType.FILE,
preview = previewWithTag preview = previewValue,
transportTag = uploadTag,
transportServer = attachmentTransportServer
) )
val packet = val packet =
@@ -4422,8 +4593,10 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
JSONObject().apply { JSONObject().apply {
put("id", attachmentId) put("id", attachmentId)
put("type", AttachmentType.FILE.value) put("type", AttachmentType.FILE.value)
put("preview", previewWithTag) put("preview", previewValue)
put("blob", "") // Пустой blob - не сохраняем в БД! put("blob", "") // Пустой blob - не сохраняем в БД!
put("transportTag", uploadTag)
put("transportServer", attachmentTransportServer)
} }
) )
} }
@@ -4607,17 +4780,22 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
} }
} }
// Preview содержит tag::blurhash (как в desktop) val attachmentTransportServer =
val previewWithTag = if (uploadTag.isNotEmpty()) {
if (uploadTag.isNotEmpty()) "$uploadTag::$avatarBlurhash" TransportManager.getTransportServer().orEmpty()
else avatarBlurhash } else {
""
}
val previewValue = avatarBlurhash
val avatarAttachment = val avatarAttachment =
MessageAttachment( MessageAttachment(
id = avatarAttachmentId, id = avatarAttachmentId,
blob = "", // 🔥 ПУСТОЙ blob - файл на Transport Server! blob = "", // 🔥 ПУСТОЙ blob - файл на Transport Server!
type = AttachmentType.AVATAR, type = AttachmentType.AVATAR,
preview = previewWithTag preview = previewValue,
transportTag = uploadTag,
transportServer = attachmentTransportServer
) )
// 3. Отправляем пакет (с ПУСТЫМ blob!) // 3. Отправляем пакет (с ПУСТЫМ blob!)
@@ -4655,8 +4833,10 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
JSONObject().apply { JSONObject().apply {
put("id", avatarAttachmentId) put("id", avatarAttachmentId)
put("type", AttachmentType.AVATAR.value) put("type", AttachmentType.AVATAR.value)
put("preview", previewWithTag) // tag::blurhash put("preview", previewValue)
put("blob", "") // Пустой blob - не сохраняем в БД! 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 attachment = attachments.getJSONObject(i)
val type = AttachmentType.fromInt(attachment.optInt("type", 0)) val type = AttachmentType.fromInt(attachment.optInt("type", 0))
if (type == AttachmentType.MESSAGES) continue 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( add(
MessageAttachment( MessageAttachment(
@@ -2302,7 +2319,9 @@ private fun parseAttachmentsForGroupInfo(attachmentsJson: String): List<MessageA
preview = attachment.optString("preview", ""), preview = attachment.optString("preview", ""),
width = attachment.optInt("width", 0), width = attachment.optInt("width", 0),
height = attachment.optInt("height", 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 downloadProgress by remember(attachment.id) { mutableStateOf(0f) }
var errorLabel by remember(attachment.id) { mutableStateOf("Error") } var errorLabel by remember(attachment.id) { mutableStateOf("Error") }
val preview = getPreview(attachment.preview) val preview = getPreview(attachment)
val downloadTag = getDownloadTag(attachment.preview) val downloadTag = getDownloadTag(attachment)
// Анимация прогресса // Анимация прогресса
val animatedProgress by val animatedProgress by
@@ -965,8 +965,8 @@ fun ImageAttachment(
attachment.blob.isNotEmpty() -> { attachment.blob.isNotEmpty() -> {
DownloadStatus.DOWNLOADED DownloadStatus.DOWNLOADED
} }
// 2. Если preview НЕ содержит UUID → это наш локальный файл → DOWNLOADED // 2. Нет transport tag → это локальный файл → DOWNLOADED
!isDownloadTag(attachment.preview) -> { downloadTag.isBlank() -> {
DownloadStatus.DOWNLOADED DownloadStatus.DOWNLOADED
} }
// 3. Есть UUID (download tag) → проверяем файловую систему // 3. Есть UUID (download tag) → проверяем файловую систему
@@ -989,7 +989,7 @@ fun ImageAttachment(
} }
// Декодируем blurhash для placeholder (если есть) // Декодируем blurhash для placeholder (если есть)
if (preview.isNotEmpty() && !isDownloadTag(preview)) { if (preview.isNotEmpty()) {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
try { try {
blurhashBitmap = BlurHash.decode(preview, 200, 200) blurhashBitmap = BlurHash.decode(preview, 200, 200)
@@ -1117,7 +1117,7 @@ fun ImageAttachment(
scope.launch { scope.launch {
val idShort = shortDebugId(attachment.id) val idShort = shortDebugId(attachment.id)
val tagShort = shortDebugId(downloadTag) 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") logPhotoDebug("Start image download: id=$idShort, tag=$tagShort, server=$server")
try { try {
downloadStatus = DownloadStatus.DOWNLOADING downloadStatus = DownloadStatus.DOWNLOADING
@@ -1126,13 +1126,23 @@ fun ImageAttachment(
val startTime = System.currentTimeMillis() val startTime = System.currentTimeMillis()
val encryptedContent: String val encryptedContent: String
try { try {
encryptedContent = TransportManager.downloadFile(attachment.id, downloadTag) encryptedContent =
TransportManager.downloadFile(
attachment.id,
downloadTag,
attachment.transportServer
)
} catch (e: Exception) { } catch (e: Exception) {
// Один авто-ретрай через 1с // Один авто-ретрай через 1с
logPhotoDebug("CDN download failed, retrying: id=$idShort, reason=${e.message}") logPhotoDebug("CDN download failed, retrying: id=$idShort, reason=${e.message}")
kotlinx.coroutines.delay(1000) kotlinx.coroutines.delay(1000)
try { try {
val retryResult = TransportManager.downloadFile(attachment.id, downloadTag) val retryResult =
TransportManager.downloadFile(
attachment.id,
downloadTag,
attachment.transportServer
)
@Suppress("NAME_SHADOWING") @Suppress("NAME_SHADOWING")
val encryptedContent = retryResult val encryptedContent = retryResult
logPhotoDebug("CDN retry OK: id=$idShort") logPhotoDebug("CDN retry OK: id=$idShort")
@@ -1715,8 +1725,8 @@ fun FileAttachment(
) )
} }
val preview = attachment.preview val preview = getPreview(attachment)
val downloadTag = getDownloadTag(preview) val downloadTag = getDownloadTag(attachment)
val (fileSize, fileName) = parseFilePreview(preview) val (fileSize, fileName) = parseFilePreview(preview)
// Анимация прогресса // Анимация прогресса
@@ -1779,7 +1789,7 @@ fun FileAttachment(
isPaused = false isPaused = false
return@LaunchedEffect return@LaunchedEffect
} }
downloadStatus = if (isDownloadTag(preview)) { downloadStatus = if (downloadTag.isNotBlank()) {
// Проверяем, был ли файл уже скачан ранее // Проверяем, был ли файл уже скачан ранее
if (savedFile.exists()) DownloadStatus.DOWNLOADED if (savedFile.exists()) DownloadStatus.DOWNLOADED
else DownloadStatus.NOT_DOWNLOADED else DownloadStatus.NOT_DOWNLOADED
@@ -1828,6 +1838,7 @@ fun FileAttachment(
com.rosetta.messenger.network.FileDownloadManager.download( com.rosetta.messenger.network.FileDownloadManager.download(
attachmentId = attachment.id, attachmentId = attachment.id,
downloadTag = downloadTag, downloadTag = downloadTag,
transportServer = attachment.transportServer,
chachaKey = chachaKey, chachaKey = chachaKey,
privateKey = privateKey, privateKey = privateKey,
accountPublicKey = currentUserPublicKey, accountPublicKey = currentUserPublicKey,
@@ -2087,8 +2098,8 @@ fun AvatarAttachment(
var avatarBitmap by remember { mutableStateOf<Bitmap?>(null) } var avatarBitmap by remember { mutableStateOf<Bitmap?>(null) }
var blurhashBitmap by remember { mutableStateOf<Bitmap?>(null) } var blurhashBitmap by remember { mutableStateOf<Bitmap?>(null) }
val preview = getPreview(attachment.preview) val preview = getPreview(attachment)
val downloadTag = getDownloadTag(attachment.preview) val downloadTag = getDownloadTag(attachment)
val timeFormat = remember { java.text.SimpleDateFormat("HH:mm", java.util.Locale.getDefault()) } val timeFormat = remember { java.text.SimpleDateFormat("HH:mm", java.util.Locale.getDefault()) }
@@ -2113,8 +2124,8 @@ fun AvatarAttachment(
attachment.blob.isNotEmpty() -> { attachment.blob.isNotEmpty() -> {
DownloadStatus.DOWNLOADED DownloadStatus.DOWNLOADED
} }
// 2. Если preview НЕ содержит UUID → локальный файл → DOWNLOADED // 2. Нет transport tag → локальный файл → DOWNLOADED
!isDownloadTag(attachment.preview) -> { downloadTag.isBlank() -> {
DownloadStatus.DOWNLOADED DownloadStatus.DOWNLOADED
} }
// 3. Есть UUID (download tag) → проверяем файловую систему // 3. Есть UUID (download tag) → проверяем файловую систему
@@ -2176,7 +2187,12 @@ fun AvatarAttachment(
downloadStatus = DownloadStatus.DOWNLOADING downloadStatus = DownloadStatus.DOWNLOADING
val startTime = System.currentTimeMillis() 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 val downloadTime = System.currentTimeMillis() - startTime
downloadStatus = DownloadStatus.DECRYPTING downloadStatus = DownloadStatus.DECRYPTING
@@ -2520,6 +2536,12 @@ internal fun getDownloadTag(preview: String): String {
return "" 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("::") */ /** Получить preview без download tag Как в desktop: preview.split("::").splice(1).join("::") */
internal fun getPreview(preview: String): String { internal fun getPreview(preview: String): String {
val parts = preview.split("::") val parts = preview.split("::")
@@ -2529,6 +2551,15 @@ internal fun getPreview(preview: String): String {
return preview 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" */ /** Парсинг preview для файлов Формат: "UUID::filesize::filename" или "filesize::filename" */
private fun parseFilePreview(preview: String): Pair<Long, String> { private fun parseFilePreview(preview: String): Pair<Long, String> {
val parts = preview.split("::") val parts = preview.split("::")

View File

@@ -1338,6 +1338,23 @@ private fun parseAttachmentsForOtherProfile(attachmentsJson: String): List<Messa
val id = attachment.optString("id", "") val id = attachment.optString("id", "")
val blob = attachment.optString("blob", "") 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( add(
MessageAttachment( MessageAttachment(
@@ -1347,7 +1364,9 @@ private fun parseAttachmentsForOtherProfile(attachmentsJson: String): List<Messa
preview = attachment.optString("preview", ""), preview = attachment.optString("preview", ""),
width = attachment.optInt("width", 0), width = attachment.optInt("width", 0),
height = attachment.optInt("height", 0), height = attachment.optInt("height", 0),
localUri = attachment.optString("localUri", "") localUri = attachment.optString("localUri", ""),
transportTag = transportTag,
transportServer = transportServer
) )
) )
} }