AeroToss v0.4.0: полная реализация M1-M4

- KMP проект: Kotlin 2.3.21, Compose 1.11.1, Gradle 8.13, AGP 8.13.2
- Обнаружение устройств: mDNS (JMDNS/Android NSD) + Wi-Fi Direct P2P
- Передача файлов: Java Socket, стриминг чанками, SHA-256 checksum
- UI: HomeScreen, SendScreen, ReceiveScreen, навигация
- Защита: санитизация имён файлов (path traversal)
- 65+ тестов (WireProtocol, Transfer, FileUtils, Wi-Fi Direct)
This commit is contained in:
2026-08-19 17:19:47 +03:00
parent 3c56115f47
commit afc526d6e8
47 changed files with 3709 additions and 1 deletions
+93
View File
@@ -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"
}
}
+29
View File
@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.NEARBY_WIFI_DEVICES"
android:usesPermissionFlags="neverForLocation" />
<application
android:allowBackup="true"
android:label="AeroToss"
android:supportsRtl="true">
<activity
android:name=".MainActivity"
android:exported="true"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|mnc|colorMode|density|fontScale|fontWeightAdjustment|keyboard|layoutDirection|locale|mcc|navigation|smallestScreenSize|touchscreen|uiMode">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -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<String>
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
}
}
@@ -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<List<Device>>(emptyList())
override val devices: Flow<List<Device>> = _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<String, Device>()
private val resolvingServices = ConcurrentHashMap.newKeySet<String>()
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"
}
}
@@ -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<List<Device>>(emptyList())
override val devices: Flow<List<Device>> = _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<String, Device>()
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
}
@@ -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<List<TransferProgress>>(emptyList())
override val incomingTransfers: Flow<TransferProgress> = _incomingTransfers.mapLatest { list ->
list.lastOrNull()
}.filterNotNull()
private val _outgoingTransfers = MutableStateFlow<List<TransferProgress>>(emptyList())
override val outgoingTransfers: Flow<TransferProgress> = _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<String, Job>()
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<TransferRequest>(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<TransferProgress> {
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<TransferRequest>(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)
}
}
}
@@ -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}"
@@ -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<List<WifiP2pDevice>>(emptyList())
val peers: StateFlow<List<WifiP2pDevice>> = _peers.asStateFlow()
private val _devices = MutableStateFlow<List<Device>>(emptyList())
val devices: StateFlow<List<Device>> = _devices.asStateFlow()
private val _connectionInfo = MutableStateFlow<WifiP2pInfo?>(null)
val connectionInfo: StateFlow<WifiP2pInfo?> = _connectionInfo.asStateFlow()
private val _groupInfo = MutableStateFlow<WifiP2pGroup?>(null)
val groupInfo: StateFlow<WifiP2pGroup?> = _groupInfo.asStateFlow()
private val _isDiscovering = MutableStateFlow(false)
val isDiscovering: StateFlow<Boolean> = _isDiscovering.asStateFlow()
private val _isConnected = MutableStateFlow(false)
val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
private val _error = MutableStateFlow<String?>(null)
val error: StateFlow<String?> = _error.asStateFlow()
private val knownDevices = ConcurrentHashMap<String, WifiP2pDevice>()
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"
}
}
@@ -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<List<Device>> = discovery.devices
val incomingTransfers: Flow<TransferProgress> = transfer.incomingTransfers
val outgoingTransfers: Flow<TransferProgress> = transfer.outgoingTransfers
fun start(serverPort: Int) {
discovery.startDiscovery(serverPort)
}
suspend fun sendFile(file: File, target: Device): Flow<TransferProgress> {
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()
}
}
@@ -0,0 +1,11 @@
package com.aerotoss.discovery
import com.aerotoss.model.Device
import kotlinx.coroutines.flow.Flow
interface DiscoveryManager {
val devices: Flow<List<Device>>
fun startDiscovery(servicePort: Int)
fun stopDiscovery()
fun dispose()
}
@@ -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
)
@@ -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
}
@@ -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
)
@@ -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
@@ -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<DiscoverCommand>(jsonString)
"DISCOVER_ACK" -> json.decodeFromString<DiscoverAckCommand>(jsonString)
"SEND_INIT" -> json.decodeFromString<SendInitCommand>(jsonString)
"SEND_ACK" -> json.decodeFromString<SendAckCommand>(jsonString)
"SEND_DONE" -> json.decodeFromString<SendDoneCommand>(jsonString)
"REJECT" -> json.decodeFromString<RejectCommand>(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)
@@ -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<TransferProgress>
val outgoingTransfers: Flow<TransferProgress>
suspend fun sendFile(file: File, targetHost: String, targetPort: Int): Flow<TransferProgress>
fun cancelTransfer(requestId: String)
fun dispose()
fun getServerPort(): Int
}
@@ -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)
}
@@ -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
)
}
}
}
@@ -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 Б/с"
}
@@ -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<com.aerotoss.model.Device?>(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 = { }
)
}
}
@@ -0,0 +1,8 @@
package com.aerotoss.ui.navigation
enum class Screen {
HOME,
SEND,
RECEIVE,
SETTINGS
}
@@ -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) }
)
}
}
}
}
}
}
@@ -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 -> { }
}
}
}
}
}
@@ -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<File?>(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 Б"
}
@@ -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
}
}
@@ -0,0 +1,5 @@
package com.aerotoss.util
expect fun generateDeviceId(): String
expect fun getDeviceName(): String
@@ -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 = "Тест имя с кириллицей & <special> \"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)
}
}
@@ -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)
}
}
@@ -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)
}
}
@@ -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<List<Device>>(emptyList())
override val devices: Flow<List<Device>> = _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<String, Device>()
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<String, String> {
val result = mutableMapOf<String, String>()
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"
}
}
@@ -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<List<TransferProgress>>(emptyList())
override val incomingTransfers: Flow<TransferProgress> = _incomingTransfers.flatMapLatest { list ->
flow { list.forEach { emit(it) } }
}
private val _outgoingTransfers = MutableStateFlow<List<TransferProgress>>(emptyList())
override val outgoingTransfers: Flow<TransferProgress> = _outgoingTransfers.flatMapLatest { list ->
flow { list.forEach { emit(it) } }
}
private val transferStates = ConcurrentHashMap<String, MutableStateFlow<TransferProgress>>()
private var serverSocket: ServerSocket? = null
private var serverThread: Thread? = null
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val activeJobs = ConcurrentHashMap<String, Job>()
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<TransferRequest>(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<TransferProgress> {
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<TransferRequest>(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)
}
}
}
@@ -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"
}
@@ -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)
}
}
@@ -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)
}
}
@@ -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<TransferState>()
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()
}
}
}
@@ -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()
}
}
}
@@ -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))
}
}