Add full backup & restore to Settings (v0.14.0)

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 <noreply@anthropic.com>
This commit is contained in:
Reactorcoremeltdown
2026-07-20 22:28:24 +02:00
parent 3488bac1ca
commit 8c44ed5151
5 changed files with 267 additions and 2 deletions

View File

@@ -22,8 +22,8 @@ android {
// (android.media.midi) and AAudio low-latency audio we rely on. // (android.media.midi) and AAudio low-latency audio we rely on.
minSdk = 26 minSdk = 26
targetSdk = 34 targetSdk = 34
versionCode = 37 versionCode = 38
versionName = "0.13.4" versionName = "0.14.0"
// We provide our own instrumentation runner if/when tests are added. // We provide our own instrumentation runner if/when tests are added.
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"

View File

@@ -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()
}
}

View File

@@ -19,6 +19,7 @@ import space.rcmd.android.sizzle.model.Pattern
import space.rcmd.android.sizzle.model.Pitch import space.rcmd.android.sizzle.model.Pitch
import space.rcmd.android.sizzle.model.Project import space.rcmd.android.sizzle.model.Project
import space.rcmd.android.sizzle.model.TimeSignature import space.rcmd.android.sizzle.model.TimeSignature
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
@@ -948,6 +949,41 @@ class AppViewModel(
fun writeSngExport(): java.io.File = fun writeSngExport(): java.io.File =
songLibrary.writeExport(project.name.ifBlank { "song" }, exportSng()) 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<String?>(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 // ------------------------------------------------------ instrument/effect presets
/** Preset names available for a device type (for the editor's browser dropdown). */ /** Preset names available for a device type (for the editor's browser dropdown). */
fun presetNames(type: space.rcmd.android.sizzle.model.ToolboxType): List<String> = fun presetNames(type: space.rcmd.android.sizzle.model.ToolboxType): List<String> =

View File

@@ -94,6 +94,7 @@ fun SettingsScreen(vm: AppViewModel) {
) { ) {
item { PanicButton(vm) } item { PanicButton(vm) }
item { ProjectCard(vm) } item { ProjectCard(vm) }
item { BackupCard(vm) }
item { HelpCard() } item { HelpCard() }
item { AudioDevicesCard(vm) } item { AudioDevicesCard(vm) }
item { InterfaceCard(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<Uri?>(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. */ /** About + external links: app version, source, user guide, and support. */
@Composable @Composable
private fun HelpCard() { private fun HelpCard() {

View File

@@ -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))
}