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)
|
- Добавлена пауза и возобновление скачивания файлов прямо из пузырька (иконка pause/resume)
|
||||||
|
- Пауза скачивания теперь реальная: активный сетевой поток останавливается, а не только меняется UI-статус
|
||||||
|
- Resume продолжает загрузку с сохранённого места через HTTP Range (с безопасным fallback на полную перезагрузку, если сервер не поддерживает Range)
|
||||||
- Устранено дёргание прогресса при быстрых тапах по скачивающемуся файлу
|
- Устранено дёргание прогресса при быстрых тапах по скачивающемуся файлу
|
||||||
- Прогресс скачивания стал стабильным и не откатывается назад после pause/resume
|
- Прогресс скачивания стал стабильным и не откатывается назад после pause/resume
|
||||||
- Обновлён экран активных загрузок: добавлен статус Paused
|
- Обновлён экран активных загрузок: добавлен статус Paused
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,42 +386,97 @@ 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()
|
||||||
|
}
|
||||||
|
targetFile.parentFile?.mkdirs()
|
||||||
|
|
||||||
|
val append = writeFromOffset > 0L
|
||||||
|
var totalRead = writeFromOffset
|
||||||
val buffer = ByteArray(64 * 1024)
|
val buffer = ByteArray(64 * 1024)
|
||||||
|
|
||||||
body.byteStream().use { inputStream ->
|
body.byteStream().use { inputStream ->
|
||||||
tempFile.outputStream().use { outputStream ->
|
java.io.FileOutputStream(targetFile, append).use { outputStream ->
|
||||||
while (true) {
|
while (true) {
|
||||||
val bytesRead = inputStream.read(buffer)
|
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
|
if (bytesRead == -1) break
|
||||||
|
|
||||||
outputStream.write(buffer, 0, bytesRead)
|
outputStream.write(buffer, 0, bytesRead)
|
||||||
totalRead += bytesRead
|
totalRead += bytesRead
|
||||||
if (contentLength > 0) {
|
|
||||||
val progress = ((totalRead * 100) / contentLength).toInt().coerceIn(0, 99)
|
if (totalBytes > 0L) {
|
||||||
|
val progress =
|
||||||
|
((totalRead * 100L) / totalBytes).toInt().coerceIn(0, 99)
|
||||||
_downloading.value = _downloading.value.map {
|
_downloading.value = _downloading.value.map {
|
||||||
if (it.id == id) it.copy(progress = progress) else it
|
if (it.id == id) it.copy(progress = progress) else it
|
||||||
}
|
}
|
||||||
@@ -419,28 +485,26 @@ object TransportManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (contentLength > 0 && totalRead != contentLength) {
|
if (totalBytes > 0L && totalRead < totalBytes) {
|
||||||
tempFile.delete()
|
throw IOException(
|
||||||
throw IOException("Incomplete download: expected=$contentLength, got=$totalRead")
|
"Incomplete download: expected=$totalBytes, got=$totalRead"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
if (totalRead == 0L) {
|
if (totalRead == 0L) {
|
||||||
tempFile.delete()
|
|
||||||
throw IOException("Empty download: 0 bytes received")
|
throw IOException("Empty download: 0 bytes received")
|
||||||
}
|
}
|
||||||
|
|
||||||
_downloading.value = _downloading.value.map {
|
_downloading.value = _downloading.value.map {
|
||||||
if (it.id == id) it.copy(progress = 100) else it
|
if (it.id == id) it.copy(progress = 100) else it
|
||||||
}
|
}
|
||||||
ProtocolManager.addLog("✅ Download raw OK: id=${id.take(8)}, size=$totalRead")
|
ProtocolManager.addLog(
|
||||||
tempFile
|
"✅ Download raw(resume) OK: id=${id.take(8)}, size=$totalRead"
|
||||||
} catch (e: Exception) {
|
)
|
||||||
tempFile.delete()
|
targetFile
|
||||||
throw e
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} 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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user