Compare commits

...

4 Commits

Author SHA1 Message Date
2ff1383b13 Bump Android version to 1.3.8 (versionCode 40)
All checks were successful
Android Kernel Build / build (push) Successful in 19m27s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 16:15:16 +05:00
727b902df7 Push: поддержка новых типов и супер-уведомления для звонков
All checks were successful
Android Kernel Build / build (push) Successful in 20m0s
2026-03-30 22:46:59 +05:00
89259b2a46 Релиз 1.3.7: новый Stream, транспорт вложений и фиксы совместимости
All checks were successful
Android Kernel Build / build (push) Successful in 19m57s
2026-03-29 23:16:38 +05:00
ce6bc985be Пуши: учитывать mute и имя отправителя из payload 2026-03-29 23:12:29 +05:00
13 changed files with 921 additions and 275 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -14,6 +14,9 @@ import com.rosetta.messenger.MainActivity
import com.rosetta.messenger.R
import com.rosetta.messenger.data.AccountManager
import com.rosetta.messenger.data.PreferencesManager
import com.rosetta.messenger.network.CallForegroundService
import com.rosetta.messenger.network.CallManager
import com.rosetta.messenger.network.CallPhase
import com.rosetta.messenger.network.ProtocolManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -38,6 +41,12 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
private const val TAG = "RosettaFCM"
private const val CHANNEL_ID = "rosetta_messages"
private const val CHANNEL_NAME = "Messages"
private const val CALL_CHANNEL_ID = "rosetta_calls_push"
private const val CALL_CHANNEL_NAME = "Calls"
private const val PUSH_TYPE_PERSONAL_MESSAGE = "personal_message"
private const val PUSH_TYPE_GROUP_MESSAGE = "group_message"
private const val PUSH_TYPE_CALL = "call"
private const val PUSH_TYPE_READ = "read"
// 🔥 Флаг - приложение в foreground (видимо пользователю)
@Volatile var isAppInForeground = false
@@ -56,14 +65,43 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
fun cancelNotificationForChat(context: Context, senderPublicKey: String) {
val notificationManager =
context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val normalizedKey = senderPublicKey.trim()
if (normalizedKey.isNotEmpty()) {
notificationManager.cancel(getNotificationIdForChat(normalizedKey))
val variants = buildDialogKeyVariants(senderPublicKey)
for (key in variants) {
notificationManager.cancel(getNotificationIdForChat(key))
}
// Fallback: некоторые серверные payload могут прийти без sender key.
// Для них используется ID от пустой строки — тоже очищаем при входе в диалог.
notificationManager.cancel(getNotificationIdForChat(""))
}
private fun buildDialogKeyVariants(rawKey: String): Set<String> {
val trimmed = rawKey.trim()
if (trimmed.isBlank()) return emptySet()
val variants = linkedSetOf(trimmed)
val lower = trimmed.lowercase(Locale.ROOT)
when {
lower.startsWith("#group:") -> {
val groupId = trimmed.substringAfter(':').trim()
if (groupId.isNotBlank()) {
variants.add("group:$groupId")
variants.add(groupId)
}
}
lower.startsWith("group:") -> {
val groupId = trimmed.substringAfter(':').trim()
if (groupId.isNotBlank()) {
variants.add("#group:$groupId")
variants.add(groupId)
}
}
else -> {
variants.add("#group:$trimmed")
variants.add("group:$trimmed")
}
}
return variants
}
}
/** Вызывается когда получен новый FCM токен Отправляем его на сервер через протокол */
@@ -85,74 +123,134 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
super.onMessageReceived(remoteMessage)
Log.d(TAG, "\u2709\ufe0f onMessageReceived: messageId=${remoteMessage.messageId} from=${remoteMessage.from} data=${remoteMessage.data} notif=${remoteMessage.notification?.body}")
var handledMessageData = false
var handledByData = false
val data = remoteMessage.data
val notificationTitle = remoteMessage.notification?.title?.trim().orEmpty()
val notificationBody = remoteMessage.notification?.body?.trim().orEmpty()
// Обрабатываем data payload
if (remoteMessage.data.isNotEmpty()) {
val data = remoteMessage.data
// Обрабатываем data payload (новый server формат + legacy fallback)
if (data.isNotEmpty()) {
val type =
firstNonBlank(data, "type", "event", "action")
?.lowercase(Locale.ROOT)
.orEmpty()
val dialogKey =
firstNonBlank(
data,
"dialog",
"sender_public_key",
"from_public_key",
"fromPublicKey",
"from",
"public_key",
"publicKey"
)
val senderPublicKey =
firstNonBlank(
data,
"sender_public_key",
"from_public_key",
"fromPublicKey",
"from",
"public_key",
"publicKey"
)
val senderName =
firstNonBlank(data, "sender_name", "from_title", "sender", "title", "name")
firstNonBlank(
data,
"sender_name",
"sender_title",
"from_title",
"sender",
"title",
"name"
)
?: notificationTitle.takeIf { it.isNotBlank() }
?: dialogKey?.take(10)
?: senderPublicKey?.take(10)
?: "Rosetta"
val messagePreview =
firstNonBlank(data, "message_preview", "message", "text", "body")
?: "New message"
?: notificationBody.takeIf { it.isNotBlank() }
?: when (type) {
PUSH_TYPE_GROUP_MESSAGE -> "New group message"
PUSH_TYPE_CALL -> "Incoming call"
else -> "New message"
}
val isReadEvent = type == "message_read" || type == "read"
val isReadEvent = type == "message_read" || type == PUSH_TYPE_READ
val isMessageEvent =
type == "new_message" ||
type == "message" ||
type == "newmessage" ||
type == "msg_new"
type == "msg_new" ||
type == PUSH_TYPE_PERSONAL_MESSAGE ||
type == PUSH_TYPE_GROUP_MESSAGE
when {
isMessageEvent -> {
showMessageNotification(senderPublicKey, senderName, messagePreview)
handledMessageData = true
}
isReadEvent -> {
handledMessageData = true
if (!dialogKey.isNullOrBlank()) {
cancelNotificationForChat(applicationContext, dialogKey)
}
handledByData = true
}
type == PUSH_TYPE_CALL -> {
handleIncomingCallPush(
dialogKey = dialogKey ?: senderPublicKey.orEmpty(),
title = senderName,
body = messagePreview
)
handledByData = true
}
isMessageEvent -> {
showMessageNotification(dialogKey ?: senderPublicKey, senderName, messagePreview)
handledByData = true
}
// Fallback for servers sending data-only payload without explicit "type".
senderPublicKey != null || data.containsKey("message_preview") || data.containsKey("message") || data.containsKey("text") -> {
showMessageNotification(senderPublicKey, senderName, messagePreview)
handledMessageData = true
dialogKey != null || senderPublicKey != null ||
data.containsKey("message_preview") ||
data.containsKey("message") ||
data.containsKey("text") -> {
showMessageNotification(dialogKey ?: senderPublicKey, senderName, messagePreview)
handledByData = true
}
}
val looksLikeMessagePayload =
type.contains("message") ||
data.keys.any { key ->
val lower = key.lowercase(Locale.ROOT)
lower.contains("message") ||
lower.contains("text") ||
lower.contains("body")
}
if (!handledMessageData && !isReadEvent && looksLikeMessagePayload) {
showSimpleNotification(senderName, messagePreview, senderPublicKey)
handledMessageData = true
if (!handledByData) {
val looksLikeMessagePayload =
type.contains("message") ||
data.keys.any { key ->
val lower = key.lowercase(Locale.ROOT)
lower.contains("message") ||
lower.contains("text") ||
lower.contains("body")
}
if (looksLikeMessagePayload) {
showSimpleNotification(senderName, messagePreview, dialogKey ?: senderPublicKey)
handledByData = true
}
}
}
// Обрабатываем notification payload (если есть).
// Для new_message используем data-ветку выше, чтобы не показывать дубликаты
// с неуправляемым notification id.
// Обрабатываем notification payload (если data-ветка не сработала).
remoteMessage.notification?.let {
if (!handledMessageData) {
showSimpleNotification(it.title ?: "Rosetta", it.body ?: "New message")
if (!handledByData) {
val senderPublicKey =
firstNonBlank(
data,
"dialog",
"sender_public_key",
"from_public_key",
"fromPublicKey",
"from",
"public_key",
"publicKey"
)
showSimpleNotification(
it.title ?: "Rosetta",
it.body ?: "New message",
senderPublicKey
)
}
}
}
@@ -223,8 +321,12 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
if (isAppInForeground || !areNotificationsEnabled()) {
return
}
val senderKey = senderPublicKey?.trim().orEmpty()
if (senderKey.isNotEmpty() && isDialogMuted(senderKey)) {
return
}
// Dedup: suppress duplicate pushes within DEDUP_WINDOW_MS
val dedupKey = senderPublicKey?.trim()?.ifEmpty { null } ?: "__simple__"
val dedupKey = senderKey.ifEmpty { "__simple__" }
val now = System.currentTimeMillis()
val lastTs = lastNotifTimestamps[dedupKey]
if (lastTs != null && now - lastTs < DEDUP_WINDOW_MS) {
@@ -235,8 +337,8 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
createNotificationChannel()
// Используем sender-based ID если известен ключ — чтобы cancelNotificationForChat мог убрать уведомление
val notifId = if (!senderPublicKey.isNullOrBlank()) {
getNotificationIdForChat(senderPublicKey.trim())
val notifId = if (senderKey.isNotEmpty()) {
getNotificationIdForChat(senderKey)
} else {
(title + body).hashCode() and 0x7FFFFFFF
}
@@ -269,6 +371,78 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
notificationManager.notify(notifId, notification)
}
/** Супер push входящего звонка: пробуждаем протокол и показываем call notification */
private fun handleIncomingCallPush(dialogKey: String, title: String, body: String) {
wakeProtocolFromPush("call")
if (isAppInForeground || !areNotificationsEnabled()) return
val normalizedDialog = dialogKey.trim()
if (normalizedDialog.isNotEmpty() && isDialogMuted(normalizedDialog)) return
if (CallManager.state.value.phase != CallPhase.IDLE) return
val dedupKey = "call:${normalizedDialog.ifEmpty { "__no_dialog__" }}"
val now = System.currentTimeMillis()
val lastTs = lastNotifTimestamps[dedupKey]
if (lastTs != null && now - lastTs < DEDUP_WINDOW_MS) return
lastNotifTimestamps[dedupKey] = now
createCallNotificationChannel()
val notifId =
if (normalizedDialog.isNotEmpty()) {
getNotificationIdForChat(normalizedDialog)
} else {
("call:$title:$body").hashCode() and 0x7FFFFFFF
}
val openIntent =
Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
putExtra("open_chat", normalizedDialog)
putExtra(CallForegroundService.EXTRA_OPEN_CALL_FROM_NOTIFICATION, true)
}
val pendingIntent =
PendingIntent.getActivity(
this,
notifId,
openIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val notification =
NotificationCompat.Builder(this, CALL_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(title.ifBlank { "Incoming call" })
.setContentText(body.ifBlank { "Incoming call" })
.setPriority(NotificationCompat.PRIORITY_MAX)
.setCategory(NotificationCompat.CATEGORY_CALL)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setAutoCancel(true)
.setContentIntent(pendingIntent)
.setFullScreenIntent(pendingIntent, true)
.build()
val notificationManager =
getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.notify(notifId, notification)
}
/** Пробуждаем сетевой слой по high-priority push, чтобы быстрее получить сигнальные пакеты */
private fun wakeProtocolFromPush(reason: String) {
runCatching {
val account = AccountManager(applicationContext).getLastLoggedPublicKey().orEmpty()
ProtocolManager.initialize(applicationContext)
CallManager.initialize(applicationContext)
if (account.isNotBlank()) {
CallManager.bindAccount(account)
}
ProtocolManager.reconnectNowIfNeeded("push_$reason")
}.onFailure { error ->
Log.w(TAG, "wakeProtocolFromPush failed: ${error.message}")
}
}
/** Создать notification channel для Android 8+ */
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
@@ -289,6 +463,26 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
}
}
/** Отдельный канал для входящих звонков */
private fun createCallNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel =
NotificationChannel(
CALL_CHANNEL_ID,
CALL_CHANNEL_NAME,
NotificationManager.IMPORTANCE_HIGH
)
.apply {
description = "Incoming call notifications"
enableVibration(true)
}
val notificationManager =
getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.createNotificationChannel(channel)
}
}
/** Сохранить FCM токен в SharedPreferences */
private fun saveFcmToken(token: String) {
val prefs = getSharedPreferences("rosetta_prefs", Context.MODE_PRIVATE)
@@ -318,7 +512,10 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
val accountManager = AccountManager(applicationContext)
val currentAccount = accountManager.getLastLoggedPublicKey().orEmpty()
runBlocking(Dispatchers.IO) {
PreferencesManager(applicationContext).isChatMuted(currentAccount, senderPublicKey)
val preferences = PreferencesManager(applicationContext)
buildDialogKeyVariants(senderPublicKey).any { key ->
preferences.isChatMuted(currentAccount, key)
}
}
}.getOrDefault(false)
}

View File

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

View File

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

View File

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

View File

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