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 androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.lifecycle.lifecycleScope
import com.aerotoss.core.AeroTossManager
import com.aerotoss.discovery.CompositeAndroidDiscovery
import com.aerotoss.transfer.AndroidFileTransferManager
import com.aerotoss.ui.App
import com.aerotoss.util.androidFilePickerLauncher
import com.aerotoss.util.handleAndroidPickResult
import com.aerotoss.wifi.WifiDirectManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -16,10 +19,20 @@ import kotlinx.coroutines.withContext
class MainActivity : ComponentActivity() {
private var manager: AeroTossManager? = null
private val filePickerLauncher = registerForActivityResult(
ActivityResultContracts.OpenDocument()
) { uri ->
handleAndroidPickResult(uri, applicationContext)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (manager != null) return
androidFilePickerLauncher = { mimeTypes, _ ->
filePickerLauncher.launch(mimeTypes)
}
val transferManager = AndroidFileTransferManager(applicationContext)
val wifiDirectManager = WifiDirectManager(applicationContext)
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 {
val ss = ServerSocket(TCP_PORT, 50, InetAddress.getByName("0.0.0.0"))
serverSocket = ss
val myIp = findMyIp()
println("AeroToss: TCP server=$TCP_PORT service=$servicePort type=$myDeviceType ip=$myIp name=$deviceName")
println("AeroToss: Server TCP=$TCP_PORT service=$servicePort type=$myDeviceType ip=${findMyIp()}")
while (!Thread.currentThread().isInterrupted) {
try {
val client = ss.accept()
@@ -53,23 +52,24 @@ class TcpDiscovery(
}
ss.close()
} 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 {
try {
val socket = DatagramSocket(UDP_PORT, InetAddress.getByName("0.0.0.0"))
socket.broadcast = true
socket.soTimeout = 2000
socket.soTimeout = 1000
udpSocket = socket
println("AeroToss: UDP broadcast port=$UDP_PORT")
// Send immediately
broadcast(socket, servicePort)
var lastBroadcast = System.currentTimeMillis()
while (!Thread.currentThread().isInterrupted) {
val now = System.currentTimeMillis()
if (now - lastBroadcast > 3_000) {
if (now - lastBroadcast > 1_000) {
broadcast(socket, servicePort)
lastBroadcast = now
}
@@ -77,8 +77,6 @@ class TcpDiscovery(
try {
val pkt = DatagramPacket(buf, buf.size)
socket.receive(pkt)
val from = pkt.address.hostAddress
println("AeroToss: UDP received from $from")
onBroadcastReceived(pkt, servicePort)
} catch (_: java.net.SocketTimeoutException) {}
}
@@ -86,19 +84,16 @@ class TcpDiscovery(
} catch (e: Exception) {
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) {
try {
client.use { sock ->
val from = sock.inetAddress?.hostAddress ?: "unknown"
println("AeroToss: TCP connection from $from")
sock.soTimeout = 5000
sock.soTimeout = 3000
val reader = sock.getInputStream().bufferedReader()
val writer = sock.getOutputStream().bufferedWriter()
val line = reader.readLine() ?: return
println("AeroToss: TCP request: $line")
if (line == "DISCOVER") {
val resp = DiscoveryResponse(
id = deviceId,
@@ -107,47 +102,35 @@ class TcpDiscovery(
port = servicePort,
ip = findMyIp()
)
val jsonStr = json.encodeToString(resp)
writer.write(jsonStr)
writer.write(json.encodeToString(resp))
writer.newLine()
writer.flush()
println("AeroToss: TCP response: $jsonStr")
}
}
} catch (e: Exception) {
println("AeroToss: TCP handler error: ${e.message}")
}
} catch (_: Exception) {}
}
private fun broadcast(socket: DatagramSocket, servicePort: Int) {
try {
val msg = "AEROTOSS:$TCP_PORT:$servicePort:$deviceId:$deviceName"
val data = msg.toByteArray(Charsets.UTF_8)
val broadcastAddr = getBroadcastAddress()
println("AeroToss: Broadcasting to $broadcastAddr:$UDP_PORT")
val pkt = DatagramPacket(data, data.size, broadcastAddr, UDP_PORT)
socket.send(pkt)
} catch (e: Exception) {
println("AeroToss: Broadcast error: ${e.message}")
}
val addr = getBroadcastAddress()
socket.send(DatagramPacket(data, data.size, addr, UDP_PORT))
} catch (_: Exception) {}
}
private fun getBroadcastAddress(): InetAddress {
try {
val interfaces = NetworkInterface.getNetworkInterfaces()
while (interfaces.hasMoreElements()) {
val iface = interfaces.nextElement()
val ifaces = NetworkInterface.getNetworkInterfaces()
while (ifaces.hasMoreElements()) {
val iface = ifaces.nextElement()
if (iface.isLoopback || !iface.isUp) continue
for (interfaceAddress in iface.interfaceAddresses) {
val broadcast = interfaceAddress.broadcast
if (broadcast != null && interfaceAddress.address is java.net.Inet4Address) {
println("AeroToss: Found broadcast ${broadcast.hostAddress} on ${iface.name}")
return broadcast
}
for (ia in iface.interfaceAddresses) {
val bcast = ia.broadcast
if (bcast != null && ia.address is java.net.Inet4Address) return bcast
}
}
} catch (_: Exception) {}
println("AeroToss: Using default broadcast 255.255.255.255")
return InetAddress.getByName("255.255.255.255")
}
@@ -165,70 +148,48 @@ class TcpDiscovery(
if (remoteServicePort == myServicePort) return
val host = pkt.address.hostAddress ?: return
println("AeroToss: Broadcast from $host:$remoteTcpPort name=$remoteName")
val device = queryRemote(host, remoteTcpPort)
if (device != null) {
discoveredDevices[device.id] = device
_devices.value = discoveredDevices.values.toList()
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? {
return try {
Socket().use { sock ->
sock.connect(java.net.InetSocketAddress(host, tcpPort), 3000)
sock.soTimeout = 3000
val writer = sock.getOutputStream().bufferedWriter()
val reader = sock.getInputStream().bufferedReader()
writer.write("DISCOVER")
writer.newLine()
writer.flush()
val line = reader.readLine() ?: return null
json.decodeFromString<DiscoveryResponse>(line).let { resp ->
Device(
id = resp.id,
name = resp.name,
type = if (resp.type == "desktop") DeviceType.DESKTOP else DeviceType.PHONE,
hostAddress = resp.ip,
port = resp.port
)
}
sock.connect(java.net.InetSocketAddress(host, tcpPort), 2000)
sock.soTimeout = 2000
val w = sock.getOutputStream().bufferedWriter()
val r = sock.getInputStream().bufferedReader()
w.write("DISCOVER"); w.newLine(); w.flush()
val line = r.readLine() ?: return null
val resp = json.decodeFromString<DiscoveryResponse>(line)
Device(
id = resp.id, name = resp.name,
type = if (resp.type == "desktop") DeviceType.DESKTOP else DeviceType.PHONE,
hostAddress = resp.ip, port = resp.port
)
}
} catch (e: Exception) {
println("AeroToss: queryRemote $host:$tcpPort failed: ${e.message}")
null
}
} catch (_: Exception) { null }
}
private fun findMyIp(): String {
try {
val interfaces = NetworkInterface.getNetworkInterfaces()
while (interfaces.hasMoreElements()) {
val iface = interfaces.nextElement()
val ifaces = NetworkInterface.getNetworkInterfaces()
while (ifaces.hasMoreElements()) {
val iface = ifaces.nextElement()
if (iface.isLoopback || !iface.isUp) continue
for (addr in iface.inetAddresses) {
if (addr is java.net.Inet4Address && isPrivate(addr)) {
return addr.hostAddress
}
if (addr is java.net.Inet4Address && !addr.isLoopbackAddress) return addr.hostAddress
}
}
} catch (_: Exception) {}
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() {
serverThread?.interrupt(); serverThread = null
discoveryThread?.interrupt(); discoveryThread = null
@@ -242,13 +203,7 @@ class TcpDiscovery(
override fun dispose() { stopDiscovery() }
@Serializable
data class DiscoveryResponse(
val id: String,
val name: String,
val type: String,
val port: Int,
val ip: String
)
data class DiscoveryResponse(val id: String, val name: String, val type: String, val port: Int, val ip: String)
companion object {
const val TCP_PORT = 9998
@@ -12,7 +12,9 @@ import com.aerotoss.core.AeroTossManager
import com.aerotoss.model.Device
import com.aerotoss.model.TransferState
import com.aerotoss.ui.components.TransferProgressBar
import com.aerotoss.util.pickFile
import kotlinx.coroutines.launch
import java.io.File
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -23,10 +25,8 @@ fun SendScreen(
) {
val scope = rememberCoroutineScope()
val outgoingTransfer by manager.outgoingTransfers.collectAsState(initial = null)
var selectedFilePath by remember { mutableStateOf<String?>(null) }
var selectedFileName by remember { mutableStateOf<String?>(null) }
var selectedFile by remember { mutableStateOf<File?>(null) }
var isSending by remember { mutableStateOf(false) }
var showFilePicker by remember { mutableStateOf(false) }
Scaffold(
topBar = {
@@ -48,112 +48,72 @@ fun SendScreen(
horizontalAlignment = Alignment.CenterHorizontally
) {
if (targetDevice != null) {
Text(
"Получатель: ${targetDevice.name}",
style = MaterialTheme.typography.titleMedium
)
Text("Получатель: ${targetDevice.name}", style = MaterialTheme.typography.titleMedium)
Text("${targetDevice.hostAddress}:${targetDevice.port}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(modifier = Modifier.height(16.dp))
}
if (selectedFilePath == null) {
Text(
"Выберите файл для отправки",
style = MaterialTheme.typography.bodyLarge
)
if (selectedFile == null) {
Text("Выберите файл для отправки", style = MaterialTheme.typography.bodyLarge)
Spacer(modifier = Modifier.height(16.dp))
Button(onClick = { showFilePicker = true }) {
Button(onClick = { pickFile { path -> if (path != null) selectedFile = File(path) } }) {
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 {
Card(
modifier = Modifier.fillMaxWidth()
) {
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
"Файл: ${selectedFileName}",
style = MaterialTheme.typography.bodyLarge
)
Text("Файл: ${selectedFile?.name}", style = MaterialTheme.typography.bodyLarge)
Text("Размер: ${formatSize(selectedFile?.length() ?: 0)}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
Spacer(modifier = Modifier.height(16.dp))
if (outgoingTransfer != null && outgoingTransfer?.state == TransferState.TRANSFERRING) {
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("Отправить ещё")
when {
outgoingTransfer?.state == TransferState.TRANSFERRING -> {
outgoingTransfer?.let { TransferProgressBar(it) }
}
} else if (outgoingTransfer?.state == TransferState.FAILED) {
Text(
"Ошибка отправки",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.error
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedButton(onClick = {
selectedFilePath = null
selectedFileName = null
}) {
Text("Попробовать снова")
outgoingTransfer?.state == TransferState.COMPLETED -> {
Text("Отправлено!", style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.primary)
Spacer(modifier = Modifier.height(8.dp))
OutlinedButton(onClick = { selectedFile = null }) { Text("Ещё") }
}
} else {
Button(
onClick = {
if (!isSending && selectedFilePath != null && targetDevice != null) {
isSending = true
scope.launch {
try {
val file = java.io.File(selectedFilePath!!)
if (file.exists()) {
manager.sendFile(file, targetDevice).collect {}
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(
onClick = {
if (!isSending && selectedFile != null && targetDevice != null) {
isSending = true
scope.launch {
try {
manager.sendFile(selectedFile!!, targetDevice).collect {}
} finally {
isSending = false
}
} finally {
isSending = false
}
}
},
enabled = !isSending && selectedFile != null && targetDevice != null
) {
if (isSending) {
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
Spacer(modifier = Modifier.width(8.dp))
}
},
enabled = !isSending && selectedFilePath != null && targetDevice != null
) {
if (isSending) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
strokeWidth = 2.dp
)
Spacer(modifier = Modifier.width(8.dp))
Text("Отправить")
}
Text("Отправить")
}
}
}
}
}
}
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()
}