Release GPS2Audio v2.5.44 POI category fix
This commit is contained in:
@@ -0,0 +1,788 @@
|
||||
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.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(),
|
||||
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 ----
|
||||
val tracksToWrite = mutableMapOf<String, List<GpsTrackPoint>>()
|
||||
var tracksImported = 0
|
||||
if (selection.trackDrafts && draftsJson != null) {
|
||||
val obj = runCatching {
|
||||
JsonParser.parseString(draftsJson).asJsonObject
|
||||
}.getOrNull()
|
||||
val map: Map<String, List<GpsTrackPoint>> = if (obj != null) {
|
||||
runCatching {
|
||||
gson.fromJson<Map<String, List<GpsTrackPoint>>>(
|
||||
obj.get("tracks"),
|
||||
object : TypeToken<Map<String, List<GpsTrackPoint>>>() {}.type
|
||||
) ?: emptyMap()
|
||||
}.getOrDefault(emptyMap())
|
||||
} else emptyMap()
|
||||
|
||||
map.forEach { (origTour, points) ->
|
||||
val mappedTour = tourRename[origTour]
|
||||
?: run {
|
||||
// Tour war nicht im Tour-Import (z. B. wenn nur Drafts importiert werden):
|
||||
// prüfen, ob Name kollidiert.
|
||||
if (origTour == Waypoint.DEFAULT_TOUR_NAME) {
|
||||
origTour
|
||||
} else if (origTour in resultTours) {
|
||||
origTour
|
||||
} else {
|
||||
resultTours.add(origTour)
|
||||
origTour
|
||||
}
|
||||
}
|
||||
val existingPoints = existingTracks[mappedTour] ?: emptyList()
|
||||
if (existingPoints.isEmpty()) {
|
||||
// Bestehenden Track NICHT überschreiben.
|
||||
tracksToWrite[mappedTour] = points
|
||||
tracksImported++
|
||||
} else {
|
||||
warnings.add("Track-Entwurf für Tour „$mappedTour\" bereits vorhanden – nicht überschrieben.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 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,
|
||||
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 { "_" }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user