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, статусы идут
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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(), "много октетов")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user