Релиз 1.3.7: новый Stream, транспорт вложений и фиксы совместимости
All checks were successful
Android Kernel Build / build (push) Successful in 19m57s
All checks were successful
Android Kernel Build / build (push) Successful in 19m57s
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -1327,15 +1327,17 @@ object MessageCrypto {
|
||||
/**
|
||||
* Собираем пароль-кандидаты для полной desktop совместимости:
|
||||
* - full key+nonce (56 bytes) и legacy key-only (32 bytes)
|
||||
* - hex password (актуальный desktop формат для attachments)
|
||||
* - Buffer polyfill UTF-8 decode (desktop runtime parity: window.Buffer from "buffer")
|
||||
* - WHATWG/Node UTF-8 decode
|
||||
* - JVM UTF-8 / Latin1 fallback
|
||||
*/
|
||||
private fun buildAttachmentPasswordCandidates(chachaKeyPlain: ByteArray): List<String> {
|
||||
val candidates = LinkedHashSet<String>(12)
|
||||
val candidates = LinkedHashSet<String>(16)
|
||||
|
||||
fun addVariants(bytes: ByteArray) {
|
||||
if (bytes.isEmpty()) return
|
||||
candidates.add(bytes.joinToString("") { "%02x".format(it) })
|
||||
candidates.add(bytesToBufferPolyfillUtf8String(bytes))
|
||||
candidates.add(bytesToJsUtf8String(bytes))
|
||||
candidates.add(String(bytes, Charsets.UTF_8))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -31,14 +31,21 @@ class PacketMessage : Packet() {
|
||||
|
||||
override fun receive(stream: Stream) {
|
||||
val startPointer = stream.getReadPointerBits()
|
||||
val extended = parseFromStream(stream, readExtendedAttachmentMeta = true)
|
||||
val parsed =
|
||||
if (extended != null && !stream.hasRemainingBits()) {
|
||||
extended
|
||||
} else {
|
||||
listOf(4, 2, 0)
|
||||
.asSequence()
|
||||
.mapNotNull { attachmentMetaFieldCount ->
|
||||
stream.setReadPointerBits(startPointer)
|
||||
parseFromStream(stream, readExtendedAttachmentMeta = false)
|
||||
?: throw IllegalStateException("Failed to parse PacketMessage payload")
|
||||
parseFromStream(stream, attachmentMetaFieldCount)
|
||||
?.takeIf { !stream.hasRemainingBits() }
|
||||
}
|
||||
.firstOrNull()
|
||||
?: run {
|
||||
stream.setReadPointerBits(startPointer)
|
||||
parseFromStream(stream, 2)
|
||||
?: throw IllegalStateException(
|
||||
"Failed to parse PacketMessage payload"
|
||||
)
|
||||
}
|
||||
|
||||
fromPublicKey = parsed.fromPublicKey
|
||||
@@ -69,6 +76,8 @@ class PacketMessage : Packet() {
|
||||
stream.writeString(attachment.preview)
|
||||
stream.writeString(attachment.blob)
|
||||
stream.writeInt8(attachment.type.value)
|
||||
stream.writeString(attachment.transportTag)
|
||||
stream.writeString(attachment.transportServer)
|
||||
}
|
||||
stream.writeString(aesChachaKey)
|
||||
|
||||
@@ -77,7 +86,7 @@ class PacketMessage : Packet() {
|
||||
|
||||
private fun parseFromStream(
|
||||
parser: Stream,
|
||||
readExtendedAttachmentMeta: Boolean
|
||||
attachmentMetaFieldCount: Int
|
||||
): ParsedPacketMessage? {
|
||||
return runCatching {
|
||||
val parsedFromPublicKey = parser.readString()
|
||||
@@ -100,14 +109,17 @@ class PacketMessage : Packet() {
|
||||
val transportServer: String
|
||||
val encodedFor: String
|
||||
val encoder: String
|
||||
if (readExtendedAttachmentMeta) {
|
||||
if (attachmentMetaFieldCount >= 2) {
|
||||
transportTag = parser.readString()
|
||||
transportServer = parser.readString()
|
||||
encodedFor = parser.readString()
|
||||
encoder = parser.readString()
|
||||
} else {
|
||||
transportTag = ""
|
||||
transportServer = ""
|
||||
}
|
||||
if (attachmentMetaFieldCount >= 4) {
|
||||
encodedFor = parser.readString()
|
||||
encoder = parser.readString()
|
||||
} else {
|
||||
encodedFor = ""
|
||||
encoder = ""
|
||||
}
|
||||
|
||||
@@ -1,167 +1,318 @@
|
||||
package com.rosetta.messenger.network
|
||||
|
||||
/**
|
||||
* Binary stream for protocol packets
|
||||
* Matches the React Native implementation exactly
|
||||
* Binary stream for protocol packets.
|
||||
*
|
||||
* Parity with desktop/server:
|
||||
* - signed: Int8/16/32/64 (two's complement)
|
||||
* - unsigned: UInt8/16/32/64
|
||||
* - String: length(UInt32) + chars(UInt16)
|
||||
* - byte[]: length(UInt32) + raw bytes
|
||||
*/
|
||||
class Stream(stream: ByteArray = ByteArray(0)) {
|
||||
private var _stream = mutableListOf<Int>()
|
||||
private var _readPointer = 0
|
||||
private var _writePointer = 0
|
||||
class Stream(initial: ByteArray = byteArrayOf()) {
|
||||
private var stream: ByteArray = initial.copyOf()
|
||||
private var readPointer: Int = 0 // bits
|
||||
private var writePointer: Int = stream.size shl 3 // bits
|
||||
|
||||
init {
|
||||
_stream = stream.map { it.toInt() and 0xFF }.toMutableList()
|
||||
fun getStream(): ByteArray = stream.copyOf(length())
|
||||
|
||||
fun setStream(value: ByteArray) {
|
||||
stream = value.copyOf()
|
||||
readPointer = 0
|
||||
writePointer = stream.size shl 3
|
||||
}
|
||||
|
||||
fun getStream(): ByteArray {
|
||||
return _stream.map { it.toByte() }.toByteArray()
|
||||
}
|
||||
fun getBuffer(): ByteArray = getStream()
|
||||
|
||||
fun getReadPointerBits(): Int = _readPointer
|
||||
fun isEmpty(): Boolean = writePointer == 0
|
||||
|
||||
fun length(): Int = (writePointer + 7) shr 3
|
||||
|
||||
fun getReadPointerBits(): Int = readPointer
|
||||
|
||||
fun setReadPointerBits(bits: Int) {
|
||||
_readPointer = bits.coerceIn(0, getTotalBits())
|
||||
readPointer = bits.coerceIn(0, writePointer)
|
||||
}
|
||||
|
||||
fun getTotalBits(): Int = _stream.size * 8
|
||||
fun getTotalBits(): Int = writePointer
|
||||
|
||||
fun getRemainingBits(): Int = getTotalBits() - _readPointer
|
||||
fun getRemainingBits(): Int = (writePointer - readPointer).coerceAtLeast(0)
|
||||
|
||||
fun hasRemainingBits(): Boolean = _readPointer < getTotalBits()
|
||||
|
||||
fun setStream(stream: ByteArray) {
|
||||
_stream = stream.map { it.toInt() and 0xFF }.toMutableList()
|
||||
_readPointer = 0
|
||||
}
|
||||
|
||||
fun writeInt8(value: Int) {
|
||||
val negationBit = if (value < 0) 1 else 0
|
||||
val int8Value = Math.abs(value) and 0xFF
|
||||
|
||||
ensureCapacity(_writePointer shr 3)
|
||||
_stream[_writePointer shr 3] = _stream[_writePointer shr 3] or (negationBit shl (7 - (_writePointer and 7)))
|
||||
_writePointer++
|
||||
|
||||
for (i in 0 until 8) {
|
||||
val bit = (int8Value shr (7 - i)) and 1
|
||||
ensureCapacity(_writePointer shr 3)
|
||||
_stream[_writePointer shr 3] = _stream[_writePointer shr 3] or (bit shl (7 - (_writePointer and 7)))
|
||||
_writePointer++
|
||||
}
|
||||
}
|
||||
|
||||
fun readInt8(): Int {
|
||||
var value = 0
|
||||
val negationBit = (_stream[_readPointer shr 3] shr (7 - (_readPointer and 7))) and 1
|
||||
_readPointer++
|
||||
|
||||
for (i in 0 until 8) {
|
||||
val bit = (_stream[_readPointer shr 3] shr (7 - (_readPointer and 7))) and 1
|
||||
value = value or (bit shl (7 - i))
|
||||
_readPointer++
|
||||
}
|
||||
|
||||
return if (negationBit == 1) -value else value
|
||||
}
|
||||
fun hasRemainingBits(): Boolean = readPointer < writePointer
|
||||
|
||||
fun writeBit(value: Int) {
|
||||
val bit = value and 1
|
||||
ensureCapacity(_writePointer shr 3)
|
||||
_stream[_writePointer shr 3] = _stream[_writePointer shr 3] or (bit shl (7 - (_writePointer and 7)))
|
||||
_writePointer++
|
||||
writeBits((value and 1).toLong(), 1)
|
||||
}
|
||||
|
||||
fun readBit(): Int {
|
||||
val bit = (_stream[_readPointer shr 3] shr (7 - (_readPointer and 7))) and 1
|
||||
_readPointer++
|
||||
return bit
|
||||
}
|
||||
fun readBit(): Int = readBits(1).toInt()
|
||||
|
||||
fun writeBoolean(value: Boolean) {
|
||||
writeBit(if (value) 1 else 0)
|
||||
}
|
||||
|
||||
fun readBoolean(): Boolean {
|
||||
return readBit() == 1
|
||||
fun readBoolean(): Boolean = readBit() == 1
|
||||
|
||||
fun writeByte(value: Byte) {
|
||||
writeUInt8(value.toInt() and 0xFF)
|
||||
}
|
||||
|
||||
fun readByte(): Byte = readUInt8().toByte()
|
||||
|
||||
fun writeUInt8(value: Int) {
|
||||
val v = value and 0xFF
|
||||
|
||||
// Fast path when byte-aligned.
|
||||
if ((writePointer and 7) == 0) {
|
||||
reserveBits(8)
|
||||
stream[writePointer shr 3] = v.toByte()
|
||||
writePointer += 8
|
||||
return
|
||||
}
|
||||
|
||||
writeBits(v.toLong(), 8)
|
||||
}
|
||||
|
||||
fun readUInt8(): Int {
|
||||
if (remainingBits() < 8L) {
|
||||
throw IllegalStateException("Not enough bits to read UInt8")
|
||||
}
|
||||
|
||||
// Fast path when byte-aligned.
|
||||
if ((readPointer and 7) == 0) {
|
||||
val value = stream[readPointer shr 3].toInt() and 0xFF
|
||||
readPointer += 8
|
||||
return value
|
||||
}
|
||||
|
||||
return readBits(8).toInt() and 0xFF
|
||||
}
|
||||
|
||||
fun writeInt8(value: Int) {
|
||||
writeUInt8(value)
|
||||
}
|
||||
|
||||
fun readInt8(): Int = readUInt8().toByte().toInt()
|
||||
|
||||
fun writeUInt16(value: Int) {
|
||||
val v = value and 0xFFFF
|
||||
writeUInt8((v ushr 8) and 0xFF)
|
||||
writeUInt8(v and 0xFF)
|
||||
}
|
||||
|
||||
fun readUInt16(): Int {
|
||||
val hi = readUInt8()
|
||||
val lo = readUInt8()
|
||||
return (hi shl 8) or lo
|
||||
}
|
||||
|
||||
fun writeInt16(value: Int) {
|
||||
writeInt8(value shr 8)
|
||||
writeInt8(value and 0xFF)
|
||||
writeUInt16(value)
|
||||
}
|
||||
|
||||
fun readInt16(): Int {
|
||||
val high = readInt8() shl 8
|
||||
return high or readInt8()
|
||||
fun readInt16(): Int = readUInt16().toShort().toInt()
|
||||
|
||||
fun writeUInt32(value: Long) {
|
||||
if (value < 0L || value > 0xFFFF_FFFFL) {
|
||||
throw IllegalArgumentException("UInt32 out of range: $value")
|
||||
}
|
||||
|
||||
writeUInt8(((value ushr 24) and 0xFF).toInt())
|
||||
writeUInt8(((value ushr 16) and 0xFF).toInt())
|
||||
writeUInt8(((value ushr 8) and 0xFF).toInt())
|
||||
writeUInt8((value and 0xFF).toInt())
|
||||
}
|
||||
|
||||
fun readUInt32(): Long {
|
||||
val b1 = readUInt8().toLong() and 0xFFL
|
||||
val b2 = readUInt8().toLong() and 0xFFL
|
||||
val b3 = readUInt8().toLong() and 0xFFL
|
||||
val b4 = readUInt8().toLong() and 0xFFL
|
||||
return (b1 shl 24) or (b2 shl 16) or (b3 shl 8) or b4
|
||||
}
|
||||
|
||||
fun writeInt32(value: Int) {
|
||||
writeInt16(value shr 16)
|
||||
writeInt16(value and 0xFFFF)
|
||||
writeUInt32(value.toLong() and 0xFFFF_FFFFL)
|
||||
}
|
||||
|
||||
fun readInt32(): Int {
|
||||
val high = readInt16() shl 16
|
||||
return high or readInt16()
|
||||
fun readInt32(): Int = readUInt32().toInt()
|
||||
|
||||
/** Writes raw 64-bit pattern (UInt64 bit-pattern in Long). */
|
||||
fun writeUInt64(value: Long) {
|
||||
writeUInt8(((value ushr 56) and 0xFF).toInt())
|
||||
writeUInt8(((value ushr 48) and 0xFF).toInt())
|
||||
writeUInt8(((value ushr 40) and 0xFF).toInt())
|
||||
writeUInt8(((value ushr 32) and 0xFF).toInt())
|
||||
writeUInt8(((value ushr 24) and 0xFF).toInt())
|
||||
writeUInt8(((value ushr 16) and 0xFF).toInt())
|
||||
writeUInt8(((value ushr 8) and 0xFF).toInt())
|
||||
writeUInt8((value and 0xFF).toInt())
|
||||
}
|
||||
|
||||
fun writeInt64(value: Long) {
|
||||
val high = (value shr 32).toInt()
|
||||
val low = (value and 0xFFFFFFFF).toInt()
|
||||
writeInt32(high)
|
||||
writeInt32(low)
|
||||
}
|
||||
|
||||
fun readInt64(): Long {
|
||||
val high = readInt32().toLong()
|
||||
val low = (readInt32().toLong() and 0xFFFFFFFFL)
|
||||
/** Reads raw 64-bit pattern (UInt64 bit-pattern in Long). */
|
||||
fun readUInt64(): Long {
|
||||
val high = readUInt32() and 0xFFFF_FFFFL
|
||||
val low = readUInt32() and 0xFFFF_FFFFL
|
||||
return (high shl 32) or low
|
||||
}
|
||||
|
||||
fun writeInt64(value: Long) {
|
||||
writeUInt64(value)
|
||||
}
|
||||
|
||||
fun readInt64(): Long = readUInt64()
|
||||
|
||||
fun writeFloat32(value: Float) {
|
||||
writeInt32(java.lang.Float.floatToIntBits(value))
|
||||
}
|
||||
|
||||
fun readFloat32(): Float = java.lang.Float.intBitsToFloat(readInt32())
|
||||
|
||||
/** String: length(UInt32) + chars(UInt16). */
|
||||
fun writeString(value: String) {
|
||||
writeInt32(value.length)
|
||||
for (char in value) {
|
||||
writeInt16(char.code)
|
||||
writeUInt32(value.length.toLong())
|
||||
|
||||
if (value.isEmpty()) return
|
||||
|
||||
reserveBits(value.length.toLong() * 16L)
|
||||
for (i in value.indices) {
|
||||
writeUInt16(value[i].code and 0xFFFF)
|
||||
}
|
||||
}
|
||||
|
||||
fun readString(): String {
|
||||
val length = readInt32()
|
||||
// Desktop parity + safety: don't trust malformed string length.
|
||||
val bytesAvailable = _stream.size - (_readPointer shr 3)
|
||||
if (length < 0 || (length.toLong() * 2L) > bytesAvailable.toLong()) {
|
||||
android.util.Log.w(
|
||||
"RosettaStream",
|
||||
"readString invalid length=$length, bytesAvailable=$bytesAvailable, readPointer=$_readPointer"
|
||||
)
|
||||
return ""
|
||||
val lenLong = readUInt32()
|
||||
if (lenLong > Int.MAX_VALUE.toLong()) {
|
||||
throw IllegalStateException("String length too large: $lenLong")
|
||||
}
|
||||
val sb = StringBuilder()
|
||||
for (i in 0 until length) {
|
||||
sb.append(readInt16().toChar())
|
||||
|
||||
val length = lenLong.toInt()
|
||||
val requiredBits = length.toLong() * 16L
|
||||
if (requiredBits > remainingBits()) {
|
||||
throw IllegalStateException("Not enough bits to read string")
|
||||
}
|
||||
|
||||
val sb = StringBuilder(length)
|
||||
repeat(length) {
|
||||
sb.append(readUInt16().toChar())
|
||||
}
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
/** byte[]: length(UInt32) + payload. */
|
||||
fun writeBytes(value: ByteArray) {
|
||||
writeInt32(value.size)
|
||||
for (byte in value) {
|
||||
writeInt8(byte.toInt())
|
||||
writeUInt32(value.size.toLong())
|
||||
|
||||
if (value.isEmpty()) return
|
||||
|
||||
reserveBits(value.size.toLong() * 8L)
|
||||
|
||||
// Fast path when byte-aligned.
|
||||
if ((writePointer and 7) == 0) {
|
||||
val byteIndex = writePointer shr 3
|
||||
ensureCapacity(byteIndex + value.size - 1)
|
||||
System.arraycopy(value, 0, stream, byteIndex, value.size)
|
||||
writePointer += value.size shl 3
|
||||
return
|
||||
}
|
||||
|
||||
value.forEach { writeUInt8(it.toInt() and 0xFF) }
|
||||
}
|
||||
|
||||
fun readBytes(): ByteArray {
|
||||
val length = readInt32()
|
||||
val bytes = ByteArray(length)
|
||||
for (i in 0 until length) {
|
||||
bytes[i] = readInt8().toByte()
|
||||
}
|
||||
return bytes
|
||||
val lenLong = readUInt32()
|
||||
if (lenLong == 0L) return byteArrayOf()
|
||||
if (lenLong > Int.MAX_VALUE.toLong()) {
|
||||
throw IllegalStateException("Byte array too large: $lenLong")
|
||||
}
|
||||
|
||||
private fun ensureCapacity(index: Int) {
|
||||
while (_stream.size <= index) {
|
||||
_stream.add(0)
|
||||
val length = lenLong.toInt()
|
||||
val requiredBits = length.toLong() * 8L
|
||||
if (requiredBits > remainingBits()) {
|
||||
return byteArrayOf()
|
||||
}
|
||||
|
||||
val out = ByteArray(length)
|
||||
|
||||
// Fast path when byte-aligned.
|
||||
if ((readPointer and 7) == 0) {
|
||||
val byteIndex = readPointer shr 3
|
||||
System.arraycopy(stream, byteIndex, out, 0, length)
|
||||
readPointer += length shl 3
|
||||
return out
|
||||
}
|
||||
|
||||
for (i in 0 until length) {
|
||||
out[i] = readUInt8().toByte()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private fun remainingBits(): Long = (writePointer - readPointer).toLong()
|
||||
|
||||
private fun writeBits(value: Long, bits: Int) {
|
||||
if (bits <= 0) return
|
||||
|
||||
reserveBits(bits.toLong())
|
||||
|
||||
for (i in bits - 1 downTo 0) {
|
||||
val bit = ((value ushr i) and 1L).toInt()
|
||||
val byteIndex = writePointer shr 3
|
||||
val shift = 7 - (writePointer and 7)
|
||||
|
||||
stream[byteIndex] =
|
||||
if (bit == 1) {
|
||||
(stream[byteIndex].toInt() or (1 shl shift)).toByte()
|
||||
} else {
|
||||
(stream[byteIndex].toInt() and (1 shl shift).inv()).toByte()
|
||||
}
|
||||
|
||||
writePointer++
|
||||
}
|
||||
}
|
||||
|
||||
private fun readBits(bits: Int): Long {
|
||||
if (bits <= 0) return 0L
|
||||
if (remainingBits() < bits.toLong()) {
|
||||
throw IllegalStateException("Not enough bits to read")
|
||||
}
|
||||
|
||||
var value = 0L
|
||||
repeat(bits) {
|
||||
val bit =
|
||||
(stream[readPointer shr 3].toInt() ushr
|
||||
(7 - (readPointer and 7))) and
|
||||
1
|
||||
value = (value shl 1) or bit.toLong()
|
||||
readPointer++
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
private fun reserveBits(bitsToWrite: Long) {
|
||||
if (bitsToWrite <= 0L) return
|
||||
|
||||
val lastBitIndex = writePointer.toLong() + bitsToWrite - 1L
|
||||
if (lastBitIndex < 0L) {
|
||||
throw IllegalStateException("Bit index overflow")
|
||||
}
|
||||
|
||||
val byteIndex = lastBitIndex ushr 3
|
||||
if (byteIndex > Int.MAX_VALUE.toLong()) {
|
||||
throw IllegalStateException("Stream too large")
|
||||
}
|
||||
|
||||
ensureCapacity(byteIndex.toInt())
|
||||
}
|
||||
|
||||
private fun ensureCapacity(byteIndex: Int) {
|
||||
val requiredSize = byteIndex + 1
|
||||
if (requiredSize <= stream.size) return
|
||||
|
||||
var newSize = if (stream.isEmpty()) 32 else stream.size
|
||||
while (newSize < requiredSize) {
|
||||
newSize = if (newSize <= Int.MAX_VALUE / 2) newSize shl 1 else requiredSize
|
||||
}
|
||||
|
||||
stream = stream.copyOf(newSize)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -1544,6 +1544,23 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
val preview = first.optString("preview", "")
|
||||
if (!isLikelyCallAttachmentPreview(preview)) return null
|
||||
val transportObj = first.optJSONObject("transport")
|
||||
val transportTag =
|
||||
first.optString(
|
||||
"transportTag",
|
||||
first.optString(
|
||||
"transport_tag",
|
||||
transportObj?.optString("transport_tag", "") ?: ""
|
||||
)
|
||||
)
|
||||
val transportServer =
|
||||
first.optString(
|
||||
"transportServer",
|
||||
first.optString(
|
||||
"transport_server",
|
||||
transportObj?.optString("transport_server", "") ?: ""
|
||||
)
|
||||
)
|
||||
|
||||
return MessageAttachment(
|
||||
id = first.optString("id", "").ifBlank { "call-$fallbackId" },
|
||||
@@ -1551,7 +1568,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
type = AttachmentType.CALL,
|
||||
preview = preview,
|
||||
width = 0,
|
||||
height = 0
|
||||
height = 0,
|
||||
transportTag = transportTag,
|
||||
transportServer = transportServer
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1618,6 +1637,23 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
var blob = attachment.optString("blob", "")
|
||||
val attachmentId = attachment.optString("id", "")
|
||||
val transportObj = attachment.optJSONObject("transport")
|
||||
val transportTag =
|
||||
attachment.optString(
|
||||
"transportTag",
|
||||
attachment.optString(
|
||||
"transport_tag",
|
||||
transportObj?.optString("transport_tag", "") ?: ""
|
||||
)
|
||||
)
|
||||
val transportServer =
|
||||
attachment.optString(
|
||||
"transportServer",
|
||||
attachment.optString(
|
||||
"transport_server",
|
||||
transportObj?.optString("transport_server", "") ?: ""
|
||||
)
|
||||
)
|
||||
|
||||
// 💾 Для IMAGE и AVATAR - пробуем загрузить blob из файла если пустой
|
||||
if ((effectiveType == AttachmentType.IMAGE ||
|
||||
@@ -1649,7 +1685,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
attachment.optString(
|
||||
"localUri",
|
||||
""
|
||||
) // 🔥 Поддержка localUri из БД
|
||||
), // 🔥 Поддержка localUri из БД
|
||||
transportTag = transportTag,
|
||||
transportServer = transportServer
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -1870,11 +1908,38 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
val attWidth = attJson.optInt("width", 0)
|
||||
val attHeight = attJson.optInt("height", 0)
|
||||
val attLocalUri = attJson.optString("localUri", "")
|
||||
val transportObj = attJson.optJSONObject("transport")
|
||||
val attTransportTag =
|
||||
attJson.optString(
|
||||
"transportTag",
|
||||
attJson.optString(
|
||||
"transport_tag",
|
||||
transportObj?.optString(
|
||||
"transport_tag",
|
||||
""
|
||||
)
|
||||
?: ""
|
||||
)
|
||||
)
|
||||
val attTransportServer =
|
||||
attJson.optString(
|
||||
"transportServer",
|
||||
attJson.optString(
|
||||
"transport_server",
|
||||
transportObj?.optString(
|
||||
"transport_server",
|
||||
""
|
||||
)
|
||||
?: ""
|
||||
)
|
||||
)
|
||||
if (attId.isNotEmpty()) {
|
||||
fwdAttachments.add(MessageAttachment(
|
||||
id = attId, type = attType, preview = attPreview,
|
||||
blob = attBlob, width = attWidth, height = attHeight,
|
||||
localUri = attLocalUri
|
||||
localUri = attLocalUri,
|
||||
transportTag = attTransportTag,
|
||||
transportServer = attTransportServer
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -1952,6 +2017,31 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
val attWidth = attJson.optInt("width", 0)
|
||||
val attHeight = attJson.optInt("height", 0)
|
||||
val attLocalUri = attJson.optString("localUri", "")
|
||||
val transportObj = attJson.optJSONObject("transport")
|
||||
val attTransportTag =
|
||||
attJson.optString(
|
||||
"transportTag",
|
||||
attJson.optString(
|
||||
"transport_tag",
|
||||
transportObj?.optString(
|
||||
"transport_tag",
|
||||
""
|
||||
)
|
||||
?: ""
|
||||
)
|
||||
)
|
||||
val attTransportServer =
|
||||
attJson.optString(
|
||||
"transportServer",
|
||||
attJson.optString(
|
||||
"transport_server",
|
||||
transportObj?.optString(
|
||||
"transport_server",
|
||||
""
|
||||
)
|
||||
?: ""
|
||||
)
|
||||
)
|
||||
|
||||
if (attId.isNotEmpty()) {
|
||||
replyAttachmentsFromJson.add(
|
||||
@@ -1962,7 +2052,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
blob = attBlob,
|
||||
width = attWidth,
|
||||
height = attHeight,
|
||||
localUri = attLocalUri
|
||||
localUri = attLocalUri,
|
||||
transportTag = attTransportTag,
|
||||
transportServer = attTransportServer
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -2180,21 +2272,16 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
encryptedKey = encryptResult.encryptedKey,
|
||||
aesChachaKey = encryptAesChachaKey(encryptResult.plainKeyAndNonce, privateKey),
|
||||
plainKeyAndNonce = encryptResult.plainKeyAndNonce,
|
||||
attachmentPassword = String(encryptResult.plainKeyAndNonce, Charsets.ISO_8859_1),
|
||||
// Desktop parity: attachments are encrypted with key+nonce HEX password.
|
||||
attachmentPassword =
|
||||
encryptResult.plainKeyAndNonce.joinToString("") { "%02x".format(it) },
|
||||
isGroup = false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun encryptAttachmentPayload(payload: String, context: OutgoingEncryptionContext): String {
|
||||
return if (context.isGroup) {
|
||||
CryptoManager.encryptWithPassword(payload, context.attachmentPassword)
|
||||
} else {
|
||||
val plainKeyAndNonce =
|
||||
context.plainKeyAndNonce
|
||||
?: throw IllegalStateException("Missing key+nonce for direct message")
|
||||
MessageCrypto.encryptReplyBlob(payload, plainKeyAndNonce)
|
||||
}
|
||||
return CryptoManager.encryptWithPassword(payload, context.attachmentPassword)
|
||||
}
|
||||
|
||||
/** Обновить текст ввода */
|
||||
@@ -2720,8 +2807,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
var replyBlobForDatabase = "" // Зашифрованный blob для БД (приватным ключом)
|
||||
|
||||
// 📸 Forward: сначала загружаем IMAGE на CDN, чтобы обновить ссылки в MESSAGES blob
|
||||
// Map: originalAttId → (newAttId, newPreview) — для подстановки в reply JSON
|
||||
val forwardedAttMap = mutableMapOf<String, Pair<String, String>>()
|
||||
// Map: originalAttId -> updated attachment metadata.
|
||||
val forwardedAttMap = mutableMapOf<String, MessageAttachment>()
|
||||
if (isForwardToSend && replyMsgsToSend.isNotEmpty()) {
|
||||
val context = getApplication<Application>()
|
||||
val isSaved = (sender == recipient)
|
||||
@@ -2746,10 +2833,22 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
uploadTag = TransportManager.uploadFile(newAttId, encryptedBlob)
|
||||
}
|
||||
|
||||
val blurhash = att.preview.substringAfter("::", "")
|
||||
val newPreview = if (uploadTag.isNotEmpty()) "$uploadTag::$blurhash" else blurhash
|
||||
val blurhash = att.preview.substringAfter("::", att.preview)
|
||||
val transportServer =
|
||||
if (uploadTag.isNotEmpty()) {
|
||||
TransportManager.getTransportServer().orEmpty()
|
||||
} else {
|
||||
""
|
||||
}
|
||||
|
||||
forwardedAttMap[att.id] = Pair(newAttId, newPreview)
|
||||
forwardedAttMap[att.id] =
|
||||
att.copy(
|
||||
id = newAttId,
|
||||
preview = blurhash,
|
||||
blob = "",
|
||||
transportTag = uploadTag,
|
||||
transportServer = transportServer
|
||||
)
|
||||
|
||||
// Сохраняем локально с новым ID
|
||||
// publicKey = msg.publicKey чтобы совпадал с JSON для parseReplyFromAttachments
|
||||
@@ -2774,10 +2873,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
replyMsgsToSend.forEach { msg ->
|
||||
val attachmentsArray = JSONArray()
|
||||
msg.attachments.forEach { att ->
|
||||
// Для forward IMAGE: подставляем НОВЫЙ id и preview (CDN tag)
|
||||
// Для forward IMAGE: подставляем НОВЫЙ id/preview/transport.
|
||||
val fwdInfo = forwardedAttMap[att.id]
|
||||
val attId = fwdInfo?.first ?: att.id
|
||||
val attPreview = fwdInfo?.second ?: att.preview
|
||||
val attId = fwdInfo?.id ?: att.id
|
||||
val attPreview = fwdInfo?.preview ?: att.preview
|
||||
val attTransportTag = fwdInfo?.transportTag ?: att.transportTag
|
||||
val attTransportServer = fwdInfo?.transportServer ?: att.transportServer
|
||||
|
||||
attachmentsArray.put(
|
||||
JSONObject().apply {
|
||||
@@ -2791,6 +2892,15 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
if (att.type == AttachmentType.MESSAGES) att.blob
|
||||
else ""
|
||||
)
|
||||
put("transportTag", attTransportTag)
|
||||
put("transportServer", attTransportServer)
|
||||
put(
|
||||
"transport",
|
||||
JSONObject().apply {
|
||||
put("transport_tag", attTransportTag)
|
||||
put("transport_server", attTransportServer)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -2876,6 +2986,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
put("preview", att.preview)
|
||||
put("width", att.width)
|
||||
put("height", att.height)
|
||||
put("transportTag", att.transportTag)
|
||||
put("transportServer", att.transportServer)
|
||||
// Только для MESSAGES сохраняем blob (reply
|
||||
// data небольшие)
|
||||
// Для IMAGE/FILE - пустой blob
|
||||
@@ -2975,7 +3087,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
val replyAttachmentId = "reply_${timestamp}"
|
||||
|
||||
fun buildForwardReplyJson(
|
||||
forwardedIdMap: Map<String, Pair<String, String>> = emptyMap(),
|
||||
forwardedIdMap: Map<String, MessageAttachment> = emptyMap(),
|
||||
includeLocalUri: Boolean
|
||||
): JSONArray {
|
||||
val replyJsonArray = JSONArray()
|
||||
@@ -2983,8 +3095,10 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
val attachmentsArray = JSONArray()
|
||||
fm.attachments.forEach { att ->
|
||||
val fwdInfo = forwardedIdMap[att.id]
|
||||
val attId = fwdInfo?.first ?: att.id
|
||||
val attPreview = fwdInfo?.second ?: att.preview
|
||||
val attId = fwdInfo?.id ?: att.id
|
||||
val attPreview = fwdInfo?.preview ?: att.preview
|
||||
val attTransportTag = fwdInfo?.transportTag ?: att.transportTag
|
||||
val attTransportServer = fwdInfo?.transportServer ?: att.transportServer
|
||||
|
||||
attachmentsArray.put(
|
||||
JSONObject().apply {
|
||||
@@ -2994,6 +3108,15 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
put("width", att.width)
|
||||
put("height", att.height)
|
||||
put("blob", if (att.type == AttachmentType.MESSAGES) att.blob else "")
|
||||
put("transportTag", attTransportTag)
|
||||
put("transportServer", attTransportServer)
|
||||
put(
|
||||
"transport",
|
||||
JSONObject().apply {
|
||||
put("transport_tag", attTransportTag)
|
||||
put("transport_server", attTransportServer)
|
||||
}
|
||||
)
|
||||
if (includeLocalUri && att.localUri.isNotEmpty()) {
|
||||
put("localUri", att.localUri)
|
||||
}
|
||||
@@ -3099,8 +3222,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}
|
||||
|
||||
// 📸 Forward: загружаем IMAGE на CDN и пересобираем MESSAGES blob с новыми ID/tag
|
||||
// Map: originalAttId → (newAttId, newPreview)
|
||||
val forwardedAttMap = mutableMapOf<String, Pair<String, String>>()
|
||||
// Map: originalAttId -> updated attachment metadata.
|
||||
val forwardedAttMap = mutableMapOf<String, MessageAttachment>()
|
||||
var fwdIdx = 0
|
||||
for (fm in forwardMessages) {
|
||||
for (att in fm.attachments) {
|
||||
@@ -3121,10 +3244,21 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
uploadTag = TransportManager.uploadFile(newAttId, encryptedBlob)
|
||||
}
|
||||
|
||||
val blurhash = att.preview.substringAfter("::", "")
|
||||
val newPreview = if (uploadTag.isNotEmpty()) "$uploadTag::$blurhash" else blurhash
|
||||
|
||||
forwardedAttMap[att.id] = Pair(newAttId, newPreview)
|
||||
val blurhash = att.preview.substringAfter("::", att.preview)
|
||||
val transportServer =
|
||||
if (uploadTag.isNotEmpty()) {
|
||||
TransportManager.getTransportServer().orEmpty()
|
||||
} else {
|
||||
""
|
||||
}
|
||||
forwardedAttMap[att.id] =
|
||||
att.copy(
|
||||
id = newAttId,
|
||||
preview = blurhash,
|
||||
blob = "",
|
||||
transportTag = uploadTag,
|
||||
transportServer = transportServer
|
||||
)
|
||||
|
||||
// Сохраняем локально с новым ID
|
||||
// publicKey = fm.senderPublicKey чтобы совпадал с JSON для parseReplyFromAttachments
|
||||
@@ -3492,17 +3626,24 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
logPhotoPipeline(messageId, "saved-messages mode: upload skipped")
|
||||
}
|
||||
|
||||
// Preview содержит tag::blurhash
|
||||
val previewWithTag = if (uploadTag.isNotEmpty()) "$uploadTag::$blurhash" else blurhash
|
||||
val attachmentTransportServer =
|
||||
if (uploadTag.isNotEmpty()) {
|
||||
TransportManager.getTransportServer().orEmpty()
|
||||
} else {
|
||||
""
|
||||
}
|
||||
val previewValue = blurhash
|
||||
|
||||
val imageAttachment =
|
||||
MessageAttachment(
|
||||
id = attachmentId,
|
||||
blob = "",
|
||||
type = AttachmentType.IMAGE,
|
||||
preview = previewWithTag,
|
||||
preview = previewValue,
|
||||
width = width,
|
||||
height = height
|
||||
height = height,
|
||||
transportTag = uploadTag,
|
||||
transportServer = attachmentTransportServer
|
||||
)
|
||||
|
||||
val packet =
|
||||
@@ -3545,10 +3686,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
JSONObject().apply {
|
||||
put("id", attachmentId)
|
||||
put("type", AttachmentType.IMAGE.value)
|
||||
put("preview", previewWithTag)
|
||||
put("preview", previewValue)
|
||||
put("blob", "")
|
||||
put("width", width)
|
||||
put("height", height)
|
||||
put("transportTag", uploadTag)
|
||||
put("transportServer", attachmentTransportServer)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -3675,18 +3818,24 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
uploadTag = TransportManager.uploadFile(attachmentId, encryptedImageBlob)
|
||||
}
|
||||
|
||||
// Preview содержит tag::blurhash (как в desktop)
|
||||
val previewWithTag =
|
||||
if (uploadTag.isNotEmpty()) "$uploadTag::$blurhash" else blurhash
|
||||
val attachmentTransportServer =
|
||||
if (uploadTag.isNotEmpty()) {
|
||||
TransportManager.getTransportServer().orEmpty()
|
||||
} else {
|
||||
""
|
||||
}
|
||||
val previewValue = blurhash
|
||||
|
||||
val imageAttachment =
|
||||
MessageAttachment(
|
||||
id = attachmentId,
|
||||
blob = "", // 🔥 Пустой blob - файл на Transport Server!
|
||||
type = AttachmentType.IMAGE,
|
||||
preview = previewWithTag,
|
||||
preview = previewValue,
|
||||
width = width,
|
||||
height = height
|
||||
height = height,
|
||||
transportTag = uploadTag,
|
||||
transportServer = attachmentTransportServer
|
||||
)
|
||||
|
||||
val packet =
|
||||
@@ -3725,10 +3874,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
JSONObject().apply {
|
||||
put("id", attachmentId)
|
||||
put("type", AttachmentType.IMAGE.value)
|
||||
put("preview", previewWithTag)
|
||||
put("preview", previewValue)
|
||||
put("blob", "") // Пустой blob - не сохраняем в БД!
|
||||
put("width", width)
|
||||
put("height", height)
|
||||
put("transportTag", uploadTag)
|
||||
put("transportServer", attachmentTransportServer)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -3969,9 +4120,13 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
} else {
|
||||
""
|
||||
}
|
||||
val previewWithTag =
|
||||
if (uploadTag.isNotEmpty()) "$uploadTag::${imageData.blurhash}"
|
||||
else imageData.blurhash
|
||||
val attachmentTransportServer =
|
||||
if (uploadTag.isNotEmpty()) {
|
||||
TransportManager.getTransportServer().orEmpty()
|
||||
} else {
|
||||
""
|
||||
}
|
||||
val previewValue = imageData.blurhash
|
||||
|
||||
AttachmentFileManager.saveAttachment(
|
||||
context = context,
|
||||
@@ -3986,10 +4141,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
id = attachmentId,
|
||||
blob = if (uploadTag.isNotEmpty()) "" else encryptedImageBlob,
|
||||
type = AttachmentType.IMAGE,
|
||||
preview = previewWithTag,
|
||||
preview = previewValue,
|
||||
width = imageData.width,
|
||||
height = imageData.height,
|
||||
localUri = ""
|
||||
localUri = "",
|
||||
transportTag = uploadTag,
|
||||
transportServer = attachmentTransportServer
|
||||
)
|
||||
networkAttachments.add(finalAttachment)
|
||||
finalAttachmentsById[attachmentId] = finalAttachment
|
||||
@@ -3998,10 +4155,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
JSONObject().apply {
|
||||
put("id", attachmentId)
|
||||
put("type", AttachmentType.IMAGE.value)
|
||||
put("preview", previewWithTag)
|
||||
put("preview", previewValue)
|
||||
put("blob", "")
|
||||
put("width", imageData.width)
|
||||
put("height", imageData.height)
|
||||
put("transportTag", uploadTag)
|
||||
put("transportServer", attachmentTransportServer)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -4175,12 +4334,13 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
// Загружаем на Transport Server
|
||||
val uploadTag = TransportManager.uploadFile(attachmentId, encryptedImageBlob)
|
||||
val previewWithTag =
|
||||
if (uploadTag != null) {
|
||||
"$uploadTag::${imageData.blurhash}"
|
||||
val attachmentTransportServer =
|
||||
if (uploadTag.isNotEmpty()) {
|
||||
TransportManager.getTransportServer().orEmpty()
|
||||
} else {
|
||||
imageData.blurhash
|
||||
""
|
||||
}
|
||||
val previewValue = imageData.blurhash
|
||||
logPhotoPipeline(
|
||||
messageId,
|
||||
"group item#$index upload done: tag=${shortPhotoId(uploadTag, 12)}"
|
||||
@@ -4199,11 +4359,13 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
networkAttachments.add(
|
||||
MessageAttachment(
|
||||
id = attachmentId,
|
||||
blob = if (uploadTag != null) "" else encryptedImageBlob,
|
||||
blob = if (uploadTag.isNotEmpty()) "" else encryptedImageBlob,
|
||||
type = AttachmentType.IMAGE,
|
||||
preview = previewWithTag,
|
||||
preview = previewValue,
|
||||
width = imageData.width,
|
||||
height = imageData.height
|
||||
height = imageData.height,
|
||||
transportTag = uploadTag,
|
||||
transportServer = attachmentTransportServer
|
||||
)
|
||||
)
|
||||
|
||||
@@ -4212,10 +4374,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
JSONObject().apply {
|
||||
put("id", attachmentId)
|
||||
put("type", AttachmentType.IMAGE.value)
|
||||
put("preview", previewWithTag)
|
||||
put("preview", previewValue)
|
||||
put("blob", "") // Пустой blob - изображения в файловой системе
|
||||
put("width", imageData.width)
|
||||
put("height", imageData.height)
|
||||
put("transportTag", uploadTag)
|
||||
put("transportServer", attachmentTransportServer)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -4384,15 +4548,22 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
uploadTag = TransportManager.uploadFile(attachmentId, encryptedFileBlob)
|
||||
}
|
||||
|
||||
// Preview содержит tag::size::name (как в desktop)
|
||||
val previewWithTag = if (uploadTag.isNotEmpty()) "$uploadTag::$preview" else preview
|
||||
val attachmentTransportServer =
|
||||
if (uploadTag.isNotEmpty()) {
|
||||
TransportManager.getTransportServer().orEmpty()
|
||||
} else {
|
||||
""
|
||||
}
|
||||
val previewValue = preview
|
||||
|
||||
val fileAttachment =
|
||||
MessageAttachment(
|
||||
id = attachmentId,
|
||||
blob = "", // 🔥 Пустой blob - файл на Transport Server!
|
||||
type = AttachmentType.FILE,
|
||||
preview = previewWithTag
|
||||
preview = previewValue,
|
||||
transportTag = uploadTag,
|
||||
transportServer = attachmentTransportServer
|
||||
)
|
||||
|
||||
val packet =
|
||||
@@ -4422,8 +4593,10 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
JSONObject().apply {
|
||||
put("id", attachmentId)
|
||||
put("type", AttachmentType.FILE.value)
|
||||
put("preview", previewWithTag)
|
||||
put("preview", previewValue)
|
||||
put("blob", "") // Пустой blob - не сохраняем в БД!
|
||||
put("transportTag", uploadTag)
|
||||
put("transportServer", attachmentTransportServer)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -4607,17 +4780,22 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}
|
||||
}
|
||||
|
||||
// Preview содержит tag::blurhash (как в desktop)
|
||||
val previewWithTag =
|
||||
if (uploadTag.isNotEmpty()) "$uploadTag::$avatarBlurhash"
|
||||
else avatarBlurhash
|
||||
val attachmentTransportServer =
|
||||
if (uploadTag.isNotEmpty()) {
|
||||
TransportManager.getTransportServer().orEmpty()
|
||||
} else {
|
||||
""
|
||||
}
|
||||
val previewValue = avatarBlurhash
|
||||
|
||||
val avatarAttachment =
|
||||
MessageAttachment(
|
||||
id = avatarAttachmentId,
|
||||
blob = "", // 🔥 ПУСТОЙ blob - файл на Transport Server!
|
||||
type = AttachmentType.AVATAR,
|
||||
preview = previewWithTag
|
||||
preview = previewValue,
|
||||
transportTag = uploadTag,
|
||||
transportServer = attachmentTransportServer
|
||||
)
|
||||
|
||||
// 3. Отправляем пакет (с ПУСТЫМ blob!)
|
||||
@@ -4655,8 +4833,10 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
JSONObject().apply {
|
||||
put("id", avatarAttachmentId)
|
||||
put("type", AttachmentType.AVATAR.value)
|
||||
put("preview", previewWithTag) // tag::blurhash
|
||||
put("preview", previewValue)
|
||||
put("blob", "") // Пустой blob - не сохраняем в БД!
|
||||
put("transportTag", uploadTag)
|
||||
put("transportServer", attachmentTransportServer)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2293,6 +2293,23 @@ private fun parseAttachmentsForGroupInfo(attachmentsJson: String): List<MessageA
|
||||
val attachment = attachments.getJSONObject(i)
|
||||
val type = AttachmentType.fromInt(attachment.optInt("type", 0))
|
||||
if (type == AttachmentType.MESSAGES) continue
|
||||
val transportObj = attachment.optJSONObject("transport")
|
||||
val transportTag =
|
||||
attachment.optString(
|
||||
"transportTag",
|
||||
attachment.optString(
|
||||
"transport_tag",
|
||||
transportObj?.optString("transport_tag", "") ?: ""
|
||||
)
|
||||
)
|
||||
val transportServer =
|
||||
attachment.optString(
|
||||
"transportServer",
|
||||
attachment.optString(
|
||||
"transport_server",
|
||||
transportObj?.optString("transport_server", "") ?: ""
|
||||
)
|
||||
)
|
||||
|
||||
add(
|
||||
MessageAttachment(
|
||||
@@ -2302,7 +2319,9 @@ private fun parseAttachmentsForGroupInfo(attachmentsJson: String): List<MessageA
|
||||
preview = attachment.optString("preview", ""),
|
||||
width = attachment.optInt("width", 0),
|
||||
height = attachment.optInt("height", 0),
|
||||
localUri = attachment.optString("localUri", "")
|
||||
localUri = attachment.optString("localUri", ""),
|
||||
transportTag = transportTag,
|
||||
transportServer = transportServer
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -927,8 +927,8 @@ fun ImageAttachment(
|
||||
var downloadProgress by remember(attachment.id) { mutableStateOf(0f) }
|
||||
var errorLabel by remember(attachment.id) { mutableStateOf("Error") }
|
||||
|
||||
val preview = getPreview(attachment.preview)
|
||||
val downloadTag = getDownloadTag(attachment.preview)
|
||||
val preview = getPreview(attachment)
|
||||
val downloadTag = getDownloadTag(attachment)
|
||||
|
||||
// Анимация прогресса
|
||||
val animatedProgress by
|
||||
@@ -965,8 +965,8 @@ fun ImageAttachment(
|
||||
attachment.blob.isNotEmpty() -> {
|
||||
DownloadStatus.DOWNLOADED
|
||||
}
|
||||
// 2. Если preview НЕ содержит UUID → это наш локальный файл → DOWNLOADED
|
||||
!isDownloadTag(attachment.preview) -> {
|
||||
// 2. Нет transport tag → это локальный файл → DOWNLOADED
|
||||
downloadTag.isBlank() -> {
|
||||
DownloadStatus.DOWNLOADED
|
||||
}
|
||||
// 3. Есть UUID (download tag) → проверяем файловую систему
|
||||
@@ -989,7 +989,7 @@ fun ImageAttachment(
|
||||
}
|
||||
|
||||
// Декодируем blurhash для placeholder (если есть)
|
||||
if (preview.isNotEmpty() && !isDownloadTag(preview)) {
|
||||
if (preview.isNotEmpty()) {
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
blurhashBitmap = BlurHash.decode(preview, 200, 200)
|
||||
@@ -1117,7 +1117,7 @@ fun ImageAttachment(
|
||||
scope.launch {
|
||||
val idShort = shortDebugId(attachment.id)
|
||||
val tagShort = shortDebugId(downloadTag)
|
||||
val server = TransportManager.getTransportServer() ?: "unset"
|
||||
val server = attachment.transportServer.ifBlank { TransportManager.getTransportServer() ?: "unset" }
|
||||
logPhotoDebug("Start image download: id=$idShort, tag=$tagShort, server=$server")
|
||||
try {
|
||||
downloadStatus = DownloadStatus.DOWNLOADING
|
||||
@@ -1126,13 +1126,23 @@ fun ImageAttachment(
|
||||
val startTime = System.currentTimeMillis()
|
||||
val encryptedContent: String
|
||||
try {
|
||||
encryptedContent = TransportManager.downloadFile(attachment.id, downloadTag)
|
||||
encryptedContent =
|
||||
TransportManager.downloadFile(
|
||||
attachment.id,
|
||||
downloadTag,
|
||||
attachment.transportServer
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
// Один авто-ретрай через 1с
|
||||
logPhotoDebug("CDN download failed, retrying: id=$idShort, reason=${e.message}")
|
||||
kotlinx.coroutines.delay(1000)
|
||||
try {
|
||||
val retryResult = TransportManager.downloadFile(attachment.id, downloadTag)
|
||||
val retryResult =
|
||||
TransportManager.downloadFile(
|
||||
attachment.id,
|
||||
downloadTag,
|
||||
attachment.transportServer
|
||||
)
|
||||
@Suppress("NAME_SHADOWING")
|
||||
val encryptedContent = retryResult
|
||||
logPhotoDebug("CDN retry OK: id=$idShort")
|
||||
@@ -1715,8 +1725,8 @@ fun FileAttachment(
|
||||
)
|
||||
}
|
||||
|
||||
val preview = attachment.preview
|
||||
val downloadTag = getDownloadTag(preview)
|
||||
val preview = getPreview(attachment)
|
||||
val downloadTag = getDownloadTag(attachment)
|
||||
val (fileSize, fileName) = parseFilePreview(preview)
|
||||
|
||||
// Анимация прогресса
|
||||
@@ -1779,7 +1789,7 @@ fun FileAttachment(
|
||||
isPaused = false
|
||||
return@LaunchedEffect
|
||||
}
|
||||
downloadStatus = if (isDownloadTag(preview)) {
|
||||
downloadStatus = if (downloadTag.isNotBlank()) {
|
||||
// Проверяем, был ли файл уже скачан ранее
|
||||
if (savedFile.exists()) DownloadStatus.DOWNLOADED
|
||||
else DownloadStatus.NOT_DOWNLOADED
|
||||
@@ -1828,6 +1838,7 @@ fun FileAttachment(
|
||||
com.rosetta.messenger.network.FileDownloadManager.download(
|
||||
attachmentId = attachment.id,
|
||||
downloadTag = downloadTag,
|
||||
transportServer = attachment.transportServer,
|
||||
chachaKey = chachaKey,
|
||||
privateKey = privateKey,
|
||||
accountPublicKey = currentUserPublicKey,
|
||||
@@ -2087,8 +2098,8 @@ fun AvatarAttachment(
|
||||
var avatarBitmap by remember { mutableStateOf<Bitmap?>(null) }
|
||||
var blurhashBitmap by remember { mutableStateOf<Bitmap?>(null) }
|
||||
|
||||
val preview = getPreview(attachment.preview)
|
||||
val downloadTag = getDownloadTag(attachment.preview)
|
||||
val preview = getPreview(attachment)
|
||||
val downloadTag = getDownloadTag(attachment)
|
||||
|
||||
val timeFormat = remember { java.text.SimpleDateFormat("HH:mm", java.util.Locale.getDefault()) }
|
||||
|
||||
@@ -2113,8 +2124,8 @@ fun AvatarAttachment(
|
||||
attachment.blob.isNotEmpty() -> {
|
||||
DownloadStatus.DOWNLOADED
|
||||
}
|
||||
// 2. Если preview НЕ содержит UUID → локальный файл → DOWNLOADED
|
||||
!isDownloadTag(attachment.preview) -> {
|
||||
// 2. Нет transport tag → локальный файл → DOWNLOADED
|
||||
downloadTag.isBlank() -> {
|
||||
DownloadStatus.DOWNLOADED
|
||||
}
|
||||
// 3. Есть UUID (download tag) → проверяем файловую систему
|
||||
@@ -2176,7 +2187,12 @@ fun AvatarAttachment(
|
||||
downloadStatus = DownloadStatus.DOWNLOADING
|
||||
|
||||
val startTime = System.currentTimeMillis()
|
||||
val encryptedContent = TransportManager.downloadFile(attachment.id, downloadTag)
|
||||
val encryptedContent =
|
||||
TransportManager.downloadFile(
|
||||
attachment.id,
|
||||
downloadTag,
|
||||
attachment.transportServer
|
||||
)
|
||||
val downloadTime = System.currentTimeMillis() - startTime
|
||||
|
||||
downloadStatus = DownloadStatus.DECRYPTING
|
||||
@@ -2520,6 +2536,12 @@ internal fun getDownloadTag(preview: String): String {
|
||||
return ""
|
||||
}
|
||||
|
||||
/** Получить download tag из attachment (new format first, legacy preview fallback). */
|
||||
internal fun getDownloadTag(attachment: MessageAttachment): String {
|
||||
if (attachment.transportTag.isNotBlank()) return attachment.transportTag
|
||||
return getDownloadTag(attachment.preview)
|
||||
}
|
||||
|
||||
/** Получить preview без download tag Как в desktop: preview.split("::").splice(1).join("::") */
|
||||
internal fun getPreview(preview: String): String {
|
||||
val parts = preview.split("::")
|
||||
@@ -2529,6 +2551,15 @@ internal fun getPreview(preview: String): String {
|
||||
return preview
|
||||
}
|
||||
|
||||
/** Получить attachment preview (new format raw preview, legacy preview fallback). */
|
||||
internal fun getPreview(attachment: MessageAttachment): String {
|
||||
return if (attachment.transportTag.isNotBlank()) {
|
||||
attachment.preview
|
||||
} else {
|
||||
getPreview(attachment.preview)
|
||||
}
|
||||
}
|
||||
|
||||
/** Парсинг preview для файлов Формат: "UUID::filesize::filename" или "filesize::filename" */
|
||||
private fun parseFilePreview(preview: String): Pair<Long, String> {
|
||||
val parts = preview.split("::")
|
||||
|
||||
@@ -1338,6 +1338,23 @@ private fun parseAttachmentsForOtherProfile(attachmentsJson: String): List<Messa
|
||||
|
||||
val id = attachment.optString("id", "")
|
||||
val blob = attachment.optString("blob", "")
|
||||
val transportObj = attachment.optJSONObject("transport")
|
||||
val transportTag =
|
||||
attachment.optString(
|
||||
"transportTag",
|
||||
attachment.optString(
|
||||
"transport_tag",
|
||||
transportObj?.optString("transport_tag", "") ?: ""
|
||||
)
|
||||
)
|
||||
val transportServer =
|
||||
attachment.optString(
|
||||
"transportServer",
|
||||
attachment.optString(
|
||||
"transport_server",
|
||||
transportObj?.optString("transport_server", "") ?: ""
|
||||
)
|
||||
)
|
||||
|
||||
add(
|
||||
MessageAttachment(
|
||||
@@ -1347,7 +1364,9 @@ private fun parseAttachmentsForOtherProfile(attachmentsJson: String): List<Messa
|
||||
preview = attachment.optString("preview", ""),
|
||||
width = attachment.optInt("width", 0),
|
||||
height = attachment.optInt("height", 0),
|
||||
localUri = attachment.optString("localUri", "")
|
||||
localUri = attachment.optString("localUri", ""),
|
||||
transportTag = transportTag,
|
||||
transportServer = transportServer
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user