AeroToss: TCP discovery вместо mDNS + IP в UI

- Заменён mDNS на TCP discovery (надёжнее, работает через NAT)
- Desktop: TCP сервер на порту 9998 + UDP broadcast на порту 9999
- Phone: TCP клиент подключается к desktop для получения инфы
- Protocol: DISCOVER → JSON response (deviceId, name, type, port, host)
- DeviceCard: отображает IP адрес и порт под именем
- Убрана зависимость от mDNS (может быть добавлена позже как fallback)
This commit is contained in:
2026-08-19 23:09:29 +03:00
parent 6eeda61ad3
commit 2ba023b867
4 changed files with 242 additions and 14 deletions
@@ -18,7 +18,7 @@ class CompositeAndroidDiscovery(
private val _devices = MutableStateFlow<List<Device>>(emptyList()) private val _devices = MutableStateFlow<List<Device>>(emptyList())
override val devices: Flow<List<Device>> = _devices.asStateFlow() override val devices: Flow<List<Device>> = _devices.asStateFlow()
private val udpDiscovery = UdpBroadcastDiscovery() private val tcpDiscovery = TcpDiscovery()
@Volatile private var scope: CoroutineScope? = null @Volatile private var scope: CoroutineScope? = null
@Volatile private var discoveryJob: kotlinx.coroutines.Job? = null @Volatile private var discoveryJob: kotlinx.coroutines.Job? = null
@@ -26,21 +26,18 @@ class CompositeAndroidDiscovery(
override fun startDiscovery(servicePort: Int) { override fun startDiscovery(servicePort: Int) {
if (discoveryJob != null) return if (discoveryJob != null) return
nsdDiscovery.startDiscovery(servicePort) tcpDiscovery.startDiscovery(servicePort)
udpDiscovery.startDiscovery(servicePort)
wifiDirectManager.initialize() wifiDirectManager.initialize()
wifiDirectManager.discoverPeers() wifiDirectManager.discoverPeers()
scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
discoveryJob = scope?.launch { discoveryJob = scope?.launch {
combine( combine(
nsdDiscovery.devices, tcpDiscovery.devices,
udpDiscovery.devices,
wifiDirectManager.devices wifiDirectManager.devices
) { nsdDevices, udpDevices, wifiDirectDevices -> ) { tcpDevices, wifiDirectDevices ->
val allDevices = mutableMapOf<String, Device>() val allDevices = mutableMapOf<String, Device>()
nsdDevices.forEach { allDevices["nsd:${it.id}"] = it } tcpDevices.forEach { allDevices["tcp:${it.id}"] = it }
udpDevices.forEach { allDevices["udp:${it.id}"] = it }
wifiDirectDevices.forEach { allDevices["wifi:${it.id}"] = it } wifiDirectDevices.forEach { allDevices["wifi:${it.id}"] = it }
allDevices.values.toList() allDevices.values.toList()
}.collect { devices -> }.collect { devices ->
@@ -52,8 +49,7 @@ class CompositeAndroidDiscovery(
override fun stopDiscovery() { override fun stopDiscovery() {
discoveryJob?.cancel() discoveryJob?.cancel()
discoveryJob = null discoveryJob = null
nsdDiscovery.stopDiscovery() tcpDiscovery.stopDiscovery()
udpDiscovery.stopDiscovery()
wifiDirectManager.stopDiscovery() wifiDirectManager.stopDiscovery()
_devices.value = emptyList() _devices.value = emptyList()
} }
@@ -62,8 +58,7 @@ class CompositeAndroidDiscovery(
stopDiscovery() stopDiscovery()
scope?.cancel() scope?.cancel()
scope = null scope = null
nsdDiscovery.dispose() tcpDiscovery.dispose()
udpDiscovery.dispose()
wifiDirectManager.cleanup() wifiDirectManager.cleanup()
} }
@@ -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<List<Device>>(emptyList())
override val devices: Flow<List<Device>> = _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<String, Device>()
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<DiscoveryResponse>(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
}
}
@@ -26,6 +26,11 @@ fun DeviceCard(
text = device.name, text = device.name,
style = MaterialTheme.typography.titleMedium style = MaterialTheme.typography.titleMedium
) )
Text(
text = "${device.hostAddress}:${device.port}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text( Text(
text = device.type.name, text = device.type.name,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
@@ -4,13 +4,13 @@ import androidx.compose.runtime.*
import androidx.compose.ui.window.Window import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application import androidx.compose.ui.window.application
import com.aerotoss.core.AeroTossManager import com.aerotoss.core.AeroTossManager
import com.aerotoss.discovery.JmdnsDiscovery import com.aerotoss.discovery.TcpDiscovery
import com.aerotoss.transfer.DesktopFileTransferManager import com.aerotoss.transfer.DesktopFileTransferManager
import com.aerotoss.ui.App import com.aerotoss.ui.App
fun main() = application { fun main() = application {
val transferManager = remember { DesktopFileTransferManager() } val transferManager = remember { DesktopFileTransferManager() }
val discoveryManager = remember { JmdnsDiscovery() } val discoveryManager = remember { TcpDiscovery() }
val manager = remember { AeroTossManager(discoveryManager, transferManager) } val manager = remember { AeroTossManager(discoveryManager, transferManager) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {