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

@@ -578,6 +578,230 @@ object MessageCrypto {
null
}
}
/**
* 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)