Release GPS2Audio v2.5.44 POI category fix
This commit is contained in:
@@ -0,0 +1,302 @@
|
||||
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<String, Boolean> {
|
||||
if (perms == null) return emptyMap()
|
||||
val out = linkedMapOf<String, Boolean>()
|
||||
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, Boolean>): 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)
|
||||
}
|
||||
Reference in New Issue
Block a user