AeroToss: быстрый discovery + file picker + передача файлов

Discovery:
- Broadcast отправляется немедленно при старте (без задержки 3 сек)
- Интервал broadcast: 3 сек → 1 сек
- Timeout сокращён до 2 сек для быстрого отклика

File picker:
- Desktop: java.awt.FileDialog для выбора файла
- Android: ActivityResultContracts.OpenDocument → копирование в temp file
- SendScreen: реальный file picker вместо тестового файла
- Выбор файла: имя + размер
This commit is contained in:
2026-08-19 23:49:41 +03:00
parent fe880cbac1
commit a1b600a614
6 changed files with 155 additions and 171 deletions
@@ -3,11 +3,14 @@ package com.aerotoss
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.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.aerotoss.core.AeroTossManager import com.aerotoss.core.AeroTossManager
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
import com.aerotoss.util.androidFilePickerLauncher
import com.aerotoss.util.handleAndroidPickResult
import com.aerotoss.wifi.WifiDirectManager import com.aerotoss.wifi.WifiDirectManager
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -16,10 +19,20 @@ import kotlinx.coroutines.withContext
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private var manager: AeroTossManager? = null private var manager: AeroTossManager? = null
private val filePickerLauncher = registerForActivityResult(
ActivityResultContracts.OpenDocument()
) { uri ->
handleAndroidPickResult(uri, applicationContext)
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
if (manager != null) return if (manager != null) return
androidFilePickerLauncher = { mimeTypes, _ ->
filePickerLauncher.launch(mimeTypes)
}
val transferManager = AndroidFileTransferManager(applicationContext) val transferManager = AndroidFileTransferManager(applicationContext)
val wifiDirectManager = WifiDirectManager(applicationContext) val wifiDirectManager = WifiDirectManager(applicationContext)
val discoveryManager = CompositeAndroidDiscovery(wifiDirectManager) val discoveryManager = CompositeAndroidDiscovery(wifiDirectManager)
@@ -0,0 +1,33 @@
package com.aerotoss.util
import android.net.Uri
import java.io.File
actual fun pickFile(onResult: (filePath: String?) -> Unit) {
androidPickFileCallback = onResult
androidFilePickerLauncher?.invoke(arrayOf("*/*"), 0) ?: run {
androidPickFileCallback = null
onResult(null)
}
}
var androidFilePickerLauncher: ((Array<String>, Int) -> Unit)? = null
var androidPickFileCallback: ((String?) -> Unit)? = null
fun handleAndroidPickResult(uri: Uri?, context: android.content.Context) {
if (uri == null) {
androidPickFileCallback?.invoke(null)
androidPickFileCallback = null
return
}
try {
val tempFile = File(context.cacheDir, "aerotoss_pick_${System.currentTimeMillis()}")
context.contentResolver.openInputStream(uri)?.use { input ->
tempFile.outputStream().use { output -> input.copyTo(output) }
}
androidPickFileCallback?.invoke(tempFile.absolutePath)
} catch (_: Exception) {
androidPickFileCallback?.invoke(null)
}
androidPickFileCallback = null
}
@@ -41,8 +41,7 @@ class TcpDiscovery(
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
val myIp = findMyIp() println("AeroToss: Server TCP=$TCP_PORT service=$servicePort type=$myDeviceType ip=${findMyIp()}")
println("AeroToss: TCP server=$TCP_PORT service=$servicePort type=$myDeviceType ip=$myIp name=$deviceName")
while (!Thread.currentThread().isInterrupted) { while (!Thread.currentThread().isInterrupted) {
try { try {
val client = ss.accept() val client = ss.accept()
@@ -53,23 +52,24 @@ class TcpDiscovery(
} }
ss.close() ss.close()
} catch (e: Exception) { } catch (e: Exception) {
if (!Thread.currentThread().isInterrupted) println("AeroToss: TCP server error: ${e.message}") if (!Thread.currentThread().isInterrupted) println("AeroToss: TCP error: ${e.message}")
} }
}.apply { isDaemon = true; name = "aerotoss-tcp"; start() } }.apply { isDaemon = true; name = "tcp"; start() }
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 = 1000
udpSocket = socket udpSocket = socket
println("AeroToss: UDP broadcast port=$UDP_PORT") // Send immediately
broadcast(socket, servicePort)
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 > 1_000) {
broadcast(socket, servicePort) broadcast(socket, servicePort)
lastBroadcast = now lastBroadcast = now
} }
@@ -77,8 +77,6 @@ class TcpDiscovery(
try { try {
val pkt = DatagramPacket(buf, buf.size) val pkt = DatagramPacket(buf, buf.size)
socket.receive(pkt) socket.receive(pkt)
val from = pkt.address.hostAddress
println("AeroToss: UDP received from $from")
onBroadcastReceived(pkt, servicePort) onBroadcastReceived(pkt, servicePort)
} catch (_: java.net.SocketTimeoutException) {} } catch (_: java.net.SocketTimeoutException) {}
} }
@@ -86,19 +84,16 @@ class TcpDiscovery(
} 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-udp"; start() } }.apply { isDaemon = true; name = "udp"; start() }
} }
private fun handleRequest(client: Socket, servicePort: Int) { private fun handleRequest(client: Socket, servicePort: Int) {
try { try {
client.use { sock -> client.use { sock ->
val from = sock.inetAddress?.hostAddress ?: "unknown" sock.soTimeout = 3000
println("AeroToss: TCP connection from $from")
sock.soTimeout = 5000
val reader = sock.getInputStream().bufferedReader() val reader = sock.getInputStream().bufferedReader()
val writer = sock.getOutputStream().bufferedWriter() val writer = sock.getOutputStream().bufferedWriter()
val line = reader.readLine() ?: return val line = reader.readLine() ?: return
println("AeroToss: TCP request: $line")
if (line == "DISCOVER") { if (line == "DISCOVER") {
val resp = DiscoveryResponse( val resp = DiscoveryResponse(
id = deviceId, id = deviceId,
@@ -107,47 +102,35 @@ class TcpDiscovery(
port = servicePort, port = servicePort,
ip = findMyIp() ip = findMyIp()
) )
val jsonStr = json.encodeToString(resp) writer.write(json.encodeToString(resp))
writer.write(jsonStr)
writer.newLine() writer.newLine()
writer.flush() writer.flush()
println("AeroToss: TCP response: $jsonStr")
} }
} }
} catch (e: Exception) { } catch (_: Exception) {}
println("AeroToss: TCP handler error: ${e.message}")
}
} }
private fun broadcast(socket: DatagramSocket, servicePort: Int) { private fun broadcast(socket: DatagramSocket, servicePort: Int) {
try { try {
val msg = "AEROTOSS:$TCP_PORT:$servicePort:$deviceId:$deviceName" val msg = "AEROTOSS:$TCP_PORT:$servicePort:$deviceId:$deviceName"
val data = msg.toByteArray(Charsets.UTF_8) val data = msg.toByteArray(Charsets.UTF_8)
val broadcastAddr = getBroadcastAddress() val addr = getBroadcastAddress()
println("AeroToss: Broadcasting to $broadcastAddr:$UDP_PORT") socket.send(DatagramPacket(data, data.size, addr, UDP_PORT))
val pkt = DatagramPacket(data, data.size, broadcastAddr, UDP_PORT) } catch (_: Exception) {}
socket.send(pkt)
} catch (e: Exception) {
println("AeroToss: Broadcast error: ${e.message}")
}
} }
private fun getBroadcastAddress(): InetAddress { private fun getBroadcastAddress(): InetAddress {
try { try {
val interfaces = NetworkInterface.getNetworkInterfaces() val ifaces = NetworkInterface.getNetworkInterfaces()
while (interfaces.hasMoreElements()) { while (ifaces.hasMoreElements()) {
val iface = interfaces.nextElement() val iface = ifaces.nextElement()
if (iface.isLoopback || !iface.isUp) continue if (iface.isLoopback || !iface.isUp) continue
for (interfaceAddress in iface.interfaceAddresses) { for (ia in iface.interfaceAddresses) {
val broadcast = interfaceAddress.broadcast val bcast = ia.broadcast
if (broadcast != null && interfaceAddress.address is java.net.Inet4Address) { if (bcast != null && ia.address is java.net.Inet4Address) return bcast
println("AeroToss: Found broadcast ${broadcast.hostAddress} on ${iface.name}")
return broadcast
}
} }
} }
} catch (_: Exception) {} } catch (_: Exception) {}
println("AeroToss: Using default broadcast 255.255.255.255")
return InetAddress.getByName("255.255.255.255") return InetAddress.getByName("255.255.255.255")
} }
@@ -165,70 +148,48 @@ class TcpDiscovery(
if (remoteServicePort == myServicePort) return if (remoteServicePort == myServicePort) return
val host = pkt.address.hostAddress ?: return val host = pkt.address.hostAddress ?: return
println("AeroToss: Broadcast from $host:$remoteTcpPort name=$remoteName")
val device = queryRemote(host, remoteTcpPort) 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} [${device.type}] at ${device.hostAddress}:${device.port}") println("AeroToss: Found ${device.name} [${device.type}] at ${device.hostAddress}:${device.port}")
} else {
println("AeroToss: Failed to query $host:$remoteTcpPort")
}
} catch (e: Exception) {
println("AeroToss: Broadcast processing error: ${e.message}")
} }
} catch (_: Exception) {}
} }
private fun queryRemote(host: String, tcpPort: Int): Device? { private fun queryRemote(host: String, tcpPort: Int): Device? {
return try { return try {
Socket().use { sock -> Socket().use { sock ->
sock.connect(java.net.InetSocketAddress(host, tcpPort), 3000) sock.connect(java.net.InetSocketAddress(host, tcpPort), 2000)
sock.soTimeout = 3000 sock.soTimeout = 2000
val writer = sock.getOutputStream().bufferedWriter() val w = sock.getOutputStream().bufferedWriter()
val reader = sock.getInputStream().bufferedReader() val r = sock.getInputStream().bufferedReader()
writer.write("DISCOVER") w.write("DISCOVER"); w.newLine(); w.flush()
writer.newLine() val line = r.readLine() ?: return null
writer.flush() val resp = json.decodeFromString<DiscoveryResponse>(line)
val line = reader.readLine() ?: return null
json.decodeFromString<DiscoveryResponse>(line).let { resp ->
Device( Device(
id = resp.id, id = resp.id, name = resp.name,
name = resp.name,
type = if (resp.type == "desktop") DeviceType.DESKTOP else DeviceType.PHONE, type = if (resp.type == "desktop") DeviceType.DESKTOP else DeviceType.PHONE,
hostAddress = resp.ip, hostAddress = resp.ip, port = resp.port
port = resp.port
) )
} }
} } catch (_: Exception) { null }
} catch (e: Exception) {
println("AeroToss: queryRemote $host:$tcpPort failed: ${e.message}")
null
}
} }
private fun findMyIp(): String { private fun findMyIp(): String {
try { try {
val interfaces = NetworkInterface.getNetworkInterfaces() val ifaces = NetworkInterface.getNetworkInterfaces()
while (interfaces.hasMoreElements()) { while (ifaces.hasMoreElements()) {
val iface = interfaces.nextElement() val iface = ifaces.nextElement()
if (iface.isLoopback || !iface.isUp) continue if (iface.isLoopback || !iface.isUp) continue
for (addr in iface.inetAddresses) { for (addr in iface.inetAddresses) {
if (addr is java.net.Inet4Address && isPrivate(addr)) { if (addr is java.net.Inet4Address && !addr.isLoopbackAddress) return addr.hostAddress
return addr.hostAddress
}
} }
} }
} catch (_: Exception) {} } catch (_: Exception) {}
return "127.0.0.1" return "127.0.0.1"
} }
private fun isPrivate(addr: java.net.InetAddress): Boolean {
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() { override fun stopDiscovery() {
serverThread?.interrupt(); serverThread = null serverThread?.interrupt(); serverThread = null
discoveryThread?.interrupt(); discoveryThread = null discoveryThread?.interrupt(); discoveryThread = null
@@ -242,13 +203,7 @@ class TcpDiscovery(
override fun dispose() { stopDiscovery() } override fun dispose() { stopDiscovery() }
@Serializable @Serializable
data class DiscoveryResponse( data class DiscoveryResponse(val id: String, val name: String, val type: String, val port: Int, val ip: String)
val id: String,
val name: String,
val type: String,
val port: Int,
val ip: String
)
companion object { companion object {
const val TCP_PORT = 9998 const val TCP_PORT = 9998
@@ -12,7 +12,9 @@ import com.aerotoss.core.AeroTossManager
import com.aerotoss.model.Device import com.aerotoss.model.Device
import com.aerotoss.model.TransferState import com.aerotoss.model.TransferState
import com.aerotoss.ui.components.TransferProgressBar import com.aerotoss.ui.components.TransferProgressBar
import com.aerotoss.util.pickFile
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.File
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -23,10 +25,8 @@ fun SendScreen(
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val outgoingTransfer by manager.outgoingTransfers.collectAsState(initial = null) val outgoingTransfer by manager.outgoingTransfers.collectAsState(initial = null)
var selectedFilePath by remember { mutableStateOf<String?>(null) } var selectedFile by remember { mutableStateOf<File?>(null) }
var selectedFileName by remember { mutableStateOf<String?>(null) }
var isSending by remember { mutableStateOf(false) } var isSending by remember { mutableStateOf(false) }
var showFilePicker by remember { mutableStateOf(false) }
Scaffold( Scaffold(
topBar = { topBar = {
@@ -48,106 +48,58 @@ fun SendScreen(
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
if (targetDevice != null) { if (targetDevice != null) {
Text( Text("Получатель: ${targetDevice.name}", style = MaterialTheme.typography.titleMedium)
"Получатель: ${targetDevice.name}", Text("${targetDevice.hostAddress}:${targetDevice.port}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
style = MaterialTheme.typography.titleMedium
)
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
} }
if (selectedFilePath == null) { if (selectedFile == null) {
Text( Text("Выберите файл для отправки", style = MaterialTheme.typography.bodyLarge)
"Выберите файл для отправки",
style = MaterialTheme.typography.bodyLarge
)
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
Button(onClick = { pickFile { path -> if (path != null) selectedFile = File(path) } }) {
Button(onClick = { showFilePicker = true }) {
Text("Выбрать файл") Text("Выбрать файл")
} }
if (showFilePicker) {
Spacer(modifier = Modifier.height(8.dp))
Text(
"Выберите файл через системный диалог",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedButton(onClick = {
selectedFilePath = "/tmp/manual_file.txt"
selectedFileName = "manual_file.txt"
showFilePicker = false
}) {
Text("Загрузить тестовый файл")
}
}
} else { } else {
Card( Card(modifier = Modifier.fillMaxWidth()) {
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier.padding(16.dp)) { Column(modifier = Modifier.padding(16.dp)) {
Text( Text("Файл: ${selectedFile?.name}", style = MaterialTheme.typography.bodyLarge)
"Файл: ${selectedFileName}", Text("Размер: ${formatSize(selectedFile?.length() ?: 0)}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
style = MaterialTheme.typography.bodyLarge
)
} }
} }
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
if (outgoingTransfer != null && outgoingTransfer?.state == TransferState.TRANSFERRING) { when {
outgoingTransfer?.state == TransferState.TRANSFERRING -> {
outgoingTransfer?.let { TransferProgressBar(it) } outgoingTransfer?.let { TransferProgressBar(it) }
} else if (outgoingTransfer?.state == TransferState.COMPLETED) {
Text(
"Файл успешно отправлен!",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedButton(onClick = {
selectedFilePath = null
selectedFileName = null
}) {
Text("Отправить ещё")
} }
} else if (outgoingTransfer?.state == TransferState.FAILED) { outgoingTransfer?.state == TransferState.COMPLETED -> {
Text( Text("Отправлено!", style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.primary)
"Ошибка отправки",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.error
)
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
OutlinedButton(onClick = { OutlinedButton(onClick = { selectedFile = null }) { Text("Ещё") }
selectedFilePath = null
selectedFileName = null
}) {
Text("Попробовать снова")
} }
} else { outgoingTransfer?.state == TransferState.FAILED -> {
Text("Ошибка: ${outgoingTransfer?.error}", style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.error)
Spacer(modifier = Modifier.height(8.dp))
OutlinedButton(onClick = { selectedFile = null }) { Text("Повтор") }
}
else -> {
Button( Button(
onClick = { onClick = {
if (!isSending && selectedFilePath != null && targetDevice != null) { if (!isSending && selectedFile != null && targetDevice != null) {
isSending = true isSending = true
scope.launch { scope.launch {
try { try {
val file = java.io.File(selectedFilePath!!) manager.sendFile(selectedFile!!, targetDevice).collect {}
if (file.exists()) {
manager.sendFile(file, targetDevice).collect {}
}
} finally { } finally {
isSending = false isSending = false
} }
} }
} }
}, },
enabled = !isSending && selectedFilePath != null && targetDevice != null enabled = !isSending && selectedFile != null && targetDevice != null
) { ) {
if (isSending) { if (isSending) {
CircularProgressIndicator( CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
modifier = Modifier.size(20.dp),
strokeWidth = 2.dp
)
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
} }
Text("Отправить") Text("Отправить")
@@ -156,4 +108,12 @@ fun SendScreen(
} }
} }
} }
}
}
private fun formatSize(bytes: Long): String = when {
bytes >= 1_073_741_824 -> "%.1f ГБ".format(bytes / 1_073_741_824.0)
bytes >= 1_048_576 -> "%.1f МБ".format(bytes / 1_048_576.0)
bytes >= 1024 -> "%.1f КБ".format(bytes / 1024.0)
else -> "$bytes Б"
} }
@@ -0,0 +1,3 @@
package com.aerotoss.util
expect fun pickFile(onResult: (filePath: String?) -> Unit)
@@ -0,0 +1,20 @@
package com.aerotoss.util
import java.awt.FileDialog
import java.awt.Frame
actual fun pickFile(onResult: (filePath: String?) -> Unit) {
Thread {
try {
val dialog = FileDialog(null as Frame?, "Выберите файл", FileDialog.LOAD)
dialog.isVisible = true
if (dialog.file != null) {
onResult(dialog.directory + dialog.file)
} else {
onResult(null)
}
} catch (_: Exception) {
onResult(null)
}
}.start()
}