diff --git a/app/src/androidMain/kotlin/com/aerotoss/discovery/AndroidNsdDiscovery.kt b/app/src/androidMain/kotlin/com/aerotoss/discovery/AndroidNsdDiscovery.kt index 251663e..e0e1def 100644 --- a/app/src/androidMain/kotlin/com/aerotoss/discovery/AndroidNsdDiscovery.kt +++ b/app/src/androidMain/kotlin/com/aerotoss/discovery/AndroidNsdDiscovery.kt @@ -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>(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() private val resolvingServices = ConcurrentHashMap.newKeySet() @@ -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" } diff --git a/app/src/desktopMain/kotlin/com/aerotoss/discovery/JmdnsDiscovery.kt b/app/src/desktopMain/kotlin/com/aerotoss/discovery/JmdnsDiscovery.kt index 4a9c3c9..74cac70 100644 --- a/app/src/desktopMain/kotlin/com/aerotoss/discovery/JmdnsDiscovery.kt +++ b/app/src/desktopMain/kotlin/com/aerotoss/discovery/JmdnsDiscovery.kt @@ -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() + 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 { 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) } }