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:
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user