823 lines
40 KiB
Kotlin
823 lines
40 KiB
Kotlin
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.7–v2.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 { "_" }
|
||
}
|
||
}
|