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
@@ -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"
}
}