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:
@@ -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 ->
|
||||
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,
|
||||
id = resp.id, name = resp.name,
|
||||
type = if (resp.type == "desktop") DeviceType.DESKTOP else DeviceType.PHONE,
|
||||
hostAddress = resp.ip,
|
||||
port = resp.port
|
||||
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,106 +48,58 @@ 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) {
|
||||
when {
|
||||
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("Отправить ещё")
|
||||
}
|
||||
} else if (outgoingTransfer?.state == TransferState.FAILED) {
|
||||
Text(
|
||||
"Ошибка отправки",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
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("Попробовать снова")
|
||||
OutlinedButton(onClick = { selectedFile = 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(
|
||||
onClick = {
|
||||
if (!isSending && selectedFilePath != null && targetDevice != null) {
|
||||
if (!isSending && selectedFile != null && targetDevice != null) {
|
||||
isSending = true
|
||||
scope.launch {
|
||||
try {
|
||||
val file = java.io.File(selectedFilePath!!)
|
||||
if (file.exists()) {
|
||||
manager.sendFile(file, targetDevice).collect {}
|
||||
}
|
||||
manager.sendFile(selectedFile!!, targetDevice).collect {}
|
||||
} finally {
|
||||
isSending = false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = !isSending && selectedFilePath != null && targetDevice != null
|
||||
enabled = !isSending && selectedFile != null && targetDevice != null
|
||||
) {
|
||||
if (isSending) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(20.dp),
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
}
|
||||
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()
|
||||
}
|
||||
Reference in New Issue
Block a user