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 } } } } }