AeroToss: исправлен mDNS (заменён JMDNS на кастомную UDP-реализацию)

- 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
This commit is contained in:
2026-08-19 19:40:00 +03:00
parent 49d0936c38
commit 518827ea95
5 changed files with 235 additions and 96 deletions
-1
View File
@@ -59,7 +59,6 @@ kotlin {
implementation(compose.desktop.currentOs) implementation(compose.desktop.currentOs)
implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.serialization.json)
implementation(libs.jmdns)
implementation(libs.junit) implementation(libs.junit)
} }
} }
@@ -52,7 +52,7 @@ class AndroidFileTransferManager(private val context: Context) : TransferManager
if (!running.compareAndSet(false, true)) return serverSocket?.localPort ?: 0 if (!running.compareAndSet(false, true)) return serverSocket?.localPort ?: 0
try { try {
val socket = ServerSocket(port, 50, InetAddress.getLoopbackAddress()) val socket = ServerSocket(port, 50, InetAddress.getByName("0.0.0.0"))
serverSocket = socket serverSocket = socket
serverThread = Thread { serverThread = Thread {
@@ -7,89 +7,60 @@ import com.aerotoss.util.getDeviceName
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import java.net.DatagramPacket
import java.net.InetAddress import java.net.InetAddress
import java.net.MulticastSocket
import java.net.NetworkInterface import java.net.NetworkInterface
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import javax.jmdns.JmDNS
import javax.jmdns.ServiceEvent
import javax.jmdns.ServiceListener
import javax.jmdns.ServiceInfo
class JmdnsDiscovery : DiscoveryManager { class JmdnsDiscovery : DiscoveryManager {
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 var jmdns: JmDNS? = null private var multicastSocket: MulticastSocket? = null
private var serviceListener: ServiceListener? = null
private var discoveryThread: Thread? = null private var discoveryThread: Thread? = null
private val deviceId = generateDeviceId() private val deviceId = generateDeviceId()
private val deviceName = getDeviceName() private val deviceName = getDeviceName()
private val discoveredDevices = ConcurrentHashMap<String, Device>() private val discoveredDevices = ConcurrentHashMap<String, Device>()
private val localServicePort = (10000..60000).random()
override fun startDiscovery(servicePort: Int) { override fun startDiscovery(servicePort: Int) {
if (discoveryThread != null) return if (discoveryThread != null) return
discoveryThread = Thread { discoveryThread = Thread {
try { try {
val addr = findLocalAddress() ?: return@Thread val localAddr = findLocalAddress() ?: InetAddress.getLocalHost()
println("AeroToss: Starting mDNS on ${addr.hostAddress}") println("AeroToss: Starting mDNS on ${localAddr.hostAddress}")
jmdns = JmDNS.create(addr, "aerotoss-$deviceId")
val serviceInfo = ServiceInfo.create( val group = InetAddress.getByName("224.0.0.251")
SERVICE_TYPE, val socket = MulticastSocket(5353)
SERVICE_NAME, socket.reuseAddress = true
servicePort, socket.networkInterface = NetworkInterface.getByInetAddress(localAddr)
"path=/ aerotoss=1 id=$deviceId name=$deviceName" socket.joinGroup(java.net.InetSocketAddress(group, 5353), socket.networkInterface)
) multicastSocket = socket
jmdns?.registerService(serviceInfo)
println("AeroToss: Registered service on port $servicePort")
serviceListener = object : ServiceListener { registerService(socket, group, servicePort)
override fun serviceAdded(event: ServiceEvent) {
println("AeroToss: Service added: ${event.name} type=${event.type}")
jmdns?.requestServiceInfo(event.type, event.name, true)
}
override fun serviceRemoved(event: ServiceEvent) { val buf = ByteArray(4096)
println("AeroToss: Service removed: ${event.name}") while (!Thread.currentThread().isInterrupted) {
discoveredDevices.remove(event.name) val packet = DatagramPacket(buf, buf.size)
_devices.value = discoveredDevices.values.toList() try {
} socket.soTimeout = 2000
socket.receive(packet)
override fun serviceResolved(event: ServiceEvent) { processPacket(packet.data, packet.length, packet.address)
val info = event.info } catch (_: java.net.SocketTimeoutException) {
val hostAddresses = info.hostAddresses // Send announcement periodically
println("AeroToss: Service resolved: ${event.name} addresses=${hostAddresses.toList()}") registerService(socket, group, servicePort)
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()
} }
} }
} socket.leaveGroup(group)
} socket.close()
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()
} catch (e: Exception) { } catch (e: Exception) {
if (!Thread.currentThread().isInterrupted) {
e.printStackTrace() e.printStackTrace()
println("AeroToss: mDNS error: ${e.message}") println("AeroToss: mDNS error: ${e.message}")
} }
}
}.apply { }.apply {
isDaemon = true isDaemon = true
name = "aerotoss-mdns" name = "aerotoss-mdns"
@@ -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<String, String> {
val result = mutableMapOf<String, String>()
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() { override fun stopDiscovery() {
discoveryThread?.interrupt() discoveryThread?.interrupt()
discoveryThread = null discoveryThread = null
try { try { multicastSocket?.close() } catch (_: Exception) {}
serviceListener?.let { jmdns?.removeServiceListener(SERVICE_TYPE, it) } multicastSocket = null
serviceListener?.let { jmdns?.removeServiceListener("_aerotoss._tcp", it) }
jmdns?.unregisterAllServices()
jmdns?.close()
} catch (_: Exception) {
}
jmdns = null
serviceListener = null
discoveredDevices.clear() discoveredDevices.clear()
_devices.value = emptyList() _devices.value = emptyList()
} }
@@ -117,6 +277,11 @@ class JmdnsDiscovery : DiscoveryManager {
stopDiscovery() stopDiscovery()
} }
companion object {
const val SERVICE_TYPE = "_aerotoss._tcp"
const val SERVICE_NAME = "AeroToss"
}
private fun findLocalAddress(): InetAddress? { private fun findLocalAddress(): InetAddress? {
return try { return try {
NetworkInterface.getNetworkInterfaces()?.toList() NetworkInterface.getNetworkInterfaces()?.toList()
@@ -132,29 +297,4 @@ class JmdnsDiscovery : DiscoveryManager {
private fun isVirtual(iface: NetworkInterface): Boolean { private fun isVirtual(iface: NetworkInterface): Boolean {
return iface.isVirtual || iface.name.startsWith("vmnet") || iface.name.startsWith("veth") return iface.isVirtual || iface.name.startsWith("vmnet") || iface.name.startsWith("veth")
} }
private fun parseAttributes(raw: ByteArray): Map<String, String> {
val result = mutableMapOf<String, String>()
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"
}
} }
@@ -49,7 +49,7 @@ class DesktopFileTransferManager : TransferManager {
if (!running.compareAndSet(false, true)) return serverSocket?.localPort ?: 0 if (!running.compareAndSet(false, true)) return serverSocket?.localPort ?: 0
try { try {
val socket = ServerSocket(port, 50, InetAddress.getLoopbackAddress()) val socket = ServerSocket(port, 50, InetAddress.getByName("0.0.0.0"))
serverSocket = socket serverSocket = socket
serverThread = Thread { serverThread = Thread {
+1 -1
View File
@@ -5,7 +5,7 @@ agp = "8.13.2"
ktor = "3.5.2" ktor = "3.5.2"
kotlinx-serialization = "1.11.0" kotlinx-serialization = "1.11.0"
kotlinx-coroutines = "1.10.1" kotlinx-coroutines = "1.10.1"
jmdns = "3.6.3" jmdns = "3.5.12"
[libraries] [libraries]
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }