From 8c44ed515184e1887c2b1692c18164cde875ffda Mon Sep 17 00:00:00 2001 From: Reactorcoremeltdown Date: Mon, 20 Jul 2026 22:28:24 +0200 Subject: [PATCH] Add full backup & restore to Settings (v0.14.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new Backup & Restore card saves everything the app persists — songs, presets (with their sample WAVs), themes, the autosaved project, and the settings + input-binding DataStore blobs — into one .zip via the system file picker, and restores from one. Restore is defensive: it extracts to a staging folder and only swaps it into place once the archive validates, so a corrupt/wrong file can't destroy current data; it's zip-slip guarded and runs off the main thread. Since DataStore caches in memory, restore prompts to reopen the app to finish applying. Covered by BackupIoTest (round-trip replace, bad-file safety, empty-zip rejection). Co-Authored-By: Claude Opus 4.8 --- app/build.gradle.kts | 4 +- .../space/rcmd/android/sizzle/io/BackupIo.kt | 107 ++++++++++++++++++ .../rcmd/android/sizzle/ui/AppViewModel.kt | 36 ++++++ .../sizzle/ui/settings/SettingsScreen.kt | 52 +++++++++ .../rcmd/android/sizzle/io/BackupIoTest.kt | 70 ++++++++++++ 5 files changed, 267 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/space/rcmd/android/sizzle/io/BackupIo.kt create mode 100644 app/src/test/java/space/rcmd/android/sizzle/io/BackupIoTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3fe9c69..b54104b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -22,8 +22,8 @@ android { // (android.media.midi) and AAudio low-latency audio we rely on. minSdk = 26 targetSdk = 34 - versionCode = 37 - versionName = "0.13.4" + versionCode = 38 + versionName = "0.14.0" // We provide our own instrumentation runner if/when tests are added. testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/app/src/main/java/space/rcmd/android/sizzle/io/BackupIo.kt b/app/src/main/java/space/rcmd/android/sizzle/io/BackupIo.kt new file mode 100644 index 0000000..9ddb4db --- /dev/null +++ b/app/src/main/java/space/rcmd/android/sizzle/io/BackupIo.kt @@ -0,0 +1,107 @@ +// SPDX-FileCopyrightText: 2026 Reactorcoremeltdown +// SPDX-License-Identifier: GPL-3.0-or-later + +package space.rcmd.android.sizzle.io + +import java.io.BufferedInputStream +import java.io.BufferedOutputStream +import java.io.File +import java.io.InputStream +import java.io.OutputStream +import java.util.zip.ZipEntry +import java.util.zip.ZipInputStream +import java.util.zip.ZipOutputStream + +/** + * Whole-app backup: a single ZIP of everything the app persists under its private + * files dir — saved songs, themes, instrument/effect presets (with their sample + * WAVs), the autosaved project, and the DataStore blobs holding settings + input + * bindings. Restore extracts into a staging folder first and only swaps it into + * place once the archive looks valid, so a corrupt or wrong file can never destroy + * the current data. Because the DataStore prefs are cached in memory while the app + * runs, a restore only takes full effect after the app is restarted. + */ +object BackupIo { + // Top-level content areas under filesDir. "datastore" holds the settings + + // bindings Preferences files (see SettingsStore / BindingStore). + private val CONTENT_DIRS = listOf("songs", "themes", "presets", "datastore") + private const val AUTOSAVE = "autosave.sng" + private const val STAGING = ".restore_tmp" + + /** Write a full backup of [filesDir] to [out] as a ZIP. */ + fun write(filesDir: File, out: OutputStream) { + ZipOutputStream(BufferedOutputStream(out)).use { zip -> + for (dir in CONTENT_DIRS) addTree(zip, File(filesDir, dir), dir) + File(filesDir, AUTOSAVE).takeIf { it.isFile }?.let { addFile(zip, it, AUTOSAVE) } + } + } + + /** + * Restore a backup ZIP from [input] into [filesDir], replacing the current data. + * Returns false (leaving current data untouched) if the archive is empty or holds + * none of the expected areas. Settings/bindings apply after an app restart. + */ + fun read(filesDir: File, input: InputStream): Boolean { + val staging = File(filesDir, STAGING) + staging.deleteRecursively(); staging.mkdirs() + val stagingRoot = staging.canonicalPath + File.separator + + var any = false + try { + ZipInputStream(BufferedInputStream(input)).use { zin -> + var entry: ZipEntry? = zin.nextEntry + while (entry != null) { + val name = entry.name + if (!entry.isDirectory && !name.contains("..")) { + val target = File(staging, name) + // Zip-slip guard: the resolved path must stay inside staging. + if (target.canonicalPath.startsWith(stagingRoot)) { + target.parentFile?.mkdirs() + target.outputStream().use { zin.copyTo(it) } + any = true + } + } + zin.closeEntry() + entry = zin.nextEntry + } + } + } catch (e: Exception) { + // A truncated / non-ZIP file: bail out without touching the live data. + staging.deleteRecursively(); return false + } + + val looksValid = any && + (CONTENT_DIRS.any { File(staging, it).exists() } || File(staging, AUTOSAVE).isFile) + if (!looksValid) { staging.deleteRecursively(); return false } + + // Swap each restored area into place (delete the live one first so a rename + // over it succeeds; fall back to a copy if rename can't cross the boundary). + for (dir in CONTENT_DIRS) { + val src = File(staging, dir) + if (!src.exists()) continue + val dst = File(filesDir, dir) + dst.deleteRecursively() + if (!src.renameTo(dst)) src.copyRecursively(dst, overwrite = true) + } + File(staging, AUTOSAVE).takeIf { it.isFile }?.let { src -> + val dst = File(filesDir, AUTOSAVE) + dst.delete() + if (!src.renameTo(dst)) src.copyTo(dst, overwrite = true) + } + staging.deleteRecursively() + return true + } + + private fun addTree(zip: ZipOutputStream, dir: File, prefix: String) { + if (!dir.isDirectory) return + dir.walkTopDown().filter { it.isFile }.forEach { f -> + addFile(zip, f, prefix + "/" + f.relativeTo(dir).path.replace(File.separatorChar, '/')) + } + } + + private fun addFile(zip: ZipOutputStream, f: File, entryName: String) { + zip.putNextEntry(ZipEntry(entryName)) + f.inputStream().use { it.copyTo(zip) } + zip.closeEntry() + } +} diff --git a/app/src/main/java/space/rcmd/android/sizzle/ui/AppViewModel.kt b/app/src/main/java/space/rcmd/android/sizzle/ui/AppViewModel.kt index e594939..5fe8cba 100644 --- a/app/src/main/java/space/rcmd/android/sizzle/ui/AppViewModel.kt +++ b/app/src/main/java/space/rcmd/android/sizzle/ui/AppViewModel.kt @@ -19,6 +19,7 @@ import space.rcmd.android.sizzle.model.Pattern import space.rcmd.android.sizzle.model.Pitch import space.rcmd.android.sizzle.model.Project import space.rcmd.android.sizzle.model.TimeSignature +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.distinctUntilChanged @@ -948,6 +949,41 @@ class AppViewModel( fun writeSngExport(): java.io.File = songLibrary.writeExport(project.name.ifBlank { "song" }, exportSng()) + // ------------------------------------------------------------- full backup/restore + /** Result of the last backup/restore, shown in a dialog; null when dismissed. */ + var backupMessage by mutableStateOf(null); private set + fun dismissBackupMessage() { backupMessage = null } + + /** Write a full backup (songs, presets, themes, autosave, settings + bindings) to + * the SAF [uri] the user picked. Runs off the main thread. */ + fun backupTo(uri: android.net.Uri) { + viewModelScope.launch(Dispatchers.IO) { + val ok = runCatching { + appContext.contentResolver.openOutputStream(uri)?.use { + space.rcmd.android.sizzle.io.BackupIo.write(appContext.filesDir, it) + } != null + }.getOrDefault(false) + backupMessage = if (ok) "Backup saved." else "Couldn't write the backup." + } + } + + /** Restore a full backup from the SAF [uri], replacing all current data. Settings + * and bindings apply after the app is restarted. Runs off the main thread. */ + fun restoreFrom(uri: android.net.Uri) { + viewModelScope.launch(Dispatchers.IO) { + val ok = runCatching { + appContext.contentResolver.openInputStream(uri)?.use { + space.rcmd.android.sizzle.io.BackupIo.read(appContext.filesDir, it) + } ?: false + }.getOrDefault(false) + backupMessage = if (ok) { + "Backup restored. Close and reopen the app to finish applying it." + } else { + "Restore failed — that doesn't look like a Sizzletracker backup." + } + } + } + // ------------------------------------------------------ instrument/effect presets /** Preset names available for a device type (for the editor's browser dropdown). */ fun presetNames(type: space.rcmd.android.sizzle.model.ToolboxType): List = diff --git a/app/src/main/java/space/rcmd/android/sizzle/ui/settings/SettingsScreen.kt b/app/src/main/java/space/rcmd/android/sizzle/ui/settings/SettingsScreen.kt index 396c4c3..07bde4b 100644 --- a/app/src/main/java/space/rcmd/android/sizzle/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/space/rcmd/android/sizzle/ui/settings/SettingsScreen.kt @@ -94,6 +94,7 @@ fun SettingsScreen(vm: AppViewModel) { ) { item { PanicButton(vm) } item { ProjectCard(vm) } + item { BackupCard(vm) } item { HelpCard() } item { AudioDevicesCard(vm) } item { InterfaceCard(vm) } @@ -372,6 +373,57 @@ private fun AudioDevicesCard(vm: AppViewModel) { } } +/** Whole-app backup to a single ZIP (songs, presets, themes, settings + bindings) + * and restore from one. Restore is destructive and needs an app restart. */ +@Composable +private fun BackupCard(vm: AppViewModel) { + var confirmRestore by remember { mutableStateOf(null) } + val backupPicker = rememberLauncherForActivityResult( + ActivityResultContracts.CreateDocument("application/zip"), + ) { uri -> uri?.let { vm.backupTo(it) } } + val restorePicker = rememberLauncherForActivityResult( + ActivityResultContracts.OpenDocument(), + ) { uri -> if (uri != null) confirmRestore = uri } + + SettingsCard("Backup & Restore", subtitle = "Save or restore all songs, presets, themes & settings") { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton(onClick = { backupPicker.launch("sizzletracker-backup.zip") }) { Text("Back up all") } + OutlinedButton(onClick = { restorePicker.launch(arrayOf("application/zip", "application/octet-stream", "*/*")) }) { + Text("Restore") + } + } + Text( + "Backup writes one .zip. Restore replaces ALL current data and takes effect " + + "after you reopen the app.", + style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + confirmRestore?.let { uri -> + AlertDialog( + onDismissRequest = { confirmRestore = null }, + title = { Text("Restore backup?") }, + text = { + Text( + "This replaces all current songs, presets, themes and settings with the " + + "backup's contents. This can't be undone.", + ) + }, + confirmButton = { TextButton(onClick = { vm.restoreFrom(uri); confirmRestore = null }) { Text("Restore") } }, + dismissButton = { TextButton(onClick = { confirmRestore = null }) { Text("Cancel") } }, + ) + } + + vm.backupMessage?.let { msg -> + AlertDialog( + onDismissRequest = { vm.dismissBackupMessage() }, + title = { Text("Backup & Restore") }, + text = { Text(msg) }, + confirmButton = { TextButton(onClick = { vm.dismissBackupMessage() }) { Text("OK") } }, + ) + } +} + /** About + external links: app version, source, user guide, and support. */ @Composable private fun HelpCard() { diff --git a/app/src/test/java/space/rcmd/android/sizzle/io/BackupIoTest.kt b/app/src/test/java/space/rcmd/android/sizzle/io/BackupIoTest.kt new file mode 100644 index 0000000..8e99643 --- /dev/null +++ b/app/src/test/java/space/rcmd/android/sizzle/io/BackupIoTest.kt @@ -0,0 +1,70 @@ +// SPDX-FileCopyrightText: 2026 Reactorcoremeltdown +// SPDX-License-Identifier: GPL-3.0-or-later + +package space.rcmd.android.sizzle.io + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.File +import java.nio.file.Files + +/** Round-trip + safety coverage for the whole-app [BackupIo]. */ +class BackupIoTest { + + private fun tempDir(): File = Files.createTempDirectory("backup-test").toFile() + + private fun seed(dir: File) { + File(dir, "songs").mkdirs() + File(dir, "songs/tune.szg").writeText("SONG-DATA") + File(dir, "presets/DELAY").mkdirs() + File(dir, "presets/DELAY/slap.szp").writeText("PRESET-DATA") + File(dir, "datastore").mkdirs() + File(dir, "datastore/app_settings.preferences_pb").writeBytes(byteArrayOf(1, 2, 3, 4)) + File(dir, "autosave.sng").writeText("AUTO-DATA") + } + + @Test fun backupRestoreRoundTripReplacesContent() { + val source = tempDir().also { seed(it) } + val zip = ByteArrayOutputStream().also { BackupIo.write(source, it) }.toByteArray() + + // Restore into a dir that already holds different + stale data. + val target = tempDir() + File(target, "songs").mkdirs() + File(target, "songs/stale.szg").writeText("STALE") // not in the backup → must go + + assertTrue(BackupIo.read(target, ByteArrayInputStream(zip))) + + assertEquals("SONG-DATA", File(target, "songs/tune.szg").readText()) + assertFalse("a song absent from the backup is removed", File(target, "songs/stale.szg").exists()) + assertEquals("PRESET-DATA", File(target, "presets/DELAY/slap.szp").readText()) + assertEquals("AUTO-DATA", File(target, "autosave.sng").readText()) + assertArrayEquals(byteArrayOf(1, 2, 3, 4), File(target, "datastore/app_settings.preferences_pb").readBytes()) + assertFalse("staging is cleaned up", File(target, ".restore_tmp").exists()) + } + + @Test fun invalidArchiveLeavesDataIntact() { + val target = tempDir() + File(target, "songs").mkdirs() + File(target, "songs/keep.szg").writeText("KEEP") + + assertFalse(BackupIo.read(target, ByteArrayInputStream("this is not a zip".toByteArray()))) + assertEquals("existing data survives a bad restore file", "KEEP", File(target, "songs/keep.szg").readText()) + } + + @Test fun emptyZipIsRejected() { + val emptyZip = ByteArrayOutputStream().also { java.util.zip.ZipOutputStream(it).close() }.toByteArray() + val target = tempDir() + File(target, "songs").mkdirs() + File(target, "songs/keep.szg").writeText("KEEP") + + assertFalse(BackupIo.read(target, ByteArrayInputStream(emptyZip))) + assertEquals("KEEP", File(target, "songs/keep.szg").readText()) + } + + private fun assertArrayEquals(a: ByteArray, b: ByteArray) = + assertTrue("byte arrays differ", a.contentEquals(b)) +}