Release GPS2Audio v2.5.44 POI category fix

This commit is contained in:
Perplexity Computer
2026-05-30 05:42:44 +00:00
parent 18ea77b21d
commit 014e6c1bf1
100 changed files with 28584 additions and 1083 deletions
@@ -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)
}