Release GPS2Audio v2.7.38 Samsung audio fix

This commit is contained in:
Perplexity Computer
2026-06-22 09:55:44 +00:00
parent 2eeb086481
commit a3382bb0aa
23 changed files with 4914 additions and 1564 deletions
@@ -37,6 +37,7 @@ import java.util.Locale
import java.util.UUID
import de.waypointaudio.io.BackupImportManager
import de.waypointaudio.io.ImportExportManager
import de.waypointaudio.ui.RoutePlan
import de.waypointaudio.repository.WaypointRepository
import de.waypointaudio.service.BackgroundMusicManager
import de.waypointaudio.service.LivePttManager
@@ -46,10 +47,13 @@ import de.waypointaudio.service.TrackRecordingManager
import de.waypointaudio.service.WaypointLocationService
import de.waypointaudio.service.MusicPlaybackState
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.tasks.await
import kotlinx.coroutines.withTimeout
@@ -121,12 +125,42 @@ class WaypointViewModel(application: Application) : AndroidViewModel(application
private val _tourList = MutableStateFlow<List<String>>(listOf(Waypoint.DEFAULT_TOUR_NAME))
val tourList: StateFlow<List<String>> = _tourList.asStateFlow()
/**
* v2.7.5 — Öffentlich sichtbare Tourliste für die UI (Audio-Bibliothek, Tabs).
* Die Standard-Tour (DEFAULT_TOUR_NAME) wird ausgeblendet, sobald mindestens
* eine echte (benutzerdefinierte) Tour existiert. Intern bleibt DEFAULT_TOUR_NAME
* stets als Fallback erhalten — Daten werden nicht gelöscht.
* Wenn keine echten Touren vorhanden sind, wird DEFAULT_TOUR_NAME als
* einziger Eintrag angezeigt (leerer Zustand für neue Nutzer).
*/
val visibleTourList: StateFlow<List<String>> = _tourList
.map { list ->
val realTours = list.filter { it != Waypoint.DEFAULT_TOUR_NAME }
if (realTours.isEmpty()) listOf(Waypoint.DEFAULT_TOUR_NAME) else realTours
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.Eagerly,
initialValue = listOf(Waypoint.DEFAULT_TOUR_NAME)
)
/**
* Set of tour names that have been deleted optimistically.
* The combine collector must not re-add these from waypoint-derived tours.
*/
private val _deletedTours = mutableSetOf<String>()
/**
* v2.7.14 — Neutral-Explorer-Modus-Flag.
* Wenn true, unterdrückt der combine-Kollektor den automatischen Upgrade
* von DEFAULT_TOUR_NAME zur ersten echten Tour. Das ermöglicht einen
* bewusst leeren Explorer-Einstieg aus der Übersicht/Audio-Bibliothek.
* Wird durch selectNeutralTourForNewExplorer() aktiviert und durch
* selectTour(echte Tour) oder upsertFromNeutralExplorer() wieder deaktiviert.
*/
@Volatile
private var _neutralExplorerMode: Boolean = false
/**
* Aktuell ausgewählte Tour (für Tabs und Filter).
*/
@@ -163,6 +197,19 @@ class WaypointViewModel(application: Application) : AndroidViewModel(application
private val _manualPlaying = MutableStateFlow(false)
val manualPlaying: StateFlow<Boolean> = _manualPlaying.asStateFlow()
// v2.7.37 — Kurze Stille zwischen Clips/Wegpunkten bei der manuellen
// Sequenz-Wiedergabe ("Hörbuch-Gefühl"). Nur im Auto-Advance-Pfad der
// Tour-/Sequenz-Wiedergabe; NICHT bei GPS, PTT, Atmo oder Einzelclip-Vorschau.
// Bewusst kein UI-Setting und keine Persistenz — fester interner Wert.
private var manualGapJob: Job? = null
/** Bricht eine laufende Auto-Advance-Pause ab, sodass kein verzögerter
* Start nach Stop/Pause/Skip/Tour-Wechsel mehr feuert. */
private fun cancelManualGap() {
manualGapJob?.cancel()
manualGapJob = null
}
/** Name of the waypoint currently loaded in the manual player (null = nothing loaded). */
private val _manualCurrentName = MutableStateFlow<String?>(null)
val manualCurrentName: StateFlow<String?> = _manualCurrentName.asStateFlow()
@@ -265,6 +312,66 @@ class WaypointViewModel(application: Application) : AndroidViewModel(application
get() = _activeTourRoute
private val _activeTourRoute = MutableStateFlow<de.waypointaudio.data.TourRoute?>(null)
// ─── Session-Route (v2.6.2) ───────────────────────────────────────────────
// Hält die zuletzt im Explorer berechnete/geplante Route (OSRM oder Planlinie)
// für die aktuelle Tour. Überlebt Navigation/Workspace-Wechsel, da im ViewModel
// gespeichert. Wird beim Tourwechsel oder explizitem Löschen geleert.
// Kein Datenbankschreiben — dient ausschließlich der Session-Wiederherstellung.
// Enthält: tourName (zur Validierung) + TourRoute-Objekt.
private val _currentSessionRoute =
MutableStateFlow<Pair<String, de.waypointaudio.data.TourRoute>?>(null)
val currentSessionRoute: StateFlow<Pair<String, de.waypointaudio.data.TourRoute>?> =
_currentSessionRoute.asStateFlow()
/**
* v2.6.2 — Speichert die aktuell berechnete/geplante Route für die laufende
* Explorer-Session. Wird nicht in die Datenbank geschrieben, sondern dient
* der Wiederherstellung der Kartenvisualisierung nach einem Workspace-Wechsel.
* Zusätzlich wird die Route dauerhaft als Tourroute persistiert, damit sie
* auch nach App-Neustart sichtbar ist.
*/
fun setCurrentSessionRoute(
tourName: String,
route: de.waypointaudio.data.TourRoute
) {
_currentSessionRoute.value = tourName to route
// Gleichzeitig dauerhaft persistieren, damit activeTourRoute beim Zurückkehren
// die Route enthält und savedRouteLine sie als Backup zeichnet.
saveTourRouteFor(tourName, route)
}
/**
* v2.6.2 — Löscht die Session-Route (z. B. beim Tourwechsel oder explizitem
* Löschen durch den Nutzer). Berührt nicht die persistente Tourroute.
*/
fun clearCurrentSessionRoute() {
_currentSessionRoute.value = null
}
// ─── Session-Plan (v2.7.2) ───────────────────────────────────────────────
// Hält den zuletzt genutzten RoutePlan (Start/Via/Ziel) pro Tour.
// Überlebt Navigation/Workspace-Wechsel, da im ViewModel gespeichert.
// Wird beim echten Tourwechsel geleert. Kein DB-Schreiben.
private val _currentSessionPlan =
MutableStateFlow<Pair<String, RoutePlan>?>(null)
val currentSessionPlan: StateFlow<Pair<String, RoutePlan>?> =
_currentSessionPlan.asStateFlow()
/**
* v2.7.2 — Speichert den aktuellen RoutePlan für die laufende Session.
* Wird bei jedem Plan-Update aufgerufen (Start/Via/Ziel-Änderung).
*/
fun setCurrentSessionPlan(tourName: String, plan: RoutePlan) {
_currentSessionPlan.value = tourName to plan
}
/**
* v2.7.2 — Löscht den Session-Plan (beim Tourwechsel oder explizitem Leeren).
*/
fun clearCurrentSessionPlan() {
_currentSessionPlan.value = null
}
// ─── Tour-Cover (v2.5.5) ──────────────────────────────────────────────────
// Persistente Map tourName → TourCover. Wenn keine Auswahl existiert,
// behält die UI ihre deterministische Standard-Optik (siehe TourHeroSection).
@@ -301,6 +408,16 @@ class WaypointViewModel(application: Application) : AndroidViewModel(application
if (current.isNotBlank() && current !in combined) {
_selectedTour.value = combined.firstOrNull() ?: Waypoint.DEFAULT_TOUR_NAME
}
// v2.7.5 — Wenn Standard-Tour ausgewählt ist, aber echte Touren existieren,
// automatisch zur ersten echten Tour wechseln. Standard bleibt interner Fallback.
// v2.7.14 — Wenn _neutralExplorerMode aktiv ist, wird dieser Upgrade NICHT
// durchgeführt: Explorer soll leer/neutral bleiben (Einstieg aus Übersicht).
if (_selectedTour.value == Waypoint.DEFAULT_TOUR_NAME && !_neutralExplorerMode) {
val firstReal = combined.firstOrNull { it != Waypoint.DEFAULT_TOUR_NAME }
if (firstReal != null) {
_selectedTour.value = firstReal
}
}
}
}
// Begleitmusik-Einstellungen für die initiale Tour laden
@@ -329,10 +446,15 @@ class WaypointViewModel(application: Application) : AndroidViewModel(application
_activeTourRoute.value = map[_selectedTour.value]
}
}
// Beim Tourwechsel die aktive Tourroute aktualisieren.
// Beim Tourwechsel die aktive Tourroute und Session-Route aktualisieren.
viewModelScope.launch {
_selectedTour.collect { tour ->
_activeTourRoute.value = _tourRoutes.value[tour]
// v2.6.2 — Session-Route leeren, wenn der Nutzer die Tour wechselt.
// So zeigt der Explorer nach einem Tourwechsel keine fremde Route.
_currentSessionRoute.value = null
// v2.7.2 — Session-Plan leeren beim Tourwechsel.
_currentSessionPlan.value = null
}
}
// v2.5.5 — Tour-Cover beobachten.
@@ -415,13 +537,59 @@ class WaypointViewModel(application: Application) : AndroidViewModel(application
/**
* v2.5.34 — Explorer-Kontext-Fix:
* Setzt die ausgewählte Tour auf den neutralen Standard zurück.
* Wird aufgerufen, wenn der Explorer aus der Audio-Bibliothek/Home
* geöffnet wird, damit kein zuletzt aktiver Tour-Kontext übernommen wird.
* Setzt die ausgewählte Tour auf den neutralen Standard zurück,
* AUSSER wenn bereits echte (nicht-Standard) Touren existieren
* dann wird direkt die erste echte Tour gewählt.
*
* v2.7.10 — Waypoint-Persistence-Fix:
* Wenn eine spontane "Unbenannte Tour" im Explorer erzeugt wurde und der
* Nutzer den Explorer verlässt und erneut öffnet, darf selectDefaultTour()
* nicht blind auf DEFAULT zurückspringen und die frisch erstellten Wegpunkte
* hinter einem Tour-Filter verstecken. Stattdessen: Wenn echte Touren
* vorhanden sind, sofort zur ersten echten Tour wechseln (gleiche Logik wie
* im combine-Kollektor, aber synchron, um Race Conditions zu vermeiden).
*
* Der GPS-Dienst und die Wiedergabe werden dabei NICHT unterbrochen.
*/
fun selectDefaultTour() {
selectTour(de.waypointaudio.data.Waypoint.DEFAULT_TOUR_NAME)
val knownTours = _tourList.value
val firstReal = knownTours.firstOrNull { it != de.waypointaudio.data.Waypoint.DEFAULT_TOUR_NAME }
if (firstReal != null) {
// Echte Touren existieren direkt zur ersten echten Tour wechseln.
// Kein Rücksprung auf DEFAULT_TOUR_NAME, damit Wegpunkte sichtbar bleiben.
selectTour(firstReal)
} else {
// Keine echten Touren Standard-Tour ist der einzig sinnvolle Zustand.
selectTour(de.waypointaudio.data.Waypoint.DEFAULT_TOUR_NAME)
}
}
/**
* v2.7.14 — Neutraler Explorer-Start aus der Übersicht/Audio-Bibliothek.
*
* Setzt selectedTour explizit auf DEFAULT_TOUR_NAME ("Unbenannte Tour") und
* aktiviert den Neutral-Explorer-Modus-Flag, der den automatischen Upgrade
* durch den combine-Kollektor (v2.7.5-Logik) verhindert.
*
* Konzept:
* - Explorer aus Audio-Bibliothek/Übersicht → diese Funktion → leerer Kontext
* - Explorer aus Touransicht/WaypointListScreen → selectTour(tourName) bleibt unverändert
*
* Der Flag wird zurückgesetzt, sobald:
* a) der Nutzer einen Wegpunkt im neutralen Kontext setzt (upsertFromNeutralExplorer)
* b) der Nutzer explizit eine echte Tour über selectTour() auswählt
*/
fun selectNeutralTourForNewExplorer() {
_neutralExplorerMode = true
// Direkt auf DEFAULT setzen; selectTour() würde den Guard auslösen (DEFAULT nicht in
// knownTours wenn nur echte Touren existieren), daher direktes Assignment.
_selectedTour.value = de.waypointaudio.data.Waypoint.DEFAULT_TOUR_NAME
musicManager.loadTour(de.waypointaudio.data.Waypoint.DEFAULT_TOUR_NAME)
viewModelScope.launch {
loadMusicSettingsForCurrentTour()
loadTourDefaultsForCurrentTour()
loadPersistedTrackForCurrentTour()
}
}
fun selectTour(tourName: String) {
@@ -429,6 +597,17 @@ class WaypointViewModel(application: Application) : AndroidViewModel(application
val knownTours = _tourList.value
val safeTarget = if (tourName in knownTours || knownTours.isEmpty()) tourName
else knownTours.first()
// v2.7.14 — Wechsel zu einer echten Tour deaktiviert den Neutral-Explorer-Modus.
if (safeTarget != de.waypointaudio.data.Waypoint.DEFAULT_TOUR_NAME) {
_neutralExplorerMode = false
}
// v2.7.38 — Audio-Zuverlässigkeit: Ein evtl. anstehender Auto-Advance-
// Gap-Job gehört zur alten Tour. Beim Tour-Wechsel abbrechen, damit kein
// verspäteter Start in der falschen Tour feuert. Aktiv laufende
// Wiedergabe bleibt davon unberührt (Persistenz, s. u.).
if (safeTarget != _selectedTour.value) {
cancelManualGap()
}
_selectedTour.value = safeTarget
// v2.5.30 — Playback-Persistenz beim Tour-Wechsel:
// Laufende Wiedergabe (manuell oder GPS-gesteuert) wird beim blossen
@@ -467,7 +646,12 @@ class WaypointViewModel(application: Application) : AndroidViewModel(application
// Use direct assignment instead of selectTour() to avoid triggering
// music-loading coroutine before the tour list is fully stable.
_selectedTour.value = trimmed
// v2.7.14 — Neue echte Tour erstellt: Neutral-Explorer-Modus deaktivieren.
if (trimmed != de.waypointaudio.data.Waypoint.DEFAULT_TOUR_NAME) {
_neutralExplorerMode = false
}
// Reset manual player state
cancelManualGap()
manualPlayer.stop()
_manualPlaying.value = false
_manualCurrentName.value = null
@@ -493,20 +677,78 @@ class WaypointViewModel(application: Application) : AndroidViewModel(application
return true
}
/**
* v2.7.7 — Spontane-Tour-Autocreate (v2.7.8: "Unbenannte Tour"):
* Erzeugt eine neue echte Tour mit eindeutigem Namen ("Unbenannte Tour",
* "Unbenannte Tour 2", usw.) und wählt sie sofort als selectedTour.
* Wird aufgerufen, wenn im neutralen Explorer-Kontext (selectedTour ==
* DEFAULT_TOUR_NAME) der erste Wegpunkt gesetzt wird.
* Rückgabe: der Name der neu erzeugten Tour.
* Interner Funktionsname bleibt createSpontaneousTour() für minimales Risiko.
*/
fun createSpontaneousTour(): String {
val base = "Unbenannte Tour"
val existing = _tourList.value
var candidate = base
var counter = 2
while (candidate in existing) {
candidate = "$base $counter"
counter++
}
addTour(candidate) // setzt auch _selectedTour auf candidate
return candidate
}
/**
* v2.7.7 — Upsert-Wrapper für den neutralen Explorer-Kontext.
* Wenn [waypoint].tourName == DEFAULT_TOUR_NAME (neutral), wird automatisch
* eine neue Spontane Tour erzeugt, [waypoint] auf diese Tour umgeschrieben
* und erst dann gespeichert. Danach ist selectedTour = neue Tour.
* In allen anderen Kontexten verhält sich diese Funktion identisch zu [upsert].
*/
fun upsertFromNeutralExplorer(waypoint: Waypoint) {
val isNeutral = waypoint.tourName.ifBlank { Waypoint.DEFAULT_TOUR_NAME } == Waypoint.DEFAULT_TOUR_NAME
if (isNeutral) {
val newTourName = createSpontaneousTour()
val adjusted = waypoint.copy(tourName = newTourName)
upsert(adjusted)
} else {
upsert(waypoint)
}
}
/**
* Verschiebt eine Tour in der Tab-Reihenfolge.
* Index-Verschiebung: from → to. Ungültige Indizes werden ignoriert.
* Persistiert die neue Reihenfolge sofort.
*/
fun moveTour(fromIndex: Int, toIndex: Int) {
val current = _tourList.value.toMutableList()
if (fromIndex !in current.indices || toIndex !in current.indices) return
// v2.7.32 — Bugfix: Die Reorder-UI (TourReorderDialog) arbeitet auf
// visibleTourList (Standard-Tour ausgeblendet), moveTour operierte aber
// direkt auf _tourList (inkl. DEFAULT_TOUR_NAME). Lag die Standard-Tour
// an Index 0, ergab sich ein Off-by-one, wodurch die erste und letzte
// SICHTBARE Tour faktisch nicht mehr verschoben werden konnten.
// Fix: Indizes beziehen sich auf die sichtbare Liste; die ausgeblendete
// Standard-Tour behält ihre relative Position in _tourList.
val full = _tourList.value
val visible = full.filter { it != Waypoint.DEFAULT_TOUR_NAME }
if (fromIndex !in visible.indices || toIndex !in visible.indices) return
if (fromIndex == toIndex) return
val item = current.removeAt(fromIndex)
current.add(toIndex, item)
_tourList.value = current
val reorderedVisible = visible.toMutableList()
val item = reorderedVisible.removeAt(fromIndex)
reorderedVisible.add(toIndex, item)
// Merge zurück nach _tourList: ausgeblendete Standard-Tour(en) an ihrer
// ursprünglichen relativen Position erhalten, sichtbare Touren in neuer
// Reihenfolge einsetzen.
val iter = reorderedVisible.iterator()
val merged = full.map { name ->
if (name == Waypoint.DEFAULT_TOUR_NAME) name else iter.next()
}
_tourList.value = merged
viewModelScope.launch {
runCatching { repository.saveTours(current) }
runCatching { repository.saveTours(merged) }
.onFailure { e ->
android.util.Log.e("WaypointViewModel", "Fehler beim Speichern der Tour-Reihenfolge", e)
}
@@ -558,10 +800,13 @@ class WaypointViewModel(application: Application) : AndroidViewModel(application
viewModelScope.launch {
val updated = current.map { if (it == oldName) trimmed else it }
repository.saveTours(updated)
val updatedWps = _waypoints.value.map {
// v2.7.21 — Race-Condition-Fix: Direkt aus DataStore lesen.
val freshWps = runCatching { repository.waypoints.first() }
.getOrDefault(_waypoints.value)
val updatedWps = freshWps.map {
if (it.tourName == oldName) it.copy(tourName = trimmed) else it
}
repository.saveAll(updatedWps)
repository.saveAllForced(updatedWps)
// v2.4.6 — Tourroute-Eintrag mitziehen, damit die Route nicht
// verwaist unter dem alten Namen liegen bleibt.
runCatching { repository.renameTourRouteKey(oldName, trimmed) }
@@ -596,7 +841,14 @@ class WaypointViewModel(application: Application) : AndroidViewModel(application
}
}
/** Speichert/ersetzt die Route für eine konkrete Tour (nicht zwingend die aktive). */
/**
* Speichert/ersetzt die Route für eine konkrete Tour (nicht zwingend die aktive).
*
* v2.7.21 — Waypoint-Preservation-Invariante:
* Diese Funktion schreibt AUSSCHLIESSLICH in KEY_TOUR_ROUTES.
* Audio-Wegpunkte (KEY_WAYPOINTS) werden durch diesen Aufruf NIEMALS verändert.
* Der Routenplaner darf diese Funktion frei nutzen.
*/
fun saveTourRouteFor(tourName: String, route: de.waypointaudio.data.TourRoute) {
viewModelScope.launch {
runCatching {
@@ -726,6 +978,10 @@ class WaypointViewModel(application: Application) : AndroidViewModel(application
val nextTour = current.firstOrNull() ?: Waypoint.DEFAULT_TOUR_NAME
if (_selectedTour.value == tourName) {
// Reset manual player state
// v2.7.38 — Auch den anstehenden Auto-Advance-Gap-Job abbrechen,
// sonst startet er 1,7 s nach dem Stop fälschlich erneut Audio
// gegen die gerade gelöschte Tour.
cancelManualGap()
manualPlayer.stop()
_manualPlaying.value = false
_manualCurrentName.value = null
@@ -739,10 +995,17 @@ class WaypointViewModel(application: Application) : AndroidViewModel(application
viewModelScope.launch {
runCatching {
repository.saveTours(current)
val updatedWps = _waypoints.value.map {
// v2.7.21 — Race-Condition-Fix: Direkt aus DataStore lesen statt
// _waypoints.value, um sicherzustellen, dass keine Wegpunkte verloren
// gehen, falls _waypoints.value noch nicht vollständig geladen ist.
val freshWps = runCatching { repository.waypoints.first() }
.getOrDefault(_waypoints.value)
val updatedWps = freshWps.map {
if (it.tourName == tourName) it.copy(tourName = Waypoint.DEFAULT_TOUR_NAME) else it
}
repository.saveAll(updatedWps)
// Forciert schreiben, da dies eine bewusste Mutations-Operation ist
// (Tour-Löschung mit Reassignment auf DEFAULT_TOUR_NAME).
repository.saveAllForced(updatedWps)
// Clear GPS track for the deleted tour
repository.clearGpsTrack(tourName)
// v2.4.6 — Gespeicherte Tourroute beim Löschen entfernen
@@ -885,7 +1148,13 @@ class WaypointViewModel(application: Application) : AndroidViewModel(application
else base.withClips(newClips)
}
if (newWps.isNotEmpty()) {
repository.saveAll(_waypoints.value + newWps)
// v2.7.21 — Race-Condition-Fix: Statt _waypoints.value (könnte noch
// leer sein, wenn der DataStore-Lade-Coroutine noch nicht feuerte)
// direkt aus dem DataStore lesen. Das garantiert die vollständige
// Wegpunktliste und verhindert versehentlichen Datenverlust.
val currentWps = runCatching { repository.waypoints.first() }
.getOrDefault(_waypoints.value)
repository.saveAll(currentWps + newWps)
}
// Tour-Cover, Tour-Route und Tour-Defaults referenziell mitkopieren.
runCatching {
@@ -1193,49 +1462,68 @@ class WaypointViewModel(application: Application) : AndroidViewModel(application
) {
viewModelScope.launch {
val ctx = getApplication<Application>()
val gpsTracks = runCatching { repository.gpsTracks.first() }.getOrDefault(emptyMap())
val tourDefaultsMap = runCatching {
repository.tourDefaults.first()
}.getOrDefault(emptyMap())
val musicSettingsMap = runCatching {
musicStore.settingsFlow.first()
}.getOrDefault(emptyMap())
// v2.7.36 FIX: Das gesamte Backup (Flow-Reads, ZIP-Aufbau und das
// chunk-weise Kopieren aller Mediendateien) lief bisher direkt im
// viewModelScope auf dem Main-Dispatcher. Bei Backups mit Medien
// blockierte das den UI-Thread für Sekunden → ANR ("App reagiert
// nicht / Absturz"), obwohl die Datei erfolgreich geschrieben wurde.
// Wie der Import-Pfad läuft die schwere IO jetzt auf Dispatchers.IO;
// die State-Updates für Erfolg/Fehler bleiben danach auf Main.
val result = runCatching {
kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
val gpsTracks = runCatching { repository.gpsTracks.first() }.getOrDefault(emptyMap())
val tourDefaultsMap = runCatching {
repository.tourDefaults.first()
}.getOrDefault(emptyMap())
val musicSettingsMap = runCatching {
musicStore.settingsFlow.first()
}.getOrDefault(emptyMap())
val pkgInfo = runCatching {
ctx.packageManager.getPackageInfo(ctx.packageName, 0)
}.getOrNull()
@Suppress("DEPRECATION")
val versionCode = pkgInfo?.let {
runCatching { it.longVersionCode.toInt() }
.getOrDefault(it.versionCode)
} ?: 0
val pkgInfo = runCatching {
ctx.packageManager.getPackageInfo(ctx.packageName, 0)
}.getOrNull()
@Suppress("DEPRECATION")
val versionCode = pkgInfo?.let {
runCatching { it.longVersionCode.toInt() }
.getOrDefault(it.versionCode)
} ?: 0
val tourRoutesMap = runCatching { repository.tourRoutes.first() }.getOrDefault(emptyMap())
val tourCoversMap = runCatching { repository.tourCovers.first() }.getOrDefault(emptyMap())
// v2.5.48 — Echte TrackDraft-Objekte für Backup lesen (nicht gpsTracks).
val trackDraftsSnapshot = runCatching { repository.trackDrafts.first() }.getOrDefault(emptyList())
val tourRoutesMap = runCatching { repository.tourRoutes.first() }.getOrDefault(emptyMap())
val tourCoversMap = runCatching { repository.tourCovers.first() }.getOrDefault(emptyMap())
// v2.5.48 — Echte TrackDraft-Objekte für Backup lesen (nicht gpsTracks).
val trackDraftsSnapshot = runCatching { repository.trackDrafts.first() }.getOrDefault(emptyList())
val input = de.waypointaudio.io.BackupExportManager.BackupInput(
waypoints = _waypoints.value,
tours = _tourList.value,
tourDefaults = tourDefaultsMap,
gpsTracks = gpsTracks,
musicSettings = musicSettingsMap,
tourRoutes = tourRoutesMap,
tourCovers = tourCoversMap,
trackDrafts = trackDraftsSnapshot,
appVersionName = pkgInfo?.versionName ?: "",
appVersionCode = versionCode
)
val selection = de.waypointaudio.io.BackupExportManager.BackupSelection(
toursAndWaypoints = toursAndWaypoints,
trackDrafts = trackDrafts,
mediaAndAtmo = mediaAndAtmo,
settings = settings
)
de.waypointaudio.io.BackupExportManager.writeBackup(
ctx, uri, selection, input
)
}
}.getOrElse { e ->
// Falls trotz interner Absicherung etwas Unerwartetes durchschlägt
// (z. B. SecurityException auf der Ziel-URI): nicht abstürzen,
// sondern als nicht-fataler Fehler melden.
de.waypointaudio.io.BackupExportManager.BackupResult(
errorMessage = "Backup-Fehler: ${e.localizedMessage ?: e.javaClass.simpleName}"
)
}
val input = de.waypointaudio.io.BackupExportManager.BackupInput(
waypoints = _waypoints.value,
tours = _tourList.value,
tourDefaults = tourDefaultsMap,
gpsTracks = gpsTracks,
musicSettings = musicSettingsMap,
tourRoutes = tourRoutesMap,
tourCovers = tourCoversMap,
trackDrafts = trackDraftsSnapshot,
appVersionName = pkgInfo?.versionName ?: "",
appVersionCode = versionCode
)
val selection = de.waypointaudio.io.BackupExportManager.BackupSelection(
toursAndWaypoints = toursAndWaypoints,
trackDrafts = trackDrafts,
mediaAndAtmo = mediaAndAtmo,
settings = settings
)
val result = de.waypointaudio.io.BackupExportManager.writeBackup(
ctx, uri, selection, input
)
if (result.errorMessage != null) {
_errorMessage.value = result.errorMessage
} else {
@@ -1352,7 +1640,9 @@ class WaypointViewModel(application: Application) : AndroidViewModel(application
// ---- Persistieren ----
runCatching {
if (toursAndWaypoints) {
repository.saveAll(payload.mergedWaypoints)
// v2.7.21 — Backup-Restore ist ein forcierter Vollersatz; Preservation-Guard
// nicht anwenden (mergedWaypoints kann leer sein bei leeren Backups).
repository.saveAllForced(payload.mergedWaypoints)
if (payload.mergedTours != existingTours) {
repository.saveTours(payload.mergedTours)
}
@@ -1567,6 +1857,17 @@ class WaypointViewModel(application: Application) : AndroidViewModel(application
_singlePlayingActive.value = false
}
// v2.7.37 — Während der Stille-Pause läuft kein MediaPlayer, der State
// ist aber "playing". Pause-Tippen soll dann die Auto-Advance-Pause
// abbrechen und nicht neu starten.
if (manualGapJob != null) {
cancelManualGap()
_manualPlaying.value = false
stopProgressTicker(keepSnapshot = true)
musicManager.afterWaypointAudio()
return
}
if (manualPlayer.isPlaying) {
manualPlayer.pause()
_manualPlaying.value = false
@@ -1655,6 +1956,10 @@ class WaypointViewModel(application: Application) : AndroidViewModel(application
*/
private fun startPlayableAt(sequence: List<PlayableItem>, seqIndex: Int) {
if (sequence.isEmpty()) return
// v2.7.37 — Jede neue Playback-Entscheidung (Skip/Play/Resume) bricht
// eine evtl. laufende Auto-Advance-Stille ab, damit kein verspäteter
// Start den frisch gewählten Clip überschreibt.
cancelManualGap()
val safeIdx = seqIndex.coerceIn(0, sequence.size - 1)
val item = sequence[safeIdx]
_manualIndex.value = item.waypointIndex - 1
@@ -1684,7 +1989,18 @@ class WaypointViewModel(application: Application) : AndroidViewModel(application
title = title,
tour = _selectedTour.value
)
manualPlayer.play(ctx, item.uri) {
manualPlayer.play(ctx, item.uri, onError = {
// v2.7.38 — Wiedergabe konnte nicht starten (z. B. Samsung-SAF-/
// Codec-Fehler). Zustand sauber zurücksetzen statt "spielt" ohne
// Ton anzuzeigen, und den Nutzer informieren.
cancelManualGap()
_manualPlaying.value = false
_manualCurrentName.value = null
_currentPlayable.value = null
stopProgressTicker(keepSnapshot = false)
musicManager.afterWaypointAudio()
_errorMessage.value = "Audio konnte nicht abgespielt werden. Bitte erneut versuchen."
}) {
if (!autoAdvance) {
_manualPlaying.value = false
_manualCurrentName.value = null
@@ -1712,9 +2028,57 @@ class WaypointViewModel(application: Application) : AndroidViewModel(application
return@play
}
val nextItem = nextSeq[nextIdx]
_currentPlayable.value = nextItem
_manualIndex.value = nextItem.waypointIndex - 1
playFromCurrentPlayable(autoAdvance = true)
// v2.7.37 — Kurze Stille vor dem nächsten Eintrag (Hörbuch-Gefühl).
// Läuft als abbrechbarer Job: Pause/Stop/Skip/Tour-Wechsel rufen
// cancelManualGap() und verhindern so einen verspäteten Auto-Start.
// Die Sequenz wird NACH der Pause erneut ausgewertet, da sich der
// Zustand (PTT, gelöschte Wegpunkte, deaktivierte Clips) während der
// Wartezeit ändern kann.
//
// v2.7.38 — Audio-Zuverlässigkeit (Samsung-Regressionsfix):
// Die Gap ist STRIKT auf den Auto-Advance NACH einem natürlich
// beendeten Clip beschränkt. Sie darf den Folge-Clip nur dann
// starten, wenn währenddessen weder die Tour gewechselt wurde noch
// der Nutzer pausiert/gestoppt hat. Andernfalls wird der verzögerte
// Start verworfen, statt den Zustand fälschlich auf "spielt" zu
// lassen oder Audio in der falschen Tour zu starten.
val gapTour = _selectedTour.value
cancelManualGap()
manualGapJob = viewModelScope.launch {
delay(MANUAL_GAP_MS)
manualGapJob = null
// Tour gewechselt während der Stille → kein Auto-Advance.
// Laufende/zukünftige Wiedergabe der anderen Tour nicht stören;
// nur den eigenen verspäteten Start verwerfen.
if (_selectedTour.value != gapTour) {
return@launch
}
// Nutzer hat während der Stille pausiert/gestoppt
// (z. B. Pause-Tippen, Stop, neuer Single-Clip) → nicht starten.
if (!_manualPlaying.value || manualPlayer.isSingleMode) {
return@launch
}
if (pttManager.pttActive.value) {
_manualPlaying.value = false
stopProgressTicker(keepSnapshot = true)
return@launch
}
val seqAfterGap = playableSequence()
val resolvedIdx = seqAfterGap.indexOfFirst { it.compoundId == nextItem.compoundId }
if (resolvedIdx < 0) {
// Nächster Eintrag existiert nicht mehr → sauber stoppen.
_manualPlaying.value = false
_manualCurrentName.value = null
_currentPlayable.value = null
stopProgressTicker(keepSnapshot = false)
musicManager.afterWaypointAudio()
return@launch
}
val resolved = seqAfterGap[resolvedIdx]
_currentPlayable.value = resolved
_manualIndex.value = resolved.waypointIndex - 1
playFromCurrentPlayable(autoAdvance = true)
}
}
startProgressTicker()
}
@@ -1737,6 +2101,7 @@ class WaypointViewModel(application: Application) : AndroidViewModel(application
/** Stop manual playback completely (both playlist and single mode). */
fun manualStop() {
cancelManualGap()
manualPlayer.stop()
_manualPlaying.value = false
_singlePlayingWaypointId.value = null
@@ -1816,6 +2181,11 @@ class WaypointViewModel(application: Application) : AndroidViewModel(application
val wpName = waypoint.name.ifBlank { "Wegpunkt" }
val displayName = if (clipTitle.isBlank()) wpName else "$wpName · $clipTitle"
// v2.7.37 — Einzelclip-Vorschau übernimmt: eine evtl. laufende
// Auto-Advance-Stille der Tour-Sequenz abbrechen, sonst würde der
// verzögerte Start die Vorschau überschreiben.
cancelManualGap()
// Tour-Play beenden, falls aktiv.
if (_manualPlaying.value && !manualPlayer.isSingleMode) {
manualPlayer.stop()
@@ -2362,5 +2732,12 @@ class WaypointViewModel(application: Application) : AndroidViewModel(application
* Akku spürbar gegenüber häufigeren Ticks.
*/
private const val PROGRESS_TICK_MS = 500L
/**
* v2.7.37 — Feste Stille-Pause vor dem automatischen Start des nächsten
* Eintrags in der manuellen Sequenz-Wiedergabe (Clip→Clip und
* Wegpunkt→Wegpunkt). Gibt der Wiedergabe ein hörbuchartiges Gefühl.
*/
private const val MANUAL_GAP_MS = 1700L
}
}