diff --git "a/H, data[6:8])[0]}\")\n except socket.timeout:\n print(f\"попытка {i+1}: тишина\")\nprint(\"готово\")\nEOF" "b/H, data[6:8])[0]}\")\n except socket.timeout:\n print(f\"попытка {i+1}: тишина\")\nprint(\"готово\")\nEOF"
new file mode 100644
index 0000000..e69de29
diff --git a/app-glasses/src/main/AndroidManifest.xml b/app-glasses/src/main/AndroidManifest.xml
index 4ea4cfa..3164e3f 100644
--- a/app-glasses/src/main/AndroidManifest.xml
+++ b/app-glasses/src/main/AndroidManifest.xml
@@ -3,6 +3,10 @@
+
+
hostConnection.sendToHost(msg) }
hostConnection = HostConnection(
- url = GlassesConfig.HOST_URL,
+ fallbackUrl = GlassesConfig.HOST_URL,
movieController = movieController,
downloadManager = downloadManager,
context = applicationContext,
)
hostConnection.start()
- log("app", "HostConnection стартует: ${GlassesConfig.HOST_URL}")
+ log("app", "HostConnection стартует: mDNS-поиск, fallback: ${GlassesConfig.HOST_URL}")
}
companion object {
diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/HostConnection.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/HostConnection.kt
index 015d338..5098c8b 100644
--- a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/HostConnection.kt
+++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/HostConnection.kt
@@ -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 = _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()
}
diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/MdnsClient.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/MdnsClient.kt
new file mode 100644
index 0000000..fcf29b2
--- /dev/null
+++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/MdnsClient.kt
@@ -0,0 +1,374 @@
+package pw.binom.viewmate.glasses
+
+import android.content.Context
+import android.net.wifi.WifiManager
+import java.io.ByteArrayOutputStream
+import java.net.DatagramPacket
+import java.net.Inet4Address
+import java.net.InetAddress
+import java.net.InetSocketAddress
+import java.net.MulticastSocket
+import java.net.NetworkInterface
+import java.net.SocketTimeoutException
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+
+/**
+ * Прямой mDNS-клиент (RFC 6762) вместо NsdManager: сам шлёт PTR-запрос
+ * `_viewmate._tcp.local` на 224.0.0.251:5353 и парсит ответ по UDP. Работает
+ * даже там, где системный NsdManager сломан (на некоторых OEM-прошивках
+ * дискавери стартует, но колбэки приложению не доходят).
+ *
+ * Вся работа — в Dispatchers.IO (блокирующий сокет с soTimeout), main-поток
+ * не вешается. На эмуляторе multicast обычно недоступен — сокет просто
+ * таймаутится и срабатывает fallback на 10.0.2.2.
+ */
+class MdnsClient(context: Context) {
+
+ private val wifiManager =
+ context.applicationContext.getSystemService(Context.WIFI_SERVICE) as? WifiManager
+
+ /** MulticastLock, удерживается только на время поиска. */
+ private var heldLock: WifiManager.MulticastLock? = null
+
+ /**
+ * Найти телефон: шлём PTR-запрос, слушаем multicast до [timeoutMs] мс
+ * (переотправка каждые [SO_TIMEOUT_MS]). Возвращает IP телефона либо null.
+ */
+ suspend fun findPhoneIp(timeoutMs: Long): String? = withContext(Dispatchers.IO) {
+ val socket = try {
+ MulticastSocket()
+ } catch (e: Exception) {
+ log("mdns", "mDNS: не удалось открыть UDP-сокет: ${e.message}")
+ return@withContext null
+ }
+ val lock = acquireMulticastLock()
+ var receivedAny = false
+ var loggedPackets = 0
+ try {
+ socket.soTimeout = SO_TIMEOUT_MS
+ val group = InetAddress.getByName(MDNS_GROUP)
+ val netInterfaces = pickNetworkInterfaces()
+ if (netInterfaces.isEmpty()) {
+ log("mdns", "mDNS: нет активного сетевого интерфейса с IPv4")
+ return@withContext null
+ }
+ val primary = netInterfaces.first()
+ log("mdns", "mDNS: интерфейс ${primary.name} (${firstIpv4(primary) ?: "?"})")
+ // Join на ВСЕ кандидаты: на RayNeo первый интерфейс может быть не wlan0,
+ // а join на несколько интерфейсов одного сокета не мешает друг другу.
+ for (iface in netInterfaces) {
+ runCatching { socket.joinGroup(InetSocketAddress(group, MDNS_PORT), iface) }
+ .onFailure { log("mdns", "mDNS: join на ${iface.name} не удался: ${it.message}") }
+ }
+ val query = buildPtrQuery()
+ val target = InetSocketAddress(group, MDNS_PORT)
+ val deadline = System.currentTimeMillis() + timeoutMs
+ val buffer = ByteArray(MAX_PACKET_SIZE)
+
+ while (System.currentTimeMillis() < deadline) {
+ runCatching {
+ socket.send(DatagramPacket(query, query.size, target))
+ }.onFailure {
+ log("mdns", "mDNS: не удалось отправить запрос: ${it.message}")
+ }
+ // Слушаем ответы до soTimeout; по таймауту — новый запрос (до deadline).
+ while (System.currentTimeMillis() < deadline) {
+ val packet = DatagramPacket(buffer, buffer.size)
+ val received = try {
+ socket.receive(packet)
+ true
+ } catch (e: SocketTimeoutException) {
+ false
+ } catch (e: Exception) {
+ log("mdns", "mDNS: ошибка приёма: ${e.message}")
+ false
+ }
+ if (!received) break
+ receivedAny = true
+ val data = buffer.copyOf(packet.length)
+ // Диагностика: приходят ли вообще ответы на очки (первые пакеты, не спамить).
+ if (loggedPackets < MAX_PACKET_LOG_COUNT) {
+ loggedPackets++
+ val ancount = DnsPacket(data).u16(6) ?: 0
+ log("mdns", "mDNS: пакет от ${packet.address.hostAddress} (${data.size} байт, ANCOUNT=$ancount)")
+ }
+ val ip = parseMdnsResponse(data)
+ if (ip != null) {
+ log("mdns", "mDNS: найден $SERVICE_TYPE → $ip:$SERVER_PORT")
+ return@withContext ip
+ }
+ }
+ }
+ if (!receivedAny) {
+ log("mdns", "mDNS: ни одного пакета за ${timeoutMs}мс (multicast не работает?)")
+ }
+ null
+ } catch (e: Exception) {
+ log("mdns", "mDNS: ошибка поиска: ${e.message}")
+ null
+ } finally {
+ runCatching { socket.close() }
+ releaseMulticastLock()
+ }
+ }
+
+ /** Остановить mDNS-поиск (снять MulticastLock — экономия батареи). */
+ fun stop() {
+ releaseMulticastLock()
+ }
+
+ private fun acquireMulticastLock(): WifiManager.MulticastLock? {
+ val manager = wifiManager ?: return null
+ val lock = try {
+ manager.createMulticastLock("viewmate-mdns")
+ } catch (e: Exception) {
+ log("mdns", "mDNS: не удалось создать MulticastLock: ${e.message}")
+ return null
+ }
+ try {
+ lock.setReferenceCounted(false)
+ lock.acquire()
+ heldLock = lock
+ return lock
+ } catch (e: Exception) {
+ log("mdns", "mDNS: не удалось получить MulticastLock: ${e.message}")
+ runCatching { lock.release() }
+ return null
+ }
+ }
+
+ private fun releaseMulticastLock() {
+ val lock = heldLock ?: return
+ heldLock = null
+ runCatching {
+ if (lock.isHeld) lock.release()
+ }.onFailure {
+ log("mdns", "mDNS: ошибка release MulticastLock: ${it.message}")
+ }
+ }
+
+ /**
+ * Кандидаты на multicast: все up+IPv4 интерфейсы, но Wi-Fi (имя содержит
+ * `wl`, т.е. wlan0/wl...) — первым. На RayNeo первый интерфейс может быть
+ * не wlan0 (usb/eth), поэтому join делается на все кандидаты по очереди.
+ */
+ private fun pickNetworkInterfaces(): List {
+ val interfaces = runCatching { NetworkInterface.getNetworkInterfaces() }.getOrNull() ?: return emptyList()
+ val candidates = interfaces.asSequence()
+ .filter { iface ->
+ runCatching {
+ iface.isUp && !iface.isLoopback &&
+ iface.inetAddresses.asSequence().any { it is Inet4Address }
+ }.getOrDefault(false)
+ }
+ .toList()
+ return candidates.sortedBy { iface -> if (iface.name.contains("wl")) 0 else 1 }
+ }
+
+ companion object {
+ const val SERVICE_TYPE = "_viewmate._tcp"
+ const val SERVER_PORT = 8080
+ const val WS_PATH = "/ws/glasses"
+
+ /**
+ * Таймаут поиска на реальном устройстве: mDNS RayNeo отвечает медленнее
+ * 5с (сервис находился на ~7-й секунде) — ждём до 15с, чтобы не упасть
+ * в fallback, когда телефон рядом.
+ */
+ const val DEVICE_TIMEOUT_MS = 15_000L
+
+ /** Таймаут на эмуляторе: mDNS там отсутствует — fallback быстрее. */
+ const val EMULATOR_TIMEOUT_MS = 3_000L
+
+ private const val MDNS_GROUP = "224.0.0.251"
+ private const val MDNS_PORT = 5353
+ private const val SO_TIMEOUT_MS = 3_000
+ private const val MAX_PACKET_SIZE = 4096
+ private const val MAX_PACKET_LOG_COUNT = 5
+
+ /**
+ * Полный ws-URL телефона по найденному [ip]: `ws://:8080/ws/glasses`.
+ */
+ fun phoneWsUrl(ip: String): String = "ws://$ip:$SERVER_PORT$WS_PATH"
+
+ /**
+ * Локальный IPv4 очков (обычно wlan0): первый up+IPv4 интерфейс, Wi-Fi
+ * в приоритете. Используется скан-планом Б для подсчёта своей /24.
+ */
+ fun localIpv4(): String? {
+ val interfaces = runCatching { NetworkInterface.getNetworkInterfaces() }.getOrNull() ?: return null
+ return interfaces.asSequence()
+ .filter { iface -> runCatching { iface.isUp && !iface.isLoopback }.getOrDefault(false) }
+ .sortedBy { iface -> if (iface.name.contains("wl")) 0 else 1 }
+ .mapNotNull { iface -> firstIpv4(iface) }
+ .firstOrNull()
+ }
+
+ /**
+ * Чистая логика выбора таймаута поиска (для юнит-тестов): на эмуляторе
+ * mDNS бессмысленно ждать — fallback быстрее.
+ */
+ fun mdnsTimeoutMs(isEmulator: Boolean): Long =
+ if (isEmulator) EMULATOR_TIMEOUT_MS else DEVICE_TIMEOUT_MS
+
+ /**
+ * Чистая логика выбора адреса (для юнит-тестов): непустой [host]
+ * → он, иначе [fallback].
+ */
+ fun resolvePhoneAddress(host: String?, fallback: String): String =
+ if (!host.isNullOrBlank()) host else fallback
+
+ /**
+ * Чистая функция: из сырого mDNS-пакета достаёт IPv4 телефона.
+ *
+ * Парсит заголовок, пропускает question-секцию, в answer-секции ищет
+ * PTR и A-записи (первый валидный IPv4, не 0.0.0.0); A-запись допускается
+ * и в additional-секции (mDNS-ресолверы часто кладут её туда). Чужой
+ * multicast-мусор отсекается: ответ принимается, только если в нём есть
+ * PTR нашего сервиса — владелец `_viewmate._tcp.local` ИЛИ таргет
+ * начинается с `viewmate`. Мусор/битый пакет → null.
+ */
+ fun parseMdnsResponse(packet: ByteArray): String? {
+ val dns = DnsPacket(packet)
+ val qdcount = dns.u16(4) ?: return null
+ val ancount = dns.u16(6) ?: return null
+ val arcount = dns.u16(10) ?: return null
+
+ var pos = 12
+ repeat(qdcount) {
+ val name = dns.readName(pos) ?: return null
+ pos = name.second + 4
+ if (pos > packet.size) return null
+ }
+
+ var ipv4: String? = null
+ var ptrViewmate = false
+
+ fun scanRecords(count: Int) {
+ var remaining = count
+ while (remaining > 0 && pos <= packet.size) {
+ val name = dns.readName(pos) ?: return
+ pos = name.second
+ val type = dns.u16(pos) ?: return
+ val rdlength = dns.u16(pos + 8) ?: return
+ val rdataStart = pos + 10
+ if (rdataStart + rdlength > packet.size) return
+ when (type) {
+ // PTR: владелец `_viewmate._tcp.local`, RDATA — имя инстанса
+ // (напр. viewmate._viewmate._tcp.local)
+ 12 -> {
+ val owner = name.first.lowercase()
+ val target = dns.readName(rdataStart)?.first
+ if (owner == SERVICE_TYPE + ".local" ||
+ (target != null && target.lowercase().startsWith("viewmate"))
+ ) {
+ ptrViewmate = true
+ }
+ }
+
+ // A: RDATA — 4 байта IPv4
+ 1 -> if (rdlength == 4 && ipv4 == null) {
+ val b0 = packet[rdataStart].toInt() and 0xFF
+ val b1 = packet[rdataStart + 1].toInt() and 0xFF
+ val b2 = packet[rdataStart + 2].toInt() and 0xFF
+ val b3 = packet[rdataStart + 3].toInt() and 0xFF
+ if (b0 != 0 || b1 != 0 || b2 != 0 || b3 != 0) {
+ ipv4 = "$b0.$b1.$b2.$b3"
+ }
+ }
+ }
+ pos = rdataStart + rdlength
+ remaining--
+ }
+ }
+
+ scanRecords(ancount)
+ scanRecords(arcount)
+
+ if (ipv4 == null || !ptrViewmate) return null
+ return ipv4
+ }
+
+ /** PTR-запрос `_viewmate._tcp.local`: ID=0, flags=0, QDCOUNT=1, QTYPE=12, QCLASS=1. */
+ private fun buildPtrQuery(): ByteArray {
+ val out = ByteArrayOutputStream()
+ out.write(0); out.write(0) // ID
+ out.write(0); out.write(0) // FLAGS
+ out.write(0); out.write(1) // QDCOUNT
+ out.write(0); out.write(0) // ANCOUNT
+ out.write(0); out.write(0) // NSCOUNT
+ out.write(0); out.write(0) // ARCOUNT
+ out.write(encodeName(arrayOf("_viewmate", "_tcp", "local")))
+ out.write(0); out.write(12) // QTYPE = PTR
+ out.write(0); out.write(1) // QCLASS = IN
+ return out.toByteArray()
+ }
+
+ private fun encodeName(labels: Array): ByteArray {
+ val out = ByteArrayOutputStream()
+ for (label in labels) {
+ out.write(label.length)
+ out.write(label.toByteArray(Charsets.US_ASCII))
+ }
+ out.write(0)
+ return out.toByteArray()
+ }
+ }
+}
+
+/** Первый IPv4-адрес интерфейса (для логов и подсчёта подсети при скане). */
+private fun firstIpv4(iface: NetworkInterface): String? {
+ for (addr in iface.inetAddresses) {
+ if (addr is Inet4Address) return addr.hostAddress
+ }
+ return null
+}
+
+/** Парсер DNS-пакета: безопасное чтение полей и имён (labels + сжатие 0xC0). */
+private class DnsPacket(val data: ByteArray) {
+
+ fun u16(at: Int): Int? =
+ if (at + 2 > data.size) null
+ else ((data[at].toInt() and 0xFF) shl 8) or (data[at + 1].toInt() and 0xFF)
+
+ /**
+ * Прочитать имя с offset [at] (labels + терминатор 0, со сжатием 0xC0).
+ * Возвращает текст имени и позицию сразу после поля (с учётом сжатия).
+ */
+ fun readName(at: Int): Pair? {
+ val parts = ArrayList()
+ var pos = at
+ var resume = at
+ var firstPointer = true
+ var hops = 0
+ while (true) {
+ if (pos >= data.size) return null
+ val len = data[pos].toInt() and 0xFF
+ when {
+ len == 0 -> {
+ if (firstPointer) resume = pos + 1
+ return parts.joinToString(".") to resume
+ }
+
+ len and 0xC0 == 0xC0 -> {
+ if (pos + 1 >= data.size) return null
+ val ptr = ((len and 0x3F) shl 8) or (data[pos + 1].toInt() and 0xFF)
+ if (ptr >= data.size) return null
+ if (firstPointer) {
+ resume = pos + 2
+ firstPointer = false
+ }
+ if (++hops > 16) return null
+ pos = ptr
+ }
+
+ else -> {
+ if (pos + 1 + len > data.size) return null
+ parts.add(String(data, pos + 1, len, Charsets.US_ASCII))
+ pos += 1 + len
+ }
+ }
+ }
+ }
+}
diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/PhoneScan.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/PhoneScan.kt
new file mode 100644
index 0000000..992c8a1
--- /dev/null
+++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/PhoneScan.kt
@@ -0,0 +1,141 @@
+package pw.binom.viewmate.glasses
+
+import java.io.InputStream
+import java.net.InetSocketAddress
+import java.net.Socket
+import java.security.SecureRandom
+import java.util.Base64
+import kotlin.math.min
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.async
+import kotlinx.coroutines.sync.Semaphore
+import kotlinx.coroutines.sync.withPermit
+import kotlinx.coroutines.withContext
+
+/**
+ * План Б для очков: если mDNS-мультикаст на устройстве мёртв (RayNeo),
+ * телефон ищется прямым сканированием своей /24 подсети. На каждом адресе —
+ * TCP-коннект к :8080, при открытом порту — короткий WS-хендшейк `/ws/glasses`:
+ * наш Ktor-сервер телефона отвечает `HTTP/1.1 101 Switching Protocols`.
+ */
+object PhoneScan {
+
+ const val PORT = 8080
+ const val WS_PATH = "/ws/glasses"
+
+ /** Таймаут TCP-подключения к одному хосту (мёртвый адрес не должен висеть). */
+ const val CONNECT_TIMEOUT_MS = 300
+
+ /** Таймаут чтения ответа после подключения. */
+ const val READ_TIMEOUT_MS = 500
+
+ /** Сколько хостов пробуем параллельно (слотов, как 32–64 в задаче). */
+ const val CONCURRENCY = 48
+
+ /** Общий таймаут скан-цикла. */
+ const val SCAN_TIMEOUT_MS = 5_000L
+
+ private val random = SecureRandom()
+
+ /**
+ * Чистая функция (для юнит-тестов): все адреса /24 подсети для [ip]
+ * (192.168.76.152 → 192.168.76.1..192.168.76.254). Битый адрес → пустой список.
+ */
+ fun subnetRange(ip: String): List {
+ val parts = ip.split(".")
+ if (parts.size != 4) return emptyList()
+ val octets = parts.map { it.toIntOrNull() }
+ if (octets.any { it == null || it !in 0..255 }) return emptyList()
+ val (a, b, c) = octets.map { it!! }
+ return (1..254).map { "$a.$b.$c.$it" }
+ }
+
+ /**
+ * Просканировать /24 подсеть [localIp] и вернуть ws-URL телефона
+ * (`ws://:8080/ws/glasses`) либо null. Параллельно, батчами по
+ * [CONCURRENCY], с общим дедлайном [timeoutMs].
+ */
+ suspend fun findPhoneWsUrl(localIp: String, timeoutMs: Long = SCAN_TIMEOUT_MS): String? =
+ withContext(Dispatchers.IO) {
+ val addresses = subnetRange(localIp)
+ if (addresses.isEmpty()) {
+ log("scan", "scan: некорректный адрес $localIp — нечего сканировать")
+ return@withContext null
+ }
+ val network = addresses.first().substringBeforeLast('.')
+ log("scan", "scan: сканирую $network.1..254:$PORT (${addresses.size} хостов)")
+ val deadline = System.currentTimeMillis() + timeoutMs
+ val semaphore = Semaphore(CONCURRENCY)
+ var index = 0
+ while (index < addresses.size) {
+ if (System.currentTimeMillis() >= deadline) break
+ val end = min(index + CONCURRENCY, addresses.size)
+ val batch = addresses.subList(index, end).map { candidate ->
+ async {
+ semaphore.withPermit {
+ if (System.currentTimeMillis() >= deadline) null
+ else if (isGlassesServer(candidate)) candidate else null
+ }
+ }
+ }
+ index = end
+ for (deferred in batch) {
+ val hit = deferred.await()
+ if (hit != null) {
+ log("scan", "scan: найден телефон $hit:$PORT")
+ return@withContext "ws://$hit:$PORT$WS_PATH"
+ }
+ }
+ }
+ log("scan", "scan: телефон не найден за ${timeoutMs}мс")
+ null
+ }
+
+ /** Наш ли это сервер на [ip]: TCP-коннект к :8080 + WS-хендшейк `/ws/glasses`. */
+ private fun isGlassesServer(ip: String): Boolean {
+ val socket = Socket()
+ return try {
+ socket.connect(InetSocketAddress(ip, PORT), CONNECT_TIMEOUT_MS)
+ socket.soTimeout = READ_TIMEOUT_MS
+ val request = buildHandshake(ip)
+ socket.getOutputStream().write(request.toByteArray(Charsets.US_ASCII))
+ socket.getOutputStream().flush()
+ readStatusLine(socket.getInputStream())
+ } catch (e: Exception) {
+ false
+ } finally {
+ runCatching { socket.close() }
+ }
+ }
+
+ private fun buildHandshake(ip: String): String {
+ val keyBytes = ByteArray(16)
+ random.nextBytes(keyBytes)
+ val key = Base64.getEncoder().encodeToString(keyBytes)
+ return "GET $WS_PATH HTTP/1.1\r\n" +
+ "Host: $ip:$PORT\r\n" +
+ "Upgrade: websocket\r\n" +
+ "Connection: Upgrade\r\n" +
+ "Sec-WebSocket-Key: $key\r\n" +
+ "Sec-WebSocket-Version: 13\r\n" +
+ "\r\n"
+ }
+
+ /** Прочитать строку статуса (`HTTP/1.1 101 ...` или `... 200 ...`) с учётом частичных чтений. */
+ private fun readStatusLine(input: InputStream): Boolean {
+ val buf = ByteArray(256)
+ var n = 0
+ while (n < buf.size) {
+ val read = input.read(buf, n, buf.size - n)
+ if (read < 0) break
+ n += read
+ val text = String(buf, 0, n, Charsets.US_ASCII)
+ val lineEnd = text.indexOf("\r\n")
+ if (lineEnd >= 0) {
+ val status = text.substring(0, lineEnd)
+ return status.startsWith("HTTP/1.1 101") || status.startsWith("HTTP/1.1 200")
+ }
+ }
+ return false
+ }
+}
diff --git a/app-glasses/src/test/kotlin/pw/binom/viewmate/glasses/MdnsClientTest.kt b/app-glasses/src/test/kotlin/pw/binom/viewmate/glasses/MdnsClientTest.kt
new file mode 100644
index 0000000..ce94812
--- /dev/null
+++ b/app-glasses/src/test/kotlin/pw/binom/viewmate/glasses/MdnsClientTest.kt
@@ -0,0 +1,143 @@
+package pw.binom.viewmate.glasses
+
+import java.io.ByteArrayOutputStream
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+
+class MdnsClientTest {
+
+ private val fallback = "ws://10.0.2.2:8080/ws/glasses"
+
+ @Test
+ fun mdnsHostWins() {
+ assertEquals(
+ "ws://192.168.76.7:8080/ws/glasses",
+ MdnsClient.resolvePhoneAddress("ws://192.168.76.7:8080/ws/glasses", fallback),
+ "mDNS нашёл телефон — используем его адрес",
+ )
+ }
+
+ @Test
+ fun nullHostFallsBack() {
+ assertEquals(fallback, MdnsClient.resolvePhoneAddress(null, fallback), "mDNS ничего не нашёл — fallback")
+ }
+
+ @Test
+ fun blankHostFallsBack() {
+ assertEquals(fallback, MdnsClient.resolvePhoneAddress("", fallback), "пустой адрес — fallback")
+ assertEquals(fallback, MdnsClient.resolvePhoneAddress(" ", fallback), "пробельный адрес — fallback")
+ }
+
+ @Test
+ fun emptyFallbackStaysEmpty() {
+ assertEquals("", MdnsClient.resolvePhoneAddress(null, ""), "пустой fallback не ломается")
+ }
+
+ @Test
+ fun emulatorTimeoutShort() {
+ assertEquals(3_000L, MdnsClient.mdnsTimeoutMs(isEmulator = true), "эмулятор: mDNS нет — быстрый fallback")
+ }
+
+ @Test
+ fun deviceTimeoutLong() {
+ assertEquals(15_000L, MdnsClient.mdnsTimeoutMs(isEmulator = false), "железо: mDNS RayNeo медленный — ждём 15с")
+ }
+
+ @Test
+ fun parsesPtrSrvA() {
+ // Синтетический mDNS-ответ: PTR + SRV + A → IP телефона.
+ assertEquals("192.168.76.121", MdnsClient.parseMdnsResponse(buildPtrSrvAPacket()))
+ }
+
+ @Test
+ fun acceptsAnswerInAdditionalSection() {
+ // A-запись в additional-секции (ARCOUNT) — так отвечают настоящие mDNS-ресолверы.
+ val packet = buildPtrSrvAPacket(ipInAdditional = true)
+ assertEquals("192.168.76.121", MdnsClient.parseMdnsResponse(packet))
+ }
+
+ @Test
+ fun garbageReturnsNull() {
+ assertNull(MdnsClient.parseMdnsResponse(ByteArray(0)), "пустой пакет — null")
+ assertNull(MdnsClient.parseMdnsResponse(byteArrayOf(1, 2, 3)), "короткий мусор — null")
+ }
+
+ @Test
+ fun foreignMulticastNoiseRejected() {
+ // Чужой сервис (не viewmate): A-запись есть, но PTR-имя другое.
+ val packet = buildForeignServicePacket()
+ assertNull(MdnsClient.parseMdnsResponse(packet), "чужой multicast-мусор — null")
+ }
+
+ private fun buildPtrSrvAPacket(ipInAdditional: Boolean = false): ByteArray {
+ val questionName = name("_viewmate", "_tcp", "local")
+ val question = questionName + u16(12) + u16(1)
+
+ val ptrRdata = name("viewmate", "_viewmate", "_tcp", "local")
+ val ptrAnswer = ptr(12) + u16(12) + u16(1) + u16ttl(120) + u16(ptrRdata.size) + ptrRdata
+
+ val ptrRdataOffset = 12 + question.size + ptrAnswer.size - ptrRdata.size
+ val targetName = name("honor", "local")
+ val srvRdata = u16(0) + u16(0) + u16(8080) + targetName
+ val srvAnswer = ptr(ptrRdataOffset) + u16(33) + u16(1) + u16ttl(120) + u16(srvRdata.size) + srvRdata
+
+ val srvRdataOffset = 12 + question.size + ptrAnswer.size + srvAnswer.size - srvRdata.size
+ val targetOffset = srvRdataOffset + 6
+ val aRecord = ptr(targetOffset) + u16(1) + u16(1) + u16ttl(120) + u16(4) + ip4(192, 168, 76, 121)
+
+ val ancount = if (ipInAdditional) 2 else 3
+ val arcount = if (ipInAdditional) 1 else 0
+ val header = u16(0) + u16(0x8400) + u16(1) + u16(ancount) + u16(0) + u16(arcount)
+ return header + question + ptrAnswer + srvAnswer + aRecord
+ }
+
+ private fun buildForeignServicePacket(): ByteArray {
+ val questionName = name("_googlecast", "_tcp", "local")
+ val question = questionName + u16(12) + u16(1)
+
+ val ptrRdata = name("chromecast", "_googlecast", "_tcp", "local")
+ val ptrAnswer = ptr(12) + u16(12) + u16(1) + u16ttl(120) + u16(ptrRdata.size) + ptrRdata
+
+ val ptrRdataOffset = 12 + question.size + ptrAnswer.size - ptrRdata.size
+ val targetName = name("kitchen", "local")
+ val srvRdata = u16(0) + u16(0) + u16(8009) + targetName
+ val srvAnswer = ptr(ptrRdataOffset) + u16(33) + u16(1) + u16ttl(120) + u16(srvRdata.size) + srvRdata
+
+ val srvRdataOffset = 12 + question.size + ptrAnswer.size + srvAnswer.size - srvRdata.size
+ val targetOffset = srvRdataOffset + 6
+ val aAnswer = ptr(targetOffset) + u16(1) + u16(1) + u16ttl(120) + u16(4) + ip4(192, 168, 1, 50)
+
+ val header = u16(0) + u16(0x8400) + u16(1) + u16(3) + u16(0) + u16(0)
+ return header + question + ptrAnswer + srvAnswer + aAnswer
+ }
+
+ private fun u16(v: Int): ByteArray =
+ byteArrayOf(((v shr 8) and 0xFF).toByte(), (v and 0xFF).toByte())
+
+ private fun ip4(a: Int, b: Int, c: Int, d: Int): ByteArray =
+ byteArrayOf(a.toByte(), b.toByte(), c.toByte(), d.toByte())
+
+ private fun u16ttl(v: Int): ByteArray =
+ byteArrayOf(0, 0, ((v shr 8) and 0xFF).toByte(), (v and 0xFF).toByte())
+
+ private fun label(s: String): ByteArray =
+ byteArrayOf(s.length.toByte()) + s.toByteArray(Charsets.US_ASCII)
+
+ private fun name(vararg labels: String): ByteArray {
+ val out = ByteArrayOutputStream()
+ for (l in labels) out.write(label(l))
+ out.write(0)
+ return out.toByteArray()
+ }
+
+ private fun ptr(offset: Int): ByteArray =
+ byteArrayOf((0xC0 or ((offset shr 8) and 0x3F)).toByte(), (offset and 0xFF).toByte())
+
+ private operator fun ByteArray.plus(other: ByteArray): ByteArray {
+ val out = ByteArray(size + other.size)
+ System.arraycopy(this, 0, out, 0, size)
+ System.arraycopy(other, 0, out, size, other.size)
+ return out
+ }
+}
diff --git a/app-glasses/src/test/kotlin/pw/binom/viewmate/glasses/PhoneScanTest.kt b/app-glasses/src/test/kotlin/pw/binom/viewmate/glasses/PhoneScanTest.kt
new file mode 100644
index 0000000..a2e813d
--- /dev/null
+++ b/app-glasses/src/test/kotlin/pw/binom/viewmate/glasses/PhoneScanTest.kt
@@ -0,0 +1,27 @@
+package pw.binom.viewmate.glasses
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+class PhoneScanTest {
+
+ @Test
+ fun subnetRangeBuildsDot24() {
+ val range = PhoneScan.subnetRange("192.168.76.152")
+ assertEquals(254, range.size, "в /24 — 254 хоста")
+ assertEquals("192.168.76.1", range.first(), "сеть начинается с .1")
+ assertEquals("192.168.76.254", range.last(), "сеть заканчивается на .254")
+ assertEquals("192.168.76.121", range[120], "телефон (в задаче 192.168.76.121) внутри подсети")
+ assertTrue(range.none { it.endsWith(".0") || it.endsWith(".255") }, "без сетевого и бродкаст-адреса")
+ }
+
+ @Test
+ fun subnetRangeRejectsInvalid() {
+ assertTrue(PhoneScan.subnetRange("").isEmpty(), "пустая строка")
+ assertTrue(PhoneScan.subnetRange("не-адрес").isEmpty(), "не адрес")
+ assertTrue(PhoneScan.subnetRange("192.168.76").isEmpty(), "мало октетов")
+ assertTrue(PhoneScan.subnetRange("256.1.1.1").isEmpty(), "октет вне 0..255")
+ assertTrue(PhoneScan.subnetRange("192.168.76.152.5").isEmpty(), "много октетов")
+ }
+}
diff --git a/app-phone/src/main/AndroidManifest.xml b/app-phone/src/main/AndroidManifest.xml
index 6751873..38a50ca 100644
--- a/app-phone/src/main/AndroidManifest.xml
+++ b/app-phone/src/main/AndroidManifest.xml
@@ -3,6 +3,10 @@
+
+
audioSync.sync(positionMs, playing) }
@@ -100,6 +104,12 @@ class PhoneApp : Application() {
}
}
+ override fun onTerminate() {
+ nsd.unpublish()
+ server.stop()
+ super.onTerminate()
+ }
+
companion object {
lateinit var instance: PhoneApp
private set