Compare commits

..

5 Commits

Author SHA1 Message Date
Perplexity Computer 2eeb086481 Release GPS2Audio v2.5.48 TrackDraft backup fix 2026-05-30 22:19:04 +00:00
Perplexity Computer 990b66e24f Release GPS2Audio v2.5.46 POI stability 2026-05-30 19:37:32 +00:00
Perplexity Computer 5cfc5aee1d Release GPS2Audio v2.5.45 Explorer POI autoload 2026-05-30 18:04:55 +00:00
Perplexity Computer 014e6c1bf1 Release GPS2Audio v2.5.44 POI category fix 2026-05-30 05:42:44 +00:00
marcel 18ea77b21d docs: add GPS2Audio release overview 2026-05-15 14:46:23 +02:00
103 changed files with 29398 additions and 1083 deletions
-11
View File
@@ -1,11 +0,0 @@
*.iml
.gradle
/local.properties
/.idea/
.DS_Store
/build
/captures
.externalNativeBuild
.cxx
local.properties
app/build/
+31
View File
@@ -191,3 +191,34 @@
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
--------------------------------------------------------------------
Commons Clause License Condition v1.0
--------------------------------------------------------------------
"Commons Clause" License Condition v1.0
The Software is provided to you by the Licensor under the License,
as defined above, subject to the following condition.
Without limiting other conditions in the License, the grant of
rights under the License will not include, and the License does not
grant to you, the right to Sell the Software.
For purposes of the foregoing, "Sell" means practicing any or all of
the rights granted to you under the License to provide to third
parties, for a fee or other consideration (including without
limitation fees for hosting or consulting/support services related
to the Software), a product or service whose value derives, entirely
or substantially, from the functionality of the Software. Any
license notice or attribution required by the License must also
include this Commons Clause License Condition notice.
Software: GPS2Audio
License: Apache License 2.0
Licensor: Marcel Mayer (NesoHub)
See `TERMS.md` (German, user-facing) for the project-specific
interpretation of "Sell" — explicitly including paid tours,
SaaS / hosted services, and integration into commercial offerings.
+44
View File
@@ -0,0 +1,44 @@
# NOTICE — GPS2Audio
GPS2Audio
Copyright 20242026 Marcel Mayer (NesoHub).
This product is licensed under the **Apache License, Version 2.0**
("Apache 2.0") with the addition of the **Commons Clause License
Condition v1.0**.
You may obtain a copy of Apache 2.0 at:
https://www.apache.org/licenses/LICENSE-2.0
The Commons Clause restricts the right to **Sell** the software
or to provide it as a commercial / hosted service. See `LICENSE`
and `TERMS.md` (German user-facing version) for the binding
project-internal interpretation, including the list of restricted
commercial use cases (paid tours, SaaS, B2B integration, etc.).
## Third-party components
The Android application ships with the following third-party
components, used under their respective licenses. Their licenses
are **not** overridden by the Commons Clause and apply to those
components only.
| Component | License | Notes |
| --- | --- | --- |
| AndroidX / Jetpack libraries (core, lifecycle, activity, navigation, datastore, splashscreen) | Apache 2.0 | © The Android Open Source Project |
| Jetpack Compose, Material3, Material Icons Extended | Apache 2.0 | © The Android Open Source Project |
| Kotlin standard library & coroutines | Apache 2.0 | © JetBrains s.r.o. |
| Google Play services Location | Custom Google license | © Google LLC |
| AndroidX Media3 / ExoPlayer | Apache 2.0 | © The Android Open Source Project |
| osmdroid | Apache 2.0 | © osmdroid contributors |
| MapLibre Native Android SDK | BSD-2-Clause | © MapLibre contributors |
| Gson | Apache 2.0 | © Google LLC |
| OpenStreetMap raster tiles | © OpenStreetMap contributors, ODbL | Map data and tiles served by OSM and compatible providers |
See `THIRD_PARTY_NOTICES.md` for the canonical, longer list and
attribution requirements. New third-party additions MUST be added
both there and here, and the in-app About screen MUST keep its
Apache 2.0 + Commons Clause notice intact.
This NOTICE file itself is provided as required by Section 4(d) of
the Apache License 2.0.
+65
View File
@@ -0,0 +1,65 @@
# GPS2Audio Release-Übersicht
Diese Übersicht fasst die offiziell hochgeladenen GPS2Audio-Releases zusammen. Sie beginnt mit dem ersten signierten Release `v1.6.2-osm-layout` und dokumentiert danach die fortlaufende 2.x-Entwicklung bis zur aktuellen Basis `v2.1.3`.
## Aktuelle stabile Version
**GPS2Audio v2.1.3** ist aktuell die empfohlene Basis. Diese Version enthält die vollständige PTT-Recorder-Funktion mit automatischer Speicherung, Anhören, Zuweisen zu Wegpunkten und sichtbarem Löschen in der PTT-Aufnahmen-Bibliothek.
Release: https://gitea.nesohub.org/marcel/GPS2Audio/releases/tag/v2.1.3
## Veröffentlichte Versionen
| Version | Schwerpunkt | Inhalt |
|---|---|---|
| `v1.6.2-osm-layout` | Erstes signiertes Release | OSM-Layout, stabile Basis, signierte APK als erste updatefähige Veröffentlichung. |
| `v2.0.0` | UI-Überarbeitung Phase 1 | Grundlegende Anpassungen nach der neuen UI-Vorlage, modernisierte Startseite und strukturierteres Layout. |
| `v2.0.1` | Direkte Reihenfolge-Bearbeitung | Waypoints konnten einfacher umgeordnet werden, ohne vorher explizit in einen Bearbeitungsmodus wechseln zu müssen. |
| `v2.0.2` | Drag-and-drop-Reihenfolge | Umstellung der Waypoint-Sortierung auf direktes Drag-and-drop. |
| `v2.0.3` | PTT pausiert Waypoint-Audio | Wenn PTT gedrückt wird, pausiert laufendes Waypoint-Audio und wird danach korrekt fortgesetzt. |
| `v2.0.4` | Atmo-Hintergrundwiedergabe | Erste Anpassungen, damit Atmo-Streams beim Minimieren der App weiterlaufen. |
| `v2.0.5` | Atmo-Service-Hotfix | Stabilisierung des Hintergrunddienstes für Atmo-Wiedergabe. |
| `v2.0.6` | Atmo-Hintergrund-Fix | Weitere Korrekturen am Hintergrundverhalten der Atmo-Wiedergabe. |
| `v2.0.7` | Service-eigener Atmo-Player | Der Atmo-Player wurde in den Service verlagert, damit Streams zuverlässig im Hintergrund weiterlaufen. |
| `v2.0.8` | Stream-Playlist | Erweiterung von Atmo um eine richtige Stream-Playlist statt nur einer einzelnen URL. |
| `v2.1.0` | PTT-Recorder | Einführung der Recorder-Funktion für PTT-Notizen. |
| `v2.1.1` | PTT-Aufnahmen anhören und zuweisen | Aufnahmen können in der Bibliothek angehört und Wegpunkten zugewiesen werden; neue Wegpunkte können aus Aufnahmen erstellt werden. |
| `v2.1.2` | Automatisches Speichern | Nach dem Loslassen der PTT wird ohne störende Nachfrage automatisch im Hintergrund gespeichert. |
| `v2.1.3` | Sichtbarer Löschen-Button | Die PTT-Aufnahmen-Bibliothek erhielt einen klar sichtbaren Löschen-Button pro Aufnahme. |
| `v2.4.0` | MapLibre Engine Cutover | Aktiver Renderer im Explorer wurde auf MapLibre Native umgestellt. osmdroid bleibt nur noch als interner Fallback erhalten. |
| `v2.4.1` | Map-Stile und Terrain | Modern hell, Dunkel, Topografisch, Terrain/3D und Satellit als auswählbare Stile (persistent). |
| `v2.4.2` | Explorer-Routenplanung | Eigener Routenplaner direkt im Explorer: Start/Via/Ziel aus mehreren Quellen, OSRM-Routing optional, Planlinien-Fallback ohne Endpunkt, Routen als Tour-Entwurf speicherbar. |
| `v2.4.3` | Routenplaner Layout-Hotfix | Reines UI-/Layout-Fix: BottomSheet wird scrollbar, respektiert die System-Navigationsleiste (`navigationBarsPadding`), und die primären Aktionen 'Route berechnen' / 'Planlinie' liegen in einer Sticky Bottom Action Bar oberhalb der Gestenleiste. Keine neuen Features, keine API-Änderungen. |
| `v2.4.4` | Routenplaner Endpunkt-Hotfix | „Route berechnen" ist nicht mehr stumm/grau, wenn kein Endpunkt gesetzt ist: der Button reagiert mit klarem Statushinweis. Im Sheet steht zusätzlich ein One-Tap-Button „OSRM-Demo nutzen", der den öffentlichen Testserver hinterlegt. Die Endpunkt-URL wird normalisiert — sowohl `https://router.project-osrm.org` als auch die vollständige `/route/v1/driving`-Variante werden akzeptiert (keine Doppelprefixe). Fehler werden in Deutsch mit Detail angezeigt. Keine Layout-Regressionen. |
| `v2.4.5` | Karten-UX-Hotfix Clip-Linie und Planlinie | Reiner Karten-UX-Hotfix. Der Clip-Längenabschnitt beginnt jetzt AM Waypoint und erstreckt sich nach vorne entlang der Route/Track-Geometrie — nicht mehr symmetrisch um den Projektionspunkt. Damit wirkt der Waypoint visuell als Trigger-Start des Clips. Zusätzlich wird die Luftlinie/Planlinie nur noch angezeigt, wenn keine berechnete Route vorhanden ist; die Doppeldarstellung von Route und Planlinie ist entfernt. Legende im Routenplaner spiegelt den aktiven Layer (Route ODER Planlinie). Routing-/Endpunkt-Verhalten und Layout aus v2.4.4/v2.4.3 unverändert. |
| `v2.5.36` | MapLibre Phantom-Layer-Fix | Root-Cause-Fix der weißen Phantom-GPX-Linie im neutralen Standard-Explorer (0 Wegpunkte). Ursache: `currentTrack.isNotEmpty()` in `visibleTrack` feuerte vor der `isNeutralStandard`-Prüfung, weil `loadPersistedTrackForCurrentTour` den TrackRecordingManager auch für DEFAULT_TOUR_NAME befüllte. Fix: (1) `isNeutralStandard`-Guard vor `currentTrack.isNotEmpty()` verschoben; (2) ViewModel befüllt den Manager-Track nicht mehr für die neutrale Standardtour. Alle echten Touren, laufende Aufzeichnungen und aktive Drafts unverändert. Versionscode 90. |
| `v2.4.6` | Persistente Tourrouten | Reiner Datenmodell-/Persistenzschritt. Eine Tour kann eine dauerhaft gespeicherte Route besitzen (Start, Ziel, Via, Geometrie, Profil, Endpunkt/Quelle, Distanz/Dauer, Zeitstempel). Routenplaner kann die berechnete Route oder die Planlinie an die aktuelle Tour binden („Tourroute speichern/ersetzen/löschen"). Clipabschnitte folgen primär der frisch berechneten Route, dann der gespeicherten Tourroute, dann dem Track, zuletzt einer Planlinie — keine freien Linien. Wegpunkte bleiben Bausteine ohne fest verdrahtete Route und lassen sich später in andere Touren kopieren. Rückwärtskompatibel: bestehende JSON/Backups ohne Tourroute laden weiterhin; Backup-Export/Import nehmen Tourrouten mit. Versionscode 53. |
## Release-Links
- `v1.6.2-osm-layout`: https://gitea.nesohub.org/marcel/GPS2Audio/releases/tag/v1.6.2-osm-layout
- `v2.0.0`: https://gitea.nesohub.org/marcel/GPS2Audio/releases/tag/v2.0.0
- `v2.0.1`: https://gitea.nesohub.org/marcel/GPS2Audio/releases/tag/v2.0.1
- `v2.0.2`: https://gitea.nesohub.org/marcel/GPS2Audio/releases/tag/v2.0.2
- `v2.0.3`: https://gitea.nesohub.org/marcel/GPS2Audio/releases/tag/v2.0.3
- `v2.0.4`: https://gitea.nesohub.org/marcel/GPS2Audio/releases/tag/v2.0.4
- `v2.0.5`: https://gitea.nesohub.org/marcel/GPS2Audio/releases/tag/v2.0.5
- `v2.0.6`: https://gitea.nesohub.org/marcel/GPS2Audio/releases/tag/v2.0.6
- `v2.0.7`: https://gitea.nesohub.org/marcel/GPS2Audio/releases/tag/v2.0.7
- `v2.0.8`: https://gitea.nesohub.org/marcel/GPS2Audio/releases/tag/v2.0.8
- `v2.1.0`: https://gitea.nesohub.org/marcel/GPS2Audio/releases/tag/v2.1.0
- `v2.1.1`: https://gitea.nesohub.org/marcel/GPS2Audio/releases/tag/v2.1.1
- `v2.1.2`: https://gitea.nesohub.org/marcel/GPS2Audio/releases/tag/v2.1.2
- `v2.1.3`: https://gitea.nesohub.org/marcel/GPS2Audio/releases/tag/v2.1.3
## Installationshinweis
Die signierten Release-APKs sind als Update-Linie gedacht. Für Updates muss eine neuere Version über eine bestehende Installation installiert werden, die mit demselben Release-Keystore signiert wurde. Debug-Builds und parallel installierbare Zwischenstände gehören nicht zur offiziellen Update-Linie.
## Sicherheits- und Archivhinweis
Die hochgeladenen Release-Pakete enthalten APK, Quellcode-ZIP und Handoff beziehungsweise Release Notes. Keystore-Dateien, Passwörter und lokale Build-Geheimnisse gehören nicht in das Repository und wurden nicht als Release-Anhang veröffentlicht.
## Empfohlene Nutzung
Für neue Nutzer ist `v2.1.3` der beste Einstieg. Ältere Versionen bleiben als Archiv erhalten, damit Entwicklungsschritte, Fehlerbehebungen und Zwischenstände nachvollziehbar bleiben.
+115
View File
@@ -0,0 +1,115 @@
# Nutzungsbedingungen GPS2Audio
**Version:** 2.5.11-terms-1
**Stand:** 27. Mai 2026
Diese Nutzungsbedingungen ergänzen die Lizenzbedingungen
(Apache License 2.0 + Commons Clause License Condition v1.0)
für die Anwendung „GPS2Audio“, ihren Quellcode, ihre Assets und
ihre Marke. Durch die Installation und Nutzung der App stimmst du
diesen Bedingungen zu.
## 1. Lizenz und Modell
GPS2Audio ist **source-available**. Der Code und die mitgelieferten
Assets von GPS2Audio (ausgenommen klar gekennzeichnete Drittanbieter-
Bibliotheken) stehen unter:
- **Apache License 2.0**, **eingeschränkt durch die Commons Clause
License Condition v1.0**.
Drittanbieter-Bibliotheken (z. B. AndroidX, Material3, ExoPlayer/Media3,
osmdroid, MapLibre, Gson, kotlinx.coroutines, OkHttp/HTTP-Clients,
Splash-Screen-Backport) behalten ihre **jeweilige Originallizenz**.
Siehe `NOTICE.md` und `THIRD_PARTY_NOTICES.md`.
## 2. Erlaubte Nutzung
Erlaubt sind insbesondere:
- **Private, nicht-kommerzielle Nutzung** der App und der gelieferten
signierten Builds.
- **Forks**, eigene **Modifikationen** und **Weitergabe des Quellcodes**
unter Beibehaltung dieser Lizenzhinweise sowie der Commons-Clause-
Beschränkung.
- Erstellen und Teilen **eigener Touren** für den persönlichen Gebrauch
oder im Rahmen eindeutig nicht-kommerzieller Aktivitäten (z. B.
private Wanderungen, kostenlose Vereinsveranstaltungen ohne Gegen-
leistung, kostenfreie Lehre).
## 3. Untersagte kommerzielle Nutzung (Commons Clause)
Ohne ausdrückliche **vorherige schriftliche Genehmigung** des
Entwicklers ist jede **kommerzielle Nutzung** untersagt. Untersagt sind
insbesondere aber nicht ausschließlich:
- **Verkauf** der App, von Builds, von Forks oder von Quellcode-
Bestandteilen, auch als „Pro“-/„Premium“-Variante.
- **SaaS / Hosted Service**: das Anbieten von GPS2Audio (auch in
modifizierter Form) als Online-Dienst, Backend-Service oder
Plattform-Bestandteil gegen Entgelt, gegen Werbung oder gegen
Nutzerdaten als Gegenleistung.
- **Bezahlte Touren / Tourismusangebote**: kommerzielle Stadt-, Bus-,
Schiff-, Wander-, Rad- oder vergleichbare geführte Touren, bei denen
GPS2Audio direkt oder indirekt Bestandteil eines bezahlten oder
beworbenen Produkts ist.
- **Integration in kommerzielle Angebote** Dritter (B2B-Whitelabel,
ÖPNV-/Verkehrsverbund-Apps, Hotel-/Resort-Gästeapps, App-Bundles).
- Re-Branding und Wiederveröffentlichung des Quellcodes in App-Stores,
insbesondere kostenpflichtig oder werbefinanziert.
Für solche Nutzungen ist ein **separater Lizenz- bzw. Kooperations-
vertrag** mit dem Entwickler erforderlich. Anfragen bitte per
E-Mail an den Entwickler.
## 4. Marke, Logo, Builds
Markenname „GPS2Audio“, das Logo, die offiziellen signierten Builds
sowie kuratierte Tour-Inhalte unterliegen zusätzlich diesen
Nutzungsbedingungen und sind **nicht** durch die Apache License 2.0
freigegeben.
## 5. Datenschutz / Standortdaten
GPS2Audio verarbeitet Standortdaten **ausschließlich lokal** auf dem
Endgerät, um Wegpunkte zu erkennen und Audio abzuspielen. Es findet
keine serverseitige Standortspeicherung durch den Entwickler statt.
Karten-Tiles werden direkt von den jeweiligen Anbietern
(OpenStreetMap-basiert) geladen; deren Datenschutzhinweise gelten
zusätzlich.
## 6. Haftungsausschluss
Die Software wird **„wie sie ist“ (as is)** und ohne jegliche
Gewährleistung bereitgestellt, im Rahmen dessen, was geltendes Recht
zulässt. Eine Haftung für Schäden insbesondere für Folgen einer
Standort- oder Audio-Fehlfunktion, von Datenverlust oder von
Personen- oder Sachschäden während der Nutzung der App im Freien
(Wandern, Radfahren, Bootfahren, Autofahren) ist ausgeschlossen,
soweit gesetzlich zulässig.
Die App ist **kein Sicherheits- oder Navigationssystem**. Bediene
GPS2Audio nicht während Tätigkeiten, bei denen Unaufmerksamkeit zu
Personenschäden führen kann (z. B. Bedienen eines Fahrzeugs).
## 7. Zustimmungsmechanismus
Mit dem **erstmaligen Start** der App und nach **Version­upgrades
dieser Nutzungsbedingungen** wird ein **In-App-Dialog** angezeigt, in
dem du diesen Bedingungen aktiv zustimmen musst.
- Die akzeptierte Version wird lokal mit der ID
`2.5.11-terms-1` gespeichert.
- Ohne Zustimmung **kann GPS2Audio nicht genutzt werden** und beendet
sich nach Ablehnung.
- Wird in einer späteren App-Version eine neue Terms-Version
hinterlegt, erscheint der Dialog erneut.
## 8. Kontakt
Kommerzielle Nutzung, Lizenzanfragen, Tourismus-Kooperationen:
E-Mail an den Entwickler (siehe „Über diese App“).
GPS2Audio © Marcel Mayer.
Apache License 2.0 + Commons Clause License Condition v1.0.
+82
View File
@@ -0,0 +1,82 @@
# Third-party notices — GPS2Audio
GPS2Audio is licensed under Apache License 2.0 + Commons Clause
License Condition v1.0 (see `LICENSE`, `NOTICE.md`, `TERMS.md`).
The third-party components below are NOT covered by the Commons
Clause; they remain under their original licenses. The user-facing
"About" screen in GPS2Audio refers users to this file as the
canonical attribution source.
## AndroidX / Jetpack
- Components: `androidx.core:core-ktx`, `androidx.lifecycle:*`,
`androidx.activity:activity-compose`,
`androidx.compose.*`, `androidx.compose.material3:material3`,
`androidx.compose.material:material-icons-extended`,
`androidx.navigation:navigation-compose`,
`androidx.datastore:datastore-preferences`,
`androidx.core:core-splashscreen`,
`androidx.media3:media3-exoplayer`, `androidx.media3:media3-common`.
- License: **Apache License 2.0**.
- Copyright: © The Android Open Source Project.
- URL: https://developer.android.com/jetpack
## Kotlin standard library & coroutines
- Components: `org.jetbrains.kotlin:*`, `org.jetbrains.kotlinx:kotlinx-coroutines-android`,
`org.jetbrains.kotlinx:kotlinx-coroutines-play-services`.
- License: **Apache License 2.0**.
- Copyright: © JetBrains s.r.o.
- URL: https://kotlinlang.org/
## Google Play services Location
- Component: `com.google.android.gms:play-services-location`.
- License: Custom Google Play services license. See:
https://developers.google.com/android/guides/terms
- Copyright: © Google LLC.
## Gson
- Component: `com.google.code.gson:gson`.
- License: **Apache License 2.0**.
- Copyright: © Google LLC.
- URL: https://github.com/google/gson
## osmdroid
- Component: `org.osmdroid:osmdroid-android`.
- License: **Apache License 2.0**.
- Copyright: © osmdroid contributors.
- URL: https://github.com/osmdroid/osmdroid
## MapLibre Native Android SDK
- Component: `org.maplibre.gl:android-sdk`.
- License: **BSD 2-Clause License**.
- Copyright: © MapLibre contributors.
- URL: https://github.com/maplibre/maplibre-native
## OpenStreetMap data / tiles
- Map data and most raster tile sources used by the Explorer/Map
views are derived from **OpenStreetMap** data.
- License: **Open Database License (ODbL) 1.0** for data,
**CC BY-SA 2.0** for cartography of standard `tile.openstreetmap.org`
tiles. Map attribution must remain visible in the UI.
- Copyright: © OpenStreetMap contributors.
- URL: https://www.openstreetmap.org/copyright
## Routing / POI providers (Nominatim, OSRM, Overpass)
- The app may query public OSM-based services for geocoding,
routing and POI lookup (Nominatim, OSRM, Overpass). These are
community-provided endpoints; their respective usage policies apply.
---
This list is canonical. Any new direct Maven/Gradle dependency
added to the project MUST be reflected here AND in `NOTICE.md`.
The user-visible "About" dialog points users to this file via
its license summary text.
+182 -2
View File
@@ -1,23 +1,189 @@
import java.util.Properties
import java.io.FileInputStream
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
}
val keystorePropertiesFile = rootProject.file("keystore.properties")
val keystoreProperties = Properties().apply {
if (keystorePropertiesFile.exists()) {
load(FileInputStream(keystorePropertiesFile))
}
}
fun resolveSigningValue(propsKey: String, envKey: String): String? {
val fromProps = keystoreProperties.getProperty(propsKey)
if (!fromProps.isNullOrBlank()) return fromProps
val fromEnv = System.getenv(envKey)
if (!fromEnv.isNullOrBlank()) return fromEnv
return null
}
android {
namespace = "de.waypointaudio"
compileSdk = 35
buildToolsVersion = "35.0.0"
defaultConfig {
applicationId = "de.waypointaudio"
minSdk = 26
targetSdk = 35
versionCode = 1
versionName = "1.0"
// v2.5.18 — Bugfix: GPX-Import ist jetzt strikt additiv.
// - Datenverlust-Schutz: Der GPX-Import in v2.5.17 hat über
// `repository.saveAll(imported)` versehentlich alle bestehenden
// Wegpunkte in sämtlichen Touren ersetzt. Ab v2.5.18 löscht ein
// GPX-Import nie bestehende Daten.
// - Neuer Import-Dialog mit zwei Optionen:
// (A) Als neue Tour importieren (editierbarer Name, Default aus
// GPX-Dateiname).
// (B) Zu bestehender Tour hinzufügen (Auswahl der Tour).
// Abbrechen verändert nichts.
// - JSON-Import/-Export in der sichtbaren UI entfernt; das ZIP-Backup
// übernimmt diese Rolle. Interne JSON-Parser/-Modelle bleiben für
// Backup-Kompatibilität erhalten.
// - v2.5.17 Lizenz/Pro/About bleibt unverändert.
// - Alle weiteren Funktionen (Multi-Clip, Sequenzplayer, Countdown,
// maximierbarer Player, Tour duplizieren, Waypoint kopieren,
// Explorer/MapLibre/Routing, Backup ZIP, PTT/Atmo, Hintergrund,
// Tourcover) bleiben unverändert.
//
// v2.5.19 — Menü- und UX-Politur:
// • „Track Editor" aus dem Hauptmenü entfernt (über Explorer
// erreichbar). Funktionalität (TrackDraftListScreen/-Editor)
// bleibt vollständig im Code.
// • „Klassische Ansicht" umbenannt zu „Bearbeitungsmodus" — bessere
// UX-Begriffswahl auf Deutsch.
// • „Touren ordnen" aus der einzelnen Touransicht in das Menü der
// Audio-Bibliothek (HomeScreen) verschoben. Sortier-Funktion
// (TourReorderDialog + WaypointViewModel.moveTour) unverändert.
// • Backup: Tour-Cover (Icon + Palette + Bild) sind ab v2.5.7
// bereits Teil des ZIP-Backups (media/covers/<tour>.jpg) und
// werden beim Import wiederhergestellt. v2.5.19 ist hierbei
// inhaltlich unverändert; dieser Eintrag dient der Klarstellung
// in der Versionshistorie.
//
// v2.5.20 — Phase 2 des Lizenzsystems:
// • Echter Import einer .gps2audio-license-Datei über das Android
// Storage Access Framework (ACTION_OPEN_DOCUMENT).
// • Offline-Signaturprüfung (ECDSA P-256 SHA-256) gegen den bereits
// in res/raw eingebetteten Public Key, kanonisches JSON wie im
// Python-Tool (sortierte Keys, kein Whitespace).
// • Lokale Ablage der validierten Lizenz in filesDir/license/
// inkl. Backup-Ausschluss (data_extraction_rules + backup_rules).
// • Anzeige im About-Dialog: Status (Keine/Gültig/Ungültig/Abgelaufen),
// Lizenztyp, Lizenznehmer, Gültigkeit, Lizenz-ID, Bindung,
// Permissions (deutsch beschriftet).
// • Buttons „Lizenz importieren / exportieren / entfernen" inkl.
// Bestätigungsdialog. Keine Feature-Sperren, alle bestehenden
// Funktionen bleiben unverändert nutzbar.
// v2.5.21 — Re-Import-Bugfix + Geräte-ID:
// • Behebt: Lizenzdatei konnte nach Entfernen + Re-Import nicht mehr
// gelesen werden. Ursache: LicenseStatusProvider.initialized-Flag
// wurde nach remove() nicht zurückgesetzt; kombiniert mit
// transienten SAF-URI-Permissions führte das zu "Lizenzdatei konnte
// nicht gelesen werden" beim zweiten Import-Versuch.
// • Fix: initialized wird in remove() auf false gesetzt. import()
// liest Bytes immer frisch, ohne gecachte URIs.
// • Geräte-ID: app-spezifische UUID (SharedPreferences, kein Backup),
// SHA-256-Hash "GPS2AudioDevice:"+uuid für Lizenz-Tool-Kompatibilität.
// Anzeige + Kopier-Button im Über-Dialog. Keine harte Bindung aktiv.
// v2.5.23 — Lizenzimport-Regression-Fix:
// • v2.5.22 wechselte zu GetContent(), was auf manchen OEM-Geräten
// (Huawei/EMUI, MIUI-Galerie-Modus) nur Medien anzeigt — .gps2audio-
// license-Dateien unsichtbar, Erstimport unmöglich.
// • Fix: Zurück zu OpenDocument() mit breiten MIME-Typen (json/text/
// octet-stream/*). Echter SAF-Datei-Browser zeigt alle Dateien.
// • importNonce-Mechanismus: Key-basierter Launcher-Slot-Reset erzwingt
// frischen SAF-Picker auch bei Wiederwahl derselben URI.
// • Robustes Lesen (copyTo statt readBytes), differenzierte
// Fehlermeldungen und reloadTrigger aus v2.5.22 beibehalten.
// v2.5.24 — Lizenzimport-Fallbacks:
// • readLicenseBytes(): drei Lesestrategien (openInputStream, openFileDescriptor,
// File-Fallback) mit separater Fehlersammlung.
// • Diagnose-Dialog bei Totalausfall: technische Fehlermeldungen statt generisches Toast.
// • Textimport-Fallback: Button "Lizenz aus Zwischenablage / Text einfügen" öffnet
// AlertDialog mit großem Textfeld + Zwischenablage-Schaltfläche.
// • OpenDocument() mit nur "*/*" (keine MIME-Liste, verhindert Galerie-Modus auf MIUI).
// • importNonce-Mechanismus aus v2.5.23 unverändert beibehalten.
// v2.5.25 — License-Import-Thread-Fix:
// • Alle blockierenden I/O-Operationen (contentResolver.openInputStream,
// openFileDescriptor, FileInputStream, ByteArrayOutputStream.copyTo,
// LicenseStatusProvider.import inkl. JSON/Signaturvalidierung) auf
// Dispatchers.IO verschoben. Root Cause: NetworkOnMainThreadException
// auf Android 9+ (StrictMode blockiert Netzwerk-/Disk-I/O auf Main-Thread).
// • ActivityResult-Callback startet Coroutine (rememberCoroutineScope);
// withContext(Dispatchers.IO) für readLicenseBytes + import();
// danach UI-State/Toast/reloadTrigger auf Main-Thread.
// • isImporting-Flag verhindert Doppelauslösung des Import-Buttons.
// • Export-Callback (openOutputStream) ebenfalls auf IO.
// • Textimport: import() ebenfalls auf IO.
// • Alle v2.5.24-Fallbacks (drei Lesestrategien, Diagnose-Dialog,
// Textimport, OpenDocument */*, importNonce) unverändert.
// v2.5.29 — Lizenz-Persistenz-Fix: Update-sichere Lizenzablage.
// v2.5.30 — Home Atmo-Modul entfernt; Playback-Persistenz beim Tour-Wechsel.
// • Atmo-Modul/-Toggle auf der Startseite (HomeScreen) entfernt.
// Atmo bleibt vollständig in der Tour-Ansicht (WaypointListScreen) verfügbar.
// • selectTour() stoppt keine laufende Wiedergabe mehr beim Tour-Wechsel.
// Stop nur noch bei expliziten Nutzeraktionen: Play in anderer Tour,
// Stop-Button, GPS-Dienst bewusst stoppen.
//
// v2.5.31 — Tourcover-Backup-Fix: frei gewählte Bilder werden jetzt
// zuverlässig gesichert und beim Import wiederhergestellt.
// • BackupExportManager: ZIP-Entry-Pfade für Cover-Bilder erhalten
// einen eindeutigen Index-Präfix ("media/covers/001_tourname.jpg"),
// damit Touren mit gleichem sanitisierten Namen nicht kollidieren.
// • BackupExportManager: Fallback-Lesestrategie für Cover-Bilder:
// absoluter Dateipfad → ContentResolver → file://-URI.
// • BackupImportManager: Verbesserte Fehlerprotokollierung beim
// Cover-Bild-Import; Rückwärtskompatibel mit v2.5.7v2.5.30-Backups.
// • Kein Änderungen am Lizenzsystem, Feature-Gates, Touren/Waypoints.
// v2.5.34 — Explorer-Kontext-Fix: selectDefaultTour() beim Einstieg aus
// der Audio-Bibliothek/Home verhindert das Übernehmen des zuletzt aktiven
// Tour-Kontexts. Einstieg aus konkreter Tour behält Tour-Karte.
// v2.5.38 — Kartenattribution, POI-Layer, Kartenstil-Persistenz.
// • Kartenattribution rechtlich sauber: kompaktes Badge bottom-left
// (anklickbar → openstreetmap.org/copyright), vollständiger Karteninfo-
// Dialog aus dem Ebenen-Sheet (OpenFreeMap, OpenMapTiles, OSM, ODbL).
// • POI-Layer-Kategorien im Ebenen-Sheet: Sehenswürdigkeiten (default an),
// Infrastruktur, Gastronomie, Mobilität (default aus). Symbol-Layer des
// geladenen MapLibre-Styles werden per Layer-ID-Heuristik ein-/ausgeblendet.
// Auswahl wird persistent gespeichert (DataStore). Kein Crash wenn Layer fehlt.
// • Kartenstil-Persistenz: baseStyle zuverlässig beim Neustart wiederhergestellt.
// POI-Auswahl ebenfalls persistent. Alle MapStyleStore-Keys unverändert.
// • Stylewechsel stellt Overlays, Attribution und POI-Sichtbarkeit wieder her.
// • Keine Regressionen: Phantom-Layer-Fix, Lizenzsystem, Multi-Clip,
// PTT, Tour-Zähler, Backup/Cover, GPX-Import, Explorer/Routing,
// Tour-/Waypoint-Kopien, Hintergrundwiedergabe.
versionCode = 102
versionName = "2.5.48"
}
val storeFilePath = resolveSigningValue("storeFile", "GPS2AUDIO_RELEASE_STORE_FILE")
val storePassword = resolveSigningValue("storePassword", "GPS2AUDIO_RELEASE_STORE_PASSWORD")
val keyAliasValue = resolveSigningValue("keyAlias", "GPS2AUDIO_RELEASE_KEY_ALIAS")
val keyPasswordValue = resolveSigningValue("keyPassword", "GPS2AUDIO_RELEASE_KEY_PASSWORD")
val hasReleaseSigning = storeFilePath != null && storePassword != null &&
keyAliasValue != null && keyPasswordValue != null
signingConfigs {
if (hasReleaseSigning) {
create("release") {
storeFile = file(storeFilePath!!)
this.storePassword = storePassword
keyAlias = keyAliasValue
keyPassword = keyPasswordValue
}
}
}
buildTypes {
release {
if (hasReleaseSigning) {
signingConfig = signingConfigs.getByName("release")
}
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
@@ -38,6 +204,11 @@ android {
buildFeatures {
compose = true
}
lint {
abortOnError = false
checkReleaseBuilds = false
}
}
dependencies {
@@ -58,7 +229,16 @@ dependencies {
implementation(libs.kotlinx.coroutines.play.services)
implementation(libs.play.services.location)
implementation(libs.osmdroid)
// v2.2.2 — MapLibre Native Android SDK als Build-Abhängigkeit eingezogen.
// Das eigentliche Rendering nutzt weiterhin osmdroid (siehe ExplorerScreen),
// damit Stabilität/Größe nicht regressieren. MapLibre dient als Grundlage
// für künftige Vektor-/3D-Layer-Schritte (siehe Handoff).
implementation(libs.maplibre.android.sdk)
implementation(libs.media3.exoplayer)
implementation(libs.media3.common)
// v2.5.8 — Android Splash Screen API (Backport für API 26+). Wir nutzen
// den stabilen Weg via androidx.core.splashscreen, damit Pre-31 dieselbe
// Intro-Optik bekommt wie API 31+.
implementation(libs.androidx.core.splashscreen)
debugImplementation(libs.androidx.ui.tooling)
}
+22 -1
View File
@@ -18,6 +18,8 @@
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<!-- Foreground service with microphone access (Android 14+) -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<!-- Foreground service for background music playback (Atmo/Begleitmusik, Android 14+) -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<!-- Audio (MediaPlayer needs no special permission for local files) -->
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
@@ -38,11 +40,13 @@
<application
android:name=".WaypointApp"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@drawable/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@drawable/ic_launcher"
android:supportsRtl="true"
android:theme="@style/Theme.WaypointAudioGuide">
android:theme="@style/Theme.WaypointAudioGuide.Splash">
<activity
android:name=".MainActivity"
@@ -75,6 +79,23 @@
android:exported="false"
android:foregroundServiceType="microphone" />
<!-- Atmo/Begleitmusik foreground service: hält die Hintergrund-Audio-
Wiedergabe (Stream oder lokale Playlist) lebendig, wenn die App
minimiert wird oder der Bildschirm sich abschaltet. -->
<service
android:name=".service.AtmoPlaybackService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="mediaPlayback" />
<!-- v2.5.10 — Manuelle Wiedergabe (Tour-/Trackplay aus Wegpunkt-Liste).
Hält die MediaPlayer-Wiedergabe stabil, wenn der Bildschirm abschaltet. -->
<service
android:name=".service.ManualPlaybackService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="mediaPlayback" />
<!-- File provider for sound file selection (Android 7+) -->
<provider
android:name="androidx.core.content.FileProvider"
@@ -8,14 +8,32 @@ import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.*
import androidx.core.content.ContextCompat
import androidx.lifecycle.lifecycleScope
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import de.waypointaudio.data.TermsStore
import de.waypointaudio.data.TermsVersions
import de.waypointaudio.data.WakeLockSettingsStore
import de.waypointaudio.service.WakeLockController
import de.waypointaudio.ui.TermsDialog
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import de.waypointaudio.ui.ExplorerScreen
import de.waypointaudio.ui.ExplorerScreenMapLibre
import de.waypointaudio.ui.HomeScreen
import de.waypointaudio.ui.MapMode
import de.waypointaudio.ui.MapScreen
import de.waypointaudio.ui.PermissionRequiredScreen
import de.waypointaudio.ui.TrackDraftEditorScreen
import de.waypointaudio.ui.TrackDraftListScreen
import de.waypointaudio.ui.VersionNoticeDialogHost
import de.waypointaudio.ui.WaypointListScreen
import de.waypointaudio.ui.theme.WaypointAudioTheme
import de.waypointaudio.viewmodel.WaypointViewModel
@@ -40,6 +58,16 @@ class MainActivity : ComponentActivity() {
/** Nur Standortberechtigungen steuern, ob die App-UI angezeigt wird. */
private val locationPermissionsGranted = mutableStateOf(false)
/** Hält das Display abhängig von der Nutzer-Einstellung aktiv. */
lateinit var wakeLockController: WakeLockController
private set
/**
* v2.5.9 — Terms-Akzeptanz. -1 = noch nicht geladen, 0 = noch nicht akzeptiert
* (Dialog zeigen), 1 = akzeptiert (UI freigeben).
*/
private val termsState = mutableStateOf(-1)
// Launcher für Standortberechtigungen (zwingend erforderlich)
private val locationPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
@@ -60,31 +88,157 @@ class MainActivity : ComponentActivity() {
) { /* optional kein Block der Hauptoberfläche */ }
override fun onCreate(savedInstanceState: Bundle?) {
// v2.5.8 — Android Splash Screen (androidx.core.splashscreen). MUSS vor
// super.onCreate() installiert werden. Sorgt für ein kurzes, dezentes
// GPS2Audio-Intro mit Logo (Map-Pin + Wellen) auf Navy-Hintergrund.
// Wir halten den Splash bewusst nur ca. 700 ms — kein Loading-Spinner,
// keine Werbe-Pause. Auf API 31+ animiert das System das Logo automatisch
// beim Übergang. Auf API 2630 nutzt der Backport dieselbe Optik.
val splash = installSplashScreen()
val splashStart = android.os.SystemClock.elapsedRealtime()
splash.setKeepOnScreenCondition {
android.os.SystemClock.elapsedRealtime() - splashStart < 700L
}
super.onCreate(savedInstanceState)
// Wake-Lock-Controller initialisieren
wakeLockController = WakeLockController(this)
val wakeStore = WakeLockSettingsStore(applicationContext)
lifecycleScope.launchWhenStarted {
wakeStore.settings.collect { settings ->
wakeLockController.applySettings(settings)
}
}
lifecycleScope.launchWhenStarted {
viewModel.serviceRunning.collect { running ->
wakeLockController.setTourActive(running)
}
}
// v2.5.9 — Terms-Akzeptanz laden. Solange unklar, UI nicht aufdecken.
val termsStore = TermsStore(applicationContext)
lifecycleScope.launch {
val accepted = termsStore.acceptedVersion.first()
termsState.value = if (accepted == TermsVersions.CURRENT) 1 else 0
}
// Standortberechtigungen prüfen und ggf. anfordern
checkAndRequestLocationPermissions()
setContent {
WaypointAudioTheme(darkTheme = isSystemInDarkTheme()) {
val locationGranted by locationPermissionsGranted
val termsAccepted by termsState
if (locationGranted) {
// v2.5.9 — Solange der Akzeptanzstatus noch nicht geladen wurde
// (termsAccepted == -1) zeigen wir nichts; das verhindert ein
// kurzes "Aufflackern" der UI bevor der Dialog erscheint.
if (termsAccepted == -1) {
// bewusst leeres Theme — DataStore ist nach wenigen ms da.
} else if (termsAccepted == 0) {
TermsDialog(
onAccept = {
lifecycleScope.launch {
termsStore.setAccepted(TermsVersions.CURRENT)
termsState.value = 1
}
},
onDecline = {
// Ohne Zustimmung darf die App nicht weiter genutzt werden.
finishAndRemoveTask()
}
)
} else if (locationGranted) {
val navController = rememberNavController()
NavHost(
navController = navController,
startDestination = "list"
startDestination = "home"
) {
// v2.5.0 — neue Hauptseite "Audio-Bibliothek".
composable("home") {
HomeScreen(
viewModel = viewModel,
onOpenTour = { _ ->
// Tour wurde im HomeScreen bereits über
// viewModel.selectTour() aktiviert. Wir öffnen
// direkt die bestehende Tour-/Waypoint-Ansicht.
navController.navigate("list")
},
// v2.5.34 — Explorer-Kontext-Fix:
// Einstieg aus Audio-Bibliothek/Home muss immer neutral
// öffnen. selectDefaultTour() setzt selectedTour auf
// DEFAULT_TOUR_NAME, bevor der Explorer gerendert wird.
// Einstieg aus WaypointListScreen (Tour-Ansicht) behält
// die jeweils aktive Tour — dort wird selectDefaultTour()
// nicht aufgerufen.
onOpenExplorer = {
viewModel.selectDefaultTour()
navController.navigate("explorer")
},
// v2.5.19 — „Track Editor" wurde aus dem Hauptmenü
// entfernt; Drafts bleiben über den Explorer
// erreichbar. Die „drafts"-Route bleibt für den
// Explorer-Fluss bestehen.
onOpenClassicView = { navController.navigate("list") }
)
}
composable("list") {
WaypointListScreen(
viewModel = viewModel,
onNavigateToMap = { navController.navigate("map") }
onNavigateToMap = { navController.navigate("map") },
onNavigateToDrafts = { navController.navigate("drafts") },
onNavigateToExplorer = { navController.navigate("explorer") }
)
}
composable("map") {
// v2.2.3: Legacy-Editor — nur noch als interner Fallback erreichbar.
// Mode-Switch zwischen Karteneditor und Explorer entfällt, Explorer
// ist der zentrale Arbeitsbereich.
MapScreen(
viewModel = viewModel,
onNavigateBack = { navController.popBackStack() },
currentMode = null,
onModeChange = null
)
}
composable("explorer") {
// v2.4.0 — MapLibre Engine Cutover: aktiver Renderer ist jetzt MapLibre.
// Die osmdroid-Variante in [ExplorerScreen] bleibt im Quelltext als
// dokumentierter Fallback erhalten.
ExplorerScreenMapLibre(
viewModel = viewModel,
onNavigateBack = { navController.popBackStack() },
onOpenInEditor = { /* abgelöst — Explorer übernimmt */ },
onOpenDraft = { id -> navController.navigate("draft/$id") }
)
// Hinweis: ExplorerScreen (osmdroid) bleibt aus Build-/Symbol-
// Kompatibilität in der Sourcetree.
@Suppress("unused")
val osmdroidFallbackRef: @androidx.compose.runtime.Composable () -> Unit = {
ExplorerScreen(
viewModel = viewModel,
onNavigateBack = { navController.popBackStack() },
onOpenInEditor = { /* abgelöst — Explorer übernimmt */ },
onOpenDraft = { id -> navController.navigate("draft/$id") }
)
}
}
composable("drafts") {
TrackDraftListScreen(
viewModel = viewModel,
onOpenDraft = { id -> navController.navigate("draft/$id") },
onNavigateBack = { navController.popBackStack() }
)
}
composable(
route = "draft/{draftId}",
arguments = listOf(navArgument("draftId") { type = NavType.StringType })
) { backStackEntry ->
val id = backStackEntry.arguments?.getString("draftId") ?: ""
TrackDraftEditorScreen(
viewModel = viewModel,
draftId = id,
onNavigateBack = { navController.popBackStack() }
)
}
@@ -105,6 +259,16 @@ class MainActivity : ComponentActivity() {
if (locationPermissionsGranted.value) {
requestOptionalPermissions()
}
if (::wakeLockController.isInitialized) {
wakeLockController.onActivityResumed()
}
}
override fun onPause() {
if (::wakeLockController.isInitialized) {
wakeLockController.onActivityPaused()
}
super.onPause()
}
// ---------------------------------------------------------------------------
@@ -3,15 +3,61 @@ package de.waypointaudio
import android.app.Application
import android.app.NotificationChannel
import android.app.NotificationManager
import de.waypointaudio.license.LicenseStatusProvider
/**
* Application-Klasse initialisiert globale Ressourcen.
*
* v2.5.29 — Lizenz-Persistenz-Fix:
* Beim Start des App-Prozesses wird die Lizenz sofort aus dem persistenten
* Speicher geladen (LicenseStatusProvider.reload auf einem Hintergrundthread).
*
* Hintergrund: LicenseStatusProvider ist ein Kotlin-object (Prozess-Singleton).
* Nach einem APK-Update startet Android einen neuen Prozess — das Singleton
* ist dann frisch initialisiert mit "Keine Lizenz" als Standardwert.
* Die Lizenzdatei liegt in filesDir/license/ und überlebt APK-Updates; sie muss
* aber aktiv geladen werden. Bisher geschah das nur, wenn der Nutzer den
* Über-Dialog öffnete. Durch das Vorladen hier ist der Status beim ersten
* Compose-Render bereits korrekt gesetzt.
*
* Implementierung: Thread (kein Coroutine-Framework nötig in Application.onCreate),
* um keine Blockierung des Hauptthreads durch Datei-I/O zu verursachen.
* LicenseStatusProvider.reload() ist thread-safe (MutableState-Update ist
* Compose-intern threadsicher; UI rekomponiert beim nächsten Frame).
*/
class WaypointApp : Application() {
override fun onCreate() {
super.onCreate()
createNotificationChannels()
initMapLibre()
// v2.5.29 — Lizenzstatus vorladen, bevor die UI rendert.
// Läuft auf einem Hintergrundthread, um Datei-I/O vom Main-Thread fernzuhalten.
// ensureLoaded() ist @Synchronized, also Race-frei, auch wenn AboutDialog
// gleichzeitig aufgerufen wird.
Thread {
LicenseStatusProvider.ensureLoaded(applicationContext)
}.start()
}
/**
* v2.4.0 — MapLibre Native Android SDK initialisieren (AKTIVER Renderer).
*
* Ab v2.4.0 rendert der Explorer mit MapLibre Native (siehe
* ExplorerScreenMapLibre.kt). Init ohne API-Key, ohne Telemetrie. Tiles
* werden direkt vom Endgerät geladen (OSM Standard bzw. ESRI World Imagery
* oder eigene Tile-URL für Satellit).
*
* Wenn die Initialisierung fehlschlägt (selten — fehlende native .so),
* bleibt der MapLibre-Renderer nicht nutzbar. Als interner Fallback ist
* der osmdroid-basierte [ui.ExplorerScreen] im Quelltext erhalten.
*/
private fun initMapLibre() {
runCatching {
org.maplibre.android.MapLibre.getInstance(this)
}.onFailure { e ->
android.util.Log.w("WaypointApp", "MapLibre init übersprungen: ${e.message}")
}
}
private fun createNotificationChannels() {
@@ -0,0 +1,44 @@
package de.waypointaudio.data
import java.util.UUID
/**
* v2.5.12 — Ein einzelner Audio-Clip eines Wegpunkts.
*
* Mehrere Clips pro Wegpunkt werden vom Tour-Play in der durch [orderIndex]
* vorgegebenen Reihenfolge nacheinander abgespielt; deaktivierte Clips werden
* dabei übersprungen.
*
* @param id Stabile UUID. Wird bei Persistenz/Backup/Import beibehalten.
* @param title Anzeigename des Clips (z. B. "Einleitung", "Kapitel 2").
* Leer = Fallback auf [displayFileName] bzw. "Audio-Datei".
* @param uri Audio-URI (content:// oder file://). Leer = ungültiger Clip.
* @param displayFileName Lesbarer Dateiname für UI/Backup (z. B. der originale
* Dateiname aus dem Storage Access Framework).
* @param isActive Wenn false, wird der Clip beim Tour-Play übersprungen.
* Manuelles Einzel-Abspielen ist trotzdem möglich.
* @param isPrimary Markiert den "Haupt"-Clip eines Wegpunkts. Beim Migrieren
* legacy-Soundclips ist genau dieser Clip primary = true.
* Der primary-Clip wird in Listenansichten als Standard-
* Vorschau verwendet.
* @param orderIndex Stabile Sortierung. Niedrigere Werte werden zuerst
* abgespielt. Bei gleichen Werten entscheidet die UUID,
* damit die Reihenfolge deterministisch bleibt.
*/
data class AudioClip(
val id: String = UUID.randomUUID().toString(),
val title: String = "",
val uri: String = "",
val displayFileName: String = "",
val isActive: Boolean = true,
val isPrimary: Boolean = false,
val orderIndex: Int = 0
) {
/** Effektive Anzeige: Titel → Dateiname → "Audio-Datei". */
val effectiveTitle: String
get() = title.ifBlank { displayFileName.ifBlank { "Audio-Datei" } }
/** True, wenn der Clip eine nicht-leere URI besitzt. */
val hasAudio: Boolean
get() = uri.isNotBlank()
}
@@ -2,14 +2,23 @@ package de.waypointaudio.data
import android.content.Context
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
/**
* Globale Audio-Routing-Einstellungen für Live/PTT.
* Aufnahmeformat für PTT-Mitschnitt.
* M4A_AAC wird produktiv unterstützt; WAV ist in v2.1.0 absichtlich deaktiviert
* (siehe Handoff). Der enum-Wert bleibt vorhanden für künftige Erweiterung.
*/
enum class PttRecordingFormat { M4A_AAC, WAV }
/**
* Globale Audio-Routing- und PTT-Einstellungen für Live/PTT.
*
* Gerätekennungen (AudioDeviceInfo.id) sind Sitzungs-spezifisch und können sich nach
* einem Neustart oder nach dem Trennen/Verbinden von Bluetooth-Geräten ändern.
@@ -18,22 +27,32 @@ import kotlinx.coroutines.flow.map
*
* @param selectedInputDeviceId ID des Eingabegeräts, null = Systemstandard
* @param selectedOutputDeviceId ID des Ausgabegeräts, null = Systemstandard
* @param liveOutputEnabled Mikrofon-Loop auf Ausgabegerät (klassische PTT).
* Standard: true (rückwärtskompatibel zu v2.0.8).
* @param recordingEnabled PTT-Aufnahme aktiv. Standard: false.
* @param recordingFormat Aufnahmeformat. Standard: M4A_AAC.
*/
data class AudioRoutingSettings(
val selectedInputDeviceId: Int? = null,
val selectedOutputDeviceId: Int? = null
val selectedOutputDeviceId: Int? = null,
val liveOutputEnabled: Boolean = true,
val recordingEnabled: Boolean = false,
val recordingFormat: PttRecordingFormat = PttRecordingFormat.M4A_AAC
)
private val Context.audioRoutingDataStore by preferencesDataStore(name = "audio_routing_settings")
/**
* Persistenz-Schicht für Audio-Routing-Einstellungen via DataStore.
* Persistenz-Schicht für Audio-Routing- und PTT-Einstellungen via DataStore.
*/
class AudioRoutingStore(private val context: Context) {
private companion object {
val KEY_INPUT_DEVICE_ID = intPreferencesKey("selected_input_device_id")
val KEY_OUTPUT_DEVICE_ID = intPreferencesKey("selected_output_device_id")
val KEY_INPUT_DEVICE_ID = intPreferencesKey("selected_input_device_id")
val KEY_OUTPUT_DEVICE_ID = intPreferencesKey("selected_output_device_id")
val KEY_LIVE_OUTPUT = booleanPreferencesKey("ptt_live_output_enabled")
val KEY_RECORDING_ENABLED = booleanPreferencesKey("ptt_recording_enabled")
val KEY_RECORDING_FORMAT = stringPreferencesKey("ptt_recording_format")
// Sentinel: -1 means "use system default" (null cannot be stored as Int)
const val NO_DEVICE = -1
}
@@ -42,14 +61,22 @@ class AudioRoutingStore(private val context: Context) {
.map { prefs ->
AudioRoutingSettings(
selectedInputDeviceId = prefs[KEY_INPUT_DEVICE_ID].takeIf { it != null && it != NO_DEVICE },
selectedOutputDeviceId = prefs[KEY_OUTPUT_DEVICE_ID].takeIf { it != null && it != NO_DEVICE }
selectedOutputDeviceId = prefs[KEY_OUTPUT_DEVICE_ID].takeIf { it != null && it != NO_DEVICE },
liveOutputEnabled = prefs[KEY_LIVE_OUTPUT] ?: true,
recordingEnabled = prefs[KEY_RECORDING_ENABLED] ?: false,
recordingFormat = prefs[KEY_RECORDING_FORMAT]
?.let { runCatching { PttRecordingFormat.valueOf(it) }.getOrNull() }
?: PttRecordingFormat.M4A_AAC
)
}
suspend fun save(settings: AudioRoutingSettings) {
context.audioRoutingDataStore.edit { prefs ->
prefs[KEY_INPUT_DEVICE_ID] = settings.selectedInputDeviceId ?: NO_DEVICE
prefs[KEY_OUTPUT_DEVICE_ID] = settings.selectedOutputDeviceId ?: NO_DEVICE
prefs[KEY_INPUT_DEVICE_ID] = settings.selectedInputDeviceId ?: NO_DEVICE
prefs[KEY_OUTPUT_DEVICE_ID] = settings.selectedOutputDeviceId ?: NO_DEVICE
prefs[KEY_LIVE_OUTPUT] = settings.liveOutputEnabled
prefs[KEY_RECORDING_ENABLED] = settings.recordingEnabled
prefs[KEY_RECORDING_FORMAT] = settings.recordingFormat.name
}
}
@@ -0,0 +1,37 @@
package de.waypointaudio.data
import android.content.Context
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
/**
* v2.5.13 — Speichert, für welchen versionCode der Nutzer den
* "Hintergrundwiedergabe absichern"-Hinweis bereits weggeklickt hat.
*
* Damit nervt der Hinweis nicht erneut bei jedem Tour-Start, taucht aber
* automatisch bei jedem neuen Update (steigt der versionCode) wieder auf,
* weil sich Empfehlungen mit jeder Version ändern können.
*/
private val Context.backgroundReliabilityDataStore by preferencesDataStore(
name = "background_reliability_settings"
)
class BackgroundReliabilityStore(private val context: Context) {
private companion object {
val KEY_DISMISSED_VERSION_CODE = longPreferencesKey("dismissed_version_code")
}
/** Letzter ausgeblendeter versionCode; 0 = noch nie ausgeblendet. */
val dismissedVersionCode: Flow<Long> = context.backgroundReliabilityDataStore.data
.map { it[KEY_DISMISSED_VERSION_CODE] ?: 0L }
suspend fun setDismissedVersionCode(code: Long) {
context.backgroundReliabilityDataStore.edit { prefs ->
prefs[KEY_DISMISSED_VERSION_CODE] = code
}
}
}
@@ -0,0 +1,40 @@
package de.waypointaudio.data
import android.content.Context
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
/**
* Persistente Einstellungen des Explorers (v2.2.3).
*
* Aktuell nur „GPS-Nachführung an/aus". Die Einstellung überlebt
* Neustarts und Navigation; eine manuelle Kartenverschiebung darf
* den Wert nicht automatisch wieder auf „an" setzen.
*/
data class ExplorerSettings(
val followLocation: Boolean = true,
)
private val Context.explorerDataStore by preferencesDataStore(name = "explorer_settings")
class ExplorerSettingsStore(private val context: Context) {
private companion object {
val KEY_FOLLOW = booleanPreferencesKey("follow_location")
}
val settings: Flow<ExplorerSettings> = context.explorerDataStore.data.map { prefs ->
ExplorerSettings(
followLocation = prefs[KEY_FOLLOW] ?: true
)
}
suspend fun setFollowLocation(enabled: Boolean) {
context.explorerDataStore.edit { prefs ->
prefs[KEY_FOLLOW] = enabled
}
}
}
@@ -0,0 +1,53 @@
package de.waypointaudio.data
import android.content.Context
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
/**
* v2.5.0 — Sichtbarkeitsoptionen für die optionalen Module der neuen
* Audio-Bibliothek-Hauptseite. PTT- und Atmo-Modul lassen sich
* unabhängig ein- und ausblenden ("Startseite anpassen").
*
* Default: beide Module sind sichtbar, damit bestehende Nutzer ihren
* bisherigen Zugang zu Live-PTT und Atmo nicht verlieren.
*/
data class HomeModulesSettings(
val showPtt: Boolean = true,
val showAtmo: Boolean = true,
val showMiniNowPlaying: Boolean = true
)
private val Context.homeModulesDataStore by preferencesDataStore(name = "home_modules_settings")
class HomeModulesSettingsStore(private val context: Context) {
private companion object {
val KEY_SHOW_PTT = booleanPreferencesKey("show_ptt")
val KEY_SHOW_ATMO = booleanPreferencesKey("show_atmo")
val KEY_SHOW_MINI_NOW = booleanPreferencesKey("show_mini_now")
}
val settings: Flow<HomeModulesSettings> = context.homeModulesDataStore.data.map { prefs ->
HomeModulesSettings(
showPtt = prefs[KEY_SHOW_PTT] ?: true,
showAtmo = prefs[KEY_SHOW_ATMO] ?: true,
showMiniNowPlaying = prefs[KEY_SHOW_MINI_NOW] ?: true
)
}
suspend fun setShowPtt(value: Boolean) {
context.homeModulesDataStore.edit { it[KEY_SHOW_PTT] = value }
}
suspend fun setShowAtmo(value: Boolean) {
context.homeModulesDataStore.edit { it[KEY_SHOW_ATMO] = value }
}
suspend fun setShowMiniNowPlaying(value: Boolean) {
context.homeModulesDataStore.edit { it[KEY_SHOW_MINI_NOW] = value }
}
}
@@ -0,0 +1,143 @@
package de.waypointaudio.data
import android.content.Context
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import org.osmdroid.tileprovider.tilesource.ITileSource
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
import org.osmdroid.tileprovider.tilesource.XYTileSource
import org.osmdroid.util.MapTileIndex
/**
* Verfügbare Karten-Anbieter (Phase 1 von v2.0.0).
*
* Alle Provider werden via [org.osmdroid.tileprovider.tilesource.XYTileSource]
* angebunden — kein Library-Wechsel nötig. Die OSM-Attribution
* (siehe `osm_attribution_short`) bleibt für sämtliche Provider sichtbar.
*
* Hinweise zu Provider-spezifischen Bedingungen:
* - **CartoDB** Tiles dürfen für nicht-kommerzielle Nutzung frei verwendet
* werden (Attribution `© OpenStreetMap & CartoDB`).
* - **OpenTopoMap** ist für nicht-kommerzielle Nutzung frei.
* - **ESRI** Tiles sind für persönliche, nicht-kommerzielle Nutzung
* via Attribution `© Esri` erlaubt.
* - **Stadia Maps** verlangt einen API-Key für produktive Nutzung; der Default
* funktioniert für Tests; im Zweifel CartoDB/OSM verwenden.
*/
enum class MapProvider(
val key: String,
val displayNameRes: Int
) {
OSM("osm", de.waypointaudio.R.string.map_provider_osm),
CARTO_POSITRON("carto_positron", de.waypointaudio.R.string.map_provider_carto_positron),
CARTO_DARK("carto_dark", de.waypointaudio.R.string.map_provider_carto_dark),
OPEN_TOPO("open_topo", de.waypointaudio.R.string.map_provider_topo),
ESRI_SATELLITE("esri_satellite", de.waypointaudio.R.string.map_provider_esri_satellite),
ESRI_HYBRID("esri_hybrid", de.waypointaudio.R.string.map_provider_esri_hybrid),
STADIA_ALIDADE("stadia_alidade", de.waypointaudio.R.string.map_provider_stadia_alidade),
STADIA_OUTDOORS("stadia_outdoors", de.waypointaudio.R.string.map_provider_stadia_outdoors);
fun toTileSource(): ITileSource = when (this) {
OSM -> TileSourceFactory.MAPNIK
CARTO_POSITRON -> XYTileSource(
"CartoDB Positron", 1, 19, 256, ".png",
arrayOf(
"https://a.basemaps.cartocdn.com/light_all/",
"https://b.basemaps.cartocdn.com/light_all/",
"https://c.basemaps.cartocdn.com/light_all/"
),
"© OpenStreetMap-Mitwirkende, © CARTO"
)
CARTO_DARK -> XYTileSource(
"CartoDB Dark Matter", 1, 19, 256, ".png",
arrayOf(
"https://a.basemaps.cartocdn.com/dark_all/",
"https://b.basemaps.cartocdn.com/dark_all/",
"https://c.basemaps.cartocdn.com/dark_all/"
),
"© OpenStreetMap-Mitwirkende, © CARTO"
)
OPEN_TOPO -> XYTileSource(
"OpenTopoMap", 1, 17, 256, ".png",
arrayOf(
"https://a.tile.opentopomap.org/",
"https://b.tile.opentopomap.org/",
"https://c.tile.opentopomap.org/"
),
"Karte: © OpenTopoMap (CC-BY-SA)"
)
ESRI_SATELLITE -> object : XYTileSource(
"ESRI World Imagery", 1, 19, 256, "",
arrayOf("https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/"),
"Tiles © Esri"
) {
override fun getTileURLString(pMapTileIndex: Long): String {
return baseUrl +
MapTileIndex.getZoom(pMapTileIndex) + "/" +
MapTileIndex.getY(pMapTileIndex) + "/" +
MapTileIndex.getX(pMapTileIndex)
}
}
ESRI_HYBRID -> object : XYTileSource(
"ESRI Hybrid Reference", 1, 19, 256, "",
arrayOf("https://server.arcgisonline.com/ArcGIS/rest/services/Reference/World_Boundaries_and_Places/MapServer/tile/"),
"Tiles © Esri"
) {
override fun getTileURLString(pMapTileIndex: Long): String {
return baseUrl +
MapTileIndex.getZoom(pMapTileIndex) + "/" +
MapTileIndex.getY(pMapTileIndex) + "/" +
MapTileIndex.getX(pMapTileIndex)
}
}
STADIA_ALIDADE -> XYTileSource(
"Stadia Alidade Smooth", 1, 20, 256, ".png",
arrayOf("https://tiles.stadiamaps.com/tiles/alidade_smooth/"),
"© Stadia Maps, © OpenStreetMap-Mitwirkende"
)
STADIA_OUTDOORS -> XYTileSource(
"Stadia Outdoors", 1, 20, 256, ".png",
arrayOf("https://tiles.stadiamaps.com/tiles/outdoors/"),
"© Stadia Maps, © OpenStreetMap-Mitwirkende"
)
}
companion object {
val DEFAULT = OSM
fun fromKey(key: String?): MapProvider =
values().firstOrNull { it.key == key } ?: DEFAULT
}
}
private val Context.mapProviderDataStore by preferencesDataStore(name = "map_provider_settings")
/**
* Persistiert den ausgewählten Karten-Provider app-weit.
*
* Hinweis: Die Auswahl ist bewusst app-weit (nicht pro Tour) gehalten Touren
* verwalten bisher keine kartenspezifischen Einstellungen, und der Wechsel des
* Anbieters ist im Karten-Menü jederzeit erreichbar.
*/
class MapProviderStore(private val context: Context) {
private companion object {
val KEY_PROVIDER = stringPreferencesKey("selected_map_provider")
}
val provider: Flow<MapProvider> = context.mapProviderDataStore.data.map { prefs ->
MapProvider.fromKey(prefs[KEY_PROVIDER])
}
suspend fun currentProvider(): MapProvider = provider.first()
suspend fun setProvider(provider: MapProvider) {
context.mapProviderDataStore.edit { prefs ->
prefs[KEY_PROVIDER] = provider.key
}
}
}
@@ -0,0 +1,241 @@
package de.waypointaudio.data
import android.content.Context
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
/**
* Basis-Kartenstil — v2.5.38 Explorer Map Styles.
*
* Alle Stile nutzen OpenFreeMap (https://openfreemap.org) für Vektor-Tiles,
* kostenlos ohne API-Key, ohne eigenen Server. Attribution: © OpenFreeMap,
* © OpenMapTiles, Daten: © OpenStreetMap-Mitwirkende.
*
* MODERN_2D — Positron: heller, reduzierter Vektorstil. Ideal als
* neutraler Hintergrund für Wegpunkte/Routen.
* URL: https://tiles.openfreemap.org/styles/positron
*
* DARK — Dark Matter: dunkler Vektorstil.
* URL: https://tiles.openfreemap.org/styles/dark
*
* OUTDOOR — Fiord/Outdoor: Liberty-Vektorstil mit Outdoor-/Geländecharakter.
* URL: https://tiles.openfreemap.org/styles/liberty
* (Liberty ist der vollständigste, lebendigste OpenFreeMap-Style
* mit deutlicher Grün-/Geländeanmutung.)
*
* CITY_3D — 3D Stadt: Positron-Basis + MapLibre fill-extrusion für
* Gebäude-Layer (building). Wenn kein building-Layer in der
* Style-Source, automatischer 2D-Fallback ohne Crash.
* URL: https://tiles.openfreemap.org/styles/positron
* + programmatische fill-extrusion nachträglich per addLayer.
*
* CLASSIC — Klassisch: OSM-Raster-Tiles (tile.openstreetmap.org),
* raster-basierter Fallback. Kein Vektor.
*
* SATELLITE — Legacy aus v2.4.x. Wird auf MODERN_2D migriert.
* Satellit ist ab v2.5.37 nicht mehr im UI angeboten.
* TERRAIN_3D — Legacy aus v2.4.x. Wird auf CITY_3D migriert.
* TOPO — Legacy aus v2.4.x. Wird auf OUTDOOR migriert.
* PERSPECTIVE — Legacy aus v2.3.x. Wird auf CITY_3D migriert.
*/
enum class MapBaseStyle(val key: String) {
MODERN_2D("modern_2d"),
DARK("dark"),
OUTDOOR("outdoor"),
CITY_3D("city_3d"),
CLASSIC("classic"),
// Legacy — für Rückwärtskompatibilität gespeicherter Settings:
SATELLITE("satellite"),
TERRAIN_3D("terrain_3d"),
TOPO("topo"),
PERSPECTIVE("perspective");
companion object {
val DEFAULT = CITY_3D
fun fromKey(key: String?): MapBaseStyle {
return when (key) {
"modern_2d" -> MODERN_2D
"dark" -> DARK
"outdoor" -> OUTDOOR
"city_3d" -> CITY_3D
"classic" -> CLASSIC
// Legacy migration:
"satellite" -> MODERN_2D
"terrain_3d" -> CITY_3D
"topo" -> OUTDOOR
"perspective"-> CITY_3D
else -> DEFAULT
}
}
}
}
/**
* POI-Kategorie-Auswahl — v2.5.38.
*
* Jede Kategorie schaltet eine Gruppe von MapLibre-Symbol-Layern sichtbar/unsichtbar.
* Wenn der Style keinen passenden Layer enthält, passiert nichts (kein Crash).
*
* Kategorien:
* SIGHTS — Sehenswürdigkeiten / Tourismus (tourism, historic, amenity:place_of_worship)
* INFRASTRUCTURE — Infrastruktur (hospital, pharmacy, police, post, atm, bank, fuel)
* GASTRONOMY — Gastronomie (restaurant, cafe, fast_food, bar, pub, ice_cream)
* MOBILITY — Mobilität (bus_stop, train_station, parking, bicycle, car_sharing)
*/
enum class PoiCategory(val key: String) {
SIGHTS("sights"),
INFRASTRUCTURE("infrastructure"),
GASTRONOMY("gastronomy"),
MOBILITY("mobility");
companion object {
fun fromKey(key: String?): PoiCategory? = values().firstOrNull { it.key == key }
}
}
/**
* Persistierte POI-Sichtbarkeit je Kategorie — v2.5.44.
* Default: Alle vier Hauptkategorien aktiv, damit der Nutzer sofort
* alle POI-Typen sieht (Fix: vorher nur Sehenswürdigkeiten sichtbar).
* Namen-Labels bleiben standardmäßig aus (Performance/Übersichtlichkeit).
*/
data class PoiLayerSettings(
val showSights: Boolean = true,
val showInfrastructure: Boolean = true,
val showGastronomy: Boolean = true,
val showMobility: Boolean = true,
// v2.5.42 — Namen-Labels auf der Karte standardmäßig deaktiviert
val showPoiNames: Boolean = false,
)
/**
* Gespeicherte Karten-Stil-Einstellungen — v2.5.38.
*
* @param baseStyle gewählter Basis-Stil (wird persistent gespeichert)
* @param showAudioLength ob die visuelle Audio-/Trigger-Länge als
* Ring/Indikator je Wegpunkt eingeblendet wird.
* @param perspectiveTilt Kamera-Pitch für CITY_3D (Grad, 0..60).
* @param showAudioLines Clip-Längen als projizierte Liniensegmente.
* @param routingEndpoint optionaler OSRM-Routing-Endpoint.
* @param poiLayers POI-Kategorien-Sichtbarkeit (persistent).
*
* Legacy-Felder (customSatelliteUrl, customTopoUrl, customTerrainDemUrl) werden
* weiterhin gelesen, aber nicht mehr in der UI angeboten — für Rückwärts-
* kompatibilität mit gespeicherten Preferences aus v2.4.x.
*/
data class MapStyleSettings(
val baseStyle: MapBaseStyle = MapBaseStyle.DEFAULT,
val customSatelliteUrl: String = "", // legacy, wird ignoriert
val customTopoUrl: String = "", // legacy, wird ignoriert
val customTerrainDemUrl: String = "", // legacy, wird ignoriert
val showAudioLength: Boolean = true,
val perspectiveTilt: Int = 35,
val showAudioLines: Boolean = true,
val routingEndpoint: String = "",
val poiLayers: PoiLayerSettings = PoiLayerSettings(),
)
private val Context.mapStyleDataStore by preferencesDataStore(name = "map_style_settings")
class MapStyleStore(private val context: Context) {
private companion object {
val KEY_BASE_STYLE = stringPreferencesKey("base_style")
val KEY_SAT_URL = stringPreferencesKey("custom_satellite_url")
val KEY_TOPO_URL = stringPreferencesKey("custom_topo_url")
val KEY_TERRAIN_DEM_URL = stringPreferencesKey("custom_terrain_dem_url")
val KEY_SHOW_AUDIO_LEN = booleanPreferencesKey("show_audio_length")
val KEY_PERSPECTIVE_TILT = stringPreferencesKey("perspective_tilt")
val KEY_SHOW_AUDIO_LINES = booleanPreferencesKey("show_audio_lines")
val KEY_ROUTING_ENDPOINT = stringPreferencesKey("routing_endpoint")
// v2.5.38 — POI-Layer-Persistenz
val KEY_POI_SIGHTS = booleanPreferencesKey("poi_sights")
val KEY_POI_INFRASTRUCTURE = booleanPreferencesKey("poi_infrastructure")
val KEY_POI_GASTRONOMY = booleanPreferencesKey("poi_gastronomy")
val KEY_POI_MOBILITY = booleanPreferencesKey("poi_mobility")
// v2.5.42
val KEY_POI_SHOW_NAMES = booleanPreferencesKey("poi_show_names")
}
val settings: Flow<MapStyleSettings> = context.mapStyleDataStore.data.map { prefs ->
MapStyleSettings(
baseStyle = MapBaseStyle.fromKey(prefs[KEY_BASE_STYLE]),
customSatelliteUrl = prefs[KEY_SAT_URL].orEmpty(),
customTopoUrl = prefs[KEY_TOPO_URL].orEmpty(),
customTerrainDemUrl = prefs[KEY_TERRAIN_DEM_URL].orEmpty(),
showAudioLength = prefs[KEY_SHOW_AUDIO_LEN] ?: true,
perspectiveTilt = prefs[KEY_PERSPECTIVE_TILT]?.toIntOrNull()?.coerceIn(0, 60) ?: 35,
showAudioLines = prefs[KEY_SHOW_AUDIO_LINES] ?: true,
routingEndpoint = prefs[KEY_ROUTING_ENDPOINT].orEmpty(),
poiLayers = PoiLayerSettings(
showSights = prefs[KEY_POI_SIGHTS] ?: true,
// v2.5.44: Default auf true geändert — Nutzer ohne gespeicherte
// Einstellung bekommen jetzt alle Kategorien aktiv
showInfrastructure = prefs[KEY_POI_INFRASTRUCTURE] ?: true,
showGastronomy = prefs[KEY_POI_GASTRONOMY] ?: true,
showMobility = prefs[KEY_POI_MOBILITY] ?: true,
showPoiNames = prefs[KEY_POI_SHOW_NAMES] ?: false,
)
)
}
suspend fun setBaseStyle(style: MapBaseStyle) {
context.mapStyleDataStore.edit { it[KEY_BASE_STYLE] = style.key }
}
suspend fun setShowAudioLength(enabled: Boolean) {
context.mapStyleDataStore.edit { it[KEY_SHOW_AUDIO_LEN] = enabled }
}
suspend fun setPerspectiveTilt(tilt: Int) {
context.mapStyleDataStore.edit {
it[KEY_PERSPECTIVE_TILT] = tilt.coerceIn(0, 60).toString()
}
}
suspend fun setShowAudioLines(enabled: Boolean) {
context.mapStyleDataStore.edit { it[KEY_SHOW_AUDIO_LINES] = enabled }
}
// v2.5.38 — POI-Layer-Persistenz
suspend fun setPoiLayers(poi: PoiLayerSettings) {
context.mapStyleDataStore.edit {
it[KEY_POI_SIGHTS] = poi.showSights
it[KEY_POI_INFRASTRUCTURE] = poi.showInfrastructure
it[KEY_POI_GASTRONOMY] = poi.showGastronomy
it[KEY_POI_MOBILITY] = poi.showMobility
it[KEY_POI_SHOW_NAMES] = poi.showPoiNames
}
}
// v2.5.37 — Legacy-Stubs für Aufrufstellen aus v2.4.x die
// noch customSatelliteUrl / customTopoUrl / customTerrainDemUrl schreiben.
// Diese Werte werden weiterhin gelesen (Preferences bleiben),
// aber im UI nicht mehr angeboten und vom Style-Builder ignoriert.
suspend fun setCustomSatelliteUrl(url: String) {
context.mapStyleDataStore.edit { it[KEY_SAT_URL] = url.trim() }
}
suspend fun setCustomTopoUrl(url: String) {
context.mapStyleDataStore.edit { it[KEY_TOPO_URL] = url.trim() }
}
suspend fun setCustomTerrainDemUrl(url: String) {
context.mapStyleDataStore.edit { it[KEY_TERRAIN_DEM_URL] = url.trim() }
}
/**
* Speichert den Routing-Endpunkt — v2.4.4: nutzt
* [de.waypointaudio.util.OsrmRoutingClient.normalizeBase], sodass der
* Nutzer sowohl die reine Basis-URL (`https://router.project-osrm.org`)
* als auch die vollständige `/route/v1/driving`-Variante speichern darf.
* Intern landet in den Preferences immer die normalisierte Basis-URL,
* was Doppelprefixe bei der eigentlichen Request-URL ausschließt.
*/
suspend fun setRoutingEndpoint(url: String) {
val normalized = de.waypointaudio.util.OsrmRoutingClient.normalizeBase(url)
context.mapStyleDataStore.edit { it[KEY_ROUTING_ENDPOINT] = normalized }
}
}
@@ -0,0 +1,86 @@
package de.waypointaudio.data
/**
* v2.5.14 — Flach gewickelter Playback-Eintrag.
*
* Ein [PlayableItem] beschreibt genau eine abspielbare Audio-Quelle in der
* Tour-Reihenfolge. Die Sequenz wird aus den Wegpunkten der aktuell sichtbaren
* Tour aufgebaut: pro Wegpunkt werden alle aktiven Clips in der durch
* [AudioClip.orderIndex] vorgegebenen Reihenfolge expandiert. Diese flache
* Liste ist die *einzige Wahrheit* für die manuelle Wiedergabe, die
* Vor/Zurück-Navigation im Clip-Detail-Player und die Anzeige "Clip 2 von 3".
*
* Wichtig:
* - [waypointIndex] und [waypointTotal] referenzieren die Position des
* Wegpunkts in der abspielbaren Tour-Liste (1-basiert, nicht der Index in
* der DataStore-Reihenfolge).
* - [clipIndex] und [clipTotal] referenzieren die Position des Clips
* innerhalb seines Wegpunkts (1-basiert).
* - [compoundId] = "${waypointId}#${clipId}" ist der ID-Schlüssel, den der
* [de.waypointaudio.service.ManualAudioPlayer] im Single-Mode verwendet.
* Damit erkennt der Player "schon spielend", wenn ein zweites Tippen auf
* denselben Clip in Pause/Resume münden soll.
*/
data class PlayableItem(
val waypointId: String,
val clipId: String,
val waypointIndex: Int,
val waypointTotal: Int,
val clipIndex: Int,
val clipTotal: Int,
val waypointName: String,
val clipTitle: String,
val uri: String,
) {
val compoundId: String
get() = "$waypointId#$clipId"
/**
* Anzeigename für Notification/Mini-Player. Bei mehreren Clips wird das
* Suffix "(2/3)" angehängt, damit der Nutzer auch ohne offenes Detail-
* Sheet erkennt, an welcher Stelle er sich befindet.
*/
val displayTitle: String
get() {
val wp = waypointName.ifBlank { "Wegpunkt" }
val clip = clipTitle.takeIf { it.isNotBlank() }
return when {
clipTotal <= 1 -> wp
clip == null -> "$wp ($clipIndex/$clipTotal)"
else -> "$wp · $clip ($clipIndex/$clipTotal)"
}
}
}
/**
* v2.5.14 — Erzeugt die flache Playback-Sequenz für eine Liste von Wegpunkten
* (typischerweise: alle aktiven, abspielbaren Wegpunkte der aktuellen Tour).
*
* Wegpunkte ohne aktive Clips werden übersprungen. Innerhalb eines Wegpunkts
* werden die Clips in der durch [Waypoint.activeClips] festgelegten Sortierung
* (orderIndex, dann id) expandiert.
*/
fun List<Waypoint>.toPlayableSequence(): List<PlayableItem> {
val waypointsWithClips = this
.map { wp -> wp to wp.activeClips() }
.filter { it.second.isNotEmpty() }
val waypointTotal = waypointsWithClips.size
val items = mutableListOf<PlayableItem>()
waypointsWithClips.forEachIndexed { wpIdx, (wp, clips) ->
val clipTotal = clips.size
clips.forEachIndexed { clipIdx, clip ->
items += PlayableItem(
waypointId = wp.id,
clipId = clip.id,
waypointIndex = wpIdx + 1,
waypointTotal = waypointTotal,
clipIndex = clipIdx + 1,
clipTotal = clipTotal,
waypointName = wp.name,
clipTitle = clip.effectiveTitle,
uri = clip.uri,
)
}
}
return items
}
@@ -0,0 +1,37 @@
package de.waypointaudio.data
/**
* v2.5.14 — Schlanker Fortschritts-Snapshot des manuellen Players.
*
* Wird vom ViewModel periodisch (Tick ~500 ms) aktualisiert, solange Audio
* läuft, und vom Clip-Detail-Player sowie der kompakten Wiedergabeleiste
* konsumiert.
*
* @property positionMs Verstrichene Position in ms (>= 0).
* @property durationMs Gesamtdauer in ms; -1 wenn unbekannt (Streams,
* noch nicht geprepared). UI sollte dann "--:--" zeigen.
* @property isPlaying True, solange tatsächlich abgespielt wird (nicht
* pausiert/gestoppt). Bequemes Flag für die Hülle.
*/
data class PlaybackProgress(
val positionMs: Long = 0L,
val durationMs: Long = -1L,
val isPlaying: Boolean = false,
) {
/** Verbleibende Restzeit in ms, oder -1 wenn unbekannt. */
val remainingMs: Long
get() = if (durationMs > 0) (durationMs - positionMs).coerceAtLeast(0L) else -1L
companion object {
val EMPTY = PlaybackProgress()
}
}
/** Formatiert eine Millisekundenzahl als "m:ss" oder "--:--" wenn unbekannt. */
fun formatRemaining(remainingMs: Long): String {
if (remainingMs < 0) return "--:--"
val totalSeconds = (remainingMs + 500) / 1000
val m = totalSeconds / 60
val s = totalSeconds % 60
return "%d:%02d".format(m, s)
}
@@ -0,0 +1,118 @@
package de.waypointaudio.data
import android.content.Context
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import java.util.UUID
/**
* Metadaten einer PTT-Aufnahme.
*
* Die Audiodatei selbst liegt in [filePath] (App-spezifisches Verzeichnis,
* Unterordner `recordings/`). [uri] ist die file://-Repräsentation, die für
* Wegpunkt-Zuweisung und Wiedergabe verwendet wird.
*
* @param id UUID der Aufnahme
* @param displayName Anzeigename (Datei ohne Erweiterung, vom Nutzer ggf. überschreibbar)
* @param filePath Absoluter Pfad zur Audiodatei
* @param uri file://-URI für Wiedergabe und Wegpunkt-Zuweisung
* @param format Dateiformat ("m4a", "wav")
* @param createdAt Erstellzeitpunkt in Unix-Millisekunden
* @param durationMs Aufnahmedauer in Millisekunden (0 wenn unbekannt)
* @param latitude Optionaler Breitengrad der Aufnahmestelle
* @param longitude Optionaler Längengrad der Aufnahmestelle
*/
data class PttRecording(
val id: String = UUID.randomUUID().toString(),
val displayName: String,
val filePath: String,
val uri: String,
val format: String,
val createdAt: Long,
val durationMs: Long = 0L,
val latitude: Double? = null,
val longitude: Double? = null
)
private val Context.pttRecordingsDataStore by preferencesDataStore(name = "ptt_recordings")
/**
* Persistente Bibliothek aller PTT-Aufnahmen (Metadaten).
*
* Die Audiodateien liegen im app-spezifischen Verzeichnis
* `<filesDir>/recordings/`. Dieser Store hält ausschließlich die Metadaten.
*/
class PttRecordingStore(private val context: Context) {
private companion object {
val KEY_ENTRIES = stringPreferencesKey("recordings_json")
}
private val gson = Gson()
private val listType = object : TypeToken<List<PttRecording>>() {}.type
/** Reaktiver Flow aller Aufnahme-Metadaten (neueste zuerst, sortiert nach createdAt desc). */
val recordings: Flow<List<PttRecording>> = context.pttRecordingsDataStore.data
.map { prefs ->
val raw = prefs[KEY_ENTRIES]
if (raw.isNullOrBlank()) emptyList()
else runCatching {
gson.fromJson<List<PttRecording>>(raw, listType) ?: emptyList()
}.getOrElse { emptyList() }
}
.map { list -> list.sortedByDescending { it.createdAt } }
/** Liefert die aktuelle Liste einmalig (für innerhalb-Transaktionen). */
private suspend fun readOnce(): List<PttRecording> {
var result: List<PttRecording> = emptyList()
context.pttRecordingsDataStore.edit { prefs ->
val raw = prefs[KEY_ENTRIES]
result = if (raw.isNullOrBlank()) emptyList()
else runCatching {
gson.fromJson<List<PttRecording>>(raw, listType) ?: emptyList()
}.getOrElse { emptyList() }
}
return result
}
suspend fun add(entry: PttRecording) {
context.pttRecordingsDataStore.edit { prefs ->
val raw = prefs[KEY_ENTRIES]
val list = if (raw.isNullOrBlank()) emptyList()
else runCatching {
gson.fromJson<List<PttRecording>>(raw, listType) ?: emptyList()
}.getOrElse { emptyList() }
val updated = (list + entry).distinctBy { it.id }
prefs[KEY_ENTRIES] = gson.toJson(updated)
}
}
suspend fun delete(id: String) {
context.pttRecordingsDataStore.edit { prefs ->
val raw = prefs[KEY_ENTRIES]
val list = if (raw.isNullOrBlank()) emptyList()
else runCatching {
gson.fromJson<List<PttRecording>>(raw, listType) ?: emptyList()
}.getOrElse { emptyList() }
prefs[KEY_ENTRIES] = gson.toJson(list.filterNot { it.id == id })
}
}
suspend fun rename(id: String, newName: String) {
context.pttRecordingsDataStore.edit { prefs ->
val raw = prefs[KEY_ENTRIES]
val list = if (raw.isNullOrBlank()) emptyList()
else runCatching {
gson.fromJson<List<PttRecording>>(raw, listType) ?: emptyList()
}.getOrElse { emptyList() }
prefs[KEY_ENTRIES] = gson.toJson(list.map {
if (it.id == id) it.copy(displayName = newName) else it
})
}
}
}
@@ -0,0 +1,41 @@
package de.waypointaudio.data
import android.content.Context
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
/**
* v2.5.9 — Persistiert die vom Nutzer akzeptierte Version der
* Nutzungsbedingungen. Wird beim ersten App-Start abgefragt; eine
* neue Terms-Version (z. B. „2.5.11-terms-1") führt zur erneuten
* Anzeige des Zustimmungsdialogs.
*/
private val Context.termsDataStore by preferencesDataStore(name = "terms_settings")
object TermsVersions {
/**
* Aktuell gültige Terms-Version. Beim Bump dieser Konstante muss
* der In-App-Dialog beim nächsten Start neu zustimmen lassen.
*/
const val CURRENT = "2.5.11-terms-1"
}
class TermsStore(private val context: Context) {
private companion object {
val KEY_ACCEPTED_VERSION = stringPreferencesKey("accepted_terms_version")
}
/** Zuletzt akzeptierte Terms-Version (Leerstring = noch nie akzeptiert). */
val acceptedVersion: Flow<String> = context.termsDataStore.data
.map { it[KEY_ACCEPTED_VERSION] ?: "" }
suspend fun setAccepted(version: String) {
context.termsDataStore.edit { prefs ->
prefs[KEY_ACCEPTED_VERSION] = version
}
}
}
@@ -33,13 +33,27 @@ data class PlaylistItem(
val displayName: String
)
/**
* Ein einzelner Stream-Eintrag (z. B. Internetradio-Sender) in der Stream-Playlist
* einer Tour.
*
* @param url HTTP/HTTPS-Direkt-URL zum Stream.
* @param name Optionaler Anzeigename. Wenn leer, wird die URL/der Host angezeigt.
*/
data class StreamEntry(
val url: String,
val name: String = ""
)
/**
* Begleitmusik-Einstellungen für eine Tour.
*
* @param enabled Ob Begleitmusik für diese Tour aktiv ist.
* @param sourceType Quelle: lokale Playlist oder Stream-URL.
* @param localPlaylist Liste von Audiodateien (content:// URIs) aus dem lokalen Speicher.
* @param streamUrl Direkte HTTP/HTTPS-Stream-URL (z. B. Internetradio).
* @param streamPlaylist Liste von Stream-Einträgen (ab v2.0.8 Stream-Playlist statt einzelner URL).
* @param streamUrl Legacy-Feld für einzelne Stream-URL (Backward-Compat bei Bedarf in
* streamPlaylist migriert; siehe [migrated]).
* @param behavior Verhalten wenn Wegpunkt-Audio abgespielt wird.
* @param fadeDurationMs Dauer des Fade-Effekts in Millisekunden.
* @param duckVolume Lautstärke (0.01.0) während des Duck-Modus.
@@ -51,10 +65,37 @@ data class TourAudioSettings(
val enabled: Boolean = false,
val sourceType: MusicSourceType = MusicSourceType.LOCAL_PLAYLIST,
val localPlaylist: List<PlaylistItem> = emptyList(),
val streamPlaylist: List<StreamEntry> = emptyList(),
val streamUrl: String? = null,
val behavior: WaypointMusicBehavior = WaypointMusicBehavior.PAUSE_RESUME,
val fadeDurationMs: Long = 1500L,
val duckVolume: Float = 0.25f,
val shuffle: Boolean = false,
val autoStartAfterWaypoint: Boolean = false
)
) {
/**
* Liefert die effektive Stream-Playlist wenn keine streamPlaylist gespeichert ist,
* aber das Legacy-streamUrl gesetzt ist, wird daraus ein einzelner Eintrag erzeugt.
*/
val effectiveStreamPlaylist: List<StreamEntry>
get() = when {
streamPlaylist.isNotEmpty() -> streamPlaylist
!streamUrl.isNullOrBlank() -> listOf(StreamEntry(url = streamUrl, name = ""))
else -> emptyList()
}
/**
* Erzeugt eine Kopie mit migrierter Stream-Playlist:
* falls streamPlaylist leer ist und streamUrl gesetzt war, wird streamUrl in
* die Stream-Playlist überführt und das Legacy-Feld geleert.
*/
fun migrated(): TourAudioSettings {
if (streamPlaylist.isEmpty() && !streamUrl.isNullOrBlank()) {
return copy(
streamPlaylist = listOf(StreamEntry(url = streamUrl, name = "")),
streamUrl = null
)
}
return this
}
}
@@ -0,0 +1,68 @@
package de.waypointaudio.data
/**
* v2.5.5 — Persistierbares Tour-Cover.
*
* Statt eines externen Bild-Pickers wählt der Nutzer aus einem festen,
* deterministischen Designkatalog (Icon + Farbverlauf). Das hält den
* Datenpfad stabil, vermeidet Berechtigungs-/Lifecycle-Themen und lässt
* sich verlustfrei via JSON/Backup transportieren.
*
* Persistenz:
* - JSON-Map `tourName → TourCover` im [WaypointStore] unter eigenem Key.
* - Bestehende Backups ohne diesen Key bleiben gültig (Default leer).
* - Backup-Export/Import nehmen die Map mit (siehe BackupExportManager).
*
* UI:
* - Wenn für eine Tour kein Cover gewählt ist, behält die Hero-Karte ihre
* bisherige deterministische Optik (Akzentfarbe + Icon-Heuristik aus dem
* Tournamen). „Cover ändern…“ ersetzt das durch die Auswahl.
*/
enum class TourCoverIcon {
HEADPHONES,
MUSIC_NOTE,
HIKING,
PARK,
CITY,
BIKE,
WAVES,
EXPLORE,
LANDSCAPE,
CAMPAIGN,
NIGHTLIFE,
THEATER_COMEDY,
}
/**
* Auswählbare Farbschemen für das Cover. Werte sind Identifier; die konkrete
* Farbabbildung erfolgt in der UI (siehe TourCoverPalette in WaypointListScreen),
* damit Theme-Anpassungen ohne Daten-Migration möglich bleiben.
*/
enum class TourCoverPalette {
TEAL,
OCEAN,
SUNSET,
FOREST,
BERRY,
DUSK,
SAND,
MIDNIGHT,
}
/**
* Vollständiges, persistiertes Cover einer Tour.
*
* Bewusst minimal: nur Icon + Palette. Texte/Beschriftungen bleiben aus dem
* Tournamen abgeleitet. Damit ist die Auswahl klein, robust und schnell.
*/
data class TourCover(
val icon: TourCoverIcon = TourCoverIcon.HEADPHONES,
val palette: TourCoverPalette = TourCoverPalette.TEAL,
/**
* v2.5.7 — Optionaler Pfad zu einem app-internen Cover-Bild. Liegt ein Bild
* vor, dominiert es Hero/Card; sonst wird Icon+Palette gerendert. Wird beim
* Speichern auf eine app-interne Datei kopiert (siehe TourCoverImageStore).
* Null/leer = kein Bild gewählt → Fallback auf Icon+Palette.
*/
val imageUri: String? = null,
)
@@ -34,7 +34,7 @@ class TourMusicStore(private val context: Context) {
val json = prefs[KEY_MUSIC_SETTINGS] ?: "{}"
runCatching {
val raw: Map<String, TourAudioSettingsJson>? = gson.fromJson(json, mapType)
raw?.mapValues { (_, v) -> v.toDomain() } ?: emptyMap()
raw?.mapValues { (_, v) -> v.toDomain().migrated() } ?: emptyMap()
}.getOrDefault(emptyMap())
}
@@ -81,10 +81,21 @@ internal data class PlaylistItemJson(
}
}
internal data class StreamEntryJson(
val url: String = "",
val name: String = ""
) {
fun toDomain() = StreamEntry(url = url, name = name)
companion object {
fun fromDomain(entry: StreamEntry) = StreamEntryJson(entry.url, entry.name)
}
}
internal data class TourAudioSettingsJson(
val enabled: Boolean = false,
val sourceType: String = "LOCAL_PLAYLIST",
val localPlaylist: List<PlaylistItemJson> = emptyList(),
val streamPlaylist: List<StreamEntryJson>? = null,
val streamUrl: String? = null,
val behavior: String = "PAUSE_RESUME",
val fadeDurationMs: Long = 1500L,
@@ -97,6 +108,7 @@ internal data class TourAudioSettingsJson(
sourceType = runCatching { MusicSourceType.valueOf(sourceType) }
.getOrDefault(MusicSourceType.LOCAL_PLAYLIST),
localPlaylist = localPlaylist.map { it.toDomain() },
streamPlaylist = streamPlaylist?.map { it.toDomain() } ?: emptyList(),
streamUrl = streamUrl,
behavior = runCatching { WaypointMusicBehavior.valueOf(behavior) }
.getOrDefault(WaypointMusicBehavior.PAUSE_RESUME),
@@ -111,6 +123,7 @@ internal data class TourAudioSettingsJson(
enabled = s.enabled,
sourceType = s.sourceType.name,
localPlaylist = s.localPlaylist.map { PlaylistItemJson.fromDomain(it) },
streamPlaylist = s.streamPlaylist.map { StreamEntryJson.fromDomain(it) },
streamUrl = s.streamUrl,
behavior = s.behavior.name,
fadeDurationMs = s.fadeDurationMs,
@@ -0,0 +1,94 @@
package de.waypointaudio.data
import java.util.UUID
/**
* v2.4.6 — Tourroute (an die Tour gebunden, nicht an einen einzelnen Wegpunkt).
*
* Architekturentscheidung (Nutzerabstimmung):
* - Eine [Tour] kann dauerhaft eine zuvor geplante/berechnete Route besitzen.
* - Wegpunkte bleiben ein wiederverwendbarer Baustein sie tragen keine
* Route. Dadurch lassen sich Wegpunkte später in andere Touren kopieren,
* ohne dass dort eine fremde Route mitgezogen wird.
* - Die [polyline] ist die führende Geometrie. Clipabschnitte und
* Längenlinien projizieren sich darauf — siehe Priorität in
* [de.waypointaudio.ui.ExplorerScreenMapLibre].
*
* Persistenz:
* - Gespeichert im [WaypointStore] als JSON-Map `tourName → TourRoute`.
* - Bestehende Backups/JSON ohne dieses Feld bleiben gültig (Default leer).
* - Backup-Export/Import nehmen Tourrouten mit (siehe BackupExportManager).
*/
enum class RouteSourceKind {
/** Route stammt aus einer OSRM-Berechnung. */
OSRM,
/** Reine Planlinie (Luftlinie zwischen den Stops). */
PLANLINE,
/** Aus einem GPS-Track / Draft übernommen (zukünftig). */
TRACK,
/** Aus einem importierten Backup übernommen. */
IMPORTED,
/**
* v2.5.3 — Manuell auf der Karte gezeichnete Route ("Route zeichnen").
* Die Polyline entsteht direkt durch Antippen der Karte, ohne OSRM-Aufruf.
* Inhaltlich gleich zu OSRM/PLANLINE (führende Geometrie ist [TourRoute.polyline]),
* aber als getrennte Quelle markiert, damit die UI klar zwischen
* "berechnet" und "gezeichnet" unterscheiden kann.
*/
MANUAL,
}
/**
* Ein Wegpunkt der Route — Start, Via oder Ziel — mit Label und Quellhinweis.
* Bewusst eigene Struktur (nicht [Waypoint]), damit Tourrouten unabhängig
* von der globalen Wegpunktliste persistieren.
*/
data class TourRouteStop(
val latitude: Double,
val longitude: Double,
val label: String = "",
val sourceHint: String = "",
)
/** Ein Lat/Lon-Punkt der Geometrie. */
data class TourRoutePoint(
val lat: Double,
val lon: Double,
)
/**
* Vollständige Tourroute.
*
* @param id Stabile ID (UUID).
* @param start Startpunkt (optional, kann bei reinen Geometrie-Importen fehlen).
* @param destination Zielpunkt (optional, siehe oben).
* @param via Geordnete Liste der Via-Stopps.
* @param polyline Routengeometrie als Lat/Lon-Punkte (führende Quelle).
* @param profile Routingprofil (z. B. "driving"). Leer = unspezifiziert.
* @param source Quellkategorie (OSRM/Planline/Track/Imported).
* @param endpoint Routing-Endpunkt oder Quellbeschreibung (Backend-URL, „Planlinie", …).
* @param distanceMeters Distanz in Metern (null wenn unbekannt, z. B. Planline).
* @param durationSeconds Dauer in Sekunden (null wenn unbekannt).
* @param createdAtMillis Anlegezeitpunkt (Unix ms).
* @param updatedAtMillis Letzte Aktualisierung (Unix ms).
*/
data class TourRoute(
val id: String = UUID.randomUUID().toString(),
val start: TourRouteStop? = null,
val destination: TourRouteStop? = null,
val via: List<TourRouteStop> = emptyList(),
val polyline: List<TourRoutePoint> = emptyList(),
val profile: String = "driving",
val source: RouteSourceKind = RouteSourceKind.OSRM,
val endpoint: String = "",
val distanceMeters: Double? = null,
val durationSeconds: Double? = null,
val createdAtMillis: Long = System.currentTimeMillis(),
val updatedAtMillis: Long = System.currentTimeMillis(),
) {
/** Anzahl gespeicherter Geometriepunkte. Nützlich für Status/UI. */
val pointCount: Int get() = polyline.size
/** True wenn die Geometrie als Linie nutzbar ist. */
val hasGeometry: Boolean get() = polyline.size >= 2
}
@@ -0,0 +1,38 @@
package de.waypointaudio.data
import java.util.UUID
/**
* Ein eigenständig archivierter Track-Entwurf (Draft) — entsteht durch
* eine GPS-Aufzeichnung und kann später unabhängig weiterer Aufzeichnungen
* editiert und in eine Tour als Wegpunkte importiert werden.
*
* Wichtig: Jeder Draft hat eine eigene UUID. Neue Aufzeichnungen erzeugen
* NEUE Drafts und überschreiben keine vorhandenen.
*
* @param id Eindeutige ID
* @param name Anzeigename (vorgegeben aus Tour + Datum, vom Nutzer änderbar)
* @param sourceTourName Name der Tour, in der die Aufzeichnung lief
* @param createdAt Unix-Millisekunden des Anlegens
* @param updatedAt Unix-Millisekunden der letzten Bearbeitung
* @param points Geordnete Liste der Trackpunkte (vollständig)
* @param markedIndexes Indizes (in [points]) der explizit als künftiger
* Wegpunkt markierten Punkte. Leer = nichts markiert.
* @param notes Optionale Notiz des Nutzers
*/
data class TrackDraft(
val id: String = UUID.randomUUID().toString(),
val name: String = "",
val sourceTourName: String = Waypoint.DEFAULT_TOUR_NAME,
val createdAt: Long = System.currentTimeMillis(),
val updatedAt: Long = System.currentTimeMillis(),
val points: List<GpsTrackPoint> = emptyList(),
val markedIndexes: List<Int> = emptyList(),
val notes: String = ""
) {
/** Punktanzahl. */
val pointCount: Int get() = points.size
/** Anzahl markierter (für Import vorgesehener) Punkte. */
val markedCount: Int get() = markedIndexes.size
}
@@ -0,0 +1,32 @@
package de.waypointaudio.data
import android.content.Context
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
/**
* Speichert den letzten versionCode, für den der Versionshinweis-Dialog
* vom Nutzer ausgeblendet wurde. Steigt der versionCode, erscheint der
* Dialog automatisch erneut auch wenn er zuvor deaktiviert wurde.
*/
private val Context.versionNoticeDataStore by preferencesDataStore(name = "version_notice_settings")
class VersionNoticeStore(private val context: Context) {
private companion object {
val KEY_DISMISSED_VERSION_CODE = longPreferencesKey("dismissed_version_code")
}
/** Letzter ausgeblendeter versionCode, oder 0 falls noch nie ausgeblendet. */
val dismissedVersionCode: Flow<Long> = context.versionNoticeDataStore.data
.map { it[KEY_DISMISSED_VERSION_CODE] ?: 0L }
suspend fun setDismissedVersionCode(code: Long) {
context.versionNoticeDataStore.edit { prefs ->
prefs[KEY_DISMISSED_VERSION_CODE] = code
}
}
}
@@ -0,0 +1,64 @@
package de.waypointaudio.data
import android.content.Context
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
/**
* Modus, wann das Display aktiv gehalten werden soll.
*/
enum class WakeLockMode(val storageKey: String, val germanLabel: String) {
NEVER("never", "Nie"),
APP_FOREGROUND("foreground", "Immer wenn App im Vordergrund"),
ACTIVE_TOUR("active_tour", "Nur bei aktiver Tour/GPS");
companion object {
fun fromKey(key: String?): WakeLockMode = when (key) {
APP_FOREGROUND.storageKey -> APP_FOREGROUND
ACTIVE_TOUR.storageKey -> ACTIVE_TOUR
else -> NEVER
}
}
}
data class WakeLockSettings(
val mode: WakeLockMode = WakeLockMode.NEVER,
/** True, sobald der Akku-Hinweis dem Nutzer einmal gezeigt wurde. */
val batteryHintShown: Boolean = false
)
private val Context.wakeLockDataStore by preferencesDataStore(name = "wake_lock_settings")
class WakeLockSettingsStore(private val context: Context) {
private companion object {
val KEY_MODE = stringPreferencesKey("wake_lock_mode")
val KEY_BATTERY_HINT_SHOWN = booleanPreferencesKey("battery_hint_shown")
}
val settings: Flow<WakeLockSettings> = context.wakeLockDataStore.data.map { prefs ->
WakeLockSettings(
mode = WakeLockMode.fromKey(prefs[KEY_MODE]),
batteryHintShown = prefs[KEY_BATTERY_HINT_SHOWN] == true
)
}
suspend fun currentSettings(): WakeLockSettings = settings.first()
suspend fun setMode(mode: WakeLockMode) {
context.wakeLockDataStore.edit { prefs ->
prefs[KEY_MODE] = mode.storageKey
}
}
suspend fun markBatteryHintShown() {
context.wakeLockDataStore.edit { prefs ->
prefs[KEY_BATTERY_HINT_SHOWN] = true
}
}
}
@@ -23,11 +23,15 @@ enum class PlaybackMode {
* @param latitude Breitengrad in Dezimalgrad
* @param longitude Längengrad in Dezimalgrad
* @param radiusMeters Erkennungsradius in Metern
* @param soundUri URI zur Audiodatei (content:// oder file://)
* @param soundName Anzeigename der Audiodatei
* @param soundUri Legacy-Single-Clip-URI (v2.5.11 und älter). In v2.5.12
* ist [audioClips] die führende Repräsentation; das
* Feld bleibt zur Rückwärtskompatibilität gefüllt
* (typischerweise = primary-Clip-URI), sodass alte
* Lese-Pfade unverändert funktionieren.
* @param soundName Legacy-Dateiname (v2.5.11 und älter). Wie [soundUri]
* aus Kompatibilitätsgründen weiter gepflegt.
* @param isActive Ob der Wegpunkt aktiv überwacht wird
* @param tourName Name der Tour/Route (Standard: "Standard"). Rückwärtskompatibel:
* bestehende Wegpunkte ohne dieses Feld erhalten automatisch "Standard".
* @param tourName Name der Tour/Route (Standard: "Standard").
*
* --- Abspielregeln (optional, Standardwerte = bisheriges Verhalten) ---
*
@@ -39,6 +43,14 @@ enum class PlaybackMode {
* @param scheduleEndMillis Spätestes Datum/Uhrzeit (Unix-Millisekunden), bis zu dem Abspielen erlaubt ist
* @param allowedStartMinutes Tagesminute (01439), ab der das tägliche Zeitfenster beginnt
* @param allowedEndMinutes Tagesminute (01439), bis zu der das tägliche Zeitfenster endet
*
* --- v2.5.12 Multi-Clip ---
*
* @param audioClips Liste der Audio-Clips dieses Wegpunkts. Leer =
* Migration aus Legacy-Feldern [soundUri]/[soundName]
* via [effectiveClips]. Aktive Clips werden in
* Reihenfolge ([AudioClip.orderIndex]) abgespielt;
* inaktive werden übersprungen.
*/
data class Waypoint(
val id: String = UUID.randomUUID().toString(),
@@ -61,10 +73,118 @@ data class Waypoint(
val scheduleStartMillis: Long? = null,
val scheduleEndMillis: Long? = null,
val allowedStartMinutes: Int? = null,
val allowedEndMinutes: Int? = null
val allowedEndMinutes: Int? = null,
// v2.5.12 — Multi-Clip-Erweiterung. Default = leer, damit alte JSONs ohne
// diesen Key weiterhin laden (Gson füllt den Default ein).
val audioClips: List<AudioClip> = emptyList()
) {
companion object {
/** Standard-Tourname für bestehende Wegpunkte ohne tourName-Feld. */
const val DEFAULT_TOUR_NAME = "Standard"
}
}
/**
* v2.5.12 — Liefert die *effektiv anzuwendende* Cliplist für diesen Wegpunkt.
*
* Verhalten:
* - Ist [Waypoint.audioClips] nicht leer, wird diese Liste in stabiler
* Sortierung (orderIndex, dann id) zurückgegeben.
* - Andernfalls wird sofern [Waypoint.soundUri] vorhanden ist ein
* synthetischer primary-Clip aus [Waypoint.soundUri]/[Waypoint.soundName]
* erzeugt. Damit funktionieren alle alten Wegpunkte ohne Migration
* transparent weiter.
* - Hat ein Wegpunkt weder [Waypoint.audioClips] noch [Waypoint.soundUri],
* ist die Rückgabe leer.
*
* Die ID des synthetischen Legacy-Clips wird deterministisch aus der Waypoint-
* ID abgeleitet, damit sich Skip-/UI-Zustände über mehrere Aufrufe hinweg
* stabil referenzieren lassen.
*/
fun Waypoint.effectiveClips(): List<AudioClip> {
if (audioClips.isNotEmpty()) {
return audioClips.sortedWith(
compareBy({ it.orderIndex }, { it.id })
)
}
if (soundUri.isBlank()) return emptyList()
return listOf(
AudioClip(
id = "legacy-$id",
title = soundName,
uri = soundUri,
displayFileName = soundName,
isActive = true,
isPrimary = true,
orderIndex = 0
)
)
}
/**
* Alle aktiven, abspielbaren Clips (für Tour-Play). Reihenfolge wie
* [effectiveClips]; ohne deaktivierte und ohne leere URIs.
*/
fun Waypoint.activeClips(): List<AudioClip> =
effectiveClips().filter { it.isActive && it.hasAudio }
/**
* Primärer Clip (Vorschau, Listenansicht, Legacy-Fallback).
*
* Priorität:
* 1. Erster Clip in [effectiveClips] mit isPrimary = true (sofern abspielbar).
* 2. Erster *aktiver* Clip mit URI.
* 3. Erster Clip mit URI überhaupt.
* 4. null, falls kein Audio existiert.
*/
fun Waypoint.primaryClip(): AudioClip? {
val clips = effectiveClips()
return clips.firstOrNull { it.isPrimary && it.hasAudio }
?: clips.firstOrNull { it.isActive && it.hasAudio }
?: clips.firstOrNull { it.hasAudio }
}
/**
* URI des primären Clips, oder Legacy-[Waypoint.soundUri] als Fallback.
* Praktisch für alle Stellen, die früher direkt `waypoint.soundUri` lasen.
*/
fun Waypoint.primarySoundUri(): String =
primaryClip()?.uri?.ifBlank { soundUri } ?: soundUri
/**
* Anzeigename des primären Clips, oder Legacy-[Waypoint.soundName].
*/
fun Waypoint.primarySoundName(): String =
primaryClip()?.effectiveTitle?.ifBlank { soundName } ?: soundName
/**
* v2.5.12 — Hilfsfunktion zum Setzen der Clipliste mit konsistenten
* Legacy-Spiegel-Feldern. Die Felder [Waypoint.soundUri] und
* [Waypoint.soundName] werden auf den primären Clip gesetzt, sodass
* - alte Lese-Pfade weiter den Hauptclip sehen,
* - JSON-Exports (legacy-Format) ein sinnvolles soundUri-Feld enthalten,
* - der Tour-Play (multi-clip-aware) trotzdem alle Clips kennt.
*
* orderIndex wird beim Speichern auf die Listen-Position normalisiert,
* damit die Reihenfolge stabil aus der UI übernommen werden kann.
*/
fun Waypoint.withClips(newClips: List<AudioClip>): Waypoint {
if (newClips.isEmpty()) {
return copy(audioClips = emptyList())
}
val normalized = newClips.mapIndexed { idx, c -> c.copy(orderIndex = idx) }
val primary = normalized.firstOrNull { it.isPrimary && it.hasAudio }
?: normalized.firstOrNull { it.isActive && it.hasAudio }
?: normalized.firstOrNull { it.hasAudio }
?: normalized.first()
// Genau einen Clip als primary markieren (idempotent)
val finalized = normalized.map { c ->
c.copy(isPrimary = (c.id == primary.id))
}
return copy(
audioClips = finalized,
soundUri = primary.uri,
soundName = primary.effectiveTitle
)
}
@@ -0,0 +1,57 @@
package de.waypointaudio.data
import android.content.Context
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
/**
* v2.5.4 — Sichtbarkeitsoptionen für die optionalen Tour-Werkzeuge der
* klassischen Tour-/Waypoint-Ansicht ([WaypointListScreen]). Atmo, PTT
* und Tour-Zähler lassen sich unabhängig voneinander ein- und ausblenden
* ("Ansicht anpassen"), damit die Tour-/Wegpunkt-Inhalte visuell im
* Vordergrund stehen.
*
* Default: alle drei Module sind sichtbar, damit bestehende Nutzer ihre
* gewohnten Bedienelemente nicht verlieren. Persistenz erfolgt über einen
* eigenen DataStore unabhängig von der bereits existierenden
* [HomeModulesSettings]-Logik der Startseite.
*/
data class WaypointListViewSettings(
val showPtt: Boolean = true,
val showAtmo: Boolean = true,
val showTourCounter: Boolean = true
)
private val Context.waypointListViewDataStore by preferencesDataStore(name = "waypoint_list_view_settings")
class WaypointListViewSettingsStore(private val context: Context) {
private companion object {
val KEY_SHOW_PTT = booleanPreferencesKey("wl_show_ptt")
val KEY_SHOW_ATMO = booleanPreferencesKey("wl_show_atmo")
val KEY_SHOW_TOUR_COUNTER = booleanPreferencesKey("wl_show_tour_counter")
}
val settings: Flow<WaypointListViewSettings> = context.waypointListViewDataStore.data.map { prefs ->
WaypointListViewSettings(
showPtt = prefs[KEY_SHOW_PTT] ?: true,
showAtmo = prefs[KEY_SHOW_ATMO] ?: true,
showTourCounter = prefs[KEY_SHOW_TOUR_COUNTER] ?: true
)
}
suspend fun setShowPtt(value: Boolean) {
context.waypointListViewDataStore.edit { it[KEY_SHOW_PTT] = value }
}
suspend fun setShowAtmo(value: Boolean) {
context.waypointListViewDataStore.edit { it[KEY_SHOW_ATMO] = value }
}
suspend fun setShowTourCounter(value: Boolean) {
context.waypointListViewDataStore.edit { it[KEY_SHOW_TOUR_COUNTER] = value }
}
}
@@ -38,8 +38,21 @@ class WaypointStore(private val context: Context) {
private val KEY_TOURS = stringPreferencesKey("tours_json")
private val KEY_TOUR_DEFAULTS = stringPreferencesKey("tour_playback_defaults_json")
private val KEY_GPS_TRACKS = stringPreferencesKey("gps_tracks_json")
private val KEY_TRACK_DRAFTS = stringPreferencesKey("track_drafts_json")
// v2.4.6 — persistente Tourrouten (tourName → TourRoute, JSON).
// Eigene Schlüssel-Datei: bestehende Backups/JSON ohne diesen Key
// bleiben gültig (Default = leere Map).
private val KEY_TOUR_ROUTES = stringPreferencesKey("tour_routes_json")
// v2.5.5 — Persistente Tour-Cover (tourName → TourCover, JSON).
// Optional pro Tour; fehlt der Eintrag, bleibt die deterministische
// Standard-Optik (Akzentfarbe + Icon-Heuristik aus Tournamen) erhalten.
private val KEY_TOUR_COVERS = stringPreferencesKey("tour_covers_json")
}
private val draftListType = object : TypeToken<List<TrackDraft>>() {}.type
private val tourRoutesMapType = object : TypeToken<Map<String, TourRoute>>() {}.type
private val tourCoversMapType = object : TypeToken<Map<String, TourCover>>() {}.type
/** Liefert einen Flow mit der aktuellen Wegpunktliste. */
val waypointsFlow: Flow<List<Waypoint>> = context.dataStore.data.map { prefs ->
val json = prefs[KEY_WAYPOINTS] ?: "[]"
@@ -72,6 +85,33 @@ class WaypointStore(private val context: Context) {
}.getOrDefault(emptyMap())
}
/**
* v2.4.6 — Persistente Tourrouten als Map (tourName → TourRoute).
* Rückwärtskompatibel: fehlt der Key komplett, ist die Map leer.
* Auch korrupte JSON werden defensiv auf leer abgefangen, damit eine
* einzelne kaputte Route nicht den Rest der App blockiert.
*/
val tourRoutesFlow: Flow<Map<String, TourRoute>> =
context.dataStore.data.map { prefs ->
val json = prefs[KEY_TOUR_ROUTES] ?: "{}"
runCatching {
gson.fromJson<Map<String, TourRoute>>(json, tourRoutesMapType) ?: emptyMap()
}.getOrDefault(emptyMap())
}
/**
* v2.5.5 — Persistente Tour-Cover als Map (tourName → TourCover).
* Rückwärtskompatibel: fehlt der Key, ist die Map leer und Touren
* bekommen ihre bisherige deterministische Hero-Optik.
*/
val tourCoversFlow: Flow<Map<String, TourCover>> =
context.dataStore.data.map { prefs ->
val json = prefs[KEY_TOUR_COVERS] ?: "{}"
runCatching {
gson.fromJson<Map<String, TourCover>>(json, tourCoversMapType) ?: emptyMap()
}.getOrDefault(emptyMap())
}
/** GPS-Track-Flow: Map tour → Liste von TrackPoint. */
val gpsTracksFlow: Flow<Map<String, List<GpsTrackPoint>>> =
context.dataStore.data.map { prefs ->
@@ -82,6 +122,59 @@ class WaypointStore(private val context: Context) {
}.getOrDefault(emptyMap())
}
/**
* Reaktiver Flow der archivierten Track-Drafts.
* Drafts sind eigenständige, persistierte Aufzeichnungen — neue
* Aufzeichnungen werden als zusätzliche Einträge gespeichert,
* niemals durch Überschreiben.
*/
val trackDraftsFlow: Flow<List<TrackDraft>> = context.dataStore.data.map { prefs ->
val json = prefs[KEY_TRACK_DRAFTS] ?: "[]"
runCatching {
gson.fromJson<List<TrackDraft>>(json, draftListType) ?: emptyList()
}.getOrDefault(emptyList())
}
/** Fügt einen neuen Track-Draft hinzu (immer als neuen Eintrag). */
suspend fun addTrackDraft(draft: TrackDraft) {
context.dataStore.edit { prefs ->
val json = prefs[KEY_TRACK_DRAFTS] ?: "[]"
val list: MutableList<TrackDraft> = runCatching {
gson.fromJson<List<TrackDraft>>(json, draftListType)?.toMutableList()
}.getOrNull() ?: mutableListOf()
list.add(draft)
prefs[KEY_TRACK_DRAFTS] = gson.toJson(list)
}
}
/**
* Aktualisiert einen vorhandenen Draft anhand seiner ID.
* Falls noch nicht vorhanden, wird er hinzugefügt (defensiv).
*/
suspend fun upsertTrackDraft(draft: TrackDraft) {
context.dataStore.edit { prefs ->
val json = prefs[KEY_TRACK_DRAFTS] ?: "[]"
val list: MutableList<TrackDraft> = runCatching {
gson.fromJson<List<TrackDraft>>(json, draftListType)?.toMutableList()
}.getOrNull() ?: mutableListOf()
val idx = list.indexOfFirst { it.id == draft.id }
if (idx >= 0) list[idx] = draft else list.add(draft)
prefs[KEY_TRACK_DRAFTS] = gson.toJson(list)
}
}
/** Löscht einen Track-Draft anhand seiner ID. */
suspend fun deleteTrackDraft(id: String) {
context.dataStore.edit { prefs ->
val json = prefs[KEY_TRACK_DRAFTS] ?: "[]"
val list: MutableList<TrackDraft> = runCatching {
gson.fromJson<List<TrackDraft>>(json, draftListType)?.toMutableList()
}.getOrNull() ?: mutableListOf()
list.removeAll { it.id == id }
prefs[KEY_TRACK_DRAFTS] = gson.toJson(list)
}
}
/** Speichert die gesamte Wegpunktliste. */
suspend fun saveAll(waypoints: List<Waypoint>) {
context.dataStore.edit { prefs ->
@@ -131,6 +224,125 @@ class WaypointStore(private val context: Context) {
}
}
/**
* v2.4.6 — Speichert (oder ersetzt) die gespeicherte Route einer Tour.
* Setzt [TourRoute.updatedAtMillis] auf die aktuelle Zeit, sofern das
* übergebene Objekt nicht selbst einen neueren Zeitstempel mitbringt.
*/
suspend fun saveTourRoute(tourName: String, route: TourRoute) {
context.dataStore.edit { prefs ->
val json = prefs[KEY_TOUR_ROUTES] ?: "{}"
val map: MutableMap<String, TourRoute> = runCatching {
gson.fromJson<Map<String, TourRoute>>(json, tourRoutesMapType)?.toMutableMap()
}.getOrNull() ?: mutableMapOf()
val stamped = route.copy(
updatedAtMillis = if (route.updatedAtMillis > 0) route.updatedAtMillis
else System.currentTimeMillis()
)
map[tourName] = stamped
prefs[KEY_TOUR_ROUTES] = gson.toJson(map)
}
}
/** Entfernt die gespeicherte Route einer Tour (falls vorhanden). */
suspend fun deleteTourRoute(tourName: String) {
context.dataStore.edit { prefs ->
val json = prefs[KEY_TOUR_ROUTES] ?: "{}"
val map: MutableMap<String, TourRoute> = runCatching {
gson.fromJson<Map<String, TourRoute>>(json, tourRoutesMapType)?.toMutableMap()
}.getOrNull() ?: mutableMapOf()
if (map.remove(tourName) != null) {
prefs[KEY_TOUR_ROUTES] = gson.toJson(map)
}
}
}
/**
* Benennt die Tour einer gespeicherten Route um. Wird aus
* [WaypointViewModel.renameTour]/[deleteTour] aufgerufen, damit die
* Route nicht verwaist zurückbleibt.
*/
suspend fun renameTourRouteKey(oldTour: String, newTour: String) {
if (oldTour == newTour) return
context.dataStore.edit { prefs ->
val json = prefs[KEY_TOUR_ROUTES] ?: "{}"
val map: MutableMap<String, TourRoute> = runCatching {
gson.fromJson<Map<String, TourRoute>>(json, tourRoutesMapType)?.toMutableMap()
}.getOrNull() ?: mutableMapOf()
val existing = map.remove(oldTour) ?: return@edit
map[newTour] = existing.copy(updatedAtMillis = System.currentTimeMillis())
prefs[KEY_TOUR_ROUTES] = gson.toJson(map)
}
}
/** Bulk-Schreiben der Routen (für Backup-Import). Vorhandene Keys werden überschrieben. */
suspend fun mergeTourRoutes(routes: Map<String, TourRoute>) {
if (routes.isEmpty()) return
context.dataStore.edit { prefs ->
val json = prefs[KEY_TOUR_ROUTES] ?: "{}"
val map: MutableMap<String, TourRoute> = runCatching {
gson.fromJson<Map<String, TourRoute>>(json, tourRoutesMapType)?.toMutableMap()
}.getOrNull() ?: mutableMapOf()
map.putAll(routes)
prefs[KEY_TOUR_ROUTES] = gson.toJson(map)
}
}
/** v2.5.5 — Speichert das Cover einer Tour (überschreibt vorhandenes). */
suspend fun saveTourCover(tourName: String, cover: TourCover) {
context.dataStore.edit { prefs ->
val json = prefs[KEY_TOUR_COVERS] ?: "{}"
val map: MutableMap<String, TourCover> = runCatching {
gson.fromJson<Map<String, TourCover>>(json, tourCoversMapType)?.toMutableMap()
}.getOrNull() ?: mutableMapOf()
map[tourName] = cover
prefs[KEY_TOUR_COVERS] = gson.toJson(map)
}
}
/** v2.5.5 — Entfernt das Cover einer Tour (falls vorhanden). */
suspend fun deleteTourCover(tourName: String) {
context.dataStore.edit { prefs ->
val json = prefs[KEY_TOUR_COVERS] ?: "{}"
val map: MutableMap<String, TourCover> = runCatching {
gson.fromJson<Map<String, TourCover>>(json, tourCoversMapType)?.toMutableMap()
}.getOrNull() ?: mutableMapOf()
if (map.remove(tourName) != null) {
prefs[KEY_TOUR_COVERS] = gson.toJson(map)
}
}
}
/**
* v2.5.5 — Tour-Umbenennung: Cover-Eintrag mitziehen, damit er nicht
* unter einem verwaisten Tournamen liegen bleibt.
*/
suspend fun renameTourCoverKey(oldTour: String, newTour: String) {
if (oldTour == newTour) return
context.dataStore.edit { prefs ->
val json = prefs[KEY_TOUR_COVERS] ?: "{}"
val map: MutableMap<String, TourCover> = runCatching {
gson.fromJson<Map<String, TourCover>>(json, tourCoversMapType)?.toMutableMap()
}.getOrNull() ?: mutableMapOf()
val existing = map.remove(oldTour) ?: return@edit
map[newTour] = existing
prefs[KEY_TOUR_COVERS] = gson.toJson(map)
}
}
/** v2.5.5 — Bulk-Schreiben der Cover (für Backup-Import). */
suspend fun mergeTourCovers(covers: Map<String, TourCover>) {
if (covers.isEmpty()) return
context.dataStore.edit { prefs ->
val json = prefs[KEY_TOUR_COVERS] ?: "{}"
val map: MutableMap<String, TourCover> = runCatching {
gson.fromJson<Map<String, TourCover>>(json, tourCoversMapType)?.toMutableMap()
}.getOrNull() ?: mutableMapOf()
map.putAll(covers)
prefs[KEY_TOUR_COVERS] = gson.toJson(map)
}
}
/** Löscht den GPS-Track einer Tour. */
suspend fun clearGpsTrack(tourName: String) {
context.dataStore.edit { prefs ->
@@ -0,0 +1,455 @@
package de.waypointaudio.io
import android.content.Context
import android.net.Uri
import android.util.Log
import com.google.gson.GsonBuilder
import de.waypointaudio.data.GpsTrackPoint
import de.waypointaudio.data.TourAudioSettings
import de.waypointaudio.data.TourCover
import de.waypointaudio.data.TourPlaybackDefaults
import de.waypointaudio.data.TourRoute
import de.waypointaudio.data.TrackDraft
import de.waypointaudio.data.Waypoint
import de.waypointaudio.data.effectiveClips
import java.io.BufferedOutputStream
import java.io.OutputStream
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
/**
* Erstellt ein ZIP-Backup mit ausgewählten Datenkategorien.
*
* ZIP-Aufbau:
* manifest.json Zeitstempel, App-Version, ausgewählte Sektionen
* data/tours.json Wegpunkte + Tourliste + Tour-Vorgaben (wenn Touren gewählt)
* data/track_drafts.json GPS-Track-Aufzeichnungen pro Tour (wenn Track-Entwürfe gewählt)
* data/music_settings.json Begleitmusik-Einstellungen pro Tour (wenn Medien/Atmo gewählt)
* data/settings.json einfache App-Einstellungen (wenn Einstellungen gewählt)
* media/... referenzierte Mediendateien (wenn zugänglich)
* media_manifest.json Liste der erfassten / nicht erreichbaren Mediendateien
*
* v2.5.31 — Bugfix: Tour-Cover-Bilder (frei gewählte Bilder) werden jetzt
* zuverlässig ins Backup aufgenommen.
*
* Ursache des Bugs:
* - Die ZIP-Entry-Namen für Cover-Bilder basierten ausschließlich auf dem
* sanitisierten Tournamen ("media/covers/<tourname>.jpg"). Bei zwei Touren
* mit gleichem sanitisierten Namen kollidierten die Einträge, ZipOutputStream
* warf eine Exception, und das Cover wurde stillschweigend als null exportiert.
* - Jetzt werden die ZIP-Pfade mit einem Index-Präfix eindeutig gemacht:
* "media/covers/<idx>_<tourname>.jpg".
* - Zusätzlich: Wenn imageUri ein absoluter Pfad ist und die Datei nicht
* (mehr) existiert, wird als Fallback versucht, die URI via ContentResolver
* zu lesen (für legacy content://-Pfade in zukünftigen Migrations-Pfaden).
*/
object BackupExportManager {
private const val TAG = "BackupExportManager"
private val gson = GsonBuilder().setPrettyPrinting().create()
data class BackupSelection(
val toursAndWaypoints: Boolean = true,
val trackDrafts: Boolean = true,
val mediaAndAtmo: Boolean = true,
val settings: Boolean = true
)
data class BackupInput(
val waypoints: List<Waypoint> = emptyList(),
val tours: List<String> = emptyList(),
val tourDefaults: Map<String, TourPlaybackDefaults> = emptyMap(),
val gpsTracks: Map<String, List<GpsTrackPoint>> = emptyMap(),
val musicSettings: Map<String, TourAudioSettings> = emptyMap(),
// v2.4.6 — Tourrouten gehören zur Tour und werden mit ihr exportiert.
val tourRoutes: Map<String, TourRoute> = emptyMap(),
// v2.5.5 — Tour-Cover (Icon + Palette) sind ein reines UI-Detail pro Tour
// und wandern mit dem Tour-Export mit.
val tourCovers: Map<String, TourCover> = emptyMap(),
// v2.5.48 — Echte TrackDraft-Objekte (Name, Notiz, Markierungen, Punkte).
// gpsTracks bleibt für die Live-Aufzeichnung weiterhin im Backup-Input,
// wird aber NICHT mehr in track_drafts.json geschrieben.
val trackDrafts: List<TrackDraft> = emptyList(),
val appVersionName: String = "",
val appVersionCode: Int = 0
)
data class BackupResult(
val errorMessage: String? = null,
val mediaCopied: Int = 0,
val mediaFailed: Int = 0,
val sectionsWritten: List<String> = emptyList()
)
/**
* Schreibt ein ZIP-Backup an die Ziel-URI. Robust: Fehler einzelner Dateien
* werden im media_manifest erfasst, statt das ganze Backup abzubrechen.
*/
fun writeBackup(
context: Context,
targetUri: Uri,
selection: BackupSelection,
input: BackupInput
): BackupResult {
return runCatching {
val out: OutputStream = context.contentResolver.openOutputStream(targetUri)
?: return BackupResult(errorMessage = "Konnte Datei nicht öffnen: $targetUri")
val mediaCollected = mutableListOf<MediaEntry>()
val mediaMissing = mutableListOf<MediaEntry>()
val sectionsWritten = mutableListOf<String>()
ZipOutputStream(BufferedOutputStream(out)).use { zip ->
// ----- manifest.json -----
val manifest = mapOf(
"format" to "GPS2Audio-Backup",
"formatVersion" to 1,
"createdAt" to SimpleDateFormat(
"yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US
).format(Date()),
"appVersionName" to input.appVersionName,
"appVersionCode" to input.appVersionCode,
"sections" to mapOf(
"toursAndWaypoints" to selection.toursAndWaypoints,
"trackDrafts" to selection.trackDrafts,
"mediaAndAtmo" to selection.mediaAndAtmo,
"settings" to selection.settings
)
)
writeJsonEntry(zip, "manifest.json", manifest)
// ----- data/tours.json -----
if (selection.toursAndWaypoints) {
// v2.5.7 — Tour-Cover-Bilder mitnehmen: die in TourCover hinterlegten
// absoluten Dateipfade verweisen auf app-interne Dateien. Wir packen
// sie unter `media/covers/<idx>_<tour>.jpg` ins ZIP. Im exportierten
// JSON wird der `imageUri` durch den relativen ZIP-Pfad ersetzt,
// damit der Import die Datei auf eine neue app-interne Ablage
// umbiegen kann, ohne auf den ursprünglichen, absoluten Pfad
// angewiesen zu sein.
//
// v2.5.31 FIX: Jeder ZIP-Eintrag bekommt ein Index-Präfix,
// damit Touren mit gleichem sanitisierten Namen nicht kollidieren.
val exportedCovers = mutableMapOf<String, TourCover>()
val coverImagesCopied = mutableListOf<String>()
// Menge bereits verwendeter ZIP-Pfade zur Kollisionsvermeidung.
val usedZipPaths = mutableSetOf<String>()
var coverIdx = 0
input.tourCovers.forEach { (tour, cover) ->
val imgPath = cover.imageUri
if (!imgPath.isNullOrBlank()) {
// v2.5.31: Eindeutiger ZIP-Eintrag durch Index-Präfix,
// verhindert ZipException bei gleichnamigen Touren.
val safeTour = sanitize(tour).ifBlank { "cover" }
var zipPath: String
do {
zipPath = "media/covers/${String.format(Locale.US, "%03d", coverIdx)}_${safeTour}.jpg"
coverIdx++
} while (zipPath in usedZipPaths)
usedZipPaths.add(zipPath)
// Versuche, die Bilddaten zu lesen.
// Pfad 1: absoluter Dateipfad (Normalfall nach saveFromUri).
// Pfad 2: Fallback via ContentResolver, falls imageUri ein
// content://-URI ist (legacy oder Migration).
val imageBytes: ByteArray? = readCoverImageBytes(context, imgPath)
if (imageBytes != null && imageBytes.isNotEmpty()) {
runCatching {
zip.putNextEntry(ZipEntry(zipPath))
zip.write(imageBytes)
zip.closeEntry()
exportedCovers[tour] = cover.copy(imageUri = zipPath)
coverImagesCopied.add(tour)
}.onFailure { e ->
Log.w(TAG, "Cover-ZIP-Eintrag fehlgeschlagen Tour: $tour, Fehler: ${e.localizedMessage}")
// Bild-Kopie fehlgeschlagen Cover ohne Bild exportieren,
// damit Icon+Palette weiterhin gerettet werden.
exportedCovers[tour] = cover.copy(imageUri = null)
}
} else {
// Bild nicht lesbar nur Icon+Palette exportieren.
Log.w(TAG, "Cover-Bild nicht lesbar: Tour=$tour, Pfad=$imgPath")
exportedCovers[tour] = cover.copy(imageUri = null)
}
} else {
exportedCovers[tour] = cover
}
}
val toursPayload = mapOf(
"waypoints" to input.waypoints,
"tours" to input.tours,
"tourDefaults" to input.tourDefaults,
// v2.4.6 — Tourrouten Teil des Tour-Exports.
"tourRoutes" to input.tourRoutes,
// v2.5.5/2.5.7/2.5.31 — Tour-Cover (Icon + Palette + ggf.
// ZIP-relativer Bildpfad) mitnehmen.
"tourCovers" to exportedCovers
)
writeJsonEntry(zip, "data/tours.json", toursPayload)
val routeNote = if (input.tourRoutes.isNotEmpty())
", ${input.tourRoutes.size} Tourrouten" else ""
val coverNote = if (input.tourCovers.isNotEmpty())
", ${input.tourCovers.size} Cover" else ""
val coverImgNote = if (coverImagesCopied.isNotEmpty())
", ${coverImagesCopied.size} Cover-Bilder" else ""
sectionsWritten.add("Touren und Wegpunkte (${input.waypoints.size} Wegpunkte, ${input.tours.size} Touren$routeNote$coverNote$coverImgNote)")
}
// ----- data/track_drafts.json -----
// v2.5.48 FIX: Exportiert die echten TrackDraft-Objekte (Name, Notiz,
// Markierungen, Punkte) statt der gpsTracks-Map. Altes Format
// ("tracks": { tourName: [GpsTrackPoint] }) wird beim Import
// weiterhin toleriert (Abwärtskompatibilität).
if (selection.trackDrafts) {
val trackPayload = mapOf(
"drafts" to input.trackDrafts
)
writeJsonEntry(zip, "data/track_drafts.json", trackPayload)
val pointCount = input.trackDrafts.sumOf { it.points.size }
sectionsWritten.add("Track-Entwürfe (${input.trackDrafts.size} Entwürfe, $pointCount Punkte)")
}
// ----- data/music_settings.json + media/... -----
if (selection.mediaAndAtmo) {
val musicPayload = mapOf(
"musicSettings" to input.musicSettings.mapValues { (_, s) ->
mapOf(
"enabled" to s.enabled,
"sourceType" to s.sourceType.name,
"localPlaylist" to s.localPlaylist.map { p ->
mapOf(
"uriString" to p.uriString,
"displayName" to p.displayName
)
},
"streamPlaylist" to s.effectiveStreamPlaylist.map { e ->
mapOf(
"url" to e.url,
"name" to e.name
)
},
"streamUrl" to s.streamUrl,
"behavior" to s.behavior.name,
"fadeDurationMs" to s.fadeDurationMs,
"duckVolume" to s.duckVolume,
"shuffle" to s.shuffle,
"autoStartAfterWaypoint" to s.autoStartAfterWaypoint
)
}
)
writeJsonEntry(zip, "data/music_settings.json", musicPayload)
sectionsWritten.add("Medien/Atmo (${input.musicSettings.size} Tour-Konfigurationen)")
// Mediendateien einsammeln: alle Atmo-Playlist-URIs + alle Wegpunkt-soundUris
val sources = mutableListOf<MediaSource>()
input.musicSettings.forEach { (tour, s) ->
s.localPlaylist.forEachIndexed { idx, item ->
sources.add(
MediaSource(
uriString = item.uriString,
displayName = item.displayName,
category = "atmo",
relativeFolder = "media/atmo/${sanitize(tour)}",
fallbackName = "track_${idx + 1}"
)
)
}
}
if (selection.toursAndWaypoints) {
// v2.5.12 — Alle Clips eines Wegpunkts exportieren.
// Legacy-Wegpunkte ohne audioClips werden über
// [effectiveClips] auf einen synthetischen Clip
// abgebildet, sodass der Export beide Welten kennt.
input.waypoints.forEach { wp ->
val tourFolder = sanitize(
wp.tourName.ifBlank { Waypoint.DEFAULT_TOUR_NAME }
)
val clips = wp.effectiveClips()
// Wenn das Multi-Clip-Modell leer ist und auch
// soundUri leer, gibt es einfach nichts zu exportieren.
clips.forEachIndexed { idx, clip ->
if (clip.hasAudio) {
sources.add(
MediaSource(
uriString = clip.uri,
displayName = clip.displayFileName
.ifBlank { clip.effectiveTitle },
category = "waypoint",
relativeFolder =
"media/waypoints/$tourFolder",
fallbackName = "${wp.id}_clip_${idx + 1}"
)
)
}
}
}
}
sources.forEach { src ->
val result = copyMediaToZip(context, src, zip)
if (result.success) {
mediaCollected.add(MediaEntry(src.uriString, src.displayName, src.category, result.zipPath, result.sizeBytes, null))
} else {
mediaMissing.add(MediaEntry(src.uriString, src.displayName, src.category, null, 0L, result.errorMessage))
}
}
}
// ----- data/settings.json -----
if (selection.settings) {
val settingsPayload = mapOf(
"trackMinDistanceMeters" to 5,
"tourDefaults" to input.tourDefaults,
"note" to "Globale Audio-Routing-IDs sind sitzungsspezifisch und werden nicht exportiert."
)
writeJsonEntry(zip, "data/settings.json", settingsPayload)
sectionsWritten.add("Einstellungen")
}
// ----- media_manifest.json -----
val mediaManifest = mapOf(
"collected" to mediaCollected,
"unavailable" to mediaMissing,
"summary" to mapOf(
"collectedCount" to mediaCollected.size,
"unavailableCount" to mediaMissing.size
)
)
writeJsonEntry(zip, "media_manifest.json", mediaManifest)
zip.flush()
}
BackupResult(
errorMessage = null,
mediaCopied = mediaCollected.size,
mediaFailed = mediaMissing.size,
sectionsWritten = sectionsWritten
)
}.getOrElse { e ->
BackupResult(errorMessage = "Backup-Fehler: ${e.localizedMessage ?: e.javaClass.simpleName}")
}
}
/**
* v2.5.31 — Liest die Rohdaten eines Cover-Bildes.
*
* Strategie:
* 1. Wenn [imgPath] ein absoluter Dateipfad ist und die Datei existiert →
* direkt lesen.
* 2. Wenn [imgPath] ein content://-URI ist (legacy oder Migrationspfad) →
* über ContentResolver lesen.
* 3. Wenn die Datei nicht existiert aber der Pfad wie ein absoluter Pfad
* aussieht → Fallback: versuche als file://-URI zu parsen.
*
* Gibt null zurück, wenn keiner der Wege funktioniert.
*/
private fun readCoverImageBytes(context: Context, imgPath: String): ByteArray? {
return runCatching {
// Pfad 1: direkt als Datei lesen (Normalfall)
val file = java.io.File(imgPath)
if (file.exists() && file.canRead()) {
return file.readBytes()
}
// Pfad 2: URI-basierter Zugriff (content:// oder file://)
if (imgPath.startsWith("content://") || imgPath.startsWith("file://")) {
val uri = Uri.parse(imgPath)
return context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
}
// Pfad 3: Absoluter Pfad der nicht direkt existiert — versuche als file:// URI
val fileUri = Uri.fromFile(file)
context.contentResolver.openInputStream(fileUri)?.use { it.readBytes() }
}.onFailure { e ->
Log.w(TAG, "readCoverImageBytes fehlgeschlagen fuer: $imgPath, Fehler: ${e.localizedMessage}")
}.getOrNull()
}
private fun writeJsonEntry(zip: ZipOutputStream, path: String, payload: Any) {
zip.putNextEntry(ZipEntry(path))
zip.write(gson.toJson(payload).toByteArray(Charsets.UTF_8))
zip.closeEntry()
}
private data class MediaSource(
val uriString: String,
val displayName: String,
val category: String,
val relativeFolder: String,
val fallbackName: String
)
private data class MediaEntry(
val sourceUri: String,
val displayName: String,
val category: String,
val zipPath: String?,
val sizeBytes: Long,
val error: String?
)
private data class CopyResult(
val success: Boolean,
val zipPath: String = "",
val sizeBytes: Long = 0L,
val errorMessage: String? = null
)
/**
* Versucht, die Mediendatei in das ZIP zu kopieren. Schlägt der Zugriff fehl,
* wird der Eintrag im media_manifest.json als "unavailable" vermerkt das
* gesamte Backup bricht NICHT ab.
*/
private fun copyMediaToZip(
context: Context,
src: MediaSource,
zip: ZipOutputStream
): CopyResult {
return runCatching {
val uri = Uri.parse(src.uriString)
val fileName = pickFileName(src)
val zipPath = "${src.relativeFolder}/$fileName"
val input = context.contentResolver.openInputStream(uri)
?: return CopyResult(false, errorMessage = "InputStream nicht verfügbar")
input.use { ins ->
zip.putNextEntry(ZipEntry(zipPath))
val buf = ByteArray(64 * 1024)
var total = 0L
while (true) {
val n = ins.read(buf)
if (n <= 0) break
zip.write(buf, 0, n)
total += n
}
zip.closeEntry()
CopyResult(success = true, zipPath = zipPath, sizeBytes = total)
}
}.getOrElse { e ->
// closeEntry darf nicht doppelt aufgerufen werden ZipOutputStream ist
// robust genug, aber hier einfach Fehler zurückgeben.
CopyResult(false, errorMessage = e.localizedMessage ?: e.javaClass.simpleName)
}
}
private fun pickFileName(src: MediaSource): String {
val candidate = src.displayName.ifBlank { src.fallbackName }
val safe = sanitize(candidate)
return if (safe.isBlank()) "media_${System.nanoTime()}" else safe
}
private fun sanitize(s: String): String {
// Ersetze Pfad- und Steuerzeichen durch "_". Mehrfach-Unterstriche normalisieren.
return s.replace(Regex("[\\\\/:*?\"<>|\\u0000-\\u001F]"), "_")
.replace(Regex("_+"), "_")
.trim('_', ' ', '.')
.take(120)
}
}
@@ -0,0 +1,822 @@
package de.waypointaudio.io
import android.content.Context
import android.net.Uri
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import com.google.gson.JsonObject
import com.google.gson.JsonParser
import com.google.gson.reflect.TypeToken
import de.waypointaudio.data.GpsTrackPoint
import de.waypointaudio.data.MusicSourceType
import de.waypointaudio.data.PlaylistItem
import de.waypointaudio.data.StreamEntry
import de.waypointaudio.data.TourAudioSettings
import de.waypointaudio.data.TourPlaybackDefaults
import de.waypointaudio.data.TourCover
import de.waypointaudio.data.TourRoute
import de.waypointaudio.data.TrackDraft
import de.waypointaudio.data.Waypoint
import de.waypointaudio.data.WaypointMusicBehavior
import java.io.File
import java.io.FileOutputStream
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.UUID
import java.util.zip.ZipEntry
import java.util.zip.ZipInputStream
/**
* Liest und importiert ein vom [BackupExportManager] erzeugtes ZIP-Backup.
*
* Strategie: Merge-only. Bestehende Daten werden NICHT überschrieben.
* - Touren mit gleichem Namen werden beim Import umbenannt mit Suffix " (Import)"
* bzw. " (Import 2)", … bis ein freier Name gefunden ist.
* - Wegpunkte aus solchen Touren werden mitgewandert (tourName aktualisiert).
* - Wegpunkt-IDs werden bei Kollision durch neue UUIDs ersetzt.
* - Track-Entwürfe werden zur jeweiligen (ggf. umbenannten) Tour gespeichert.
* - Begleitmusik-Einstellungen werden nur für Touren importiert, die noch keine
* Konfiguration haben (per Tour). Für umbenannte Touren wird die importierte
* Konfiguration unter dem neuen Namen abgelegt.
* - Medien aus `media/atmo/...` und `media/waypoints/...` werden in den
* App-internen `files/`-Ordner extrahiert (als `file://`-URIs adressierbar).
* Nach dem Entpacken werden die importierten Atmo-Playlist- und
* Wegpunkt-`soundUri`-Verweise nach Möglichkeit auf die extrahierten Dateien
* umgeschrieben. Verbleibende `content://`-URIs bleiben als Best-Effort
* erhalten Hinweistext in [ImportResult.warnings].
*/
object BackupImportManager {
private val gson: Gson = GsonBuilder().create()
data class ImportSelection(
val toursAndWaypoints: Boolean = true,
val trackDrafts: Boolean = true,
val mediaAndAtmo: Boolean = true,
val settings: Boolean = true
)
/**
* Liste der vom Manifest deklarierten Sektionen UND die im ZIP tatsächlich
* vorhandenen Sektionen (für UI-Vorauswahl).
*/
data class ManifestInfo(
val format: String?,
val formatVersion: Int?,
val createdAt: String?,
val appVersionName: String?,
val appVersionCode: Int?,
val sectionsAvailable: SectionAvailability,
val errorMessage: String? = null
)
data class SectionAvailability(
val toursAndWaypoints: Boolean = false,
val trackDrafts: Boolean = false,
val mediaAndAtmo: Boolean = false,
val settings: Boolean = false
)
data class ImportResult(
val errorMessage: String? = null,
val toursImported: Int = 0,
val toursRenamed: Int = 0,
val waypointsImported: Int = 0,
val tourDefaultsImported: Int = 0,
val tracksImported: Int = 0,
val musicSettingsImported: Int = 0,
val mediaExtracted: Int = 0,
val mediaSkipped: Int = 0,
val warnings: List<String> = emptyList(),
val newTourSelection: String? = null
)
/** Ergebnis-Daten, das der Aufrufer (ViewModel) in die DataStores schreibt. */
data class ImportPayload(
val mergedWaypoints: List<Waypoint>,
val mergedTours: List<String>,
val tourDefaultsToWrite: Map<String, TourPlaybackDefaults>,
val tracksToWrite: Map<String, List<GpsTrackPoint>>,
val musicToWrite: Map<String, TourAudioSettings>,
// v2.4.6 — Tourrouten, die geschrieben werden sollen (Map endgültiger Tourname → Route).
val tourRoutesToWrite: Map<String, TourRoute> = emptyMap(),
// v2.5.5 — Tour-Cover, die geschrieben werden sollen (Map endgültiger Tourname → Cover).
val tourCoversToWrite: Map<String, TourCover> = emptyMap(),
// v2.5.48 — Echte TrackDraft-Objekte, die als neue Drafts hinzugefügt werden sollen.
val trackDraftsToAdd: List<TrackDraft> = emptyList(),
val trackMinDistanceMeters: Int?,
val newTourSelection: String?,
val warnings: List<String>,
val toursImported: Int,
val toursRenamed: Int,
val waypointsImported: Int,
val tracksImported: Int,
val musicSettingsImported: Int,
val mediaExtracted: Int,
val mediaSkipped: Int,
val tourDefaultsImported: Int,
val tourRoutesImported: Int = 0,
val tourCoversImported: Int = 0
)
/**
* Liest das Manifest und gibt vorhandene Sektionen zurück, ohne weitere
* Daten anzuwenden. Nutzt SAF-`Uri`. Off-UI-Thread aufrufen.
*/
fun readManifest(context: Context, sourceUri: Uri): ManifestInfo {
return runCatching {
val ins = context.contentResolver.openInputStream(sourceUri)
?: return ManifestInfo(null, null, null, null, null,
SectionAvailability(),
errorMessage = "Datei konnte nicht geöffnet werden.")
var manifestJson: JsonObject? = null
val available = mutableMapOf(
"tours" to false,
"drafts" to false,
"music" to false,
"settings" to false
)
ZipInputStream(ins).use { zin ->
while (true) {
val e = zin.nextEntry ?: break
when (e.name) {
"manifest.json" -> {
val bytes = zin.readBytes()
manifestJson = JsonParser.parseString(String(bytes, Charsets.UTF_8))
.asJsonObject
}
"data/tours.json" -> available["tours"] = true
"data/track_drafts.json" -> available["drafts"] = true
"data/music_settings.json" -> available["music"] = true
"data/settings.json" -> available["settings"] = true
}
zin.closeEntry()
}
}
if (manifestJson == null) {
return ManifestInfo(
format = null, formatVersion = null, createdAt = null,
appVersionName = null, appVersionCode = null,
sectionsAvailable = SectionAvailability(),
errorMessage = "Kein gültiges Backup: manifest.json fehlt."
)
}
val mf = manifestJson!!
val format = mf.get("format")?.asString
val formatVersion = mf.get("formatVersion")?.asInt
if (format != "GPS2Audio-Backup") {
return ManifestInfo(
format = format, formatVersion = formatVersion, createdAt = null,
appVersionName = null, appVersionCode = null,
sectionsAvailable = SectionAvailability(),
errorMessage = "Format wird nicht unterstützt: $format"
)
}
ManifestInfo(
format = format,
formatVersion = formatVersion,
createdAt = mf.get("createdAt")?.asString,
appVersionName = mf.get("appVersionName")?.asString,
appVersionCode = mf.get("appVersionCode")?.asInt,
sectionsAvailable = SectionAvailability(
toursAndWaypoints = available["tours"] == true,
trackDrafts = available["drafts"] == true,
mediaAndAtmo = available["music"] == true,
settings = available["settings"] == true
)
)
}.getOrElse { e ->
ManifestInfo(
format = null, formatVersion = null, createdAt = null,
appVersionName = null, appVersionCode = null,
sectionsAvailable = SectionAvailability(),
errorMessage = "Backup konnte nicht gelesen werden: ${e.localizedMessage ?: e.javaClass.simpleName}"
)
}
}
/**
* Liest das Backup und berechnet die zu schreibenden, gemergten Daten.
* Schreibt KEINE DataStore-Daten das macht der Aufrufer (ViewModel).
* Schreibt jedoch die extrahierten Mediendateien in den App-internen Speicher.
*/
fun buildImportPayload(
context: Context,
sourceUri: Uri,
selection: ImportSelection,
existingWaypoints: List<Waypoint>,
existingTours: List<String>,
existingTourDefaults: Map<String, TourPlaybackDefaults>,
existingTracks: Map<String, List<GpsTrackPoint>>,
existingMusic: Map<String, TourAudioSettings>,
// v2.4.6 — bestehende Tourrouten, damit wir keine bereits vorhandene Route überschreiben.
existingTourRoutes: Map<String, TourRoute> = emptyMap(),
// v2.5.5 — bestehende Tour-Cover, damit eigene Auswahl nicht überschrieben wird.
existingTourCovers: Map<String, TourCover> = emptyMap()
): Pair<ImportPayload?, String?> {
return runCatching {
// ---- ZIP einlesen (alle JSON in Memory, Medien in interne Datei extrahieren) ----
val mediaTargetDir = File(context.filesDir, "imported_media").apply { mkdirs() }
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
val sessionDir = File(mediaTargetDir, timestamp).apply { mkdirs() }
// Map: zip-pfad → file://-URI der extrahierten Datei
val extractedMedia = mutableMapOf<String, String>()
// Mapping originaler displayName/dateiname → zip-pfade (für Best-Effort-Match)
val mediaByLeafName = mutableMapOf<String, MutableList<String>>()
var manifestJson: JsonObject? = null
var toursJson: String? = null
var draftsJson: String? = null
var musicJson: String? = null
var settingsJson: String? = null
val ins0 = context.contentResolver.openInputStream(sourceUri)
?: return null to "Datei konnte nicht geöffnet werden."
ZipInputStream(ins0).use { zin ->
while (true) {
val e = zin.nextEntry ?: break
when {
e.isDirectory -> {}
e.name == "manifest.json" -> {
manifestJson = JsonParser.parseString(zin.readBytes().toString(Charsets.UTF_8))
.asJsonObject
}
e.name == "data/tours.json" ->
toursJson = zin.readBytes().toString(Charsets.UTF_8)
e.name == "data/track_drafts.json" ->
draftsJson = zin.readBytes().toString(Charsets.UTF_8)
e.name == "data/music_settings.json" ->
musicJson = zin.readBytes().toString(Charsets.UTF_8)
e.name == "data/settings.json" ->
settingsJson = zin.readBytes().toString(Charsets.UTF_8)
// Mediendateien
e.name.startsWith("media/") &&
!e.name.endsWith("/") -> {
// v2.5.7 — Cover-Bilder (media/covers/...) gehören zu
// "Touren und Wegpunkte" und werden auch dann extrahiert,
// wenn Medien/Atmo nicht gewählt wurde, damit Tour-Cover
// beim Import nicht verloren gehen.
val isCoverFile = e.name.startsWith("media/covers/")
val shouldExtract =
(isCoverFile && selection.toursAndWaypoints) ||
(!isCoverFile && selection.mediaAndAtmo)
if (shouldExtract) {
val outFile = safeMediaTarget(sessionDir, e)
if (outFile != null) {
FileOutputStream(outFile).use { fout ->
zin.copyTo(fout)
}
val uri = Uri.fromFile(outFile).toString()
extractedMedia[e.name] = uri
val leaf = outFile.name.lowercase(Locale.US)
mediaByLeafName.getOrPut(leaf) { mutableListOf() }
.add(e.name)
}
}
}
else -> { /* Ignorieren (z. B. media_manifest.json) */ }
}
zin.closeEntry()
}
}
if (manifestJson == null) {
return null to "Kein gültiges Backup: manifest.json fehlt."
}
val mf = manifestJson!!
if (mf.get("format")?.asString != "GPS2Audio-Backup") {
return null to "Format wird nicht unterstützt."
}
val warnings = mutableListOf<String>()
// ---- Tours + Waypoints ----
val resultTours = existingTours.toMutableList()
// Mapping: original importierter Tourname → endgültiger Name (ggf. umbenannt)
val tourRename = mutableMapOf<String, String>()
var toursImported = 0
var toursRenamed = 0
var newTourSelection: String? = null
val mergedWaypoints = existingWaypoints.toMutableList()
val existingIds = existingWaypoints.map { it.id }.toMutableSet()
var waypointsImported = 0
val tourDefaultsToWrite = mutableMapOf<String, TourPlaybackDefaults>()
var tourDefaultsImported = 0
// v2.4.6 — Tourrouten Merge-Buffer.
val tourRoutesToWrite = mutableMapOf<String, TourRoute>()
var tourRoutesImported = 0
// v2.5.5 — Tour-Cover Merge-Buffer.
val tourCoversToWrite = mutableMapOf<String, TourCover>()
var tourCoversImported = 0
if (selection.toursAndWaypoints && toursJson != null) {
val payload = runCatching {
JsonParser.parseString(toursJson).asJsonObject
}.getOrNull()
if (payload != null) {
val importedTourList: List<String> = runCatching {
gson.fromJson<List<String>>(
payload.get("tours"),
object : TypeToken<List<String>>() {}.type
) ?: emptyList()
}.getOrDefault(emptyList())
importedTourList.forEach { rawName ->
val name = rawName.ifBlank { Waypoint.DEFAULT_TOUR_NAME }
if (name == Waypoint.DEFAULT_TOUR_NAME) {
// Standard-Tour wird nicht umbenannt Inhalte werden gemergt.
tourRename[name] = name
} else if (name in resultTours) {
val unique = uniqueName(name, resultTours)
tourRename[name] = unique
resultTours.add(unique)
toursRenamed++
toursImported++
} else {
tourRename[name] = name
resultTours.add(name)
toursImported++
}
}
if (toursImported > 0) {
// Ersten neu importierten/umbenannten Tournamen für UI vormerken
newTourSelection = importedTourList
.firstOrNull { it != Waypoint.DEFAULT_TOUR_NAME }
?.let { tourRename[it] }
}
val importedWps: List<Waypoint> = runCatching {
gson.fromJson<List<Waypoint>>(
payload.get("waypoints"),
object : TypeToken<List<Waypoint>>() {}.type
) ?: emptyList()
}.getOrDefault(emptyList())
importedWps.forEach { w ->
val originalTour = w.tourName.ifBlank { Waypoint.DEFAULT_TOUR_NAME }
val mappedTour = tourRename[originalTour]
?: run {
// Tour war nicht in `tours`, also als neue/aliased aufnehmen
if (originalTour == Waypoint.DEFAULT_TOUR_NAME ||
originalTour !in resultTours
) {
if (originalTour != Waypoint.DEFAULT_TOUR_NAME) {
resultTours.add(originalTour)
}
tourRename[originalTour] = originalTour
originalTour
} else {
val unique = uniqueName(originalTour, resultTours)
tourRename[originalTour] = unique
resultTours.add(unique)
toursRenamed++
unique
}
}
val newId = if (w.id in existingIds) UUID.randomUUID().toString() else w.id
existingIds.add(newId)
val tourMediaFolder =
"media/waypoints/${sanitize(originalTour)}/"
val rewrittenSoundUri = rewriteMediaReference(
originalUri = w.soundUri,
originalDisplayName = w.soundName,
extractedMedia = extractedMedia,
mediaByLeafName = mediaByLeafName,
preferZipFolder = tourMediaFolder
)
// v2.5.12 — Auch die URIs der Multi-Clip-Liste auf
// extrahierte Dateien umschreiben. Alte Backups ohne
// audioClips erzeugen hier eine leere Liste die
// Migration auf Legacy-Felder erfolgt zur Laufzeit
// über [effectiveClips].
val rewrittenClips = w.audioClips.map { c ->
val newUri = rewriteMediaReference(
originalUri = c.uri,
originalDisplayName = c.displayFileName
.ifBlank { c.title },
extractedMedia = extractedMedia,
mediaByLeafName = mediaByLeafName,
preferZipFolder = tourMediaFolder
) ?: c.uri
c.copy(uri = newUri)
}
mergedWaypoints.add(
w.copy(
id = newId,
tourName = mappedTour,
soundUri = rewrittenSoundUri ?: w.soundUri,
audioClips = rewrittenClips
)
)
waypointsImported++
}
val tourDefaultsImportedRaw: Map<String, TourPlaybackDefaults> = runCatching {
gson.fromJson<Map<String, TourPlaybackDefaults>>(
payload.get("tourDefaults"),
object : TypeToken<Map<String, TourPlaybackDefaults>>() {}.type
) ?: emptyMap()
}.getOrDefault(emptyMap())
tourDefaultsImportedRaw.forEach { (origName, def) ->
val mapped = tourRename[origName] ?: origName
if (mapped !in existingTourDefaults) {
tourDefaultsToWrite[mapped] = def.copy(tourName = mapped)
tourDefaultsImported++
}
}
// v2.4.6 — Tourrouten aus tours.json (optional, rückwärtskompatibel).
val tourRoutesImportedRaw: Map<String, TourRoute> = runCatching {
gson.fromJson<Map<String, TourRoute>>(
payload.get("tourRoutes"),
object : TypeToken<Map<String, TourRoute>>() {}.type
) ?: emptyMap()
}.getOrDefault(emptyMap())
tourRoutesImportedRaw.forEach { (origName, route) ->
val mapped = tourRename[origName] ?: origName
if (mapped !in existingTourRoutes) {
tourRoutesToWrite[mapped] = route.copy(
updatedAtMillis = System.currentTimeMillis()
)
tourRoutesImported++
} else {
warnings.add("Tourroute für „$mapped“ bereits vorhanden nicht überschrieben.")
}
}
// v2.5.5/2.5.7/2.5.31 — Tour-Cover aus tours.json (optional, rückwärtskompatibel).
// Falls Cover-Bild vorhanden ist (imageUri = ZIP-relativer Pfad
// wie "media/covers/<idx>_<tour>.jpg" (v2.5.31+) oder
// "media/covers/<tour>.jpg" (v2.5.7v2.5.30)), wird die extrahierte
// Datei via [TourCoverImageStore.saveFromFile] in die app-interne
// Ablage übernommen. Schlägt das fehl, bleibt Icon+Palette
// erhalten und das Bild entfällt.
//
// Rückwärtskompatibilität: Beide Pfad-Formate werden erkannt, da
// der extractedMedia-Index immer den originalen ZIP-Entry-Namen
// (e.name) als Schlüssel verwendet.
val tourCoversImportedRaw: Map<String, TourCover> = runCatching {
gson.fromJson<Map<String, TourCover>>(
payload.get("tourCovers"),
object : TypeToken<Map<String, TourCover>>() {}.type
) ?: emptyMap()
}.getOrDefault(emptyMap())
tourCoversImportedRaw.forEach { (origName, cover) ->
val mapped = tourRename[origName] ?: origName
if (mapped !in existingTourCovers) {
val zipRelativeImage = cover.imageUri
val resolvedImagePath: String? = if (
!zipRelativeImage.isNullOrBlank() &&
zipRelativeImage.startsWith("media/covers/")
) {
val extractedFileUri = extractedMedia[zipRelativeImage]
if (extractedFileUri != null) {
runCatching {
val path = Uri.parse(extractedFileUri).path
if (path != null) {
de.waypointaudio.util.TourCoverImageStore
.saveFromFile(context, java.io.File(path), mapped)
} else {
android.util.Log.w("BackupImportManager",
"Cover-URI-Pfad null für Tour \"$mapped\" (extractedFileUri=$extractedFileUri)")
null
}
}.onFailure { e ->
android.util.Log.w("BackupImportManager",
"saveFromFile fehlgeschlagen für Tour \"$mapped\": ${e.localizedMessage}")
}.getOrNull()
} else {
// Bild-Datei war nicht im ZIP oder wurde nicht extrahiert.
android.util.Log.w("BackupImportManager",
"Cover-Datei \"$zipRelativeImage\" nicht im ZIP für Tour \"$mapped\"")
null
}
} else {
// imageUri ist null, leer oder ein absoluter Pfad
// vom Quell-Gerät nicht importierbar.
null
}
tourCoversToWrite[mapped] = cover.copy(imageUri = resolvedImagePath)
tourCoversImported++
}
}
} else {
warnings.add("data/tours.json konnte nicht gelesen werden.")
}
}
// ---- Track-Entwürfe ----
// v2.5.48 FIX: Importiert echte TrackDraft-Objekte aus dem "drafts"-Array.
// Abwärtskompatibilität: Alte Backups schrieben fälschlicherweise die
// gpsTracks-Map unter "tracks" in track_drafts.json. Falls "drafts" fehlt
// aber "tracks" vorhanden ist, wird das alte Format erkannt und toleriert
// (kein Crash, keine Datenverluste an bestehenden Touren/Waypoints).
val tracksToWrite = mutableMapOf<String, List<GpsTrackPoint>>()
val trackDraftsToAdd = mutableListOf<TrackDraft>()
var tracksImported = 0
if (selection.trackDrafts && draftsJson != null) {
val obj = runCatching {
JsonParser.parseString(draftsJson).asJsonObject
}.getOrNull()
if (obj != null) {
// Neues Format (v2.5.48+): "drafts" enthält eine Liste echter TrackDraft-Objekte.
val hasDraftsKey = obj.has("drafts") && !obj.get("drafts").isJsonNull
if (hasDraftsKey) {
// Neues Format: Liste von TrackDraft-Objekten
val importedDrafts: List<TrackDraft> = runCatching {
gson.fromJson<List<TrackDraft>>(
obj.get("drafts"),
object : TypeToken<List<TrackDraft>>() {}.type
) ?: emptyList()
}.getOrDefault(emptyList())
importedDrafts.forEach { draft ->
// Tourname-Mapping anwenden (für den Fall, dass Tour umbenannt wurde).
val origTour = draft.sourceTourName
val mappedTour = tourRename[origTour]
?: run {
if (origTour == Waypoint.DEFAULT_TOUR_NAME ||
origTour in resultTours
) {
origTour
} else {
resultTours.add(origTour)
origTour
}
}
// Neue UUID vergeben, damit importierte Drafts nie mit
// vorhandenen Drafts-IDs kollidieren.
val newDraft = draft.copy(
id = java.util.UUID.randomUUID().toString(),
sourceTourName = mappedTour,
updatedAt = System.currentTimeMillis()
)
trackDraftsToAdd.add(newDraft)
tracksImported++
}
} else {
// Altes (fehlerhaftes) Format: "tracks" enthält gpsTracks-Map.
// Tolerieren ohne Crash, aber nicht in Tracks oder Drafts importieren,
// da der Inhalt GPS-Track-Daten (nicht Draft-Metadaten) sind.
// Hinweis in Warnings ausgeben.
val hasTracksKey = obj.has("tracks") && !obj.get("tracks").isJsonNull
if (hasTracksKey) {
warnings.add(
"Hinweis: track_drafts.json aus älterem Backup-Format erkannt " +
"(enthält GPS-Tracks statt Draft-Objekte). " +
"TrackDraft-Namen/Notizen können nicht wiederhergestellt werden. " +
"Bestehende Touren und Wegpunkte bleiben unverändert."
)
}
// Keine Daten importieren aus altem, fehlerh. Format — kein Crash.
}
}
}
// ---- Begleitmusik ----
val musicToWrite = mutableMapOf<String, TourAudioSettings>()
var musicSettingsImported = 0
if (selection.mediaAndAtmo && musicJson != null) {
val obj = runCatching {
JsonParser.parseString(musicJson).asJsonObject
}.getOrNull()
val mapJson: JsonObject? = obj?.getAsJsonObject("musicSettings")
mapJson?.entrySet()?.forEach { entry ->
val origTour = entry.key
val mappedTour = tourRename[origTour] ?: origTour
if (mappedTour in existingMusic) {
warnings.add("Begleitmusik für Tour „$mappedTour\" bereits vorhanden nicht überschrieben.")
return@forEach
}
val src = entry.value.asJsonObject
val settings = parseMusicSettings(src) ?: return@forEach
val rewritten = settings.copy(
localPlaylist = settings.localPlaylist.map { item ->
val newUri = rewriteMediaReference(
originalUri = item.uriString,
originalDisplayName = item.displayName,
extractedMedia = extractedMedia,
mediaByLeafName = mediaByLeafName,
preferZipFolder = "media/atmo/${sanitize(origTour)}/"
) ?: item.uriString
PlaylistItem(uriString = newUri, displayName = item.displayName)
}
)
musicToWrite[mappedTour] = rewritten
musicSettingsImported++
}
}
// ---- Settings ----
var trackMinDistanceMeters: Int? = null
if (selection.settings && settingsJson != null) {
val obj = runCatching {
JsonParser.parseString(settingsJson).asJsonObject
}.getOrNull()
val v = obj?.get("trackMinDistanceMeters")
if (v != null && v.isJsonPrimitive && v.asJsonPrimitive.isNumber) {
val n = v.asInt
if (n in 1..500) trackMinDistanceMeters = n
}
}
// ---- Medien-Statistik ----
val mediaExtracted = extractedMedia.size
val mediaSkipped = if (selection.mediaAndAtmo && extractedMedia.isEmpty()) {
// ZIP enthielt evtl. keine Medien kein Fehler
0
} else 0
if (mediaExtracted > 0) {
warnings.add("Importierte Mediendateien wurden nach app-internem Speicher kopiert (interner Pfad).")
}
if (selection.mediaAndAtmo) {
// Falls `content://`-URIs übrig sind, weisen wir darauf hin
val orphanContentUris = mergedWaypoints.sumOf { wp ->
val legacy = if (wp.soundUri.startsWith("content://")) 1 else 0
val clips = wp.audioClips.count { it.uri.startsWith("content://") }
legacy + clips
} + musicToWrite.values.sumOf { ms ->
ms.localPlaylist.count { it.uriString.startsWith("content://") }
}
if (orphanContentUris > 0) {
warnings.add(
"Hinweis: Einige Verweise zeigen auf externe `content://`-URIs aus dem ursprünglichen Gerät. " +
"Diese sind nach Import meist nicht mehr abspielbar."
)
}
}
ImportPayload(
mergedWaypoints = mergedWaypoints,
mergedTours = resultTours.distinct(),
tourDefaultsToWrite = tourDefaultsToWrite,
tracksToWrite = tracksToWrite,
musicToWrite = musicToWrite,
tourRoutesToWrite = tourRoutesToWrite,
tourCoversToWrite = tourCoversToWrite,
// v2.5.48 — Echte TrackDraft-Objekte weitergeben.
trackDraftsToAdd = trackDraftsToAdd,
trackMinDistanceMeters = trackMinDistanceMeters,
newTourSelection = newTourSelection,
warnings = warnings,
toursImported = toursImported,
toursRenamed = toursRenamed,
waypointsImported = waypointsImported,
tracksImported = tracksImported,
musicSettingsImported = musicSettingsImported,
mediaExtracted = mediaExtracted,
mediaSkipped = mediaSkipped,
tourDefaultsImported = tourDefaultsImported,
tourRoutesImported = tourRoutesImported,
tourCoversImported = tourCoversImported
) to null
}.getOrElse { e ->
null to "Backup konnte nicht importiert werden: ${e.localizedMessage ?: e.javaClass.simpleName}"
}
}
// ----------------------------------------------------------------------
// Hilfsfunktionen
// ----------------------------------------------------------------------
private fun uniqueName(base: String, existing: List<String>): String {
val first = "$base (Import)"
if (first !in existing) return first
var i = 2
while (true) {
val cand = "$base (Import $i)"
if (cand !in existing) return cand
i++
if (i > 999) return "$base (Import ${System.currentTimeMillis()})"
}
}
private fun parseMusicSettings(obj: JsonObject): TourAudioSettings? {
return runCatching {
val sourceType = runCatching {
MusicSourceType.valueOf(obj.get("sourceType")?.asString ?: "LOCAL_PLAYLIST")
}.getOrDefault(MusicSourceType.LOCAL_PLAYLIST)
val behavior = runCatching {
WaypointMusicBehavior.valueOf(obj.get("behavior")?.asString ?: "PAUSE_RESUME")
}.getOrDefault(WaypointMusicBehavior.PAUSE_RESUME)
val playlist = obj.getAsJsonArray("localPlaylist")?.mapNotNull { el ->
val o = el.asJsonObject
PlaylistItem(
uriString = o.get("uriString")?.asString ?: return@mapNotNull null,
displayName = o.get("displayName")?.asString ?: ""
)
} ?: emptyList()
val streamList = obj.getAsJsonArray("streamPlaylist")?.mapNotNull { el ->
val o = el.asJsonObject
val url = o.get("url")?.asString ?: return@mapNotNull null
StreamEntry(
url = url,
name = o.get("name")?.asString ?: ""
)
} ?: emptyList()
val legacyStreamUrl = obj.get("streamUrl")?.takeIf { !it.isJsonNull }?.asString
// Legacy-Backups (v2.0.7 und älter) hatten nur streamUrl wenn keine streamPlaylist
// gespeichert wurde, aber legacy-URL vorhanden ist, migrieren wir sie zu einem
// einzelnen StreamEntry.
val effectiveStreams = if (streamList.isEmpty() && !legacyStreamUrl.isNullOrBlank()) {
listOf(StreamEntry(url = legacyStreamUrl, name = ""))
} else streamList
TourAudioSettings(
enabled = obj.get("enabled")?.asBoolean ?: false,
sourceType = sourceType,
localPlaylist = playlist,
streamPlaylist = effectiveStreams,
streamUrl = null,
behavior = behavior,
fadeDurationMs = obj.get("fadeDurationMs")?.asLong ?: 1500L,
duckVolume = obj.get("duckVolume")?.asFloat ?: 0.25f,
shuffle = obj.get("shuffle")?.asBoolean ?: false,
autoStartAfterWaypoint = obj.get("autoStartAfterWaypoint")?.asBoolean ?: false
)
}.getOrNull()
}
/**
* Versucht, eine Original-URI (die zur Backup-Zeit eingelesen wurde) auf eine
* frisch extrahierte Datei abzubilden.
*
* 1. Wenn Originalpfad in [extractedMedia] direkt referenziert ist (z. B.
* bei `file://`-URIs aus dem Backup-Manifest), → diese benutzen.
* 2. Sonst: nach `displayName`/Dateiname im bevorzugten Ordner suchen.
* 3. Sonst: nach `displayName`/Dateiname global suchen.
*
* Gibt `null` zurück, wenn keine extrahierte Datei zugeordnet werden konnte.
*/
private fun rewriteMediaReference(
originalUri: String,
originalDisplayName: String,
extractedMedia: Map<String, String>,
mediaByLeafName: Map<String, List<String>>,
preferZipFolder: String
): String? {
if (originalUri.isBlank()) return null
// 1. Direkter Treffer (Pfad)
extractedMedia[originalUri]?.let { return it }
// 2. Nach displayName im bevorzugten Ordner
val sanName = sanitize(originalDisplayName.ifBlank { "" }).lowercase(Locale.US)
if (sanName.isNotBlank()) {
val candidates = mediaByLeafName[sanName] ?: emptyList()
val preferred = candidates.firstOrNull { it.startsWith(preferZipFolder) }
if (preferred != null) extractedMedia[preferred]?.let { return it }
// 3. Global
candidates.firstOrNull()?.let { extractedMedia[it]?.let { u -> return u } }
}
// 4. Letzter Pfad-Komponente der originalen URI versuchen
val tail = originalUri.substringAfterLast('/').substringBefore('?')
.lowercase(Locale.US)
if (tail.isNotBlank()) {
val candidates = mediaByLeafName[tail] ?: emptyList()
val preferred = candidates.firstOrNull { it.startsWith(preferZipFolder) }
if (preferred != null) extractedMedia[preferred]?.let { return it }
candidates.firstOrNull()?.let { extractedMedia[it]?.let { u -> return u } }
}
return null
}
/** Verhindert Path-Traversal beim Entpacken in einen Zielordner. */
private fun safeMediaTarget(baseDir: File, entry: ZipEntry): File? {
val rel = entry.name.removePrefix("media/")
if (rel.contains("..")) return null
val safe = rel.split('/').joinToString(File.separator) { sanitizeForFs(it) }
if (safe.isBlank()) return null
val target = File(baseDir, safe)
// Path-Traversal-Schutz: Ziel muss Unterpfad von baseDir bleiben
val basePath = baseDir.canonicalPath
if (!target.canonicalPath.startsWith(basePath)) return null
target.parentFile?.mkdirs()
return target
}
private fun sanitize(s: String): String {
return s.replace(Regex("[\\\\/:*?\"<>|\\u0000-\\u001F]"), "_")
.replace(Regex("_+"), "_")
.trim('_', ' ', '.')
.take(120)
}
private fun sanitizeForFs(s: String): String {
if (s.isBlank()) return "_"
return sanitize(s).ifBlank { "_" }
}
}
@@ -0,0 +1,98 @@
package de.waypointaudio.license
import org.json.JSONArray
import org.json.JSONObject
/**
* v2.5.20 — Kanonische JSON-Serialisierung, kompatibel zum Python-Tool
* (`canonical_json_bytes`):
* - Keys lexikografisch sortiert.
* - Trennzeichen ohne Whitespace: "," / ":".
* - `ensure_ascii=False` ⇒ Unicode-Strings werden nicht escapet, ausser den
* minimal nötigen JSON-Escapes (`\"`, `\\`, Steuerzeichen < 0x20).
* - `null` für JSON-Null; `JSONObject.NULL` und Kotlin-`null` gleich behandelt.
*
* Diese kanonische Form ist die Eingabe für die ECDSA-P256-SHA256-Signatur.
*/
internal object CanonicalJson {
fun encode(value: Any?): String {
val sb = StringBuilder()
appendValue(sb, value)
return sb.toString()
}
private fun appendValue(sb: StringBuilder, value: Any?) {
when {
value == null || value === JSONObject.NULL -> sb.append("null")
value is Boolean -> sb.append(if (value) "true" else "false")
value is Number -> appendNumber(sb, value)
value is String -> appendString(sb, value)
value is JSONObject -> appendObject(sb, value)
value is JSONArray -> appendArray(sb, value)
else -> appendString(sb, value.toString())
}
}
private fun appendObject(sb: StringBuilder, obj: JSONObject) {
val keys = obj.keys().asSequence().toList().sorted()
sb.append('{')
for ((index, key) in keys.withIndex()) {
if (index > 0) sb.append(',')
appendString(sb, key)
sb.append(':')
appendValue(sb, obj.opt(key))
}
sb.append('}')
}
private fun appendArray(sb: StringBuilder, arr: JSONArray) {
sb.append('[')
for (i in 0 until arr.length()) {
if (i > 0) sb.append(',')
appendValue(sb, arr.opt(i))
}
sb.append(']')
}
private fun appendNumber(sb: StringBuilder, n: Number) {
when (n) {
is Int, is Long, is Short, is Byte -> sb.append(n.toString())
is Double -> {
if (n.isNaN() || n.isInfinite()) {
sb.append("null")
} else if (n == n.toLong().toDouble()) {
sb.append(n.toLong().toString())
} else {
sb.append(n.toString())
}
}
is Float -> appendNumber(sb, n.toDouble())
else -> sb.append(n.toString())
}
}
private fun appendString(sb: StringBuilder, s: String) {
sb.append('"')
for (c in s) {
when (c.code) {
0x22 -> sb.append("\\\"")
0x5C -> sb.append("\\\\")
0x08 -> sb.append("\\b")
0x0C -> sb.append("\\f")
0x0A -> sb.append("\\n")
0x0D -> sb.append("\\r")
0x09 -> sb.append("\\t")
else -> {
val code = c.code
if (code < 0x20) {
sb.append("\\u%04x".format(code))
} else {
sb.append(c)
}
}
}
}
sb.append('"')
}
}
@@ -0,0 +1,73 @@
package de.waypointaudio.license
import android.content.Context
import android.content.SharedPreferences
import java.security.MessageDigest
import java.util.UUID
/**
* v2.5.21 — App-spezifische Geräte-ID für optionale gerätegebundene Lizenzen.
*
* Architektur-Entscheidungen:
* - KEIN Einsatz von Android-Hardware-IDs (ANDROID_ID, Build.SERIAL o.Ä.):
* Diese sind seit Android 8+ pro App/Benutzer unterschiedlich und nicht
* verlässlich. Stattdessen wird beim ersten Aufruf eine zufällige UUID v4
* generiert und app-intern gespeichert.
* - Speicherort: SharedPreferences in der Datei "gps2audio_license_device"
* (MODE_PRIVATE). Diese Datei liegt außerhalb des normalen Tour-/Medien-
* Backup-Bereichs. Via data_extraction_rules wird sie vom automatischen
* Android-Backup ausgeschlossen (keine sensiblen Daten, aber bewusste
* Entscheidung: wenn der Nutzer App-Daten löscht, darf sich die ID ändern).
* - Die raw UUID dient als Geräte-ID. Für die Anzeige wird ein SHA-256-Hash
* nach dem Schema sha256("GPS2AudioDevice:" + uuid) errechnet, damit das
* Python-Lizenz-Tool dasselbe Format erwartet und verwenden kann.
* - In v2.5.21 KEINE harte Gerätebindung aktiv. Die ID wird nur angezeigt
* und kann kopiert werden. Phase 3 kann die Prüfung in LicenseValidator
* ergänzen, ohne die ID-Erzeugungslogik zu ändern.
* - Wenn App-Daten gelöscht werden, ändert sich die ID. Das ist für Phase 3
* im Handoff dokumentiert und akzeptiert.
*/
object DeviceIdProvider {
private const val PREFS_FILE = "gps2audio_license_device"
private const val KEY_DEVICE_UUID = "device_uuid"
/**
* Liefert die rohe app-spezifische Geräte-UUID.
* Beim ersten Aufruf wird eine neue UUID generiert und persistiert.
*/
fun getDeviceUuid(context: Context): String {
val prefs = prefs(context)
val existing = prefs.getString(KEY_DEVICE_UUID, null)
if (!existing.isNullOrBlank()) return existing
val fresh = UUID.randomUUID().toString()
prefs.edit().putString(KEY_DEVICE_UUID, fresh).apply()
return fresh
}
/**
* Liefert den SHA-256-Hash der Geräte-ID im Format, das das GPS2Audio-
* Python-Lizenztool erwartet: sha256("GPS2AudioDevice:" + device_uuid).
* Ausgabe: lowercase Hex-String (64 Zeichen).
*/
fun getDeviceIdHash(context: Context): String {
val uuid = getDeviceUuid(context)
val input = "GPS2AudioDevice:$uuid"
val digest = MessageDigest.getInstance("SHA-256")
val hashBytes = digest.digest(input.toByteArray(Charsets.UTF_8))
return hashBytes.joinToString("") { "%02x".format(it) }
}
/**
* Kurzform des Hash für die UI (erste 16 Zeichen + "..." + letzte 8 Zeichen).
* Zeigt z.B. "a1b2c3d4e5f60708...90abcdef" um die Anzeige übersichtlicher
* zu halten. Die Zwischenablage enthält stets den vollen Hash.
*/
fun getDeviceIdHashShort(context: Context): String {
val full = getDeviceIdHash(context)
return if (full.length > 24) "${full.take(16)}${full.takeLast(8)}" else full
}
private fun prefs(context: Context): SharedPreferences =
context.getSharedPreferences(PREFS_FILE, Context.MODE_PRIVATE)
}
@@ -0,0 +1,101 @@
package de.waypointaudio.license
/**
* v2.5.32 — Zentrale Feature-Berechtigungsprüfung.
*
* Diese Klasse kapselt alle Regeln, welche Lizenztypen Zugriff auf
* Profi-Funktionen gewähren. Sie liest ausschließlich aus [LicenseStatus]
* und ändert NIEMALS Lizenzdaten, Import-Logik, Signaturprüfung,
* Geräte-ID, CanonicalJson oder Backup-Regeln.
*
* Lizenztypen mit professionellem Zugang:
* - "pro", "profi", "commercial" → Profi-Zugang solange status == VALID
* - "developer" → immer erlaubt wenn status == VALID
* - "trial" → Profi-Zugang solange status == VALID (valid_until-Prüfung
* erfolgt bereits in LicenseValidator; ist abgelaufen → state == EXPIRED)
*
* Ohne Lizenz (state == NONE) oder mit abgelaufener/ungültiger Lizenz:
* - Keine Profi-Funktion freigegeben.
* - Vorhandene Daten (Zusatzclips, PTT-Aufnahmen, Zählerwerte) werden
* NIEMALS gelöscht.
*
* Feature-Gates:
* - [hasProfessionalAccess]: gemeinsames Haupt-Gate
* - [canUseMultiClip]: Zusatzclips bearbeiten/hinzufügen/aktivieren
* - [canUsePtt]: PTT-Modul anzeigen/nutzen
* - [canUseTourCounter]: Tour-Zähler anzeigen/nutzen
*
* UI-Empfehlung: Prüfe den Status über [LicenseStatusProvider.status], das
* als Compose-State vorliegt. Reagiert reaktiv auf Lizenzimport ohne
* App-Neustart.
*/
object LicenseFeatureAccess {
/**
* Professionelle Lizenztypen (case-insensitive), die Profi-Zugang gewähren.
*/
private val PRO_LICENSE_TYPES = setOf("pro", "profi", "commercial", "developer", "trial")
/**
* Prüft, ob die aktuelle Lizenz professionellen Zugang gewährt.
*
* Bedingungen:
* 1. [LicenseStatus.state] muss [LicenseStatus.State.VALID] sein.
* 2. [LicenseStatus.licenseTypeRaw] muss in [PRO_LICENSE_TYPES] enthalten sein
* (case-insensitive), ODER in [LicenseStatus.permissions] ist mindestens
* eine Profi-Permission gesetzt.
* 3. Developer-Lizenzen sind immer eingeschlossen, wenn state == VALID.
*/
fun hasProfessionalAccess(status: LicenseStatus): Boolean {
if (status.state != LicenseStatus.State.VALID) return false
val typeRaw = status.licenseTypeRaw?.lowercase()?.trim() ?: ""
if (typeRaw in PRO_LICENSE_TYPES) return true
// Fallback: explizite Permissions (z. B. "commercial_use" oder "paid_tours")
return status.permissions.any { (_, v) -> v }
}
/**
* Shortcut: Liest den aktuellen Status direkt aus [LicenseStatusProvider].
* Für Compose-Code bevorzuge den Status-Parameter, damit die Rekomposition
* reaktiv funktioniert.
*/
fun hasProfessionalAccessCurrent(): Boolean =
hasProfessionalAccess(LicenseStatusProvider.current())
/**
* Multi-Clip-Waypoints: Bearbeiten, Hinzufügen, Aktivieren von Zusatzclips.
* Hauptclip (index 0 / isPrimary) bleibt immer frei.
* Bestehende Clips werden niemals gelöscht.
*/
fun canUseMultiClip(status: LicenseStatus): Boolean = hasProfessionalAccess(status)
/**
* PTT (Push-to-Talk): Live-PTT-Modul, Aufnahmen starten.
* Bestehende PTT-Aufnahmen bleiben erhalten.
*/
fun canUsePtt(status: LicenseStatus): Boolean = hasProfessionalAccess(status)
/**
* Tour-Zähler: Abspiel-Modi, Zähler-Reset, Vorgaben.
* Bestehende Zählerwerte bleiben erhalten.
*/
fun canUseTourCounter(status: LicenseStatus): Boolean = hasProfessionalAccess(status)
/**
* Freundlicher Hinweistext für das Multi-Clip-Gate.
*/
const val MSG_MULTI_CLIP =
"Multi-Clip-Waypoints sind Teil der GPS2Audio Profi-Lizenz."
/**
* Freundlicher Hinweistext für das PTT-Gate.
*/
const val MSG_PTT =
"Live-PTT ist Teil der GPS2Audio Profi-Lizenz."
/**
* Freundlicher Hinweistext für das Tour-Zähler-Gate.
*/
const val MSG_TOUR_COUNTER =
"Der Tour-Zähler ist Teil der GPS2Audio Profi-Lizenz."
}
@@ -0,0 +1,31 @@
package de.waypointaudio.license
/**
* v2.5.20 — Erweitertes Lizenzstatus-Datenmodell.
*
* v2.5.17 hat ein bewusst minimales String-only-Modell eingeführt, das die
* About-Card direkt rendern konnte. v2.5.20 ergänzt strukturierte Felder,
* damit die echte Offline-Lizenzprüfung Lizenztyp, Lizenznehmer, Gültigkeit,
* Permissions, Bindung und Lizenz-ID anzeigen kann.
*
* Bewusst unverändert bleiben die alten Felder ([stateText], [holderText],
* [validityText], [featuresText], [isPro]), damit die bisherige UI ohne
* Anpassung weiterläuft und der Card-Aufbau in [de.waypointaudio.ui.AboutDialog]
* stabil bleibt.
*/
data class LicenseStatus(
val stateText: String,
val holderText: String,
val validityText: String,
val featuresText: String,
val isPro: Boolean,
val state: State = State.NONE,
val licenseId: String? = null,
val licenseTypeRaw: String? = null,
val licenseTypeLabel: String? = null,
val bindingText: String? = null,
val permissions: Map<String, Boolean> = emptyMap(),
val errorMessage: String? = null,
) {
enum class State { NONE, VALID, INVALID, EXPIRED }
}
@@ -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)
}
@@ -0,0 +1,148 @@
package de.waypointaudio.license
import android.content.Context
import android.content.SharedPreferences
import java.io.File
/**
* v2.5.29 — Persistente, app-interne Ablage der importierten Lizenzdatei.
*
* Speicherorte (primär + Fallback):
*
* 1. PRIMÄR: `filesDir/license/gps2audio.license.json`
* `filesDir` ist app-privat (`/data/data/<pkg>/files`). Diese Datei
* überlebt normale APK-Updates, weil Android-App-Updates die App-Daten
* (filesDir, SharedPreferences usw.) NICHT löschen. Sie wird nur beim
* expliziten „App-Daten löschen" oder bei der Deinstallation entfernt.
*
* 2. FALLBACK: SharedPreferences `gps2audio_license_store`
* Beim Schreiben wird der Roh-JSON-Inhalt zusätzlich als String in den
* SharedPreferences gespeichert. Beim Lesen: wenn die primäre Datei
* fehlt (z. B. nach seltener Dateisystem-Anomalie), aber der Preferences-
* Eintrag vorhanden ist, wird die Datei aus den Preferences wiederhergestellt.
* Diese zweite Kopie dient ausschließlich als Ausfallsicherung — sie wird
* gelöscht, wenn der Nutzer die Lizenz explizit entfernt.
*
* Backup-Ausschluss (unverändert):
* - `license/`-Verzeichnis und `gps2audio_license_store.xml` sind in
* `backup_rules.xml` (Android < 12) und `data_extraction_rules.xml`
* (Android 12+) vom automatischen Cloud-Backup ausgeschlossen.
* - WICHTIG: Backup-Ausschluss betrifft NUR den automatischen Cloud-Backup
* (Google Drive). Er NICHT bewirkt, dass beim APK-Update Daten gelöscht
* werden. APK-Updates lassen App-Daten grundsätzlich unangetastet.
*
* Nur EINE Lizenz wird gespeichert. Beim Import wird die alte Datei atomar
* ersetzt — aber nur, wenn die neue Lizenz gültig ist (siehe LicenseStatusProvider.import).
*/
internal object LicenseStorage {
private const val DIR_NAME = "license"
private const val FILE_NAME = "gps2audio.license.json"
// v2.5.29 — Fallback-SharedPreferences für doppelte Ablage des Lizenz-JSON.
private const val PREFS_STORE = "gps2audio_license_store"
private const val KEY_RAW_JSON = "raw_json"
// ---------------------------------------------------------------------------
// Primäre Datei-Operationen
// ---------------------------------------------------------------------------
fun file(context: Context): File {
val dir = File(context.filesDir, DIR_NAME)
if (!dir.exists()) dir.mkdirs()
return File(dir, FILE_NAME)
}
/**
* Liest den Lizenz-JSON-Rohtext.
*
* v2.5.29 — Fallback-Logik:
* 1. Primäre Datei lesen.
* 2. Falls Datei fehlt oder leer: SharedPreferences-Fallback prüfen.
* 3. Falls Fallback vorhanden: Datei daraus wiederherstellen und zurückgeben.
* So ist nach dem nächsten Zugriff wieder die primäre Datei da.
*/
fun readRaw(context: Context): String? {
// 1. Primäre Datei
val f = file(context)
if (f.isFile) {
val content = try {
f.readText(Charsets.UTF_8).trim()
} catch (_: Throwable) {
null
}
if (!content.isNullOrEmpty()) return content
}
// 2. Fallback: SharedPreferences
val fromPrefs = prefs(context).getString(KEY_RAW_JSON, null)
if (!fromPrefs.isNullOrEmpty()) {
// 3. Datei aus Preferences wiederherstellen (self-healing)
try {
val tmp = File(f.parentFile ?: file(context).parentFile!!, f.name + ".tmp")
tmp.writeText(fromPrefs, Charsets.UTF_8)
if (f.exists()) f.delete()
tmp.renameTo(f)
} catch (_: Throwable) {
// Wiederherstellung gescheitert, Preferences-Wert trotzdem zurückgeben
}
return fromPrefs
}
return null
}
/**
* Schreibt den Lizenz-JSON-Rohtext atomar in die primäre Datei
* und zusätzlich in die Fallback-SharedPreferences.
*/
fun writeRaw(context: Context, raw: String): Boolean {
return try {
val f = file(context)
val tmp = File(f.parentFile, f.name + ".tmp")
tmp.writeText(raw, Charsets.UTF_8)
if (f.exists()) f.delete()
val renamed = tmp.renameTo(f)
// v2.5.29 — Fallback-Preferences immer schreiben, unabhängig vom Datei-Ergebnis
prefs(context).edit().putString(KEY_RAW_JSON, raw).apply()
renamed
} catch (_: Throwable) {
// Wenn Datei-Schreiben fehlschlägt, trotzdem Preferences versuchen
try {
prefs(context).edit().putString(KEY_RAW_JSON, raw).apply()
} catch (_: Throwable) { /* ignore */ }
false
}
}
/**
* Löscht die Lizenz vollständig: primäre Datei UND Fallback-Preferences.
* Wird nur beim expliziten Nutzer-Klick auf „Lizenz entfernen" aufgerufen.
*/
fun delete(context: Context): Boolean {
return try {
val f = file(context)
val fileDeleted = if (f.exists()) f.delete() else true
// v2.5.29 — Fallback-Preferences ebenfalls löschen
prefs(context).edit().remove(KEY_RAW_JSON).apply()
fileDeleted
} catch (_: Throwable) {
false
}
}
/**
* Prüft ob eine Lizenz vorhanden ist (primäre Datei ODER Fallback).
*/
fun exists(context: Context): Boolean {
if (file(context).isFile) return true
return !prefs(context).getString(KEY_RAW_JSON, null).isNullOrEmpty()
}
// ---------------------------------------------------------------------------
// Hilfsmethoden
// ---------------------------------------------------------------------------
private fun prefs(context: Context): SharedPreferences =
context.getSharedPreferences(PREFS_STORE, Context.MODE_PRIVATE)
}
@@ -0,0 +1,180 @@
package de.waypointaudio.license
import android.content.Context
import android.util.Base64
import de.waypointaudio.R
import org.json.JSONObject
import java.io.BufferedReader
import java.io.InputStreamReader
import java.security.KeyFactory
import java.security.PublicKey
import java.security.Signature
import java.security.spec.X509EncodedKeySpec
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone
/**
* v2.5.20 — Echte Offline-Lizenzprüfung.
*
* v2.5.17 hat lediglich den Public Key als Raw-Resource eingebettet und ein
* Skelett für die spätere Prüfung bereitgestellt. v2.5.20 implementiert nun:
* - Lesen einer `.gps2audio-license`-Datei (JSON).
* - Strukturelle Prüfung (`schema_version`, `payload`, `product`, Signatur-
* Algorithmus).
* - ECDSA-P256-SHA256-Verifikation gegen den eingebetteten Public Key.
* - Gültigkeitsprüfung (`valid_until` falls gesetzt).
*
* Die Methode [verifyOffline] gibt ein [Result]-Objekt mit dem Ergebnis
* zurück. Sie wirft NIEMALS — auch nicht bei ungültigem JSON oder fehlender
* Resource — sondern liefert ein passendes Fehlerergebnis.
*
* WICHTIG: Diese Prüfung beeinflusst KEINE App-Funktion. Sie wird nur für
* die Anzeige im Lizenzbereich verwendet (siehe [LicenseStatusProvider]).
* Der Private Key bleibt ausschliesslich beim Ersteller.
*/
object LicenseValidator {
private const val SCHEMA_VERSION = "gps2audio-license-v1"
private const val SIGNATURE_ALGORITHM = "ECDSA_P256_SHA256"
private const val PRODUCT_NAME = "GPS2Audio"
data class Result(
val state: LicenseStatus.State,
val payload: JSONObject? = null,
val rawJson: String? = null,
val message: String? = null,
) {
val isValid: Boolean get() = state == LicenseStatus.State.VALID
}
fun readEmbeddedPublicKeyPem(context: Context): String? = try {
context.resources.openRawResource(R.raw.gps2audio_license_public).use { input ->
BufferedReader(InputStreamReader(input, Charsets.UTF_8)).readText()
}
} catch (_: Throwable) {
null
}
/**
* Prüft den übergebenen Lizenz-Dateiinhalt offline.
*
* Reihenfolge der Checks:
* 1. JSON parsen.
* 2. `schema_version` muss "gps2audio-license-v1" sein.
* 3. `payload.product` muss "GPS2Audio" sein.
* 4. `signature.algorithm` muss "ECDSA_P256_SHA256" sein.
* 5. ECDSA-Verifikation gegen den eingebetteten Public Key.
* 6. `payload.valid_until` (falls vorhanden) darf nicht in der
* Vergangenheit liegen.
*/
fun verifyOffline(context: Context, licenseFileBytes: ByteArray): Result {
val raw = try {
String(licenseFileBytes, Charsets.UTF_8)
} catch (_: Throwable) {
return Result(LicenseStatus.State.INVALID, message = "Datei ist kein gültiges UTF-8")
}
val doc = try {
JSONObject(raw)
} catch (_: Throwable) {
return Result(LicenseStatus.State.INVALID, message = "Lizenzdatei ist kein gültiges JSON")
}
val schemaVersion = doc.optString("schema_version", "")
val payload = doc.optJSONObject("payload")
val signatureObj = doc.optJSONObject("signature")
if (schemaVersion != SCHEMA_VERSION || payload == null) {
return Result(LicenseStatus.State.INVALID, payload, raw, "Ungültiges Lizenzformat")
}
val product = payload.optString("product", "")
if (product != PRODUCT_NAME) {
return Result(LicenseStatus.State.INVALID, payload, raw, "Falsches Produkt: $product")
}
val algorithm = signatureObj?.optString("algorithm", "") ?: ""
if (algorithm != SIGNATURE_ALGORITHM) {
return Result(LicenseStatus.State.INVALID, payload, raw, "Unbekannter Signaturalgorithmus")
}
val signatureValue = signatureObj.optString("value", "")
val signatureBytes = try {
Base64.decode(signatureValue, Base64.DEFAULT)
} catch (_: Throwable) {
return Result(LicenseStatus.State.INVALID, payload, raw, "Signatur ist kein gültiges Base64")
}
val publicKey = loadEmbeddedPublicKey(context)
?: return Result(LicenseStatus.State.INVALID, payload, raw, "Eingebetteter Public Key fehlt")
val signedContent = JSONObject().apply {
put("schema_version", schemaVersion)
put("payload", payload)
}
val canonical = CanonicalJson.encode(signedContent)
val signatureValid = try {
val sig = Signature.getInstance("SHA256withECDSA")
sig.initVerify(publicKey)
sig.update(canonical.toByteArray(Charsets.UTF_8))
sig.verify(signatureBytes)
} catch (_: Throwable) {
false
}
if (!signatureValid) {
return Result(LicenseStatus.State.INVALID, payload, raw, "Signatur ungültig")
}
val validUntilRaw = payload.optString("valid_until", "")
val validUntilNonNull = if (payload.isNull("valid_until")) null else validUntilRaw.ifEmpty { null }
if (validUntilNonNull != null) {
val expiry = parseIsoDate(validUntilNonNull)
if (expiry == null) {
return Result(LicenseStatus.State.INVALID, payload, raw, "Ungültiges Ablaufdatum")
}
if (expiry.before(Date())) {
return Result(LicenseStatus.State.EXPIRED, payload, raw, "Lizenz abgelaufen")
}
}
return Result(LicenseStatus.State.VALID, payload, raw, "Lizenz ist gültig")
}
private fun loadEmbeddedPublicKey(context: Context): PublicKey? {
val pem = readEmbeddedPublicKeyPem(context) ?: return null
return try {
val base64 = pem
.replace("-----BEGIN PUBLIC KEY-----", "")
.replace("-----END PUBLIC KEY-----", "")
.replace("\\s".toRegex(), "")
val der = Base64.decode(base64, Base64.DEFAULT)
val spec = X509EncodedKeySpec(der)
KeyFactory.getInstance("EC").generatePublic(spec)
} catch (_: Throwable) {
null
}
}
private fun parseIsoDate(value: String): Date? {
val cleaned = value.trim().let {
if (it.endsWith("Z")) it.dropLast(1) + "+0000" else it.replace(Regex("([+-]\\d{2}):(\\d{2})$"), "$1$2")
}
val formats = listOf(
"yyyy-MM-dd'T'HH:mm:ssZ",
"yyyy-MM-dd'T'HH:mm:ss.SSSZ",
"yyyy-MM-dd",
)
for (pattern in formats) {
try {
val sdf = SimpleDateFormat(pattern, Locale.US)
sdf.timeZone = TimeZone.getTimeZone("UTC")
return sdf.parse(cleaned)
} catch (_: Throwable) {
// try next
}
}
return null
}
}
@@ -2,7 +2,10 @@ package de.waypointaudio.repository
import android.content.Context
import de.waypointaudio.data.GpsTrackPoint
import de.waypointaudio.data.TourCover
import de.waypointaudio.data.TourPlaybackDefaults
import de.waypointaudio.data.TourRoute
import de.waypointaudio.data.TrackDraft
import de.waypointaudio.data.Waypoint
import de.waypointaudio.data.WaypointStore
import kotlinx.coroutines.flow.Flow
@@ -27,6 +30,43 @@ class WaypointRepository(context: Context) {
/** Reaktiver Flow der gespeicherten GPS-Tracks. */
val gpsTracks: Flow<Map<String, List<GpsTrackPoint>>> = store.gpsTracksFlow
/**
* v2.4.6 — Reaktiver Flow der persistierten Tourrouten (Map tourName → TourRoute).
* Fehlt der Eintrag, gibt es für die Tour keine gespeicherte Route.
*/
val tourRoutes: Flow<Map<String, TourRoute>> = store.tourRoutesFlow
/** Speichert (oder ersetzt) die Route einer Tour. */
suspend fun saveTourRoute(tourName: String, route: TourRoute) =
store.saveTourRoute(tourName, route)
/** Entfernt die gespeicherte Route einer Tour (kein Fehler, wenn keine vorhanden). */
suspend fun deleteTourRoute(tourName: String) = store.deleteTourRoute(tourName)
/** Tour-Umbenennung: Route-Eintrag mitziehen. */
suspend fun renameTourRouteKey(oldName: String, newName: String) =
store.renameTourRouteKey(oldName, newName)
/** Bulk-Merge für Backup-Import. */
suspend fun mergeTourRoutes(routes: Map<String, TourRoute>) = store.mergeTourRoutes(routes)
/** v2.5.5 — Reaktiver Flow der persistierten Tour-Cover (Map tourName → TourCover). */
val tourCovers: Flow<Map<String, TourCover>> = store.tourCoversFlow
/** v2.5.5 — Speichert/ersetzt das Cover einer Tour. */
suspend fun saveTourCover(tourName: String, cover: TourCover) =
store.saveTourCover(tourName, cover)
/** v2.5.5 — Entfernt das Cover einer Tour (kein Fehler, wenn keins gesetzt). */
suspend fun deleteTourCover(tourName: String) = store.deleteTourCover(tourName)
/** v2.5.5 — Tour-Umbenennung: Cover-Eintrag mitziehen. */
suspend fun renameTourCoverKey(oldName: String, newName: String) =
store.renameTourCoverKey(oldName, newName)
/** v2.5.5 — Bulk-Merge für Backup-Import. */
suspend fun mergeTourCovers(covers: Map<String, TourCover>) = store.mergeTourCovers(covers)
/** Wegpunkt hinzufügen oder aktualisieren. */
suspend fun upsert(waypoint: Waypoint) = store.upsert(waypoint)
@@ -73,4 +113,18 @@ class WaypointRepository(context: Context) {
/** Löscht den GPS-Track einer Tour. */
suspend fun clearGpsTrack(tourName: String) =
store.clearGpsTrack(tourName)
// ─── Track-Drafts ─────────────────────────────────────────────────────────
/** Reaktiver Flow aller archivierten Track-Drafts. */
val trackDrafts: Flow<List<TrackDraft>> = store.trackDraftsFlow
/** Fügt einen neuen Track-Draft hinzu (immer als neuen Eintrag). */
suspend fun addTrackDraft(draft: TrackDraft) = store.addTrackDraft(draft)
/** Aktualisiert (oder fügt hinzu) einen Track-Draft. */
suspend fun upsertTrackDraft(draft: TrackDraft) = store.upsertTrackDraft(draft)
/** Löscht einen Track-Draft. */
suspend fun deleteTrackDraft(id: String) = store.deleteTrackDraft(id)
}
@@ -0,0 +1,737 @@
package de.waypointaudio.service
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.net.Uri
import android.os.Build
import android.os.IBinder
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.media3.common.AudioAttributes
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer
import de.waypointaudio.MainActivity
import de.waypointaudio.R
import de.waypointaudio.data.MusicSourceType
import de.waypointaudio.data.PlaylistItem
import de.waypointaudio.data.StreamEntry
import de.waypointaudio.data.TourAudioSettings
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
/**
* Vordergrund-Dienst, der die Begleitmusik (Atmo) im Hintergrund am Leben hält.
*
* v2.0.7 Architektur:
* ====================
* Der Service besitzt jetzt den **ExoPlayer** direkt nicht nur die Notification.
*
* Hintergrund: In v2.0.4v2.0.6 lief die Wiedergabe in einem App-Singleton
* (`BackgroundMusicPlayer`), das nur einen "Shell"-Foreground-Service nutzte.
* Auf Android 12+/14 hat das nicht zuverlässig funktioniert: die OS-Heuristiken
* koppeln aktive Hintergrund-Wiedergabe an einen Service mit Typ
* `mediaPlayback`, der die Audio-Pipeline auch wirklich besitzt. Andernfalls
* kann der Prozess in "cached" wechseln und der Player wird angehalten auch
* mit gesetztem `setWakeMode()`.
*
* Mit v2.0.7:
* - Service hält ExoPlayer-Instanz von [onStartCommand] bis [onDestroy].
* - Sämtliche play/pause/stop/next/prev/volume Anweisungen werden via Intent-
* Action an den Service geschickt.
* - Service exponiert einen process-singleton State-Flow, den UI / ViewModel
* direkt beobachten.
* - [BackgroundMusicPlayer] ist nur noch ein dünner Facade-Wrapper, der
* die bestehende öffentliche API erhält.
*
* Lifecycle / Lebensdauer:
* - Service startet wenn Atmo erstmalig Wiedergabe beginnt (`play()`).
* - Service läuft **ununterbrochen** weiter durch:
* - Pause/Resume durch PTT / Wegpunkt-Audio / manuellen Player
* - App-Wechsel in den Hintergrund (Home, Recents)
* - Bildschirm aus
* - System-Doze (durch FGS + wakeMode geschützt)
* - Service stoppt sich erst bei explizitem [ACTION_STOP] (User stoppt
* Begleitmusik, Tour wird gewechselt mit deaktivierter Atmo, App wird
* beendet via `BackgroundMusicManager.release()`).
*
* Crash-Schutz:
* - `startForeground` wird in `onCreate` als allererstes mit einer
* minimal-validen Notification aufgerufen (5s System-Frist).
* - Wenn POST_NOTIFICATIONS verweigert ist, schlägt `startForeground` nicht
* fehl (Android zeigt nur keine Notification an Service bleibt aktiv).
* - Exceptions in onStartCommand werden geloggt, brechen aber nicht den
* Service ab. Auch null-Intent (Re-Delivery) lässt den Service weiterlaufen.
*
* Audio-Fokus:
* - `handleAudioFocus = false` die Fokus-/Pause-/Resume-Logik bei
* Unterbrechungen (PTT, Wegpunkt-Audio, manueller Player) regelt
* [AtmoResumeManager] auf Anwendungsebene.
*/
class AtmoPlaybackService : Service() {
companion object {
const val ACTION_START = "de.waypointaudio.atmo.ACTION_START"
const val ACTION_STOP = "de.waypointaudio.atmo.ACTION_STOP"
const val ACTION_PAUSE = "de.waypointaudio.atmo.ACTION_PAUSE"
const val ACTION_RESUME = "de.waypointaudio.atmo.ACTION_RESUME"
const val ACTION_NEXT = "de.waypointaudio.atmo.ACTION_NEXT"
const val ACTION_PREVIOUS = "de.waypointaudio.atmo.ACTION_PREVIOUS"
const val ACTION_SET_VOLUME = "de.waypointaudio.atmo.ACTION_SET_VOLUME"
const val ACTION_UPDATE_SETTINGS = "de.waypointaudio.atmo.ACTION_UPDATE_SETTINGS"
const val EXTRA_VOLUME = "atmo_volume"
const val EXTRA_IS_STREAM = "atmo_is_stream"
const val EXTRA_STREAM_URL = "atmo_stream_url"
const val EXTRA_STREAM_URLS = "atmo_stream_urls"
const val EXTRA_STREAM_NAMES = "atmo_stream_names"
const val EXTRA_PLAYLIST_URIS = "atmo_playlist_uris"
const val EXTRA_PLAYLIST_NAMES = "atmo_playlist_names"
const val EXTRA_SHUFFLE = "atmo_shuffle"
const val EXTRA_TITLE = "atmo_title"
const val EXTRA_SOURCE = "atmo_source"
private const val TAG = "AtmoPlaybackService"
private const val NOTIFICATION_ID = 1101
const val CHANNEL_ID = "atmo_playback_channel"
// ─── Process-singleton state-flow ────────────────────────────────────
//
// Der State-Flow existiert auch, wenn der Service gerade nicht läuft
// dann enthält er nur den leeren MusicPlaybackState. So muss die UI
// nicht zwischen "Service nicht da" und "Service läuft" unterscheiden.
private val _state = MutableStateFlow(MusicPlaybackState())
val state: StateFlow<MusicPlaybackState> = _state.asStateFlow()
internal fun publishStateExternal(s: MusicPlaybackState) {
_state.value = s
}
// ─── Companion API used by BackgroundMusicPlayer / Manager ───────────
/**
* Startet den Service mit der gegebenen Quelle. Wenn der Service bereits
* läuft, wird die Wiedergabe an die neue Quelle angepasst (Player wird
* intern neu aufgebaut).
*/
fun start(context: Context, settings: TourAudioSettings) {
val intent = Intent(context, AtmoPlaybackService::class.java).apply {
action = ACTION_START
putSettings(settings)
putExtra(EXTRA_TITLE, titleFor(settings))
putExtra(EXTRA_SOURCE, sourceFor(settings))
}
sendIntent(context, intent)
}
fun updateSettings(context: Context, settings: TourAudioSettings) {
val intent = Intent(context, AtmoPlaybackService::class.java).apply {
action = ACTION_UPDATE_SETTINGS
putSettings(settings)
putExtra(EXTRA_TITLE, titleFor(settings))
putExtra(EXTRA_SOURCE, sourceFor(settings))
}
sendIntent(context, intent)
}
/**
* Vollständiger Stop (User hat Atmo gestoppt, Tour gewechselt, etc.).
* Service beendet sich danach selbst.
*/
fun stop(context: Context) {
val intent = Intent(context, AtmoPlaybackService::class.java).apply {
action = ACTION_STOP
}
sendIntent(context, intent)
}
/** Temporäre Pause (PTT, Wegpunkt-Audio). Service bleibt aktiv. */
fun pause(context: Context) {
val intent = Intent(context, AtmoPlaybackService::class.java).apply {
action = ACTION_PAUSE
}
sendIntent(context, intent)
}
/** Resume nach Pause (PTT-Ende, Wegpunkt-Ende). */
fun resume(context: Context) {
val intent = Intent(context, AtmoPlaybackService::class.java).apply {
action = ACTION_RESUME
}
sendIntent(context, intent)
}
fun next(context: Context) {
val intent = Intent(context, AtmoPlaybackService::class.java).apply {
action = ACTION_NEXT
}
sendIntent(context, intent)
}
fun previous(context: Context) {
val intent = Intent(context, AtmoPlaybackService::class.java).apply {
action = ACTION_PREVIOUS
}
sendIntent(context, intent)
}
fun setVolume(context: Context, volume: Float) {
val intent = Intent(context, AtmoPlaybackService::class.java).apply {
action = ACTION_SET_VOLUME
putExtra(EXTRA_VOLUME, volume)
}
sendIntent(context, intent)
}
private fun Intent.putSettings(settings: TourAudioSettings) {
val isStream = settings.sourceType == MusicSourceType.STREAM_URL
putExtra(EXTRA_IS_STREAM, isStream)
if (isStream) {
val entries = settings.effectiveStreamPlaylist
val urls = entries.map { it.url }.toTypedArray()
val names = entries.map { it.name }.toTypedArray()
putExtra(EXTRA_STREAM_URLS, urls)
putExtra(EXTRA_STREAM_NAMES, names)
// Legacy-Feld weiterhin mitschicken (Erstes Element), damit ältere
// Re-Delivery-Intents nicht ins Leere laufen.
putExtra(EXTRA_STREAM_URL, entries.firstOrNull()?.url.orEmpty())
} else {
val uris = settings.localPlaylist.map { it.uriString }.toTypedArray()
val names = settings.localPlaylist.map { it.displayName }.toTypedArray()
putExtra(EXTRA_PLAYLIST_URIS, uris)
putExtra(EXTRA_PLAYLIST_NAMES, names)
}
putExtra(EXTRA_SHUFFLE, settings.shuffle)
}
private fun titleFor(settings: TourAudioSettings): String = when (settings.sourceType) {
MusicSourceType.STREAM_URL -> "Stream"
MusicSourceType.LOCAL_PLAYLIST -> "Playlist"
}
private fun sourceFor(settings: TourAudioSettings): String = when (settings.sourceType) {
MusicSourceType.STREAM_URL -> {
val entries = settings.effectiveStreamPlaylist
when {
entries.isEmpty() -> ""
entries.size == 1 -> {
val url = entries.first().url
runCatching { java.net.URL(url).host.ifBlank { url } }.getOrElse { url }
}
else -> "${entries.size} Streams"
}
}
MusicSourceType.LOCAL_PLAYLIST -> "${settings.localPlaylist.size} Titel"
}
private fun sendIntent(context: Context, intent: Intent) {
runCatching {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent)
} else {
context.startService(intent)
}
}.onFailure { e ->
Log.w(TAG, "Konnte Intent ${intent.action} nicht zustellen", e)
}
}
}
private val mainScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private var progressJob: Job? = null
private var player: ExoPlayer? = null
private var isStream: Boolean = false
private var loadedPlaylist: List<PlaylistItem> = emptyList()
private var loadedStreamEntries: List<StreamEntry> = emptyList()
private var streamUrl: String = ""
private var normalVolume: Float = 1.0f
private var foregroundStarted: Boolean = false
private var currentTitle: String = ""
private var currentSource: String = ""
// ─── Lifecycle ─────────────────────────────────────────────────────────────
override fun onCreate() {
super.onCreate()
createChannel()
promoteToForeground(buildNotification("", ""))
Log.i(TAG, "AtmoPlaybackService erstellt")
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (!foregroundStarted) {
promoteToForeground(buildNotification(currentTitle, currentSource))
}
when (intent?.action) {
ACTION_START -> handleStart(intent)
ACTION_UPDATE_SETTINGS -> handleUpdateSettings(intent)
ACTION_STOP -> handleStop()
ACTION_PAUSE -> handlePause()
ACTION_RESUME -> handleResume()
ACTION_NEXT -> handleNext()
ACTION_PREVIOUS -> handlePrevious()
ACTION_SET_VOLUME -> handleSetVolume(intent.getFloatExtra(EXTRA_VOLUME, 1.0f))
else -> {
// Null intent kann passieren bei Re-Delivery / System-Restart.
// Wir lassen den Service in dem Fall **am Leben** (Player intakt)
// andernfalls würden wir laufende Wiedergabe killen.
Log.d(TAG, "onStartCommand mit null/unbekannter Action: ${intent?.action}")
}
}
// START_STICKY: Wenn der Prozess vom System gekillt wird (was bei FGS
// selten passiert), versucht Android den Service wiederherzustellen.
// Anders als v2.0.6 (START_NOT_STICKY) verlieren wir damit nicht den
// Anker für die Hintergrund-Wiedergabe.
return START_STICKY
}
override fun onBind(intent: Intent?): IBinder? = null
override fun onDestroy() {
Log.i(TAG, "AtmoPlaybackService onDestroy Player wird freigegeben")
progressJob?.cancel()
runCatching {
player?.stop()
player?.release()
}
player = null
mainScope.cancel()
publishStateExternal(MusicPlaybackState())
stopForegroundCompat()
super.onDestroy()
}
override fun onTaskRemoved(rootIntent: Intent?) {
// Wenn der Nutzer die App aus den Recents wischt, lassen wir die
// Wiedergabe explizit weiterlaufen das ist genau das Verhalten,
// das die User von einem Musik-Player erwarten. Sie können in der
// Notification weiterhin stoppen.
super.onTaskRemoved(rootIntent)
}
// ─── Action handlers ───────────────────────────────────────────────────────
private fun handleStart(intent: Intent) {
applySettingsFromIntent(intent, startPlayback = true)
}
private fun handleUpdateSettings(intent: Intent) {
val newIsStream = intent.getBooleanExtra(EXTRA_IS_STREAM, false)
val newStreamUrls = readStreamUrls(intent)
val newUris = intent.getStringArrayExtra(EXTRA_PLAYLIST_URIS).orEmpty().toList()
val newShuffle = intent.getBooleanExtra(EXTRA_SHUFFLE, false)
val sameSource = (isStream == newIsStream) && when {
newIsStream -> loadedStreamEntries.map { it.url } == newStreamUrls
else -> loadedPlaylist.map { it.uriString } == newUris
}
val currentlyPlaying = player?.isPlaying == true
if (!sameSource) {
applySettingsFromIntent(intent, startPlayback = currentlyPlaying)
} else {
player?.shuffleModeEnabled = newShuffle
currentTitle = intent.getStringExtra(EXTRA_TITLE).orEmpty()
currentSource = intent.getStringExtra(EXTRA_SOURCE).orEmpty()
refreshNotification()
publishCurrentState()
}
}
/**
* Liest die Stream-URLs aus dem Intent. Bevorzugt das neue Array (`EXTRA_STREAM_URLS`),
* fällt auf das Legacy-Feld (`EXTRA_STREAM_URL`) zurück.
*/
private fun readStreamUrls(intent: Intent): List<String> {
val arr = intent.getStringArrayExtra(EXTRA_STREAM_URLS)
if (!arr.isNullOrEmpty()) return arr.toList()
val legacy = intent.getStringExtra(EXTRA_STREAM_URL).orEmpty()
return if (legacy.isNotBlank()) listOf(legacy) else emptyList()
}
private fun readStreamNames(intent: Intent): List<String> {
val arr = intent.getStringArrayExtra(EXTRA_STREAM_NAMES)
if (arr != null) return arr.toList()
return emptyList()
}
private fun applySettingsFromIntent(intent: Intent, startPlayback: Boolean) {
val newIsStream = intent.getBooleanExtra(EXTRA_IS_STREAM, false)
val newStreamUrls = readStreamUrls(intent)
val newStreamNames = readStreamNames(intent)
val newUris = intent.getStringArrayExtra(EXTRA_PLAYLIST_URIS).orEmpty()
val newNames = intent.getStringArrayExtra(EXTRA_PLAYLIST_NAMES).orEmpty()
val newShuffle = intent.getBooleanExtra(EXTRA_SHUFFLE, false)
currentTitle = intent.getStringExtra(EXTRA_TITLE).orEmpty()
currentSource = intent.getStringExtra(EXTRA_SOURCE).orEmpty()
isStream = newIsStream
streamUrl = newStreamUrls.firstOrNull().orEmpty()
loadedStreamEntries = if (newIsStream) {
newStreamUrls.mapIndexed { i, url ->
StreamEntry(url = url, name = newStreamNames.getOrNull(i).orEmpty())
}
} else emptyList()
loadedPlaylist = if (newIsStream) emptyList()
else newUris.mapIndexed { i, uri ->
PlaylistItem(uri, newNames.getOrNull(i).orEmpty())
}
val items = if (newIsStream) {
buildStreamItems(loadedStreamEntries)
} else {
buildLocalItems(loadedPlaylist)
}
if (items.isEmpty()) {
Log.w(TAG, "Keine abspielbaren Quellen gefunden")
publishCurrentState()
return
}
releasePlayerInternal()
val exo = ExoPlayer.Builder(applicationContext).build().also { p ->
val attrs = AudioAttributes.Builder()
.setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
.setUsage(C.USAGE_MEDIA)
.build()
p.setAudioAttributes(attrs, /* handleAudioFocus = */ false)
p.setHandleAudioBecomingNoisy(true)
if (newIsStream) {
p.setWakeMode(C.WAKE_MODE_NETWORK)
} else {
p.setWakeMode(C.WAKE_MODE_LOCAL)
}
p.setMediaItems(items)
p.repeatMode = Player.REPEAT_MODE_ALL
p.shuffleModeEnabled = newShuffle
p.volume = normalVolume
p.addListener(object : Player.Listener {
override fun onPlayerError(error: androidx.media3.common.PlaybackException) {
Log.e(TAG, "ExoPlayer Fehler: ${error.message}", error)
publishCurrentState()
}
override fun onIsPlayingChanged(playing: Boolean) {
publishCurrentState()
refreshNotification()
}
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
publishCurrentState()
}
override fun onPlaybackStateChanged(playbackState: Int) {
publishCurrentState()
}
})
p.prepare()
if (startPlayback) p.play()
}
player = exo
startProgressPolling()
refreshNotification()
publishCurrentState()
Log.i(TAG, "Player initialisiert (stream=$newIsStream, items=${items.size})")
}
private fun handleStop() {
Log.i(TAG, "ACTION_STOP Service wird beendet")
progressJob?.cancel()
runCatching {
player?.stop()
player?.release()
}
player = null
loadedPlaylist = emptyList()
loadedStreamEntries = emptyList()
publishStateExternal(MusicPlaybackState())
stopForegroundCompat()
stopSelf()
}
private fun handlePause() {
player?.pause()
publishCurrentState()
refreshNotification()
}
private fun handleResume() {
val p = player ?: run {
Log.d(TAG, "Resume ignoriert Player nicht aufgebaut")
return
}
p.volume = normalVolume
p.play()
publishCurrentState()
refreshNotification()
}
private fun handleNext() {
val p = player ?: return
if (p.hasNextMediaItem()) p.seekToNextMediaItem() else p.seekTo(0, 0)
publishCurrentState()
}
private fun handlePrevious() {
val p = player ?: return
if (p.hasPreviousMediaItem()) p.seekToPreviousMediaItem() else p.seekTo(0, 0)
publishCurrentState()
}
private fun handleSetVolume(volume: Float) {
val v = volume.coerceIn(0f, 1f)
player?.volume = v
}
// ─── Helpers ───────────────────────────────────────────────────────────────
private fun releasePlayerInternal() {
progressJob?.cancel()
progressJob = null
runCatching {
player?.stop()
player?.release()
}
player = null
}
private fun buildStreamItems(entries: List<StreamEntry>): List<MediaItem> {
return entries.mapNotNull { entry ->
val url = entry.url
if (url.isBlank()) return@mapNotNull null
val lower = url.lowercase()
if (!lower.startsWith("http://") && !lower.startsWith("https://")) {
Log.w(TAG, "Stream-URL ungültig: $url")
return@mapNotNull null
}
MediaItem.Builder().setUri(Uri.parse(url)).build()
}
}
private fun buildLocalItems(playlist: List<PlaylistItem>): List<MediaItem> {
return playlist.mapNotNull { item ->
runCatching {
val uri = Uri.parse(item.uriString)
runCatching {
applicationContext.contentResolver.takePersistableUriPermission(
uri,
android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION
)
}
MediaItem.Builder().setUri(uri).build()
}.onFailure { e ->
Log.e(TAG, "Ungültige URI: ${item.uriString}", e)
}.getOrNull()
}
}
private fun startProgressPolling() {
progressJob?.cancel()
progressJob = mainScope.launch {
while (isActive) {
delay(500L)
publishCurrentState()
}
}
}
private fun publishCurrentState() {
val p = player
if (p == null) {
publishStateExternal(MusicPlaybackState())
return
}
val idx = p.currentMediaItemIndex
val total = p.mediaItemCount
val playlist = loadedPlaylist
val streams = loadedStreamEntries
val title: String = when {
isStream -> {
if (streams.isNotEmpty() && idx in streams.indices) {
val entry = streams[idx]
entry.name.ifBlank {
runCatching { java.net.URL(entry.url).host.ifBlank { entry.url } }
.getOrElse { entry.url }
}
} else streamUrl
}
playlist.isNotEmpty() && idx in playlist.indices -> playlist[idx].displayName
else -> ""
}
val nextTitle: String = when {
isStream && streams.size > 1 -> {
val nextIdx = (idx + 1) % streams.size
val entry = streams.getOrNull(nextIdx)
entry?.let { e ->
e.name.ifBlank {
runCatching { java.net.URL(e.url).host.ifBlank { e.url } }
.getOrElse { e.url }
}
}.orEmpty()
}
isStream -> ""
playlist.size > 1 -> {
val nextIdx = (idx + 1) % playlist.size
if (nextIdx in playlist.indices) playlist[nextIdx].displayName else ""
}
else -> ""
}
val sourceLabel: String = when {
isStream -> {
when {
streams.size > 1 -> "${streams.size} Streams"
else -> {
val url = streams.firstOrNull()?.url ?: streamUrl
runCatching { java.net.URL(url).host.ifBlank { url } }.getOrElse { url }
}
}
}
else -> "Playlist"
}
val positionMs = if (!isStream) p.currentPosition.coerceAtLeast(0L) else 0L
val durationMs = if (!isStream) {
val d = p.duration
if (d > 0) d else 0L
} else 0L
publishStateExternal(
MusicPlaybackState(
isPlaying = p.isPlaying,
hasContent = true,
currentTitle = title,
sourceLabel = sourceLabel,
isStream = isStream,
positionMs = positionMs,
durationMs = durationMs,
playlistIndex = if (isStream && streams.size > 1) idx
else if (!isStream) idx else -1,
playlistTotal = if (isStream && streams.size > 1) total
else if (!isStream) total else 0,
nextTitle = nextTitle,
supportsSkip = total > 1
)
)
}
// ─── Notification & Foreground ─────────────────────────────────────────────
private fun refreshNotification() {
runCatching {
val nm = getSystemService(NotificationManager::class.java)
nm?.notify(NOTIFICATION_ID, buildNotification(currentTitle, currentSource))
}.onFailure { e ->
Log.w(TAG, "Notification-Update fehlgeschlagen", e)
}
}
private fun promoteToForeground(notification: Notification) {
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
startForeground(
NOTIFICATION_ID,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK
)
} else {
startForeground(NOTIFICATION_ID, notification)
}
foregroundStarted = true
} catch (e: Throwable) {
Log.w(TAG, "startForeground fehlgeschlagen", e)
}
}
private fun stopForegroundCompat() {
runCatching {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
stopForeground(Service.STOP_FOREGROUND_REMOVE)
} else {
@Suppress("DEPRECATION")
stopForeground(true)
}
}
foregroundStarted = false
}
private fun createChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val nm = getSystemService(NotificationManager::class.java) ?: return
if (nm.getNotificationChannel(CHANNEL_ID) != null) return
val channel = NotificationChannel(
CHANNEL_ID,
getString(R.string.atmo_notification_channel_name),
NotificationManager.IMPORTANCE_LOW
).apply {
description = getString(R.string.atmo_notification_channel_description)
setShowBadge(false)
}
nm.createNotificationChannel(channel)
}
private fun buildNotification(title: String, source: String): Notification {
val openAppIntent = Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
}
val contentIntent = PendingIntent.getActivity(
this,
0,
openAppIntent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
val stopIntent = Intent(this, AtmoPlaybackService::class.java).apply {
action = ACTION_STOP
}
val stopPending = PendingIntent.getService(
this,
1,
stopIntent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
val safeTitle = title.ifBlank { getString(R.string.atmo_notification_default_title) }
val safeSource = source.ifBlank { getString(R.string.atmo_notification_default_source) }
return NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_launcher)
.setContentTitle(safeTitle)
.setContentText(safeSource)
.setOngoing(true)
.setOnlyAlertOnce(true)
.setSilent(true)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setCategory(NotificationCompat.CATEGORY_TRANSPORT)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setShowWhen(false)
.setContentIntent(contentIntent)
.addAction(
NotificationCompat.Action.Builder(
R.drawable.ic_launcher,
getString(R.string.music_test_stop),
stopPending
).build()
)
.build()
}
}
@@ -0,0 +1,135 @@
package de.waypointaudio.service
import android.content.Context
import android.util.Log
/**
* Zentrale Stelle für das Pausieren und Wiederaufnehmen der Atmo (Begleitmusik)
* bei beliebigen Unterbrechungen.
*
* Drei Unterbrechungsfälle in der App:
* 1. Wegpunkt-Audio (GPS-getriggert oder manuell)
* 2. Manueller Player (Einzeldatei / Playlist)
* 3. Push-To-Talk (Live-Sprechen)
*
* Kernregel: Bei jeder Unterbrechung **pause()** statt **stop()** aufrufen,
* damit der Player die aktuelle Position behält. Nach der Unterbrechung wird
* nur dann fortgesetzt, wenn die Atmo unmittelbar vor der Unterbrechung lief.
*
* Die Wegpunkt-Pfade ([BackgroundMusicPlayer.beforeWaypointAudio] /
* [BackgroundMusicPlayer.afterWaypointAudio]) bringen bereits eigene Fade/Duck-
* Logik mit dieser Manager ist explizit für **alle** Unterbrechungswege ein
* einheitlicher Einstiegspunkt, der die korrekte pause/resume-Semantik
* sicherstellt und gegen doppelte Aufrufe robust ist.
*/
class AtmoResumeManager private constructor(private val appContext: Context) {
companion object {
private const val TAG = "AtmoResumeManager"
@Volatile
private var instance: AtmoResumeManager? = null
fun getInstance(context: Context): AtmoResumeManager {
return instance ?: synchronized(this) {
instance ?: AtmoResumeManager(context.applicationContext).also { instance = it }
}
}
}
/**
* Ein einzelner aktiver Unterbrechungs-Kontext.
* Hält den Zustand, ob die Atmo zum Zeitpunkt des Beginns spielte,
* damit beim Ende dieselbe Information für den Resume genutzt werden kann.
*/
enum class Source { WAYPOINT, MANUAL_PLAYER, PTT }
/**
* Aktuell aktive Unterbrechungen (Set, da gleichzeitig auftretbar:
* z. B. Wegpunkt-Audio + PTT). Atmo wird erst fortgesetzt, wenn alle
* Unterbrechungen beendet sind.
*/
private val activeInterruptions = mutableSetOf<Source>()
/** Snapshot, ob die Atmo lief, als die *erste* Unterbrechung begann. */
private var wasAtmoPlayingAtFirstInterruption: Boolean = false
/**
* Beginnt eine Unterbrechung. Pausiert die Atmo nur dann, wenn sie aktiv lief
* (Position wird intern in der MediaPlayer-Instanz erhalten).
*/
@Synchronized
fun notifyInterruptStart(source: Source) {
val firstInterruption = activeInterruptions.isEmpty()
activeInterruptions.add(source)
if (!firstInterruption) {
Log.d(TAG, "Zusätzliche Unterbrechung: $source (aktiv: $activeInterruptions)")
return
}
runCatching {
val mgr = BackgroundMusicManager.getInstance(appContext)
val playingNow = mgr.player.isPlaying
wasAtmoPlayingAtFirstInterruption = playingNow
if (playingNow) {
mgr.pauseMusic()
Log.d(TAG, "Atmo pausiert wegen $source")
} else {
Log.d(TAG, "Atmo lief nicht keine Pause nötig ($source)")
}
}.onFailure { e ->
Log.w(TAG, "Konnte Atmo nicht pausieren ($source)", e)
wasAtmoPlayingAtFirstInterruption = false
}
}
/**
* Beendet eine Unterbrechung. Atmo wird nur dann fortgesetzt, wenn
* - sie zum Zeitpunkt der *ersten* Unterbrechung lief, und
* - keine weitere Unterbrechung mehr aktiv ist.
*
* Wurde die Atmo zwischenzeitlich vom Nutzer komplett gestoppt
* ([BackgroundMusicPlayer.stop]), so ist `player.hasContent == false`
* und wir versuchen keinen Resume.
*/
@Synchronized
fun notifyInterruptEnd(source: Source) {
activeInterruptions.remove(source)
if (activeInterruptions.isNotEmpty()) {
Log.d(TAG, "Unterbrechung $source beendet, weitere aktiv: $activeInterruptions")
return
}
val shouldResume = wasAtmoPlayingAtFirstInterruption
wasAtmoPlayingAtFirstInterruption = false
if (!shouldResume) {
Log.d(TAG, "Atmo nicht fortgesetzt: vor Unterbrechung nicht aktiv")
return
}
runCatching {
val mgr = BackgroundMusicManager.getInstance(appContext)
val settings = mgr.currentSettings
if (!settings.enabled) {
Log.d(TAG, "Atmo nicht fortgesetzt: in Tour-Einstellungen deaktiviert")
return@runCatching
}
if (!mgr.player.hasContent) {
Log.d(TAG, "Atmo nicht fortgesetzt: während Unterbrechung manuell gestoppt")
return@runCatching
}
if (mgr.player.isPlaying) {
Log.d(TAG, "Atmo läuft bereits kein erneuter Start")
return@runCatching
}
mgr.player.play()
Log.d(TAG, "Atmo fortgesetzt nach $source")
}.onFailure { e ->
Log.w(TAG, "Konnte Atmo nicht fortsetzen ($source)", e)
}
}
/** Notfall-Reset (z. B. Service-Restart). */
@Synchronized
fun reset() {
activeInterruptions.clear()
wasAtmoPlayingAtFirstInterruption = false
}
}
@@ -104,6 +104,44 @@ class AudioPlayer {
mediaPlayer = null
}
/**
* Pausiert die aktuelle Wiedergabe ohne den MediaPlayer freizugeben.
* Anschließend kann [resume] aufgerufen werden, um an derselben Position
* weiterzuspielen. Wird verwendet, wenn PTT-Sprechen einsetzt und das
* Wegpunkt-Audio während dieser Zeit nicht hörbar sein soll.
*/
fun pause() {
runCatching {
mediaPlayer?.let {
if (it.isPlaying) it.pause()
}
}.onFailure { e ->
Log.w(TAG, "Pause fehlgeschlagen", e)
}
}
/**
* Setzt eine zuvor mit [pause] angehaltene Wiedergabe fort. Wenn der
* MediaPlayer bereits freigegeben wurde oder nicht im Pause-Zustand ist,
* passiert nichts.
*/
fun resume() {
runCatching {
val mp = mediaPlayer ?: return
if (!mp.isPlaying) mp.start()
}.onFailure { e ->
Log.w(TAG, "Resume fehlgeschlagen", e)
}
}
/** True, wenn aktuell eine Wiedergabe aktiv ist (nicht pausiert/gestoppt). */
val isPlaying: Boolean
get() = runCatching { mediaPlayer?.isPlaying == true }.getOrDefault(false)
/** True, wenn ein MediaPlayer existiert (spielt oder pausiert). */
val hasContent: Boolean
get() = mediaPlayer != null
companion object {
private const val TAG = "AudioPlayer"
}
@@ -1,16 +1,8 @@
package de.waypointaudio.service
import android.content.Context
import android.net.Uri
import android.util.Log
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.datasource.DefaultDataSource
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.MediaSource
import androidx.media3.exoplayer.source.ProgressiveMediaSource
import de.waypointaudio.data.MusicSourceType
import de.waypointaudio.data.PlaylistItem
import de.waypointaudio.data.TourAudioSettings
import de.waypointaudio.data.WaypointMusicBehavior
import kotlinx.coroutines.CoroutineScope
@@ -19,12 +11,9 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* Zustand des Begleitmusik-Players, der in der UI angezeigt werden kann.
@@ -55,18 +44,27 @@ data class MusicPlaybackState(
)
/**
* Begleitmusik-Player auf Basis von AndroidX Media3 ExoPlayer.
* Begleitmusik-Player ab v2.0.7 ein **dünner Facade-Wrapper** um
* [AtmoPlaybackService].
*
* Unterstützt:
* - Lokale Playlists via content:// URIs (persistable permissions werden beim Abspielen gesichert)
* - Direkte HTTP/HTTPS Audio-Stream-URLs
* - Fade-Out/In, Duck (Lautstärke reduzieren), Pause/Resume, Weiter-Spielen
* - Optionaler Auto-Start nach Wegpunkt-Audio (autoStartAfterWaypoint)
* Architektur:
* - Die eigentliche ExoPlayer-Instanz lebt im Foreground-Service
* [AtmoPlaybackService]. Der Service wird gestartet, sobald
* [play] / [buildPlayer] aufgerufen werden, und bleibt am Leben, bis
* [stop] aufgerufen wird.
* - Der Service exponiert einen process-singleton StateFlow, den dieser
* Player über [playbackState] direkt weiterleitet. UI und ViewModel
* abonnieren denselben Flow.
* - Pause/Resume/Next/Previous/SetVolume gehen ausschließlich über
* Service-Intents die Activity-Lifecycle hat keinen Einfluss mehr
* auf die Wiedergabe.
*
* Lifecycle: Instanz an ViewModel oder Application koppeln; [release] beim Aufräumen aufrufen.
* Bestehende öffentliche API (siehe v2.0.6) bleibt erhalten, damit Manager
* und ViewModel keine Anpassungen brauchen. [isPlaying] und [hasContent]
* werden jetzt aus dem letzten Snapshot des Service-StateFlows abgeleitet.
*
* Thread-Sicherheit: ExoPlayer-Zugriffe müssen auf dem Main-Thread erfolgen.
* Alle öffentlichen Methoden müssen aus dem Main-Dispatcher aufgerufen werden.
* Fade-/Duck-Animationen werden über kurze Koroutinen umgesetzt, die
* wiederholt [AtmoPlaybackService.setVolume] aufrufen.
*/
class BackgroundMusicPlayer(private val context: Context) {
@@ -76,49 +74,37 @@ class BackgroundMusicPlayer(private val context: Context) {
}
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private var player: ExoPlayer? = null
private var fadeJob: Job? = null
private var progressJob: Job? = null
// Aktuell geladene Einstellungen
private var currentSettings: TourAudioSettings? = null
// Ziel-Lautstärke im Normalbetrieb (vor Duck/Fade)
private var normalVolume: Float = 1.0f
private var lastSetVolume: Float = 1.0f
// True wenn Musik gerade wegen Wegpunkt-Audio unterdrückt wird
private var waypointSuppressed: Boolean = false
// True wenn Player war aktiv bevor Waypoint ausgelöst wurde
private var wasPlayingBeforeWaypoint: Boolean = false
// Snapshot-Liste der geladenen PlaylistItems (für Titel-Lookup)
private var loadedPlaylist: List<PlaylistItem> = emptyList()
/**
* State-Flow direkt vom Service. Wenn der Service noch nie gestartet wurde,
* enthält der Flow den leeren MusicPlaybackState (alle Felder default).
*/
val playbackState: StateFlow<MusicPlaybackState> = AtmoPlaybackService.state
// -----------------------------------------------------------------------
// Öffentlicher State-Flow
// Öffentliche Status-Getter (abgeleitet aus dem Service-Flow)
// -----------------------------------------------------------------------
private val _playbackState = MutableStateFlow(MusicPlaybackState())
/** Aktueller Wiedergabe-Zustand; sammle diesen Flow in der UI. */
val playbackState: StateFlow<MusicPlaybackState> = _playbackState.asStateFlow()
/** Ob gerade Musik abgespielt wird (nicht pausiert/gestoppt). */
val isPlaying: Boolean
get() = playbackState.value.isPlaying
/** Ob ein Track geladen ist (auch wenn pausiert). */
val hasContent: Boolean
get() = playbackState.value.hasContent
// -----------------------------------------------------------------------
// Öffentliche API
// -----------------------------------------------------------------------
/** Ob gerade Musik abgespielt wird (nicht pausiert/gestoppt). */
val isPlaying: Boolean
get() = player?.isPlaying == true
/** Ob ein Track geladen ist (auch wenn pausiert). */
val hasContent: Boolean
get() = player != null
/**
* Lädt neue Einstellungen und bereitet den Player vor.
* Stoppt zuvor laufende Wiedergabe wenn Quelle sich geändert hat.
*/
fun loadSettings(settings: TourAudioSettings) {
currentSettings = settings
normalVolume = 1.0f
@@ -126,73 +112,77 @@ class BackgroundMusicPlayer(private val context: Context) {
/**
* Startet Wiedergabe mit den aktuell geladenen Einstellungen.
* Baut ExoPlayer auf wenn nötig.
* Wenn der Service schon läuft mit gleicher Quelle: einfach Resume.
* Sonst: neue Quelle an Service übergeben (Service baut Player neu auf).
*/
fun play() {
val settings = currentSettings ?: return
if (!settings.enabled) return
val srcOk = when (settings.sourceType) {
MusicSourceType.LOCAL_PLAYLIST -> settings.localPlaylist.isNotEmpty()
MusicSourceType.STREAM_URL -> settings.effectiveStreamPlaylist.isNotEmpty()
}
if (!srcOk) {
Log.w(TAG, "play(): keine Quelle konfiguriert")
return
}
if (player == null) {
buildPlayer(settings)
if (hasContent) {
// Service hat bereits einen Player Resume genügt.
AtmoPlaybackService.resume(context)
} else {
player?.play()
buildPlayer(settings)
}
}
/** Pausiert Wiedergabe ohne Ressourcen freizugeben. */
fun pause() {
cancelFade()
player?.pause()
updateState()
AtmoPlaybackService.pause(context)
}
/** Stoppt Wiedergabe und gibt ExoPlayer frei. */
fun stop() {
cancelFade()
releasePlayer()
updateState()
}
/** Nächster Titel in der Playlist (nur für lokale Playlists sinnvoll). */
fun next() {
val p = player ?: return
if (p.hasNextMediaItem()) p.seekToNextMediaItem() else p.seekTo(0, 0)
updateState()
}
/** Vorheriger Titel in der Playlist. */
fun previous() {
val p = player ?: return
if (p.hasPreviousMediaItem()) p.seekToPreviousMediaItem() else p.seekTo(0, 0)
updateState()
}
/** Setzt die Lautstärke sofort (0.0 1.0). */
fun setVolume(volume: Float) {
player?.volume = volume.coerceIn(0f, 1f)
}
/** Gibt alle Ressourcen frei. Nach diesem Aufruf ist die Instanz nicht mehr nutzbar. */
fun release() {
scope.cancel()
releasePlayer()
}
// -----------------------------------------------------------------------
// Wegpunkt-Interaktion
// -----------------------------------------------------------------------
/**
* Vor dem Abspielen eines Wegpunkt-Audios aufrufen.
* Wendet das konfigurierte Verhalten an (Pause, Fade-Out, Duck, Weiter).
* Stoppt Wiedergabe komplett und beendet den Service.
* Service stoppt sich danach selbst (FGS-Notification verschwindet).
*/
fun stop() {
cancelFade()
AtmoPlaybackService.stop(context)
}
fun next() {
AtmoPlaybackService.next(context)
}
fun previous() {
AtmoPlaybackService.previous(context)
}
fun setVolume(volume: Float) {
val v = volume.coerceIn(0f, 1f)
lastSetVolume = v
AtmoPlaybackService.setVolume(context, v)
}
/**
* Gibt alle Ressourcen frei. Wird beim App-Shutdown aufgerufen.
* Der Service wird über [stop] beendet.
*/
fun release() {
scope.cancel()
AtmoPlaybackService.stop(context)
}
// -----------------------------------------------------------------------
// Wegpunkt-Interaktion (verändert sich nicht delegiert an Service via API)
// -----------------------------------------------------------------------
fun beforeWaypointAudio(settings: TourAudioSettings) {
if (!settings.enabled) return
wasPlayingBeforeWaypoint = isPlaying
waypointSuppressed = true
// Nur wenn player vorhanden (Musik läuft/pausiert)
if (player == null) return
if (!hasContent) return
when (settings.behavior) {
WaypointMusicBehavior.PAUSE_RESUME -> {
@@ -201,7 +191,7 @@ class BackgroundMusicPlayer(private val context: Context) {
WaypointMusicBehavior.FADE_OUT_IN -> {
if (wasPlayingBeforeWaypoint) {
fadeOut(settings.fadeDurationMs) {
player?.pause()
AtmoPlaybackService.pause(context)
}
}
}
@@ -211,16 +201,11 @@ class BackgroundMusicPlayer(private val context: Context) {
}
}
WaypointMusicBehavior.CONTINUE_UNDERLAY -> {
// Nichts tun Musik läuft auf normaler Lautstärke weiter
// Nichts tun.
}
}
}
/**
* Nach dem Abspielen eines Wegpunkt-Audios aufrufen.
* Stellt vorherigen Zustand wieder her.
* Wenn autoStartAfterWaypoint aktiv und Musik war nicht spielend, wird sie gestartet.
*/
fun afterWaypointAudio(settings: TourAudioSettings) {
if (!settings.enabled) {
waypointSuppressed = false
@@ -228,7 +213,6 @@ class BackgroundMusicPlayer(private val context: Context) {
}
val wasSuppressed = waypointSuppressed
waypointSuppressed = false
if (!wasSuppressed) return
val shouldAutoStart = settings.autoStartAfterWaypoint && !wasPlayingBeforeWaypoint
@@ -236,15 +220,15 @@ class BackgroundMusicPlayer(private val context: Context) {
when (settings.behavior) {
WaypointMusicBehavior.PAUSE_RESUME -> {
if (wasPlayingBeforeWaypoint) {
player?.play()
AtmoPlaybackService.resume(context)
} else if (shouldAutoStart) {
startOrPlay(settings)
}
}
WaypointMusicBehavior.FADE_OUT_IN -> {
if (wasPlayingBeforeWaypoint) {
player?.volume = 0f
player?.play()
AtmoPlaybackService.setVolume(context, 0f)
AtmoPlaybackService.resume(context)
fadeIn(settings.fadeDurationMs)
} else if (shouldAutoStart) {
startOrPlay(settings)
@@ -259,9 +243,7 @@ class BackgroundMusicPlayer(private val context: Context) {
}
}
WaypointMusicBehavior.CONTINUE_UNDERLAY -> {
if (shouldAutoStart) {
startOrPlay(settings)
}
if (shouldAutoStart) startOrPlay(settings)
}
}
}
@@ -270,186 +252,25 @@ class BackgroundMusicPlayer(private val context: Context) {
// Interner Aufbau
// -----------------------------------------------------------------------
/**
* Startet Wiedergabe: baut Player neu wenn noch nicht vorhanden, sonst play().
*/
private fun startOrPlay(settings: TourAudioSettings) {
if (player == null) {
if (!hasContent) {
buildPlayer(settings)
} else {
player?.play()
AtmoPlaybackService.resume(context)
}
}
/**
* Sendet die neue Quelle an den Service. Der Service wird ggf. gestartet
* (mit `startForegroundService`) und baut den ExoPlayer intern auf.
*/
private fun buildPlayer(settings: TourAudioSettings) {
releasePlayer()
val isStream = settings.sourceType == MusicSourceType.STREAM_URL
val items = when (settings.sourceType) {
MusicSourceType.LOCAL_PLAYLIST -> buildLocalItems(settings.localPlaylist)
MusicSourceType.STREAM_URL -> buildStreamItems(settings.streamUrl)
}
if (items.isEmpty()) {
Log.w(TAG, "Keine abspielbaren Quellen gefunden")
return
}
// Playlist-Snapshot für Titel-Lookup speichern
loadedPlaylist = if (isStream) emptyList() else settings.localPlaylist
val exo = ExoPlayer.Builder(context).build().also { p ->
p.setMediaItems(items)
p.repeatMode = Player.REPEAT_MODE_ALL
p.shuffleModeEnabled = settings.shuffle
p.volume = normalVolume
p.addListener(object : Player.Listener {
override fun onPlayerError(error: androidx.media3.common.PlaybackException) {
Log.e(TAG, "ExoPlayer Fehler: ${error.message}", error)
updateState()
}
override fun onIsPlayingChanged(playing: Boolean) {
updateState()
}
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
updateState()
}
override fun onPlaybackStateChanged(playbackState: Int) {
updateState()
}
})
p.prepare()
p.play()
}
player = exo
Log.i(TAG, "BackgroundMusicPlayer gestartet (${settings.sourceType})")
startProgressPolling()
updateState()
}
/** Startet ein Coroutine-Polling für die Wiedergabe-Position (lokale Dateien). */
private fun startProgressPolling() {
progressJob?.cancel()
progressJob = scope.launch {
while (isActive) {
delay(500L)
updateState()
}
}
}
/** Liest den aktuellen Player-Zustand und schreibt ihn in [_playbackState]. */
private fun updateState() {
val p = player
val settings = currentSettings
if (p == null || settings == null) {
_playbackState.value = MusicPlaybackState()
return
}
val isStream = settings.sourceType == MusicSourceType.STREAM_URL
val idx = p.currentMediaItemIndex
val total = p.mediaItemCount
val playlist = loadedPlaylist
// Titel des aktuellen Tracks
val currentTitle: String = when {
isStream -> settings.streamUrl ?: ""
playlist.isNotEmpty() && idx in playlist.indices -> playlist[idx].displayName
else -> ""
}
// Titel des nächsten Tracks
val nextTitle: String = when {
isStream -> ""
playlist.size > 1 -> {
val nextIdx = (idx + 1) % playlist.size
if (nextIdx in playlist.indices) playlist[nextIdx].displayName else ""
}
else -> ""
}
// Quell-Label
val sourceLabel: String = when {
isStream -> {
val url = settings.streamUrl ?: ""
runCatching {
java.net.URL(url).host.ifBlank { url }
}.getOrElse { url }
}
else -> "Playlist"
}
// Position und Dauer nur für lokale Dateien mit bekannter Länge
val positionMs = if (!isStream) p.currentPosition.coerceAtLeast(0L) else 0L
val durationMs = if (!isStream) {
val d = p.duration
if (d > 0) d else 0L
} else 0L
_playbackState.value = MusicPlaybackState(
isPlaying = p.isPlaying,
hasContent = true,
currentTitle = currentTitle,
sourceLabel = sourceLabel,
isStream = isStream,
positionMs = positionMs,
durationMs = durationMs,
playlistIndex = if (!isStream) idx else -1,
playlistTotal = if (!isStream) total else 0,
nextTitle = nextTitle,
supportsSkip = !isStream && total > 1
)
}
private fun buildLocalItems(playlist: List<PlaylistItem>): List<MediaItem> {
return playlist.mapNotNull { item ->
runCatching {
val uri = Uri.parse(item.uriString)
// Persistable URI-Berechtigung sichern (beste-Effort)
runCatching {
context.contentResolver.takePersistableUriPermission(
uri,
android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION
)
}
MediaItem.Builder()
.setUri(uri)
.build()
}.onFailure { e ->
Log.e(TAG, "Ungültige URI: ${item.uriString}", e)
}.getOrNull()
}
}
private fun buildStreamItems(streamUrl: String?): List<MediaItem> {
if (streamUrl.isNullOrBlank()) return emptyList()
val lower = streamUrl.lowercase()
if (!lower.startsWith("http://") && !lower.startsWith("https://")) {
Log.w(TAG, "Stream-URL ist keine gültige http/https-URL: $streamUrl")
return emptyList()
}
return listOf(
MediaItem.Builder()
.setUri(Uri.parse(streamUrl))
.build()
)
}
private fun releasePlayer() {
cancelFade()
progressJob?.cancel()
progressJob = null
runCatching {
player?.stop()
player?.release()
}
player = null
loadedPlaylist = emptyList()
_playbackState.value = MusicPlaybackState()
AtmoPlaybackService.start(context, settings)
Log.i(TAG, "BackgroundMusicPlayer.buildPlayer → Service gestartet (${settings.sourceType})")
}
// -----------------------------------------------------------------------
// Fade-Helfer (Koroutinen-basiert)
// Fade-Helfer
// -----------------------------------------------------------------------
private fun cancelFade() {
@@ -459,7 +280,7 @@ class BackgroundMusicPlayer(private val context: Context) {
private fun fadeOut(durationMs: Long, onDone: () -> Unit) {
cancelFade()
val startVolume = player?.volume ?: 1f
val startVolume = lastSetVolume.coerceIn(0f, 1f)
fadeJob = scope.launch {
val steps = (durationMs / FADE_STEP_INTERVAL_MS).coerceAtLeast(1)
val stepSize = startVolume / steps
@@ -467,13 +288,13 @@ class BackgroundMusicPlayer(private val context: Context) {
repeat(steps.toInt()) {
if (!isActive) return@launch
current = (current - stepSize).coerceAtLeast(0f)
withContext(Dispatchers.Main) { player?.volume = current }
AtmoPlaybackService.setVolume(context, current)
lastSetVolume = current
delay(FADE_STEP_INTERVAL_MS)
}
withContext(Dispatchers.Main) {
player?.volume = 0f
onDone()
}
AtmoPlaybackService.setVolume(context, 0f)
lastSetVolume = 0f
onDone()
}
}
@@ -487,16 +308,18 @@ class BackgroundMusicPlayer(private val context: Context) {
repeat(steps.toInt()) {
if (!isActive) return@launch
current = (current + stepSize).coerceAtMost(targetVolume)
withContext(Dispatchers.Main) { player?.volume = current }
AtmoPlaybackService.setVolume(context, current)
lastSetVolume = current
delay(FADE_STEP_INTERVAL_MS)
}
withContext(Dispatchers.Main) { player?.volume = targetVolume }
AtmoPlaybackService.setVolume(context, targetVolume)
lastSetVolume = targetVolume
}
}
private fun fadeToVolume(targetVolume: Float, durationMs: Long) {
cancelFade()
val startVolume = player?.volume ?: normalVolume
val startVolume = lastSetVolume.coerceIn(0f, 1f)
fadeJob = scope.launch {
val steps = (durationMs / FADE_STEP_INTERVAL_MS).coerceAtLeast(1)
val delta = (targetVolume - startVolume) / steps
@@ -504,10 +327,12 @@ class BackgroundMusicPlayer(private val context: Context) {
repeat(steps.toInt()) {
if (!isActive) return@launch
current = (current + delta).coerceIn(0f, 1f)
withContext(Dispatchers.Main) { player?.volume = current }
AtmoPlaybackService.setVolume(context, current)
lastSetVolume = current
delay(FADE_STEP_INTERVAL_MS)
}
withContext(Dispatchers.Main) { player?.volume = targetVolume }
AtmoPlaybackService.setVolume(context, targetVolume)
lastSetVolume = targetVolume
}
}
}
@@ -1,10 +1,18 @@
package de.waypointaudio.service
import android.Manifest
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.util.Log
import androidx.core.content.ContextCompat
import com.google.android.gms.location.LocationServices
import de.waypointaudio.data.AudioRoutingSettings
import de.waypointaudio.data.AudioRoutingStore
import de.waypointaudio.data.PttRecording
import de.waypointaudio.data.PttRecordingFormat
import de.waypointaudio.data.PttRecordingStore
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -14,23 +22,35 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.tasks.await
import kotlinx.coroutines.withTimeoutOrNull
/**
* App-weiter Singleton-Manager für Live / PTT.
*
* Steuert den [LivePttService] (startet/stoppt den Vordergrundservice) und
* verwaltet den Status (pttActive, Gerätewahl, Fehler).
* Steuert den [LivePttService] (Live-Ausgabe) und/oder den [PttRecorder] (Aufnahme),
* je nach Nutzer-Einstellungen ([AudioRoutingSettings.liveOutputEnabled],
* [AudioRoutingSettings.recordingEnabled]).
*
* Audio-Priorität:
* - Wenn PTT aktiv: Begleitmusik wird pausiert (über [BackgroundMusicManager.pauseMusic]).
* - Wegpunkt-Tracks: Der [WaypointLocationService] prüft [isActive] und verschiebt Wiedergabe.
* - Beim PTT-Ende: Begleitmusik wird wiederhergestellt (über [BackgroundMusicManager.afterWaypointAudio]).
*
* Aufnahme:
* - MediaRecorder läuft parallel zum Live-Loop (AudioRecord). Auf einigen Geräten
* kann eines von beiden versagen; in diesem Fall wird der Vorgang fortgesetzt
* und die UI über [statusMessage] informiert.
* - Aufnahmen werden in <filesDir>/recordings/ abgelegt und in [PttRecordingStore]
* indiziert. Wegpunkt-Zuweisung erfolgt über das Standard-soundUri-Feld.
*
* Bekannte Einschränkungen:
* - Android-Audiorouting via setPreferredDevice() wird nicht von allen Geräten unterstützt.
* - Bluetooth SCO: Erfordert ggf. AudioManager.startBluetoothSco() für volles Routing.
* Dieser MVP setzt setPreferredDevice() und loggt Fehler.
* - Echo/Feedback: Bei Nutzung des eingebauten Lautsprechers ohne Headset zu erwarten.
* - Live + Aufnahme gleichzeitig: Auf einigen Geräten konkurrieren AudioRecord
* (Live) und MediaRecorder (Aufnahme) um das Mikrofon. Bei Fehler wird die
* Aufnahme bevorzugt und die Live-Ausgabe übersprungen (oder umgekehrt).
*/
class LivePttManager private constructor(private val appContext: Context) {
@@ -49,6 +69,9 @@ class LivePttManager private constructor(private val appContext: Context) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private val store = AudioRoutingStore(appContext)
private val recordingStore = PttRecordingStore(appContext)
private val recorder = PttRecorder(appContext)
private val fusedLocation = LocationServices.getFusedLocationProviderClient(appContext)
// ─── Öffentliche States ───────────────────────────────────────────────────
@@ -56,15 +79,19 @@ class LivePttManager private constructor(private val appContext: Context) {
private val _pttActive = MutableStateFlow(false)
val pttActive: StateFlow<Boolean> = _pttActive.asStateFlow()
/** Aktuell gespeicherte Routing-Einstellungen. */
/** true wenn aktuell eine Aufnahme läuft. */
private val _recordingActive = MutableStateFlow(false)
val recordingActive: StateFlow<Boolean> = _recordingActive.asStateFlow()
/** Aktuell gespeicherte Routing-/PTT-Einstellungen. */
private val _routingSettings = MutableStateFlow(AudioRoutingSettings())
val routingSettings: StateFlow<AudioRoutingSettings> = _routingSettings.asStateFlow()
/** Verfügbare Eingabegeräte (aktualisiert beim Öffnen der Einstellungen). */
/** Verfügbare Eingabegeräte. */
private val _inputDevices = MutableStateFlow<List<AudioDeviceItem>>(emptyList())
val inputDevices: StateFlow<List<AudioDeviceItem>> = _inputDevices.asStateFlow()
/** Verfügbare Ausgabegeräte (aktualisiert beim Öffnen der Einstellungen). */
/** Verfügbare Ausgabegeräte. */
private val _outputDevices = MutableStateFlow<List<AudioDeviceItem>>(emptyList())
val outputDevices: StateFlow<List<AudioDeviceItem>> = _outputDevices.asStateFlow()
@@ -75,10 +102,27 @@ class LivePttManager private constructor(private val appContext: Context) {
private val _statusMessage = MutableStateFlow<String?>(null)
val statusMessage: StateFlow<String?> = _statusMessage.asStateFlow()
/**
* Reaktiver Flow der gespeicherten Aufnahmen. Wird von der UI für Bibliothek
* und Post-Aufnahme-Dialog beobachtet.
*/
val recordings: Flow<List<PttRecording>> = recordingStore.recordings
/**
* Zuletzt fertig gestellte Aufnahme (für Post-Aufnahme-Dialog).
* Wird von der UI mit [clearLastRecording] zurückgesetzt.
*/
private val _lastRecording = MutableStateFlow<PttRecording?>(null)
val lastRecording: StateFlow<PttRecording?> = _lastRecording.asStateFlow()
/**
* Zentraler Atmo-Resume-Manager.
*/
private val atmoResume = AtmoResumeManager.getInstance(appContext)
// ─── Initialisierung ──────────────────────────────────────────────────────
init {
// Gespeicherte Einstellungen laden
scope.launch {
store.settings.collect { settings ->
_routingSettings.value = settings
@@ -89,10 +133,7 @@ class LivePttManager private constructor(private val appContext: Context) {
// ─── PTT starten / stoppen ────────────────────────────────────────────────
/**
* Startet PTT:
* 1. Atmo pausieren
* 2. Foreground Service starten
* 3. Status setzen
* Startet PTT (Live und/oder Aufnahme) abhängig von den Einstellungen.
*/
fun startPtt() {
if (_pttActive.value) {
@@ -100,41 +141,69 @@ class LivePttManager private constructor(private val appContext: Context) {
return
}
// Begleitmusik pausieren
val settings = _routingSettings.value
// Sicherheit: Wenn der Nutzer beides deaktiviert hat, Live-Modus erzwingen.
val live = settings.liveOutputEnabled || !settings.recordingEnabled
val record = settings.recordingEnabled
// Begleitmusik & Wegpunkt-Audio pausieren
pauseAtmo()
val settings = _routingSettings.value
val intent = Intent(appContext, LivePttService::class.java).apply {
action = LivePttService.ACTION_START_PTT
settings.selectedInputDeviceId?.let {
putExtra(LivePttService.EXTRA_INPUT_DEVICE_ID, it)
}
settings.selectedOutputDeviceId?.let {
putExtra(LivePttService.EXTRA_OUTPUT_DEVICE_ID, it)
var liveStarted = false
var recordStarted = false
// Aufnahme zuerst starten (höhere Priorität für saubere Datei). Wenn beides
// aktiv ist, beansprucht MediaRecorder den MIC-Kanal; der Live-AudioRecord
// läuft auf VOICE_COMMUNICATION und teilt sich auf den meisten Geräten den
// Eingang ohne Konflikt.
if (record) {
val file = recorder.start(settings.recordingFormat)
if (file != null) {
recordStarted = true
_recordingActive.value = true
Log.i(TAG, "PTT-Aufnahme gestartet: ${file.absolutePath}")
} else {
_statusMessage.value = "Aufnahme konnte nicht gestartet werden"
Log.w(TAG, "PTT-Aufnahme konnte nicht gestartet werden")
}
}
runCatching {
appContext.startForegroundService(intent)
if (live) {
val intent = Intent(appContext, LivePttService::class.java).apply {
action = LivePttService.ACTION_START_PTT
settings.selectedInputDeviceId?.let {
putExtra(LivePttService.EXTRA_INPUT_DEVICE_ID, it)
}
settings.selectedOutputDeviceId?.let {
putExtra(LivePttService.EXTRA_OUTPUT_DEVICE_ID, it)
}
}
runCatching {
appContext.startForegroundService(intent)
liveStarted = true
Log.i(TAG, "PTT-Live gestartet")
}.onFailure { e ->
Log.e(TAG, "PTT-Live konnte nicht gestartet werden", e)
_statusMessage.value = "PTT-Live-Fehler: ${e.localizedMessage ?: e.javaClass.simpleName}"
}
}
if (liveStarted || recordStarted) {
_pttActive.value = true
_statusMessage.value = null
Log.i(TAG, "PTT gestartet")
}.onFailure { e ->
Log.e(TAG, "PTT konnte nicht gestartet werden", e)
_statusMessage.value = "PTT-Fehler: ${e.localizedMessage ?: e.javaClass.simpleName}"
} else {
_statusMessage.value = "PTT konnte nicht gestartet werden"
restoreAtmo()
}
}
/**
* Stoppt PTT:
* 1. Foreground Service stoppen
* 2. Atmo wiederherstellen
* 3. Status setzen
* Stoppt PTT. Aufnahme wird finalisiert und in der Bibliothek gespeichert;
* die fertige Aufnahme wird in [lastRecording] gepostet.
*/
fun stopPtt() {
if (!_pttActive.value) return
// Live-Service stoppen (falls aktiv)
val intent = Intent(appContext, LivePttService::class.java).apply {
action = LivePttService.ACTION_STOP_PTT
}
@@ -144,6 +213,26 @@ class LivePttManager private constructor(private val appContext: Context) {
Log.w(TAG, "Fehler beim Stoppen des PTT-Service", e)
}
// Aufnahme finalisieren Standort optional ermitteln
if (_recordingActive.value) {
scope.launch {
val (lat, lon) = fetchLastLocationOrNull()
val meta = recorder.stop(latitude = lat, longitude = lon)
_recordingActive.value = false
if (meta != null) {
runCatching {
recordingStore.add(meta)
}.onFailure { e ->
Log.e(TAG, "Aufnahme-Metadaten konnten nicht gespeichert werden", e)
}
_lastRecording.value = meta
Log.i(TAG, "PTT-Aufnahme gespeichert: ${meta.filePath} (${meta.durationMs} ms)")
} else {
Log.w(TAG, "PTT-Aufnahme lieferte keine Metadaten zurück (Fehler oder zu kurz)")
}
}
}
_pttActive.value = false
_statusMessage.value = null
restoreAtmo()
@@ -155,63 +244,105 @@ class LivePttManager private constructor(private val appContext: Context) {
if (_pttActive.value) stopPtt() else startPtt()
}
/** Verwirft die zuletzt fertig gestellte Aufnahme aus der UI-Anzeige (Dialog geschlossen). */
fun clearLastRecording() {
_lastRecording.value = null
}
// ─── Aufnahme-Bibliothek ──────────────────────────────────────────────────
/** Löscht eine Aufnahme (Datei + Metadaten). */
fun deleteRecording(recording: PttRecording) {
scope.launch {
runCatching {
val f = java.io.File(recording.filePath)
if (f.exists()) f.delete()
recordingStore.delete(recording.id)
Log.i(TAG, "Aufnahme gelöscht: ${recording.displayName}")
}.onFailure { e ->
Log.e(TAG, "Fehler beim Löschen der Aufnahme", e)
}
}
}
/** Benennt eine Aufnahme um (nur Anzeigename). */
fun renameRecording(recording: PttRecording, newName: String) {
scope.launch {
runCatching { recordingStore.rename(recording.id, newName.trim()) }
.onFailure { e -> Log.e(TAG, "Fehler beim Umbenennen", e) }
}
}
// ─── Geräteverwaltung ────────────────────────────────────────────────────
/** Lädt die verfügbaren Audiogeräte neu. */
fun refreshDevices() {
_inputDevices.value = AudioDeviceManager.getInputDevices(appContext)
_outputDevices.value = AudioDeviceManager.getOutputDevices(appContext)
Log.d(TAG, "Geräte neu geladen: ${_inputDevices.value.size} Eingabe, ${_outputDevices.value.size} Ausgabe")
}
/** Speichert neue Routing-Einstellungen persistent. */
fun saveRoutingSettings(settings: AudioRoutingSettings) {
_routingSettings.value = settings
// Validierung: mindestens eine Option (Live oder Aufnahme) muss aktiv sein.
val safe = if (!settings.liveOutputEnabled && !settings.recordingEnabled) {
settings.copy(liveOutputEnabled = true)
} else settings
_routingSettings.value = safe
scope.launch {
runCatching {
store.save(settings)
Log.i(TAG, "Routing-Einstellungen gespeichert: Input=${settings.selectedInputDeviceId}, Output=${settings.selectedOutputDeviceId}")
store.save(safe)
Log.i(TAG, "PTT-Einstellungen gespeichert: live=${safe.liveOutputEnabled}, " +
"record=${safe.recordingEnabled}, format=${safe.recordingFormat}")
}.onFailure { e ->
Log.e(TAG, "Fehler beim Speichern der Routing-Einstellungen", e)
Log.e(TAG, "Fehler beim Speichern der PTT-Einstellungen", e)
}
}
}
/** Löscht die Statusmeldung. */
fun clearStatus() {
_statusMessage.value = null
}
// ─── Standort-Abfrage für Aufnahme-Metadaten ─────────────────────────────
@SuppressLint("MissingPermission")
private suspend fun fetchLastLocationOrNull(): Pair<Double?, Double?> {
val fine = ContextCompat.checkSelfPermission(
appContext, Manifest.permission.ACCESS_FINE_LOCATION
) == PackageManager.PERMISSION_GRANTED
val coarse = ContextCompat.checkSelfPermission(
appContext, Manifest.permission.ACCESS_COARSE_LOCATION
) == PackageManager.PERMISSION_GRANTED
if (!fine && !coarse) return null to null
return withTimeoutOrNull(1500L) {
runCatching {
val loc = fusedLocation.lastLocation.await()
if (loc != null) loc.latitude to loc.longitude else null to null
}.getOrElse { null to null }
} ?: (null to null)
}
// ─── Atmo-Integration ────────────────────────────────────────────────────
private fun pauseAtmo() {
atmoResume.notifyInterruptStart(AtmoResumeManager.Source.PTT)
runCatching {
val mgr = BackgroundMusicManager.getInstance(appContext)
if (mgr.player.isPlaying) {
mgr.pauseMusic()
Log.d(TAG, "Atmo pausiert für PTT")
}
WaypointAudioInterruptionCoordinator.beginInterruption()
}.onFailure { e ->
Log.w(TAG, "Konnte Atmo nicht pausieren: ${e.message}")
Log.w(TAG, "Konnte Wegpunkt-Audio nicht pausieren (PTT-Start)", e)
}
}
private fun restoreAtmo() {
runCatching {
val mgr = BackgroundMusicManager.getInstance(appContext)
val settings = mgr.currentSettings
if (settings.enabled) {
mgr.afterWaypointAudio()
Log.d(TAG, "Atmo nach PTT wiederhergestellt")
}
WaypointAudioInterruptionCoordinator.endInterruption()
}.onFailure { e ->
Log.w(TAG, "Konnte Atmo nicht wiederherstellen: ${e.message}")
Log.w(TAG, "Konnte Wegpunkt-Audio nicht fortsetzen (PTT-Ende)", e)
}
atmoResume.notifyInterruptEnd(AtmoResumeManager.Source.PTT)
}
// ─── Lifecycle ────────────────────────────────────────────────────────────
/** Ressourcen freigeben. Danach nicht mehr nutzbar. */
fun release() {
if (_pttActive.value) stopPtt()
scope.cancel()
@@ -3,6 +3,7 @@ package de.waypointaudio.service
import android.content.Context
import android.media.MediaPlayer
import android.net.Uri
import android.os.PowerManager
import android.util.Log
/**
@@ -22,6 +23,8 @@ class ManualAudioPlayer {
private var mediaPlayer: MediaPlayer? = null
private var currentUri: String? = null
/** v2.5.10 — App-Context für FGS-Stop aus releasePlayer/stop heraus. */
private var lastAppContext: Context? = null
/**
* Whether the player is currently in single-item mode.
@@ -42,6 +45,24 @@ class ManualAudioPlayer {
val hasTrack: Boolean
get() = mediaPlayer != null
/**
* v2.5.14 — Aktuelle Wiedergabeposition in Millisekunden. Liefert 0, wenn
* kein Player geladen ist oder die Position (noch) nicht ermittelbar ist.
*/
fun getCurrentPositionMs(): Long = runCatching {
mediaPlayer?.currentPosition?.toLong() ?: 0L
}.getOrDefault(0L)
/**
* v2.5.14 — Gesamtdauer der aktuell geladenen Datei in Millisekunden.
* Liefert -1, wenn unbekannt (Streams, noch nicht geprepared, oder bereits
* freigegeben). UI-Layer sollte einen `--:--`-Fallback rendern.
*/
fun getDurationMs(): Long = runCatching {
val d = mediaPlayer?.duration ?: -1
if (d <= 0) -1L else d.toLong()
}.getOrDefault(-1L)
// -------------------------------------------------------------------------
// Playlist mode (existing global manual player bar)
// -------------------------------------------------------------------------
@@ -169,6 +190,9 @@ class ManualAudioPlayer {
mediaPlayer = MediaPlayer().apply {
// Grant URI permission for content:// URIs originating from SAF
setDataSource(context, uri)
// v2.5.10 — MediaPlayer-eigener WakeLock als zweites Sicherheitsnetz.
// Greift zusätzlich zum PARTIAL_WAKE_LOCK des ManualPlaybackService.
setWakeMode(context.applicationContext, PowerManager.PARTIAL_WAKE_LOCK)
prepare()
start()
setOnCompletionListener {
@@ -183,12 +207,43 @@ class ManualAudioPlayer {
}
}
currentUri = soundUri
lastAppContext = context.applicationContext
// v2.5.10 — Foreground-Service als Lebenshalter starten, sobald
// tatsächlich Wiedergabe läuft. Updates der Notification-Texte
// erfolgen aus dem ViewModel über setMeta/start.
ManualPlaybackService.start(
context.applicationContext,
title = pendingTitle.takeIf { it.isNotBlank() } ?: "GPS2Audio",
tour = pendingTour
)
}.onFailure { e ->
Log.e(TAG, "Fehler beim Abspielen von $soundUri", e)
releasePlayer()
}
}
// v2.5.10 — Optionale Anzeige-Metadaten für die Foreground-Notification.
// Die ViewModel-Schicht setzt diese vor dem nächsten play()-Aufruf.
@Volatile private var pendingTitle: String = ""
@Volatile private var pendingTour: String = ""
/** Setzt Titel/Tour, die beim nächsten Start als Notification-Text erscheinen. */
fun setNextNotificationMeta(title: String?, tour: String?) {
pendingTitle = title.orEmpty()
pendingTour = tour.orEmpty()
}
/** Aktualisiert die Notification eines bereits laufenden ManualPlaybackService. */
fun updateNotificationMeta(context: Context, title: String?, tour: String?) {
pendingTitle = title.orEmpty()
pendingTour = tour.orEmpty()
if (mediaPlayer != null) {
ManualPlaybackService.updateMeta(
context.applicationContext, title, tour
)
}
}
// -------------------------------------------------------------------------
// Pause / Stop / Release
// -------------------------------------------------------------------------
@@ -206,6 +261,21 @@ class ManualAudioPlayer {
}
}
/**
* Resume a previously paused playback at the same position. Used by the
* PTT interruption flow to bring back a waypoint audio that was paused
* because the user pressed PTT. If the MediaPlayer has been released or is
* already playing, this is a no-op.
*/
fun resume() {
runCatching {
val mp = mediaPlayer ?: return
if (!mp.isPlaying) mp.start()
}.onFailure { e ->
Log.e(TAG, "Fehler beim Fortsetzen", e)
}
}
/**
* Stop and release resources. Clears current track and single-mode state.
*/
@@ -231,6 +301,9 @@ class ManualAudioPlayer {
currentUri = null
isSingleMode = false
singleWaypointId = null
// v2.5.10 — Foreground-Service stoppen, sobald keine manuelle
// Wiedergabe mehr existiert. WakeLock wird vom Service freigegeben.
lastAppContext?.let { ctx -> ManualPlaybackService.stop(ctx) }
}
companion object {
@@ -0,0 +1,224 @@
package de.waypointaudio.service
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import android.os.PowerManager
import android.util.Log
import androidx.core.app.NotificationCompat
import de.waypointaudio.MainActivity
import de.waypointaudio.R
/**
* v2.5.10 — Foreground-Service mit Typ `mediaPlayback`, der die manuelle
* Wiedergabe (Tour-/Track-Play aus dem Waypoint-Listenmodus heraus) am
* Leben hält.
*
* **Problem in v2.5.8 und davor**
* ===============================
* `ManualAudioPlayer` benutzt einen einfachen [android.media.MediaPlayer],
* aber ohne dedizierten Foreground-Service. Sobald der Bildschirm sich
* abschaltet (Doze, Light-Doze, App-Standby), wird die App nach ca. 30
* Minuten in den Cached-Zustand verschoben, der CPU-Scheduler bremst und
* MediaPlayer fällt schließlich aus.
*
* **Lösung in v2.5.10**
* ======================
* Sobald die manuelle Wiedergabe startet (`ManualAudioPlayer.play(...)` oder
* `playSingle(...)`), wird dieser Service über [start] gestartet. Er:
*
* - ruft `startForeground(...)` mit Typ `FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK`
* auf — das ist die Voraussetzung für stabile Hintergrund-Wiedergabe
* auf Android 12+/14;
* - hält einen `PowerManager.PARTIAL_WAKE_LOCK`, damit Doze den CPU-Takt
* nicht zurückfährt;
* - zeigt eine niederpriore Notification mit Titel/Artist, die der
* bestehenden Atmo-Notification ähnelt.
*
* **Wichtig: keine Player-Ownership im Service**
* ===============================================
* Der eigentliche [android.media.MediaPlayer] bleibt — wie bisher — im
* [ManualAudioPlayer] der ViewModel-Schicht. Der Service ist nur der
* "Lebenshalter" und tritt mit dem Player NICHT in Interaktion. Das hält
* die Architektur einfach und vermeidet Regressionen in PTT-Interruption-
* und Atmo-Koexistenz-Logik, die alle gegen [ManualAudioPlayer] geschrieben
* wurden.
*
* Der Service stoppt sich selbst auf [ACTION_STOP] oder beim Service-
* destroy. WakeLock wird zwingend in [onDestroy] freigegeben.
*/
class ManualPlaybackService : Service() {
companion object {
const val ACTION_START = "de.waypointaudio.manual.ACTION_START"
const val ACTION_STOP = "de.waypointaudio.manual.ACTION_STOP"
const val ACTION_UPDATE_META = "de.waypointaudio.manual.ACTION_UPDATE_META"
const val EXTRA_TITLE = "manual_title"
const val EXTRA_TOUR = "manual_tour"
private const val TAG = "ManualPlaybackService"
private const val NOTIFICATION_ID = 1201
const val CHANNEL_ID = "manual_playback_channel"
/**
* Startet den Service, falls noch nicht aktiv, und aktualisiert
* Titel/Tour-Anzeige in der Notification.
*/
fun start(context: Context, title: String?, tour: String?) {
val intent = Intent(context, ManualPlaybackService::class.java).apply {
action = ACTION_START
putExtra(EXTRA_TITLE, title.orEmpty())
putExtra(EXTRA_TOUR, tour.orEmpty())
}
// startForegroundService MUSS auf Android O+ verwendet werden.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent)
} else {
context.startService(intent)
}
}
/** Aktualisiert nur die Notification-Texte, ohne den Service neu zu starten. */
fun updateMeta(context: Context, title: String?, tour: String?) {
val intent = Intent(context, ManualPlaybackService::class.java).apply {
action = ACTION_UPDATE_META
putExtra(EXTRA_TITLE, title.orEmpty())
putExtra(EXTRA_TOUR, tour.orEmpty())
}
try { context.startService(intent) } catch (e: Exception) {
Log.w(TAG, "updateMeta failed: ${e.message}")
}
}
fun stop(context: Context) {
val intent = Intent(context, ManualPlaybackService::class.java).apply {
action = ACTION_STOP
}
try { context.startService(intent) } catch (e: Exception) {
Log.w(TAG, "stop dispatch failed: ${e.message}")
}
}
fun ensureChannel(context: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val nm = context.getSystemService(NotificationManager::class.java)
if (nm.getNotificationChannel(CHANNEL_ID) == null) {
nm.createNotificationChannel(
NotificationChannel(
CHANNEL_ID,
"Manuelle Wiedergabe",
NotificationManager.IMPORTANCE_LOW
).apply {
description = "Hält Tour-/Trackwiedergabe im Hintergrund stabil"
setShowBadge(false)
}
)
}
}
}
}
private var wakeLock: PowerManager.WakeLock? = null
private var currentTitle: String = ""
private var currentTour: String = ""
override fun onBind(intent: Intent?): IBinder? = null
override fun onCreate() {
super.onCreate()
ensureChannel(this)
// Sofortige Foreground-Notification: System gewährt nur ~5s Frist
// nach startForegroundService(), bevor es eine ANR-artige Sanktion
// gibt — wir nutzen daher schon hier eine valide Minimal-Notification.
startForegroundCompat(buildNotification())
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_STOP -> {
Log.d(TAG, "ACTION_STOP")
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
return START_NOT_STICKY
}
ACTION_START, ACTION_UPDATE_META -> {
currentTitle = intent.getStringExtra(EXTRA_TITLE).orEmpty()
currentTour = intent.getStringExtra(EXTRA_TOUR).orEmpty()
acquireWakeLockIfNeeded()
val nm = getSystemService(NotificationManager::class.java)
nm.notify(NOTIFICATION_ID, buildNotification())
}
else -> {
// Re-Delivery / null-Intent: einfach am Leben bleiben.
acquireWakeLockIfNeeded()
}
}
return START_STICKY
}
override fun onDestroy() {
releaseWakeLock()
super.onDestroy()
}
private fun startForegroundCompat(notification: Notification) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(
NOTIFICATION_ID,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK
)
} else {
startForeground(NOTIFICATION_ID, notification)
}
}
private fun buildNotification(): Notification {
val pi = PendingIntent.getActivity(
this,
0,
Intent(this, MainActivity::class.java),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val title = currentTitle.ifBlank { getString(R.string.app_name) }
val text = if (currentTour.isNotBlank()) "Tour: $currentTour" else "Wiedergabe läuft"
return NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_splash_logo)
.setContentTitle(title)
.setContentText(text)
.setOngoing(true)
.setOnlyAlertOnce(true)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setCategory(NotificationCompat.CATEGORY_TRANSPORT)
.setContentIntent(pi)
.build()
}
private fun acquireWakeLockIfNeeded() {
if (wakeLock?.isHeld == true) return
val pm = getSystemService(POWER_SERVICE) as PowerManager
wakeLock = pm.newWakeLock(
PowerManager.PARTIAL_WAKE_LOCK,
"GPS2Audio:ManualPlayback"
).also { lock ->
lock.setReferenceCounted(false)
// 4h Hard-Cap als Sicherheitsnetz, falls Stopp-Pfad jemals
// ausgelassen wird. Im Regelfall wird der Lock durch
// onDestroy/Release lange vorher freigegeben.
lock.acquire(4 * 60 * 60 * 1000L)
}
}
private fun releaseWakeLock() {
runCatching { wakeLock?.takeIf { it.isHeld }?.release() }
wakeLock = null
}
}
@@ -0,0 +1,178 @@
package de.waypointaudio.service
import android.annotation.SuppressLint
import android.content.Context
import android.media.MediaMetadataRetriever
import android.media.MediaRecorder
import android.os.Build
import android.util.Log
import de.waypointaudio.data.PttRecording
import de.waypointaudio.data.PttRecordingFormat
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.UUID
/**
* Kapselt die MediaRecorder-basierte PTT-Aufnahme in eine M4A/AAC-Datei.
*
* Implementierungshinweise:
* - MediaRecorder belegt das Mikrofon exklusiv. Wenn parallel zu Live-Ausgabe
* (AudioRecord) aufgenommen werden soll, kann es auf einigen Geräten zu
* Konflikten kommen. v2.1.0 verwendet daher AudioSource.MIC und versucht,
* AudioRecord & MediaRecorder gleichzeitig zu betreiben. Schlägt das fehl,
* läuft die Aufnahme allein.
* - Die Datei wird in <filesDir>/recordings/ angelegt, ein FileProvider-URI
* wird daraus konstruiert.
*/
class PttRecorder(private val context: Context) {
companion object {
private const val TAG = "PttRecorder"
private const val SAMPLE_RATE = 44_100
private const val BIT_RATE = 96_000
private const val CHANNEL_COUNT = 1
}
private var recorder: MediaRecorder? = null
private var currentFile: File? = null
private var currentId: String = ""
private var currentDisplayName: String = ""
private var currentFormat: PttRecordingFormat = PttRecordingFormat.M4A_AAC
private var startEpochMs: Long = 0L
/** True wenn aktuell eine Aufnahme läuft. */
val isRecording: Boolean
get() = recorder != null
/**
* Startet eine neue Aufnahme.
*
* @param format Aufnahmeformat. WAV wird in v2.1.0 noch nicht unterstützt;
* als Fallback wird M4A_AAC verwendet.
* @return das angelegte File-Objekt oder null bei Fehler.
*/
@SuppressLint("MissingPermission")
fun start(format: PttRecordingFormat = PttRecordingFormat.M4A_AAC): File? {
if (recorder != null) {
Log.w(TAG, "start() aufgerufen, obwohl bereits eine Aufnahme läuft")
return currentFile
}
val recordingsDir = File(context.filesDir, "recordings").apply { mkdirs() }
// WAV wird in v2.1.0 nicht unterstützt → Fallback auf M4A
val effectiveFormat = if (format == PttRecordingFormat.M4A_AAC) format
else PttRecordingFormat.M4A_AAC
val extension = if (effectiveFormat == PttRecordingFormat.M4A_AAC) "m4a" else "wav"
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
val id = UUID.randomUUID().toString()
val baseName = "PTT_$timestamp"
val outFile = File(recordingsDir, "$baseName.$extension")
val rec = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
MediaRecorder(context)
} else {
@Suppress("DEPRECATION")
MediaRecorder()
}
return try {
rec.setAudioSource(MediaRecorder.AudioSource.MIC)
rec.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
rec.setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
rec.setAudioChannels(CHANNEL_COUNT)
rec.setAudioSamplingRate(SAMPLE_RATE)
rec.setAudioEncodingBitRate(BIT_RATE)
rec.setOutputFile(outFile.absolutePath)
rec.prepare()
rec.start()
recorder = rec
currentFile = outFile
currentId = id
currentDisplayName = baseName
currentFormat = effectiveFormat
startEpochMs = System.currentTimeMillis()
Log.i(TAG, "Aufnahme gestartet → ${outFile.absolutePath}")
outFile
} catch (e: Exception) {
Log.e(TAG, "Aufnahme konnte nicht gestartet werden", e)
runCatching { rec.release() }
recorder = null
currentFile = null
try { if (outFile.exists()) outFile.delete() } catch (_: Exception) {}
null
}
}
/**
* Stoppt die Aufnahme und liefert die fertige PttRecording-Metadaten zurück.
* @param latitude Optionaler Breitengrad zum Zeitpunkt der Aufnahme
* @param longitude Optionaler Längengrad zum Zeitpunkt der Aufnahme
* @return Metadaten oder null wenn keine Aufnahme aktiv war oder ein Fehler auftrat.
*/
fun stop(latitude: Double? = null, longitude: Double? = null): PttRecording? {
val rec = recorder ?: return null
val file = currentFile ?: return null
return try {
rec.stop()
rec.release()
recorder = null
val duration = readDurationMs(file)
val uriString = "file://${file.absolutePath}"
PttRecording(
id = currentId,
displayName = currentDisplayName,
filePath = file.absolutePath,
uri = uriString,
format = file.extension.ifBlank { "m4a" },
createdAt = startEpochMs,
durationMs = duration,
latitude = latitude,
longitude = longitude
)
} catch (e: Exception) {
Log.e(TAG, "Fehler beim Stoppen der Aufnahme", e)
runCatching { rec.release() }
recorder = null
// Datei verwerfen wenn sie zu klein/leer ist
try {
if (file.exists() && file.length() < 1024L) file.delete()
} catch (_: Exception) {}
null
} finally {
currentFile = null
currentId = ""
currentDisplayName = ""
startEpochMs = 0L
}
}
/** Bricht die laufende Aufnahme ab und löscht die Datei. */
fun cancel() {
val rec = recorder
val file = currentFile
recorder = null
currentFile = null
currentId = ""
currentDisplayName = ""
startEpochMs = 0L
runCatching { rec?.stop() }
runCatching { rec?.release() }
try { if (file != null && file.exists()) file.delete() } catch (_: Exception) {}
}
private fun readDurationMs(file: File): Long {
return runCatching {
MediaMetadataRetriever().use { mmr ->
mmr.setDataSource(file.absolutePath)
mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLongOrNull() ?: 0L
}
}.getOrElse { 0L }
}
private inline fun <R> MediaMetadataRetriever.use(block: (MediaMetadataRetriever) -> R): R {
return try { block(this) } finally { runCatching { release() } }
}
}
@@ -0,0 +1,99 @@
package de.waypointaudio.service
import android.app.Activity
import android.util.Log
import android.view.WindowManager
import de.waypointaudio.data.WakeLockMode
import de.waypointaudio.data.WakeLockSettings
/**
* Steuert das Wachhalten des Displays für eine Activity.
*
* Setzt [WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON] auf das Activity-Fenster,
* das vom Android-System verwaltet wird das Flag wird automatisch gelöst, sobald
* die Activity in den Hintergrund (onPause/onStop) wechselt.
*
* FLAG_KEEP_SCREEN_ON ist die offiziell empfohlene Lösung
* (siehe https://developer.android.com/training/scheduling/wakelock):
* - Verbraucht weniger Akku als ein expliziter PowerManager-WakeLock
* - Wird automatisch beim Verlassen der App freigegeben
* - Funktioniert ab API 1, keine spezielle Permission nötig
*
* Die WAKE_LOCK-Berechtigung ist trotzdem im Manifest, weil sie auch von
* MediaPlayer / ExoPlayer (Audio-Wiedergabe im Service) benötigt wird.
*/
class WakeLockController(private val activity: Activity) {
companion object {
private const val TAG = "WakeLockController"
}
/** Letzter angewandter Modus & Zustand "Tour läuft" für UI-Status. */
var currentMode: WakeLockMode = WakeLockMode.NEVER
private set
private var tourActive: Boolean = false
private var attached: Boolean = false
/**
* Aktualisiert den Modus aus den persistierten Einstellungen.
* Wendet den Effekt sofort an, sofern die Activity attached ist.
*/
fun applySettings(settings: WakeLockSettings) {
currentMode = settings.mode
reevaluate()
}
/** Wird vom Service-/ViewModel-State aufgerufen, sobald sich der Tour-/GPS-Status ändert. */
fun setTourActive(active: Boolean) {
if (tourActive == active) return
tourActive = active
reevaluate()
}
/** onResume / onStart einer Activity: Flag ggf. setzen. */
fun onActivityResumed() {
attached = true
reevaluate()
}
/** onPause / onStop einer Activity: Flag aufheben. */
fun onActivityPaused() {
attached = false
clearFlag()
}
private fun shouldKeepOn(): Boolean = when (currentMode) {
WakeLockMode.NEVER -> false
WakeLockMode.APP_FOREGROUND -> true
WakeLockMode.ACTIVE_TOUR -> tourActive
}
private fun reevaluate() {
if (!attached) {
clearFlag()
return
}
if (shouldKeepOn()) setFlag() else clearFlag()
}
private fun setFlag() {
runCatching {
activity.runOnUiThread {
activity.window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
}.onFailure { e ->
Log.w(TAG, "Konnte KEEP_SCREEN_ON nicht aktivieren", e)
}
}
private fun clearFlag() {
runCatching {
activity.runOnUiThread {
activity.window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
}.onFailure { e ->
Log.w(TAG, "Konnte KEEP_SCREEN_ON nicht entfernen", e)
}
}
}
@@ -0,0 +1,156 @@
package de.waypointaudio.service
import android.util.Log
/**
* Prozessweiter Singleton, der alle aktuell aktiven Wegpunkt-Audio-Quellen
* (GPS-getriggert über [AudioPlayer], manuell über [ManualAudioPlayer]) kennt
* und sie bei einer Unterbrechung (aktuell: PTT) zentral pausieren bzw. nach
* deren Ende fortsetzen kann.
*
* Designziele (v2.0.3):
* - PTT und Wegpunkt-Audio werden nie gleichzeitig hörbar.
* - Wenn vor PTT-Start kein Wegpunkt-Audio lief, passiert nichts.
* - Wenn ein Wegpunkt-Audio lief, wird es zu Beginn von PTT pausiert und
* nach Ende von PTT genau wieder fortgesetzt (gleiche Position).
* - Doppelte PTT-Start- / Stop-Events erzeugen keine wiederholten pause/resume-
* Aufrufe (Idempotenz).
* - Es entstehen keine UI-Änderungen.
*
* Architektur:
* Jede laufende Wegpunkt-Audio-Komponente registriert sich beim Start ihrer
* Wiedergabe mit einer [Source] und stellt zwei Lambdas (pause/resume) sowie
* einen Live-Check (isCurrentlyPlaying) bereit. Beim Stoppen oder natürlichen
* Ende deregistriert sie sich wieder. Der Coordinator behält pro Source nur
* einen Eintrag.
*/
object WaypointAudioInterruptionCoordinator {
private const val TAG = "WaypointAudioInterrupt"
/** Mögliche Quellen einer Wegpunkt-Audio-Wiedergabe. */
enum class Source { GPS, MANUAL }
private data class Registration(
val isCurrentlyPlaying: () -> Boolean,
val pause: () -> Unit,
val resume: () -> Unit
)
/** Registrierte aktive Quellen (zu jeder Source max. eine Registrierung). */
private val registrations = mutableMapOf<Source, Registration>()
/** Quellen, die bei der laufenden Unterbrechung tatsächlich pausiert wurden. */
private val pausedDuringInterruption = mutableSetOf<Source>()
/** Aktuell aktive Unterbrechung? (verhindert wiederholtes pause/resume) */
private var interruptionActive: Boolean = false
/**
* Registriert eine aktive Audio-Quelle. Wird typischerweise direkt nach
* dem Start der Wiedergabe aufgerufen. Eine eventuell zuvor registrierte
* Registrierung derselben Quelle wird ersetzt.
*/
@Synchronized
fun register(
source: Source,
isCurrentlyPlaying: () -> Boolean,
pause: () -> Unit,
resume: () -> Unit
) {
registrations[source] = Registration(isCurrentlyPlaying, pause, resume)
Log.d(TAG, "Quelle registriert: $source (aktive: ${registrations.keys})")
// Sonderfall: PTT ist bereits aktiv und die Quelle startet trotzdem
// (z. B. eine GPS-Wiedergabe, die zwischen den States kollidiert). Damit
// PTT und Wegpunkt nicht doppelt hörbar werden, sofort pausieren und für
// den späteren Resume vormerken.
if (interruptionActive && isCurrentlyPlaying()) {
runCatching { pause() }.onFailure {
Log.w(TAG, "Pause der frisch registrierten Quelle $source fehlgeschlagen", it)
}
pausedDuringInterruption.add(source)
Log.d(TAG, "Quelle $source bei laufender Unterbrechung sofort pausiert")
}
}
/**
* Deregistriert eine zuvor registrierte Quelle. Wird beim natürlichen
* Ende, beim expliziten Stop oder bei Tour-/Player-Wechseln aufgerufen.
*
* Wenn die Quelle während einer aktiven Unterbrechung deregistriert wird,
* wird sie aus dem [pausedDuringInterruption]-Set entfernt sie wird also
* nicht erneut "fortgesetzt", wenn die Unterbrechung endet.
*/
@Synchronized
fun unregister(source: Source) {
if (registrations.remove(source) != null) {
Log.d(TAG, "Quelle deregistriert: $source (verbleibend: ${registrations.keys})")
}
pausedDuringInterruption.remove(source)
}
/**
* Beginnt eine Unterbrechung (z. B. PTT-Start).
* Alle aktuell tatsächlich spielenden Quellen werden pausiert und für den
* späteren Resume vermerkt. Wiederholte Aufrufe ohne zwischenzeitliches
* [endInterruption] sind No-Ops.
*/
@Synchronized
fun beginInterruption() {
if (interruptionActive) {
Log.d(TAG, "beginInterruption ignoriert bereits aktiv")
return
}
interruptionActive = true
pausedDuringInterruption.clear()
registrations.forEach { (source, reg) ->
val playing = runCatching { reg.isCurrentlyPlaying() }.getOrDefault(false)
if (playing) {
runCatching { reg.pause() }.onFailure {
Log.w(TAG, "Pause für $source fehlgeschlagen", it)
}
pausedDuringInterruption.add(source)
Log.d(TAG, "Wegpunkt-Audio pausiert wegen Unterbrechung: $source")
}
}
if (pausedDuringInterruption.isEmpty()) {
Log.d(TAG, "Keine aktive Wegpunkt-Audio-Quelle nichts zu pausieren")
}
}
/**
* Beendet eine Unterbrechung (z. B. PTT-Stop).
* Quellen, die zu Beginn der Unterbrechung tatsächlich pausiert wurden und
* noch registriert sind, werden fortgesetzt. Wiederholte Aufrufe ohne
* passendes [beginInterruption] sind No-Ops.
*/
@Synchronized
fun endInterruption() {
if (!interruptionActive) {
Log.d(TAG, "endInterruption ignoriert nicht aktiv")
return
}
interruptionActive = false
val toResume = pausedDuringInterruption.toList()
pausedDuringInterruption.clear()
toResume.forEach { source ->
val reg = registrations[source]
if (reg == null) {
Log.d(TAG, "Resume übersprungen: Quelle $source nicht mehr registriert")
return@forEach
}
runCatching { reg.resume() }.onFailure {
Log.w(TAG, "Resume für $source fehlgeschlagen", it)
}
Log.d(TAG, "Wegpunkt-Audio fortgesetzt nach Unterbrechung: $source")
}
}
/** Gibt an, ob aktuell eine Unterbrechung läuft (für Tests / Logging). */
@Synchronized
fun isInterrupting(): Boolean = interruptionActive
}
@@ -23,6 +23,7 @@ import de.waypointaudio.data.PlaybackMode
import de.waypointaudio.data.TourAudioSettings
import de.waypointaudio.data.TourMusicStore
import de.waypointaudio.data.Waypoint
import de.waypointaudio.data.activeClips
import de.waypointaudio.repository.WaypointRepository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -157,6 +158,9 @@ class WaypointLocationService : Service() {
private fun stopTracking() {
fusedLocationClient.removeLocationUpdates(locationCallback)
audioPlayer.stop()
WaypointAudioInterruptionCoordinator.unregister(
WaypointAudioInterruptionCoordinator.Source.GPS
)
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
Log.i(TAG, "GPS-Tracking gestoppt")
@@ -206,9 +210,10 @@ class WaypointLocationService : Service() {
val msg = getString(R.string.notification_waypoint_reached, waypoint.name)
updateNotification(msg)
// Ton nur abspielen wenn Audiodatei vorhanden und Abspielregeln erfuellt
if (waypoint.soundUri.isBlank()) {
Log.d(TAG, "Kein Ton fuer Wegpunkt '${waypoint.name}' - keine Audiodatei")
// v2.5.12 — Effektive Clipliste prüfen. activeClips() berücksichtigt
// sowohl das neue audioClips-Modell als auch Legacy-soundUri.
if (waypoint.activeClips().isEmpty()) {
Log.d(TAG, "Kein Ton fuer Wegpunkt '${waypoint.name}' - keine aktiven Audioclips")
return
}
@@ -246,21 +251,28 @@ class WaypointLocationService : Service() {
/**
* Spielt den Wegpunkt-Ton ab (Begleitmusik-Sequenz).
* Kann direkt oder nach PTT-Ende aufgerufen werden.
*
* v2.5.12 — Multi-Clip:
* - Es werden ALLE aktiven Clips des Wegpunkts der Reihe nach abgespielt.
* - Inaktive Clips werden übersprungen (siehe [Waypoint.activeClips]).
* - Begleitmusik wird einmalig vor dem ersten Clip pausiert/geduckt
* und einmalig nach dem letzten Clip wiederhergestellt — nicht zwischen
* den Clips, damit die Sequenz nahtlos wirkt.
* - playCount wird genau einmal pro Eintrittsereignis erhöht, nachdem
* der letzte Clip natürlich endete (keine Erhöhung bei Fehler).
* - Bei einem Fehler eines Clips wird abgebrochen, Begleitmusik wieder
* hergestellt; playCount bleibt unverändert.
*/
private fun playWaypointAudio(waypoint: Waypoint) {
// Begleitmusik-Sequenz:
// 1. beforeWaypointAudio() - Begleitmusik pausieren/ducken/faden
// 2. audioPlayer.play() - Wegpunkt-Ton starten
// 3a. onCompletion - nach naturalem Ende: afterWaypointAudio() + playCount++
// 3b. onError - bei Fehler: afterWaypointAudio() (kein playCount++)
//
// Alles laeuft im Main-Dispatcher, da ExoPlayer (Begleitmusik) Main erfordert.
// Die Callbacks von AudioPlayer kommen auf dem MediaPlayer-Thread und werden
// per serviceScope.launch(Dispatchers.Main) weitergeleitet.
serviceScope.launch(Dispatchers.Main) {
val mgr = musicManager
val clipUris = waypoint.activeClips().map { it.uri }
if (clipUris.isEmpty()) {
Log.d(TAG, "playWaypointAudio: keine aktiven Clips für '${waypoint.name}'")
return@launch
}
// Tour-Einstellungen laden
val settings: TourAudioSettings? = if (mgr != null) {
runCatching {
@@ -269,7 +281,7 @@ class WaypointLocationService : Service() {
}.getOrNull()
} else null
// 1. Begleitmusik vor Wegpunkt-Audio anpassen
// 1. Begleitmusik einmalig vor erstem Clip anpassen
if (mgr != null && settings != null && settings.enabled) {
runCatching {
mgr.currentSettings = settings
@@ -279,35 +291,85 @@ class WaypointLocationService : Service() {
}
}
// 2. Wegpunkt-Ton abspielen
// onCompletion und onError werden auf dem MediaPlayer-Thread aufgerufen;
// wir leiten sie zurueck in den Main-Dispatcher fuer ExoPlayer-Zugriffe.
audioPlayer.play(
context = applicationContext,
soundUri = waypoint.soundUri,
onCompletion = {
// Natuerliches Ende: Begleitmusik wiederherstellen + Zaehler erhoehen
serviceScope.launch(Dispatchers.Main) {
Log.d(TAG, "Wegpunkt-Ton beendet (natural completion): ${waypoint.name}")
restoreBackgroundMusic(mgr, settings)
}
// playCount auf IO-Thread inkrementieren (Repository-Zugriff)
serviceScope.launch(Dispatchers.IO) {
runCatching {
repository.incrementPlayCount(waypoint.id)
}.onFailure { e ->
Log.e(TAG, "Fehler beim Inkrementieren von playCount fuer ${waypoint.name}", e)
}
}
},
onError = { e ->
// Fehler: Begleitmusik trotzdem wiederherstellen; kein playCount++
serviceScope.launch(Dispatchers.Main) {
Log.w(TAG, "Wegpunkt-Ton Fehler (${waypoint.name}): ${e?.message}")
restoreBackgroundMusic(mgr, settings)
}
}
// 2. Beim PTT-Coordinator registrieren über die gesamte
// Multi-Clip-Sequenz hinweg. Pause/Resume betreffen immer
// den aktuell aktiven MediaPlayer im AudioPlayer-Wrapper.
WaypointAudioInterruptionCoordinator.register(
source = WaypointAudioInterruptionCoordinator.Source.GPS,
isCurrentlyPlaying = { audioPlayer.isPlaying },
pause = { audioPlayer.pause() },
resume = { audioPlayer.resume() }
)
// 3. Clips sequenziell abspielen.
playClipSequence(
waypoint = waypoint,
clipUris = clipUris,
index = 0,
mgr = mgr,
settings = settings
)
}
}
/**
* v2.5.12 — Spielt den Clip mit Index [index] und ruft sich selbst nach
* natürlichem Ende für den nächsten Clip auf. Bei Fehler oder am Ende
* der Liste wird die Begleitmusik wiederhergestellt; der Coordinator
* abgemeldet; und bei natürlichem Ende der gesamten Sequenz wird
* playCount inkrementiert.
*/
private fun playClipSequence(
waypoint: Waypoint,
clipUris: List<String>,
index: Int,
mgr: BackgroundMusicManager?,
settings: TourAudioSettings?
) {
if (index !in clipUris.indices) {
// Sequenz erfolgreich durchlaufen
finishSequence(waypoint, mgr, settings, incrementPlayCount = true)
return
}
val uri = clipUris[index]
audioPlayer.play(
context = applicationContext,
soundUri = uri,
onCompletion = {
serviceScope.launch(Dispatchers.Main) {
Log.d(TAG, "Clip ${index + 1}/${clipUris.size} beendet (${waypoint.name})")
playClipSequence(waypoint, clipUris, index + 1, mgr, settings)
}
},
onError = { e ->
Log.w(TAG, "Clip ${index + 1}/${clipUris.size} Fehler (${waypoint.name}): ${e?.message}")
// Bei Fehler abbrechen, Begleitmusik wiederherstellen, KEIN playCount++
serviceScope.launch(Dispatchers.Main) {
finishSequence(waypoint, mgr, settings, incrementPlayCount = false)
}
}
)
}
private fun finishSequence(
waypoint: Waypoint,
mgr: BackgroundMusicManager?,
settings: TourAudioSettings?,
incrementPlayCount: Boolean
) {
WaypointAudioInterruptionCoordinator.unregister(
WaypointAudioInterruptionCoordinator.Source.GPS
)
restoreBackgroundMusic(mgr, settings)
if (incrementPlayCount) {
serviceScope.launch(Dispatchers.IO) {
runCatching {
repository.incrementPlayCount(waypoint.id)
}.onFailure { e ->
Log.e(TAG, "Fehler beim Inkrementieren von playCount fuer ${waypoint.name}", e)
}
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,265 @@
package de.waypointaudio.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import de.waypointaudio.R
import de.waypointaudio.data.BackgroundReliabilityStore
import de.waypointaudio.util.AppVersion
import de.waypointaudio.util.BackgroundReliability
import kotlinx.coroutines.launch
/**
* v2.5.13 — Dezenter Hinweis "Hintergrundwiedergabe absichern".
*
* Anzeigeregeln:
* - Nur sichtbar, wenn die App NICHT von Akkuoptimierungen ausgenommen ist
* (sonst gibt es nichts zu tun) UND der Nutzer den Hinweis für diese
* Version noch nicht weggeklickt hat.
* - Auf Xiaomi/Redmi/Poco wird die spezifische Schrittliste (Akku, Autostart,
* Recents-Schloss, Notifications) gezeigt — sonst eine generische Variante.
* - "Verstanden" merkt den aktuellen versionCode, "Nicht mehr anzeigen" ist
* eine Checkbox, weil so vorhandene Dialoge in der App (Versionshinweis,
* Terms) bereits funktionieren — konsistente UX.
* - Blockiert die Wiedergabe nicht: der Nutzer kann den Dialog jederzeit
* schließen, und das Playback läuft währenddessen weiter.
*/
@Composable
fun BackgroundReliabilityDialogHost(
/**
* Auslöser, ob der Hinweis überhaupt geprüft werden soll. Wird die
* Komponente immer in der Composition gehalten, übernimmt sie selbst
* die Anzeige-Entscheidung; das ist gewollt einfach.
*/
triggerCheck: Boolean,
onDismissed: () -> Unit = {}
) {
val context = LocalContext.current
val store = remember(context) {
BackgroundReliabilityStore(context.applicationContext)
}
val versionInfo = remember(context) { AppVersion.get(context) }
val dismissedCode by store.dismissedVersionCode.collectAsState(initial = -1L)
val scope = rememberCoroutineScope()
var visible by remember { mutableStateOf(false) }
var dontShowAgain by remember { mutableStateOf(false) }
LaunchedEffect(triggerCheck, dismissedCode, versionInfo.versionCode) {
if (!triggerCheck) return@LaunchedEffect
if (dismissedCode < 0L) return@LaunchedEffect
if (dismissedCode >= versionInfo.versionCode) return@LaunchedEffect
// Bereits ausgenommen → kein Bedarf, Nutzer mit dem Hinweis zu nerven.
if (BackgroundReliability.isIgnoringBatteryOptimizations(context)) return@LaunchedEffect
visible = true
}
if (!visible) return
BackgroundReliabilityDialog(
isXiaomi = BackgroundReliability.isXiaomiFamily(),
isOptimizationsIgnored = BackgroundReliability.isIgnoringBatteryOptimizations(context),
dontShowAgain = dontShowAgain,
onDontShowAgainChanged = { dontShowAgain = it },
onOpenBattery = { BackgroundReliability.openBatteryOptimizationSettings(context) },
onOpenAppDetails = { BackgroundReliability.openAppDetailsSettings(context) },
onOpenNotifications = { BackgroundReliability.openNotificationSettings(context) },
onOpenXiaomiAutostart = { BackgroundReliability.openXiaomiAutostartSettings(context) },
onDismiss = {
if (dontShowAgain) {
scope.launch {
store.setDismissedVersionCode(versionInfo.versionCode)
}
}
visible = false
onDismissed()
}
)
}
/**
* Manuell aufrufbarer Dialog für den Einstellungen/About-Bereich. Ignoriert
* den "für Version verstanden"-Speicher und zeigt den Hinweis immer.
*/
@Composable
fun BackgroundReliabilityManualDialog(
onClose: () -> Unit
) {
val context = LocalContext.current
BackgroundReliabilityDialog(
isXiaomi = BackgroundReliability.isXiaomiFamily(),
isOptimizationsIgnored = BackgroundReliability.isIgnoringBatteryOptimizations(context),
dontShowAgain = false,
onDontShowAgainChanged = {},
showDontShowAgain = false,
onOpenBattery = { BackgroundReliability.openBatteryOptimizationSettings(context) },
onOpenAppDetails = { BackgroundReliability.openAppDetailsSettings(context) },
onOpenNotifications = { BackgroundReliability.openNotificationSettings(context) },
onOpenXiaomiAutostart = { BackgroundReliability.openXiaomiAutostartSettings(context) },
onDismiss = onClose
)
}
@Composable
private fun BackgroundReliabilityDialog(
isXiaomi: Boolean,
isOptimizationsIgnored: Boolean,
dontShowAgain: Boolean,
onDontShowAgainChanged: (Boolean) -> Unit,
onOpenBattery: () -> Unit,
onOpenAppDetails: () -> Unit,
onOpenNotifications: () -> Unit,
onOpenXiaomiAutostart: () -> Unit,
onDismiss: () -> Unit,
showDontShowAgain: Boolean = true
) {
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text(
text = stringResource(R.string.bg_reliability_title),
style = MaterialTheme.typography.headlineSmall
)
},
text = {
Column(
modifier = Modifier.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = stringResource(R.string.bg_reliability_intro),
style = MaterialTheme.typography.bodyMedium
)
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Box(Modifier.padding(12.dp)) {
Text(
text = if (isOptimizationsIgnored) {
stringResource(R.string.bg_reliability_status_ok)
} else {
stringResource(R.string.bg_reliability_status_optimized)
},
style = MaterialTheme.typography.bodySmall
)
}
}
if (isXiaomi) {
Text(
text = stringResource(R.string.bg_reliability_xiaomi_heading),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
BulletLine(stringResource(R.string.bg_reliability_xiaomi_item_battery))
BulletLine(stringResource(R.string.bg_reliability_xiaomi_item_autostart))
BulletLine(stringResource(R.string.bg_reliability_xiaomi_item_lock))
BulletLine(stringResource(R.string.bg_reliability_xiaomi_item_notify))
} else {
Text(
text = stringResource(R.string.bg_reliability_generic_heading),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
BulletLine(stringResource(R.string.bg_reliability_generic_item_battery))
BulletLine(stringResource(R.string.bg_reliability_generic_item_notify))
}
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(
onClick = onOpenBattery,
modifier = Modifier.fillMaxWidth()
) {
Text(stringResource(R.string.bg_reliability_btn_battery))
}
OutlinedButton(
onClick = onOpenNotifications,
modifier = Modifier.fillMaxWidth()
) {
Text(stringResource(R.string.bg_reliability_btn_notify))
}
OutlinedButton(
onClick = onOpenAppDetails,
modifier = Modifier.fillMaxWidth()
) {
Text(stringResource(R.string.bg_reliability_btn_app))
}
if (isXiaomi) {
OutlinedButton(
onClick = onOpenXiaomiAutostart,
modifier = Modifier.fillMaxWidth()
) {
Text(stringResource(R.string.bg_reliability_btn_autostart))
}
}
}
if (showDontShowAgain) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(top = 4.dp)
) {
Checkbox(
checked = dontShowAgain,
onCheckedChange = onDontShowAgainChanged
)
Text(
text = stringResource(R.string.bg_reliability_dont_show_again),
style = MaterialTheme.typography.bodyMedium
)
}
}
}
},
confirmButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.bg_reliability_understood))
}
}
)
}
@Composable
private fun BulletLine(text: String) {
Row(verticalAlignment = Alignment.Top) {
Text(
text = "",
style = MaterialTheme.typography.bodyMedium
)
Text(
text = text,
style = MaterialTheme.typography.bodyMedium
)
}
}
@@ -10,6 +10,8 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
@@ -20,6 +22,7 @@ import androidx.compose.ui.unit.dp
import de.waypointaudio.R
import de.waypointaudio.data.MusicSourceType
import de.waypointaudio.data.PlaylistItem
import de.waypointaudio.data.StreamEntry
import de.waypointaudio.data.TourAudioSettings
import de.waypointaudio.data.WaypointMusicBehavior
import de.waypointaudio.viewmodel.WaypointViewModel
@@ -30,7 +33,7 @@ import de.waypointaudio.viewmodel.WaypointViewModel
* Öffnet sich über das sichtbare Begleitmusik-Panel oder über das Overflow-Menü;
* zeigt alle Optionen für die aktuell gewählte Tour.
*/
@OptIn(ExperimentalMaterial3Api::class)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
fun BegleitmusikDialog(
tourName: String,
@@ -40,19 +43,33 @@ fun BegleitmusikDialog(
val context = LocalContext.current
val initialSettings by viewModel.musicSettings.collectAsState()
val musicPlaying by viewModel.musicPlaying.collectAsState()
val audioLibrary by viewModel.audioLibrary.collectAsState()
// Sub-Dialog: Auswahl aus der Audio-Bibliothek
var showLibraryPicker by remember { mutableStateOf(false) }
// Lokaler Bearbeitungszustand erst beim Speichern in VM schreiben
var enabled by remember(initialSettings) { mutableStateOf(initialSettings.enabled) }
var sourceType by remember(initialSettings) { mutableStateOf(initialSettings.sourceType) }
var localPlaylist by remember(initialSettings) { mutableStateOf(initialSettings.localPlaylist) }
var streamUrl by remember(initialSettings) { mutableStateOf(initialSettings.streamUrl ?: "") }
// Stream-Playlist Legacy streamUrl wird beim Initialisieren in einen Eintrag migriert,
// sodass im Dialog immer die neue Repräsentation verwendet wird.
var streamPlaylist by remember(initialSettings) {
mutableStateOf(initialSettings.effectiveStreamPlaylist)
}
var behavior by remember(initialSettings) { mutableStateOf(initialSettings.behavior) }
var fadeDurationMs by remember(initialSettings) { mutableStateOf(initialSettings.fadeDurationMs) }
var duckVolume by remember(initialSettings) { mutableStateOf(initialSettings.duckVolume) }
var shuffle by remember(initialSettings) { mutableStateOf(initialSettings.shuffle) }
var autoStartAfterWaypoint by remember(initialSettings) { mutableStateOf(initialSettings.autoStartAfterWaypoint) }
// Eingabezustand für „neuen Stream hinzufügen"
var newStreamName by remember(initialSettings) { mutableStateOf("") }
var newStreamUrl by remember(initialSettings) { mutableStateOf("") }
var newStreamError by remember { mutableStateOf("") }
var streamUrlError by remember { mutableStateOf("") }
// Index des gerade editierten Stream-Eintrags (-1 = kein Edit-Modus)
var editingStreamIndex by remember { mutableStateOf(-1) }
// Launcher für mehrere Audiodateien
val multiPickLauncher = rememberLauncherForActivityResult(
@@ -84,23 +101,19 @@ fun BegleitmusikDialog(
fun validateAndSave(): Boolean {
if (enabled && sourceType == MusicSourceType.STREAM_URL) {
val url = streamUrl.trim()
if (url.isBlank()) {
streamUrlError = context.getString(R.string.music_stream_url_empty_error)
return false
}
val lower = url.lowercase()
if (!lower.startsWith("http://") && !lower.startsWith("https://")) {
streamUrlError = context.getString(R.string.music_stream_url_invalid_error)
if (streamPlaylist.isEmpty()) {
streamUrlError = context.getString(R.string.music_stream_playlist_empty_error)
return false
}
}
streamUrlError = ""
// streamUrl-Legacy-Feld leer lassen Playlist ist jetzt kanonisch.
val settings = TourAudioSettings(
enabled = enabled,
sourceType = sourceType,
localPlaylist = localPlaylist,
streamUrl = streamUrl.trim().ifBlank { null },
streamPlaylist = streamPlaylist,
streamUrl = null,
behavior = behavior,
fadeDurationMs = fadeDurationMs,
duckVolume = duckVolume,
@@ -111,6 +124,18 @@ fun BegleitmusikDialog(
return true
}
fun validateStreamUrlInput(url: String): String? {
val trimmed = url.trim()
if (trimmed.isBlank()) {
return context.getString(R.string.music_stream_url_empty_error)
}
val lower = trimmed.lowercase()
if (!lower.startsWith("http://") && !lower.startsWith("https://")) {
return context.getString(R.string.music_stream_url_invalid_error)
}
return null
}
AlertDialog(
onDismissRequest = onDismiss,
title = {
@@ -187,10 +212,16 @@ fun BegleitmusikDialog(
color = MaterialTheme.colorScheme.onSurfaceVariant
)
if (localPlaylist.isEmpty()) {
// Hinweis je nach Verfügbarkeit der Audio-Bibliothek anpassen,
// damit die User wissen, dass sie auch bereits hinzugefügte
// Dateien erneut auswählen können.
Text(
stringResource(R.string.music_playlist_empty),
if (audioLibrary.isEmpty())
stringResource(R.string.music_playlist_empty)
else
stringResource(R.string.music_playlist_empty_with_library),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f)
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
} else {
localPlaylist.forEachIndexed { idx, item ->
@@ -229,7 +260,11 @@ fun BegleitmusikDialog(
}
}
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.fillMaxWidth()
) {
OutlinedButton(
onClick = {
multiPickLauncher.launch(
@@ -241,6 +276,21 @@ fun BegleitmusikDialog(
Spacer(Modifier.width(4.dp))
Text(stringResource(R.string.music_playlist_add))
}
// Aus Bibliothek auswählen (bereits bekannte Audiodateien
// wiederverwenden tour-übergreifend & inkl. Wegpunkt-Audios)
if (audioLibrary.isNotEmpty()) {
OutlinedButton(
onClick = { showLibraryPicker = true }
) {
Icon(
Icons.Filled.LibraryMusic,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
Spacer(Modifier.width(4.dp))
Text(stringResource(R.string.music_playlist_pick_from_library))
}
}
if (localPlaylist.isNotEmpty()) {
OutlinedButton(
onClick = { localPlaylist = emptyList() },
@@ -266,17 +316,165 @@ fun BegleitmusikDialog(
}
}
// ─── Stream-URL ───────────────────────────────────────────────
// ─── Stream-Playlist ──────────────────────────────────────────
if (sourceType == MusicSourceType.STREAM_URL) {
Text(
stringResource(R.string.music_stream_playlist_label),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
if (streamPlaylist.isEmpty()) {
Text(
stringResource(R.string.music_stream_playlist_empty),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
} else {
streamPlaylist.forEachIndexed { idx, entry ->
val displayLabel = entry.name.ifBlank { entry.url }
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Icon(
Icons.Filled.Radio,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(Modifier.width(6.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = displayLabel,
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (entry.name.isNotBlank() && entry.url.isNotBlank()) {
Text(
text = entry.url,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.55f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
IconButton(
onClick = {
if (idx > 0) {
streamPlaylist = streamPlaylist.toMutableList().also { list ->
val tmp = list[idx - 1]
list[idx - 1] = list[idx]
list[idx] = tmp
}
}
},
enabled = idx > 0,
modifier = Modifier.size(32.dp)
) {
Icon(
Icons.Filled.KeyboardArrowUp,
contentDescription = stringResource(R.string.music_stream_move_up),
modifier = Modifier.size(18.dp)
)
}
IconButton(
onClick = {
if (idx < streamPlaylist.size - 1) {
streamPlaylist = streamPlaylist.toMutableList().also { list ->
val tmp = list[idx + 1]
list[idx + 1] = list[idx]
list[idx] = tmp
}
}
},
enabled = idx < streamPlaylist.size - 1,
modifier = Modifier.size(32.dp)
) {
Icon(
Icons.Filled.KeyboardArrowDown,
contentDescription = stringResource(R.string.music_stream_move_down),
modifier = Modifier.size(18.dp)
)
}
IconButton(
onClick = {
editingStreamIndex = idx
newStreamName = entry.name
newStreamUrl = entry.url
newStreamError = ""
},
modifier = Modifier.size(32.dp)
) {
Icon(
Icons.Filled.Edit,
contentDescription = stringResource(R.string.music_stream_edit),
modifier = Modifier.size(16.dp)
)
}
IconButton(
onClick = {
streamPlaylist = streamPlaylist.toMutableList()
.also { it.removeAt(idx) }
if (editingStreamIndex == idx) {
editingStreamIndex = -1
newStreamName = ""
newStreamUrl = ""
newStreamError = ""
} else if (editingStreamIndex > idx) {
editingStreamIndex -= 1
}
},
modifier = Modifier.size(32.dp)
) {
Icon(
Icons.Filled.Close,
contentDescription = stringResource(R.string.music_stream_remove),
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.error
)
}
}
}
}
// Anzeige des Stream-Playlist-Validierungsfehlers (z. B. leer beim Save)
if (streamUrlError.isNotBlank()) {
Text(
streamUrlError,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
// ─── Eingabe: neuer / editierter Stream ───────────────────
val editing = editingStreamIndex in streamPlaylist.indices
Text(
if (editing) stringResource(R.string.music_stream_edit_section_label)
else stringResource(R.string.music_stream_add_section_label),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary
)
OutlinedTextField(
value = streamUrl,
onValueChange = { streamUrl = it; streamUrlError = "" },
value = newStreamName,
onValueChange = { newStreamName = it },
label = { Text(stringResource(R.string.music_stream_name_label)) },
placeholder = { Text(stringResource(R.string.music_stream_name_placeholder)) },
singleLine = true,
leadingIcon = { Icon(Icons.Filled.Label, null) },
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = newStreamUrl,
onValueChange = { newStreamUrl = it; newStreamError = "" },
label = { Text(stringResource(R.string.music_stream_url_label)) },
placeholder = { Text("https://stream.example.com/radio") },
isError = streamUrlError.isNotBlank(),
isError = newStreamError.isNotBlank(),
supportingText = {
if (streamUrlError.isNotBlank()) {
Text(streamUrlError, color = MaterialTheme.colorScheme.error)
if (newStreamError.isNotBlank()) {
Text(newStreamError, color = MaterialTheme.colorScheme.error)
} else {
Text(
stringResource(R.string.music_stream_url_hint),
@@ -288,6 +486,89 @@ fun BegleitmusikDialog(
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.fillMaxWidth()
) {
OutlinedButton(
onClick = {
val err = validateStreamUrlInput(newStreamUrl)
if (err != null) {
newStreamError = err
return@OutlinedButton
}
val entry = StreamEntry(
url = newStreamUrl.trim(),
name = newStreamName.trim()
)
streamPlaylist = if (editing) {
streamPlaylist.toMutableList().also { list ->
list[editingStreamIndex] = entry
}
} else {
streamPlaylist + entry
}
editingStreamIndex = -1
newStreamName = ""
newStreamUrl = ""
newStreamError = ""
streamUrlError = ""
}
) {
Icon(
if (editing) Icons.Filled.Check else Icons.Filled.Add,
null, Modifier.size(16.dp)
)
Spacer(Modifier.width(4.dp))
Text(
if (editing) stringResource(R.string.music_stream_save_entry)
else stringResource(R.string.music_stream_add_entry)
)
}
if (editing) {
OutlinedButton(
onClick = {
editingStreamIndex = -1
newStreamName = ""
newStreamUrl = ""
newStreamError = ""
}
) {
Text(stringResource(R.string.cancel))
}
}
if (streamPlaylist.isNotEmpty()) {
OutlinedButton(
onClick = {
streamPlaylist = emptyList()
editingStreamIndex = -1
},
colors = ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.error
)
) {
Text(stringResource(R.string.music_stream_clear))
}
}
}
// Shuffle auch für Stream-Playlist erlauben (sinnvoll bei
// mehreren Sendern, die in zufälliger Reihenfolge laufen sollen)
if (streamPlaylist.size > 1) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text(
stringResource(R.string.music_shuffle),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f)
)
Switch(checked = shuffle, onCheckedChange = { shuffle = it })
}
}
// Hinweis zu YouTube/SoundCloud/radio.de
Surface(
color = MaterialTheme.colorScheme.secondaryContainer,
@@ -456,6 +737,135 @@ fun BegleitmusikDialog(
}
}
)
// Sub-Dialog: Bekannte Audiodateien aus der Bibliothek auswählen.
if (showLibraryPicker) {
LibraryPickerDialog(
library = audioLibrary,
alreadySelectedUris = localPlaylist.map { it.uriString }.toSet(),
onDismiss = { showLibraryPicker = false },
onConfirm = { picked ->
if (picked.isNotEmpty()) {
// URI-Berechtigungen erneut sichern (idempotent für bereits
// freigegebene URIs; verhindert Lese-Fehler nach App-Restart)
picked.forEach { item ->
runCatching {
context.contentResolver.takePersistableUriPermission(
Uri.parse(item.uriString),
android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION
)
}
}
localPlaylist = (localPlaylist + picked).distinctBy { it.uriString }
}
showLibraryPicker = false
}
)
}
}
/**
* Sub-Dialog zur Auswahl von Audiodateien aus der tour-übergreifenden Audio-Bibliothek.
*
* Zeigt alle bekannten Audiodateien (aus Wegpunkten und allen Begleitmusik-Playlists),
* mit Häkchen für die Auswahl. Bereits in der aktuellen Playlist befindliche Dateien
* sind vorab angekreuzt und können hier nicht entfernt werden nur hinzukommende
* Auswahl wird übergeben.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun LibraryPickerDialog(
library: List<de.waypointaudio.data.PlaylistItem>,
alreadySelectedUris: Set<String>,
onDismiss: () -> Unit,
onConfirm: (List<de.waypointaudio.data.PlaylistItem>) -> Unit
) {
// Nur Dateien anzeigen, die noch nicht in der aktuellen Playlist sind
val available = remember(library, alreadySelectedUris) {
library.filter { it.uriString !in alreadySelectedUris }
}
val checkedUris = remember { mutableStateListOf<String>() }
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text(
stringResource(R.string.music_library_picker_title),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold
)
},
text = {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
stringResource(R.string.music_library_picker_subtitle),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
if (available.isEmpty()) {
Text(
stringResource(R.string.music_library_picker_empty),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
} else {
available.forEach { item ->
val isChecked = item.uriString in checkedUris
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Checkbox(
checked = isChecked,
onCheckedChange = { c ->
if (c) {
if (item.uriString !in checkedUris) checkedUris += item.uriString
} else {
checkedUris.remove(item.uriString)
}
}
)
Icon(
Icons.Filled.AudioFile,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(Modifier.width(6.dp))
Text(
text = item.displayName,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
},
confirmButton = {
TextButton(
enabled = checkedUris.isNotEmpty(),
onClick = {
val picked = available.filter { it.uriString in checkedUris }
onConfirm(picked)
}
) {
Text(stringResource(R.string.music_library_picker_add))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.cancel))
}
}
)
}
@Composable
@@ -0,0 +1,677 @@
package de.waypointaudio.ui
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.VolumeOff
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.CloseFullscreen
import androidx.compose.material.icons.filled.OpenInFull
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.SkipNext
import androidx.compose.material.icons.filled.SkipPrevious
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import de.waypointaudio.data.PlaybackProgress
import de.waypointaudio.data.Waypoint
import de.waypointaudio.data.formatRemaining
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.sin
/**
* v2.5.11 — Clip-Detailansicht (Bottom Sheet) für einen einzelnen
* Wegpunkt/Clip.
*
* Zeigt Titel + Tour-Kontext, Wiedergabe-Status, Play/Pause-Steuerung
* (Prev/Next falls sinnvoll), und eine animierte, gestaltete Waveform-
* /Spectrum-Visualisierung. Es findet *keine* echte FFT statt — die
* Visualisierung lebt rein von `infiniteRepeatable`, damit sie bei
* Wiedergabe lebendig wirkt und im Pause-Zustand zur Ruhe kommt.
*
* v2.5.15 — Ein zusätzlicher Maximize-Button erlaubt das Umschalten in
* eine echte Vollbild-Detailansicht ([ClipDetailFullscreen]), die mehr
* Raum für Waveform, Cliplist, Countdown und Transport-Steuerung bietet.
*/
data class WaypointDetailUiState(
val waypoint: Waypoint,
val tourName: String,
val positionInTour: Int,
val totalInTour: Int,
val isCurrentlyPlaying: Boolean,
/**
* Liste aller (aktiven und inaktiven) Clips dieses Waypoints. Wird
* ab v2.5.14 vollständig angezeigt; der gerade aktive Clip wird
* farblich hervorgehoben.
*/
val tracks: List<TrackPreview>,
/**
* v2.5.14 — Position des aktuell geladenen Clips innerhalb des Waypoints
* (1-basiert). 0 bedeutet "kein Clip geladen / unbekannt", in welchem
* Fall die Anzeige "Clip N/M" ausgeblendet wird.
*/
val currentClipIndex: Int = 0,
/** v2.5.14 — Anzahl Clips dieses Waypoints (Anzeige "Clip 1 von 3"). */
val totalClips: Int = 0,
/** v2.5.14 — Fortschritts-/Countdown-Snapshot. */
val progress: PlaybackProgress = PlaybackProgress.EMPTY,
) {
data class TrackPreview(
val title: String,
val uri: String,
val isPrimary: Boolean,
/** v2.5.14 — True, wenn dies der gerade geladene/abspielende Clip ist. */
val isCurrent: Boolean = false,
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ClipDetailSheet(
state: WaypointDetailUiState,
onPlayPause: () -> Unit,
onPrevious: (() -> Unit)?,
onNext: (() -> Unit)?,
onStop: () -> Unit,
onDismiss: () -> Unit,
onMaximize: (() -> Unit)? = null,
) {
val sheetState = rememberModalBottomSheetState(
skipPartiallyExpanded = true
)
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
containerColor = MaterialTheme.colorScheme.surface
) {
ClipDetailHeader(
state = state,
onDismiss = onDismiss,
onMaximize = onMaximize,
isFullscreen = false,
)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
ClipDetailBody(
state = state,
waveformHeight = 108.dp,
showTracksInline = true,
)
ClipDetailTransport(
state = state,
onPlayPause = onPlayPause,
onPrevious = onPrevious,
onNext = onNext,
onStop = onStop,
)
Spacer(Modifier.height(8.dp))
}
}
}
/**
* v2.5.15 — Vollbild-Variante der Clip-Detailansicht. Wird als modaler
* Dialog mit `usePlatformDefaultWidth = false` geöffnet, sodass die
* gesamte Bildschirmfläche zur Verfügung steht. Inhalte sind dieselben
* wie im Bottom-Sheet, aber luftiger angeordnet und mit größerer
* Waveform-Visualisierung. Die Clipliste wird scrollbar dargestellt,
* sodass auch Wegpunkte mit vielen Clips komfortabel bedient werden.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ClipDetailFullscreen(
state: WaypointDetailUiState,
onPlayPause: () -> Unit,
onPrevious: (() -> Unit)?,
onNext: (() -> Unit)?,
onStop: () -> Unit,
onDismiss: () -> Unit,
onMinimize: () -> Unit,
) {
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(
usePlatformDefaultWidth = false,
dismissOnBackPress = true,
dismissOnClickOutside = false,
)
) {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.surface,
) {
Scaffold(
topBar = {
TopAppBar(
title = {
Text(
text = state.waypoint.name.ifBlank { "Unbenannt" },
maxLines = 1,
overflow = TextOverflow.Ellipsis,
fontWeight = FontWeight.SemiBold,
)
},
navigationIcon = {
IconButton(onClick = onMinimize) {
Icon(
imageVector = Icons.Filled.CloseFullscreen,
contentDescription = "Vollbild verlassen",
)
}
},
actions = {
IconButton(onClick = onDismiss) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = "Player schließen",
)
}
}
)
}
) { innerPadding ->
ClipDetailFullscreenContent(
state = state,
onPlayPause = onPlayPause,
onPrevious = onPrevious,
onNext = onNext,
onStop = onStop,
contentPadding = innerPadding,
)
}
}
}
}
@Composable
private fun ClipDetailFullscreenContent(
state: WaypointDetailUiState,
onPlayPause: () -> Unit,
onPrevious: (() -> Unit)?,
onNext: (() -> Unit)?,
onStop: () -> Unit,
contentPadding: PaddingValues,
) {
val tracks = state.tracks
Column(
modifier = Modifier
.fillMaxSize()
.padding(contentPadding)
.padding(horizontal = 20.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
// Tour-/Positions-Kontext direkt unter dem TopAppBar-Titel.
val sub = buildString {
append("Tour: ${state.tourName}")
if (state.totalInTour > 0) {
append(" · ")
append("Position ${state.positionInTour}/${state.totalInTour}")
}
if (!state.waypoint.isActive) append(" · inaktiv")
}
Text(
text = sub,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 4.dp),
)
// Größere Waveform für mehr Bühne im Vollbild.
ClipWaveform(
isPlaying = state.isCurrentlyPlaying,
hasAudio = tracks.any { it.uri.isNotBlank() } ||
state.waypoint.soundUri.isNotBlank(),
heightDp = 200,
)
ClipDetailStatusRow(state = state)
ClipDetailRemainingRow(state = state)
// Cliplist scrollt unabhängig, falls viele Clips vorhanden sind.
if (tracks.isEmpty()) {
ClipDetailEmptyTracksRow()
} else {
Text(
text = "Clips",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
)
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.weight(1f, fill = true),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(tracks, key = { it.uri + "|" + it.title }) { track ->
ClipTrackRow(track = track, isPlaying = state.isCurrentlyPlaying)
}
}
}
ClipDetailTransport(
state = state,
onPlayPause = onPlayPause,
onPrevious = onPrevious,
onNext = onNext,
onStop = onStop,
buttonScale = 1.25f,
)
Spacer(Modifier.height(8.dp))
}
}
@Composable
private fun ClipDetailHeader(
state: WaypointDetailUiState,
onDismiss: () -> Unit,
onMaximize: (() -> Unit)?,
isFullscreen: Boolean,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = state.waypoint.name.ifBlank { "Unbenannt" },
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
val sub = buildString {
append("Tour: ${state.tourName}")
if (state.totalInTour > 0) {
append(" · ")
append("Position ${state.positionInTour}/${state.totalInTour}")
}
if (!state.waypoint.isActive) append(" · inaktiv")
}
Text(
text = sub,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.65f),
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
if (onMaximize != null && !isFullscreen) {
IconButton(onClick = onMaximize) {
Icon(
imageVector = Icons.Filled.OpenInFull,
contentDescription = "Detail maximieren",
)
}
}
IconButton(onClick = onDismiss) {
Icon(
Icons.Filled.Close,
contentDescription = "Detail schließen"
)
}
}
}
@Composable
private fun ClipDetailBody(
state: WaypointDetailUiState,
waveformHeight: androidx.compose.ui.unit.Dp,
showTracksInline: Boolean,
) {
ClipWaveform(
isPlaying = state.isCurrentlyPlaying,
hasAudio = state.tracks.any { it.uri.isNotBlank() } ||
state.waypoint.soundUri.isNotBlank(),
heightDp = waveformHeight.value.toInt(),
)
ClipDetailStatusRow(state = state)
ClipDetailRemainingRow(state = state)
if (showTracksInline) {
if (state.tracks.isEmpty()) {
ClipDetailEmptyTracksRow()
} else {
state.tracks.forEach { track ->
ClipTrackRow(track = track, isPlaying = state.isCurrentlyPlaying)
}
}
}
}
@Composable
private fun ClipDetailStatusRow(state: WaypointDetailUiState) {
val statusText = when {
state.waypoint.soundUri.isBlank() && state.tracks.isEmpty()
-> "Kein Audio hinterlegt"
state.isCurrentlyPlaying -> "Wiedergabe läuft"
else -> "Bereit"
}
val clipPositionLabel =
if (state.totalClips > 1 && state.currentClipIndex in 1..state.totalClips) {
"Clip ${state.currentClipIndex} von ${state.totalClips}"
} else null
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = statusText,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
modifier = Modifier.weight(1f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (clipPositionLabel != null) {
Spacer(Modifier.size(8.dp))
Text(
text = clipPositionLabel,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
)
}
}
}
@Composable
private fun ClipDetailRemainingRow(state: WaypointDetailUiState) {
val remainingLabel: String? = when {
!state.isCurrentlyPlaying && state.progress.positionMs <= 0L -> null
state.progress.durationMs > 0L ->
"Verbleibend " + formatRemaining(state.progress.remainingMs)
else -> null
}
if (remainingLabel != null) {
Text(
text = remainingLabel,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.75f),
)
}
}
@Composable
private fun ClipDetailEmptyTracksRow() {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.AutoMirrored.Filled.VolumeOff,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f),
modifier = Modifier.size(18.dp)
)
Spacer(Modifier.size(8.dp))
Text(
text = "Dieser Wegpunkt hat aktuell keinen Audioclip.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
}
}
@Composable
private fun ClipTrackRow(track: WaypointDetailUiState.TrackPreview, isPlaying: Boolean) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
val dotActive = isPlaying && track.isCurrent
Box(
modifier = Modifier
.size(10.dp)
.clip(RoundedCornerShape(50))
.background(
if (dotActive)
MaterialTheme.colorScheme.primary
else if (track.isCurrent)
MaterialTheme.colorScheme.primary.copy(alpha = 0.6f)
else
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f)
)
)
Spacer(Modifier.size(8.dp))
Text(
text = track.title.ifBlank { "Audio-Datei" },
style = MaterialTheme.typography.bodyMedium,
color = if (track.isCurrent)
MaterialTheme.colorScheme.primary
else
MaterialTheme.colorScheme.onSurface,
modifier = Modifier.weight(1f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
@Composable
private fun ClipDetailTransport(
state: WaypointDetailUiState,
onPlayPause: () -> Unit,
onPrevious: (() -> Unit)?,
onNext: (() -> Unit)?,
onStop: () -> Unit,
buttonScale: Float = 1f,
) {
val playSize = (64f * buttonScale).dp
val playIconSize = (48f * buttonScale).dp
val sideIconSize = (34f * buttonScale).dp
val stopIconSize = (28f * buttonScale).dp
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically
) {
val prevEnabled = onPrevious != null
IconButton(
onClick = { onPrevious?.invoke() },
enabled = prevEnabled
) {
Icon(
Icons.Filled.SkipPrevious,
contentDescription = "Vorheriger Clip",
modifier = Modifier.size(sideIconSize),
tint = if (prevEnabled) MaterialTheme.colorScheme.onSurface
else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f)
)
}
IconButton(
onClick = onPlayPause,
enabled = state.tracks.isNotEmpty() ||
state.waypoint.soundUri.isNotBlank(),
modifier = Modifier.size(playSize)
) {
Icon(
imageVector = if (state.isCurrentlyPlaying)
Icons.Filled.Pause else Icons.Filled.PlayArrow,
contentDescription = if (state.isCurrentlyPlaying)
"Pause" else "Wiedergabe",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(playIconSize)
)
}
val nextEnabled = onNext != null
IconButton(
onClick = { onNext?.invoke() },
enabled = nextEnabled
) {
Icon(
Icons.Filled.SkipNext,
contentDescription = "Nächster Clip",
modifier = Modifier.size(sideIconSize),
tint = if (nextEnabled) MaterialTheme.colorScheme.onSurface
else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f)
)
}
IconButton(
onClick = onStop,
enabled = state.isCurrentlyPlaying
) {
Icon(
Icons.Filled.Stop,
contentDescription = "Stop",
modifier = Modifier.size(stopIconSize),
tint = if (state.isCurrentlyPlaying)
MaterialTheme.colorScheme.onSurface
else
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f)
)
}
}
}
/**
* Stilisierte Waveform / Pseudo-Spectrum-Analyzer.
*
* Bewusst keine echte FFT — die Visualisierung soll bei laufender Wiedergabe
* lebendig wirken und bei Pause/ohne Audio zur Ruhe kommen. Wir kombinieren
* mehrere Sinus-Wellen mit unterschiedlichen Frequenzen und einen langsam
* driftenden Phasenwert, was eine glaubhafte, organische Bewegung erzeugt.
*
* v2.5.15 — Die Höhe ist parametrisierbar, damit dieselbe Visualisierung
* sowohl im kompakten Bottom-Sheet als auch im Vollbild eingesetzt werden
* kann.
*/
@Composable
private fun ClipWaveform(
isPlaying: Boolean,
hasAudio: Boolean,
heightDp: Int = 108,
) {
val transition = rememberInfiniteTransition(label = "clip-wave")
val phase by transition.animateFloat(
initialValue = 0f,
targetValue = (2f * PI).toFloat(),
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 2400, easing = LinearEasing),
repeatMode = RepeatMode.Restart
),
label = "clip-wave-phase"
)
val primary = MaterialTheme.colorScheme.primary
val onSurface = MaterialTheme.colorScheme.onSurface
val height = heightDp.dp
Box(
modifier = Modifier
.fillMaxWidth()
.height(height)
.clip(RoundedCornerShape(12.dp))
.background(
Brush.verticalGradient(
colors = listOf(
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.55f),
MaterialTheme.colorScheme.surface
)
)
)
) {
Canvas(
modifier = Modifier
.fillMaxWidth()
.height(height)
.padding(horizontal = 8.dp, vertical = 6.dp)
) {
val w = size.width
val h = size.height
val midY = h / 2f
// Quelle: Spectrum-Balken (32 Stück) — leben nur, wenn isPlaying.
val barCount = 32
val barSpacing = 3f
val barWidth = (w - (barCount - 1) * barSpacing) / barCount
val activity = if (!hasAudio) 0.10f else if (isPlaying) 1.0f else 0.18f
for (i in 0 until barCount) {
val u = i.toFloat() / (barCount - 1).toFloat()
// gemischtes Pseudo-Spektrum
val s1 = sin(u * 6f + phase) * 0.55f
val s2 = sin(u * 13f + phase * 1.7f + 0.5f) * 0.30f
val s3 = sin(u * 21f - phase * 0.6f) * 0.15f
val raw = (s1 + s2 + s3)
val amp = (abs(raw) * activity + 0.06f).coerceIn(0.06f, 1.0f)
val barH = amp * (h * 0.86f)
val x = i * (barWidth + barSpacing)
val top = midY - barH / 2f
val color = if (isPlaying && hasAudio) {
Color(
red = primary.red,
green = primary.green,
blue = primary.blue,
alpha = (0.55f + 0.35f * amp).coerceIn(0f, 1f)
)
} else {
Color(
red = onSurface.red,
green = onSurface.green,
blue = onSurface.blue,
alpha = (0.18f + 0.12f * amp).coerceIn(0f, 1f)
)
}
drawRoundRect(
color = color,
topLeft = Offset(x, top),
size = androidx.compose.ui.geometry.Size(barWidth, barH),
cornerRadius = androidx.compose.ui.geometry.CornerRadius(barWidth / 2f, barWidth / 2f)
)
}
}
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,948 @@
package de.waypointaudio.ui
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.Image
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import de.waypointaudio.R
import de.waypointaudio.data.HomeModulesSettings
import de.waypointaudio.data.HomeModulesSettingsStore
import de.waypointaudio.data.MusicSourceType
import de.waypointaudio.data.TourCover
import de.waypointaudio.data.TourCoverIcon
import de.waypointaudio.data.TourCoverPalette
import de.waypointaudio.data.Waypoint
import de.waypointaudio.license.LicenseFeatureAccess
import de.waypointaudio.license.LicenseStatusProvider
import de.waypointaudio.service.LivePttManager
import de.waypointaudio.viewmodel.WaypointViewModel
import kotlinx.coroutines.launch
import kotlin.math.abs
/**
* v2.5.0 — Neue Hauptseite "Audio-Bibliothek".
*
* Touren erscheinen als Cover-Karten in einem Grid. Der Klick auf eine Karte
* setzt die Tour aktiv und öffnet die bestehende Tour-/Waypoint-Ansicht
* ([WaypointListScreen]) — alle bisherigen Funktionen (Drag-and-drop,
* Audiozuordnung, Atmo, Begleitmusik, Backup, Touren ordnen) bleiben dort
* vollständig erreichbar.
*
* Optionale Module für PTT und Atmo werden über das Drei-Punkt-Menü
* "Startseite anpassen" gesteuert ([HomeModulesSettingsStore]). Bei aktivem
* Modul erscheint ein kompakter Block direkt auf der Startseite.
*
* Cover sind automatisch aus der GPS2Audio-Farbwelt (Teal/Türkis, dunkle
* Verläufe, Akzentlinien) generiert — keine externen Bilder nötig.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HomeScreen(
viewModel: WaypointViewModel,
onOpenTour: (String) -> Unit,
onOpenExplorer: () -> Unit,
onOpenClassicView: () -> Unit
) {
val context = LocalContext.current
val store = remember(context) { HomeModulesSettingsStore(context.applicationContext) }
val scope = rememberCoroutineScope()
val tourList by viewModel.tourList.collectAsState()
val waypoints by viewModel.waypoints.collectAsState()
val tourRoutes by viewModel.tourRoutes.collectAsState()
val tourCovers by viewModel.tourCovers.collectAsState()
val selectedTour by viewModel.selectedTour.collectAsState()
val serviceRunning by viewModel.serviceRunning.collectAsState()
// musicSettings: auf Home nicht mehr verwendet (Atmo-Modul entfernt in v2.5.30).
// Wird weiterhin in WaypointListScreen/Tour-Ansicht genutzt.
val musicPlaying by viewModel.musicPlaying.collectAsState()
val manualPlaying by viewModel.manualPlaying.collectAsState()
val manualCurrentName by viewModel.manualCurrentName.collectAsState()
val homeModules by store.settings.collectAsState(initial = HomeModulesSettings())
// v2.5.32 — Feature-Gate: PTT erfordert Profi-Zugang.
val licenseStatus = LicenseStatusProvider.status
val canPtt = LicenseFeatureAccess.canUsePtt(licenseStatus)
var showOverflow by remember { mutableStateOf(false) }
var showCustomize by remember { mutableStateOf(false) }
var showAbout by remember { mutableStateOf(false) }
// v2.5.19 — Tour-Sortier-Dialog auf der Hauptseite
var showTourReorder by remember { mutableStateOf(false) }
Scaffold(
topBar = {
// v2.5.8 — Wiedergabe-zuerst-UI: Der große GPS2Audio-Schriftzug ist
// raus. Stattdessen kompakte Topbar mit Brand-Icon links und
// schlankem „Audio-Bibliothek"-Label. So wirkt die Startseite mehr
// wie eine Audio-/Tour-Bibliothek, weniger wie ein technisches
// Dashboard. Aktionen (Explorer + Overflow) bleiben unverändert.
TopAppBar(
title = {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
Image(
painter = painterResource(id = R.drawable.ic_launcher),
contentDescription = "GPS2Audio",
modifier = Modifier.size(28.dp)
)
Text(
text = "Audio-Bibliothek",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
},
actions = {
IconButton(onClick = onOpenExplorer) {
Icon(Icons.Filled.Explore, contentDescription = "Explorer")
}
IconButton(onClick = { showOverflow = true }) {
Icon(Icons.Filled.MoreVert, contentDescription = "Menü")
}
DropdownMenu(
expanded = showOverflow,
onDismissRequest = { showOverflow = false }
) {
DropdownMenuItem(
text = { Text("Startseite anpassen") },
leadingIcon = { Icon(Icons.Filled.Tune, null) },
onClick = {
showOverflow = false
showCustomize = true
}
)
DropdownMenuItem(
text = { Text("Bearbeitungsmodus") },
leadingIcon = { Icon(Icons.Filled.ViewList, null) },
onClick = {
showOverflow = false
onOpenClassicView()
}
)
// v2.5.19 — „Touren ordnen" auf der Hauptseite/Audio-
// Bibliothek: Der Einstieg wurde aus der einzelnen
// Touransicht (WaypointListScreen) entfernt, weil er
// dort kein Tour-spezifischer Schritt ist, sondern eine
// Verwaltungsaktion über die gesamte Tourliste hinweg.
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_tour_reorder)) },
leadingIcon = { Icon(Icons.Filled.SwapVert, null) },
onClick = {
showOverflow = false
showTourReorder = true
},
enabled = tourList.size > 1
)
// v2.5.19 — „Track Editor" (Track-Entwürfe) aus dem
// Hauptmenü entfernt, weil dieselbe Funktion über den
// Explorer erreichbar ist (Explorer öffnet einen Draft
// im Vollbild-Editor).
DropdownMenuItem(
text = { Text("Explorer / Karte") },
leadingIcon = { Icon(Icons.Filled.Map, null) },
onClick = {
showOverflow = false
onOpenExplorer()
}
)
HorizontalDivider()
DropdownMenuItem(
text = { Text("Über GPS2Audio") },
leadingIcon = { Icon(Icons.Filled.Info, null) },
onClick = {
showOverflow = false
showAbout = true
}
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
titleContentColor = MaterialTheme.colorScheme.onSurface,
actionIconContentColor = MaterialTheme.colorScheme.primary
)
)
},
containerColor = MaterialTheme.colorScheme.background
) { padding ->
Column(
modifier = Modifier
.padding(padding)
.fillMaxSize()
.verticalScroll(rememberScrollState())
) {
// v2.5.8 — Hero-Kachel mit großem "Deine Audio-Touren · X Touren · Y
// Wegpunkte" wurde entfernt. Sie wirkte als zweiter technischer
// Header neben der TopAppBar. Der Explorer-Schnellzugriff bleibt
// über das TopAppBar-Icon und das Overflow-Menü vollständig
// erreichbar (siehe oben). Das spart vertikalen Platz für die
// eigentliche Audio-Bibliothek.
// ─ Atmo-Modul: auf der Startseite nicht angezeigt (v2.5.30).
// Atmo ist weiterhin in der Tour-/WaypointListScreen verfügbar.
// (homeModules.showAtmo wird auf der Home-Seite ignoriert)
// ─ Optionales PTT-Modul ─
// v2.5.32: PTT-Modul nur anzeigen wenn Profi-Zugang vorhanden.
// PTT-Aufnahmen bleiben ohne Profi-Zugang erhalten (nicht gelöscht).
if (homeModules.showPtt && canPtt) {
PttModuleCard(
pttManager = viewModel.pttManager,
onOpenRecordings = { onOpenClassicView() }
)
}
// ─ Bibliothek-Header ─
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, end = 16.dp, top = 18.dp, bottom = 6.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Filled.LibraryMusic,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
Spacer(Modifier.width(8.dp))
Text(
text = "Touren",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.weight(1f)
)
Text(
text = "${tourList.size}",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
// ─ Cover-Grid ─
// Wir nutzen ein einfaches Wrap-Layout statt LazyVerticalGrid in
// einer scrollbaren Column, um Compose-Nested-Scroll-Konflikte
// zu vermeiden.
val columns = 2
val rows = tourList.chunked(columns)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
rows.forEach { row ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
row.forEach { tour ->
val tourWaypoints = waypoints.count {
it.tourName.ifBlank { Waypoint.DEFAULT_TOUR_NAME } == tour
}
val hasRoute = tourRoutes[tour] != null
val isActive = tour == selectedTour && serviceRunning
val isSelected = tour == selectedTour
TourCoverCard(
tourName = tour,
waypointCount = tourWaypoints,
hasRoute = hasRoute,
isActive = isActive,
isSelected = isSelected,
cover = tourCovers[tour],
modifier = Modifier.weight(1f),
onClick = {
viewModel.selectTour(tour)
onOpenTour(tour)
}
)
}
// fill empty slot in last row
if (row.size < columns) {
Spacer(Modifier.weight(1f))
}
}
}
}
// ─ Now-Playing-Bar (kompakt, am Ende) ─
if (homeModules.showMiniNowPlaying) {
val isPlayingAnything = musicPlaying || manualPlaying
AnimatedVisibility(visible = isPlayingAnything) {
MiniNowPlayingBar(
atmoPlaying = musicPlaying,
manualPlaying = manualPlaying,
manualName = manualCurrentName,
tourName = selectedTour,
onStopAtmo = { viewModel.stopMusicPlayback() },
onStopManual = { viewModel.manualStop() }
)
}
}
Spacer(Modifier.height(80.dp))
}
}
if (showCustomize) {
CustomizeHomeDialog(
settings = homeModules,
canPtt = canPtt,
onSetShowPtt = { v -> scope.launch { store.setShowPtt(v) } },
// v2.5.30: Atmo-Toggle auf Startseite entfernt; Atmo bleibt in Tour-Ansicht.
onSetShowAtmo = { _ -> /* no-op on home */ },
onSetShowMini = { v -> scope.launch { store.setShowMiniNowPlaying(v) } },
onDismiss = { showCustomize = false }
)
}
if (showAbout) {
AboutDialog(onDismiss = { showAbout = false })
}
// v2.5.19 — Tour-Sortier-Dialog (verschoben von der einzelnen Touransicht
// in die Audio-Bibliothek). Wiederverwendung der bestehenden
// TourReorderDialog-Komponente aus WaypointListScreen (jetzt internal).
if (showTourReorder) {
TourReorderDialog(
tours = tourList,
onMove = { from, to -> viewModel.moveTour(from, to) },
onDismiss = { showTourReorder = false }
)
}
// Versions-Hinweis (einmal pro versionCode)
VersionNoticeDialogHost()
}
/* ───────────────────────────── Hero / Quick actions ───────────────────────── */
@Composable
private fun HeroQuickActions(
tourCount: Int,
waypointCount: Int,
onOpenExplorer: () -> Unit
) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp),
shape = RoundedCornerShape(20.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
),
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Deine Audio-Touren",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
Text(
text = "$tourCount Touren · $waypointCount Wegpunkte",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.85f)
)
}
FilledTonalIconButton(
onClick = onOpenExplorer,
colors = IconButtonDefaults.filledTonalIconButtonColors(
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary
),
modifier = Modifier.size(48.dp)
) {
Icon(Icons.Filled.Map, contentDescription = "Explorer")
}
}
}
}
/* ───────────────────────────── Tour cover card ───────────────────────────── */
@Composable
private fun TourCoverCard(
tourName: String,
waypointCount: Int,
hasRoute: Boolean,
isActive: Boolean,
isSelected: Boolean,
cover: TourCover? = null,
modifier: Modifier = Modifier,
onClick: () -> Unit
) {
val accent = generatedTourAccent(tourName)
val coverEndColor = Color(0xFF0D1B2A)
val onColor = MaterialTheme.colorScheme.onSurface
// v2.5.7 — Bild-Cover lädt einmalig per remember(path) via Helfer in
// WaypointListScreen (interne sichtbar im selben UI-Paket).
val coverImage = rememberTourCoverBitmap(cover?.imageUri)
// v2.5.7 — Cover-Variante aus persistiertem TourCover ableiten:
// 1) wenn Bild vorhanden → Bild
// 2) sonst wenn Palette/Icon explizit gewählt → diese
// 3) sonst Fallback wie bisher (generierter Akzent + Heuristik-Icon)
val (coverStart, coverEnd) = if (cover != null) {
homeTourCoverPaletteColors(cover.palette)
} else {
accent.copy(alpha = 0.95f) to coverEndColor
}
val coverIcon = cover?.let { homeTourCoverIconVector(it.icon) } ?: pickTourIcon(tourName)
Card(
modifier = modifier
.fillMaxWidth()
.clickable { onClick() },
shape = RoundedCornerShape(18.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
border = if (isSelected) BorderStroke(
width = 1.5.dp,
color = MaterialTheme.colorScheme.primary
) else null
) {
Column {
// Cover-Block (Top): Bild ODER generierter Verlauf + Icon
Box(
modifier = Modifier
.fillMaxWidth()
.height(110.dp)
.clip(RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp))
.drawWithCache {
val brush = Brush.linearGradient(
colors = listOf(coverStart, coverEnd),
start = Offset(0f, 0f),
end = Offset(size.width, size.height)
)
onDrawBehind {
drawRect(brush = brush, size = Size(size.width, size.height))
// dezente Wellenlinien nur, wenn kein Bild-Cover dominiert
val path = Path().apply {
moveTo(0f, size.height * 0.68f)
cubicTo(
size.width * 0.25f, size.height * 0.55f,
size.width * 0.55f, size.height * 0.85f,
size.width, size.height * 0.6f
)
}
drawPath(
path = path,
color = Color.White.copy(alpha = 0.18f),
style = Stroke(width = 2.dp.toPx())
)
val path2 = Path().apply {
moveTo(0f, size.height * 0.85f)
cubicTo(
size.width * 0.3f, size.height * 0.7f,
size.width * 0.6f, size.height * 1.05f,
size.width, size.height * 0.78f
)
}
drawPath(
path = path2,
color = Color.White.copy(alpha = 0.10f),
style = Stroke(width = 2.dp.toPx())
)
}
},
contentAlignment = Alignment.Center
) {
if (coverImage != null) {
// Bild-Cover füllt die ganze Kachel (center-crop).
androidx.compose.foundation.Image(
bitmap = coverImage,
contentDescription = null,
contentScale = androidx.compose.ui.layout.ContentScale.Crop,
modifier = Modifier.matchParentSize()
)
} else {
Icon(
imageVector = coverIcon,
contentDescription = null,
tint = Color.White.copy(alpha = 0.92f),
modifier = Modifier.size(46.dp)
)
}
// Aktiv-Indikator
if (isActive) {
Box(
modifier = Modifier
.align(Alignment.TopEnd)
.padding(8.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.tertiary)
.padding(horizontal = 8.dp, vertical = 3.dp)
) {
Text(
"LIVE",
color = MaterialTheme.colorScheme.onTertiary,
fontWeight = FontWeight.Bold,
fontSize = 9.sp
)
}
}
}
// Text-Block
Column(modifier = Modifier.padding(12.dp)) {
Text(
text = tourName,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = onColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(Modifier.height(2.dp))
Text(
text = subtitleFor(waypointCount, hasRoute),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(Modifier.height(8.dp))
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically
) {
Badge(
icon = Icons.Filled.PinDrop,
text = "$waypointCount"
)
if (hasRoute) {
Badge(
icon = Icons.Filled.Route,
text = "Route",
tint = MaterialTheme.colorScheme.tertiary
)
}
}
}
}
}
}
@Composable
private fun Badge(
icon: androidx.compose.ui.graphics.vector.ImageVector,
text: String,
tint: Color = MaterialTheme.colorScheme.primary
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.clip(RoundedCornerShape(50))
.background(tint.copy(alpha = 0.15f))
.padding(horizontal = 8.dp, vertical = 3.dp)
) {
Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.size(12.dp))
Spacer(Modifier.width(4.dp))
Text(
text = text,
style = MaterialTheme.typography.labelSmall,
color = tint,
fontWeight = FontWeight.Medium
)
}
}
/* ───────────────────────────── Atmo module ────────────────────────────── */
@Composable
private fun AtmoModuleCard(
enabled: Boolean,
playing: Boolean,
sourceLabel: String,
selectedTour: String,
onPlayPause: () -> Unit,
onStop: () -> Unit,
onNext: () -> Unit,
onPrevious: () -> Unit,
onConfigure: () -> Unit
) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 6.dp),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)
) {
Column(modifier = Modifier.padding(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Filled.MusicNote,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp)
)
Spacer(Modifier.width(8.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Atmo",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold
)
Text(
text = if (enabled) "$sourceLabel · $selectedTour"
else "Deaktiviert · $selectedTour",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
IconButton(onClick = onConfigure) {
Icon(
Icons.Filled.Tune,
contentDescription = "Atmo konfigurieren",
tint = MaterialTheme.colorScheme.primary
)
}
}
Spacer(Modifier.height(6.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
FilledTonalIconButton(
onClick = onPrevious,
enabled = enabled
) { Icon(Icons.Filled.SkipPrevious, contentDescription = "Vorheriger Titel") }
FilledIconButton(
onClick = onPlayPause,
enabled = enabled,
colors = IconButtonDefaults.filledIconButtonColors(
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary
)
) {
Icon(
if (playing) Icons.Filled.Pause else Icons.Filled.PlayArrow,
contentDescription = if (playing) "Pause" else "Atmo abspielen"
)
}
FilledTonalIconButton(
onClick = onNext,
enabled = enabled
) { Icon(Icons.Filled.SkipNext, contentDescription = "Nächster Titel") }
FilledTonalIconButton(
onClick = onStop,
enabled = enabled && playing
) { Icon(Icons.Filled.Stop, contentDescription = "Atmo stoppen") }
Spacer(Modifier.weight(1f))
if (playing) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.clip(RoundedCornerShape(50))
.background(MaterialTheme.colorScheme.tertiary.copy(alpha = 0.15f))
.padding(horizontal = 8.dp, vertical = 3.dp)
) {
Box(
modifier = Modifier
.size(7.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.tertiary)
)
Spacer(Modifier.width(5.dp))
Text(
"LIVE",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.tertiary,
fontWeight = FontWeight.Bold
)
}
}
}
}
}
}
/* ───────────────────────────── PTT module ──────────────────────────────── */
@Composable
private fun PttModuleCard(
pttManager: LivePttManager,
onOpenRecordings: () -> Unit
) {
// Wiederverwendung der bestehenden LivePttCard (volle Funktion: Mic-Permission,
// Aufnahmen, Geräte). Die Card wird hier nur in einen Wrapper gehüllt, der
// Abstände/Form an die übrige Bibliothek-Oberfläche anpasst.
LivePttCard(
pttManager = pttManager,
onOpenRecordings = onOpenRecordings,
modifier = Modifier.padding(horizontal = 0.dp, vertical = 6.dp)
)
}
/* ───────────────────────────── Mini now-playing ────────────────────────── */
@Composable
private fun MiniNowPlayingBar(
atmoPlaying: Boolean,
manualPlaying: Boolean,
manualName: String?,
tourName: String,
onStopAtmo: () -> Unit,
onStopManual: () -> Unit
) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 10.dp),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.tertiaryContainer
),
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.size(10.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.tertiary)
)
Spacer(Modifier.width(10.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = when {
manualPlaying && manualName != null -> manualName
atmoPlaying -> "Atmo läuft"
else -> "Wiedergabe aktiv"
},
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onTertiaryContainer,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = "Tour: $tourName",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onTertiaryContainer.copy(alpha = 0.8f),
maxLines = 1
)
}
if (manualPlaying) {
IconButton(onClick = onStopManual) {
Icon(Icons.Filled.Stop, "Wiedergabe stoppen")
}
} else if (atmoPlaying) {
IconButton(onClick = onStopAtmo) {
Icon(Icons.Filled.Stop, "Atmo stoppen")
}
}
}
}
}
/* ───────────────────────────── Customize dialog ────────────────────────── */
@Composable
private fun CustomizeHomeDialog(
settings: HomeModulesSettings,
canPtt: Boolean = true,
onSetShowPtt: (Boolean) -> Unit,
onSetShowAtmo: (Boolean) -> Unit,
onSetShowMini: (Boolean) -> Unit,
onDismiss: () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Startseite anpassen") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
text = "Wähle, welche Module auf der Hauptseite erscheinen sollen.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(6.dp))
// v2.5.30: Atmo-Modul-Toggle auf der Startseite entfernt.
// Atmo bleibt in der Tour-/WaypointListScreen vollständig verfügbar.
ToggleRow(
label = "PTT-Modul",
description = if (canPtt) "Schnellzugriff auf Live-PTT und Aufnahmen"
else "Profi-Funktion · GPS2Audio Profi-Lizenz erforderlich",
checked = settings.showPtt && canPtt,
enabled = canPtt,
onCheckedChange = { if (canPtt) onSetShowPtt(it) }
)
ToggleRow(
label = "Now-Playing-Bar",
description = "Zeigt laufende Wiedergabe als kompakte Leiste",
checked = settings.showMiniNowPlaying,
onCheckedChange = onSetShowMini
)
}
},
confirmButton = {
TextButton(onClick = onDismiss) { Text("Fertig") }
}
)
}
@Composable
private fun ToggleRow(
label: String,
description: String,
checked: Boolean,
enabled: Boolean = true,
onCheckedChange: (Boolean) -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
label,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
color = if (enabled) MaterialTheme.colorScheme.onSurface
else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f)
)
Text(
description,
style = MaterialTheme.typography.labelSmall,
color = if (enabled) MaterialTheme.colorScheme.onSurfaceVariant
else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
)
}
Switch(
checked = checked,
onCheckedChange = onCheckedChange,
enabled = enabled
)
}
}
/* ───────────────────────────── Helpers ─────────────────────────────────── */
private fun subtitleFor(waypointCount: Int, hasRoute: Boolean): String {
val parts = mutableListOf<String>()
parts.add("$waypointCount Wegpunkte")
if (hasRoute) parts.add("Route gespeichert")
return parts.joinToString(" · ")
}
private fun atmoSourceLabel(type: MusicSourceType): String = when (type) {
MusicSourceType.STREAM_URL -> "Stream-Playlist"
MusicSourceType.LOCAL_PLAYLIST -> "Lokale Playlist"
}
/**
* Deterministische Akzentfarbe basierend auf Tour-Namen, aus der GPS2Audio-
* Teal/Türkis-Familie. Kein Magenta — das bleibt Warn-/Lösch-Aktionen vorbehalten.
*/
private fun generatedTourAccent(tourName: String): Color {
val palette = listOf(
Color(0xFF0B7B82), // Teal 600
Color(0xFF1C5461), // dark teal
Color(0xFF004F54), // teal 800
Color(0xFF4DB6AC), // cyan 300
Color(0xFF26867E), // teal mid
Color(0xFF15838C), // teal alt
Color(0xFF3A8A82), // muted teal
Color(0xFF0E5A66) // deep teal
)
val hash = abs(tourName.hashCode())
return palette[hash % palette.size]
}
/**
* v2.5.7 — Spiegelt die in [WaypointListScreen] verwendete Palette-Zuordnung
* für [TourCoverPalette]. So sehen Home-Cards und Touransicht-Hero gleich aus,
* wenn ein Cover explizit ausgewählt wurde.
*/
private fun homeTourCoverPaletteColors(palette: TourCoverPalette): Pair<Color, Color> = when (palette) {
TourCoverPalette.TEAL -> Color(0xFF0B7B82) to Color(0xFF26867E)
TourCoverPalette.OCEAN -> Color(0xFF1E3C72) to Color(0xFF2A5298)
TourCoverPalette.SUNSET -> Color(0xFFFF6E7F) to Color(0xFFBFE9FF)
TourCoverPalette.FOREST -> Color(0xFF1F4037) to Color(0xFF99F2C8)
TourCoverPalette.BERRY -> Color(0xFF7B1FA2) to Color(0xFFE91E63)
TourCoverPalette.DUSK -> Color(0xFF3A1C71) to Color(0xFFD76D77)
TourCoverPalette.SAND -> Color(0xFFCB956D) to Color(0xFFEACDA3)
TourCoverPalette.MIDNIGHT -> Color(0xFF0F2027) to Color(0xFF2C5364)
}
/** v2.5.7 — Spiegelt die Icon-Mappings aus [WaypointListScreen]. */
private fun homeTourCoverIconVector(icon: TourCoverIcon): androidx.compose.ui.graphics.vector.ImageVector = when (icon) {
TourCoverIcon.HEADPHONES -> Icons.Filled.Headphones
TourCoverIcon.MUSIC_NOTE -> Icons.Filled.MusicNote
TourCoverIcon.HIKING -> Icons.Filled.Hiking
TourCoverIcon.PARK -> Icons.Filled.Park
TourCoverIcon.CITY -> Icons.Filled.LocationCity
TourCoverIcon.BIKE -> Icons.Filled.DirectionsBike
TourCoverIcon.WAVES -> Icons.Filled.Waves
TourCoverIcon.EXPLORE -> Icons.Filled.Explore
TourCoverIcon.LANDSCAPE -> Icons.Filled.Landscape
TourCoverIcon.CAMPAIGN -> Icons.Filled.Campaign
TourCoverIcon.NIGHTLIFE -> Icons.Filled.Nightlife
TourCoverIcon.THEATER_COMEDY -> Icons.Filled.TheaterComedy
}
/* Wähle Icon nach einfachem Heuristik-Schlüsselwort im Tour-Namen. */
private fun pickTourIcon(name: String): androidx.compose.ui.graphics.vector.ImageVector {
val lower = name.lowercase()
return when {
"wald" in lower || "natur" in lower || "park" in lower -> Icons.Filled.Park
"stadt" in lower || "city" in lower || "altstadt" in lower -> Icons.Filled.LocationCity
"berg" in lower || "alpen" in lower || "wander" in lower -> Icons.Filled.Hiking
"rad" in lower || "bike" in lower || "fahrrad" in lower -> Icons.Filled.DirectionsBike
"see" in lower || "fluss" in lower || "wasser" in lower -> Icons.Filled.Waves
"audio" in lower || "musik" in lower -> Icons.Filled.MusicNote
else -> Icons.Filled.Headphones
}
}
@@ -21,7 +21,8 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.FiberManualRecord
import androidx.compose.material.icons.filled.LibraryMusic
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.MicOff
import androidx.compose.material.icons.filled.Refresh
@@ -41,7 +42,8 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MenuAnchorType
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
@@ -61,30 +63,33 @@ import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import de.waypointaudio.R
import de.waypointaudio.data.AudioRoutingSettings
import de.waypointaudio.service.AudioDeviceItem
import de.waypointaudio.data.PttRecordingFormat
import de.waypointaudio.service.LivePttManager
/**
* Karte für Live / PTT auf dem Hauptbildschirm.
* Platzierung: zwischen Atmo-Mini-Player und Wegpunkt-Tracks-Überschrift.
*
* Features:
* - PTT starten / stoppen (Toggle-Button)
* - Statusanzeige (Bereit / Mikrofon aktiv / Berechtigung fehlt)
* - Echo-Warnung bei fehlendem Headset
* - Button zum Öffnen der Audio-Geräte-Einstellungen
* - Gerätewahl-Kurzanzeige (gewählte Geräte-Namen)
* v2.1.0:
* - Toggle „Live-Ausgabe" und „Aufnahme speichern" (mit Format M4A/AAC).
* - Aufnahme-Indikator (rotes Punkt) im aktiven Zustand.
* - Knopf „Aufnahmen…" öffnet die Bibliothek; Post-Aufnahme-Dialog erscheint
* automatisch nach jeder gespeicherten Aufnahme.
*
* @param pttManager Singleton-Manager
* @param onOpenRecordings Callback um die Aufnahme-Bibliothek zu öffnen
*/
@Composable
fun LivePttCard(
pttManager: LivePttManager,
onOpenRecordings: () -> Unit = {},
modifier: Modifier = Modifier
) {
val context = LocalContext.current
val pttActive by pttManager.pttActive.collectAsState()
val routing by pttManager.routingSettings.collectAsState()
val inputDevices by pttManager.inputDevices.collectAsState()
val statusMsg by pttManager.statusMessage.collectAsState()
val pttActive by pttManager.pttActive.collectAsState()
val recordingActive by pttManager.recordingActive.collectAsState()
val routing by pttManager.routingSettings.collectAsState()
val inputDevices by pttManager.inputDevices.collectAsState()
val statusMsg by pttManager.statusMessage.collectAsState()
var hasMicPermission by remember {
mutableStateOf(
@@ -106,12 +111,10 @@ fun LivePttCard(
}
}
// Geräte laden wenn Karte angezeigt wird
LaunchedEffect(Unit) {
pttManager.refreshDevices()
}
// Farbanimation für PTT-Button
val pttContainerColor by animateColorAsState(
targetValue = if (pttActive)
MaterialTheme.colorScheme.error
@@ -121,24 +124,26 @@ fun LivePttCard(
label = "pttColor"
)
// Anzeige-Name des gewählten Eingabegeräts
val inputDeviceName = remember(routing.selectedInputDeviceId, inputDevices) {
if (routing.selectedInputDeviceId == null) null
else inputDevices.firstOrNull { it.id == routing.selectedInputDeviceId }?.displayName
}
// v2.5.1 — Card mit sanften Cornern und größerem Innenabstand für
// einen ruhigeren Look in der GPS2Audio-Farbwelt.
Card(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 2.dp),
.padding(horizontal = 12.dp, vertical = 4.dp),
shape = androidx.compose.foundation.shape.RoundedCornerShape(14.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)
) {
Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) {
// ── Kopfzeile: Titel + Geräte-Button ─────────────────────────────
// ── Kopfzeile: Titel + Aufnahmen-Button + Einstellungen-Button ───
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
@@ -158,7 +163,13 @@ fun LivePttCard(
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.weight(1f)
)
// Gerätewahl-Button
IconButton(onClick = onOpenRecordings) {
Icon(
Icons.Filled.LibraryMusic,
contentDescription = stringResource(R.string.ptt_recordings_button),
tint = MaterialTheme.colorScheme.primary
)
}
IconButton(
onClick = {
pttManager.refreshDevices()
@@ -178,13 +189,13 @@ fun LivePttCard(
// ── Status-Zeile ──────────────────────────────────────────────────
val statusText = when {
!hasMicPermission -> stringResource(R.string.ptt_status_permission_missing)
recordingActive -> stringResource(R.string.ptt_recording_status)
pttActive -> stringResource(R.string.ptt_status_active)
statusMsg != null -> statusMsg!!
else -> stringResource(R.string.ptt_status_ready)
}
Row(verticalAlignment = Alignment.CenterVertically) {
// Status-Indikator (runder Punkt)
Box(
modifier = Modifier
.size(8.dp)
@@ -205,9 +216,17 @@ fun LivePttCard(
else -> MaterialTheme.colorScheme.onSurfaceVariant
}
)
if (recordingActive) {
Spacer(Modifier.width(8.dp))
Icon(
imageVector = Icons.Filled.FiberManualRecord,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(12.dp)
)
}
}
// Gewähltes Gerät anzeigen (falls nicht Systemstandard)
if (inputDeviceName != null) {
Spacer(Modifier.height(4.dp))
Text(
@@ -217,7 +236,6 @@ fun LivePttCard(
)
}
// Berechtigung verweigert Hinweis
if (showPermissionDeniedHint) {
Spacer(Modifier.height(4.dp))
Text(
@@ -227,6 +245,19 @@ fun LivePttCard(
)
}
// Mode summary (Live / Aufnahme / beides)
Spacer(Modifier.height(4.dp))
val modeText = when {
routing.liveOutputEnabled && routing.recordingEnabled -> "Live + Aufnahme"
routing.recordingEnabled -> "Nur Aufnahme"
else -> "Nur Live"
}
Text(
text = modeText,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.outline
)
Spacer(Modifier.height(6.dp))
// ── PTT-Hauptbutton ───────────────────────────────────────────────
@@ -257,10 +288,9 @@ fun LivePttCard(
)
}
// Echo-Warnung (immer sichtbar wenn kein Headset konfiguriert)
val noHeadset = routing.selectedOutputDeviceId == null &&
routing.selectedInputDeviceId == null
if (noHeadset) {
if (noHeadset && routing.liveOutputEnabled) {
Spacer(Modifier.height(6.dp))
Text(
text = stringResource(R.string.ptt_echo_warning),
@@ -271,9 +301,8 @@ fun LivePttCard(
}
}
// ── Audio-Geräte-Dialog ───────────────────────────────────────────────────
if (showDeviceDialog) {
AudioDeviceDialog(
PttSettingsDialog(
pttManager = pttManager,
onDismiss = { showDeviceDialog = false }
)
@@ -281,12 +310,12 @@ fun LivePttCard(
}
// ─────────────────────────────────────────────────────────────────────────────
// Audio-Geräte-Dialog
// PTT-Einstellungen Dialog (Live/Aufnahme + Geräte)
// ─────────────────────────────────────────────────────────────────────────────
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AudioDeviceDialog(
fun PttSettingsDialog(
pttManager: LivePttManager,
onDismiss: () -> Unit
) {
@@ -294,9 +323,12 @@ fun AudioDeviceDialog(
val outputDevices by pttManager.outputDevices.collectAsState()
val routing by pttManager.routingSettings.collectAsState()
// Lokale Auswahl-States
var selectedInputId by remember(routing) { mutableStateOf(routing.selectedInputDeviceId) }
var selectedOutputId by remember(routing) { mutableStateOf(routing.selectedOutputDeviceId) }
var liveEnabled by remember(routing) { mutableStateOf(routing.liveOutputEnabled) }
var recordEnabled by remember(routing) { mutableStateOf(routing.recordingEnabled) }
var format by remember(routing) { mutableStateOf(routing.recordingFormat) }
var validationHint by remember { mutableStateOf<String?>(null) }
var inputExpanded by remember { mutableStateOf(false) }
var outputExpanded by remember { mutableStateOf(false) }
@@ -314,10 +346,113 @@ fun AudioDeviceDialog(
text = {
Column(
modifier = Modifier.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp)
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
// ── Live / Aufnahme-Modus ─────────────────────────────────────
Text(
text = stringResource(R.string.ptt_settings_section),
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary
)
// ── Refresh-Button ────────────────────────────────────────────
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringResource(R.string.ptt_live_output_toggle),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold
)
Text(
text = stringResource(R.string.ptt_live_output_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Switch(
checked = liveEnabled,
onCheckedChange = { v ->
liveEnabled = v
validationHint = null
if (!v && !recordEnabled) {
liveEnabled = true
validationHint = "Mindestens eine Option aktiv lassen."
}
}
)
}
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringResource(R.string.ptt_recording_toggle),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold
)
Text(
text = stringResource(R.string.ptt_recording_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Switch(
checked = recordEnabled,
onCheckedChange = { v ->
recordEnabled = v
validationHint = null
if (!v && !liveEnabled) {
recordEnabled = true
validationHint = "Mindestens eine Option aktiv lassen."
}
}
)
}
validationHint?.let { hint ->
Text(
text = hint,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.error
)
}
// Format (M4A only, WAV disabled)
if (recordEnabled) {
Text(
text = stringResource(R.string.ptt_format_label),
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary
)
Row(verticalAlignment = Alignment.CenterVertically) {
RadioButton(
selected = format == PttRecordingFormat.M4A_AAC,
onClick = { format = PttRecordingFormat.M4A_AAC }
)
Text(stringResource(R.string.ptt_format_m4a))
}
Row(verticalAlignment = Alignment.CenterVertically) {
RadioButton(
selected = false,
onClick = { /* WAV deaktiviert in v2.1.0 */ },
enabled = false
)
Text(
stringResource(R.string.ptt_format_wav),
color = MaterialTheme.colorScheme.outline
)
}
}
HorizontalDivider()
// ── Geräteauswahl ─────────────────────────────────────────────
TextButton(
onClick = { pttManager.refreshDevices() },
modifier = Modifier.align(Alignment.End)
@@ -329,7 +464,6 @@ fun AudioDeviceDialog(
style = MaterialTheme.typography.labelMedium)
}
// ── Eingabegerät (Mikrofon) ───────────────────────────────────
Text(
text = stringResource(R.string.audio_input_device_label),
style = MaterialTheme.typography.labelMedium,
@@ -365,7 +499,6 @@ fun AudioDeviceDialog(
expanded = inputExpanded,
onDismissRequest = { inputExpanded = false }
) {
// Systemstandard
DropdownMenuItem(
text = { Text(stringResource(R.string.ptt_device_system_default)) },
onClick = {
@@ -387,7 +520,6 @@ fun AudioDeviceDialog(
}
}
// ── Ausgabegerät (Lautsprecher) ───────────────────────────────
Text(
text = stringResource(R.string.audio_output_device_label),
style = MaterialTheme.typography.labelMedium,
@@ -423,7 +555,6 @@ fun AudioDeviceDialog(
expanded = outputExpanded,
onDismissRequest = { outputExpanded = false }
) {
// Systemstandard
DropdownMenuItem(
text = { Text(stringResource(R.string.ptt_device_system_default)) },
onClick = {
@@ -445,7 +576,6 @@ fun AudioDeviceDialog(
}
}
// ── Hinweis zu Geräte-IDs ─────────────────────────────────────
HorizontalDivider()
Text(
text = stringResource(R.string.audio_devices_hint),
@@ -459,7 +589,10 @@ fun AudioDeviceDialog(
pttManager.saveRoutingSettings(
AudioRoutingSettings(
selectedInputDeviceId = selectedInputId,
selectedOutputDeviceId = selectedOutputId
selectedOutputDeviceId = selectedOutputId,
liveOutputEnabled = liveEnabled,
recordingEnabled = recordEnabled,
recordingFormat = format
)
)
onDismiss()
@@ -0,0 +1,948 @@
package de.waypointaudio.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AltRoute
import androidx.compose.material.icons.filled.Explore
import androidx.compose.material.icons.filled.DarkMode
import androidx.compose.material.icons.filled.GpsFixed
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Landscape
import androidx.compose.material.icons.filled.Layers
import androidx.compose.material.icons.filled.LightMode
import androidx.compose.material.icons.filled.LocationCity
import androidx.compose.material.icons.filled.Map
import androidx.compose.material.icons.filled.MusicNote
import androidx.compose.material.icons.filled.Nightlife
import androidx.compose.material.icons.filled.PinDrop
import androidx.compose.material.icons.filled.Route
import androidx.compose.material.icons.filled.RadioButtonChecked
import androidx.compose.material.icons.filled.Timeline
import androidx.compose.material.icons.filled.Label
import androidx.compose.material.icons.filled.LabelOff
import androidx.compose.material.icons.filled.Place
import androidx.compose.material.icons.filled.LocationOn
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Slider
import androidx.compose.material3.SmallFloatingActionButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import de.waypointaudio.R
import de.waypointaudio.data.MapBaseStyle
import de.waypointaudio.data.PoiLayerSettings
/**
* Daten-Container für die Layer-Schalter eines Kartenscreens.
*
* v2.4.1 — Erweitertes Basis-Stil-Menü:
* - Modern hell, Dunkel, Topografisch, Terrain/3D, Satellit
* v2.5.38 — POI-Layer-Kategorien ergänzt.
*/
data class MapLayerState(
val followLocation: Boolean = true,
val showMarkers: Boolean = true,
val showRadius: Boolean = true,
val showTrack: Boolean = true,
val showAudioLength: Boolean = true,
val baseStyle: MapBaseStyle = MapBaseStyle.MODERN_2D,
val showAudioLines: Boolean = true,
val poiLayers: PoiLayerSettings = PoiLayerSettings(),
)
/**
* Floating-Button + BottomSheet für Karten-Ebenen.
*
* v2.4.1 — Neue auswählbare Basis-Stile: Modern hell, Dunkel, Topo, Terrain/3D,
* Satellit. Auswahl wird über [MapStyleStore] persistiert.
* v2.5.38 — POI-Layer-Abschnitt (Sehenswürdigkeiten, Infrastruktur, Gastronomie,
* Mobilität) als Chips. Karteninfo-Dialog (Attribution). Kartentyp persistiert.
* v2.5.39 — Sheet-Inhalt scrollbar (verticalScroll), damit POI-Kategorien und
* Karteninfo auch auf kleinen Displays erreichbar sind.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MapLayerFab(
state: MapLayerState,
onStateChange: (MapLayerState) -> Unit,
customSatelliteUrl: String = "",
onCustomSatelliteUrlChange: (String) -> Unit = {},
customTopoUrl: String = "",
onCustomTopoUrlChange: (String) -> Unit = {},
customTerrainDemUrl: String = "",
onCustomTerrainDemUrlChange: (String) -> Unit = {},
perspectiveTilt: Int = 35,
onPerspectiveTiltChange: (Int) -> Unit = {},
onShowRoutingInfo: () -> Unit = {},
onShowRoutePlanner: () -> Unit = {},
// v2.5.45 — Auto-POI-Load-Toggle
poiAutoLoadEnabled: Boolean = true,
onPoiAutoLoadChange: (Boolean) -> Unit = {},
modifier: Modifier = Modifier
) {
var showSheet by remember { mutableStateOf(false) }
var showStyleHint by remember { mutableStateOf<MapBaseStyle?>(null) }
var showAttributionDialog by remember { mutableStateOf(false) }
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
Box(modifier = modifier) {
SmallFloatingActionButton(
onClick = { showSheet = true },
containerColor = MaterialTheme.colorScheme.surface,
contentColor = MaterialTheme.colorScheme.primary
) {
Icon(
Icons.Filled.Layers,
contentDescription = stringResource(R.string.map_layers_open)
)
}
}
if (showSheet) {
ModalBottomSheet(
onDismissRequest = { showSheet = false },
sheetState = sheetState
) {
Column(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 600.dp)
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp)
.padding(bottom = 16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Filled.Layers, contentDescription = null, modifier = Modifier.size(20.dp))
Spacer(Modifier.width(8.dp))
Text(
stringResource(R.string.map_layers_title),
style = MaterialTheme.typography.titleMedium
)
}
Text(
stringResource(R.string.map_layers_base_section),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
// v2.5.37 — Erste Zeile: Modern, Dunkel, Outdoor
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth()
) {
BaseStyleChip(
label = stringResource(R.string.map_layers_base_2d),
icon = Icons.Filled.LightMode,
selected = state.baseStyle == MapBaseStyle.MODERN_2D,
modifier = Modifier.weight(1f),
onClick = {
onStateChange(state.copy(baseStyle = MapBaseStyle.MODERN_2D))
}
)
BaseStyleChip(
label = stringResource(R.string.map_layers_base_dark),
icon = Icons.Filled.DarkMode,
selected = state.baseStyle == MapBaseStyle.DARK,
modifier = Modifier.weight(1f),
onClick = {
onStateChange(state.copy(baseStyle = MapBaseStyle.DARK))
}
)
BaseStyleChip(
label = stringResource(R.string.map_layers_base_outdoor),
icon = Icons.Filled.Landscape,
selected = state.baseStyle == MapBaseStyle.OUTDOOR,
modifier = Modifier.weight(1f),
onClick = {
onStateChange(state.copy(baseStyle = MapBaseStyle.OUTDOOR))
}
)
}
// v2.5.37 — Zweite Zeile: 3D Stadt, Klassisch
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth()
) {
BaseStyleChip(
label = stringResource(R.string.map_layers_base_city3d),
icon = Icons.Filled.LocationCity,
selected = state.baseStyle == MapBaseStyle.CITY_3D,
modifier = Modifier.weight(1f),
onClick = {
onStateChange(state.copy(baseStyle = MapBaseStyle.CITY_3D))
}
)
BaseStyleChip(
label = stringResource(R.string.map_layers_base_classic),
icon = Icons.Filled.Map,
selected = state.baseStyle == MapBaseStyle.CLASSIC,
modifier = Modifier.weight(1f),
onClick = {
onStateChange(state.copy(baseStyle = MapBaseStyle.CLASSIC))
}
)
// Platzhalter rechts für symmetrisches Layout
Spacer(Modifier.weight(1f))
}
// v2.5.37 — Info-Block für Dunkel-Stil
if (state.baseStyle == MapBaseStyle.DARK) {
StyleConfigSurface(
tone = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.55f)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Filled.DarkMode,
contentDescription = null,
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.onTertiaryContainer
)
Spacer(Modifier.width(8.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
stringResource(R.string.map_layers_dark_note),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onTertiaryContainer
)
}
TextButton(onClick = { showStyleHint = MapBaseStyle.DARK }) {
Text(stringResource(R.string.map_layers_satellite_info_button))
}
}
}
}
// v2.5.37 — Info-Block für 3D-Stadt-Stil
if (state.baseStyle == MapBaseStyle.CITY_3D) {
StyleConfigSurface(
tone = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.55f)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Filled.LocationCity,
contentDescription = null,
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.onTertiaryContainer
)
Spacer(Modifier.width(8.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
stringResource(R.string.map_layers_city3d_note),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onTertiaryContainer
)
}
TextButton(onClick = { showStyleHint = MapBaseStyle.CITY_3D }) {
Text(stringResource(R.string.map_layers_satellite_info_button))
}
}
Spacer(Modifier.height(8.dp))
Text(
stringResource(R.string.map_layers_terrain_pitch_label) +
": " + perspectiveTilt + "°",
style = MaterialTheme.typography.labelMedium
)
Slider(
value = perspectiveTilt.coerceIn(0, 60).toFloat(),
valueRange = 0f..60f,
steps = 11,
onValueChange = { onPerspectiveTiltChange(it.toInt()) }
)
}
}
// v2.5.37 — Info-Block für Outdoor-Stil
if (state.baseStyle == MapBaseStyle.OUTDOOR) {
StyleConfigSurface(
tone = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.4f)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Filled.Landscape,
contentDescription = null,
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.onTertiaryContainer
)
Spacer(Modifier.width(8.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
stringResource(R.string.map_layers_outdoor_note),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onTertiaryContainer
)
}
TextButton(onClick = { showStyleHint = MapBaseStyle.OUTDOOR }) {
Text(stringResource(R.string.map_layers_satellite_info_button))
}
}
}
}
Text(
stringResource(R.string.map_layers_overlays_section),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
LayerSwitchRow(
icon = Icons.Filled.GpsFixed,
label = stringResource(R.string.map_layers_follow),
checked = state.followLocation,
onCheckedChange = { onStateChange(state.copy(followLocation = it)) }
)
LayerSwitchRow(
icon = Icons.Filled.PinDrop,
label = stringResource(R.string.map_layers_markers),
checked = state.showMarkers,
onCheckedChange = { onStateChange(state.copy(showMarkers = it)) }
)
LayerSwitchRow(
icon = Icons.Filled.RadioButtonChecked,
label = stringResource(R.string.map_layers_radius),
checked = state.showRadius,
onCheckedChange = { onStateChange(state.copy(showRadius = it)) }
)
LayerSwitchRow(
icon = Icons.Filled.MusicNote,
label = stringResource(R.string.map_layers_audio_length),
checked = state.showAudioLength,
onCheckedChange = { onStateChange(state.copy(showAudioLength = it)) }
)
LayerSwitchRow(
icon = Icons.Filled.Timeline,
label = stringResource(R.string.map_layers_audio_lines),
checked = state.showAudioLines,
onCheckedChange = { onStateChange(state.copy(showAudioLines = it)) }
)
Text(
stringResource(R.string.map_layers_audio_lines_hint),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.75f),
modifier = Modifier.padding(start = 28.dp)
)
if (state.showAudioLines) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 28.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
AudioLineLegendChip(
color = 0xFF66BB6A.toInt(),
label = stringResource(R.string.map_layers_audio_lines_legend_short)
)
AudioLineLegendChip(
color = 0xFFFFA726.toInt(),
label = stringResource(R.string.map_layers_audio_lines_legend_medium)
)
AudioLineLegendChip(
color = 0xFFE53935.toInt(),
label = stringResource(R.string.map_layers_audio_lines_legend_long)
)
}
}
LayerSwitchRow(
icon = Icons.Filled.Timeline,
label = stringResource(R.string.map_layers_track),
checked = state.showTrack,
onCheckedChange = { onStateChange(state.copy(showTrack = it)) }
)
// ─── v2.5.38 — POI-Layer-Abschnitt ───────────────────────────
Spacer(Modifier.height(2.dp))
Text(
stringResource(R.string.poi_section_title),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
if (state.baseStyle == MapBaseStyle.CLASSIC) {
Text(
stringResource(R.string.poi_unavailable_classic),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
modifier = Modifier.padding(start = 4.dp)
)
} else {
Text(
stringResource(R.string.poi_section_subtitle),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.75f),
modifier = Modifier.padding(start = 4.dp)
)
// Zeile 1: Sehenswürdigkeiten + Infrastruktur
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth()
) {
PoiCategoryChip(
label = stringResource(R.string.poi_cat_sights),
icon = Icons.Filled.Place,
selected = state.poiLayers.showSights,
modifier = Modifier.weight(1f),
onClick = {
onStateChange(
state.copy(
poiLayers = state.poiLayers.copy(
showSights = !state.poiLayers.showSights
)
)
)
}
)
PoiCategoryChip(
label = stringResource(R.string.poi_cat_infrastructure),
icon = Icons.Filled.LocationOn,
selected = state.poiLayers.showInfrastructure,
modifier = Modifier.weight(1f),
onClick = {
onStateChange(
state.copy(
poiLayers = state.poiLayers.copy(
showInfrastructure = !state.poiLayers.showInfrastructure
)
)
)
}
)
}
// Zeile 2: Gastronomie + Mobilität
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth()
) {
PoiCategoryChip(
label = stringResource(R.string.poi_cat_gastronomy),
icon = Icons.Filled.Nightlife,
selected = state.poiLayers.showGastronomy,
modifier = Modifier.weight(1f),
onClick = {
onStateChange(
state.copy(
poiLayers = state.poiLayers.copy(
showGastronomy = !state.poiLayers.showGastronomy
)
)
)
}
)
PoiCategoryChip(
label = stringResource(R.string.poi_cat_mobility),
icon = Icons.Filled.Route,
selected = state.poiLayers.showMobility,
modifier = Modifier.weight(1f),
onClick = {
onStateChange(
state.copy(
poiLayers = state.poiLayers.copy(
showMobility = !state.poiLayers.showMobility
)
)
)
}
)
}
// v2.5.42 — POI-Namen-Toggle: standardmäßig deaktiviert
val anyPoiActive = state.poiLayers.showSights ||
state.poiLayers.showGastronomy ||
state.poiLayers.showMobility ||
state.poiLayers.showInfrastructure
if (anyPoiActive) {
LayerSwitchRow(
icon = if (state.poiLayers.showPoiNames)
Icons.Filled.Label else Icons.Filled.LabelOff,
label = "POI-Namen anzeigen",
checked = state.poiLayers.showPoiNames,
onCheckedChange = {
onStateChange(
state.copy(
poiLayers = state.poiLayers.copy(
showPoiNames = it
)
)
)
}
)
// v2.5.45 — Auto-POI-Laden Toggle
LayerSwitchRow(
icon = Icons.Filled.Explore,
label = "POIs automatisch nachladen",
checked = poiAutoLoadEnabled,
onCheckedChange = onPoiAutoLoadChange
)
Text(
"Auto-Laden: POIs werden beim Kartenverschieben aktualisiert (ab Zoom 12)",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.75f),
modifier = Modifier.padding(start = 28.dp)
)
}
}
// ─── v2.5.38 — Karteninfo / Attribution ──────────────────────
Spacer(Modifier.height(2.dp))
Surface(
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f),
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Filled.Info,
contentDescription = null,
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.width(8.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
stringResource(R.string.map_info_dialog_title),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
"OpenFreeMap · OpenMapTiles · © OpenStreetMap-Mitwirkende",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.78f)
)
}
TextButton(onClick = { showAttributionDialog = true }) {
Text(stringResource(R.string.map_info_open))
}
}
}
// v2.4.2 — Routenplaner-Eintrag im Layer-Panel.
Surface(
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.55f),
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Filled.AltRoute,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onPrimaryContainer
)
Spacer(Modifier.width(10.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
stringResource(R.string.route_planner_open_short),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
Text(
stringResource(R.string.route_planner_subtitle),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.78f)
)
}
TextButton(onClick = onShowRoutePlanner) {
Text(stringResource(R.string.route_planner_open))
}
}
}
Surface(
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.55f),
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Filled.AltRoute,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.width(10.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
stringResource(R.string.routing_poi_open),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
stringResource(R.string.routing_poi_search_attribution),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.75f)
)
}
TextButton(onClick = onShowRoutingInfo) {
Text(stringResource(R.string.routing_poi_open))
}
}
}
TextButton(
onClick = { showSheet = false },
modifier = Modifier.align(Alignment.End)
) {
Text(stringResource(R.string.map_layers_close))
}
}
}
}
// v2.5.38 — Stil-Info-Dialog (unverändert aus v2.5.37)
showStyleHint?.let { style ->
val title: String
val body: String
when (style) {
MapBaseStyle.DARK -> {
title = stringResource(R.string.map_layers_base_dark)
body = stringResource(R.string.map_layers_dark_info)
}
MapBaseStyle.OUTDOOR -> {
title = stringResource(R.string.map_layers_base_outdoor)
body = stringResource(R.string.map_layers_outdoor_info)
}
MapBaseStyle.CITY_3D -> {
title = stringResource(R.string.map_layers_base_city3d)
body = stringResource(R.string.map_layers_city3d_info)
}
MapBaseStyle.CLASSIC -> {
title = stringResource(R.string.map_layers_base_classic)
body = stringResource(R.string.map_layers_classic_info)
}
else -> {
title = stringResource(R.string.map_layers_base_2d)
body = stringResource(R.string.map_layers_modern_info)
}
}
AlertDialog(
onDismissRequest = { showStyleHint = null },
title = { Text(title) },
text = { Text(body) },
confirmButton = {
TextButton(onClick = { showStyleHint = null }) {
Text(stringResource(R.string.version_notice_ok))
}
}
)
}
// v2.5.38 — Karteninfo / Attribution-Dialog
if (showAttributionDialog) {
MapAttributionDialog(onDismiss = { showAttributionDialog = false })
}
}
/**
* v2.5.38 — Vollständiger Karteninfo-/Attribution-Dialog.
*
* Zeigt rechtlich korrekte Namensnennung für:
* - OpenFreeMap (openfreemap.org)
* - OpenMapTiles (openmaptiles.org)
* - OpenStreetMap-Mitwirkende (ODbL)
*
* Links werden als anklickbare Texte dargestellt.
*/
@Composable
fun MapAttributionDialog(onDismiss: () -> Unit) {
val uriHandler = LocalUriHandler.current
AlertDialog(
onDismissRequest = onDismiss,
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Filled.Info,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(Modifier.width(8.dp))
Text(stringResource(R.string.map_info_dialog_title))
}
},
text = {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
// Kartenstil
Text(
stringResource(R.string.map_info_style_label),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
stringResource(R.string.map_info_style_source),
style = MaterialTheme.typography.bodyMedium
)
// Kartendaten
Text(
stringResource(R.string.map_info_data_label),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
stringResource(R.string.map_info_data_text),
style = MaterialTheme.typography.bodyMedium
)
// Links
Text(
stringResource(R.string.map_info_links_label),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
// openfreemap.org
Surface(
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.35f),
modifier = Modifier
.fillMaxWidth()
.clickable {
runCatching {
uriHandler.openUri("https://openfreemap.org")
}
}
) {
Text(
"" + stringResource(R.string.map_info_link_ofm),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp)
)
}
// openmaptiles.org
Surface(
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.35f),
modifier = Modifier
.fillMaxWidth()
.clickable {
runCatching {
uriHandler.openUri("https://openmaptiles.org")
}
}
) {
Text(
"" + stringResource(R.string.map_info_link_omt),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp)
)
}
// openstreetmap.org/copyright
Surface(
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.35f),
modifier = Modifier
.fillMaxWidth()
.clickable {
runCatching {
uriHandler.openUri("https://www.openstreetmap.org/copyright")
}
}
) {
Text(
"" + stringResource(R.string.map_info_link_osm),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp)
)
}
Spacer(Modifier.height(2.dp))
Text(
"Lizenz: ODbL (Open Database License). Namensnennung erforderlich.",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.75f)
)
}
},
confirmButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.map_info_close))
}
}
)
}
@Composable
private fun StyleConfigSurface(
tone: androidx.compose.ui.graphics.Color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
content: @Composable androidx.compose.foundation.layout.ColumnScope.() -> Unit
) {
Surface(
shape = RoundedCornerShape(12.dp),
color = tone,
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
content = content
)
}
}
@Composable
private fun BaseStyleChip(
label: String,
icon: ImageVector,
selected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
val container = if (selected) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.55f)
}
val content = if (selected) {
MaterialTheme.colorScheme.onPrimaryContainer
} else {
MaterialTheme.colorScheme.onSurfaceVariant
}
Surface(
shape = RoundedCornerShape(14.dp),
color = container,
modifier = modifier.clickable(onClick = onClick)
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 10.dp, horizontal = 6.dp)
) {
Icon(icon, contentDescription = null, modifier = Modifier.size(20.dp), tint = content)
Spacer(Modifier.height(4.dp))
Text(
label,
style = MaterialTheme.typography.labelMedium,
color = content
)
}
}
}
/**
* v2.5.38 — POI-Kategorie-Chip (kompaktes Toggle-Chip-Design).
*/
@Composable
private fun PoiCategoryChip(
label: String,
icon: ImageVector,
selected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
val container = if (selected) {
MaterialTheme.colorScheme.secondaryContainer
} else {
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f)
}
val content = if (selected) {
MaterialTheme.colorScheme.onSecondaryContainer
} else {
MaterialTheme.colorScheme.onSurfaceVariant
}
Surface(
shape = RoundedCornerShape(12.dp),
color = container,
modifier = modifier.clickable(onClick = onClick)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 8.dp)
) {
Icon(icon, contentDescription = null, modifier = Modifier.size(16.dp), tint = content)
Spacer(Modifier.width(4.dp))
Text(
label,
style = MaterialTheme.typography.labelSmall,
color = content
)
}
}
}
@Composable
private fun AudioLineLegendChip(color: Int, label: String) {
Row(verticalAlignment = Alignment.CenterVertically) {
Surface(
shape = RoundedCornerShape(2.dp),
color = androidx.compose.ui.graphics.Color(color),
modifier = Modifier
.width(14.dp)
.height(4.dp)
) {}
Spacer(Modifier.width(4.dp))
Text(label, style = MaterialTheme.typography.labelSmall)
}
}
@Composable
private fun LayerSwitchRow(
icon: ImageVector,
label: String,
checked: Boolean,
onCheckedChange: (Boolean) -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
icon,
contentDescription = null,
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.75f)
)
Spacer(Modifier.width(10.dp))
Text(
label,
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.bodyMedium
)
Switch(checked = checked, onCheckedChange = onCheckedChange)
}
}
@@ -0,0 +1,128 @@
package de.waypointaudio.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Explore
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import de.waypointaudio.R
import androidx.compose.foundation.clickable
/**
* Modi des vereinten Kartenmoduls (v2.2.1).
*
* EXPLORER — Live-Standort + read-only Wegpunkte (kein PTT, kein Atmo).
* EDITOR — Bestehender Karteneditor (Long-Press neue Wegpunkte, Track-Aufzeichnung).
*
* ROUTE/IMPORT sind v2.2.1 noch nicht eigenständige Modi — Import/Drafts laufen
* weiterhin über das Overflow-Menü bzw. den Karteneditor.
*/
enum class MapMode { EXPLORER, EDITOR }
/**
* Kompakte Segmented-Control für die TopAppBar.
* Wird im Title-Slot der jeweiligen Map/Explorer-TopAppBar gerendert, damit die
* gemeinsame Visualisierung „eine Karte mit Modusumschalter" entsteht.
*/
@Composable
fun MapModeSegmentedHeader(
current: MapMode,
onChange: (MapMode) -> Unit,
subtitle: String? = null,
modifier: Modifier = Modifier,
contentColor: androidx.compose.ui.graphics.Color? = null
) {
val on = contentColor ?: MaterialTheme.colorScheme.onPrimary
val container = on.copy(alpha = 0.14f)
val selectedContainer = on.copy(alpha = 0.28f)
androidx.compose.foundation.layout.Column(modifier = modifier) {
Surface(
shape = RoundedCornerShape(20.dp),
color = container
) {
Row(
modifier = Modifier.padding(horizontal = 4.dp, vertical = 3.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(2.dp)
) {
Segment(
label = stringResource(R.string.map_mode_explorer),
icon = Icons.Filled.Explore,
selected = current == MapMode.EXPLORER,
selectedColor = selectedContainer,
contentColor = on,
onClick = { if (current != MapMode.EXPLORER) onChange(MapMode.EXPLORER) }
)
Segment(
label = stringResource(R.string.map_mode_editor),
icon = Icons.Filled.Edit,
selected = current == MapMode.EDITOR,
selectedColor = selectedContainer,
contentColor = on,
onClick = { if (current != MapMode.EDITOR) onChange(MapMode.EDITOR) }
)
}
}
if (subtitle != null) {
Spacer(Modifier.height(1.dp))
Text(
text = subtitle,
style = MaterialTheme.typography.labelSmall,
color = on.copy(alpha = 0.8f)
)
}
}
}
@Composable
private fun Segment(
label: String,
icon: androidx.compose.ui.graphics.vector.ImageVector,
selected: Boolean,
selectedColor: androidx.compose.ui.graphics.Color,
contentColor: androidx.compose.ui.graphics.Color,
onClick: () -> Unit
) {
Surface(
shape = RoundedCornerShape(16.dp),
color = if (selected) selectedColor else androidx.compose.ui.graphics.Color.Transparent,
modifier = Modifier
.clip(RoundedCornerShape(16.dp))
.clickable(onClick = onClick)
) {
Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
icon,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = contentColor
)
Spacer(Modifier.width(4.dp))
Text(
text = label,
style = MaterialTheme.typography.labelLarge,
color = contentColor
)
}
}
}
@@ -0,0 +1,66 @@
package de.waypointaudio.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.selection.selectable
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import de.waypointaudio.R
import de.waypointaudio.data.MapProvider
/**
* Auswahl-Dialog für den Karten-Tile-Provider.
*
* Listet alle Provider aus [MapProvider]. Die OSM-Attribution bleibt für alle
* Provider sichtbar (eigenes Overlay in MapScreen).
*/
@Composable
fun MapProviderDialog(
selected: MapProvider,
onSelect: (MapProvider) -> Unit,
onDismiss: () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.map_provider_title)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
MapProvider.values().forEach { provider ->
Row(
modifier = Modifier
.fillMaxWidth()
.selectable(
selected = (provider == selected),
onClick = { onSelect(provider) }
)
.padding(vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(
selected = (provider == selected),
onClick = { onSelect(provider) }
)
Text(
stringResource(provider.displayNameRes),
style = MaterialTheme.typography.bodyMedium
)
}
}
}
},
confirmButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.about_close)) }
}
)
}
@@ -29,8 +29,12 @@ import androidx.compose.material.icons.filled.AddLocation
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.FiberManualRecord
import androidx.compose.material.icons.filled.Layers
import androidx.compose.material.icons.filled.MyLocation
import androidx.compose.material.icons.filled.PlayCircle
import androidx.compose.material.icons.filled.RadioButtonChecked
import androidx.compose.material.icons.filled.RadioButtonUnchecked
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material.icons.filled.Timeline
import androidx.compose.material3.AlertDialog
@@ -72,13 +76,14 @@ import com.google.android.gms.location.Priority
import com.google.android.gms.tasks.CancellationTokenSource
import de.waypointaudio.R
import de.waypointaudio.data.GpsTrackPoint
import de.waypointaudio.data.MapProvider
import de.waypointaudio.data.MapProviderStore
import de.waypointaudio.data.Waypoint
import de.waypointaudio.viewmodel.WaypointViewModel
import kotlinx.coroutines.launch
import kotlinx.coroutines.tasks.await
import org.osmdroid.config.Configuration
import org.osmdroid.events.MapEventsReceiver
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
import org.osmdroid.util.GeoPoint
import org.osmdroid.views.MapView
import org.osmdroid.views.overlay.MapEventsOverlay
@@ -116,7 +121,12 @@ import kotlin.math.sqrt
@Composable
fun MapScreen(
viewModel: WaypointViewModel,
onNavigateBack: () -> Unit
onNavigateBack: () -> Unit,
// v2.2.1: Wenn gesetzt, zeigt die TopAppBar einen Explorer/Editor-Modus-
// Umschalter im Titel-Slot. Dadurch wirken Explorer und Editor als ein
// gemeinsames Kartenmodul.
currentMode: MapMode? = null,
onModeChange: ((MapMode) -> Unit)? = null
) {
val allWaypoints by viewModel.waypoints.collectAsState()
val selectedTour by viewModel.selectedTour.collectAsState()
@@ -149,6 +159,14 @@ fun MapScreen(
var currentLocation by remember { mutableStateOf<Pair<Double, Double>?>(null) }
var showAddWaypointAction by remember { mutableStateOf(false) }
// Karten-Anbieter (Phase 1): persistent via DataStore, app-weit gespeichert.
// Hinweis: Eine Pro-Tour-Auswahl ist mit der bisherigen Tour-Settings-Architektur
// nicht direkt vereinbar (Touren halten Audio-Einstellungen, keine Karten-Settings),
// daher bewusst app-weit. Die Auswahl ist über das Karten-Menü jederzeit umstellbar.
val providerStore = remember { MapProviderStore(context.applicationContext) }
val selectedProvider by providerStore.provider.collectAsState(initial = MapProvider.DEFAULT)
var showProviderDialog by remember { mutableStateOf(false) }
// Editor-Dialog-State
var editDialogWaypoint by remember { mutableStateOf<Waypoint?>(null) }
var editDialogPrefill by remember { mutableStateOf<Pair<Double, Double>?>(null) }
@@ -167,6 +185,15 @@ fun MapScreen(
// Bestätigungsdialog für das Löschen eines Wegpunkts
var deleteConfirmWaypoint by remember { mutableStateOf<Waypoint?>(null) }
// Ortssuche (Nominatim/OpenStreetMap) — temporärer roter Pin auf der Karte.
// Verändert weder Wegpunkte, Tracks, Drafts noch den Aufnahmezustand.
var showSearchDialog by remember { mutableStateOf(false) }
var searchPin by remember { mutableStateOf<GeoPoint?>(null) }
// v2.2.1: Layer-Panel-Status. Die Switches steuern aktuell vorwiegend
// Sichtbarkeit; markers/radius/track werden in drawMapOverlays gefiltert.
var layerState by remember { mutableStateOf(MapLayerState()) }
// Bitmap für Standort-Puck (einmalig erstellt)
val puckBitmap = remember { createLocationPuckBitmap(context) }
@@ -174,17 +201,27 @@ fun MapScreen(
LocationServices.getFusedLocationProviderClient(context)
}
// ─── Karte neu zeichnen wenn Wegpunkte / Track / Farben sich ändern ────────
LaunchedEffect(waypoints, primaryColor, currentTrack) {
// ─── Karten-Anbieter Wechsel: Tile-Source aktualisieren und Cache invalidieren ─
LaunchedEffect(selectedProvider) {
val mv = mapView ?: return@LaunchedEffect
mv.setTileSource(selectedProvider.toTileSource())
mv.invalidate()
}
// ─── Karte neu zeichnen wenn Wegpunkte / Track / Farben / Auswahl / Such-Pin / Layer sich ändern ─
LaunchedEffect(waypoints, primaryColor, currentTrack, actionMenuWaypoint, searchPin, layerState) {
val mv = mapView ?: return@LaunchedEffect
drawMapOverlays(
mv = mv,
waypoints = waypoints,
trackPoints = currentTrack,
waypoints = if (layerState.showMarkers) waypoints else emptyList(),
trackPoints = if (layerState.showTrack) currentTrack else emptyList(),
primaryArgb = primaryColor.toArgb(),
errorArgb = errorColor.toArgb(),
trackArgb = trackColor.toArgb(),
onWaypointTapped = { wp -> actionMenuWaypoint = wp }
selectedWaypointId = actionMenuWaypoint?.id,
onWaypointTapped = { wp -> actionMenuWaypoint = wp },
searchPin = searchPin,
showRadius = layerState.showRadius
)
}
@@ -223,6 +260,10 @@ fun MapScreen(
showEditDialog = false
editDialogWaypoint = null
editDialogPrefill = null
},
// v2.5.16 — Kopieren auch aus dem Karten-Bearbeiten-Flow möglich.
onCopyToOtherTour = editDialogWaypoint?.let { wp ->
{ targetTour -> viewModel.copyWaypointToTour(wp.id, targetTour) }
}
)
}
@@ -350,18 +391,54 @@ fun MapScreen(
)
}
// ─── Karten-Anbieter Auswahl-Dialog ─────────────────────────────────────
if (showProviderDialog) {
MapProviderDialog(
selected = selectedProvider,
onSelect = { provider ->
scope.launch {
providerStore.setProvider(provider)
}
showProviderDialog = false
},
onDismiss = { showProviderDialog = false }
)
}
// ─── Ortssuche Dialog (Nominatim/OSM) ────────────────────────────────────
if (showSearchDialog) {
PlaceSearchDialog(
onDismiss = { showSearchDialog = false },
onResultSelected = { result ->
val gp = GeoPoint(result.latitude, result.longitude)
searchPin = gp
mapView?.controller?.animateTo(gp)
mapView?.controller?.setZoom(15.0)
showSearchDialog = false
}
)
}
Scaffold(
topBar = {
TopAppBar(
title = {
Column {
Text(stringResource(R.string.map_view))
if (tourList.size > 1) {
Text(
text = selectedTour,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.8f)
)
if (currentMode != null && onModeChange != null) {
MapModeSegmentedHeader(
current = currentMode,
onChange = onModeChange,
subtitle = if (tourList.size > 1) selectedTour else null
)
} else {
Column {
Text(stringResource(R.string.map_view))
if (tourList.size > 1) {
Text(
text = selectedTour,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.8f)
)
}
}
}
},
@@ -373,10 +450,33 @@ fun MapScreen(
)
}
},
actions = {
IconButton(onClick = { showProviderDialog = true }) {
Icon(
Icons.Filled.Layers,
contentDescription = stringResource(R.string.map_provider_menu)
)
}
IconButton(onClick = { showSearchDialog = true }) {
Icon(
Icons.Filled.Search,
contentDescription = stringResource(R.string.search_place_action)
)
}
if (searchPin != null) {
IconButton(onClick = { searchPin = null }) {
Icon(
Icons.Filled.Delete,
contentDescription = stringResource(R.string.search_place_close)
)
}
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primary,
titleContentColor = MaterialTheme.colorScheme.onPrimary,
navigationIconContentColor = MaterialTheme.colorScheme.onPrimary
navigationIconContentColor = MaterialTheme.colorScheme.onPrimary,
actionIconContentColor = MaterialTheme.colorScheme.onPrimary
)
)
},
@@ -518,7 +618,7 @@ fun MapScreen(
modifier = Modifier.fillMaxSize(),
factory = { ctx ->
MapView(ctx).apply {
setTileSource(TileSourceFactory.MAPNIK)
setTileSource(selectedProvider.toTileSource())
setMultiTouchControls(true)
controller.setZoom(12.0)
@@ -557,12 +657,15 @@ fun MapScreen(
mapView = this
drawMapOverlays(
mv = this,
waypoints = waypoints,
trackPoints = currentTrack,
waypoints = if (layerState.showMarkers) waypoints else emptyList(),
trackPoints = if (layerState.showTrack) currentTrack else emptyList(),
primaryArgb = primaryColor.toArgb(),
errorArgb = errorColor.toArgb(),
trackArgb = trackColor.toArgb(),
onWaypointTapped = { wp -> actionMenuWaypoint = wp }
selectedWaypointId = actionMenuWaypoint?.id,
onWaypointTapped = { wp -> actionMenuWaypoint = wp },
searchPin = searchPin,
showRadius = layerState.showRadius
)
}
},
@@ -570,12 +673,15 @@ fun MapScreen(
mapView = mv
drawMapOverlays(
mv = mv,
waypoints = waypoints,
trackPoints = currentTrack,
waypoints = if (layerState.showMarkers) waypoints else emptyList(),
trackPoints = if (layerState.showTrack) currentTrack else emptyList(),
primaryArgb = primaryColor.toArgb(),
errorArgb = errorColor.toArgb(),
trackArgb = trackColor.toArgb(),
onWaypointTapped = { wp -> actionMenuWaypoint = wp }
selectedWaypointId = actionMenuWaypoint?.id,
onWaypointTapped = { wp -> actionMenuWaypoint = wp },
searchPin = searchPin,
showRadius = layerState.showRadius
)
}
)
@@ -618,11 +724,12 @@ fun MapScreen(
}
// ─── Track-Statistik-Panel ───────────────────────────────────────
// Bottom padding leaves a clear strip for the OSM attribution badge.
AnimatedVisibility(
visible = isRecording || currentTrack.isNotEmpty(),
modifier = Modifier
.align(Alignment.BottomStart)
.padding(start = 12.dp, bottom = 12.dp),
.padding(start = 12.dp, bottom = 40.dp),
enter = fadeIn() + slideInVertically(initialOffsetY = { it }),
exit = fadeOut() + slideOutVertically(targetOffsetY = { it })
) {
@@ -710,11 +817,12 @@ fun MapScreen(
}
// ─── Editor-Hinweis (unten mittig) ──────────────────────────────
// Raised above the OSM attribution strip so the mandatory badge is never covered.
AnimatedVisibility(
visible = !isRecording && currentTrack.isEmpty(),
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 16.dp),
.padding(bottom = 40.dp),
enter = fadeIn(),
exit = fadeOut()
) {
@@ -731,6 +839,26 @@ fun MapScreen(
)
}
}
// ─── Layer-/3D-Overlay-FAB (v2.2.1) ──────────────────────────────
// Schwebt rechts oben über der Karte, öffnet das Material-3-Panel
// mit Layer-Switches und 3D-Vorbereitungs-Hinweis.
MapLayerFab(
state = layerState,
onStateChange = { layerState = it },
modifier = Modifier
.align(Alignment.TopEnd)
.padding(top = 8.dp, end = 8.dp)
)
// ─── OSM-Attribution (Pflicht) ───────────────────────────────────
// Moved to bottom-left so the FAB stack at bottom-right cannot overlap it.
// The track-stats card and editor hint above leave a clear strip for this badge.
OsmAttributionBadge(
modifier = Modifier
.align(Alignment.BottomStart)
.padding(start = 6.dp, bottom = 6.dp)
)
}
}
@@ -752,42 +880,73 @@ private fun drawMapOverlays(
primaryArgb: Int,
errorArgb: Int,
trackArgb: Int,
onWaypointTapped: (Waypoint) -> Unit
selectedWaypointId: String?,
onWaypointTapped: (Waypoint) -> Unit,
searchPin: GeoPoint? = null,
showRadius: Boolean = true
) {
val overlays = mv.overlays
// Radius-Polygone, Wegpunkt-Marker und Track-Polylines entfernen
// Radius-Polygone, Wegpunkt-Marker und Track-Polylines entfernen.
// Mein-Standort-Puck überlebt (eigener Marker mit title="Mein Standort").
overlays.removeAll(overlays.filterIsInstance<Polygon>().toSet())
val waypointMarkers = overlays.filterIsInstance<Marker>()
.filter { it.title != "Mein Standort" }
overlays.removeAll(waypointMarkers.toSet())
overlays.removeAll(overlays.filterIsInstance<Polyline>().toSet())
// GPS-Track zeichnen
val density = mv.resources.displayMetrics.density
// GPS-Track zeichnen — kräftiger, mit weißem Halo für Kontrast auf jedem Untergrund.
if (trackPoints.size >= 2) {
val pts = trackPoints.map { GeoPoint(it.latitude, it.longitude) }
// Halo (heller Rand)
val halo = Polyline(mv).apply {
setPoints(pts)
outlinePaint.color = 0xFFFFFFFF.toInt()
outlinePaint.strokeWidth = 18f * density / 2f
outlinePaint.strokeCap = Paint.Cap.ROUND
outlinePaint.strokeJoin = Paint.Join.ROUND
outlinePaint.alpha = 230
}
overlays.add(halo)
// Hauptlinie
val polyline = Polyline(mv).apply {
setPoints(trackPoints.map { GeoPoint(it.latitude, it.longitude) })
setPoints(pts)
outlinePaint.color = trackArgb
outlinePaint.strokeWidth = 6f
outlinePaint.alpha = 200
outlinePaint.strokeWidth = 12f * density / 2f
outlinePaint.strokeCap = Paint.Cap.ROUND
outlinePaint.strokeJoin = Paint.Join.ROUND
outlinePaint.alpha = 235
}
overlays.add(polyline)
}
// Wegpunkte zeichnen
// Wegpunkte zeichnen — größere, gut sichtbare Streckenpunkt-Handles
for (wp in waypoints) {
val geoPoint = GeoPoint(wp.latitude, wp.longitude)
// Radius-Kreis
val circle = buildRadiusCircle(geoPoint, wp.radiusMeters.toDouble(), primaryArgb)
overlays.add(circle)
// Radius-Kreis (nur wenn Layer aktiv)
if (showRadius) {
val circle = buildRadiusCircle(geoPoint, wp.radiusMeters.toDouble(), primaryArgb)
overlays.add(circle)
}
// Marker mit Tap-Handler
val isSelected = selectedWaypointId != null && wp.id == selectedWaypointId
// Größerer Marker mit Kontrast-Halo. Ausgewählter Punkt: andere Farbe + größer.
val handleBitmap = createWaypointHandleBitmap(
context = mv.context,
fillArgb = if (isSelected) errorArgb else primaryArgb,
isSelected = isSelected
)
val marker = Marker(mv).apply {
position = geoPoint
title = wp.name
snippet = "%.6f, %.6f · %dm".format(Locale.US, wp.latitude, wp.longitude, wp.radiusMeters.toInt())
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
alpha = if (wp.isActive) 1.0f else 0.4f
// ANCHOR_CENTER, ANCHOR_CENTER → Touch-Trefferfläche entspricht der Bitmap-Größe und
// sitzt zentriert über dem Punkt (deutlich größer als der frühere Default-Pin).
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
alpha = if (wp.isActive) 1.0f else 0.55f
icon = android.graphics.drawable.BitmapDrawable(mv.context.resources, handleBitmap)
// infoWindow = null → Tap löst onMarkerClick aus
setOnMarkerClickListener { _, _ ->
onWaypointTapped(wp)
@@ -797,9 +956,79 @@ private fun drawMapOverlays(
overlays.add(marker)
}
// Temporärer roter Such-Treffer-Pin (Nominatim/OSM). Verändert keine Daten.
if (searchPin != null) {
val pinBm = makeSearchPinBitmapShared(96)
val pinMarker = Marker(mv).apply {
position = searchPin
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
icon = android.graphics.drawable.BitmapDrawable(mv.context.resources, pinBm)
title = null
snippet = null
subDescription = null
infoWindow = null
setOnMarkerClickListener { _, _ -> true }
}
overlays.add(pinMarker)
}
mv.invalidate()
}
/**
* Zeichnet einen großen, kontraststarken Kreis-Handle für einen Streckenpunkt.
* Die Bitmap-Größe bestimmt zugleich die Touch-Trefferfläche des osmdroid-Markers.
* - Normaler Punkt: ca. 44dp Trefferfläche, sichtbarer Kreis ~26dp mit weißem Halo
* - Ausgewählter Punkt: ca. 56dp Trefferfläche, sichtbarer Kreis ~36dp mit dunklem Außenrand
*/
private fun createWaypointHandleBitmap(
context: android.content.Context,
fillArgb: Int,
isSelected: Boolean
): Bitmap {
val density = context.resources.displayMetrics.density
// Touch-Trefferfläche (Bitmap-Kantenlänge) mind. 44dp/56dp lt. Material Touch-Targets
val touchDp = if (isSelected) 56f else 44f
val sizePx = (touchDp * density).toInt()
val bmp = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bmp)
val cx = sizePx / 2f
val cy = sizePx / 2f
// Sichtbarer Kreis-Radius
val visibleRadiusDp = if (isSelected) 18f else 13f
val visibleRadius = visibleRadiusDp * density
val haloThicknessDp = if (isSelected) 4.5f else 3.5f
val haloThickness = haloThicknessDp * density
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
// Weicher Schatten-/Halo-Ring (weiß), damit der Punkt auf jedem Kartenuntergrund sichtbar bleibt
paint.style = Paint.Style.FILL
paint.color = 0xFFFFFFFF.toInt()
canvas.drawCircle(cx, cy, visibleRadius + haloThickness, paint)
// Füllung
paint.color = fillArgb
canvas.drawCircle(cx, cy, visibleRadius, paint)
// Kontrast-Außenrand (dunkel) beim ausgewählten Punkt deutlich kräftiger
paint.style = Paint.Style.STROKE
paint.strokeWidth = (if (isSelected) 3f else 2f) * density
paint.color = 0xFF202020.toInt()
canvas.drawCircle(cx, cy, visibleRadius, paint)
if (isSelected) {
// Innerer heller Ring als zusätzliche „Selected"-Markierung
paint.style = Paint.Style.STROKE
paint.strokeWidth = 2f * density
paint.color = 0xFFFFFFFF.toInt()
canvas.drawCircle(cx, cy, visibleRadius - 4f * density, paint)
}
return bmp
}
// ---------------------------------------------------------------------------
// Hilfsfunktionen: Standort-Puck, Marker, Radius-Kreis, Distanz
// ---------------------------------------------------------------------------
@@ -0,0 +1,229 @@
package de.waypointaudio.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import de.waypointaudio.R
import de.waypointaudio.util.NominatimClient
import de.waypointaudio.util.NominatimResult
import kotlinx.coroutines.launch
/**
* Modaler Dialog: Ortssuche via Nominatim/OpenStreetMap.
* Wird sowohl im normalen Karten-Screen ([MapScreen]) als auch im
* [TrackDraftEditorScreen] verwendet.
*
* Hinweise:
* - Reuse über alle Map-Screens hinweg, damit Verhalten/Texte konsistent bleiben.
* - HTTP-Fehler werden als generische Fehlermeldung angezeigt.
* - Das Auswählen eines Ergebnisses ruft [onResultSelected] auf — der Aufrufer
* entscheidet, was mit dem Treffer passiert (z.B. Kartenzentrum + roter Pin).
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PlaceSearchDialog(
onDismiss: () -> Unit,
onResultSelected: (NominatimResult) -> Unit
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
var query by remember { mutableStateOf("") }
var loading by remember { mutableStateOf(false) }
var results by remember { mutableStateOf<List<NominatimResult>>(emptyList()) }
var hasSearched by remember { mutableStateOf(false) }
var errorMessage by remember { mutableStateOf<String?>(null) }
fun runSearch() {
if (query.isBlank() || loading) return
loading = true
errorMessage = null
hasSearched = true
scope.launch {
val outcome = runCatching { NominatimClient.search(context, query) }
loading = false
outcome.onSuccess { list ->
results = list
errorMessage = null
}.onFailure { _ ->
results = emptyList()
errorMessage = context.getString(R.string.search_place_error)
}
}
}
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.search_place_title)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedTextField(
value = query,
onValueChange = { query = it },
label = { Text(stringResource(R.string.search_place_query_label)) },
singleLine = true,
modifier = Modifier.weight(1f)
)
Button(
onClick = { runSearch() },
enabled = query.isNotBlank() && !loading
) {
Text(stringResource(R.string.search_place_button))
}
}
Text(
stringResource(R.string.search_place_attribution),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
)
HorizontalDivider()
when {
loading -> {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(vertical = 8.dp)
) {
CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp)
Text(
stringResource(R.string.search_place_loading),
style = MaterialTheme.typography.bodySmall
)
}
}
errorMessage != null -> {
Text(
errorMessage!!,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
hasSearched && results.isEmpty() -> {
Text(
stringResource(R.string.search_place_no_results),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
)
}
results.isNotEmpty() -> {
LazyColumn(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 280.dp)
) {
items(results) { r ->
Surface(
color = MaterialTheme.colorScheme.surfaceVariant,
shape = RoundedCornerShape(8.dp),
modifier = Modifier
.fillMaxWidth()
.clickable { onResultSelected(r) }
) {
Text(
r.displayName,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp)
)
}
}
}
}
}
}
},
confirmButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.search_place_close))
}
}
)
}
/**
* Erzeugt eine rote Pin-Bitmap für temporäre Such-Treffer-Marker auf der Karte.
* Wird sowohl in [MapScreen] als auch in [TrackDraftEditorScreen] verwendet.
*/
internal fun makeSearchPinBitmapShared(sizePx: Int): android.graphics.Bitmap {
val size = sizePx.coerceAtLeast(24)
val bm = android.graphics.Bitmap.createBitmap(size, size, android.graphics.Bitmap.Config.ARGB_8888)
val canvas = android.graphics.Canvas(bm)
val cx = size / 2f
val cyTop = size * 0.36f
val r = size * 0.30f
val shadow = android.graphics.Paint().apply {
isAntiAlias = true
style = android.graphics.Paint.Style.FILL
color = 0x66000000.toInt()
}
canvas.drawCircle(cx, size - size * 0.08f, size * 0.10f, shadow)
val tail = android.graphics.Path().apply {
moveTo(cx - r * 0.55f, cyTop + r * 0.55f)
lineTo(cx + r * 0.55f, cyTop + r * 0.55f)
lineTo(cx, size - size * 0.05f)
close()
}
val body = android.graphics.Paint().apply {
isAntiAlias = true
style = android.graphics.Paint.Style.FILL
color = 0xFFD32F2F.toInt()
}
canvas.drawPath(tail, body)
canvas.drawCircle(cx, cyTop, r, body)
val ring = android.graphics.Paint().apply {
isAntiAlias = true
style = android.graphics.Paint.Style.STROKE
color = 0xFFFFFFFF.toInt()
strokeWidth = (size * 0.06f).coerceAtLeast(2f)
}
canvas.drawCircle(cx, cyTop, r, ring)
val dot = android.graphics.Paint().apply {
isAntiAlias = true
style = android.graphics.Paint.Style.FILL
color = 0xFFFFFFFF.toInt()
}
canvas.drawCircle(cx, cyTop, r * 0.32f, dot)
return bm
}
@@ -0,0 +1,683 @@
package de.waypointaudio.ui
import android.media.MediaPlayer
import android.net.Uri
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AddLocationAlt
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.Assignment
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import de.waypointaudio.R
import de.waypointaudio.data.PttRecording
import de.waypointaudio.data.Waypoint
import de.waypointaudio.service.LivePttManager
import de.waypointaudio.viewmodel.WaypointViewModel
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
private fun formatDuration(ms: Long): String {
if (ms <= 0L) return ""
val totalSec = ms / 1000
val mm = totalSec / 60
val ss = totalSec % 60
return "%d:%02d".format(mm, ss)
}
private fun formatTimestamp(ms: Long): String {
return SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.GERMANY).format(Date(ms))
}
/**
* Lokaler Preview-Player für die PTT-Bibliothek (UI-isoliert).
*
* Bewusst NICHT beim WaypointAudioInterruptionCoordinator registriert: die
* Wiedergabe ist eine reine Voransicht innerhalb des Dialogs und endet
* spätestens beim Schließen des Dialogs/Wechsel der Aufnahme. So bleibt
* der bestehende Atmo-/PTT-/Waypoint-Audio-Pfad unverändert.
*/
private class RecordingPreviewPlayer {
private var mediaPlayer: MediaPlayer? = null
var playingId: String? = null
private set
fun isPlaying(id: String): Boolean =
playingId == id && mediaPlayer?.isPlaying == true
fun toggle(
context: android.content.Context,
recording: PttRecording,
onCompletion: () -> Unit
) {
val mp = mediaPlayer
if (playingId == recording.id && mp != null) {
runCatching {
if (mp.isPlaying) mp.pause() else mp.start()
}.onFailure {
release()
load(context, recording, onCompletion)
}
return
}
release()
load(context, recording, onCompletion)
}
private fun load(
context: android.content.Context,
recording: PttRecording,
onCompletion: () -> Unit
) {
runCatching {
mediaPlayer = MediaPlayer().apply {
setDataSource(context, Uri.parse(recording.uri))
prepare()
start()
setOnCompletionListener {
release()
onCompletion()
}
setOnErrorListener { mp, _, _ ->
runCatching { mp.release() }
release()
onCompletion()
true
}
}
playingId = recording.id
}.onFailure {
release()
}
}
fun release() {
runCatching {
mediaPlayer?.let {
if (it.isPlaying) it.stop()
it.release()
}
}
mediaPlayer = null
playingId = null
}
}
/**
* Post-Aufnahme-Dialog. Erscheint automatisch nach jeder gespeicherten PTT-Aufnahme.
* Buttons: „Als Waypoint-Audio zuweisen", „OK", „Löschen".
*/
@Composable
fun PostRecordingDialog(
recording: PttRecording,
viewModel: WaypointViewModel,
onAssign: (PttRecording) -> Unit,
onDelete: (PttRecording) -> Unit,
onDismiss: () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
icon = {
Icon(
Icons.Filled.Mic,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
},
title = { Text(stringResource(R.string.ptt_recording_saved_title)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(stringResource(R.string.ptt_recording_saved_name, recording.displayName))
Text(stringResource(R.string.ptt_recording_saved_duration, formatDuration(recording.durationMs)))
if (recording.latitude != null && recording.longitude != null) {
Text(stringResource(
R.string.ptt_recording_saved_gps,
recording.latitude,
recording.longitude
))
}
}
},
confirmButton = {
Button(onClick = { onAssign(recording) }) {
Icon(Icons.Filled.Assignment, contentDescription = null,
modifier = Modifier.size(18.dp))
Spacer(Modifier.width(6.dp))
Text(stringResource(R.string.ptt_recording_assign_waypoint))
}
},
dismissButton = {
Row {
TextButton(onClick = { onDelete(recording) }) {
Icon(Icons.Filled.Delete, contentDescription = null,
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.error)
Spacer(Modifier.width(4.dp))
Text(stringResource(R.string.ptt_recording_delete),
color = MaterialTheme.colorScheme.error)
}
Spacer(Modifier.width(4.dp))
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.ptt_recording_ok))
}
}
}
)
}
/**
* Aufnahme-Bibliothek (Liste aller PTT-Aufnahmen). Aktionen pro Eintrag:
* Anhören (Play/Pause), Zuweisen, Löschen.
*
* Nur eine Aufnahme kann gleichzeitig vorgehört werden. Beim Schließen des
* Dialogs sowie beim Wechsel der Aufnahme wird der lokale Preview-Player
* sicher gestoppt und freigegeben.
*/
@Composable
fun PttRecordingsLibraryDialog(
pttManager: LivePttManager,
viewModel: WaypointViewModel,
onAssign: (PttRecording) -> Unit,
onDismiss: () -> Unit
) {
val context = LocalContext.current
val recordings by pttManager.recordings.collectAsState(initial = emptyList())
var pendingDelete by remember { mutableStateOf<PttRecording?>(null) }
// Lokaler Preview-Player. Wird beim Schließen des Dialogs zwingend freigegeben.
val previewPlayer = remember { RecordingPreviewPlayer() }
var playingId by remember { mutableStateOf<String?>(null) }
DisposableEffect(Unit) {
onDispose { previewPlayer.release() }
}
Dialog(onDismissRequest = {
previewPlayer.release()
playingId = null
onDismiss()
}) {
Surface(
shape = RoundedCornerShape(20.dp),
color = MaterialTheme.colorScheme.surface,
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Filled.Mic,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
Spacer(Modifier.width(8.dp))
Text(
text = stringResource(R.string.ptt_recordings_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.weight(1f)
)
IconButton(onClick = {
previewPlayer.release()
playingId = null
onDismiss()
}) {
Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.close))
}
}
Spacer(Modifier.height(8.dp))
HorizontalDivider()
Spacer(Modifier.height(8.dp))
if (recordings.isEmpty()) {
Text(
text = stringResource(R.string.ptt_recordings_empty),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 24.dp)
)
} else {
LazyColumn(
verticalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.height(360.dp)
) {
items(recordings, key = { it.id }) { rec ->
val isThisPlaying = playingId == rec.id
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Column(modifier = Modifier.padding(10.dp)) {
Text(
text = rec.displayName,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold
)
Text(
text = stringResource(
R.string.ptt_recording_meta,
formatTimestamp(rec.createdAt),
formatDuration(rec.durationMs)
),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
if (rec.latitude != null && rec.longitude != null) {
Text(
text = "GPS: %.5f, %.5f".format(rec.latitude, rec.longitude),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(Modifier.height(4.dp))
// Erste Aktionsreihe: Anhören/Pause und Zuweisen.
// „Als Waypoint-Audio zuweisen" ist ein langes Label und
// konnte zuvor zusammen mit „Löschen" in einer einzelnen Reihe
// das Löschen-Element aus dem sichtbaren Bereich drängen
// deshalb sind die Aktionen jetzt in zwei Reihen aufgeteilt,
// sodass „Löschen" immer sichtbar bleibt.
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
TextButton(onClick = {
// Wechsel: bisherige Vorschau stoppen, dann neue starten
if (playingId != null && playingId != rec.id) {
previewPlayer.release()
}
previewPlayer.toggle(context, rec) {
// Natürliches Ende
playingId = null
}
playingId = if (previewPlayer.playingId == rec.id) rec.id else null
}) {
Icon(
if (isThisPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
Spacer(Modifier.width(4.dp))
Text(
stringResource(
if (isThisPlaying) R.string.ptt_recording_preview_pause
else R.string.ptt_recording_preview_play
),
style = MaterialTheme.typography.labelMedium
)
}
TextButton(onClick = {
// Vor Zuweisung Vorschau stoppen
previewPlayer.release()
playingId = null
onAssign(rec)
}) {
Icon(Icons.Filled.Assignment, contentDescription = null,
modifier = Modifier.size(16.dp))
Spacer(Modifier.width(4.dp))
Text(stringResource(R.string.ptt_recording_assign_waypoint),
style = MaterialTheme.typography.labelMedium)
}
}
// Zweite Aktionsreihe: Löschen klar sichtbar in eigener Reihe.
// OutlinedButton statt TextButton, damit der Button auch auf
// schmalen Bildschirmen visuell deutlich als Aktion erkennbar
// ist und nicht von darüberliegendem Text überdeckt wird.
Row(
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
OutlinedButton(
onClick = {
// Wenn diese Aufnahme gerade gespielt wird, stoppen
if (playingId == rec.id) {
previewPlayer.release()
playingId = null
}
pendingDelete = rec
},
modifier = Modifier.semantics {
contentDescription =
"Aufnahme ${rec.displayName} löschen"
}
) {
Icon(Icons.Filled.Delete, contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.error)
Spacer(Modifier.width(4.dp))
Text(stringResource(R.string.ptt_recording_delete),
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.labelMedium)
}
}
}
}
}
}
}
}
}
}
pendingDelete?.let { rec ->
AlertDialog(
onDismissRequest = { pendingDelete = null },
icon = { Icon(Icons.Filled.Delete, contentDescription = null,
tint = MaterialTheme.colorScheme.error) },
title = { Text(stringResource(R.string.ptt_recording_delete_confirm_title)) },
text = { Text(stringResource(R.string.ptt_recording_delete_confirm_msg, rec.displayName)) },
confirmButton = {
Button(onClick = {
pttManager.deleteRecording(rec)
pendingDelete = null
}) {
Text(stringResource(R.string.delete))
}
},
dismissButton = {
TextButton(onClick = { pendingDelete = null }) {
Text(stringResource(R.string.cancel))
}
}
)
}
}
/**
* Wegpunkt-Auswahl: Auswahl einer Tour (Dropdown) und Zuweisung der Aufnahme zu einem
* bestehenden Wegpunkt der Tour. Falls die Tour keine Wegpunkte hat, kann ein neuer
* Wegpunkt direkt aus der Aufnahme erstellt werden („Neuen Wegpunkt aus Aufnahme erstellen").
*
* Position für den neuen Wegpunkt (Priorität):
* 1. Aktuelle GPS-Position (viewModel.currentLocation, falls vorhanden)
* 2. GPS-Position aus den Aufnahme-Metadaten
* 3. Safe Default 0.0, 0.0 (entspricht Waypoint-Datenmodell-Default)
*/
@Composable
fun WaypointChooserDialog(
recording: PttRecording,
viewModel: WaypointViewModel,
onAssigned: (Waypoint) -> Unit,
onDismiss: () -> Unit
) {
val context = LocalContext.current
val tourList by viewModel.tourList.collectAsState()
val selectedTour by viewModel.selectedTour.collectAsState()
val allWaypoints by viewModel.waypoints.collectAsState()
val currentLocation by viewModel.currentLocation.collectAsState()
// Lokale Tour-Auswahl im Dialog initial die aktuell aktive Tour.
var dialogTour by remember(selectedTour) { mutableStateOf(selectedTour) }
var tourMenuExpanded by remember { mutableStateOf(false) }
val candidates = remember(dialogTour, allWaypoints) {
allWaypoints.filter { it.tourName.ifBlank { Waypoint.DEFAULT_TOUR_NAME } == dialogTour }
}
Dialog(onDismissRequest = onDismiss) {
Surface(
shape = RoundedCornerShape(20.dp),
color = MaterialTheme.colorScheme.surface,
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Column(modifier = Modifier.padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Filled.Assignment, contentDescription = null,
tint = MaterialTheme.colorScheme.primary)
Spacer(Modifier.width(8.dp))
Text(
text = stringResource(R.string.ptt_waypoint_chooser_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.weight(1f)
)
IconButton(onClick = onDismiss) {
Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.close))
}
}
Spacer(Modifier.height(4.dp))
// Tour-Auswahl (Dropdown). Erlaubt Wechsel zu einer Tour mit Wegpunkten.
Box {
OutlinedButton(
onClick = { tourMenuExpanded = true },
modifier = Modifier.fillMaxWidth()
) {
Text(
text = stringResource(R.string.ptt_waypoint_chooser_tour_label, dialogTour),
modifier = Modifier.weight(1f)
)
Icon(Icons.Filled.ArrowDropDown, contentDescription = null)
}
DropdownMenu(
expanded = tourMenuExpanded,
onDismissRequest = { tourMenuExpanded = false }
) {
tourList.forEach { name ->
DropdownMenuItem(
text = { Text(name) },
onClick = {
dialogTour = name
tourMenuExpanded = false
}
)
}
}
}
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
if (candidates.isEmpty()) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = stringResource(R.string.ptt_waypoint_chooser_empty),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = stringResource(R.string.ptt_waypoint_chooser_empty_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Button(
onClick = {
val lat = currentLocation?.first
?: recording.latitude
?: 0.0
val lon = currentLocation?.second
?: recording.longitude
?: 0.0
val baseName = recording.displayName.ifBlank { "PTT-Aufnahme" }
val newWaypoint = Waypoint(
name = baseName,
latitude = lat,
longitude = lon,
radiusMeters = 50f,
soundUri = recording.uri,
soundName = recording.displayName,
isActive = true,
tourName = dialogTour.ifBlank { Waypoint.DEFAULT_TOUR_NAME }
)
viewModel.upsert(newWaypoint)
// Wenn der Dialog eine andere Tour als die aktuell ausgewählte
// betrifft, dorthin wechseln, damit der neue Wegpunkt sichtbar wird.
if (newWaypoint.tourName != selectedTour) {
viewModel.selectTour(newWaypoint.tourName)
}
viewModel.postInfo(
context.getString(
R.string.ptt_waypoint_chooser_created,
newWaypoint.name,
newWaypoint.tourName
)
)
onAssigned(newWaypoint)
},
modifier = Modifier.fillMaxWidth()
) {
Icon(Icons.Filled.AddLocationAlt, contentDescription = null,
modifier = Modifier.size(18.dp))
Spacer(Modifier.width(6.dp))
Text(stringResource(R.string.ptt_waypoint_chooser_create_new))
}
}
} else {
LazyColumn(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.height(320.dp)
) {
items(candidates, key = { it.id }) { wp ->
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
),
onClick = {
val updated = wp.copy(
soundUri = recording.uri,
soundName = recording.displayName
)
viewModel.upsert(updated)
viewModel.postInfo(
context.getString(
R.string.ptt_waypoint_chooser_assigned,
updated.name.ifBlank { "(ohne Namen)" }
)
)
onAssigned(updated)
}
) {
Column(modifier = Modifier.padding(10.dp)) {
Text(
text = wp.name.ifBlank { "(ohne Namen)" },
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold
)
Text(
text = "%.5f, %.5f · %.0f m".format(
wp.latitude, wp.longitude, wp.radiusMeters
),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
if (wp.soundName.isNotBlank()) {
Text(
text = "Audio: ${wp.soundName}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.outline
)
}
}
}
}
}
Spacer(Modifier.height(8.dp))
// Zusätzlich: auch in nicht-leeren Touren kann ein neuer Wegpunkt
// direkt aus der Aufnahme erstellt werden.
OutlinedButton(
onClick = {
val lat = currentLocation?.first
?: recording.latitude
?: 0.0
val lon = currentLocation?.second
?: recording.longitude
?: 0.0
val baseName = recording.displayName.ifBlank { "PTT-Aufnahme" }
val newWaypoint = Waypoint(
name = baseName,
latitude = lat,
longitude = lon,
radiusMeters = 50f,
soundUri = recording.uri,
soundName = recording.displayName,
isActive = true,
tourName = dialogTour.ifBlank { Waypoint.DEFAULT_TOUR_NAME }
)
viewModel.upsert(newWaypoint)
if (newWaypoint.tourName != selectedTour) {
viewModel.selectTour(newWaypoint.tourName)
}
viewModel.postInfo(
context.getString(
R.string.ptt_waypoint_chooser_created,
newWaypoint.name,
newWaypoint.tourName
)
)
onAssigned(newWaypoint)
},
modifier = Modifier.fillMaxWidth()
) {
Icon(Icons.Filled.AddLocationAlt, contentDescription = null,
modifier = Modifier.size(18.dp))
Spacer(Modifier.width(6.dp))
Text(stringResource(R.string.ptt_waypoint_chooser_create_new))
}
}
Spacer(Modifier.height(8.dp))
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.cancel))
}
}
}
}
}
}
@@ -0,0 +1,121 @@
package de.waypointaudio.ui
import java.util.UUID
/**
* v2.4.2 — Explorer-Routenplaner: zustandsneutrale Datenmodelle.
*
* Bewusst klein und Composable-freundlich: Eine Route besteht aus
* (Start, optionalen Via-Stopps, Ziel). Jeder Stopp kennt seine Quelle
* (Aktueller Standort, Kartenmitte, Wegpunkt, POI, manuell getippt),
* damit die UI sinnvolle Labels anzeigen kann.
*
* - Keine Persistenz: Der Planer-Zustand lebt nur im Sheet-Scope.
* - Keine Auto-Berechnung: Routing wird nur auf Nutzer-Klick ausgelöst.
* - Kein verbindlicher Token: Wenn kein Endpunkt konfiguriert ist,
* bleibt nur die Planlinie (Luftlinie) sichtbar.
*/
/** Quelle, aus der ein Routen-Stopp gewählt wurde. */
enum class RouteStopSource {
/** Aktueller GPS-Standort des Geräts. */
CURRENT_LOCATION,
/** Kartenmitte zum Zeitpunkt der Auswahl. */
MAP_CENTER,
/** Vorhandener Wegpunkt der aktuellen Tour. */
WAYPOINT,
/** Treffer aus der Online-POI-Suche (Overpass). */
POI,
/** Vom Nutzer manuell eingetippte Koordinate (Lat/Lon-Felder). */
MANUAL,
/** Treffer aus der Ortssuche (Nominatim). */
SEARCH,
}
/**
* Ein einzelner Routen-Stopp (Start, Via oder Ziel).
*
* - [id] stabile UI-Identität für Listen-Operationen.
* - [label] menschenlesbare Beschriftung (z. B. POI-Name, Wegpunktname,
* "Mein Standort", "Eigene Koordinate").
*/
data class RouteStop(
val id: String = UUID.randomUUID().toString(),
val latitude: Double,
val longitude: Double,
val label: String,
val source: RouteStopSource,
)
/**
* v2.5.2 — Routingprofil für die Berechnung.
*
* Mapping auf den OSRM-kompatiblen Pfad-Bestandteil:
* - [DRIVING] → "driving" (Auto)
* - [CYCLING] → "cycling" (Fahrrad)
* - [WALKING] → "walking" (Fußgänger)
*
* Die Schiff-/Boot-Variante ist bewusst ausgespart (Nutzerwunsch v2.5.2).
* Persistente Tourrouten speichern den OSRM-Schlüssel (`driving`/`cycling`/`walking`)
* als String — bestehende Routen ohne explizites Profil fallen sicher auf `driving`.
*/
enum class RoutingProfile(val osrmKey: String) {
DRIVING("driving"),
CYCLING("cycling"),
WALKING("walking");
companion object {
/** Tolerantes Mapping aus persistierten oder importierten Strings. */
fun fromKey(key: String?): RoutingProfile {
if (key.isNullOrBlank()) return DRIVING
return when (key.trim().lowercase()) {
"cycling", "bike", "bicycle" -> CYCLING
"walking", "foot", "pedestrian" -> WALKING
else -> DRIVING
}
}
}
}
/**
* Komplette Routen-Definition: Start + Via-Liste + Ziel.
*
* Alle drei Felder können `null` sein, solange der Plan noch unvollständig ist.
* Eine Berechnung ist erst möglich, wenn Start und Ziel gesetzt sind.
*
* v2.5.2: [profile] hält die aktuelle Fortbewegungsart (Auto/Fahrrad/Fußgänger);
* Default Auto, damit Bestandsverhalten unverändert bleibt.
*/
data class RoutePlan(
val start: RouteStop? = null,
val via: List<RouteStop> = emptyList(),
val destination: RouteStop? = null,
val profile: RoutingProfile = RoutingProfile.DRIVING,
) {
val isComplete: Boolean get() = start != null && destination != null
/** Liefert alle Stops in Reihenfolge Start → Via … → Ziel. */
fun orderedStops(): List<RouteStop> {
val out = mutableListOf<RouteStop>()
start?.let { out += it }
out += via
destination?.let { out += it }
return out
}
/** Lat/Lon-Paare für OSRM (Start..Via..Ziel). */
fun toOsrmPoints(): List<Pair<Double, Double>> =
orderedStops().map { it.latitude to it.longitude }
}
/**
* Welche Rolle ein Stopp in der Route spielt. Wird vom UI an die
* Auswahl-Quellen-Callbacks weitergereicht, um zu wissen, wohin der
* gewählte Punkt landen soll.
*/
enum class RouteStopRole { START, VIA, DESTINATION }
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,377 @@
package de.waypointaudio.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AltRoute
import androidx.compose.material.icons.filled.Bookmarks
import androidx.compose.material.icons.filled.MovieFilter
import androidx.compose.material.icons.filled.Place
import androidx.compose.material.icons.filled.Save
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import de.waypointaudio.R
import de.waypointaudio.util.OverpassPoi
import de.waypointaudio.util.OverpassPoiClient
import kotlinx.coroutines.launch
/**
* v2.3.1 — Routing & Online-POI MVP.
*
* Bewusst sparsam, kostenneutral und ehrlich abgegrenzt:
* - Routing: konfigurierbarer OSRM-kompatibler Endpunkt (self-hosted bevorzugt).
* Ohne konfigurierten Endpunkt wird kein Netzwerk-Call ausgelöst — Storyboard-
* /Luftlinie bleibt Fallback.
* - POI: nutzergetriggerte Overpass-Suche (kein Polling, kein Auto-Refresh).
* Treffer können als Wegpunkt der aktuellen Tour übernommen oder ins
* Storyboard (separater Sheet) gemerkt werden.
*
* Diese Komponente ist absichtlich state-leicht: Sie liefert Aktionen via
* Callbacks zurück (POI in Tour übernehmen, Route berechnen). Die Daten-
* persistenz und Karten-Darstellung der Route übernimmt der Aufrufer.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RoutingPoiSheet(
centerLat: Double?,
centerLon: Double?,
routingEndpoint: String,
onRoutingEndpointChange: (String) -> Unit,
onAddPoiAsWaypoint: (OverpassPoi) -> Unit,
onAddPoiToStoryboard: (OverpassPoi) -> Unit,
onComputeRoute: () -> Unit,
routeStatus: String?,
onDismiss: () -> Unit,
) {
val context = LocalContext.current
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val scope = rememberCoroutineScope()
var endpointDraft by remember(routingEndpoint) { mutableStateOf(routingEndpoint) }
var query by remember { mutableStateOf("") }
var results by remember { mutableStateOf<List<OverpassPoi>>(emptyList()) }
var loading by remember { mutableStateOf(false) }
var errorMsg by remember { mutableStateOf<String?>(null) }
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.padding(bottom = 16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Filled.AltRoute, contentDescription = null, modifier = Modifier.size(20.dp))
Spacer(Modifier.width(8.dp))
Text(
stringResource(R.string.routing_poi_title),
style = MaterialTheme.typography.titleMedium
)
}
// --- Routing endpoint config ---
Surface(
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.55f),
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
Text(
stringResource(R.string.routing_endpoint_label),
style = MaterialTheme.typography.labelMedium
)
Text(
stringResource(R.string.routing_endpoint_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
// v2.4.4: Beide Eingabeformen sind erlaubt — Hinweis dazu.
Text(
stringResource(R.string.routing_endpoint_accepted_formats),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.85f)
)
OutlinedTextField(
value = endpointDraft,
onValueChange = { endpointDraft = it },
singleLine = true,
placeholder = { Text("https://router.project-osrm.org") },
modifier = Modifier.fillMaxWidth()
)
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth()
) {
Button(
onClick = { onRoutingEndpointChange(endpointDraft) },
modifier = Modifier.weight(1f)
) {
Icon(Icons.Filled.Save, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(6.dp))
Text(stringResource(R.string.routing_endpoint_save))
}
Button(
onClick = onComputeRoute,
enabled = routingEndpoint.isNotBlank(),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.tertiaryContainer,
contentColor = MaterialTheme.colorScheme.onTertiaryContainer
),
modifier = Modifier.weight(1f)
) {
Icon(Icons.Filled.AltRoute, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(6.dp))
Text(stringResource(R.string.routing_compute_button))
}
}
// v2.4.4: One-Click Demo-Setup. Trägt den Demo-Endpunkt
// in den Entwurf ein, der Nutzer kann dann Speichern.
TextButton(
onClick = {
endpointDraft = de.waypointaudio.util.OsrmRoutingClient.DEMO_ENDPOINT
},
modifier = Modifier.fillMaxWidth()
) {
Icon(Icons.Filled.AltRoute, contentDescription = null, modifier = Modifier.size(14.dp))
Spacer(Modifier.width(6.dp))
Text(stringResource(R.string.routing_endpoint_use_demo))
}
if (routingEndpoint.isBlank()) {
Text(
stringResource(R.string.routing_endpoint_not_configured),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.85f)
)
} else if (routingEndpoint.contains("router.project-osrm.org")) {
Text(
stringResource(R.string.routing_endpoint_demo_warning),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.error
)
}
if (routeStatus != null) {
Text(
routeStatus,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary
)
}
}
}
// --- POI search ---
Surface(
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.55f),
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
Text(
stringResource(R.string.routing_poi_search_label),
style = MaterialTheme.typography.labelMedium
)
OutlinedTextField(
value = query,
onValueChange = { query = it },
singleLine = true,
placeholder = { Text(stringResource(R.string.routing_poi_search_hint)) },
modifier = Modifier.fillMaxWidth()
)
Button(
onClick = {
val lat = centerLat
val lon = centerLon
if (lat == null || lon == null) {
errorMsg = context.getString(R.string.explorer_hint_no_location)
return@Button
}
loading = true
errorMsg = null
scope.launch {
runCatching {
OverpassPoiClient.search(context, query, lat, lon)
}.onSuccess {
results = it
if (it.isEmpty()) {
errorMsg = context.getString(R.string.routing_poi_search_no_results)
}
}.onFailure {
errorMsg = context.getString(R.string.routing_poi_search_error)
}
loading = false
}
},
enabled = !loading && query.isNotBlank(),
modifier = Modifier.fillMaxWidth()
) {
Icon(Icons.Filled.Search, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(6.dp))
Text(
if (loading) stringResource(R.string.routing_poi_search_loading)
else stringResource(R.string.routing_poi_search_button)
)
}
Text(
stringResource(R.string.routing_poi_search_attribution),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.75f)
)
errorMsg?.let {
Text(it, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.error)
}
}
}
// --- Results ---
if (loading) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
} else if (results.isNotEmpty()) {
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 320.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
items(results) { poi ->
Surface(
shape = RoundedCornerShape(10.dp),
color = MaterialTheme.colorScheme.surface,
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Filled.Place,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(Modifier.width(6.dp))
Column(modifier = Modifier.weight(1f)) {
Text(poi.displayName, style = MaterialTheme.typography.bodyMedium)
val distM = if (centerLat != null && centerLon != null) {
haversineMeters(centerLat, centerLon, poi.latitude, poi.longitude)
} else null
Text(
buildString {
append(poi.category)
if (distM != null) {
append(" · ")
append(
context.getString(
R.string.routing_poi_distance,
if (distM >= 1000) "%.1f km".format(distM / 1000.0)
else "%d m".format(distM.toInt())
)
)
}
},
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
TextButton(onClick = { onAddPoiAsWaypoint(poi) }) {
Icon(
Icons.Filled.Place,
contentDescription = null,
modifier = Modifier.size(14.dp)
)
Spacer(Modifier.width(4.dp))
Text(stringResource(R.string.routing_poi_add_waypoint))
}
TextButton(onClick = { onAddPoiToStoryboard(poi) }) {
Icon(
Icons.Filled.MovieFilter,
contentDescription = null,
modifier = Modifier.size(14.dp)
)
Spacer(Modifier.width(4.dp))
Text(stringResource(R.string.routing_poi_add_storyboard))
}
}
}
}
}
}
}
TextButton(
onClick = onDismiss,
modifier = Modifier.align(Alignment.End)
) {
Text(stringResource(R.string.close))
}
}
}
}
private fun haversineMeters(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double {
val r = 6_371_000.0
val phi1 = Math.toRadians(lat1)
val phi2 = Math.toRadians(lat2)
val dPhi = Math.toRadians(lat2 - lat1)
val dLam = Math.toRadians(lon2 - lon1)
val a = Math.sin(dPhi / 2) * Math.sin(dPhi / 2) +
Math.cos(phi1) * Math.cos(phi2) *
Math.sin(dLam / 2) * Math.sin(dLam / 2)
val c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
return r * c
}
@@ -0,0 +1,102 @@
package de.waypointaudio.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import de.waypointaudio.R
/**
* v2.5.9 — Erstmaliger Nutzungsbedingungen-/Lizenzhinweis-Dialog.
*
* Versioniert über [de.waypointaudio.data.TermsVersions.CURRENT];
* Zustimmung wird im [de.waypointaudio.data.TermsStore] persistiert.
* Ablehnen beendet die Activity — die App lässt sich nicht ohne
* Zustimmung verwenden.
*/
@Composable
fun TermsDialog(
onAccept: () -> Unit,
onDecline: () -> Unit
) {
AlertDialog(
onDismissRequest = { /* keine versehentliche Dismiss-Aktion */ },
title = {
Text(
text = stringResource(R.string.terms_dialog_title),
style = MaterialTheme.typography.headlineSmall
)
},
text = {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Text(
text = stringResource(R.string.terms_dialog_intro),
style = MaterialTheme.typography.bodyMedium
)
Text(
text = stringResource(R.string.terms_dialog_license_heading),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold
)
Text(
text = stringResource(R.string.terms_dialog_license_body),
style = MaterialTheme.typography.bodySmall
)
Text(
text = stringResource(R.string.terms_dialog_commercial_heading),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold
)
Text(
text = stringResource(R.string.terms_dialog_commercial_body),
style = MaterialTheme.typography.bodySmall
)
Text(
text = stringResource(R.string.terms_dialog_liability_heading),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold
)
Text(
text = stringResource(R.string.terms_dialog_liability_body),
style = MaterialTheme.typography.bodySmall
)
Text(
text = stringResource(R.string.terms_dialog_version_label),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
}
},
confirmButton = {
TextButton(onClick = onAccept) {
Text(stringResource(R.string.terms_dialog_accept))
}
},
dismissButton = {
TextButton(onClick = onDecline) {
Text(stringResource(R.string.terms_dialog_decline))
}
}
)
}
@@ -0,0 +1,930 @@
package de.waypointaudio.ui
import android.annotation.SuppressLint
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Bookmark
import androidx.compose.material.icons.filled.BookmarkBorder
import androidx.compose.material.icons.filled.Bookmarks
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.DeleteSweep
import androidx.compose.material.icons.filled.Done
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.layout.heightIn
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import de.waypointaudio.R
import de.waypointaudio.data.GpsTrackPoint
import de.waypointaudio.data.TrackDraft
import de.waypointaudio.util.NominatimClient
import de.waypointaudio.util.NominatimResult
import de.waypointaudio.viewmodel.WaypointViewModel
import kotlinx.coroutines.launch
import org.osmdroid.config.Configuration
import org.osmdroid.events.MapEventsReceiver
import de.waypointaudio.data.MapProvider
import de.waypointaudio.data.MapProviderStore
import org.osmdroid.util.GeoPoint
import org.osmdroid.views.MapView
import org.osmdroid.views.overlay.MapEventsOverlay
import org.osmdroid.views.overlay.Marker
import org.osmdroid.views.overlay.Polyline
import java.io.File
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.pow
import kotlin.math.sin
import kotlin.math.sqrt
/**
* Karten-basierter Editor für einen [TrackDraft].
*
* Interaktion:
* - Tippen auf einen Streckenpunkt → schaltet Markierung für späteren Import
* als Wegpunkt um.
* - Lang drücken auf einen Streckenpunkt → wählt ihn zum Verschieben/Löschen.
* - Lang drücken auf die Karte → verschiebt den ausgewählten Punkt dorthin.
* - Toolbar: Alle markieren / Markierungen löschen, Umbenennen, Track löschen.
* - FAB "In Tour übernehmen": importiert nur markierte Punkte. Ohne Markierung
* deaktiviert mit erklärendem Hinweis.
*
* Während aktiver GPS-Aufzeichnung sind Bearbeiten/Importieren gesperrt.
*
* Attribution: Kartendaten © OpenStreetMap-Mitwirkende (ODbL).
*/
@SuppressLint("MissingPermission")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TrackDraftEditorScreen(
viewModel: WaypointViewModel,
draftId: String,
onNavigateBack: () -> Unit
) {
val context = LocalContext.current
val mapProviderStore = remember { MapProviderStore(context.applicationContext) }
val selectedMapProvider by mapProviderStore.provider.collectAsState(initial = MapProvider.DEFAULT)
val drafts by viewModel.trackDrafts.collectAsState()
val tourList by viewModel.tourList.collectAsState()
val selectedTour by viewModel.selectedTour.collectAsState()
val isRecording by viewModel.isRecording.collectAsState()
val storedDraft = drafts.firstOrNull { it.id == draftId }
var working by remember(draftId, storedDraft?.updatedAt) {
mutableStateOf(storedDraft ?: TrackDraft(id = draftId))
}
LaunchedEffect(storedDraft) {
if (storedDraft == null) onNavigateBack()
}
var selectedIndex by remember { mutableStateOf<Int?>(null) }
var showRenameDialog by remember { mutableStateOf(false) }
var showImportDialog by remember { mutableStateOf(false) }
var showDeleteConfirm by remember { mutableStateOf(false) }
var showSearchDialog by remember { mutableStateOf(false) }
var searchPin by remember { mutableStateOf<GeoPoint?>(null) }
var dirty by remember { mutableStateOf(false) }
val primary = MaterialTheme.colorScheme.primary
val tertiary = MaterialTheme.colorScheme.tertiary
val errorColor = MaterialTheme.colorScheme.error
val markedColor = Color(0xFFFFAB00) // distinctive amber for marked points
LaunchedEffect(Unit) {
Configuration.getInstance().apply {
userAgentValue = context.packageName
osmdroidTileCache = File(context.cacheDir, "osmdroid")
}
}
var mapView by remember { mutableStateOf<MapView?>(null) }
fun toggleMark(idx: Int) {
if (idx !in working.points.indices) return
val isMarked = idx in working.markedIndexes
val newMarked = if (isMarked)
working.markedIndexes - idx
else
(working.markedIndexes + idx).distinct().sorted()
working = working.copy(markedIndexes = newMarked)
dirty = true
viewModel.saveDraft(working)
}
fun deleteSelected() {
val sel = selectedIndex ?: return
if (isRecording) return
val pts = working.points.toMutableList()
if (sel !in pts.indices) return
pts.removeAt(sel)
val newMarked = working.markedIndexes
.filter { it != sel }
.map { if (it > sel) it - 1 else it }
working = working.copy(points = pts, markedIndexes = newMarked)
dirty = true
selectedIndex = null
viewModel.saveDraft(working)
}
LaunchedEffect(selectedMapProvider) {
val mv = mapView ?: return@LaunchedEffect
mv.setTileSource(selectedMapProvider.toTileSource())
mv.invalidate()
}
LaunchedEffect(working, selectedIndex, isRecording, searchPin) {
val mv = mapView ?: return@LaunchedEffect
drawDraftOverlays(
mv = mv,
draft = working,
selectedIndex = selectedIndex,
polyArgb = tertiary.toArgb(),
pointArgb = primary.toArgb(),
markedArgb = markedColor.toArgb(),
selectedArgb = errorColor.toArgb(),
searchPin = searchPin,
onPointTapped = { idx -> toggleMark(idx) }
)
}
DisposableEffect(Unit) {
onDispose {
if (dirty) viewModel.saveDraft(working)
mapView?.onDetach()
}
}
if (showRenameDialog) {
var nameField by remember { mutableStateOf(working.name) }
var notesField by remember { mutableStateOf(working.notes) }
AlertDialog(
onDismissRequest = { showRenameDialog = false },
title = { Text(stringResource(R.string.draft_edit_meta_title)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = nameField,
onValueChange = { nameField = it },
label = { Text(stringResource(R.string.draft_name_label)) },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = notesField,
onValueChange = { notesField = it },
label = { Text(stringResource(R.string.draft_notes_label)) },
modifier = Modifier.fillMaxWidth()
)
}
},
confirmButton = {
Button(onClick = {
working = working.copy(name = nameField.trim(), notes = notesField)
dirty = true
viewModel.saveDraft(working)
showRenameDialog = false
}) { Text(stringResource(R.string.save)) }
},
dismissButton = {
TextButton(onClick = { showRenameDialog = false }) {
Text(stringResource(R.string.cancel))
}
}
)
}
if (showImportDialog) {
DraftImportDialog(
draft = working,
tourList = tourList,
preselectedTour = selectedTour,
isRecording = isRecording,
onConfirm = { tour ->
viewModel.importDraftAsWaypoints(working, tour)
showImportDialog = false
onNavigateBack()
},
onDismiss = { showImportDialog = false }
)
}
if (showSearchDialog) {
PlaceSearchDialog(
onDismiss = { showSearchDialog = false },
onResultSelected = { result ->
val gp = GeoPoint(result.latitude, result.longitude)
searchPin = gp
mapView?.controller?.animateTo(gp)
mapView?.controller?.setZoom(15.0)
showSearchDialog = false
}
)
}
if (showDeleteConfirm) {
AlertDialog(
onDismissRequest = { showDeleteConfirm = false },
title = { Text(stringResource(R.string.draft_delete_confirm_title)) },
text = { Text(stringResource(R.string.draft_delete_confirm_msg, working.name.ifBlank { "Draft" })) },
confirmButton = {
Button(
onClick = {
viewModel.deleteDraft(working.id)
showDeleteConfirm = false
onNavigateBack()
},
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error,
contentColor = MaterialTheme.colorScheme.onError
)
) { Text(stringResource(R.string.delete)) }
},
dismissButton = {
TextButton(onClick = { showDeleteConfirm = false }) {
Text(stringResource(R.string.cancel))
}
}
)
}
val canImport = !isRecording && working.markedCount > 0
Scaffold(
topBar = {
TopAppBar(
title = {
Column {
Text(
working.name.ifBlank { stringResource(R.string.draft_unnamed) },
style = MaterialTheme.typography.titleMedium
)
Text(
stringResource(
R.string.draft_editor_subtitle,
working.pointCount,
working.markedCount
),
style = MaterialTheme.typography.labelSmall
)
}
},
navigationIcon = {
IconButton(onClick = {
if (dirty) viewModel.saveDraft(working)
onNavigateBack()
}) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.back)
)
}
},
actions = {
IconButton(onClick = { showSearchDialog = true }) {
Icon(
Icons.Filled.Search,
contentDescription = stringResource(R.string.search_place_action)
)
}
IconButton(
onClick = {
if (working.markedCount == working.pointCount) {
working = working.copy(markedIndexes = emptyList())
} else {
working = working.copy(
markedIndexes = working.points.indices.toList()
)
}
dirty = true
viewModel.saveDraft(working)
},
enabled = !isRecording && working.pointCount > 0
) {
Icon(
if (working.markedCount == working.pointCount && working.pointCount > 0)
Icons.Filled.DeleteSweep
else
Icons.Filled.Bookmarks,
contentDescription = if (working.markedCount == working.pointCount && working.pointCount > 0)
stringResource(R.string.draft_unmark_all)
else
stringResource(R.string.draft_mark_all)
)
}
IconButton(onClick = { showRenameDialog = true }) {
Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.draft_edit_meta_title))
}
IconButton(onClick = { showDeleteConfirm = true }) {
Icon(
Icons.Filled.Delete,
contentDescription = stringResource(R.string.draft_delete),
tint = MaterialTheme.colorScheme.onPrimary
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primary,
titleContentColor = MaterialTheme.colorScheme.onPrimary,
navigationIconContentColor = MaterialTheme.colorScheme.onPrimary,
actionIconContentColor = MaterialTheme.colorScheme.onPrimary
)
)
},
floatingActionButton = {
ExtendedFloatingActionButton(
onClick = { if (canImport) showImportDialog = true },
containerColor = if (canImport)
MaterialTheme.colorScheme.primary
else
MaterialTheme.colorScheme.surfaceVariant,
contentColor = if (canImport)
MaterialTheme.colorScheme.onPrimary
else
MaterialTheme.colorScheme.onSurfaceVariant,
icon = { Icon(Icons.Filled.Download, contentDescription = null) },
text = {
Text(
if (working.markedCount > 0)
stringResource(R.string.draft_import_button) + " (${working.markedCount})"
else
stringResource(R.string.draft_import_button)
)
}
)
}
) { innerPadding ->
Box(modifier = Modifier.fillMaxSize().padding(innerPadding)) {
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { ctx ->
MapView(ctx).apply {
setTileSource(selectedMapProvider.toTileSource())
setMultiTouchControls(true)
val center = working.points.firstOrNull()?.let {
GeoPoint(it.latitude, it.longitude)
} ?: GeoPoint(51.1657, 10.4515)
controller.setZoom(15.0)
controller.setCenter(center)
// Long-press on map:
// - If close to a track point: select that point
// - Else if a point is already selected: move it there
val ev = object : MapEventsReceiver {
override fun singleTapConfirmedHelper(p: GeoPoint?) = false
override fun longPressHelper(p: GeoPoint?): Boolean {
if (p == null || isRecording) return true
val mvLocal = mapView ?: return true
val nearest = nearestPointWithinPixels(
mvLocal, working.points, p, thresholdPx = 80.0
)
if (nearest != null) {
selectedIndex = if (selectedIndex == nearest) null else nearest
return true
}
val sel = selectedIndex
if (sel != null) {
val pts = working.points.toMutableList()
if (sel in pts.indices) {
pts[sel] = pts[sel].copy(
latitude = p.latitude,
longitude = p.longitude
)
working = working.copy(points = pts)
dirty = true
viewModel.saveDraft(working)
}
}
return true
}
}
overlays.add(MapEventsOverlay(ev))
mapView = this
drawDraftOverlays(
mv = this,
draft = working,
selectedIndex = selectedIndex,
polyArgb = tertiary.toArgb(),
pointArgb = primary.toArgb(),
markedArgb = markedColor.toArgb(),
selectedArgb = errorColor.toArgb(),
onPointTapped = { idx -> toggleMark(idx) }
)
}
},
update = { mv -> mapView = mv }
)
// Always-visible help banner at top so the gesture is discoverable
Surface(
shape = RoundedCornerShape(14.dp),
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.92f),
shadowElevation = 3.dp,
modifier = Modifier
.align(Alignment.TopCenter)
.padding(top = 8.dp, start = 12.dp, end = 12.dp)
.fillMaxWidth()
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)
) {
Icon(
Icons.Filled.BookmarkBorder,
contentDescription = null,
tint = Color(0xFFFFAB00),
modifier = Modifier.size(20.dp)
)
Spacer(Modifier.width(8.dp))
Text(
stringResource(R.string.draft_editor_help),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurface
)
}
}
// Selection action bar — only when user long-pressed a point
val sel = selectedIndex
if (sel != null && sel in working.points.indices) {
Surface(
shape = RoundedCornerShape(20.dp),
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.96f),
shadowElevation = 4.dp,
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 88.dp, start = 12.dp, end = 12.dp)
) {
Column(modifier = Modifier.padding(12.dp)) {
Text(
stringResource(R.string.draft_point_selected, sel + 1, working.pointCount),
style = MaterialTheme.typography.labelMedium
)
val isMarked = sel in working.markedIndexes
Spacer(Modifier.height(6.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(
onClick = { toggleMark(sel) },
colors = ButtonDefaults.buttonColors(
containerColor = if (isMarked)
MaterialTheme.colorScheme.tertiary
else
MaterialTheme.colorScheme.primary
)
) {
Icon(
if (isMarked) Icons.Filled.Bookmark else Icons.Filled.BookmarkBorder,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
Spacer(Modifier.width(4.dp))
Text(
if (isMarked)
stringResource(R.string.draft_point_unmark)
else
stringResource(R.string.draft_point_mark)
)
}
Button(
onClick = { deleteSelected() },
enabled = !isRecording,
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer
)
) {
Icon(Icons.Filled.Delete, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(4.dp))
Text(stringResource(R.string.delete))
}
}
Text(
stringResource(R.string.draft_point_move_hint),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
modifier = Modifier.padding(top = 6.dp)
)
}
}
}
// Recording warning banner
if (isRecording) {
Surface(
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.94f),
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 156.dp, start = 12.dp, end = 12.dp)
) {
Text(
stringResource(R.string.draft_locked_while_recording),
modifier = Modifier.padding(12.dp),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onErrorContainer
)
}
}
// "Keine Wegpunkte markiert"-Hinweis am FAB
if (!isRecording && working.markedCount == 0 && working.pointCount > 0) {
Surface(
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.92f),
shadowElevation = 2.dp,
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(end = 12.dp, bottom = 88.dp)
) {
Text(
stringResource(R.string.draft_import_disabled_hint),
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurface
)
}
}
// OSM attribution moved to bottom-left so the import FAB at bottom-right
// cannot overlap the mandatory attribution badge.
OsmAttributionBadge(
modifier = Modifier
.align(Alignment.BottomStart)
.padding(start = 6.dp, bottom = 6.dp)
)
}
}
}
/**
* Modaler Dialog: Draft als Wegpunkte importieren.
* Nur markierte Punkte werden importiert. Ohne Markierung ist der Import deaktiviert.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun DraftImportDialog(
draft: TrackDraft,
tourList: List<String>,
preselectedTour: String,
isRecording: Boolean,
onConfirm: (tour: String) -> Unit,
onDismiss: () -> Unit
) {
var targetTour by remember { mutableStateOf(preselectedTour) }
var menuExpanded by remember { mutableStateOf(false) }
val markedCount = draft.markedCount
val totalCount = draft.pointCount
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.draft_import_title)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
if (isRecording) {
Text(
stringResource(R.string.draft_locked_while_recording),
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall
)
}
Text(
if (markedCount > 0)
stringResource(R.string.draft_import_hint_marked, markedCount, totalCount)
else
stringResource(R.string.draft_import_hint_none),
style = MaterialTheme.typography.bodySmall
)
Text(
stringResource(R.string.draft_import_target_label),
style = MaterialTheme.typography.labelMedium
)
Box {
OutlinedTextField(
value = targetTour,
onValueChange = { targetTour = it },
readOnly = false,
label = { Text(stringResource(R.string.tour_name_label)) },
modifier = Modifier
.fillMaxWidth()
.clickable { menuExpanded = true }
)
DropdownMenu(
expanded = menuExpanded,
onDismissRequest = { menuExpanded = false }
) {
tourList.forEach { t ->
DropdownMenuItem(
text = { Text(t) },
onClick = {
targetTour = t
menuExpanded = false
}
)
}
}
}
}
},
confirmButton = {
Button(
onClick = { onConfirm(targetTour.ifBlank { preselectedTour }) },
enabled = !isRecording && markedCount > 0
) {
Icon(Icons.Filled.Done, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(4.dp))
Text(stringResource(R.string.draft_import_confirm, markedCount))
}
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) }
}
)
}
// ---------------------------------------------------------------------------
// Map overlays for draft editor
// ---------------------------------------------------------------------------
private fun drawDraftOverlays(
mv: MapView,
draft: TrackDraft,
selectedIndex: Int?,
polyArgb: Int,
pointArgb: Int,
markedArgb: Int,
selectedArgb: Int,
searchPin: GeoPoint? = null,
onPointTapped: (Int) -> Unit
) {
val overlays = mv.overlays
overlays.removeAll(overlays.filterIsInstance<Polyline>().toSet())
overlays.removeAll(overlays.filterIsInstance<Marker>().toSet())
if (draft.points.size >= 2) {
val geoPoints = draft.points.map { GeoPoint(it.latitude, it.longitude) }
val halo = Polyline(mv).apply {
setPoints(geoPoints)
outlinePaint.color = 0xFFFFFFFF.toInt()
outlinePaint.strokeWidth = 28f
outlinePaint.strokeCap = android.graphics.Paint.Cap.ROUND
outlinePaint.strokeJoin = android.graphics.Paint.Join.ROUND
outlinePaint.alpha = 230
infoWindow = null
}
val polyline = Polyline(mv).apply {
setPoints(geoPoints)
outlinePaint.color = polyArgb
outlinePaint.strokeWidth = 18f
outlinePaint.strokeCap = android.graphics.Paint.Cap.ROUND
outlinePaint.strokeJoin = android.graphics.Paint.Join.ROUND
outlinePaint.alpha = 255
infoWindow = null
}
overlays.add(halo)
overlays.add(polyline)
}
val markedSet = draft.markedIndexes.toSet()
draft.points.forEachIndexed { idx, p ->
val gp = GeoPoint(p.latitude, p.longitude)
val color = when {
idx == selectedIndex -> selectedArgb
idx in markedSet -> markedArgb
else -> pointArgb
}
val sizePx = when {
idx == selectedIndex -> 96
idx in markedSet -> 80
else -> 64
}
val bm = makePointBitmap(sizePx, color, idx == selectedIndex, idx in markedSet)
val marker = Marker(mv).apply {
position = gp
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
icon = android.graphics.drawable.BitmapDrawable(mv.context.resources, bm)
title = null
snippet = null
subDescription = null
infoWindow = null
setOnMarkerClickListener { _, _ ->
onPointTapped(idx)
true
}
}
overlays.add(marker)
}
if (searchPin != null) {
val pinBm = makeSearchPinBitmapShared(96)
val pinMarker = Marker(mv).apply {
position = searchPin
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
icon = android.graphics.drawable.BitmapDrawable(mv.context.resources, pinBm)
title = null
snippet = null
subDescription = null
infoWindow = null
setOnMarkerClickListener { _, _ -> true }
}
overlays.add(pinMarker)
}
mv.invalidate()
}
private fun makePointBitmap(
sizePx: Int,
argb: Int,
selected: Boolean = false,
marked: Boolean = false
): android.graphics.Bitmap {
val size = sizePx.coerceAtLeast(16)
val bm = android.graphics.Bitmap.createBitmap(size, size, android.graphics.Bitmap.Config.ARGB_8888)
val canvas = android.graphics.Canvas(bm)
val cx = size / 2f
val cy = size / 2f
val outerR = size / 2f - 2f
val halo = android.graphics.Paint().apply {
isAntiAlias = true
style = android.graphics.Paint.Style.FILL
color = 0xFFFFFFFF.toInt()
alpha = 220
}
canvas.drawCircle(cx, cy, outerR, halo)
val fill = android.graphics.Paint().apply {
isAntiAlias = true
style = android.graphics.Paint.Style.FILL
color = argb
}
canvas.drawCircle(cx, cy, outerR - size * 0.12f, fill)
val ring = android.graphics.Paint().apply {
isAntiAlias = true
style = android.graphics.Paint.Style.STROKE
color = 0xFFFFFFFF.toInt()
strokeWidth = (size * 0.10f).coerceAtLeast(3f)
}
canvas.drawCircle(cx, cy, outerR - size * 0.05f, ring)
if (selected) {
val accent = android.graphics.Paint().apply {
isAntiAlias = true
style = android.graphics.Paint.Style.STROKE
color = 0xFF000000.toInt()
strokeWidth = (size * 0.08f).coerceAtLeast(3f)
}
canvas.drawCircle(cx, cy, outerR - size * 0.20f, accent)
val pip = android.graphics.Paint().apply {
isAntiAlias = true
style = android.graphics.Paint.Style.FILL
color = 0xFFFFFFFF.toInt()
}
canvas.drawCircle(cx, cy, size * 0.12f, pip)
} else if (marked) {
// Bookmark-pip indicates marked (waypoint candidate)
val pip = android.graphics.Paint().apply {
isAntiAlias = true
style = android.graphics.Paint.Style.FILL
color = 0xFF000000.toInt()
}
canvas.drawCircle(cx, cy, size * 0.20f, pip)
val pipInner = android.graphics.Paint().apply {
isAntiAlias = true
style = android.graphics.Paint.Style.FILL
color = 0xFFFFFFFF.toInt()
}
canvas.drawCircle(cx, cy, size * 0.10f, pipInner)
}
return bm
}
// ---------------------------------------------------------------------------
// OSM Attribution badge
// ---------------------------------------------------------------------------
@Composable
fun OsmAttributionBadge(modifier: Modifier = Modifier) {
val uriHandler = LocalUriHandler.current
val text = stringResource(R.string.osm_attribution_short)
Surface(
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.85f),
modifier = modifier
.clickable {
runCatching { uriHandler.openUri("https://www.openstreetmap.org/copyright") }
}
) {
Text(
text = text,
style = MaterialTheme.typography.labelSmall.copy(textDecoration = TextDecoration.Underline),
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 3.dp)
)
}
}
/**
* Finds the index of the nearest track point to [p] in screen-pixel space.
* Returns null if no point is within [thresholdPx] pixels of the press.
*/
private fun nearestPointWithinPixels(
mv: MapView,
points: List<GpsTrackPoint>,
p: GeoPoint,
thresholdPx: Double
): Int? {
if (points.isEmpty()) return null
val proj = mv.projection
val pressPx = proj.toPixels(p, null)
var bestIdx = -1
var bestDist = Double.MAX_VALUE
for ((idx, tp) in points.withIndex()) {
val gp = GeoPoint(tp.latitude, tp.longitude)
val px = proj.toPixels(gp, null)
val dx = (px.x - pressPx.x).toDouble()
val dy = (px.y - pressPx.y).toDouble()
val d = sqrt(dx * dx + dy * dy)
if (d < bestDist) {
bestDist = d
bestIdx = idx
}
}
return if (bestIdx >= 0 && bestDist <= thresholdPx) bestIdx else null
}
private fun draftDistanceMeters(points: List<GpsTrackPoint>): Double {
if (points.size < 2) return 0.0
val r = 6_371_000.0
var total = 0.0
for (i in 1 until points.size) {
val a = points[i - 1]; val b = points[i]
val dLat = Math.toRadians(b.latitude - a.latitude)
val dLon = Math.toRadians(b.longitude - a.longitude)
val s = sin(dLat / 2).pow(2) +
cos(Math.toRadians(a.latitude)) * cos(Math.toRadians(b.latitude)) *
sin(dLon / 2).pow(2)
total += 2 * r * atan2(sqrt(s), sqrt(1 - s))
}
return total
}
fun formatDistanceMeters(points: List<GpsTrackPoint>): String {
val d = draftDistanceMeters(points)
return if (d >= 1000) "%.1f km".format(d / 1000.0) else "${d.toInt()} m"
}
@@ -0,0 +1,213 @@
package de.waypointaudio.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.BookmarkAdded
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Map
import androidx.compose.material.icons.filled.Timeline
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import de.waypointaudio.R
import de.waypointaudio.viewmodel.WaypointViewModel
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* Archiv aller aufgezeichneten Track-Drafts.
*
* Jede Zeile zeigt Name, Quell-Tour, Anzahl Punkte/Markierungen, Distanz
* und das letzte Bearbeitungsdatum. Tap → öffnet den Karten-Editor.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TrackDraftListScreen(
viewModel: WaypointViewModel,
onOpenDraft: (String) -> Unit,
onNavigateBack: () -> Unit
) {
val drafts by viewModel.trackDrafts.collectAsState()
val isRecording by viewModel.isRecording.collectAsState()
Scaffold(
topBar = {
// v2.5.1 — TopAppBar im Surface-Stil, passend zur neuen HomeScreen.
TopAppBar(
title = { Text(stringResource(R.string.draft_list_title)) },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.back)
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
titleContentColor = MaterialTheme.colorScheme.onSurface,
navigationIconContentColor = MaterialTheme.colorScheme.primary,
actionIconContentColor = MaterialTheme.colorScheme.primary
)
)
},
containerColor = MaterialTheme.colorScheme.background
) { padding ->
Box(modifier = Modifier.fillMaxSize().padding(padding)) {
if (drafts.isEmpty()) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxSize().padding(24.dp)
) {
Icon(
Icons.Filled.Timeline,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f)
)
Spacer(Modifier.padding(8.dp))
Text(
stringResource(R.string.draft_list_empty),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
)
}
} else {
LazyColumn(
contentPadding = androidx.compose.foundation.layout.PaddingValues(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
if (isRecording) {
item {
Card(
shape = RoundedCornerShape(14.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
),
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
) {
Text(
stringResource(R.string.draft_locked_while_recording),
modifier = Modifier.padding(14.dp),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onErrorContainer
)
}
}
}
items(drafts, key = { it.id }) { draft ->
// v2.5.1 — Card-Optik harmonisiert: weichere Cornern,
// surface-Container (statt SurfaceVariant) und leichte
// Erhebung, passend zu den HomeScreen-Tour-Cards.
Card(
modifier = Modifier
.fillMaxWidth()
.clickable { onOpenDraft(draft.id) },
shape = RoundedCornerShape(14.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)
) {
Row(
modifier = Modifier.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Filled.Map,
contentDescription = null,
tint = MaterialTheme.colorScheme.tertiary
)
Spacer(Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
draft.name.ifBlank { stringResource(R.string.draft_unnamed) },
style = MaterialTheme.typography.titleMedium
)
val dateStr = SimpleDateFormat(
"dd.MM.yyyy HH:mm", Locale.getDefault()
).format(Date(draft.updatedAt))
Text(
stringResource(
R.string.draft_list_subtitle,
draft.sourceTourName,
dateStr
),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.padding(2.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
stringResource(
R.string.draft_list_points_dist,
draft.pointCount,
formatDistanceMeters(draft.points)
),
style = MaterialTheme.typography.bodySmall
)
if (draft.markedCount > 0) {
Spacer(Modifier.width(8.dp))
Icon(
Icons.Filled.BookmarkAdded,
contentDescription = null,
modifier = Modifier.size(14.dp),
tint = androidx.compose.ui.graphics.Color(0xFFFFAB00)
)
Spacer(Modifier.width(2.dp))
Text(
stringResource(R.string.draft_list_marked_count, draft.markedCount),
style = MaterialTheme.typography.bodySmall
)
}
}
}
IconButton(onClick = { onOpenDraft(draft.id) }) {
Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.map_waypoint_edit))
}
IconButton(onClick = { viewModel.deleteDraft(draft.id) }) {
Icon(
Icons.Filled.Delete,
contentDescription = stringResource(R.string.delete),
tint = MaterialTheme.colorScheme.error
)
}
}
}
}
}
}
}
}
}
@@ -0,0 +1,118 @@
package de.waypointaudio.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Checkbox
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import de.waypointaudio.R
import de.waypointaudio.data.VersionNoticeStore
import de.waypointaudio.util.AppVersion
import kotlinx.coroutines.launch
/**
* Zeigt nach App-Start einen Hinweis-Dialog für die aktuell installierte Version.
*
* Verhalten:
* - Sichtbar, solange `dismissedVersionCode < aktueller versionCode`.
* - Nutzer kann „Für diese Version nicht mehr anzeigen“ aktivieren und mit OK schließen
* → der aktuelle versionCode wird gespeichert.
* - Wird OK ohne Häkchen gedrückt, bleibt der Dialog beim nächsten Start nochmals stehen.
* - Bei Versions-Update (höherer versionCode) erscheint der Dialog automatisch erneut.
*/
@Composable
fun VersionNoticeDialogHost() {
val context = LocalContext.current
val store = remember(context) { VersionNoticeStore(context.applicationContext) }
val versionInfo = remember(context) { AppVersion.get(context) }
val dismissedCode by store.dismissedVersionCode.collectAsState(initial = -1L)
val scope = rememberCoroutineScope()
var visible by remember { mutableStateOf(false) }
var dontShowAgain by remember { mutableStateOf(false) }
// dismissedCode == -1L => initialer Wert (DataStore noch nicht geladen) -> warten
LaunchedEffect(dismissedCode, versionInfo.versionCode) {
if (dismissedCode >= 0L) {
visible = dismissedCode < versionInfo.versionCode
}
}
if (!visible) return
AlertDialog(
onDismissRequest = {
// Außerhalb des Dialogs tippen: nur schließen, nicht für künftige Starts ausblenden
visible = false
},
title = {
Text(
text = stringResource(R.string.version_notice_title),
style = MaterialTheme.typography.headlineSmall
)
},
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
text = stringResource(
R.string.version_notice_heading,
versionInfo.display
),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Text(
text = stringResource(R.string.version_notice_body_v2538),
style = MaterialTheme.typography.bodyMedium
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(top = 4.dp)
) {
Checkbox(
checked = dontShowAgain,
onCheckedChange = { dontShowAgain = it }
)
Text(
text = stringResource(R.string.version_notice_dont_show_again),
style = MaterialTheme.typography.bodyMedium
)
}
}
},
confirmButton = {
TextButton(onClick = {
if (dontShowAgain) {
scope.launch {
store.setDismissedVersionCode(versionInfo.versionCode)
}
}
visible = false
}) {
Text(stringResource(R.string.version_notice_ok))
}
}
)
}
@@ -0,0 +1,141 @@
package de.waypointaudio.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.selection.selectable
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import de.waypointaudio.R
import de.waypointaudio.data.WakeLockMode
import de.waypointaudio.data.WakeLockSettings
import de.waypointaudio.data.WakeLockSettingsStore
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* Dialog zur Auswahl des Wake-Lock-Modus (Display aktiv halten).
*
* Zeigt drei Optionen und zeigt beim ersten Wechsel auf einen aktiven Modus
* einen einmaligen Akku-Hinweis. Der gewählte Modus wird sofort persistiert.
*/
@Composable
fun WakeLockSettingsDialog(onDismiss: () -> Unit) {
val context = LocalContext.current
val store = remember { WakeLockSettingsStore(context.applicationContext) }
val scope = remember { CoroutineScope(Dispatchers.Main) }
var settings by remember { mutableStateOf(WakeLockSettings()) }
var loaded by remember { mutableStateOf(false) }
var showBatteryHint by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
settings = store.currentSettings()
loaded = true
}
fun selectMode(newMode: WakeLockMode) {
if (newMode == settings.mode) return
val previousMode = settings.mode
settings = settings.copy(mode = newMode)
scope.launch {
withContext(Dispatchers.IO) {
store.setMode(newMode)
}
// Einmaliger Akku-Hinweis: nur beim erstmaligen Aktivieren (von NEVER zu aktiv)
if (newMode != WakeLockMode.NEVER &&
previousMode == WakeLockMode.NEVER &&
!settings.batteryHintShown
) {
showBatteryHint = true
withContext(Dispatchers.IO) {
store.markBatteryHintShown()
}
settings = settings.copy(batteryHintShown = true)
}
}
}
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.wake_lock_title)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
stringResource(R.string.wake_lock_description),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
if (loaded) {
WakeLockOption(
label = stringResource(R.string.wake_lock_mode_never),
selected = settings.mode == WakeLockMode.NEVER,
onClick = { selectMode(WakeLockMode.NEVER) }
)
WakeLockOption(
label = stringResource(R.string.wake_lock_mode_active_tour),
selected = settings.mode == WakeLockMode.ACTIVE_TOUR,
onClick = { selectMode(WakeLockMode.ACTIVE_TOUR) }
)
WakeLockOption(
label = stringResource(R.string.wake_lock_mode_foreground),
selected = settings.mode == WakeLockMode.APP_FOREGROUND,
onClick = { selectMode(WakeLockMode.APP_FOREGROUND) }
)
}
}
},
confirmButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.about_close)) }
}
)
if (showBatteryHint) {
AlertDialog(
onDismissRequest = { showBatteryHint = false },
title = { Text(stringResource(R.string.wake_lock_battery_hint_title)) },
text = { Text(stringResource(R.string.wake_lock_battery_hint_msg)) },
confirmButton = {
TextButton(onClick = { showBatteryHint = false }) {
Text(stringResource(R.string.wake_lock_battery_hint_ok))
}
}
)
}
}
@Composable
private fun WakeLockOption(
label: String,
selected: Boolean,
onClick: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.selectable(selected = selected, onClick = onClick)
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(selected = selected, onClick = onClick)
Text(label, style = MaterialTheme.typography.bodyMedium)
}
}
@@ -12,9 +12,14 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowDownward
import androidx.compose.material.icons.filled.ArrowUpward
import androidx.compose.material.icons.filled.AudioFile
import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material.icons.filled.SwapHoriz
import androidx.compose.material3.*
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.*
@@ -26,9 +31,14 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import de.waypointaudio.R
import de.waypointaudio.data.AudioClip
import de.waypointaudio.data.PlaybackMode
import de.waypointaudio.data.TourPlaybackDefaults
import de.waypointaudio.data.Waypoint
import de.waypointaudio.data.effectiveClips
import de.waypointaudio.data.withClips
import de.waypointaudio.license.LicenseFeatureAccess
import de.waypointaudio.license.LicenseStatusProvider
import java.text.SimpleDateFormat
import java.util.Date
@@ -69,11 +79,23 @@ fun WaypointEditDialog(
prefillTourName: String = Waypoint.DEFAULT_TOUR_NAME,
tourDefaults: TourPlaybackDefaults = TourPlaybackDefaults(),
onConfirm: (Waypoint) -> Unit,
onDismiss: () -> Unit
onDismiss: () -> Unit,
/**
* v2.5.15 — Optionaler Callback: kopiert den bearbeiteten Wegpunkt
* in eine andere Tour. Wenn null oder es keine alternative Tour gibt,
* wird die Aktion ausgeblendet. Der Aufrufer ist für ID-Neuvergabe
* und Persistenz zuständig (siehe [de.waypointaudio.viewmodel.WaypointViewModel.copyWaypointToTour]).
*/
onCopyToOtherTour: ((targetTour: String) -> Unit)? = null,
) {
val context = LocalContext.current
val isNew = waypoint == null
// v2.5.32 — Feature-Gate: Multi-Clip-Waypoints erfordern Profi-Zugang.
// Der Wert liest den Compose-State direkt, reagiert reaktiv auf Lizenzimport.
val licenseStatus = LicenseStatusProvider.status
val canMultiClip = LicenseFeatureAccess.canUseMultiClip(licenseStatus)
// Coordinate pre-fill: GPS position takes priority for new waypoints
val initialLat = when {
waypoint != null -> "%.7f".format(Locale.US, waypoint.latitude)
@@ -91,8 +113,18 @@ fun WaypointEditDialog(
var latStr by remember { mutableStateOf(initialLat) }
var lngStr by remember { mutableStateOf(initialLng) }
var radiusStr by remember { mutableStateOf(waypoint?.radiusMeters?.toInt()?.toString() ?: "50") }
var soundUri by remember { mutableStateOf(waypoint?.soundUri ?: "") }
var soundName by remember { mutableStateOf(waypoint?.soundName ?: "") }
// v2.5.12 — Multi-Clip-Editor.
// Initial aus effectiveClips() befüllen, sodass alte Wegpunkte ohne audioClips
// ihren Legacy-soundUri als ersten Clip vorfinden.
val clips = remember {
mutableStateListOf<AudioClip>().apply {
waypoint?.effectiveClips()?.let { addAll(it) }
}
}
// Index des Clips, der durch den nächsten Picker-Treffer ersetzt werden soll.
// null = ein neuer Clip wird angehängt.
var replaceTargetIndex by remember { mutableStateOf<Int?>(null) }
// Tour name: use existing waypoint's tour, or prefill for new, fallback to default
val initialTour = when {
@@ -144,7 +176,8 @@ fun WaypointEditDialog(
}
}
// Audio file picker
// Audio file picker — v2.5.12: erzeugt einen neuen Clip oder ersetzt
// die Audio-Quelle eines bestehenden Clips. Steuerung über [replaceTargetIndex].
val audioPickerLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument()
) { uri: Uri? ->
@@ -155,9 +188,31 @@ fun WaypointEditDialog(
android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION
)
}
soundUri = uri.toString()
soundName = uri.lastPathSegment?.substringAfterLast('/')?.substringAfterLast(':')
val pickedUri = uri.toString()
val pickedName = uri.lastPathSegment?.substringAfterLast('/')?.substringAfterLast(':')
?: uri.toString().takeLast(30)
val tgt = replaceTargetIndex
if (tgt != null && tgt in clips.indices) {
val old = clips[tgt]
clips[tgt] = old.copy(
uri = pickedUri,
displayFileName = pickedName,
// Titel nur dann automatisch übernehmen, wenn noch keiner gesetzt war
title = old.title.ifBlank { pickedName }
)
} else {
clips.add(
AudioClip(
title = pickedName,
uri = pickedUri,
displayFileName = pickedName,
isActive = true,
isPrimary = clips.isEmpty(),
orderIndex = clips.size
)
)
}
replaceTargetIndex = null
}
}
@@ -319,6 +374,23 @@ fun WaypointEditDialog(
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.55f)
)
// ---------------------------------------------------------------
// v2.5.16 — Wegpunkt kopieren (nur im Bearbeiten-Modus sichtbar)
// Direkt unter der Tour-Zuordnung, damit der Aktionsbereich
// ohne Scrollen auffindbar ist. Wird nur gezeigt, wenn der
// Aufrufer einen Copy-Callback liefert (Player/Playback nicht).
// ---------------------------------------------------------------
if (waypoint != null && onCopyToOtherTour != null) {
WaypointCopySection(
currentTour = tourName,
existingTours = existingTours,
onCopy = { target ->
onCopyToOtherTour(target)
onDismiss()
}
)
}
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
// Coordinates
@@ -362,33 +434,236 @@ fun WaypointEditDialog(
modifier = Modifier.fillMaxWidth()
)
// Audio file selection
OutlinedButton(
onClick = { audioPickerLauncher.launch(arrayOf("audio/*")) },
modifier = Modifier.fillMaxWidth()
) {
Icon(
Icons.Filled.AudioFile,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(Modifier.width(8.dp))
Text(stringResource(R.string.choose_sound))
// ---------------------------------------------------------------
// v2.5.12 — Audio-Clips (Mehrfach-Auswahl + Reihenfolge)
// v2.5.32 — Multi-Clip-Gate: Hauptclip (idx 0) immer frei,
// Zusatzclips (idx > 0) nur mit Profi-Zugang bearbeiten.
// ---------------------------------------------------------------
Text(
text = "Audio-Clips",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary
)
// v2.5.32 — Profi-Hinweis: nur anzeigen, wenn Zusatzclips vorhanden
// und kein Profi-Zugang. Daten werden nicht gelöscht.
if (!canMultiClip && clips.size > 1) {
Surface(
color = MaterialTheme.colorScheme.secondaryContainer,
shape = MaterialTheme.shapes.small,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = LicenseFeatureAccess.MSG_MULTI_CLIP,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp)
)
}
}
if (soundName.isNotBlank()) {
Text(
text = soundName,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
maxLines = 2
)
} else {
if (clips.isEmpty()) {
Text(
text = stringResource(R.string.no_sound_selected),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.45f)
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.55f)
)
} else {
clips.forEachIndexed { idx, clip ->
// v2.5.32: Zusatzclips (idx > 0) sind ohne Profi-Zugang
// lesbar (Daten bleiben erhalten), aber nicht bearbeitbar.
val isExtraClip = idx > 0
val clipEditable = !isExtraClip || canMultiClip
Surface(
tonalElevation = 1.dp,
shape = MaterialTheme.shapes.small,
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
// Kopfzeile: Position + (primary)-Marker
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = "Clip ${idx + 1}" + if (clip.isPrimary) " · Hauptclip" else "",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
modifier = Modifier.weight(1f)
)
if (clipEditable) {
// Reihenfolge nach oben / unten
IconButton(
onClick = {
if (idx > 0) {
val tmp = clips[idx - 1]
clips[idx - 1] = clips[idx]
clips[idx] = tmp
}
},
enabled = idx > 0
) {
Icon(
Icons.Filled.ArrowUpward,
contentDescription = "Nach oben",
modifier = Modifier.size(18.dp)
)
}
IconButton(
onClick = {
if (idx < clips.size - 1) {
val tmp = clips[idx + 1]
clips[idx + 1] = clips[idx]
clips[idx] = tmp
}
},
enabled = idx < clips.size - 1
) {
Icon(
Icons.Filled.ArrowDownward,
contentDescription = "Nach unten",
modifier = Modifier.size(18.dp)
)
}
}
}
// Titel-Feld
OutlinedTextField(
value = clip.title,
onValueChange = { newTitle ->
if (clipEditable) clips[idx] = clip.copy(title = newTitle)
},
label = { Text("Titel") },
placeholder = { Text(clip.displayFileName.ifBlank { "Audio-Clip" }) },
singleLine = true,
readOnly = !clipEditable,
modifier = Modifier.fillMaxWidth()
)
// Dateiname (Quelle) als kleiner Hinweis
if (clip.displayFileName.isNotBlank()) {
Text(
text = clip.displayFileName,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.55f),
maxLines = 2
)
} else if (!clip.hasAudio) {
Text(
text = "Keine Audio-Datei zugewiesen",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
if (clipEditable) {
// Aktions-Zeile
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
// Aktiv-Schalter
Text(
text = "Aktiv",
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.weight(1f)
)
Switch(
checked = clip.isActive,
onCheckedChange = { checked ->
clips[idx] = clip.copy(isActive = checked)
}
)
// Audio ersetzen
IconButton(onClick = {
replaceTargetIndex = idx
audioPickerLauncher.launch(arrayOf("audio/*"))
}) {
Icon(
Icons.Filled.SwapHoriz,
contentDescription = "Audio ersetzen",
modifier = Modifier.size(20.dp)
)
}
// Clip löschen
IconButton(onClick = {
clips.removeAt(idx)
}) {
Icon(
Icons.Filled.Delete,
contentDescription = "Clip löschen",
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.error
)
}
}
// Als Hauptclip markieren (nur sinnvoll, wenn noch nicht primary)
if (!clip.isPrimary && clip.hasAudio) {
TextButton(onClick = {
// Alle Clips zurücksetzen, dann diesen markieren
for (i in clips.indices) {
clips[i] = clips[i].copy(isPrimary = (i == idx))
}
}) {
Text(
text = "Als Hauptclip festlegen",
style = MaterialTheme.typography.bodySmall
)
}
}
} // end clipEditable
}
}
}
}
// "Neuen Clip hinzufügen" startet Picker im Anhängen-Modus.
// v2.5.32: Nur anzeigen wenn Profi-Zugang vorhanden ODER noch kein
// Clip (Hauptclip hinzufügen ist immer frei).
if (canMultiClip || clips.isEmpty()) {
OutlinedButton(
onClick = {
replaceTargetIndex = null
audioPickerLauncher.launch(arrayOf("audio/*"))
},
modifier = Modifier.fillMaxWidth()
) {
Icon(
Icons.Filled.Add,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(Modifier.width(8.dp))
Text(
if (clips.isEmpty()) stringResource(R.string.choose_sound)
else "Weiteren Clip hinzufügen"
)
}
} else if (clips.isNotEmpty()) {
// Hauptclip-Audio-Wechsel bleibt immer möglich (index 0)
OutlinedButton(
onClick = {
replaceTargetIndex = 0
audioPickerLauncher.launch(arrayOf("audio/*"))
},
modifier = Modifier.fillMaxWidth()
) {
Icon(
Icons.Filled.SwapHoriz,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(Modifier.width(8.dp))
Text("Hauptclip-Audio wechseln")
}
}
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
@@ -702,6 +977,9 @@ fun WaypointEditDialog(
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.55f)
)
}
// v2.5.16 — Kopiersektion wurde in den oberen Tour-Bereich
// verschoben und ist dort gut sichtbar.
}
},
confirmButton = {
@@ -738,26 +1016,36 @@ fun WaypointEditDialog(
maxPlayCountError || scheduleRangeError
if (!hasError) {
onConfirm(
(waypoint ?: Waypoint()).copy(
name = name.trim(),
latitude = lat!!,
longitude = lng!!,
radiusMeters = radius!!,
soundUri = soundUri,
soundName = soundName,
tourName = safeTourName,
// Playback rules
playbackMode = playbackMode,
maxPlayCount = if (playbackMode == PlaybackMode.LIMITED_COUNT) maxCount else null,
playCount = currentPlayCount,
scheduleEnabled = scheduleEnabled,
scheduleStartMillis = if (scheduleEnabled) scheduleStartMillis else null,
scheduleEndMillis = if (scheduleEnabled) scheduleEndMillis else null,
allowedStartMinutes = if (scheduleEnabled) allowedStartMinutes else null,
allowedEndMinutes = if (scheduleEnabled) allowedEndMinutes else null
)
// v2.5.12 — Clips über withClips() konsistent in audioClips +
// Legacy-soundUri/-soundName spiegeln. Filtere Clips ohne URI
// (z. B. wenn der User auf "+" geklickt, aber dann den Picker
// abgebrochen hat — solche Phantom-Clips entstehen hier zwar
// nicht, aber wir bleiben defensiv).
val cleanClips = clips.filter { it.hasAudio }
val base = (waypoint ?: Waypoint()).copy(
name = name.trim(),
latitude = lat!!,
longitude = lng!!,
radiusMeters = radius!!,
tourName = safeTourName,
// Playback rules
playbackMode = playbackMode,
maxPlayCount = if (playbackMode == PlaybackMode.LIMITED_COUNT) maxCount else null,
playCount = currentPlayCount,
scheduleEnabled = scheduleEnabled,
scheduleStartMillis = if (scheduleEnabled) scheduleStartMillis else null,
scheduleEndMillis = if (scheduleEnabled) scheduleEndMillis else null,
allowedStartMinutes = if (scheduleEnabled) allowedStartMinutes else null,
allowedEndMinutes = if (scheduleEnabled) allowedEndMinutes else null
)
val withAudio = if (cleanClips.isEmpty()) {
// Keine Clips mehr — Legacy-Felder leeren, damit kein
// Geister-Audio aus alten Daten zurückbleibt.
base.copy(audioClips = emptyList(), soundUri = "", soundName = "")
} else {
base.withClips(cleanClips)
}
onConfirm(withAudio)
}
}) {
Text(stringResource(R.string.save))
@@ -770,3 +1058,107 @@ fun WaypointEditDialog(
}
)
}
/**
* v2.5.16 — Kopiersektion für den Bearbeiten-Dialog.
*
* Stellt zwei Aktionen bereit:
* - "In andere Tour kopieren" mit Drop-down-Auswahl (alle Touren außer der
* aktuellen).
* - "In dieser Tour duplizieren" als zweite Aktion. Hängt eine 1:1-Kopie
* ans Ende der aktuellen Tour. Auch nutzbar, wenn nur eine Tour existiert.
*
* Wird vom [WaypointEditDialog] nur eingebunden, wenn ein bestehender
* Wegpunkt bearbeitet wird und ein onCopyToOtherTour-Callback vorhanden ist
* — also nie in der Player-/Playback-Ansicht.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun WaypointCopySection(
currentTour: String,
existingTours: List<String>,
onCopy: (targetTour: String) -> Unit,
) {
val effectiveCurrent = currentTour.ifBlank { Waypoint.DEFAULT_TOUR_NAME }
val otherTours = remember(existingTours, effectiveCurrent) {
existingTours.filter { it.isNotBlank() && it != effectiveCurrent }
}
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
Text(
text = "Wegpunkt kopieren",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary
)
Text(
text = "Erzeugt eine eigenständige Kopie mit allen Clips. Das Original " +
"bleibt unverändert. Die Kopie wird ans Ende der Zieltour gehängt.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.65f)
)
if (otherTours.isNotEmpty()) {
var copyTargetMenuExpanded by remember { mutableStateOf(false) }
var copyTarget by remember(otherTours) { mutableStateOf(otherTours.first()) }
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
ExposedDropdownMenuBox(
expanded = copyTargetMenuExpanded,
onExpandedChange = { copyTargetMenuExpanded = it },
modifier = Modifier.weight(1f)
) {
OutlinedTextField(
value = copyTarget,
onValueChange = {},
readOnly = true,
label = { Text("Ziel-Tour") },
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(
expanded = copyTargetMenuExpanded
)
},
singleLine = true,
modifier = Modifier
.menuAnchor()
.fillMaxWidth()
)
ExposedDropdownMenu(
expanded = copyTargetMenuExpanded,
onDismissRequest = { copyTargetMenuExpanded = false }
) {
otherTours.forEach { t ->
DropdownMenuItem(
text = { Text(t) },
onClick = {
copyTarget = t
copyTargetMenuExpanded = false
}
)
}
}
}
FilledTonalButton(onClick = { onCopy(copyTarget) }) {
Text("Kopieren")
}
}
} else {
Text(
text = "Es ist noch keine weitere Tour vorhanden. Du kannst diesen " +
"Wegpunkt aber in der aktuellen Tour „$effectiveCurrent“ duplizieren.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.65f)
)
}
// Zweite Aktion: Duplizieren in derselben Tour. Immer verfügbar, auch
// wenn keine andere Tour existiert — deckt den Single-Tour-Fall ab.
OutlinedButton(
onClick = { onCopy(effectiveCurrent) },
modifier = Modifier.fillMaxWidth()
) {
Text("In dieser Tour duplizieren („$effectiveCurrent“)")
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,43 @@
package de.waypointaudio.util
import android.content.Context
import android.content.pm.PackageInfo
import android.content.pm.PackageManager
import android.os.Build
/**
* Liefert die aktuelle App-Version zur Laufzeit aus dem PackageManager.
* Damit muss die Anzeige bei Versions-Updates nicht mehr manuell gepflegt werden.
*/
data class AppVersionInfo(
val versionName: String,
val versionCode: Long
) {
/** Anzeige-Format, z. B. "1.5.2-version-dialog (17)". */
val display: String get() = "$versionName ($versionCode)"
}
object AppVersion {
fun get(context: Context): AppVersionInfo {
val pm = context.packageManager
val pkg = context.packageName
val info: PackageInfo = try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
pm.getPackageInfo(pkg, PackageManager.PackageInfoFlags.of(0))
} else {
@Suppress("DEPRECATION")
pm.getPackageInfo(pkg, 0)
}
} catch (_: PackageManager.NameNotFoundException) {
return AppVersionInfo(versionName = "?", versionCode = 0L)
}
val name = info.versionName ?: "?"
val code = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
info.longVersionCode
} else {
@Suppress("DEPRECATION")
info.versionCode.toLong()
}
return AppVersionInfo(versionName = name, versionCode = code)
}
}
@@ -0,0 +1,151 @@
package de.waypointaudio.util
import android.content.ActivityNotFoundException
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.PowerManager
import android.provider.Settings
import android.util.Log
/**
* v2.5.13 — Hintergrund-Zuverlässigkeit / Xiaomi-Hilfe.
*
* Auf Xiaomi/Redmi/Poco mit MIUI/HyperOS kann das System Apps zusätzlich
* zu den Standard-Akkuoptimierungen aggressiv beenden (Autostart-Block,
* fehlendes Recents-Lock, Battery-Saver-Restriktionen). Foreground-Service
* + WakeLock allein reichen dort nicht aus. Diese Utility bündelt die
* relevanten Abfragen und Intents, damit die UI dem Nutzer gezielt
* helfen kann, die richtigen Schalter zu finden — ohne dass die App
* selbst Systemeinstellungen ändert.
*/
object BackgroundReliability {
private const val TAG = "BgReliability"
/**
* True, wenn die App von Android-Akkuoptimierungen ausgenommen ist.
* Auf API-Leveln, auf denen [PowerManager.isIgnoringBatteryOptimizations]
* nicht verfügbar ist, geben wir true zurück (kein Hinweis nötig).
*/
fun isIgnoringBatteryOptimizations(context: Context): Boolean {
return try {
val pm = context.getSystemService(Context.POWER_SERVICE) as? PowerManager
?: return true
pm.isIgnoringBatteryOptimizations(context.packageName)
} catch (e: Throwable) {
Log.w(TAG, "isIgnoringBatteryOptimizations failed: ${e.message}")
true
}
}
/**
* Xiaomi/Redmi/Poco/HyperOS — case-insensitive Erkennung über
* Manufacturer und Brand. Wir behandeln alle drei Marken gleich,
* weil sie auf derselben MIUI-/HyperOS-Codebasis laufen.
*/
fun isXiaomiFamily(): Boolean {
val m = (Build.MANUFACTURER ?: "").lowercase()
val b = (Build.BRAND ?: "").lowercase()
return listOf("xiaomi", "redmi", "poco").any { tag ->
m.contains(tag) || b.contains(tag)
}
}
/**
* Versuche, den System-Dialog zur Aufhebung der Akkuoptimierung zu
* öffnen. Fallback: allgemeine Akku-Einstellungen, dann App-Details.
* Crasht nie, gibt false zurück, wenn nichts gestartet werden konnte.
*/
fun openBatteryOptimizationSettings(context: Context): Boolean {
// Direkter Dialog (zeigt den App-spezifischen Whitelist-Toggle).
val direct = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
data = Uri.parse("package:${context.packageName}")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
if (tryStart(context, direct)) return true
// Fallback 1: Liste aller Apps mit Akkuoptimierungs-Status.
val list = Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
if (tryStart(context, list)) return true
// Fallback 2: App-Details (Akku-Sektion ist von dort erreichbar).
return openAppDetailsSettings(context)
}
/** Öffnet die App-Detail-Einstellungen. */
fun openAppDetailsSettings(context: Context): Boolean {
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.parse("package:${context.packageName}")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
return tryStart(context, intent)
}
/**
* Öffnet die App-Benachrichtigungseinstellungen (API 26+). Fallback:
* App-Details. Auf älteren API-Leveln direkt App-Details.
*/
fun openNotificationSettings(context: Context): Boolean {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
if (tryStart(context, intent)) return true
}
return openAppDetailsSettings(context)
}
/**
* Versuch, die MIUI-Autostart-Verwaltung zu öffnen. Diese Intents sind
* NICHT Teil der öffentlichen Android-API und können auf jeder HyperOS-/
* MIUI-Version verschwinden oder anders heißen. Deshalb:
* - Wir probieren mehrere bekannte Komponenten der Reihe nach,
* - fallen still auf die App-Details zurück, wenn nichts greift,
* - und werfen nie eine Exception nach außen.
*/
fun openXiaomiAutostartSettings(context: Context): Boolean {
val candidates = listOf(
ComponentName(
"com.miui.securitycenter",
"com.miui.permcenter.autostart.AutoStartManagementActivity"
),
ComponentName(
"com.miui.securitycenter",
"com.miui.appmanager.ApplicationsDetailsActivity"
),
ComponentName(
"com.miui.securitycenter",
"com.miui.powercenter.PowerSettings"
)
)
for (cmp in candidates) {
val intent = Intent().apply {
component = cmp
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
if (tryStart(context, intent)) return true
}
return openAppDetailsSettings(context)
}
private fun tryStart(context: Context, intent: Intent): Boolean {
return try {
context.startActivity(intent)
true
} catch (_: ActivityNotFoundException) {
false
} catch (e: SecurityException) {
Log.w(TAG, "startActivity blocked: ${e.message}")
false
} catch (e: Throwable) {
Log.w(TAG, "startActivity unexpected: ${e.message}")
false
}
}
}
@@ -0,0 +1,631 @@
package de.waypointaudio.util
import android.content.Context
import android.util.Log
import de.waypointaudio.data.PoiCategory
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.net.HttpURLConnection
import java.net.URL
import java.net.URLEncoder
import kotlin.math.*
/**
* POI-Untertyp — v2.5.45.
*
* Abgeleitet aus OSM-Tags (amenity, tourism, historic, railway, highway …).
* Wird für Marker-Piktogramm-Varianten und Detail-Sheet genutzt.
* KEIN Persistenzmodell-Einfluss: nur In-Memory/Runtime.
*
* Für SIGHTS detailliert (Burg, Kirche, Museum, Aussicht, Denkmal …);
* für GASTRONOMY (Restaurant, Café, Bar, Schnellimbiss);
* für MOBILITY und INFRASTRUCTURE: wichtigste Typen.
*/
enum class PoiSubtype {
// SIGHTS
CASTLE, // historic=castle|palace|fort
RUIN, // historic=ruins|archaeological_site
CHURCH, // amenity=place_of_worship + religion=christian | historic=church|chapel|monastery
MONASTERY, // historic=monastery
MUSEUM, // tourism=museum
VIEWPOINT, // tourism=viewpoint
MONUMENT, // historic=monument|memorial
GALLERY, // tourism=gallery|artwork
THEATRE, // amenity=theatre|cinema
ZOO, // tourism=zoo|theme_park
ATTRACTION, // tourism=attraction (generic)
HISTORIC_OTHER, // historic=* (other)
// GASTRONOMY
RESTAURANT, // amenity=restaurant
CAFE, // amenity=cafe
BAR, // amenity=bar|pub|biergarten
FAST_FOOD, // amenity=fast_food|food_court|ice_cream
// MOBILITY
PARKING, // amenity=parking
BUS_STOP, // highway=bus_stop
TRAIN_STATION, // railway=station|halt
TRAM_STOP, // railway=tram_stop
FUEL, // amenity=fuel|charging_station
// INFRASTRUCTURE
PHARMACY, // amenity=pharmacy
HOSPITAL, // amenity=hospital|clinic
TOILETS, // amenity=toilets
DRINKING_WATER, // amenity=drinking_water
// Generic fallback per category
GENERIC_SIGHTS,
GENERIC_GASTRONOMY,
GENERIC_MOBILITY,
GENERIC_INFRASTRUCTURE;
/** Menschenlesbarer Label für das Detail-Sheet. */
val displayLabel: String get() = when (this) {
CASTLE -> "Burg/Schloss"
RUIN -> "Ruine/Archäologie"
CHURCH -> "Kirche/Gotteshaus"
MONASTERY -> "Kloster"
MUSEUM -> "Museum"
VIEWPOINT -> "Aussichtspunkt"
MONUMENT -> "Denkmal/Gedenkstätte"
GALLERY -> "Galerie/Kunstwerk"
THEATRE -> "Theater/Kino"
ZOO -> "Zoo/Freizeitpark"
ATTRACTION -> "Sehenswürdigkeit"
HISTORIC_OTHER -> "Historisch"
RESTAURANT -> "Restaurant"
CAFE -> "Café"
BAR -> "Bar/Pub"
FAST_FOOD -> "Schnellimbiss"
PARKING -> "Parkplatz"
BUS_STOP -> "Bushaltestelle"
TRAIN_STATION -> "Bahnhof"
TRAM_STOP -> "Straßenbahnhaltestelle"
FUEL -> "Tankstelle/Ladestation"
PHARMACY -> "Apotheke"
HOSPITAL -> "Krankenhaus/Klinik"
TOILETS -> "Toiletten"
DRINKING_WATER -> "Trinkwasser"
GENERIC_SIGHTS -> "Sehenswürdigkeit"
GENERIC_GASTRONOMY -> "Gastronomie"
GENERIC_MOBILITY -> "Mobilität"
GENERIC_INFRASTRUCTURE -> "Infrastruktur"
}
}
/**
* Ein Explorer-POI aus Overpass/OpenStreetMap.
*
* v2.5.40 — Erweitertes Datenmodell gegenüber [OverpassPoi]:
* - osmId / osmType für Attribution/Detail-Link
* - rawTags für das POI-Detail-Sheet
* - category aus [PoiCategory]
*
* v2.5.45 — Subtype-Feld ergänzt (kein Backup-/Persistenzmodell-Einfluss).
*/
data class ExplorerPoiResult(
val osmId: Long,
val osmType: String, // "node", "way", "relation"
val displayName: String,
val category: PoiCategory,
val categoryLabel: String, // lesbarer Label wie "Restaurant", "Museum" …
val subtype: PoiSubtype = PoiSubtype.GENERIC_SIGHTS, // v2.5.45
val latitude: Double,
val longitude: Double,
val rawTags: Map<String, String> = emptyMap(),
)
/** Einfacher Bounding-Box-Typ für die Overpass-Abfrage. */
data class LatLngBbox(
val south: Double,
val west: Double,
val north: Double,
val east: Double,
)
/**
* Explorer-Overpass-Client — v2.5.45 (Explorer POI Autoload).
*
* Rate-Limit-Strategie:
* - Automatisches Polling auf explizite Nutzerwahl — standardmäßig aktiviert,
* aber kontrolliert:
* • Debounce 1.5 s nach Kartenbewegung
* • Nur wenn POI-Layer aktiv
* • Mindest-Zoom ≥ 12 (kein Auto-Laden bei sehr weitem Ausschnitt)
* • Cache-basiert: neue Anfrage nur bei deutlicher Bewegung (> MOVE_THRESHOLD_M)
* oder Zoom-/Kategorie-Wechsel
* • Max-Radius ≤ 5 km (wie bisher)
* - Manueller Lade-Button als Fallback bleibt erhalten.
* - In-Memory-Cache je Kategorie + BBox-Fingerprint.
* - Overpass timeout auf 25 s gesetzt; max. 100 Ergebnisse je Kategorie.
*
* v2.5.45 — Subtype-Auflösung aus OSM-Tags (kein Persistenzmodell-Einfluss).
*
* OSM-Tags je Kategorie: (unverändert zu v2.5.44)
*
* SIGHTS: tourism=attraction|viewpoint|museum|gallery|artwork|zoo|theme_park,
* historic=*, amenity=theatre|arts_centre|cinema|place_of_worship
* GASTRONOMY: amenity=restaurant|cafe|bar|pub|fast_food|ice_cream|biergarten|food_court
* MOBILITY: amenity=parking|bicycle_parking|fuel|charging_station|bus_station|ferry_terminal,
* highway=bus_stop, railway=station|halt|tram_stop,
* public_transport=station|platform, aeroway=aerodrome
* INFRASTRUCTURE: amenity=toilets|hospital|clinic|pharmacy|bank|atm|post_office|
* police|fire_station|library|drinking_water|shelter
*
* Attribution: POI-Daten © OpenStreetMap-Mitwirkende, ODbL.
* Overpass API: overpass-api.de
*/
object ExplorerPoiClient {
private const val TAG = "ExplorerPoiClient"
private const val OVERPASS_URL = "https://overpass-api.de/api/interpreter"
private const val MAX_RADIUS_M = 5000
// v2.5.46 — Auto-Load-Parameter (erhöhte Schwellen für weniger Flackern)
/** Minimaler Zoom für automatisches Laden (unter diesem Zoom: kein Auto-Load). */
const val AUTO_LOAD_MIN_ZOOM = 12.0
/** Mindest-Kartenbewegung in Metern, bevor ein neuer Auto-Load ausgelöst wird.
* v2.5.46: erhöht von 300 m auf 600 m, um überflüssige Nachlades bei kleinen
* Kamerabewegungen (Kippen, Heading-Änderungen, kleiner Zoom) zu vermeiden. */
const val MOVE_THRESHOLD_M = 600.0
// In-Memory-Cache: key = "kategorie|bboxFingerprint"
private val cache = mutableMapOf<String, List<ExplorerPoiResult>>()
/** Cache leeren — z. B. beim Verlassen des Explorers oder bei Kategorie-Wechsel. */
fun clearCache() = cache.clear()
/**
* Prüft ob ein Auto-Load sinnvoll ist.
* Gibt true zurück wenn:
* - Mindestens ein POI-Layer aktiv ist
* - Zoom ≥ AUTO_LOAD_MIN_ZOOM
* - Karte hat sich um > MOVE_THRESHOLD_M vom letzten Auto-Load-Zentrum entfernt
* ODER Zoom hat sich um > 0.8 verändert ODER Kategorien haben sich geändert.
*/
fun shouldAutoLoad(
zoom: Double,
centerLat: Double,
centerLon: Double,
lastAutoLat: Double?,
lastAutoLon: Double?,
lastAutoZoom: Double?,
lastAutoCategories: Set<PoiCategory>?,
activeCategories: Set<PoiCategory>,
): Boolean {
if (activeCategories.isEmpty()) return false
if (zoom < AUTO_LOAD_MIN_ZOOM) return false
if (lastAutoLat == null || lastAutoLon == null || lastAutoZoom == null || lastAutoCategories == null) return true
if (activeCategories != lastAutoCategories) return true
val moved = haversineM(centerLat, centerLon, lastAutoLat, lastAutoLon)
// v2.5.46: Zoom-Delta-Schwelle erhöht auf 1.2, damit minimales Reinzoomen
// (z. B. Pitch-Änderung oder sehr kleiner Zoom) keinen Reload auslöst.
val zoomDelta = kotlin.math.abs(zoom - lastAutoZoom)
return moved > MOVE_THRESHOLD_M || zoomDelta > 1.2
}
/**
* Lädt POIs für [categories] im Kartenausschnitt [bbox].
*
* Wenn der Kartenausschnitt zu groß ist (Radius der BBox > [MAX_RADIUS_M]),
* wird auf den Mittelpunkt + MAX_RADIUS_M begrenzt und [onRadiusClamped] aufgerufen.
*
* @return Map von Kategorie → Liste von POIs.
* @throws RuntimeException bei Netzwerkfehler oder HTTP-Fehlercode.
*/
suspend fun loadPois(
context: Context,
bbox: LatLngBbox,
categories: Set<PoiCategory>,
onRadiusClamped: () -> Unit = {},
): Map<PoiCategory, List<ExplorerPoiResult>> = withContext(Dispatchers.IO) {
if (categories.isEmpty()) return@withContext emptyMap()
val version = AppVersion.get(context).versionName
val userAgent = "GPS2Audio/$version (Android; de.waypointaudio; explorer-poi)"
// Prüfe ob BBox zu groß ist; wenn ja → Radius-Modus
val centerLat = (bbox.south + bbox.north) / 2.0
val centerLon = (bbox.west + bbox.east) / 2.0
val halfDiagM = haversineM(bbox.south, bbox.west, bbox.north, bbox.east) / 2.0
val effectiveRadius = halfDiagM.coerceAtMost(MAX_RADIUS_M.toDouble()).toInt()
if (halfDiagM > MAX_RADIUS_M) {
withContext(Dispatchers.Main) { onRadiusClamped() }
}
val result = mutableMapOf<PoiCategory, List<ExplorerPoiResult>>()
for (cat in categories) {
val cacheKey = "${cat.key}|${centerLat.toBits()}|${centerLon.toBits()}|$effectiveRadius"
val cached = cache[cacheKey]
if (cached != null) {
result[cat] = cached
continue
}
val pois = fetchCategory(
cat, centerLat, centerLon, effectiveRadius, userAgent
)
cache[cacheKey] = pois
result[cat] = pois
}
// v2.5.44 — Logcat-Zusammenfassung je Kategorie (intern, nicht störend)
val sightsCount = result[PoiCategory.SIGHTS]?.size ?: 0
val gastroCount = result[PoiCategory.GASTRONOMY]?.size ?: 0
val mobilCount = result[PoiCategory.MOBILITY]?.size ?: 0
val infraCount = result[PoiCategory.INFRASTRUCTURE]?.size ?: 0
val total = sightsCount + gastroCount + mobilCount + infraCount
Log.i(TAG, "POI-Load: $total gesamt — " +
"${sightsCount} Sehenswürdigkeiten, " +
"${gastroCount} Gastronomie, " +
"${mobilCount} Mobilität, " +
"${infraCount} Infrastruktur")
result
}
/**
* Erzeugt eine menschenlesbare Zusammenfassung der geladenen POI-Anzahlen.
* Kann von der UI als Snackbar-Text verwendet werden.
*/
fun summarize(result: Map<PoiCategory, List<ExplorerPoiResult>>): String {
val parts = buildList {
result[PoiCategory.SIGHTS]?.size?.let { if (it > 0) add("$it Sehensw.") }
result[PoiCategory.GASTRONOMY]?.size?.let { if (it > 0) add("$it Gastro") }
result[PoiCategory.MOBILITY]?.size?.let { if (it > 0) add("$it Mobilität") }
result[PoiCategory.INFRASTRUCTURE]?.size?.let{ if (it > 0) add("$it Infra") }
}
val total = result.values.sumOf { it.size }
return if (parts.isEmpty()) "Keine POIs gefunden"
else "POIs: $total geladen (${parts.joinToString(", ")})"
}
private suspend fun fetchCategory(
category: PoiCategory,
centerLat: Double,
centerLon: Double,
radiusM: Int,
userAgent: String,
): List<ExplorerPoiResult> {
val overpassQuery = buildOverpassQuery(category, centerLat, centerLon, radiusM)
val encoded = URLEncoder.encode(overpassQuery, "UTF-8")
val url = URL("$OVERPASS_URL?data=$encoded")
val conn = (url.openConnection() as HttpURLConnection).apply {
connectTimeout = 15_000
readTimeout = 30_000
requestMethod = "GET"
setRequestProperty("User-Agent", userAgent)
setRequestProperty("Accept", "application/json")
}
try {
val code = conn.responseCode
if (code !in 200..299) throw RuntimeException("HTTP $code für ${category.key}")
val body = conn.inputStream.bufferedReader(Charsets.UTF_8).use { it.readText() }
return parseOverpassResponse(body, category)
} finally {
conn.disconnect()
}
}
private fun buildOverpassQuery(
category: PoiCategory,
lat: Double,
lon: Double,
radiusM: Int,
): String {
val around = "around:$radiusM,$lat,$lon"
val filters = categoryFilters(category)
val unionParts = filters.joinToString("\n") { (key, value) ->
if (value == "*") {
""" node[$key]($around);
way[$key]($around);
relation[$key]($around);"""
} else {
""" node[$key=$value]($around);
way[$key=$value]($around);
relation[$key=$value]($around);"""
}
}
return """[out:json][timeout:25];
(
$unionParts
);
out center 100;""".trimIndent()
}
private fun categoryFilters(cat: PoiCategory): List<Pair<String, String>> = when (cat) {
PoiCategory.SIGHTS -> listOf(
"tourism" to "attraction",
"tourism" to "viewpoint",
"tourism" to "museum",
"tourism" to "gallery",
"tourism" to "artwork",
"tourism" to "zoo",
"tourism" to "theme_park",
"historic" to "*",
"amenity" to "theatre",
"amenity" to "arts_centre",
"amenity" to "cinema",
"amenity" to "place_of_worship",
)
PoiCategory.GASTRONOMY -> listOf(
"amenity" to "restaurant",
"amenity" to "cafe",
"amenity" to "bar",
"amenity" to "pub",
"amenity" to "fast_food",
"amenity" to "ice_cream",
"amenity" to "biergarten",
"amenity" to "food_court",
)
PoiCategory.MOBILITY -> listOf(
"amenity" to "parking",
"amenity" to "bicycle_parking",
"amenity" to "fuel",
"amenity" to "charging_station",
"amenity" to "bus_station",
"amenity" to "ferry_terminal",
"highway" to "bus_stop",
"railway" to "station",
"railway" to "halt",
"railway" to "tram_stop",
"public_transport" to "station",
"public_transport" to "platform",
"aeroway" to "aerodrome",
)
PoiCategory.INFRASTRUCTURE -> listOf(
"amenity" to "toilets",
"amenity" to "hospital",
"amenity" to "clinic",
"amenity" to "pharmacy",
"amenity" to "bank",
"amenity" to "atm",
"amenity" to "post_office",
"amenity" to "police",
"amenity" to "fire_station",
"amenity" to "library",
"amenity" to "drinking_water",
"amenity" to "shelter",
)
}
private fun parseOverpassResponse(
json: String,
category: PoiCategory,
): List<ExplorerPoiResult> {
val root = JSONObject(json)
val elements = root.optJSONArray("elements") ?: return emptyList()
val out = ArrayList<ExplorerPoiResult>(elements.length())
val seen = HashSet<Long>()
for (i in 0 until elements.length()) {
val el = elements.optJSONObject(i) ?: continue
val osmId = el.optLong("id", -1L)
val osmType = el.optString("type", "node")
if (osmId < 0) continue
if (!seen.add(osmId)) continue
val lat = el.optDouble("lat", Double.NaN).let {
if (it.isNaN()) el.optJSONObject("center")?.optDouble("lat", Double.NaN) ?: Double.NaN
else it
}
val lon = el.optDouble("lon", Double.NaN).let {
if (it.isNaN()) el.optJSONObject("center")?.optDouble("lon", Double.NaN) ?: Double.NaN
else it
}
if (lat.isNaN() || lon.isNaN()) continue
val tags = el.optJSONObject("tags") ?: continue
val tagsMap = buildMap {
val keys = tags.keys()
while (keys.hasNext()) {
val k = keys.next()
put(k, tags.optString(k))
}
}
val catLabel = resolveCategoryLabel(tagsMap, category)
val subtype = resolveSubtype(tagsMap, category) // v2.5.45
val name = tagsMap["name"]?.takeIf { it.isNotBlank() }
?: tagsMap["ref"]?.takeIf { it.isNotBlank() }
?: humanizeTag(tagsMap, category)
?: continue
out.add(
ExplorerPoiResult(
osmId = osmId,
osmType = osmType,
displayName = name,
category = category,
categoryLabel = catLabel,
subtype = subtype,
latitude = lat,
longitude = lon,
rawTags = tagsMap,
)
)
}
return out
}
/**
* v2.5.45 — Subtype aus OSM-Tags ableiten (In-Memory only, kein Backup-Einfluss).
*/
private fun resolveSubtype(tags: Map<String, String>, cat: PoiCategory): PoiSubtype {
val amenity = tags["amenity"]
val tourism = tags["tourism"]
val historic = tags["historic"]
val railway = tags["railway"]
val highway = tags["highway"]
return when (cat) {
PoiCategory.SIGHTS -> when {
historic == "castle" || historic == "palace" || historic == "fort" -> PoiSubtype.CASTLE
historic == "ruins" || historic == "archaeological_site" -> PoiSubtype.RUIN
historic == "monastery" -> PoiSubtype.MONASTERY
historic == "church" -> PoiSubtype.CHURCH
historic == "monument" || historic == "memorial" -> PoiSubtype.MONUMENT
historic != null -> PoiSubtype.HISTORIC_OTHER
amenity == "place_of_worship" -> PoiSubtype.CHURCH
tourism == "museum" -> PoiSubtype.MUSEUM
tourism == "viewpoint" -> PoiSubtype.VIEWPOINT
tourism == "gallery" || tourism == "artwork" -> PoiSubtype.GALLERY
tourism == "zoo" || tourism == "theme_park" -> PoiSubtype.ZOO
amenity == "theatre" || amenity == "cinema" || amenity == "arts_centre" -> PoiSubtype.THEATRE
tourism == "attraction" -> PoiSubtype.ATTRACTION
else -> PoiSubtype.GENERIC_SIGHTS
}
PoiCategory.GASTRONOMY -> when (amenity) {
"restaurant" -> PoiSubtype.RESTAURANT
"cafe" -> PoiSubtype.CAFE
"bar", "pub", "biergarten" -> PoiSubtype.BAR
"fast_food", "food_court", "ice_cream" -> PoiSubtype.FAST_FOOD
else -> PoiSubtype.GENERIC_GASTRONOMY
}
PoiCategory.MOBILITY -> when {
amenity == "parking" || amenity == "bicycle_parking" -> PoiSubtype.PARKING
highway == "bus_stop" || amenity == "bus_station" -> PoiSubtype.BUS_STOP
railway == "station" || railway == "halt" -> PoiSubtype.TRAIN_STATION
railway == "tram_stop" -> PoiSubtype.TRAM_STOP
amenity == "fuel" || amenity == "charging_station" -> PoiSubtype.FUEL
else -> PoiSubtype.GENERIC_MOBILITY
}
PoiCategory.INFRASTRUCTURE -> when (amenity) {
"pharmacy" -> PoiSubtype.PHARMACY
"hospital", "clinic" -> PoiSubtype.HOSPITAL
"toilets" -> PoiSubtype.TOILETS
"drinking_water" -> PoiSubtype.DRINKING_WATER
else -> PoiSubtype.GENERIC_INFRASTRUCTURE
}
}
}
private fun resolveCategoryLabel(tags: Map<String, String>, cat: PoiCategory): String {
val amenity = tags["amenity"]
val tourism = tags["tourism"]
val historic = tags["historic"]
val railway = tags["railway"]
val highway = tags["highway"]
val publicTransport = tags["public_transport"]
val aeroway = tags["aeroway"]
if (cat != PoiCategory.SIGHTS) {
when (cat) {
PoiCategory.GASTRONOMY -> return when (amenity) {
"restaurant" -> "Restaurant"
"cafe" -> "Café"
"bar" -> "Bar"
"pub" -> "Pub"
"fast_food" -> "Schnellimbiss"
"ice_cream" -> "Eisdiele"
"biergarten" -> "Biergarten"
"food_court" -> "Foodcourt"
else -> "Gastronomie"
}
PoiCategory.MOBILITY -> return when {
amenity == "parking" -> "Parkplatz"
amenity == "bicycle_parking" -> "Fahrradstellplatz"
amenity == "fuel" -> "Tankstelle"
amenity == "charging_station" -> "E-Ladestation"
amenity == "bus_station" -> "Busbahnhof"
amenity == "ferry_terminal" -> "Fährterminal"
highway == "bus_stop" -> "Bushaltestelle"
railway == "station" -> "Bahnhof"
railway == "halt" -> "Haltepunkt"
railway == "tram_stop" -> "Straßenbahnhaltestelle"
publicTransport == "station" -> "ÖPNV-Station"
publicTransport == "platform" -> "Bahnsteig"
aeroway == "aerodrome" -> "Flughafen"
else -> "Mobilität"
}
PoiCategory.INFRASTRUCTURE -> return when (amenity) {
"toilets" -> "Toilette"
"hospital" -> "Krankenhaus"
"clinic" -> "Klinik"
"pharmacy" -> "Apotheke"
"bank" -> "Bank"
"atm" -> "Geldautomat"
"post_office" -> "Postamt"
"police" -> "Polizei"
"fire_station" -> "Feuerwehr"
"library" -> "Bibliothek"
"drinking_water"-> "Trinkwasser"
"shelter" -> "Unterstand"
else -> "Infrastruktur"
}
else -> { /* SIGHTS — weiter unten */ }
}
}
return when (cat) {
PoiCategory.SIGHTS -> when {
tourism == "attraction" -> "Sehenswürdigkeit"
tourism == "viewpoint" -> "Aussichtspunkt"
tourism == "museum" -> "Museum"
tourism == "gallery" -> "Galerie"
tourism == "artwork" -> "Kunstwerk"
tourism == "zoo" -> "Zoo"
tourism == "theme_park" -> "Freizeitpark"
historic != null -> humanizeHistoric(historic)
amenity == "theatre" -> "Theater"
amenity == "arts_centre" -> "Kulturzentrum"
amenity == "cinema" -> "Kino"
amenity == "place_of_worship" -> "Gotteshaus"
else -> "Sehenswürdigkeit"
}
PoiCategory.GASTRONOMY -> "Gastronomie"
PoiCategory.MOBILITY -> "Mobilität"
PoiCategory.INFRASTRUCTURE-> "Infrastruktur"
}
}
private fun humanizeTag(tags: Map<String, String>, cat: PoiCategory): String? {
return when (cat) {
PoiCategory.SIGHTS -> {
val label = resolveCategoryLabel(tags, cat)
when {
tags["historic"] != null -> label
tags["tourism"] != null -> label
tags["amenity"] in setOf(
"theatre","arts_centre","cinema","place_of_worship"
) -> label
else -> null
}
}
PoiCategory.GASTRONOMY -> resolveCategoryLabel(tags, cat)
PoiCategory.MOBILITY -> resolveCategoryLabel(tags, cat)
PoiCategory.INFRASTRUCTURE -> resolveCategoryLabel(tags, cat)
}
}
private fun humanizeHistoric(value: String): String = when (value) {
"castle" -> "Burg/Schloss"
"monument" -> "Denkmal"
"memorial" -> "Gedenkstätte"
"ruins" -> "Ruine"
"church" -> "Kirche"
"fort" -> "Festung"
"battlefield" -> "Schlachtfeld"
"archaeological_site" -> "Archäologische Stätte"
"manor" -> "Herrenhaus"
"wayside_cross" -> "Wegkreuz"
"wayside_shrine" -> "Wegschrein"
"city_gate" -> "Stadttor"
"locomotive" -> "Lokomotive (hist.)"
"aircraft" -> "Flugzeug (hist.)"
"ship" -> "Schiff (hist.)"
"monastery" -> "Kloster"
"palace" -> "Schloss/Palast"
"fort" -> "Festung"
else -> "Historisch"
}
/** Haversine-Distanz in Metern zwischen zwei LatLon-Punkten. */
fun haversineM(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double {
val r = 6_371_000.0
val dLat = Math.toRadians(lat2 - lat1)
val dLon = Math.toRadians(lon2 - lon1)
val a = sin(dLat / 2).pow(2) +
cos(Math.toRadians(lat1)) * cos(Math.toRadians(lat2)) * sin(dLon / 2).pow(2)
return 2 * r * asin(sqrt(a))
}
}
@@ -0,0 +1,75 @@
package de.waypointaudio.util
import android.content.Context
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONArray
import java.net.HttpURLConnection
import java.net.URL
import java.net.URLEncoder
/**
* Ein einzelnes Suchergebnis von Nominatim (OpenStreetMap).
*/
data class NominatimResult(
val displayName: String,
val latitude: Double,
val longitude: Double
)
/**
* Leichter Nominatim-Client (HttpURLConnection) für die Ortssuche im Editor.
*
* Hinweise:
* - Nutzt die öffentliche Nominatim-Instanz unter https://nominatim.openstreetmap.org/.
* - User-Agent enthält App-Name und Versionsnummer (Nominatim Usage Policy).
* - Limit auf 5 Ergebnisse, format=jsonv2.
* - Antwort wird off-UI-thread (IO-Dispatcher) abgeholt.
*/
object NominatimClient {
private const val BASE_URL = "https://nominatim.openstreetmap.org/search"
suspend fun search(context: Context, query: String): List<NominatimResult> = withContext(Dispatchers.IO) {
val q = query.trim()
if (q.isEmpty()) return@withContext emptyList()
val version = AppVersion.get(context).versionName
val userAgent = "GPS2Audio/$version"
val encoded = URLEncoder.encode(q, "UTF-8")
val url = URL("$BASE_URL?format=jsonv2&limit=5&q=$encoded")
val conn = (url.openConnection() as HttpURLConnection).apply {
connectTimeout = 10_000
readTimeout = 15_000
requestMethod = "GET"
setRequestProperty("User-Agent", userAgent)
setRequestProperty("Accept", "application/json")
setRequestProperty("Accept-Language", "de,en;q=0.7")
}
try {
val code = conn.responseCode
if (code !in 200..299) {
throw RuntimeException("HTTP $code")
}
val body = conn.inputStream.bufferedReader(Charsets.UTF_8).use { it.readText() }
parseResults(body)
} finally {
conn.disconnect()
}
}
private fun parseResults(json: String): List<NominatimResult> {
val arr = JSONArray(json)
val out = ArrayList<NominatimResult>(arr.length())
for (i in 0 until arr.length()) {
val obj = arr.optJSONObject(i) ?: continue
val name = obj.optString("display_name").orEmpty()
val latStr = obj.optString("lat")
val lonStr = obj.optString("lon")
val lat = latStr.toDoubleOrNull() ?: continue
val lon = lonStr.toDoubleOrNull() ?: continue
if (name.isBlank()) continue
out.add(NominatimResult(displayName = name, latitude = lat, longitude = lon))
}
return out
}
}
@@ -0,0 +1,164 @@
package de.waypointaudio.util
import android.content.Context
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONArray
import org.json.JSONObject
import java.net.HttpURLConnection
import java.net.URL
/**
* Ergebnis einer Routing-Abfrage.
*/
data class OsrmRouteResult(
/** Geometrie der Route als Liste von Lat/Lon-Paaren (lat zuerst). */
val polyline: List<Pair<Double, Double>>,
/** Gesamtdistanz in Metern. */
val distanceMeters: Double,
/** Gesamtdauer in Sekunden. */
val durationSeconds: Double,
)
/**
* Leichter OSRM-Client (v2.3.1, v2.4.4 URL-Normalisierung) für konfigurierbare Routing-Endpunkte.
*
* - Erwartet einen OSRM-kompatiblen `/route/v1/driving/...`-Endpunkt.
* - Self-hosted bevorzugt; ein optionaler Demo-Endpunkt (router.project-osrm.org)
* wird vom Nutzer explizit eingetragen — keine harte Default-URL im Code.
* - User-Agent enthält App-Name und Versionsnummer.
* - Liefert Geometrie als `geojson` (lon,lat-Punkte) — wir konvertieren in
* (lat,lon)-Paare für osmdroid-Polylines.
* - Auf fehlerhafte Antworten / 4xx/5xx wird eine RuntimeException geworfen,
* damit die UI „Routenberechnung fehlgeschlagen" anzeigen kann.
*
* v2.4.4: [normalizeBase] akzeptiert sowohl reine Basis-URL als auch eine
* bereits vollständige `/route/v1/driving`-Prefix-URL — beides wird intern
* auf die Basis-URL reduziert, damit `route()` immer genau einen
* `/route/v1/driving/`-Pfad anhängt (keine doppelten Prefixe).
*/
object OsrmRoutingClient {
/** Demo-OSRM-Server. Nur Test, Rate-Limits — siehe Nutzerhinweis. */
const val DEMO_ENDPOINT = "https://router.project-osrm.org"
/** v2.5.2 — Erlaubte Profilschlüssel im OSRM-Pfad. */
private val KNOWN_PROFILES = listOf("driving", "cycling", "walking", "foot", "bike", "car")
/**
* Normalisiert eine vom Nutzer eingegebene Endpunkt-URL auf die Basis-URL.
*
* - Trimmen, Whitespace entfernen.
* - Trailing slashes entfernen.
* - Falls die Eingabe bereits einen Pfad `/route/v1/<profil>` (mit oder ohne
* weitere Koordinaten) enthält, wird dieser Pfad entfernt, sodass die
* interne Aufrufkonstruktion ihn genau einmal anhängt — keine doppelten
* `/route/v1/...` mehr. Das gilt sowohl für `driving` (v2.4.4-Standard)
* als auch für `cycling`/`walking` (v2.5.2).
* - Liefert leeren String zurück, wenn die Eingabe leer/ungültig ist.
*/
fun normalizeBase(raw: String?): String {
if (raw.isNullOrBlank()) return ""
var s = raw.trim()
// v2.5.2: erkenne `/route/v1/<profil>` für alle gängigen Profile.
for (p in KNOWN_PROFILES) {
val marker = "/route/v1/$p"
val idx = s.indexOf(marker)
if (idx >= 0) {
s = s.substring(0, idx)
break
}
}
s = s.trimEnd('/')
return s
}
/**
* Berechnet eine Route zwischen [waypoints] (mindestens 2 Punkte).
*
* @param endpoint Basis-URL des OSRM-Servers (z. B. `https://router.project-osrm.org`).
* Wird intern via [normalizeBase] normalisiert — der Nutzer
* darf sowohl die reine Basis-URL als auch die vollständige
* `/route/v1/driving`-Variante hinterlegen.
*/
suspend fun route(
context: Context,
endpoint: String,
waypoints: List<Pair<Double, Double>>,
profile: String = "driving",
): OsrmRouteResult = withContext(Dispatchers.IO) {
require(waypoints.size >= 2) { "OSRM benötigt mindestens 2 Punkte." }
val base = normalizeBase(endpoint)
require(base.startsWith("http://") || base.startsWith("https://")) {
"Endpunkt muss mit http:// oder https:// beginnen."
}
// v2.5.2: Profil sicher whitelisten — falls jemand einen unbekannten
// String reicht, fallen wir hart auf `driving` zurück, damit das
// Routing nicht durch Tippfehler dauerhaft scheitert.
val safeProfile = when (profile.trim().lowercase()) {
"cycling", "bike", "bicycle" -> "cycling"
"walking", "foot", "pedestrian" -> "walking"
else -> "driving"
}
val version = AppVersion.get(context).versionName
val userAgent = "GPS2Audio/$version"
// OSRM erwartet lon,lat (nicht lat,lon).
val coords = waypoints.joinToString(";") { (lat, lon) -> "$lon,$lat" }
val url = URL("$base/route/v1/$safeProfile/$coords?overview=full&geometries=geojson")
val conn = (url.openConnection() as HttpURLConnection).apply {
connectTimeout = 12_000
readTimeout = 25_000
requestMethod = "GET"
setRequestProperty("User-Agent", userAgent)
setRequestProperty("Accept", "application/json")
}
try {
val code = conn.responseCode
if (code !in 200..299) throw RuntimeException("HTTP $code")
val body = conn.inputStream.bufferedReader(Charsets.UTF_8).use { it.readText() }
parseRoute(body)
} finally {
conn.disconnect()
}
}
private fun parseRoute(json: String): OsrmRouteResult {
val root = JSONObject(json)
val routes = root.optJSONArray("routes") ?: throw RuntimeException("Keine routes im OSRM-Response")
if (routes.length() == 0) throw RuntimeException("OSRM hat keine Route geliefert")
val first = routes.optJSONObject(0) ?: throw RuntimeException("OSRM-Route ungültig")
val distance = first.optDouble("distance", 0.0)
val duration = first.optDouble("duration", 0.0)
val geom = first.optJSONObject("geometry") ?: throw RuntimeException("Keine Geometrie")
val coords: JSONArray = geom.optJSONArray("coordinates")
?: throw RuntimeException("Geometrie hat keine coordinates")
val poly = ArrayList<Pair<Double, Double>>(coords.length())
for (i in 0 until coords.length()) {
val pair = coords.optJSONArray(i) ?: continue
// GeoJSON: [lon, lat]
val lon = pair.optDouble(0, Double.NaN)
val lat = pair.optDouble(1, Double.NaN)
if (lon.isNaN() || lat.isNaN()) continue
poly.add(lat to lon)
}
return OsrmRouteResult(polyline = poly, distanceMeters = distance, durationSeconds = duration)
}
/** Formatiert eine Distanz freundlich (z. B. „1,3 km" oder „420 m"). */
fun formatDistance(meters: Double): String {
return if (meters >= 1000) "%.1f km".format(meters / 1000.0)
else "%d m".format(meters.toInt())
}
/** Formatiert eine Dauer freundlich (z. B. „12 min", „1:23 h"). */
fun formatDuration(seconds: Double): String {
val total = seconds.toInt()
val h = total / 3600
val m = (total % 3600) / 60
return if (h > 0) "%d:%02d h".format(h, m) else "%d min".format(m.coerceAtLeast(1))
}
}
@@ -0,0 +1,159 @@
package de.waypointaudio.util
import android.content.Context
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.net.HttpURLConnection
import java.net.URL
import java.net.URLEncoder
/**
* Ein Online-POI-Treffer aus Overpass/OpenStreetMap.
*
* Bewusst minimal: Name, Kategorie/Tag, Lat/Lon. Die UI berechnet Distanz und
* formatiert weitere Felder.
*/
data class OverpassPoi(
val displayName: String,
val category: String,
val latitude: Double,
val longitude: Double,
)
/**
* Leichter, nutzergetriggerter Overpass-Client (v2.3.1) für die POI-Suche.
*
* - Nutzt die öffentliche Overpass-API (z. B. overpass-api.de). Bewusst KEIN
* Auto-Polling — nur explizite Suche durch den Nutzer.
* - User-Agent enthält App-Name und Versionsnummer (Nominatim/Overpass
* Usage Policy: contact identifier).
* - Antwort wird off-UI-thread (IO-Dispatcher) abgeholt.
* - Kategorien-Mapping: einfache Stichwortheuristik. Wenn der Nutzer ein
* konkretes OSM-amenity-Tag eintippt (z. B. `bakery`, `museum`, `bus_stop`),
* wird das direkt als Filter genutzt; sonst Volltextsuche im `name`-Tag.
* - Radius standardmäßig 1500 m um das Suchzentrum.
*/
object OverpassPoiClient {
private const val OVERPASS_URL = "https://overpass-api.de/api/interpreter"
/**
* Mapping bekannter deutscher Suchbegriffe auf OSM-amenity/shop/tourism-Werte.
* Wenn nichts trifft, wird `null` zurückgegeben und es erfolgt eine
* Volltextsuche über das `name`-Tag.
*/
private val keywordMap = mapOf(
"bäckerei" to "amenity=bakery",
"baeckerei" to "amenity=bakery",
"bakery" to "shop=bakery",
"café" to "amenity=cafe",
"cafe" to "amenity=cafe",
"restaurant" to "amenity=restaurant",
"supermarkt" to "shop=supermarket",
"supermarket" to "shop=supermarket",
"apotheke" to "amenity=pharmacy",
"pharmacy" to "amenity=pharmacy",
"bahnhof" to "railway=station",
"station" to "railway=station",
"bus" to "highway=bus_stop",
"bus_stop" to "highway=bus_stop",
"museum" to "tourism=museum",
"hotel" to "tourism=hotel",
"kirche" to "amenity=place_of_worship",
"church" to "amenity=place_of_worship",
"parkplatz" to "amenity=parking",
"parking" to "amenity=parking",
"toilette" to "amenity=toilets",
"toilet" to "amenity=toilets",
"spielplatz" to "leisure=playground",
"playground" to "leisure=playground",
"viewpoint" to "tourism=viewpoint",
"aussicht" to "tourism=viewpoint",
)
/**
* Führt eine POI-Suche um [centerLat]/[centerLon] mit Radius [radiusM]
* durch. Liefert max. 25 Treffer.
*
* @throws RuntimeException bei HTTP-Fehler oder ungültiger JSON-Antwort.
*/
suspend fun search(
context: Context,
query: String,
centerLat: Double,
centerLon: Double,
radiusM: Int = 1500,
): List<OverpassPoi> = withContext(Dispatchers.IO) {
val q = query.trim()
if (q.isEmpty()) return@withContext emptyList()
val version = AppVersion.get(context).versionName
val userAgent = "GPS2Audio/$version"
val keyEqVal = keywordMap[q.lowercase()]
val query =
if (keyEqVal != null) {
val (k, v) = keyEqVal.split("=", limit = 2).let { it[0] to it[1] }
"""[out:json][timeout:25];
(node[$k=$v](around:$radiusM,$centerLat,$centerLon);
way[$k=$v](around:$radiusM,$centerLat,$centerLon);
relation[$k=$v](around:$radiusM,$centerLat,$centerLon););
out center 25;""".trimIndent()
} else {
val safe = q.replace("\"", "")
"""[out:json][timeout:25];
(node[name~"$safe",i](around:$radiusM,$centerLat,$centerLon);
way[name~"$safe",i](around:$radiusM,$centerLat,$centerLon);
relation[name~"$safe",i](around:$radiusM,$centerLat,$centerLon););
out center 25;""".trimIndent()
}
val encoded = URLEncoder.encode(query, "UTF-8")
val url = URL("$OVERPASS_URL?data=$encoded")
val conn = (url.openConnection() as HttpURLConnection).apply {
connectTimeout = 12_000
readTimeout = 25_000
requestMethod = "GET"
setRequestProperty("User-Agent", userAgent)
setRequestProperty("Accept", "application/json")
setRequestProperty("Accept-Language", "de,en;q=0.7")
}
try {
val code = conn.responseCode
if (code !in 200..299) throw RuntimeException("HTTP $code")
val body = conn.inputStream.bufferedReader(Charsets.UTF_8).use { it.readText() }
parseElements(body)
} finally {
conn.disconnect()
}
}
private fun parseElements(json: String): List<OverpassPoi> {
val root = JSONObject(json)
val elements = root.optJSONArray("elements") ?: return emptyList()
val out = ArrayList<OverpassPoi>(elements.length())
for (i in 0 until elements.length()) {
val el = elements.optJSONObject(i) ?: continue
val lat = el.optDouble("lat", Double.NaN).let {
if (it.isNaN()) el.optJSONObject("center")?.optDouble("lat", Double.NaN) else it
}
val lon = el.optDouble("lon", Double.NaN).let {
if (it.isNaN()) el.optJSONObject("center")?.optDouble("lon", Double.NaN) else it
}
if (lat == null || lon == null || lat.isNaN() || lon.isNaN()) continue
val tags = el.optJSONObject("tags") ?: continue
val name = tags.optString("name").orEmpty().ifBlank {
tags.optString("ref").orEmpty()
}
if (name.isBlank()) continue
val category = listOf("amenity", "shop", "tourism", "leisure", "highway", "railway")
.mapNotNull { k ->
val v = tags.optString(k)
if (v.isNotBlank()) "$k=$v" else null
}
.firstOrNull() ?: "poi"
out.add(OverpassPoi(displayName = name, category = category, latitude = lat, longitude = lon))
}
return out
}
}
@@ -0,0 +1,164 @@
package de.waypointaudio.util
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Canvas
import android.graphics.Matrix
import android.graphics.Rect
import android.net.Uri
import android.util.Log
import java.io.File
import java.io.FileOutputStream
/**
* v2.5.7 — Persistente Ablage für vom Nutzer gewählte Tour-Cover-Bilder.
*
* Aufgabe:
* - Aus dem Android Photo Picker / ActivityResult kommt ein temporäres
* `content://`-URI mit begrenzter Lebensdauer. Wir kopieren das Bild
* einmalig in die app-interne `filesDir/tour_covers/`. Dadurch:
* • Permissions werden nicht über den App-Lebenszyklus benötigt.
* • Externes Verschieben/Löschen der Quelle bricht das Cover nicht.
* • Der Pfad lässt sich verlustfrei ins Backup/ZIP einpacken.
*
* - Beim Speichern wird das Bild quadratisch zentral zugeschnitten und auf
* eine moderate Kantenlänge (max. 1024 px) runterskaliert. So passt es in
* Hero und Cover-Card („center-crop / fill"), und das Backup bleibt klein.
*
* - Die Dateinamen sind aus dem Tournamen abgeleitet, mit zusätzlichem
* Hash-Suffix, damit gleichnamige Touren aus unterschiedlichen Backups
* nicht kollidieren.
*
* Robustheit:
* - Schlägt das Lesen/Decodieren fehl, wird `null` zurückgegeben — der Aufrufer
* fällt dann auf das deterministische Icon+Palette-Cover zurück.
* - Beim Löschen werden Fehler still ignoriert.
*/
object TourCoverImageStore {
private const val TAG = "TourCoverImageStore"
private const val DIR_NAME = "tour_covers"
private const val MAX_DIM = 1024
private const val JPEG_QUALITY = 88
/** Verzeichnis, in dem alle Cover-Bilder liegen. Wird bei Bedarf angelegt. */
fun coversDir(context: Context): File {
val dir = File(context.filesDir, DIR_NAME)
if (!dir.exists()) dir.mkdirs()
return dir
}
/**
* Kopiert das Bild hinter [sourceUri] in die app-interne Ablage und liefert
* den absoluten Dateipfad zurück. Das Bild wird quadratisch zentral
* zugeschnitten und auf [MAX_DIM] Kantenlänge skaliert.
*
* @return absoluter Pfad oder `null` bei Fehlern.
*/
fun saveFromUri(context: Context, sourceUri: Uri, tourName: String): String? {
return runCatching {
val input = context.contentResolver.openInputStream(sourceUri)
?: return null
val bytes = input.use { it.readBytes() }
val bmp = decodeSquareCenterCrop(bytes) ?: return null
val targetDir = coversDir(context)
val baseName = sanitize(tourName)
val unique = Integer.toHexString(System.nanoTime().hashCode())
val targetFile = File(targetDir, "${baseName}_$unique.jpg")
FileOutputStream(targetFile).use { fos ->
bmp.compress(Bitmap.CompressFormat.JPEG, JPEG_QUALITY, fos)
fos.flush()
}
bmp.recycle()
targetFile.absolutePath
}.onFailure { e ->
Log.e(TAG, "saveFromUri fehlgeschlagen für $sourceUri", e)
}.getOrNull()
}
/**
* Kopiert eine bereits existierende Datei (Pfad oder file://) in die Ablage.
* Wird vom Backup-Import genutzt, wenn ein Cover-Bild aus dem ZIP entpackt
* und unter neuem Pfad gespeichert werden soll.
*/
fun saveFromFile(context: Context, file: File, tourName: String): String? {
return runCatching {
val bytes = file.readBytes()
val bmp = decodeSquareCenterCrop(bytes) ?: return null
val targetDir = coversDir(context)
val baseName = sanitize(tourName)
val unique = Integer.toHexString(System.nanoTime().hashCode())
val targetFile = File(targetDir, "${baseName}_$unique.jpg")
FileOutputStream(targetFile).use { fos ->
bmp.compress(Bitmap.CompressFormat.JPEG, JPEG_QUALITY, fos)
}
bmp.recycle()
targetFile.absolutePath
}.onFailure { e ->
Log.e(TAG, "saveFromFile fehlgeschlagen für ${file.absolutePath}", e)
}.getOrNull()
}
/** Löscht eine Cover-Datei (kein Fehler, wenn sie schon weg ist). */
fun delete(path: String?) {
if (path.isNullOrBlank()) return
runCatching {
val f = File(path)
if (f.exists()) f.delete()
}
}
/**
* Decodiert ein Byte-Array zu einem quadratischen Bitmap (center crop) mit
* maximaler Kantenlänge [MAX_DIM]. Liefert `null` bei dekodierungs- oder
* Speicherfehlern.
*/
private fun decodeSquareCenterCrop(bytes: ByteArray): Bitmap? {
// 1) Bounds messen, um inSampleSize zu wählen
val boundsOpts = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, boundsOpts)
val srcW = boundsOpts.outWidth
val srcH = boundsOpts.outHeight
if (srcW <= 0 || srcH <= 0) return null
val shortSide = minOf(srcW, srcH)
var sample = 1
// Verkleinern bis die kürzere Seite knapp über MAX_DIM liegt
while (shortSide / (sample * 2) > MAX_DIM) sample *= 2
val decodeOpts = BitmapFactory.Options().apply {
inJustDecodeBounds = false
inSampleSize = sample
inPreferredConfig = Bitmap.Config.ARGB_8888
}
val decoded = BitmapFactory.decodeByteArray(bytes, 0, bytes.size, decodeOpts)
?: return null
// 2) Center crop quadratisch
val side = minOf(decoded.width, decoded.height)
val x = (decoded.width - side) / 2
val y = (decoded.height - side) / 2
val cropped = Bitmap.createBitmap(decoded, x, y, side, side)
if (cropped !== decoded) decoded.recycle()
// 3) Auf Zielgröße skalieren (falls noch zu groß)
return if (cropped.width > MAX_DIM) {
val scaled = Bitmap.createScaledBitmap(cropped, MAX_DIM, MAX_DIM, true)
if (scaled !== cropped) cropped.recycle()
scaled
} else {
cropped
}
}
private fun sanitize(s: String): String {
val cleaned = s.replace(Regex("[\\\\/:*?\"<>|\\u0000-\\u001F]"), "_")
.replace(Regex("_+"), "_")
.trim('_', ' ', '.')
.take(40)
return if (cleaned.isBlank()) "tour" else cleaned
}
}
@@ -0,0 +1,80 @@
package de.waypointaudio.util
import android.content.Context
import android.media.MediaMetadataRetriever
import android.net.Uri
import java.io.File
import kotlin.math.max
/**
* Auflösung der Audiodauer eines Wegpunkts (v2.3.0).
*
* Wird im Explorer verwendet, um pro Marker einen Audio-Dauer-Indikator
* (Ring/Badge) zu rendern. Kostenoptimiert: ohne externe Dienste.
*
* - Greift via `MediaMetadataRetriever` zuerst auf die echte Audiolänge zu.
* - Fällt bei nicht lesbaren content://-URIs auf 0 zurück (kein Crash);
* in diesem Fall wird das Audio-Längen-Overlay einfach nicht gezeichnet
* und stattdessen der bestehende Trigger-Radius sichtbar.
*
* Bewusst kein blockierender I/O auf dem Main-Thread: Aufrufer ruft die
* Funktion einmalig in einem `LaunchedEffect` auf und cached das Ergebnis
* in einer einfachen Map.
*/
object WaypointAudioDuration {
fun resolveDurationMs(context: Context, soundUri: String): Long {
if (soundUri.isBlank()) return 0L
return runCatching {
val mmr = MediaMetadataRetriever()
try {
if (soundUri.startsWith("content://")) {
mmr.setDataSource(context, Uri.parse(soundUri))
} else if (soundUri.startsWith("file://")) {
val path = Uri.parse(soundUri).path ?: return 0L
if (!File(path).exists()) return 0L
mmr.setDataSource(path)
} else {
val f = File(soundUri)
if (!f.exists()) return 0L
mmr.setDataSource(soundUri)
}
val raw = mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)
raw?.toLongOrNull() ?: 0L
} finally {
runCatching { mmr.release() }
}
}.getOrElse { 0L }
}
/**
* Klassifiziert eine Audiolänge in „kurz / mittel / lang"
* für eine konsistente, ruhige Karten-Legende.
*/
fun classify(durationMs: Long): AudioLengthBucket = when {
durationMs <= 0L -> AudioLengthBucket.UNKNOWN
durationMs < 15_000L -> AudioLengthBucket.SHORT
durationMs < 60_000L -> AudioLengthBucket.MEDIUM
else -> AudioLengthBucket.LONG
}
/**
* Liefert einen 0..1-Wert, der einen visuellen Audiolängen-Ring
* skaliert (gesättigt bei 120s = voller Ring).
*/
fun normalizedFraction(durationMs: Long): Float {
if (durationMs <= 0L) return 0f
val capped = max(0L, durationMs).coerceAtMost(120_000L)
return capped / 120_000f
}
fun formatShort(durationMs: Long): String {
if (durationMs <= 0L) return ""
val totalSec = (durationMs / 1000L).coerceAtLeast(1L)
val m = totalSec / 60
val s = totalSec % 60
return if (m > 0) "%d:%02d".format(m, s) else "0:%02d".format(s)
}
}
enum class AudioLengthBucket { UNKNOWN, SHORT, MEDIUM, LONG }
File diff suppressed because it is too large Load Diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

@@ -0,0 +1,53 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
GPS2Audio splash icon (v2.5.8)
Wird vom Android Splash Screen (androidx.core.splashscreen) zentriert
auf dem Splash-Hintergrund (navy) gezeichnet. Reine Vektorgrafik
keine externen Bilder. Form: Map-Pin mit Sound-Wellen, identisch zur
Launcher-Identität, aber dezent angepasst (etwas mehr Whitespace).
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="192dp"
android:height="192dp"
android:viewportWidth="192"
android:viewportHeight="192">
<!-- Map-pin teardrop body teal -->
<path
android:fillColor="#4DB6AC"
android:pathData="
M86,40
C71,40 59,52 59,67
C59,88 86,124 86,124
C86,124 113,88 113,67
C113,52 101,40 86,40Z" />
<!-- Pin inner cut-out circle navy -->
<path
android:fillColor="#0D1B2A"
android:pathData="M86,57 a10,10 0 1 0 0.001 0Z" />
<!-- Sound-wave arc 1 inner, white -->
<path
android:strokeColor="#FFFFFF"
android:strokeWidth="3.5"
android:fillColor="#00000000"
android:strokeLineCap="round"
android:pathData="M125,82 Q132,96 125,110" />
<!-- Sound-wave arc 2 middle, white -->
<path
android:strokeColor="#FFFFFF"
android:strokeWidth="3.5"
android:fillColor="#00000000"
android:strokeLineCap="round"
android:pathData="M133,75 Q145,96 133,117" />
<!-- Sound-wave arc 3 outer, light teal -->
<path
android:strokeColor="#80CBC4"
android:strokeWidth="3"
android:fillColor="#00000000"
android:strokeLineCap="round"
android:pathData="M141,68 Q157,96 141,124" />
</vector>
@@ -0,0 +1,4 @@
-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEZ544rSBSWPjtB/0erDgpIoZhfgTg
7lk7rPb2trDPkpTGVFL9hMH6saaXiLn8KfRi26CfLufJGOMaX9DWYHo0wQ==
-----END PUBLIC KEY-----
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="splash_background">#0D1B2A</color>
</resources>
+657 -13
View File
@@ -17,6 +17,12 @@
<string name="track_recording_notification_text">GPS-Track wird aufgezeichnet%1$s</string>
<string name="track_recording_permission_missing">Standort-Berechtigung fehlt. Bitte erteilen Sie die Berechtigung „Genauer Standort“ in den App-Einstellungen, damit die Track-Aufzeichnung im Hintergrund funktioniert.</string>
<!-- Notification (Atmo/Begleitmusik Hintergrund-Wiedergabe) -->
<string name="atmo_notification_channel_name">Begleitmusik (Atmo)</string>
<string name="atmo_notification_channel_description">Hält die Begleitmusik im Hintergrund aktiv, wenn die App minimiert ist.</string>
<string name="atmo_notification_default_title">Begleitmusik läuft</string>
<string name="atmo_notification_default_source">GPS2Audio</string>
<!-- UI strings -->
<string name="add_waypoint">Wegpunkt hinzufügen</string>
<string name="edit_waypoint">Wegpunkt bearbeiten</string>
@@ -50,12 +56,58 @@
<string name="map_view">Karte</string>
<string name="map_attribution">Kartendaten © OpenStreetMap-Mitwirkende</string>
<!-- OpenStreetMap-Attribution (Pflicht-Hinweis auf jeder Karte) -->
<string name="osm_attribution_short">© OpenStreetMap-Mitwirkende</string>
<string name="osm_attribution_full">Kartendaten © OpenStreetMap-Mitwirkende · ODbL</string>
<!-- Track-Editor / Draft-Archiv -->
<string name="menu_track_editor">Track-Editor</string>
<string name="draft_list_title">Track-Editor</string>
<string name="draft_list_empty">Noch keine Aufzeichnungen.\nNimm einen Track auf der Karte auf — er wird hier automatisch archiviert.</string>
<string name="draft_list_subtitle">Tour: %1$s · %2$s</string>
<string name="draft_list_points_dist">%1$d Punkte · %2$s</string>
<string name="draft_list_marked_count">%1$d markiert</string>
<string name="draft_unnamed">Unbenannter Track</string>
<string name="draft_editor_subtitle">%1$d Punkte · %2$d markiert</string>
<string name="draft_edit_meta_title">Track umbenennen</string>
<string name="draft_name_label">Name</string>
<string name="draft_notes_label">Notiz (optional)</string>
<string name="draft_delete">Aufzeichnung löschen</string>
<string name="draft_delete_confirm_title">Aufzeichnung löschen?</string>
<string name="draft_delete_confirm_msg">„%1$s" wird unwiderruflich gelöscht.</string>
<string name="draft_point_selected">Punkt %1$d / %2$d</string>
<string name="draft_point_mark">Als Wegpunkt markieren</string>
<string name="draft_point_unmark">Markierung entfernen</string>
<string name="draft_point_move_hint">Karte lange drücken, um den ausgewählten Punkt zu verschieben.</string>
<string name="draft_editor_help">Tippe einen Streckenpunkt, um ihn als Wegpunkt zu markieren. Lang drücken wählt ihn zum Verschieben oder Löschen aus.</string>
<string name="draft_mark_all">Alle markieren</string>
<string name="draft_unmark_all">Markierungen löschen</string>
<string name="draft_locked_while_recording">Während der Aufzeichnung gesperrt — bitte zuerst stoppen.</string>
<string name="draft_import_button">In Tour übernehmen</string>
<string name="draft_import_title">Markierte Punkte als Wegpunkte importieren</string>
<string name="draft_import_hint_marked">%1$d von %2$d markierte Punkte werden importiert. Nicht markierte bleiben im Draft.</string>
<string name="draft_import_hint_none">Keine Wegpunkte markiert — tippe Streckenpunkte, um sie zu markieren.</string>
<string name="draft_import_target_label">Zieltour</string>
<string name="draft_import_confirm">%1$d übernehmen</string>
<string name="draft_import_disabled_hint">Keine Wegpunkte markiert</string>
<!-- Editor: Ortssuche (Nominatim) -->
<string name="search_place_action">Ort suchen</string>
<string name="search_place_title">Ort suchen</string>
<string name="search_place_query_label">Suchbegriff</string>
<string name="search_place_button">Suchen</string>
<string name="search_place_loading">Suche läuft …</string>
<string name="search_place_no_results">Keine Treffer gefunden.</string>
<string name="search_place_error">Suche fehlgeschlagen — bitte Internetverbindung prüfen.</string>
<string name="search_place_attribution">Suche: OpenStreetMap/Nominatim</string>
<string name="search_place_close">Schließen</string>
<!-- Map: Als Wegpunkt übernehmen -->
<string name="add_waypoint_from_map">Als Wegpunkt übernehmen</string>
<string name="current_location_hint">Koordinaten aus aktuellem GPS-Standort vorausgefüllt.</string>
<!-- Map Editor -->
<string name="map_editor_hint">Marker antippen · Karte lange drücken</string>
<string name="map_editor_hint">Streckenpunkt antippen zum Auswählen (rot) · Karte lange drücken für neuen Punkt</string>
<string name="map_waypoint_edit">Bearbeiten</string>
<string name="map_waypoint_delete">Löschen</string>
<string name="map_waypoint_unnamed">Unbenannter Wegpunkt</string>
@@ -84,28 +136,246 @@
<string name="menu_about">Über diese App</string>
<string name="about_title">Über diese App</string>
<string name="about_app_name">GPS2Audio</string>
<string name="about_description">Überwacht GPS-Wegpunkte und spielt beim Betreten des jeweiligen Radius automatisch eine Audiodatei ab. Wegpunkte können auch manuell in der Übersicht abgespielt werden.</string>
<string name="about_description">GPS2Audio überwacht GPS-Wegpunkte und spielt beim Betreten des jeweiligen Radius automatisch eine Audiodatei ab. Ideal für geführte Touren, Audioguides und ortsbezogene Klangerlebnisse.</string>
<string name="about_version_label">Version</string>
<string name="about_version">1.0</string>
<string name="about_version">1.5.5-tabrow-fix</string>
<string name="about_developer_label">Entwickler</string>
<string name="about_developer">Marcel Mayer</string>
<string name="about_email_label">E-Mail</string>
<string name="about_email">marcel.mayer@nesohub.org</string>
<string name="about_terms_label">Nutzungsbedingungen</string>
<string name="about_terms_text">Die private und nicht-kommerzielle Nutzung von GPS2Audio ist frei. Jede kommerzielle Nutzung der App, der Marke „GPS2Audio“, der bereitgestellten Inhalte/Touren sowie der signierten Builds insbesondere durch Bus-, Reise-, Schifffahrts- oder andere gewerbliche Anbieter ist ohne ausdrückliche schriftliche Genehmigung des Entwicklers untersagt. Für gewerbliche Nutzung ist ein gesonderter Vertrag erforderlich. Anfragen bitte per E-Mail an den Entwickler.</string>
<string name="about_license_label">Lizenz</string>
<string name="about_license_name">Apache License 2.0</string>
<string name="about_license_summary">Diese App ist Open Source und wird unter der Apache License 2.0 veröffentlicht. Nutzung, Veränderung, Forks und Weitergabe sind unter den Bedingungen dieser Lizenz erlaubt. Copyright- und Lizenzhinweise müssen erhalten bleiben. Die Software wird ohne Gewährleistung bereitgestellt.</string>
<string name="about_license_name">Apache License 2.0 + Commons Clause</string>
<string name="about_license_summary">GPS2Audio-eigener Code, Assets und Branding stehen unter der Apache License 2.0 mit der Commons Clause License Condition v1.0. Die Apache-2.0-Rechte (Nutzung, Modifikation, Forks, Weitergabe) bleiben erhalten — Copyright- und Lizenzhinweise müssen mitgegeben werden. Die Commons Clause schließt jedoch ausdrücklich das Recht zum Verkauf der Software oder zum Betrieb als kommerziellen/gehosteten Dienst aus (siehe Nutzungsbedingungen und TERMS.md). Drittanbieter-Bibliotheken behalten ihre jeweiligen Originallizenzen (siehe NOTICE / THIRD_PARTY_NOTICES). Die Software wird ohne Gewährleistung bereitgestellt.</string>
<string name="about_license_url">https://www.apache.org/licenses/LICENSE-2.0</string>
<string name="about_license_disclaimer">Dieser Hinweis ersetzt keine Rechtsberatung. Maßgeblich ist im Zweifel der Originaltext der Apache License 2.0.</string>
<string name="about_close">Schließen</string>
<string name="about_copyright">Copyright 2026 Marcel Mayer</string>
<!-- Matrix contact -->
<string name="about_matrix_label">Matrix-Kontakt</string>
<string name="about_matrix_description">Scanne den QR-Code oder tippe den Button, um mich über Matrix zu kontaktieren.</string>
<string name="about_matrix_id">@neso:nesohub.org</string>
<string name="about_matrix_url">https://matrix.to/#/@neso:nesohub.org</string>
<string name="about_matrix_open_button">Matrix-Chat öffnen</string>
<string name="about_matrix_open_cd">Matrix-Chat mit @neso:nesohub.org öffnen</string>
<string name="about_matrix_qr_content_description">QR-Code für Matrix-Kontakt @neso:nesohub.org</string>
<!-- v2.5.17 — Lizenz &amp; Nutzung / GPS2Audio Pro
Die bisherige Spenden-/„Entwicklung unterstützen“-Sektion wurde
entfernt und durch einen professionellen Lizenz-Hinweis ersetzt.
Der PayPal-Link bleibt erhalten, dient aber nicht mehr als
Spenden-Aufruf, sondern als Kanal für eine Pro-Lizenz-Anfrage. -->
<string name="about_pro_section_label">Lizenz &amp; Nutzung</string>
<string name="about_pro_title">GPS2Audio Profi</string>
<string name="about_pro_tagline">Die GPS2Audio Profi-Lizenz ist eine einjährig gültige Lizenz für professionelle Anwender, Reiseleiter, Institutionen und Anbieter geführter Touren.</string>
<string name="about_pro_tagline2">Sie ermöglicht professionelle Nutzung und unterstützt die kontinuierliche Weiterentwicklung der App.</string>
<string name="about_pro_renewal_note">Die Lizenz läuft nicht automatisch weiter und kann bei Bedarf aktiv erneuert werden. Laufzeit und Konditionen werden in der Lizenzdatei hinterlegt.</string>
<string name="about_pro_features_label">Für professionelle Nutzung vorgesehen:</string>
<string name="about_pro_features_list">• Multi-Clip-Waypoints\n• Live/PTT-Funktion\n• Tour-Zähler</string>
<string name="about_pro_required_intro">Typische Einsatzbereiche:</string>
<string name="about_pro_required_line1">• Kommerzielle Nutzung und bezahlte Touren</string>
<string name="about_pro_required_line2">• Institutionelle Nutzung (Behörden, Tourismusverbände, Reiseveranstalter, Bildungsträger)</string>
<string name="about_pro_required_line3">• Kundenprojekte und Whitelabel-/Auftragsarbeiten</string>
<string name="about_pro_required_line4">• Professionelle Reiseleitung und geführte Touren</string>
<string name="about_pro_private_note">Die private und interne (nicht-kommerzielle) Nutzung bleibt gemäß den Lizenzbedingungen (Apache 2.0 + Commons Clause) möglich.</string>
<string name="about_pro_manual_note">Anfragen werden zeitnah bearbeitet. Bitte Lizenznehmer-Name, Firma/Institution und Verwendungszweck angeben.</string>
<string name="about_pro_request_button">Profi-Lizenz per E-Mail anfragen</string>
<string name="about_pro_request_open_cd">E-Mail-App für Profi-Lizenz-Anfrage öffnen</string>
<string name="about_pro_email_recipient">marcel.mayer@nesohub.org</string>
<string name="about_pro_email_subject">GPS2Audio Profi-Lizenz anfragen</string>
<string name="about_pro_email_body_template">Hallo,
ich interessiere mich für eine GPS2Audio Profi-Lizenz.
Lizenznehmer/Firma:
Ansprechpartner:
E-Mail:
Nutzung: professionell / institutionell / Testversion
Geräte-ID: %s
Bemerkung:
Viele Grüße</string>
<string name="about_pro_email_no_app">Keine E-Mail-App gefunden. Bitte senden Sie eine E-Mail an marcel.mayer@nesohub.org mit Ihrem Namen, Ihrer Firma und dem Verwendungszweck.</string>
<string name="about_pro_contact_hint">Empfänger: marcel.mayer@nesohub.org</string>
<!-- v2.5.17 — Lizenzstatus-Anzeige im About-Dialog.
Diese Werte sind Platzhalter und werden in einer späteren Version
durch die echte Offline-Lizenzprüfung gefüllt. Aktuell ist der
Status statisch „Keine Lizenz importiert“, damit nichts crasht und
keine aggressiven Feature-Gates aktiv werden. -->
<string name="about_license_status_label">Lizenzstatus</string>
<string name="about_license_status_state_label">Status</string>
<string name="about_license_status_state_value">Keine Lizenz importiert</string>
<string name="about_license_status_holder_label">Lizenznehmer</string>
<string name="about_license_status_holder_value">-</string>
<string name="about_license_status_validity_label">Gültigkeit</string>
<string name="about_license_status_validity_value">-</string>
<string name="about_license_status_features_label">Freigeschaltete Funktionen</string>
<string name="about_license_status_features_value">Basisfunktionen</string>
<string name="about_license_status_hint">Eine importierte Pro-Lizenzdatei wird hier künftig angezeigt. Bis dahin sind alle bestehenden Funktionen ohne Einschränkung verfügbar.</string>
<!-- v2.5.20 — Lizenz Import/Export/Entfernen. Diese Buttons aktivieren
keine Feature-Sperren; sie dienen nur Anzeige und Vorbereitung. -->
<string name="about_license_actions_label">Lizenzaktionen</string>
<string name="about_license_action_import">Lizenz importieren</string>
<string name="about_license_action_export">Lizenz exportieren</string>
<string name="about_license_action_remove">Lizenz entfernen</string>
<string name="about_license_action_remove_confirm_title">Lizenz entfernen?</string>
<string name="about_license_action_remove_confirm_text">Die importierte Lizenz wird lokal gelöscht. Die App fällt auf „Keine Lizenz importiert" zurück. Bestehende Touren und Mediendateien bleiben unverändert.</string>
<string name="about_license_action_remove_confirm_ok">Entfernen</string>
<string name="about_license_action_remove_confirm_cancel">Abbrechen</string>
<string name="about_license_action_remove_done">Lizenz entfernt</string>
<string name="about_license_action_remove_fail">Lizenzdatei konnte nicht entfernt werden</string>
<string name="about_license_import_success">Lizenz importiert: %1$s</string>
<string name="about_license_import_fail">Lizenz ungültig: %1$s</string>
<string name="about_license_import_fail_invalid">Ungültige Lizenzdatei: %1$s</string>
<string name="about_license_import_read_fail">Lizenzdatei konnte nicht gelesen werden</string>
<string name="about_license_import_err_open">Datei konnte nicht geöffnet werden. Bitte erneut versuchen.</string>
<string name="about_license_import_err_empty">Datei ist leer oder enthält keine Daten.</string>
<string name="about_license_import_err_permission">Keine Leseberechtigung für diese Datei. Bitte Datei-Manager verwenden.</string>
<string name="about_license_import_err_io">Lesefehler beim Zugriff auf die Datei. Bitte erneut versuchen.</string>
<string name="about_license_import_err_no_app">Kein Datei-Manager verfügbar. Bitte einen Datei-Manager installieren.</string>
<string name="about_license_export_filename_default">gps2audio.license.json</string>
<string name="about_license_export_success">Lizenz exportiert</string>
<string name="about_license_export_fail">Lizenz konnte nicht exportiert werden</string>
<string name="about_license_export_nothing">Keine importierte Lizenz vorhanden</string>
<string name="about_license_type_label">Lizenztyp</string>
<string name="about_license_id_label">Lizenz-ID</string>
<string name="about_license_binding_label">Bindung</string>
<string name="about_license_permissions_label">Berechtigungen</string>
<string name="about_license_no_permissions">-</string>
<!-- v2.5.21 — Geräte-ID -->
<string name="about_device_id_label">Lizenz-Geräte-ID</string>
<string name="about_device_id_hint">Für optionale gerätegebundene Lizenzen. Nicht im normalen Backup enthalten. Wenn App-Daten gelöscht werden, ändert sich die ID.</string>
<string name="about_device_id_field_label">Geräte-ID</string>
<string name="about_device_id_copy_label">GPS2Audio Geräte-ID</string>
<string name="about_device_id_copy_button">ID kopieren</string>
<string name="about_device_id_copied">Geräte-ID in Zwischenablage kopiert</string>
<!-- Version notice popup (shown once per new versionCode) -->
<string name="version_notice_title">GPS2Audio aktualisiert</string>
<string name="version_notice_heading">Neue Version: %1$s</string>
<string name="version_notice_body_v2512">Neu in 2.5.12: Wegpunkte können jetzt mehrere Audio-Clips enthalten. Jeder Clip hat eigenen Titel, kann einzeln aktiviert/deaktiviert, ersetzt, gelöscht und in der Reihenfolge sortiert werden. Im Tour-Play werden alle aktiven Clips eines Wegpunkts nacheinander abgespielt, inaktive übersprungen. Einzelabspielung des Hauptclips bleibt direkt aus der Liste verfügbar. Backup/Restore, PTT, Atmo/Stream, Foreground-Service und Karten-/Routen-Funktionen sind unverändert; alte Wegpunkte mit nur einer Audiodatei laden ohne Migration weiter.</string>
<string name="version_notice_body_v2523">Neu in 2.5.23: Lizenzimport-Regression-Fix. Erstimport nach Neuinstallation funktioniert wieder zuverlässig. Dateiauswahl nutzt wieder OpenDocument() (echter Datei-Browser) statt GetContent() (das auf manchen Geräten nur Medien anzeigt). Reimport derselben Datei nach „Lizenz entfernen“ funktioniert via importNonce-Mechanismus. Fehlermeldungen (aus v2.5.22) bleiben differenziert. Geräte-ID und alle anderen Funktionen aus v2.5.21/v2.5.22 bleiben unverändert.</string>
<string name="version_notice_body_v2521">Neu in 2.5.21: Lizenz-Re-Import-Bugfix und Geräte-ID. Der Lizenzimport funktioniert jetzt zuverlässig auch nach Entfernen und erneutem Importieren einer Lizenzdatei. Außerdem zeigt der Lizenz-Bereich jetzt eine Geräte-ID an (SHA-256-Hash) — für spätere optionale gerätegebundene Lizenzen. In dieser Version ist keine harte Gerätebindung aktiv. Alle bisherigen Funktionen aus v2.5.20 bleiben unverändert.</string>
<string name="version_notice_body_v2519">Neu in 2.5.19: Menü- und UX-Politur. Der Eintrag „Track Editor" wurde aus dem Hauptmenü entfernt die Funktion ist über den Explorer weiterhin vollständig erreichbar. „Klassische Ansicht" heißt jetzt „Bearbeitungsmodus" (klarere Begriffswahl). „Touren ordnen" liegt jetzt im Menü der Audio-Bibliothek (Hauptseite) und nicht mehr in der einzelnen Touransicht. Tour-Cover (Bild, Icon, Palette) werden weiterhin als Teil des ZIP-Backups exportiert und beim Import wiederhergestellt. Alle bisherigen Funktionen additiver GPX-Import, Lizenz/Pro/About, Multi-Clip, Sequenzplayer, Countdown, maximierbarer Player, Tour duplizieren, Waypoint kopieren, Explorer/MapLibre/Routing, PTT/Atmo, Hintergrundwiedergabe bleiben unverändert.</string>
<string name="version_notice_body_v242">Neu in 2.5.1: Design-Harmonisierung der sekundären Bereiche. Die Tour-/Waypoint-Ansicht, klassische Hauptansicht, PTT-Bereich, Atmo/Stream-Playlist, Track-Entwürfe, Backup/Import sowie About-Dialog nutzen jetzt die gleiche GPS2Audio-Farbwelt wie die neue Startseite: dunkle Surface-Karten, türkise Primäraktionen, magenta/destruktiv, grün/aktiv, weichere Cards und kompaktere Listen. Tabs, Top-App-Bars und Dialoge wirken ruhiger und moderner, ohne dass Funktionen entfallen: Explorer, MapLibre, Routenplaner, POI, PTT, Atmo/Stream-Playlist, Recorder, Backup/Import, About/PayPal, Drag-and-drop/Sortierung, TourRoute-Persistenz und Classic-Fallback bleiben unverändert erreichbar. Wegpunkte lassen sich weiterhin direkt per Drag-and-drop verschieben.</string>
<string name="version_notice_body">Neu in 2.4.0: MapLibre Engine Cutover — der Explorer rendert jetzt aktiv mit MapLibre Native (org.maplibre.gl 11.x), nicht mehr mit osmdroid. Wegpunkte, Standort/Follow, Trigger-Radius/Audiozone, GPS-Track, Drafts, Storyboard, Routing/POI-MVP, Suche und Tap-Editiermodus sind 1:1 portiert. Echter Pitch/Tilt für den Perspektiv-Modus über MapLibre-Kamera. Clip-Längenlinien werden ab jetzt nur noch entlang einer bekannten Geometrie (aufgezeichneter Track/Draft oder berechnete OSRM-Route) gezeichnet — keine freien Projektionen mehr. Bestehende Funktionen (Backup, PTT, Atmo, Audio-Bibliothek, Touren ordnen, Drag-and-drop, About/PayPal) unverändert. Der osmdroid-Renderer bleibt im Quelltext als dokumentierter Fallback erhalten, ist aber nicht mehr sichtbar verdrahtet.</string>
<!-- v2.2.1 — Unified Map module -->
<string name="map_mode_explorer">Explorer</string>
<string name="map_mode_editor">Editor</string>
<string name="map_mode_switch_to_editor">Zum Editor wechseln</string>
<string name="map_mode_switch_to_explorer">Zum Explorer wechseln</string>
<string name="map_unified_title">Karte</string>
<string name="map_layers_title">Karten-Ebenen</string>
<string name="map_layers_open">Ebenen / 3D</string>
<string name="map_layers_follow">Standort folgen</string>
<string name="map_layers_markers">Wegpunkt-Marker</string>
<string name="map_layers_radius">Audio-Radius / Reichweite</string>
<string name="map_layers_track">GPS-Track / Draft-Linien</string>
<string name="map_layers_3d_label">3D-Modus</string>
<string name="map_layers_3d_prepared">3D in Vorbereitung — MapLibre ist in v2.2.2 als Build-Abhängigkeit verdrahtet, aktives Rendering läuft aber weiterhin auf osmdroid (2D-Raster).</string>
<string name="map_layers_3d_unavailable">Echtes 3D (Vektor-Terrain mit DEM-Höhendaten) ist in v2.3.0 noch nicht aktiv die App verwendet weiterhin einen Raster-Renderer (osmdroid) mit MapLibre-Tile-Anbindung. Der Perspektiv-Modus bietet stattdessen eine geneigte Kameraansicht als Vorschau auf den späteren MapLibre-3D-Renderer.</string>
<string name="map_layers_close">Schließen</string>
<!-- v2.3.0 — MapLibre/Overlays -->
<string name="map_layers_base_section">Basis-Karte</string>
<string name="map_layers_overlays_section">Ebenen</string>
<!-- v2.5.37 — Explorer Map Styles (OpenFreeMap Vektor-Stile) -->
<string name="map_layers_base_2d">Modern</string>
<string name="map_layers_base_dark">Dunkel</string>
<string name="map_layers_base_outdoor">Outdoor</string>
<string name="map_layers_base_city3d">3D Stadt</string>
<string name="map_layers_base_classic">Klassisch</string>
<!-- Legacy (nicht mehr im UI, aber Strings bleiben für Kompatibilität) -->
<string name="map_layers_base_topo">Outdoor</string>
<string name="map_layers_base_terrain">3D Stadt</string>
<string name="map_layers_base_satellite">Satellit</string>
<string name="map_layers_base_3d">3D / Perspektive</string>
<!-- v2.5.37 — Stil-Infotexte (OpenFreeMap Vektor-Stile) -->
<string name="map_layers_modern_info">Modern (OpenFreeMap Positron): Heller, reduzierter Vektorstil. Keine API-Key-Pflicht, keine Nutzungsgrenzen. Quelle: openfreemap.org. Attribution: © OpenFreeMap, © OpenMapTiles, Daten: © OpenStreetMap-Mitwirkende (ODbL).</string>
<string name="map_layers_dark_info">Dunkel (OpenFreeMap Dark Matter): Echter dunkler Vektorstil. Keine API-Key-Pflicht. Quelle: openfreemap.org. Attribution: © OpenFreeMap, © OpenMapTiles, Daten: © OpenStreetMap-Mitwirkende (ODbL).</string>
<string name="map_layers_dark_note">Dunkler Vektorstil (OpenFreeMap Dark) — kein API-Key, kostenlos.</string>
<string name="map_layers_outdoor_info">Outdoor (OpenFreeMap Liberty): Lebendiger Vektorstil mit ausgeprägter Gelände-, Grün- und Wegenetz-Darstellung. Ideal für Wandern und Außenbereiche. Keine API-Key-Pflicht. Quelle: openfreemap.org. Attribution: © OpenFreeMap, © OpenStreetMap-Mitwirkende (ODbL).</string>
<string name="map_layers_outdoor_note">Outdoor-Vektorstil (OpenFreeMap Liberty) — Gelände und Wegenetze.</string>
<string name="map_layers_city3d_info">3D Stadt (OpenFreeMap Positron + Gebäude-Extrusion): Heller Vektorstil mit dreidimensional dargestellten Gebäuden via MapLibre fill-extrusion. Camera-Pitch einstellbar. Wenn keine building-Quelle im Stil vorhanden, automatischer 2D-Fallback ohne Absturz. Keine API-Key-Pflicht. Quelle: openfreemap.org.</string>
<string name="map_layers_city3d_note">3D-Stadtansicht mit Gebäude-Extrusion — kein API-Key, kostenlos. Bei fehlendem building-Layer: 2D-Fallback.</string>
<string name="map_layers_classic_info">Klassisch (OSM-Raster): Standard-OpenStreetMap-Rastertiles (tile.openstreetmap.org). Attribution: © OpenStreetMap-Mitwirkende (ODbL). Kein Vektor, kein 3D.</string>
<!-- Legacy-Strings (bleiben für Rückwärtskompatibilität) -->
<string name="map_layers_topo_default_attribution">Karte: © OpenFreeMap Liberty, Daten: © OpenStreetMap-Mitwirkende</string>
<string name="map_layers_topo_custom_label">Outdoor-Stil-Quelle</string>
<string name="map_layers_topo_custom_hint">OpenFreeMap Liberty wird automatisch verwendet.</string>
<string name="map_layers_topo_not_configured">Outdoor-Quelle nicht konfiguriert</string>
<string name="map_layers_topo_info">Outdoor-Stil (OpenFreeMap Liberty): Kostenlos, kein API-Key. Attribution sichtbar.</string>
<string name="map_layers_terrain_custom_label">3D-Stadt Pitch-Einstellung</string>
<string name="map_layers_terrain_custom_hint">Neigungswinkel für den 3D-Stadtmodus.</string>
<string name="map_layers_terrain_not_configured">3D-Stadtansicht aktiv (Positron + Gebäude-Extrusion).</string>
<string name="map_layers_terrain_info">3D Stadt: OpenFreeMap Positron mit Gebäude-Extrusion. Camera-Pitch einstellbar. Kein API-Key benötigt.</string>
<string name="map_layers_terrain_pitch_label">Neigung (Pitch)</string>
<string name="map_layers_audio_length">Audio-Länge je Wegpunkt</string>
<string name="map_layers_satellite_custom_label">Eigene Satelliten-Tile-URL</string>
<string name="map_layers_satellite_custom_hint">Optional. XYZ-Format mit {z}/{x}/{y}. Bei leerem Feld wird die Standard-Quelle (ESRI World Imagery) verwendet siehe Quellenhinweis auf der Karte.</string>
<string name="map_layers_satellite_info_button">Info / Quelle</string>
<string name="map_layers_satellite_info">Satellit nutzt standardmäßig ESRI World Imagery (kostenfrei für persönliche/nicht-kommerzielle Nutzung, Attribution „Tiles © Esri\"). Eigene Tile-URL ist möglich, z. B. self-hosted oder ein anderer Anbieter. Es werden keine kostenpflichtigen APIs erzwungen.</string>
<string name="map_layers_3d_preview_note">Perspektive aktiv: geneigte Kameraansicht als Vorschau. Echtes 3D-Terrain (DEM) folgt mit dem MapLibre-Native-Renderer.</string>
<string name="map_layers_routing_label">Routenplaner</string>
<string name="map_layers_routing_prepared">Folgt — kein externer Routingdienst aktiv. Self-hosted OSRM/Valhalla/GraphHopper geplant.</string>
<string name="map_provider_maplibre_demo">MapLibre Demotiles (frei)</string>
<string name="routing_dialog_title">Routenplaner folgt</string>
<string name="routing_dialog_body">GPS2Audio v2.3.0 enthält bewusst keine Routing-Engine. Ein vollwertiger Routenplaner würde entweder einen externen, kostenpflichtigen Dienst benötigen (vermieden) oder eine self-hosted Engine (z. B. OSRM, Valhalla, GraphHopper) bzw. einen Offline-Routing-Ansatz. Die nächste Version bindet eine der genannten Engines an das vorhandene Storyboard bleibt Eingabe.</string>
<string name="map_attribution_satellite_esri">Tiles © Esri</string>
<string name="map_attribution_satellite_custom">Satellit: eigene Tile-Quelle</string>
<string name="explorer_waypoint_audio_duration">Audio-Dauer: %1$s</string>
<!-- Explorer (v2.2.0) -->
<string name="explorer_title">Explorer</string>
<string name="explorer_open">Explorer öffnen</string>
<string name="explorer_subtitle">Karte · Standort · Wegpunkte (read-only)</string>
<string name="explorer_hint_no_location">Standort nicht verfügbar — Berechtigung oder GPS prüfen.</string>
<string name="explorer_hint_idle">Tippe „Mein Standort", um die Karte auf deine Position zu zentrieren. Wegpunkte der aktuellen Tour werden angezeigt.</string>
<string name="explorer_follow_on">Nachführung an</string>
<string name="explorer_follow_off">Nachführung aus</string>
<string name="explorer_open_in_editor">In Karteneditor öffnen</string>
<string name="explorer_save_as_tour">Auswahl übernehmen</string>
<string name="explorer_save_as_tour_hint">Übernahme folgt im nächsten Schritt.</string>
<string name="explorer_no_ptt_atmo_hint">Explorer ist ein reiner Vorerkundungs-Modus — keine PTT, keine Atmo, keine Aufzeichnung.</string>
<string name="explorer_waypoints_count">%1$d Wegpunkte sichtbar</string>
<string name="explorer_my_location">Mein Standort</string>
<string name="explorer_accuracy_meters">Genauigkeit: %1$d m</string>
<!-- Explorer v2.2.2 — Editier-Aktionen direkt im Explorer. -->
<string name="explorer_subtitle_v222">Karte · Standort · Wegpunkte bearbeiten</string>
<string name="explorer_add_waypoint">Wegpunkt</string>
<string name="explorer_action_edit">Wegpunkt bearbeiten</string>
<string name="explorer_action_move_tap">Position per Tap setzen</string>
<string name="explorer_action_move_to_center">Auf Kartenmitte verschieben</string>
<string name="explorer_action_tap_place">Neuer Wegpunkt per Tap</string>
<string name="explorer_tap_place_active">Tippe auf die Karte, um einen neuen Wegpunkt zu setzen.</string>
<string name="explorer_tap_move_active">Tippe die neue Position an — der gewählte Wegpunkt wird dorthin verschoben.</string>
<string name="explorer_cancel_tap">Tap-Modus abbrechen</string>
<string name="menu_legacy_map_editor">Karteneditor (Track / Drafts)</string>
<!-- v2.2.3 — Explorer als zentraler Arbeitsbereich: GPS-Recording, Drafts, Storyboard -->
<string name="explorer_subtitle_v223">Karte · Standort · Wegpunkte · GPS-Aufzeichnung · Drafts · Storyboard</string>
<string name="explorer_subtitle_recording">Aufzeichnung läuft · %1$d Punkte</string>
<string name="explorer_subtitle_draft_active">Draft aktiv: %1$s · %2$d Punkte · %3$d markiert</string>
<string name="explorer_drafts_open">Aufgenommene Tracks (Drafts)</string>
<string name="explorer_drafts_title">Aufgenommene Tracks</string>
<string name="explorer_drafts_hint">Wähle einen Draft, um seine Punkte direkt im Explorer zu sehen und zu bearbeiten. Detaillierte Bearbeitung im Vollbild-Editor weiterhin verfügbar.</string>
<string name="explorer_storyboard_open">Storyboard / Routenplanung</string>
<string name="explorer_storyboard_title">Storyboard / Routenplanung</string>
<string name="explorer_storyboard_hint">Bring die Wegpunkte der aktuellen Tour in eine gewünschte Reihenfolge und speichere sie als neue (oder bestehende) Tour. Kein Straßen-Routing — eine konzeptionelle Tour-Zusammenstellung.</string>
<string name="explorer_storyboard_target_tour">Zieltour (Name)</string>
<string name="explorer_storyboard_empty">Keine Wegpunkte in der aktuellen Tour. Lege erst welche im Explorer an.</string>
<string name="explorer_storyboard_up">Nach oben</string>
<string name="explorer_storyboard_down">Nach unten</string>
<string name="explorer_storyboard_save">Reihenfolge übernehmen</string>
<string name="explorer_storyboard_no_routing_hint">Hinweis: Es wird keine externe Routing-Engine verwendet (keine API, keine Kosten). Das Storyboard ist eine geordnete Wegpunkt-Liste — Entwurfsstatus.</string>
<string name="explorer_track_point_title">Trackpunkt %1$d</string>
<string name="explorer_track_point_no_draft_hint">Aktiviere zuerst einen Draft im Drafts-Sheet, um Trackpunkte zu bearbeiten.</string>
<string name="explorer_tap_move_track_active">Tippe die neue Position an — der gewählte Trackpunkt wird dorthin verschoben.</string>
<!-- v2.2.2 MapLibre / Layer-Hinweise -->
<string name="map_engine_status_osm">Karten-Engine: osmdroid (OSM-Raster)</string>
<string name="map_engine_status_maplibre_prepared">Karten-Engine: MapLibre-Anbindung vorbereitet · Aktiv: osmdroid</string>
<string name="version_notice_dont_show_again">Für diese Version nicht mehr anzeigen</string>
<string name="version_notice_ok">OK</string>
<!-- v2.5.9 — Matrix-Kontakt entfernt (siehe Handoff). -->
<!-- Abspielregeln -->
<string name="playback_rules_section">Abspielregeln</string>
@@ -192,11 +462,19 @@
<!-- Lokale Playlist -->
<string name="music_playlist_label">Playlist</string>
<string name="music_playlist_empty">Noch keine Audiodateien ausgewählt.</string>
<string name="music_playlist_empty_with_library">Noch keine Audiodateien für diese Tour ausgewählt wähle neue Dateien hinzu oder nimm bekannte aus der Bibliothek.</string>
<string name="music_playlist_add">Dateien hinzufügen</string>
<string name="music_playlist_pick_from_library">Aus Bibliothek auswählen</string>
<string name="music_playlist_clear">Playlist leeren</string>
<string name="music_playlist_remove">Entfernen</string>
<string name="music_shuffle">Zufällige Reihenfolge</string>
<!-- Audio-Bibliothek (tour-übergreifend) -->
<string name="music_library_picker_title">Bibliothek auswählen</string>
<string name="music_library_picker_subtitle">Audiodateien, die bereits zu dieser App hinzugefügt wurden aus Wegpunkten oder aus den Playlists anderer Touren. Auswahl wird zur aktuellen Playlist hinzugefügt.</string>
<string name="music_library_picker_empty">Alle bekannten Audiodateien sind bereits in dieser Playlist.</string>
<string name="music_library_picker_add">Hinzufügen</string>
<!-- Stream-URL -->
<string name="music_stream_url_label">Stream-URL</string>
<string name="music_stream_url_hint">Direkte http/https-Audio-URL erforderlich (z. B. Internetradio)</string>
@@ -204,6 +482,22 @@
<string name="music_stream_url_invalid_error">URL muss mit http:// oder https:// beginnen</string>
<string name="music_stream_platform_hint">Hinweis: YouTube, SoundCloud und radio.de geben keine direkt abspielbaren Audio-URLs aus. Bitte eine direkte Stream-URL (z. B. von Internetradio-Anbietern) eingeben. Seiten-Links funktionieren nicht.</string>
<!-- Stream-Playlist (ab v2.0.8) -->
<string name="music_stream_playlist_label">Stream-Playlist</string>
<string name="music_stream_playlist_empty">Noch keine Streams hinzugefügt unten URL eingeben und „Hinzufügen“ antippen.</string>
<string name="music_stream_playlist_empty_error">Bitte mindestens einen Stream zur Playlist hinzufügen.</string>
<string name="music_stream_add_section_label">Stream hinzufügen</string>
<string name="music_stream_edit_section_label">Stream bearbeiten</string>
<string name="music_stream_name_label">Name (optional)</string>
<string name="music_stream_name_placeholder">z. B. Deutschlandfunk Kultur</string>
<string name="music_stream_add_entry">Hinzufügen</string>
<string name="music_stream_save_entry">Übernehmen</string>
<string name="music_stream_clear">Playlist leeren</string>
<string name="music_stream_edit">Bearbeiten</string>
<string name="music_stream_remove">Entfernen</string>
<string name="music_stream_move_up">Nach oben</string>
<string name="music_stream_move_down">Nach unten</string>
<!-- Verhalten beim Wegpunkt -->
<string name="music_behavior_label">Verhalten bei Wegpunkt-Audio</string>
<string name="music_behavior_pause_resume">Pausieren und fortsetzen</string>
@@ -307,4 +601,354 @@
<string name="tour_counter_reset_with_apply">Zähler beim Anwenden zurücksetzen</string>
<string name="tour_counter_apply_button">Auf alle Wegpunkte anwenden</string>
<string name="tour_counter_apply_hint">Gilt für alle vorhandenen Wegpunkte dieser Tour. Neue Wegpunkte erben diesen Modus automatisch als Tour-Vorgabe.</string>
<!-- Sortier-/Bearbeitungsmodus -->
<string name="sort_mode_enter">Reihenfolge bearbeiten</string>
<string name="sort_mode_exit">Fertig</string>
<string name="sort_mode_disabled_hint">Reihenfolge bearbeiten ist während GPS-Dienst oder Track-Aufzeichnung deaktiviert.</string>
<string name="sort_mode_active_hint">Sortiermodus aktiv ziehe ▲▼ zum Verschieben.</string>
<string name="sort_move_up">Nach oben verschieben</string>
<string name="sort_move_down">Nach unten verschieben</string>
<string name="sort_tours_label">Touren-Reihenfolge</string>
<!-- Direktes Sortieren / Touren ordnen (v2.0.1) -->
<string name="menu_tour_reorder">Touren ordnen</string>
<string name="tour_reorder_dialog_hint">Reihenfolge der Touren festlegen. Pfeile zum Verschieben.</string>
<string name="reorder_blocked_hint_short">Sortieren während GPS/Aufzeichnung deaktiviert</string>
<!-- Drag-and-Drop Reihenfolge (v2.0.2) -->
<string name="drag_handle_cd">Ziehgriff zum Verschieben</string>
<string name="drag_reorder_hint_short">Ziehgriff halten und verschieben</string>
<!-- Wake Lock / Display aktiv halten -->
<string name="menu_wake_lock">Display aktiv halten</string>
<string name="wake_lock_title">Display aktiv halten</string>
<string name="wake_lock_description">Verhindert, dass sich das Display während einer Tour sperrt. Erhöht den Akkuverbrauch.</string>
<string name="wake_lock_mode_never">Nie</string>
<string name="wake_lock_mode_foreground">Immer wenn App im Vordergrund</string>
<string name="wake_lock_mode_active_tour">Nur bei aktiver Tour/GPS</string>
<string name="wake_lock_battery_hint_title">Hinweis zum Akkuverbrauch</string>
<string name="wake_lock_battery_hint_msg">Wenn das Display dauerhaft an bleibt, steigt der Akkuverbrauch deutlich. Schließe das Telefon bei längeren Touren möglichst an eine Ladequelle an.</string>
<string name="wake_lock_battery_hint_ok">Verstanden</string>
<!-- Karten-Provider Auswahl -->
<string name="map_provider_menu">Karten-Anbieter</string>
<string name="map_provider_title">Karten-Anbieter wählen</string>
<string name="map_provider_osm">OpenStreetMap (Standard)</string>
<string name="map_provider_carto_positron">CartoDB Positron (hell)</string>
<string name="map_provider_carto_dark">CartoDB Dark Matter (dunkel)</string>
<string name="map_provider_topo">OpenTopoMap (topografisch)</string>
<string name="map_provider_esri_satellite">ESRI Satellitenbild</string>
<string name="map_provider_esri_hybrid">ESRI Satellit + Beschriftung</string>
<string name="map_provider_stadia_alidade">Stadia Alidade</string>
<string name="map_provider_stadia_outdoors">Stadia Outdoors</string>
<!-- PTT-Aufnahme (v2.1.0) -->
<string name="ptt_settings_section">Live / Aufnahme</string>
<string name="ptt_live_output_toggle">Live-Ausgabe</string>
<string name="ptt_live_output_desc">Mikrofon wird über das Ausgabegerät hörbar gemacht.</string>
<string name="ptt_recording_toggle">Aufnahme speichern</string>
<string name="ptt_recording_desc">PTT-Mitschnitt wird als Audiodatei gespeichert (M4A/AAC).</string>
<string name="ptt_format_label">Aufnahme-Format</string>
<string name="ptt_format_m4a">M4A/AAC</string>
<string name="ptt_format_wav">WAV (in v2.1.0 nicht verfügbar)</string>
<string name="ptt_validation_both_off">Mindestens „Live-Ausgabe" oder „Aufnahme speichern" muss aktiv sein. Live wurde automatisch aktiviert.</string>
<string name="ptt_recordings_button">Aufnahmen…</string>
<string name="ptt_recording_status">Aufnahme läuft</string>
<string name="ptt_recording_saved_title">Aufnahme gespeichert</string>
<string name="ptt_recording_saved_name">Datei: %1$s</string>
<string name="ptt_recording_saved_duration">Dauer: %1$s</string>
<string name="ptt_recording_saved_gps">GPS: %1$.5f, %2$.5f</string>
<string name="ptt_recording_assign_waypoint">Als Waypoint-Audio zuweisen</string>
<string name="ptt_recording_delete">Löschen</string>
<string name="ptt_recording_ok">OK</string>
<string name="ptt_recordings_title">PTT-Aufnahmen</string>
<string name="ptt_recordings_empty">Noch keine Aufnahmen.\nAktiviere „Aufnahme speichern" in den PTT-Einstellungen und starte PTT.</string>
<string name="ptt_recording_meta">%1$s · %2$s</string>
<string name="ptt_recording_delete_confirm_title">Aufnahme löschen?</string>
<string name="ptt_recording_delete_confirm_msg">„%1$s" wird unwiderruflich gelöscht.</string>
<string name="ptt_waypoint_chooser_title">Wegpunkt auswählen</string>
<string name="ptt_waypoint_chooser_empty">Keine Wegpunkte in dieser Tour.</string>
<string name="ptt_waypoint_chooser_empty_hint">Du kannst die Aufnahme direkt als neuen Wegpunkt in der gewählten Tour anlegen.</string>
<string name="ptt_waypoint_chooser_tour_label">Tour: %1$s</string>
<string name="ptt_waypoint_chooser_create_new">Neuen Wegpunkt aus Aufnahme erstellen</string>
<string name="ptt_waypoint_chooser_created">Wegpunkt „%1$s" in Tour „%2$s" angelegt.</string>
<string name="ptt_waypoint_chooser_assigned">Audio dem Wegpunkt „%1$s" zugewiesen.</string>
<string name="ptt_recording_preview_play">Anhören</string>
<string name="ptt_recording_preview_pause">Pause</string>
<string name="ptt_recording_autosaved_toast">PTT-Aufnahme gespeichert: %1$s (%2$s)</string>
<string name="close">Schließen</string>
<!-- v2.3.1 — Waypoint dialog play action -->
<string name="explorer_action_play_clip">Clip abspielen</string>
<string name="explorer_action_pause_clip">Clip pausieren</string>
<string name="explorer_action_no_clip">Kein Clip hinterlegt</string>
<!-- v2.3.1 — Compact Map info & FAB labels -->
<string name="explorer_status_compact">Karte · %1$d Wegpunkte</string>
<string name="explorer_status_recording_compact">REC · %1$d</string>
<string name="explorer_status_draft_compact">Draft · %1$d/%2$d</string>
<string name="explorer_fab_track_short">REC</string>
<string name="explorer_fab_stop_short">Stop</string>
<!-- v2.4.0 — Clip-length lines: jetzt nur noch entlang einer bekannten
Geometrie (aufgezeichneter Track/Draft oder berechnete Route).
Ohne Geometrie wird nur Trigger-Radius/Dauer-Badge angezeigt. -->
<string name="map_layers_audio_lines">Clipabschnitte entlang Route/Track</string>
<string name="map_layers_audio_lines_hint">Wird nur gezeichnet, wenn ein aufgezeichneter Track/Draft oder eine berechnete Route vorliegt — keine freien Projektionen mehr.</string>
<string name="map_layers_audio_lines_legend_short">≤ 15s kurz</string>
<string name="map_layers_audio_lines_legend_medium">1560s mittel</string>
<string name="map_layers_audio_lines_legend_long">≥ 60s lang</string>
<!-- v2.4.0 — MapLibre Engine Cutover -->
<string name="explorer_renderer_maplibre_badge">MapLibre · Engine v2.4</string>
<!-- v2.3.1 — Routing/POI MVP -->
<string name="routing_poi_open">Routing &amp; POI</string>
<string name="routing_poi_title">Routing &amp; Online-POI (MVP)</string>
<string name="routing_poi_search_label">POI-Suche (online)</string>
<string name="routing_poi_search_hint">z. B. „bäckerei", „museum", „bahnhof"</string>
<string name="routing_poi_search_button">Suchen</string>
<string name="routing_poi_search_loading">Suche läuft …</string>
<string name="routing_poi_search_no_results">Keine POIs gefunden.</string>
<string name="routing_poi_search_error">Suche fehlgeschlagen — bitte Verbindung/Server prüfen.</string>
<string name="routing_poi_search_attribution">POI-Quelle: OpenStreetMap · Overpass/Nominatim · Bitte sparsam abfragen.</string>
<string name="routing_poi_add_waypoint">Als Wegpunkt</string>
<string name="routing_poi_add_storyboard">Ins Storyboard</string>
<string name="routing_poi_distance">%1$s entfernt</string>
<string name="routing_endpoint_label">Routing-Endpunkt (OSRM-kompatibel)</string>
<string name="routing_endpoint_hint">URL, z. B. https://router.project-osrm.org (Demo, Limits) oder eigene self-hosted Instanz.</string>
<string name="routing_endpoint_save">Speichern</string>
<string name="routing_endpoint_not_configured">Routing-Endpunkt nicht konfiguriert — Storyboard-Linie wird als Luftlinie/Planlinie angezeigt.</string>
<string name="routing_endpoint_demo_warning">Demo-OSRM-Server (router.project-osrm.org) hat Rate-Limits und ist nicht für Produktion gedacht.</string>
<string name="routing_compute_button">Route berechnen</string>
<string name="routing_compute_loading">Route wird berechnet …</string>
<string name="routing_compute_error">Routenberechnung fehlgeschlagen — Endpunkt prüfen.</string>
<string name="routing_compute_no_points">Mindestens zwei Wegpunkte im Storyboard nötig.</string>
<string name="routing_compute_success">Route geladen (%1$s · %2$s).</string>
<string name="routing_planline_hint">Planlinie (Luftlinie). Für echte Straßenrouten Endpunkt konfigurieren.</string>
<!-- v2.4.2 — Explorer-Routenplaner -->
<string name="route_planner_title">Routenplanung</string>
<string name="route_planner_subtitle">Start, Via-Punkte und Ziel wählen, Route berechnen oder Planlinie nutzen.</string>
<string name="route_planner_open">Routenplanung öffnen</string>
<string name="route_planner_open_short">Routenplaner</string>
<string name="route_planner_section_start">Start</string>
<string name="route_planner_section_via">Via-Stopps</string>
<string name="route_planner_section_destination">Ziel</string>
<string name="route_planner_empty_start">Noch kein Start gewählt</string>
<string name="route_planner_empty_destination">Noch kein Ziel gewählt</string>
<string name="route_planner_empty_via">Keine Via-Stopps. Beliebige Zwischenstopps lassen sich oben hinzufügen.</string>
<string name="route_planner_choose_start">Start wählen</string>
<string name="route_planner_choose_destination">Ziel wählen</string>
<string name="route_planner_add_via">Via-Stopp hinzufügen</string>
<string name="route_planner_clear">Plan zurücksetzen</string>
<string name="route_planner_compute">Route berechnen</string>
<string name="route_planner_planline">Nur Planlinie zeigen</string>
<string name="route_planner_planline_active">Planlinie wird angezeigt.</string>
<string name="route_planner_planline_hint">Planlinie ist eine Luftlinie zwischen den Stops — sie folgt keinen Straßen.</string>
<string name="route_planner_route_summary">Route: %1$s · %2$s</string>
<string name="route_planner_planline_summary">Planlinie: %1$s (Luftlinie)</string>
<string name="route_planner_save_as_tour">Als Tour-Entwurf speichern</string>
<string name="route_planner_save_as_tour_done">Tour-Entwurf %1$s gespeichert.</string>
<string name="route_planner_save_as_tour_error">Tour-Entwurf konnte nicht gespeichert werden.</string>
<string name="route_planner_save_as_tour_dialog_title">Tour-Entwurf speichern</string>
<string name="route_planner_save_as_tour_dialog_hint">Name der neuen Tour</string>
<string name="route_planner_add_stops_as_waypoints">Punkte als Wegpunkte übernehmen</string>
<string name="route_planner_add_stops_done">%1$d Wegpunkte hinzugefügt.</string>
<string name="route_planner_no_route">Noch keine berechnete Route. Endpunkt konfigurieren und berechnen, oder Planlinie nutzen.</string>
<string name="route_planner_endpoint_not_configured">Routing-Endpunkt nicht konfiguriert — nur Planlinie verfügbar.</string>
<string name="route_planner_endpoint_configure">Endpunkt einrichten</string>
<string name="route_planner_status_ready">Bereit. Stops wählen und berechnen.</string>
<string name="route_planner_role_start">Start</string>
<string name="route_planner_role_via">Via %1$d</string>
<string name="route_planner_role_destination">Ziel</string>
<string name="route_planner_picker_title">Quelle wählen</string>
<string name="route_planner_picker_current_location">Aktueller Standort</string>
<string name="route_planner_picker_current_location_unavailable">Aktueller Standort: noch keine Position</string>
<string name="route_planner_picker_map_center">Kartenmitte</string>
<string name="route_planner_picker_waypoint">Vorhandenen Wegpunkt wählen</string>
<string name="route_planner_picker_waypoint_empty">Keine Wegpunkte in dieser Tour</string>
<string name="route_planner_picker_poi">Online-POI suchen</string>
<string name="route_planner_picker_manual">Eigene Koordinate eingeben</string>
<string name="route_planner_picker_search">Ortssuche (Nominatim)</string>
<string name="route_planner_manual_title">Koordinate eingeben</string>
<string name="route_planner_manual_lat">Breitengrad (Lat)</string>
<string name="route_planner_manual_lon">Längengrad (Lon)</string>
<string name="route_planner_manual_apply">Übernehmen</string>
<string name="route_planner_manual_invalid">Ungültige Koordinaten.</string>
<string name="route_planner_remove_via">Via entfernen</string>
<string name="route_planner_move_via_up">Nach oben</string>
<string name="route_planner_move_via_down">Nach unten</string>
<string name="route_planner_label_current_location">Mein Standort</string>
<string name="route_planner_label_map_center">Kartenmitte</string>
<string name="route_planner_label_manual">Eigene Koordinate</string>
<string name="route_planner_status_error">Routing fehlgeschlagen — Endpunkt prüfen.</string>
<string name="route_planner_status_error_with_detail">Routing fehlgeschlagen: %1$s — Endpunkt oder Netzwerk prüfen.</string>
<string name="route_planner_status_endpoint_missing">Kein Routing-Endpunkt gesetzt. Mit „OSRM-Demo nutzen\" startest du sofort einen Testlauf, oder richte unter „Endpunkt einrichten\" einen eigenen Server ein.</string>
<string name="route_planner_use_demo_endpoint">OSRM-Demo nutzen</string>
<string name="route_planner_use_demo_endpoint_hint">Setzt einen öffentlichen OSRM-Testserver (router.project-osrm.org). Nur für Tests/Demo, Rate-Limits, keine Garantie. Bitte sparsam verwenden.</string>
<string name="route_planner_demo_endpoint_set">Demo-Endpunkt aktiv (router.project-osrm.org). Jetzt „Route berechnen\" drücken.</string>
<string name="routing_endpoint_use_demo">OSRM-Demo-URL einsetzen</string>
<string name="routing_endpoint_accepted_formats">Erlaubt: Basis-URL (https://router.project-osrm.org) oder vollständig mit Pfad /route/v1/driving. Wird intern normalisiert.</string>
<string name="route_planner_layer_section">Routen-Darstellung</string>
<string name="route_planner_layer_show">Route/Planlinie anzeigen</string>
<string name="route_planner_layer_hint">Wenn aktiviert, werden berechnete Route, Planlinie und Stop-Marker auf der Karte gezeichnet.</string>
<string name="route_planner_legend_route">Berechnete Route</string>
<string name="route_planner_legend_planline">Planlinie (Luftlinie)</string>
<string name="route_planner_set_as_start">Als Start</string>
<string name="route_planner_set_as_via">Als Via</string>
<string name="route_planner_set_as_destination">Als Ziel</string>
<string name="route_planner_poi_use">Punkt verwenden</string>
<string name="route_planner_poi_explanation">Treffer wird als Stopp dem Routenplaner hinzugefügt.</string>
<string name="route_planner_use_searched">Gesuchten Ort übernehmen</string>
<string name="route_planner_attribution">Routing optional via OSRM-kompatiblem Endpunkt — sparsam abfragen.</string>
<!-- v2.5.2 — Routingprofile (Auto/Fahrrad/Fußgänger) -->
<string name="route_planner_profile_section">Fortbewegungsmittel</string>
<string name="route_planner_profile_driving">Auto</string>
<string name="route_planner_profile_cycling">Fahrrad</string>
<string name="route_planner_profile_walking">Fußgänger</string>
<string name="route_planner_profile_hint">Wird für die Routing-Anfrage verwendet (driving/cycling/walking) und mit der gespeicherten Tourroute übernommen.</string>
<string name="route_planner_status_error_with_profile">Routing für „%1$s\" fehlgeschlagen: %2$s — Endpunkt oder Netzwerk prüfen.</string>
<string name="route_planner_status_endpoint_missing_with_profile">Kein Routing-Endpunkt gesetzt (Profil: %1$s). Mit „OSRM-Demo nutzen\" startest du sofort einen Testlauf, oder richte unter „Endpunkt einrichten\" einen eigenen Server ein.</string>
<string name="tour_route_profile_label">Profil: %1$s</string>
<!-- v2.4.6 — Tourrouten (persistente Route pro Tour) -->
<string name="tour_route_status_title">Tourroute „%1$s"</string>
<string name="tour_route_status_none">Keine Tourroute gespeichert.</string>
<string name="tour_route_updated_at">aktualisiert %1$s</string>
<string name="tour_route_source">Quelle: %1$s</string>
<string name="tour_route_save">Tourroute speichern</string>
<string name="tour_route_replace">Tourroute ersetzen</string>
<string name="tour_route_delete">Tourroute löschen</string>
<string name="tour_route_hint_compute_first">Erst Route berechnen oder Planlinie zeigen — dann lässt sich die Tourroute dauerhaft speichern.</string>
<string name="tour_route_saved_msg">Tourroute für „%1$s" gespeichert.</string>
<string name="tour_route_replaced_msg">Tourroute für „%1$s" ersetzt.</string>
<string name="tour_route_deleted_msg">Tourroute für „%1$s" gelöscht.</string>
<string name="tour_route_loaded_status">Gespeicherte Tourroute geladen (%1$s).</string>
<string name="tour_route_source_planline">Planlinie</string>
<string name="tour_route_source_track">GPS-Track</string>
<!-- v2.5.3 — Manueller Routemodus im Explorer -->
<string name="explorer_mode_station">Station hinzufügen</string>
<string name="explorer_mode_draw_route">Route zeichnen</string>
<string name="explorer_mode_draw_route_active">Route zeichnen: Tippe auf die Karte, um den Wegverlauf zu setzen. Nur Stationen spielen Audio ab.</string>
<string name="explorer_mode_draw_route_hint">Route zeichnen: Wegverlauf setzen. Nur Stationen spielen Audio ab.</string>
<string name="explorer_route_point_dialog_title">Routenpunkt</string>
<string name="explorer_route_point_move">Verschieben</string>
<string name="explorer_route_point_delete">Löschen</string>
<string name="explorer_route_point_move_active">Tippe die neue Position an — der Routenpunkt wird dorthin verschoben.</string>
<string name="explorer_manual_route_badge">Manuell gezeichnete Route</string>
<string name="explorer_manual_route_cleared">Manuelle Route gelöscht.</string>
<string name="explorer_manual_route_saved">Manuelle Route gespeichert (%1$d Punkte).</string>
<string name="explorer_manual_route_clear">Manuelle Route löschen</string>
<string name="tour_route_source_manual">Manuell gezeichnet</string>
<string name="version_notice_body_v231">Neu in 2.3.1: Wegpunkt-Dialog spielt Clips jetzt direkt ab statt „auf Kartenmitte verschieben". Karten-UI deutlich entschlackt: nur noch Icon-FABs und kompakte Status-Chips. Clip-Längenlinien projizieren die Audiodauer als farbige Segmente auf die Karte (Layer schaltbar). Neue Routing &amp; Online-POI-Sektion: konfigurierbarer OSRM-Endpunkt + Overpass-POI-Suche (frei, sparsam). MapLibre-Pipeline weiter vorbereitet; aktives Rendering bleibt osmdroid.</string>
<!-- v2.5.9 — In-App Nutzungsbedingungen-Dialog -->
<string name="terms_dialog_title">Nutzungsbedingungen</string>
<string name="terms_dialog_intro">GPS2Audio ist source-available. Vor der ersten Nutzung musst du den folgenden Bedingungen zustimmen. Ohne Zustimmung kann die App nicht verwendet werden.</string>
<string name="terms_dialog_license_heading">Lizenz</string>
<string name="terms_dialog_license_body">Code, Branding und Assets von GPS2Audio stehen unter Apache License 2.0 mit Commons Clause License Condition v1.0. Drittanbieter-Bibliotheken behalten ihre jeweilige Originallizenz (siehe NOTICE / THIRD_PARTY_NOTICES).</string>
<string name="terms_dialog_commercial_heading">Erlaubt / nicht erlaubt</string>
<string name="terms_dialog_commercial_body">Private und nicht-kommerzielle Nutzung ist frei. Kommerzielle Nutzung insbesondere Verkauf, SaaS/Hosted-Service, bezahlte Touren (z. B. Bus-, Schiff-, Stadt-, Wander- oder Radführungen) sowie Integration in kommerzielle Angebote ist nur mit ausdrücklicher schriftlicher Genehmigung des Entwicklers zulässig.</string>
<string name="terms_dialog_liability_heading">Haftungsausschluss</string>
<string name="terms_dialog_liability_body">Die App wird ohne Gewährleistung bereitgestellt. GPS2Audio ist kein Sicherheits- oder Navigationssystem. Nutze die App nicht während Tätigkeiten, bei denen Unaufmerksamkeit zu Personen- oder Sachschäden führen kann.</string>
<string name="terms_dialog_version_label">Version 2.5.11-terms-1 · Vollständiger Text in TERMS.md</string>
<string name="terms_dialog_accept">Ich stimme zu</string>
<string name="terms_dialog_decline">Ablehnen und beenden</string>
<!-- v2.5.13 — Hintergrundwiedergabe absichern (Xiaomi/HyperOS-Hilfe) -->
<string name="bg_reliability_title">Hintergrundwiedergabe absichern</string>
<string name="bg_reliability_intro">Damit die Tour-Wiedergabe auch im Standby weiterläuft, empfehlen wir kurz einen Blick in die Systemeinstellungen. Du kannst die Wiedergabe trotzdem sofort starten — der Hinweis erscheint nur einmal pro Version.</string>
<string name="bg_reliability_xiaomi_heading">Empfehlung für Xiaomi / Redmi / Poco (MIUI / HyperOS)</string>
<string name="bg_reliability_xiaomi_item_battery">Akku &amp; Leistung: GPS2Audio auf „Keine Einschränkungen“ stellen.</string>
<string name="bg_reliability_xiaomi_item_autostart">Hintergrund-Autostart für GPS2Audio erlauben.</string>
<string name="bg_reliability_xiaomi_item_lock">In der Recent-Apps-Übersicht das Schloss-Symbol über der GPS2Audio-Karte aktivieren.</string>
<string name="bg_reliability_xiaomi_item_notify">Benachrichtigungen für GPS2Audio erlauben.</string>
<string name="bg_reliability_generic_heading">Empfehlung</string>
<string name="bg_reliability_generic_item_battery">Akkuoptimierung für GPS2Audio deaktivieren.</string>
<string name="bg_reliability_generic_item_notify">Benachrichtigungen für GPS2Audio erlauben.</string>
<string name="bg_reliability_status_optimized">Status: Akkuoptimierung für GPS2Audio ist aktiv — Wiedergabe kann im Standby gestoppt werden.</string>
<string name="bg_reliability_status_ok">Status: Akkuoptimierung für GPS2Audio ist ausgenommen.</string>
<string name="bg_reliability_btn_battery">Akkuoptimierung öffnen</string>
<string name="bg_reliability_btn_app">App-Einstellungen öffnen</string>
<string name="bg_reliability_btn_notify">Benachrichtigungen öffnen</string>
<string name="bg_reliability_btn_autostart">MIUI-Autostart öffnen</string>
<string name="bg_reliability_dont_show_again">Für diese Version nicht mehr anzeigen</string>
<string name="bg_reliability_understood">Verstanden</string>
<string name="bg_reliability_settings_entry">Hintergrundwiedergabe</string>
<string name="bg_reliability_settings_entry_subtitle">Tipps, falls die Wiedergabe im Standby stoppt</string>
<!-- v2.5.24 — Lizenzimport-Fallbacks -->
<!-- Primärer Dateiimport: Button-Label unverändert -->
<!-- Fallback-Textimport -->
<string name="about_license_action_paste">Lizenz aus Zwischenablage / Text einfügen</string>
<string name="about_license_paste_title">Lizenztext einfügen</string>
<string name="about_license_paste_hint">Fallback für Geräte, auf denen der Datei-Import nicht funktioniert. Öffne die .gps2audio-license-Datei in einem Texteditor, kopiere den gesamten Inhalt und füge ihn hier ein.</string>
<string name="about_license_paste_field_label">JSON-Lizenztext</string>
<string name="about_license_paste_from_clipboard">Aus Zwischenablage einfügen</string>
<string name="about_license_paste_clipboard_empty">Zwischenablage ist leer.</string>
<string name="about_license_paste_empty_error">Kein Text eingegeben.</string>
<string name="about_license_paste_import_button">Importieren</string>
<!-- Diagnose-Dialog bei Totalausfall aller Lesestrategien -->
<string name="about_license_import_err_diag_title">Datei konnte nicht gelesen werden</string>
<string name="about_license_import_err_diag_intro">Alle Lesestrategien sind fehlgeschlagen. Technische Details:</string>
<string name="about_license_import_err_diag_hint">Tipp: Versuche den Textimport ("Lizenz aus Zwischenablage / Text einfügen") als Alternative.</string>
<!-- Version-Notice v2.5.24 -->
<string name="version_notice_body_v2524">Neu in 2.5.24: Lizenzimport-Fallbacks. Datei-Import nutzt jetzt drei Lesestrategien (openInputStream, openFileDescriptor, File-Fallback). Bei Totalausfall erscheint ein Diagnose-Dialog mit technischem Fehlerdetail statt eines generischen Fehlers. Neuer Textimport-Button: JSON-Inhalt der Lizenzdatei direkt einfügen (Fallback für MIUI/OEM-Dateimanager). Dateiauswahl nutzt OpenDocument() mit nur "*/*" für maximale Datei-Browser-Kompatibilität. importNonce-Mechanismus aus v2.5.23 unverändert beibehalten. Alle anderen Funktionen unverändert.</string>
<!-- Version-Notice v2.5.29 -->
<string name="version_notice_body_v2537">Neu in 2.5.37: Explorer Map Styles. Alle Kartenstile nutzen jetzt echte MapLibre-Vektordaten via OpenFreeMap (kostenlos, kein API-Key). Modern: OpenFreeMap Positron (hell, reduziert). Dunkel: OpenFreeMap Dark Matter (echter Vektor-Dark-Style). Outdoor: OpenFreeMap Liberty (Gelände, Wege, Grünflächen). 3D Stadt: Positron + MapLibre fill-extrusion für Gebäude (Camera-Pitch einstellbar; bei fehlendem building-Layer: 2D-Fallback ohne Absturz). Klassisch: OSM-Rastertiles als Fallback. Satellit nicht mehr im UI. Stilwahl wird persistent gespeichert. Attribution bleibt sichtbar.</string>
<string name="version_notice_body_v2529">Neu in 2.5.29: Lizenz-Persistenz-Fix. Eine importierte Lizenz überlebt jetzt normale APK-Updates zuverlässig. Ursache: Nach einem Update wurde der Lizenzstatus erst beim Öffnen des Über-Dialogs geladen — die Lizenzanzeige zeigte daher nach dem Update kurzzeitig „Keine Lizenz importiert", obwohl die Datei intakt war. Fix: Lizenz wird jetzt beim App-Start im Hintergrund geladen (WaypointApp.onCreate). Zusätzlich wird das Lizenz-JSON redundant in den SharedPreferences gesichert (self-healing Fallback). Explicit „Lizenz entfernen" löscht weiterhin beide Speicher. Backup-Ausschluss (kein Cloud-Backup) bleibt unverändert. Alle anderen Funktionen unverändert.</string>
<!-- v2.5.9 — Erweiterter About-Block (Apache 2.0 + Commons Clause sichtbar) -->
<string name="about_license_commons_clause_label">Zusatzklausel</string>
<string name="about_license_commons_clause_name">Commons Clause License Condition v1.0</string>
<string name="about_license_commons_clause_summary">Die Apache 2.0-Rechte schließen ausdrücklich NICHT das Recht ein, GPS2Audio zu verkaufen oder als kommerziellen/gehosteten Dienst (SaaS, bezahlte Touren, B2B-Whitelabel) anzubieten. Für solche Nutzungen ist eine separate schriftliche Genehmigung erforderlich.</string>
<string name="about_third_party_label">Drittanbieter-Komponenten</string>
<string name="about_third_party_text">Drittanbieter-Bibliotheken (AndroidX, Material3, ExoPlayer/Media3, osmdroid, MapLibre, Gson u. a.) bleiben unter ihren jeweiligen Originallizenzen. Siehe NOTICE.md / THIRD_PARTY_NOTICES.md im Quellpaket.</string>
<!-- v2.5.38 — Kartenattribution (legal, kompakt + Info-Dialog) -->
<string name="map_attribution_compact">© OpenStreetMap · ODbL</string>
<string name="map_attribution_badge_label">Karteninfo</string>
<string name="map_info_dialog_title">Karteninfo</string>
<string name="map_info_style_label">Kartenstil</string>
<string name="map_info_style_source">OpenFreeMap / OpenMapTiles</string>
<string name="map_info_data_label">Kartendaten</string>
<string name="map_info_data_text">© OpenStreetMap-Mitwirkende, ODbL</string>
<string name="map_info_links_label">Quellen</string>
<string name="map_info_link_ofm">openfreemap.org</string>
<string name="map_info_link_omt">openmaptiles.org</string>
<string name="map_info_link_osm">openstreetmap.org/copyright</string>
<string name="map_info_close">Schließen</string>
<string name="map_info_open">Karteninfo</string>
<!-- v2.5.38 — POI-Layer-Kategorien -->
<string name="poi_section_title">POI anzeigen</string>
<string name="poi_section_subtitle">Kategorien aktivieren, dann unten auf der Karte „POIs laden“ tippen. Daten aus OpenStreetMap/Overpass — kein API-Key, rate-limit-freundlich.</string>
<string name="poi_cat_sights">Sehenswürdigkeiten</string>
<string name="poi_cat_infrastructure">Infrastruktur</string>
<string name="poi_cat_gastronomy">Gastronomie</string>
<string name="poi_cat_mobility">Mobilität</string>
<string name="poi_unavailable_classic">POI-Layer nur bei Vektor-Stilen (Modern, Dunkel, Outdoor, 3D Stadt) verfügbar. Im Klassisch-Modus (Raster) nicht anwendbar.</string>
<!-- v2.5.38 — Version Notice -->
<string name="version_notice_body_v2538">Neu in 2.5.38: Kartenattribution rechtlich sauber umgesetzt — kompaktes Badge unten links mit Link auf openstreetmap.org/copyright, Info-Dialog über Karteninfo-Schaltfläche mit vollständigen Angaben (OpenFreeMap, OpenMapTiles, OSM-Mitwirkende, ODbL). POI-Layer im Explorer eingebaut: Sehenswürdigkeiten, Infrastruktur, Gastronomie, Mobilität — schaltbar per Chips im Ebenen-Sheet, Auswahl persistent gespeichert. Kartenstil-Auswahl wird zuverlässig über App-Neustarts hinweg persistiert (DataStore). Alle Stile, Phantom-Layer-Fix, Lizenzsystem, Multi-Clip, PTT, Tour-Zähler, Backup, GPX-Import, Routing unverändert.</string>
<!-- v2.5.40 — Explorer POI / Kompass / Toolbar -->
<string name="explorer_poi_load_button">POIs im Kartenbereich laden</string>
<string name="explorer_poi_loading">POIs werden geladen …</string>
<string name="explorer_poi_none_found">Keine POIs in diesem Bereich gefunden.</string>
<string name="explorer_poi_radius_clamped">Kartenausschnitt zu groß Abfrage auf max. 5 km Radius begrenzt.</string>
<string name="explorer_compass_button">Nach Norden ausrichten</string>
<string name="explorer_poi_detail_category">Kategorie</string>
<string name="explorer_poi_detail_distance">Entfernung</string>
<string name="explorer_poi_detail_tags">OSM-Tags</string>
<string name="explorer_poi_detail_attribution">POI-Daten: OpenStreetMap/Overpass (ODbL)</string>
<string name="explorer_poi_detail_add_waypoint">Als Wegpunkt</string>
<string name="explorer_poi_detail_close">Schließen</string>
<string name="explorer_poi_added_snack">als Wegpunkt angelegt.</string>
<string name="version_notice_body_v2540">Neu in 2.5.40: Echter Explorer-POI-Layer über Overpass-API — Sehenswürdigkeiten, Gastronomie, Mobilität, Infrastruktur direkt aus OpenStreetMap laden (kostenlos, kein API-Key, rate-limit-freundlich, nur auf Nutzer-Aktion). Antippen eines POI-Markers öffnet Detail-Sheet mit Name, Kategorie, Entfernung, Tags und Option „Als Wegpunkt“. Kompass-Button in der rechten Werkzeugleiste: setzt Karten-Ausrichtung auf Norden (Bearing 0) mit Animation. Rechte Werkzeugleiste neu geordnet — kein Überlappen der Buttons mehr. Alle v2.5.39-Fixes (Scroll, Attribution, Kartenstil-Persistenz) bleiben erhalten.</string>
</resources>
+13
View File
@@ -1,4 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!--
v2.5.8 — Splash-Theme. Wir benutzen androidx.core.splashscreen, damit
auch API 26-30 dieselbe ruhige Intro-Optik wie API 31+ bekommen. Der
Splash-Hintergrund ist GPS2Audio-Navy (#0D1B2A), das Logo ist die
Map-Pin-/Wellen-Marke. postSplashScreenTheme wechselt nach dem Intro
auf das eigentliche App-Theme (unverändert NoActionBar).
-->
<style name="Theme.WaypointAudioGuide.Splash" parent="Theme.SplashScreen">
<item name="windowSplashScreenBackground">@color/splash_background</item>
<item name="windowSplashScreenAnimatedIcon">@drawable/ic_splash_logo</item>
<item name="postSplashScreenTheme">@style/Theme.WaypointAudioGuide</item>
</style>
<style name="Theme.WaypointAudioGuide" parent="android:Theme.Material.Light.NoActionBar" />
</resources>
+17
View File
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
v2.5.29 — Legacy-Backup-Regeln (Android < 12).
Lizenzdatei und Geräte-ID-SharedPreferences werden vom AutoBackup ausgeschlossen.
- license/: App-interne Lizenzdatei (bewusster Ausschluss, separater Export).
- gps2audio_license_device.xml: Geräte-UUID-SharedPreferences.
- gps2audio_license_store.xml: v2.5.29 — Fallback-Kopie des Lizenz-JSON.
WICHTIG: Backup-Ausschluss betrifft NUR den automatischen Cloud-Backup.
APK-Updates lassen App-Daten (filesDir, SharedPreferences) grundsätzlich unangetastet.
Der Ausschluss hier führt NICHT dazu, dass normale Updates die Lizenz löschen.
-->
<full-backup-content>
<exclude domain="file" path="license/" />
<exclude domain="sharedpref" path="gps2audio_license_device.xml" />
<exclude domain="sharedpref" path="gps2audio_license_store.xml" />
</full-backup-content>
@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
v2.5.29 — Datenextraktions-/Backup-Regeln.
Lizenzdatei, Geräte-ID und Lizenz-Fallback-Preferences werden vom
automatischen Android-Backup ausgeschlossen.
- file/license/: Lizenzdatei. Soll separat über den Lizenzbereich der App
exportiert/importiert werden, nicht als Beifang eines Gerätebackups.
- sharedpref/gps2audio_license_device: Geräte-UUID für Lizenz-Bindung.
Bewusst nicht im Cloud-Backup — wenn der Nutzer ein neues Gerät einrichtet,
erhält es eine eigene Geräte-ID.
- sharedpref/gps2audio_license_store: v2.5.29 — Fallback-Kopie des Lizenz-JSON.
Bewusst vom Backup ausgeschlossen (wie primäre Lizenzdatei).
WICHTIG: Backup-Ausschluss betrifft NUR den automatischen Cloud-Backup
(Google One / device-transfer). APK-Updates lassen App-Daten grundsätzlich
unangetastet. Die hier konfigurierten Ausschlüsse führen NICHT dazu, dass
normale Updates die Lizenz löschen.
-->
<data-extraction-rules>
<cloud-backup>
<exclude domain="file" path="license/" />
<exclude domain="sharedpref" path="gps2audio_license_device" />
<exclude domain="sharedpref" path="gps2audio_license_store" />
</cloud-backup>
<device-transfer>
<exclude domain="file" path="license/" />
<exclude domain="sharedpref" path="gps2audio_license_device" />
<exclude domain="sharedpref" path="gps2audio_license_store" />
</device-transfer>
</data-extraction-rules>
+4 -4
View File
@@ -1,5 +1,5 @@
android.useAndroidX=true
android.nonTransitiveRClass=true
android.suppressUnsupportedCompileSdk=35
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
org.gradle.parallel=true
android.enableJetifier=true
org.gradle.jvmargs=-Xmx3g -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError
org.gradle.parallel=false
org.gradle.workers.max=1
+8
View File
@@ -13,6 +13,12 @@ navigationCompose = "2.8.2"
osmdroid = "6.1.20"
ksp = "2.0.21-1.0.25"
media3 = "1.4.1"
splashscreen = "1.0.1"
# MapLibre Native Android SDK — wird in v2.2.2 als Abhängigkeit eingezogen,
# damit künftige Schritte (Vektor-Tiles, 3D-Extrusions, Style-Layer) ohne
# weitere Gradle-Änderung zugänglich sind. Das aktive Rendering läuft in
# v2.2.2 weiterhin über osmdroid (Raster), siehe ExplorerScreen.kt.
maplibre = "11.5.0"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -33,8 +39,10 @@ kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-
kotlinx-coroutines-play-services = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-play-services", version.ref = "coroutines" }
play-services-location = { group = "com.google.android.gms", name = "play-services-location", version.ref = "locationServices" }
osmdroid = { group = "org.osmdroid", name = "osmdroid-android", version.ref = "osmdroid" }
maplibre-android-sdk = { group = "org.maplibre.gl", name = "android-sdk", version.ref = "maplibre" }
media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3" }
media3-common = { group = "androidx.media3", name = "media3-common", version.ref = "media3" }
androidx-core-splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "splashscreen" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
+3 -1
View File
@@ -1,5 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+2
View File
@@ -0,0 +1,2 @@
#!/bin/bash
exec /home/user/tools/gradle/bin/gradle "$@"
@@ -0,0 +1,239 @@
# GPS2Audio v2.5.44 — Handoff: POI Category Fix
**Version:** 2.5.44 · versionCode 98
**Basis:** v2.5.43 (Canvas-Piktogramme, versionCode 97)
**Datum:** 2025-05-30
---
## Artefakte
| Datei | Beschreibung |
|---|---|
| `GPS2Audio_v2_5_44_poi_category_fix_universal_signed.apk` | Signierte Universal-Release-APK (57 MB) |
| `GPS2Audio_v2_5_44_poi_category_fix_source.zip` | Vollständiger Quellcode (ohne Keystore/Credentials) |
| `GPS2Audio_v2_5_44_poi_category_fix_handoff.md` | Dieses Dokument |
**APK-Prüfung:**
- Signatur: APK Signature Scheme v2 ✓ (Verified)
- applicationId: `de.waypointaudio`
- versionCode: 98
- versionName: 2.5.44
---
## Root Cause
### Primärer Bug: Falsche Default-Einstellungen in `PoiLayerSettings`
In `data/MapStyleSettings.kt` war der Default für alle Nicht-SIGHTS-Kategorien `false`:
```kotlin
// v2.5.43 (FALSCH):
data class PoiLayerSettings(
val showSights: Boolean = true,
val showInfrastructure: Boolean = false, // ← Bug: default aus
val showGastronomy: Boolean = false, // ← Bug: default aus
val showMobility: Boolean = false, // ← Bug: default aus
val showPoiNames: Boolean = false,
)
```
Erstnutzer oder Nutzer ohne gespeicherte Preferences sahen deshalb **nur Sehenswürdigkeiten** (SIGHTS). Da `PoiLayerSettings.showSights=true` und der SIGHTS-Filter `historic=*` enthält, wurden ausschließlich historische/touristische POIs geladen und angezeigt.
### Sekundärer Bug: `humanizeTag()` filterte namenlose Nicht-SIGHTS-POIs heraus
In `ExplorerPoiClient.humanizeTag()` wurde für die SIGHTS-Kategorie geprüft, ob der Label **nicht** "Sehenswürdigkeit" ist. Für GASTRONOMY/MOBILITY/INFRASTRUCTURE war der Rückgabewert korrekt. Allerdings bedeutete die Logik bei `tourism=attraction`-Elementen ohne Namen: `null → continue → überspringen`. Das betraf hauptsächlich namenlose Sehenswürdigkeiten, aber auch Grenzfälle mit mehreren Tags.
### Tertiärer Aspekt: Fehlende Kategorie-Diversität bei Default-Ladevorgang
Da nur SIGHTS geladen wurde und `historic=*` ein sehr breiter Wildcard-Filter ist, dominierten Denkmäler/Historisches die Ergebnisse — auch wenn theoretisch andere tourism-Werte dabei sein sollten.
---
## Geänderte Dateien
### 1. `app/src/main/kotlin/de/waypointaudio/data/MapStyleSettings.kt`
**Änderung 1:** Default-Werte in `PoiLayerSettings` auf `true` gesetzt:
```kotlin
// v2.5.44 (FIX):
data class PoiLayerSettings(
val showSights: Boolean = true,
val showInfrastructure: Boolean = true, // ← Fix: default an
val showGastronomy: Boolean = true, // ← Fix: default an
val showMobility: Boolean = true, // ← Fix: default an
val showPoiNames: Boolean = false,
)
```
**Änderung 2:** DataStore-Fallback ebenfalls auf `true` gesetzt (für Nutzer mit bestehender Installation aber noch nicht gespeicherten Preferences für diese Felder):
```kotlin
poiLayers = PoiLayerSettings(
showSights = prefs[KEY_POI_SIGHTS] ?: true,
showInfrastructure = prefs[KEY_POI_INFRASTRUCTURE] ?: true, // ← Fix
showGastronomy = prefs[KEY_POI_GASTRONOMY] ?: true, // ← Fix
showMobility = prefs[KEY_POI_MOBILITY] ?: true, // ← Fix
showPoiNames = prefs[KEY_POI_SHOW_NAMES] ?: false,
)
```
---
### 2. `app/src/main/kotlin/de/waypointaudio/util/ExplorerPoiClient.kt`
**Änderung 1 — `humanizeTag()` korrigiert:**
Namenlose POIs in GASTRONOMY/MOBILITY/INFRASTRUCTURE werden nun nicht mehr verworfen. Für SIGHTS bleibt die Filterlogik erhalten (generisches "Sehenswürdigkeit" ohne spezifischen Tag = überspringen), aber `tourism=*`-Tags werden jetzt ebenfalls als valide Fallback-Basis anerkannt:
```kotlin
private fun humanizeTag(tags: Map<String, String>, cat: PoiCategory): String? {
return when (cat) {
PoiCategory.SIGHTS -> {
val label = resolveCategoryLabel(tags, cat)
when {
tags["historic"] != null -> label // Denkmäler etc. immer behalten
tags["tourism"] != null -> label // viewpoint, museum etc.
tags["amenity"] in setOf("theatre","arts_centre","cinema","place_of_worship") -> label
else -> null // zu generisch → überspringen
}
}
// GASTRONOMY, MOBILITY, INFRASTRUCTURE: immer Fallback-Label zurückgeben
PoiCategory.GASTRONOMY -> resolveCategoryLabel(tags, cat)
PoiCategory.MOBILITY -> resolveCategoryLabel(tags, cat)
PoiCategory.INFRASTRUCTURE -> resolveCategoryLabel(tags, cat)
}
}
```
**Änderung 2 — `resolveCategoryLabel()` Priorität korrigiert:**
Die Kategorie-spezifischen Nicht-SIGHTS-Kategorien werden jetzt zuerst ausgewertet, bevor in den SIGHTS-Block gefallen wird:
```kotlin
private fun resolveCategoryLabel(tags: Map<String, String>, cat: PoiCategory): String {
// Prüfe zuerst spezifische Nicht-SIGHTS-Tags (GASTRONOMY/MOBILITY/INFRASTRUCTURE)
if (cat != PoiCategory.SIGHTS) {
when (cat) {
PoiCategory.GASTRONOMY -> return when (amenity) { ... }
PoiCategory.MOBILITY -> return when { ... }
PoiCategory.INFRASTRUCTURE -> return when (amenity) { ... }
else -> {}
}
}
// Dann SIGHTS-Mapping
return when (cat) { ... }
}
```
**Änderung 3 — `categoryFilters()` erweitert:**
Neue OSM-Tags hinzugefügt:
- SIGHTS: `amenity=place_of_worship`
- MOBILITY: `amenity=ferry_terminal`, `railway=tram_stop`, `aeroway=aerodrome`
- INFRASTRUCTURE: `amenity=drinking_water`, `amenity=shelter`
**Änderung 4 — `summarize()` Hilfsfunktion hinzugefügt:**
```kotlin
fun summarize(result: Map<PoiCategory, List<ExplorerPoiResult>>): String {
// Gibt z.B. "POIs: 23 geladen (12 Sehensw., 5 Gastro, 3 Mobilität, 3 Infra)" zurück
}
```
**Änderung 5 — Logcat-Zusammenfassung nach Laden:**
```
I/ExplorerPoiClient: POI-Load: 23 gesamt — 12 Sehenswürdigkeiten, 5 Gastronomie, 3 Mobilität, 3 Infrastruktur
```
**Änderung 6 — `humanizeHistoric()` erweitert:**
Neue Historic-Werte: `manor`, `wayside_cross`, `wayside_shrine`, `city_gate`, `locomotive`, `aircraft`, `ship`.
---
### 3. `app/src/main/kotlin/de/waypointaudio/ui/ExplorerScreenMapLibre.kt`
**Änderung — Snackbar-Text nach POI-Laden:**
Statt nur "Keine POIs gefunden" oder leer bei Erfolg wird nun die Zusammenfassung als Snackbar angezeigt:
```kotlin
explorerPoiSnackbar = if (total == 0) {
"Keine POIs in diesem Bereich gefunden."
} else {
ExplorerPoiClient.summarize(results)
}
// → "POIs: 23 geladen (12 Sehensw., 5 Gastro, 3 Mobilität, 3 Infra)"
```
---
## Erhaltene Funktionen (keine Regressionen)
Alle Funktionen aus v2.5.40v2.5.43 sind unverändert erhalten:
| Feature | Status |
|---|---|
| Echte Overpass-POIs im Explorer | ✓ unverändert |
| POI-Detail-Sheet (RoutingPoiSheet) | ✓ unverändert |
| POI als Wegpunkt übernehmen | ✓ unverändert |
| Kompass / Nordausrichtung | ✓ unverändert |
| Buttonlayout ohne Überlagerung | ✓ unverändert |
| Layer-Sheet scrollbar | ✓ unverändert |
| Karten-Attribution rechtskonform | ✓ unverändert |
| Persistenter Kartentyp / POI-Layer / POI-Namen-Toggle | ✓ unverändert |
| Phantom-Track-Fixes | ✓ unverändert |
| Routenplaner / Waypoints | ✓ unverändert |
| Lizenzsystem | ✓ unverändert |
| Multi-Clip / Sequenzplayer | ✓ unverändert |
| Canvas-Piktogramme (v2.5.43) | ✓ unverändert |
| POI-Legende (v2.5.41) | ✓ unverändert |
| POI-Namen-Labels Toggle (v2.5.42) | ✓ unverändert |
---
## Tests / Build-Checks
| Schritt | Ergebnis |
|---|---|
| Kotlin-Kompilierung (`assembleRelease`) | BUILD SUCCESSFUL |
| APK-Signatur-Verifikation (`apksigner verify`) | Verifies ✓ |
| APK Scheme v2 | Verified ✓ |
| applicationId | `de.waypointaudio` ✓ |
| versionCode | 98 ✓ |
| versionName | 2.5.44 ✓ |
| Runtime-Test | Nicht möglich (kein verbundenes Gerät im Build-Environment) |
---
## Bekannte Grenzen
1. **Nutzer mit gespeicherten Preferences (v2.5.38v2.5.43):** Wer aktiv Gastronomie/Mobilität/Infrastruktur auf `false` gesetzt hat, behält diese Einstellung (DataStore-Wert ist explizit `false`, Fallback greift nicht). Nur Nutzer, die **nie** die Kategorien geändert haben, bekommen das neue Default.
2. **Keine Laufzeit-Tests auf echtem Gerät:** Build-Environment hat keinen verbundenen Android-Emulator. POI-Abfragen wurden anhand der Overpass-Abfrage-Logik und Code-Review verifiziert.
3. **Overpass-Rate-Limits:** Bei gleichzeitigem Laden aller 4 Kategorien werden 4 serielle HTTP-Anfragen abgeschickt (je Kategorie eine). In Spitzenzeiten kann dies zu Timeouts führen (bestehende Logik, kein Regressionsrisiko).
4. **Cache nicht invalidiert bei Default-Änderung:** Wenn ein Nutzer zuvor bereits POIs geladen hat (nur SIGHTS), zeigt der Cache weiterhin nur SIGHTS bis er manuell leert (App-Neustart oder Tab-Wechsel).
---
## Overpass-Abfragen je Kategorie (Beispiel, Radius 1500 m)
```
[out:json][timeout:25];
(
node[amenity=restaurant](around:1500,...);
way[amenity=restaurant](around:1500,...);
relation[amenity=restaurant](around:1500,...);
node[amenity=cafe](around:1500,...);
... (alle GASTRONOMY-Filter als OR-Union)
);
out center 100;
```
Jede Kategorie ist eine separate Anfrage. Die UI zeigt alle aktivierten Kategorien per OR: wenn GASTRONOMY + MOBILITY aktiv, werden zwei Anfragen abgeschickt und die Ergebnisse zusammengeführt.

Some files were not shown because too many files have changed in this diff Show More