feat: Simplify tint color handling for icons in FileAttachment component
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user