From 62f9472695bb66d3f721e5bd75c3411d4cabdf23 Mon Sep 17 00:00:00 2001 From: Stefan Mogeritsch Date: Wed, 29 Apr 2026 15:08:11 +0200 Subject: [PATCH] =?UTF-8?q?feat(device-initialization,=20core):=20Plan-USB?= =?UTF-8?q?-Backup=20hinzugef=C3=BCgt,=20BackupService=20implementiert=20u?= =?UTF-8?q?nd=20UI-Export-Button=20erg=C3=A4nzt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Stefan Mogeritsch --- docs/01_Architecture/MASTER_ROADMAP.md | 2 +- .../core/network/backup/BackupService.kt | 23 +++++ .../core/network/discovery/DiscoveryModule.kt | 2 +- .../core/network/backup/FileBackupService.kt | 87 +++++++++++++++++++ .../core/network/discovery/DiscoveryModule.kt | 3 + .../di/DeviceInitializationModule.kt | 2 +- .../DeviceInitializationViewModel.kt | 25 +++++- .../DeviceInitializationConfig.jvm.kt | 13 +++ 8 files changed, 153 insertions(+), 4 deletions(-) create mode 100644 frontend/core/network/src/commonMain/kotlin/at/mocode/frontend/core/network/backup/BackupService.kt create mode 100644 frontend/core/network/src/jvmMain/kotlin/at/mocode/frontend/core/network/backup/FileBackupService.kt diff --git a/docs/01_Architecture/MASTER_ROADMAP.md b/docs/01_Architecture/MASTER_ROADMAP.md index 7db49c6a..8280310f 100644 --- a/docs/01_Architecture/MASTER_ROADMAP.md +++ b/docs/01_Architecture/MASTER_ROADMAP.md @@ -85,8 +85,8 @@ Fokus: Physische Implementierung der Turnier-Hierarchie und technisches Onboardi * [x] **Plan-USB Integration (UI):** Implementiert (Wartet auf Hardware-Test). * [x] **Offline-Lizenzierung (Konzept):** Dokumentiert (ADR-0026). * [x] **UX-Optimierung:** Implementiert (Wartet auf Hardware-Test). +* [x] **Plan-USB Implementierung:** Delta-Logik & AES-Export (Wartet auf Hardware-Test). * [ ] **PoC Verifikation:** 🔴 OFFEN (Hardware-Test durch User erforderlich). -* [ ] **Plan-USB Implementierung:** 🔴 OFFEN (Verschlüsselter Datei-Export). ### MEILENSTEIN 1: Die Basis-Hierarchie (Prio 1) ⚪ GEPLANT diff --git a/frontend/core/network/src/commonMain/kotlin/at/mocode/frontend/core/network/backup/BackupService.kt b/frontend/core/network/src/commonMain/kotlin/at/mocode/frontend/core/network/backup/BackupService.kt new file mode 100644 index 00000000..37415a81 --- /dev/null +++ b/frontend/core/network/src/commonMain/kotlin/at/mocode/frontend/core/network/backup/BackupService.kt @@ -0,0 +1,23 @@ +package at.mocode.frontend.core.network.backup + +import kotlinx.serialization.Serializable + +@Serializable +data class BackupPayload( + val timestamp: Long, + val deviceName: String, + val data: String, + val checksum: String +) + +interface BackupService { + /** + * Schreibt Daten verschlüsselt in das Backup-Verzeichnis. + */ + fun exportDelta(data: String, targetPath: String, sharedKey: String): Result + + /** + * Liest Daten aus einer verschlüsselten Datei ein. + */ + fun importDelta(filePath: String, sharedKey: String): Result +} diff --git a/frontend/core/network/src/commonMain/kotlin/at/mocode/frontend/core/network/discovery/DiscoveryModule.kt b/frontend/core/network/src/commonMain/kotlin/at/mocode/frontend/core/network/discovery/DiscoveryModule.kt index afc8aad7..e2d2c0e5 100644 --- a/frontend/core/network/src/commonMain/kotlin/at/mocode/frontend/core/network/discovery/DiscoveryModule.kt +++ b/frontend/core/network/src/commonMain/kotlin/at/mocode/frontend/core/network/discovery/DiscoveryModule.kt @@ -3,7 +3,7 @@ package at.mocode.frontend.core.network.discovery import org.koin.core.module.Module /** - * Erwartetes Koin-Modul für die Netzwerk-Discovery. + * Erwartetes Koin-Modul für die Netzwerk-Discovery und Backup. * Plattform-spezifische Implementierungen (JVM mit JmDNS, JS/Wasm evtl. No-op) * müssen hier injiziert werden. */ diff --git a/frontend/core/network/src/jvmMain/kotlin/at/mocode/frontend/core/network/backup/FileBackupService.kt b/frontend/core/network/src/jvmMain/kotlin/at/mocode/frontend/core/network/backup/FileBackupService.kt new file mode 100644 index 00000000..aba03f6f --- /dev/null +++ b/frontend/core/network/src/jvmMain/kotlin/at/mocode/frontend/core/network/backup/FileBackupService.kt @@ -0,0 +1,87 @@ +package at.mocode.frontend.core.network.backup + +import kotlinx.serialization.json.Json +import java.io.File +import java.security.MessageDigest +import java.util.* +import javax.crypto.Cipher +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.SecretKeySpec + +class FileBackupService(private val deviceName: String) : BackupService { + private val json = Json { prettyPrint = true } + + override fun exportDelta(data: String, targetPath: String, sharedKey: String): Result { + return try { + val timestamp = System.currentTimeMillis() + val checksum = calculateChecksum(data) + val payload = BackupPayload(timestamp, deviceName, data, checksum) + val jsonContent = json.encodeToString(payload) + + val encryptedData = encrypt(jsonContent, sharedKey) + + val dir = File(targetPath) + if (!dir.exists()) dir.mkdirs() + + val fileName = "delta_${timestamp}_${deviceName}.msbackup" + val file = File(dir, fileName) + file.writeText(encryptedData) + + println("[Plan-USB] Export erfolgreich: ${file.absolutePath}") + Result.success(file.absoluteName) + } catch (e: Exception) { + println("[Plan-USB] Export fehlgeschlagen: ${e.message}") + Result.failure(e) + } + } + + override fun importDelta(filePath: String, sharedKey: String): Result { + return try { + val file = File(filePath) + val encryptedData = file.readText() + val jsonContent = decrypt(encryptedData, sharedKey) + val payload = json.decodeFromString(jsonContent) + + if (calculateChecksum(payload.data) != payload.checksum) { + throw Exception("Checksummenfehler: Daten wurden möglicherweise manipuliert.") + } + + println("[Plan-USB] Import erfolgreich von ${payload.deviceName}") + Result.success(payload.data) + } catch (e: Exception) { + println("[Plan-USB] Import fehlgeschlagen: ${e.message}") + Result.failure(e) + } + } + + private fun calculateChecksum(data: String): String { + val bytes = MessageDigest.getInstance("SHA-256").digest(data.toByteArray()) + return bytes.joinToString("") { "%02x".format(it) } + } + + private fun encrypt(data: String, key: String): String { + val secretKey = generateKey(key) + val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") + val iv = IvParameterSpec(ByteArray(16)) // Vereinfacht für PoC + cipher.init(Cipher.ENCRYPT_MODE, secretKey, iv) + val encrypted = cipher.doFinal(data.toByteArray()) + return Base64.getEncoder().encodeToString(encrypted) + } + + private fun decrypt(encrypted: String, key: String): String { + val secretKey = generateKey(key) + val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") + val iv = IvParameterSpec(ByteArray(16)) + cipher.init(Cipher.DECRYPT_MODE, secretKey, iv) + val decrypted = cipher.doFinal(Base64.getDecoder().decode(encrypted)) + return String(decrypted) + } + + private fun generateKey(key: String): SecretKeySpec { + val sha = MessageDigest.getInstance("SHA-256") + val keyBytes = sha.digest(key.toByteArray()).copyOf(16) // AES-128 für Kompatibilität + return SecretKeySpec(keyBytes, "AES") + } +} + +private val File.absoluteName: String get() = this.name diff --git a/frontend/core/network/src/jvmMain/kotlin/at/mocode/frontend/core/network/discovery/DiscoveryModule.kt b/frontend/core/network/src/jvmMain/kotlin/at/mocode/frontend/core/network/discovery/DiscoveryModule.kt index 22189b32..4cf497b6 100644 --- a/frontend/core/network/src/jvmMain/kotlin/at/mocode/frontend/core/network/discovery/DiscoveryModule.kt +++ b/frontend/core/network/src/jvmMain/kotlin/at/mocode/frontend/core/network/discovery/DiscoveryModule.kt @@ -1,5 +1,7 @@ package at.mocode.frontend.core.network.discovery +import at.mocode.frontend.core.network.backup.BackupService +import at.mocode.frontend.core.network.backup.FileBackupService import org.koin.core.module.Module import org.koin.dsl.module @@ -8,4 +10,5 @@ import org.koin.dsl.module */ actual val discoveryModule: Module = module { single { JmDnsDiscoveryService() } + single { (deviceName: String) -> FileBackupService(deviceName) } } diff --git a/frontend/features/device-initialization/src/commonMain/kotlin/at/mocode/frontend/features/device/initialization/di/DeviceInitializationModule.kt b/frontend/features/device-initialization/src/commonMain/kotlin/at/mocode/frontend/features/device/initialization/di/DeviceInitializationModule.kt index 6777b108..8fe59ae5 100644 --- a/frontend/features/device-initialization/src/commonMain/kotlin/at/mocode/frontend/features/device/initialization/di/DeviceInitializationModule.kt +++ b/frontend/features/device-initialization/src/commonMain/kotlin/at/mocode/frontend/features/device/initialization/di/DeviceInitializationModule.kt @@ -6,5 +6,5 @@ import at.mocode.frontend.features.device.initialization.presentation.DeviceInit import org.koin.dsl.module val deviceInitializationModule = module { - factory { DeviceInitializationViewModel(get()) } + factory { DeviceInitializationViewModel(get(), { deviceName -> get { org.koin.core.parameter.parametersOf(deviceName) } }) } } diff --git a/frontend/features/device-initialization/src/commonMain/kotlin/at/mocode/frontend/features/device/initialization/presentation/DeviceInitializationViewModel.kt b/frontend/features/device-initialization/src/commonMain/kotlin/at/mocode/frontend/features/device/initialization/presentation/DeviceInitializationViewModel.kt index fe8eba3d..057037ee 100644 --- a/frontend/features/device-initialization/src/commonMain/kotlin/at/mocode/frontend/features/device/initialization/presentation/DeviceInitializationViewModel.kt +++ b/frontend/features/device-initialization/src/commonMain/kotlin/at/mocode/frontend/features/device/initialization/presentation/DeviceInitializationViewModel.kt @@ -5,15 +5,18 @@ package at.mocode.frontend.features.device.initialization.presentation import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import at.mocode.frontend.core.network.backup.BackupService import at.mocode.frontend.core.network.discovery.NetworkDiscoveryService import at.mocode.frontend.features.device.initialization.domain.model.DeviceInitializationSettings import at.mocode.frontend.features.device.initialization.domain.model.ExpectedClient import at.mocode.frontend.features.device.initialization.domain.model.NetworkRole import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch +import kotlin.time.Clock class DeviceInitializationViewModel( - private val discoveryService: NetworkDiscoveryService + private val discoveryService: NetworkDiscoveryService, + private val backupServiceProvider: (String) -> BackupService ) : ViewModel() { private val _uiState = MutableStateFlow(DeviceInitializationUiState()) val uiState: StateFlow = _uiState.asStateFlow() @@ -89,6 +92,26 @@ class DeviceInitializationViewModel( } } + fun testUsbBackup() { + val settings = uiState.value.settings + if (settings.backupPath.isBlank() || settings.sharedKey.isBlank()) { + println("[DeviceInit] Backup-Pfad oder Shared Key fehlt.") + return + } + + viewModelScope.launch { + val service = backupServiceProvider(settings.deviceName) + val testData = "PoC Testdaten - ${settings.deviceName} - ${Clock.System.now()}" + val result = service.exportDelta(testData, settings.backupPath, settings.sharedKey) + + if (result.isSuccess) { + println("[DeviceInit] USB-Backup Test erfolgreich.") + } else { + println("[DeviceInit] USB-Backup Test fehlgeschlagen: ${result.exceptionOrNull()?.message}") + } + } + } + fun completeInitialization() { println("[DeviceInit] Konfiguration wird finalisiert...") _uiState.update { it.copy(isLocked = true) } diff --git a/frontend/features/device-initialization/src/jvmMain/kotlin/at/mocode/frontend/features/device/initialization/presentation/DeviceInitializationConfig.jvm.kt b/frontend/features/device-initialization/src/jvmMain/kotlin/at/mocode/frontend/features/device/initialization/presentation/DeviceInitializationConfig.jvm.kt index cec7bc28..e2323b62 100644 --- a/frontend/features/device-initialization/src/jvmMain/kotlin/at/mocode/frontend/features/device/initialization/presentation/DeviceInitializationConfig.jvm.kt +++ b/frontend/features/device-initialization/src/jvmMain/kotlin/at/mocode/frontend/features/device/initialization/presentation/DeviceInitializationConfig.jvm.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.text.KeyboardActions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Usb import androidx.compose.material.icons.outlined.Visibility import androidx.compose.material.icons.outlined.VisibilityOff import androidx.compose.material3.* @@ -141,6 +142,18 @@ actual fun DeviceInitializationConfig( enabled = !uiState.isLocked ) + if (!uiState.isLocked && settings.backupPath.isNotBlank() && settings.sharedKey.isNotBlank()) { + OutlinedButton( + onClick = { viewModel.testUsbBackup() }, + modifier = Modifier.padding(top = 4.dp).align(Alignment.End), + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp) + ) { + Icon(Icons.Default.Usb, null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text("Plan-USB Test-Export", style = MaterialTheme.typography.labelLarge) + } + } + val printers = remember { val systemPrinters = PrintServiceLookup.lookupPrintServices(null, null).map { it.name }.toMutableList() if (!systemPrinters.contains("PDF-Export (Lokal)")) {