From 3c4c30bf1f972cf0756795d5d928e79d837fce54 Mon Sep 17 00:00:00 2001 From: Reactorcoremeltdown Date: Tue, 21 Jul 2026 11:45:18 +0200 Subject: [PATCH] Share full projects as .szgz bundles between instances (v0.16.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Export/Import project buttons to Settings → Project. A bundle is a ZIP holding the full .szg project plus every sample WAV it references (Sampler pads, SoundFont), so unlike a plain .szg — whose sample ids only resolve on the originating device — it opens complete on another Sizzletracker instance. Export shares the bundle via the system share sheet. Import replaces the current project, extracts the WAVs to a persistent content-addressed samples dir, re-points each slot's padNFile/sfFile at the extracted file, then rehydrates — so the audio is restored for the session and survives a restart. Invalid files are rejected cleanly. Covered by ProjectBundleIoTest. Co-Authored-By: Claude Opus 4.8 --- app/build.gradle.kts | 4 +- .../rcmd/android/sizzle/io/ProjectBundleIo.kt | 131 ++++++++++++++++++ .../rcmd/android/sizzle/io/SongLibrary.kt | 8 ++ .../rcmd/android/sizzle/ui/AppViewModel.kt | 29 ++++ .../sizzle/ui/settings/SettingsScreen.kt | 45 +++++- .../android/sizzle/io/ProjectBundleIoTest.kt | 59 ++++++++ 6 files changed, 268 insertions(+), 8 deletions(-) create mode 100644 app/src/main/java/space/rcmd/android/sizzle/io/ProjectBundleIo.kt create mode 100644 app/src/test/java/space/rcmd/android/sizzle/io/ProjectBundleIoTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 886251b..27849d6 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 = 40 - versionName = "0.15.1" + versionCode = 41 + versionName = "0.16.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/ProjectBundleIo.kt b/app/src/main/java/space/rcmd/android/sizzle/io/ProjectBundleIo.kt new file mode 100644 index 0000000..cba78f7 --- /dev/null +++ b/app/src/main/java/space/rcmd/android/sizzle/io/ProjectBundleIo.kt @@ -0,0 +1,131 @@ +// SPDX-FileCopyrightText: 2026 Reactorcoremeltdown +// SPDX-License-Identifier: GPL-3.0-or-later + +package space.rcmd.android.sizzle.io + +import space.rcmd.android.sizzle.audio.SampleStore +import space.rcmd.android.sizzle.model.Project +import space.rcmd.android.sizzle.model.SamplerPads +import space.rcmd.android.sizzle.model.ToolboxType +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 + +/** + * A portable, self-contained project bundle for sharing whole projects between + * Sizzletracker instances: a ZIP holding the full `.szg` project plus every sample + * WAV it references (Sampler pads and SoundFont slots). Unlike a plain `.szg` — whose + * sample ids only resolve against the originating device's stores — a bundle carries + * the audio, so importing on another device rebuilds the project *and* its sounds. + */ +object ProjectBundleIo { + private const val PROJECT_ENTRY = "project.szg" + private const val MANIFEST = "samples/manifest.txt" + private const val SAMPLE_DIR = "samples/" + + /** Write [project] and all its referenced samples to [out] as a ZIP bundle. */ + fun write(project: Project, out: OutputStream) { + ZipOutputStream(BufferedOutputStream(out)).use { zip -> + zip.putNextEntry(ZipEntry(PROJECT_ENTRY)) + zip.write(ProjectIo.save(project).toByteArray(Charsets.UTF_8)) + zip.closeEntry() + + val manifest = StringBuilder() + collectSampleIds(project).forEachIndexed { i, id -> + val sample = SampleStore.get(id) ?: return@forEachIndexed + zip.putNextEntry(ZipEntry("$SAMPLE_DIR$i.wav")) + zip.write(SampleStore.encodeWav(sample)) + zip.closeEntry() + manifest.append(i).append('\t').append(id).append('\n') + } + zip.putNextEntry(ZipEntry(MANIFEST)) + zip.write(manifest.toString().toByteArray(Charsets.UTF_8)) + zip.closeEntry() + } + } + + /** + * Load a project bundle from [input] into [project] (replacing it), extracting its + * sample WAVs into [samplesDir] and pointing each sample-referencing slot at the + * on-disk copy (so the caller's `rehydrate` — and every later startup — resolves + * the audio). Returns false, leaving the project untouched, if the ZIP holds no + * project. The caller should rehydrate + rebuild FX routing afterwards, as for a + * normal load. + */ + fun read(project: Project, input: InputStream, samplesDir: File): Boolean { + var projectText: String? = null + val manifest = HashMap() // sample index -> original id + val wavByIndex = HashMap() // sample index -> WAV bytes + try { + ZipInputStream(BufferedInputStream(input)).use { zin -> + var entry: ZipEntry? = zin.nextEntry + while (entry != null) { + val name = entry.name + if (!entry.isDirectory && !name.contains("..")) { + val bytes = zin.readBytes() + when { + name == PROJECT_ENTRY -> projectText = String(bytes, Charsets.UTF_8) + name == MANIFEST -> String(bytes, Charsets.UTF_8).lineSequence().forEach { ln -> + val tab = ln.indexOf('\t') + if (tab > 0) manifest[ln.substring(0, tab)] = ln.substring(tab + 1) + } + name.startsWith(SAMPLE_DIR) && name.endsWith(".wav") -> + wavByIndex[name.removePrefix(SAMPLE_DIR).removeSuffix(".wav")] = bytes + } + } + zin.closeEntry() + entry = zin.nextEntry + } + } + } catch (e: Exception) { + return false + } + val text = projectText ?: return false + + // Persist each sample and map its original id to the extracted file. Content- + // addressed names dedup re-imports and never collide. + samplesDir.mkdirs() + val idToPath = HashMap() + for ((idx, originalId) in manifest) { + val bytes = wavByIndex[idx] ?: continue + val f = File(samplesDir, "s" + Integer.toHexString(bytes.contentHashCode()) + ".wav") + if (!f.exists()) f.writeBytes(bytes) + idToPath[originalId] = f.absolutePath + } + + ProjectIo.loadInto(project, text) + + // Re-point sample-referencing slots at the extracted WAVs (absolute paths), so + // rehydrate resolves them here and after any future restart. + project.toolbox.forEach { slot -> + when (slot.type) { + ToolboxType.SAMPLER -> for (p in 0 until SamplerPads.COUNT) { + idToPath[slot.string(SamplerPads.key(p))]?.let { slot.set("pad${p}File", it) } + } + ToolboxType.SOUNDFONT -> idToPath[slot.string("sfSample")]?.let { slot.set("sfFile", it) } + else -> {} + } + } + return true + } + + /** Every non-blank sample id referenced by the project's Sampler / SoundFont slots. */ + private fun collectSampleIds(project: Project): List { + val ids = LinkedHashSet() + project.toolbox.forEach { slot -> + when (slot.type) { + ToolboxType.SAMPLER -> for (p in 0 until SamplerPads.COUNT) { + slot.string(SamplerPads.key(p)).takeIf { it.isNotBlank() }?.let { ids.add(it) } + } + ToolboxType.SOUNDFONT -> slot.string("sfSample").takeIf { it.isNotBlank() }?.let { ids.add(it) } + else -> {} + } + } + return ids.toList() + } +} diff --git a/app/src/main/java/space/rcmd/android/sizzle/io/SongLibrary.kt b/app/src/main/java/space/rcmd/android/sizzle/io/SongLibrary.kt index 45c91a5..e779b62 100644 --- a/app/src/main/java/space/rcmd/android/sizzle/io/SongLibrary.kt +++ b/app/src/main/java/space/rcmd/android/sizzle/io/SongLibrary.kt @@ -54,12 +54,20 @@ class SongLibrary(private val baseDir: File) { return f } + /** A shareable file path in the export sub-dir for a full project bundle ([BUNDLE_EXT]); + * the caller writes the ZIP into it via [ProjectBundleIo]. */ + fun bundleExportFile(name: String): File { + val dir = File(baseDir, EXPORT_DIR).apply { mkdirs() } + return File(dir, sanitize(name) + BUNDLE_EXT) + } + /** Keep names safe as file names while staying human-readable. */ private fun sanitize(name: String): String = name.trim().replace(Regex("[^A-Za-z0-9._ -]"), "_").ifBlank { "song" } private companion object { const val EXT = ".szg" // full project (ProjectIo) + const val BUNDLE_EXT = ".szgz" // full project + samples ZIP bundle (ProjectBundleIo) const val EXPORT_DIR = ".export" } } 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 5fe8cba..15f2c03 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 @@ -949,6 +949,35 @@ class AppViewModel( fun writeSngExport(): java.io.File = songLibrary.writeExport(project.name.ifBlank { "song" }, exportSng()) + /** Write the current project as a full, self-contained `.szgz` bundle (project + + * all referenced sample WAVs) to a shareable file, for moving whole projects + * between Sizzletracker instances. */ + fun writeProjectBundle(): java.io.File { + val f = songLibrary.bundleExportFile(project.name.ifBlank { "project" }) + f.outputStream().use { space.rcmd.android.sizzle.io.ProjectBundleIo.write(project, it) } + return f + } + + /** Import a full project bundle (in place), extracting its samples and restoring + * the audio. Returns false (leaving the current project) if it isn't a valid + * bundle. Replaces the current project, like [loadSong]. */ + fun importProjectBundle(bytes: ByteArray): Boolean { + engine.stop() + val samplesDir = java.io.File(appContext.filesDir, "projectsamples") + val ok = runCatching { + space.rcmd.android.sizzle.io.ProjectBundleIo.read( + project, java.io.ByteArrayInputStream(bytes), samplesDir, + ) + }.getOrDefault(false) + if (ok) { + presetLibrary.rehydrate(project) // decode the extracted WAVs into the SampleStore + engine.rebuildFxChains() // mixer FX routing may have changed + cursorLine = 0; cursorTrack = 0 + touched() + } + return ok + } + // ------------------------------------------------------------- full backup/restore /** Result of the last backup/restore, shown in a dialog; null when dismissed. */ var backupMessage by mutableStateOf(null); private set 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 07bde4b..d62eec5 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 @@ -236,7 +236,21 @@ private fun ProjectCard(vm: AppViewModel) { } } - SettingsCard("Project", subtitle = "Full projects saved on this device; .sng for desktop interchange") { + // Import a full project bundle (.szgz: project + its sample WAVs) from another + // Sizzletracker instance, replacing the current project. + val bundleImporter = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> + if (uri == null) return@rememberLauncherForActivityResult + val bytes = runCatching { + context.contentResolver.openInputStream(uri)?.use { it.readBytes() } + }.getOrNull() + when { + bytes == null -> importError = "Couldn't read the selected file." + !vm.importProjectBundle(bytes) -> importError = + "Couldn't import this file. It doesn't look like a Sizzletracker project bundle." + } + } + + SettingsCard("Project", subtitle = "Full projects saved on this device; share bundles between instances") { // On-device full-project browser: the load dropdown sits ABOVE the action // buttons (save / new / delete) so the selector is clear of them. Box { @@ -271,10 +285,29 @@ private fun ProjectCard(vm: AppViewModel) { style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) + HorizontalDivider() + // Full project bundle (.szgz): project + all sample WAVs, for sharing whole + // projects between Sizzletracker instances. + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton(onClick = { shareFile(context, vm.writeProjectBundle(), "application/zip", "Share project") }) { + Icon(Icons.Filled.ContentCopy, null, Modifier.padding(end = 6.dp)) + Text("Export project") + } + OutlinedButton(onClick = { bundleImporter.launch(arrayOf("application/zip", "application/octet-stream", "*/*")) }) { + Icon(Icons.Filled.ContentPaste, null, Modifier.padding(end = 6.dp)) + Text("Import project") + } + } + Text( + "A bundle carries everything — patterns, mixer, FX, instruments and samples — " + + "so it opens complete on another device. Import replaces the current project.", + style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + HorizontalDivider() // Desktop interchange: .sng is only exported / imported, never the library. Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - OutlinedButton(onClick = { shareSongFile(context, vm.writeSngExport()) }) { + OutlinedButton(onClick = { shareFile(context, vm.writeSngExport(), "text/plain", "Share song") }) { Icon(Icons.Filled.ContentCopy, null, Modifier.padding(end = 6.dp)) Text("Export .sng") } @@ -348,15 +381,15 @@ private fun ProjectCard(vm: AppViewModel) { } } -/** Launch the system share sheet for an exported .sng file. */ -private fun shareSongFile(context: Context, file: java.io.File) { +/** Launch the system share sheet for an exported file (song, .sng, or bundle). */ +private fun shareFile(context: Context, file: java.io.File, mime: String, title: String) { val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file) val send = Intent(Intent.ACTION_SEND).apply { - type = "text/plain" + type = mime putExtra(Intent.EXTRA_STREAM, uri) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) } - context.startActivity(Intent.createChooser(send, "Share song")) + context.startActivity(Intent.createChooser(send, title)) } @Composable diff --git a/app/src/test/java/space/rcmd/android/sizzle/io/ProjectBundleIoTest.kt b/app/src/test/java/space/rcmd/android/sizzle/io/ProjectBundleIoTest.kt new file mode 100644 index 0000000..cb96459 --- /dev/null +++ b/app/src/test/java/space/rcmd/android/sizzle/io/ProjectBundleIoTest.kt @@ -0,0 +1,59 @@ +// 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.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test +import space.rcmd.android.sizzle.audio.SampleStore +import space.rcmd.android.sizzle.model.Project +import space.rcmd.android.sizzle.model.SamplerPads +import space.rcmd.android.sizzle.model.ToolboxType +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.File +import java.nio.file.Files + +/** A project bundle must carry the referenced sample audio and rebuild it on import. */ +class ProjectBundleIoTest { + + @Test fun roundTripCarriesTheSampleAndRepointsTheSlot() { + // A source project with a Sampler pad referencing a sample in the store. + val id = "src-sample-id" + SampleStore.put(id, SampleStore.Sample(FloatArray(256) { it / 256f - 0.5f }, 48_000)) + val src = Project().apply { + name = "Shared Tune" + toolbox[3].fill(ToolboxType.SAMPLER) + toolbox[3].set(SamplerPads.key(0), id) + } + + val bytes = ByteArrayOutputStream().also { ProjectBundleIo.write(src, it) }.toByteArray() + + // Import into a fresh project + samples dir (simulating another device). + val samplesDir = Files.createTempDirectory("bundle-samples").toFile() + val dst = Project() + assertTrue(ProjectBundleIo.read(dst, ByteArrayInputStream(bytes), samplesDir)) + + assertEquals("Shared Tune", dst.name) + assertEquals(ToolboxType.SAMPLER, dst.toolbox[3].type) + // The pad still references the original id (resolved by rehydrate at load time)… + assertEquals(id, dst.toolbox[3].string(SamplerPads.key(0))) + // …and now carries a portable file ref to the extracted WAV, which exists and decodes. + val wavPath = dst.toolbox[3].string("pad0File") + assertTrue("pad0File points at an extracted WAV", wavPath.isNotBlank()) + val wav = File(wavPath) + assertTrue("extracted WAV exists on disk", wav.exists()) + val decoded = SampleStore.decodeWav(wav.readBytes()) + assertNotNull("the carried sample decodes", decoded) + assertTrue(decoded!!.data.isNotEmpty()) + } + + @Test fun invalidBundleIsRejected() { + val dst = Project() + val samplesDir = Files.createTempDirectory("bundle-samples2").toFile() + assertFalse(ProjectBundleIo.read(dst, ByteArrayInputStream("not a zip".toByteArray()), samplesDir)) + } +}