diff --git a/app/src/androidMain/kotlin/com/aerotoss/MainActivity.kt b/app/src/androidMain/kotlin/com/aerotoss/MainActivity.kt index 442034e..6b98387 100644 --- a/app/src/androidMain/kotlin/com/aerotoss/MainActivity.kt +++ b/app/src/androidMain/kotlin/com/aerotoss/MainActivity.kt @@ -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) diff --git a/app/src/androidMain/kotlin/com/aerotoss/util/AndroidFilePicker.kt b/app/src/androidMain/kotlin/com/aerotoss/util/AndroidFilePicker.kt new file mode 100644 index 0000000..5855bf0 --- /dev/null +++ b/app/src/androidMain/kotlin/com/aerotoss/util/AndroidFilePicker.kt @@ -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, 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 +} diff --git a/app/src/commonMain/kotlin/com/aerotoss/discovery/TcpDiscovery.kt b/app/src/commonMain/kotlin/com/aerotoss/discovery/TcpDiscovery.kt index c1d4be9..d65fdcf 100644 --- a/app/src/commonMain/kotlin/com/aerotoss/discovery/TcpDiscovery.kt +++ b/app/src/commonMain/kotlin/com/aerotoss/discovery/TcpDiscovery.kt @@ -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(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(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 diff --git a/app/src/commonMain/kotlin/com/aerotoss/ui/screens/SendScreen.kt b/app/src/commonMain/kotlin/com/aerotoss/ui/screens/SendScreen.kt index 56bd93a..1ffc119 100644 --- a/app/src/commonMain/kotlin/com/aerotoss/ui/screens/SendScreen.kt +++ b/app/src/commonMain/kotlin/com/aerotoss/ui/screens/SendScreen.kt @@ -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(null) } - var selectedFileName by remember { mutableStateOf(null) } + var selectedFile by remember { mutableStateOf(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 Б" +} diff --git a/app/src/commonMain/kotlin/com/aerotoss/util/FilePicker.kt b/app/src/commonMain/kotlin/com/aerotoss/util/FilePicker.kt new file mode 100644 index 0000000..4e69a10 --- /dev/null +++ b/app/src/commonMain/kotlin/com/aerotoss/util/FilePicker.kt @@ -0,0 +1,3 @@ +package com.aerotoss.util + +expect fun pickFile(onResult: (filePath: String?) -> Unit) diff --git a/app/src/desktopMain/kotlin/com/aerotoss/util/DesktopFilePicker.kt b/app/src/desktopMain/kotlin/com/aerotoss/util/DesktopFilePicker.kt new file mode 100644 index 0000000..88ab0a9 --- /dev/null +++ b/app/src/desktopMain/kotlin/com/aerotoss/util/DesktopFilePicker.kt @@ -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() +}