698b6cf6dd
- 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, статусы идут
375 lines
17 KiB
Kotlin
375 lines
17 KiB
Kotlin
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
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|