diff --git a/.gitignore b/.gitignore index aee0a06..392c796 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,7 @@ local.properties /captures .externalNativeBuild/ .cxx/ +*.keystore # === OS === .DS_Store diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..55ed06a --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + For the complete license text, see https://www.gnu.org/licenses/gpl-3.0.txt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5c6bf73..843b00f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -5,7 +5,7 @@ plugins { alias(libs.plugins.kotlin.serialization) alias(libs.plugins.compose.multiplatform) alias(libs.plugins.compose.compiler) - alias(libs.plugins.android.library) + alias(libs.plugins.android.application) } kotlin { @@ -71,7 +71,36 @@ android { compileSdk = 35 defaultConfig { + applicationId = "com.aerotoss" minSdk = 24 + targetSdk = 35 + versionCode = 1 + versionName = "1.0.0" + } + + signingConfigs { + getByName("debug") { + storeFile = file("debug.keystore") + storePassword = "aerotoss123" + keyAlias = "aerotoss" + keyPassword = "aerotoss123" + } + create("release") { + storeFile = file("debug.keystore") + storePassword = "aerotoss123" + keyAlias = "aerotoss" + keyPassword = "aerotoss123" + } + } + + buildTypes { + debug { + signingConfig = signingConfigs.getByName("debug") + } + release { + signingConfig = signingConfigs.getByName("release") + isMinifyEnabled = false + } } compileOptions { @@ -89,5 +118,14 @@ android { compose.desktop { application { mainClass = "com.aerotoss.MainKt" + + nativeDistributions { + targetFormats(org.jetbrains.compose.desktop.application.dsl.TargetFormat.Dmg) + packageName = "AeroToss" + packageVersion = "1.0.0" + description = "Кроссплатформенное приложение для передачи файлов" + vendor = "AeroToss" + licenseFile = file("../LICENSE") + } } } diff --git a/app/src/androidMain/kotlin/com/aerotoss/discovery/CompositeAndroidDiscovery.kt b/app/src/androidMain/kotlin/com/aerotoss/discovery/CompositeAndroidDiscovery.kt index c17233b..a65fa68 100644 --- a/app/src/androidMain/kotlin/com/aerotoss/discovery/CompositeAndroidDiscovery.kt +++ b/app/src/androidMain/kotlin/com/aerotoss/discovery/CompositeAndroidDiscovery.kt @@ -18,8 +18,8 @@ class CompositeAndroidDiscovery( private val _devices = MutableStateFlow>(emptyList()) override val devices: Flow> = _devices.asStateFlow() - private var scope: CoroutineScope? = null - private var discoveryJob: kotlinx.coroutines.Job? = null + @Volatile private var scope: CoroutineScope? = null + @Volatile private var discoveryJob: kotlinx.coroutines.Job? = null override fun startDiscovery(servicePort: Int) { if (discoveryJob != null) return diff --git a/app/src/androidMain/kotlin/com/aerotoss/transfer/AndroidTransferManager.kt b/app/src/androidMain/kotlin/com/aerotoss/transfer/AndroidTransferManager.kt index 0c1e48c..3507dc5 100644 --- a/app/src/androidMain/kotlin/com/aerotoss/transfer/AndroidTransferManager.kt +++ b/app/src/androidMain/kotlin/com/aerotoss/transfer/AndroidTransferManager.kt @@ -11,11 +11,13 @@ import kotlinx.coroutines.* import kotlinx.coroutines.flow.* import kotlinx.serialization.json.Json import java.io.* +import java.net.InetAddress import java.net.ServerSocket import java.net.Socket import java.security.MessageDigest import java.util.UUID import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.Semaphore import java.util.concurrent.atomic.AtomicBoolean @OptIn(ExperimentalCoroutinesApi::class) @@ -36,6 +38,7 @@ class AndroidFileTransferManager(private val context: Context) : TransferManager private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val activeJobs = ConcurrentHashMap() private val running = AtomicBoolean(false) + private val connectionLimiter = Semaphore(10) private val downloadsDir: File = run { val dir = context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS) @@ -46,12 +49,11 @@ class AndroidFileTransferManager(private val context: Context) : TransferManager override fun getServerPort(): Int = serverSocket?.localPort ?: 0 fun startServer(port: Int = 0): Int { - if (running.get()) return serverSocket?.localPort ?: 0 + if (!running.compareAndSet(false, true)) return serverSocket?.localPort ?: 0 try { - val socket = ServerSocket(port) + val socket = ServerSocket(port, 50, InetAddress.getLoopbackAddress()) serverSocket = socket - running.set(true) serverThread = Thread { while (running.get() && !socket.isClosed) { @@ -76,20 +78,33 @@ class AndroidFileTransferManager(private val context: Context) : TransferManager } private suspend fun handleIncomingConnection(socket: Socket) { + if (!connectionLimiter.tryAcquire()) { + try { socket.close() } catch (_: Exception) {} + return + } withContext(Dispatchers.IO) { val progressId = UUID.randomUUID().toString() var actualFile: File? = null var requestFileName: String = "" try { socket.use { sock -> - sock.soTimeout = 30_000 + sock.soTimeout = 60_000 val input = DataInputStream(sock.getInputStream()) val output = DataOutputStream(sock.getOutputStream()) val requestJson = input.readUTF() - val request = Json.decodeFromString(requestJson) + val request = try { + Json.decodeFromString(requestJson) + } catch (_: Exception) { + return@withContext + } requestFileName = request.fileName + if (request.fileSize <= 0 || request.fileSize > FileUtils.MAX_FILE_SIZE) { + output.writeBoolean(false) + return@withContext + } + val progress = TransferProgress( id = progressId, request = request, @@ -108,6 +123,10 @@ class AndroidFileTransferManager(private val context: Context) : TransferManager } val file = FileUtils.resolveUniqueFile(downloadsDir, request.fileName) + if (!FileUtils.validateFilePath(file, downloadsDir)) { + output.writeBoolean(false) + return@withContext + } actualFile = file val sha256 = MessageDigest.getInstance("SHA-256") var bytesWritten = 0L @@ -159,8 +178,7 @@ class AndroidFileTransferManager(private val context: Context) : TransferManager )) } } - } catch (e: Exception) { - e.printStackTrace() + } catch (_: Exception) { actualFile?.let { FileUtils.deleteIfExists(it) } val current = _incomingTransfers.value.find { it.id == progressId } if (current != null && current.state != TransferState.COMPLETED && @@ -168,14 +186,19 @@ class AndroidFileTransferManager(private val context: Context) : TransferManager ) { updateIncomingById(progressId, current.copy( state = TransferState.FAILED, - error = e.message ?: "Unknown error" + error = "Transfer failed" )) } + } finally { + connectionLimiter.release() } } } override suspend fun sendFile(file: File, targetHost: String, targetPort: Int): Flow { + require(file.exists() && file.isFile) { "File does not exist or is not a regular file" } + require(file.length() <= FileUtils.MAX_FILE_SIZE) { "File exceeds maximum size" } + val requestId = UUID.randomUUID().toString() val request = TransferRequest( fileName = file.name, @@ -260,11 +283,10 @@ class AndroidFileTransferManager(private val context: Context) : TransferManager )) } } - } catch (e: Exception) { - e.printStackTrace() + } catch (_: Exception) { updateOutgoingById(requestId, initialProgress.copy( state = TransferState.FAILED, - error = e.message ?: "Unknown error" + error = "Transfer failed" )) } finally { activeJobs.remove(requestId) @@ -294,19 +316,19 @@ class AndroidFileTransferManager(private val context: Context) : TransferManager private fun updateIncoming(progress: TransferProgress) { _incomingTransfers.update { list -> - list.filter { it.id != progress.id }.plus(progress) + list.filter { it.id != progress.id }.plus(progress).takeLast(50) } } private fun updateIncomingById(id: String, progress: TransferProgress) { _incomingTransfers.update { list -> - list.filter { it.id != id }.plus(progress) + list.filter { it.id != id }.plus(progress).takeLast(50) } } private fun updateOutgoing(progress: TransferProgress) { _outgoingTransfers.update { list -> - list.filter { it.id != progress.id }.plus(progress) + list.filter { it.id != progress.id }.plus(progress).takeLast(50) } } diff --git a/app/src/commonMain/kotlin/com/aerotoss/core/AeroTossManager.kt b/app/src/commonMain/kotlin/com/aerotoss/core/AeroTossManager.kt index 918c3b2..5821dd3 100644 --- a/app/src/commonMain/kotlin/com/aerotoss/core/AeroTossManager.kt +++ b/app/src/commonMain/kotlin/com/aerotoss/core/AeroTossManager.kt @@ -20,6 +20,7 @@ class AeroTossManager( } suspend fun sendFile(file: File, target: Device): Flow { + require(file.exists() && file.isFile) { "File does not exist or is not a regular file" } return transfer.sendFile(file, target.hostAddress, target.port) } 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 26d4bf0..56bd93a 100644 --- a/app/src/commonMain/kotlin/com/aerotoss/ui/screens/SendScreen.kt +++ b/app/src/commonMain/kotlin/com/aerotoss/ui/screens/SendScreen.kt @@ -13,7 +13,6 @@ import com.aerotoss.model.Device import com.aerotoss.model.TransferState import com.aerotoss.ui.components.TransferProgressBar import kotlinx.coroutines.launch -import java.io.File @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -24,8 +23,10 @@ fun SendScreen( ) { val scope = rememberCoroutineScope() val outgoingTransfer by manager.outgoingTransfers.collectAsState(initial = null) - var selectedFile by remember { mutableStateOf(null) } + var selectedFilePath by remember { mutableStateOf(null) } + var selectedFileName by remember { mutableStateOf(null) } var isSending by remember { mutableStateOf(false) } + var showFilePicker by remember { mutableStateOf(false) } Scaffold( topBar = { @@ -54,36 +55,42 @@ fun SendScreen( Spacer(modifier = Modifier.height(16.dp)) } - if (selectedFile == null) { + if (selectedFilePath == null) { Text( "Выберите файл для отправки", style = MaterialTheme.typography.bodyLarge ) Spacer(modifier = Modifier.height(16.dp)) - Button( - onClick = { - selectedFile = File("/tmp/test_file.txt").apply { - writeText("Тестовый файл AeroToss ${System.currentTimeMillis()}") - } - } - ) { + Button(onClick = { showFilePicker = true }) { 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() ) { Column(modifier = Modifier.padding(16.dp)) { Text( - "Файл: ${selectedFile?.name}", + "Файл: ${selectedFileName}", style = MaterialTheme.typography.bodyLarge ) - Text( - "Размер: ${formatSize(selectedFile?.length() ?: 0)}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) } } @@ -99,37 +106,42 @@ fun SendScreen( ) Spacer(modifier = Modifier.height(8.dp)) OutlinedButton(onClick = { - selectedFile = null + selectedFilePath = null + selectedFileName = null }) { Text("Отправить ещё") } } else if (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 + selectedFilePath = null + selectedFileName = null }) { Text("Попробовать снова") } } else { Button( onClick = { - if (!isSending && selectedFile != null && targetDevice != null) { + if (!isSending && selectedFilePath != null && targetDevice != null) { isSending = true scope.launch { try { - manager.sendFile(selectedFile!!, targetDevice).collect {} + val file = java.io.File(selectedFilePath!!) + if (file.exists()) { + manager.sendFile(file, targetDevice).collect {} + } } finally { isSending = false } } } }, - enabled = !isSending && selectedFile != null && targetDevice != null + enabled = !isSending && selectedFilePath != null && targetDevice != null ) { if (isSending) { CircularProgressIndicator( @@ -145,10 +157,3 @@ 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 Б" -} diff --git a/app/src/commonMain/kotlin/com/aerotoss/util/FileUtils.kt b/app/src/commonMain/kotlin/com/aerotoss/util/FileUtils.kt index 128f218..ce93de8 100644 --- a/app/src/commonMain/kotlin/com/aerotoss/util/FileUtils.kt +++ b/app/src/commonMain/kotlin/com/aerotoss/util/FileUtils.kt @@ -3,6 +3,9 @@ package com.aerotoss.util import java.io.File object FileUtils { + private const val MAX_RESOLVE_COUNTER = 1000 + const val MAX_FILE_SIZE = 10L * 1024 * 1024 * 1024 // 10 GB + fun sanitizeFileName(fileName: String): String { val sanitized = fileName .replace(Regex("[/\\\\]"), "_") @@ -13,6 +16,14 @@ object FileUtils { return if (sanitized.isEmpty()) "unnamed_file" else sanitized } + fun validateFilePath(file: File, allowedDir: File): Boolean { + return try { + file.canonicalPath.startsWith(allowedDir.canonicalPath) + } catch (_: Exception) { + false + } + } + fun resolveUniqueFile(dir: File, fileName: String): File { val sanitized = sanitizeFileName(fileName) val file = File(dir, sanitized) @@ -24,15 +35,19 @@ object FileUtils { var candidate = File(dir, "${name}_${counter}.${ext}") while (candidate.exists()) { counter++ + if (counter > MAX_RESOLVE_COUNTER) { + return File(dir, "${name}_${System.currentTimeMillis()}.${ext}") + } candidate = File(dir, "${name}_${counter}.${ext}") } return candidate } fun deleteIfExists(file: File): Boolean { - if (file.exists()) { - return file.delete() + return try { + file.delete() + } catch (_: Exception) { + false } - return false } } diff --git a/app/src/desktopMain/kotlin/com/aerotoss/transfer/DesktopTransferManager.kt b/app/src/desktopMain/kotlin/com/aerotoss/transfer/DesktopTransferManager.kt index 979b7c2..84cc39b 100644 --- a/app/src/desktopMain/kotlin/com/aerotoss/transfer/DesktopTransferManager.kt +++ b/app/src/desktopMain/kotlin/com/aerotoss/transfer/DesktopTransferManager.kt @@ -9,11 +9,13 @@ import kotlinx.coroutines.* import kotlinx.coroutines.flow.* import kotlinx.serialization.json.Json import java.io.* +import java.net.InetAddress import java.net.ServerSocket import java.net.Socket import java.security.MessageDigest import java.util.UUID import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.Semaphore import java.util.concurrent.atomic.AtomicBoolean @OptIn(ExperimentalCoroutinesApi::class) @@ -35,20 +37,20 @@ class DesktopFileTransferManager : TransferManager { private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val activeJobs = ConcurrentHashMap() private val running = AtomicBoolean(false) + private val connectionLimiter = Semaphore(10) - private val downloadsDir: File = File(System.getProperty("user.home"), "AeroTossDownloads").apply { + private val downloadsDir: File = File(System.getProperty("user.home"), "Downloads/AeroToss").apply { mkdirs() } override fun getServerPort(): Int = serverSocket?.localPort ?: 0 fun startServer(port: Int = 0): Int { - if (running.get()) return serverSocket?.localPort ?: 0 + if (!running.compareAndSet(false, true)) return serverSocket?.localPort ?: 0 try { - val socket = ServerSocket(port) + val socket = ServerSocket(port, 50, InetAddress.getLoopbackAddress()) serverSocket = socket - running.set(true) serverThread = Thread { while (running.get() && !socket.isClosed) { @@ -73,20 +75,33 @@ class DesktopFileTransferManager : TransferManager { } private suspend fun handleIncomingConnection(socket: Socket) { + if (!connectionLimiter.tryAcquire()) { + try { socket.close() } catch (_: Exception) {} + return + } withContext(Dispatchers.IO) { val progressId = UUID.randomUUID().toString() var actualFile: File? = null var requestFileName: String = "" try { socket.use { sock -> - sock.soTimeout = 30_000 + sock.soTimeout = 60_000 val input = DataInputStream(sock.getInputStream()) val output = DataOutputStream(sock.getOutputStream()) val requestJson = input.readUTF() - val request = Json.decodeFromString(requestJson) + val request = try { + Json.decodeFromString(requestJson) + } catch (_: Exception) { + return@withContext + } requestFileName = request.fileName + if (request.fileSize < 0 || request.fileSize > FileUtils.MAX_FILE_SIZE) { + output.writeBoolean(false) + return@withContext + } + val progress = TransferProgress( id = progressId, request = request, @@ -105,6 +120,10 @@ class DesktopFileTransferManager : TransferManager { } val file = FileUtils.resolveUniqueFile(downloadsDir, request.fileName) + if (!FileUtils.validateFilePath(file, downloadsDir)) { + output.writeBoolean(false) + return@withContext + } actualFile = file val sha256 = MessageDigest.getInstance("SHA-256") var bytesWritten = 0L @@ -157,7 +176,6 @@ class DesktopFileTransferManager : TransferManager { } } } catch (e: Exception) { - e.printStackTrace() actualFile?.let { FileUtils.deleteIfExists(it) } val current = _incomingTransfers.value.find { it.id == progressId } if (current != null && current.state != TransferState.COMPLETED && @@ -165,14 +183,19 @@ class DesktopFileTransferManager : TransferManager { ) { updateIncomingById(progressId, current.copy( state = TransferState.FAILED, - error = e.message ?: "Unknown error" + error = "Transfer failed" )) } + } finally { + connectionLimiter.release() } } } override suspend fun sendFile(file: File, targetHost: String, targetPort: Int): Flow { + require(file.exists() && file.isFile) { "File does not exist or is not a regular file" } + require(file.length() <= FileUtils.MAX_FILE_SIZE) { "File exceeds maximum size" } + val requestId = UUID.randomUUID().toString() val request = TransferRequest( fileName = file.name, @@ -251,11 +274,10 @@ class DesktopFileTransferManager : TransferManager { } updateOutgoing(stateFlow.value) } - } catch (e: Exception) { - e.printStackTrace() + } catch (_: Exception) { stateFlow.value = stateFlow.value.copy( state = TransferState.FAILED, - error = e.message ?: "Unknown error" + error = "Transfer failed" ) updateOutgoing(stateFlow.value) } finally { @@ -288,19 +310,19 @@ class DesktopFileTransferManager : TransferManager { private fun updateIncoming(progress: TransferProgress) { _incomingTransfers.update { list -> - list.filter { it.id != progress.id }.plus(progress) + list.filter { it.id != progress.id }.plus(progress).takeLast(50) } } private fun updateIncomingById(id: String, progress: TransferProgress) { _incomingTransfers.update { list -> - list.filter { it.id != id }.plus(progress) + list.filter { it.id != id }.plus(progress).takeLast(50) } } private fun updateOutgoing(progress: TransferProgress) { _outgoingTransfers.update { list -> - list.filter { it.id != progress.id }.plus(progress) + list.filter { it.id != progress.id }.plus(progress).takeLast(50) } } } diff --git a/app/src/desktopTest/kotlin/com/aerotoss/transfer/DesktopTransferManagerTest.kt b/app/src/desktopTest/kotlin/com/aerotoss/transfer/DesktopTransferManagerTest.kt index cb138b1..6ea7e2a 100644 --- a/app/src/desktopTest/kotlin/com/aerotoss/transfer/DesktopTransferManagerTest.kt +++ b/app/src/desktopTest/kotlin/com/aerotoss/transfer/DesktopTransferManagerTest.kt @@ -230,7 +230,7 @@ class DesktopTransferManagerTest { @Test fun testPortAlreadyInUse() { - val ss = ServerSocket(0) + val ss = ServerSocket(0, 1, java.net.InetAddress.getLoopbackAddress()) val port = ss.localPort try { val result = manager.startServer(port) diff --git a/build.gradle.kts b/build.gradle.kts index 3aff492..82591de 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -3,5 +3,5 @@ plugins { alias(libs.plugins.kotlin.serialization) apply false alias(libs.plugins.compose.multiplatform) apply false alias(libs.plugins.compose.compiler) apply false - alias(libs.plugins.android.library) apply false + alias(libs.plugins.android.application) apply false }