# GPS2Audio v2.5.20 — Lizenzsystem Phase 2 (Import & Offline-Validierung) Interne Build. **Kein Gitea-Push, kein Release.** ## Ziele dieser Version Phase 2 des in v2.5.17 begonnenen Lizenzsystems: - Import einer `.gps2audio-license`-Datei aus der Android-Dateiauswahl - Echte Offline-Signaturprüfung (ECDSA P-256 SHA-256) mit dem bereits in v2.5.17 in `res/raw/gps2audio_license_public.pem` eingebetteten Public Key - Lokale Ablage des Lizenzstatus - Echte Anzeige im About-Dialog („Lizenz & Nutzung") - **Keine Feature-Sperren** — alle bestehenden Funktionen bleiben unverändert nutzbar, auch ohne gültige Lizenz. ## Geänderte Dateien ### Neu - `app/src/main/kotlin/de/waypointaudio/license/CanonicalJson.kt` Kanonische JSON-Serialisierung kompatibel zum Python-Tool (`json.dumps(sort_keys=True, separators=(",", ":"), ensure_ascii=False)`). Sortiert Keys lexikografisch auf jeder Ebene, escapet Steuerzeichen < 0x20 und `"`/`\\`, lässt Nicht-ASCII unverändert. - `app/src/main/kotlin/de/waypointaudio/license/LicenseStorage.kt` Persistente Ablage `filesDir/license/gps2audio.license.json` (app-privat, wird beim Deinstallieren entfernt). Atomischer Write über `*.tmp` + Rename. - `app/src/main/res/xml/data_extraction_rules.xml` Android 12+: Schliesst `filesDir/license/` vom Cloud-Backup und Geräte-Transfer aus. - `app/src/main/res/xml/backup_rules.xml` Android < 12: Schliesst `filesDir/license/` vom AutoBackup aus. ### Geändert - `app/src/main/kotlin/de/waypointaudio/license/LicenseStatus.kt` Erweitert um strukturierte Felder (`state`, `licenseId`, `licenseTypeRaw`, `licenseTypeLabel`, `bindingText`, `permissions`, `errorMessage`). Die alten String-Felder (`stateText` etc.) bleiben kompatibel, damit der bisherige UI-Aufbau weiterläuft. - `app/src/main/kotlin/de/waypointaudio/license/LicenseValidator.kt` Aus dem Skelett (gab immer `null` zurück) wurde die echte Prüfung: 1. JSON parsen, `schema_version` (== `gps2audio-license-v1`) prüfen. 2. `payload.product` (== `GPS2Audio`) prüfen. 3. `signature.algorithm` (== `ECDSA_P256_SHA256`) prüfen. 4. ECDSA-P256-SHA256-Verifikation gegen den eingebetteten Public Key auf der kanonischen JSON-Form von `{schema_version, payload}`. 5. `valid_until` (falls gesetzt) gegen aktuelles Datum prüfen. 6. Alle Fehlerfälle wirft die Methode NIE — sondern liefert ein `Result(state, payload?, rawJson?, message?)`. - `app/src/main/kotlin/de/waypointaudio/license/LicenseStatusProvider.kt` Vom statischen Platzhalter zum echten Provider: - `ensureLoaded(context)` lädt die persistente Lizenz beim ersten Zugriff und prüft sie. - `import(context, bytes)` validiert eine neue Lizenz und speichert sie bei Erfolg. Bei ungültiger Datei bleibt eine vorhandene gültige Lizenz **erhalten**. - `remove(context)` löscht die persistente Lizenz. - `exportRawJson(context)` liefert die gespeicherte Original-Datei. - `status` ist ein Compose-`mutableState`, damit die About-Card live reagiert. - Permissions werden deutsch beschriftet: „Kommerzielle Nutzung", „Bezahlte Touren", „Internes Testen", „Weitergabe". - `app/src/main/kotlin/de/waypointaudio/ui/AboutDialog.kt` Neue Buttons im Bereich „Lizenz & Nutzung": - **Lizenz importieren** — `ActivityResultContracts.OpenDocument(["*/*"])`, damit `.gps2audio-license` (kein offiziell registriertes MIME) und JSON-Varianten gleichermassen wählbar sind. - **Lizenz exportieren** — `ActivityResultContracts.CreateDocument("application/json")`, Default-Dateiname `gps2audio.license.json`. Nur sichtbar, wenn lokal eine Lizenz vorhanden ist. - **Lizenz entfernen** — mit Bestätigungsdialog. Nur sichtbar, wenn eine Lizenz vorhanden ist. Erweiterte Status-Anzeige: zusätzlich Lizenztyp, Lizenz-ID, Bindung („Keine Bindung"/„Geräte-Bindung (…)"/„Organisation: …") und ein Block mit den freigeschalteten Berechtigungen. - `app/src/main/AndroidManifest.xml` `dataExtractionRules` und `fullBackupContent` referenzieren die neuen XML-Regeln, sodass die Lizenz nicht als Beifang in normalen Backups landet. - `app/src/main/res/values/strings.xml` Neue Strings: `about_license_actions_label`, `about_license_action_import|export|remove`, Bestätigungs- und Toast-Strings, `about_license_type_label`, `about_license_id_label`, `about_license_binding_label`, `about_license_permissions_label`, `about_license_export_filename_default`. - `app/build.gradle.kts` `versionCode = 74`, `versionName = "2.5.20"`. Kommentar-Block ergänzt. ## Import-/Export-UX 1. **„Lizenz importieren"** öffnet den Android-Dokumenten-Picker mit MIME-Filter `*/*` (nötig, weil `.gps2audio-license` kein registrierter MIME-Typ ist und manche Picker JSON-Filter zu restriktiv anwenden). 2. Die App liest die Datei via `contentResolver.openInputStream`, validiert sie offline und zeigt das Ergebnis als Toast an: - Erfolg: „Lizenz importiert: Lizenz ist gültig". Status, Lizenztyp, Lizenznehmer, Gültigkeit, Bindung, Lizenz-ID und Berechtigungen werden sofort in der Card angezeigt. - Fehler: „Lizenz ungültig: " (z.B. „Falsches Produkt", „Signatur ungültig", „Lizenz abgelaufen", „Ungültiges Lizenzformat"). Eine zuvor gültige, gespeicherte Lizenz bleibt unverändert. 3. **„Lizenz exportieren"** ist nur sichtbar, wenn lokal eine Datei liegt. Der CreateDocument-Picker schlägt `gps2audio.license.json` mit MIME `application/json` vor. Geschrieben wird die unveränderte Original-Datei. 4. **„Lizenz entfernen"** zeigt zuerst einen Bestätigungsdialog. Erst nach Bestätigung wird die Datei gelöscht; Touren und Mediendateien bleiben unberührt. ## Validierungsregeln | Check | Ungültig-Status | |---------------------------|-------------------------------------------| | Datei nicht UTF-8 | INVALID — „Datei ist kein gültiges UTF-8" | | Kein gültiges JSON | INVALID — „Lizenzdatei ist kein gültiges JSON" | | `schema_version` falsch | INVALID — „Ungültiges Lizenzformat" | | `payload` fehlt | INVALID — „Ungültiges Lizenzformat" | | `product` ≠ GPS2Audio | INVALID — „Falsches Produkt: …" | | `algorithm` ≠ ECDSA… | INVALID — „Unbekannter Signaturalgorithmus" | | Signatur nicht Base64 | INVALID — „Signatur ist kein gültiges Base64" | | Embedded Public Key fehlt | INVALID — „Eingebetteter Public Key fehlt" | | Signatur verifiziert nicht| INVALID — „Signatur ungültig" | | `valid_until` Parse-Fehler| INVALID — „Ungültiges Ablaufdatum" | | `valid_until` in Vergangenheit | EXPIRED — „Lizenz abgelaufen" | | sonst | VALID | `binding`-Typ: - `none` → „Keine Bindung", akzeptiert und gültig. - `device` / `devices` → akzeptiert und nur als Anzeige („Geräte-Bindung (1 Gerät)" / „Geräte-Bindung (max. N Geräte)"). **Es findet noch KEIN echter Gerätehash-Abgleich statt** — die Lizenz gilt also auch ohne Bindung-Check als gültig, sofern Signatur und Ablauf stimmen. Das ist bewusst so spezifiziert (siehe Aufgabenbeschreibung Phase 2). - `organization` → akzeptiert, Anzeige „Organisation: …". - alles andere → „Unbekannte Bindung" (Lizenz selbst bleibt aber gültig, solange Signatur stimmt). ## Speicherort & Backup - Pfad: `filesDir/license/gps2audio.license.json` (app-privat). - Beim Deinstallieren der App wird die Datei automatisch entfernt. - Backup-Verhalten: - `data_extraction_rules.xml` schliesst `license/` von Android 12+ Cloud-Backup und Device-Transfer aus. - `backup_rules.xml` schliesst `license/` vom Auto-Backup auf Android < 12 aus. - Das App-eigene ZIP-Backup (Touren/Medien) hat schon vorher nur seine eigenen Pfade angefasst und enthält die Lizenz daher nicht. - Export der Lizenz erfolgt explizit über den „Lizenz exportieren"-Button im About-Dialog. ## Kanonisches JSON Die Signaturprüfung muss exakt dieselben Bytes wie das Python-Tool (`canonical_json_bytes`) berechnen. `CanonicalJson.encode` produziert: - Keys auf jeder Ebene per `String.sorted()` lexikografisch sortiert. - Trennzeichen `,` (zwischen Items) und `:` (zwischen Key/Value), ohne Whitespace. - Nicht-ASCII bleibt unescapet (entspricht `ensure_ascii=False`). - Steuerzeichen < 0x20 werden als `\uXXXX` escapet. - `"` als `\"`, `\\` als `\\\\`, `\n`, `\r`, `\t`, `\b`, `\f` per Standard-Escapes. - `null` für `JSONObject.NULL` und Kotlin-`null`. - Ganzzahlen ohne Dezimalpunkt; Doubles, die ganzzahlig sind, ebenfalls ohne. Validiert gegen die Python-Ausgabe von `examples/developer_example.gps2audio-license` — beide produzieren bytegleich: ``` {"payload":{"binding":{"type":"none"},"customer":{"company":null,"email":"dev@nesohub.org","name":"NesoHub Developer"},…},"schema_version":"gps2audio-license-v1"} ``` ## Build-Ergebnis - Gradle 8.7, Java 21, Android SDK 35. - `versionCode = 74`, `versionName = "2.5.20"`. - Signiert mit `GPS2Audio_release_keystore_v2.jks` (Alias `gps2audio_release_v2`, PKCS12). - `apksigner verify`: - v2 scheme: **true** - v3 scheme: false (AGP 8.5.2-Default; v2 reicht für Android 7+). - APK-Grösse: ~58 MiB. - Artefakt: `/home/user/workspace/GPS2Audio_v2_5_20_license_import_signed.apk`. - Source: `/home/user/workspace/GPS2Audio_v2_5_20_license_import_source.zip` (ohne `build/`, `release-keystore.jks`, `keystore.properties`, `local.properties`). ## Regressionen vermieden Alle v2.5.19-Funktionen sind unverändert ausgeliefert: - Additiver GPX-Import inkl. Dialog „Als neue Tour" / „Zu bestehender Tour". - JSON-Import/Export aus UI weiterhin entfernt; ZIP-Backup unverändert. - Pro-Block (555 € / Jahr) bleibt sichtbar mit PayPal-Anfrage-Button. - Multi-Clip, Sequenzplayer, Countdown, maximierbarer Player. - Tour duplizieren, Tour-Cover, „Touren ordnen" im Hauptseiten-Menü. - Waypoint Copy im Edit-Modus, Explorer/MapLibre/Routing. - Backup/Import, PTT/Atmo, Hintergrundwiedergabe. ## Bekannte Einschränkungen - **Keine Feature-Sperren.** Die Lizenzprüfung ist rein anzeigend. Pro- Funktionen werden weiterhin nicht künstlich blockiert. - **Keine Gerätebindung.** `binding.type = device|devices` wird erkannt und in der Anzeige korrekt benannt, aber **es findet kein Hash-Abgleich** gegen einen Geräte-Identifier statt. Eine Lizenz mit Gerätebindung gilt daher derzeit auch ohne „passendes" Gerät als gültig (sofern Signatur und Ablauf stimmen). Phase 3 sollte den Geräte-Hash entsprechend `gps2audio_license/core.py::device_hash` (SHA-256 über `GPS2AudioDevice:`) integrieren — der Device-Identifier ist projektseitig noch zu definieren. - **Public Key fest eingebettet.** Ein Rotations-Mechanismus oder mehrere Public Keys (Multi-Trust-Anchor) sind nicht vorgesehen. Bei Schlüssel- Tausch muss eine App-Version mit neuem Key veröffentlicht werden. - **Keine Online-Aktivierung / Revocation.** Ausgestellte Lizenzen können nicht widerrufen werden, ausser über das `valid_until`-Ablaufdatum. - **`apksigner` v3-Scheme deaktiviert.** Wir verwenden die AGP-Default- Signierung (v1 = false, v2 = true). Das bleibt v2.5.17/v2.5.18/v2.5.19 konsistent — keine In-Place-Upgrades von Builds mit dem alten verlorenen Keystore sind möglich (das war bereits in v2.5.x dokumentiert). - **Toasts statt Snackbars.** Die Import-/Export-/Remove-Rückmeldungen laufen über `Toast.makeText`, da der About-Dialog kein Snackbar-Host hat. - **Picker MIME-Filter `*/*`.** Der OpenDocument-Picker filtert nicht nach Endung; falsche Dateien werden vom Validator sauber abgelehnt („Lizenzdatei ist kein gültiges JSON" o.ä.). ## Manuelle Tests / Verifikation - `aapt dump badging`: `package=de.waypointaudio versionCode=74 versionName=2.5.20`. - `apksigner verify --verbose`: signiert mit v2-Scheme, 1 Signer. - CanonicalJson-Output gegen Python-`json.dumps(..., sort_keys=True, separators=(",", ":"), ensure_ascii=False)` für das mitgelieferte `developer_example.gps2audio-license`-Beispiel byte-genau verglichen. - Funktionale End-to-End-Verifikation (Picker → Validierung → Anzeige → Export → Entfernen) konnte ohne Gerät/Emulator nur statisch im Source-Review geprüft werden. Empfohlen: auf Zielgerät installieren und eine echte vom Lizenz-Tool (`gps2audio_license`) ausgestellte Datei importieren.