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.DeviceType
import com.aerotoss.util.generateDeviceId
import com.aerotoss.util.getDeviceName
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.TimeUnit
class AndroidNsdDiscovery(context: Context) : DiscoveryManager {
private val _devices = MutableStateFlow<List<Device>>(emptyList())
@@ -21,6 +21,7 @@ class AndroidNsdDiscovery(context: Context) : DiscoveryManager {
private var registrationListener: NsdManager.RegistrationListener? = null
private var discoveryListener: NsdManager.DiscoveryListener? = null
private val deviceId = generateDeviceId()
private val deviceName = getDeviceName()
private val discoveredDevices = ConcurrentHashMap<String, Device>()
private val resolvingServices = ConcurrentHashMap.newKeySet<String>()
@@ -34,21 +35,22 @@ class AndroidNsdDiscovery(context: Context) : DiscoveryManager {
serviceName = SERVICE_NAME
serviceType = SERVICE_TYPE
setPort(port)
setAttribute("id", deviceId)
setAttribute("name", deviceName)
setAttribute("port", port.toString())
setAttribute("type", "phone")
}
registrationListener = object : NsdManager.RegistrationListener {
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) {
Log.e(TAG, "Registration failed: $errorCode")
}
override fun onServiceUnregistered(info: NsdServiceInfo) {
Log.d(TAG, "Service unregistered")
Log.d(TAG, "Unregistered")
}
override fun onUnregistrationFailed(info: NsdServiceInfo, errorCode: Int) {
Log.e(TAG, "Unregistration failed: $errorCode")
}
@@ -60,33 +62,50 @@ class AndroidNsdDiscovery(context: Context) : DiscoveryManager {
private fun startServiceDiscovery() {
discoveryListener = object : NsdManager.DiscoveryListener {
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) {
Log.d(TAG, "Service found: ${serviceInfo.serviceName} type=${serviceInfo.serviceType}")
val matchesType = serviceInfo.serviceType.contains("aerotoss")
if (matchesType &&
serviceInfo.serviceName != SERVICE_NAME &&
Log.d(TAG, "Found: ${serviceInfo.serviceName} type=${serviceInfo.serviceType}")
if (serviceInfo.serviceName != SERVICE_NAME &&
resolvingServices.add(serviceInfo.serviceName)
) {
nsdManager?.resolveService(serviceInfo, object : NsdManager.ResolveListener {
override fun onResolveFailed(info: NsdServiceInfo, errorCode: Int) {
resolvingServices.remove(info.serviceName)
Log.e(TAG, "Resolve failed: $errorCode")
Log.e(TAG, "Resolve failed: ${info.serviceName} error=$errorCode")
}
override fun onServiceResolved(info: NsdServiceInfo) {
resolvingServices.remove(info.serviceName)
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(
id = info.serviceName,
name = info.serviceName,
type = DeviceType.PHONE,
id = id,
name = name,
type = deviceType,
hostAddress = host,
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()
}
})
@@ -94,6 +113,7 @@ class AndroidNsdDiscovery(context: Context) : DiscoveryManager {
}
override fun onServiceLost(serviceInfo: NsdServiceInfo) {
Log.d(TAG, "Lost: ${serviceInfo.serviceName}")
discoveredDevices.remove(serviceInfo.serviceName)
resolvingServices.remove(serviceInfo.serviceName)
_devices.value = discoveredDevices.values.toList()
@@ -116,14 +136,8 @@ class AndroidNsdDiscovery(context: Context) : DiscoveryManager {
}
override fun stopDiscovery() {
try {
discoveryListener?.let { nsdManager?.stopServiceDiscovery(it) }
} catch (_: Exception) {
}
try {
registrationListener?.let { nsdManager?.unregisterService(it) }
} catch (_: Exception) {
}
try { discoveryListener?.let { nsdManager?.stopServiceDiscovery(it) } } catch (_: Exception) {}
try { registrationListener?.let { nsdManager?.unregisterService(it) } } catch (_: Exception) {}
discoveryListener = null
registrationListener = null
discoveredDevices.clear()
@@ -136,7 +150,7 @@ class AndroidNsdDiscovery(context: Context) : DiscoveryManager {
}
companion object {
private const val TAG = "AndroidNsdDiscovery"
private const val TAG = "AeroToss-NSD"
private const val SERVICE_TYPE = "_aerotoss._tcp."
private const val SERVICE_NAME = "AeroToss"
}
@@ -36,20 +36,18 @@ class JmdnsDiscovery : DiscoveryManager {
return@Thread
}
// Try each interface until we find one that works
for (iface in interfaces) {
try {
println("AeroToss: Trying interface ${iface.name} (${iface.inetAddresses.toList().map { it.hostAddress }})")
println("AeroToss: Trying ${iface.name} (${getIpv4(iface)?.hostAddress})")
runOnInterface(iface, servicePort)
break // If successful, stay on this interface
break
} catch (e: Exception) {
println("AeroToss: Interface ${iface.name} failed: ${e.message}")
println("AeroToss: ${iface.name} failed: ${e.message}")
}
}
} catch (e: Exception) {
if (!Thread.currentThread().isInterrupted) {
e.printStackTrace()
println("AeroToss: mDNS error: ${e.message}")
}
}
}.apply {
@@ -69,12 +67,11 @@ class JmdnsDiscovery : DiscoveryManager {
println("AeroToss: Listening on ${iface.name}")
// Initial registration
registerService(socket, group, servicePort)
// Listen for queries and respond
val buf = ByteArray(4096)
var lastRegister = System.currentTimeMillis()
var lastQuery = System.currentTimeMillis()
while (!Thread.currentThread().isInterrupted) {
val packet = DatagramPacket(buf, buf.size)
@@ -83,24 +80,57 @@ class JmdnsDiscovery : DiscoveryManager {
socket.receive(packet)
processPacket(packet.data, packet.length, packet.address)
} catch (_: java.net.SocketTimeoutException) {
// Re-register periodically (every 30 seconds)
val now = System.currentTimeMillis()
// Re-register every 30s
if (now - lastRegister > 30_000) {
registerService(socket, group, servicePort)
lastRegister = now
}
// Send discovery query every 5s
if (now - lastQuery > 5_000) {
sendQuery(socket, group)
lastQuery = now
}
}
}
socket.leaveGroup(group)
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) {
val response = buildDnsResponse(servicePort)
val packet = DatagramPacket(response, response.size, group, 5353)
try {
socket.send(packet)
println("AeroToss: Registered service on port $servicePort")
println("AeroToss: Registered on port $servicePort as '$deviceName'")
} catch (_: Exception) {}
}
@@ -108,12 +138,17 @@ class JmdnsDiscovery : DiscoveryManager {
if (length < 12) return
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)
if (anCount == 0) return
var offset = 12
var foundSrv = false
var foundTxt = false
var srvPort = 0
var txtAttributes = mapOf<String, String>()
for (i in 0 until anCount) {
offset = skipName(data, offset, length)
if (offset + 10 > length) return
@@ -127,41 +162,43 @@ class JmdnsDiscovery : DiscoveryManager {
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 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")
}
srvPort = ((srvData[2].toInt() and 0xFF) shl 8) or (srvData[3].toInt() and 0xFF)
foundSrv = true
}
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
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()
}
txtAttributes = parseTxtRecord(txtData)
foundTxt = true
}
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 {
@@ -196,33 +233,32 @@ class JmdnsDiscovery : DiscoveryManager {
val baos = java.io.ByteArrayOutputStream()
val dos = java.io.DataOutputStream(baos)
// Header
dos.writeShort(0) // ID
dos.writeShort(0x8400) // Flags: response, authoritative
dos.writeShort(0x8400) // Flags
dos.writeShort(0) // QDCOUNT
dos.writeShort(3) // ANCOUNT (PTR + SRV + TXT)
dos.writeShort(3) // ANCOUNT
dos.writeShort(0) // NSCOUNT
dos.writeShort(0) // ARCOUNT
// PTR record
// PTR: _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
dos.writeShort(1) // IN
dos.writeInt(4500) // 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
// SRV: AeroToss._aerotoss._tcp.local. → port
writeDnsName(baos, "AeroToss._aerotoss._tcp.local.")
dos.writeShort(33) // SRV
dos.writeShort(1) // class IN
dos.writeInt(120) // TTL
dos.writeShort(1) // IN
dos.writeInt(4500) // TTL
val srvRd = java.io.ByteArrayOutputStream()
srvRd.write(0) // priority
srvRd.write(0) // weight
srvRd.write(0); srvRd.write(0) // priority
srvRd.write(0); srvRd.write(0) // weight
srvRd.write((servicePort shr 8) and 0xFF)
srvRd.write(servicePort and 0xFF)
writeDnsName(srvRd, "AeroToss._aerotoss._tcp.local.")
@@ -230,15 +266,23 @@ class JmdnsDiscovery : DiscoveryManager {
dos.writeShort(srvBytes.size)
dos.write(srvBytes)
// TXT record
// TXT: AeroToss._aerotoss._tcp.local. → id, name, port
writeDnsName(baos, "AeroToss._aerotoss._tcp.local.")
dos.writeShort(16) // TXT
dos.writeShort(1) // class IN
dos.writeInt(120) // TTL
dos.writeShort(1) // IN
dos.writeInt(4500) // 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 entries = listOf(
"id=$deviceId",
"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()
dos.writeShort(txtBytes.size)
dos.write(txtBytes)
@@ -256,6 +300,10 @@ class JmdnsDiscovery : DiscoveryManager {
os.write(0)
}
private fun getIpv4(iface: NetworkInterface): InetAddress? {
return iface.inetAddresses.toList().firstOrNull { it is java.net.Inet4Address }
}
private fun getAvailableInterfaces(): List<NetworkInterface> {
return NetworkInterface.getNetworkInterfaces()?.toList()
?.filter { iface ->
@@ -264,9 +312,8 @@ class JmdnsDiscovery : DiscoveryManager {
!isVirtual(iface) &&
iface.inetAddresses.toList().any { it is java.net.Inet4Address }
}
?.sortedByDescending { iface: NetworkInterface ->
// Prefer interfaces with private IP addresses (192.168.x.x, 10.x.x.x, 172.16-31.x.x)
iface.inetAddresses.toList().any { addr: java.net.InetAddress ->
?.sortedByDescending { iface ->
iface.inetAddresses.toList().any { addr ->
addr is java.net.Inet4Address && isPrivateAddress(addr)
}
}