Files
view-mate/app-phone/src/main/kotlin/pw/binom/viewmate/phone/DownloadManager.kt
T
Hermes Agent 2844d6a3ac feat: разделение контента — на телефон только аудио, на очки только видео
Телефон качает audio-N.ogg (audioKeys вместо fileKeys, стадия «звук»),
очки получают DownloadFiles только с video.mkv (buildGlassesFileSpecs).
Watch проверяет готовность обоих: телефон — audioReady(), очки — как
было; отказ с подсказкой «Скачать на телефон/очки». Тесты: lib-core 19,
phone 36.
2026-08-20 01:41:43 +03:00

590 lines
28 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package pw.binom.viewmate.phone
import android.content.Context
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.MutableStateFlow
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 pw.binom.viewmate.core.media.MirrorFiles
import java.io.File
import java.io.FileOutputStream
import java.util.concurrent.ConcurrentHashMap
/** Стадия загрузки файла на телефон. */
enum class DownloadPhase {
DOWNLOADING,
DONE,
FAILED,
CANCELED,
}
/** Прогресс реального скачивания зеркала на телефон. */
data class DownloadProgress(
val itemId: String,
val phase: DownloadPhase,
val percent: Int,
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,
/** Ожидаемый набор дорожек: "video", "audio-0", ... — из зеркала, а не из того, что на диске. */
val expectedFiles: List<String> = emptyList(),
/** Размер каждой дорожки (key → байт), заполняется после получения заголовков. */
val fileSizes: Map<String, Long> = emptyMap(),
)
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>,
)
/**
* Удалить состояние скачивания [itemId] из всех карт. Идемпотентно:
* повторный вызов ничего не меняет (карты уже не содержат itemId).
*/
internal fun removeDownloadState(
statuses: MutableMap<String, DownloadStatus>,
downloads: MutableMap<String, DownloadProgress>,
downloadedIds: MutableSet<String>,
itemId: String,
) {
downloads.remove(itemId)
downloadedIds.remove(itemId)
statuses.remove(itemId)
}
/**
* Актуальна ли качка поколения [generation] для [itemId]. [delete] увеличивает поколение —
* старая незавершённая корутина после удаления видит несовпадение и не «воскрешает» качку.
* Нет записи — поколение 0 (никогда не удалялось).
*/
internal fun isCurrentGeneration(generations: Map<String, Long>, itemId: String, generation: Long): Boolean =
(generations[itemId] ?: 0L) == generation
/** Имя финального файла для ключа дорожки ("video" → "video.mkv", "audio-0" → "audio-0.ogg"). */
internal fun finalFileName(key: String): String = if (key == "video") "video.mkv" else "$key.ogg"
/** Имя .part-файла для ключа дорожки. */
internal fun partFileName(key: String): String = "${finalFileName(key)}.part"
/** Ключ дорожки по имени .part-файла ("video.mkv.part" → "video", "audio-0.ogg.part" → "audio-0"). */
internal fun fileKeyFor(partName: String): String {
val base = partName.removeSuffix(".part")
return if (base == "video.mkv") "video" else base.removeSuffix(".ogg")
}
/** Список ключей дорожек зеркала: video + каждая аудио (из files.audios, а не с диска). */
internal fun fileKeys(files: MirrorFiles): List<String> =
listOf("video") + files.audios.map { "audio-${it.index}" }
/** Ключи аудио-дорожек зеркала (audio-N) — телефон качает только звук. */
internal fun audioKeys(files: MirrorFiles): List<String> =
files.audios.map { "audio-${it.index}" }
/** Полностью ли скачано на диск: все финальные файлы [keys] есть, >0 байт, без .part. */
internal fun isFullyOnDisk(dir: File, keys: List<String>): Boolean {
if (keys.isEmpty()) return false
return keys.all { key ->
val final = File(dir, finalFileName(key))
final.isFile && final.length() > 0 && !File(dir, partFileName(key)).exists()
}
}
/** Общий процент скачанного на диск по .part/финальным файлам относительно [sizes]. */
internal fun diskPercent(dir: File, keys: List<String>, sizes: Map<String, Long>): Int {
if (keys.isEmpty()) return 0
val totalSum = keys.sumOf { sizes[it] ?: 0L }
if (totalSum <= 0) return 0
var done = 0L
keys.forEach { key ->
val size = sizes[key] ?: 0L
val part = File(dir, partFileName(key))
val final = File(dir, finalFileName(key))
done += when {
part.isFile && part.length() > 0 -> if (size > 0) part.length().coerceAtMost(size) else 0L
final.isFile && final.length() > 0 -> if (size > 0) size else final.length()
else -> 0L
}
}
return ((done * 100) / totalSum).toInt().coerceIn(0, 100)
}
/** Процент одного файла [fileName] по .part/финальному файлу относительно его размера; null, если посчитать нельзя. */
internal fun filePercent(dir: File, fileName: String, sizes: Map<String, Long>): Int? {
val final = File(dir, fileName)
if (final.isFile && final.length() > 0) return 100
val part = File(dir, "$fileName.part")
if (part.isFile && part.length() > 0) {
val total = sizes[fileKeyFor(part.name)] ?: 0L
if (total > 0) return ((part.length() * 100) / total).toInt().coerceIn(0, 99)
}
return null
}
/** Общий процент по живым суммам байтов (скачано/всего по всем файлам). */
internal fun combinedPercent(totals: Map<String, Long>, done: Map<String, Long>): Int {
val totalSum = totals.values.sum()
if (totalSum <= 0) return 0
val doneSum = done.values.sum()
return ((doneSum * 100) / totalSum).toInt().coerceIn(0, 100)
}
/**
* Скан папок filesDir/viewmate/<itemId>: все ожидаемые файлы → itemId в downloaded
* (заодно чистим лишние .part); есть только .part → фаза downloading с общим процентом
* по всем дорожкам. Список дорожек — из статуса (expectedFiles), а не с диска.
*/
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 savedStatus = saved[itemId]
val keys = savedStatus?.expectedFiles.orEmpty()
val parts = dir.listFiles { f -> f.isFile && f.name.endsWith(".part") }?.toList().orEmpty()
// старый формат (без expectedFiles): полным считаем наличие video.mkv
val fully = if (keys.isNotEmpty()) isFullyOnDisk(dir, keys) else File(dir, "video.mkv").isFile
if (fully) {
downloaded += itemId
parts.forEach { it.delete() }
} else if (parts.any { it.length() > 0 }) {
val percent = if (keys.isNotEmpty()) {
diskPercent(dir, keys, savedStatus?.fileSizes.orEmpty())
} else {
val total = savedStatus?.totalBytes ?: 0L
val biggest = parts.maxByOrNull { it.length() } ?: return@forEach
if (total > 0) ((biggest.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}/audio-{index}.ogg.
* Скачивание пишется в .part и переживает рестарт: статус хранится в status.json,
* при старте диск сканируется, докачка — только по явному тапу пользователя.
*/
class DownloadManager(
context: Context,
private val mirror: MirrorClient,
) {
private val baseDir = File(context.filesDir, "viewmate")
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val jobs = ConcurrentHashMap<String, Job>()
/**
* Поколение запуска качки itemId. Растёт при [delete]: незавершённая корутина
* после удаления видит несовпадение поколений и «умирает», не создавая файлы/статус заново.
*/
private val generations = ConcurrentHashMap<String, Long>()
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) // длинные файлы, паузы в потоке
.build()
private val _downloads = MutableStateFlow<Map<String, DownloadProgress>>(emptyMap())
val downloads: StateFlow<Map<String, DownloadProgress>> = _downloads.asStateFlow()
private val _downloadedIds = MutableStateFlow<Set<String>>(emptySet())
/** itemId, у которых звук уже скачан на телефон (переживает рестарт приложения). */
val downloadedIds: StateFlow<Set<String>> = _downloadedIds.asStateFlow()
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(),
expectedFiles = statuses[itemId]?.expectedFiles ?: emptyList(),
fileSizes = statuses[itemId]?.fileSizes ?: emptyMap(),
)
}
statusStore.write(statuses)
}
/** Папка скачанного для itemId. */
fun dirFor(itemId: String): File = File(baseDir, itemId)
/** Файл видео на телефоне. */
fun videoFile(itemId: String): File = File(dirFor(itemId), "video.mkv")
/** Временный файл видео (.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-") && !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()
/**
* Полностью ли скачан звук зеркала на телефон: все аудио из [files] (без .part, >0 байт).
* Список дорожек берётся из зеркала (files.audios), а не из того, что есть на диске.
*/
fun isFullyDownloaded(itemId: String, files: MirrorFiles?): Boolean {
val keys = files?.let { audioKeys(it) } ?: return false
if (keys.isEmpty()) return false
return isFullyOnDisk(dirFor(itemId), keys)
}
/**
* Все аудио-дорожки зеркала на диске (можно смотреть: звук будет).
* Список дорожек — из статуса качки (expectedFiles), а не с диска.
*/
fun audioReady(itemId: String): Boolean {
val status = statuses[itemId] ?: return false
if (status.expectedFiles.isEmpty()) return false
return isFullyOnDisk(dirFor(itemId), status.expectedFiles)
}
/** Общий процент скачанного на диск по всем дорожкам (по .part и полным файлам). */
fun overallPercent(itemId: String): Int {
val st = statuses[itemId] ?: return 0
if (st.expectedFiles.isEmpty()) return 0
return diskPercent(dirFor(itemId), st.expectedFiles, st.fileSizes)
}
/** Известные размеры дорожек (key → байт) после скачивания на телефон; пусто, если качки не было. */
fun knownFileSizes(itemId: String): Map<String, Long> =
statuses[itemId]?.fileSizes.orEmpty()
/** Процент конкретного файла [fileName] ("video.mkv", "audio-0.ogg") на диске телефона; null, если посчитать нельзя. */
fun percentForFile(itemId: String, fileName: String): Int? {
val st = statuses[itemId]
return filePercent(dirFor(itemId), fileName, st?.fileSizes.orEmpty())
}
/** Процент неполного файла [part] от полного размера дорожки (fileSizes/totalBytes из статуса). */
fun partPercent(itemId: String, part: File): Int =
percentForFile(itemId, part.name.removeSuffix(".part")) ?: 0
/**
* Начать (или продолжить) загрузку зеркала [itemId]: ТОЛЬКО аудио-дорожки (звук на телефоне).
* Если уже качается — повторный вызов игнорируется; если всё полностью скачано — тоже.
* При наличии .part докачивает с места обрыва (Range: bytes={размер}-) для каждого файла.
*/
fun startDownload(itemId: String) {
if (jobs[itemId]?.isActive == true) return
val generation = generations[itemId] ?: 0L
lateinit var job: Job
job = scope.launch {
_activeIds.value = _activeIds.value + itemId
try {
// корутина «устарела»: itemId удалили (delete) — дальше не идём,
// чтобы не создать заново каталог/статус/.part после удаления
fun stale() = !isCurrentGeneration(generations, itemId, generation)
if (stale()) return@launch
update(itemId, DownloadProgress(itemId, DownloadPhase.DOWNLOADING, resumePercent(itemId), "получаю зеркало"))
val status = mirror.mirrorByItem(itemId)
if (stale()) return@launch
val files = status?.files
if (status == null || status.status != "done" || files == null) {
if (!stale()) update(itemId, DownloadProgress(itemId, DownloadPhase.FAILED, 0, "зеркало не готово"))
return@launch
}
val keys = audioKeys(files)
val dir = dirFor(itemId)
if (stale()) return@launch
dir.mkdirs()
if (isFullyOnDisk(dir, keys)) {
update(itemId, DownloadProgress(itemId, DownloadPhase.DONE, 100, "готово"))
_downloadedIds.value = _downloadedIds.value + itemId
log("download", "$itemId уже полностью скачан на диск")
return@launch
}
setExpectedFiles(itemId, keys)
val totals = HashMap<String, Long>()
val done = HashMap<String, Long>()
keys.forEach { key ->
if (stale()) return@launch
val url = files.audios.firstOrNull { "audio-${it.index}" == key }?.url ?: return@forEach
val stage = "звук"
downloadFile(
url = S3Presigner.presignGet(url, PhoneConfig.S3_ACCESS_KEY, PhoneConfig.S3_SECRET_KEY, PhoneConfig.S3_REGION),
part = File(dir, partFileName(key)),
dest = File(dir, finalFileName(key)),
onTotal = { total ->
totals[key] = total
setFileSize(itemId, key, total)
},
onProgress = { d, t ->
totals[key] = t
done[key] = d
update(itemId, DownloadProgress(itemId, DownloadPhase.DOWNLOADING, combinedPercent(totals, done), stage))
},
)
if (stale()) return@launch
done[key] = totals[key] ?: 0L
}
if (stale()) return@launch
update(itemId, DownloadProgress(itemId, DownloadPhase.DONE, 100, "готово"))
_downloadedIds.value = _downloadedIds.value + itemId
log("download", "скачано $itemId → $dir")
} catch (e: kotlinx.coroutines.CancellationException) {
// после delete() не воскрешаем статус «отменено» — состояние уже сброшено
if (isCurrentGeneration(generations, itemId, generation)) {
update(itemId, DownloadProgress(itemId, DownloadPhase.CANCELED, 0, "отменено"))
}
throw e
} catch (e: Exception) {
if (isCurrentGeneration(generations, itemId, generation)) {
log("download", "ошибка скачивания $itemId: ${e.message}")
update(itemId, DownloadProgress(itemId, DownloadPhase.FAILED, 0, "ошибка: ${e.message}"))
}
} finally {
// снимаем ТОЛЬКО свою корутину: после cancel/delete в jobs могла лечь новая
jobs.remove(itemId, job)
_activeIds.value = _activeIds.value - itemId
}
}
jobs[itemId] = job
}
/** Отменить текущую загрузку (.part остаётся — при следующем тапе докачается). */
fun cancel(itemId: String) {
jobs.remove(itemId)?.cancel()
}
/** Удалить скачанные файлы с телефона и сбросить статус. Удаление финально. */
fun delete(itemId: String) {
cancel(itemId)
// инвалидируем незавершённую корутину: даже если она проснётся позже — дальше не пойдёт
generations[itemId] = (generations[itemId] ?: 0L) + 1
dirFor(itemId).deleteRecursively()
val downloads = _downloads.value.toMutableMap()
val downloadedIds = _downloadedIds.value.toMutableSet()
removeDownloadState(statuses, downloads, downloadedIds, itemId)
_downloads.value = downloads
_downloadedIds.value = downloadedIds
_activeIds.value = _activeIds.value - itemId
persistStatus(force = true)
log("download", "удалено скачанное $itemId (поколение → ${generations[itemId]})")
}
/** Сколько уже скачано (для фазы «получаю зеркало» при докачке) — общий процент по всем дорожкам. */
private fun resumePercent(itemId: String): Int = overallPercent(itemId)
/**
* Скачивание одного файла в [part] (докачка с Range, если уже есть данные),
* по завершении — rename в [dest] (без .part). [onProgress] отдаёт (скачано, всего).
*/
private suspend fun downloadFile(
url: String,
part: File,
dest: File,
onProgress: (done: Long, total: Long) -> 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")
// 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()
// перед созданием/открытием .part — стоп, если качку отменили/удалили
currentCoroutineContext().ensureActive()
FileOutputStream(part, resumed).use { out ->
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
var done = from
while (true) {
currentCoroutineContext().ensureActive()
val read = input.read(buffer)
if (read < 0) break
out.write(buffer, 0, read)
done += read
if (total > 0) onProgress(done, total)
}
}
// перед финализацией (.part → конечный файл) — стоп, если качку отменили/удалили,
// иначе удаление «воскреснет» переименованным .part
currentCoroutineContext().ensureActive()
if (dest.exists()) dest.delete()
if (!part.renameTo(dest)) {
part.copyTo(dest, overwrite = true)
part.delete()
}
}
}
private fun setFileSize(itemId: String, key: String, size: Long) {
if (size <= 0) return
val prev = statuses[itemId]
val sizes = prev?.fileSizes ?: emptyMap()
if (sizes.containsKey(key)) return
statuses[itemId] = DownloadStatus(
phase = prev?.phase ?: "downloading",
stage = prev?.stage ?: "",
percent = prev?.percent ?: 0,
totalBytes = (prev?.totalBytes ?: 0L) + size,
updatedAt = System.currentTimeMillis(),
expectedFiles = prev?.expectedFiles ?: emptyList(),
fileSizes = sizes + (key to size),
)
persistStatus()
}
private fun setExpectedFiles(itemId: String, keys: List<String>) {
val prev = statuses[itemId]
if (prev?.expectedFiles == keys) return
statuses[itemId] = DownloadStatus(
phase = prev?.phase ?: "downloading",
stage = prev?.stage ?: "",
percent = prev?.percent ?: 0,
totalBytes = prev?.totalBytes ?: 0L,
updatedAt = System.currentTimeMillis(),
expectedFiles = keys,
fileSizes = prev?.fileSizes ?: emptyMap(),
)
persistStatus()
}
private fun update(itemId: String, progress: DownloadProgress) {
_downloads.value = _downloads.value + (itemId to progress)
val prev = statuses[itemId]
statuses[itemId] = DownloadStatus(
phase = progress.phase.name.lowercase(),
stage = progress.stage,
percent = progress.percent,
totalBytes = prev?.totalBytes ?: 0L,
updatedAt = System.currentTimeMillis(),
expectedFiles = prev?.expectedFiles ?: emptyList(),
fileSizes = prev?.fileSizes ?: emptyMap(),
)
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)
}
}