v1.2.4: реальная пауза скачивания с resume по Range
All checks were successful
Android Kernel Build / build (push) Successful in 50m9s

This commit is contained in:
2026-03-20 14:48:17 +05:00
parent 4440016d5f
commit 9afbbae5c9
3 changed files with 148 additions and 56 deletions

View File

@@ -52,6 +52,8 @@ object ReleaseNotes {
Файлы и загрузки Файлы и загрузки
- Добавлена пауза и возобновление скачивания файлов прямо из пузырька (иконка pause/resume) - Добавлена пауза и возобновление скачивания файлов прямо из пузырька (иконка pause/resume)
- Пауза скачивания теперь реальная: активный сетевой поток останавливается, а не только меняется UI-статус
- Resume продолжает загрузку с сохранённого места через HTTP Range (с безопасным fallback на полную перезагрузку, если сервер не поддерживает Range)
- Устранено дёргание прогресса при быстрых тапах по скачивающемуся файлу - Устранено дёргание прогресса при быстрых тапах по скачивающемуся файлу
- Прогресс скачивания стал стабильным и не откатывается назад после pause/resume - Прогресс скачивания стал стабильным и не откатывается назад после pause/resume
- Обновлён экран активных загрузок: добавлен статус Paused - Обновлён экран активных загрузок: добавлен статус Paused

View File

@@ -52,6 +52,12 @@ object FileDownloadManager {
val savedFile: File 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 ─── // ─── helpers ───
private fun isGroupStoredKey(value: String): Boolean = value.startsWith("group:") private fun isGroupStoredKey(value: String): Boolean = value.startsWith("group:")
@@ -161,6 +167,9 @@ object FileDownloadManager {
* Отменяет скачивание * Отменяет скачивание
*/ */
fun cancel(attachmentId: String) { fun cancel(attachmentId: String) {
requests[attachmentId]?.let { req ->
encryptedPartFile(req).delete()
}
pauseRequested.remove(attachmentId) pauseRequested.remove(attachmentId)
resumeAfterPause.remove(attachmentId) resumeAfterPause.remove(attachmentId)
requests.remove(attachmentId) requests.remove(attachmentId)
@@ -177,6 +186,7 @@ object FileDownloadManager {
pauseRequested.remove(attachmentId) pauseRequested.remove(attachmentId)
val savedPath = request.savedFile.absolutePath val savedPath = request.savedFile.absolutePath
val encryptedPart = encryptedPartFile(request)
val resumeBase = val resumeBase =
(_downloads.value[attachmentId] (_downloads.value[attachmentId]
?.takeIf { it.status == FileDownloadStatus.PAUSED } ?.takeIf { it.status == FileDownloadStatus.PAUSED }
@@ -205,19 +215,13 @@ object FileDownloadManager {
) )
// Запускаем polling прогресса из TransportManager. // Запускаем polling прогресса из TransportManager.
// При resume удерживаем плавный прогресс без визуального отката назад. // Держим прогресс монотонным, чтобы он не дёргался вниз.
progressJob = launch { progressJob = launch {
TransportManager.downloading.collect { list -> TransportManager.downloading.collect { list ->
val entry = list.find { it.id == attachmentId } ?: return@collect val entry = list.find { it.id == attachmentId } ?: return@collect
val rawCdn = (entry.progress / 100f) * 0.8f 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 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( update(
attachmentId, attachmentId,
request.fileName, request.fileName,
@@ -238,6 +242,7 @@ object FileDownloadManager {
privateKey = request.privateKey, privateKey = request.privateKey,
fileName = request.fileName, fileName = request.fileName,
savedFile = request.savedFile, savedFile = request.savedFile,
encryptedPartFile = encryptedPart,
accountPublicKey = request.accountPublicKey, accountPublicKey = request.accountPublicKey,
savedPath = savedPath savedPath = savedPath
) )
@@ -249,6 +254,7 @@ object FileDownloadManager {
privateKey = request.privateKey, privateKey = request.privateKey,
fileName = request.fileName, fileName = request.fileName,
savedFile = request.savedFile, savedFile = request.savedFile,
encryptedPartFile = encryptedPart,
accountPublicKey = request.accountPublicKey, accountPublicKey = request.accountPublicKey,
savedPath = savedPath savedPath = savedPath
) )
@@ -264,6 +270,8 @@ object FileDownloadManager {
request.accountPublicKey, request.accountPublicKey,
savedPath savedPath
) )
encryptedPart.delete()
requests.remove(attachmentId)
} else { } else {
update( update(
attachmentId, attachmentId,
@@ -347,10 +355,21 @@ object FileDownloadManager {
privateKey: String, privateKey: String,
fileName: String, fileName: String,
savedFile: File, savedFile: File,
encryptedPartFile: File,
accountPublicKey: String, accountPublicKey: String,
savedPath: String savedPath: String
): Boolean { ): 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( update(
attachmentId, attachmentId,
fileName, fileName,
@@ -405,11 +424,18 @@ object FileDownloadManager {
privateKey: String, privateKey: String,
fileName: String, fileName: String,
savedFile: File, savedFile: File,
encryptedPartFile: File,
accountPublicKey: String, accountPublicKey: String,
savedPath: String savedPath: String
): Boolean { ): Boolean {
// Streaming: скачиваем во temp file val resumeBytes = if (encryptedPartFile.exists()) encryptedPartFile.length() else 0L
val tempFile = TransportManager.downloadFileRaw(attachmentId, downloadTag) val tempFile =
TransportManager.downloadFileRawResumable(
id = attachmentId,
tag = downloadTag,
targetFile = encryptedPartFile,
resumeFromBytes = resumeBytes
)
update( update(
attachmentId, attachmentId,
fileName, fileName,
@@ -438,7 +464,7 @@ object FileDownloadManager {
savedFile savedFile
) )
} finally { } finally {
tempFile.delete() encryptedPartFile.delete()
} }
} }
update( update(

View File

@@ -4,9 +4,11 @@ import android.content.Context
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.* import okhttp3.*
@@ -18,6 +20,7 @@ import java.util.concurrent.TimeUnit
import kotlin.coroutines.resume import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine import kotlin.coroutines.suspendCoroutine
import kotlin.coroutines.coroutineContext
/** /**
* Состояние загрузки/скачивания файла * Состояние загрузки/скачивания файла
@@ -157,6 +160,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()
}
/** /**
* Загрузить файл на транспортный сервер с отслеживанием прогресса * Загрузить файл на транспортный сервер с отслеживанием прогресса
* @param id Уникальный ID файла * @param id Уникальный ID файла
@@ -375,72 +386,125 @@ object TransportManager {
* @return Временный файл с зашифрованным содержимым * @return Временный файл с зашифрованным содержимым
*/ */
suspend fun downloadFileRaw(id: String, tag: String): File = withContext(Dispatchers.IO) { suspend fun downloadFileRaw(id: String, tag: String): File = withContext(Dispatchers.IO) {
val server = getActiveServer() val cacheDir = appContext?.cacheDir ?: throw IOException("No app context")
ProtocolManager.addLog("📥 Download raw start: id=${id.take(8)}, tag=${tag.take(10)}, server=$server") 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 { try {
withRetry { 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") .url("$server/d/$tag")
.get() .get()
.build() if (startOffset > 0L) {
requestBuilder.addHeader("Range", "bytes=$startOffset-")
val response = awaitDownloadResponse(id, request) }
val response = awaitDownloadResponse(id, requestBuilder.build())
if (!response.isSuccessful) { if (!response.isSuccessful) {
throw IOException("Download failed: ${response.code}") throw IOException("Download failed: ${response.code}")
} }
val body = response.body ?: throw IOException("Empty response body") val body = response.body ?: throw IOException("Empty response body")
val contentLength = body.contentLength() val rangeAccepted = response.code == 206
val cacheDir = appContext?.cacheDir ?: throw IOException("No app context") val writeFromOffset = if (rangeAccepted) startOffset else 0L
val tempFile = File(cacheDir, "dlr_${id.take(16)}_${System.currentTimeMillis()}.tmp") 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 { if (writeFromOffset == 0L && targetFile.exists()) {
var totalRead = 0L targetFile.delete()
val buffer = ByteArray(64 * 1024) }
targetFile.parentFile?.mkdirs()
body.byteStream().use { inputStream -> val append = writeFromOffset > 0L
tempFile.outputStream().use { outputStream -> var totalRead = writeFromOffset
while (true) { val buffer = ByteArray(64 * 1024)
val bytesRead = inputStream.read(buffer)
if (bytesRead == -1) break body.byteStream().use { inputStream ->
outputStream.write(buffer, 0, bytesRead) java.io.FileOutputStream(targetFile, append).use { outputStream ->
totalRead += bytesRead while (true) {
if (contentLength > 0) { coroutineContext.ensureActive()
val progress = ((totalRead * 100) / contentLength).toInt().coerceIn(0, 99) val bytesRead = try {
_downloading.value = _downloading.value.map { inputStream.read(buffer)
if (it.id == id) it.copy(progress = progress) else it } 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) { } catch (e: Exception) {
ProtocolManager.addLog( 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 throw e
} finally { } finally {