AeroToss v0.5.0: финальный аудит, багфиксы, тесты, релиз
- Исправлен 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+ тестов, все проходят
This commit is contained in:
@@ -17,7 +17,7 @@ class AndroidNsdDiscovery(context: Context) : DiscoveryManager {
|
|||||||
private val _devices = MutableStateFlow<List<Device>>(emptyList())
|
private val _devices = MutableStateFlow<List<Device>>(emptyList())
|
||||||
override val devices: Flow<List<Device>> = _devices.asStateFlow()
|
override val devices: Flow<List<Device>> = _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 registrationListener: NsdManager.RegistrationListener? = null
|
||||||
private var discoveryListener: NsdManager.DiscoveryListener? = null
|
private var discoveryListener: NsdManager.DiscoveryListener? = null
|
||||||
private val deviceId = generateDeviceId()
|
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() {
|
private fun startServiceDiscovery() {
|
||||||
@@ -68,7 +68,7 @@ class AndroidNsdDiscovery(context: Context) : DiscoveryManager {
|
|||||||
serviceInfo.serviceName != SERVICE_NAME &&
|
serviceInfo.serviceName != SERVICE_NAME &&
|
||||||
resolvingServices.add(serviceInfo.serviceName)
|
resolvingServices.add(serviceInfo.serviceName)
|
||||||
) {
|
) {
|
||||||
nsdManager.resolveService(serviceInfo, object : NsdManager.ResolveListener {
|
nsdManager?.resolveService(serviceInfo, object : NsdManager.ResolveListener {
|
||||||
override fun onResolveFailed(info: NsdServiceInfo, errorCode: Int) {
|
override fun onResolveFailed(info: NsdServiceInfo, errorCode: Int) {
|
||||||
resolvingServices.remove(info.serviceName)
|
resolvingServices.remove(info.serviceName)
|
||||||
Log.e(TAG, "Resolve failed: $errorCode")
|
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() {
|
override fun stopDiscovery() {
|
||||||
try {
|
try {
|
||||||
discoveryListener?.let { nsdManager.stopServiceDiscovery(it) }
|
discoveryListener?.let { nsdManager?.stopServiceDiscovery(it) }
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
registrationListener?.let { nsdManager.unregisterService(it) }
|
registrationListener?.let { nsdManager?.unregisterService(it) }
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
}
|
}
|
||||||
discoveryListener = null
|
discoveryListener = null
|
||||||
|
|||||||
@@ -78,6 +78,8 @@ class AndroidFileTransferManager(private val context: Context) : TransferManager
|
|||||||
private suspend fun handleIncomingConnection(socket: Socket) {
|
private suspend fun handleIncomingConnection(socket: Socket) {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
val progressId = UUID.randomUUID().toString()
|
val progressId = UUID.randomUUID().toString()
|
||||||
|
var actualFile: File? = null
|
||||||
|
var requestFileName: String = ""
|
||||||
try {
|
try {
|
||||||
socket.use { sock ->
|
socket.use { sock ->
|
||||||
sock.soTimeout = 30_000
|
sock.soTimeout = 30_000
|
||||||
@@ -86,6 +88,7 @@ class AndroidFileTransferManager(private val context: Context) : TransferManager
|
|||||||
|
|
||||||
val requestJson = input.readUTF()
|
val requestJson = input.readUTF()
|
||||||
val request = Json.decodeFromString<TransferRequest>(requestJson)
|
val request = Json.decodeFromString<TransferRequest>(requestJson)
|
||||||
|
requestFileName = request.fileName
|
||||||
|
|
||||||
val progress = TransferProgress(
|
val progress = TransferProgress(
|
||||||
id = progressId,
|
id = progressId,
|
||||||
@@ -105,6 +108,7 @@ class AndroidFileTransferManager(private val context: Context) : TransferManager
|
|||||||
}
|
}
|
||||||
|
|
||||||
val file = FileUtils.resolveUniqueFile(downloadsDir, request.fileName)
|
val file = FileUtils.resolveUniqueFile(downloadsDir, request.fileName)
|
||||||
|
actualFile = file
|
||||||
val sha256 = MessageDigest.getInstance("SHA-256")
|
val sha256 = MessageDigest.getInstance("SHA-256")
|
||||||
var bytesWritten = 0L
|
var bytesWritten = 0L
|
||||||
|
|
||||||
@@ -157,11 +161,11 @@ class AndroidFileTransferManager(private val context: Context) : TransferManager
|
|||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
e.printStackTrace()
|
e.printStackTrace()
|
||||||
|
actualFile?.let { FileUtils.deleteIfExists(it) }
|
||||||
val current = _incomingTransfers.value.find { it.id == progressId }
|
val current = _incomingTransfers.value.find { it.id == progressId }
|
||||||
if (current != null && current.state != TransferState.COMPLETED &&
|
if (current != null && current.state != TransferState.COMPLETED &&
|
||||||
current.state != TransferState.FAILED && current.state != TransferState.CANCELLED
|
current.state != TransferState.FAILED && current.state != TransferState.CANCELLED
|
||||||
) {
|
) {
|
||||||
FileUtils.deleteIfExists(File(downloadsDir, FileUtils.sanitizeFileName(current.request.fileName)))
|
|
||||||
updateIncomingById(progressId, current.copy(
|
updateIncomingById(progressId, current.copy(
|
||||||
state = TransferState.FAILED,
|
state = TransferState.FAILED,
|
||||||
error = e.message ?: "Unknown error"
|
error = e.message ?: "Unknown error"
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ object WireProtocol {
|
|||||||
private val json = Json {
|
private val json = Json {
|
||||||
encodeDefaults = true
|
encodeDefaults = true
|
||||||
ignoreUnknownKeys = true
|
ignoreUnknownKeys = true
|
||||||
isLenient = true
|
isLenient = false
|
||||||
}
|
}
|
||||||
|
|
||||||
fun encode(command: Command): ByteArray {
|
fun encode(command: Command): ByteArray {
|
||||||
@@ -51,8 +51,10 @@ fun Int.toByteArray(): ByteArray = byteArrayOf(
|
|||||||
(this and 0xFF).toByte()
|
(this and 0xFF).toByte()
|
||||||
)
|
)
|
||||||
|
|
||||||
fun ByteArray.toInt(): Int =
|
fun ByteArray.toInt(): Int {
|
||||||
((this[0].toInt() and 0xFF) shl 24) or
|
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[1].toInt() and 0xFF) shl 16) or
|
||||||
((this[2].toInt() and 0xFF) shl 8) or
|
((this[2].toInt() and 0xFF) shl 8) or
|
||||||
(this[3].toInt() and 0xFF)
|
(this[3].toInt() and 0xFF)
|
||||||
|
}
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ fun SendScreen(
|
|||||||
style = MaterialTheme.typography.bodyLarge
|
style = MaterialTheme.typography.bodyLarge
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
"Разазмер: ${formatSize(selectedFile?.length() ?: 0)}",
|
"Размер: ${formatSize(selectedFile?.length() ?: 0)}",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -75,6 +75,8 @@ class DesktopFileTransferManager : TransferManager {
|
|||||||
private suspend fun handleIncomingConnection(socket: Socket) {
|
private suspend fun handleIncomingConnection(socket: Socket) {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
val progressId = UUID.randomUUID().toString()
|
val progressId = UUID.randomUUID().toString()
|
||||||
|
var actualFile: File? = null
|
||||||
|
var requestFileName: String = ""
|
||||||
try {
|
try {
|
||||||
socket.use { sock ->
|
socket.use { sock ->
|
||||||
sock.soTimeout = 30_000
|
sock.soTimeout = 30_000
|
||||||
@@ -83,6 +85,7 @@ class DesktopFileTransferManager : TransferManager {
|
|||||||
|
|
||||||
val requestJson = input.readUTF()
|
val requestJson = input.readUTF()
|
||||||
val request = Json.decodeFromString<TransferRequest>(requestJson)
|
val request = Json.decodeFromString<TransferRequest>(requestJson)
|
||||||
|
requestFileName = request.fileName
|
||||||
|
|
||||||
val progress = TransferProgress(
|
val progress = TransferProgress(
|
||||||
id = progressId,
|
id = progressId,
|
||||||
@@ -102,6 +105,7 @@ class DesktopFileTransferManager : TransferManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val file = FileUtils.resolveUniqueFile(downloadsDir, request.fileName)
|
val file = FileUtils.resolveUniqueFile(downloadsDir, request.fileName)
|
||||||
|
actualFile = file
|
||||||
val sha256 = MessageDigest.getInstance("SHA-256")
|
val sha256 = MessageDigest.getInstance("SHA-256")
|
||||||
var bytesWritten = 0L
|
var bytesWritten = 0L
|
||||||
|
|
||||||
@@ -154,11 +158,11 @@ class DesktopFileTransferManager : TransferManager {
|
|||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
e.printStackTrace()
|
e.printStackTrace()
|
||||||
|
actualFile?.let { FileUtils.deleteIfExists(it) }
|
||||||
val current = _incomingTransfers.value.find { it.id == progressId }
|
val current = _incomingTransfers.value.find { it.id == progressId }
|
||||||
if (current != null && current.state != TransferState.COMPLETED &&
|
if (current != null && current.state != TransferState.COMPLETED &&
|
||||||
current.state != TransferState.FAILED && current.state != TransferState.CANCELLED
|
current.state != TransferState.FAILED && current.state != TransferState.CANCELLED
|
||||||
) {
|
) {
|
||||||
FileUtils.deleteIfExists(File(downloadsDir, FileUtils.sanitizeFileName(current.request.fileName)))
|
|
||||||
updateIncomingById(progressId, current.copy(
|
updateIncomingById(progressId, current.copy(
|
||||||
state = TransferState.FAILED,
|
state = TransferState.FAILED,
|
||||||
error = e.message ?: "Unknown error"
|
error = e.message ?: "Unknown error"
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user