feat(lab): keep earlier Lab builds and list them in Updates

This commit is contained in:
Kurt Himebauch committed 2026-11-09 20:57:57 -05:00
1 parent 914c659f4a
commit 5f0519dfbf
5 files changed
+312 -28

No files matched your search

@@ -24,14 +24,22 @@ import java.io.File
* loaded over the installed APK at startup so it can be tried without reinstalling or re-signing.
*
* `files/lab/next` holds a build waiting for the next start and `files/lab/active` the one in use,
* each with `overlay.apk` (the build's dex files and linked resources) and `overlay.json`.
* each with `overlay.apk` (the build's dex files and linked resources) and `overlay.json`. A build
* that is replaced, turned off or refused moves to `files/lab/builds`, so it can be used again.
*/
object LabOverlay {
data class Info(val label: String, val baseCommit: String, val baseApk: String)
data class Info(val label: String, val baseCommit: String, val baseApk: String, val built: Long = 0)
enum class Reason { REPLACED, TURNED_OFF, OTHER_INSTALL, DID_NOT_START, UNREADABLE }
/** A build kept in `files/lab/builds`; [usable] when it was made for this install. */
data class Saved(val dir: File, val info: Info?, val reason: Reason?, val usable: Boolean)
private const val TAG = "LabOverlay"
private const val MAX_FAILED_STARTS = 2
private const val STABLE_AFTER_MS = 10_000L
private const val KEEP_BUILDS = 12
const val SKIPPED_APP_IMAGE = "app image present"
/** The installed APK's commit; this class always comes from the APK, never from a Lab build. */
val apkCommit: String = BuildConfig.SOURCE_COMMIT
@@ -40,6 +48,9 @@ object LabOverlay {
private set
@Volatile var skipped: String? = null
private set
/** This start moved a newly staged build into use. */
@Volatile var promoted = false
private set
private var loader: Any? = null
@@ -47,6 +58,8 @@ object LabOverlay {
internal var injectDex: (ClassLoader, File) -> Unit = ::prependDex
@VisibleForTesting
internal var openResources: (File) -> Any? = { if (Build.VERSION.SDK_INT >= 30) createLoader(it) else null }
@VisibleForTesting
internal var appImage: (Context) -> Boolean = ::hasAppImage
fun root(context: Context) = File(context.filesDir, "lab")
@@ -54,24 +67,23 @@ object LabOverlay {
fun load(app: Application, mainProcess: Boolean) {
active = null
skipped = null
promoted = false
try {
val root = root(app)
if (mainProcess) promote(root)
if (mainProcess) promoted = promote(root)
val dir = File(root, "active")
val apk = File(dir, "overlay.apk")
val meta = File(dir, "overlay.json")
if (!apk.isFile || !meta.isFile) return
val info = parse(meta.readText()) ?: return refuse(root, mainProcess, "unreadable overlay.json")
// The APK's path changes with every install and update of the app.
if (info.baseCommit != apkCommit || info.baseApk != app.applicationInfo.sourceDir)
return refuse(root, mainProcess, "built for another install")
val info = parse(meta.readText()) ?: return refuse(root, mainProcess, Reason.UNREADABLE)
if (!madeFor(app, info)) return refuse(root, mainProcess, Reason.OTHER_INSTALL)
if (Build.VERSION.SDK_INT < 30) return skip("needs Android 11")
// Classes in an app image are loaded before attachBaseContext and would ignore the overlay;
// dd-lab-build recompiles the app without one (cmd package compile -m verify).
if (hasAppImage(app)) return skip("app image present")
if (appImage(app)) return skip(SKIPPED_APP_IMAGE)
if (mainProcess) {
val starts = failedStarts(root) + 1
if (starts > MAX_FAILED_STARTS) return refuse(root, true, "did not start")
if (starts > MAX_FAILED_STARTS) return refuse(root, true, Reason.DID_NOT_START)
File(root, "starts").writeText(starts.toString())
}
apk.setReadOnly()
@@ -86,12 +98,55 @@ object LabOverlay {
}
}
/** The APK's path changes with every install and update of the app, so it pins a build to one install. */
fun madeFor(context: Context, info: Info) = info.baseCommit == apkCommit && info.baseApk == context.applicationInfo.sourceDir
/** The build in `active`, whether or not this start loaded it. */
fun current(context: Context): Info? = read(File(root(context), "active"))
/** A build staged for the next start. */
fun pending(context: Context): Info? = read(File(root(context), "next"))
/** Kept builds, newest first. */
fun saved(context: Context): List<Saved> = File(root(context), "builds").listFiles().orEmpty()
.filter { File(it, "overlay.apk").isFile }
.sortedByDescending { it.name.toLongOrNull() ?: 0L }
.map { dir ->
val info = read(dir)
val reason = File(dir, "reason").takeIf { it.isFile }?.readText()?.trim()?.let { r -> Reason.entries.firstOrNull { it.name == r } }
Saved(dir, info, reason, info != null && madeFor(context, info))
}
/** Leaves the Lab build and restarts the app on the installed APK. */
fun turnOff(context: Context) {
val root = root(context)
File(root, "active").renameTo(File(root, "off-${System.currentTimeMillis()}"))
archive(root, File(root, "active"), Reason.TURNED_OFF)
File(root, "next").deleteRecursively()
File(root, "starts").delete()
restart(context)
}
/** Stages [build] for the next start; the caller restarts the app. */
fun stage(context: Context, build: Saved) {
val root = root(context)
File(root, "next").deleteRecursively()
File(build.dir, "reason").delete()
if (!build.dir.renameTo(File(root, "next"))) error("could not stage ${build.dir.name}")
}
fun delete(build: Saved) {
build.dir.deleteRecursively()
}
/**
* Android's background optimization can give the APK an app image again, which keeps a Lab
* build from loading. Recompiling without one needs a shell, which the caller provides.
*/
fun needsRecompile(context: Context) = appImage(context)
fun recompileCommand(context: Context) = "cmd package compile -m verify -f ${context.packageName}"
fun restart(context: Context) {
val launch = context.packageManager.getLaunchIntentForPackage(context.packageName)
?.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
if (launch != null) context.startActivity(launch)
@@ -101,28 +156,46 @@ object LabOverlay {
@VisibleForTesting
internal fun parse(json: String): Info? = runCatching {
val o = JSONObject(json)
Info(o.getString("label"), o.getString("baseCommit"), o.getString("baseApk"))
Info(o.getString("label"), o.getString("baseCommit"), o.getString("baseApk"), o.optLong("built"))
}.getOrNull()
private fun read(dir: File): Info? = File(dir, "overlay.json").takeIf { it.isFile && File(dir, "overlay.apk").isFile }?.let { parse(it.readText()) }
/** Moves `next` into use and keeps the build it replaces. Returns whether there was one to move. */
@VisibleForTesting
internal fun promote(root: File) {
internal fun promote(root: File): Boolean {
val next = File(root, "next")
if (!File(next, "overlay.apk").isFile || !File(next, "overlay.json").isFile) return
if (!File(next, "overlay.apk").isFile || !File(next, "overlay.json").isFile) return false
val active = File(root, "active")
archive(root, active, Reason.REPLACED)
active.deleteRecursively()
if (next.renameTo(active)) File(root, "starts").delete()
if (!next.renameTo(active)) return false
File(root, "starts").delete()
return true
}
@VisibleForTesting
internal fun archive(root: File, dir: File, reason: Reason) {
if (!File(dir, "overlay.apk").isFile) return
val builds = File(root, "builds").apply { mkdirs() }
var stamp = System.currentTimeMillis()
while (File(builds, stamp.toString()).exists()) stamp++
val target = File(builds, stamp.toString())
if (!dir.renameTo(target)) return
File(target, "reason").writeText(reason.name)
builds.listFiles().orEmpty().sortedByDescending { it.name.toLongOrNull() ?: 0L }.drop(KEEP_BUILDS).forEach { it.deleteRecursively() }
}
@VisibleForTesting
internal fun failedStarts(root: File) = File(root, "starts").takeIf { it.isFile }?.readText()?.trim()?.toIntOrNull() ?: 0
private fun refuse(root: File, mainProcess: Boolean, reason: String) {
skipped = reason
private fun refuse(root: File, mainProcess: Boolean, reason: Reason) {
skipped = reason.name
Log.w(TAG, "Lab build turned off: $reason")
if (!mainProcess) return
File(root, "active").renameTo(File(root, "off-${System.currentTimeMillis()}"))
archive(root, File(root, "active"), reason)
File(root, "starts").delete()
File(root, "last-off-reason").writeText(reason)
File(root, "last-off-reason").writeText(reason.name)
}
private fun skip(reason: String) {
@@ -0,0 +1,155 @@
package com.droiddeck.launcher.ui
import android.text.format.DateUtils
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Code
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.droiddeck.launcher.R
import com.droiddeck.launcher.core.WirelessAdbFix
import com.droiddeck.launcher.lab.LabOverlay
/** Lab builds on this device: the one in use, one waiting for a restart, and the kept ones. */
@Composable
internal fun LabBuilds() {
val context = LocalContext.current
var version by remember { mutableIntStateOf(0) }
var busy by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
val current = remember(version) { LabOverlay.current(context) }
val pending = remember(version) { LabOverlay.pending(context) }
val saved = remember(version) { LabOverlay.saved(context) }
if (current == null && pending == null && saved.isEmpty()) return
val active = LabOverlay.active
val paused = active == null && current != null && LabOverlay.skipped == LabOverlay.SKIPPED_APP_IMAGE
val restartFailed = stringResource(R.string.lab_restart_failed)
/** Android may have given the APK an app image again, which a Lab build cannot load over. */
fun restart() {
busy = true
error = null
Thread({
val result = runCatching {
if (LabOverlay.needsRecompile(context)) WirelessAdbFix.shell(context, LabOverlay.recompileCommand(context))
}
android.os.Handler(android.os.Looper.getMainLooper()).post {
busy = false
if (result.isSuccess) LabOverlay.restart(context)
else error = restartFailed.format(result.exceptionOrNull()?.message.orEmpty())
}
}, "lab-restart").start()
}
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
Column(Modifier.fillMaxWidth().padding(top = 18.dp)) {
Text(
stringResource(R.string.lab_builds), fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.5.sp,
color = colors.onSurfaceVariant, modifier = Modifier.padding(start = 2.dp, top = 2.dp, bottom = 10.dp),
)
Column(Modifier.fillMaxWidth().clip(Shape14).background(colors.surface).border(1.dp, pal.line, Shape14)) {
val rows = mutableListOf<@Composable () -> Unit>()
if (current != null) rows += {
BuildRow(
current, true,
when {
active != null -> stringResource(R.string.lab_in_use)
paused -> stringResource(R.string.lab_paused_row)
else -> stringResource(R.string.lab_not_loaded, LabOverlay.skipped.orEmpty())
},
warn = active == null,
) {
if (paused) PrimaryButton(stringResource(R.string.lab_restart), compact = true, enabled = !busy) { restart() }
SecondaryButton(stringResource(R.string.lab_turn_off), compact = true, enabled = !busy) { LabOverlay.turnOff(context) }
}
}
if (pending != null) rows += {
BuildRow(pending, false, stringResource(R.string.lab_waiting), warn = false) {
PrimaryButton(stringResource(R.string.lab_restart), compact = true, enabled = !busy) { restart() }
}
}
for (build in saved) rows += {
var armed by remember(build.dir) { mutableStateOf(false) }
val info = build.info
BuildRow(
info, false,
when {
armed -> stringResource(R.string.lab_delete_warning)
!build.usable -> stringResource(R.string.lab_other_install)
build.reason == LabOverlay.Reason.DID_NOT_START -> stringResource(R.string.lab_off_did_not_start)
else -> stringResource(R.string.lab_kept)
},
warn = armed || !build.usable || build.reason == LabOverlay.Reason.DID_NOT_START,
) {
if (build.usable && !armed) SecondaryButton(stringResource(R.string.lab_use), compact = true, enabled = !busy) {
runCatching { LabOverlay.stage(context, build) }
.onSuccess { restart() }
.onFailure { error = it.message }
}
SecondaryButton(stringResource(if (armed) R.string.lab_delete_confirm else R.string.lab_delete), compact = true, enabled = !busy) {
if (armed) {
LabOverlay.delete(build)
version++
} else armed = true
}
}
}
rows.forEachIndexed { index, row ->
row()
if (index < rows.lastIndex) Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line))
}
}
error?.let { Text(it, fontSize = 12.5.sp, color = AttentionAmber, modifier = Modifier.padding(start = 2.dp, top = 8.dp)) }
}
}
@Composable
private fun BuildRow(info: LabOverlay.Info?, current: Boolean, status: String, warn: Boolean, actions: @Composable () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth().heightIn(min = 60.dp).padding(horizontal = 14.dp, vertical = 10.dp),
) {
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(34.dp).clip(Shape12)
.background(if (current) pal.signal.copy(alpha = 0.18f) else colors.surfaceVariant)) {
Icon(Icons.Outlined.Code, null, tint = if (current) pal.signal else colors.onSurfaceVariant, modifier = Modifier.size(20.dp))
}
Column(Modifier.weight(1f)) {
Text(info?.label ?: "?", fontSize = 14.5.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground,
maxLines = 1, overflow = TextOverflow.Ellipsis)
val built = info?.built?.takeIf { it > 0 }?.let { DateUtils.getRelativeTimeSpanString(it * 1000, System.currentTimeMillis(), DateUtils.MINUTE_IN_MILLIS).toString() }
Text(listOfNotNull(status, built).joinToString(" · "), fontSize = 12.5.sp, color = if (warn) AttentionAmber else colors.onSurfaceVariant,
maxLines = 2, overflow = TextOverflow.Ellipsis)
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { actions() }
}
}
@@ -280,17 +280,12 @@ private fun ChannelPicker(u: UpdatesState, ua: UpdatesActions) {
if (tests.isEmpty()) stringResource(R.string.upd_tests_none) else pluralStringResource(R.plurals.upd_tests_count, tests.size, tests.size),
u.follow.channel == Channel.TEST, enabled = tests.isNotEmpty() || u.follow.channel == Channel.TEST,
) { tests.firstOrNull()?.let { ua.onFollow(Follow(Channel.TEST, it.pr)) } }
com.droiddeck.launcher.lab.LabOverlay.active?.let { lab ->
val context = androidx.compose.ui.platform.LocalContext.current
ChannelCard(Icons.Outlined.Code, stringResource(R.string.upd_lab), lab.label, stringResource(R.string.upd_lab_off), true) {
com.droiddeck.launcher.lab.LabOverlay.turnOff(context)
}
}
AnimatedVisibility(u.follow.channel == Channel.TEST && tests.isNotEmpty(), enter = expandVertically(Motion.sp(1f)) + fadeIn(Motion.sp(1f)), exit = shrinkVertically(Motion.sp(1f)) + fadeOut(Motion.sp(1f))) {
Column(Modifier.padding(start = 18.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
tests.forEach { t -> TestRow(t, u.follow.pr == t.pr) { ua.onFollow(Follow(Channel.TEST, t.pr)) } }
}
}
LabBuilds()
}
}
+21 -1
View File
@@ -1119,7 +1119,27 @@
<string name="upd_tests_hint">Try a fix before it\'s released</string>
<string name="upd_tests_none">None right now</string>
<string name="upd_lab">Lab build</string>
<string name="upd_lab_off">Tap to go back to the installed version</string>
<string name="lab_builds">Lab builds</string>
<string name="lab_in_use">In use</string>
<string name="lab_paused_row">Paused after Android re-optimized DroidDeck</string>
<string name="lab_not_loaded">Not loaded: %1$s</string>
<string name="lab_waiting">Waiting for a restart</string>
<string name="lab_kept">Kept</string>
<string name="lab_other_install">Built for another version of DroidDeck</string>
<string name="lab_delete_warning">Deletes this build from the device</string>
<string name="lab_restart">Restart on it</string>
<string name="lab_turn_off">Turn off</string>
<string name="lab_use">Use</string>
<string name="lab_delete">Delete</string>
<string name="lab_delete_confirm">Delete now</string>
<string name="lab_restart_failed">Could not prepare the restart: %1$s. Pair wireless debugging in Setup and try again.</string>
<string name="lab_installed_title">Lab build installed</string>
<string name="lab_installed_text">DroidDeck restarted on %1$s</string>
<string name="lab_paused_title">Lab build paused</string>
<string name="lab_paused_text">Android re-optimized DroidDeck, so %1$s is not loaded. Open Updates to restart on it.</string>
<string name="lab_off_title">Lab build turned off</string>
<string name="lab_off_other_install">It was built for another version of DroidDeck. It is kept in Updates.</string>
<string name="lab_off_did_not_start">DroidDeck did not start on it twice</string>
<string name="upd_pr">PR #%1$d</string>
<string name="upd_not_built">Not built for this copy of DroidDeck</string>
<string name="upd_updated">Updated %1$s</string>
@@ -69,14 +69,14 @@ class LabOverlayTest {
assertNull(LabOverlay.active)
assertTrue(injected.isEmpty())
assertFalse(File(root, "active").exists())
assertEquals("built for another install", File(root, "last-off-reason").readText())
assertEquals("OTHER_INSTALL", File(root, "last-off-reason").readText())
}
@Test fun aBuildForAnEarlierInstallOfTheSameCommitIsTurnedOff() {
stage("active", apk = "/data/app/old/base.apk")
LabOverlay.load(app, mainProcess = true)
assertNull(LabOverlay.active)
assertEquals("built for another install", File(root, "last-off-reason").readText())
assertEquals("OTHER_INSTALL", File(root, "last-off-reason").readText())
}
@Test fun aBuildThatKeepsFailingToStartIsTurnedOff() {
@@ -84,7 +84,7 @@ class LabOverlayTest {
File(root, "starts").writeText("2")
LabOverlay.load(app, mainProcess = true)
assertNull(LabOverlay.active)
assertEquals("did not start", File(root, "last-off-reason").readText())
assertEquals("DID_NOT_START", File(root, "last-off-reason").readText())
}
@Test fun otherProcessesLoadWithoutCountingStarts() {
@@ -98,5 +98,46 @@ class LabOverlayTest {
LabOverlay.load(app, mainProcess = true)
assertNull(LabOverlay.active)
assertTrue(injected.isEmpty())
assertFalse(LabOverlay.promoted)
}
@Test fun aNewBuildKeepsTheOneItReplaces() {
stage("active")
stage("next")
LabOverlay.load(app, mainProcess = true)
assertTrue(LabOverlay.promoted)
val saved = LabOverlay.saved(app).single()
assertEquals(LabOverlay.Reason.REPLACED, saved.reason)
assertTrue(saved.usable)
}
@Test fun aRefusedBuildIsKeptButNotUsable() {
stage("active", commit = "0000000")
LabOverlay.load(app, mainProcess = true)
val saved = LabOverlay.saved(app).single()
assertEquals(LabOverlay.Reason.OTHER_INSTALL, saved.reason)
assertFalse(saved.usable)
}
@Test fun aKeptBuildCanBeStagedAgain() {
stage("active", commit = "0000000")
LabOverlay.load(app, mainProcess = true)
stage("active")
LabOverlay.archive(root, File(root, "active"), LabOverlay.Reason.TURNED_OFF)
val usable = LabOverlay.saved(app).first { it.usable }
LabOverlay.stage(app, usable)
assertNotNull(LabOverlay.pending(app))
assertEquals(1, LabOverlay.saved(app).size)
LabOverlay.load(app, mainProcess = true)
assertTrue(LabOverlay.promoted)
assertNotNull(LabOverlay.active)
}
@Test fun onlyTheNewestBuildsAreKept() {
repeat(15) {
stage("active")
LabOverlay.archive(root, File(root, "active"), LabOverlay.Reason.TURNED_OFF)
}
assertEquals(12, LabOverlay.saved(app).size)
}
}