AeroToss: UDP broadcast discovery + mDNS A record + имена

- Добавлен UDP broadcast discovery (порт 9999) как fallback
- Desktop отправляет broadcast каждые 3 секунды
- Android использует mDNS + UDP broadcast + Wi-Fi Direct
- mDNS: добавлена A-запись для разрешения IP
- Имена: Desktop=user@host, Android=manufacturer model
- TXT record: id, name, port, type для определения устройства
- Автоперерегистрация mDNS каждые 30 секунд
- mDNS: отправка query каждые 5 секунд
This commit is contained in:
2026-08-19 22:56:56 +03:00
parent ab7b917a01
commit 817e8f09aa
3 changed files with 164 additions and 2 deletions
@@ -18,6 +18,8 @@ class CompositeAndroidDiscovery(
private val _devices = MutableStateFlow<List<Device>>(emptyList()) private val _devices = MutableStateFlow<List<Device>>(emptyList())
override val devices: Flow<List<Device>> = _devices.asStateFlow() override val devices: Flow<List<Device>> = _devices.asStateFlow()
private val udpDiscovery = UdpBroadcastDiscovery()
@Volatile private var scope: CoroutineScope? = null @Volatile private var scope: CoroutineScope? = null
@Volatile private var discoveryJob: kotlinx.coroutines.Job? = null @Volatile private var discoveryJob: kotlinx.coroutines.Job? = null
@@ -25,6 +27,7 @@ class CompositeAndroidDiscovery(
if (discoveryJob != null) return if (discoveryJob != null) return
nsdDiscovery.startDiscovery(servicePort) nsdDiscovery.startDiscovery(servicePort)
udpDiscovery.startDiscovery(servicePort)
wifiDirectManager.initialize() wifiDirectManager.initialize()
wifiDirectManager.discoverPeers() wifiDirectManager.discoverPeers()
@@ -32,10 +35,12 @@ class CompositeAndroidDiscovery(
discoveryJob = scope?.launch { discoveryJob = scope?.launch {
combine( combine(
nsdDiscovery.devices, nsdDiscovery.devices,
udpDiscovery.devices,
wifiDirectManager.devices wifiDirectManager.devices
) { nsdDevices, wifiDirectDevices -> ) { nsdDevices, udpDevices, wifiDirectDevices ->
val allDevices = mutableMapOf<String, Device>() val allDevices = mutableMapOf<String, Device>()
nsdDevices.forEach { allDevices["nsd:${it.id}"] = it } nsdDevices.forEach { allDevices["nsd:${it.id}"] = it }
udpDevices.forEach { allDevices["udp:${it.id}"] = it }
wifiDirectDevices.forEach { allDevices["wifi:${it.id}"] = it } wifiDirectDevices.forEach { allDevices["wifi:${it.id}"] = it }
allDevices.values.toList() allDevices.values.toList()
}.collect { devices -> }.collect { devices ->
@@ -48,6 +53,7 @@ class CompositeAndroidDiscovery(
discoveryJob?.cancel() discoveryJob?.cancel()
discoveryJob = null discoveryJob = null
nsdDiscovery.stopDiscovery() nsdDiscovery.stopDiscovery()
udpDiscovery.stopDiscovery()
wifiDirectManager.stopDiscovery() wifiDirectManager.stopDiscovery()
_devices.value = emptyList() _devices.value = emptyList()
} }
@@ -57,6 +63,7 @@ class CompositeAndroidDiscovery(
scope?.cancel() scope?.cancel()
scope = null scope = null
nsdDiscovery.dispose() nsdDiscovery.dispose()
udpDiscovery.dispose()
wifiDirectManager.cleanup() wifiDirectManager.cleanup()
} }
@@ -0,0 +1,129 @@
package com.aerotoss.discovery
import com.aerotoss.model.Device
import com.aerotoss.model.DeviceType
import com.aerotoss.util.generateDeviceId
import com.aerotoss.util.getDeviceName
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.net.DatagramPacket
import java.net.DatagramSocket
import java.net.InetAddress
import java.net.NetworkInterface
import java.util.concurrent.ConcurrentHashMap
class UdpBroadcastDiscovery : DiscoveryManager {
private val _devices = MutableStateFlow<List<Device>>(emptyList())
override val devices: Flow<List<Device>> = _devices.asStateFlow()
private var serverSocket: DatagramSocket? = null
private var discoveryThread: Thread? = null
private val deviceId = generateDeviceId()
private val deviceName = getDeviceName()
private val discoveredDevices = ConcurrentHashMap<String, Device>()
override fun startDiscovery(servicePort: Int) {
if (discoveryThread != null) return
discoveryThread = Thread {
try {
val socket = DatagramSocket(BROADCAST_PORT, InetAddress.getByName("0.0.0.0"))
socket.broadcast = true
socket.soTimeout = 2000
serverSocket = socket
println("AeroToss-UDP: Listening on port $BROADCAST_PORT")
// Send discovery broadcast every 3 seconds
var lastBroadcast = System.currentTimeMillis()
while (!Thread.currentThread().isInterrupted) {
// Send broadcast
val now = System.currentTimeMillis()
if (now - lastBroadcast > 3_000) {
sendBroadcast(socket, servicePort)
lastBroadcast = now
}
// Listen for responses
val buf = ByteArray(1024)
try {
val packet = DatagramPacket(buf, buf.size)
socket.receive(packet)
processPacket(packet, servicePort)
} catch (_: java.net.SocketTimeoutException) {
}
}
socket.close()
} catch (e: Exception) {
if (!Thread.currentThread().isInterrupted) {
println("AeroToss-UDP: Error: ${e.message}")
}
}
}.apply {
isDaemon = true
name = "aerotoss-udp"
start()
}
}
private fun sendBroadcast(socket: DatagramSocket, servicePort: Int) {
try {
val message = "AEROTOSS:$servicePort:$deviceId:$deviceName"
val data = message.toByteArray(Charsets.UTF_8)
val broadcastAddr = InetAddress.getByName("255.255.255.255")
val packet = DatagramPacket(data, data.size, broadcastAddr, BROADCAST_PORT)
socket.send(packet)
} catch (_: Exception) {}
}
private fun processPacket(packet: DatagramPacket, myPort: Int) {
try {
val message = String(packet.data, 0, packet.length, Charsets.UTF_8)
if (!message.startsWith("AEROTOSS:")) return
val parts = message.split(":", limit = 4)
if (parts.size < 4) return
val remotePort = parts[1].toIntOrNull() ?: return
val remoteId = parts[2]
val remoteName = parts[3]
// Don't add ourselves
if (remoteId == deviceId) return
if (remotePort == myPort) return
val hostAddress = packet.address.hostAddress ?: return
val device = Device(
id = remoteId,
name = remoteName,
type = if (remotePort == myPort) DeviceType.DESKTOP else DeviceType.PHONE,
hostAddress = hostAddress,
port = remotePort
)
discoveredDevices[remoteId] = device
_devices.value = discoveredDevices.values.toList()
println("AeroToss-UDP: Found $remoteName at $hostAddress:$remotePort")
} catch (_: Exception) {}
}
override fun stopDiscovery() {
discoveryThread?.interrupt()
discoveryThread = null
try { serverSocket?.close() } catch (_: Exception) {}
serverSocket = null
discoveredDevices.clear()
_devices.value = emptyList()
}
override fun dispose() {
stopDiscovery()
}
companion object {
const val BROADCAST_PORT = 9999
}
}
@@ -230,13 +230,16 @@ class JmdnsDiscovery : DiscoveryManager {
} }
private fun buildDnsResponse(servicePort: Int): ByteArray { private fun buildDnsResponse(servicePort: Int): ByteArray {
val localAddr = findLocalAddress()
val localIp = localAddr?.address ?: byteArrayOf(127, 0, 0, 1)
val baos = java.io.ByteArrayOutputStream() val baos = java.io.ByteArrayOutputStream()
val dos = java.io.DataOutputStream(baos) val dos = java.io.DataOutputStream(baos)
dos.writeShort(0) // ID dos.writeShort(0) // ID
dos.writeShort(0x8400) // Flags dos.writeShort(0x8400) // Flags
dos.writeShort(0) // QDCOUNT dos.writeShort(0) // QDCOUNT
dos.writeShort(3) // ANCOUNT dos.writeShort(4) // ANCOUNT (PTR + SRV + TXT + A)
dos.writeShort(0) // NSCOUNT dos.writeShort(0) // NSCOUNT
dos.writeShort(0) // ARCOUNT dos.writeShort(0) // ARCOUNT
@@ -287,6 +290,14 @@ class JmdnsDiscovery : DiscoveryManager {
dos.writeShort(txtBytes.size) dos.writeShort(txtBytes.size)
dos.write(txtBytes) dos.write(txtBytes)
// A: AeroToss._aerotoss._tcp.local. → IP address
writeDnsName(baos, "AeroToss._aerotoss._tcp.local.")
dos.writeShort(1) // A
dos.writeShort(1) // IN
dos.writeInt(4500) // TTL
dos.writeShort(4) // RDLENGTH = 4 bytes for IPv4
dos.write(localIp)
dos.flush() dos.flush()
return baos.toByteArray() return baos.toByteArray()
} }
@@ -352,4 +363,19 @@ class JmdnsDiscovery : DiscoveryManager {
const val SERVICE_TYPE = "_aerotoss._tcp" const val SERVICE_TYPE = "_aerotoss._tcp"
const val SERVICE_NAME = "AeroToss" const val SERVICE_NAME = "AeroToss"
} }
private fun findLocalAddress(): InetAddress? {
return try {
NetworkInterface.getNetworkInterfaces()?.toList()
?.filter { iface ->
iface.isUp && !iface.isLoopback && !isVirtual(iface) &&
iface.inetAddresses.toList().any { it is java.net.Inet4Address }
}
?.flatMap { it.inetAddresses.toList() }
?.firstOrNull { it is java.net.Inet4Address }
?: InetAddress.getLocalHost()
} catch (_: Exception) {
try { InetAddress.getLocalHost() } catch (_: Exception) { null }
}
}
} }