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:
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user