diff --git a/app/src/main/java/com/rosetta/messenger/crypto/MessageCrypto.kt b/app/src/main/java/com/rosetta/messenger/crypto/MessageCrypto.kt index 53faeff..86e4ac8 100644 --- a/app/src/main/java/com/rosetta/messenger/crypto/MessageCrypto.kt +++ b/app/src/main/java/com/rosetta/messenger/crypto/MessageCrypto.kt @@ -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) diff --git a/app/src/main/java/com/rosetta/messenger/data/ReleaseNotes.kt b/app/src/main/java/com/rosetta/messenger/data/ReleaseNotes.kt index ceb95c7..d6323ce 100644 --- a/app/src/main/java/com/rosetta/messenger/data/ReleaseNotes.kt +++ b/app/src/main/java/com/rosetta/messenger/data/ReleaseNotes.kt @@ -31,6 +31,7 @@ object ReleaseNotes { - Бейдж непрочитанных больше не перекрывает стрелку назад - Индикатор печати адаптирован под светлую тему - Белый значок верификации в профиле собеседника + - Белые галочки на пузырьках с файлами - Убран отладочный интерфейс из боковой панели """.trimIndent() diff --git a/app/src/main/java/com/rosetta/messenger/network/TransportManager.kt b/app/src/main/java/com/rosetta/messenger/network/TransportManager.kt index c8ded91..0d31174 100644 --- a/app/src/main/java/com/rosetta/messenger/network/TransportManager.kt +++ b/app/src/main/java/com/rosetta/messenger/network/TransportManager.kt @@ -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 { 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 } + } + } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt index 5d9334a..733b0af 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt @@ -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) ) }