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 3e41b8d..4861049 100644 --- a/app/src/main/java/com/rosetta/messenger/data/ReleaseNotes.kt +++ b/app/src/main/java/com/rosetta/messenger/data/ReleaseNotes.kt @@ -52,6 +52,8 @@ object ReleaseNotes { Файлы и загрузки - Добавлена пауза и возобновление скачивания файлов прямо из пузырька (иконка pause/resume) + - Пауза скачивания теперь реальная: активный сетевой поток останавливается, а не только меняется UI-статус + - Resume продолжает загрузку с сохранённого места через HTTP Range (с безопасным fallback на полную перезагрузку, если сервер не поддерживает Range) - Устранено дёргание прогресса при быстрых тапах по скачивающемуся файлу - Прогресс скачивания стал стабильным и не откатывается назад после pause/resume - Обновлён экран активных загрузок: добавлен статус Paused diff --git a/app/src/main/java/com/rosetta/messenger/network/FileDownloadManager.kt b/app/src/main/java/com/rosetta/messenger/network/FileDownloadManager.kt index 09f8c46..699a3f1 100644 --- a/app/src/main/java/com/rosetta/messenger/network/FileDownloadManager.kt +++ b/app/src/main/java/com/rosetta/messenger/network/FileDownloadManager.kt @@ -52,6 +52,12 @@ object FileDownloadManager { val savedFile: File ) + private fun encryptedPartFile(request: DownloadRequest): File { + val parent = request.savedFile.parentFile ?: request.savedFile.absoluteFile.parentFile + val safeId = request.attachmentId.take(32).replace(Regex("[^A-Za-z0-9._-]"), "_") + return File(parent, ".dl_${safeId}.part") + } + // ─── helpers ─── private fun isGroupStoredKey(value: String): Boolean = value.startsWith("group:") @@ -161,6 +167,9 @@ object FileDownloadManager { * Отменяет скачивание */ fun cancel(attachmentId: String) { + requests[attachmentId]?.let { req -> + encryptedPartFile(req).delete() + } pauseRequested.remove(attachmentId) resumeAfterPause.remove(attachmentId) requests.remove(attachmentId) @@ -177,6 +186,7 @@ object FileDownloadManager { pauseRequested.remove(attachmentId) val savedPath = request.savedFile.absolutePath + val encryptedPart = encryptedPartFile(request) val resumeBase = (_downloads.value[attachmentId] ?.takeIf { it.status == FileDownloadStatus.PAUSED } @@ -205,19 +215,13 @@ object FileDownloadManager { ) // Запускаем polling прогресса из TransportManager. - // При resume удерживаем плавный прогресс без визуального отката назад. + // Держим прогресс монотонным, чтобы он не дёргался вниз. progressJob = launch { TransportManager.downloading.collect { list -> val entry = list.find { it.id == attachmentId } ?: return@collect val rawCdn = (entry.progress / 100f) * 0.8f - val mapped = if (resumeBase > 0f) { - val normalized = (rawCdn / 0.8f).coerceIn(0f, 1f) - resumeBase + (0.8f - resumeBase) * normalized - } else { - rawCdn - } val current = _downloads.value[attachmentId]?.progress ?: 0f - val stable = maxOf(current, mapped).coerceIn(0f, 0.8f) + val stable = maxOf(current, rawCdn).coerceIn(0f, 0.8f) update( attachmentId, request.fileName, @@ -238,6 +242,7 @@ object FileDownloadManager { privateKey = request.privateKey, fileName = request.fileName, savedFile = request.savedFile, + encryptedPartFile = encryptedPart, accountPublicKey = request.accountPublicKey, savedPath = savedPath ) @@ -249,6 +254,7 @@ object FileDownloadManager { privateKey = request.privateKey, fileName = request.fileName, savedFile = request.savedFile, + encryptedPartFile = encryptedPart, accountPublicKey = request.accountPublicKey, savedPath = savedPath ) @@ -264,6 +270,8 @@ object FileDownloadManager { request.accountPublicKey, savedPath ) + encryptedPart.delete() + requests.remove(attachmentId) } else { update( attachmentId, @@ -347,10 +355,21 @@ object FileDownloadManager { privateKey: String, fileName: String, savedFile: File, + encryptedPartFile: File, accountPublicKey: String, savedPath: String ): Boolean { - val encryptedContent = TransportManager.downloadFile(attachmentId, downloadTag) + val resumeBytes = if (encryptedPartFile.exists()) encryptedPartFile.length() else 0L + val encryptedFile = + TransportManager.downloadFileRawResumable( + id = attachmentId, + tag = downloadTag, + targetFile = encryptedPartFile, + resumeFromBytes = resumeBytes + ) + val encryptedContent = withContext(Dispatchers.IO) { + encryptedFile.readText(Charsets.UTF_8) + } update( attachmentId, fileName, @@ -405,11 +424,18 @@ object FileDownloadManager { privateKey: String, fileName: String, savedFile: File, + encryptedPartFile: File, accountPublicKey: String, savedPath: String ): Boolean { - // Streaming: скачиваем во temp file - val tempFile = TransportManager.downloadFileRaw(attachmentId, downloadTag) + val resumeBytes = if (encryptedPartFile.exists()) encryptedPartFile.length() else 0L + val tempFile = + TransportManager.downloadFileRawResumable( + id = attachmentId, + tag = downloadTag, + targetFile = encryptedPartFile, + resumeFromBytes = resumeBytes + ) update( attachmentId, fileName, @@ -438,7 +464,7 @@ object FileDownloadManager { savedFile ) } finally { - tempFile.delete() + encryptedPartFile.delete() } } update( 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 5d2d3d8..fc0a592 100644 --- a/app/src/main/java/com/rosetta/messenger/network/TransportManager.kt +++ b/app/src/main/java/com/rosetta/messenger/network/TransportManager.kt @@ -4,9 +4,11 @@ import android.content.Context import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.isActive import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext import okhttp3.* @@ -18,6 +20,7 @@ import java.util.concurrent.TimeUnit import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException import kotlin.coroutines.suspendCoroutine +import kotlin.coroutines.coroutineContext /** * Состояние загрузки/скачивания файла @@ -156,6 +159,14 @@ object TransportManager { } }) } + + private fun parseContentRangeTotal(value: String?): Long? { + if (value.isNullOrBlank()) return null + // Example: "bytes 100-999/12345" + val totalPart = value.substringAfter('/').trim() + if (totalPart.isEmpty() || totalPart == "*") return null + return totalPart.toLongOrNull() + } /** * Загрузить файл на транспортный сервер с отслеживанием прогресса @@ -375,72 +386,125 @@ object TransportManager { * @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") + val cacheDir = appContext?.cacheDir ?: throw IOException("No app context") + val tempFile = File(cacheDir, "dlr_${id.take(16)}_${System.currentTimeMillis()}.tmp") + try { + downloadFileRawResumable( + id = id, + tag = tag, + targetFile = tempFile, + resumeFromBytes = 0L + ) + } catch (e: Exception) { + tempFile.delete() + throw e + } + } - _downloading.value = _downloading.value + TransportState(id, 0) + /** + * Resumable download with HTTP Range support. + * If server supports range (206), continues from `targetFile.length()`. + * If not, safely restarts from zero and rewrites target file. + */ + suspend fun downloadFileRawResumable( + id: String, + tag: String, + targetFile: File, + resumeFromBytes: Long = 0L + ): File = withContext(Dispatchers.IO) { + val server = getActiveServer() + ProtocolManager.addLog( + "📥 Download raw(resume) start: id=${id.take(8)}, tag=${tag.take(10)}, server=$server, resume=$resumeFromBytes" + ) + + _downloading.value = _downloading.value.filter { it.id != id } + TransportState(id, 0) try { withRetry { - val request = Request.Builder() + val existingBytes = if (targetFile.exists()) targetFile.length() else 0L + val startOffset = maxOf(existingBytes, resumeFromBytes.coerceAtLeast(0L)) + .coerceAtMost(existingBytes) + + val requestBuilder = Request.Builder() .url("$server/d/$tag") .get() - .build() - - val response = awaitDownloadResponse(id, request) + if (startOffset > 0L) { + requestBuilder.addHeader("Range", "bytes=$startOffset-") + } + val response = awaitDownloadResponse(id, requestBuilder.build()) 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") + val rangeAccepted = response.code == 206 + val writeFromOffset = if (rangeAccepted) startOffset else 0L + val incomingLength = body.contentLength().coerceAtLeast(0L) + val totalFromHeader = parseContentRangeTotal(response.header("Content-Range")) + val totalBytes = when { + totalFromHeader != null && totalFromHeader > 0L -> totalFromHeader + incomingLength > 0L -> writeFromOffset + incomingLength + else -> -1L + } - try { - var totalRead = 0L - val buffer = ByteArray(64 * 1024) + if (writeFromOffset == 0L && targetFile.exists()) { + targetFile.delete() + } + targetFile.parentFile?.mkdirs() - 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 - } + val append = writeFromOffset > 0L + var totalRead = writeFromOffset + val buffer = ByteArray(64 * 1024) + + body.byteStream().use { inputStream -> + java.io.FileOutputStream(targetFile, append).use { outputStream -> + while (true) { + coroutineContext.ensureActive() + val bytesRead = try { + inputStream.read(buffer) + } catch (e: IOException) { + if (!coroutineContext.isActive) { + throw CancellationException("Download cancelled", e) + } + throw e + } + if (bytesRead == -1) break + + outputStream.write(buffer, 0, bytesRead) + totalRead += bytesRead + + if (totalBytes > 0L) { + val progress = + ((totalRead * 100L) / totalBytes).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 } + + if (totalBytes > 0L && totalRead < totalBytes) { + throw IOException( + "Incomplete download: expected=$totalBytes, got=$totalRead" + ) + } + if (totalRead == 0L) { + 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(resume) OK: id=${id.take(8)}, size=$totalRead" + ) + targetFile } } catch (e: Exception) { ProtocolManager.addLog( - "❌ Download raw failed: id=${id.take(8)}, reason=${e.javaClass.simpleName}: ${e.message ?: "unknown"}" + "❌ Download raw(resume) failed: id=${id.take(8)}, reason=${e.javaClass.simpleName}: ${e.message ?: "unknown"}" ) throw e } finally {