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