Files
view-mate/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/MdnsClient.kt
T
Hermes Agent 698b6cf6dd 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, статусы идут
2026-08-19 20:06:27 +03:00

375 lines
17 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<NetworkInterface> {
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://<ip>: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<out String>): 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<String, Int>? {
val parts = ArrayList<String>()
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
}
}
}
}
}