feat: Simplify tint color handling for icons in FileAttachment component

This commit is contained in:
2026-02-25 20:39:36 +05:00
parent 3bdd92e8a9
commit dd5bee5e42
4 changed files with 340 additions and 22 deletions

View File

@@ -579,6 +579,230 @@ object MessageCrypto {
} }
} }
/**
* Streaming decrypt: зашифрованный файл → расшифрованный файл на диске.
* Не загружает весь контент в память — использует потоковый пайплайн:
* File → Base64Decode → AES-CBC → Inflate → Base64Decode → outputFile
* Пиковое потребление памяти: ~128KB вместо ~200MB для 30МБ файла.
*
* Поддерживает форматы:
* - ivBase64:ciphertextBase64 (обычный)
* - CHNK:iv1:ct1::iv2:ct2::... (чанкованный, Desktop >10MB)
*
* @param inputFile temp file с зашифрованным контентом (с CDN)
* @param chachaKeyPlain расшифрованный ChaCha ключ (56 bytes)
* @param outputFile куда записать результат (raw bytes файла)
* @return true если успешно
*/
fun decryptAttachmentFileStreaming(
inputFile: java.io.File,
chachaKeyPlain: ByteArray,
outputFile: java.io.File
): Boolean {
return try {
val password = bytesToJsUtf8String(chachaKeyPlain)
val pbkdf2Key = generatePBKDF2Key(password)
// Проверяем формат: CHNK: или обычный ivBase64:ciphertextBase64
val header = ByteArray(5)
var headerLen = 0
java.io.FileInputStream(inputFile).use { fis ->
while (headerLen < 5) {
val n = fis.read(header, headerLen, 5 - headerLen)
if (n == -1) break
headerLen += n
}
}
val isChunked = headerLen == 5 && String(header, Charsets.US_ASCII) == "CHNK:"
if (isChunked) {
decryptChunkedFileStreaming(inputFile, pbkdf2Key, outputFile)
} else {
decryptSingleFileStreaming(inputFile, pbkdf2Key, outputFile)
}
} catch (e: Exception) {
android.util.Log.e("MessageCrypto", "Streaming decrypt failed", e)
outputFile.delete()
false
} catch (_: OutOfMemoryError) {
System.gc()
outputFile.delete()
false
}
}
/**
* Streaming decrypt обычного формата: ivBase64:ciphertextBase64
* Пайплайн: File → Base64Decode → AES-CBC → Inflate → strip data URL → Base64Decode → file
*/
private fun decryptSingleFileStreaming(
inputFile: java.io.File,
pbkdf2Key: ByteArray,
outputFile: java.io.File
): Boolean {
// 1. Считываем IV (всё до первого ':')
var colonOffset = 0L
val ivBuf = java.io.ByteArrayOutputStream(64)
java.io.FileInputStream(inputFile).use { fis ->
while (true) {
val b = fis.read()
if (b == -1) return false
colonOffset++
if (b.toChar() == ':') break
ivBuf.write(b)
}
}
val iv = Base64.decode(ivBuf.toByteArray(), Base64.DEFAULT)
// 2. AES-256-CBC cipher
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
cipher.init(
Cipher.DECRYPT_MODE,
SecretKeySpec(pbkdf2Key, "AES"),
IvParameterSpec(iv)
)
// 3. Streaming pipeline: File[после ':'] → Base64 → AES → Inflate
val fis = java.io.FileInputStream(inputFile)
// Skip IV and ':'
var skipped = 0L
while (skipped < colonOffset) {
val s = fis.skip(colonOffset - skipped)
if (s <= 0) { fis.read(); skipped++ }
else skipped += s
}
val pipeline = java.util.zip.InflaterInputStream(
javax.crypto.CipherInputStream(
android.util.Base64InputStream(fis, Base64.DEFAULT),
cipher
)
)
pipeline.use { inflated ->
// 4. Результат — base64 текст файла, возможно с data URL prefix
// Читаем первый кусок чтобы определить и пропустить prefix
val headerBuf = ByteArray(256)
var headerReadLen = 0
while (headerReadLen < headerBuf.size) {
val n = inflated.read(headerBuf, headerReadLen, headerBuf.size - headerReadLen)
if (n == -1) break
headerReadLen += n
}
if (headerReadLen == 0) return false
// Ищем "base64," чтобы пропустить data URL prefix
val headerStr = String(headerBuf, 0, headerReadLen, Charsets.ISO_8859_1)
val marker = "base64,"
val markerIdx = headerStr.indexOf(marker)
val skip = if (markerIdx in 0..199) markerIdx + marker.length else 0
// 5. Объединяем остаток header + остаток inflated stream → Base64 decode → файл
val remaining = java.io.ByteArrayInputStream(headerBuf, skip, headerReadLen - skip)
val bodyStream = java.io.SequenceInputStream(remaining, inflated)
android.util.Base64InputStream(bodyStream, Base64.DEFAULT).use { decoded ->
outputFile.outputStream().use { out ->
val buf = ByteArray(64 * 1024)
while (true) {
val n = decoded.read(buf)
if (n == -1) break
out.write(buf, 0, n)
}
}
}
}
return outputFile.length() > 0
}
/**
* Streaming decrypt CHNK формата (Desktop >10MB).
* Формат: CHNK:iv1Base64:ct1Base64::iv2Base64:ct2Base64::...
* Каждый чанк — отдельный AES-CBC шифротекст (до 10MB compressed).
* Все чанки после расшифровки + конкатенации → inflate → base64 файла.
*/
private fun decryptChunkedFileStreaming(
inputFile: java.io.File,
pbkdf2Key: ByteArray,
outputFile: java.io.File
): Boolean {
// Для CHNK: читаем весь файл, разбиваем на чанки, расшифровываем каждый,
// конкатенируем compressed bytes → inflate → strip data URL → base64 decode → file
//
// Оптимизация: обрабатываем чанки по одному, записываем decrypted bytes
// во временный файл, потом inflate оттуда.
val cacheDir = inputFile.parentFile ?: return false
val decryptedTmp = java.io.File(cacheDir, "chnk_dec_${System.currentTimeMillis()}.tmp")
try {
// Читаем содержимое файла после "CHNK:" и разбиваем на чанки по "::"
val content = inputFile.readText(Charsets.UTF_8)
val chunksStr = content.removePrefix("CHNK:")
val chunks = chunksStr.split("::")
// Расшифровываем каждый чанк и записываем compressed bytes в tmp файл
decryptedTmp.outputStream().use { tmpOut ->
for (chunk in chunks) {
if (chunk.isBlank()) continue
val parts = chunk.split(":")
if (parts.size != 2) continue
val chunkIv = Base64.decode(parts[0], Base64.DEFAULT)
val chunkCt = Base64.decode(parts[1], Base64.DEFAULT)
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
cipher.init(
Cipher.DECRYPT_MODE,
SecretKeySpec(pbkdf2Key, "AES"),
IvParameterSpec(chunkIv)
)
val decrypted = cipher.doFinal(chunkCt)
tmpOut.write(decrypted)
}
}
// Inflate compressed concatenated data → base64 текст файла
java.util.zip.InflaterInputStream(
java.io.FileInputStream(decryptedTmp)
).use { inflated ->
// Читаем header для data URL prefix
val headerBuf = ByteArray(256)
var headerReadLen = 0
while (headerReadLen < headerBuf.size) {
val n = inflated.read(headerBuf, headerReadLen, headerBuf.size - headerReadLen)
if (n == -1) break
headerReadLen += n
}
if (headerReadLen == 0) return false
val headerStr = String(headerBuf, 0, headerReadLen, Charsets.ISO_8859_1)
val marker = "base64,"
val markerIdx = headerStr.indexOf(marker)
val skip = if (markerIdx in 0..199) markerIdx + marker.length else 0
val remaining = java.io.ByteArrayInputStream(headerBuf, skip, headerReadLen - skip)
val bodyStream = java.io.SequenceInputStream(remaining, inflated)
android.util.Base64InputStream(bodyStream, Base64.DEFAULT).use { decoded ->
outputFile.outputStream().use { out ->
val buf = ByteArray(64 * 1024)
while (true) {
val n = decoded.read(buf)
if (n == -1) break
out.write(buf, 0, n)
}
}
}
}
return outputFile.length() > 0
} finally {
decryptedTmp.delete()
}
}
/** /**
* Расшифровка attachment blob с уже готовым паролем (Latin1 string) * Расшифровка attachment blob с уже готовым паролем (Latin1 string)
* Используется когда chachaKey сохранён в БД как Latin1 string (raw bytes) * Используется когда chachaKey сохранён в БД как Latin1 string (raw bytes)

View File

@@ -31,6 +31,7 @@ object ReleaseNotes {
- Бейдж непрочитанных больше не перекрывает стрелку назад - Бейдж непрочитанных больше не перекрывает стрелку назад
- Индикатор печати адаптирован под светлую тему - Индикатор печати адаптирован под светлую тему
- Белый значок верификации в профиле собеседника - Белый значок верификации в профиле собеседника
- Белые галочки на пузырьках с файлами
- Убран отладочный интерфейс из боковой панели - Убран отладочный интерфейс из боковой панели
""".trimIndent() """.trimIndent()

View File

@@ -328,4 +328,95 @@ object TransportManager {
fun getUploadProgress(id: String): Int { fun getUploadProgress(id: String): Int {
return _uploading.value.find { it.id == id }?.progress ?: -1 return _uploading.value.find { it.id == id }?.progress ?: -1
} }
/**
* Скачать файл с CDN напрямую во временный файл (без загрузки в память).
* Вызывающий код отвечает за удаление файла.
*
* @param id Уникальный ID файла (для трекинга)
* @param tag Tag файла на сервере
* @return Временный файл с зашифрованным содержимым
*/
suspend fun downloadFileRaw(id: String, tag: String): File = withContext(Dispatchers.IO) {
val server = getActiveServer()
ProtocolManager.addLog("📥 Download raw start: id=${id.take(8)}, tag=${tag.take(10)}, server=$server")
_downloading.value = _downloading.value + TransportState(id, 0)
try {
withRetry {
val request = Request.Builder()
.url("$server/d/$tag")
.get()
.build()
val response = suspendCoroutine<Response> { cont ->
client.newCall(request).enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
cont.resumeWithException(e)
}
override fun onResponse(call: Call, response: Response) {
cont.resume(response)
}
})
}
if (!response.isSuccessful) {
throw IOException("Download failed: ${response.code}")
}
val body = response.body ?: throw IOException("Empty response body")
val contentLength = body.contentLength()
val cacheDir = appContext?.cacheDir ?: throw IOException("No app context")
val tempFile = File(cacheDir, "dlr_${id.take(16)}_${System.currentTimeMillis()}.tmp")
try {
var totalRead = 0L
val buffer = ByteArray(64 * 1024)
body.byteStream().use { inputStream ->
tempFile.outputStream().use { outputStream ->
while (true) {
val bytesRead = inputStream.read(buffer)
if (bytesRead == -1) break
outputStream.write(buffer, 0, bytesRead)
totalRead += bytesRead
if (contentLength > 0) {
val progress = ((totalRead * 100) / contentLength).toInt().coerceIn(0, 99)
_downloading.value = _downloading.value.map {
if (it.id == id) it.copy(progress = progress) else it
}
}
}
}
}
if (contentLength > 0 && totalRead != contentLength) {
tempFile.delete()
throw IOException("Incomplete download: expected=$contentLength, got=$totalRead")
}
if (totalRead == 0L) {
tempFile.delete()
throw IOException("Empty download: 0 bytes received")
}
_downloading.value = _downloading.value.map {
if (it.id == id) it.copy(progress = 100) else it
}
ProtocolManager.addLog("✅ Download raw OK: id=${id.take(8)}, size=$totalRead")
tempFile
} catch (e: Exception) {
tempFile.delete()
throw e
}
}
} catch (e: Exception) {
ProtocolManager.addLog(
"❌ Download raw failed: id=${id.take(8)}, reason=${e.javaClass.simpleName}: ${e.message ?: "unknown"}"
)
throw e
} finally {
_downloading.value = _downloading.value.filter { it.id != id }
}
}
} }

