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.
|
// (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"
|
||||||
|
|||||||
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.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> =
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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