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 = _connected.asStateFlow() private val _status = MutableStateFlow("не подключено") val status: StateFlow = _status.asStateFlow() private val _messages = MutableStateFlow>(emptyList()) val messages: StateFlow> = _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 = 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" } }