feat(core+frontend): integrate mDNS-based network discovery and update UI

- **Network Discovery Service:**
  - Added platform-specific `DiscoveryModule` with JmDNS-based `JmDnsDiscoveryService` for JVM and no-op implementation for JS.
  - Implemented service and device discovery using mDNS to enable peer-to-peer synchronization within LAN.
  - Registered the module in Koin for dependency injection and integrated it with `networkModule`.

- **Frontend Integration:**
  - Enhanced `BewerbViewModel` with intents and actions for starting, stopping, and refreshing network scans.
  - Introduced polling for discovered services during an active scan.

- **UI Additions:**
  - Added a `NetworkDiscoveryPanel` in `TurnierBewerbeTab` to display discovered services and indicate scan state.
  - Updated action buttons to include toggle functionality for network scans.
This commit is contained in:
2026-04-10 10:27:16 +02:00
parent c06eb79cba
commit 721d991c5e
15 changed files with 351 additions and 11 deletions
+1
View File
@@ -27,6 +27,7 @@ kotlin {
jvmMain.dependencies {
implementation(libs.ktor.client.cio)
implementation("org.jmdns:jmdns:3.5.5")
}
jsMain.dependencies {
@@ -7,8 +7,10 @@ import io.ktor.client.plugins.logging.*
import io.ktor.client.request.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.json.Json
import org.koin.core.module.Module
import org.koin.core.qualifier.named
import org.koin.dsl.module
import at.mocode.frontend.core.network.discovery.discoveryModule
/**
* Schnittstelle zur Token-Bereitstellung entkoppelt core-network von core-auth.
@@ -20,7 +22,8 @@ interface TokenProvider { fun getAccessToken(): String? }
* - "baseHttpClient": Roh-Client für Auth/Keycloak (kein Token-Header)
* - "apiClient": Konfigurierter Client für das API-Gateway (Auth-Header, Retry, Timeout)
*/
val networkModule = module {
val networkModule: Module = module {
includes(discoveryModule)
// 1. Basis-Client (für Auth-Endpunkte, ohne Bearer-Token)
single(named("baseHttpClient")) {
@@ -0,0 +1,10 @@
package at.mocode.frontend.core.network.discovery
import org.koin.core.module.Module
/**
* Erwartetes Koin-Modul für die Netzwerk-Discovery.
* Plattform-spezifische Implementierungen (JVM mit JmDNS, JS/Wasm evtl. No-op)
* müssen hier injiziert werden.
*/
expect val discoveryModule: Module
@@ -0,0 +1,38 @@
package at.mocode.frontend.core.network.discovery
/**
* Modell für einen entdeckten Dienst im lokalen Netzwerk.
*/
data class DiscoveredService(
val name: String,
val host: String,
val port: Int,
val metadata: Map<String, String> = emptyMap()
)
/**
* Interface für die mDNS-basierte Entdeckung von Meldestelle-Instanzen.
* Erlaubt Offline-First Synchronisation im LAN.
*/
interface NetworkDiscoveryService {
/**
* Startet das Scannen nach verfügbaren Diensten im Netzwerk.
*/
fun startDiscovery()
/**
* Stoppt den Scan-Vorgang.
*/
fun stopDiscovery()
/**
* Registriert den eigenen Dienst, damit andere Instanzen ihn finden können.
* @param port Der Port, auf dem der lokale WebSocket-Server lauscht.
*/
fun registerService(port: Int)
/**
* Gibt die Liste der aktuell entdeckten Dienste zurück.
*/
fun getDiscoveredServices(): List<DiscoveredService>
}
@@ -0,0 +1,18 @@
package at.mocode.frontend.core.network.discovery
import org.koin.core.module.Module
import org.koin.dsl.module
/**
* JS-spezifische Implementierung (vorerst No-op, da mDNS im Browser nicht nativ möglich).
*/
actual val discoveryModule: Module = module {
single<NetworkDiscoveryService> { NoOpDiscoveryService() }
}
class NoOpDiscoveryService : NetworkDiscoveryService {
override fun startDiscovery() {}
override fun stopDiscovery() {}
override fun registerService(port: Int) {}
override fun getDiscoveredServices(): List<DiscoveredService> = emptyList()
}
@@ -0,0 +1,11 @@
package at.mocode.frontend.core.network.discovery
import org.koin.core.module.Module
import org.koin.dsl.module
/**
* JVM-spezifische Implementierung des DiscoveryModules.
*/
actual val discoveryModule: Module = module {
single<NetworkDiscoveryService> { JmDnsDiscoveryService() }
}
@@ -0,0 +1,69 @@
package at.mocode.frontend.core.network.discovery
import javax.jmdns.JmDNS
import javax.jmdns.ServiceEvent
import javax.jmdns.ServiceInfo
import javax.jmdns.ServiceListener
import java.net.InetAddress
import java.util.concurrent.ConcurrentHashMap
/**
* JVM-spezifische Implementierung der Netzwerk-Discovery mittels JmDNS.
*/
class JmDnsDiscoveryService : NetworkDiscoveryService {
private var jmdns: JmDNS? = null
private val SERVICE_TYPE = "_meldestelle-biest._tcp.local."
private val discoveredServicesMap = ConcurrentHashMap<String, DiscoveredService>()
override fun startDiscovery() {
if (jmdns == null) {
jmdns = JmDNS.create(InetAddress.getLocalHost())
}
jmdns?.addServiceListener(SERVICE_TYPE, object : ServiceListener {
override fun serviceAdded(event: ServiceEvent) {
// Bei ServiceAdded fordern wir die Details an
jmdns?.requestServiceInfo(event.type, event.name)
}
override fun serviceRemoved(event: ServiceEvent) {
discoveredServicesMap.remove(event.name)
println("[Discovery] Service entfernt: ${event.name}")
}
override fun serviceResolved(event: ServiceEvent) {
val info = event.info
val service = DiscoveredService(
name = event.name,
host = info.inetAddresses.firstOrNull()?.hostAddress ?: "unknown",
port = info.port,
metadata = info.propertyNames.asSequence().associateWith { info.getPropertyString(it) }
)
discoveredServicesMap[event.name] = service
println("[Discovery] Service gefunden: ${service.name} @ ${service.host}:${service.port}")
}
})
}
override fun stopDiscovery() {
jmdns?.close()
jmdns = null
discoveredServicesMap.clear()
}
override fun registerService(port: Int) {
val serviceInfo = ServiceInfo.create(
SERVICE_TYPE,
"Meldestelle-${System.getProperty("user.name")}",
port,
"Offline-First Sync Node"
)
jmdns?.registerService(serviceInfo)
println("[Discovery] Eigenen Dienst registriert auf Port $port")
}
override fun getDiscoveredServices(): List<DiscoveredService> {
return discoveredServicesMap.values.toList()
}
}
@@ -1,5 +1,7 @@
package at.mocode.turnier.feature.presentation
import at.mocode.frontend.core.network.discovery.DiscoveredService
import at.mocode.frontend.core.network.discovery.NetworkDiscoveryService
import at.mocode.turnier.feature.domain.Bewerb
import at.mocode.turnier.feature.domain.BewerbRepository
import at.mocode.turnier.feature.domain.StartlistenRepository
@@ -10,9 +12,9 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
import kotlin.time.Duration.Companion.milliseconds
typealias BewerbListItem = Bewerb
@@ -36,6 +38,8 @@ data class BewerbState(
val showImportDialog: Boolean = false,
val showStartlistePreview: Boolean = false,
val currentStartliste: List<StartlistenZeile> = emptyList(),
val discoveredNodes: List<DiscoveredService> = emptyList(),
val isScanning: Boolean = false,
// Verknüpfung zum Dialog-VM für Abteilungs-Logik (optional)
val dialogState: BewerbAnlegenState = BewerbAnlegenState(),
)
@@ -59,12 +63,16 @@ sealed interface BewerbIntent {
data class ConfirmImport(val turnierId: Long) : BewerbIntent
data object GenerateStartliste : BewerbIntent
data object CloseStartlistePreview : BewerbIntent
data object StartNetworkScan : BewerbIntent
data object StopNetworkScan : BewerbIntent
data object RefreshDiscoveredNodes : BewerbIntent
}
class BewerbViewModel(
private val repo: BewerbRepository,
private val startlistenRepo: StartlistenRepository,
private val discoveryService: NetworkDiscoveryService? = null,
private val turnierId: Long,
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
@@ -114,9 +122,28 @@ class BewerbViewModel(
}
is BewerbIntent.GenerateStartliste -> generateStartliste()
is BewerbIntent.CloseStartlistePreview -> reduce { it.copy(showStartlistePreview = false) }
is BewerbIntent.StartNetworkScan -> startScan()
is BewerbIntent.StopNetworkScan -> stopScan()
is BewerbIntent.RefreshDiscoveredNodes -> refreshNodes()
}
}
private fun startScan() {
discoveryService?.startDiscovery()
_state.update { it.copy(isScanning = true) }
refreshNodes()
}
private fun stopScan() {
discoveryService?.stopDiscovery()
_state.update { it.copy(isScanning = false) }
}
private fun refreshNodes() {
val nodes = discoveryService?.getDiscoveredServices() ?: emptyList()
_state.update { it.copy(discoveredNodes = nodes) }
}
private fun generateStartliste() {
val selectedId = _state.value.selectedId ?: return
reduce { it.copy(isLoading = true) }
@@ -1,5 +1,6 @@
package at.mocode.turnier.feature.di
import at.mocode.frontend.core.network.discovery.NetworkDiscoveryService
import at.mocode.turnier.feature.data.remote.DefaultAbteilungRepository
import at.mocode.turnier.feature.data.remote.DefaultBewerbRepository
import at.mocode.turnier.feature.data.remote.DefaultStartlistenRepository
@@ -24,8 +25,15 @@ val turnierFeatureModule = module {
// ViewModels
factory { TurnierViewModel(repo = get()) }
// BewerbViewModel: repos + turnierId — turnierId wird per parametersOf übergeben
factory { (turnierId: Long) -> BewerbViewModel(repo = get(), startlistenRepo = get(), turnierId = turnierId) }
// BewerbViewModel: repos + discoveryService + turnierId
factory { (turnierId: Long) ->
BewerbViewModel(
repo = get(),
startlistenRepo = get(),
discoveryService = getOrNull<NetworkDiscoveryService>(),
turnierId = turnierId
)
}
// BewerbAnlegenViewModel hat keinen Repository-Parameter (nutzt StoreV2 intern)
factory { BewerbAnlegenViewModel() }
// AbteilungViewModel: repo + bewerbId + abteilungsNr — per parametersOf übergeben
@@ -43,6 +43,16 @@ fun BewerbeTabContent(
) {
val state by viewModel.state.collectAsState()
// Polling für entdeckte Dienste, wenn Scan aktiv ist
LaunchedEffect(state.isScanning) {
if (state.isScanning) {
while (true) {
viewModel.send(BewerbIntent.RefreshDiscoveredNodes)
kotlinx.coroutines.delay(2000)
}
}
}
// Dialog-ViewModel für "Bewerb anlegen"
val bewerbDialogVm = remember { BewerbAnlegenViewModel() }
val bewerbDialogState by bewerbDialogVm.state.collectAsState()
@@ -64,7 +74,12 @@ fun BewerbeTabContent(
viewModel.send(BewerbIntent.OpenImportDialog)
}
},
onGenerateStartliste = { viewModel.send(BewerbIntent.GenerateStartliste) }
onGenerateStartliste = { viewModel.send(BewerbIntent.GenerateStartliste) },
onToggleScan = {
if (state.isScanning) viewModel.send(BewerbIntent.StopNetworkScan)
else viewModel.send(BewerbIntent.StartNetworkScan)
},
isScanning = state.isScanning
)
VerticalDivider()
@@ -131,10 +146,20 @@ fun BewerbeTabContent(
// ── Rechtes Detail-Panel ──────────────────────────────────────────────
val selectedItem = state.list.find { it.id == state.selectedId }
BewerbeDetailPanel(
bewerb = selectedItem?.toUiModel(),
modifier = Modifier.width(340.dp).fillMaxHeight(),
)
Column(modifier = Modifier.width(340.dp).fillMaxHeight()) {
BewerbeDetailPanel(
bewerb = selectedItem?.toUiModel(),
modifier = Modifier.weight(1f),
)
if (state.isScanning || state.discoveredNodes.isNotEmpty()) {
HorizontalDivider()
NetworkDiscoveryPanel(
nodes = state.discoveredNodes,
isScanning = state.isScanning
)
}
}
}
if (bewerbDialogState.isOpen) {
@@ -298,6 +323,8 @@ private fun BewerbeAktionsSpalte(
onBewerbEinfuegen: () -> Unit = {},
onZnsImport: () -> Unit = {},
onGenerateStartliste: () -> Unit = {},
onToggleScan: () -> Unit = {},
isScanning: Boolean = false,
) {
Column(
modifier = modifier.padding(8.dp),
@@ -314,6 +341,11 @@ private fun BewerbeAktionsSpalte(
AktionsBtn("Bewerb nach\noben verschieben")
AktionsBtn("Bewerb nach\nunten verschieben")
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
AktionsBtn(
label = if (isScanning) "Netzwerk-Scan\nStoppen" else "Netzwerk-Scan\nStarten",
onClick = onToggleScan
)
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
AktionsBtn("Startliste\nGenerieren", onClick = onGenerateStartliste)
AktionsBtn("Startliste\nBearbeiten")
AktionsBtn("Startliste\nDrucken")
@@ -322,6 +354,47 @@ private fun BewerbeAktionsSpalte(
}
}
@Composable
private fun NetworkDiscoveryPanel(
nodes: List<at.mocode.frontend.core.network.discovery.DiscoveredService>,
isScanning: Boolean
) {
Column(
modifier = Modifier
.fillMaxWidth()
.height(200.dp)
.padding(8.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Netzwerk (P2P)", style = MaterialTheme.typography.titleSmall, color = PrimaryBlue)
if (isScanning) {
Spacer(Modifier.width(8.dp))
CircularProgressIndicator(modifier = Modifier.size(12.dp), strokeWidth = 2.dp)
}
}
Spacer(Modifier.height(4.dp))
if (nodes.isEmpty()) {
Text("Keine Instanzen gefunden", fontSize = 11.sp, color = Color.Gray)
} else {
LazyColumn {
items(nodes) { node ->
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(Icons.Default.Refresh, contentDescription = null, modifier = Modifier.size(16.dp), tint = PrimaryBlue)
Spacer(Modifier.width(8.dp))
Column {
Text(node.name, fontSize = 12.sp, fontWeight = FontWeight.Bold)
Text("${node.host}:${node.port}", fontSize = 10.sp, color = Color.Gray)
}
}
}
}
}
}
}
@Composable
private fun AktionsBtn(label: String, onClick: () -> Unit = {}) {
OutlinedButton(
@@ -128,7 +128,7 @@ fun PreviewTurnierBewerbeTab() {
override suspend fun generate(bewerbId: Long): Result<List<StartlistenZeile>> = Result.success(emptyList())
override suspend fun getByBewerb(bewerbId: Long): Result<List<StartlistenZeile>> = Result.success(emptyList())
}
val vm = BewerbViewModel(mockRepo, mockStartlistenRepo, 1L)
val vm = BewerbViewModel(mockRepo, mockStartlistenRepo, null, 1L)
MaterialTheme {
BewerbeTabContent(viewModel = vm, turnierId = 1L)
}