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)
|
||||
|
||||
@@ -31,6 +31,7 @@ object ReleaseNotes {
|
||||
- Бейдж непрочитанных больше не перекрывает стрелку назад
|
||||
- Индикатор печати адаптирован под светлую тему
|
||||
- Белый значок верификации в профиле собеседника
|
||||
- Белые галочки на пузырьках с файлами
|
||||
- Убран отладочный интерфейс из боковой панели
|
||||
""".trimIndent()
|
||||
|
||||
|
||||
@@ -328,4 +328,95 @@ object TransportManager {
|
||||
fun getUploadProgress(id: String): Int {
|
||||
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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1477,38 +1477,42 @@ fun FileAttachment(
|
||||
try {
|
||||
downloadStatus = DownloadStatus.DOWNLOADING
|
||||
|
||||
val encryptedContent = TransportManager.downloadFile(attachment.id, downloadTag)
|
||||
downloadProgress = 0.6f
|
||||
// Streaming: скачиваем во temp file, не в память
|
||||
val tempFile = TransportManager.downloadFileRaw(attachment.id, downloadTag)
|
||||
downloadProgress = 0.5f
|
||||
|
||||
downloadStatus = DownloadStatus.DECRYPTING
|
||||
|
||||
val decryptedKeyAndNonce =
|
||||
MessageCrypto.decryptKeyFromSender(chachaKey, privateKey)
|
||||
downloadProgress = 0.6f
|
||||
|
||||
val decrypted =
|
||||
MessageCrypto.decryptAttachmentBlobWithPlainKey(
|
||||
encryptedContent,
|
||||
decryptedKeyAndNonce
|
||||
// Streaming decrypt: tempFile → AES → inflate → base64 → savedFile
|
||||
// Пиковое потребление памяти ~128KB вместо ~200MB
|
||||
val success = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
MessageCrypto.decryptAttachmentFileStreaming(
|
||||
tempFile,
|
||||
decryptedKeyAndNonce,
|
||||
savedFile
|
||||
)
|
||||
downloadProgress = 0.9f
|
||||
|
||||
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)
|
||||
} finally {
|
||||
tempFile.delete()
|
||||
}
|
||||
}
|
||||
downloadProgress = 0.95f
|
||||
|
||||
if (success) {
|
||||
downloadProgress = 1f
|
||||
downloadStatus = DownloadStatus.DOWNLOADED
|
||||
} else {
|
||||
downloadStatus = DownloadStatus.ERROR
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
downloadStatus = DownloadStatus.ERROR
|
||||
} catch (_: OutOfMemoryError) {
|
||||
System.gc()
|
||||
downloadStatus = DownloadStatus.ERROR
|
||||
}
|
||||
}
|
||||
@@ -1707,15 +1711,13 @@ fun FileAttachment(
|
||||
Icon(
|
||||
painter = TelegramIcons.Done,
|
||||
contentDescription = null,
|
||||
tint =
|
||||
if (isDarkTheme) Color.White else Color(0xFF4FC3F7),
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(14.dp)
|
||||
)
|
||||
Icon(
|
||||
painter = TelegramIcons.Done,
|
||||
contentDescription = null,
|
||||
tint =
|
||||
if (isDarkTheme) Color.White else Color(0xFF4FC3F7),
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(14.dp).offset(x = 4.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user