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:
@@ -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 {
|
||||||
|
|||||||
@@ -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" }
|
||||||
|
|||||||
Reference in New Issue
Block a user