Files
view-mate/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/HostConnection.kt
T
Hermes Agent 8182a62885 feat: стабилизация гашения экрана очков (пауза/резюм звука)
Проблемы на железе (RayNeo X2 + HONOR): при гашении экрана позиция
ExoPlayer «набегала» (мусор 42 мин), откат не срабатывал (markPosition
сохранял мусор), доставка паузы телефону 5-10с (RayNeo вырубает
WiFi-радио при screen-off, игнорируя WifiLock).

Решение:
- markPosition(positionMs, trusted): защита от мусорных скачков (>30с за
  цикл = игнор); seek через трекбар помечает позицию trusted (rollback
  откатывает к seek, не к началу)
- rollback при resume: откат к последней достоверной позиции, если
  текущая убежала вперёд >5с
- WifiLock (FULL_HIGH_PERF) при просмотре + ensureWifiLock в точках
  гашения (страховка; RayNeo игнорирует, но не мешает)
- Watchdog на телефоне: очки молчат >5с (AUDIO_SILENCE_PAUSE_MS) → пауза
  звука (гарантированная верхняя граница)
- sendPlaybackPositionNow с повторами (0/2/5с) при включении экрана —
  свежая позиция уходит при пробуждении радио
- Диагностика: события пишутся в files/events.log (logcat очков забит
  EGL-спамом)
- Тесты: +14 (rollback/скачки/trusted seek)
- Железо подтверждено: позиция после гашения идеальная (продолжение с
  места паузы, не в начало), звук пауза ≤5-6с, синк diff < 1000
2026-08-19 22:55:49 +03:00

269 lines
12 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.glasses
import android.content.Context
import android.os.BatteryManager
import android.os.Build
import android.os.Environment
import android.os.StatFs
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import pw.binom.viewmate.core.MediaCommand
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
import pw.binom.viewmate.core.protocol.PlayVideo
import pw.binom.viewmate.core.protocol.PlaybackPosition
import pw.binom.viewmate.core.protocol.SetMode
import pw.binom.viewmate.core.protocol.ShowText
import pw.binom.viewmate.core.protocol.Welcome
/**
* Обвязка над [GlassesWsClient] для UI очков:
* mDNS-поиск телефона (с fallback'ом на эмуляторный адрес), реконнект,
* Hello при подключении, периодический статус/позиция,
* обработка входящих HostToGlasses (PlayVideo/MediaCommand выполняются плеером,
* DownloadFiles/DeleteFiles — менеджером скачивания).
*/
class HostConnection(
/** Адрес-фолбэк (эмулятор: ws://10.0.2.2:8080/ws/glasses); mDNS-поиск в приоритете. */
private val fallbackUrl: String,
private val movieController: MovieController,
private val downloadManager: GlassesDownloadManager,
context: Context,
) {
private val appContext = context.applicationContext
private val discovery = MdnsClient(context)
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
/** Активный WS-клиент (создаётся после выбора адреса mDNS/fallback). */
@Volatile
private var client: GlassesWsClient? = null
/** Последний найденный адрес — реконнект переиспользует его (без нового mDNS-цикла). */
@Volatile
private var lastUrl: String? = null
private val _connected = MutableStateFlow(false)
val connected: StateFlow<Boolean> = _connected.asStateFlow()
private val _status = MutableStateFlow("не подключено")
val status: StateFlow<String> = _status.asStateFlow()
private val _messages = MutableStateFlow<List<String>>(emptyList())
val messages: StateFlow<List<String>> = _messages.asStateFlow()
/** Флаг «плеер играл при обрыве» — продолжим по восстановлении связи. */
private val reconnectResume = ReconnectResumeFlag()
fun start() {
scope.launch { connectLoop() }
scope.launch {
while (isActive) {
delay(5_000)
client?.send(buildStatus())
val snapshot = withContext(Dispatchers.Main) { movieController.playbackPosition() }
if (snapshot.playing) movieController.markPosition(snapshot.positionMs)
client?.send(
PlaybackPosition(
positionMs = snapshot.positionMs,
playing = snapshot.playing,
itemId = snapshot.itemId,
audioIndex = snapshot.audioIndex,
),
)
}
}
}
/**
* Первое подключение: mDNS-дискавери телефона (до 15с на устройстве, 3с на
* эмуляторе), найденный адрес — в приоритете, иначе scan /24 (план Б),
* иначе fallback (эмуляторный 10.0.2.2). Внутри [GlassesWsClient.connect]
* живёт бесконечный реконнект с тем же адресом (переиспользование последнего
* найденного); при его отсутствии — повторный mDNS-цикл.
*/
private suspend fun connectLoop() {
val url = lastUrl ?: discoverPhoneUrl()
lastUrl = url
log("app", "подключаюсь к $url")
val c = GlassesWsClient(url)
client = c
c.connect(
onHostMessage = { msg -> handleHostMessage(msg) },
onConnected = {
_connected.value = true
_status.value = "подключено: $url"
addMessage("соединение установлено")
discovery.stop()
c.send(Hello(appVersion = APP_VERSION))
if (reconnectResume.consume()) {
withContext(Dispatchers.Main) { movieController.resume() }
addMessage("связь восстановлена — продолжаю")
}
},
onDisconnected = {
_connected.value = false
_status.value = "нет соединения (реконнект через 3с)"
val wasPlaying = withContext(Dispatchers.Main) { movieController.pauseIfPlaying() }
reconnectResume.onConnectionLost(wasPlaying)
if (wasPlaying) addMessage("связь потеряна — пауза")
},
)
}
/**
* Цепочка поиска адреса телефона: mDNS → scan /24 (план Б) → fallback.
* На эмуляторе scan пропускается (телефона в эмуляторной /24 нет, а fallback
* на 10.0.2.2 должен наступать быстро).
*/
private suspend fun discoverPhoneUrl(): String {
val found = discovery.findPhoneIp(timeoutMs = MdnsClient.mdnsTimeoutMs(isEmulator()))
if (found != null) {
val url = MdnsClient.phoneWsUrl(found)
log("app", "mDNS нашёл телефон: $url")
return url
}
if (!isEmulator()) {
val localIp = MdnsClient.localIpv4()
if (localIp != null) {
val scanned = PhoneScan.findPhoneWsUrl(localIp)
if (scanned != null) return scanned
} else {
log("app", "нет локального IPv4 — scan /24 пропущен")
}
}
log("app", "mDNS и scan не нашли телефон — fallback на $fallbackUrl")
return fallbackUrl
}
/** Отправка сообщения на телефон (используется менеджером скачивания). */
suspend fun sendToHost(msg: GlassesToHost) {
client?.send(msg)
}
/** Немедленно отправить текущую позицию (пауза/возобновление экрана). */
fun sendPlaybackPositionNow() {
val c = client ?: return
scope.launch {
val (positionMs, playing, itemId, audioIndex) =
withContext(Dispatchers.Main) { movieController.playbackPosition() }
c.send(PlaybackPosition(positionMs, playing, itemId, audioIndex))
}
}
/**
* Эмулятор (AVD/Genymotion): mDNS отсутствует, долгий поиск бессмыслен —
* таймаут сокращается до [MdnsClient.EMULATOR_TIMEOUT_MS] и быстрее
* наступает fallback на 10.0.2.2. Детект по [Build.FINGERPRINT] (префикс
* `generic`/`sdk`) и свойству ro.kernel.qemu (скрытый API — через отражение).
*/
private fun isEmulator(): Boolean {
val fingerprint = Build.FINGERPRINT
if (fingerprint.startsWith("generic") || fingerprint.startsWith("sdk")) return true
return runCatching {
val method = Class.forName("android.os.SystemProperties")
.getMethod("get", String::class.java)
(method.invoke(null, "ro.kernel.qemu") as? String) == "1"
}.getOrDefault(false)
}
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 -> {
_connected.value = true
addMessage("Welcome: режим ${msg.mode}, сессия ${msg.sessionId ?: "-"}")
}
is SetMode -> addMessage("SetMode: ${msg.mode}")
is PlayVideo -> {
addMessage("получено видео: «${msg.title}» — ${msg.videoUrl}")
reconnectResume.reset()
movieController.playVideo(msg)
}
is MediaCommandMsg -> {
addMessage("MediaCommand: ${msg.command} (seekMs=${msg.seekMs})")
if (msg.command == MediaCommand.CLOSE) reconnectResume.reset()
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}")
is ChatSessionMsg -> addMessage("ChatSession: ${msg.sessionId} — ${msg.title}")
}
}
private fun addMessage(text: String) {
log("glasses", text)
_messages.value = (_messages.value + "» $text").takeLast(50)
}
fun stop() {
client?.close()
discovery.stop()
scope.cancel()
}
companion object {
private const val APP_VERSION = "0.1.0"
}
}