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 # ADR-0027: Netzwerk-Discovery & Interface-Binding
## Status ## Status
In Prüfung (Wartet auf PoC)
Akzeptiert & Implementiert (05.05.2026)
## Kontext ## 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 ## 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. Wir führen ein robustes, mehrstufiges Netzwerk-Management für die Initialisierung ein:
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. 1. **Multi-Interface Broadcast:** Der Master registriert seinen Dienst proaktiv auf **allen** verfügbaren
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. 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 ## Konsequenzen
- Verhindert "Geistersuchen" im falschen Netzwerk.
- Erhöht die Benutzerfreundlichkeit durch automatische Vorschläge. - Deutlich höhere Stabilität in heterogenen Netzwerkumgebungen.
- Erfordert Zugriff auf System-Netzwerk-APIs in der Desktop-Shell. - 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 aggregateType: String,
val aggregateId: String val aggregateId: String
) : SyncEvent ) : 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.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import java.net.InetAddress import java.net.InetAddress
import java.net.NetworkInterface
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import javax.jmdns.JmDNS import javax.jmdns.JmDNS
import javax.jmdns.ServiceEvent import javax.jmdns.ServiceEvent
@@ -15,7 +16,7 @@ import javax.jmdns.ServiceListener
*/ */
class JmDnsDiscoveryService : NetworkDiscoveryService { class JmDnsDiscoveryService : NetworkDiscoveryService {
private var jmdns: JmDNS? = null private val jmdnsInstances = mutableListOf<JmDNS>()
private val SERVICE_TYPE = "_meldestelle._tcp.local." private val SERVICE_TYPE = "_meldestelle._tcp.local."
private val discoveredServicesMap = ConcurrentHashMap<String, DiscoveredService>() private val discoveredServicesMap = ConcurrentHashMap<String, DiscoveredService>()
@@ -23,16 +24,23 @@ class JmDnsDiscoveryService : NetworkDiscoveryService {
override val discoveredServices: StateFlow<List<DiscoveredService>> = _discoveredServices.asStateFlow() override val discoveredServices: StateFlow<List<DiscoveredService>> = _discoveredServices.asStateFlow()
override fun startDiscovery(preferredIp: String?) { override fun startDiscovery(preferredIp: String?) {
if (jmdns == null) { if (jmdnsInstances.isNotEmpty()) return
val addr = preferredIp?.let { InetAddress.getByName(it) } ?: InetAddress.getLocalHost()
println("[Discovery] Starte Discovery gebunden an: $addr") val addresses = getRelevantAddresses(preferredIp)
jmdns = JmDNS.create(addr) 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) { 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) { override fun serviceRemoved(event: ServiceEvent) {
@@ -54,25 +62,39 @@ class JmDnsDiscoveryService : NetworkDiscoveryService {
println("[Discovery] Service gefunden: ${service.name} @ ${service.host}:${service.port}") 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() { override fun stopDiscovery() {
jmdns?.close() jmdnsInstances.forEach { it.close() }
jmdns = null jmdnsInstances.clear()
discoveredServicesMap.clear() discoveredServicesMap.clear()
_discoveredServices.value = emptyList() _discoveredServices.value = emptyList()
} }
override fun registerService(port: Int, preferredIp: String?, deviceName: String?) { override fun registerService(port: Int, preferredIp: String?, deviceName: String?) {
if (jmdns == null) { if (jmdnsInstances.isEmpty()) {
val addr = preferredIp?.let { InetAddress.getByName(it) } ?: InetAddress.getLocalHost() val addresses = getRelevantAddresses(preferredIp)
println("[Discovery] Registriere Dienst gebunden an: $addr") addresses.forEach { addr ->
jmdns = JmDNS.create(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 ?: try {
val name = deviceName ?: System.getProperty("meldestelle.device.name") ?: "Meldestelle-${System.getProperty("user.name")}" java.net.InetAddress.getLocalHost().hostName
} catch (e: Exception) {
"Meldestelle-Device"
} + "-${System.getProperty("user.name", "unknown")}"
jmdnsInstances.forEach { jmdns ->
val serviceInfo = ServiceInfo.create( val serviceInfo = ServiceInfo.create(
SERVICE_TYPE, SERVICE_TYPE,
name, name,
@@ -84,8 +106,41 @@ class JmDnsDiscoveryService : NetworkDiscoveryService {
"nodeId" to name "nodeId" to name
) )
) )
jmdns?.registerService(serviceInfo) try {
println("[Discovery] Eigenen Dienst '$name' registriert auf Port $port") 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> { override fun getDiscoveredServices(): List<DiscoveredService> {
@@ -6,5 +6,5 @@ import at.mocode.frontend.features.device.initialization.presentation.DeviceInit
import org.koin.dsl.module import org.koin.dsl.module
val deviceInitializationModule = 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 package at.mocode.frontend.features.device.initialization.presentation
import androidx.compose.animation.core.* import androidx.compose.animation.core.*
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.*
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.verticalScroll 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.Icons
import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.automirrored.filled.Chat
import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.filled.NetworkCheck
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.focus.FocusRequester 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.focusProperties
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.font.FontWeight 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.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import at.mocode.frontend.features.device.initialization.domain.DeviceInitializationValidator 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 @Composable
private fun DiscoveryRadar( private fun DiscoveryRadar(
@@ -85,7 +88,8 @@ fun DeviceInitializationScreen(
) { ) {
val uiState by viewModel.uiState.collectAsState() val uiState by viewModel.uiState.collectAsState()
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
val (roleSelectorFocus, deviceNameFocus) = remember { FocusRequester.createRefs() } val roleSelectorFocus = remember { FocusRequester() }
val deviceNameFocus = remember { FocusRequester() }
// Automatische Discovery starten // Automatische Discovery starten
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
@@ -140,7 +144,7 @@ fun DeviceInitializationScreen(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp) 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 val selected = uiState.settings.appTheme == theme
FilterChip( FilterChip(
selected = selected, selected = selected,
@@ -148,9 +152,9 @@ fun DeviceInitializationScreen(
label = { label = {
Text( Text(
when (theme) { when (theme) {
at.mocode.frontend.features.device.initialization.domain.model.AppThemeSetting.SYSTEM -> "System" AppThemeSetting.SYSTEM -> "System"
at.mocode.frontend.features.device.initialization.domain.model.AppThemeSetting.LIGHT -> "Hell" AppThemeSetting.LIGHT -> "Hell"
at.mocode.frontend.features.device.initialization.domain.model.AppThemeSetting.DARK -> "Dunkel" AppThemeSetting.DARK -> "Dunkel"
}, },
style = MaterialTheme.typography.labelSmall style = MaterialTheme.typography.labelSmall
) )
@@ -184,6 +188,38 @@ fun DeviceInitializationScreen(
val hasDiscoveries = uiState.discoveredMasters.isNotEmpty() val hasDiscoveries = uiState.discoveredMasters.isNotEmpty()
val selectedInterface = uiState.settings.networkInterface 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) { LaunchedEffect(selectedInterface, role) {
if (selectedInterface.isNotEmpty()) { if (selectedInterface.isNotEmpty()) {
viewModel.startDiscovery() viewModel.startDiscovery()
@@ -209,8 +245,8 @@ fun DeviceInitializationScreen(
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
Text( Text(
text = when { text = when {
role == at.mocode.frontend.features.device.initialization.domain.model.NetworkRole.MASTER && hasDiscoveries -> "Aktive Clients im Netzwerk gefunden" role == 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 -> "Suche nach verfügbaren Clients..."
hasDiscoveries -> "Master im Netzwerk gefunden" hasDiscoveries -> "Master im Netzwerk gefunden"
else -> "Suche nach Master-Geräten..." else -> "Suche nach Master-Geräten..."
}, },
@@ -237,9 +273,38 @@ fun DeviceInitializationScreen(
) )
// MASTER-AUSWAHL FÜR CLIENTS // 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)) { 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) 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()) { if (uiState.discoveredMasters.isEmpty()) {
Surface( Surface(
@@ -366,12 +431,148 @@ fun DeviceInitializationScreen(
Text("Konfiguration finalisieren") Text("Konfiguration finalisieren")
Icon(Icons.Default.Check, null, Modifier.padding(start = 8.dp).size(18.dp)) 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 @Composable
expect fun DeviceInitializationConfig( expect fun DeviceInitializationConfig(
@@ -14,7 +14,17 @@ data class DeviceInitializationUiState(
val error: String? = null, val error: String? = null,
val isLocked: Boolean = false, val isLocked: Boolean = false,
val showRoleChangeWarning: 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 { enum class ConnectionStatus {
@@ -5,9 +5,9 @@ package at.mocode.frontend.features.device.initialization.presentation
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope 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.DiscoveredService
import at.mocode.frontend.core.network.discovery.NetworkDiscoveryService 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.DeviceInitializationSettings
import at.mocode.frontend.features.device.initialization.domain.model.ExpectedClient import at.mocode.frontend.features.device.initialization.domain.model.ExpectedClient
import at.mocode.frontend.features.device.initialization.domain.model.NetworkRole import at.mocode.frontend.features.device.initialization.domain.model.NetworkRole
@@ -18,7 +18,7 @@ import kotlin.time.Duration.Companion.milliseconds
class DeviceInitializationViewModel( class DeviceInitializationViewModel(
private val discoveryService: NetworkDiscoveryService, private val discoveryService: NetworkDiscoveryService,
private val backupServiceProvider: (String) -> BackupService private val syncService: P2pSyncService
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow(DeviceInitializationUiState()) private val _uiState = MutableStateFlow(DeviceInitializationUiState())
val uiState: StateFlow<DeviceInitializationUiState> = _uiState.asStateFlow() val uiState: StateFlow<DeviceInitializationUiState> = _uiState.asStateFlow()
@@ -46,7 +46,7 @@ class DeviceInitializationViewModel(
_uiState.update { _uiState.update {
it.copy( it.copy(
discoveredMasters = services, 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 ConnectionStatus.SEARCHING
} else { } else {
it.connectionStatus 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) { fun selectMaster(master: DiscoveredService) {
@@ -78,8 +182,15 @@ class DeviceInitializationViewModel(
if (key == "1234") { // Demo-Key if (key == "1234") { // Demo-Key
_uiState.update { it.copy(connectionStatus = ConnectionStatus.CONNECTED) } _uiState.update { it.copy(connectionStatus = ConnectionStatus.CONNECTED) }
println("[DeviceInit] Verbindung erfolgreich hergestellt!") println("[DeviceInit] Verbindung erfolgreich hergestellt!")
syncService.startServer(8080)
syncService.connectToPeer(master.host, 8080)
} else { } 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.") 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 // Falls wir ein Master sind, registrieren wir uns auch direkt, damit andere uns finden
if (uiState.value.settings.networkRole == NetworkRole.MASTER) { if (uiState.value.settings.networkRole == NetworkRole.MASTER) {
discoveryService.registerService(8080, ip, uiState.value.settings.deviceName) 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() { fun completeInitialization() {
println("[DeviceInit] Konfiguration wird finalisiert...") println("[DeviceInit] Konfiguration wird finalisiert...")
_uiState.update { it.copy(isLocked = true) } _uiState.update { it.copy(isLocked = true) }