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")