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:
@@ -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*
|
||||||
+15
@@ -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
|
||||||
|
|||||||
+102
-47
@@ -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,69 +24,123 @@ 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 ->
|
||||||
override fun serviceAdded(event: ServiceEvent) {
|
try {
|
||||||
// Bei ServiceAdded fordern wir die Details an
|
println("[Discovery] Starte Discovery gebunden an: $addr")
|
||||||
jmdns?.requestServiceInfo(event.type, event.name)
|
val jmdns = JmDNS.create(addr)
|
||||||
}
|
jmdnsInstances.add(jmdns)
|
||||||
|
|
||||||
override fun serviceRemoved(event: ServiceEvent) {
|
jmdns.addServiceListener(SERVICE_TYPE, object : ServiceListener {
|
||||||
discoveredServicesMap.remove(event.name)
|
override fun serviceAdded(event: ServiceEvent) {
|
||||||
_discoveredServices.value = discoveredServicesMap.values.toList()
|
jmdns.requestServiceInfo(event.type, event.name)
|
||||||
println("[Discovery] Service entfernt: ${event.name}")
|
}
|
||||||
}
|
|
||||||
|
|
||||||
override fun serviceResolved(event: ServiceEvent) {
|
override fun serviceRemoved(event: ServiceEvent) {
|
||||||
val info = event.info
|
discoveredServicesMap.remove(event.name)
|
||||||
val service = DiscoveredService(
|
_discoveredServices.value = discoveredServicesMap.values.toList()
|
||||||
name = event.name,
|
println("[Discovery] Service entfernt: ${event.name}")
|
||||||
host = info.inetAddresses.firstOrNull()?.hostAddress ?: "unknown",
|
}
|
||||||
port = info.port,
|
|
||||||
metadata = info.propertyNames.asSequence().associateWith { info.getPropertyString(it) }
|
override fun serviceResolved(event: ServiceEvent) {
|
||||||
)
|
val info = event.info
|
||||||
discoveredServicesMap[event.name] = service
|
val service = DiscoveredService(
|
||||||
_discoveredServices.value = discoveredServicesMap.values.toList()
|
name = event.name,
|
||||||
println("[Discovery] Service gefunden: ${service.name} @ ${service.host}:${service.port}")
|
host = info.inetAddresses.firstOrNull()?.hostAddress ?: "unknown",
|
||||||
|
port = info.port,
|
||||||
|
metadata = info.propertyNames.asSequence().associateWith { info.getPropertyString(it) }
|
||||||
|
)
|
||||||
|
discoveredServicesMap[event.name] = service
|
||||||
|
_discoveredServices.value = discoveredServicesMap.values.toList()
|
||||||
|
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")}"
|
||||||
|
|
||||||
val serviceInfo = ServiceInfo.create(
|
jmdnsInstances.forEach { jmdns ->
|
||||||
SERVICE_TYPE,
|
val serviceInfo = ServiceInfo.create(
|
||||||
name,
|
SERVICE_TYPE,
|
||||||
port,
|
name,
|
||||||
0, 0, // weight, priority
|
port,
|
||||||
mapOf(
|
0, 0, // weight, priority
|
||||||
"version" to "1.0.0",
|
mapOf(
|
||||||
"type" to "master",
|
"version" to "1.0.0",
|
||||||
"nodeId" to name
|
"type" to "master",
|
||||||
|
"nodeId" to name
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
try {
|
||||||
jmdns?.registerService(serviceInfo)
|
jmdns.registerService(serviceInfo)
|
||||||
println("[Discovery] Eigenen Dienst '$name' registriert auf Port $port")
|
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> {
|
||||||
|
|||||||
+1
-1
@@ -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()) }
|
||||||
}
|
}
|
||||||
|
|||||||
+220
-19
@@ -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)) {
|
||||||
Text("📋 Verfügbare Master im Netzwerk", style = MaterialTheme.typography.titleMedium)
|
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()) {
|
if (uiState.discoveredMasters.isEmpty()) {
|
||||||
Surface(
|
Surface(
|
||||||
@@ -366,11 +431,147 @@ 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
|
||||||
|
|||||||
+11
-1
@@ -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 {
|
||||||
|
|||||||
+116
-24
@@ -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) }
|
||||||
|
|||||||
Reference in New Issue
Block a user