package de.waypointaudio.license import android.content.Context import androidx.compose.runtime.mutableStateOf import org.json.JSONObject /** * v2.5.29 — Lizenzstatus-Anbieter: Update-Persistenz-Fix. * * Hintergrund des Bugs (gefunden in v2.5.29-Analyse): * - LicenseStatusProvider ist ein Kotlin-object (Singleton), der beim Start * eines neuen App-Prozesses frisch initialisiert wird — mit initialized=false. * - Die primäre Lizenzdatei (filesDir/license/) überlebt APK-Updates, da * Android App-Daten bei Updates NICHT löscht. * - Dennoch wurde nach einem Update "Keine Lizenz importiert" angezeigt, weil: * (a) ensureLoaded() nur in AboutDialog aufgerufen wird (LaunchedEffect) — * also erst wenn der Nutzer den Über-Dialog öffnet. * (b) ensureLoaded() setzt initialized=true BEVOR reload() läuft, was bei * parallelen Aufrufen dazu führen kann, dass der zweite Aufruf das Laden * überspringt (initialized=true nach erstem Aufruf, reload noch pending). * (c) remember(reloadTrigger) { LicenseStatusProvider.status } liest den * State synchron bei der ersten Komposition. Da ensureLoaded() noch nicht * gelaufen ist, liefert es emptyStatus(). Nach dem LaunchedEffect-Reload * aktualisiert sich der State, aber reloadTrigger hat sich nicht erhöht, * sodass remember() den alten Wert cacht. * * Fixes in v2.5.29: * 1. WaypointApp.onCreate() ruft LicenseStatusProvider.reloadBlocking(context) * auf einem Hintergrundthread auf, sodass der Lizenzstatus beim ersten * Compose-Render bereits korrekt gesetzt ist. * 2. ensureLoaded() ist jetzt @Synchronized, sodass kein Race beim parallelen * Aufruf passieren kann. * 3. reloadBlocking() / reload() setzt initialized=true NACH dem Laden, nicht davor. * Damit schützt das Flag korrekt vor doppeltem Laden ohne das Laden zu überspringen. * 4. LicenseStorage.readRaw() hat eine Fallback-Logik über SharedPreferences: * Falls die primäre Datei fehlt, wird sie aus einem Preferences-Eintrag * wiederhergestellt (self-healing). Die Preferences werden beim Schreiben * stets mitgepflegt und nur beim expliziten „Entfernen" gelöscht. * * Unverändertes Verhalten: * - Import: funktionierender SAF-Picker, Dispatchers.IO, drei Lesestrategien. * - Signaturprüfung: ECDSA-P256-SHA256, CanonicalJson, Public Key aus res/raw. * - remove(): löscht Datei UND Preferences-Fallback, setzt initialized=false. * - Backup-Ausschluss: bleibt in backup_rules.xml / data_extraction_rules.xml. * HINWEIS: Backup-Ausschluss betrifft NICHT APK-Updates. APK-Updates lassen * App-Daten grundsätzlich unangetastet. * - Keine Feature-Gates aktiviert oder deaktiviert. * * v2.5.21: * - initialized-Flag wird nach remove() zurückgesetzt. * - import() liest Bytes immer frisch, kein gecachter URI-Zustand. * * v2.5.20: * - Lizenzimport, Offline-Signaturprüfung, Anzeige im About-Dialog. * * Compose-Integration: der aktuelle Status liegt in einem MutableState, das * von der About-Card direkt gelesen werden kann. * * WICHTIG: Es werden KEINE App-Funktionen abhängig vom Ergebnis aktiviert * oder gesperrt. Der Provider dient ausschließlich der Anzeige. */ object LicenseStatusProvider { private val statusState = mutableStateOf(emptyStatus()) // v2.5.29: @Volatile + initialized wird erst NACH dem Laden auf true gesetzt. @Volatile private var initialized = false val status: LicenseStatus get() = statusState.value /** * Stellt sicher, dass die Lizenz aus dem persistenten Speicher geladen ist. * * v2.5.29 — @Synchronized: verhindert Race-Conditions bei parallelen Aufrufen * (z. B. aus LaunchedEffect und WaypointApp.onCreate gleichzeitig). * initialized wird erst NACH dem Laden auf true gesetzt. */ @Synchronized fun ensureLoaded(context: Context) { if (initialized) return reload(context) initialized = true } /** * Liest die Lizenzdatei aus dem persistenten Speicher und aktualisiert den Status. * Kann jederzeit aufgerufen werden (z. B. nach Import oder als Startup-Init). * * v2.5.29 — Diese Methode ist unverändert gegenüber v2.5.21 und kann sicher * aus einem Hintergrundthread aufgerufen werden. Das MutableState-Update ist * Thread-safe (Compose-State erlaubt Schreiben von beliebigen Threads; * Compose rekomponiert auf dem Hauptthread beim nächsten Frame). */ fun reload(context: Context) { val raw = LicenseStorage.readRaw(context) statusState.value = if (raw == null) { emptyStatus() } else { buildStatus(LicenseValidator.verifyOffline(context, raw.toByteArray(Charsets.UTF_8))) } } /** * Snapshot-Variante für Aufrufer, die nicht in Compose laufen oder den * Pre-Compose-Default (v2.5.17-kompatibel) benötigen. */ fun current(): LicenseStatus = statusState.value /** * Versucht, eine Lizenzdatei zu importieren. * * v2.5.29 — initialized wird beim Import explizit auf true gesetzt (wie v2.5.21). * * v2.5.21 — Wichtige Verhaltensänderung: * - Die übergebenen [fileBytes] werden IMMER frisch ausgewertet. * - [initialized] bleibt nach diesem Aufruf true (ist bereits geladen). * - Fehlerbehandlung: alle Exception-Pfade werden abgefangen. * * - Bei gültiger Lizenz: speichert die Datei lokal und aktualisiert den Status. * - Bei ungültiger/abgelaufener Lizenz: aktualisiert den Status NICHT, * falls bereits eine gültige Lizenz vorhanden ist. */ fun import(context: Context, fileBytes: ByteArray): ImportResult { // initialized hier explizit auf true setzen, damit ensureLoaded() danach // kein erneutes Laden überschreibt, das einen alten Stand zurückbringt. initialized = true return try { val result = LicenseValidator.verifyOffline(context, fileBytes) when (result.state) { LicenseStatus.State.VALID -> { val raw = result.rawJson ?: String(fileBytes, Charsets.UTF_8) val stored = LicenseStorage.writeRaw(context, raw) if (stored) { statusState.value = buildStatus(result) ImportResult(true, "Lizenz erfolgreich importiert") } else { // v2.5.29: writeRaw schreibt Fallback-Preferences auch bei // Datei-Fehler. Status trotzdem aktualisieren, da Fallback greift. statusState.value = buildStatus(result) ImportResult(true, "Lizenz importiert (Fallback-Speicher)") } } else -> { val keepExisting = statusState.value.state == LicenseStatus.State.VALID if (!keepExisting) { statusState.value = buildStatus(result) } ImportResult(false, result.message ?: "Lizenz ungültig") } } } catch (e: Throwable) { ImportResult(false, "Unerwarteter Fehler beim Import: ${e.message}") } } /** * Entfernt die gespeicherte Lizenz und setzt den Zustand vollständig zurück. * * v2.5.29: LicenseStorage.delete() löscht jetzt auch den Fallback in den * SharedPreferences, sodass die Lizenz nach dem Entfernen wirklich weg ist — * auch wenn der Fallback-Pfad greifen würde. * * v2.5.21 fix: initialized wird auf false gesetzt, damit ein nachfolgender * Import-Aufruf in einem sauberen Zustand startet. */ fun remove(context: Context): Boolean { val ok = LicenseStorage.delete(context) if (ok) { statusState.value = emptyStatus() // Reset initialized so the next ensureLoaded() re-reads from disk // (which will now correctly show no license). initialized = false } return ok } fun exportRawJson(context: Context): String? = LicenseStorage.readRaw(context) fun hasStoredLicense(context: Context): Boolean = LicenseStorage.exists(context) private fun emptyStatus(): LicenseStatus = LicenseStatus( stateText = "Keine Lizenz importiert", holderText = "-", validityText = "-", featuresText = "Basisfunktionen", isPro = false, state = LicenseStatus.State.NONE, ) private fun buildStatus(result: LicenseValidator.Result): LicenseStatus { val payload = result.payload if (payload == null) { return LicenseStatus( stateText = "Ungültige Lizenz", holderText = "-", validityText = "-", featuresText = "Basisfunktionen", isPro = false, state = result.state, errorMessage = result.message, ) } val typeRaw = payload.optString("license_type", "") val typeLabel = humanLicenseType(typeRaw) val holder = humanHolder(payload.optJSONObject("customer")) val validity = humanValidity(payload.opt("valid_until")) val permissions = readPermissions(payload.optJSONObject("permissions")) val featuresText = humanFeatures(permissions) val stateText = when (result.state) { LicenseStatus.State.VALID -> "Gültige Lizenz" LicenseStatus.State.EXPIRED -> "Lizenz abgelaufen" LicenseStatus.State.INVALID -> "Ungültige Lizenz" LicenseStatus.State.NONE -> "Keine Lizenz importiert" } val isPro = result.state == LicenseStatus.State.VALID && typeRaw.isNotEmpty() return LicenseStatus( stateText = stateText, holderText = holder, validityText = validity, featuresText = featuresText, isPro = isPro, state = result.state, licenseId = payload.optString("license_id", "").ifEmpty { null }, licenseTypeRaw = typeRaw.ifEmpty { null }, licenseTypeLabel = typeLabel, bindingText = humanBinding(payload.optJSONObject("binding")), permissions = permissions, errorMessage = if (result.state == LicenseStatus.State.VALID) null else result.message, ) } private fun humanLicenseType(raw: String): String? = when (raw) { "" -> null "commercial", "pro", "profi" -> "Profi" "developer" -> "Developer" "trial" -> "Trial" else -> raw.replaceFirstChar { it.uppercase() } } private fun humanHolder(customer: JSONObject?): String { if (customer == null) return "-" val company = customer.optString("company", "").trim() if (company.isNotEmpty() && company != "null") return company val name = customer.optString("name", "").trim() if (name.isNotEmpty() && name != "null") return name val email = customer.optString("email", "").trim() return if (email.isNotEmpty() && email != "null") email else "-" } private fun humanValidity(value: Any?): String { if (value == null || value === JSONObject.NULL) return "Unbefristet" val str = value.toString().trim() if (str.isEmpty() || str == "null") return "Unbefristet" val datePart = str.substringBefore('T') return datePart.ifEmpty { str } } private fun humanBinding(binding: JSONObject?): String { if (binding == null) return "Keine Bindung" return when (binding.optString("type", "none")) { "none" -> "Keine Bindung" "device" -> "Geräte-Bindung (1 Gerät)" "devices" -> { val max = binding.optInt("max_devices", -1) if (max > 0) "Geräte-Bindung (max. $max Geräte)" else "Geräte-Bindung" } "organization" -> { val org = binding.optString("organization_name", "").trim() if (org.isNotEmpty() && org != "null") "Organisation: $org" else "Organisations-Bindung" } else -> "Unbekannte Bindung" } } private fun readPermissions(perms: JSONObject?): Map { if (perms == null) return emptyMap() val out = linkedMapOf() val keys = perms.keys() while (keys.hasNext()) { val k = keys.next() out[k] = perms.optBoolean(k, false) } return out } private fun humanFeatures(permissions: Map): String { val active = permissions.filterValues { it }.keys if (active.isEmpty()) return "Basisfunktionen" return active.joinToString(", ") { humanPermissionLabel(it) } } fun humanPermissionLabel(key: String): String = when (key) { "commercial_use" -> "Kommerzielle Nutzung" "paid_tours" -> "Bezahlte Touren" "internal_testing" -> "Internes Testen" "redistribution" -> "Weitergabe" else -> key.replace('_', ' ').replaceFirstChar { it.uppercase() } } data class ImportResult(val success: Boolean, val message: String) }