feat: verbesserte Netzwerkfähigkeit und Chat-Test integriert

- **Discovery:** Unterstützung für Multi-Interface-Broadcast und manuelle IP-Eingabe.
- **UI:** Chat-Test für Verbindungsprüfung hinzugefügt.
- **ViewModel:** Datenübertragungslogik (Ping/Pong, Chat) implementiert.
- **Workflow:** Windows-MSI-Build als separaten Job hinzugefügt.

Signed-off-by: StefanMoCoAt <stefan.mo.co@gmail.com>
This commit is contained in:
2026-05-05 23:18:25 +02:00
parent 15222b5453
commit c317147ca4
11 changed files with 648 additions and 102 deletions
+44
View File
@@ -0,0 +1,44 @@
name: Feature Build — Windows MSI
on:
push:
branches: [ "feature/*" ] # Reagiert auf alle Feature-Branches
jobs:
package-windows:
name: 📦 Windows .msi Packaging
# Nur ausführen, wenn Desktop-CI explizit aktiviert ist
if: ${{ vars.DESKTOP_CI_ENABLED == 'true' }}
runs-on: windows-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup JDK 21 (Temurin)
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '21'
- name: Gradle cache
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
.gradle
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', 'gradle.properties') }}
restore-keys: |
${{ runner.os }}-gradle-
- name: Windows .msi bauen
env:
_JAVA_OPTIONS: -Djava.awt.headless=true
run: |
./gradlew :frontend:shells:meldestelle-desktop:packageMsi --stacktrace --no-daemon
- name: .msi Artefakt hochladen
uses: actions/upload-artifact@v4
with:
name: meldestelle-windows-feature-build
path: frontend/shells/meldestelle-desktop/build/compose/binaries/main/msi/*.msi
if-no-files-found: warn
@@ -1,20 +1,33 @@
# ADR-0027: Netzwerk-Discovery & Interface-Binding
## Status
In Prüfung (Wartet auf PoC)
Akzeptiert & Implementiert (05.05.2026)
## Kontext
Desktop-Rechner auf Turnieren sind oft mit mehreren Netzwerken gleichzeitig verbunden (z.B. LAN für das Turnier-Netzwerk, WLAN für Internet-Hotspot). Automatische Discovery-Dienste (JmDNS) wählen ohne explizite Konfiguration oft das falsche Interface, wodurch sich Clients und Master nicht finden.
Desktop-Rechner auf Turnieren sind oft mit mehreren Netzwerken gleichzeitig verbunden (z.B. LAN für das
Turnier-Netzwerk, WLAN für Internet-Hotspot). Automatische Discovery-Dienste (mDNS) wählen ohne explizite Konfiguration
oft ein Interface, das für die anderen Teilnehmer nicht erreichbar ist (Bridging-Probleme zwischen LAN und WLAN). Zudem
blockieren einige Router Multicast-Pakete zwischen WLAN und LAN-Segmenten.
## Entscheidung
Wir führen ein explizites Netzwerk-Management für die Initialisierung ein.
1. **Interface-Selektion:** Der Benutzer muss bei der technischen Initialisierung explizit wählen, über welches Netzwerk-Interface (IP-Adresse/Adapter) die App kommunizieren soll. Die UI zeigt hierfür benutzerfreundliche Namen (WLAN, Ethernet) an.
2. **Geführte Discovery:** Sobald ein Interface gewählt ist, startet ein "Radar-Modus". Dieser scannt aktiv via JmDNS nach vorhandenen Master-Geräten.
3. **Adaptive Rolle:** Findet die Discovery einen Master, wird dem Benutzer die Rolle "Client" vorgeschlagen. Die UI bleibt jedoch flexibel für manuelle Rollenwechsel.
4. **Fokus-Management:** Nach Auswahl der Rolle wird der Fokus automatisch in das erste relevante Eingabefeld (Gerätename) gesetzt, um einen reibungslosen Workflow zu ermöglichen.
Wir führen ein robustes, mehrstufiges Netzwerk-Management für die Initialisierung ein:
1. **Multi-Interface Broadcast:** Der Master registriert seinen Dienst proaktiv auf **allen** verfügbaren
Netzwerk-Interfaces (IPv4). Dies erhöht die Chance massiv, dass Clients in verschiedenen Segmenten (WLAN/LAN) den
Master finden.
2. **Interface-Selektion:** Der Benutzer kann weiterhin ein bevorzugtes Interface wählen. Die Master-Info-Card zeigt die
Erreichbarkeit transparent an.
3. **Manueller IP-Fallback:** Wenn mDNS fehlschlägt, kann die IP des Masters manuell eingegeben werden. Dies ist der "
Ultima-Ratio"-Weg für restriktive Netzwerke.
4. **Konnektivitäts-Check (Chat & Self-Test):** Nach dem Handshake wird ein Modal geöffnet, das einen automatischen
Ping-Pong Test durchführt und einen Test-Chat bietet. Dies verifiziert die reale Datenübertragung (Serialisierung,
WebSockets) noch vor Abschluss des Setups.
## Konsequenzen
- Verhindert "Geistersuchen" im falschen Netzwerk.
- Erhöht die Benutzerfreundlichkeit durch automatische Vorschläge.
- Erfordert Zugriff auf System-Netzwerk-APIs in der Desktop-Shell.
- Deutlich höhere Stabilität in heterogenen Netzwerkumgebungen.
- Transparenteres Feedback für den Anwender bei Verbindungsproblemen.
- Der Chat dient als "Connectivity-Proof" für das Support-Personal vor Ort.
@@ -0,0 +1,20 @@
### Journal: 05.05.2026 - Build & Packaging Issues
**Status:** Verifiziert
**Problem:**
Der User versuchte einen Windows-Installer (.msi) auf einem Linux-System zu bauen. Gradle meldete 'BUILD SUCCESSFUL',
aber es wurde kein Artefakt erzeugt.
**Ursache:**
Das Compose Multiplatform Plugin kann native Installer (MSI, DMG, DEB) nur auf dem jeweiligen Ziel-Betriebssystem bauen.
Auf Linux wird der Task zwar angeboten, führt aber zu keinem Ergebnis, da die nativen Windows-Tools (WiX Toolset)
fehlen.
**Lösung/Workaround:**
1. Für Linux wurde erfolgreich ein Paket erzeugt: .
2. Für Windows (.msi) muss der Build zwingend auf einer Windows-Maschine ausgeführt werden.
3. Empfehlung: Einrichtung einer Cross-Platform CI/CD Pipeline.
**Badge:** 🏗️ [Lead Architect] & 🐧 [DevOps Engineer]
@@ -0,0 +1,42 @@
# Journal-Eintrag: 05.05.2026 - Connectivity-Fix & Code-Qualität
## Kontext
Nach einem fehlgeschlagenen Hardware-Test am 30.04.2026 wurde die Netzwerk-Konnektivität zwischen LAN und WLAN als
kritische Schwachstelle identifiziert. Zudem gab es erhebliche Mängel in der Code-Qualität (JVM-Leaks in KMP,
Syntax-Fehler).
## Durchgeführte Arbeiten
### 1. 🧹 Code-Sanierung (Clean Code & KMP)
- **ViewModel Fix:** Sämtliche `java.*` und `System.*` Referenzen aus `commonMain` entfernt.
- **Zeitstempel:** Nutzung der idiomatischen `kotlin.time.Clock` (Kotlin 2.3.20) statt `System.currentTimeMillis()`.
- **Compose UI:** Behebung von Syntax-Fehlern in `DeviceInitializationScreen.kt` (LazyColumn Iteration und Imports).
- **Typsicherheit:** Explizite Typisierung in UI-Komponenten zur Vermeidung von Destrukturierungsfehlern.
### 2. 📡 Netzwerk-Stabilität
- **Multi-Interface Discovery:** Der `JmDnsDiscoveryService` registriert Dienste nun auf allen verfügbaren
IPv4-Interfaces gleichzeitig. Dies löst das Problem, dass Master-Geräte in LAN/WLAN-Mischumgebungen nicht gefunden
werden.
- **Manueller Fallback:** Einführung eines IP-Eingabefelds im Setup-Wizard für den Fall, dass mDNS durch Router
blockiert wird.
- **Master-Info-Card:** Anzeige der eigenen IP-Adresse auf dem Host-Gerät zur Erleichterung der manuellen Verbindung.
### 3. 💬 Interaktiver Connectivity-Check
- **Chat-Modal:** Implementierung eines Pop-ups nach dem Handshake.
- **Self-Test:** Automatischer Ping-Pong Test beim Öffnen des Modals zur Verifizierung der WebSocket-Verbindung.
- **Test-Chat:** Ermöglicht den manuellen Austausch von Nachrichten als definitiven Beweis für eine stabile
Datenverbindung.
## Status: Verifiziert & Bereit für Hardware-Test
Alle identifizierten Kompilierungsfehler (einschließlich Koin-Modul Typkonflikte) wurden behoben. Der Code folgt den
KMP-Standards für Kotlin 2.3.20. Die Architektur entspricht nun ADR-0027.
**🏗️ [Lead Architect]**
**👷 [Backend Developer]**
**🎨 [Frontend Expert]**
**🧹 [Curator]**
@@ -0,0 +1,54 @@
---
type: Journal
status: COMPLETED
owner: Curator
date: 2026-05-05
---
# 🧹 [Curator] Journal: Frühjahrsputz in der Dokumentation
**Datum:** 5. Mai 2026
## 🎯 Zielsetzung
Bereinigung der Dokumentationsstruktur (`docs/`), Archivierung veralteter Chat-Verläufe und Berichte, sowie
Konsolidierung von Assets und Event-Daten zur Wiederherstellung der Übersichtlichkeit.
## 🛠️ Durchgeführte Maßnahmen
### 1. Radikale Archivierung
- **`docs/temp/`**: Alle Dateien (viele veraltete Blueprints und Chat-Logs) wurden nach `docs/_archive/temp_2026-05-05/`
verschoben.
- **`docs/90_Reports/`**: Berichte, die älter als April 2026 sind, wurden in den Unterordner `_archive/` verschoben.
- **`docs/99_Journal/`**: Das Journal war mit über 100 Einträgen überladen. Alle Einträge aus dem April 2026 (und
früher) wurden nach `docs/99_Journal/_archive/` verschoben. Das Hauptverzeichnis ist nun bereit für die Einträge im
Mai.
### 2. Strukturierung & Konsolidierung
- **Screenshots**: Das lose Verzeichnis `docs/ScreenShots/` wurde nach `docs/80_Assets/Screenshots/` verschoben, um es
in die Asset-Hierarchie einzugliedern.
- **Event-Daten**: Spezifische Turnierdaten (`Neumarkt2026`, `St-Poetlen-Hart-2026`) wurden unter
`docs/03_Domain/Events/` gruppiert.
- **Architektur-Bereinigung**: Das Verzeichnis `docs/01_Architecture/` wurde restrukturiert. Veraltete Roadmaps wurden
archiviert, und aktive Dokumente wurden in Unterkategorien (`Concepts/`, `Specifications/`, `Roadmaps/`,
`Checklists/`) sortiert.
- **Redundanz-Eliminierung**: Veraltete/redundante Ordner (`docs/Bin/`, `docs/BilderSuDo/`) und das fälschlich angelegte
Verzeichnis `mocode/` wurden entfernt. Ein verirrtes Journal-Fragment wurde ins Archiv überführt.
### 3. Integritäts-Check
- Die `MASTER_ROADMAP.md` wurde als zentrales Steuerungsdokument verifiziert und alle internen Links auf die neue
Struktur angepasst.
- Die Agenten-Richtlinien in `AGENTS.md` bleiben die Basis für alle weiteren Sessions.
## 📊 Status Quo
- **Dokumentations-Health:** ✅ GRÜN (Aufgeräumt)
- **Archiv-Integrität:** ✅ Vorhanden
- **Nächste Schritte:** Fokus zurück auf die fachliche Implementierung der Turnier-Hierarchie (Meilenstein 1 der
Roadmap).
---
*Unterzeichnet vom Curator am 05.05.2026*
@@ -72,3 +72,18 @@ data class DataRequestEvent(
val aggregateType: String,
val aggregateId: String
) : SyncEvent
/**
* Chat-Event für den Connectivity-Test und einfache Kommunikation.
*/
@Serializable
data class ChatMessageEvent(
override val eventId: String,
override val sequenceNumber: Long,
override val originNodeId: String,
override val createdAt: Long,
override val checksum: String = "",
override val schemaVersion: Int = 1,
val senderName: String,
val message: String
) : SyncEvent
@@ -4,6 +4,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
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
@@ -15,7 +16,7 @@ import javax.jmdns.ServiceListener
*/
class JmDnsDiscoveryService : NetworkDiscoveryService {
private var jmdns: JmDNS? = null
private val jmdnsInstances = mutableListOf<JmDNS>()
private val SERVICE_TYPE = "_meldestelle._tcp.local."
private val discoveredServicesMap = ConcurrentHashMap<String, DiscoveredService>()
@@ -23,16 +24,23 @@ class JmDnsDiscoveryService : NetworkDiscoveryService {
override val discoveredServices: StateFlow<List<DiscoveredService>> = _discoveredServices.asStateFlow()
override fun startDiscovery(preferredIp: String?) {
if (jmdns == null) {
val addr = preferredIp?.let { InetAddress.getByName(it) } ?: InetAddress.getLocalHost()
println("[Discovery] Starte Discovery gebunden an: $addr")
jmdns = JmDNS.create(addr)
if (jmdnsInstances.isNotEmpty()) return
val addresses = getRelevantAddresses(preferredIp)
if (addresses.isEmpty()) {
println("[Discovery] Keine validen Netzwerk-Interfaces gefunden für Discovery.")
return
}
jmdns?.addServiceListener(SERVICE_TYPE, object : ServiceListener {
addresses.forEach { addr ->
try {
println("[Discovery] Starte Discovery gebunden an: $addr")
val jmdns = JmDNS.create(addr)
jmdnsInstances.add(jmdns)
jmdns.addServiceListener(SERVICE_TYPE, object : ServiceListener {
override fun serviceAdded(event: ServiceEvent) {
// Bei ServiceAdded fordern wir die Details an
jmdns?.requestServiceInfo(event.type, event.name)
jmdns.requestServiceInfo(event.type, event.name)
}
override fun serviceRemoved(event: ServiceEvent) {
@@ -54,25 +62,39 @@ class JmDnsDiscoveryService : NetworkDiscoveryService {
println("[Discovery] Service gefunden: ${service.name} @ ${service.host}:${service.port}")
}
})
} catch (e: Exception) {
println("[Discovery] Fehler beim Starten von JmDNS auf $addr: ${e.message}")
}
}
}
override fun stopDiscovery() {
jmdns?.close()
jmdns = null
jmdnsInstances.forEach { it.close() }
jmdnsInstances.clear()
discoveredServicesMap.clear()
_discoveredServices.value = emptyList()
}
override fun registerService(port: Int, preferredIp: String?, deviceName: String?) {
if (jmdns == null) {
val addr = preferredIp?.let { InetAddress.getByName(it) } ?: InetAddress.getLocalHost()
println("[Discovery] Registriere Dienst gebunden an: $addr")
jmdns = JmDNS.create(addr)
if (jmdnsInstances.isEmpty()) {
val addresses = getRelevantAddresses(preferredIp)
addresses.forEach { addr ->
try {
println("[Discovery] Erstelle JmDNS Instanz für Registrierung auf: $addr")
jmdnsInstances.add(JmDNS.create(addr))
} catch (e: Exception) {
println("[Discovery] Fehler beim Erstellen von JmDNS auf $addr: ${e.message}")
}
}
}
// Wir nutzen den übergebenen Namen, den vom System gesetzten oder einen sprechenden Default
val name = deviceName ?: System.getProperty("meldestelle.device.name") ?: "Meldestelle-${System.getProperty("user.name")}"
val name = deviceName ?: try {
java.net.InetAddress.getLocalHost().hostName
} catch (e: Exception) {
"Meldestelle-Device"
} + "-${System.getProperty("user.name", "unknown")}"
jmdnsInstances.forEach { jmdns ->
val serviceInfo = ServiceInfo.create(
SERVICE_TYPE,
name,
@@ -84,8 +106,41 @@ class JmDnsDiscoveryService : NetworkDiscoveryService {
"nodeId" to name
)
)
jmdns?.registerService(serviceInfo)
println("[Discovery] Eigenen Dienst '$name' registriert auf Port $port")
try {
jmdns.registerService(serviceInfo)
println("[Discovery] Dienst '$name' auf ${jmdns.inetAddress} registriert (Port $port)")
} catch (e: Exception) {
println("[Discovery] Fehler bei Registrierung auf ${jmdns.inetAddress}: ${e.message}")
}
}
}
private fun getRelevantAddresses(preferredIp: String?): List<InetAddress> {
if (preferredIp != null) {
return listOf(InetAddress.getByName(preferredIp))
}
val addresses = mutableListOf<InetAddress>()
try {
val interfaces = NetworkInterface.getNetworkInterfaces()
while (interfaces.hasMoreElements()) {
val iface = interfaces.nextElement()
if (iface.isLoopback || !iface.isUp || iface.isVirtual) continue
val inetAddresses = iface.inetAddresses
while (inetAddresses.hasMoreElements()) {
val addr = inetAddresses.nextElement()
// Nur IPv4 für maximale Kompatibilität in lokalen Netzen (ÖTO/FEI Standardumgebungen)
if (addr is java.net.Inet4Address) {
addresses.add(addr)
}
}
}
} catch (e: Exception) {
println("[Discovery] Fehler beim Auflisten der Interfaces: ${e.message}")
}
return if (addresses.isEmpty()) listOf(InetAddress.getLocalHost()) else addresses
}
override fun getDiscoveredServices(): List<DiscoveredService> {
@@ -6,5 +6,5 @@ import at.mocode.frontend.features.device.initialization.presentation.DeviceInit
import org.koin.dsl.module
val deviceInitializationModule = module {
factory { DeviceInitializationViewModel(get(), { deviceName -> get { org.koin.core.parameter.parametersOf(deviceName) } }) }
factory { DeviceInitializationViewModel(get(), get()) }
}
@@ -3,33 +3,36 @@
package at.mocode.frontend.features.device.initialization.presentation
import androidx.compose.animation.core.*
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.NetworkCheck
import androidx.compose.material.icons.automirrored.filled.Chat
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.FocusRequester.Companion.FocusRequesterFactory.component1
import androidx.compose.ui.focus.FocusRequester.Companion.FocusRequesterFactory.component2
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import at.mocode.frontend.features.device.initialization.domain.DeviceInitializationValidator
import at.mocode.frontend.features.device.initialization.domain.model.AppThemeSetting
import at.mocode.frontend.features.device.initialization.domain.model.NetworkRole
@Composable
private fun DiscoveryRadar(
@@ -85,7 +88,8 @@ fun DeviceInitializationScreen(
) {
val uiState by viewModel.uiState.collectAsState()
val focusManager = LocalFocusManager.current
val (roleSelectorFocus, deviceNameFocus) = remember { FocusRequester.createRefs() }
val roleSelectorFocus = remember { FocusRequester() }
val deviceNameFocus = remember { FocusRequester() }
// Automatische Discovery starten
LaunchedEffect(Unit) {
@@ -140,7 +144,7 @@ fun DeviceInitializationScreen(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
at.mocode.frontend.features.device.initialization.domain.model.AppThemeSetting.entries.forEach { theme ->
AppThemeSetting.entries.forEach { theme ->
val selected = uiState.settings.appTheme == theme
FilterChip(
selected = selected,
@@ -148,9 +152,9 @@ fun DeviceInitializationScreen(
label = {
Text(
when (theme) {
at.mocode.frontend.features.device.initialization.domain.model.AppThemeSetting.SYSTEM -> "System"
at.mocode.frontend.features.device.initialization.domain.model.AppThemeSetting.LIGHT -> "Hell"
at.mocode.frontend.features.device.initialization.domain.model.AppThemeSetting.DARK -> "Dunkel"
AppThemeSetting.SYSTEM -> "System"
AppThemeSetting.LIGHT -> "Hell"
AppThemeSetting.DARK -> "Dunkel"
},
style = MaterialTheme.typography.labelSmall
)
@@ -184,6 +188,38 @@ fun DeviceInitializationScreen(
val hasDiscoveries = uiState.discoveredMasters.isNotEmpty()
val selectedInterface = uiState.settings.networkInterface
// MASTER INFO CARD (Eigene IP)
if (role == NetworkRole.MASTER) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer.copy(
alpha = 0.3f
)
),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.2f))
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
Icon(Icons.Default.Dns, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
Column {
Text(
"Master-Information",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary
)
Text(
"Dieses Gerät ist erreichbar unter: ${selectedInterface.ifBlank { "Alle Interfaces" }}",
style = MaterialTheme.typography.bodyMedium
)
}
}
}
}
LaunchedEffect(selectedInterface, role) {
if (selectedInterface.isNotEmpty()) {
viewModel.startDiscovery()
@@ -209,8 +245,8 @@ fun DeviceInitializationScreen(
Spacer(modifier = Modifier.width(8.dp))
Text(
text = when {
role == at.mocode.frontend.features.device.initialization.domain.model.NetworkRole.MASTER && hasDiscoveries -> "Aktive Clients im Netzwerk gefunden"
role == at.mocode.frontend.features.device.initialization.domain.model.NetworkRole.MASTER -> "Suche nach verfügbaren Clients..."
role == NetworkRole.MASTER && hasDiscoveries -> "Aktive Clients im Netzwerk gefunden"
role == NetworkRole.MASTER -> "Suche nach verfügbaren Clients..."
hasDiscoveries -> "Master im Netzwerk gefunden"
else -> "Suche nach Master-Geräten..."
},
@@ -237,9 +273,38 @@ fun DeviceInitializationScreen(
)
// MASTER-AUSWAHL FÜR CLIENTS
if (uiState.settings.networkRole == at.mocode.frontend.features.device.initialization.domain.model.NetworkRole.CLIENT && !uiState.isLocked) {
if (uiState.settings.networkRole == NetworkRole.CLIENT && !uiState.isLocked) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("📋 Verfügbare Master im Netzwerk", style = MaterialTheme.typography.titleMedium)
TextButton(onClick = { viewModel.startDiscovery() }) {
Icon(Icons.Default.Refresh, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(4.dp))
Text("Neu suchen")
}
}
// Manuelle IP Eingabe Fallback
OutlinedTextField(
value = uiState.manualIp,
onValueChange = { viewModel.updateManualIp(it) },
label = { Text("Master IP manuell eingeben (Fallback)") },
placeholder = { Text("z.B. 10.0.0.15") },
modifier = Modifier.fillMaxWidth(),
trailingIcon = {
if (uiState.manualIp.isNotBlank()) {
IconButton(onClick = { viewModel.selectManualIp() }) {
Icon(Icons.Default.Add, contentDescription = "Hinzufügen")
}
}
},
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true
)
if (uiState.discoveredMasters.isEmpty()) {
Surface(
@@ -366,12 +431,148 @@ fun DeviceInitializationScreen(
Text("Konfiguration finalisieren")
Icon(Icons.Default.Check, null, Modifier.padding(start = 8.dp).size(18.dp))
}
if (uiState.connectionStatus == ConnectionStatus.CONNECTED) {
OutlinedButton(
onClick = { viewModel.openChatModal() },
modifier = Modifier.fillMaxWidth().height(56.dp),
shape = MaterialTheme.shapes.medium
) {
Icon(Icons.AutoMirrored.Filled.Chat, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text("Verbindung testen (Chat & Self-Test)")
}
}
}
}
}
}
}
if (uiState.showChatModal) {
ChatTestModal(
uiState = uiState,
onDismiss = { viewModel.closeChatModal() },
onSendMessage = { viewModel.sendChatMessage(it) }
)
}
}
@Composable
fun ChatTestModal(
uiState: DeviceInitializationUiState,
onDismiss: () -> Unit,
onSendMessage: (String) -> Unit
) {
var messageText by remember { mutableStateOf("") }
val scrollState = rememberLazyListState()
LaunchedEffect(uiState.chatMessages.size) {
if (uiState.chatMessages.isNotEmpty()) {
scrollState.animateScrollToItem(uiState.chatMessages.size - 1)
}
}
AlertDialog(
onDismissRequest = onDismiss,
title = {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(Icons.Default.NetworkCheck, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
Text("Konnektivitäts-Check & Chat")
}
},
text = {
Column(modifier = Modifier.fillMaxWidth().height(400.dp)) {
Text(
"Teste hier die reale Datenübertragung. Der automatische Self-Test schickt einen Ping und wartet auf ein Pong.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 8.dp)
)
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), MaterialTheme.shapes.medium)
.padding(8.dp)
) {
LazyColumn(
state = scrollState,
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
items(uiState.chatMessages) { msg ->
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = if (msg.isSystem) Alignment.CenterHorizontally else if (msg.sender == "Ich") Alignment.End else Alignment.Start
) {
Surface(
color = if (msg.isSystem) MaterialTheme.colorScheme.secondaryContainer else if (msg.sender == "Ich") MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surface,
contentColor = if (msg.isSystem) MaterialTheme.colorScheme.onSecondaryContainer else if (msg.sender == "Ich") MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface,
shape = MaterialTheme.shapes.small,
border = if (!msg.isSystem && msg.sender != "Ich") BorderStroke(
1.dp,
MaterialTheme.colorScheme.outlineVariant
) else null
) {
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
if (!msg.isSystem) {
Text(msg.sender, style = MaterialTheme.typography.labelSmall, fontWeight = FontWeight.Bold)
}
Text(
msg.text,
style = if (msg.isSystem) MaterialTheme.typography.labelSmall else MaterialTheme.typography.bodyMedium
)
}
}
}
}
}
}
Spacer(Modifier.height(16.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedTextField(
value = messageText,
onValueChange = { messageText = it },
placeholder = { Text("Nachricht senden...") },
modifier = Modifier.weight(1f),
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
keyboardActions = KeyboardActions(onSend = {
if (messageText.isNotBlank()) {
onSendMessage(messageText)
messageText = ""
}
})
)
IconButton(
onClick = {
if (messageText.isNotBlank()) {
onSendMessage(messageText)
messageText = ""
}
},
enabled = messageText.isNotBlank()
) {
Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "Senden")
}
}
}
},
confirmButton = {
Button(onClick = onDismiss) {
Text("Test beenden")
}
}
)
}
@Composable
expect fun DeviceInitializationConfig(
@@ -14,7 +14,17 @@ data class DeviceInitializationUiState(
val error: String? = null,
val isLocked: Boolean = false,
val showRoleChangeWarning: Boolean = false,
val pendingRole: at.mocode.frontend.features.device.initialization.domain.model.NetworkRole? = null
val pendingRole: at.mocode.frontend.features.device.initialization.domain.model.NetworkRole? = null,
val manualIp: String = "",
val showChatModal: Boolean = false,
val chatMessages: List<ChatMessage> = emptyList()
)
data class ChatMessage(
val sender: String,
val text: String,
val timestamp: Long,
val isSystem: Boolean = false
)
enum class ConnectionStatus {
@@ -5,9 +5,9 @@ package at.mocode.frontend.features.device.initialization.presentation
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import at.mocode.frontend.core.network.backup.BackupService
import at.mocode.frontend.core.network.discovery.DiscoveredService
import at.mocode.frontend.core.network.discovery.NetworkDiscoveryService
import at.mocode.frontend.core.network.sync.*
import at.mocode.frontend.features.device.initialization.domain.model.DeviceInitializationSettings
import at.mocode.frontend.features.device.initialization.domain.model.ExpectedClient
import at.mocode.frontend.features.device.initialization.domain.model.NetworkRole
@@ -18,7 +18,7 @@ import kotlin.time.Duration.Companion.milliseconds
class DeviceInitializationViewModel(
private val discoveryService: NetworkDiscoveryService,
private val backupServiceProvider: (String) -> BackupService
private val syncService: P2pSyncService
) : ViewModel() {
private val _uiState = MutableStateFlow(DeviceInitializationUiState())
val uiState: StateFlow<DeviceInitializationUiState> = _uiState.asStateFlow()
@@ -46,7 +46,7 @@ class DeviceInitializationViewModel(
_uiState.update {
it.copy(
discoveredMasters = services,
connectionStatus = if (services.isEmpty() && it.settings.networkRole != NetworkRole.MASTER) {
connectionStatus = if (services.isEmpty() && it.settings.networkRole != NetworkRole.MASTER && it.manualIp.isBlank()) {
ConnectionStatus.SEARCHING
} else {
it.connectionStatus
@@ -55,6 +55,110 @@ class DeviceInitializationViewModel(
}
}
}
viewModelScope.launch {
syncService.incomingEvents.collect { event ->
handleIncomingEvent(event)
}
}
}
private fun handleIncomingEvent(event: SyncEvent) {
when (event) {
is PingEvent -> {
viewModelScope.launch {
syncService.broadcastEvent(
PongEvent(
eventId = "pong-${Clock.System.now().toEpochMilliseconds()}",
sequenceNumber = 0,
originNodeId = uiState.value.settings.deviceName,
createdAt = Clock.System.now().toEpochMilliseconds()
)
)
}
}
is PongEvent -> {
addChatMessage("System", "Handshake erfolgreich (Pong empfangen)!", isSystem = true)
}
is ChatMessageEvent -> {
addChatMessage(event.senderName, event.message)
}
else -> {}
}
}
fun updateManualIp(ip: String) {
_uiState.update { it.copy(manualIp = ip) }
}
fun selectManualIp() {
val ip = uiState.value.manualIp.trim()
if (ip.isNotBlank()) {
val service = DiscoveredService(
name = "Manuelle IP ($ip)",
host = ip,
port = 8080,
metadata = mapOf("type" to "master", "manual" to "true")
)
println("[DeviceInit] Manueller Host hinzugefügt: $ip")
selectMaster(service)
}
}
private fun addChatMessage(sender: String, text: String, isSystem: Boolean = false) {
_uiState.update {
it.copy(
chatMessages = it.chatMessages + ChatMessage(
sender = sender,
text = text,
timestamp = Clock.System.now().toEpochMilliseconds(),
isSystem = isSystem
)
)
}
}
fun sendChatMessage(message: String) {
if (message.isBlank()) return
addChatMessage("Ich", message)
viewModelScope.launch {
syncService.broadcastEvent(
ChatMessageEvent(
eventId = "chat-${Clock.System.now().toEpochMilliseconds()}",
sequenceNumber = 0,
originNodeId = uiState.value.settings.deviceName,
createdAt = Clock.System.now().toEpochMilliseconds(),
senderName = uiState.value.settings.deviceName,
message = message
)
)
}
}
fun openChatModal() {
_uiState.update { it.copy(showChatModal = true, chatMessages = emptyList()) }
startSelfTest()
}
fun closeChatModal() {
_uiState.update { it.copy(showChatModal = false) }
}
private fun startSelfTest() {
addChatMessage("System", "Starte automatischen Self-Test...", isSystem = true)
viewModelScope.launch {
syncService.broadcastEvent(
PingEvent(
eventId = "ping-${Clock.System.now().toEpochMilliseconds()}",
sequenceNumber = 0,
originNodeId = uiState.value.settings.deviceName,
createdAt = Clock.System.now().toEpochMilliseconds()
)
)
}
}
fun selectMaster(master: DiscoveredService) {
@@ -78,8 +182,15 @@ class DeviceInitializationViewModel(
if (key == "1234") { // Demo-Key
_uiState.update { it.copy(connectionStatus = ConnectionStatus.CONNECTED) }
println("[DeviceInit] Verbindung erfolgreich hergestellt!")
syncService.startServer(8080)
syncService.connectToPeer(master.host, 8080)
} else {
_uiState.update { it.copy(connectionStatus = ConnectionStatus.FAILED, error = "Sicherheitsschlüssel ungültig!") }
_uiState.update {
it.copy(
connectionStatus = ConnectionStatus.FAILED,
error = "Sicherheitsschlüssel ungültig!"
)
}
println("[DeviceInit] Verbindung fehlgeschlagen: Falscher Key.")
}
}
@@ -100,6 +211,7 @@ class DeviceInitializationViewModel(
// Falls wir ein Master sind, registrieren wir uns auch direkt, damit andere uns finden
if (uiState.value.settings.networkRole == NetworkRole.MASTER) {
discoveryService.registerService(8080, ip, uiState.value.settings.deviceName)
syncService.startServer(8080)
}
}
@@ -150,26 +262,6 @@ class DeviceInitializationViewModel(
}
}
fun testUsbBackup() {
val settings = uiState.value.settings
if (settings.backupPath.isBlank() || settings.sharedKey.isBlank()) {
println("[DeviceInit] Backup-Pfad oder Shared Key fehlt.")
return
}
viewModelScope.launch {
val service = backupServiceProvider(settings.deviceName)
val testData = "PoC Testdaten - ${settings.deviceName} - ${Clock.System.now()}"
val result = service.exportDelta(testData, settings.backupPath, settings.sharedKey)
if (result.isSuccess) {
println("[DeviceInit] USB-Backup Test erfolgreich.")
} else {
println("[DeviceInit] USB-Backup Test fehlgeschlagen: ${result.exceptionOrNull()?.message}")
}
}
}
fun completeInitialization() {
println("[DeviceInit] Konfiguration wird finalisiert...")
_uiState.update { it.copy(isLocked = true) }