From 817e8f09aa0e814f78e68be62b8c50baee4c977c Mon Sep 17 00:00:00 2001 From: grendervill Date: Wed, 19 Aug 2026 22:56:56 +0300 Subject: [PATCH] =?UTF-8?q?AeroToss:=20UDP=20broadcast=20discovery=20+=20m?= =?UTF-8?q?DNS=20A=20record=20+=20=D0=B8=D0=BC=D0=B5=D0=BD=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Добавлен 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 секунд --- .../discovery/CompositeAndroidDiscovery.kt | 9 +- .../discovery/UdpBroadcastDiscovery.kt | 129 ++++++++++++++++++ .../com/aerotoss/discovery/JmdnsDiscovery.kt | 28 +++- 3 files changed, 164 insertions(+), 2 deletions(-) create mode 100644 app/src/commonMain/kotlin/com/aerotoss/discovery/UdpBroadcastDiscovery.kt diff --git a/app/src/androidMain/kotlin/com/aerotoss/discovery/CompositeAndroidDiscovery.kt b/app/src/androidMain/kotlin/com/aerotoss/discovery/CompositeAndroidDiscovery.kt index a65fa68..38653c0 100644 --- a/app/src/androidMain/kotlin/com/aerotoss/discovery/CompositeAndroidDiscovery.kt +++ b/app/src/androidMain/kotlin/com/aerotoss/discovery/CompositeAndroidDiscovery.kt @@ -18,6 +18,8 @@ class CompositeAndroidDiscovery( private val _devices = MutableStateFlow>(emptyList()) override val devices: Flow> = _devices.asStateFlow() + private val udpDiscovery = UdpBroadcastDiscovery() + @Volatile private var scope: CoroutineScope? = null @Volatile private var discoveryJob: kotlinx.coroutines.Job? = null @@ -25,6 +27,7 @@ class CompositeAndroidDiscovery( if (discoveryJob != null) return nsdDiscovery.startDiscovery(servicePort) + udpDiscovery.startDiscovery(servicePort) wifiDirectManager.initialize() wifiDirectManager.discoverPeers() @@ -32,10 +35,12 @@ class CompositeAndroidDiscovery( discoveryJob = scope?.launch { combine( nsdDiscovery.devices, + udpDiscovery.devices, wifiDirectManager.devices - ) { nsdDevices, wifiDirectDevices -> + ) { nsdDevices, udpDevices, wifiDirectDevices -> val allDevices = mutableMapOf() nsdDevices.forEach { allDevices["nsd:${it.id}"] = it } + udpDevices.forEach { allDevices["udp:${it.id}"] = it } wifiDirectDevices.forEach { allDevices["wifi:${it.id}"] = it } allDevices.values.toList() }.collect { devices -> @@ -48,6 +53,7 @@ class CompositeAndroidDiscovery( discoveryJob?.cancel() discoveryJob = null nsdDiscovery.stopDiscovery() + udpDiscovery.stopDiscovery() wifiDirectManager.stopDiscovery() _devices.value = emptyList() } @@ -57,6 +63,7 @@ class CompositeAndroidDiscovery( scope?.cancel() scope = null nsdDiscovery.dispose() + udpDiscovery.dispose() wifiDirectManager.cleanup() } diff --git a/app/src/commonMain/kotlin/com/aerotoss/discovery/UdpBroadcastDiscovery.kt b/app/src/commonMain/kotlin/com/aerotoss/discovery/UdpBroadcastDiscovery.kt new file mode 100644 index 0000000..d34e5ca --- /dev/null +++ b/app/src/commonMain/kotlin/com/aerotoss/discovery/UdpBroadcastDiscovery.kt @@ -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>(emptyList()) + override val devices: Flow> = _devices.asStateFlow() + + private var serverSocket: DatagramSocket? = null + private var discoveryThread: Thread? = null + private val deviceId = generateDeviceId() + private val deviceName = getDeviceName() + private val discoveredDevices = ConcurrentHashMap() + + 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 + } +} diff --git a/app/src/desktopMain/kotlin/com/aerotoss/discovery/JmdnsDiscovery.kt b/app/src/desktopMain/kotlin/com/aerotoss/discovery/JmdnsDiscovery.kt index 74cac70..45fd9c3 100644 --- a/app/src/desktopMain/kotlin/com/aerotoss/discovery/JmdnsDiscovery.kt +++ b/app/src/desktopMain/kotlin/com/aerotoss/discovery/JmdnsDiscovery.kt @@ -230,13 +230,16 @@ class JmdnsDiscovery : DiscoveryManager { } private fun buildDnsResponse(servicePort: Int): ByteArray { + val localAddr = findLocalAddress() + val localIp = localAddr?.address ?: byteArrayOf(127, 0, 0, 1) + val baos = java.io.ByteArrayOutputStream() val dos = java.io.DataOutputStream(baos) dos.writeShort(0) // ID dos.writeShort(0x8400) // Flags dos.writeShort(0) // QDCOUNT - dos.writeShort(3) // ANCOUNT + dos.writeShort(4) // ANCOUNT (PTR + SRV + TXT + A) dos.writeShort(0) // NSCOUNT dos.writeShort(0) // ARCOUNT @@ -287,6 +290,14 @@ class JmdnsDiscovery : DiscoveryManager { dos.writeShort(txtBytes.size) 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() return baos.toByteArray() } @@ -352,4 +363,19 @@ class JmdnsDiscovery : DiscoveryManager { const val SERVICE_TYPE = "_aerotoss._tcp" 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 } + } + } }