Релиз 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
|
// 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 {
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 =
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -31,15 +31,22 @@ 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
|
||||||
toPublicKey = parsed.toPublicKey
|
toPublicKey = parsed.toPublicKey
|
||||||
@@ -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 = ""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
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 ensureCapacity(index: Int) {
|
private fun remainingBits(): Long = (writePointer - readPointer).toLong()
|
||||||
while (_stream.size <= index) {
|
|
||||||
_stream.add(0)
|
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.
|
* 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"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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("::")
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user