From 2ff3ab447deeb5911bba28c8f10004bd5636b1b4 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Sat, 31 Oct 2026 23:32:58 -0400 Subject: [PATCH] fix: local builds bundle the addon packs they pin (#1908) --- app/build.gradle | 10 ++++++ .../com/droiddeck/launcher/core/AddonPack.kt | 14 +++++++- .../droiddeck/launcher/core/AddonPackTest.kt | 34 +++++++++++++++++++ 3 files changed, 57 insertions(+), 1 deletion(-) diff --git a/app/build.gradle b/app/build.gradle index 23a3b841f..38c5d72b0 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -300,6 +300,16 @@ def stageGogNotices = tasks.register('stageGogNotices', Copy) { android.sourceSets.main.assets.srcDir(layout.buildDirectory.dir('gogNotices')) tasks.named('preBuild').configure { dependsOn(stageGogNotices) } +// CI publishes its packs; a local build pins packs nobody hosts (see addonPacks), so the APK carries them. +if (!ciBuild) { + def stageLocalPacks = tasks.register('stageLocalPacks', Sync) { + from(layout.buildDirectory.dir('packs')) { include '*.tar.zst' } + into(layout.buildDirectory.dir('localPackAssets/packs')) + } + android.sourceSets.main.assets.srcDir(layout.buildDirectory.dir('localPackAssets')) + tasks.named('preBuild').configure { dependsOn(stageLocalPacks) } +} + def verifyRuntimeInputs = tasks.register('verifyRuntimeInputs', Exec) { mustRunAfter(stageSessionScripts, stageRuntimeAssets) workingDir rootProject.projectDir diff --git a/app/src/main/java/com/droiddeck/launcher/core/AddonPack.kt b/app/src/main/java/com/droiddeck/launcher/core/AddonPack.kt index 8f2da0d68..cc6a1007a 100644 --- a/app/src/main/java/com/droiddeck/launcher/core/AddonPack.kt +++ b/app/src/main/java/com/droiddeck/launcher/core/AddonPack.kt @@ -22,6 +22,7 @@ import java.io.FileInputStream internal class AddonPack( val name: String, private val sha256: String, val size: Long, private val required: List, private val reuseDeviceCopy: Boolean = sha256.isEmpty() && !BuildConfig.CI_BUILD, + private val bundle: (Context) -> java.io.InputStream = { it.assets.open("packs/$name.tar.zst") }, ) { sealed interface State { data object Missing : State @@ -104,7 +105,7 @@ internal class AddonPack( } val root = root(context).apply { mkdirs() } val archive = File(root, "$sha256.tar.zst") - val downloaded = urls.any { url -> + val downloaded = bundled(context, archive) || urls.any { url -> Downloader.downloadFile(url, archive, true, sha256) { p -> mutableState.value = State.Downloading(p.coerceIn(0f, 1f)) } .also { if (!it) archive.delete() } } @@ -126,6 +127,17 @@ internal class AddonPack( return true } + // A local build pins the packs it compiled, which no release hosts, so it ships them as assets. + internal fun bundled(context: Context, archive: File): Boolean { + val copied = runCatching { + bundle(context).use { input -> archive.outputStream().use { input.copyTo(it) } } + }.isSuccess + if (copied && Hashes.sha256(archive).equals(sha256, ignoreCase = true)) return true + if (copied) Log.w(TAG, "$name: the bundled pack does not match the pinned digest") + archive.delete() + return false + } + companion object { private const val TAG = "AddonPack" private const val REPO = "Droid-Deck/DroidDeck-Components" diff --git a/app/src/test/java/com/droiddeck/launcher/core/AddonPackTest.kt b/app/src/test/java/com/droiddeck/launcher/core/AddonPackTest.kt index 9172c5a5c..80c6ebd32 100644 --- a/app/src/test/java/com/droiddeck/launcher/core/AddonPackTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/core/AddonPackTest.kt @@ -2,6 +2,9 @@ package com.droiddeck.launcher.core import android.content.Context import androidx.test.core.app.ApplicationProvider +import com.github.luben.zstd.ZstdOutputStream +import org.apache.commons.compress.archivers.tar.TarArchiveEntry +import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream import java.io.File import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -54,4 +57,35 @@ class AddonPackTest { installed("b".repeat(64)) assertFalse(AddonPack("sample", "", 0L, listOf("tool"), reuseDeviceCopy = false).installed(context)) } + + @Test fun localBuildInstallsTheBundledPackWithoutDownloading() { + val archive = tarZst("tool" to "x") + val pack = AddonPack("sample", Hashes.sha256(archive), archive.length(), listOf("tool"), bundle = { archive.inputStream() }) + + assertTrue(pack.await(context)) + assertEquals("x", pack.file(context, "tool").readText()) + } + + @Test fun bundledPackWithTheWrongDigestIsRejected() { + val archive = tarZst("tool" to "x") + val pack = AddonPack("sample", "a".repeat(64), archive.length(), listOf("tool"), + bundle = { archive.inputStream() }) + + val copy = File(context.cacheDir, "copy.tar.zst") + + assertFalse(pack.bundled(context, copy)) + assertFalse(copy.exists()) + } + + private fun tarZst(vararg files: Pair): File = + File(context.cacheDir, "sample-${System.nanoTime()}.tar.zst").apply { + TarArchiveOutputStream(ZstdOutputStream(outputStream())).use { tar -> + for ((path, text) in files) { + val bytes = text.toByteArray() + tar.putArchiveEntry(TarArchiveEntry(path).apply { size = bytes.size.toLong() }) + tar.write(bytes) + tar.closeArchiveEntry() + } + } + } }