AeroToss: полный рефакторинг discovery

- TcpDiscovery принимает DeviceType в конструкторе (PHONE/DESKTOP)
- Desktop: type=DESKTOP, Phone: type=PHONE (больше не захардкожено)
- Удалён NSD discovery из Android (только TCP + Wi-Fi Direct)
- CompositeAndroidDiscovery больше не использует AndroidNsdDiscovery
- Удалён UdpBroadcastDiscovery (дублировал TcpDiscovery)
- deviceType теперь корректно передаётся в DiscoveryResponse
- hostAddress теперь всегда содержит реальный IP
This commit is contained in:
2026-08-19 23:24:01 +03:00
parent 2800f862b7
commit 827d431311
4 changed files with 85 additions and 171 deletions
@@ -1,16 +1,10 @@
package com.aerotoss
import android.Manifest
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import androidx.lifecycle.lifecycleScope
import com.aerotoss.core.AeroTossManager
import com.aerotoss.discovery.AndroidNsdDiscovery
import com.aerotoss.discovery.CompositeAndroidDiscovery
import com.aerotoss.transfer.AndroidFileTransferManager
import com.aerotoss.ui.App
@@ -20,73 +14,24 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class MainActivity : ComponentActivity() {
private var manager: AeroTossManager? = null
private val requiredPermissions: Array<String>
get() {
val perms = mutableListOf(
Manifest.permission.INTERNET,
Manifest.permission.ACCESS_WIFI_STATE,
Manifest.permission.CHANGE_WIFI_STATE,
Manifest.permission.ACCESS_NETWORK_STATE,
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION,
Manifest.permission.CHANGE_WIFI_MULTICAST_STATE,
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
perms.add(Manifest.permission.NEARBY_WIFI_DEVICES)
}
return perms.toTypedArray()
}
private val permissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { permissions ->
val allGranted = permissions.values.all { it }
if (allGranted && manager == null) {
initializeApp()
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (manager != null) return
if (hasAllPermissions()) {
initializeApp()
} else {
permissionLauncher.launch(requiredPermissions)
}
}
private fun hasAllPermissions(): Boolean {
return requiredPermissions.all {
ContextCompat.checkSelfPermission(this, it) == PackageManager.PERMISSION_GRANTED
}
}
private fun initializeApp() {
if (manager != null) return
val transferManager = AndroidFileTransferManager(applicationContext)
val nsdDiscovery = AndroidNsdDiscovery(applicationContext)
val wifiDirectManager = WifiDirectManager(applicationContext)
val discoveryManager = CompositeAndroidDiscovery(nsdDiscovery, wifiDirectManager)
val discoveryManager = CompositeAndroidDiscovery(wifiDirectManager)
val mgr = AeroTossManager(discoveryManager, transferManager)
manager = mgr
lifecycleScope.launch {
val port = withContext(Dispatchers.IO) {
transferManager.startServer(0)
}
val port = withContext(Dispatchers.IO) { transferManager.startServer(0) }
mgr.start(port)
}
setContent {
App(mgr)
}
setContent { App(mgr) }
}
override fun onDestroy() {
@@ -1,6 +1,5 @@
package com.aerotoss.discovery
import android.content.Context
import com.aerotoss.model.Device
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -8,17 +7,17 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import com.aerotoss.model.DeviceType
import com.aerotoss.wifi.WifiDirectManager
class CompositeAndroidDiscovery(
private val nsdDiscovery: AndroidNsdDiscovery,
private val wifiDirectManager: WifiDirectManager
) : DiscoveryManager {
private val _devices = MutableStateFlow<List<Device>>(emptyList())
override val devices: Flow<List<Device>> = _devices.asStateFlow()
private val tcpDiscovery = TcpDiscovery()
private val tcpDiscovery = TcpDiscovery(DeviceType.PHONE)
@Volatile private var scope: CoroutineScope? = null
@Volatile private var discoveryJob: kotlinx.coroutines.Job? = null
@@ -36,19 +35,16 @@ class CompositeAndroidDiscovery(
tcpDiscovery.devices,
wifiDirectManager.devices
) { tcpDevices, wifiDirectDevices ->
val allDevices = mutableMapOf<String, Device>()
tcpDevices.forEach { allDevices["tcp:${it.id}"] = it }
wifiDirectDevices.forEach { allDevices["wifi:${it.id}"] = it }
allDevices.values.toList()
}.collect { devices ->
_devices.value = devices
}
val all = mutableMapOf<String, Device>()
tcpDevices.forEach { all["tcp:${it.id}"] = it }
wifiDirectDevices.forEach { all["wifi:${it.id}"] = it }
all.values.toList()
}.collect { _devices.value = it }
}
}
override fun stopDiscovery() {
discoveryJob?.cancel()
discoveryJob = null
discoveryJob?.cancel(); discoveryJob = null
tcpDiscovery.stopDiscovery()
wifiDirectManager.stopDiscovery()
_devices.value = emptyList()
@@ -56,8 +52,7 @@ class CompositeAndroidDiscovery(
override fun dispose() {
stopDiscovery()
scope?.cancel()
scope = null
scope?.cancel(); scope = null
tcpDiscovery.dispose()
wifiDirectManager.cleanup()
}
@@ -18,7 +18,10 @@ import java.net.ServerSocket
import java.net.Socket
import java.util.concurrent.ConcurrentHashMap
class TcpDiscovery : DiscoveryManager {
class TcpDiscovery(
private val myDeviceType: DeviceType = DeviceType.DESKTOP
) : DiscoveryManager {
private val _devices = MutableStateFlow<List<Device>>(emptyList())
override val devices: Flow<List<Device>> = _devices.asStateFlow()
@@ -34,17 +37,16 @@ class TcpDiscovery : DiscoveryManager {
override fun startDiscovery(servicePort: Int) {
if (serverThread != null) return
// TCP server for incoming discovery requests
// TCP server
serverThread = Thread {
try {
val ss = ServerSocket(TCP_PORT, 50, InetAddress.getByName("0.0.0.0"))
serverSocket = ss
println("AeroToss: TCP server on port $TCP_PORT, service on $servicePort")
println("AeroToss: TCP=$TCP_PORT, service=$servicePort, type=$myDeviceType, name=$deviceName")
while (!Thread.currentThread().isInterrupted) {
try {
val client = ss.accept()
Thread { handleDiscoveryRequest(client, servicePort) }.start()
Thread { handleRequest(client, servicePort) }.start()
} catch (_: Exception) {
if (Thread.currentThread().isInterrupted) break
}
@@ -53,135 +55,114 @@ class TcpDiscovery : DiscoveryManager {
} catch (e: Exception) {
if (!Thread.currentThread().isInterrupted) println("AeroToss: Server error: ${e.message}")
}
}.apply { isDaemon = true; name = "aerotoss-server"; start() }
}.apply { isDaemon = true; name = "aerotoss-tcp"; start() }
// UDP broadcast: send AND receive on same socket
// UDP broadcast
discoveryThread = Thread {
try {
val socket = DatagramSocket(UDP_PORT, InetAddress.getByName("0.0.0.0"))
socket.broadcast = true
socket.soTimeout = 2000
udpSocket = socket
var lastBroadcast = System.currentTimeMillis()
while (!Thread.currentThread().isInterrupted) {
val now = System.currentTimeMillis()
if (now - lastBroadcast > 3_000) {
sendBroadcast(socket, servicePort)
broadcast(socket, servicePort)
lastBroadcast = now
}
val buf = ByteArray(1024)
try {
val packet = DatagramPacket(buf, buf.size)
socket.receive(packet)
processBroadcast(packet, servicePort)
val pkt = DatagramPacket(buf, buf.size)
socket.receive(pkt)
onBroadcastReceived(pkt, servicePort)
} catch (_: java.net.SocketTimeoutException) {}
}
socket.close()
} catch (e: Exception) {
if (!Thread.currentThread().isInterrupted) println("AeroToss: UDP error: ${e.message}")
}
}.apply { isDaemon = true; name = "aerotoss-broadcast"; start() }
}.apply { isDaemon = true; name = "aerotoss-udp"; start() }
}
private fun handleDiscoveryRequest(client: Socket, servicePort: Int) {
private fun handleRequest(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 = findNetworkAddress()
val reader = sock.getInputStream().bufferedReader()
val writer = sock.getOutputStream().bufferedWriter()
val line = reader.readLine() ?: return
if (line == "DISCOVER") {
val resp = DiscoveryResponse(
id = deviceId,
name = deviceName,
type = myDeviceType.name.lowercase(),
port = servicePort,
ip = findMyIp()
)
output.write(json.encodeToString(response))
output.newLine()
output.flush()
writer.write(json.encodeToString(resp))
writer.newLine()
writer.flush()
}
}
} catch (_: Exception) {}
}
private fun sendBroadcast(socket: DatagramSocket, servicePort: Int) {
private fun broadcast(socket: DatagramSocket, servicePort: Int) {
try {
val message = "AEROTOSS:$TCP_PORT:$servicePort:$deviceId:$deviceName"
val data = message.toByteArray(Charsets.UTF_8)
val packet = DatagramPacket(data, data.size, InetAddress.getByName("255.255.255.255"), UDP_PORT)
socket.send(packet)
val msg = "AEROTOSS:$TCP_PORT:$servicePort:$deviceId:$deviceName"
val pkt = DatagramPacket(msg.toByteArray(Charsets.UTF_8), msg.length, InetAddress.getByName("255.255.255.255"), UDP_PORT)
socket.send(pkt)
} catch (_: Exception) {}
}
private fun processBroadcast(packet: DatagramPacket, myPort: Int) {
private fun onBroadcastReceived(pkt: DatagramPacket, myServicePort: Int) {
try {
val message = String(packet.data, 0, packet.length, Charsets.UTF_8)
if (!message.startsWith("AEROTOSS:")) return
val parts = message.split(":", limit = 5)
val msg = String(pkt.data, 0, pkt.length, Charsets.UTF_8)
if (!msg.startsWith("AEROTOSS:")) return
val parts = msg.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
val hostAddress = packet.address.hostAddress ?: return
val device = queryDevice(hostAddress, remoteTcpPort, remoteId, remoteName, remoteServicePort)
if (remoteServicePort == myServicePort) return
val host = pkt.address.hostAddress ?: return
val device = queryRemote(host, remoteTcpPort)
if (device != null) {
discoveredDevices[device.id] = device
_devices.value = discoveredDevices.values.toList()
println("AeroToss: Found ${device.name} at ${device.hostAddress}:${device.port}")
println("AeroToss: Found ${device.name} [${device.type}] at ${device.hostAddress}:${device.port}")
}
} catch (_: Exception) {}
}
private fun queryDevice(host: String, tcpPort: Int, fallbackId: String, fallbackName: String, fallbackPort: Int): Device? {
private fun queryRemote(host: String, tcpPort: Int): Device? {
return try {
val socket = Socket()
socket.connect(java.net.InetSocketAddress(host, tcpPort), 3000)
socket.use { sock ->
Socket().use { sock ->
sock.connect(java.net.InetSocketAddress(host, tcpPort), 3000)
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)
val writer = sock.getOutputStream().bufferedWriter()
val reader = sock.getInputStream().bufferedReader()
writer.write("DISCOVER")
writer.newLine()
writer.flush()
val line = reader.readLine() ?: return null
val resp = json.decodeFromString<DiscoveryResponse>(line)
Device(
id = response.deviceId,
name = response.deviceName,
type = if (response.deviceType == "desktop") DeviceType.DESKTOP else DeviceType.PHONE,
hostAddress = host,
port = response.servicePort
id = resp.id,
name = resp.name,
type = if (resp.type == "desktop") DeviceType.DESKTOP else DeviceType.PHONE,
hostAddress = resp.ip,
port = resp.port
)
}
} catch (_: Exception) {
Device(
id = fallbackId,
name = fallbackName,
type = DeviceType.PHONE,
hostAddress = host,
port = fallbackPort
)
}
} catch (_: Exception) { null }
}
private fun findNetworkAddress(): String {
private fun findMyIp(): String {
return try {
NetworkInterface.getNetworkInterfaces()?.toList()
?.filter { it.isUp && !it.isLoopback }
@@ -193,36 +174,31 @@ class TcpDiscovery : DiscoveryManager {
}
private fun isPrivate(addr: java.net.InetAddress): Boolean {
val bytes = addr.address
return (bytes[0].toInt() and 0xFF == 192 && bytes[1].toInt() and 0xFF == 168) ||
(bytes[0].toInt() and 0xFF == 10) ||
(bytes[0].toInt() and 0xFF == 172 && (bytes[1].toInt() and 0xFF in 16..31))
val b = addr.address
return (b[0].toInt() and 0xFF == 192 && b[1].toInt() and 0xFF == 168) ||
(b[0].toInt() and 0xFF == 10) ||
(b[0].toInt() and 0xFF == 172 && b[1].toInt() and 0xFF in 16..31)
}
override fun stopDiscovery() {
serverThread?.interrupt()
serverThread = null
discoveryThread?.interrupt()
discoveryThread = null
serverThread?.interrupt(); serverThread = null
discoveryThread?.interrupt(); discoveryThread = null
try { serverSocket?.close() } catch (_: Exception) {}
try { udpSocket?.close() } catch (_: Exception) {}
serverSocket = null
udpSocket = null
serverSocket = null; udpSocket = null
discoveredDevices.clear()
_devices.value = emptyList()
}
override fun dispose() {
stopDiscovery()
}
override fun dispose() { stopDiscovery() }
@Serializable
data class DiscoveryResponse(
val deviceId: String,
val deviceName: String,
val deviceType: String,
val servicePort: Int,
val hostAddress: String
val id: String,
val name: String,
val type: String,
val port: Int,
val ip: String
)
companion object {
@@ -5,12 +5,13 @@ import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application
import com.aerotoss.core.AeroTossManager
import com.aerotoss.discovery.TcpDiscovery
import com.aerotoss.model.DeviceType
import com.aerotoss.transfer.DesktopFileTransferManager
import com.aerotoss.ui.App
fun main() = application {
val transferManager = remember { DesktopFileTransferManager() }
val discoveryManager = remember { TcpDiscovery() }
val discoveryManager = remember { TcpDiscovery(DeviceType.DESKTOP) }
val manager = remember { AeroTossManager(discoveryManager, transferManager) }
LaunchedEffect(Unit) {
@@ -19,10 +20,7 @@ fun main() = application {
}
Window(
onCloseRequest = {
manager.dispose()
exitApplication()
},
onCloseRequest = { manager.dispose(); exitApplication() },
title = "AeroToss"
) {
App(manager)