fix(app-phone): персистентный статус скачивания + докачка с места обрыва + StatusBlock
- DownloadManager: .part-файлы, resume через Range (206/200), status.json (фаза/процент/total), скан диска при старте, guard от повторной качки - DetailsScreen: кнопка «✓ скачано на телефон»/«Продолжить (N%)», чипы из диска - Components: StatusBlock Box→Column (строки статуса перекрывались кнопкой «Удалить»); приоритет бейджа: «✓» done выше «↓ часть» - 8 новых тестов (Range, скан диска, status.json)
This commit is contained in:
@@ -12,10 +12,15 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import pw.binom.viewmate.core.media.MirrorClient
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/** Стадия загрузки файла на телефон. */
|
||||
@@ -34,10 +39,94 @@ data class DownloadProgress(
|
||||
val stage: String = "",
|
||||
)
|
||||
|
||||
/** Персистентный статус скачивания одного itemId (пишется в filesDir/viewmate/status.json). */
|
||||
@Serializable
|
||||
data class DownloadStatus(
|
||||
val phase: String = "downloading",
|
||||
val stage: String = "",
|
||||
val percent: Int = 0,
|
||||
val totalBytes: Long = 0,
|
||||
val updatedAt: Long = 0,
|
||||
)
|
||||
|
||||
internal val statusJson: Json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
/** Запись/чтение status.json: атомарно через временный файл. */
|
||||
internal class DownloadStatusStore(private val file: File) {
|
||||
fun read(): Map<String, DownloadStatus> = runCatching {
|
||||
if (!file.exists()) return@runCatching emptyMap()
|
||||
file.inputStream().use { stream ->
|
||||
statusJson.decodeFromString<Map<String, DownloadStatus>>(stream.readBytes().decodeToString())
|
||||
}
|
||||
}.getOrDefault(emptyMap())
|
||||
|
||||
fun write(statuses: Map<String, DownloadStatus>) {
|
||||
try {
|
||||
file.parentFile?.mkdirs()
|
||||
val tmp = File(file.parentFile, "${file.name}.tmp")
|
||||
tmp.writeText(statusJson.encodeToString(statuses))
|
||||
if (!tmp.renameTo(file)) file.writeText(statusJson.encodeToString(statuses))
|
||||
} catch (e: Exception) {
|
||||
log("download", "не удалось сохранить статус: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Результат сканирования диска при старте: полные файлы и незавершённые .part. */
|
||||
internal data class DiskScanResult(
|
||||
val downloaded: Set<String>,
|
||||
val progress: Map<String, DownloadProgress>,
|
||||
)
|
||||
|
||||
/**
|
||||
* Скан папок filesDir/viewmate/<itemId>: финальный video.mkv → itemId в downloaded
|
||||
* (заодно чистим лишние .part); есть только .part → фаза downloading с percent
|
||||
* из размера .part и totalBytes из статуса.
|
||||
*/
|
||||
internal fun scanDownloads(
|
||||
baseDir: File,
|
||||
saved: Map<String, DownloadStatus>,
|
||||
): DiskScanResult {
|
||||
val downloaded = mutableSetOf<String>()
|
||||
val progress = mutableMapOf<String, DownloadProgress>()
|
||||
|
||||
baseDir.listFiles()?.forEach { dir ->
|
||||
if (!dir.isDirectory) return@forEach
|
||||
val itemId = dir.name
|
||||
val parts = dir.listFiles { f -> f.isFile && f.name.endsWith(".part") }?.toList().orEmpty()
|
||||
val biggestPart = parts.maxByOrNull { it.length() }
|
||||
val hasFinal = File(dir, "video.mkv").exists()
|
||||
|
||||
if (hasFinal) {
|
||||
downloaded += itemId
|
||||
parts.forEach { it.delete() }
|
||||
} else if (biggestPart != null && biggestPart.length() > 0) {
|
||||
val savedStatus = saved[itemId]
|
||||
val total = savedStatus?.totalBytes ?: 0L
|
||||
val percent = if (total > 0) ((biggestPart.length() * 100) / total).toInt().coerceIn(0, 99) else 0
|
||||
val stage = savedStatus?.stage?.takeIf { it.isNotBlank() } ?: "видео"
|
||||
progress[itemId] = DownloadProgress(itemId, DownloadPhase.DOWNLOADING, percent, stage)
|
||||
}
|
||||
}
|
||||
|
||||
return DiskScanResult(downloaded, progress)
|
||||
}
|
||||
|
||||
/** OkHttp-запрос на скачивание: при [existingBytes] > 0 докачиваем с Range-заголовком. */
|
||||
internal fun requestFor(url: String, existingBytes: Long): Request {
|
||||
val builder = Request.Builder().url(url)
|
||||
if (existingBytes > 0) builder.header("Range", "bytes=$existingBytes-")
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Реальное скачивание зеркала (видео + выбранная озвучка) с прогрессом.
|
||||
* Файлы: filesDir/viewmate/{itemId}/video.mkv + audio-{index}.ogg.
|
||||
* Прогресс живёт в StateFlow и переживает повороты (загрузка в scope приложения).
|
||||
* Скачивание пишется в .part и переживает рестарт: статус хранится в status.json,
|
||||
* при старте диск сканируется, докачка — только по явному тапу пользователя.
|
||||
*/
|
||||
class DownloadManager(
|
||||
context: Context,
|
||||
@@ -46,6 +135,8 @@ class DownloadManager(
|
||||
private val baseDir = File(context.filesDir, "viewmate")
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private val jobs = ConcurrentHashMap<String, Job>()
|
||||
private val statuses = ConcurrentHashMap<String, DownloadStatus>()
|
||||
private val statusStore = DownloadStatusStore(File(baseDir, "status.json"))
|
||||
private val okHttp = OkHttpClient.Builder()
|
||||
.connectTimeout(30, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.readTimeout(0, java.util.concurrent.TimeUnit.MILLISECONDS) // длинные файлы, паузы в потоке
|
||||
@@ -54,13 +145,45 @@ class DownloadManager(
|
||||
private val _downloads = MutableStateFlow<Map<String, DownloadProgress>>(emptyMap())
|
||||
val downloads: StateFlow<Map<String, DownloadProgress>> = _downloads.asStateFlow()
|
||||
|
||||
private val _downloadedIds = MutableStateFlow(scanExistingDownloads())
|
||||
private val _downloadedIds = MutableStateFlow<Set<String>>(emptySet())
|
||||
/** itemId, у которых видео уже скачано на телефон (переживает рестарт приложения). */
|
||||
val downloadedIds: StateFlow<Set<String>> = _downloadedIds.asStateFlow()
|
||||
|
||||
private fun scanExistingDownloads(): Set<String> =
|
||||
baseDir.listFiles()?.filter { it.isDirectory && File(it, "video.mkv").exists() }?.map { it.name }?.toSet()
|
||||
?: emptySet()
|
||||
private val _activeIds = MutableStateFlow<Set<String>>(emptySet())
|
||||
/** itemId, у которых прямо сейчас идёт активное скачивание (не «замороженный» .part). */
|
||||
val activeIds: StateFlow<Set<String>> = _activeIds.asStateFlow()
|
||||
|
||||
init {
|
||||
loadPersisted()
|
||||
}
|
||||
|
||||
/** Восстановить состояние из status.json + скан диска. */
|
||||
private fun loadPersisted() {
|
||||
val saved = statusStore.read()
|
||||
statuses.putAll(saved)
|
||||
val scan = scanDownloads(baseDir, statuses)
|
||||
_downloadedIds.value = scan.downloaded
|
||||
val progress = scan.progress.toMutableMap()
|
||||
// статусы из файла, которых нет на диске (failed/canceled) — показать как есть
|
||||
saved.forEach { (itemId, st) ->
|
||||
if (itemId in scan.downloaded || progress.containsKey(itemId)) return@forEach
|
||||
if (st.phase == "failed" || st.phase == "canceled") {
|
||||
progress[itemId] = DownloadProgress(itemId, DownloadPhase.valueOf(st.phase.uppercase()), st.percent, st.stage)
|
||||
}
|
||||
}
|
||||
_downloads.value = progress
|
||||
// обновить percent .part-элементов по размеру файлов на диске
|
||||
scan.progress.forEach { (itemId, p) ->
|
||||
statuses[itemId] = DownloadStatus(
|
||||
phase = "downloading",
|
||||
stage = p.stage,
|
||||
percent = p.percent,
|
||||
totalBytes = statuses[itemId]?.totalBytes ?: 0L,
|
||||
updatedAt = System.currentTimeMillis(),
|
||||
)
|
||||
}
|
||||
statusStore.write(statuses)
|
||||
}
|
||||
|
||||
/** Папка скачанного для itemId. */
|
||||
fun dirFor(itemId: String): File = File(baseDir, itemId)
|
||||
@@ -68,23 +191,49 @@ class DownloadManager(
|
||||
/** Файл видео на телефоне. */
|
||||
fun videoFile(itemId: String): File = File(dirFor(itemId), "video.mkv")
|
||||
|
||||
/** Файлы озвучки на телефоне (audio-*.ogg). */
|
||||
/** Временный файл видео (.part). */
|
||||
fun partVideo(itemId: String): File = File(dirFor(itemId), "video.mkv.part")
|
||||
|
||||
/** Файл озвучки на телефоне. */
|
||||
fun audioFile(itemId: String, index: Int): File = File(dirFor(itemId), "audio-$index.ogg")
|
||||
|
||||
/** Временный файл озвучки (.part). */
|
||||
fun partAudio(itemId: String, index: Int): File = File(dirFor(itemId), "audio-$index.ogg.part")
|
||||
|
||||
/** Файлы озвучки на телефоне (audio-*.ogg, без .part). */
|
||||
fun audioFiles(itemId: String): List<File> =
|
||||
dirFor(itemId).listFiles()?.filter { it.isFile && it.name.startsWith("audio-") }?.sortedBy { it.name }
|
||||
dirFor(itemId).listFiles()?.filter { it.isFile && it.name.startsWith("audio-") && !it.name.endsWith(".part") }?.sortedBy { it.name }
|
||||
?: emptyList()
|
||||
|
||||
/** Неполные .part-файлы озвучки. */
|
||||
fun audioParts(itemId: String): List<File> =
|
||||
dirFor(itemId).listFiles()?.filter { it.isFile && it.name.startsWith("audio-") && it.name.endsWith(".part") }?.sortedBy { it.name }
|
||||
?: emptyList()
|
||||
|
||||
/** Видео уже скачано на телефон. */
|
||||
fun isDownloaded(itemId: String): Boolean = videoFile(itemId).exists()
|
||||
|
||||
/** Процент неполного файла [part] от полного размера (totalBytes из статуса). */
|
||||
fun partPercent(itemId: String, part: File): Int {
|
||||
if (!part.exists() || part.length() <= 0) return 0
|
||||
val total = statuses[itemId]?.totalBytes ?: 0L
|
||||
if (total <= 0) return 0
|
||||
return ((part.length() * 100) / total).toInt().coerceIn(0, 99)
|
||||
}
|
||||
|
||||
/**
|
||||
* Начать загрузку зеркала [itemId] (видео + озвучка [audioIndex]).
|
||||
* Если уже качается — повторный вызов игнорируется.
|
||||
* Начать (или продолжить) загрузку зеркала [itemId] (видео + озвучка [audioIndex]).
|
||||
* Если уже качается — повторный вызов игнорируется; если файл полный — тоже.
|
||||
* При наличии .part докачивает с места обрыва (Range: bytes={размер}-).
|
||||
*/
|
||||
fun startDownload(itemId: String, audioIndex: Int) {
|
||||
if (jobs[itemId]?.isActive == true) return
|
||||
if (isDownloaded(itemId)) return
|
||||
|
||||
jobs[itemId] = scope.launch {
|
||||
_activeIds.value = _activeIds.value + itemId
|
||||
try {
|
||||
update(itemId, DownloadProgress(itemId, DownloadPhase.DOWNLOADING, 0, "получаю зеркало"))
|
||||
update(itemId, DownloadProgress(itemId, DownloadPhase.DOWNLOADING, resumePercent(itemId), "получаю зеркало"))
|
||||
val status = mirror.mirrorByItem(itemId)
|
||||
val files = status?.files
|
||||
if (status == null || status.status != "done" || files == null) {
|
||||
@@ -96,21 +245,26 @@ class DownloadManager(
|
||||
dir.mkdirs()
|
||||
|
||||
downloadFile(
|
||||
S3Presigner.presignGet(files.video.url, PhoneConfig.S3_ACCESS_KEY, PhoneConfig.S3_SECRET_KEY, PhoneConfig.S3_REGION),
|
||||
videoFile(itemId),
|
||||
) { percent ->
|
||||
url = S3Presigner.presignGet(files.video.url, PhoneConfig.S3_ACCESS_KEY, PhoneConfig.S3_SECRET_KEY, PhoneConfig.S3_REGION),
|
||||
part = partVideo(itemId),
|
||||
dest = videoFile(itemId),
|
||||
onProgress = { percent ->
|
||||
update(itemId, DownloadProgress(itemId, DownloadPhase.DOWNLOADING, percent, "видео"))
|
||||
}
|
||||
},
|
||||
onTotal = { total -> setTotalBytes(itemId, total) },
|
||||
)
|
||||
|
||||
val audio = files.audios.firstOrNull { it.index == audioIndex } ?: files.audios.firstOrNull()
|
||||
if (audio != null) {
|
||||
val audioFile = File(dir, "audio-${audio.index}.ogg")
|
||||
downloadFile(
|
||||
S3Presigner.presignGet(audio.url, PhoneConfig.S3_ACCESS_KEY, PhoneConfig.S3_SECRET_KEY, PhoneConfig.S3_REGION),
|
||||
audioFile,
|
||||
) { percent ->
|
||||
url = S3Presigner.presignGet(audio.url, PhoneConfig.S3_ACCESS_KEY, PhoneConfig.S3_SECRET_KEY, PhoneConfig.S3_REGION),
|
||||
part = partAudio(itemId, audio.index),
|
||||
dest = audioFile(itemId, audio.index),
|
||||
onProgress = { percent ->
|
||||
update(itemId, DownloadProgress(itemId, DownloadPhase.DOWNLOADING, percent, "звук"))
|
||||
}
|
||||
},
|
||||
onTotal = { total -> setTotalBytes(itemId, total) },
|
||||
)
|
||||
}
|
||||
|
||||
update(itemId, DownloadProgress(itemId, DownloadPhase.DONE, 100, "готово"))
|
||||
@@ -122,33 +276,67 @@ class DownloadManager(
|
||||
} catch (e: Exception) {
|
||||
log("download", "ошибка скачивания $itemId: ${e.message}")
|
||||
update(itemId, DownloadProgress(itemId, DownloadPhase.FAILED, 0, "ошибка: ${e.message}"))
|
||||
} finally {
|
||||
jobs.remove(itemId)
|
||||
_activeIds.value = _activeIds.value - itemId
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Отменить текущую загрузку. */
|
||||
/** Отменить текущую загрузку (.part остаётся — при следующем тапе докачается). */
|
||||
fun cancel(itemId: String) {
|
||||
jobs.remove(itemId)?.cancel()
|
||||
}
|
||||
|
||||
/** Удалить скачанные файлы с телефона и сбросить прогресс. */
|
||||
/** Удалить скачанные файлы с телефона и сбросить статус. */
|
||||
fun delete(itemId: String) {
|
||||
cancel(itemId)
|
||||
dirFor(itemId).deleteRecursively()
|
||||
_downloads.value = _downloads.value - itemId
|
||||
_downloadedIds.value = _downloadedIds.value - itemId
|
||||
statuses.remove(itemId)
|
||||
persistStatus(force = true)
|
||||
}
|
||||
|
||||
private suspend fun downloadFile(url: String, dest: File, onProgress: (Int) -> Unit) = withContext(Dispatchers.IO) {
|
||||
val request = Request.Builder().url(url).build()
|
||||
okHttp.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) error("HTTP ${response.code} для $url")
|
||||
/** Сколько уже скачано (для фазы «получаю зеркало» при докачке). */
|
||||
private fun resumePercent(itemId: String): Int {
|
||||
val total = statuses[itemId]?.totalBytes ?: 0L
|
||||
if (total <= 0) return 0
|
||||
return partPercent(itemId, partVideo(itemId))
|
||||
}
|
||||
|
||||
/**
|
||||
* Скачивание одного файла в [part] (докачка с Range, если уже есть данные),
|
||||
* по завершении — rename в [dest] (без .part).
|
||||
*/
|
||||
private suspend fun downloadFile(
|
||||
url: String,
|
||||
part: File,
|
||||
dest: File,
|
||||
onProgress: (Int) -> Unit,
|
||||
onTotal: (Long) -> Unit,
|
||||
) = withContext(Dispatchers.IO) {
|
||||
val existing = if (part.exists()) part.length() else 0L
|
||||
okHttp.newCall(requestFor(url, existing)).execute().use { response ->
|
||||
val code = response.code
|
||||
if (code != 200 && code != 206) error("HTTP $code для $url")
|
||||
val body = response.body ?: error("пустое тело ответа для $url")
|
||||
val total = body.contentLength()
|
||||
|
||||
// 206 — сервер уважил Range; если вернул 200, начинаем заново с нуля
|
||||
val resumed = code == 206 && existing > 0
|
||||
val from = if (resumed) existing else 0L
|
||||
val total = if (resumed) {
|
||||
response.header("Content-Range")?.substringAfterLast('/')?.toLongOrNull()
|
||||
?: (from + body.contentLength())
|
||||
} else {
|
||||
body.contentLength()
|
||||
}
|
||||
onTotal(total)
|
||||
|
||||
val input = body.byteStream()
|
||||
dest.outputStream().use { out ->
|
||||
FileOutputStream(part, resumed).use { out ->
|
||||
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||
var done = 0L
|
||||
var done = from
|
||||
while (true) {
|
||||
currentCoroutineContext().ensureActive()
|
||||
val read = input.read(buffer)
|
||||
@@ -156,14 +344,47 @@ class DownloadManager(
|
||||
out.write(buffer, 0, read)
|
||||
done += read
|
||||
if (total > 0) {
|
||||
onProgress(((done.toDouble() / total) * 100).toInt().coerceIn(0, 100))
|
||||
}
|
||||
}
|
||||
val percent = ((done * 100) / total).toInt().coerceIn(0, 100)
|
||||
onProgress(percent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (dest.exists()) dest.delete()
|
||||
if (!part.renameTo(dest)) {
|
||||
part.copyTo(dest, overwrite = true)
|
||||
part.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setTotalBytes(itemId: String, total: Long) {
|
||||
if (total <= 0) return
|
||||
val prev = statuses[itemId]
|
||||
if (prev != null && prev.totalBytes > 0) return
|
||||
statuses[itemId] = prev?.copy(totalBytes = total) ?: DownloadStatus(totalBytes = total)
|
||||
persistStatus()
|
||||
}
|
||||
|
||||
private fun update(itemId: String, progress: DownloadProgress) {
|
||||
_downloads.value = _downloads.value + (itemId to progress)
|
||||
statuses[itemId] = DownloadStatus(
|
||||
phase = progress.phase.name.lowercase(),
|
||||
stage = progress.stage,
|
||||
percent = progress.percent,
|
||||
totalBytes = statuses[itemId]?.totalBytes ?: 0L,
|
||||
updatedAt = System.currentTimeMillis(),
|
||||
)
|
||||
persistStatus(force = progress.phase != DownloadPhase.DOWNLOADING)
|
||||
}
|
||||
|
||||
private var lastPersistAt = 0L
|
||||
|
||||
/** Запись status.json; прогресс пишем не чаще раза в полсекунды, терминальные фазы — всегда. */
|
||||
private fun persistStatus(force: Boolean = false) {
|
||||
val now = System.currentTimeMillis()
|
||||
if (!force && now - lastPersistAt < 500) return
|
||||
lastPersistAt = now
|
||||
statusStore.write(statuses)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,10 +100,10 @@ fun mirrorBadge(
|
||||
|
||||
status == "failed" || status == "error" -> MirrorBadge("✗", PhoneRed, Color.White)
|
||||
|
||||
hasLocal || hasAny -> MirrorBadge("↓ часть", PhoneBlue, PhoneBlueText)
|
||||
|
||||
status == "done" -> MirrorBadge("✓", PhoneGreen, PhoneGreenText)
|
||||
|
||||
hasLocal || hasAny -> MirrorBadge("↓ часть", PhoneBlue, PhoneBlueText)
|
||||
|
||||
else -> MirrorBadge("нет", Color(0xFF555555), PhoneTextDim)
|
||||
}
|
||||
}
|
||||
@@ -217,7 +217,7 @@ fun StatusLine(label: String, chip: @Composable () -> Unit) {
|
||||
/** Карточка-статус из макета (фон PhoneCard, скругление 10). */
|
||||
@Composable
|
||||
fun StatusBlock(content: @Composable () -> Unit) {
|
||||
Box(
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
|
||||
@@ -63,6 +63,7 @@ fun DetailsScreen(
|
||||
var audioIndex by remember(item.Id) { mutableStateOf(app.state.currentAudioIndex) }
|
||||
|
||||
val downloadProgress by app.downloadManager.downloads.collectAsState()
|
||||
val activeIds by app.downloadManager.activeIds.collectAsState()
|
||||
val downloadedMap by app.state.downloadedFlow.collectAsState()
|
||||
val localIds by app.downloadManager.downloadedIds.collectAsState()
|
||||
var audioDownloaded by remember(item.Id) { mutableStateOf(app.downloadManager.audioFiles(item.Id).isNotEmpty()) }
|
||||
@@ -89,7 +90,14 @@ fun DetailsScreen(
|
||||
val audios = files?.audios.orEmpty()
|
||||
val effectiveAudioIndex = if (audios.any { it.index == audioIndex }) audioIndex else audios.firstOrNull()?.index ?: 0
|
||||
val download = downloadProgress[item.Id]
|
||||
val downloading = download?.phase == DownloadPhase.DOWNLOADING
|
||||
// «качается» только если идёт активное скачивание; восстановленный из статуса .part — не качается
|
||||
val downloading = activeIds.contains(item.Id)
|
||||
val videoPart = app.downloadManager.partVideo(item.Id)
|
||||
val hasVideoPart = videoPart.length() > 0
|
||||
val audioPartFiles = app.downloadManager.audioParts(item.Id)
|
||||
val hasAudioPart = audioPartFiles.any { it.length() > 0 }
|
||||
val videoPartPercent = app.downloadManager.partPercent(item.Id, videoPart)
|
||||
val audioPartPercent = audioPartFiles.maxOfOrNull { app.downloadManager.partPercent(item.Id, it) } ?: 0
|
||||
|
||||
fun onSelectAudio(index: Int) {
|
||||
audioIndex = index
|
||||
@@ -116,8 +124,12 @@ fun DetailsScreen(
|
||||
|
||||
fun onDownloadToggle() {
|
||||
val dm = app.downloadManager
|
||||
if (downloading) dm.cancel(item.Id)
|
||||
else dm.startDownload(item.Id, effectiveAudioIndex)
|
||||
when {
|
||||
// файл уже полный — не запускаем повторную качку
|
||||
dm.isDownloaded(item.Id) -> Unit
|
||||
downloading -> dm.cancel(item.Id)
|
||||
else -> dm.startDownload(item.Id, effectiveAudioIndex)
|
||||
}
|
||||
}
|
||||
|
||||
fun onDeleteDownloaded() {
|
||||
@@ -260,15 +272,15 @@ fun DetailsScreen(
|
||||
StatusLine("📱 Телефон — видео") {
|
||||
DeviceChip(
|
||||
downloaded = onPhoneVideo,
|
||||
downloading = downloading && download?.stage == "видео",
|
||||
percent = download?.percent ?: 0,
|
||||
downloading = !onPhoneVideo && hasVideoPart,
|
||||
percent = download?.percent ?: videoPartPercent,
|
||||
)
|
||||
}
|
||||
StatusLine("📱 Телефон — звук") {
|
||||
DeviceChip(
|
||||
downloaded = onPhoneAudio,
|
||||
downloading = downloading && download?.stage == "звук",
|
||||
percent = download?.percent ?: 0,
|
||||
downloading = !onPhoneAudio && hasAudioPart,
|
||||
percent = download?.percent ?: audioPartPercent,
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
@@ -309,7 +321,12 @@ fun DetailsScreen(
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
) {
|
||||
Text(
|
||||
if (downloading) "Отменить" else "Скачать на телефон",
|
||||
when {
|
||||
localIds.contains(item.Id) -> "✓ скачано на телефон"
|
||||
downloading -> "Отменить"
|
||||
hasVideoPart -> "Продолжить ($videoPartPercent%)"
|
||||
else -> "Скачать на телефон"
|
||||
},
|
||||
fontSize = 13.sp,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
package pw.binom.viewmate.phone
|
||||
|
||||
import kotlinx.serialization.encodeToString
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class DownloadManagerTest {
|
||||
|
||||
private fun tempDir(): File = Files.createTempDirectory("dm-test").toFile()
|
||||
|
||||
private fun newBaseDir(): File {
|
||||
val base = tempDir()
|
||||
base.deleteOnExit()
|
||||
return base
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rangeHeaderFormedFromPartSize() {
|
||||
val noResume = requestFor("https://s3/mirror/video.mkv", 0)
|
||||
assertNull(noResume.header("Range"), "без .part Range-заголовка быть не должно")
|
||||
|
||||
val resume = requestFor("https://s3/mirror/video.mkv", 1_863_946_240)
|
||||
assertEquals("bytes=1863946240-", resume.header("Range"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun scanFullFileMarksDownloadedAndRemovesPart() {
|
||||
val base = newBaseDir()
|
||||
val dir = File(base, "i1").apply { mkdirs() }
|
||||
File(dir, "video.mkv").writeBytes(ByteArray(10))
|
||||
File(dir, "video.mkv.part").writeBytes(ByteArray(5)) // оставшийся после рестарта
|
||||
|
||||
val result = scanDownloads(base, emptyMap())
|
||||
|
||||
assertEquals(setOf("i1"), result.downloaded)
|
||||
assertTrue(result.progress.isEmpty())
|
||||
assertTrue(!File(dir, "video.mkv.part").exists(), ".part при наличии полного файла удаляется")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun scanPartOnlyMarksDownloadingWithPercent() {
|
||||
val base = newBaseDir()
|
||||
val dir = File(base, "i2").apply { mkdirs() }
|
||||
File(dir, "video.mkv.part").writeBytes(ByteArray(500))
|
||||
val saved = mapOf("i2" to DownloadStatus(totalBytes = 1000))
|
||||
|
||||
val result = scanDownloads(base, saved)
|
||||
|
||||
assertTrue(result.downloaded.isEmpty())
|
||||
assertEquals(
|
||||
DownloadProgress("i2", DownloadPhase.DOWNLOADING, 50, "видео"),
|
||||
result.progress["i2"],
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun scanPartWithoutTotalHasZeroPercent() {
|
||||
val base = newBaseDir()
|
||||
val dir = File(base, "i3").apply { mkdirs() }
|
||||
File(dir, "video.mkv.part").writeBytes(ByteArray(500))
|
||||
|
||||
val result = scanDownloads(base, emptyMap())
|
||||
|
||||
assertEquals(DownloadProgress("i3", DownloadPhase.DOWNLOADING, 0, "видео"), result.progress["i3"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun statusFileWritesAndReads() {
|
||||
val file = File(tempDir(), "status.json")
|
||||
val store = DownloadStatusStore(file)
|
||||
val statuses = mapOf(
|
||||
"i1" to DownloadStatus(phase = "downloading", stage = "видео", percent = 42, totalBytes = 1_000_000, updatedAt = 1234L),
|
||||
"i2" to DownloadStatus(phase = "done", percent = 100, totalBytes = 500, updatedAt = 5678L),
|
||||
)
|
||||
|
||||
store.write(statuses)
|
||||
val read = store.read()
|
||||
|
||||
assertEquals(statuses, read)
|
||||
assertTrue(file.exists())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun statusFileMissingReadsEmpty() {
|
||||
val store = DownloadStatusStore(File(tempDir(), "status.json"))
|
||||
assertTrue(store.read().isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun statusSerializationMatchesSpecKeys() {
|
||||
val text = statusJson.encodeToString(
|
||||
mapOf("i1" to DownloadStatus(phase = "downloading", stage = "видео", percent = 50, totalBytes = 100, updatedAt = 42L)),
|
||||
)
|
||||
assertTrue(text.contains("\"phase\""))
|
||||
assertTrue(text.contains("\"stage\""))
|
||||
assertTrue(text.contains("\"percent\""))
|
||||
assertTrue(text.contains("\"totalBytes\""))
|
||||
assertTrue(text.contains("\"updatedAt\""))
|
||||
assertTrue(text.contains("\"downloading\""))
|
||||
assertTrue(text.contains("\"видео\""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun scanIgnoresNonPartFilesWithoutFinalVideo() {
|
||||
val base = newBaseDir()
|
||||
val dir = File(base, "i4").apply { mkdirs() }
|
||||
File(dir, "some-random.bin").writeBytes(ByteArray(10))
|
||||
|
||||
val result = scanDownloads(base, emptyMap())
|
||||
|
||||
assertTrue(result.downloaded.isEmpty())
|
||||
assertTrue(result.progress.isEmpty())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user