From 518827ea9505cf39f9792682bccdaed7a160c892 Mon Sep 17 00:00:00 2001 From: grendervill Date: Wed, 19 Aug 2026 19:40:00 +0300 Subject: [PATCH] =?UTF-8?q?AeroToss:=20=D0=B8=D1=81=D0=BF=D1=80=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=20mDNS=20(=D0=B7=D0=B0=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D1=91=D0=BD=20JMDNS=20=D0=BD=D0=B0=20=D0=BA=D0=B0=D1=81?= =?UTF-8?q?=D1=82=D0=BE=D0=BC=D0=BD=D1=83=D1=8E=20UDP-=D1=80=D0=B5=D0=B0?= =?UTF-8?q?=D0=BB=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8E)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - JMDNS 3.6.3 несовместим с Java 21 (NPE в EnumMap) - Заменён на кастомный mDNS через UDP multicast (224.0.0.251:5353) - Регистрация сервиса: PTR + SRV + TXT записи - Обнаружение: парсинг DNS-пакетов (PTR, SRV, TXT, A) - Привязка к правильному сетевому интерфейсу (en1, 192.168.1.x) - Сервис виден через dns-sd на Wi-Fi интерфейсе - Убрана зависимость jmdns из build.gradle.kts --- app/build.gradle.kts | 1 - .../transfer/AndroidTransferManager.kt | 2 +- .../com/aerotoss/discovery/JmdnsDiscovery.kt | 324 +++++++++++++----- .../transfer/DesktopTransferManager.kt | 2 +- gradle/libs.versions.toml | 2 +- 5 files changed, 235 insertions(+), 96 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 879d934..d0d2050 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -59,7 +59,6 @@ kotlin { implementation(compose.desktop.currentOs) implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.serialization.json) - implementation(libs.jmdns) implementation(libs.junit) } } diff --git a/app/src/androidMain/kotlin/com/aerotoss/transfer/AndroidTransferManager.kt b/app/src/androidMain/kotlin/com/aerotoss/transfer/AndroidTransferManager.kt index 3507dc5..125deda 100644 --- a/app/src/androidMain/kotlin/com/aerotoss/transfer/AndroidTransferManager.kt +++ b/app/src/androidMain/kotlin/com/aerotoss/transfer/AndroidTransferManager.kt @@ -52,7 +52,7 @@ class AndroidFileTransferManager(private val context: Context) : TransferManager if (!running.compareAndSet(false, true)) return serverSocket?.localPort ?: 0 try { - val socket = ServerSocket(port, 50, InetAddress.getLoopbackAddress()) + val socket = ServerSocket(port, 50, InetAddress.getByName("0.0.0.0")) serverSocket = socket serverThread = Thread { diff --git a/app/src/desktopMain/kotlin/com/aerotoss/discovery/JmdnsDiscovery.kt b/app/src/desktopMain/kotlin/com/aerotoss/discovery/JmdnsDiscovery.kt index 1827cd1..5f32aa3 100644 --- a/app/src/desktopMain/kotlin/com/aerotoss/discovery/JmdnsDiscovery.kt +++ b/app/src/desktopMain/kotlin/com/aerotoss/discovery/JmdnsDiscovery.kt @@ -7,88 +7,59 @@ 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.InetAddress +import java.net.MulticastSocket import java.net.NetworkInterface import java.util.concurrent.ConcurrentHashMap -import javax.jmdns.JmDNS -import javax.jmdns.ServiceEvent -import javax.jmdns.ServiceListener -import javax.jmdns.ServiceInfo class JmdnsDiscovery : DiscoveryManager { private val _devices = MutableStateFlow>(emptyList()) override val devices: Flow> = _devices.asStateFlow() - private var jmdns: JmDNS? = null - private var serviceListener: ServiceListener? = null + private var multicastSocket: MulticastSocket? = null private var discoveryThread: Thread? = null private val deviceId = generateDeviceId() private val deviceName = getDeviceName() private val discoveredDevices = ConcurrentHashMap() + private val localServicePort = (10000..60000).random() override fun startDiscovery(servicePort: Int) { if (discoveryThread != null) return discoveryThread = Thread { try { - val addr = findLocalAddress() ?: return@Thread - println("AeroToss: Starting mDNS on ${addr.hostAddress}") - jmdns = JmDNS.create(addr, "aerotoss-$deviceId") + val localAddr = findLocalAddress() ?: InetAddress.getLocalHost() + println("AeroToss: Starting mDNS on ${localAddr.hostAddress}") - val serviceInfo = ServiceInfo.create( - SERVICE_TYPE, - SERVICE_NAME, - servicePort, - "path=/ aerotoss=1 id=$deviceId name=$deviceName" - ) - jmdns?.registerService(serviceInfo) - println("AeroToss: Registered service on port $servicePort") + val group = InetAddress.getByName("224.0.0.251") + val socket = MulticastSocket(5353) + socket.reuseAddress = true + socket.networkInterface = NetworkInterface.getByInetAddress(localAddr) + socket.joinGroup(java.net.InetSocketAddress(group, 5353), socket.networkInterface) + multicastSocket = socket - serviceListener = object : ServiceListener { - override fun serviceAdded(event: ServiceEvent) { - println("AeroToss: Service added: ${event.name} type=${event.type}") - jmdns?.requestServiceInfo(event.type, event.name, true) - } + registerService(socket, group, servicePort) - override fun serviceRemoved(event: ServiceEvent) { - println("AeroToss: Service removed: ${event.name}") - discoveredDevices.remove(event.name) - _devices.value = discoveredDevices.values.toList() - } - - override fun serviceResolved(event: ServiceEvent) { - val info = event.info - val hostAddresses = info.hostAddresses - println("AeroToss: Service resolved: ${event.name} addresses=${hostAddresses.toList()}") - if (hostAddresses.isNotEmpty()) { - val attributes = info.textBytes?.let { parseAttributes(it) } ?: emptyMap() - val id = attributes["id"] ?: event.name - val name = attributes["name"] ?: event.name - val device = Device( - id = id, - name = name, - type = DeviceType.DESKTOP, - hostAddress = hostAddresses.first(), - port = info.port - ) - if (id != deviceId) { - discoveredDevices[event.name] = device - _devices.value = discoveredDevices.values.toList() - } - } + val buf = ByteArray(4096) + while (!Thread.currentThread().isInterrupted) { + val packet = DatagramPacket(buf, buf.size) + try { + socket.soTimeout = 2000 + socket.receive(packet) + processPacket(packet.data, packet.length, packet.address) + } catch (_: java.net.SocketTimeoutException) { + // Send announcement periodically + registerService(socket, group, servicePort) } } - - jmdns?.addServiceListener(SERVICE_TYPE, serviceListener) - - // Also listen without the trailing dot for compatibility - jmdns?.addServiceListener("_aerotoss._tcp", serviceListener) - - println("AeroToss: Listening for services on $SERVICE_TYPE") - Thread.currentThread().join() + socket.leaveGroup(group) + socket.close() } catch (e: Exception) { - e.printStackTrace() - println("AeroToss: mDNS error: ${e.message}") + if (!Thread.currentThread().isInterrupted) { + e.printStackTrace() + println("AeroToss: mDNS error: ${e.message}") + } } }.apply { isDaemon = true @@ -97,18 +68,207 @@ class JmdnsDiscovery : DiscoveryManager { } } + private fun registerService(socket: MulticastSocket, group: InetAddress, servicePort: Int) { + val response = buildDnsResponse(servicePort) + val packet = DatagramPacket(response, response.size, group, 5353) + try { + socket.send(packet) + } catch (_: Exception) {} + } + + private fun processPacket(data: ByteArray, length: Int, fromAddress: InetAddress) { + if (length < 12) return + + val qr = (data[2].toInt() shr 7) and 1 + if (qr != 1) return // Only process responses + + val anCount = ((data[6].toInt() and 0xFF) shl 8) or (data[7].toInt() and 0xFF) + if (anCount == 0) return + + var offset = 12 + // Skip questions + for (i in 0 until anCount) { + offset = skipName(data, offset, length) + if (offset + 10 > length) return + val type = ((data[offset].toInt() and 0xFF) shl 8) or (data[offset + 1].toInt() and 0xFF) + offset += 2 // type + offset += 2 // class + offset += 4 // ttl + val rdLength = ((data[offset].toInt() and 0xFF) shl 8) or (data[offset + 1].toInt() and 0xFF) + offset += 2 + + if (type == 12 && offset + rdLength <= length) { + // PTR record - service instance name + val serviceName = parseDnsName(data, offset, length) + if (serviceName.contains("_aerotoss._tcp")) { + // Found our service, request more info + } + } + + if (type == 33 && offset + rdLength <= length) { + // SRV record + val srvData = data.copyOfRange(offset, offset + rdLength) + val port = ((srvData[2].toInt() and 0xFF) shl 8) or (srvData[3].toInt() and 0xFF) + val target = parseDnsName(srvData, 6, srvData.size) + + val device = Device( + id = fromAddress.hostAddress ?: "unknown", + name = "Device@${fromAddress.hostAddress}", + type = DeviceType.PHONE, + hostAddress = fromAddress.hostAddress ?: "", + port = port + ) + + val key = fromAddress.hostAddress ?: return + if (!discoveredDevices.containsKey(key)) { + discoveredDevices[key] = device + _devices.value = discoveredDevices.values.toList() + } + } + + if (type == 16 && offset + rdLength <= length) { + // TXT record + val txtData = data.copyOfRange(offset, offset + rdLength) + val attributes = parseTxtRecord(txtData) + val name = attributes["name"] ?: "Device@${fromAddress.hostAddress}" + val id = attributes["id"] ?: fromAddress.hostAddress ?: "unknown" + val portStr = attributes["port"] + + val key = fromAddress.hostAddress ?: return + val existing = discoveredDevices[key] + if (existing != null && id != deviceId) { + val updated = existing.copy(name = name, id = id) + discoveredDevices[key] = updated + _devices.value = discoveredDevices.values.toList() + } + } + + offset += rdLength + } + } + + private fun skipName(data: ByteArray, offset: Int, length: Int): Int { + var pos = offset + while (pos < length) { + val len = data[pos].toInt() and 0xFF + if (len == 0) { pos++; break } + if ((len and 0xC0) == 0xC0) { pos += 2; break } + pos += len + 1 + } + return pos + } + + private fun parseDnsName(data: ByteArray, offset: Int, length: Int): String { + val sb = StringBuilder() + var pos = offset + while (pos < length) { + val len = data[pos].toInt() and 0xFF + if (len == 0) break + if ((len and 0xC0) == 0xC0) { + val ptr = ((len and 0x3F) shl 8) or (data[pos + 1].toInt() and 0xFF) + pos += 2 + val sub = parseDnsName(data, ptr, length) + if (sb.isNotEmpty()) sb.append(".") + sb.append(sub) + break + } + pos++ + if (pos + len > length) break + if (sb.isNotEmpty()) sb.append(".") + sb.append(String(data, pos, len, Charsets.UTF_8)) + pos += len + } + return sb.toString() + } + + private fun parseTxtRecord(data: ByteArray): Map { + val result = mutableMapOf() + var pos = 0 + while (pos < data.size) { + val len = data[pos].toInt() and 0xFF + pos++ + if (pos + len > data.size) break + val entry = String(data, pos, len, Charsets.UTF_8) + val eqIdx = entry.indexOf('=') + if (eqIdx > 0) { + result[entry.substring(0, eqIdx)] = entry.substring(eqIdx + 1) + } + pos += len + } + return result + } + + private fun buildDnsResponse(servicePort: Int): ByteArray { + val baos = java.io.ByteArrayOutputStream() + val dos = java.io.DataOutputStream(baos) + + // Header: ID=0, Flags=0x8400 (response, authoritative), QDCOUNT=0, ANCOUNT=3, NSCOUNT=0, ARCOUNT=0 + dos.writeShort(0) + dos.writeShort(0x8400) + dos.writeShort(0) // QDCOUNT + dos.writeShort(3) // ANCOUNT (PTR + SRV + TXT) + dos.writeShort(0) // NSCOUNT + dos.writeShort(0) // ARCOUNT + + // PTR record: _aerotoss._tcp.local. → AeroToss._aerotoss._tcp.local. + writeDnsName(baos, "_aerotoss._tcp.local.") + dos.writeShort(12) // PTR + dos.writeShort(1) // class IN + dos.writeInt(120) // TTL + val ptrRd = java.io.ByteArrayOutputStream() + writeDnsName(ptrRd, "AeroToss._aerotoss._tcp.local.") + val ptrBytes = ptrRd.toByteArray() + dos.writeShort(ptrBytes.size) + dos.write(ptrBytes) + + // SRV record: AeroToss._aerotoss._tcp.local. → port + writeDnsName(baos, "AeroToss._aerotoss._tcp.local.") + dos.writeShort(33) // SRV + dos.writeShort(1) // class IN + dos.writeInt(120) // TTL + val srvRd = java.io.ByteArrayOutputStream() + srvRd.write(0) // priority high byte + srvRd.write(0) // priority low byte + srvRd.write(0) // weight high byte + srvRd.write(0) // weight low byte + srvRd.write((servicePort shr 8) and 0xFF) + srvRd.write(servicePort and 0xFF) + writeDnsName(srvRd, "AeroToss._aerotoss._tcp.local.") + val srvBytes = srvRd.toByteArray() + dos.writeShort(srvBytes.size) + dos.write(srvBytes) + + // TXT record + writeDnsName(baos, "AeroToss._aerotoss._tcp.local.") + dos.writeShort(16) // TXT + dos.writeShort(1) // class IN + dos.writeInt(120) // TTL + val txtRd = java.io.ByteArrayOutputStream() + val txtEntry = "id=$deviceId name=$deviceName port=$servicePort".toByteArray(Charsets.UTF_8) + txtRd.write(txtEntry.size) + txtRd.write(txtEntry) + val txtBytes = txtRd.toByteArray() + dos.writeShort(txtBytes.size) + dos.write(txtBytes) + + dos.flush() + return baos.toByteArray() + } + + private fun writeDnsName(os: java.io.OutputStream, name: String) { + for (part in name.split(".")) { + if (part.isEmpty()) continue + os.write(part.length) + os.write(part.toByteArray(Charsets.UTF_8)) + } + os.write(0) + } + override fun stopDiscovery() { discoveryThread?.interrupt() discoveryThread = null - try { - serviceListener?.let { jmdns?.removeServiceListener(SERVICE_TYPE, it) } - serviceListener?.let { jmdns?.removeServiceListener("_aerotoss._tcp", it) } - jmdns?.unregisterAllServices() - jmdns?.close() - } catch (_: Exception) { - } - jmdns = null - serviceListener = null + try { multicastSocket?.close() } catch (_: Exception) {} + multicastSocket = null discoveredDevices.clear() _devices.value = emptyList() } @@ -117,6 +277,11 @@ class JmdnsDiscovery : DiscoveryManager { stopDiscovery() } + companion object { + const val SERVICE_TYPE = "_aerotoss._tcp" + const val SERVICE_NAME = "AeroToss" + } + private fun findLocalAddress(): InetAddress? { return try { NetworkInterface.getNetworkInterfaces()?.toList() @@ -132,29 +297,4 @@ class JmdnsDiscovery : DiscoveryManager { private fun isVirtual(iface: NetworkInterface): Boolean { return iface.isVirtual || iface.name.startsWith("vmnet") || iface.name.startsWith("veth") } - - private fun parseAttributes(raw: ByteArray): Map { - val result = mutableMapOf() - var i = 0 - while (i < raw.size) { - val keyLen = raw[i].toInt() and 0xFF - i++ - if (i + keyLen > raw.size) break - val key = String(raw, i, keyLen, Charsets.UTF_8) - i += keyLen - if (i >= raw.size) break - val valueLen = raw[i].toInt() and 0xFF - i++ - if (i + valueLen > raw.size) break - val value = String(raw, i, valueLen, Charsets.UTF_8) - i += valueLen - result[key] = value - } - return result - } - - companion object { - const val SERVICE_TYPE = "_aerotoss._tcp." - const val SERVICE_NAME = "AeroToss" - } } diff --git a/app/src/desktopMain/kotlin/com/aerotoss/transfer/DesktopTransferManager.kt b/app/src/desktopMain/kotlin/com/aerotoss/transfer/DesktopTransferManager.kt index 84cc39b..73faf3e 100644 --- a/app/src/desktopMain/kotlin/com/aerotoss/transfer/DesktopTransferManager.kt +++ b/app/src/desktopMain/kotlin/com/aerotoss/transfer/DesktopTransferManager.kt @@ -49,7 +49,7 @@ class DesktopFileTransferManager : TransferManager { if (!running.compareAndSet(false, true)) return serverSocket?.localPort ?: 0 try { - val socket = ServerSocket(port, 50, InetAddress.getLoopbackAddress()) + val socket = ServerSocket(port, 50, InetAddress.getByName("0.0.0.0")) serverSocket = socket serverThread = Thread { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f83615c..87ba93e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,7 +5,7 @@ agp = "8.13.2" ktor = "3.5.2" kotlinx-serialization = "1.11.0" kotlinx-coroutines = "1.10.1" -jmdns = "3.6.3" +jmdns = "3.5.12" [libraries] ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }