feat(desktop, network): Chat-Funktion hinzugefügt und P2P-Sync verbessert
Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
This commit is contained in:
+59
-59
@@ -9,79 +9,79 @@ import javax.crypto.spec.IvParameterSpec
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
class FileBackupService(private val deviceName: String) : BackupService {
|
||||
private val json = Json { prettyPrint = true }
|
||||
private val json = Json { prettyPrint = true }
|
||||
|
||||
override fun exportDelta(data: String, targetPath: String, sharedKey: String): Result<String> {
|
||||
return try {
|
||||
val timestamp = System.currentTimeMillis()
|
||||
val checksum = calculateChecksum(data)
|
||||
val payload = BackupPayload(timestamp, deviceName, data, checksum)
|
||||
val jsonContent = json.encodeToString(payload)
|
||||
override fun exportDelta(data: String, targetPath: String, sharedKey: String): Result<String> {
|
||||
return try {
|
||||
val timestamp = System.currentTimeMillis()
|
||||
val checksum = calculateChecksum(data)
|
||||
val payload = BackupPayload(timestamp, deviceName, data, checksum)
|
||||
val jsonContent = json.encodeToString(payload)
|
||||
|
||||
val encryptedData = encrypt(jsonContent, sharedKey)
|
||||
val encryptedData = encrypt(jsonContent, sharedKey)
|
||||
|
||||
val dir = File(targetPath)
|
||||
if (!dir.exists()) dir.mkdirs()
|
||||
val dir = File(targetPath)
|
||||
if (!dir.exists()) dir.mkdirs()
|
||||
|
||||
val fileName = "delta_${timestamp}_${deviceName}.msbackup"
|
||||
val file = File(dir, fileName)
|
||||
file.writeText(encryptedData)
|
||||
val fileName = "delta_${timestamp}_${deviceName}.msbackup"
|
||||
val file = File(dir, fileName)
|
||||
file.writeText(encryptedData)
|
||||
|
||||
println("[Plan-USB] Export erfolgreich: ${file.absolutePath}")
|
||||
Result.success(file.absoluteName)
|
||||
} catch (e: Exception) {
|
||||
println("[Plan-USB] Export fehlgeschlagen: ${e.message}")
|
||||
Result.failure(e)
|
||||
}
|
||||
println("[Plan-USB] Export erfolgreich: ${file.absolutePath}")
|
||||
Result.success(file.absoluteName)
|
||||
} catch (e: Exception) {
|
||||
println("[Plan-USB] Export fehlgeschlagen: ${e.message}")
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
override fun importDelta(filePath: String, sharedKey: String): Result<String> {
|
||||
return try {
|
||||
val file = File(filePath)
|
||||
val encryptedData = file.readText()
|
||||
val jsonContent = decrypt(encryptedData, sharedKey)
|
||||
val payload = json.decodeFromString<BackupPayload>(jsonContent)
|
||||
override fun importDelta(filePath: String, sharedKey: String): Result<String> {
|
||||
return try {
|
||||
val file = File(filePath)
|
||||
val encryptedData = file.readText()
|
||||
val jsonContent = decrypt(encryptedData, sharedKey)
|
||||
val payload = json.decodeFromString<BackupPayload>(jsonContent)
|
||||
|
||||
if (calculateChecksum(payload.data) != payload.checksum) {
|
||||
throw Exception("Checksummenfehler: Daten wurden möglicherweise manipuliert.")
|
||||
}
|
||||
if (calculateChecksum(payload.data) != payload.checksum) {
|
||||
throw Exception("Checksummenfehler: Daten wurden möglicherweise manipuliert.")
|
||||
}
|
||||
|
||||
println("[Plan-USB] Import erfolgreich von ${payload.deviceName}")
|
||||
Result.success(payload.data)
|
||||
} catch (e: Exception) {
|
||||
println("[Plan-USB] Import fehlgeschlagen: ${e.message}")
|
||||
Result.failure(e)
|
||||
}
|
||||
println("[Plan-USB] Import erfolgreich von ${payload.deviceName}")
|
||||
Result.success(payload.data)
|
||||
} catch (e: Exception) {
|
||||
println("[Plan-USB] Import fehlgeschlagen: ${e.message}")
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun calculateChecksum(data: String): String {
|
||||
val bytes = MessageDigest.getInstance("SHA-256").digest(data.toByteArray())
|
||||
return bytes.joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
private fun calculateChecksum(data: String): String {
|
||||
val bytes = MessageDigest.getInstance("SHA-256").digest(data.toByteArray())
|
||||
return bytes.joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
|
||||
private fun encrypt(data: String, key: String): String {
|
||||
val secretKey = generateKey(key)
|
||||
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
|
||||
val iv = IvParameterSpec(ByteArray(16)) // Vereinfacht für PoC
|
||||
cipher.init(Cipher.ENCRYPT_MODE, secretKey, iv)
|
||||
val encrypted = cipher.doFinal(data.toByteArray())
|
||||
return Base64.getEncoder().encodeToString(encrypted)
|
||||
}
|
||||
private fun encrypt(data: String, key: String): String {
|
||||
val secretKey = generateKey(key)
|
||||
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
|
||||
val iv = IvParameterSpec(ByteArray(16)) // Vereinfacht für PoC
|
||||
cipher.init(Cipher.ENCRYPT_MODE, secretKey, iv)
|
||||
val encrypted = cipher.doFinal(data.toByteArray())
|
||||
return Base64.getEncoder().encodeToString(encrypted)
|
||||
}
|
||||
|
||||
private fun decrypt(encrypted: String, key: String): String {
|
||||
val secretKey = generateKey(key)
|
||||
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
|
||||
val iv = IvParameterSpec(ByteArray(16))
|
||||
cipher.init(Cipher.DECRYPT_MODE, secretKey, iv)
|
||||
val decrypted = cipher.doFinal(Base64.getDecoder().decode(encrypted))
|
||||
return String(decrypted)
|
||||
}
|
||||
private fun decrypt(encrypted: String, key: String): String {
|
||||
val secretKey = generateKey(key)
|
||||
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
|
||||
val iv = IvParameterSpec(ByteArray(16))
|
||||
cipher.init(Cipher.DECRYPT_MODE, secretKey, iv)
|
||||
val decrypted = cipher.doFinal(Base64.getDecoder().decode(encrypted))
|
||||
return String(decrypted)
|
||||
}
|
||||
|
||||
private fun generateKey(key: String): SecretKeySpec {
|
||||
val sha = MessageDigest.getInstance("SHA-256")
|
||||
val keyBytes = sha.digest(key.toByteArray()).copyOf(16) // AES-128 für Kompatibilität
|
||||
return SecretKeySpec(keyBytes, "AES")
|
||||
}
|
||||
private fun generateKey(key: String): SecretKeySpec {
|
||||
val sha = MessageDigest.getInstance("SHA-256")
|
||||
val keyBytes = sha.digest(key.toByteArray()).copyOf(16) // AES-128 für Kompatibilität
|
||||
return SecretKeySpec(keyBytes, "AES")
|
||||
}
|
||||
}
|
||||
|
||||
private val File.absoluteName: String get() = this.name
|
||||
|
||||
+2
-2
@@ -9,6 +9,6 @@ import org.koin.dsl.module
|
||||
* JVM-spezifische Implementierung des DiscoveryModules.
|
||||
*/
|
||||
actual val discoveryModule: Module = module {
|
||||
single<NetworkDiscoveryService> { JmDnsDiscoveryService() }
|
||||
single<BackupService> { (deviceName: String) -> FileBackupService(deviceName) }
|
||||
single<NetworkDiscoveryService> { JmDnsDiscoveryService() }
|
||||
single<BackupService> { (deviceName: String) -> FileBackupService(deviceName) }
|
||||
}
|
||||
|
||||
+10
-4
@@ -22,8 +22,10 @@ class JmDnsDiscoveryService : NetworkDiscoveryService {
|
||||
private val registeredSet = ConcurrentHashMap.newKeySet<String>() // key: "${name}@${addr.hostAddress}:$port"
|
||||
|
||||
// Debounce/Guards
|
||||
@Volatile private var lastStartRequestedAt: Long = 0L
|
||||
@Volatile private var lastStartIp: String? = null
|
||||
@Volatile
|
||||
private var lastStartRequestedAt: Long = 0L
|
||||
@Volatile
|
||||
private var lastStartIp: String? = null
|
||||
|
||||
private val _discoveredServices = MutableStateFlow<List<DiscoveredService>>(emptyList())
|
||||
override val discoveredServices: StateFlow<List<DiscoveredService>> = _discoveredServices.asStateFlow()
|
||||
@@ -149,7 +151,10 @@ class JmDnsDiscoveryService : NetworkDiscoveryService {
|
||||
val name = iface.name.lowercase()
|
||||
// Filtere Docker/Bridged/VETH/VM-Schnittstellen heraus
|
||||
if (iface.isLoopback || !iface.isUp || iface.isVirtual) continue
|
||||
if (name.startsWith("br-") || name.startsWith("docker") || name.startsWith("veth") || name.contains("vmnet") || name.contains("virbr")) continue
|
||||
if (name.startsWith("br-") || name.startsWith("docker") || name.startsWith("veth") || name.contains("vmnet") || name.contains(
|
||||
"virbr"
|
||||
)
|
||||
) continue
|
||||
|
||||
val inetAddresses = iface.inetAddresses
|
||||
while (inetAddresses.hasMoreElements()) {
|
||||
@@ -172,7 +177,8 @@ class JmDnsDiscoveryService : NetworkDiscoveryService {
|
||||
// Bevorzuge private LAN IPv4 (192.168.x.x, 10.x.x.x, 172.16-31.x.x)
|
||||
fun isPrivateIPv4(a: InetAddress): Boolean {
|
||||
val h = a.hostAddress
|
||||
return h.startsWith("192.168.") || h.startsWith("10.") || (h.startsWith("172.") && h.split('.').getOrNull(1)?.toIntOrNull() in 16..31)
|
||||
return h.startsWith("192.168.") || h.startsWith("10.") || (h.startsWith("172.") && h.split('.').getOrNull(1)
|
||||
?.toIntOrNull() in 16..31)
|
||||
}
|
||||
return addresses.sortedWith(compareByDescending<InetAddress> { isPrivateIPv4(it) }
|
||||
.thenBy { it.hostAddress })
|
||||
|
||||
+148
-112
@@ -2,145 +2,181 @@ package at.mocode.frontend.core.network.sync
|
||||
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.plugins.websocket.*
|
||||
import io.ktor.client.plugins.websocket.WebSockets
|
||||
import io.ktor.server.application.*
|
||||
import io.ktor.server.engine.*
|
||||
import io.ktor.server.netty.*
|
||||
import io.ktor.server.routing.*
|
||||
import io.ktor.server.websocket.*
|
||||
import io.ktor.websocket.*
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.util.*
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
/**
|
||||
* JVM-spezifische Implementierung des P2pSyncService mit Fokus auf Stabilität.
|
||||
* Beinhaltet Reconnection-Logik, Heartbeats und robustes Session-Management.
|
||||
*/
|
||||
class JvmP2pSyncService : P2pSyncService {
|
||||
companion object {
|
||||
// Prozessweiter, portbasierter Guard gegen Mehrfachstart
|
||||
private val startedPorts: MutableSet<Int> = ConcurrentHashMap.newKeySet()
|
||||
companion object {
|
||||
private val startedPorts: MutableSet<Int> = ConcurrentHashMap.newKeySet()
|
||||
private const val RECONNECT_DELAY_MS = 3000L
|
||||
private const val PING_INTERVAL_MS = 5000L
|
||||
private const val PING_TIMEOUT_MS = 10000L
|
||||
}
|
||||
|
||||
private var server: EmbeddedServer<*, *>? = null
|
||||
private var currentPort: Int? = null
|
||||
private val client = HttpClient {
|
||||
install(WebSockets) {
|
||||
pingInterval = PING_INTERVAL_MS.milliseconds
|
||||
}
|
||||
private var server: EmbeddedServer<*, *>? = null
|
||||
private var currentPort: Int? = null
|
||||
private val client = HttpClient {
|
||||
install(io.ktor.client.plugins.websocket.WebSockets)
|
||||
}
|
||||
|
||||
private val _incomingEvents = MutableSharedFlow<SyncEvent>(extraBufferCapacity = 64)
|
||||
override val incomingEvents: Flow<SyncEvent> = _incomingEvents.asSharedFlow()
|
||||
|
||||
private val activeSessions = Collections.synchronizedSet(LinkedHashSet<DefaultWebSocketSession>())
|
||||
private val _connectedPeers = MutableStateFlow<List<String>>(emptyList())
|
||||
override val connectedPeers: Flow<List<String>> = _connectedPeers.asStateFlow()
|
||||
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
private val connectionJobs = ConcurrentHashMap<String, Job>()
|
||||
|
||||
override fun startServer(port: Int) {
|
||||
if (server != null) {
|
||||
println("[P2P Server] Bereits aktiv auf Port ${currentPort ?: port}")
|
||||
return
|
||||
}
|
||||
|
||||
private val _incomingEvents = MutableSharedFlow<SyncEvent>()
|
||||
override val incomingEvents: Flow<SyncEvent> = _incomingEvents.asSharedFlow()
|
||||
|
||||
private val activeSessions = Collections.synchronizedSet(LinkedHashSet<DefaultWebSocketSession>())
|
||||
private val _connectedPeers = MutableStateFlow<List<String>>(emptyList())
|
||||
override val connectedPeers: Flow<List<String>> = _connectedPeers.asStateFlow()
|
||||
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
override fun startServer(port: Int) {
|
||||
// Instanz-Guard (gleiche Instanz)
|
||||
if (server != null) {
|
||||
println("[P2P Server] Bereits gestartet (Instanz) auf Port ${currentPort ?: port} – idempotent")
|
||||
return
|
||||
}
|
||||
|
||||
// Prozessweiter, portbasierter Guard
|
||||
println("[P2P Server] Versuche Port $port zu reservieren...")
|
||||
if (!startedPorts.add(port)) {
|
||||
println("[P2P Server] Bereits gestartet (Prozess) auf Port $port – idempotent, kein neuer Bind")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
server = embeddedServer(Netty, port = port) {
|
||||
install(io.ktor.server.websocket.WebSockets)
|
||||
routing {
|
||||
webSocket("/sync") {
|
||||
println("[P2P Server] Neuer Peer verbunden")
|
||||
activeSessions.add(this)
|
||||
updatePeers()
|
||||
try {
|
||||
for (frame in incoming) {
|
||||
if (frame is Frame.Text) {
|
||||
val text = frame.readText()
|
||||
try {
|
||||
val event = Json.decodeFromString<SyncEvent>(text)
|
||||
_incomingEvents.emit(event)
|
||||
} catch (e: Exception) {
|
||||
println("[P2P Server] Fehler beim Dekodieren: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
activeSessions.remove(this)
|
||||
updatePeers()
|
||||
println("[P2P Server] Peer getrennt")
|
||||
}
|
||||
}
|
||||
}
|
||||
}.start(wait = false)
|
||||
currentPort = port
|
||||
} catch (e: Exception) {
|
||||
// Start fehlgeschlagen -> Port-Lock wieder freigeben
|
||||
startedPorts.remove(port)
|
||||
server = null
|
||||
currentPort = null
|
||||
println("[P2P Server] Start auf Port $port fehlgeschlagen: ${e.message}")
|
||||
throw e
|
||||
}
|
||||
if (!startedPorts.add(port)) {
|
||||
println("[P2P Server] Port $port wird bereits von einer anderen Instanz genutzt.")
|
||||
return
|
||||
}
|
||||
|
||||
override fun stopServer() {
|
||||
try {
|
||||
server?.stop(1000, 2000)
|
||||
} finally {
|
||||
server = null
|
||||
currentPort?.let { startedPorts.remove(it) }
|
||||
currentPort = null
|
||||
try {
|
||||
server = embeddedServer(Netty, port = port, host = "0.0.0.0") {
|
||||
install(io.ktor.server.websocket.WebSockets) {
|
||||
pingPeriod = PING_INTERVAL_MS.milliseconds
|
||||
timeout = PING_TIMEOUT_MS.milliseconds
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun connectToPeer(host: String, port: Int) {
|
||||
scope.launch {
|
||||
routing {
|
||||
webSocket("/sync") {
|
||||
val remote = call.request.local.remoteAddress
|
||||
println("[P2P Server] Neuer Peer verbunden: $remote")
|
||||
activeSessions.add(this)
|
||||
updatePeers()
|
||||
try {
|
||||
client.webSocket(host = host, port = port, path = "/sync") {
|
||||
println("[P2P Client] Verbunden mit $host:$port")
|
||||
activeSessions.add(this)
|
||||
updatePeers()
|
||||
try {
|
||||
for (frame in incoming) {
|
||||
if (frame is Frame.Text) {
|
||||
val text = frame.readText()
|
||||
val event = Json.decodeFromString<SyncEvent>(text)
|
||||
_incomingEvents.emit(event)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
activeSessions.remove(this)
|
||||
updatePeers()
|
||||
println("[P2P Client] Verbindung zu $host:$port beendet")
|
||||
}
|
||||
for (frame in incoming) {
|
||||
if (frame is Frame.Text) {
|
||||
val text = frame.readText()
|
||||
try {
|
||||
val event = Json.decodeFromString<SyncEvent>(text)
|
||||
_incomingEvents.emit(event)
|
||||
} catch (ex: Exception) {
|
||||
println("[P2P Server] Fehler beim Dekodieren von $remote: ${ex.message}")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
println("[P2P Client] Fehler bei Verbindung zu $host:$port: ${e.message}")
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
println("[P2P Server] Verbindung zu $remote unterbrochen: ${ex.message}")
|
||||
} finally {
|
||||
activeSessions.remove(this)
|
||||
updatePeers()
|
||||
println("[P2P Server] Peer $remote getrennt")
|
||||
}
|
||||
}
|
||||
}
|
||||
}.start(wait = false)
|
||||
currentPort = port
|
||||
println("[P2P Server] Erfolgreich gestartet auf Port $port")
|
||||
} catch (ex: Exception) {
|
||||
startedPorts.remove(port)
|
||||
server = null
|
||||
currentPort = null
|
||||
println("[P2P Server] Fehler beim Starten des Servers auf Port $port: ${ex.message}")
|
||||
throw ex
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun broadcastEvent(event: SyncEvent) {
|
||||
val text = Json.encodeToString(event)
|
||||
activeSessions.toList().forEach { session ->
|
||||
override fun stopServer() {
|
||||
connectionJobs.values.forEach { it.cancel() }
|
||||
connectionJobs.clear()
|
||||
try {
|
||||
server?.stop(1000, 2000)
|
||||
} finally {
|
||||
server = null
|
||||
currentPort?.let { startedPorts.remove(it) }
|
||||
currentPort = null
|
||||
println("[P2P Server] Server gestoppt.")
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun connectToPeer(host: String, port: Int) {
|
||||
val peerKey = "$host:$port"
|
||||
|
||||
connectionJobs[peerKey]?.cancel()
|
||||
|
||||
val job = scope.launch {
|
||||
while (isActive) {
|
||||
try {
|
||||
println("[P2P Client] Verbindungsversuch zu $peerKey...")
|
||||
client.webSocket(host = host, port = port, path = "/sync") {
|
||||
println("[P2P Client] Verbunden mit $peerKey")
|
||||
activeSessions.add(this)
|
||||
updatePeers()
|
||||
try {
|
||||
session.send(Frame.Text(text))
|
||||
} catch (e: Exception) {
|
||||
println("[P2P] Fehler beim Senden an Session: ${e.message}")
|
||||
for (frame in incoming) {
|
||||
if (frame is Frame.Text) {
|
||||
val text = frame.readText()
|
||||
val event = Json.decodeFromString<SyncEvent>(text)
|
||||
_incomingEvents.emit(event)
|
||||
}
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
println("[P2P Client] Verbindung zu $peerKey abgebrochen: ${ex.message}")
|
||||
} finally {
|
||||
activeSessions.remove(this)
|
||||
updatePeers()
|
||||
println("[P2P Client] Session mit $peerKey beendet.")
|
||||
}
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
println("[P2P Client] Konnte keine Verbindung zu $peerKey herstellen: ${ex.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun updatePeers() {
|
||||
// Da wir keine einfachen IPs in den Sessions haben ohne tieferes Casting,
|
||||
// nutzen wir hier erst mal einen Platzhalter oder zählen nur.
|
||||
_connectedPeers.value = activeSessions.map { "Peer-${it.hashCode()}" }
|
||||
if (isActive) {
|
||||
println("[P2P Client] Erneuter Versuch für $peerKey in ${RECONNECT_DELAY_MS}ms...")
|
||||
delay(RECONNECT_DELAY_MS.milliseconds)
|
||||
}
|
||||
}
|
||||
}
|
||||
connectionJobs[peerKey] = job
|
||||
}
|
||||
|
||||
override suspend fun broadcastEvent(event: SyncEvent) {
|
||||
val text = Json.encodeToString(event)
|
||||
val sessions = activeSessions.toList()
|
||||
sessions.forEach { session ->
|
||||
try {
|
||||
if (session.isActive) {
|
||||
session.send(Frame.Text(text))
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
// Session wird durch Heartbeat/Loop automatisch bereinigt
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updatePeers() {
|
||||
_connectedPeers.value = activeSessions.map { session ->
|
||||
when (session) {
|
||||
is DefaultWebSocketServerSession -> session.call.request.local.remoteAddress
|
||||
else -> "Outgoing-Peer"
|
||||
}
|
||||
}.distinct()
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -7,6 +7,6 @@ import org.koin.dsl.module
|
||||
* JVM-spezifische Implementierung des SyncModules.
|
||||
*/
|
||||
actual val syncModule: Module = module {
|
||||
single<P2pSyncService> { JvmP2pSyncService() }
|
||||
single { SyncManager(get(), get()) }
|
||||
single<P2pSyncService> { JvmP2pSyncService() }
|
||||
single { SyncManager(get(), get()) }
|
||||
}
|
||||
|
||||
+24
-24
@@ -5,33 +5,33 @@ import kotlin.test.Test
|
||||
|
||||
class JvmP2pSyncServiceTest {
|
||||
|
||||
@Test
|
||||
fun starting_server_twice_on_same_port_should_not_fail_but_use_guard() = runTest {
|
||||
val service1 = JvmP2pSyncService()
|
||||
val service2 = JvmP2pSyncService()
|
||||
val port = 9091
|
||||
@Test
|
||||
fun starting_server_twice_on_same_port_should_not_fail_but_use_guard() = runTest {
|
||||
val service1 = JvmP2pSyncService()
|
||||
val service2 = JvmP2pSyncService()
|
||||
val port = 9091
|
||||
|
||||
try {
|
||||
service1.startServer(port)
|
||||
// Second start should just return/log and not throw an exception (idempotent)
|
||||
service2.startServer(port)
|
||||
} finally {
|
||||
service1.stopServer()
|
||||
service2.stopServer()
|
||||
}
|
||||
try {
|
||||
service1.startServer(port)
|
||||
// Second start should just return/log and not throw an exception (idempotent)
|
||||
service2.startServer(port)
|
||||
} finally {
|
||||
service1.stopServer()
|
||||
service2.stopServer()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun stopping_server_should_release_port_lock() = runTest {
|
||||
val service1 = JvmP2pSyncService()
|
||||
val service2 = JvmP2pSyncService()
|
||||
val port = 9092
|
||||
@Test
|
||||
fun stopping_server_should_release_port_lock() = runTest {
|
||||
val service1 = JvmP2pSyncService()
|
||||
val service2 = JvmP2pSyncService()
|
||||
val port = 9092
|
||||
|
||||
service1.startServer(port)
|
||||
service1.stopServer()
|
||||
service1.startServer(port)
|
||||
service1.stopServer()
|
||||
|
||||
// After stopping, starting again on same port (even from different instance) should work
|
||||
service2.startServer(port)
|
||||
service2.stopServer()
|
||||
}
|
||||
// After stopping, starting again on same port (even from different instance) should work
|
||||
service2.startServer(port)
|
||||
service2.stopServer()
|
||||
}
|
||||
}
|
||||
|
||||
+10
-10
@@ -1,23 +1,23 @@
|
||||
package at.mocode.frontend.core.network.sync
|
||||
|
||||
import org.koin.core.module.Module
|
||||
import org.koin.dsl.module
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import org.koin.core.module.Module
|
||||
import org.koin.dsl.module
|
||||
|
||||
/**
|
||||
* Wasm-spezifische Implementierung (vorerst No-op).
|
||||
*/
|
||||
actual val syncModule: Module = module {
|
||||
single<P2pSyncService> { NoOpP2pSyncService() }
|
||||
single { SyncManager(get(), get()) }
|
||||
single<P2pSyncService> { NoOpP2pSyncService() }
|
||||
single { SyncManager(get(), get()) }
|
||||
}
|
||||
|
||||
class NoOpP2pSyncService : P2pSyncService {
|
||||
override fun startServer(port: Int) {}
|
||||
override fun stopServer() {}
|
||||
override suspend fun connectToPeer(host: String, port: Int) {}
|
||||
override suspend fun broadcastEvent(event: SyncEvent) {}
|
||||
override val incomingEvents: Flow<SyncEvent> = emptyFlow()
|
||||
override val connectedPeers: Flow<List<String>> = emptyFlow()
|
||||
override fun startServer(port: Int) {}
|
||||
override fun stopServer() {}
|
||||
override suspend fun connectToPeer(host: String, port: Int) {}
|
||||
override suspend fun broadcastEvent(event: SyncEvent) {}
|
||||
override val incomingEvents: Flow<SyncEvent> = emptyFlow()
|
||||
override val connectedPeers: Flow<List<String>> = emptyFlow()
|
||||
}
|
||||
|
||||
+3
-2
@@ -26,10 +26,11 @@ import org.koin.compose.viewmodel.koinViewModel
|
||||
*/
|
||||
@Composable
|
||||
fun DesktopApp() {
|
||||
val deviceInitViewModel: at.mocode.frontend.features.device.initialization.presentation.DeviceInitializationViewModel = koinViewModel()
|
||||
val deviceInitViewModel: at.mocode.frontend.features.device.initialization.presentation.DeviceInitializationViewModel =
|
||||
koinViewModel()
|
||||
val deviceSettings by deviceInitViewModel.uiState.collectAsState()
|
||||
|
||||
val isDark = when(deviceSettings.settings.appTheme) {
|
||||
val isDark = when (deviceSettings.settings.appTheme) {
|
||||
at.mocode.frontend.features.device.initialization.domain.model.AppThemeSetting.SYSTEM -> isSystemInDarkTheme()
|
||||
at.mocode.frontend.features.device.initialization.domain.model.AppThemeSetting.LIGHT -> false
|
||||
at.mocode.frontend.features.device.initialization.domain.model.AppThemeSetting.DARK -> true
|
||||
|
||||
+1
-3
@@ -27,7 +27,7 @@ private fun PreviewContent() {
|
||||
Surface {
|
||||
|
||||
// --- REITER ---
|
||||
//ReiterScreen(viewModel = ReiterViewModel())
|
||||
//ReiterScreen(viewModel = ReiterViewModel())
|
||||
|
||||
// --- PFERDE ---
|
||||
// PferdeScreen(viewModel = PferdeViewModel())
|
||||
@@ -35,8 +35,6 @@ private fun PreviewContent() {
|
||||
// --- VEREIN ---
|
||||
|
||||
|
||||
|
||||
|
||||
// ── Hier den gewünschten Screen eintragen ──────────────────────
|
||||
// VeranstalterAuswahlScreen(onVeranstalterSelected = {}, onNeuerVeranstalter = {})
|
||||
// VeranstalterNeuScreen(onBack = {}, onSave = {})
|
||||
|
||||
+3
@@ -8,6 +8,8 @@ import at.mocode.frontend.core.navigation.DeepLinkHandler
|
||||
import at.mocode.frontend.core.navigation.NavigationPort
|
||||
import at.mocode.frontend.shell.desktop.navigation.DesktopNavigationPort
|
||||
import at.mocode.frontend.shell.desktop.repository.DesktopMasterdataRepository
|
||||
import at.mocode.frontend.shell.desktop.screens.chat.presentation.ChatViewModel
|
||||
import org.koin.core.module.dsl.viewModel
|
||||
import org.koin.dsl.module
|
||||
|
||||
/**
|
||||
@@ -35,4 +37,5 @@ val desktopModule = module {
|
||||
single<CurrentUserProvider> { DesktopCurrentUserProvider(get()) }
|
||||
single { DeepLinkHandler(get(), get()) }
|
||||
single<MasterdataRepository> { DesktopMasterdataRepository(get()) }
|
||||
viewModel { ChatViewModel(get()) }
|
||||
}
|
||||
|
||||
+29
-59
@@ -1,17 +1,12 @@
|
||||
package at.mocode.frontend.shell.desktop
|
||||
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Window
|
||||
import androidx.compose.ui.window.WindowState
|
||||
import androidx.compose.ui.window.application
|
||||
import at.mocode.frontend.core.auth.di.authModule
|
||||
import at.mocode.frontend.core.localdb.AppDatabase
|
||||
import at.mocode.frontend.core.localdb.DatabaseProvider
|
||||
import at.mocode.frontend.core.localdb.localDbModule
|
||||
import at.mocode.frontend.core.network.chat.KtorWebSocketServerService
|
||||
import at.mocode.frontend.core.network.discovery.NetworkDiscoveryService
|
||||
import at.mocode.frontend.core.network.networkModule
|
||||
import at.mocode.frontend.core.sync.di.syncModule
|
||||
import at.mocode.frontend.core.network.sync.SyncManager
|
||||
import at.mocode.frontend.features.billing.di.billingModule
|
||||
import at.mocode.frontend.features.device.initialization.di.deviceInitializationModule
|
||||
import at.mocode.frontend.features.funktionaer.di.funktionaerModule
|
||||
@@ -24,76 +19,51 @@ import at.mocode.frontend.features.turnier.di.turnierFeatureModule
|
||||
import at.mocode.frontend.features.veranstalter.di.veranstalterModule
|
||||
import at.mocode.frontend.features.verein.di.vereinFeatureModule
|
||||
import at.mocode.frontend.features.zns.import.di.znsImportModule
|
||||
import at.mocode.frontend.shell.desktop.data.repository.StoreVeranstaltungRepository
|
||||
import at.mocode.frontend.shell.desktop.di.desktopModule
|
||||
import at.mocode.veranstaltung.feature.di.veranstaltungModule
|
||||
import at.mocode.veranstaltung.feature.domain.repository.VeranstaltungRepository
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.koin.core.context.GlobalContext
|
||||
import org.koin.core.context.loadKoinModules
|
||||
import org.koin.core.context.startKoin
|
||||
import org.koin.dsl.module
|
||||
|
||||
fun main() = application {
|
||||
try {
|
||||
startKoin {
|
||||
fun main() {
|
||||
application {
|
||||
// Koin Starten
|
||||
val koinApp = startKoin {
|
||||
printLogger()
|
||||
modules(
|
||||
networkModule,
|
||||
syncModule,
|
||||
authModule,
|
||||
localDbModule,
|
||||
pingFeatureModule,
|
||||
nennungFeatureModule,
|
||||
znsImportModule,
|
||||
profileModule,
|
||||
billingModule,
|
||||
pferdeModule,
|
||||
reiterModule,
|
||||
funktionaerModule,
|
||||
vereinFeatureModule,
|
||||
veranstalterModule,
|
||||
turnierFeatureModule,
|
||||
veranstaltungModule,
|
||||
module {
|
||||
single<VeranstaltungRepository> { StoreVeranstaltungRepository() }
|
||||
},
|
||||
deviceInitializationModule,
|
||||
desktopModule,
|
||||
deviceInitializationModule,
|
||||
billingModule,
|
||||
funktionaerModule,
|
||||
nennungFeatureModule,
|
||||
pferdeModule,
|
||||
pingFeatureModule,
|
||||
profileModule,
|
||||
reiterModule,
|
||||
turnierFeatureModule,
|
||||
veranstalterModule,
|
||||
veranstaltungModule,
|
||||
vereinFeatureModule,
|
||||
znsImportModule
|
||||
)
|
||||
}
|
||||
|
||||
// Datenbank EAGER initialisieren (JVM-safe via runBlocking)
|
||||
val koin = GlobalContext.get()
|
||||
val dbProvider = koin.get<DatabaseProvider>()
|
||||
val koin = koinApp.koin
|
||||
|
||||
// Datenbank initialisieren und als Singleton registrieren
|
||||
val dbProvider: DatabaseProvider = koin.get()
|
||||
val database = runBlocking { dbProvider.createDatabase() }
|
||||
koin.loadModules(listOf(module { single { database } }))
|
||||
|
||||
loadKoinModules(module {
|
||||
single<AppDatabase> { database }
|
||||
})
|
||||
// SyncManager initialisieren und starten (Default Port 8080)
|
||||
val syncManager: SyncManager = koin.get()
|
||||
syncManager.start(8080)
|
||||
|
||||
println("[DesktopApp] KOIN & DB initialisiert")
|
||||
|
||||
// Start POC Netzwerk-Dienste
|
||||
try {
|
||||
val wsServer = koin.get<KtorWebSocketServerService>()
|
||||
wsServer.start()
|
||||
val discovery = koin.get<NetworkDiscoveryService>()
|
||||
discovery.startDiscovery()
|
||||
discovery.registerService(wsServer.getPort())
|
||||
} catch(e: Exception) {
|
||||
println("[DesktopApp] Netzwerk-Dienste Fehler: %s".format(e.message))
|
||||
Window(onCloseRequest = ::exitApplication, title = "Meldestelle Desktop") {
|
||||
DesktopApp()
|
||||
}
|
||||
|
||||
at.mocode.frontend.shell.desktop.data.Store.seed()
|
||||
} catch (e: Exception) {
|
||||
println("[DesktopApp] Startup-Fehler: %s".format(e.message))
|
||||
}
|
||||
|
||||
Window(
|
||||
onCloseRequest = ::exitApplication,
|
||||
title = "Meldestelle",
|
||||
state = WindowState(width = 1600.dp, height = 900.dp),
|
||||
) {
|
||||
DesktopApp()
|
||||
}
|
||||
}
|
||||
|
||||
+20
-31
@@ -4,6 +4,7 @@ import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Send
|
||||
@@ -16,30 +17,24 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import at.mocode.frontend.core.designsystem.theme.AppColors
|
||||
import at.mocode.frontend.core.designsystem.theme.Dimens
|
||||
import java.time.LocalTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
data class ChatMessage(
|
||||
val id: String,
|
||||
val sender: String,
|
||||
val text: String,
|
||||
val time: String,
|
||||
val isFromMe: Boolean
|
||||
)
|
||||
import at.mocode.frontend.shell.desktop.screens.chat.presentation.ChatMessageState
|
||||
import at.mocode.frontend.shell.desktop.screens.chat.presentation.ChatViewModel
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
|
||||
@Composable
|
||||
fun ChatScreen(
|
||||
onBack: () -> Unit
|
||||
onBack: () -> Unit,
|
||||
viewModel: ChatViewModel = koinViewModel()
|
||||
) {
|
||||
var messageText by remember { mutableStateOf("") }
|
||||
val messages = remember { mutableStateListOf<ChatMessage>() }
|
||||
val timeFormatter = DateTimeFormatter.ofPattern("HH:mm")
|
||||
val messages by viewModel.messages.collectAsState()
|
||||
val peerCount by viewModel.peerCount.collectAsState()
|
||||
val scrollState = rememberLazyListState()
|
||||
|
||||
// Mock initial messages
|
||||
LaunchedEffect(Unit) {
|
||||
if (messages.isEmpty()) {
|
||||
messages.add(ChatMessage("1", "Richter-Turm 1", "Startliste für Bewerb 5 ist fertig?", "10:45", false))
|
||||
messages.add(ChatMessage("2", "Meldestelle", "Ja, wird gerade gedruckt.", "10:46", true))
|
||||
// Auto-scroll to bottom on new messages
|
||||
LaunchedEffect(messages.size) {
|
||||
if (messages.isNotEmpty()) {
|
||||
scrollState.animateScrollToItem(messages.size - 1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,9 +56,9 @@ fun ChatScreen(
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Text(
|
||||
"LAN-Kanal: aktiv (3 Teilnehmer)",
|
||||
"LAN-Kanal: aktiv ($peerCount Teilnehmer verbunden)",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = AppColors.Success
|
||||
color = if (peerCount > 0) AppColors.Success else MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -71,11 +66,12 @@ fun ChatScreen(
|
||||
|
||||
// Chat Messages
|
||||
LazyColumn(
|
||||
state = scrollState,
|
||||
modifier = Modifier.weight(1f).fillMaxWidth().padding(horizontal = Dimens.SpacingM),
|
||||
contentPadding = PaddingValues(vertical = Dimens.SpacingM),
|
||||
verticalArrangement = Arrangement.spacedBy(Dimens.SpacingS)
|
||||
) {
|
||||
items(messages) { msg ->
|
||||
items(messages, key = { it.id }) { msg ->
|
||||
ChatBubble(msg)
|
||||
}
|
||||
}
|
||||
@@ -102,18 +98,11 @@ fun ChatScreen(
|
||||
IconButton(
|
||||
onClick = {
|
||||
if (messageText.isNotBlank()) {
|
||||
messages.add(
|
||||
ChatMessage(
|
||||
id = messages.size.toString(),
|
||||
sender = "Meldestelle",
|
||||
text = messageText,
|
||||
time = LocalTime.now().format(timeFormatter),
|
||||
isFromMe = true
|
||||
)
|
||||
)
|
||||
viewModel.sendMessage(messageText)
|
||||
messageText = ""
|
||||
}
|
||||
},
|
||||
enabled = messageText.isNotBlank(),
|
||||
colors = IconButtonDefaults.iconButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimary
|
||||
@@ -128,7 +117,7 @@ fun ChatScreen(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChatBubble(msg: ChatMessage) {
|
||||
private fun ChatBubble(msg: ChatMessageState) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = if (msg.isFromMe) Alignment.End else Alignment.Start
|
||||
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
package at.mocode.frontend.shell.desktop.screens.chat.presentation
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import at.mocode.frontend.core.network.sync.ChatMessageEvent
|
||||
import at.mocode.frontend.core.network.sync.SyncManager
|
||||
import at.mocode.frontend.features.device.initialization.data.local.DeviceInitializationSettingsManager
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import java.time.LocalTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.*
|
||||
import kotlin.time.Clock
|
||||
|
||||
data class ChatMessageState(
|
||||
val id: String,
|
||||
val sender: String,
|
||||
val text: String,
|
||||
val time: String,
|
||||
val isFromMe: Boolean
|
||||
)
|
||||
|
||||
class ChatViewModel(
|
||||
private val syncManager: SyncManager
|
||||
) : ViewModel() {
|
||||
private val timeFormatter = DateTimeFormatter.ofPattern("HH:mm")
|
||||
private val settings = DeviceInitializationSettingsManager.loadSettings()
|
||||
private val myName = settings?.deviceName ?: "Meldestelle"
|
||||
|
||||
private val _messages = MutableStateFlow<List<ChatMessageState>>(emptyList())
|
||||
val messages: StateFlow<List<ChatMessageState>> = _messages.asStateFlow()
|
||||
|
||||
private val _peerCount = MutableStateFlow(0)
|
||||
val peerCount: StateFlow<Int> = _peerCount.asStateFlow()
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
syncManager.getIncomingEvents().collect { event ->
|
||||
if (event is ChatMessageEvent) {
|
||||
_messages.update {
|
||||
it + ChatMessageState(
|
||||
id = event.eventId,
|
||||
sender = event.senderName,
|
||||
text = event.message,
|
||||
time = LocalTime.now().format(timeFormatter),
|
||||
isFromMe = event.originNodeId == myName
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
syncManager.getConnectedPeers().collect { peers ->
|
||||
_peerCount.value = peers.size
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun sendMessage(text: String) {
|
||||
if (text.isBlank()) return
|
||||
|
||||
val event = ChatMessageEvent(
|
||||
eventId = UUID.randomUUID().toString(),
|
||||
sequenceNumber = 0,
|
||||
originNodeId = myName,
|
||||
createdAt = Clock.System.now().toEpochMilliseconds(),
|
||||
senderName = myName,
|
||||
message = text
|
||||
)
|
||||
|
||||
// Sofort lokal anzeigen
|
||||
_messages.update {
|
||||
it + ChatMessageState(
|
||||
id = event.eventId,
|
||||
sender = myName,
|
||||
text = text,
|
||||
time = LocalTime.now().format(timeFormatter),
|
||||
isFromMe = true
|
||||
)
|
||||
}
|
||||
|
||||
syncManager.broadcastEvent(event)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user