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 package com.aerotoss
import android.Manifest
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.aerotoss.core.AeroTossManager import com.aerotoss.core.AeroTossManager
import com.aerotoss.discovery.AndroidNsdDiscovery
import com.aerotoss.discovery.CompositeAndroidDiscovery import com.aerotoss.discovery.CompositeAndroidDiscovery
import com.aerotoss.transfer.AndroidFileTransferManager import com.aerotoss.transfer.AndroidFileTransferManager
import com.aerotoss.ui.App import com.aerotoss.ui.App
@@ -20,73 +14,24 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private var manager: AeroTossManager? = null 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?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) 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 if (manager != null) return
val transferManager = AndroidFileTransferManager(applicationContext) val transferManager = AndroidFileTransferManager(applicationContext)
val nsdDiscovery = AndroidNsdDiscovery(applicationContext)
val wifiDirectManager = WifiDirectManager(applicationContext) val wifiDirectManager = WifiDirectManager(applicationContext)
val discoveryManager = CompositeAndroidDiscovery(nsdDiscovery, wifiDirectManager) val discoveryManager = CompositeAndroidDiscovery(wifiDirectManager)
val mgr = AeroTossManager(discoveryManager, transferManager) val mgr = AeroTossManager(discoveryManager, transferManager)
manager = mgr manager = mgr
lifecycleScope.launch { lifecycleScope.launch {
val port = withContext(Dispatchers.IO) { val port = withContext(Dispatchers.IO) { transferManager.startServer(0) }
transferManager.startServer(0)
}
mgr.start(port) mgr.start(port)
} }
setContent { setContent { App(mgr) }
App(mgr)
}
} }
override fun onDestroy() { override fun onDestroy() {
@@ -1,6 +1,5 @@
package com.aerotoss.discovery package com.aerotoss.discovery
import android.content.Context
import com.aerotoss.model.Device import com.aerotoss.model.Device
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -8,17 +7,17 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import com.aerotoss.model.DeviceType
import com.aerotoss.wifi.WifiDirectManager import com.aerotoss.wifi.WifiDirectManager
class CompositeAndroidDiscovery( class CompositeAndroidDiscovery(
private val nsdDiscovery: AndroidNsdDiscovery,
private val wifiDirectManager: WifiDirectManager private val wifiDirectManager: WifiDirectManager
) : DiscoveryManager { ) : DiscoveryManager {
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 tcpDiscovery = TcpDiscovery() private val tcpDiscovery = TcpDiscovery(DeviceType.PHONE)
@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
@@ -36,19 +35,16 @@ class CompositeAndroidDiscovery(
tcpDiscovery.devices, tcpDiscovery.devices,
wifiDirectManager.devices wifiDirectManager.devices
) { tcpDevices, wifiDirectDevices -> ) { tcpDevices, wifiDirectDevices ->
val allDevices = mutableMapOf<String, Device>() val all = mutableMapOf<String, Device>()
tcpDevices.forEach { allDevices["tcp:${it.id}"] = it } tcpDevices.forEach { all["tcp:${it.id}"] = it }
wifiDirectDevices.forEach { allDevices["wifi:${it.id}"] = it } wifiDirectDevices.forEach { all["wifi:${it.id}"] = it }
allDevices.values.toList() all.values.toList()
}.collect { devices -> }.collect { _devices.value = it }
_devices.value = devices
}
} }
} }
override fun stopDiscovery() { override fun stopDiscovery() {
discoveryJob?.cancel() discoveryJob?.cancel(); discoveryJob = null
discoveryJob = null
tcpDiscovery.stopDiscovery() tcpDiscovery.stopDiscovery()
wifiDirectManager.stopDiscovery() wifiDirectManager.stopDiscovery()
_devices.value = emptyList() _devices.value = emptyList()
@@ -56,8 +52,7 @@ class CompositeAndroidDiscovery(
override fun dispose() { override fun dispose() {
stopDiscovery() stopDiscovery()
scope?.cancel() scope?.cancel(); scope = null
scope = null
tcpDiscovery.dispose() tcpDiscovery.dispose()
wifiDirectManager.cleanup() wifiDirectManager.cleanup()
} }
@@ -18,7 +18,10 @@ import java.net.ServerSocket
import java.net.Socket import java.net.Socket
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
class TcpDiscovery : DiscoveryManager { class TcpDiscovery(
private val myDeviceType: DeviceType = DeviceType.DESKTOP
) : DiscoveryManager {
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()
@@ -34,17 +37,16 @@ class TcpDiscovery : DiscoveryManager {
override fun startDiscovery(servicePort: Int) { override fun startDiscovery(servicePort: Int) {
if (serverThread != null) return if (serverThread != null) return
// TCP server for incoming discovery requests // TCP server
serverThread = Thread { serverThread = Thread {
try { try {
val ss = ServerSocket(TCP_PORT, 50, InetAddress.getByName("0.0.0.0")) val ss = ServerSocket(TCP_PORT, 50, InetAddress.getByName("0.0.0.0"))
serverSocket = ss 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) { while (!Thread.currentThread().isInterrupted) {
try { try {
val client = ss.accept() val client = ss.accept()
Thread { handleDiscoveryRequest(client, servicePort) }.start() Thread { handleRequest(client, servicePort) }.start()
} catch (_: Exception) { } catch (_: Exception) {
if (Thread.currentThread().isInterrupted) break if (Thread.currentThread().isInterrupted) break
} }
@@ -53,135 +55,114 @@ class TcpDiscovery : DiscoveryManager {
} catch (e: Exception) { } catch (e: Exception) {
if (!Thread.currentThread().isInterrupted) println("AeroToss: Server error: ${e.message}") 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 { discoveryThread = Thread {
try { try {
val socket = DatagramSocket(UDP_PORT, InetAddress.getByName("0.0.0.0")) val socket = DatagramSocket(UDP_PORT, InetAddress.getByName("0.0.0.0"))
socket.broadcast = true socket.broadcast = true
socket.soTimeout = 2000 socket.soTimeout = 2000
udpSocket = socket udpSocket = socket
var lastBroadcast = System.currentTimeMillis() var lastBroadcast = System.currentTimeMillis()
while (!Thread.currentThread().isInterrupted) { while (!Thread.currentThread().isInterrupted) {
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
if (now - lastBroadcast > 3_000) { if (now - lastBroadcast > 3_000) {
sendBroadcast(socket, servicePort) broadcast(socket, servicePort)
lastBroadcast = now lastBroadcast = now
} }
val buf = ByteArray(1024) val buf = ByteArray(1024)
try { try {
val packet = DatagramPacket(buf, buf.size) val pkt = DatagramPacket(buf, buf.size)
socket.receive(packet) socket.receive(pkt)
processBroadcast(packet, servicePort) onBroadcastReceived(pkt, servicePort)
} catch (_: java.net.SocketTimeoutException) {} } catch (_: java.net.SocketTimeoutException) {}
} }
socket.close() socket.close()
} catch (e: Exception) { } catch (e: Exception) {
if (!Thread.currentThread().isInterrupted) println("AeroToss: UDP error: ${e.message}") 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 { try {
client.use { sock -> client.use { sock ->
sock.soTimeout = 5000 sock.soTimeout = 5000
val input = sock.getInputStream().bufferedReader() val reader = sock.getInputStream().bufferedReader()
val output = sock.getOutputStream().bufferedWriter() val writer = sock.getOutputStream().bufferedWriter()
val line = reader.readLine() ?: return
val request = input.readLine() ?: return if (line == "DISCOVER") {
if (request == "DISCOVER") { val resp = DiscoveryResponse(
val response = DiscoveryResponse( id = deviceId,
deviceId = deviceId, name = deviceName,
deviceName = deviceName, type = myDeviceType.name.lowercase(),
deviceType = "desktop", port = servicePort,
servicePort = servicePort, ip = findMyIp()
hostAddress = findNetworkAddress()
) )
output.write(json.encodeToString(response)) writer.write(json.encodeToString(resp))
output.newLine() writer.newLine()
output.flush() writer.flush()
} }
} }
} catch (_: Exception) {} } catch (_: Exception) {}
} }
private fun sendBroadcast(socket: DatagramSocket, servicePort: Int) { private fun broadcast(socket: DatagramSocket, servicePort: Int) {
try { try {
val message = "AEROTOSS:$TCP_PORT:$servicePort:$deviceId:$deviceName" val msg = "AEROTOSS:$TCP_PORT:$servicePort:$deviceId:$deviceName"
val data = message.toByteArray(Charsets.UTF_8) val pkt = DatagramPacket(msg.toByteArray(Charsets.UTF_8), msg.length, InetAddress.getByName("255.255.255.255"), UDP_PORT)
val packet = DatagramPacket(data, data.size, InetAddress.getByName("255.255.255.255"), UDP_PORT) socket.send(pkt)
socket.send(packet)
} catch (_: Exception) {} } catch (_: Exception) {}
} }
private fun processBroadcast(packet: DatagramPacket, myPort: Int) { private fun onBroadcastReceived(pkt: DatagramPacket, myServicePort: Int) {
try { try {
val message = String(packet.data, 0, packet.length, Charsets.UTF_8) val msg = String(pkt.data, 0, pkt.length, Charsets.UTF_8)
if (!message.startsWith("AEROTOSS:")) return if (!msg.startsWith("AEROTOSS:")) return
val parts = msg.split(":", limit = 5)
val parts = message.split(":", limit = 5)
if (parts.size < 5) return if (parts.size < 5) return
val remoteTcpPort = parts[1].toIntOrNull() ?: return val remoteTcpPort = parts[1].toIntOrNull() ?: return
val remoteServicePort = parts[2].toIntOrNull() ?: return val remoteServicePort = parts[2].toIntOrNull() ?: return
val remoteId = parts[3] val remoteId = parts[3]
val remoteName = parts[4] val remoteName = parts[4]
if (remoteId == deviceId) return if (remoteId == deviceId) return
if (remoteServicePort == myPort) return if (remoteServicePort == myServicePort) return
val hostAddress = packet.address.hostAddress ?: return
val device = queryDevice(hostAddress, remoteTcpPort, remoteId, remoteName, remoteServicePort)
val host = pkt.address.hostAddress ?: return
val device = queryRemote(host, remoteTcpPort)
if (device != null) { if (device != null) {
discoveredDevices[device.id] = device discoveredDevices[device.id] = device
_devices.value = discoveredDevices.values.toList() _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) {} } 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 { return try {
val socket = Socket() Socket().use { sock ->
socket.connect(java.net.InetSocketAddress(host, tcpPort), 3000) sock.connect(java.net.InetSocketAddress(host, tcpPort), 3000)
socket.use { sock ->
sock.soTimeout = 3000 sock.soTimeout = 3000
val output = sock.getOutputStream().bufferedWriter() val writer = sock.getOutputStream().bufferedWriter()
val input = sock.getInputStream().bufferedReader() val reader = sock.getInputStream().bufferedReader()
writer.write("DISCOVER")
output.write("DISCOVER") writer.newLine()
output.newLine() writer.flush()
output.flush() val line = reader.readLine() ?: return null
val resp = json.decodeFromString<DiscoveryResponse>(line)
val responseLine = input.readLine() ?: return null
val response = json.decodeFromString<DiscoveryResponse>(responseLine)
Device( Device(
id = response.deviceId, id = resp.id,
name = response.deviceName, name = resp.name,
type = if (response.deviceType == "desktop") DeviceType.DESKTOP else DeviceType.PHONE, type = if (resp.type == "desktop") DeviceType.DESKTOP else DeviceType.PHONE,
hostAddress = host, hostAddress = resp.ip,
port = response.servicePort port = resp.port
) )
} }
} catch (_: Exception) { } catch (_: Exception) { null }
Device(
id = fallbackId,
name = fallbackName,
type = DeviceType.PHONE,
hostAddress = host,
port = fallbackPort
)
}
} }
private fun findNetworkAddress(): String { private fun findMyIp(): String {
return try { return try {
NetworkInterface.getNetworkInterfaces()?.toList() NetworkInterface.getNetworkInterfaces()?.toList()
?.filter { it.isUp && !it.isLoopback } ?.filter { it.isUp && !it.isLoopback }
@@ -193,36 +174,31 @@ class TcpDiscovery : DiscoveryManager {
} }
private fun isPrivate(addr: java.net.InetAddress): Boolean { private fun isPrivate(addr: java.net.InetAddress): Boolean {
val bytes = addr.address val b = addr.address
return (bytes[0].toInt() and 0xFF == 192 && bytes[1].toInt() and 0xFF == 168) || return (b[0].toInt() and 0xFF == 192 && b[1].toInt() and 0xFF == 168) ||
(bytes[0].toInt() and 0xFF == 10) || (b[0].toInt() and 0xFF == 10) ||
(bytes[0].toInt() and 0xFF == 172 && (bytes[1].toInt() and 0xFF in 16..31)) (b[0].toInt() and 0xFF == 172 && b[1].toInt() and 0xFF in 16..31)
} }
override fun stopDiscovery() { override fun stopDiscovery() {
serverThread?.interrupt() serverThread?.interrupt(); serverThread = null
serverThread = null discoveryThread?.interrupt(); discoveryThread = null
discoveryThread?.interrupt()
discoveryThread = null
try { serverSocket?.close() } catch (_: Exception) {} try { serverSocket?.close() } catch (_: Exception) {}
try { udpSocket?.close() } catch (_: Exception) {} try { udpSocket?.close() } catch (_: Exception) {}
serverSocket = null serverSocket = null; udpSocket = null
udpSocket = null
discoveredDevices.clear() discoveredDevices.clear()
_devices.value = emptyList() _devices.value = emptyList()
} }
override fun dispose() { override fun dispose() { stopDiscovery() }
stopDiscovery()
}
@Serializable @Serializable
data class DiscoveryResponse( data class DiscoveryResponse(
val deviceId: String, val id: String,
val deviceName: String, val name: String,
val deviceType: String, val type: String,
val servicePort: Int, val port: Int,
val hostAddress: String val ip: String
) )
companion object { companion object {
@@ -5,12 +5,13 @@ 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.TcpDiscovery import com.aerotoss.discovery.TcpDiscovery
import com.aerotoss.model.DeviceType
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 { TcpDiscovery() } val discoveryManager = remember { TcpDiscovery(DeviceType.DESKTOP) }
val manager = remember { AeroTossManager(discoveryManager, transferManager) } val manager = remember { AeroTossManager(discoveryManager, transferManager) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
@@ -19,10 +20,7 @@ fun main() = application {
} }
Window( Window(
onCloseRequest = { onCloseRequest = { manager.dispose(); exitApplication() },
manager.dispose()
exitApplication()
},
title = "AeroToss" title = "AeroToss"
) { ) {
App(manager) App(manager)