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:
Hermes Agent
2026-08-19 13:46:03 +03:00
parent 6069400b88
commit 724f90a385
4 changed files with 398 additions and 42 deletions
@@ -12,10 +12,15 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext 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.OkHttpClient
import okhttp3.Request import okhttp3.Request
import pw.binom.viewmate.core.media.MirrorClient import pw.binom.viewmate.core.media.MirrorClient
import java.io.File import java.io.File
import java.io.FileOutputStream
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
/** Стадия загрузки файла на телефон. */ /** Стадия загрузки файла на телефон. */
@@ -34,10 +39,94 @@ data class DownloadProgress(
val stage: String = "", 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. * Файлы: filesDir/viewmate/{itemId}/video.mkv + audio-{index}.ogg.
* Прогресс живёт в StateFlow и переживает повороты (загрузка в scope приложения). * Скачивание пишется в .part и переживает рестарт: статус хранится в status.json,
* при старте диск сканируется, докачка — только по явному тапу пользователя.
*/ */
class DownloadManager( class DownloadManager(
context: Context, context: Context,
@@ -46,6 +135,8 @@ class DownloadManager(
private val baseDir = File(context.filesDir, "viewmate") private val baseDir = File(context.filesDir, "viewmate")
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val jobs = ConcurrentHashMap<String, Job>() 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() private val okHttp = OkHttpClient.Builder()
.connectTimeout(30, java.util.concurrent.TimeUnit.SECONDS) .connectTimeout(30, java.util.concurrent.TimeUnit.SECONDS)
.readTimeout(0, java.util.concurrent.TimeUnit.MILLISECONDS) // длинные файлы, паузы в потоке .readTimeout(0, java.util.concurrent.TimeUnit.MILLISECONDS) // длинные файлы, паузы в потоке
@@ -54,13 +145,45 @@ class DownloadManager(
private val _downloads = MutableStateFlow<Map<String, DownloadProgress>>(emptyMap()) private val _downloads = MutableStateFlow<Map<String, DownloadProgress>>(emptyMap())
val downloads: StateFlow<Map<String, DownloadProgress>> = _downloads.asStateFlow() val downloads: StateFlow<Map<String, DownloadProgress>> = _downloads.asStateFlow()
private val _downloadedIds = MutableStateFlow(scanExistingDownloads()) private val _downloadedIds = MutableStateFlow<Set<String>>(emptySet())
/** itemId, у которых видео уже скачано на телефон (переживает рестарт приложения). */ /** itemId, у которых видео уже скачано на телефон (переживает рестарт приложения). */
val downloadedIds: StateFlow<Set<String>> = _downloadedIds.asStateFlow() val downloadedIds: StateFlow<Set<String>> = _downloadedIds.asStateFlow()
private fun scanExistingDownloads(): Set<String> = private val _activeIds = MutableStateFlow<Set<String>>(emptySet())
baseDir.listFiles()?.filter { it.isDirectory && File(it, "video.mkv").exists() }?.map { it.name }?.toSet() /** itemId, у которых прямо сейчас идёт активное скачивание (не «замороженный» .part). */
?: emptySet() 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. */ /** Папка скачанного для itemId. */
fun dirFor(itemId: String): File = File(baseDir, 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") 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> = 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() ?: emptyList()
/** Видео уже скачано на телефон. */ /** Видео уже скачано на телефон. */
fun isDownloaded(itemId: String): Boolean = videoFile(itemId).exists() 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) { fun startDownload(itemId: String, audioIndex: Int) {
if (jobs[itemId]?.isActive == true) return if (jobs[itemId]?.isActive == true) return
if (isDownloaded(itemId)) return
jobs[itemId] = scope.launch { jobs[itemId] = scope.launch {
_activeIds.value = _activeIds.value + itemId
try { try {
update(itemId, DownloadProgress(itemId, DownloadPhase.DOWNLOADING, 0, "получаю зеркало")) update(itemId, DownloadProgress(itemId, DownloadPhase.DOWNLOADING, resumePercent(itemId), "получаю зеркало"))
val status = mirror.mirrorByItem(itemId) val status = mirror.mirrorByItem(itemId)
val files = status?.files val files = status?.files
if (status == null || status.status != "done" || files == null) { if (status == null || status.status != "done" || files == null) {
@@ -96,21 +245,26 @@ class DownloadManager(
dir.mkdirs() dir.mkdirs()
downloadFile( downloadFile(
S3Presigner.presignGet(files.video.url, PhoneConfig.S3_ACCESS_KEY, PhoneConfig.S3_SECRET_KEY, PhoneConfig.S3_REGION), url = S3Presigner.presignGet(files.video.url, PhoneConfig.S3_ACCESS_KEY, PhoneConfig.S3_SECRET_KEY, PhoneConfig.S3_REGION),
videoFile(itemId), part = partVideo(itemId),
) { percent -> dest = videoFile(itemId),
onProgress = { percent ->
update(itemId, DownloadProgress(itemId, DownloadPhase.DOWNLOADING, percent, "видео")) update(itemId, DownloadProgress(itemId, DownloadPhase.DOWNLOADING, percent, "видео"))
} },
onTotal = { total -> setTotalBytes(itemId, total) },
)
val audio = files.audios.firstOrNull { it.index == audioIndex } ?: files.audios.firstOrNull() val audio = files.audios.firstOrNull { it.index == audioIndex } ?: files.audios.firstOrNull()
if (audio != null) { if (audio != null) {
val audioFile = File(dir, "audio-${audio.index}.ogg")
downloadFile( downloadFile(
S3Presigner.presignGet(audio.url, PhoneConfig.S3_ACCESS_KEY, PhoneConfig.S3_SECRET_KEY, PhoneConfig.S3_REGION), url = S3Presigner.presignGet(audio.url, PhoneConfig.S3_ACCESS_KEY, PhoneConfig.S3_SECRET_KEY, PhoneConfig.S3_REGION),
audioFile, part = partAudio(itemId, audio.index),
) { percent -> dest = audioFile(itemId, audio.index),
onProgress = { percent ->
update(itemId, DownloadProgress(itemId, DownloadPhase.DOWNLOADING, percent, "звук")) update(itemId, DownloadProgress(itemId, DownloadPhase.DOWNLOADING, percent, "звук"))
} },
onTotal = { total -> setTotalBytes(itemId, total) },
)
} }
update(itemId, DownloadProgress(itemId, DownloadPhase.DONE, 100, "готово")) update(itemId, DownloadProgress(itemId, DownloadPhase.DONE, 100, "готово"))
@@ -122,33 +276,67 @@ class DownloadManager(
} catch (e: Exception) { } catch (e: Exception) {
log("download", "ошибка скачивания $itemId: ${e.message}") log("download", "ошибка скачивания $itemId: ${e.message}")
update(itemId, DownloadProgress(itemId, DownloadPhase.FAILED, 0, "ошибка: ${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) { fun cancel(itemId: String) {
jobs.remove(itemId)?.cancel() jobs.remove(itemId)?.cancel()
} }
/** Удалить скачанные файлы с телефона и сбросить прогресс. */ /** Удалить скачанные файлы с телефона и сбросить статус. */
fun delete(itemId: String) { fun delete(itemId: String) {
cancel(itemId) cancel(itemId)
dirFor(itemId).deleteRecursively() dirFor(itemId).deleteRecursively()
_downloads.value = _downloads.value - itemId _downloads.value = _downloads.value - itemId
_downloadedIds.value = _downloadedIds.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() private fun resumePercent(itemId: String): Int {
okHttp.newCall(request).execute().use { response -> val total = statuses[itemId]?.totalBytes ?: 0L
if (!response.isSuccessful) error("HTTP ${response.code} для $url") 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 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() val input = body.byteStream()
dest.outputStream().use { out -> FileOutputStream(part, resumed).use { out ->
val buffer = ByteArray(DEFAULT_BUFFER_SIZE) val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
var done = 0L var done = from
while (true) { while (true) {
currentCoroutineContext().ensureActive() currentCoroutineContext().ensureActive()
val read = input.read(buffer) val read = input.read(buffer)
@@ -156,14 +344,47 @@ class DownloadManager(
out.write(buffer, 0, read) out.write(buffer, 0, read)
done += read done += read
if (total > 0) { 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) { private fun update(itemId: String, progress: DownloadProgress) {
_downloads.value = _downloads.value + (itemId to progress) _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) status == "failed" || status == "error" -> MirrorBadge("✗", PhoneRed, Color.White)
hasLocal || hasAny -> MirrorBadge("↓ часть", PhoneBlue, PhoneBlueText)
status == "done" -> MirrorBadge("✓", PhoneGreen, PhoneGreenText) status == "done" -> MirrorBadge("✓", PhoneGreen, PhoneGreenText)
hasLocal || hasAny -> MirrorBadge("↓ часть", PhoneBlue, PhoneBlueText)
else -> MirrorBadge("нет", Color(0xFF555555), PhoneTextDim) else -> MirrorBadge("нет", Color(0xFF555555), PhoneTextDim)
} }
} }
@@ -217,7 +217,7 @@ fun StatusLine(label: String, chip: @Composable () -> Unit) {
/** Карточка-статус из макета (фон PhoneCard, скругление 10). */ /** Карточка-статус из макета (фон PhoneCard, скругление 10). */
@Composable @Composable
fun StatusBlock(content: @Composable () -> Unit) { fun StatusBlock(content: @Composable () -> Unit) {
Box( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clip(RoundedCornerShape(10.dp)) .clip(RoundedCornerShape(10.dp))
@@ -63,6 +63,7 @@ fun DetailsScreen(
var audioIndex by remember(item.Id) { mutableStateOf(app.state.currentAudioIndex) } var audioIndex by remember(item.Id) { mutableStateOf(app.state.currentAudioIndex) }
val downloadProgress by app.downloadManager.downloads.collectAsState() val downloadProgress by app.downloadManager.downloads.collectAsState()
val activeIds by app.downloadManager.activeIds.collectAsState()
val downloadedMap by app.state.downloadedFlow.collectAsState() val downloadedMap by app.state.downloadedFlow.collectAsState()
val localIds by app.downloadManager.downloadedIds.collectAsState() val localIds by app.downloadManager.downloadedIds.collectAsState()
var audioDownloaded by remember(item.Id) { mutableStateOf(app.downloadManager.audioFiles(item.Id).isNotEmpty()) } 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 audios = files?.audios.orEmpty()
val effectiveAudioIndex = if (audios.any { it.index == audioIndex }) audioIndex else audios.firstOrNull()?.index ?: 0 val effectiveAudioIndex = if (audios.any { it.index == audioIndex }) audioIndex else audios.firstOrNull()?.index ?: 0
val download = downloadProgress[item.Id] 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) { fun onSelectAudio(index: Int) {
audioIndex = index audioIndex = index
@@ -116,8 +124,12 @@ fun DetailsScreen(
fun onDownloadToggle() { fun onDownloadToggle() {
val dm = app.downloadManager val dm = app.downloadManager
if (downloading) dm.cancel(item.Id) when {
else dm.startDownload(item.Id, effectiveAudioIndex) // файл уже полный — не запускаем повторную качку
dm.isDownloaded(item.Id) -> Unit
downloading -> dm.cancel(item.Id)
else -> dm.startDownload(item.Id, effectiveAudioIndex)
}
} }
fun onDeleteDownloaded() { fun onDeleteDownloaded() {
@@ -260,15 +272,15 @@ fun DetailsScreen(
StatusLine("📱 Телефон — видео") { StatusLine("📱 Телефон — видео") {
DeviceChip( DeviceChip(
downloaded = onPhoneVideo, downloaded = onPhoneVideo,
downloading = downloading && download?.stage == "видео", downloading = !onPhoneVideo && hasVideoPart,
percent = download?.percent ?: 0, percent = download?.percent ?: videoPartPercent,
) )
} }
StatusLine("📱 Телефон — звук") { StatusLine("📱 Телефон — звук") {
DeviceChip( DeviceChip(
downloaded = onPhoneAudio, downloaded = onPhoneAudio,
downloading = downloading && download?.stage == "звук", downloading = !onPhoneAudio && hasAudioPart,
percent = download?.percent ?: 0, percent = download?.percent ?: audioPartPercent,
) )
} }
Spacer(modifier = Modifier.height(6.dp)) Spacer(modifier = Modifier.height(6.dp))
@@ -309,7 +321,12 @@ fun DetailsScreen(
shape = RoundedCornerShape(10.dp), shape = RoundedCornerShape(10.dp),
) { ) {
Text( Text(
if (downloading) "Отменить" else "Скачать на телефон", when {
localIds.contains(item.Id) -> "✓ скачано на телефон"
downloading -> "Отменить"
hasVideoPart -> "Продолжить ($videoPartPercent%)"
else -> "Скачать на телефон"
},
fontSize = 13.sp, 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())
}
}