Files
GPS2Audio/app/src/main/kotlin/de/waypointaudio/io/BackupImportManager.kt
T
2026-05-30 22:19:04 +00:00

823 lines
40 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { "_" }
}
}