diff --git a/app/src/main/java/com/droiddeck/launcher/lab/LabOverlay.kt b/app/src/main/java/com/droiddeck/launcher/lab/LabOverlay.kt index ebbc31011..eb2dbae41 100644 --- a/app/src/main/java/com/droiddeck/launcher/lab/LabOverlay.kt +++ b/app/src/main/java/com/droiddeck/launcher/lab/LabOverlay.kt @@ -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 = 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) { diff --git a/app/src/main/java/com/droiddeck/launcher/ui/LabBuilds.kt b/app/src/main/java/com/droiddeck/launcher/ui/LabBuilds.kt new file mode 100644 index 000000000..ad59c60ac --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/LabBuilds.kt @@ -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(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() } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/UpdatesPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/UpdatesPage.kt index 91e2aa6f8..aa94a5bfb 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/UpdatesPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/UpdatesPage.kt @@ -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() } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 09693d4e9..8ce25d9d8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1119,7 +1119,27 @@ Try a fix before it\'s released None right now Lab build - Tap to go back to the installed version + Lab builds + In use + Paused after Android re-optimized DroidDeck + Not loaded: %1$s + Waiting for a restart + Kept + Built for another version of DroidDeck + Deletes this build from the device + Restart on it + Turn off + Use + Delete + Delete now + Could not prepare the restart: %1$s. Pair wireless debugging in Setup and try again. + Lab build installed + DroidDeck restarted on %1$s + Lab build paused + Android re-optimized DroidDeck, so %1$s is not loaded. Open Updates to restart on it. + Lab build turned off + It was built for another version of DroidDeck. It is kept in Updates. + DroidDeck did not start on it twice PR #%1$d Not built for this copy of DroidDeck Updated %1$s diff --git a/app/src/test/java/com/droiddeck/launcher/lab/LabOverlayTest.kt b/app/src/test/java/com/droiddeck/launcher/lab/LabOverlayTest.kt index bd4b5ed8f..254e7ce01 100644 --- a/app/src/test/java/com/droiddeck/launcher/lab/LabOverlayTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/lab/LabOverlayTest.kt @@ -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) } }