feat(device-initialization, core): Plan-USB-Backup hinzugefügt, BackupService implementiert und UI-Export-Button ergänzt
Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
This commit is contained in:
@@ -85,8 +85,8 @@ Fokus: Physische Implementierung der Turnier-Hierarchie und technisches Onboardi
|
|||||||
* [x] **Plan-USB Integration (UI):** Implementiert (Wartet auf Hardware-Test).
|
* [x] **Plan-USB Integration (UI):** Implementiert (Wartet auf Hardware-Test).
|
||||||
* [x] **Offline-Lizenzierung (Konzept):** Dokumentiert (ADR-0026).
|
* [x] **Offline-Lizenzierung (Konzept):** Dokumentiert (ADR-0026).
|
||||||
* [x] **UX-Optimierung:** Implementiert (Wartet auf Hardware-Test).
|
* [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).
|
* [ ] **PoC Verifikation:** 🔴 OFFEN (Hardware-Test durch User erforderlich).
|
||||||
* [ ] **Plan-USB Implementierung:** 🔴 OFFEN (Verschlüsselter Datei-Export).
|
|
||||||
|
|
||||||
### MEILENSTEIN 1: Die Basis-Hierarchie (Prio 1) ⚪ GEPLANT
|
### MEILENSTEIN 1: Die Basis-Hierarchie (Prio 1) ⚪ GEPLANT
|
||||||
|
|
||||||
|
|||||||
+23
@@ -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<String>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liest Daten aus einer verschlüsselten Datei ein.
|
||||||
|
*/
|
||||||
|
fun importDelta(filePath: String, sharedKey: String): Result<String>
|
||||||
|
}
|
||||||
+1
-1
@@ -3,7 +3,7 @@ package at.mocode.frontend.core.network.discovery
|
|||||||
import org.koin.core.module.Module
|
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)
|
* Plattform-spezifische Implementierungen (JVM mit JmDNS, JS/Wasm evtl. No-op)
|
||||||
* müssen hier injiziert werden.
|
* müssen hier injiziert werden.
|
||||||
*/
|
*/
|
||||||
|
|||||||
+87
@@ -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<String> {
|
||||||
|
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<String> {
|
||||||
|
return try {
|
||||||
|
val file = File(filePath)
|
||||||
|
val encryptedData = file.readText()
|
||||||
|
val jsonContent = decrypt(encryptedData, sharedKey)
|
||||||
|
val payload = json.decodeFromString<BackupPayload>(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
|
||||||
+3
@@ -1,5 +1,7 @@
|
|||||||
package at.mocode.frontend.core.network.discovery
|
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.core.module.Module
|
||||||
import org.koin.dsl.module
|
import org.koin.dsl.module
|
||||||
|
|
||||||
@@ -8,4 +10,5 @@ import org.koin.dsl.module
|
|||||||
*/
|
*/
|
||||||
actual val discoveryModule: Module = module {
|
actual val discoveryModule: Module = module {
|
||||||
single<NetworkDiscoveryService> { JmDnsDiscoveryService() }
|
single<NetworkDiscoveryService> { JmDnsDiscoveryService() }
|
||||||
|
single<BackupService> { (deviceName: String) -> FileBackupService(deviceName) }
|
||||||
}
|
}
|
||||||
|
|||||||
+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()) }
|
factory { DeviceInitializationViewModel(get(), { deviceName -> get { org.koin.core.parameter.parametersOf(deviceName) } }) }
|
||||||
}
|
}
|
||||||
|
|||||||
+24
-1
@@ -5,15 +5,18 @@ 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.NetworkDiscoveryService
|
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.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
|
||||||
import kotlinx.coroutines.flow.*
|
import kotlinx.coroutines.flow.*
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlin.time.Clock
|
||||||
|
|
||||||
class DeviceInitializationViewModel(
|
class DeviceInitializationViewModel(
|
||||||
private val discoveryService: NetworkDiscoveryService
|
private val discoveryService: NetworkDiscoveryService,
|
||||||
|
private val backupServiceProvider: (String) -> BackupService
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
private val _uiState = MutableStateFlow(DeviceInitializationUiState())
|
private val _uiState = MutableStateFlow(DeviceInitializationUiState())
|
||||||
val uiState: StateFlow<DeviceInitializationUiState> = _uiState.asStateFlow()
|
val uiState: StateFlow<DeviceInitializationUiState> = _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() {
|
fun completeInitialization() {
|
||||||
println("[DeviceInit] Konfiguration wird finalisiert...")
|
println("[DeviceInit] Konfiguration wird finalisiert...")
|
||||||
_uiState.update { it.copy(isLocked = true) }
|
_uiState.update { it.copy(isLocked = true) }
|
||||||
|
|||||||
+13
@@ -7,6 +7,7 @@ import androidx.compose.foundation.text.KeyboardActions
|
|||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
import androidx.compose.material.icons.filled.Delete
|
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.Visibility
|
||||||
import androidx.compose.material.icons.outlined.VisibilityOff
|
import androidx.compose.material.icons.outlined.VisibilityOff
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
@@ -141,6 +142,18 @@ actual fun DeviceInitializationConfig(
|
|||||||
enabled = !uiState.isLocked
|
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 printers = remember {
|
||||||
val systemPrinters = PrintServiceLookup.lookupPrintServices(null, null).map { it.name }.toMutableList()
|
val systemPrinters = PrintServiceLookup.lookupPrintServices(null, null).map { it.name }.toMutableList()
|
||||||
if (!systemPrinters.contains("PDF-Export (Lokal)")) {
|
if (!systemPrinters.contains("PDF-Export (Lokal)")) {
|
||||||
|
|||||||
Reference in New Issue
Block a user