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.serialization.decodeFromString import kotlinx.serialization.encodeToString 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.GlassesTransport import pw.binom.viewmate.core.net.WifiGlassesTransport import pw.binom.viewmate.core.protocol.AssistantStateMsg import pw.binom.viewmate.core.protocol.ChatHistoryItem import pw.binom.viewmate.core.protocol.ChatHistoryMsg 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.SetScale import pw.binom.viewmate.core.protocol.SetVertical import pw.binom.viewmate.core.protocol.Shutdown import pw.binom.viewmate.core.protocol.ShowText import pw.binom.viewmate.core.protocol.SttCancel import pw.binom.viewmate.core.protocol.SttDone import pw.binom.viewmate.core.protocol.SttPhrase import pw.binom.viewmate.core.protocol.Welcome import pw.binom.viewmate.core.protocol.protocolJson /** * Обвязка над [GlassesTransport] для UI очков (TASK-transport.md п.1/п.4): * mDNS-поиск телефона (с fallback'ом на эмуляторный адрес, остаётся на * WiFi-стороне — [WifiGlassesTransport]), реконнект, Hello при подключении, * периодический статус/позиция, обработка входящих HostToGlasses (строкой из * транспорта; декод — здесь, не в транспорте). Дефолтный транспорт — WiFi; * Bluetooth SPP ([BtGlassesTransport]) — флагом [GlassesConfig.USE_BLUETOOTH]. */ 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) /** Транспортом включается выбор [GlassesConfig.USE_BLUETOOTH]: дефолт — WiFi. */ private val useBt: Boolean = GlassesConfig.USE_BLUETOOTH /** Активный транспорт (создаётся после выбора адреса: mDNS/fallback или SPP-MAC). */ @Volatile private var transport: GlassesTransport? = null /** Последний найденный адрес — реконнект переиспользует его (WiFi: URL, BT: MAC). */ @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) sendToHost(buildStatus()) val snapshot = withContext(Dispatchers.Main) { movieController.playbackPosition() } if (snapshot.playing) movieController.markPosition(snapshot.positionMs) sendToHost( PlaybackPosition( positionMs = snapshot.positionMs, playing = snapshot.playing, itemId = snapshot.itemId, audioIndex = snapshot.audioIndex, ), ) } } } /** Развилка по [useBt]: WiFi (mDNS/scan/fallback) или SPP (адрес — [GlassesConfig.btRemoteMac]). */ private suspend fun connectLoop() { if (useBt) connectLoopBt() else connectLoopWifi() } /** * Первое подключение (WiFi): mDNS-дискавери телефона (до 15с на устройстве, * 3с на эмуляторе), найденный адрес — в приоритете, иначе scan /24 (план Б). * Fallback на эмуляторный 10.0.2.2 только на эмуляторе; на устройстве при * пустом адресе — повторный поиск через 3с (lastUrl не запоминается). * Внутри [GlassesTransport.connect] живёт бесконечный реконнект с тем же * адресом (переиспользование последнего найденного); при его отсутствии — * повторный mDNS-цикл. */ private suspend fun connectLoopWifi() { val url = lastUrl ?: discoverPhoneUrl() if (url.isEmpty()) { log("app", "телефон не найден — повтор поиска через 3с") delay(3_000) connectLoopWifi() return } lastUrl = url log("app", "подключаюсь к $url (WiFi)") val t = WifiGlassesTransport(url) transport = t t.connect( onMessage = { text -> handleHostMessageText(text) }, onConnected = { _connected.value = true _status.value = "подключено: ${t.name} $url" addMessage("соединение установлено") discovery.stop() sendToHost(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("связь потеряна — пауза") }, ) } /** * SPP-цикл (BT): адрес телефона — [GlassesConfig.btRemoteMac] (введено * вручную), реконнект-цикл живёт внутри [BtGlassesTransport.connect]. */ private suspend fun connectLoopBt() { val mac = lastUrl ?: (GlassesConfig.btRemoteMac() ?: "") if (mac.isEmpty()) { log("app", "BT: MAC-адрес телефона не задан — SPP недоступен") delay(3_000) connectLoopBt() return } lastUrl = mac log("app", "подключаюсь к $mac (Bluetooth SPP)") val t = BtGlassesTransport(appContext) transport = t t.connect( onMessage = { text -> handleHostMessageText(text) }, onConnected = { _connected.value = true _status.value = "подключено: bluetooth $mac" addMessage("соединение установлено") sendToHost(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 (план Б). * На эмуляторе scan пропускается (телефона в эмуляторной /24 нет) и сразу * наступает fallback на 10.0.2.2. На устройстве fallback запрещён — при * неудачном поиске возвращается пустой адрес (повторный поиск в [connectLoopWifi]). */ 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 пропущен") } } if (isEmulator()) { log("app", "эмулятор — fallback на $fallbackUrl") return fallbackUrl } log("app", "mDNS и scan не нашли телефон — повтор поиска через 3с") return "" } /** Входящая JSON-строка из транспорта: декод конверта [HostToGlasses] здесь (не в транспорте). */ private suspend fun handleHostMessageText(text: String) { val msg = runCatching { protocolJson.decodeFromString(HostToGlasses.serializer(), text) }.onFailure { log("glasses", "ОШИБКА декодирования входящего: ${it::class.simpleName}: ${it.message} (текст: ${text.take(120)})") }.getOrNull() ?: return handleHostMessage(msg) } /** Отправка сообщения на телефон (используется менеджером скачивания). */ suspend fun sendToHost(msg: GlassesToHost) { val t = transport ?: return t.send(protocolJson.encodeToString(GlassesToHost.serializer(), msg)) } /** Немедленно отправить текущую позицию (пауза/возобновление экрана). */ fun sendPlaybackPositionNow() { if (transport == null) return scope.launch { val (positionMs, playing, itemId, audioIndex) = withContext(Dispatchers.Main) { movieController.playbackPosition() } sendToHost(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 SetScale -> { GlassesApp.instance.applyScale(msg.scale) log("app", "scale → ${msg.scale}") } is SetVertical -> { GlassesApp.instance.applyVertical(msg.vertical) log("app", "vertical → ${msg.vertical}") } 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}") GlassesApp.instance.thinking.value = false GlassesApp.instance.answerScrollDelta.value = 0f GlassesApp.instance.assistantText.value = msg.text } is ChatSessionMsg -> addMessage("ChatSession: ${msg.sessionId} — ${msg.title}") is ChatHistoryMsg -> { log("chat", "история сессии ${msg.sessionId ?: "-"}: ${msg.messages.size} сообщ.") GlassesApp.instance.chatHistory.value = msg.messages .filter { it.role == "user" || it.role == "assistant" } .map { ChatEntry(it.role, it.content) } } is SttPhrase -> { GlassesApp.instance.sttText.value = msg.full log("stt", "фраза: ${msg.phrase}") } is SttDone -> { log("stt", "ВЕСЬ ТЕКСТ: ${msg.full}") GlassesApp.instance.listening.value = false } is SttCancel -> { when (msg.reason) { SttCancel.REASON_CANCEL -> { // Явная отмена диктовки двойным тапом: закрыть микрофон, сбросить // listening/thinking, НО остаться в режиме (показать последний ответ), // микрофон заново НЕ открывать, из режима НЕ выходить. log("stt", "отмена диктовки (двойной тап) — микрофон закрыт, остаёмся в режиме") GlassesApp.instance.listening.value = false GlassesApp.instance.thinking.value = false } SttCancel.REASON_TIMEOUT -> { // Авто-отмена по тишине 30с: пользователь молчит/ушёл — ВЫХОДИМ из // режима (микрофон закрыть, оверлей скрыть), как exitAssistantMode. log("stt", "тишина 30с — выход из режима общения") GlassesApp.instance.mainActivity?.onSttTimeout() ?: log("stt", "mainActivity ещё нет — не могу выйти из режима") } else -> { // reason="user" — пустой клик без голоса: не выходим из режима, // возвращаемся в «слушаю» (микрофон снова открыт). log("stt", "отменено — возврат в «слушаю»") GlassesApp.instance.thinking.value = false GlassesApp.instance.mainActivity?.resumeSttListening() ?: log("stt", "mainActivity ещё нет — не могу вернуть микрофон") } } } is Shutdown -> { addMessage("Shutdown: закрываю приложение") // как в pair-view: finish() Activity очков GlassesApp.instance.mainActivity?.finishAffinity() // страховка: процесс завершится сам, но kill убирает зависшие потоки (RayNeo) android.os.Process.killProcess(android.os.Process.myPid()) } } } private fun addMessage(text: String) { log("glasses", text) _messages.value = (_messages.value + "» $text").takeLast(50) } fun stop() { transport?.close() discovery.stop() scope.cancel() } companion object { private const val APP_VERSION = "0.1.0" } }