# 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