AeroToss: mDNS queries + имена устройств + типы

- Desktop отправляет mDNS-запросы каждые 5 секунд для обнаружения
- При обнаружении читается TXT record: name, id, type, port
- Тип устройства определяется из TXT (phone/desktop)
- Android NSD: setAttribute для TXT записей (id, name, port, type)
- Имена: Desktop = user@hostname, Android = manufacturer model
- Исправлен тип устройства: DESKTOP → PHONE/DESKTOP из TXT
This commit is contained in:
2026-08-19 19:58:39 +03:00
parent 8bbea442f3
commit ab7b917a01
2 changed files with 142 additions and 81 deletions
@@ -7,11 +7,11 @@ import android.util.Log
import com.aerotoss.model.Device import com.aerotoss.model.Device
import com.aerotoss.model.DeviceType import com.aerotoss.model.DeviceType
import com.aerotoss.util.generateDeviceId import com.aerotoss.util.generateDeviceId
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.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.TimeUnit
class AndroidNsdDiscovery(context: Context) : DiscoveryManager { class AndroidNsdDiscovery(context: Context) : DiscoveryManager {
private val _devices = MutableStateFlow<List<Device>>(emptyList()) private val _devices = MutableStateFlow<List<Device>>(emptyList())
@@ -21,6 +21,7 @@ class AndroidNsdDiscovery(context: Context) : DiscoveryManager {
private var registrationListener: NsdManager.RegistrationListener? = null private var registrationListener: NsdManager.RegistrationListener? = null
private var discoveryListener: NsdManager.DiscoveryListener? = null private var discoveryListener: NsdManager.DiscoveryListener? = null
private val deviceId = generateDeviceId() private val deviceId = generateDeviceId()
private val deviceName = getDeviceName()
private val discoveredDevices = ConcurrentHashMap<String, Device>() private val discoveredDevices = ConcurrentHashMap<String, Device>()
private val resolvingServices = ConcurrentHashMap.newKeySet<String>() private val resolvingServices = ConcurrentHashMap.newKeySet<String>()
@@ -34,21 +35,22 @@ class AndroidNsdDiscovery(context: Context) : DiscoveryManager {
serviceName = SERVICE_NAME serviceName = SERVICE_NAME
serviceType = SERVICE_TYPE serviceType = SERVICE_TYPE
setPort(port) setPort(port)
setAttribute("id", deviceId)
setAttribute("name", deviceName)
setAttribute("port", port.toString())
setAttribute("type", "phone")
} }
registrationListener = object : NsdManager.RegistrationListener { registrationListener = object : NsdManager.RegistrationListener {
override fun onServiceRegistered(info: NsdServiceInfo) { override fun onServiceRegistered(info: NsdServiceInfo) {
Log.d(TAG, "Service registered: ${info.serviceName}") Log.d(TAG, "Registered as: ${info.serviceName}")
} }
override fun onRegistrationFailed(info: NsdServiceInfo, errorCode: Int) { override fun onRegistrationFailed(info: NsdServiceInfo, errorCode: Int) {
Log.e(TAG, "Registration failed: $errorCode") Log.e(TAG, "Registration failed: $errorCode")
} }
override fun onServiceUnregistered(info: NsdServiceInfo) { override fun onServiceUnregistered(info: NsdServiceInfo) {
Log.d(TAG, "Service unregistered") Log.d(TAG, "Unregistered")
} }
override fun onUnregistrationFailed(info: NsdServiceInfo, errorCode: Int) { override fun onUnregistrationFailed(info: NsdServiceInfo, errorCode: Int) {
Log.e(TAG, "Unregistration failed: $errorCode") Log.e(TAG, "Unregistration failed: $errorCode")
} }
@@ -60,33 +62,50 @@ class AndroidNsdDiscovery(context: Context) : DiscoveryManager {
private fun startServiceDiscovery() { private fun startServiceDiscovery() {
discoveryListener = object : NsdManager.DiscoveryListener { discoveryListener = object : NsdManager.DiscoveryListener {
override fun onDiscoveryStarted(serviceType: String) { override fun onDiscoveryStarted(serviceType: String) {
Log.d(TAG, "Discovery started for type: $serviceType") Log.d(TAG, "Discovery started for: $serviceType")
} }
override fun onServiceFound(serviceInfo: NsdServiceInfo) { override fun onServiceFound(serviceInfo: NsdServiceInfo) {
Log.d(TAG, "Service found: ${serviceInfo.serviceName} type=${serviceInfo.serviceType}") Log.d(TAG, "Found: ${serviceInfo.serviceName} type=${serviceInfo.serviceType}")
val matchesType = serviceInfo.serviceType.contains("aerotoss") if (serviceInfo.serviceName != SERVICE_NAME &&
if (matchesType &&
serviceInfo.serviceName != SERVICE_NAME &&
resolvingServices.add(serviceInfo.serviceName) resolvingServices.add(serviceInfo.serviceName)
) { ) {
nsdManager?.resolveService(serviceInfo, object : NsdManager.ResolveListener { nsdManager?.resolveService(serviceInfo, object : NsdManager.ResolveListener {
override fun onResolveFailed(info: NsdServiceInfo, errorCode: Int) { override fun onResolveFailed(info: NsdServiceInfo, errorCode: Int) {
resolvingServices.remove(info.serviceName) resolvingServices.remove(info.serviceName)
Log.e(TAG, "Resolve failed: $errorCode") Log.e(TAG, "Resolve failed: ${info.serviceName} error=$errorCode")
} }
override fun onServiceResolved(info: NsdServiceInfo) { override fun onServiceResolved(info: NsdServiceInfo) {
resolvingServices.remove(info.serviceName) resolvingServices.remove(info.serviceName)
val host = info.host?.hostAddress ?: return val host = info.host?.hostAddress ?: return
// Try to get device name from TXT records
var name = info.serviceName
var id = info.serviceName
var deviceType = DeviceType.PHONE
try {
val attributes = info.attributes
attributes["name"]?.let { name = String(it) }
attributes["id"]?.let { id = String(it) }
attributes["type"]?.let { type ->
if (String(type) == "desktop") {
deviceType = DeviceType.DESKTOP
}
}
} catch (_: Exception) {}
val device = Device( val device = Device(
id = info.serviceName, id = id,
name = info.serviceName, name = name,
type = DeviceType.PHONE, type = deviceType,
hostAddress = host, hostAddress = host,
port = info.port port = info.port
) )
discoveredDevices[info.serviceName] = device
Log.d(TAG, "Resolved: $name ($id) at $host:${info.port}")
discoveredDevices[id] = device
_devices.value = discoveredDevices.values.toList() _devices.value = discoveredDevices.values.toList()
} }
}) })
@@ -94,6 +113,7 @@ class AndroidNsdDiscovery(context: Context) : DiscoveryManager {
} }
override fun onServiceLost(serviceInfo: NsdServiceInfo) { override fun onServiceLost(serviceInfo: NsdServiceInfo) {
Log.d(TAG, "Lost: ${serviceInfo.serviceName}")
discoveredDevices.remove(serviceInfo.serviceName) discoveredDevices.remove(serviceInfo.serviceName)
resolvingServices.remove(serviceInfo.serviceName) resolvingServices.remove(serviceInfo.serviceName)
_devices.value = discoveredDevices.values.toList() _devices.value = discoveredDevices.values.toList()
@@ -116,14 +136,8 @@ class AndroidNsdDiscovery(context: Context) : DiscoveryManager {
} }
override fun stopDiscovery() { override fun stopDiscovery() {
try { try { discoveryListener?.let { nsdManager?.stopServiceDiscovery(it) } } catch (_: Exception) {}
discoveryListener?.let { nsdManager?.stopServiceDiscovery(it) } try { registrationListener?.let { nsdManager?.unregisterService(it) } } catch (_: Exception) {}
} catch (_: Exception) {
}
try {
registrationListener?.let { nsdManager?.unregisterService(it) }
} catch (_: Exception) {
}
discoveryListener = null discoveryListener = null
registrationListener = null registrationListener = null
discoveredDevices.clear() discoveredDevices.clear()
@@ -136,7 +150,7 @@ class AndroidNsdDiscovery(context: Context) : DiscoveryManager {
} }
companion object { companion object {
private const val TAG = "AndroidNsdDiscovery" private const val TAG = "AeroToss-NSD"
private const val SERVICE_TYPE = "_aerotoss._tcp." private const val SERVICE_TYPE = "_aerotoss._tcp."
private const val SERVICE_NAME = "AeroToss" private const val SERVICE_NAME = "AeroToss"
} }
@@ -36,20 +36,18 @@ class JmdnsDiscovery : DiscoveryManager {
return@Thread return@Thread
} }
// Try each interface until we find one that works
for (iface in interfaces) { for (iface in interfaces) {
try { try {
println("AeroToss: Trying interface ${iface.name} (${iface.inetAddresses.toList().map { it.hostAddress }})") println("AeroToss: Trying ${iface.name} (${getIpv4(iface)?.hostAddress})")
runOnInterface(iface, servicePort) runOnInterface(iface, servicePort)
break // If successful, stay on this interface break
} catch (e: Exception) { } catch (e: Exception) {
println("AeroToss: Interface ${iface.name} failed: ${e.message}") println("AeroToss: ${iface.name} failed: ${e.message}")
} }
} }
} catch (e: Exception) { } catch (e: Exception) {
if (!Thread.currentThread().isInterrupted) { if (!Thread.currentThread().isInterrupted) {
e.printStackTrace() e.printStackTrace()
println("AeroToss: mDNS error: ${e.message}")
} }
} }
}.apply { }.apply {
@@ -69,12 +67,11 @@ class JmdnsDiscovery : DiscoveryManager {
println("AeroToss: Listening on ${iface.name}") println("AeroToss: Listening on ${iface.name}")
// Initial registration
registerService(socket, group, servicePort) registerService(socket, group, servicePort)
// Listen for queries and respond
val buf = ByteArray(4096) val buf = ByteArray(4096)
var lastRegister = System.currentTimeMillis() var lastRegister = System.currentTimeMillis()
var lastQuery = System.currentTimeMillis()
while (!Thread.currentThread().isInterrupted) { while (!Thread.currentThread().isInterrupted) {
val packet = DatagramPacket(buf, buf.size) val packet = DatagramPacket(buf, buf.size)
@@ -83,24 +80,57 @@ class JmdnsDiscovery : DiscoveryManager {
socket.receive(packet) socket.receive(packet)
processPacket(packet.data, packet.length, packet.address) processPacket(packet.data, packet.length, packet.address)
} catch (_: java.net.SocketTimeoutException) { } catch (_: java.net.SocketTimeoutException) {
// Re-register periodically (every 30 seconds)
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
// Re-register every 30s
if (now - lastRegister > 30_000) { if (now - lastRegister > 30_000) {
registerService(socket, group, servicePort) registerService(socket, group, servicePort)
lastRegister = now lastRegister = now
} }
// Send discovery query every 5s
if (now - lastQuery > 5_000) {
sendQuery(socket, group)
lastQuery = now
}
} }
} }
socket.leaveGroup(group) socket.leaveGroup(group)
socket.close() socket.close()
} }
private fun sendQuery(socket: MulticastSocket, group: InetAddress) {
try {
val query = buildDnsQuery()
val packet = DatagramPacket(query, query.size, group, 5353)
socket.send(packet)
} catch (_: Exception) {}
}
private fun buildDnsQuery(): ByteArray {
val baos = java.io.ByteArrayOutputStream()
val dos = java.io.DataOutputStream(baos)
dos.writeShort(0) // ID
dos.writeShort(0x0000) // Flags: standard query
dos.writeShort(1) // QDCOUNT
dos.writeShort(0) // ANCOUNT
dos.writeShort(0) // NSCOUNT
dos.writeShort(0) // ARCOUNT
// Question: _aerotoss._tcp.local. type=ANY class=IN
writeDnsName(baos, "_aerotoss._tcp.local.")
dos.writeShort(255) // type ANY
dos.writeShort(1) // class IN
dos.flush()
return baos.toByteArray()
}
private fun registerService(socket: MulticastSocket, group: InetAddress, servicePort: Int) { private fun registerService(socket: MulticastSocket, group: InetAddress, servicePort: Int) {
val response = buildDnsResponse(servicePort) val response = buildDnsResponse(servicePort)
val packet = DatagramPacket(response, response.size, group, 5353) val packet = DatagramPacket(response, response.size, group, 5353)
try { try {
socket.send(packet) socket.send(packet)
println("AeroToss: Registered service on port $servicePort") println("AeroToss: Registered on port $servicePort as '$deviceName'")
} catch (_: Exception) {} } catch (_: Exception) {}
} }
@@ -108,12 +138,17 @@ class JmdnsDiscovery : DiscoveryManager {
if (length < 12) return if (length < 12) return
val qr = (data[2].toInt() shr 7) and 1 val qr = (data[2].toInt() shr 7) and 1
if (qr != 1) return // Only process responses if (qr != 1) return
val anCount = ((data[6].toInt() and 0xFF) shl 8) or (data[7].toInt() and 0xFF) val anCount = ((data[6].toInt() and 0xFF) shl 8) or (data[7].toInt() and 0xFF)
if (anCount == 0) return if (anCount == 0) return
var offset = 12 var offset = 12
var foundSrv = false
var foundTxt = false
var srvPort = 0
var txtAttributes = mapOf<String, String>()
for (i in 0 until anCount) { for (i in 0 until anCount) {
offset = skipName(data, offset, length) offset = skipName(data, offset, length)
if (offset + 10 > length) return if (offset + 10 > length) return
@@ -127,41 +162,43 @@ class JmdnsDiscovery : DiscoveryManager {
if (type == 33 && offset + rdLength <= length) { if (type == 33 && offset + rdLength <= length) {
// SRV record // SRV record
val srvData = data.copyOfRange(offset, offset + rdLength) val srvData = data.copyOfRange(offset, offset + rdLength)
val port = ((srvData[2].toInt() and 0xFF) shl 8) or (srvData[3].toInt() and 0xFF) srvPort = ((srvData[2].toInt() and 0xFF) shl 8) or (srvData[3].toInt() and 0xFF)
foundSrv = true
val key = fromAddress.hostAddress ?: continue
if (!discoveredDevices.containsKey(key)) {
val device = Device(
id = key,
name = "Device@${fromAddress.hostAddress}",
type = DeviceType.PHONE,
hostAddress = fromAddress.hostAddress,
port = port
)
discoveredDevices[key] = device
_devices.value = discoveredDevices.values.toList()
println("AeroToss: Discovered device at ${fromAddress.hostAddress}:$port")
}
} }
if (type == 16 && offset + rdLength <= length) { if (type == 16 && offset + rdLength <= length) {
// TXT record // TXT record
val txtData = data.copyOfRange(offset, offset + rdLength) val txtData = data.copyOfRange(offset, offset + rdLength)
val attributes = parseTxtRecord(txtData) txtAttributes = parseTxtRecord(txtData)
val name = attributes["name"] ?: "Device@${fromAddress.hostAddress}" foundTxt = true
val id = attributes["id"] ?: fromAddress.hostAddress
val key = fromAddress.hostAddress ?: continue
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 offset += rdLength
} }
if (foundSrv && foundTxt) {
val key = fromAddress.hostAddress ?: return
val id = txtAttributes["id"] ?: return
val name = txtAttributes["name"] ?: fromAddress.hostAddress
val port = txtAttributes["port"]?.toIntOrNull() ?: srvPort
val deviceType = when (txtAttributes["type"]) {
"phone" -> DeviceType.PHONE
else -> DeviceType.DESKTOP
}
if (id != deviceId) {
val device = Device(
id = id,
name = name,
type = deviceType,
hostAddress = fromAddress.hostAddress,
port = port
)
discoveredDevices[key] = device
_devices.value = discoveredDevices.values.toList()
println("AeroToss: Found $name at ${fromAddress.hostAddress}:$port")
}
}
} }
private fun skipName(data: ByteArray, offset: Int, length: Int): Int { private fun skipName(data: ByteArray, offset: Int, length: Int): Int {
@@ -196,33 +233,32 @@ class JmdnsDiscovery : DiscoveryManager {
val baos = java.io.ByteArrayOutputStream() val baos = java.io.ByteArrayOutputStream()
val dos = java.io.DataOutputStream(baos) val dos = java.io.DataOutputStream(baos)
// Header
dos.writeShort(0) // ID dos.writeShort(0) // ID
dos.writeShort(0x8400) // Flags: response, authoritative dos.writeShort(0x8400) // Flags
dos.writeShort(0) // QDCOUNT dos.writeShort(0) // QDCOUNT
dos.writeShort(3) // ANCOUNT (PTR + SRV + TXT) dos.writeShort(3) // ANCOUNT
dos.writeShort(0) // NSCOUNT dos.writeShort(0) // NSCOUNT
dos.writeShort(0) // ARCOUNT dos.writeShort(0) // ARCOUNT
// PTR record // PTR: _aerotoss._tcp.local. → AeroToss._aerotoss._tcp.local.
writeDnsName(baos, "_aerotoss._tcp.local.") writeDnsName(baos, "_aerotoss._tcp.local.")
dos.writeShort(12) // PTR dos.writeShort(12) // PTR
dos.writeShort(1) // class IN dos.writeShort(1) // IN
dos.writeInt(120) // TTL dos.writeInt(4500) // TTL
val ptrRd = java.io.ByteArrayOutputStream() val ptrRd = java.io.ByteArrayOutputStream()
writeDnsName(ptrRd, "AeroToss._aerotoss._tcp.local.") writeDnsName(ptrRd, "AeroToss._aerotoss._tcp.local.")
val ptrBytes = ptrRd.toByteArray() val ptrBytes = ptrRd.toByteArray()
dos.writeShort(ptrBytes.size) dos.writeShort(ptrBytes.size)
dos.write(ptrBytes) dos.write(ptrBytes)
// SRV record // SRV: AeroToss._aerotoss._tcp.local. → port
writeDnsName(baos, "AeroToss._aerotoss._tcp.local.") writeDnsName(baos, "AeroToss._aerotoss._tcp.local.")
dos.writeShort(33) // SRV dos.writeShort(33) // SRV
dos.writeShort(1) // class IN dos.writeShort(1) // IN
dos.writeInt(120) // TTL dos.writeInt(4500) // TTL
val srvRd = java.io.ByteArrayOutputStream() val srvRd = java.io.ByteArrayOutputStream()
srvRd.write(0) // priority srvRd.write(0); srvRd.write(0) // priority
srvRd.write(0) // weight srvRd.write(0); srvRd.write(0) // weight
srvRd.write((servicePort shr 8) and 0xFF) srvRd.write((servicePort shr 8) and 0xFF)
srvRd.write(servicePort and 0xFF) srvRd.write(servicePort and 0xFF)
writeDnsName(srvRd, "AeroToss._aerotoss._tcp.local.") writeDnsName(srvRd, "AeroToss._aerotoss._tcp.local.")
@@ -230,15 +266,23 @@ class JmdnsDiscovery : DiscoveryManager {
dos.writeShort(srvBytes.size) dos.writeShort(srvBytes.size)
dos.write(srvBytes) dos.write(srvBytes)
// TXT record // TXT: AeroToss._aerotoss._tcp.local. → id, name, port
writeDnsName(baos, "AeroToss._aerotoss._tcp.local.") writeDnsName(baos, "AeroToss._aerotoss._tcp.local.")
dos.writeShort(16) // TXT dos.writeShort(16) // TXT
dos.writeShort(1) // class IN dos.writeShort(1) // IN
dos.writeInt(120) // TTL dos.writeInt(4500) // TTL
val txtRd = java.io.ByteArrayOutputStream() val txtRd = java.io.ByteArrayOutputStream()
val txtEntry = "id=$deviceId name=$deviceName port=$servicePort".toByteArray(Charsets.UTF_8) val entries = listOf(
txtRd.write(txtEntry.size) "id=$deviceId",
txtRd.write(txtEntry) "name=$deviceName",
"port=$servicePort",
"type=desktop"
)
for (entry in entries) {
val bytes = entry.toByteArray(Charsets.UTF_8)
txtRd.write(bytes.size)
txtRd.write(bytes)
}
val txtBytes = txtRd.toByteArray() val txtBytes = txtRd.toByteArray()
dos.writeShort(txtBytes.size) dos.writeShort(txtBytes.size)
dos.write(txtBytes) dos.write(txtBytes)
@@ -256,6 +300,10 @@ class JmdnsDiscovery : DiscoveryManager {
os.write(0) os.write(0)
} }
private fun getIpv4(iface: NetworkInterface): InetAddress? {
return iface.inetAddresses.toList().firstOrNull { it is java.net.Inet4Address }
}
private fun getAvailableInterfaces(): List<NetworkInterface> { private fun getAvailableInterfaces(): List<NetworkInterface> {
return NetworkInterface.getNetworkInterfaces()?.toList() return NetworkInterface.getNetworkInterfaces()?.toList()
?.filter { iface -> ?.filter { iface ->
@@ -264,9 +312,8 @@ class JmdnsDiscovery : DiscoveryManager {
!isVirtual(iface) && !isVirtual(iface) &&
iface.inetAddresses.toList().any { it is java.net.Inet4Address } iface.inetAddresses.toList().any { it is java.net.Inet4Address }
} }
?.sortedByDescending { iface: NetworkInterface -> ?.sortedByDescending { iface ->
// Prefer interfaces with private IP addresses (192.168.x.x, 10.x.x.x, 172.16-31.x.x) iface.inetAddresses.toList().any { addr ->
iface.inetAddresses.toList().any { addr: java.net.InetAddress ->
addr is java.net.Inet4Address && isPrivateAddress(addr) addr is java.net.Inet4Address && isPrivateAddress(addr)
} }
} }