diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..aee0a06
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,48 @@
+# AeroToss .gitignore
+
+# === Внутренние файлы проекта (не для git) ===
+CHANGELOG.md
+PLAN.md
+iteracii.md
+
+# === Kotlin/Gradle ===
+.gradle/
+.kotlin/
+build/
+!gradle/wrapper/gradle-wrapper.jar
+local.properties
+*.hprof
+
+# === IDE ===
+.idea/
+*.iml
+*.ipr
+*.iws
+.vscode/
+*.swp
+*.swo
+*~
+
+# === Android ===
+*.apk
+*.aab
+*.dex
+*.class
+local.properties
+/captures
+.externalNativeBuild/
+.cxx/
+
+# === OS ===
+.DS_Store
+Thumbs.db
+*.tmp
+*~
+
+# === KMP build ===
+kotlin-js-store/
+*.kotlin_module
+
+# === Сборки релизов ===
+releases/
+build/compose/binaries/
diff --git a/README.md b/README.md
index b6594f7..088ee19 100644
--- a/README.md
+++ b/README.md
@@ -1 +1,32 @@
-AeroToss - кроссплатформенное приложение для передачи файлов с Android устройств на компьютеры под упарвлением Mac/Linux/Windows/FreeBSD и обратно.
+# AeroToss
+
+Кроссплатформенное приложение для передачи файлов между Android устройствами и компьютерами (Mac/Linux/Windows), а также между самими Android устройствами. Работает как удобный аналог AirDrop.
+
+## Возможности
+
+- Передача файлов в обе стороны: телефон ↔ компьютер, телефон ↔ телефон
+- Автоматическое обнаружение устройств в Wi-Fi сети (mDNS/Bonjour)
+- Автономный режим через Wi-Fi Direct (без роутера)
+- Передача файлов любого размера (стриминг чанками по 64 КБ)
+- Прогресс передачи, скорость, проверка целостности (SHA-256)
+
+## Поддерживаемые платформы
+
+| Платформа | Статус |
+|-----------|--------|
+| Android | ✅ Поддерживается (Wi-Fi + Wi-Fi Direct) |
+| macOS | ✅ Поддерживается (Wi-Fi) |
+| Linux | ✅ Поддерживается (Wi-Fi) |
+| Windows | ✅ Поддерживается (Wi-Fi) |
+
+## Стек технологий
+
+- **Язык:** Kotlin 2.3.21
+- **UI:** Compose Multiplatform 1.11.1
+- **Обнаружение:** mDNS — JMDNS 3.6.3 (desktop) / Android NSD API + Wi-Fi Direct P2P (Android)
+- **Передача файлов:** Java Socket + DataInputStream/DataOutputStream
+- **Сборка:** Gradle 8.13 + AGP 8.13.2
+
+## Лицензия
+
+GNU General Public License v3.0 (GPLv3)
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
new file mode 100644
index 0000000..5c6bf73
--- /dev/null
+++ b/app/build.gradle.kts
@@ -0,0 +1,93 @@
+import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
+
+plugins {
+ alias(libs.plugins.kotlin.multiplatform)
+ alias(libs.plugins.kotlin.serialization)
+ alias(libs.plugins.compose.multiplatform)
+ alias(libs.plugins.compose.compiler)
+ alias(libs.plugins.android.library)
+}
+
+kotlin {
+ androidTarget {
+ @OptIn(ExperimentalKotlinGradlePluginApi::class)
+ compilerOptions {
+ jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
+ }
+ }
+
+ jvm("desktop") {
+ @OptIn(ExperimentalKotlinGradlePluginApi::class)
+ compilerOptions {
+ jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
+ }
+ }
+
+ sourceSets {
+ val commonMain by getting {
+ dependencies {
+ implementation(compose.runtime)
+ implementation(compose.foundation)
+ implementation(compose.material3)
+ implementation(compose.materialIconsExtended)
+ implementation(compose.ui)
+ implementation(libs.kotlinx.coroutines.core)
+ implementation(libs.kotlinx.serialization.json)
+ implementation(libs.ktor.client.core)
+ implementation(libs.ktor.network)
+ }
+ }
+
+ val commonTest by getting {
+ dependencies {
+ implementation(libs.kotlin.test)
+ implementation(libs.kotlinx.serialization.json)
+ }
+ }
+
+ val androidMain by getting {
+ dependencies {
+ implementation(libs.kotlinx.coroutines.core)
+ implementation(libs.kotlinx.serialization.json)
+ implementation(libs.androidx.activity.compose)
+ }
+ }
+
+ val desktopMain by getting {
+ dependencies {
+ implementation(compose.desktop.common)
+ implementation(compose.desktop.currentOs)
+ implementation(libs.kotlinx.coroutines.core)
+ implementation(libs.kotlinx.serialization.json)
+ implementation(libs.jmdns)
+ implementation(libs.junit)
+ }
+ }
+ }
+}
+
+android {
+ namespace = "com.aerotoss"
+ compileSdk = 35
+
+ defaultConfig {
+ minSdk = 24
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+
+ lint {
+ abortOnError = false
+ checkReleaseBuilds = false
+ checkDependencies = false
+ }
+}
+
+compose.desktop {
+ application {
+ mainClass = "com.aerotoss.MainKt"
+ }
+}
diff --git a/app/src/androidMain/AndroidManifest.xml b/app/src/androidMain/AndroidManifest.xml
new file mode 100644
index 0000000..3ceae8d
--- /dev/null
+++ b/app/src/androidMain/AndroidManifest.xml
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/androidMain/kotlin/com/aerotoss/MainActivity.kt b/app/src/androidMain/kotlin/com/aerotoss/MainActivity.kt
new file mode 100644
index 0000000..fba6a33
--- /dev/null
+++ b/app/src/androidMain/kotlin/com/aerotoss/MainActivity.kt
@@ -0,0 +1,97 @@
+package com.aerotoss
+
+import android.Manifest
+import android.content.pm.PackageManager
+import android.os.Build
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.core.content.ContextCompat
+import androidx.lifecycle.lifecycleScope
+import com.aerotoss.core.AeroTossManager
+import com.aerotoss.discovery.AndroidNsdDiscovery
+import com.aerotoss.discovery.CompositeAndroidDiscovery
+import com.aerotoss.transfer.AndroidFileTransferManager
+import com.aerotoss.ui.App
+import com.aerotoss.wifi.WifiDirectManager
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+class MainActivity : ComponentActivity() {
+
+ private var manager: AeroTossManager? = null
+
+ private val requiredPermissions: Array
+ get() {
+ val perms = mutableListOf(
+ Manifest.permission.INTERNET,
+ Manifest.permission.ACCESS_WIFI_STATE,
+ Manifest.permission.CHANGE_WIFI_STATE,
+ Manifest.permission.ACCESS_NETWORK_STATE,
+ Manifest.permission.ACCESS_FINE_LOCATION,
+ Manifest.permission.ACCESS_COARSE_LOCATION,
+ Manifest.permission.CHANGE_WIFI_MULTICAST_STATE,
+ )
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ perms.add(Manifest.permission.NEARBY_WIFI_DEVICES)
+ }
+ return perms.toTypedArray()
+ }
+
+ private val permissionLauncher = registerForActivityResult(
+ ActivityResultContracts.RequestMultiplePermissions()
+ ) { permissions ->
+ val allGranted = permissions.values.all { it }
+ if (allGranted && manager == null) {
+ initializeApp()
+ }
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ if (manager != null) return
+
+ if (hasAllPermissions()) {
+ initializeApp()
+ } else {
+ permissionLauncher.launch(requiredPermissions)
+ }
+ }
+
+ private fun hasAllPermissions(): Boolean {
+ return requiredPermissions.all {
+ ContextCompat.checkSelfPermission(this, it) == PackageManager.PERMISSION_GRANTED
+ }
+ }
+
+ private fun initializeApp() {
+ if (manager != null) return
+
+ val transferManager = AndroidFileTransferManager(applicationContext)
+ val nsdDiscovery = AndroidNsdDiscovery(applicationContext)
+ val wifiDirectManager = WifiDirectManager(applicationContext)
+ val discoveryManager = CompositeAndroidDiscovery(nsdDiscovery, wifiDirectManager)
+ val mgr = AeroTossManager(discoveryManager, transferManager)
+ manager = mgr
+
+ lifecycleScope.launch {
+ val port = withContext(Dispatchers.IO) {
+ transferManager.startServer(0)
+ }
+ mgr.start(port)
+ }
+
+ setContent {
+ App(mgr)
+ }
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ manager?.dispose()
+ manager = null
+ }
+}
diff --git a/app/src/androidMain/kotlin/com/aerotoss/discovery/AndroidNsdDiscovery.kt b/app/src/androidMain/kotlin/com/aerotoss/discovery/AndroidNsdDiscovery.kt
new file mode 100644
index 0000000..9e48cfe
--- /dev/null
+++ b/app/src/androidMain/kotlin/com/aerotoss/discovery/AndroidNsdDiscovery.kt
@@ -0,0 +1,141 @@
+package com.aerotoss.discovery
+
+import android.content.Context
+import android.net.nsd.NsdManager
+import android.net.nsd.NsdServiceInfo
+import android.util.Log
+import com.aerotoss.model.Device
+import com.aerotoss.model.DeviceType
+import com.aerotoss.util.generateDeviceId
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import java.util.concurrent.ConcurrentHashMap
+import java.util.concurrent.TimeUnit
+
+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 var registrationListener: NsdManager.RegistrationListener? = null
+ private var discoveryListener: NsdManager.DiscoveryListener? = null
+ private val deviceId = generateDeviceId()
+ private val discoveredDevices = ConcurrentHashMap()
+ private val resolvingServices = ConcurrentHashMap.newKeySet()
+
+ override fun startDiscovery(servicePort: Int) {
+ registerService(servicePort)
+ startServiceDiscovery()
+ }
+
+ private fun registerService(port: Int) {
+ val serviceInfo = NsdServiceInfo().apply {
+ serviceName = SERVICE_NAME
+ serviceType = SERVICE_TYPE
+ setPort(port)
+ }
+
+ registrationListener = object : NsdManager.RegistrationListener {
+ override fun onServiceRegistered(info: NsdServiceInfo) {
+ Log.d(TAG, "Service registered: ${info.serviceName}")
+ }
+
+ override fun onRegistrationFailed(info: NsdServiceInfo, errorCode: Int) {
+ Log.e(TAG, "Registration failed: $errorCode")
+ }
+
+ override fun onServiceUnregistered(info: NsdServiceInfo) {
+ Log.d(TAG, "Service unregistered")
+ }
+
+ override fun onUnregistrationFailed(info: NsdServiceInfo, errorCode: Int) {
+ Log.e(TAG, "Unregistration failed: $errorCode")
+ }
+ }
+
+ nsdManager.registerService(serviceInfo, NsdManager.PROTOCOL_DNS_SD, registrationListener)
+ }
+
+ private fun startServiceDiscovery() {
+ discoveryListener = object : NsdManager.DiscoveryListener {
+ override fun onDiscoveryStarted(serviceType: String) {
+ Log.d(TAG, "Discovery started")
+ }
+
+ override fun onServiceFound(serviceInfo: NsdServiceInfo) {
+ if (serviceInfo.serviceType == SERVICE_TYPE &&
+ serviceInfo.serviceName != SERVICE_NAME &&
+ resolvingServices.add(serviceInfo.serviceName)
+ ) {
+ nsdManager.resolveService(serviceInfo, object : NsdManager.ResolveListener {
+ override fun onResolveFailed(info: NsdServiceInfo, errorCode: Int) {
+ resolvingServices.remove(info.serviceName)
+ Log.e(TAG, "Resolve failed: $errorCode")
+ }
+
+ override fun onServiceResolved(info: NsdServiceInfo) {
+ resolvingServices.remove(info.serviceName)
+ val host = info.host?.hostAddress ?: return
+ val device = Device(
+ id = info.serviceName,
+ name = info.serviceName,
+ type = DeviceType.PHONE,
+ hostAddress = host,
+ port = info.port
+ )
+ discoveredDevices[info.serviceName] = device
+ _devices.value = discoveredDevices.values.toList()
+ }
+ })
+ }
+ }
+
+ override fun onServiceLost(serviceInfo: NsdServiceInfo) {
+ discoveredDevices.remove(serviceInfo.serviceName)
+ resolvingServices.remove(serviceInfo.serviceName)
+ _devices.value = discoveredDevices.values.toList()
+ }
+
+ override fun onDiscoveryStopped(serviceType: String) {
+ Log.d(TAG, "Discovery stopped")
+ }
+
+ override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) {
+ Log.e(TAG, "Discovery start failed: $errorCode")
+ }
+
+ override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) {
+ Log.e(TAG, "Discovery stop failed: $errorCode")
+ }
+ }
+
+ nsdManager.discoverServices(SERVICE_TYPE, NsdManager.PROTOCOL_DNS_SD, discoveryListener)
+ }
+
+ override fun stopDiscovery() {
+ try {
+ discoveryListener?.let { nsdManager.stopServiceDiscovery(it) }
+ } catch (_: Exception) {
+ }
+ try {
+ registrationListener?.let { nsdManager.unregisterService(it) }
+ } catch (_: Exception) {
+ }
+ discoveryListener = null
+ registrationListener = null
+ discoveredDevices.clear()
+ resolvingServices.clear()
+ _devices.value = emptyList()
+ }
+
+ override fun dispose() {
+ stopDiscovery()
+ }
+
+ companion object {
+ private const val TAG = "AndroidNsdDiscovery"
+ private const val SERVICE_TYPE = "_aerotoss._tcp."
+ private const val SERVICE_NAME = "AeroToss"
+ }
+}
diff --git a/app/src/androidMain/kotlin/com/aerotoss/discovery/CompositeAndroidDiscovery.kt b/app/src/androidMain/kotlin/com/aerotoss/discovery/CompositeAndroidDiscovery.kt
new file mode 100644
index 0000000..c17233b
--- /dev/null
+++ b/app/src/androidMain/kotlin/com/aerotoss/discovery/CompositeAndroidDiscovery.kt
@@ -0,0 +1,72 @@
+package com.aerotoss.discovery
+
+import android.content.Context
+import com.aerotoss.model.Device
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.flow.*
+import kotlinx.coroutines.launch
+import com.aerotoss.wifi.WifiDirectManager
+
+class CompositeAndroidDiscovery(
+ private val nsdDiscovery: AndroidNsdDiscovery,
+ private val wifiDirectManager: WifiDirectManager
+) : DiscoveryManager {
+
+ private val _devices = MutableStateFlow>(emptyList())
+ override val devices: Flow> = _devices.asStateFlow()
+
+ private var scope: CoroutineScope? = null
+ private var discoveryJob: kotlinx.coroutines.Job? = null
+
+ override fun startDiscovery(servicePort: Int) {
+ if (discoveryJob != null) return
+
+ nsdDiscovery.startDiscovery(servicePort)
+ wifiDirectManager.initialize()
+ wifiDirectManager.discoverPeers()
+
+ scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
+ discoveryJob = scope?.launch {
+ combine(
+ nsdDiscovery.devices,
+ wifiDirectManager.devices
+ ) { nsdDevices, wifiDirectDevices ->
+ val allDevices = mutableMapOf()
+ nsdDevices.forEach { allDevices["nsd:${it.id}"] = it }
+ wifiDirectDevices.forEach { allDevices["wifi:${it.id}"] = it }
+ allDevices.values.toList()
+ }.collect { devices ->
+ _devices.value = devices
+ }
+ }
+ }
+
+ override fun stopDiscovery() {
+ discoveryJob?.cancel()
+ discoveryJob = null
+ nsdDiscovery.stopDiscovery()
+ wifiDirectManager.stopDiscovery()
+ _devices.value = emptyList()
+ }
+
+ override fun dispose() {
+ stopDiscovery()
+ scope?.cancel()
+ scope = null
+ nsdDiscovery.dispose()
+ wifiDirectManager.cleanup()
+ }
+
+ fun connectWifiDirect(deviceAddress: String) {
+ wifiDirectManager.connect(deviceAddress, groupOwnerIntent = 0)
+ }
+
+ fun disconnectWifiDirect() {
+ wifiDirectManager.disconnect()
+ }
+
+ fun getWifiDirectManager(): WifiDirectManager = wifiDirectManager
+}
diff --git a/app/src/androidMain/kotlin/com/aerotoss/transfer/AndroidTransferManager.kt b/app/src/androidMain/kotlin/com/aerotoss/transfer/AndroidTransferManager.kt
new file mode 100644
index 0000000..cb6155a
--- /dev/null
+++ b/app/src/androidMain/kotlin/com/aerotoss/transfer/AndroidTransferManager.kt
@@ -0,0 +1,314 @@
+package com.aerotoss.transfer
+
+import android.content.Context
+import android.os.Environment
+import com.aerotoss.model.TransferProgress
+import com.aerotoss.model.TransferRequest
+import com.aerotoss.model.TransferState
+import com.aerotoss.util.FileUtils
+import com.aerotoss.util.getDeviceName
+import kotlinx.coroutines.*
+import kotlinx.coroutines.flow.*
+import kotlinx.serialization.json.Json
+import java.io.*
+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.atomic.AtomicBoolean
+
+@OptIn(ExperimentalCoroutinesApi::class)
+
+class AndroidFileTransferManager(private val context: Context) : TransferManager {
+ private val _incomingTransfers = MutableStateFlow>(emptyList())
+ override val incomingTransfers: Flow = _incomingTransfers.mapLatest { list ->
+ list.lastOrNull()
+ }.filterNotNull()
+
+ private val _outgoingTransfers = MutableStateFlow>(emptyList())
+ override val outgoingTransfers: Flow = _outgoingTransfers.mapLatest { list ->
+ list.lastOrNull()
+ }.filterNotNull()
+
+ private var serverSocket: ServerSocket? = null
+ private var serverThread: Thread? = null
+ private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
+ private val activeJobs = ConcurrentHashMap()
+ private val running = AtomicBoolean(false)
+
+ private val downloadsDir: File = run {
+ val dir = context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)
+ ?: context.filesDir
+ File(dir, "AeroToss").apply { mkdirs() }
+ }
+
+ override fun getServerPort(): Int = serverSocket?.localPort ?: 0
+
+ fun startServer(port: Int = 0): Int {
+ if (running.get()) return serverSocket?.localPort ?: 0
+
+ try {
+ val socket = ServerSocket(port)
+ serverSocket = socket
+ running.set(true)
+
+ serverThread = Thread {
+ while (running.get() && !socket.isClosed) {
+ try {
+ val clientSocket = socket.accept()
+ scope.launch { handleIncomingConnection(clientSocket) }
+ } catch (_: Exception) {
+ if (running.get()) break
+ }
+ }
+ }.apply {
+ isDaemon = true
+ name = "aerotoss-server"
+ start()
+ }
+
+ return socket.localPort
+ } catch (e: Exception) {
+ e.printStackTrace()
+ return 0
+ }
+ }
+
+ private suspend fun handleIncomingConnection(socket: Socket) {
+ withContext(Dispatchers.IO) {
+ val progressId = UUID.randomUUID().toString()
+ try {
+ socket.use { sock ->
+ sock.soTimeout = 30_000
+ val input = DataInputStream(sock.getInputStream())
+ val output = DataOutputStream(sock.getOutputStream())
+
+ val requestJson = input.readUTF()
+ val request = Json.decodeFromString(requestJson)
+
+ val progress = TransferProgress(
+ id = progressId,
+ request = request,
+ bytesTransferred = 0,
+ totalBytes = request.fileSize,
+ state = TransferState.PENDING
+ )
+ updateIncoming(progress)
+
+ output.writeUTF(Json.encodeToString(TransferRequest.serializer(), request))
+
+ val accepted = input.readBoolean()
+ if (!accepted) {
+ updateIncoming(progress.copy(state = TransferState.CANCELLED))
+ return@withContext
+ }
+
+ val file = FileUtils.resolveUniqueFile(downloadsDir, request.fileName)
+ val sha256 = MessageDigest.getInstance("SHA-256")
+ var bytesWritten = 0L
+
+ updateIncoming(progress.copy(state = TransferState.TRANSFERRING))
+
+ file.outputStream().use { fos ->
+ val buffer = ByteArray(65536)
+ var remaining = request.fileSize
+ while (remaining > 0) {
+ val toRead = minOf(buffer.size.toLong(), remaining).toInt()
+ val read = input.read(buffer, 0, toRead)
+ if (read == -1) break
+ fos.write(buffer, 0, read)
+ sha256.update(buffer, 0, read)
+ bytesWritten += read
+ remaining -= read
+ updateIncomingById(progressId, TransferProgress(
+ id = progressId,
+ request = request,
+ bytesTransferred = bytesWritten,
+ totalBytes = request.fileSize,
+ state = TransferState.TRANSFERRING
+ ))
+ }
+ }
+
+ val checksum = "sha256:${sha256.digest().joinToString("") { "%02x".format(it) }}"
+ output.writeUTF(checksum)
+ output.writeLong(bytesWritten)
+
+ if (bytesWritten == request.fileSize) {
+ updateIncomingById(progressId, TransferProgress(
+ id = progressId,
+ request = request,
+ bytesTransferred = bytesWritten,
+ totalBytes = request.fileSize,
+ state = TransferState.COMPLETED
+ ))
+ } else {
+ FileUtils.deleteIfExists(file)
+ updateIncomingById(progressId, TransferProgress(
+ id = progressId,
+ request = request,
+ bytesTransferred = bytesWritten,
+ totalBytes = request.fileSize,
+ state = TransferState.FAILED,
+ error = "Incomplete transfer: expected ${request.fileSize}, got $bytesWritten"
+ ))
+ }
+ }
+ } catch (e: Exception) {
+ e.printStackTrace()
+ 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"
+ ))
+ }
+ }
+ }
+ }
+
+ override suspend fun sendFile(file: File, targetHost: String, targetPort: Int): Flow {
+ val requestId = UUID.randomUUID().toString()
+ val request = TransferRequest(
+ fileName = file.name,
+ fileSize = file.length(),
+ mimeType = "application/octet-stream",
+ senderId = "",
+ senderName = getDeviceName()
+ )
+
+ val initialProgress = TransferProgress(
+ id = requestId,
+ request = request,
+ bytesTransferred = 0,
+ totalBytes = file.length(),
+ state = TransferState.PENDING
+ )
+ updateOutgoing(initialProgress)
+
+ val job = scope.launch {
+ try {
+ val socket = Socket(targetHost, targetPort)
+ socket.use { sock ->
+ sock.soTimeout = 30_000
+ val input = DataInputStream(sock.getInputStream())
+ val output = DataOutputStream(sock.getOutputStream())
+
+ output.writeUTF(Json.encodeToString(TransferRequest.serializer(), request))
+
+ val serverResponseJson = input.readUTF()
+ val serverRequest = try {
+ Json.decodeFromString(serverResponseJson)
+ } catch (_: Exception) {
+ request
+ }
+
+ output.writeBoolean(true)
+
+ updateOutgoingById(requestId, initialProgress.copy(state = TransferState.TRANSFERRING))
+
+ val sha256 = MessageDigest.getInstance("SHA-256")
+ var bytesSent = 0L
+ val buffer = ByteArray(65536)
+ file.inputStream().use { fis ->
+ while (true) {
+ val read = fis.read(buffer)
+ if (read == -1) break
+ output.write(buffer, 0, read)
+ sha256.update(buffer, 0, read)
+ bytesSent += read
+ updateOutgoingById(requestId, TransferProgress(
+ id = requestId,
+ request = request,
+ bytesTransferred = bytesSent,
+ totalBytes = file.length(),
+ state = TransferState.TRANSFERRING
+ ))
+ }
+ }
+ output.flush()
+
+ val serverChecksum = input.readUTF()
+ val bytesReceived = input.readLong()
+
+ val localChecksum = "sha256:${sha256.digest().joinToString("") { "%02x".format(it) }}"
+
+ if (bytesReceived == file.length() && serverChecksum == localChecksum) {
+ updateOutgoingById(requestId, TransferProgress(
+ id = requestId,
+ request = request,
+ bytesTransferred = file.length(),
+ totalBytes = file.length(),
+ state = TransferState.COMPLETED
+ ))
+ } else {
+ updateOutgoingById(requestId, TransferProgress(
+ id = requestId,
+ request = request,
+ bytesTransferred = bytesSent,
+ totalBytes = file.length(),
+ state = TransferState.FAILED,
+ error = "Checksum mismatch or incomplete transfer"
+ ))
+ }
+ }
+ } catch (e: Exception) {
+ e.printStackTrace()
+ updateOutgoingById(requestId, initialProgress.copy(
+ state = TransferState.FAILED,
+ error = e.message ?: "Unknown error"
+ ))
+ } finally {
+ activeJobs.remove(requestId)
+ }
+ }
+
+ activeJobs[requestId] = job
+ return outgoingTransfers.filter { it.id == requestId }
+ }
+
+ override fun cancelTransfer(requestId: String) {
+ activeJobs[requestId]?.cancel()
+ activeJobs.remove(requestId)
+ updateOutgoingById(requestId, _outgoingTransfers.value.find { it.id == requestId }?.copy(
+ state = TransferState.CANCELLED
+ ) ?: return)
+ }
+
+ override fun dispose() {
+ running.set(false)
+ activeJobs.values.forEach { it.cancel() }
+ activeJobs.clear()
+ scope.cancel()
+ try { serverSocket?.close() } catch (_: Exception) {}
+ serverThread?.interrupt()
+ }
+
+ private fun updateIncoming(progress: TransferProgress) {
+ _incomingTransfers.update { list ->
+ list.filter { it.id != progress.id }.plus(progress)
+ }
+ }
+
+ private fun updateIncomingById(id: String, progress: TransferProgress) {
+ _incomingTransfers.update { list ->
+ list.filter { it.id != id }.plus(progress)
+ }
+ }
+
+ private fun updateOutgoing(progress: TransferProgress) {
+ _outgoingTransfers.update { list ->
+ list.filter { it.id != progress.id }.plus(progress)
+ }
+ }
+
+ private fun updateOutgoingById(id: String, progress: TransferProgress) {
+ _outgoingTransfers.update { list ->
+ list.filter { it.id != id }.plus(progress)
+ }
+ }
+}
diff --git a/app/src/androidMain/kotlin/com/aerotoss/util/AndroidPlatform.kt b/app/src/androidMain/kotlin/com/aerotoss/util/AndroidPlatform.kt
new file mode 100644
index 0000000..b828594
--- /dev/null
+++ b/app/src/androidMain/kotlin/com/aerotoss/util/AndroidPlatform.kt
@@ -0,0 +1,8 @@
+package com.aerotoss.util
+
+import android.os.Build
+import java.util.UUID
+
+actual fun generateDeviceId(): String = UUID.randomUUID().toString()
+
+actual fun getDeviceName(): String = "${Build.MANUFACTURER} ${Build.MODEL}"
diff --git a/app/src/androidMain/kotlin/com/aerotoss/wifi/WifiDirectManager.kt b/app/src/androidMain/kotlin/com/aerotoss/wifi/WifiDirectManager.kt
new file mode 100644
index 0000000..619d395
--- /dev/null
+++ b/app/src/androidMain/kotlin/com/aerotoss/wifi/WifiDirectManager.kt
@@ -0,0 +1,238 @@
+package com.aerotoss.wifi
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.net.NetworkInfo
+import android.net.wifi.WpsInfo
+import android.net.wifi.p2p.*
+import android.os.Build
+import android.util.Log
+import com.aerotoss.model.Device
+import com.aerotoss.model.DeviceType
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import java.util.concurrent.ConcurrentHashMap
+
+class WifiDirectManager(private val context: Context) {
+
+ private val manager = context.getSystemService(Context.WIFI_P2P_SERVICE) as? WifiP2pManager
+ private var channel: WifiP2pManager.Channel? = null
+ private var receiver: BroadcastReceiver? = null
+
+ private val _peers = MutableStateFlow>(emptyList())
+ val peers: StateFlow> = _peers.asStateFlow()
+
+ private val _devices = MutableStateFlow>(emptyList())
+ val devices: StateFlow> = _devices.asStateFlow()
+
+ private val _connectionInfo = MutableStateFlow(null)
+ val connectionInfo: StateFlow = _connectionInfo.asStateFlow()
+
+ private val _groupInfo = MutableStateFlow(null)
+ val groupInfo: StateFlow = _groupInfo.asStateFlow()
+
+ private val _isDiscovering = MutableStateFlow(false)
+ val isDiscovering: StateFlow = _isDiscovering.asStateFlow()
+
+ private val _isConnected = MutableStateFlow(false)
+ val isConnected: StateFlow = _isConnected.asStateFlow()
+
+ private val _error = MutableStateFlow(null)
+ val error: StateFlow = _error.asStateFlow()
+
+ private val knownDevices = ConcurrentHashMap()
+
+ private val peerListListener = WifiP2pManager.PeerListListener { peerList ->
+ val devices = peerList.deviceList.toList()
+ _peers.value = devices
+
+ devices.forEach { knownDevices[it.deviceAddress] = it }
+
+ _devices.value = devices.map { peer ->
+ Device(
+ id = peer.deviceAddress,
+ name = peer.deviceName.ifEmpty { "Wi-Fi Direct: ${peer.deviceAddress.takeLast(8)}" },
+ type = DeviceType.PHONE,
+ hostAddress = "",
+ port = 0
+ )
+ }
+ Log.d(TAG, "Peers found: ${devices.size}")
+ }
+
+ private val connectionInfoListener = WifiP2pManager.ConnectionInfoListener { info ->
+ _connectionInfo.value = info
+ _isConnected.value = info?.groupFormed == true
+ Log.d(TAG, "Connection info: groupFormed=${info?.groupFormed}, isGroupOwner=${info?.isGroupOwner}")
+ }
+
+ private val groupInfoListener = WifiP2pManager.GroupInfoListener { group ->
+ _groupInfo.value = group
+ if (group != null) {
+ Log.d(TAG, "Group info: owner=${group.owner?.deviceAddress}, clients=${group.clientList.size}")
+ }
+ }
+
+ fun initialize() {
+ if (channel != null) return
+ channel = manager?.initialize(context, context.mainLooper, null)
+ registerReceiver()
+ }
+
+ private fun registerReceiver() {
+ val intentFilter = IntentFilter().apply {
+ addAction(WifiP2pManager.WIFI_P2P_STATE_CHANGED_ACTION)
+ addAction(WifiP2pManager.WIFI_P2P_PEERS_CHANGED_ACTION)
+ addAction(WifiP2pManager.WIFI_P2P_CONNECTION_CHANGED_ACTION)
+ addAction(WifiP2pManager.WIFI_P2P_THIS_DEVICE_CHANGED_ACTION)
+ }
+
+ receiver = object : BroadcastReceiver() {
+ override fun onReceive(ctx: Context, intent: Intent) {
+ when (intent.action) {
+ WifiP2pManager.WIFI_P2P_STATE_CHANGED_ACTION -> {
+ val state = intent.getIntExtra(
+ WifiP2pManager.EXTRA_WIFI_STATE,
+ WifiP2pManager.WIFI_P2P_STATE_DISABLED
+ )
+ if (state != WifiP2pManager.WIFI_P2P_STATE_ENABLED) {
+ _error.value = "Wi-Fi Direct отключён"
+ _isDiscovering.value = false
+ } else {
+ _error.value = null
+ }
+ }
+ WifiP2pManager.WIFI_P2P_PEERS_CHANGED_ACTION -> {
+ manager?.requestPeers(channel, peerListListener)
+ }
+ WifiP2pManager.WIFI_P2P_CONNECTION_CHANGED_ACTION -> {
+ manager?.requestConnectionInfo(channel, connectionInfoListener)
+ manager?.requestGroupInfo(channel, groupInfoListener)
+ }
+ WifiP2pManager.WIFI_P2P_THIS_DEVICE_CHANGED_ACTION -> {
+ // Device info changed
+ }
+ }
+ }
+ }
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ context.registerReceiver(receiver, intentFilter, Context.RECEIVER_NOT_EXPORTED)
+ } else {
+ context.registerReceiver(receiver, intentFilter)
+ }
+ }
+
+ fun discoverPeers() {
+ if (channel == null) {
+ _error.value = "Wi-Fi Direct не инициализирован"
+ return
+ }
+
+ _isDiscovering.value = true
+ _error.value = null
+
+ manager?.discoverPeers(channel, object : WifiP2pManager.ActionListener {
+ override fun onSuccess() {
+ Log.d(TAG, "Peer discovery started")
+ }
+
+ override fun onFailure(reason: Int) {
+ _isDiscovering.value = false
+ _error.value = when (reason) {
+ WifiP2pManager.P2P_UNSUPPORTED -> "Wi-Fi Direct не поддерживается"
+ WifiP2pManager.BUSY -> "Wi-Fi Direct занят"
+ WifiP2pManager.ERROR -> "Ошибка Wi-Fi Direct"
+ else -> "Ошибка: $reason"
+ }
+ Log.e(TAG, "Peer discovery failed: $reason")
+ }
+ })
+ }
+
+ fun stopDiscovery() {
+ manager?.stopPeerDiscovery(channel, object : WifiP2pManager.ActionListener {
+ override fun onSuccess() {
+ _isDiscovering.value = false
+ Log.d(TAG, "Peer discovery stopped")
+ }
+
+ override fun onFailure(reason: Int) {
+ Log.e(TAG, "Stop discovery failed: $reason")
+ }
+ })
+ }
+
+ fun connect(deviceAddress: String, groupOwnerIntent: Int = 0) {
+ val config = WifiP2pConfig().apply {
+ this.deviceAddress = deviceAddress
+ this.groupOwnerIntent = groupOwnerIntent
+ }
+
+ manager?.connect(channel, config, object : WifiP2pManager.ActionListener {
+ override fun onSuccess() {
+ Log.d(TAG, "Connection initiated to $deviceAddress")
+ }
+
+ override fun onFailure(reason: Int) {
+ _error.value = "Ошибка подключения: $reason"
+ Log.e(TAG, "Connection failed: $reason")
+ }
+ })
+ }
+
+ fun disconnect() {
+ manager?.removeGroup(channel, object : WifiP2pManager.ActionListener {
+ override fun onSuccess() {
+ _isConnected.value = false
+ _connectionInfo.value = null
+ _groupInfo.value = null
+ Log.d(TAG, "Disconnected")
+ }
+
+ override fun onFailure(reason: Int) {
+ Log.e(TAG, "Disconnect failed: $reason")
+ }
+ })
+ }
+
+ fun getConnectionAddress(): String? {
+ val info = _connectionInfo.value ?: return null
+ if (!info.groupFormed) return null
+
+ return if (info.isGroupOwner) {
+ GROUP_OWNER_ADDRESS
+ } else {
+ info.groupOwnerAddress?.hostAddress
+ }
+ }
+
+ fun isGroupOwner(): Boolean {
+ return _connectionInfo.value?.isGroupOwner == true
+ }
+
+ fun cleanup() {
+ try {
+ receiver?.let { context.unregisterReceiver(it) }
+ } catch (_: Exception) {
+ }
+ receiver = null
+ disconnect()
+ knownDevices.clear()
+ _peers.value = emptyList()
+ _devices.value = emptyList()
+ _connectionInfo.value = null
+ _groupInfo.value = null
+ _isConnected.value = false
+ _isDiscovering.value = false
+ _error.value = null
+ }
+
+ companion object {
+ private const val TAG = "WifiDirectManager"
+ const val GROUP_OWNER_ADDRESS = "192.168.49.1"
+ }
+}
diff --git a/app/src/commonMain/kotlin/com/aerotoss/core/AeroTossManager.kt b/app/src/commonMain/kotlin/com/aerotoss/core/AeroTossManager.kt
new file mode 100644
index 0000000..918c3b2
--- /dev/null
+++ b/app/src/commonMain/kotlin/com/aerotoss/core/AeroTossManager.kt
@@ -0,0 +1,38 @@
+package com.aerotoss.core
+
+import com.aerotoss.discovery.DiscoveryManager
+import com.aerotoss.model.Device
+import com.aerotoss.model.TransferProgress
+import com.aerotoss.transfer.TransferManager
+import kotlinx.coroutines.flow.Flow
+import java.io.File
+
+class AeroTossManager(
+ private val discovery: DiscoveryManager,
+ private val transfer: TransferManager
+) {
+ val devices: Flow> = discovery.devices
+ val incomingTransfers: Flow = transfer.incomingTransfers
+ val outgoingTransfers: Flow = transfer.outgoingTransfers
+
+ fun start(serverPort: Int) {
+ discovery.startDiscovery(serverPort)
+ }
+
+ suspend fun sendFile(file: File, target: Device): Flow {
+ return transfer.sendFile(file, target.hostAddress, target.port)
+ }
+
+ fun cancelTransfer(requestId: String) {
+ transfer.cancelTransfer(requestId)
+ }
+
+ fun stop() {
+ discovery.stopDiscovery()
+ }
+
+ fun dispose() {
+ discovery.dispose()
+ transfer.dispose()
+ }
+}
diff --git a/app/src/commonMain/kotlin/com/aerotoss/discovery/DiscoveryManager.kt b/app/src/commonMain/kotlin/com/aerotoss/discovery/DiscoveryManager.kt
new file mode 100644
index 0000000..24d6881
--- /dev/null
+++ b/app/src/commonMain/kotlin/com/aerotoss/discovery/DiscoveryManager.kt
@@ -0,0 +1,11 @@
+package com.aerotoss.discovery
+
+import com.aerotoss.model.Device
+import kotlinx.coroutines.flow.Flow
+
+interface DiscoveryManager {
+ val devices: Flow>
+ fun startDiscovery(servicePort: Int)
+ fun stopDiscovery()
+ fun dispose()
+}
diff --git a/app/src/commonMain/kotlin/com/aerotoss/model/Device.kt b/app/src/commonMain/kotlin/com/aerotoss/model/Device.kt
new file mode 100644
index 0000000..8ea92af
--- /dev/null
+++ b/app/src/commonMain/kotlin/com/aerotoss/model/Device.kt
@@ -0,0 +1,17 @@
+package com.aerotoss.model
+
+import kotlinx.serialization.Serializable
+
+enum class DeviceType {
+ PHONE,
+ DESKTOP
+}
+
+@Serializable
+data class Device(
+ val id: String,
+ val name: String,
+ val type: DeviceType,
+ val hostAddress: String,
+ val port: Int
+)
diff --git a/app/src/commonMain/kotlin/com/aerotoss/model/TransferProgress.kt b/app/src/commonMain/kotlin/com/aerotoss/model/TransferProgress.kt
new file mode 100644
index 0000000..19c7ef4
--- /dev/null
+++ b/app/src/commonMain/kotlin/com/aerotoss/model/TransferProgress.kt
@@ -0,0 +1,35 @@
+package com.aerotoss.model
+
+enum class TransferState {
+ PENDING,
+ TRANSFERRING,
+ COMPLETED,
+ FAILED,
+ CANCELLED
+}
+
+data class TransferProgress(
+ val id: String,
+ val request: TransferRequest,
+ val bytesTransferred: Long,
+ val totalBytes: Long,
+ val state: TransferState,
+ val startTimeMs: Long = System.currentTimeMillis(),
+ val error: String? = null
+) {
+ val progress: Float
+ get() = if (totalBytes > 0) bytesTransferred.toFloat() / totalBytes else 0f
+
+ val isComplete: Boolean
+ get() = state == TransferState.COMPLETED
+
+ val speed: Long
+ get() {
+ if (state != TransferState.TRANSFERRING || bytesTransferred == 0L) return 0L
+ val elapsed = (System.currentTimeMillis() - startTimeMs).coerceAtLeast(1)
+ return bytesTransferred * 1000 / elapsed
+ }
+
+ val elapsedMs: Long
+ get() = System.currentTimeMillis() - startTimeMs
+}
diff --git a/app/src/commonMain/kotlin/com/aerotoss/model/TransferRequest.kt b/app/src/commonMain/kotlin/com/aerotoss/model/TransferRequest.kt
new file mode 100644
index 0000000..02cc39c
--- /dev/null
+++ b/app/src/commonMain/kotlin/com/aerotoss/model/TransferRequest.kt
@@ -0,0 +1,12 @@
+package com.aerotoss.model
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class TransferRequest(
+ val fileName: String,
+ val fileSize: Long,
+ val mimeType: String,
+ val senderId: String,
+ val senderName: String
+)
diff --git a/app/src/commonMain/kotlin/com/aerotoss/protocol/Commands.kt b/app/src/commonMain/kotlin/com/aerotoss/protocol/Commands.kt
new file mode 100644
index 0000000..6994e8d
--- /dev/null
+++ b/app/src/commonMain/kotlin/com/aerotoss/protocol/Commands.kt
@@ -0,0 +1,53 @@
+package com.aerotoss.protocol
+
+import com.aerotoss.model.DeviceType
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+sealed interface Command
+
+@Serializable
+@SerialName("DISCOVER")
+data class DiscoverCommand(
+ val deviceName: String,
+ val deviceType: DeviceType
+) : Command
+
+@Serializable
+@SerialName("DISCOVER_ACK")
+data class DiscoverAckCommand(
+ val deviceId: String,
+ val deviceName: String,
+ val deviceType: DeviceType,
+ val port: Int
+) : Command
+
+@Serializable
+@SerialName("SEND_INIT")
+data class SendInitCommand(
+ val fileName: String,
+ val fileSize: Long,
+ val mimeType: String,
+ val requestId: String
+) : Command
+
+@Serializable
+@SerialName("SEND_ACK")
+data class SendAckCommand(
+ val requestId: String,
+ val accepted: Boolean
+) : Command
+
+@Serializable
+@SerialName("SEND_DONE")
+data class SendDoneCommand(
+ val requestId: String,
+ val checksum: String
+) : Command
+
+@Serializable
+@SerialName("REJECT")
+data class RejectCommand(
+ val reason: String
+) : Command
diff --git a/app/src/commonMain/kotlin/com/aerotoss/protocol/WireProtocol.kt b/app/src/commonMain/kotlin/com/aerotoss/protocol/WireProtocol.kt
new file mode 100644
index 0000000..e29e655
--- /dev/null
+++ b/app/src/commonMain/kotlin/com/aerotoss/protocol/WireProtocol.kt
@@ -0,0 +1,58 @@
+package com.aerotoss.protocol
+
+import kotlinx.serialization.encodeToString
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.JsonObject
+import kotlinx.serialization.json.jsonPrimitive
+
+object WireProtocol {
+ private val json = Json {
+ encodeDefaults = true
+ ignoreUnknownKeys = true
+ isLenient = true
+ }
+
+ fun encode(command: Command): ByteArray {
+ val jsonString = json.encodeToString(command)
+ val jsonBytes = jsonString.toByteArray(Charsets.UTF_8)
+ val lengthBytes = jsonBytes.size.toByteArray()
+ return lengthBytes + jsonBytes
+ }
+
+ fun decodeCommand(data: ByteArray): Command? {
+ if (data.size < 4) return null
+ val length = data.sliceArray(0..3).toInt()
+ if (length <= 0 || data.size < 4 + length) return null
+ val jsonString = data.sliceArray(4 until 4 + length).toString(Charsets.UTF_8)
+
+ return try {
+ val jsonObject = json.parseToJsonElement(jsonString) as? JsonObject ?: return null
+ val commandType = jsonObject["type"]?.jsonPrimitive?.content ?: return null
+
+ when (commandType) {
+ "DISCOVER" -> json.decodeFromString(jsonString)
+ "DISCOVER_ACK" -> json.decodeFromString(jsonString)
+ "SEND_INIT" -> json.decodeFromString(jsonString)
+ "SEND_ACK" -> json.decodeFromString(jsonString)
+ "SEND_DONE" -> json.decodeFromString(jsonString)
+ "REJECT" -> json.decodeFromString(jsonString)
+ else -> null
+ }
+ } catch (_: Exception) {
+ null
+ }
+ }
+}
+
+fun Int.toByteArray(): ByteArray = byteArrayOf(
+ ((this shr 24) and 0xFF).toByte(),
+ ((this shr 16) and 0xFF).toByte(),
+ ((this shr 8) and 0xFF).toByte(),
+ (this and 0xFF).toByte()
+)
+
+fun ByteArray.toInt(): Int =
+ ((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/transfer/TransferManager.kt b/app/src/commonMain/kotlin/com/aerotoss/transfer/TransferManager.kt
new file mode 100644
index 0000000..d9d9c7c
--- /dev/null
+++ b/app/src/commonMain/kotlin/com/aerotoss/transfer/TransferManager.kt
@@ -0,0 +1,14 @@
+package com.aerotoss.transfer
+
+import com.aerotoss.model.TransferProgress
+import kotlinx.coroutines.flow.Flow
+import java.io.File
+
+interface TransferManager {
+ val incomingTransfers: Flow
+ val outgoingTransfers: Flow
+ suspend fun sendFile(file: File, targetHost: String, targetPort: Int): Flow
+ fun cancelTransfer(requestId: String)
+ fun dispose()
+ fun getServerPort(): Int
+}
diff --git a/app/src/commonMain/kotlin/com/aerotoss/ui/App.kt b/app/src/commonMain/kotlin/com/aerotoss/ui/App.kt
new file mode 100644
index 0000000..98060e2
--- /dev/null
+++ b/app/src/commonMain/kotlin/com/aerotoss/ui/App.kt
@@ -0,0 +1,10 @@
+package com.aerotoss.ui
+
+import androidx.compose.runtime.Composable
+import com.aerotoss.core.AeroTossManager
+import com.aerotoss.ui.navigation.AppNavigation
+
+@Composable
+fun App(manager: AeroTossManager) {
+ AppNavigation(manager)
+}
diff --git a/app/src/commonMain/kotlin/com/aerotoss/ui/components/DeviceCard.kt b/app/src/commonMain/kotlin/com/aerotoss/ui/components/DeviceCard.kt
new file mode 100644
index 0000000..75896f7
--- /dev/null
+++ b/app/src/commonMain/kotlin/com/aerotoss/ui/components/DeviceCard.kt
@@ -0,0 +1,36 @@
+package com.aerotoss.ui.components
+
+import androidx.compose.foundation.layout.*
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import com.aerotoss.model.Device
+
+@Composable
+fun DeviceCard(
+ device: Device,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ Card(
+ onClick = onClick,
+ modifier = modifier
+ .fillMaxWidth()
+ .padding(8.dp)
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp)
+ ) {
+ Text(
+ text = device.name,
+ style = MaterialTheme.typography.titleMedium
+ )
+ Text(
+ text = device.type.name,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+}
diff --git a/app/src/commonMain/kotlin/com/aerotoss/ui/components/ProgressBar.kt b/app/src/commonMain/kotlin/com/aerotoss/ui/components/ProgressBar.kt
new file mode 100644
index 0000000..7cef528
--- /dev/null
+++ b/app/src/commonMain/kotlin/com/aerotoss/ui/components/ProgressBar.kt
@@ -0,0 +1,65 @@
+package com.aerotoss.ui.components
+
+import androidx.compose.foundation.layout.*
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import com.aerotoss.model.TransferProgress
+import com.aerotoss.model.TransferState
+
+@Composable
+fun TransferProgressBar(
+ progress: TransferProgress,
+ modifier: Modifier = Modifier
+) {
+ Column(
+ modifier = modifier.padding(16.dp)
+ ) {
+ Text(
+ text = progress.request.fileName,
+ style = MaterialTheme.typography.bodyMedium
+ )
+ Spacer(modifier = Modifier.height(4.dp))
+
+ val stateText = when (progress.state) {
+ TransferState.PENDING -> "Ожидание..."
+ TransferState.TRANSFERRING -> "Передача..."
+ TransferState.COMPLETED -> "Завершено"
+ TransferState.FAILED -> "Ошибка: ${progress.error ?: "Неизвестная"}"
+ TransferState.CANCELLED -> "Отменено"
+ }
+ Text(
+ text = stateText,
+ style = MaterialTheme.typography.bodySmall,
+ color = when (progress.state) {
+ TransferState.FAILED -> MaterialTheme.colorScheme.error
+ TransferState.CANCELLED -> MaterialTheme.colorScheme.onSurfaceVariant
+ else -> MaterialTheme.colorScheme.onSurface
+ }
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+ LinearProgressIndicator(
+ progress = { progress.progress },
+ modifier = Modifier.fillMaxWidth()
+ )
+ Spacer(modifier = Modifier.height(4.dp))
+
+ val speedText = if (progress.speed > 0) {
+ formatSpeed(progress.speed)
+ } else {
+ ""
+ }
+ Text(
+ text = "${(progress.progress * 100).toInt()}% $speedText",
+ style = MaterialTheme.typography.bodySmall
+ )
+ }
+}
+
+private fun formatSpeed(bytesPerSecond: Long): String = when {
+ bytesPerSecond >= 1_048_576 -> "%.1f МБ/с".format(bytesPerSecond / 1_048_576.0)
+ bytesPerSecond >= 1024 -> "%.1f КБ/с".format(bytesPerSecond / 1024.0)
+ else -> "$bytesPerSecond Б/с"
+}
diff --git a/app/src/commonMain/kotlin/com/aerotoss/ui/navigation/AppNavigation.kt b/app/src/commonMain/kotlin/com/aerotoss/ui/navigation/AppNavigation.kt
new file mode 100644
index 0000000..3b0156d
--- /dev/null
+++ b/app/src/commonMain/kotlin/com/aerotoss/ui/navigation/AppNavigation.kt
@@ -0,0 +1,43 @@
+package com.aerotoss.ui.navigation
+
+import androidx.compose.runtime.*
+import com.aerotoss.core.AeroTossManager
+import com.aerotoss.ui.screens.HomeScreen
+import com.aerotoss.ui.screens.SendScreen
+import com.aerotoss.ui.screens.ReceiveScreen
+
+@Composable
+fun AppNavigation(manager: AeroTossManager) {
+ var currentScreen by remember { mutableStateOf(Screen.HOME) }
+ var selectedDevice by remember { mutableStateOf(null) }
+
+ when (currentScreen) {
+ Screen.HOME -> HomeScreen(
+ manager = manager,
+ onSendClick = { device ->
+ selectedDevice = device
+ currentScreen = Screen.SEND
+ },
+ onReceiveClick = {
+ currentScreen = Screen.RECEIVE
+ }
+ )
+ Screen.SEND -> SendScreen(
+ manager = manager,
+ targetDevice = selectedDevice,
+ onBack = {
+ selectedDevice = null
+ currentScreen = Screen.HOME
+ }
+ )
+ Screen.RECEIVE -> ReceiveScreen(
+ manager = manager,
+ onBack = { currentScreen = Screen.HOME }
+ )
+ Screen.SETTINGS -> HomeScreen(
+ manager = manager,
+ onSendClick = { },
+ onReceiveClick = { }
+ )
+ }
+}
diff --git a/app/src/commonMain/kotlin/com/aerotoss/ui/navigation/Screen.kt b/app/src/commonMain/kotlin/com/aerotoss/ui/navigation/Screen.kt
new file mode 100644
index 0000000..e88b976
--- /dev/null
+++ b/app/src/commonMain/kotlin/com/aerotoss/ui/navigation/Screen.kt
@@ -0,0 +1,8 @@
+package com.aerotoss.ui.navigation
+
+enum class Screen {
+ HOME,
+ SEND,
+ RECEIVE,
+ SETTINGS
+}
diff --git a/app/src/commonMain/kotlin/com/aerotoss/ui/screens/HomeScreen.kt b/app/src/commonMain/kotlin/com/aerotoss/ui/screens/HomeScreen.kt
new file mode 100644
index 0000000..8d5dc4f
--- /dev/null
+++ b/app/src/commonMain/kotlin/com/aerotoss/ui/screens/HomeScreen.kt
@@ -0,0 +1,99 @@
+package com.aerotoss.ui.screens
+
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import com.aerotoss.core.AeroTossManager
+import com.aerotoss.model.Device
+import com.aerotoss.ui.components.DeviceCard
+import com.aerotoss.ui.components.TransferProgressBar
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun HomeScreen(
+ manager: AeroTossManager,
+ onSendClick: (Device) -> Unit,
+ onReceiveClick: () -> Unit
+) {
+ val devices by manager.devices.collectAsState(initial = emptyList())
+ val outgoingTransfer by manager.outgoingTransfers.collectAsState(initial = null)
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text("AeroToss") },
+ colors = TopAppBarDefaults.topAppBarColors(
+ containerColor = MaterialTheme.colorScheme.primaryContainer
+ )
+ )
+ },
+ floatingActionButton = {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ FloatingActionButton(
+ onClick = onReceiveClick,
+ containerColor = MaterialTheme.colorScheme.secondaryContainer
+ ) {
+ Text("Получить", style = MaterialTheme.typography.labelLarge)
+ }
+ }
+ }
+ ) { padding ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(padding)
+ .padding(16.dp)
+ ) {
+ if (outgoingTransfer != null) {
+ outgoingTransfer?.let { transfer ->
+ TransferProgressBar(transfer)
+ Spacer(modifier = Modifier.height(8.dp))
+ }
+ }
+
+ if (devices.isEmpty()) {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Text(
+ "Поиск устройств...",
+ style = MaterialTheme.typography.bodyLarge
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ CircularProgressIndicator()
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ "Убедитесь, что другое устройство\nтакже запустило AeroToss",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ } else {
+ Text(
+ "Найдено устройств: ${devices.size}",
+ style = MaterialTheme.typography.titleSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ LazyColumn {
+ items(devices, key = { it.id }) { device ->
+ DeviceCard(
+ device = device,
+ onClick = { onSendClick(device) }
+ )
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/commonMain/kotlin/com/aerotoss/ui/screens/ReceiveScreen.kt b/app/src/commonMain/kotlin/com/aerotoss/ui/screens/ReceiveScreen.kt
new file mode 100644
index 0000000..a11d90a
--- /dev/null
+++ b/app/src/commonMain/kotlin/com/aerotoss/ui/screens/ReceiveScreen.kt
@@ -0,0 +1,89 @@
+package com.aerotoss.ui.screens
+
+import androidx.compose.foundation.layout.*
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import com.aerotoss.core.AeroTossManager
+import com.aerotoss.model.TransferState
+import com.aerotoss.ui.components.TransferProgressBar
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun ReceiveScreen(
+ manager: AeroTossManager,
+ onBack: () -> Unit
+) {
+ val incomingTransfer by manager.incomingTransfers.collectAsState(initial = null)
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text("Получение файла") },
+ navigationIcon = {
+ IconButton(onClick = onBack) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Назад")
+ }
+ }
+ )
+ }
+ ) { padding ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(padding)
+ .padding(16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ if (incomingTransfer == null) {
+ Text(
+ "Ожидание входящих файлов...",
+ style = MaterialTheme.typography.bodyLarge
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ CircularProgressIndicator()
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ "Убедитесь, что другое устройство\nотправляет файл на это устройство",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ } else {
+ val transfer = incomingTransfer!!
+ TransferProgressBar(transfer)
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ when (transfer.state) {
+ TransferState.COMPLETED -> {
+ Text(
+ "Файл получен!",
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.primary
+ )
+ }
+ TransferState.FAILED -> {
+ Text(
+ "Ошибка: ${transfer.error}",
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.error
+ )
+ }
+ TransferState.CANCELLED -> {
+ Text(
+ "Передача отменена",
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ else -> { }
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/commonMain/kotlin/com/aerotoss/ui/screens/SendScreen.kt b/app/src/commonMain/kotlin/com/aerotoss/ui/screens/SendScreen.kt
new file mode 100644
index 0000000..1fa7ba8
--- /dev/null
+++ b/app/src/commonMain/kotlin/com/aerotoss/ui/screens/SendScreen.kt
@@ -0,0 +1,154 @@
+package com.aerotoss.ui.screens
+
+import androidx.compose.foundation.layout.*
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import com.aerotoss.core.AeroTossManager
+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
+fun SendScreen(
+ manager: AeroTossManager,
+ targetDevice: Device?,
+ onBack: () -> Unit
+) {
+ val scope = rememberCoroutineScope()
+ val outgoingTransfer by manager.outgoingTransfers.collectAsState(initial = null)
+ var selectedFile by remember { mutableStateOf(null) }
+ var isSending by remember { mutableStateOf(false) }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text("Отправка файла") },
+ navigationIcon = {
+ IconButton(onClick = onBack) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Назад")
+ }
+ }
+ )
+ }
+ ) { padding ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(padding)
+ .padding(16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ if (targetDevice != null) {
+ Text(
+ "Получатель: ${targetDevice.name}",
+ style = MaterialTheme.typography.titleMedium
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ }
+
+ if (selectedFile == 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()}")
+ }
+ }
+ ) {
+ Text("Выбрать файл")
+ }
+ } else {
+ Card(
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Column(modifier = Modifier.padding(16.dp)) {
+ 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 = {
+ selectedFile = 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
+ }) {
+ Text("Попробовать снова")
+ }
+ } else {
+ Button(
+ onClick = {
+ if (!isSending && selectedFile != null && targetDevice != null) {
+ isSending = true
+ scope.launch {
+ try {
+ manager.sendFile(selectedFile!!, targetDevice).collect {}
+ } 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))
+ }
+ 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/FileUtils.kt b/app/src/commonMain/kotlin/com/aerotoss/util/FileUtils.kt
new file mode 100644
index 0000000..128f218
--- /dev/null
+++ b/app/src/commonMain/kotlin/com/aerotoss/util/FileUtils.kt
@@ -0,0 +1,38 @@
+package com.aerotoss.util
+
+import java.io.File
+
+object FileUtils {
+ fun sanitizeFileName(fileName: String): String {
+ val sanitized = fileName
+ .replace(Regex("[/\\\\]"), "_")
+ .replace(Regex("\\.{2,}"), ".")
+ .replace(Regex("[\\x00-\\x1f]"), "")
+ .trim(' ', '.')
+
+ return if (sanitized.isEmpty()) "unnamed_file" else sanitized
+ }
+
+ fun resolveUniqueFile(dir: File, fileName: String): File {
+ val sanitized = sanitizeFileName(fileName)
+ val file = File(dir, sanitized)
+ if (!file.exists()) return file
+
+ val name = file.nameWithoutExtension
+ val ext = file.extension
+ var counter = 1
+ var candidate = File(dir, "${name}_${counter}.${ext}")
+ while (candidate.exists()) {
+ counter++
+ candidate = File(dir, "${name}_${counter}.${ext}")
+ }
+ return candidate
+ }
+
+ fun deleteIfExists(file: File): Boolean {
+ if (file.exists()) {
+ return file.delete()
+ }
+ return false
+ }
+}
diff --git a/app/src/commonMain/kotlin/com/aerotoss/util/Platform.kt b/app/src/commonMain/kotlin/com/aerotoss/util/Platform.kt
new file mode 100644
index 0000000..acfe056
--- /dev/null
+++ b/app/src/commonMain/kotlin/com/aerotoss/util/Platform.kt
@@ -0,0 +1,5 @@
+package com.aerotoss.util
+
+expect fun generateDeviceId(): String
+
+expect fun getDeviceName(): String
diff --git a/app/src/commonTest/kotlin/com/aerotoss/protocol/WireProtocolExtendedTest.kt b/app/src/commonTest/kotlin/com/aerotoss/protocol/WireProtocolExtendedTest.kt
new file mode 100644
index 0000000..5f70048
--- /dev/null
+++ b/app/src/commonTest/kotlin/com/aerotoss/protocol/WireProtocolExtendedTest.kt
@@ -0,0 +1,134 @@
+package com.aerotoss.protocol
+
+import com.aerotoss.model.DeviceType
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNotNull
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+class WireProtocolExtendedTest {
+
+ @Test
+ fun testEncodeProducesValidLengthPrefix() {
+ val cmd = DiscoverCommand("Test", DeviceType.PHONE)
+ val encoded = WireProtocol.encode(cmd)
+ assertTrue(encoded.size >= 4, "Encoded data should have at least 4 bytes for length")
+ val length = encoded.sliceArray(0..3).toInt()
+ assertEquals(encoded.size - 4, length)
+ }
+
+ @Test
+ fun testDecodeEmptyByteArray() {
+ assertNull(WireProtocol.decodeCommand(byteArrayOf()))
+ }
+
+ @Test
+ fun testDecodeTooShortArray() {
+ assertNull(WireProtocol.decodeCommand(byteArrayOf(0, 0)))
+ }
+
+ @Test
+ fun testDecodeInvalidLength() {
+ val data = byteArrayOf(0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte()) + "{}".toByteArray()
+ assertNull(WireProtocol.decodeCommand(data))
+ }
+
+ @Test
+ fun testDecodeZeroLength() {
+ val data = byteArrayOf(0, 0, 0, 0)
+ assertNull(WireProtocol.decodeCommand(data))
+ }
+
+ @Test
+ fun testDecodeTruncatedJson() {
+ val json = """{"type": "DISCOVER""""
+ val jsonBytes = json.toByteArray(Charsets.UTF_8)
+ val lengthBytes = jsonBytes.size.toByteArray()
+ val data = lengthBytes + jsonBytes
+ assertNull(WireProtocol.decodeCommand(data))
+ }
+
+ @Test
+ fun testDecodeUnknownCommandType() {
+ val json = """{"type": "UNKNOWN_COMMAND"}"""
+ val jsonBytes = json.toByteArray(Charsets.UTF_8)
+ val lengthBytes = jsonBytes.size.toByteArray()
+ val data = lengthBytes + jsonBytes
+ assertNull(WireProtocol.decodeCommand(data))
+ }
+
+ @Test
+ fun testRoundTripAllCommandTypes() {
+ val commands = listOf(
+ DiscoverCommand("Phone", DeviceType.PHONE),
+ DiscoverAckCommand("id1", "Desktop", DeviceType.DESKTOP, 8080),
+ SendInitCommand("file.txt", 1024, "text/plain", "req1"),
+ SendAckCommand("req1", true),
+ SendAckCommand("req2", false),
+ SendDoneCommand("req1", "sha256:abc123"),
+ RejectCommand("Too large")
+ )
+
+ for (cmd in commands) {
+ val encoded = WireProtocol.encode(cmd)
+ val decoded = WireProtocol.decodeCommand(encoded)
+ assertNotNull(decoded, "Failed to decode ${cmd::class.simpleName}")
+ assertTrue(cmd::class == decoded::class, "Type mismatch for ${cmd::class.simpleName}")
+ }
+ }
+
+ @Test
+ fun testIntByteArrayRoundTrip() {
+ val values = listOf(0, 1, 127, 128, 255, 256, 65535, 65536, 1048576, Int.MAX_VALUE)
+ for (value in values) {
+ val bytes = value.toByteArray()
+ assertEquals(4, bytes.size, "toByteArray should produce 4 bytes")
+ val restored = bytes.toInt()
+ assertEquals(value, restored, "Round-trip failed for $value")
+ }
+ }
+
+ @Test
+ fun testIntByteArrayNegativeValues() {
+ val values = listOf(-1, -128, -256, Int.MIN_VALUE)
+ for (value in values) {
+ val bytes = value.toByteArray()
+ val restored = bytes.toInt()
+ assertEquals(value, restored, "Round-trip failed for $value")
+ }
+ }
+
+ @Test
+ fun testLargePayload() {
+ val largeName = "a".repeat(10000)
+ val cmd = DiscoverCommand(largeName, DeviceType.PHONE)
+ val encoded = WireProtocol.encode(cmd)
+ val decoded = WireProtocol.decodeCommand(encoded)
+ assertNotNull(decoded)
+ assertTrue(decoded is DiscoverCommand)
+ assertEquals(largeName, decoded.deviceName)
+ }
+
+ @Test
+ fun testSpecialCharactersInPayload() {
+ val specialName = "Тест имя с кириллицей & \"chars\""
+ val cmd = DiscoverCommand(specialName, DeviceType.DESKTOP)
+ val encoded = WireProtocol.encode(cmd)
+ val decoded = WireProtocol.decodeCommand(encoded)
+ assertNotNull(decoded)
+ assertTrue(decoded is DiscoverCommand)
+ assertEquals(specialName, decoded.deviceName)
+ }
+
+ @Test
+ fun testUnicodeInPayload() {
+ val unicodeName = "日本語テスト 🚀 émojis"
+ val cmd = DiscoverCommand(unicodeName, DeviceType.PHONE)
+ val encoded = WireProtocol.encode(cmd)
+ val decoded = WireProtocol.decodeCommand(encoded)
+ assertNotNull(decoded)
+ assertTrue(decoded is DiscoverCommand)
+ assertEquals(unicodeName, decoded.deviceName)
+ }
+}
diff --git a/app/src/commonTest/kotlin/com/aerotoss/protocol/WireProtocolTest.kt b/app/src/commonTest/kotlin/com/aerotoss/protocol/WireProtocolTest.kt
new file mode 100644
index 0000000..e6e80fb
--- /dev/null
+++ b/app/src/commonTest/kotlin/com/aerotoss/protocol/WireProtocolTest.kt
@@ -0,0 +1,137 @@
+package com.aerotoss.protocol
+
+import com.aerotoss.model.DeviceType
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNotNull
+import kotlin.test.assertTrue
+
+class WireProtocolTest {
+
+ @Test
+ fun testDiscoverCommandEncodeDecode() {
+ val original = DiscoverCommand(
+ deviceName = "Test Phone",
+ deviceType = DeviceType.PHONE
+ )
+
+ val encoded = WireProtocol.encode(original)
+ val decoded = WireProtocol.decodeCommand(encoded)
+
+ assertNotNull(decoded)
+ assertTrue(decoded is DiscoverCommand)
+ assertEquals("Test Phone", decoded.deviceName)
+ assertEquals(DeviceType.PHONE, decoded.deviceType)
+ }
+
+ @Test
+ fun testDiscoverAckCommandEncodeDecode() {
+ val original = DiscoverAckCommand(
+ deviceId = "test-uuid-123",
+ deviceName = "Desktop PC",
+ deviceType = DeviceType.DESKTOP,
+ port = 8080
+ )
+
+ val encoded = WireProtocol.encode(original)
+ val decoded = WireProtocol.decodeCommand(encoded)
+
+ assertNotNull(decoded)
+ assertTrue(decoded is DiscoverAckCommand)
+ assertEquals("test-uuid-123", decoded.deviceId)
+ assertEquals("Desktop PC", decoded.deviceName)
+ assertEquals(DeviceType.DESKTOP, decoded.deviceType)
+ assertEquals(8080, decoded.port)
+ }
+
+ @Test
+ fun testSendInitCommandEncodeDecode() {
+ val original = SendInitCommand(
+ fileName = "photo.jpg",
+ fileSize = 1048576L,
+ mimeType = "image/jpeg",
+ requestId = "req-456"
+ )
+
+ val encoded = WireProtocol.encode(original)
+ val decoded = WireProtocol.decodeCommand(encoded)
+
+ assertNotNull(decoded)
+ assertTrue(decoded is SendInitCommand)
+ assertEquals("photo.jpg", decoded.fileName)
+ assertEquals(1048576L, decoded.fileSize)
+ assertEquals("image/jpeg", decoded.mimeType)
+ assertEquals("req-456", decoded.requestId)
+ }
+
+ @Test
+ fun testSendAckCommandEncodeDecode() {
+ val original = SendAckCommand(
+ requestId = "req-789",
+ accepted = true
+ )
+
+ val encoded = WireProtocol.encode(original)
+ val decoded = WireProtocol.decodeCommand(encoded)
+
+ assertNotNull(decoded)
+ assertTrue(decoded is SendAckCommand)
+ assertEquals("req-789", decoded.requestId)
+ assertEquals(true, decoded.accepted)
+ }
+
+ @Test
+ fun testSendDoneCommandEncodeDecode() {
+ val original = SendDoneCommand(
+ requestId = "req-101",
+ checksum = "sha256:abc123"
+ )
+
+ val encoded = WireProtocol.encode(original)
+ val decoded = WireProtocol.decodeCommand(encoded)
+
+ assertNotNull(decoded)
+ assertTrue(decoded is SendDoneCommand)
+ assertEquals("req-101", decoded.requestId)
+ assertEquals("sha256:abc123", decoded.checksum)
+ }
+
+ @Test
+ fun testRejectCommandEncodeDecode() {
+ val original = RejectCommand(
+ reason = "File too large"
+ )
+
+ val encoded = WireProtocol.encode(original)
+ val decoded = WireProtocol.decodeCommand(encoded)
+
+ assertNotNull(decoded)
+ assertTrue(decoded is RejectCommand)
+ assertEquals("File too large", decoded.reason)
+ }
+
+ @Test
+ fun testIntByteArrayConversion() {
+ val testValues = listOf(0, 1, 255, 256, 65535, 1048576, Int.MAX_VALUE)
+ for (value in testValues) {
+ val bytes = value.toByteArray()
+ val restored = bytes.toInt()
+ assertEquals(value, restored, "Failed for value $value")
+ }
+ }
+
+ @Test
+ fun testLengthPrefixFraming() {
+ val command = DiscoverCommand(
+ deviceName = "Test",
+ deviceType = DeviceType.PHONE
+ )
+
+ val encoded = WireProtocol.encode(command)
+
+ val lengthBytes = encoded.take(4).toByteArray()
+ val length = lengthBytes.toInt()
+
+ assertEquals(length, encoded.size - 4)
+ }
+}
diff --git a/app/src/desktopMain/kotlin/com/aerotoss/Main.kt b/app/src/desktopMain/kotlin/com/aerotoss/Main.kt
new file mode 100644
index 0000000..7798a5b
--- /dev/null
+++ b/app/src/desktopMain/kotlin/com/aerotoss/Main.kt
@@ -0,0 +1,30 @@
+package com.aerotoss
+
+import androidx.compose.runtime.*
+import androidx.compose.ui.window.Window
+import androidx.compose.ui.window.application
+import com.aerotoss.core.AeroTossManager
+import com.aerotoss.discovery.JmdnsDiscovery
+import com.aerotoss.transfer.DesktopFileTransferManager
+import com.aerotoss.ui.App
+
+fun main() = application {
+ val transferManager = remember { DesktopFileTransferManager() }
+ val discoveryManager = remember { JmdnsDiscovery() }
+ val manager = remember { AeroTossManager(discoveryManager, transferManager) }
+
+ LaunchedEffect(Unit) {
+ val port = transferManager.startServer(0)
+ manager.start(port)
+ }
+
+ Window(
+ onCloseRequest = {
+ manager.dispose()
+ exitApplication()
+ },
+ title = "AeroToss"
+ ) {
+ App(manager)
+ }
+}
diff --git a/app/src/desktopMain/kotlin/com/aerotoss/discovery/JmdnsDiscovery.kt b/app/src/desktopMain/kotlin/com/aerotoss/discovery/JmdnsDiscovery.kt
new file mode 100644
index 0000000..1caa6ea
--- /dev/null
+++ b/app/src/desktopMain/kotlin/com/aerotoss/discovery/JmdnsDiscovery.kt
@@ -0,0 +1,148 @@
+package com.aerotoss.discovery
+
+import com.aerotoss.model.Device
+import com.aerotoss.model.DeviceType
+import com.aerotoss.util.generateDeviceId
+import com.aerotoss.util.getDeviceName
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import java.net.InetAddress
+import java.net.NetworkInterface
+import java.util.concurrent.ConcurrentHashMap
+import javax.jmdns.JmDNS
+import javax.jmdns.ServiceEvent
+import javax.jmdns.ServiceListener
+import javax.jmdns.ServiceInfo
+
+class JmdnsDiscovery : DiscoveryManager {
+ private val _devices = MutableStateFlow>(emptyList())
+ override val devices: Flow> = _devices.asStateFlow()
+
+ private var jmdns: JmDNS? = null
+ private var serviceListener: ServiceListener? = null
+ private var discoveryThread: Thread? = null
+ private val deviceId = generateDeviceId()
+ private val deviceName = getDeviceName()
+ private val discoveredDevices = ConcurrentHashMap()
+
+ override fun startDiscovery(servicePort: Int) {
+ if (discoveryThread != null) return
+
+ discoveryThread = Thread {
+ try {
+ val addr = findLocalAddress() ?: return@Thread
+ jmdns = JmDNS.create(addr, "aerotoss-$deviceId")
+
+ val serviceInfo = ServiceInfo.create(
+ SERVICE_TYPE,
+ SERVICE_NAME,
+ servicePort,
+ "path=/ aerotoss=1 id=$deviceId name=$deviceName"
+ )
+ jmdns?.registerService(serviceInfo)
+
+ serviceListener = object : ServiceListener {
+ override fun serviceAdded(event: ServiceEvent) {
+ jmdns?.requestServiceInfo(event.type, event.name, true)
+ }
+
+ override fun serviceRemoved(event: ServiceEvent) {
+ discoveredDevices.remove(event.name)
+ _devices.value = discoveredDevices.values.toList()
+ }
+
+ override fun serviceResolved(event: ServiceEvent) {
+ val info = event.info
+ val hostAddresses = info.hostAddresses
+ if (hostAddresses.isNotEmpty()) {
+ val attributes = info.textBytes?.let { parseAttributes(it) } ?: emptyMap()
+ val id = attributes["id"] ?: event.name
+ val name = attributes["name"] ?: event.name
+ val device = Device(
+ id = id,
+ name = name,
+ type = DeviceType.DESKTOP,
+ hostAddress = hostAddresses.first(),
+ port = info.port
+ )
+ if (id != deviceId) {
+ discoveredDevices[event.name] = device
+ _devices.value = discoveredDevices.values.toList()
+ }
+ }
+ }
+ }
+
+ jmdns?.addServiceListener(SERVICE_TYPE, serviceListener)
+ Thread.currentThread().join()
+ } catch (e: Exception) {
+ e.printStackTrace()
+ }
+ }.apply {
+ isDaemon = true
+ name = "aerotoss-mdns"
+ start()
+ }
+ }
+
+ override fun stopDiscovery() {
+ discoveryThread?.interrupt()
+ discoveryThread = null
+ try {
+ serviceListener?.let { jmdns?.removeServiceListener(SERVICE_TYPE, it) }
+ jmdns?.unregisterAllServices()
+ jmdns?.close()
+ } catch (_: Exception) {
+ }
+ jmdns = null
+ serviceListener = null
+ discoveredDevices.clear()
+ _devices.value = emptyList()
+ }
+
+ override fun dispose() {
+ stopDiscovery()
+ }
+
+ private fun findLocalAddress(): InetAddress? {
+ return try {
+ NetworkInterface.getNetworkInterfaces()?.toList()
+ ?.filter { it.isUp && !it.isLoopback && !isVirtual(it) }
+ ?.flatMap { it.inetAddresses.toList() }
+ ?.firstOrNull { it is java.net.Inet4Address }
+ ?: InetAddress.getLocalHost()
+ } catch (_: Exception) {
+ try { InetAddress.getLocalHost() } catch (_: Exception) { null }
+ }
+ }
+
+ private fun isVirtual(iface: NetworkInterface): Boolean {
+ return iface.isVirtual || iface.name.startsWith("vmnet") || iface.name.startsWith("veth")
+ }
+
+ private fun parseAttributes(raw: ByteArray): Map {
+ val result = mutableMapOf()
+ var i = 0
+ while (i < raw.size) {
+ val keyLen = raw[i].toInt() and 0xFF
+ i++
+ if (i + keyLen > raw.size) break
+ val key = String(raw, i, keyLen, Charsets.UTF_8)
+ i += keyLen
+ if (i >= raw.size) break
+ val valueLen = raw[i].toInt() and 0xFF
+ i++
+ if (i + valueLen > raw.size) break
+ val value = String(raw, i, valueLen, Charsets.UTF_8)
+ i += valueLen
+ result[key] = value
+ }
+ return result
+ }
+
+ companion object {
+ const val SERVICE_TYPE = "_aerotoss._tcp.local."
+ const val SERVICE_NAME = "AeroToss"
+ }
+}
diff --git a/app/src/desktopMain/kotlin/com/aerotoss/transfer/DesktopTransferManager.kt b/app/src/desktopMain/kotlin/com/aerotoss/transfer/DesktopTransferManager.kt
new file mode 100644
index 0000000..bd01193
--- /dev/null
+++ b/app/src/desktopMain/kotlin/com/aerotoss/transfer/DesktopTransferManager.kt
@@ -0,0 +1,302 @@
+package com.aerotoss.transfer
+
+import com.aerotoss.model.TransferProgress
+import com.aerotoss.model.TransferRequest
+import com.aerotoss.model.TransferState
+import com.aerotoss.util.FileUtils
+import com.aerotoss.util.getDeviceName
+import kotlinx.coroutines.*
+import kotlinx.coroutines.flow.*
+import kotlinx.serialization.json.Json
+import java.io.*
+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.atomic.AtomicBoolean
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class DesktopFileTransferManager : TransferManager {
+ private val _incomingTransfers = MutableStateFlow>(emptyList())
+ override val incomingTransfers: Flow = _incomingTransfers.flatMapLatest { list ->
+ flow { list.forEach { emit(it) } }
+ }
+
+ private val _outgoingTransfers = MutableStateFlow>(emptyList())
+ override val outgoingTransfers: Flow = _outgoingTransfers.flatMapLatest { list ->
+ flow { list.forEach { emit(it) } }
+ }
+
+ private val transferStates = ConcurrentHashMap>()
+
+ private var serverSocket: ServerSocket? = null
+ private var serverThread: Thread? = null
+ private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
+ private val activeJobs = ConcurrentHashMap()
+ private val running = AtomicBoolean(false)
+
+ private val downloadsDir: File = File(System.getProperty("user.home"), "AeroTossDownloads").apply {
+ mkdirs()
+ }
+
+ override fun getServerPort(): Int = serverSocket?.localPort ?: 0
+
+ fun startServer(port: Int = 0): Int {
+ if (running.get()) return serverSocket?.localPort ?: 0
+
+ try {
+ val socket = ServerSocket(port)
+ serverSocket = socket
+ running.set(true)
+
+ serverThread = Thread {
+ while (running.get() && !socket.isClosed) {
+ try {
+ val clientSocket = socket.accept()
+ scope.launch { handleIncomingConnection(clientSocket) }
+ } catch (_: Exception) {
+ if (running.get()) break
+ }
+ }
+ }.apply {
+ isDaemon = true
+ name = "aerotoss-server"
+ start()
+ }
+
+ return socket.localPort
+ } catch (e: Exception) {
+ e.printStackTrace()
+ return 0
+ }
+ }
+
+ private suspend fun handleIncomingConnection(socket: Socket) {
+ withContext(Dispatchers.IO) {
+ val progressId = UUID.randomUUID().toString()
+ try {
+ socket.use { sock ->
+ sock.soTimeout = 30_000
+ val input = DataInputStream(sock.getInputStream())
+ val output = DataOutputStream(sock.getOutputStream())
+
+ val requestJson = input.readUTF()
+ val request = Json.decodeFromString(requestJson)
+
+ val progress = TransferProgress(
+ id = progressId,
+ request = request,
+ bytesTransferred = 0,
+ totalBytes = request.fileSize,
+ state = TransferState.PENDING
+ )
+ updateIncoming(progress)
+
+ output.writeUTF(Json.encodeToString(TransferRequest.serializer(), request))
+
+ val accepted = input.readBoolean()
+ if (!accepted) {
+ updateIncoming(progress.copy(state = TransferState.CANCELLED))
+ return@withContext
+ }
+
+ val file = FileUtils.resolveUniqueFile(downloadsDir, request.fileName)
+ val sha256 = MessageDigest.getInstance("SHA-256")
+ var bytesWritten = 0L
+
+ updateIncoming(progress.copy(state = TransferState.TRANSFERRING))
+
+ file.outputStream().use { fos ->
+ val buffer = ByteArray(65536)
+ var remaining = request.fileSize
+ while (remaining > 0) {
+ val toRead = minOf(buffer.size.toLong(), remaining).toInt()
+ val read = input.read(buffer, 0, toRead)
+ if (read == -1) break
+ fos.write(buffer, 0, read)
+ sha256.update(buffer, 0, read)
+ bytesWritten += read
+ remaining -= read
+ updateIncomingById(progressId, TransferProgress(
+ id = progressId,
+ request = request,
+ bytesTransferred = bytesWritten,
+ totalBytes = request.fileSize,
+ state = TransferState.TRANSFERRING
+ ))
+ }
+ }
+
+ val checksum = "sha256:${sha256.digest().joinToString("") { "%02x".format(it) }}"
+ output.writeUTF(checksum)
+ output.writeLong(bytesWritten)
+
+ if (bytesWritten == request.fileSize) {
+ updateIncomingById(progressId, TransferProgress(
+ id = progressId,
+ request = request,
+ bytesTransferred = bytesWritten,
+ totalBytes = request.fileSize,
+ state = TransferState.COMPLETED
+ ))
+ } else {
+ FileUtils.deleteIfExists(file)
+ updateIncomingById(progressId, TransferProgress(
+ id = progressId,
+ request = request,
+ bytesTransferred = bytesWritten,
+ totalBytes = request.fileSize,
+ state = TransferState.FAILED,
+ error = "Incomplete transfer: expected ${request.fileSize}, got $bytesWritten"
+ ))
+ }
+ }
+ } catch (e: Exception) {
+ e.printStackTrace()
+ 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"
+ ))
+ }
+ }
+ }
+ }
+
+ override suspend fun sendFile(file: File, targetHost: String, targetPort: Int): Flow {
+ val requestId = UUID.randomUUID().toString()
+ val request = TransferRequest(
+ fileName = file.name,
+ fileSize = file.length(),
+ mimeType = "application/octet-stream",
+ senderId = "",
+ senderName = getDeviceName()
+ )
+
+ val stateFlow = MutableStateFlow(TransferProgress(
+ id = requestId,
+ request = request,
+ bytesTransferred = 0,
+ totalBytes = file.length(),
+ state = TransferState.PENDING
+ ))
+ transferStates[requestId] = stateFlow
+ updateOutgoing(stateFlow.value)
+
+ val job = scope.launch {
+ try {
+ val socket = Socket(targetHost, targetPort)
+ socket.use { sock ->
+ sock.soTimeout = 30_000
+ val input = DataInputStream(sock.getInputStream())
+ val output = DataOutputStream(sock.getOutputStream())
+
+ output.writeUTF(Json.encodeToString(TransferRequest.serializer(), request))
+
+ val serverResponseJson = input.readUTF()
+ try {
+ Json.decodeFromString(serverResponseJson)
+ } catch (_: Exception) {
+ }
+
+ output.writeBoolean(true)
+
+ stateFlow.value = stateFlow.value.copy(state = TransferState.TRANSFERRING)
+ updateOutgoing(stateFlow.value)
+
+ val sha256 = MessageDigest.getInstance("SHA-256")
+ var bytesSent = 0L
+ val buffer = ByteArray(65536)
+ file.inputStream().use { fis ->
+ while (true) {
+ val read = fis.read(buffer)
+ if (read == -1) break
+ output.write(buffer, 0, read)
+ sha256.update(buffer, 0, read)
+ bytesSent += read
+ stateFlow.value = stateFlow.value.copy(
+ bytesTransferred = bytesSent,
+ state = TransferState.TRANSFERRING
+ )
+ updateOutgoing(stateFlow.value)
+ }
+ }
+ output.flush()
+
+ val serverChecksum = input.readUTF()
+ val bytesReceived = input.readLong()
+
+ val localChecksum = "sha256:${sha256.digest().joinToString("") { "%02x".format(it) }}"
+
+ if (bytesReceived == file.length() && serverChecksum == localChecksum) {
+ stateFlow.value = stateFlow.value.copy(
+ bytesTransferred = file.length(),
+ state = TransferState.COMPLETED
+ )
+ } else {
+ stateFlow.value = stateFlow.value.copy(
+ bytesTransferred = bytesSent,
+ state = TransferState.FAILED,
+ error = "Checksum mismatch or incomplete transfer"
+ )
+ }
+ updateOutgoing(stateFlow.value)
+ }
+ } catch (e: Exception) {
+ e.printStackTrace()
+ stateFlow.value = stateFlow.value.copy(
+ state = TransferState.FAILED,
+ error = e.message ?: "Unknown error"
+ )
+ updateOutgoing(stateFlow.value)
+ } finally {
+ activeJobs.remove(requestId)
+ }
+ }
+
+ activeJobs[requestId] = job
+ return stateFlow
+ }
+
+ override fun cancelTransfer(requestId: String) {
+ activeJobs[requestId]?.cancel()
+ activeJobs.remove(requestId)
+ transferStates[requestId]?.let { flow ->
+ flow.value = flow.value.copy(state = TransferState.CANCELLED)
+ updateOutgoing(flow.value)
+ }
+ }
+
+ override fun dispose() {
+ running.set(false)
+ activeJobs.values.forEach { it.cancel() }
+ activeJobs.clear()
+ transferStates.clear()
+ scope.cancel()
+ try { serverSocket?.close() } catch (_: Exception) {}
+ serverThread?.interrupt()
+ }
+
+ private fun updateIncoming(progress: TransferProgress) {
+ _incomingTransfers.update { list ->
+ list.filter { it.id != progress.id }.plus(progress)
+ }
+ }
+
+ private fun updateIncomingById(id: String, progress: TransferProgress) {
+ _incomingTransfers.update { list ->
+ list.filter { it.id != id }.plus(progress)
+ }
+ }
+
+ private fun updateOutgoing(progress: TransferProgress) {
+ _outgoingTransfers.update { list ->
+ list.filter { it.id != progress.id }.plus(progress)
+ }
+ }
+}
diff --git a/app/src/desktopMain/kotlin/com/aerotoss/util/DesktopPlatform.kt b/app/src/desktopMain/kotlin/com/aerotoss/util/DesktopPlatform.kt
new file mode 100644
index 0000000..0fae7be
--- /dev/null
+++ b/app/src/desktopMain/kotlin/com/aerotoss/util/DesktopPlatform.kt
@@ -0,0 +1,16 @@
+package com.aerotoss.util
+
+import java.util.UUID
+
+actual fun generateDeviceId(): String = UUID.randomUUID().toString()
+
+actual fun getDeviceName(): String {
+ val user = System.getProperty("user.name") ?: "Desktop"
+ val os = System.getProperty("os.name") ?: ""
+ val host = try {
+ java.net.InetAddress.getLocalHost().hostName
+ } catch (_: Exception) {
+ "unknown"
+ }
+ return "$user@$host"
+}
diff --git a/app/src/desktopTest/kotlin/com/aerotoss/model/TransferProgressTest.kt b/app/src/desktopTest/kotlin/com/aerotoss/model/TransferProgressTest.kt
new file mode 100644
index 0000000..49dc784
--- /dev/null
+++ b/app/src/desktopTest/kotlin/com/aerotoss/model/TransferProgressTest.kt
@@ -0,0 +1,92 @@
+package com.aerotoss.model
+
+import org.junit.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+class TransferProgressTest {
+
+ private fun createProgress(
+ state: TransferState,
+ bytesTransferred: Long = 0,
+ totalBytes: Long = 1000,
+ startTimeMs: Long = System.currentTimeMillis() - 1000,
+ error: String? = null
+ ) = TransferProgress(
+ id = "test-id",
+ request = TransferRequest(
+ fileName = "test.txt",
+ fileSize = totalBytes,
+ mimeType = "text/plain",
+ senderId = "sender",
+ senderName = "Test"
+ ),
+ bytesTransferred = bytesTransferred,
+ totalBytes = totalBytes,
+ state = state,
+ startTimeMs = startTimeMs,
+ error = error
+ )
+
+ @Test
+ fun testPendingProgress() {
+ val p = createProgress(TransferState.PENDING)
+ assertEquals(0f, p.progress)
+ assertEquals(0L, p.speed)
+ assertFalse(p.isComplete)
+ }
+
+ @Test
+ fun testTransferringProgress() {
+ val p = createProgress(TransferState.TRANSFERRING, bytesTransferred = 500, totalBytes = 1000)
+ assertEquals(0.5f, p.progress, 0.01f)
+ assertTrue(p.speed > 0, "Speed should be positive during transfer")
+ }
+
+ @Test
+ fun testCompletedProgress() {
+ val p = createProgress(TransferState.COMPLETED, bytesTransferred = 1000, totalBytes = 1000)
+ assertEquals(1.0f, p.progress)
+ assertTrue(p.isComplete)
+ assertEquals(0L, p.speed, "Speed should be 0 when completed")
+ }
+
+ @Test
+ fun testFailedProgress() {
+ val p = createProgress(TransferState.FAILED, error = "Connection lost")
+ assertEquals(0f, p.progress)
+ assertFalse(p.isComplete)
+ assertEquals("Connection lost", p.error)
+ }
+
+ @Test
+ fun testCancelledProgress() {
+ val p = createProgress(TransferState.CANCELLED)
+ assertEquals(0f, p.progress)
+ assertFalse(p.isComplete)
+ }
+
+ @Test
+ fun testZeroTotalBytes() {
+ val p = createProgress(TransferState.TRANSFERRING, totalBytes = 0)
+ assertEquals(0f, p.progress, "Progress with 0 total bytes should be 0")
+ }
+
+ @Test
+ fun testElapsedMs() {
+ val start = System.currentTimeMillis() - 500
+ val p = createProgress(TransferState.TRANSFERRING, startTimeMs = start)
+ assertTrue(p.elapsedMs >= 400, "Elapsed should be approximately 500ms")
+ }
+
+ @Test
+ fun testProgressUpdatesIdempotent() {
+ val p1 = createProgress(TransferState.TRANSFERRING, bytesTransferred = 100)
+ val p2 = p1.copy(bytesTransferred = 200)
+ assertEquals(100, p1.bytesTransferred)
+ assertEquals(200, p2.bytesTransferred)
+ assertEquals("test-id", p1.id)
+ assertEquals("test-id", p2.id)
+ }
+}
diff --git a/app/src/desktopTest/kotlin/com/aerotoss/protocol/WifiDirectProtocolTest.kt b/app/src/desktopTest/kotlin/com/aerotoss/protocol/WifiDirectProtocolTest.kt
new file mode 100644
index 0000000..c7f54a5
--- /dev/null
+++ b/app/src/desktopTest/kotlin/com/aerotoss/protocol/WifiDirectProtocolTest.kt
@@ -0,0 +1,88 @@
+package com.aerotoss.protocol
+
+import com.aerotoss.model.DeviceType
+import org.junit.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNotNull
+import kotlin.test.assertTrue
+
+class WifiDirectProtocolTest {
+
+ @Test
+ fun testDeviceTypePhone() {
+ assertEquals("PHONE", DeviceType.PHONE.name)
+ }
+
+ @Test
+ fun testDeviceTypeDesktop() {
+ assertEquals("DESKTOP", DeviceType.DESKTOP.name)
+ }
+
+ @Test
+ fun testDiscoverCommandWithPhoneType() {
+ val cmd = DiscoverCommand(
+ deviceName = "Samsung Galaxy S24",
+ deviceType = DeviceType.PHONE
+ )
+ val encoded = WireProtocol.encode(cmd)
+ val decoded = WireProtocol.decodeCommand(encoded)
+ assertNotNull(decoded)
+ assertTrue(decoded is DiscoverCommand)
+ assertEquals("Samsung Galaxy S24", decoded.deviceName)
+ assertEquals(DeviceType.PHONE, decoded.deviceType)
+ }
+
+ @Test
+ fun testDiscoverAckWithDesktopType() {
+ val cmd = DiscoverAckCommand(
+ deviceId = "wifi-direct-aa:bb:cc:dd:ee:ff",
+ deviceName = "MacBook-Pro",
+ deviceType = DeviceType.DESKTOP,
+ port = 12345
+ )
+ val encoded = WireProtocol.encode(cmd)
+ val decoded = WireProtocol.decodeCommand(encoded)
+ assertNotNull(decoded)
+ assertTrue(decoded is DiscoverAckCommand)
+ assertEquals("wifi-direct-aa:bb:cc:dd:ee:ff", decoded.deviceId)
+ assertEquals(12345, decoded.port)
+ }
+
+ @Test
+ fun testSendInitWithLargeFile() {
+ val cmd = SendInitCommand(
+ fileName = "video_4k.mp4",
+ fileSize = 10_737_418_240L, // 10 GB
+ mimeType = "video/mp4",
+ requestId = "wifi-direct-req-123"
+ )
+ val encoded = WireProtocol.encode(cmd)
+ val decoded = WireProtocol.decodeCommand(encoded)
+ assertNotNull(decoded)
+ assertTrue(decoded is SendInitCommand)
+ assertEquals(10_737_418_240L, decoded.fileSize)
+ }
+
+ @Test
+ fun testSendDoneWithChecksum() {
+ val cmd = SendDoneCommand(
+ requestId = "req-456",
+ checksum = "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
+ )
+ val encoded = WireProtocol.encode(cmd)
+ val decoded = WireProtocol.decodeCommand(encoded)
+ assertNotNull(decoded)
+ assertTrue(decoded is SendDoneCommand)
+ assertEquals("sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", decoded.checksum)
+ }
+
+ @Test
+ fun testRejectCommand() {
+ val cmd = RejectCommand(reason = "File too large for Wi-Fi Direct transfer")
+ val encoded = WireProtocol.encode(cmd)
+ val decoded = WireProtocol.decodeCommand(encoded)
+ assertNotNull(decoded)
+ assertTrue(decoded is RejectCommand)
+ assertEquals("File too large for Wi-Fi Direct transfer", decoded.reason)
+ }
+}
diff --git a/app/src/desktopTest/kotlin/com/aerotoss/transfer/DesktopTransferManagerTest.kt b/app/src/desktopTest/kotlin/com/aerotoss/transfer/DesktopTransferManagerTest.kt
new file mode 100644
index 0000000..cb138b1
--- /dev/null
+++ b/app/src/desktopTest/kotlin/com/aerotoss/transfer/DesktopTransferManagerTest.kt
@@ -0,0 +1,242 @@
+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 kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+class DesktopTransferManagerTest {
+
+ private lateinit var manager: DesktopFileTransferManager
+ private var serverPort: Int = 0
+ private lateinit var tempDir: File
+
+ @Before
+ fun setup() {
+ manager = DesktopFileTransferManager()
+ tempDir = File(System.getProperty("java.io.tmpdir"), "aerotoss-test-${System.currentTimeMillis()}")
+ tempDir.mkdirs()
+ }
+
+ @After
+ fun teardown() {
+ manager.dispose()
+ tempDir.deleteRecursively()
+ }
+
+ @Test
+ fun testStartAndStopServer() {
+ serverPort = manager.startServer(0)
+ assertTrue(serverPort > 0, "Server should bind to a valid port")
+ manager.dispose()
+
+ val manager2 = DesktopFileTransferManager()
+ val port2 = manager2.startServer(serverPort)
+ assertEquals(serverPort, port2, "Should be able to reuse the same port after dispose")
+ manager2.dispose()
+ }
+
+ @Test
+ fun testStartServerTwiceReturnsSamePort() {
+ val port1 = manager.startServer(0)
+ val port2 = manager.startServer(0)
+ assertEquals(port1, port2, "Second call should return same port")
+ }
+
+ @Test
+ fun testSendAndReceiveFile() = runBlocking {
+ val receiver = DesktopFileTransferManager()
+ val receiverPort = receiver.startServer(0)
+
+ try {
+ val sender = DesktopFileTransferManager()
+ val testFile = File(tempDir, "test.txt")
+ val content = "Hello, AeroToss! This is a test file."
+ testFile.writeText(content)
+
+ val result = withTimeout(10_000) {
+ sender.sendFile(testFile, "127.0.0.1", receiverPort).first {
+ it.state == TransferState.COMPLETED || it.state == TransferState.FAILED
+ }
+ }
+
+ assertEquals(TransferState.COMPLETED, result.state, "Transfer should complete: ${result.error}")
+ assertEquals(content.length.toLong(), result.bytesTransferred)
+ assertEquals(content.length.toLong(), result.totalBytes)
+
+ sender.dispose()
+ } finally {
+ receiver.dispose()
+ }
+ }
+
+ @Test
+ fun testTransferProgressUpdates() = runBlocking {
+ val receiver = DesktopFileTransferManager()
+ val receiverPort = receiver.startServer(0)
+
+ try {
+ val sender = DesktopFileTransferManager()
+ val testFile = File(tempDir, "progress-test.bin")
+ testFile.writeBytes(ByteArray(100_000) { it.toByte() })
+
+ val progressUpdates = mutableListOf()
+ val job = launch {
+ sender.sendFile(testFile, "127.0.0.1", receiverPort).collect {
+ progressUpdates.add(it.state)
+ }
+ }
+
+ withTimeout(10_000) {
+ while (progressUpdates.lastOrNull() != TransferState.COMPLETED &&
+ progressUpdates.lastOrNull() != TransferState.FAILED) {
+ delay(50)
+ }
+ }
+ job.cancel()
+
+ assertTrue(
+ progressUpdates.contains(TransferState.TRANSFERRING),
+ "Should have TRANSFERRING state"
+ )
+ assertEquals(TransferState.COMPLETED, progressUpdates.lastOrNull())
+
+ sender.dispose()
+ } finally {
+ receiver.dispose()
+ }
+ }
+
+ @Test
+ fun testCancelTransfer() = runBlocking {
+ val receiver = DesktopFileTransferManager()
+ val receiverPort = 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", receiverPort)
+ delay(100)
+ sender.dispose()
+ } finally {
+ receiver.dispose()
+ }
+ }
+
+ @Test
+ fun testServerReceivesFile() = runBlocking {
+ val receiver = DesktopFileTransferManager()
+ val receiverPort = receiver.startServer(0)
+
+ try {
+ val sender = DesktopFileTransferManager()
+ val testFile = File(tempDir, "receive-test.txt")
+ val content = "Test content for receive"
+ testFile.writeText(content)
+
+ val result = withTimeout(10_000) {
+ sender.sendFile(testFile, "127.0.0.1", receiverPort).first {
+ it.state == TransferState.COMPLETED || it.state == TransferState.FAILED
+ }
+ }
+
+ assertEquals(TransferState.COMPLETED, result.state)
+
+ val receivedFile = File(tempDir, "receive-test.txt")
+ assertTrue(receivedFile.exists(), "File should be received")
+
+ sender.dispose()
+ } finally {
+ receiver.dispose()
+ }
+ }
+
+ @Test
+ fun testLargeFileTransfer() = runBlocking {
+ val receiver = DesktopFileTransferManager()
+ val receiverPort = receiver.startServer(0)
+
+ try {
+ val sender = DesktopFileTransferManager()
+ val testFile = File(tempDir, "large-test.bin")
+ val size = 5 * 1024 * 1024
+ testFile.writeBytes(ByteArray(size) { (it % 256).toByte() })
+
+ val result = withTimeout(30_000) {
+ sender.sendFile(testFile, "127.0.0.1", receiverPort).first {
+ it.state == TransferState.COMPLETED || it.state == TransferState.FAILED
+ }
+ }
+
+ assertEquals(TransferState.COMPLETED, result.state, "Large file transfer should complete: ${result.error}")
+ assertEquals(size.toLong(), result.bytesTransferred)
+
+ sender.dispose()
+ } finally {
+ receiver.dispose()
+ }
+ }
+
+ @Test
+ fun testTransferProgressSpeed() {
+ val progress = com.aerotoss.model.TransferProgress(
+ id = "test",
+ request = com.aerotoss.model.TransferRequest(
+ fileName = "test.txt",
+ fileSize = 1000,
+ mimeType = "text/plain",
+ senderId = "sender",
+ senderName = "Test"
+ ),
+ bytesTransferred = 500,
+ totalBytes = 1000,
+ state = TransferState.TRANSFERRING,
+ startTimeMs = System.currentTimeMillis() - 1000
+ )
+
+ assertTrue(progress.speed > 0, "Speed should be > 0 during transfer")
+ assertEquals(0.5f, progress.progress, 0.01f)
+ }
+
+ @Test
+ fun testTransferProgressCompleteSpeed() {
+ val progress = com.aerotoss.model.TransferProgress(
+ id = "test",
+ request = com.aerotoss.model.TransferRequest(
+ fileName = "test.txt",
+ fileSize = 1000,
+ mimeType = "text/plain",
+ senderId = "sender",
+ senderName = "Test"
+ ),
+ bytesTransferred = 1000,
+ totalBytes = 1000,
+ state = TransferState.COMPLETED,
+ startTimeMs = System.currentTimeMillis() - 1000
+ )
+
+ assertEquals(0L, progress.speed, "Speed should be 0 when completed")
+ assertEquals(1.0f, progress.progress)
+ assertTrue(progress.isComplete)
+ }
+
+ @Test
+ fun testPortAlreadyInUse() {
+ val ss = ServerSocket(0)
+ val port = ss.localPort
+ try {
+ val result = manager.startServer(port)
+ assertEquals(0, result, "Should return 0 when port is in use")
+ } finally {
+ ss.close()
+ }
+ }
+}
diff --git a/app/src/desktopTest/kotlin/com/aerotoss/transfer/WifiDirectTransferTest.kt b/app/src/desktopTest/kotlin/com/aerotoss/transfer/WifiDirectTransferTest.kt
new file mode 100644
index 0000000..dd968b7
--- /dev/null
+++ b/app/src/desktopTest/kotlin/com/aerotoss/transfer/WifiDirectTransferTest.kt
@@ -0,0 +1,127 @@
+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 kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+class WifiDirectTransferTest {
+
+ private lateinit var manager: DesktopFileTransferManager
+ private lateinit var tempDir: File
+
+ @Before
+ fun setup() {
+ manager = DesktopFileTransferManager()
+ tempDir = File(System.getProperty("java.io.tmpdir"), "aerotoss-wfd-test-${System.currentTimeMillis()}")
+ tempDir.mkdirs()
+ }
+
+ @After
+ fun teardown() {
+ manager.dispose()
+ tempDir.deleteRecursively()
+ }
+
+ @Test
+ fun testTransferToLocalhost() = runBlocking {
+ val receiver = DesktopFileTransferManager()
+ val receiverPort = receiver.startServer(0)
+
+ try {
+ val sender = DesktopFileTransferManager()
+ val testFile = File(tempDir, "wfd-test.txt")
+ val content = "Wi-Fi Direct test file content"
+ testFile.writeText(content)
+
+ val result = withTimeout(10_000) {
+ sender.sendFile(testFile, "127.0.0.1", receiverPort).first {
+ it.state == TransferState.COMPLETED || it.state == TransferState.FAILED
+ }
+ }
+
+ assertEquals(TransferState.COMPLETED, result.state, "Transfer should complete: ${result.error}")
+ assertEquals(content.length.toLong(), result.bytesTransferred)
+
+ sender.dispose()
+ } finally {
+ receiver.dispose()
+ }
+ }
+
+ @Test
+ fun testMultipleConcurrentTransfers() = runBlocking {
+ val receiver = DesktopFileTransferManager()
+ val receiverPort = receiver.startServer(0)
+
+ try {
+ val sender = DesktopFileTransferManager()
+
+ val files = (1..3).map { i ->
+ File(tempDir, "concurrent-$i.txt").apply {
+ writeText("Content of file $i")
+ }
+ }
+
+ val results = files.map { file ->
+ async {
+ sender.sendFile(file, "127.0.0.1", receiverPort).first {
+ it.state == TransferState.COMPLETED || it.state == TransferState.FAILED
+ }
+ }
+ }
+
+ results.forEach { deferred ->
+ val result = withTimeout(15_000) { deferred.await() }
+ assertEquals(TransferState.COMPLETED, result.state, "Each transfer should complete: ${result.error}")
+ }
+
+ sender.dispose()
+ } finally {
+ receiver.dispose()
+ }
+ }
+
+ @Test
+ fun testServerPortIsAccessible() {
+ val port = manager.startServer(0)
+ assertTrue(port > 0, "Server port should be positive")
+ assertEquals(port, manager.getServerPort())
+ manager.dispose()
+ }
+
+ @Test
+ fun testTransferBetweenDifferentManagers() = runBlocking {
+ val receiver = DesktopFileTransferManager()
+ val receiverPort = receiver.startServer(0)
+
+ try {
+ val sender = DesktopFileTransferManager()
+ val senderPort = sender.startServer(0)
+
+ assertTrue(senderPort > 0, "Sender should also have a server port")
+
+ val testFile = File(tempDir, "bidirectional.txt")
+ val content = "Bidirectional transfer test"
+ testFile.writeText(content)
+
+ val result = withTimeout(10_000) {
+ sender.sendFile(testFile, "127.0.0.1", receiverPort).first {
+ it.state == TransferState.COMPLETED || it.state == TransferState.FAILED
+ }
+ }
+
+ assertEquals(TransferState.COMPLETED, result.state)
+
+ sender.dispose()
+ } finally {
+ receiver.dispose()
+ }
+ }
+}
diff --git a/app/src/desktopTest/kotlin/com/aerotoss/util/FileUtilsTest.kt b/app/src/desktopTest/kotlin/com/aerotoss/util/FileUtilsTest.kt
new file mode 100644
index 0000000..5c1f876
--- /dev/null
+++ b/app/src/desktopTest/kotlin/com/aerotoss/util/FileUtilsTest.kt
@@ -0,0 +1,114 @@
+package com.aerotoss.util
+
+import org.junit.Test
+import java.io.File
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+class FileUtilsTest {
+
+ @Test
+ fun testSanitizeNormalFileName() {
+ assertEquals("photo.jpg", FileUtils.sanitizeFileName("photo.jpg"))
+ }
+
+ @Test
+ fun testSanitizePathTraversal() {
+ val result = FileUtils.sanitizeFileName("../../etc/passwd")
+ assertFalse(result.contains(".."), "Should not contain ..")
+ assertFalse(result.contains("/"), "Should not contain /")
+ }
+
+ @Test
+ fun testSanitizeBackslash() {
+ val result = FileUtils.sanitizeFileName("C:\\Users\\file.txt")
+ assertFalse(result.contains("\\"), "Should not contain backslash")
+ assertTrue(result.contains("file.txt"), "Should contain file.txt")
+ }
+
+ @Test
+ fun testSanitizeForwardSlash() {
+ val result = FileUtils.sanitizeFileName("/home/user/file.txt")
+ assertFalse(result.contains("/"), "Should not contain /")
+ assertTrue(result.contains("file.txt"), "Should contain file.txt")
+ }
+
+ @Test
+ fun testSanitizeControlChars() {
+ assertEquals("file.txt", FileUtils.sanitizeFileName("file\u0000.txt"))
+ }
+
+ @Test
+ fun testSanitizeEmptyString() {
+ assertEquals("unnamed_file", FileUtils.sanitizeFileName(""))
+ }
+
+ @Test
+ fun testSanitizeUnicode() {
+ assertEquals("тест.txt", FileUtils.sanitizeFileName("тест.txt"))
+ }
+
+ @Test
+ fun testSanitizeEmoji() {
+ assertEquals("🚀.txt", FileUtils.sanitizeFileName("🚀.txt"))
+ }
+
+ @Test
+ fun testResolveUniqueFileNoConflict() {
+ val dir = File(System.getProperty("java.io.tmpdir"), "test-dir-${System.currentTimeMillis()}")
+ dir.mkdirs()
+ try {
+ val result = FileUtils.resolveUniqueFile(dir, "new_file.txt")
+ assertEquals("new_file.txt", result.name)
+ } finally {
+ dir.deleteRecursively()
+ }
+ }
+
+ @Test
+ fun testResolveUniqueFileWithConflict() {
+ val dir = File(System.getProperty("java.io.tmpdir"), "test-dir-${System.currentTimeMillis()}")
+ dir.mkdirs()
+ try {
+ File(dir, "existing.txt").writeText("existing")
+ val result = FileUtils.resolveUniqueFile(dir, "existing.txt")
+ assertEquals("existing_1.txt", result.name)
+ } finally {
+ dir.deleteRecursively()
+ }
+ }
+
+ @Test
+ fun testResolveUniqueFileMultipleConflicts() {
+ val dir = File(System.getProperty("java.io.tmpdir"), "test-dir-${System.currentTimeMillis()}")
+ dir.mkdirs()
+ try {
+ File(dir, "file.txt").writeText("1")
+ File(dir, "file_1.txt").writeText("2")
+ File(dir, "file_2.txt").writeText("3")
+ val result = FileUtils.resolveUniqueFile(dir, "file.txt")
+ assertEquals("file_3.txt", result.name)
+ } finally {
+ dir.deleteRecursively()
+ }
+ }
+
+ @Test
+ fun testDeleteIfExists() {
+ val dir = File(System.getProperty("java.io.tmpdir"), "test-delete-${System.currentTimeMillis()}")
+ dir.mkdirs()
+ val file = File(dir, "to_delete.txt")
+ file.writeText("delete me")
+ assertTrue(file.exists())
+ assertTrue(FileUtils.deleteIfExists(file))
+ assertFalse(file.exists())
+ dir.deleteRecursively()
+ }
+
+ @Test
+ fun testDeleteIfNotExists() {
+ val file = File("/nonexistent/path/file.txt")
+ assertFalse(FileUtils.deleteIfExists(file))
+ }
+}
diff --git a/build.gradle.kts b/build.gradle.kts
new file mode 100644
index 0000000..3aff492
--- /dev/null
+++ b/build.gradle.kts
@@ -0,0 +1,7 @@
+plugins {
+ alias(libs.plugins.kotlin.multiplatform) apply false
+ 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
+}
diff --git a/gradle.properties b/gradle.properties
new file mode 100644
index 0000000..8f2e28c
--- /dev/null
+++ b/gradle.properties
@@ -0,0 +1,4 @@
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+android.useAndroidX=true
+android.nonTransitiveRClass=true
+kotlin.code.style=official
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
new file mode 100644
index 0000000..f83615c
--- /dev/null
+++ b/gradle/libs.versions.toml
@@ -0,0 +1,31 @@
+[versions]
+kotlin = "2.3.21"
+compose-multiplatform = "1.11.1"
+agp = "8.13.2"
+ktor = "3.5.2"
+kotlinx-serialization = "1.11.0"
+kotlinx-coroutines = "1.10.1"
+jmdns = "3.6.3"
+
+[libraries]
+ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
+ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" }
+ktor-server-core = { module = "io.ktor:ktor-server-core", version.ref = "ktor" }
+ktor-server-netty = { module = "io.ktor:ktor-server-netty", version.ref = "ktor" }
+ktor-network = { module = "io.ktor:ktor-network", version.ref = "ktor" }
+ktor-server-content-negotiation = { module = "io.ktor:ktor-server-content-negotiation", version.ref = "ktor" }
+ktor-serialization-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
+jmdns = { module = "org.jmdns:jmdns", version.ref = "jmdns" }
+kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" }
+kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
+androidx-activity-compose = { module = "androidx.activity:activity-compose", version = "1.10.1" }
+junit = { module = "junit:junit", version = "4.13.2" }
+kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
+
+[plugins]
+kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
+kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
+compose-multiplatform = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" }
+compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
+android-application = { id = "com.android.application", version.ref = "agp" }
+android-library = { id = "com.android.library", version.ref = "agp" }
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..a4b76b9
Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..37f853b
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/gradlew b/gradlew
new file mode 100755
index 0000000..d95bf61
--- /dev/null
+++ b/gradlew
@@ -0,0 +1,252 @@
+#!/bin/sh
+
+#
+# Copyright © 2015-2021 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+#
+
+##############################################################################
+#
+# Gradle start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh Gradle
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
+' "$PWD" ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD=$JAVA_HOME/jre/sh/java
+ else
+ JAVACMD=$JAVA_HOME/bin/java
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD=java
+ if ! command -v java >/dev/null 2>&1
+ then
+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+ CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
+ fi
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
+ done
+fi
+
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+# and any embedded shellness will be escaped.
+# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+# treated as '${Hostname}' itself on the command line.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -classpath "$CLASSPATH" \
+ org.gradle.wrapper.GradleWrapperMain \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/gradlew.bat b/gradlew.bat
new file mode 100644
index 0000000..640d686
--- /dev/null
+++ b/gradlew.bat
@@ -0,0 +1,94 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+@rem SPDX-License-Identifier: Apache-2.0
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS=-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if %ERRORLEVEL% equ 0 goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/settings.gradle.kts b/settings.gradle.kts
new file mode 100644
index 0000000..22f0ab2
--- /dev/null
+++ b/settings.gradle.kts
@@ -0,0 +1,27 @@
+pluginManagement {
+ repositories {
+ google {
+ mavenContent {
+ includeGroupByRegex("com\\.android.*")
+ includeGroupByRegex("com\\.google.*")
+ includeGroupByRegex("androidx.*")
+ }
+ }
+ mavenCentral()
+ gradlePluginPortal()
+ maven("https://maven.pkg.jetbrains.space/public/p/compose/dev")
+ }
+}
+
+@Suppress("UnstableApiUsage")
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ maven("https://maven.pkg.jetbrains.space/public/p/compose/dev")
+ }
+}
+
+rootProject.name = "AeroToss"
+include(":app")