feat(app-glasses): авто-обнаружение телефона — mDNS-клиент + скан /24 (RayNeo mDNS мёртв)

- MdnsClient: свой mDNS (UDP 5353, PTR _viewmate._tcp, парсер со сжатием 0xC0), join на всех Wi-Fi-интерфейсах, MulticastLock
- PhoneScan: план Б — TCP-скан своей /24 :8080 батчами, детект по WS-handshake (101), таймаут 5с
- HostConnection: цепочка mDNS → scan /24 (пропуск на эмуляторе) → fallback 10.0.2.2
- app-phone: NsdPublisher (_viewmate._tcp, порт 8080) + NEARBY_WIFI_DEVICES
- Тесты 27/0 glasses (MdnsClientTest, PhoneScanTest), 67/0 phone
- Железо (RayNeo X2 + HONOR): mDNS multicast режется WiFi-стеком очков («ни одного пакета за 15с»), скан /24 нашёл телефон за 1.2с, ws://192.168.76.121:8080, hello→welcome, статусы идут
This commit is contained in:
Hermes Agent
2026-08-19 20:06:27 +03:00
parent 35f462b34a
commit 698b6cf6dd
11 changed files with 880 additions and 32 deletions
@@ -2,6 +2,7 @@ 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
@@ -34,20 +35,30 @@ import pw.binom.viewmate.core.protocol.Welcome
/**
* Обвязка над [GlassesWsClient] для UI очков:
* реконнект, Hello при подключении, периодический статус/позиция,
* mDNS-поиск телефона (с fallback'ом на эмуляторный адрес), реконнект,
* Hello при подключении, периодический статус/позиция,
* обработка входящих HostToGlasses (PlayVideo/MediaCommand выполняются плеером,
* DownloadFiles/DeleteFiles — менеджером скачивания).
*/
class HostConnection(
private val url: String,
/** Адрес-фолбэк (эмулятор: 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 client = GlassesWsClient(url)
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()
@@ -61,40 +72,99 @@ class HostConnection(
private val reconnectResume = ReconnectResumeFlag()
fun start() {
scope.launch {
client.connect(
onHostMessage = { msg -> handleHostMessage(msg) },
onConnected = {
_connected.value = true
_status.value = "подключено: $url"
addMessage("соединение установлено")
client.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("связь потеряна — пауза")
},
)
}
scope.launch { connectLoop() }
scope.launch {
while (isActive) {
delay(5_000)
client.send(buildStatus())
client?.send(buildStatus())
val (positionMs, playing) = withContext(Dispatchers.Main) { movieController.playbackPosition() }
client.send(PlaybackPosition(positionMs = positionMs, playing = playing))
client?.send(PlaybackPosition(positionMs = positionMs, playing = playing))
}
}
}
/**
* Первое подключение: 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)
suspend fun sendToHost(msg: GlassesToHost) {
client?.send(msg)
}
/**
* Эмулятор (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()
@@ -169,7 +239,8 @@ class HostConnection(
}
fun stop() {
client.close()
client?.close()
discovery.stop()
scope.cancel()
}