View File

@@ -1477,38 +1477,42 @@ fun FileAttachment(
try { try {
downloadStatus = DownloadStatus.DOWNLOADING downloadStatus = DownloadStatus.DOWNLOADING
val encryptedContent = TransportManager.downloadFile(attachment.id, downloadTag) // Streaming: скачиваем во temp file, не в память
downloadProgress = 0.6f val tempFile = TransportManager.downloadFileRaw(attachment.id, downloadTag)
downloadProgress = 0.5f
downloadStatus = DownloadStatus.DECRYPTING downloadStatus = DownloadStatus.DECRYPTING
val decryptedKeyAndNonce = val decryptedKeyAndNonce =
MessageCrypto.decryptKeyFromSender(chachaKey, privateKey) MessageCrypto.decryptKeyFromSender(chachaKey, privateKey)
downloadProgress = 0.6f
val decrypted = // Streaming decrypt: tempFile → AES → inflate → base64 → savedFile
MessageCrypto.decryptAttachmentBlobWithPlainKey( // Пиковое потребление памяти ~128KB вместо ~200MB
encryptedContent, val success = withContext(Dispatchers.IO) {
decryptedKeyAndNonce try {
MessageCrypto.decryptAttachmentFileStreaming(
tempFile,
decryptedKeyAndNonce,
savedFile
) )
downloadProgress = 0.9f } finally {
tempFile.delete()
if (decrypted != null) {
withContext(Dispatchers.IO) {
// Декодим base64 в байты (обработка data URL и plain base64)
val base64Data = if (decrypted.contains(",")) {
decrypted.substringAfter(",")
} else {
decrypted
}
val bytes = Base64.decode(base64Data, Base64.DEFAULT)
savedFile.writeBytes(bytes)
} }
}
downloadProgress = 0.95f
if (success) {
downloadProgress = 1f downloadProgress = 1f
downloadStatus = DownloadStatus.DOWNLOADED downloadStatus = DownloadStatus.DOWNLOADED
} else { } else {
downloadStatus = DownloadStatus.ERROR downloadStatus = DownloadStatus.ERROR
} }
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace()
downloadStatus = DownloadStatus.ERROR
} catch (_: OutOfMemoryError) {
System.gc()
downloadStatus = DownloadStatus.ERROR downloadStatus = DownloadStatus.ERROR
} }
} }
@@ -1707,15 +1711,13 @@ fun FileAttachment(
Icon( Icon(
painter = TelegramIcons.Done, painter = TelegramIcons.Done,
contentDescription = null, contentDescription = null,
tint = tint = Color.White,
if (isDarkTheme) Color.White else Color(0xFF4FC3F7),
modifier = Modifier.size(14.dp) modifier = Modifier.size(14.dp)
) )
Icon( Icon(
painter = TelegramIcons.Done, painter = TelegramIcons.Done,
contentDescription = null, contentDescription = null,
tint = tint = Color.White,
if (isDarkTheme) Color.White else Color(0xFF4FC3F7),
modifier = Modifier.size(14.dp).offset(x = 4.dp) modifier = Modifier.size(14.dp).offset(x = 4.dp)
) )
} }