fix(app-phone): звук при watch (диагностика+подстраховка mirrorByItem) + качка не воскресает после удаления

- onWatch: явные логи в каждой точке отказа (watch result / audio source / уже играет / не скачано / mediaDir пуст), свежий mirrorByItem если files не догрузился, звук стартует с позиции очков при уже идущем видео
- resolveAudioSource → resolveAudioSourceDetailed (AudioSourceResult с причиной null)
- DownloadManager: поколения generations — delete() инвалидирует старую корутину (не воскрешает качку), ensureActive перед .part/rename, jobs.remove(itemId, job)
- Тесты 62/0 (в т.ч. поколения — поймали null != 0L)

Инфраструктурный блокер (не код): SeaweedFS на Server3 76.66 в IO-шторме (load ~184, wa 20%, чужой yt-dlp на хосте) — большие GET с s3.binom.pw висят/рвутся с ~14:00; качка на очки FAILED по этой причине.
This commit is contained in:
Hermes Agent
2026-08-19 17:19:53 +03:00
parent fafdddc121
commit 49dac8b33f
29 changed files with 2237 additions and 173 deletions
@@ -20,11 +20,21 @@ class GlassesApp : Application() {
lateinit var movieController: MovieController
private set
/** Менеджер скачивания контента в память очков (отправку на телефон — через HostConnection). */
lateinit var downloadManager: GlassesDownloadManager
private set
override fun onCreate() {
super.onCreate()
instance = this
movieController = MovieController(applicationContext)
hostConnection = HostConnection(GlassesConfig.HOST_URL, movieController)
downloadManager = GlassesDownloadManager(applicationContext) { msg -> hostConnection.sendToHost(msg) }
hostConnection = HostConnection(
url = GlassesConfig.HOST_URL,
movieController = movieController,
downloadManager = downloadManager,
context = applicationContext,
)
hostConnection.start()
log("app", "HostConnection стартует: ${GlassesConfig.HOST_URL}")
}
@@ -0,0 +1,309 @@
package pw.binom.viewmate.glasses
import android.content.Context
import kotlinx.coroutines.CancellationException
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.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.protocol.DownloadFiles
import pw.binom.viewmate.core.protocol.DownloadPhase
import pw.binom.viewmate.core.protocol.DownloadProgress
import pw.binom.viewmate.core.protocol.FileSpec
import pw.binom.viewmate.core.protocol.GlassesToHost
import java.io.File
import java.io.FileOutputStream
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.TimeUnit
// --- чистая логика (тестируется без Android) ---
/** Имя .part-файла ("video.mkv" → "video.mkv.part"). */
internal fun partName(fileName: String): String = "$fileName.part"
/** Полностью ли скачано на диск: все [fileNames] есть, >0 байт, без .part. */
internal fun isFullyOnDisk(dir: File, fileNames: List<String>): Boolean {
if (fileNames.isEmpty()) return false
return fileNames.all { name ->
val final = File(dir, name)
final.isFile && final.length() > 0 && !File(dir, partName(name)).exists()
}
}
/** OkHttp-запрос на скачивание: при [existingBytes] > 0 докачиваем с Range. */
internal fun glassesRequestFor(url: String, existingBytes: Long): Request {
val builder = Request.Builder().url(url)
if (existingBytes > 0) builder.header("Range", "bytes=$existingBytes-")
return builder.build()
}
/** Процент одного файла (done/total). */
internal fun filePercent(doneBytes: Long, totalBytes: Long): Int {
if (totalBytes <= 0) return 0
return ((doneBytes * 100) / totalBytes).toInt().coerceIn(0, 100)
}
/** Общий процент по всем файлам (суммы байтов). */
internal fun overallPercent(doneBytes: Map<String, Long>, totalBytes: Map<String, Long>): Int {
val total = totalBytes.values.sum()
if (total <= 0) return 0
val done = doneBytes.values.sum()
return ((done * 100) / total).toInt().coerceIn(0, 100)
}
// --- персистентность ---
@Serializable
internal data class GlassesState(
val expected: Map<String, List<String>> = emptyMap(),
val downloaded: List<String> = emptyList(),
)
internal val glassesStateJson: Json = Json {
ignoreUnknownKeys = true
encodeDefaults = true
}
/** Запись/чтение status.json очков: атомарно через временный файл. */
internal class GlassesStateStore(private val file: File) {
fun read(): GlassesState = runCatching {
if (!file.exists()) return@runCatching GlassesState()
file.inputStream().use { stream ->
glassesStateJson.decodeFromString<GlassesState>(stream.readBytes().decodeToString())
}
}.getOrDefault(GlassesState())
fun write(state: GlassesState) {
try {
file.parentFile?.mkdirs()
val tmp = File(file.parentFile, "${file.name}.tmp")
tmp.writeText(glassesStateJson.encodeToString(state))
if (!tmp.renameTo(file)) file.writeText(glassesStateJson.encodeToString(state))
} catch (e: Exception) {
log("glasses", "не удалось сохранить состояние: ${e.message}")
}
}
}
/**
* Менеджер скачивания контента в память очков (аналог телефонного DownloadManager).
* Каталог: filesDir/viewmate/{itemId}/video.mkv + audio-N.ogg. Скачивание пишется в .part
* и докачивается с места обрыва (Range). Прогресс шлём на телефон через [send].
*/
class GlassesDownloadManager(
context: Context,
private val send: suspend (GlassesToHost) -> Unit,
) {
val baseDir = File(context.filesDir, "viewmate")
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val jobs = ConcurrentHashMap<String, Job>()
private val expectedFiles = ConcurrentHashMap<String, List<String>>()
private val stateStore = GlassesStateStore(File(baseDir, "status.json"))
private val _downloadedIds = MutableStateFlow<Set<String>>(emptySet())
/** itemId, у которых контент полностью скачан на очки (переживает рестарт). */
val downloadedIds: StateFlow<Set<String>> = _downloadedIds.asStateFlow()
private val _mediaBytes = MutableStateFlow(0L)
/** Сумма байт полностью скачанных файлов (для GlassesStatus.mediaBytes). */
val mediaBytes: StateFlow<Long> = _mediaBytes.asStateFlow()
private val okHttp = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(0, TimeUnit.MILLISECONDS) // длинные файлы, паузы в потоке
.build()
init {
loadPersisted()
mediaBytesNow()
}
private fun loadPersisted() {
val state = stateStore.read()
expectedFiles.putAll(state.expected)
_downloadedIds.value = state.downloaded.toSet()
}
private fun persist() {
stateStore.write(
GlassesState(
expected = expectedFiles.entries.associate { (k, v) -> k to v.toList() },
downloaded = _downloadedIds.value.toList(),
),
)
}
fun dirFor(itemId: String): File = File(baseDir, itemId)
/** Полностью ли скачано зеркало на очки: все ожидаемые файлы есть без .part (список — из DownloadFiles). */
fun isFullyDownloaded(itemId: String): Boolean {
val names = expectedFiles[itemId] ?: return false
return isFullyOnDisk(dirFor(itemId), names)
}
/** Локальный путь к видео для PlayVideo (file://...), null если не скачано. */
fun localVideoPath(itemId: String): String? {
if (!isFullyDownloaded(itemId)) return null
return "file://${File(dirFor(itemId), "video.mkv").absolutePath}"
}
/** Пересчитать mediaBytes с диска и вернуть значение. */
fun mediaBytesNow(): Long {
var total = 0L
baseDir.listFiles()?.forEach { dir ->
if (dir.isDirectory) {
dir.listFiles { f -> f.isFile && !f.name.endsWith(".part") }?.forEach { total += it.length() }
}
}
_mediaBytes.value = total
return total
}
/** Обработка DownloadFiles: скачиваем все файлы последовательно со сквозным прогрессом на телефон. */
fun handleDownloadFiles(msg: DownloadFiles) {
if (jobs[msg.itemId]?.isActive == true) return
val names = msg.files.map { it.name }
expectedFiles[msg.itemId] = names
persist()
jobs[msg.itemId] = scope.launch {
val done = HashMap<String, Long>()
val totals = HashMap<String, Long>()
try {
val dir = dirFor(msg.itemId)
dir.mkdirs()
if (isFullyOnDisk(dir, names)) {
markDownloaded(msg.itemId, true)
log("glasses", "${msg.itemId} уже полностью скачан на очки")
return@launch
}
msg.files.forEach { spec ->
var lastSentAt = 0L
val total = downloadFile(
spec = spec,
part = File(dir, partName(spec.name)),
dest = File(dir, spec.name),
onTotal = { totals[spec.name] = it },
onProgress = { d, t ->
done[spec.name] = d
totals[spec.name] = t
val now = System.currentTimeMillis()
// прогресс шлём не чаще раза в полсекунды
if (now - lastSentAt >= 500) {
lastSentAt = now
send(DownloadProgress(msg.itemId, spec.name, filePercent(d, t), d, t, DownloadPhase.DOWNLOADING))
}
},
)
done[spec.name] = total
totals[spec.name] = total
send(DownloadProgress(msg.itemId, spec.name, 100, total, total, DownloadPhase.DONE))
}
markDownloaded(msg.itemId, true)
mediaBytesNow()
log("glasses", "скачано на очки ${msg.itemId} → $dir")
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
log("glasses", "ошибка скачивания ${msg.itemId}: ${e.message}")
val last = done.entries.lastOrNull()
send(
DownloadProgress(
itemId = msg.itemId,
fileName = last?.key ?: names.firstOrNull().orEmpty(),
percent = last?.let { filePercent(it.value, totals[it.key] ?: 0L) } ?: 0,
doneBytes = last?.value ?: 0L,
totalBytes = last?.let { totals[it.key] ?: 0L } ?: 0L,
phase = DownloadPhase.FAILED,
),
)
} finally {
jobs.remove(msg.itemId)
}
}
}
/** Обработка DeleteFiles: удалить каталог контента на очках. */
fun handleDeleteFiles(itemId: String) {
jobs.remove(itemId)?.cancel()
dirFor(itemId).deleteRecursively()
expectedFiles.remove(itemId)
markDownloaded(itemId, false)
mediaBytesNow()
log("glasses", "удалён каталог $itemId с очков")
}
private fun markDownloaded(itemId: String, downloaded: Boolean) {
_downloadedIds.value = if (downloaded) _downloadedIds.value + itemId else _downloadedIds.value - itemId
persist()
}
/**
* Скачивание одного файла в [part] (докачка с Range, если уже есть данные),
* по завершении — rename в [dest] (без .part). Возвращает полный размер.
*/
private suspend fun downloadFile(
spec: FileSpec,
part: File,
dest: File,
onProgress: suspend (done: Long, total: Long) -> Unit,
onTotal: suspend (Long) -> Unit,
): Long {
val existing = if (part.exists()) part.length() else 0L
okHttp.newCall(glassesRequestFor(spec.url, existing)).execute().use { response ->
val code = response.code
if (code != 200 && code != 206) error("HTTP $code для ${spec.name}")
val body = response.body ?: error("пустое тело ответа для ${spec.name}")
// 206 — сервер уважил Range; если вернул 200, начинаем заново с нуля
val resumed = code == 206 && existing > 0
val from = if (resumed) existing else 0L
val total = if (spec.sizeBytes > 0) {
spec.sizeBytes
} else if (resumed) {
response.header("Content-Range")?.substringAfterLast('/')?.toLongOrNull()
?: (from + body.contentLength())
} else {
body.contentLength()
}
onTotal(total)
val input = body.byteStream()
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)
}
}
if (dest.exists()) dest.delete()
if (!part.renameTo(dest)) {
part.copyTo(dest, overwrite = true)
part.delete()
}
return total
}
}
}
@@ -1,5 +1,9 @@
package pw.binom.viewmate.glasses
import android.content.Context
import android.os.BatteryManager
import android.os.Environment
import android.os.StatFs
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -14,7 +18,10 @@ import kotlinx.coroutines.flow.asStateFlow
import pw.binom.viewmate.core.net.GlassesWsClient
import pw.binom.viewmate.core.protocol.AssistantStateMsg
import pw.binom.viewmate.core.protocol.ChatSessionMsg
import pw.binom.viewmate.core.protocol.DeleteFiles
import pw.binom.viewmate.core.protocol.DownloadFiles
import pw.binom.viewmate.core.protocol.GlassesStatus
import pw.binom.viewmate.core.protocol.GlassesToHost
import pw.binom.viewmate.core.protocol.Hello
import pw.binom.viewmate.core.protocol.HostToGlasses
import pw.binom.viewmate.core.protocol.MediaCommandMsg
@@ -27,12 +34,16 @@ import pw.binom.viewmate.core.protocol.Welcome
/**
* Обвязка над [GlassesWsClient] для UI очков:
* реконнект, Hello при подключении, периодический статус/позиция,
* обработка входящих HostToGlasses (PlayVideo/MediaCommand выполняются плеером).
* обработка входящих HostToGlasses (PlayVideo/MediaCommand выполняются плеером,
* DownloadFiles/DeleteFiles — менеджером скачивания).
*/
class HostConnection(
private val url: String,
private val movieController: MovieController,
private val downloadManager: GlassesDownloadManager,
context: Context,
) {
private val appContext = context.applicationContext
private val client = GlassesWsClient(url)
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@@ -53,7 +64,7 @@ class HostConnection(
_connected.value = true
_status.value = "подключено: $url"
addMessage("соединение установлено")
client.send(Hello(appVersion = "0.1.0"))
client.send(Hello(appVersion = APP_VERSION))
},
onDisconnected = {
_connected.value = false
@@ -64,14 +75,44 @@ class HostConnection(
scope.launch {
while (isActive) {
delay(5_000)
// статус/позиция — заглушки (реальные батарея/память — позже)
client.send(GlassesStatus(batteryPercent = 80, storageUsedGb = 10.5, storageTotalGb = 32.0))
client.send(buildStatus())
val (positionMs, playing) = withContext(Dispatchers.Main) { movieController.playbackPosition() }
client.send(PlaybackPosition(positionMs = positionMs, playing = playing))
}
}
}
/** Отправка сообщения на телефон (используется менеджером скачивания). */
suspend fun sendToHost(msg: GlassesToHost) = client.send(msg)
private fun buildStatus(): GlassesStatus {
val (totalBytes, freeBytes) = diskBytes()
val usedBytes = (totalBytes - freeBytes).coerceAtLeast(0L)
val media = downloadManager.mediaBytesNow()
return GlassesStatus(
batteryPercent = batteryPercent(),
storageUsedGb = usedBytes / 1_000_000_000.0,
storageTotalGb = totalBytes / 1_000_000_000.0,
storageTotalBytes = totalBytes,
storageFreeBytes = freeBytes,
mediaBytes = media,
downloadedItemIds = downloadManager.downloadedIds.value.toList(),
mediaDir = downloadManager.baseDir.absolutePath,
appVersion = APP_VERSION,
)
}
private fun batteryPercent(): Int {
val bm = appContext.getSystemService(Context.BATTERY_SERVICE) as? BatteryManager
val level = bm?.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY)
return if (level != null && level in 0..100) level else 80
}
private fun diskBytes(): Pair<Long, Long> = runCatching {
val stat = StatFs(Environment.getDataDirectory().absolutePath)
stat.totalBytes to stat.availableBytes
}.getOrDefault(0L to 0L)
private suspend fun handleHostMessage(msg: HostToGlasses) {
when (msg) {
is Welcome -> {
@@ -91,6 +132,16 @@ class HostConnection(
movieController.applyMediaCommand(msg.command, msg.seekMs)
}
is DownloadFiles -> {
addMessage("скачивание на очки: «${msg.title}» (${msg.files.size} файлов)")
downloadManager.handleDownloadFiles(msg)
}
is DeleteFiles -> {
addMessage("удаление с очков: ${msg.itemId}")
downloadManager.handleDeleteFiles(msg.itemId)
}
is AssistantStateMsg -> addMessage("AssistantState: ${msg.state} (${msg.recordingSeconds}с)")
is ShowText -> addMessage("ShowText: ${msg.text}")
@@ -108,4 +159,8 @@ class HostConnection(
client.close()
scope.cancel()
}
companion object {
private const val APP_VERSION = "0.1.0"
}
}
@@ -1,6 +1,7 @@
package pw.binom.viewmate.glasses
import android.content.Context
import android.net.Uri
import android.os.Handler
import android.os.Looper
import com.google.android.exoplayer2.MediaItem
@@ -10,6 +11,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import pw.binom.viewmate.core.MediaCommand
import pw.binom.viewmate.core.protocol.PlayVideo
import java.io.File
/**
* Держатель плеера фильма: создаёт/освобождает SimpleExoPlayer,
@@ -30,6 +32,16 @@ class MovieController(private val context: Context) {
fun playVideo(msg: PlayVideo) {
mainHandler.post {
// Стриминг отключён полностью: играем ТОЛЬКО локальный файл file://, который есть на диске.
if (!msg.videoUrl.startsWith("file://")) {
log("glasses", "PlayVideo отклонён: стриминг запрещён, url не локальный (${msg.videoUrl.take(80)})")
return@post
}
val local = Uri.parse(msg.videoUrl).path?.let { File(it) }
if (local == null || !local.isFile || local.length() <= 0) {
log("glasses", "PlayVideo отклонён: локальный файл не найден (${msg.videoUrl})")
return@post
}
val player = _player ?: SimpleExoPlayer.Builder(context).build().also {
_player = it
log("glasses", "SimpleExoPlayer создан")