feat: Simplify tint color handling for icons in FileAttachment component
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ object ReleaseNotes {
|
|||||||
- Бейдж непрочитанных больше не перекрывает стрелку назад
|
- Бейдж непрочитанных больше не перекрывает стрелку назад
|
||||||
- Индикатор печати адаптирован под светлую тему
|
- Индикатор печати адаптирован под светлую тему
|
||||||
- Белый значок верификации в профиле собеседника
|
- Белый значок верификации в профиле собеседника
|
||||||
|
- Белые галочки на пузырьках с файлами
|
||||||
- Убран отладочный интерфейс из боковой панели
|
- Убран отладочный интерфейс из боковой панели
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
|
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user