# GPS2Audio v2.5.21 — Handoff-Dokument **Build-Datum:** 2026-05-28 **Version:** 2.5.21 (versionCode 75) **Basis:** GPS2Audio_v2_5_20_license_import_source.zip **Keystore:** GPS2Audio_release_keystore_v2.jks (Alias: gps2audio_release_v2) --- ## Artefakte | Datei | Beschreibung | |---|---| | `GPS2Audio_v2_5_21_license_reimport_deviceid_signed.apk` | Signierte Release-APK (57 MB, v2 Signature Scheme) | | `GPS2Audio_v2_5_21_license_reimport_deviceid_source.zip` | Quellcode (ohne build/, .gradle/, keystore.properties, .jks) | | `GPS2Audio_v2_5_21_license_reimport_deviceid_handoff.md` | Dieses Dokument | --- ## Bug-Ursache: Re-Import nach Entfernen ### Symptom Lizenzimport funktioniert beim ersten Mal. Nach „Lizenz entfernen" und erneutem Import wird der Fehler „Lizenzdatei konnte nicht gelesen werden" angezeigt oder die Lizenz wird nicht akzeptiert. ### Ursache (v2.5.20) **Primäre Ursache — `initialized`-Flag nicht zurückgesetzt:** In `LicenseStatusProvider` (v2.5.20) gab es ein `private var initialized = false`-Flag, das nach dem ersten Laden auf `true` gesetzt wurde und danach nie mehr zurückgesetzt wurde — auch nicht nach `remove()`. Das hatte für sich allein wenig Wirkung auf den direkten Import-Pfad, schuf aber eine inkonsistente Zustandsmaschine. **Sekundäre Ursache — SAF URI-Permissions:** Der `importLauncher` in `AboutDialog.kt` nutzt `ActivityResultContracts.OpenDocument()`, das einen temporären URI mit temporären READ-Permissions liefert. Diese Permissions gelten nur für die Dauer des `ActivityResult`-Callbacks. In v2.5.20 war der Code grundsätzlich korrekt (liest im Callback), aber die Fehlerbehandlung unterschied nicht zwischen: - Leerer Datei (bytes.isEmpty()) - Nicht lesbarem URI (null) Beim Re-Import aus demselben Speicherort (z.B. Downloads) konnte Android in seltenen Fällen den gleichen URI mit anderer Zugriffsweise liefern (je nach DocumentProvider), was zum Lese-Fehler führte. **Dritte Ursache — fehlende Exception-Behandlung in `import()`:** `LicenseStatusProvider.import()` hatte keinen try-catch-Block um den gesamten Verarbeitungspfad. Unerwartete Exceptions führten zu einem Crash oder stummen Scheitern. ### Fix (v2.5.21) **1. `LicenseStatusProvider.kt`:** ```kotlin // remove() setzt initialized zurück: fun remove(context: Context): Boolean { val ok = LicenseStorage.delete(context) if (ok) { statusState.value = emptyStatus() initialized = false // NEU: sauberer Reset } return ok } // import() mit @Volatile-Annotation und try-catch: @Volatile private var initialized = false fun import(context: Context, fileBytes: ByteArray): ImportResult { initialized = true // explizit setzen return try { val result = LicenseValidator.verifyOffline(context, fileBytes) // ... wie bisher ... } catch (e: Throwable) { ImportResult(false, "Unerwarteter Fehler beim Import: ${e.message}") } } ``` **2. `AboutDialog.kt`:** ```kotlin // Explizite Prüfung auf leere Bytes: val bytes = try { context.contentResolver.openInputStream(uri)?.use { it.readBytes() } } catch (e: Throwable) { null } if (bytes == null || bytes.isEmpty()) { Toast.makeText(context, "...", Toast.LENGTH_LONG).show() return@rememberLauncherForActivityResult } ``` --- ## Geräte-ID-Implementierung ### Architektur **Datei:** `app/src/main/kotlin/de/waypointaudio/license/DeviceIdProvider.kt` **Erzeugung:** 1. Beim ersten Aufruf von `DeviceIdProvider.getDeviceUuid(context)` wird eine zufällige UUID v4 generiert und in `SharedPreferences` gespeichert (Datei: `gps2audio_license_device`). 2. Bei jedem weiteren Aufruf wird die gespeicherte UUID zurückgegeben. **Anzeige-Hash (für Lizenz-Tool-Kompatibilität):** ``` hash = sha256("GPS2AudioDevice:" + device_uuid) ``` Ausgabe: lowercase Hex-String (64 Zeichen). Das Python-Lizenz-Tool muss dasselbe Format verwenden. **Speicherort:** `SharedPreferences` (MODE_PRIVATE), Dateiname: `gps2audio_license_device` **Backup-Ausschluss:** - `backup_rules.xml` (Android < 12): `` - `data_extraction_rules.xml` (Android 12+): `` für cloud-backup und device-transfer **UI:** - Im Lizenzbereich des About-Dialogs, unterhalb der Lizenzaktionen - Zeigt: `Lizenz-Geräte-ID` als Titel, Erklärungstext, kurzgefasste ID (16 Zeichen + … + 8 Zeichen) - Button „ID kopieren" legt den vollständigen 64-Zeichen-Hash in die Zwischenablage ### In v2.5.21 NICHT aktiv - **Keine Gerätebindungs-Prüfung** in `LicenseValidator`. Der Hash wird nur angezeigt. - Der `LicenseStatus` und `LicenseValidator.Result` enthalten kein `deviceId`-Feld. --- ## Geänderte Dateien | Datei | Änderungstyp | Beschreibung | |---|---|---| | `app/src/main/kotlin/de/waypointaudio/license/DeviceIdProvider.kt` | **NEU** | Geräte-UUID-Generierung + SHA-256-Hash | | `app/src/main/kotlin/de/waypointaudio/license/LicenseStatusProvider.kt` | **GEÄNDERT** | `initialized`-Reset in `remove()`, `@Volatile`, try-catch in `import()` | | `app/src/main/kotlin/de/waypointaudio/ui/AboutDialog.kt` | **GEÄNDERT** | Leere-Bytes-Prüfung im importLauncher, Geräte-ID-Sektion ergänzt | | `app/src/main/kotlin/de/waypointaudio/ui/VersionNoticeDialog.kt` | **GEÄNDERT** | Verweist auf `version_notice_body_v2521` | | `app/src/main/res/values/strings.xml` | **GEÄNDERT** | 6 neue Strings für Geräte-ID, 1 neuer String für Versionshinweis | | `app/src/main/res/xml/backup_rules.xml` | **GEÄNDERT** | `gps2audio_license_device.xml` ausgeschlossen | | `app/src/main/res/xml/data_extraction_rules.xml` | **GEÄNDERT** | `gps2audio_license_device` für cloud-backup + device-transfer ausgeschlossen | | `app/build.gradle.kts` | **GEÄNDERT** | versionCode 74→75, versionName "2.5.20"→"2.5.21" | --- ## Build-Ergebnis ``` BUILD SUCCESSFUL in 1m 27s 43 actionable tasks: 43 executed APK: app-release.apk (57 MB) Signatur: APK Signature Scheme v2 ✓ Verified using v2 scheme (APK Signature Scheme v2): true Number of signers: 1 ``` --- ## Test-Empfehlungen ### Re-Import-Bug-Test 1. App öffnen → Über diese App → Lizenz importieren → gültige `.gps2audio-license`-Datei wählen → Erfolg prüfen 2. „Lizenz entfernen" → Bestätigen → Status sollte „Keine Lizenz importiert" zeigen 3. Erneut „Lizenz importieren" → dieselbe Datei (aus Downloads oder beliebigem Speicherort) → muss erfolgreich importiert werden 4. Schritt 2–3 nochmals mit einer anderen Lizenzdatei wiederholen ### Geräte-ID-Test 1. About-Dialog öffnen → im Lizenzbereich scrollen → „Lizenz-Geräte-ID" finden 2. Angezeigte Kurzform prüfen (Format: `xxxxxxxxxxxxxxxx…yyyyyyyy`) 3. „ID kopieren" → Zwischenablage in Texteditor einfügen → voller 64-Zeichen-SHA-256-Hash prüfen 4. App-Daten löschen → erneut öffnen → andere ID erscheint (erwartet, dokumentiert) ### Backup-Ausschluss-Test 1. Vollbackup erstellen (ADB oder Geräteeinstellungen) 2. Backup-ZIP inspizieren → kein `gps2audio_license_device` oder `license/` Eintrag --- ## Bekannte Einschränkungen | # | Einschränkung | Auswirkung | Phase | |---|---|---|---| | 1 | Geräte-ID ändert sich bei App-Datenlöschung | Gerätegebundene Lizenz verliert ihre Bindung | Phase 3 | | 2 | Keine Gerätebindungs-Prüfung aktiv | Lizenz mit `binding.type = "device"` wird NICHT gegen die Geräte-ID geprüft | Phase 3 | | 3 | APK-Signatur nur v2 (nicht v1/v3) | Inkompatibel mit sehr alten Stores; nicht rückwärtskompatibel mit altem Keystore | Bekannt seit v2.5.17 | | 4 | SharedPreferences nicht verschlüsselt | UUID ist im Klartext; für Geräte-ID akzeptabel (kein Geheimnis) | Akzeptiert | | 5 | Leere bytes.isEmpty()-Prüfung | Theoretisch könnte eine legitim leere Datei abgelehnt werden; praktisch irrelevant für JSON-Lizenzdateien | Akzeptiert | --- ## Phase 3 — Vorbereitung Gerätebindung Wenn harte Gerätebindung aktiviert werden soll: 1. In `LicenseValidator.verifyOffline()` nach der Signaturprüfung: ```kotlin val binding = payload.optJSONObject("binding") if (binding?.optString("type") == "device") { val expectedDeviceHash = binding.optString("device_id_hash", "") val actualHash = DeviceIdProvider.getDeviceIdHash(context) if (expectedDeviceHash != actualHash) { return Result(LicenseStatus.State.INVALID, payload, raw, "Lizenz nicht für dieses Gerät") } } ``` 2. Im Python-Lizenz-Tool beim Ausstellen: ```python import hashlib device_hash = hashlib.sha256(f"GPS2AudioDevice:{device_uuid}".encode()).hexdigest() ``` 3. `binding.device_id_hash` im Lizenz-JSON auf den SHA-256-Hash setzen. 4. Dokumentieren: Geräte-ID-Änderung bei App-Datenlöschung macht die Lizenz ungültig → Reissue-Prozess nötig. --- ## Regressionen Alle v2.5.20-Funktionen bleiben unverändert: - Lizenzimport, Offline-Signaturprüfung (ECDSA P-256 SHA-256) - Lizenzexport, Lizenzentfernen - Keine Feature-Sperren - v2.5.19 Menüänderungen (Track Editor, Bearbeitungsmodus, Touren ordnen) - v2.5.18 additiver GPX-Import - Backup/Import (ZIP), Tour-Cover, Multi-Clip, Sequenzplayer, Countdown - Maximierbarer Player, Tour duplizieren, Waypoint Copy im Edit-Modus - Explorer/MapLibre/Routing, PTT/Atmo, Hintergrundwiedergabe