diff --git a/app/src/androidMain/kotlin/com/aerotoss/discovery/CompositeAndroidDiscovery.kt b/app/src/androidMain/kotlin/com/aerotoss/discovery/CompositeAndroidDiscovery.kt index 38653c0..4d4d210 100644 --- a/app/src/androidMain/kotlin/com/aerotoss/discovery/CompositeAndroidDiscovery.kt +++ b/app/src/androidMain/kotlin/com/aerotoss/discovery/CompositeAndroidDiscovery.kt @@ -18,7 +18,7 @@ class CompositeAndroidDiscovery( private val _devices = MutableStateFlow>(emptyList()) override val devices: Flow> = _devices.asStateFlow() - private val udpDiscovery = UdpBroadcastDiscovery() + private val tcpDiscovery = TcpDiscovery() @Volatile private var scope: CoroutineScope? = null @Volatile private var discoveryJob: kotlinx.coroutines.Job? = null @@ -26,21 +26,18 @@ class CompositeAndroidDiscovery( override fun startDiscovery(servicePort: Int) { if (discoveryJob != null) return - nsdDiscovery.startDiscovery(servicePort) - udpDiscovery.startDiscovery(servicePort) + tcpDiscovery.startDiscovery(servicePort) wifiDirectManager.initialize() wifiDirectManager.discoverPeers() scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) discoveryJob = scope?.launch { combine( - nsdDiscovery.devices, - udpDiscovery.devices, + tcpDiscovery.devices, wifiDirectManager.devices - ) { nsdDevices, udpDevices, wifiDirectDevices -> + ) { tcpDevices, wifiDirectDevices -> val allDevices = mutableMapOf() - nsdDevices.forEach { allDevices["nsd:${it.id}"] = it } - udpDevices.forEach { allDevices["udp:${it.id}"] = it } + tcpDevices.forEach { allDevices["tcp:${it.id}"] = it } wifiDirectDevices.forEach { allDevices["wifi:${it.id}"] = it } allDevices.values.toList() }.collect { devices -> @@ -52,8 +49,7 @@ class CompositeAndroidDiscovery( override fun stopDiscovery() { discoveryJob?.cancel() discoveryJob = null - nsdDiscovery.stopDiscovery() - udpDiscovery.stopDiscovery() + tcpDiscovery.stopDiscovery() wifiDirectManager.stopDiscovery() _devices.value = emptyList() } @@ -62,8 +58,7 @@ class CompositeAndroidDiscovery( stopDiscovery() scope?.cancel() scope = null - nsdDiscovery.dispose() - udpDiscovery.dispose() + tcpDiscovery.dispose() wifiDirectManager.cleanup() } diff --git a/app/src/commonMain/kotlin/com/aerotoss/discovery/TcpDiscovery.kt b/app/src/commonMain/kotlin/com/aerotoss/discovery/TcpDiscovery.kt new file mode 100644 index 0000000..5c0f376 --- /dev/null +++ b/app/src/commonMain/kotlin/com/aerotoss/discovery/TcpDiscovery.kt @@ -0,0 +1,228 @@ +package com.aerotoss.discovery + +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 kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import java.net.DatagramPacket +import java.net.DatagramSocket +import java.net.InetAddress +import java.net.NetworkInterface +import java.net.ServerSocket +import java.net.Socket +import java.util.concurrent.ConcurrentHashMap + +class TcpDiscovery : DiscoveryManager { + private val _devices = MutableStateFlow>(emptyList()) + override val devices: Flow> = _devices.asStateFlow() + + private var serverSocket: ServerSocket? = null + private var serverThread: Thread? = null + private var discoveryThread: Thread? = null + private val deviceId = generateDeviceId() + private val deviceName = getDeviceName() + private val discoveredDevices = ConcurrentHashMap() + private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true } + + override fun startDiscovery(servicePort: Int) { + if (serverThread != null) return + + // Start TCP server for incoming discovery requests + serverThread = Thread { + try { + val ss = ServerSocket(TCP_PORT, 50, InetAddress.getByName("0.0.0.0")) + serverSocket = ss + println("AeroToss-TCP: Server listening on port $TCP_PORT") + + while (!Thread.currentThread().isInterrupted) { + try { + val client = ss.accept() + Thread { + handleDiscoveryRequest(client, servicePort) + }.start() + } catch (_: Exception) { + if (Thread.currentThread().isInterrupted) break + } + } + ss.close() + } catch (e: Exception) { + if (!Thread.currentThread().isInterrupted) { + println("AeroToss-TCP: Server error: ${e.message}") + } + } + }.apply { + isDaemon = true + name = "aerotoss-tcp-server" + start() + } + + // Start UDP broadcast sender + discoveryThread = Thread { + try { + val socket = DatagramSocket() + socket.broadcast = true + socket.soTimeout = 2000 + + var lastBroadcast = System.currentTimeMillis() + + while (!Thread.currentThread().isInterrupted) { + val now = System.currentTimeMillis() + if (now - lastBroadcast > 3_000) { + sendBroadcast(socket, servicePort) + lastBroadcast = now + } + + // Listen for broadcasts from other devices + val buf = ByteArray(1024) + try { + val packet = DatagramPacket(buf, buf.size) + socket.receive(packet) + processBroadcast(packet, servicePort) + } catch (_: java.net.SocketTimeoutException) {} + } + socket.close() + } catch (e: Exception) { + if (!Thread.currentThread().isInterrupted) { + println("AeroToss-TCP: Broadcast error: ${e.message}") + } + } + }.apply { + isDaemon = true + name = "aerotoss-tcp-broadcast" + start() + } + } + + private fun handleDiscoveryRequest(client: Socket, servicePort: Int) { + try { + client.use { sock -> + sock.soTimeout = 5000 + val input = sock.getInputStream().bufferedReader() + val output = sock.getOutputStream().bufferedWriter() + + val request = input.readLine() ?: return + if (request == "DISCOVER") { + val response = DiscoveryResponse( + deviceId = deviceId, + deviceName = deviceName, + deviceType = "desktop", + servicePort = servicePort, + hostAddress = sock.localAddress.hostAddress + ) + output.write(json.encodeToString(response)) + output.newLine() + output.flush() + } + } + } catch (_: Exception) {} + } + + private fun sendBroadcast(socket: DatagramSocket, servicePort: Int) { + try { + val message = "AEROTOSS:$TCP_PORT:$servicePort:$deviceId:$deviceName" + val data = message.toByteArray(Charsets.UTF_8) + val broadcastAddr = InetAddress.getByName("255.255.255.255") + val packet = DatagramPacket(data, data.size, broadcastAddr, UDP_PORT) + socket.send(packet) + } catch (_: Exception) {} + } + + private fun processBroadcast(packet: DatagramPacket, myPort: Int) { + try { + val message = String(packet.data, 0, packet.length, Charsets.UTF_8) + if (!message.startsWith("AEROTOSS:")) return + + val parts = message.split(":", limit = 5) + if (parts.size < 5) return + + val remoteTcpPort = parts[1].toIntOrNull() ?: return + val remoteServicePort = parts[2].toIntOrNull() ?: return + val remoteId = parts[3] + val remoteName = parts[4] + + if (remoteId == deviceId) return + if (remoteServicePort == myPort) return + + // Connect to remote device to get full info + val hostAddress = packet.address.hostAddress ?: return + val device = queryDevice(hostAddress, remoteTcpPort, remoteId, remoteName, remoteServicePort) + + if (device != null) { + discoveredDevices[device.id] = device + _devices.value = discoveredDevices.values.toList() + println("AeroToss-TCP: Found ${device.name} at ${device.hostAddress}:${device.port}") + } + } catch (_: Exception) {} + } + + private fun queryDevice(host: String, tcpPort: Int, fallbackId: String, fallbackName: String, fallbackPort: Int): Device? { + return try { + val socket = Socket() + socket.connect(java.net.InetSocketAddress(host, tcpPort), 3000) + socket.use { sock -> + sock.soTimeout = 3000 + val output = sock.getOutputStream().bufferedWriter() + val input = sock.getInputStream().bufferedReader() + + output.write("DISCOVER") + output.newLine() + output.flush() + + val responseLine = input.readLine() ?: return null + val response = json.decodeFromString(responseLine) + + Device( + id = response.deviceId, + name = response.deviceName, + type = if (response.deviceType == "desktop") DeviceType.DESKTOP else DeviceType.PHONE, + hostAddress = host, + port = response.servicePort + ) + } + } catch (_: Exception) { + // Fallback to broadcast info + Device( + id = fallbackId, + name = fallbackName, + type = DeviceType.PHONE, + hostAddress = host, + port = fallbackPort + ) + } + } + + override fun stopDiscovery() { + serverThread?.interrupt() + serverThread = null + discoveryThread?.interrupt() + discoveryThread = null + try { serverSocket?.close() } catch (_: Exception) {} + serverSocket = null + discoveredDevices.clear() + _devices.value = emptyList() + } + + override fun dispose() { + stopDiscovery() + } + + @Serializable + data class DiscoveryResponse( + val deviceId: String, + val deviceName: String, + val deviceType: String, + val servicePort: Int, + val hostAddress: String + ) + + companion object { + const val TCP_PORT = 9998 + const val UDP_PORT = 9999 + } +} diff --git a/app/src/commonMain/kotlin/com/aerotoss/ui/components/DeviceCard.kt b/app/src/commonMain/kotlin/com/aerotoss/ui/components/DeviceCard.kt index 75896f7..42fb18b 100644 --- a/app/src/commonMain/kotlin/com/aerotoss/ui/components/DeviceCard.kt +++ b/app/src/commonMain/kotlin/com/aerotoss/ui/components/DeviceCard.kt @@ -26,6 +26,11 @@ fun DeviceCard( text = device.name, style = MaterialTheme.typography.titleMedium ) + Text( + text = "${device.hostAddress}:${device.port}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) Text( text = device.type.name, style = MaterialTheme.typography.bodySmall, diff --git a/app/src/desktopMain/kotlin/com/aerotoss/Main.kt b/app/src/desktopMain/kotlin/com/aerotoss/Main.kt index 7798a5b..9ff656b 100644 --- a/app/src/desktopMain/kotlin/com/aerotoss/Main.kt +++ b/app/src/desktopMain/kotlin/com/aerotoss/Main.kt @@ -4,13 +4,13 @@ import androidx.compose.runtime.* import androidx.compose.ui.window.Window import androidx.compose.ui.window.application import com.aerotoss.core.AeroTossManager -import com.aerotoss.discovery.JmdnsDiscovery +import com.aerotoss.discovery.TcpDiscovery import com.aerotoss.transfer.DesktopFileTransferManager import com.aerotoss.ui.App fun main() = application { val transferManager = remember { DesktopFileTransferManager() } - val discoveryManager = remember { JmdnsDiscovery() } + val discoveryManager = remember { TcpDiscovery() } val manager = remember { AeroTossManager(discoveryManager, transferManager) } LaunchedEffect(Unit) {