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 = emptyList(), val newTourSelection: String? = null ) /** Ergebnis-Daten, das der Aufrufer (ViewModel) in die DataStores schreibt. */ data class ImportPayload( val mergedWaypoints: List, val mergedTours: List, val tourDefaultsToWrite: Map, val tracksToWrite: Map>, val musicToWrite: Map, // v2.4.6 — Tourrouten, die geschrieben werden sollen (Map endgültiger Tourname → Route). val tourRoutesToWrite: Map = emptyMap(), // v2.5.5 — Tour-Cover, die geschrieben werden sollen (Map endgültiger Tourname → Cover). val tourCoversToWrite: Map = emptyMap(), // v2.5.48 — Echte TrackDraft-Objekte, die als neue Drafts hinzugefügt werden sollen. val trackDraftsToAdd: List = emptyList(), val trackMinDistanceMeters: Int?, val newTourSelection: String?, val warnings: List, 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, existingTours: List, existingTourDefaults: Map, existingTracks: Map>, existingMusic: Map, // v2.4.6 — bestehende Tourrouten, damit wir keine bereits vorhandene Route überschreiben. existingTourRoutes: Map = emptyMap(), // v2.5.5 — bestehende Tour-Cover, damit eigene Auswahl nicht überschrieben wird. existingTourCovers: Map = emptyMap() ): Pair { 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() // Mapping originaler displayName/dateiname → zip-pfade (für Best-Effort-Match) val mediaByLeafName = mutableMapOf>() 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() // ---- Tours + Waypoints ---- val resultTours = existingTours.toMutableList() // Mapping: original importierter Tourname → endgültiger Name (ggf. umbenannt) val tourRename = mutableMapOf() 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() var tourDefaultsImported = 0 // v2.4.6 — Tourrouten Merge-Buffer. val tourRoutesToWrite = mutableMapOf() var tourRoutesImported = 0 // v2.5.5 — Tour-Cover Merge-Buffer. val tourCoversToWrite = mutableMapOf() var tourCoversImported = 0 if (selection.toursAndWaypoints && toursJson != null) { val payload = runCatching { JsonParser.parseString(toursJson).asJsonObject }.getOrNull() if (payload != null) { val importedTourList: List = runCatching { gson.fromJson>( payload.get("tours"), object : TypeToken>() {}.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 = runCatching { gson.fromJson>( payload.get("waypoints"), object : TypeToken>() {}.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 = runCatching { gson.fromJson>( payload.get("tourDefaults"), object : TypeToken>() {}.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 = runCatching { gson.fromJson>( payload.get("tourRoutes"), object : TypeToken>() {}.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/_.jpg" (v2.5.31+) oder // "media/covers/.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 = runCatching { gson.fromJson>( payload.get("tourCovers"), object : TypeToken>() {}.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>() val trackDraftsToAdd = mutableListOf() 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 = runCatching { gson.fromJson>( obj.get("drafts"), object : TypeToken>() {}.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() 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 { 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, mediaByLeafName: Map>, 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 { "_" } } }