From 05cebd4a4f20cdd4300a06ecdd89e09e23db6db8 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Wed, 30 Sep 2026 21:46:45 -0400 Subject: [PATCH] fix: offer updates to release-signed local builds and align the Updates page (#112) --- app/build.gradle | 3 ++ .../main/java/com/droiddeck/launcher/App.kt | 1 + .../com/droiddeck/launcher/ui/UpdatesPage.kt | 26 +++++++++---- .../droiddeck/launcher/update/AppUpdates.kt | 37 ++++++++++++++++--- .../launcher/update/AppUpdatesTest.kt | 2 +- 5 files changed, 56 insertions(+), 13 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 37efe56dc..d2bb5daaf 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -55,6 +55,8 @@ def sourceCommit = providers.environmentVariable('DROIDDECK_SOURCE_COMMIT').getO ?: providers.environmentVariable('GITHUB_SHA').getOrNull() ?: gitValue(['rev-parse', 'HEAD']) ?: '' def prNumber = (providers.environmentVariable('DROIDDECK_PR_NUMBER').getOrNull() ?: '0').with { it.isInteger() ? it : '0' } def ciBuild = providers.environmentVariable('GITHUB_ACTIONS').getOrNull() == 'true' +// The release key's certificate: a build signed with it, from CI or a computer, updates in place. +def releaseSigner = rootProject.file('keystore/release-signer.sha256').text.trim() // When the source was committed, for "built 3 hours ago": fixed per commit, so it costs no rebuilds. def commitTime = (gitValue(['show', '-s', '--format=%ct', 'HEAD']) ?: '0').with { it.isLong() ? it : '0' } // tools/release/variants.txt as "package=suffix;...", so the updater picks the apk of the package it runs as. @@ -83,6 +85,7 @@ android { buildConfigField "String", "SOURCE_COMMIT", "\"${sourceCommit}\"" buildConfigField "int", "PR_NUMBER", "${prNumber}" buildConfigField "boolean", "CI_BUILD", "${ciBuild}" + buildConfigField "String", "RELEASE_SIGNER", "\"${releaseSigner}\"" buildConfigField "long", "COMMIT_TIME", "${commitTime}L * 1000L" buildConfigField "String", "VARIANT_SUFFIXES", "\"${variantSuffixes}\"" diff --git a/app/src/main/java/com/droiddeck/launcher/App.kt b/app/src/main/java/com/droiddeck/launcher/App.kt index 7cc6db7d6..77551f925 100644 --- a/app/src/main/java/com/droiddeck/launcher/App.kt +++ b/app/src/main/java/com/droiddeck/launcher/App.kt @@ -17,6 +17,7 @@ class App : Application() { // Before anything else touches the session prefs: a new install's first read decides it. com.droiddeck.launcher.session.SessionPrefs.settleDeckModeDefault(this) com.droiddeck.launcher.session.SessionPrefs.settleGpuDriverMode(this) + com.droiddeck.launcher.update.AppUpdates.init(this) GpuClockPin.clearLeftover(this) BwrapSpawner.start(this) } 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 6d824a604..e1efe1b06 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/UpdatesPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/UpdatesPage.kt @@ -112,10 +112,15 @@ internal fun UpdatesPage(s: FrontEndState, a: FrontEndActions, modifier: Modifie if (LocalNarrowPane.current) { StatusPanel(s, u, ua, me) Box(Modifier.height(18.dp)) + ChannelLabel() ChannelPicker(u, ua) - } else Row(horizontalArrangement = Arrangement.spacedBy(18.dp)) { - Box(Modifier.weight(1f)) { ChannelPicker(u, ua) } - Box(Modifier.weight(1.15f)) { StatusPanel(s, u, ua, me) } + } else { + // The label sits over both columns, so the status card lines up with the first channel. + ChannelLabel() + Row(horizontalArrangement = Arrangement.spacedBy(18.dp)) { + Box(Modifier.weight(1f)) { ChannelPicker(u, ua) } + Box(Modifier.weight(1.15f)) { StatusPanel(s, u, ua, me) } + } } } } @@ -140,13 +145,13 @@ private fun StatusPanel(s: FrontEndState, u: UpdatesState, ua: UpdatesActions, m val pal = LocalPalette.current val catalog = u.catalog val release = catalog?.let { AppUpdates.release(it, u.follow) } - val offer = if (me.ci) catalog?.let { AppUpdates.offer(it, u.follow, me) } else null + val offer = if (me.updatable) catalog?.let { AppUpdates.offer(it, u.follow, me) } else null val name = channelName(u.follow) val offered = release != null && (offer == Offer.UPDATE || offer == Offer.SWITCH) class Look(val tint: Color, val status: String, val headline: String, val detail: String?) val look = when { - !me.ci -> Look(colors.onSurfaceVariant, "Local build", "Built on a computer", - "It's signed differently from the builds here, so Android won't update it in place. Uninstall it to switch.") + !me.updatable -> Look(colors.onSurfaceVariant, "Signed differently", "Can't update in place", + "This copy isn't signed with DroidDeck's release key, so Android won't install the builds here over it. Uninstall it to switch.") catalog == null -> Look(colors.onSurfaceVariant, if (u.checking) "Checking…" else "Not checked yet", "Updates", null) offer == Offer.UPDATE -> Look(AttentionAmber, "Update available", newBuild(u.follow, release!!), null) offer == Offer.SWITCH -> Look(pal.signal, "Ready to switch", newBuild(u.follow, release!!), null) @@ -235,13 +240,20 @@ private fun changeTitle(t: String): String = private fun megabytes(bytes: Long) = "${(bytes + 524_288) / 1_048_576} MB" /** The three channels as cards to pick from; Test builds opens its list of PRs under it. */ +@Composable +private fun ChannelLabel() { + Text( + "UPDATE CHANNEL", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.5.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(start = 2.dp, top = 2.dp, bottom = 10.dp), + ) +} + @Composable private fun ChannelPicker(u: UpdatesState, ua: UpdatesActions) { val colors = MaterialTheme.colorScheme val catalog = u.catalog val tests = catalog?.tests.orEmpty() Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { - Text("UPDATE CHANNEL", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.5.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(start = 2.dp, top = 2.dp)) ChannelCard( Icons.Outlined.Verified, "Stable", "Tested releases, for most people", catalog?.stable?.let { "${it.version ?: it.tag} · ${ago(it.publishedAt)}" }, u.follow.channel == Channel.STABLE, diff --git a/app/src/main/java/com/droiddeck/launcher/update/AppUpdates.kt b/app/src/main/java/com/droiddeck/launcher/update/AppUpdates.kt index 955ea2b1e..5d3eb1593 100644 --- a/app/src/main/java/com/droiddeck/launcher/update/AppUpdates.kt +++ b/app/src/main/java/com/droiddeck/launcher/update/AppUpdates.kt @@ -1,6 +1,8 @@ package com.droiddeck.launcher.update import android.content.Context +import android.content.pm.PackageManager +import android.os.Build import com.droiddeck.launcher.BuildConfig import com.droiddeck.launcher.core.Hashes import org.json.JSONArray @@ -8,6 +10,7 @@ import org.json.JSONObject import java.io.IOException import java.net.HttpURLConnection import java.net.URL +import java.security.MessageDigest import java.time.Instant /** @@ -47,8 +50,11 @@ object AppUpdates { class Catalog(val stable: Release?, val nightly: Release?, val tests: List, val checkedAt: Long) - /** What is running: the commit it was built from, its PR (0 for none), whether CI made it, and when it was committed. */ - class Installed(val commit: String, val pr: Int, val version: String, val ci: Boolean, val committedAt: Long = 0L) + /** + * What is running: the commit it was built from, its PR (0 for none), whether the builds here + * install over it (it is signed with the release key), and when it was committed. + */ + class Installed(val commit: String, val pr: Int, val version: String, val updatable: Boolean, val committedAt: Long = 0L) enum class Offer { /** This is the build the channel has. */ @@ -63,7 +69,28 @@ object AppUpdates { GONE, } - fun installed(): Installed = Installed(BuildConfig.SOURCE_COMMIT, BuildConfig.PR_NUMBER, BuildConfig.VERSION_NAME, BuildConfig.CI_BUILD, BuildConfig.COMMIT_TIME) + @Volatile private var releaseSigned = BuildConfig.CI_BUILD + + /** From App.onCreate: whether this copy is signed with the release key, which a local build may be too. */ + fun init(context: Context) { + releaseSigned = runCatching { signers(context).any { it.equals(BuildConfig.RELEASE_SIGNER, ignoreCase = true) } } + .getOrDefault(BuildConfig.CI_BUILD) + } + + /** SHA-256 of each certificate the package is signed with, including the ones a rotated key vouches for. */ + private fun signers(context: Context): List { + val pm = context.packageManager + @Suppress("DEPRECATION") + val certs = if (Build.VERSION.SDK_INT >= 28) { + val info = pm.getPackageInfo(context.packageName, PackageManager.GET_SIGNING_CERTIFICATES).signingInfo ?: return emptyList() + if (info.hasMultipleSigners()) info.apkContentsSigners else info.signingCertificateHistory + } else { + pm.getPackageInfo(context.packageName, PackageManager.GET_SIGNATURES).signatures + } ?: return emptyList() + return certs.map { cert -> MessageDigest.getInstance("SHA-256").digest(cert.toByteArray()).joinToString("") { "%02x".format(it) } } + } + + fun installed(): Installed = Installed(BuildConfig.SOURCE_COMMIT, BuildConfig.PR_NUMBER, BuildConfig.VERSION_NAME, releaseSigned, BuildConfig.COMMIT_TIME) fun release(catalog: Catalog, follow: Follow): Release? = when (follow.channel) { Channel.STABLE -> catalog.stable @@ -87,7 +114,7 @@ object AppUpdates { /** The dot on the rail: the followed channel has a newer build of what is running. */ fun hasUpdate(catalog: Catalog?, follow: Follow): Boolean = - catalog != null && installed().ci && offer(catalog, follow) == Offer.UPDATE + catalog != null && installed().updatable && offer(catalog, follow) == Offer.UPDATE fun isRunning(r: Release, me: Installed = installed()): Boolean { if (me.commit.length < 7 || r.commit.length < 7 || r.pr != me.pr) return false @@ -105,7 +132,7 @@ object AppUpdates { me.pr != 0 -> Follow(Channel.TEST, me.pr) catalog == null -> return Follow(Channel.STABLE) catalog.stable != null && isRunning(catalog.stable, me) -> Follow(Channel.STABLE) - me.ci -> Follow(Channel.NIGHTLY) + me.updatable -> Follow(Channel.NIGHTLY) else -> Follow(Channel.STABLE) } setFollow(context, inferred) diff --git a/app/src/test/java/com/droiddeck/launcher/update/AppUpdatesTest.kt b/app/src/test/java/com/droiddeck/launcher/update/AppUpdatesTest.kt index 2ac5f5e80..6e57f3d67 100644 --- a/app/src/test/java/com/droiddeck/launcher/update/AppUpdatesTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/update/AppUpdatesTest.kt @@ -20,7 +20,7 @@ class AppUpdatesTest { checkedAt = 0L, ) - private fun me(commit: String, pr: Int = 0, version: String = "0.2.0") = Installed(commit, pr, version, ci = true) + private fun me(commit: String, pr: Int = 0, version: String = "0.2.0") = Installed(commit, pr, version, updatable = true) @Test fun theStableBuildIsCurrentOnStable() {