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
@@ -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 {
@@ -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<List<Device>>(emptyList())
override val devices: Flow<List<Device>> = _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<String, Device>()
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<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() {
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<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
try {
val socket = ServerSocket(port, 50, InetAddress.getLoopbackAddress())
val socket = ServerSocket(port, 50, InetAddress.getByName("0.0.0.0"))
serverSocket = socket
serverThread = Thread {