From 5434ab314cd0ed6b58179b3275ed5911df86146b Mon Sep 17 00:00:00 2001 From: grendervill Date: Wed, 19 Aug 2026 17:50:39 +0300 Subject: [PATCH] =?UTF-8?q?AeroToss=20v0.5.0:=20=D1=84=D0=B8=D0=BD=D0=B0?= =?UTF-8?q?=D0=BB=D1=8C=D0=BD=D1=8B=D0=B9=20=D0=B0=D1=83=D0=B4=D0=B8=D1=82?= =?UTF-8?q?,=20=D0=B1=D0=B0=D0=B3=D1=84=D0=B8=D0=BA=D1=81=D1=8B,=20=D1=82?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D1=8B,=20=D1=80=D0=B5=D0=BB=D0=B8=D0=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Исправлен cleanup файлов (catch-блок удаляет реальный файл) - Null safety для NsdManager (as? с проверками) - JSON парсер: isLenient = false (отвергает malformed JSON) - ByteArray.toInt(): добавлена require(size >= 4) - Исправлен typo в SendScreen - TransferEdgeCaseTest: 8 новых тестов edge cases - Собран macOS arm64 JAR (28 МБ) - Итого 73+ тестов, все проходят --- .../aerotoss/discovery/AndroidNsdDiscovery.kt | 12 +- .../transfer/AndroidTransferManager.kt | 6 +- .../com/aerotoss/protocol/WireProtocol.kt | 8 +- .../com/aerotoss/ui/screens/SendScreen.kt | 2 +- .../transfer/DesktopTransferManager.kt | 6 +- .../aerotoss/transfer/TransferEdgeCaseTest.kt | 169 ++++++++++++++++++ 6 files changed, 191 insertions(+), 12 deletions(-) create mode 100644 app/src/desktopTest/kotlin/com/aerotoss/transfer/TransferEdgeCaseTest.kt diff --git a/app/src/androidMain/kotlin/com/aerotoss/discovery/AndroidNsdDiscovery.kt b/app/src/androidMain/kotlin/com/aerotoss/discovery/AndroidNsdDiscovery.kt index 9e48cfe..5caf881 100644 --- a/app/src/androidMain/kotlin/com/aerotoss/discovery/AndroidNsdDiscovery.kt +++ b/app/src/androidMain/kotlin/com/aerotoss/discovery/AndroidNsdDiscovery.kt @@ -17,7 +17,7 @@ class AndroidNsdDiscovery(context: Context) : DiscoveryManager { private val _devices = MutableStateFlow>(emptyList()) override val devices: Flow> = _devices.asStateFlow() - private val nsdManager = context.getSystemService(Context.NSD_SERVICE) as NsdManager + private val nsdManager = context.getSystemService(Context.NSD_SERVICE) as? NsdManager private var registrationListener: NsdManager.RegistrationListener? = null private var discoveryListener: NsdManager.DiscoveryListener? = null private val deviceId = generateDeviceId() @@ -54,7 +54,7 @@ class AndroidNsdDiscovery(context: Context) : DiscoveryManager { } } - nsdManager.registerService(serviceInfo, NsdManager.PROTOCOL_DNS_SD, registrationListener) + nsdManager?.registerService(serviceInfo, NsdManager.PROTOCOL_DNS_SD, registrationListener) } private fun startServiceDiscovery() { @@ -68,7 +68,7 @@ class AndroidNsdDiscovery(context: Context) : DiscoveryManager { serviceInfo.serviceName != SERVICE_NAME && resolvingServices.add(serviceInfo.serviceName) ) { - nsdManager.resolveService(serviceInfo, object : NsdManager.ResolveListener { + nsdManager?.resolveService(serviceInfo, object : NsdManager.ResolveListener { override fun onResolveFailed(info: NsdServiceInfo, errorCode: Int) { resolvingServices.remove(info.serviceName) Log.e(TAG, "Resolve failed: $errorCode") @@ -110,16 +110,16 @@ class AndroidNsdDiscovery(context: Context) : DiscoveryManager { } } - nsdManager.discoverServices(SERVICE_TYPE, NsdManager.PROTOCOL_DNS_SD, discoveryListener) + nsdManager?.discoverServices(SERVICE_TYPE, NsdManager.PROTOCOL_DNS_SD, discoveryListener) } override fun stopDiscovery() { try { - discoveryListener?.let { nsdManager.stopServiceDiscovery(it) } + discoveryListener?.let { nsdManager?.stopServiceDiscovery(it) } } catch (_: Exception) { } try { - registrationListener?.let { nsdManager.unregisterService(it) } + registrationListener?.let { nsdManager?.unregisterService(it) } } catch (_: Exception) { } discoveryListener = null diff --git a/app/src/androidMain/kotlin/com/aerotoss/transfer/AndroidTransferManager.kt b/app/src/androidMain/kotlin/com/aerotoss/transfer/AndroidTransferManager.kt index cb6155a..0c1e48c 100644 --- a/app/src/androidMain/kotlin/com/aerotoss/transfer/AndroidTransferManager.kt +++ b/app/src/androidMain/kotlin/com/aerotoss/transfer/AndroidTransferManager.kt @@ -78,6 +78,8 @@ class AndroidFileTransferManager(private val context: Context) : TransferManager private suspend fun handleIncomingConnection(socket: Socket) { withContext(Dispatchers.IO) { val progressId = UUID.randomUUID().toString() + var actualFile: File? = null + var requestFileName: String = "" try { socket.use { sock -> sock.soTimeout = 30_000 @@ -86,6 +88,7 @@ class AndroidFileTransferManager(private val context: Context) : TransferManager val requestJson = input.readUTF() val request = Json.decodeFromString(requestJson) + requestFileName = request.fileName val progress = TransferProgress( id = progressId, @@ -105,6 +108,7 @@ class AndroidFileTransferManager(private val context: Context) : TransferManager } val file = FileUtils.resolveUniqueFile(downloadsDir, request.fileName) + actualFile = file val sha256 = MessageDigest.getInstance("SHA-256") var bytesWritten = 0L @@ -157,11 +161,11 @@ class AndroidFileTransferManager(private val context: Context) : 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 && current.state != TransferState.FAILED && current.state != TransferState.CANCELLED ) { - FileUtils.deleteIfExists(File(downloadsDir, FileUtils.sanitizeFileName(current.request.fileName))) updateIncomingById(progressId, current.copy( state = TransferState.FAILED, error = e.message ?: "Unknown error" diff --git a/app/src/commonMain/kotlin/com/aerotoss/protocol/WireProtocol.kt b/app/src/commonMain/kotlin/com/aerotoss/protocol/WireProtocol.kt index e29e655..cd31ca0 100644 --- a/app/src/commonMain/kotlin/com/aerotoss/protocol/WireProtocol.kt +++ b/app/src/commonMain/kotlin/com/aerotoss/protocol/WireProtocol.kt @@ -9,7 +9,7 @@ object WireProtocol { private val json = Json { encodeDefaults = true ignoreUnknownKeys = true - isLenient = true + isLenient = false } fun encode(command: Command): ByteArray { @@ -51,8 +51,10 @@ fun Int.toByteArray(): ByteArray = byteArrayOf( (this and 0xFF).toByte() ) -fun ByteArray.toInt(): Int = - ((this[0].toInt() and 0xFF) shl 24) or +fun ByteArray.toInt(): Int { + require(size >= 4) { "ByteArray must have at least 4 elements" } + return ((this[0].toInt() and 0xFF) shl 24) or ((this[1].toInt() and 0xFF) shl 16) or ((this[2].toInt() and 0xFF) shl 8) or (this[3].toInt() and 0xFF) +} 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 1fa7ba8..26d4bf0 100644 --- a/app/src/commonMain/kotlin/com/aerotoss/ui/screens/SendScreen.kt +++ b/app/src/commonMain/kotlin/com/aerotoss/ui/screens/SendScreen.kt @@ -80,7 +80,7 @@ fun SendScreen( style = MaterialTheme.typography.bodyLarge ) Text( - "Разазмер: ${formatSize(selectedFile?.length() ?: 0)}", + "Размер: ${formatSize(selectedFile?.length() ?: 0)}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) diff --git a/app/src/desktopMain/kotlin/com/aerotoss/transfer/DesktopTransferManager.kt b/app/src/desktopMain/kotlin/com/aerotoss/transfer/DesktopTransferManager.kt index bd01193..979b7c2 100644 --- a/app/src/desktopMain/kotlin/com/aerotoss/transfer/DesktopTransferManager.kt +++ b/app/src/desktopMain/kotlin/com/aerotoss/transfer/DesktopTransferManager.kt @@ -75,6 +75,8 @@ class DesktopFileTransferManager : TransferManager { private suspend fun handleIncomingConnection(socket: Socket) { withContext(Dispatchers.IO) { val progressId = UUID.randomUUID().toString() + var actualFile: File? = null + var requestFileName: String = "" try { socket.use { sock -> sock.soTimeout = 30_000 @@ -83,6 +85,7 @@ class DesktopFileTransferManager : TransferManager { val requestJson = input.readUTF() val request = Json.decodeFromString(requestJson) + requestFileName = request.fileName val progress = TransferProgress( id = progressId, @@ -102,6 +105,7 @@ class DesktopFileTransferManager : TransferManager { } val file = FileUtils.resolveUniqueFile(downloadsDir, request.fileName) + actualFile = file val sha256 = MessageDigest.getInstance("SHA-256") var bytesWritten = 0L @@ -154,11 +158,11 @@ 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 && current.state != TransferState.FAILED && current.state != TransferState.CANCELLED ) { - FileUtils.deleteIfExists(File(downloadsDir, FileUtils.sanitizeFileName(current.request.fileName))) updateIncomingById(progressId, current.copy( state = TransferState.FAILED, error = e.message ?: "Unknown error" diff --git a/app/src/desktopTest/kotlin/com/aerotoss/transfer/TransferEdgeCaseTest.kt b/app/src/desktopTest/kotlin/com/aerotoss/transfer/TransferEdgeCaseTest.kt new file mode 100644 index 0000000..859c0c8 --- /dev/null +++ b/app/src/desktopTest/kotlin/com/aerotoss/transfer/TransferEdgeCaseTest.kt @@ -0,0 +1,169 @@ +package com.aerotoss.transfer + +import com.aerotoss.model.TransferState +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.first +import org.junit.After +import org.junit.Before +import org.junit.Test +import java.io.File +import java.net.ServerSocket +import java.net.Socket +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class TransferEdgeCaseTest { + + private lateinit var tempDir: File + + @Before + fun setup() { + tempDir = File(System.getProperty("java.io.tmpdir"), "aerotoss-edge-${System.currentTimeMillis()}") + tempDir.mkdirs() + } + + @After + fun teardown() { + tempDir.deleteRecursively() + } + + @Test + fun testEmptyFileTransfer() = runBlocking { + val receiver = DesktopFileTransferManager() + val port = receiver.startServer(0) + try { + val sender = DesktopFileTransferManager() + val testFile = File(tempDir, "empty.txt") + testFile.writeText("") + + val result = withTimeout(10_000) { + sender.sendFile(testFile, "127.0.0.1", port).first { + it.state == TransferState.COMPLETED || it.state == TransferState.FAILED + } + } + + assertEquals(TransferState.COMPLETED, result.state) + assertEquals(0L, result.bytesTransferred) + sender.dispose() + } finally { + receiver.dispose() + } + } + + @Test + fun testTransferToNonexistentServer() = runBlocking { + val sender = DesktopFileTransferManager() + val testFile = File(tempDir, "fail.txt") + testFile.writeText("test") + + val result = withTimeout(10_000) { + sender.sendFile(testFile, "127.0.0.1", 59999).first { + it.state == TransferState.COMPLETED || it.state == TransferState.FAILED + } + } + + assertEquals(TransferState.FAILED, result.state) + assertTrue(result.error != null) + sender.dispose() + } + + @Test + fun testTransferCancellation() = runBlocking { + val receiver = DesktopFileTransferManager() + val port = receiver.startServer(0) + try { + val sender = DesktopFileTransferManager() + val testFile = File(tempDir, "cancel-test.bin") + testFile.writeBytes(ByteArray(1_000_000) { it.toByte() }) + + val flow = sender.sendFile(testFile, "127.0.0.1", port) + delay(200) + sender.dispose() + + receiver.dispose() + } finally { + try { receiver.dispose() } catch (_: Exception) {} + } + } + + @Test + fun testFileNameSanitizationInTransfer() = runBlocking { + val receiver = DesktopFileTransferManager() + val port = receiver.startServer(0) + try { + val sender = DesktopFileTransferManager() + val testFile = File(tempDir, "normal.txt") + testFile.writeText("content") + + val result = withTimeout(10_000) { + sender.sendFile(testFile, "127.0.0.1", port).first { + it.state == TransferState.COMPLETED || it.state == TransferState.FAILED + } + } + + assertEquals(TransferState.COMPLETED, result.state) + sender.dispose() + } finally { + receiver.dispose() + } + } + + @Test + fun testMultipleSequentialTransfers() = runBlocking { + val receiver = DesktopFileTransferManager() + val port = receiver.startServer(0) + try { + val sender = DesktopFileTransferManager() + + for (i in 1..3) { + val testFile = File(tempDir, "seq-$i.txt") + testFile.writeText("Content $i") + + val result = withTimeout(10_000) { + sender.sendFile(testFile, "127.0.0.1", port).first { + it.state == TransferState.COMPLETED || it.state == TransferState.FAILED + } + } + + assertEquals(TransferState.COMPLETED, result.state, "Transfer $i should complete") + } + + sender.dispose() + } finally { + receiver.dispose() + } + } + + @Test + fun testServerHandlesMalformedConnection() { + val server = DesktopFileTransferManager() + val port = server.startServer(0) + try { + val socket = Socket("127.0.0.1", port) + socket.getOutputStream().write("malformed data".toByteArray()) + socket.close() + Thread.sleep(500) + } catch (_: Exception) { + } finally { + server.dispose() + } + } + + @Test + fun testConcurrentDispose() { + val server = DesktopFileTransferManager() + server.startServer(0) + server.dispose() + server.dispose() + server.dispose() + } + + @Test + fun testGetServerPortReturnsValidPort() { + val manager = DesktopFileTransferManager() + val port = manager.startServer(0) + assertTrue(port > 0 && port < 65536) + assertEquals(port, manager.getServerPort()) + manager.dispose() + } +}