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:
@@ -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"
|
||||
|
||||
107
app/src/main/java/space/rcmd/android/sizzle/io/BackupIo.kt
Normal file
107
app/src/main/java/space/rcmd/android/sizzle/io/BackupIo.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
@@ -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<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
|
||||
/** Preset names available for a device type (for the editor's browser dropdown). */
|
||||
fun presetNames(type: space.rcmd.android.sizzle.model.ToolboxType): List<String> =
|
||||
|
||||
@@ -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<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. */
|
||||
@Composable
|
||||
private fun HelpCard() {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
Reference in New Issue
Block a user