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:
@@ -3,6 +3,10 @@
|
|||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
|
||||||
|
<uses-permission
|
||||||
|
android:name="android.permission.NEARBY_WIFI_DEVICES"
|
||||||
|
android:usesPermissionFlags="neverForLocation" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".GlassesApp"
|
android:name=".GlassesApp"
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import android.app.Application
|
|||||||
|
|
||||||
/** Конфигурация очков: адрес WS-сервера телефона. Креды не нужны — очки просто экран. */
|
/** Конфигурация очков: адрес WS-сервера телефона. Креды не нужны — очки просто экран. */
|
||||||
object GlassesConfig {
|
object GlassesConfig {
|
||||||
// Эмулятор — 10.0.2.2 (localhost хоста); на устройстве — LAN-адрес телефона.
|
// Fallback: эмулятор — 10.0.2.2 (localhost хоста); на устройстве телефон
|
||||||
|
// находится авто-обнаружением по mDNS (_viewmate._tcp).
|
||||||
const val HOST_URL = "ws://10.0.2.2:8080/ws/glasses"
|
const val HOST_URL = "ws://10.0.2.2:8080/ws/glasses"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,13 +31,13 @@ class GlassesApp : Application() {
|
|||||||
movieController = MovieController(applicationContext)
|
movieController = MovieController(applicationContext)
|
||||||
downloadManager = GlassesDownloadManager(applicationContext) { msg -> hostConnection.sendToHost(msg) }
|
downloadManager = GlassesDownloadManager(applicationContext) { msg -> hostConnection.sendToHost(msg) }
|
||||||
hostConnection = HostConnection(
|
hostConnection = HostConnection(
|
||||||
url = GlassesConfig.HOST_URL,
|
fallbackUrl = GlassesConfig.HOST_URL,
|
||||||
movieController = movieController,
|
movieController = movieController,
|
||||||
downloadManager = downloadManager,
|
downloadManager = downloadManager,
|
||||||
context = applicationContext,
|
context = applicationContext,
|
||||||
)
|
)
|
||||||
hostConnection.start()
|
hostConnection.start()
|
||||||
log("app", "HostConnection стартует: ${GlassesConfig.HOST_URL}")
|
log("app", "HostConnection стартует: mDNS-поиск, fallback: ${GlassesConfig.HOST_URL}")
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package pw.binom.viewmate.glasses
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.os.BatteryManager
|
import android.os.BatteryManager
|
||||||
|
import android.os.Build
|
||||||
import android.os.Environment
|
import android.os.Environment
|
||||||
import android.os.StatFs
|
import android.os.StatFs
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@@ -34,20 +35,30 @@ import pw.binom.viewmate.core.protocol.Welcome
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Обвязка над [GlassesWsClient] для UI очков:
|
* Обвязка над [GlassesWsClient] для UI очков:
|
||||||
* реконнект, Hello при подключении, периодический статус/позиция,
|
* mDNS-поиск телефона (с fallback'ом на эмуляторный адрес), реконнект,
|
||||||
|
* Hello при подключении, периодический статус/позиция,
|
||||||
* обработка входящих HostToGlasses (PlayVideo/MediaCommand выполняются плеером,
|
* обработка входящих HostToGlasses (PlayVideo/MediaCommand выполняются плеером,
|
||||||
* DownloadFiles/DeleteFiles — менеджером скачивания).
|
* DownloadFiles/DeleteFiles — менеджером скачивания).
|
||||||
*/
|
*/
|
||||||
class HostConnection(
|
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 movieController: MovieController,
|
||||||
private val downloadManager: GlassesDownloadManager,
|
private val downloadManager: GlassesDownloadManager,
|
||||||
context: Context,
|
context: Context,
|
||||||
) {
|
) {
|
||||||
private val appContext = context.applicationContext
|
private val appContext = context.applicationContext
|
||||||
private val client = GlassesWsClient(url)
|
private val discovery = MdnsClient(context)
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
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)
|
private val _connected = MutableStateFlow(false)
|
||||||
val connected: StateFlow<Boolean> = _connected.asStateFlow()
|
val connected: StateFlow<Boolean> = _connected.asStateFlow()
|
||||||
|
|
||||||
@@ -61,40 +72,99 @@ class HostConnection(
|
|||||||
private val reconnectResume = ReconnectResumeFlag()
|
private val reconnectResume = ReconnectResumeFlag()
|
||||||
|
|
||||||
fun start() {
|
fun start() {
|
||||||
scope.launch {
|
scope.launch { connectLoop() }
|
||||||
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 {
|
scope.launch {
|
||||||
while (isActive) {
|
while (isActive) {
|
||||||
delay(5_000)
|
delay(5_000)
|
||||||
client.send(buildStatus())
|
client?.send(buildStatus())
|
||||||
val (positionMs, playing) = withContext(Dispatchers.Main) { movieController.playbackPosition() }
|
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 {
|
private fun buildStatus(): GlassesStatus {
|
||||||
val (totalBytes, freeBytes) = diskBytes()
|
val (totalBytes, freeBytes) = diskBytes()
|
||||||
@@ -169,7 +239,8 @@ class HostConnection(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun stop() {
|
fun stop() {
|
||||||
client.close()
|
client?.close()
|
||||||
|
discovery.stop()
|
||||||
scope.cancel()
|
scope.cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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<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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String> {
|
||||||
|
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://<ip>: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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(), "много октетов")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,10 @@
|
|||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
|
||||||
|
<uses-permission
|
||||||
|
android:name="android.permission.NEARBY_WIFI_DEVICES"
|
||||||
|
android:usesPermissionFlags="neverForLocation" />
|
||||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package pw.binom.viewmate.phone
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.nsd.NsdManager
|
||||||
|
import android.net.nsd.NsdServiceInfo
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Публикация WS-сервера телефона через mDNS (NSD): очки находят телефон
|
||||||
|
* авто-обнаружением по `_viewmate._tcp`. Регистрация/разрегистрация
|
||||||
|
* обёрнуты в try/catch — NSD не должен валить приложение (на эмуляторе
|
||||||
|
* mDNS может отсутствовать, на Android 13+ registerService может требовать
|
||||||
|
* NEARBY_WIFI_DEVICES и кидать SecurityException).
|
||||||
|
*/
|
||||||
|
class NsdPublisher(context: Context) {
|
||||||
|
|
||||||
|
private val nsdManager: NsdManager? =
|
||||||
|
runCatching {
|
||||||
|
context.applicationContext.getSystemService(Context.NSD_SERVICE) as NsdManager
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
|
private val registrationListener = object : NsdManager.RegistrationListener {
|
||||||
|
override fun onServiceRegistered(info: NsdServiceInfo) {
|
||||||
|
log("nsd", "NSD: сервис опубликован ${info.serviceType}:${info.port}")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onRegistrationFailed(info: NsdServiceInfo, errorCode: Int) {
|
||||||
|
log("nsd", "NSD: публикация не удалась: код $errorCode")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onServiceUnregistered(info: NsdServiceInfo) {
|
||||||
|
log("nsd", "NSD: сервис снят с публикации")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onUnregistrationFailed(info: NsdServiceInfo, errorCode: Int) {
|
||||||
|
log("nsd", "NSD: снятие с публикации не удалось: код $errorCode")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var registered = false
|
||||||
|
|
||||||
|
/** Опубликовать сервис `_viewmate._tcp:[port]` (не должен бросать). */
|
||||||
|
fun publish(serviceName: String = "viewmate", serviceType: String = "_viewmate._tcp", port: Int = PhoneConfig.SERVER_PORT) {
|
||||||
|
val nsdManager = nsdManager ?: run {
|
||||||
|
log("nsd", "NSD: NsdManager недоступен — публикация пропущена")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (registered) return
|
||||||
|
val serviceInfo = NsdServiceInfo().apply {
|
||||||
|
this.serviceName = serviceName
|
||||||
|
this.serviceType = serviceType
|
||||||
|
this.port = port
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
nsdManager.registerService(serviceInfo, NsdManager.PROTOCOL_DNS_SD, registrationListener)
|
||||||
|
registered = true
|
||||||
|
log("nsd", "NSD: публикация $serviceType:$port запущена ($serviceName)")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
log("nsd", "NSD: публикация не удалась: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Снять сервис с публикации (при остановке сервера / завершении приложения). */
|
||||||
|
fun unpublish() {
|
||||||
|
val nsdManager = nsdManager ?: return
|
||||||
|
if (!registered) return
|
||||||
|
try {
|
||||||
|
nsdManager.unregisterService(registrationListener)
|
||||||
|
registered = false
|
||||||
|
} catch (e: Exception) {
|
||||||
|
log("nsd", "NSD: снятие с публикации не удалось: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,9 @@ class PhoneApp : Application() {
|
|||||||
/** WS-сервер для очков. */
|
/** WS-сервер для очков. */
|
||||||
val server: GlassesServer = GlassesServer(port = PhoneConfig.SERVER_PORT, state = state)
|
val server: GlassesServer = GlassesServer(port = PhoneConfig.SERVER_PORT, state = state)
|
||||||
|
|
||||||
|
/** Публикация сервера в локальной сети (mDNS `_viewmate._tcp`) для авто-обнаружения очками. */
|
||||||
|
val nsd: NsdPublisher by lazy { NsdPublisher(this) }
|
||||||
|
|
||||||
/** HTTP-клиенты бэкендов (Jellyfin-каталог, media-mirror). */
|
/** HTTP-клиенты бэкендов (Jellyfin-каталог, media-mirror). */
|
||||||
val jellyfin: JellyfinClient = JellyfinClient(PhoneConfig.JELLYFIN_URL, PhoneConfig.JELLYFIN_API_KEY)
|
val jellyfin: JellyfinClient = JellyfinClient(PhoneConfig.JELLYFIN_URL, PhoneConfig.JELLYFIN_API_KEY)
|
||||||
val mirror: MirrorClient = MirrorClient(PhoneConfig.MIRROR_URL, PhoneConfig.MIRROR_API_KEY)
|
val mirror: MirrorClient = MirrorClient(PhoneConfig.MIRROR_URL, PhoneConfig.MIRROR_API_KEY)
|
||||||
@@ -53,6 +56,7 @@ class PhoneApp : Application() {
|
|||||||
|
|
||||||
server.start()
|
server.start()
|
||||||
log("server", "WS-сервер запущен на ${PhoneConfig.SERVER_PORT}")
|
log("server", "WS-сервер запущен на ${PhoneConfig.SERVER_PORT}")
|
||||||
|
nsd.publish()
|
||||||
|
|
||||||
// каждый PlaybackPosition от очков (5с) → синхронизация аудио-плеера
|
// каждый PlaybackPosition от очков (5с) → синхронизация аудио-плеера
|
||||||
server.hub.onPlaybackPosition = { positionMs, playing -> audioSync.sync(positionMs, playing) }
|
server.hub.onPlaybackPosition = { positionMs, playing -> audioSync.sync(positionMs, playing) }
|
||||||
@@ -100,6 +104,12 @@ class PhoneApp : Application() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onTerminate() {
|
||||||
|
nsd.unpublish()
|
||||||
|
server.stop()
|
||||||
|
super.onTerminate()
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
lateinit var instance: PhoneApp
|
lateinit var instance: PhoneApp
|
||||||
private set
|
private set
|
||||||
|
|||||||
Reference in New Issue
Block a user