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)
- Пауза скачивания теперь реальная: активный сетевой поток останавливается, а не только меняется UI-статус
- Resume продолжает загрузку с сохранённого места через HTTP Range (с безопасным fallback на полную перезагрузку, если сервер не поддерживает Range)
- Устранено дёргание прогресса при быстрых тапах по скачивающемуся файлу
- Прогресс скачивания стал стабильным и не откатывается назад после pause/resume
- Обновлён экран активных загрузок: добавлен статус Paused

View File

@@ -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(

View File

@@ -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 {