v1.2.4: реальная пауза скачивания с resume по Range
All checks were successful
Android Kernel Build / build (push) Successful in 50m9s
All checks were successful
Android Kernel Build / build (push) Successful in 50m9s
This commit is contained in:
@@ -52,6 +52,8 @@ object ReleaseNotes {
|
||||
|
||||
Файлы и загрузки
|
||||
- Добавлена пауза и возобновление скачивания файлов прямо из пузырька (иконка pause/resume)
|
||||
- Пауза скачивания теперь реальная: активный сетевой поток останавливается, а не только меняется UI-статус
|
||||
- Resume продолжает загрузку с сохранённого места через HTTP Range (с безопасным fallback на полную перезагрузку, если сервер не поддерживает Range)
|
||||
- Устранено дёргание прогресса при быстрых тапах по скачивающемуся файлу
|
||||
- Прогресс скачивания стал стабильным и не откатывается назад после pause/resume
|
||||
- Обновлён экран активных загрузок: добавлен статус Paused
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user