Say a build is signed with the wrong key before the installer does
Three things. A download whose signing certificate does not match the installed copy is stopped with a dialog naming both digests and offering the one thing that gets past it: removing the old app. Android's own answer is "App not installed" with no cause, which reads as the download having failed. Where either certificate cannot be read the answer is "don't know" and the install goes ahead as before. The download is carried on the component's state so that the removal is followed by the install it was for rather than by a second download. Pressing that revealed that ACTION_DELETE now needs REQUEST_DELETE_PACKAGES, and fails invisibly without it -- so the "Remove the old app" offer for a renamed package had presumably never worked either. The build mode and the installed variant are one dropdown, not two. They answer to the same words, so two pickers offering debug and release read as one choice asked twice. Where a component declares modes the mode is the whole answer, and the server serves the build named after it rather than the newest. Every dropdown now hangs from one outlined pill with a chevron. And a checkout parked on a chosen commit is no longer called out of date, with HEAD in the commit picker as the way back to following the branch. The commit list comes from that branch rather than from HEAD, so parking no longer hides the commits after it -- the same one-way door the tracked-only dirty check closed, in a place that check did not reach. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3aa6f2f290
commit
f983db154d
14 files changed
+885
-101
No files matched your search
@@ -392,6 +392,37 @@ mutable at runtime from the phone.
|
||||
somebody named HEAD -- worth doing now that the sheet can produce the
|
||||
state, where before it was only reachable on the build machine.
|
||||
|
||||
- **A checkout parked on a chosen commit is not one to call out of
|
||||
date, and the way back is `HEAD`.** Two halves of the same fact.
|
||||
The commit list is read from the **branch** (`git::head_branch`, then
|
||||
`git log <branch>`), not from HEAD: listed from HEAD, the commits
|
||||
*after* the parked one are not in the list, so the picker that moved
|
||||
the checkout back could only move it further back -- the same one-way
|
||||
door the tracked-only dirty check was written to close, in a place
|
||||
that check does not reach. `head_branch` answers the branch a
|
||||
checkout is on, or, when it is parked, the single local branch
|
||||
containing that commit; `None` when several contain it or none does,
|
||||
because there is then no such thing as *the* branch and choosing one
|
||||
would be this server deciding which history somebody meant. It rides
|
||||
on `/refs` as `head`, and the picker's `HEAD` entry moves there --
|
||||
shown as the *current* value while the checkout is following that
|
||||
branch, which is what "not pinned to anything" looks like. A checkout
|
||||
parked further back than the window gets its own commit appended, so
|
||||
the picker never has nothing to show for where it is.
|
||||
And `freshness` takes a `parked` flag, reporting a differing commit as
|
||||
**unknown** rather than `Behind` while the checkout is detached: every
|
||||
way it reads as behind while parked is a build that failed, a
|
||||
declaration waiting to be accepted, or a component with no build step,
|
||||
and each of those already says so on the same card beside the button
|
||||
for it -- so "out of date" there is either redundant or a nag about a
|
||||
decision somebody made. Unknown rather than current, because nothing
|
||||
measured the output to be what was wanted. `component_is_stale` is
|
||||
deliberately *not* changed: Update and Rebuild still rebuild a parked
|
||||
checkout, which is the whole point of parking one. The flag is passed
|
||||
down from `describe` rather than read per component, since git has
|
||||
already been asked for that project's status once and `/manifest` is
|
||||
fetched on every open, resume and Refresh.
|
||||
|
||||
- **Unrelated histories are the one pull failure the phone may override.**
|
||||
A checkout sharing no commit with its upstream has no fast-forward and
|
||||
never will, so with nothing offered the card is one that can never be
|
||||
@@ -745,18 +776,41 @@ mutable at runtime from the phone.
|
||||
release one. It stays quiet until this server has built the component
|
||||
once, so a project built by hand is not told it is out of date.
|
||||
|
||||
- **Each component card has a settings sheet, and the variant picker
|
||||
lives in it.** The gear sits at the row's right-hand end beside the log
|
||||
- **Each component card has a settings sheet, and there is one picker in
|
||||
it, not two.** The gear sits at the row's right-hand end beside the log
|
||||
button, drawn unconditionally so its presence is never the signal. The
|
||||
sheet holds the build mode, which of the discovered builds this phone
|
||||
installs, whether to strip, and Enrol; a section with nothing to offer
|
||||
says so rather than vanishing, since "this project declares one way of
|
||||
building" and "we could not tell" must not look alike. The variant
|
||||
picker moved here from the foot of the card because on the card the two
|
||||
read as one choice -- both say `debug` and `release` -- and they are
|
||||
not: one decides what the build machine *builds*, the other which
|
||||
finished build this phone takes. Everything applies on Save except
|
||||
Enrol, which is an action and happens on the press.
|
||||
sheet holds one **Build** choice, whether to strip, and Enrol; a
|
||||
section with nothing to offer says so rather than vanishing, since
|
||||
"this project declares one way of building" and "we could not tell"
|
||||
must not look alike. Everything applies on Save except Enrol, which is
|
||||
an action and happens on the press.
|
||||
The build mode and the installed variant were two dropdowns, and Iris
|
||||
asked for one (2026-09-02). They are genuinely different questions --
|
||||
what the build machine *builds*, and which finished build this phone
|
||||
*takes* -- but they answer to the same words, so two pickers both
|
||||
offering `debug` and `release` read as one choice asked twice; moving
|
||||
the variant off the card into the sheet had not fixed that, it had only
|
||||
moved it. So **where a component declares modes, the mode is the whole
|
||||
answer**: `AppEntry::resolve_apk` serves the build named after the
|
||||
component's effective mode when the phone asks for no particular one,
|
||||
which is what lets the phone stop having a second opinion. Saving in
|
||||
that state clears any stored variant outright rather than leaving it,
|
||||
since a pin set before the modes existed would otherwise go on quietly
|
||||
overriding a control that is no longer shown. A component with no
|
||||
declared modes still gets the variant list, because otherwise there
|
||||
would be nothing to choose at all -- and the note says which of the two
|
||||
it is offering, so "the machine decides" and "this phone decides" are
|
||||
never confused.
|
||||
Serving the mode's build rather than the newest is the right default
|
||||
for its own sake: a debug build made by hand after the mode was set to
|
||||
release is *newer*, and serving it would put a debug app on the phone
|
||||
from a card saying release.
|
||||
Every dropdown in the app hangs from one `PickerButton` -- an outlined
|
||||
pill with a chevron, Iris's ask in the same message -- so "there is a
|
||||
choice here" looks the same for a mode, a branch and a commit. The
|
||||
chevron is `md-chevron_down`, which like every other glyph had to be
|
||||
added to **both** `NerdIcons.kt` and `build-icon-font.sh` and the font
|
||||
regenerated.
|
||||
**The row's text is inside one weighted child so it can never push the
|
||||
controls off the edge**, and every reading in it truncates with an
|
||||
ellipsis. A control that leaves because the text grew is one the reader
|
||||
@@ -781,7 +835,40 @@ mutable at runtime from the phone.
|
||||
on disk to be served. Storing it server-side meant one enrolled device
|
||||
silently changing what another was offered. The stored choice is keyed
|
||||
by project *and* component on the device, so pinning one client to a
|
||||
release build says nothing about the other.
|
||||
release build says nothing about the other. Naming none is answered by
|
||||
the component's own mode before it is answered by the mtime; see the
|
||||
settings sheet below.
|
||||
|
||||
- **A build that cannot be installed over what is on the phone says so
|
||||
before the installer does.** Android refuses a package signed by a
|
||||
different key than the installed copy and reports it as "App not
|
||||
installed" with no cause, which reads as the download having failed.
|
||||
So `SigningKeys.kt` compares the downloaded APK's certificates against
|
||||
the installed package's, after the download and before the install
|
||||
intent, and the card puts up a dialog naming both digests with the one
|
||||
thing that gets past it: remove the old app. Iris asked for this on
|
||||
2026-09-02.
|
||||
It is **not** a guarantee and must not become one: where either side
|
||||
cannot be read the answer is "don't know", the install goes ahead, and
|
||||
Android decides exactly as before -- blocking on a guess would be worse
|
||||
than the sentence it replaces. Certificate *sets* are compared first,
|
||||
because a multiply-signed package is only replaceable by one signed by
|
||||
all the same keys and `hasSigningCertificate` reports false for it
|
||||
however the certificate is presented; that call is then asked as well,
|
||||
because it is the only thing that knows a rotated key's lineage.
|
||||
The download is carried on the state (`ComponentState.WrongKey`) so
|
||||
that removing the old app is followed by the install it was for rather
|
||||
than by a second download. The removal happens in the system's own
|
||||
dialog with this app off screen, so `continuePendingInstalls` is called
|
||||
from **both** things that can learn it finished -- the package
|
||||
broadcast and the resume -- since which arrives first depends on how
|
||||
long somebody spends in that dialog.
|
||||
**`ACTION_DELETE` needs `REQUEST_DELETE_PACKAGES` now**, and without it
|
||||
the failure is invisible from this side: the uninstaller starts, logs
|
||||
that this uid lacks the permission, and finishes without drawing, so
|
||||
the button reads as dead. It needed no permission when this app's first
|
||||
uninstall offer (the renamed-package one) was written, so that button
|
||||
had presumably never worked and nothing said so. Found by pressing it.
|
||||
|
||||
- **A project can produce more than one APK, and each component's builds
|
||||
are found under its own `cwd`.** `APK_PATTERNS` is anchored at
|
||||
|
||||
@@ -6,6 +6,16 @@
|
||||
an ACTION_VIEW intent; without it the intent silently fails on
|
||||
Android 8+ (see ApkInstaller.kt's canRequestInstall()). -->
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
<!-- The other half of the same story, and the same failure mode: an
|
||||
ACTION_DELETE for another package puts up the system's uninstall
|
||||
confirmation, and without this the confirmation is refused before
|
||||
it draws. It refuses silently from this side. The activity starts,
|
||||
logs that this uid does not hold REQUEST_DELETE_PACKAGES, and
|
||||
finishes, so the button reads as doing nothing at all. Measured on
|
||||
API 37; ACTION_DELETE needed no permission when this app's
|
||||
uninstall offers were first written, which is why they were, and
|
||||
why nobody noticed. See ApkInstaller.kt's uninstallIntent(). -->
|
||||
<uses-permission android:name="android.permission.REQUEST_DELETE_PACKAGES" />
|
||||
<!-- Android 17 (API 37) made Local Network Protection mandatory: an app
|
||||
targeting 37+ needs this runtime permission to reach *any* local
|
||||
network address, including a plain socket to a LAN IP literal with
|
||||
|
||||
@@ -105,10 +105,16 @@ fun downloadFromRoute(
|
||||
* Asks the system to remove [packageName], which shows its own confirmation dialog before anything
|
||||
* happens.
|
||||
*
|
||||
* ACTION_DELETE rather than PackageInstaller.uninstall(): it needs no permission at all, where the
|
||||
* newer call wants REQUEST_DELETE_PACKAGES to put up the same dialog. Removing someone's app is not
|
||||
* a thing to do quietly on their behalf, so the dialog is the point rather than a limitation being
|
||||
* worked around.
|
||||
* ACTION_DELETE rather than PackageInstaller.uninstall(): both put up the same system confirmation,
|
||||
* and this one needs no callback plumbing to do it. Removing someone's app is not a thing to do
|
||||
* quietly on their behalf, so the dialog is the point rather than a limitation being worked around
|
||||
* -- which is also why nothing here waits for a result: what the removal was *for* is picked up
|
||||
* from the package broadcast instead (see UpdaterScreen's `continuePendingInstalls`).
|
||||
*
|
||||
* Both forms need REQUEST_DELETE_PACKAGES in the manifest on a current Android, and the failure
|
||||
* without it is silent from this side: the activity starts and finishes without drawing, so the
|
||||
* button looks dead. It did not always need it, which is how this app shipped an offer to remove an
|
||||
* app that could not remove one.
|
||||
*/
|
||||
fun uninstallIntent(packageName: String): Intent =
|
||||
Intent(Intent.ACTION_DELETE, Uri.parse("package:$packageName"))
|
||||
|
||||
@@ -177,7 +177,19 @@ data class GitCommit(
|
||||
)
|
||||
|
||||
/** What the project settings sheet's two pickers are built from. */
|
||||
data class CheckoutRefs(val branches: List<GitBranch>, val commits: List<GitCommit>)
|
||||
data class CheckoutRefs(
|
||||
val branches: List<GitBranch>,
|
||||
val commits: List<GitCommit>,
|
||||
/**
|
||||
* The branch the commit picker's `HEAD` entry moves to, which is what makes picking a commit
|
||||
* undoable from here.
|
||||
*
|
||||
* Null when the build machine cannot say which branch is meant -- a checkout parked on a commit
|
||||
* that several branches contain, or none. The picker says so; choosing one would be guessing
|
||||
* which history somebody had in mind.
|
||||
*/
|
||||
val head: String?,
|
||||
)
|
||||
|
||||
/**
|
||||
* The branches and recent commits of one project's checkout.
|
||||
@@ -213,6 +225,7 @@ fun checkoutRefs(key: String): CheckoutRefs =
|
||||
current = commit.optBoolean("current", false),
|
||||
)
|
||||
},
|
||||
head = body.optString("head").takeIf { it.isNotEmpty() },
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -79,6 +79,12 @@ fun registerPackageChangeReceiver(
|
||||
object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val packageName = intent.data?.schemeSpecificPart ?: return
|
||||
// An install over an existing copy sends REMOVED and then
|
||||
// ADDED, with EXTRA_REPLACING marking the pair. Reporting
|
||||
// the first would put "this package is gone" on screen for
|
||||
// the moment between them, which is a state nothing here
|
||||
// is in.
|
||||
if (intent.getBooleanExtra(Intent.EXTRA_REPLACING, false)) return
|
||||
onPackageChanged(packageName)
|
||||
}
|
||||
}
|
||||
@@ -86,6 +92,11 @@ fun registerPackageChangeReceiver(
|
||||
IntentFilter().apply {
|
||||
addAction(Intent.ACTION_PACKAGE_ADDED)
|
||||
addAction(Intent.ACTION_PACKAGE_REPLACED)
|
||||
// Removals as well as arrivals, because one of them is a step
|
||||
// in something this app started: an app signed with a
|
||||
// different key has to be removed before the download can go
|
||||
// on, and this is what says the way is clear.
|
||||
addAction(Intent.ACTION_PACKAGE_REMOVED)
|
||||
addDataScheme("package")
|
||||
}
|
||||
ContextCompat.registerReceiver(context, receiver, filter, ContextCompat.RECEIVER_NOT_EXPORTED)
|
||||
|
||||
@@ -12,9 +12,9 @@ import androidx.compose.ui.text.font.FontFamily
|
||||
*
|
||||
* Ordinary Unicode won't do it -- there is no character for a git branch, and the ones that exist
|
||||
* for the rest aren't reliably in an Android system font, so they arrive as tofu boxes on
|
||||
* somebody's phone. The font here is `app/build-icon-font.sh`'s output: eight glyphs, 2 KB, from
|
||||
* the 2.5 MB symbols font. Adding one means adding its codepoint in *both* places -- a codepoint
|
||||
* here that the script didn't subset is a glyph that silently isn't there.
|
||||
* somebody's phone. The font here is `app/build-icon-font.sh`'s output: nine glyphs, 2 KB, from the
|
||||
* 2.5 MB symbols font. Adding one means adding its codepoint in *both* places -- a codepoint here
|
||||
* that the script didn't subset is a glyph that silently isn't there.
|
||||
*
|
||||
* All Material Design Icons bar one, so they read as one family; the exception is noted where it is
|
||||
* declared.
|
||||
@@ -57,3 +57,6 @@ val LOG_GLYPH = glyph(0xF02D)
|
||||
|
||||
/** `md-trash_can_outline` -- remove a scan directory. */
|
||||
val TRASH_GLYPH = glyph(0xF0A7A)
|
||||
|
||||
/** `md-chevron_down` -- this button opens a list to pick from. */
|
||||
val CHEVRON_DOWN_GLYPH = glyph(0xF0140)
|
||||
@@ -0,0 +1,108 @@
|
||||
package com.example.devupdater
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageInfo
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import java.io.File
|
||||
import java.security.MessageDigest
|
||||
|
||||
/*
|
||||
* Whether a downloaded APK can replace what is already on this phone.
|
||||
*
|
||||
* Android refuses to install a package over one signed with a different
|
||||
* key, and what it says about it is "App not installed" with no cause --
|
||||
* which reads as the download having gone wrong rather than as the two
|
||||
* builds being unrelated. That sentence is among the most expensive here
|
||||
* to be handed, because the thing to do about it (remove the old app,
|
||||
* losing its data) is not one anybody guesses.
|
||||
*
|
||||
* So the comparison is made here, after the download and before the
|
||||
* installer is opened, and the phone says what it found. The check is
|
||||
* read-only and local: two PackageManager calls and a digest.
|
||||
*
|
||||
* It is deliberately *not* a guarantee. Where either side cannot be read
|
||||
* the answer is "don't know", which behaves exactly as before -- the
|
||||
* install goes ahead and Android decides. Blocking on a guess would be
|
||||
* worse than the sentence it is trying to replace.
|
||||
*/
|
||||
|
||||
/** Two signing certificates that do not match, and where each came from. */
|
||||
data class SigningMismatch(
|
||||
val packageName: String,
|
||||
/** SHA-256 of the certificate the installed copy was signed with. */
|
||||
val installed: String,
|
||||
/** SHA-256 of the certificate the downloaded build was signed with. */
|
||||
val downloaded: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* Why installing [apk] over [packageName] would be refused, or null when it would not be -- which
|
||||
* includes every case this cannot find out.
|
||||
*
|
||||
* Null for: nothing installed under that name, either side unreadable, an OS too old to be asked
|
||||
* (the certificates are only reachable through an API 28 call; below that this says nothing rather
|
||||
* than reaching for the deprecated `signatures` field), and of course a key that matches.
|
||||
*/
|
||||
fun signingMismatch(context: Context, apk: File, packageName: String): SigningMismatch? {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) return null
|
||||
val packages = context.packageManager
|
||||
val installed =
|
||||
try {
|
||||
packages.getPackageInfo(packageName, PackageManager.GET_SIGNING_CERTIFICATES)
|
||||
} catch (_: PackageManager.NameNotFoundException) {
|
||||
// Nothing to replace, so nothing to disagree with: a first
|
||||
// install of any build succeeds whatever signed it.
|
||||
return null
|
||||
}
|
||||
val here = signers(installed)
|
||||
val incoming =
|
||||
signers(packages.getPackageArchiveInfo(apk.path, PackageManager.GET_SIGNING_CERTIFICATES))
|
||||
if (here.isEmpty() || incoming.isEmpty()) return null
|
||||
|
||||
// Set equality rather than "any in common", because a package signed
|
||||
// by several keys is only replaceable by one signed by all of them --
|
||||
// and it is asked first because it is the case `hasSigningCertificate`
|
||||
// cannot answer: that call reports false for a multiply-signed package
|
||||
// however the certificate is presented.
|
||||
if (here.map(::digest).toSet() == incoming.map(::digest).toSet()) return null
|
||||
// The rotation case. A key that has been rotated signs an update that
|
||||
// the installed copy's lineage accepts, and the OS is the only thing
|
||||
// that knows the lineage -- so it is asked rather than guessed at from
|
||||
// the certificates in hand.
|
||||
if (
|
||||
incoming.any {
|
||||
packages.hasSigningCertificate(packageName, it, PackageManager.CERT_INPUT_RAW_X509)
|
||||
}
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return SigningMismatch(
|
||||
packageName = packageName,
|
||||
installed = digest(here.first()),
|
||||
downloaded = digest(incoming.first()),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The certificates a package or an archive is signed with, newest lineage entry only.
|
||||
*
|
||||
* `apkContentsSigners` rather than the whole history: what matters is what actually signed this
|
||||
* copy, and the history is what [signingMismatch] asks the OS about separately.
|
||||
*/
|
||||
private fun signers(info: PackageInfo?): List<ByteArray> =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
info?.signingInfo?.apkContentsSigners.orEmpty().map { it.toByteArray() }
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
/**
|
||||
* SHA-256 of a certificate, as lower-case hex.
|
||||
*
|
||||
* The same digest `apksigner verify --print-certs` prints on the build machine, so the two ends of
|
||||
* a mismatch can be compared without converting anything by hand -- which is the whole use for it
|
||||
* once the dialog has said what to do.
|
||||
*/
|
||||
private fun digest(certificate: ByteArray): String =
|
||||
MessageDigest.getInstance("SHA-256").digest(certificate).joinToString("") { "%02x".format(it) }
|
||||
@@ -15,6 +15,7 @@ import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
@@ -25,6 +26,7 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.wrapContentSize
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
@@ -37,6 +39,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedCard
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
@@ -231,6 +234,16 @@ private sealed class ComponentState {
|
||||
*/
|
||||
data class Busy(val what: String) : ComponentState()
|
||||
|
||||
/**
|
||||
* The download is here, and Android would refuse to install it: what is on the phone was signed
|
||||
* with a different key.
|
||||
*
|
||||
* A state rather than an error, because there is something to do about it and the file to do it
|
||||
* with is already on the phone -- carried here so that removing the old app can be followed by
|
||||
* the install it was for, rather than by a second download.
|
||||
*/
|
||||
data class WrongKey(val file: File, val mismatch: SigningMismatch) : ComponentState()
|
||||
|
||||
/** Why the last thing this component was asked to do stopped. */
|
||||
data class Error(val message: String) : ComponentState()
|
||||
}
|
||||
@@ -1020,11 +1033,51 @@ private fun AppListScreen(
|
||||
setComponent(entry.key, component, failure(e)?.let(ComponentState::Error))
|
||||
return@launch
|
||||
}
|
||||
// Android refuses a package signed by a different key than
|
||||
// the copy already here, and says only "App not installed"
|
||||
// about it -- so the comparison is made here, while there is
|
||||
// still something on screen to say what happened and what to
|
||||
// do. Only after the download, because it is the downloaded
|
||||
// file's own certificate that decides it.
|
||||
val installed = entry.components.firstOrNull { it.name == component }?.apk?.packageName
|
||||
val mismatch = installed?.let {
|
||||
withContext(Dispatchers.IO) { signingMismatch(context, file, it) }
|
||||
}
|
||||
if (mismatch != null) {
|
||||
setComponent(entry.key, component, ComponentState.WrongKey(file, mismatch))
|
||||
return@launch
|
||||
}
|
||||
setComponent(entry.key, component, null)
|
||||
install(file)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Installs a download that was waiting for the old app to be removed, now that it is gone.
|
||||
*
|
||||
* The removal happens in the system's own uninstall dialog, so this app is not on screen when
|
||||
* it finishes and cannot simply carry on from the button press. Called from both places that
|
||||
* can learn about it -- the package broadcast, and coming back to this screen -- because which
|
||||
* of them arrives first depends on how long somebody spends in that dialog, and neither alone
|
||||
* covers both orders.
|
||||
*
|
||||
* Nothing happens for a package that is still installed: backing out of the removal leaves the
|
||||
* offer exactly as it was, which is what a cancelled dialog should do.
|
||||
*/
|
||||
fun continuePendingInstalls() {
|
||||
componentStates.forEach { (key, states) ->
|
||||
states.forEach { (component, state) ->
|
||||
if (
|
||||
state is ComponentState.WrongKey &&
|
||||
!isInstalled(context, state.mismatch.packageName)
|
||||
) {
|
||||
setComponent(key, component, null)
|
||||
install(state.file)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a management call, then refetches so the list reflects the server rather than a guess.
|
||||
*/
|
||||
@@ -1239,6 +1292,10 @@ private fun AppListScreen(
|
||||
// first-composition run below cannot stack two.
|
||||
LifecycleResumeEffect(Unit) {
|
||||
foreground = true
|
||||
// Before the refresh, and not waiting on it: coming back from the
|
||||
// system's uninstall dialog is the other half of a press that
|
||||
// happened here, and the manifest has nothing to say about it.
|
||||
continuePendingInstalls()
|
||||
refresh(afterAGap = true)
|
||||
// Not "the app is gone" -- the coroutines started above keep
|
||||
// running, deliberately, because a download that finishes while
|
||||
@@ -1264,6 +1321,9 @@ private fun AppListScreen(
|
||||
}
|
||||
}
|
||||
?.let(::updateInstalledState)
|
||||
// The removal this app asked for, arriving before the
|
||||
// resume above when somebody is quick about it.
|
||||
continuePendingInstalls()
|
||||
}
|
||||
onDispose { context.unregisterReceiver(receiver) }
|
||||
}
|
||||
@@ -1411,6 +1471,13 @@ private fun AppListScreen(
|
||||
manage(entry) { setAppSettings(entry.key, gitIpv4) }
|
||||
},
|
||||
onCheckout = { target -> startCheckout(entry, target) },
|
||||
// Only this component's slot is
|
||||
// cleared: the download it was
|
||||
// holding is dropped, and every
|
||||
// other card is left alone.
|
||||
onCancelInstall = { component ->
|
||||
setComponent(entry.key, component, null)
|
||||
},
|
||||
onApprove = {
|
||||
manage(entry) { approveDeclaration(entry.key) }
|
||||
},
|
||||
@@ -1580,6 +1647,8 @@ private fun AppCard(
|
||||
onComponentSettings: (component: String, mode: String?, strip: Boolean?) -> Unit,
|
||||
/** Fetch this component's link from the build machine and open it here. */
|
||||
onEnroll: (component: String) -> Unit,
|
||||
/** Give up on a download whose signing key does not match what is installed. */
|
||||
onCancelInstall: (component: String) -> Unit,
|
||||
// Which component this card is running a service action for, if any --
|
||||
// so the one being acted on is the one that shows it, rather than
|
||||
// every row going quiet together.
|
||||
@@ -1840,6 +1909,7 @@ private fun AppCard(
|
||||
onServiceAction(component.name, action, purge)
|
||||
},
|
||||
onBuild = { onComponentBuild(entry, component.name) },
|
||||
onCancelInstall = { onCancelInstall(component.name) },
|
||||
controls = {
|
||||
if (!component.isServer) {
|
||||
// Said before the button, because it
|
||||
@@ -2197,8 +2267,8 @@ private fun CheckoutSection(
|
||||
else ->
|
||||
SettingsNote(
|
||||
"Moves the checkout on the build machine and builds what that leaves behind, the " +
|
||||
"same as Pull does. Picking a commit leaves it on no branch until a branch " +
|
||||
"is picked again."
|
||||
"same as Pull does. Picking a commit parks it there, on no branch; HEAD is " +
|
||||
"the way back to following the branch."
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2227,6 +2297,11 @@ private fun CheckoutSection(
|
||||
Text("Commit", style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f))
|
||||
CommitPicker(
|
||||
commits = loaded?.commits.orEmpty(),
|
||||
head = loaded?.head,
|
||||
// Taken from the branch line the card already shows rather
|
||||
// than worked out again here, so the two cannot disagree
|
||||
// about whether this checkout is on a branch.
|
||||
detached = git.branch == DETACHED_HEAD,
|
||||
enabled = enabled,
|
||||
onSelect = onCheckout,
|
||||
)
|
||||
@@ -2242,16 +2317,14 @@ private fun BranchPicker(
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
Box {
|
||||
TextButton(onClick = { expanded = true }, enabled = enabled && branches.isNotEmpty()) {
|
||||
// "HEAD" is what git says when no branch is checked out. Said
|
||||
// in words here, because a reader has no way to know that the
|
||||
// literal string is a state rather than a branch somebody
|
||||
// made.
|
||||
Text(
|
||||
// "HEAD" is what git says when no branch is checked out. Said in
|
||||
// words here, because a reader has no way to know that the literal
|
||||
// string is a state rather than a branch somebody made.
|
||||
PickerButton(
|
||||
if (current == DETACHED_HEAD) "none" else current,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
enabled = enabled && branches.isNotEmpty(),
|
||||
) {
|
||||
expanded = true
|
||||
}
|
||||
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||
branches.forEach { branch ->
|
||||
@@ -2278,15 +2351,68 @@ private fun BranchPicker(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Which commit the checkout sits on, and the way back from having picked one.
|
||||
*
|
||||
* [head] is the branch the `HEAD` entry moves to, which is what makes picking a commit something a
|
||||
* phone can undo: a commit detaches the checkout, and re-attaching it is checking the branch out
|
||||
* again. Shown as the current value while the checkout is following that branch, which is what
|
||||
* "this is not pinned to anything" looks like -- the commit it happens to be on is the tip, and
|
||||
* naming it there would read as a choice somebody made.
|
||||
*
|
||||
* Null [head] is a checkout parked where no single branch can be meant. The entry stays, so its
|
||||
* absence is never the signal, and says what to do instead.
|
||||
*/
|
||||
@Composable
|
||||
private fun CommitPicker(commits: List<GitCommit>, enabled: Boolean, onSelect: (String) -> Unit) {
|
||||
private fun CommitPicker(
|
||||
commits: List<GitCommit>,
|
||||
head: String?,
|
||||
detached: Boolean,
|
||||
enabled: Boolean,
|
||||
onSelect: (String) -> Unit,
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
val here = commits.firstOrNull { it.current }
|
||||
Box {
|
||||
TextButton(onClick = { expanded = true }, enabled = enabled && commits.isNotEmpty()) {
|
||||
Text(here?.short ?: "\u2014", maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
PickerButton(
|
||||
if (!detached) DETACHED_HEAD else here?.short ?: "\u2014",
|
||||
enabled = enabled && commits.isNotEmpty(),
|
||||
) {
|
||||
expanded = true
|
||||
}
|
||||
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
DETACHED_HEAD,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
when {
|
||||
!detached -> "latest on $head \u2713"
|
||||
head != null -> "back to the latest on $head"
|
||||
// Several branches contain this commit, or
|
||||
// none does, so there is no such thing as
|
||||
// the one to return to -- and choosing one
|
||||
// would be guessing which history was
|
||||
// meant.
|
||||
else -> "pick a branch above to leave this commit"
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
},
|
||||
enabled = head != null,
|
||||
onClick = {
|
||||
expanded = false
|
||||
head?.takeIf { detached }?.let(onSelect)
|
||||
},
|
||||
)
|
||||
commits.forEach { commit ->
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
@@ -2302,7 +2428,14 @@ private fun CommitPicker(commits: List<GitCommit>, enabled: Boolean, onSelect: (
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
if (commit.current) "${commit.subject} ✓" else commit.subject,
|
||||
// Ticked only when it is the selection,
|
||||
// which is not the same as being where the
|
||||
// checkout is: following a branch, the tip
|
||||
// is where HEAD is *and* the entry above
|
||||
// is what was chosen, and two ticks would
|
||||
// say the choice was made twice.
|
||||
if (commit.current && detached) "${commit.subject} ✓"
|
||||
else commit.subject,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
@@ -2311,7 +2444,10 @@ private fun CommitPicker(commits: List<GitCommit>, enabled: Boolean, onSelect: (
|
||||
},
|
||||
onClick = {
|
||||
expanded = false
|
||||
if (!commit.current) onSelect(commit.sha)
|
||||
// Picking the tip while following the branch is a
|
||||
// real move: it parks the checkout there, which is
|
||||
// what stops it advancing on the next pull.
|
||||
if (!commit.current || !detached) onSelect(commit.sha)
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -2706,6 +2842,8 @@ private fun ComponentCard(
|
||||
onAction: (String, Purge) -> Unit,
|
||||
/** Build this component again. A server's is drawn here, beside its service buttons. */
|
||||
onBuild: () -> Unit,
|
||||
/** Throw away a download that cannot be installed over what is here. */
|
||||
onCancelInstall: () -> Unit = {},
|
||||
// Controls belonging to this component that only the caller can build
|
||||
// -- an APK's Update button, which needs the project's build state.
|
||||
// The service's own buttons are decided here, from the component.
|
||||
@@ -3026,12 +3164,14 @@ private fun ComponentCard(
|
||||
}
|
||||
}
|
||||
|
||||
// The variant picker used to sit here. It moved into the
|
||||
// settings sheet, where the build mode is: on the card the two
|
||||
// read as the same choice -- both say "debug" and "release" --
|
||||
// and they are not. One decides what the build machine
|
||||
// *builds*; the other decides which of the finished builds
|
||||
// this phone installs.
|
||||
// The variant picker used to sit here, and then in the
|
||||
// settings sheet beside the build mode. It is now the same
|
||||
// control as the mode: on the card the two read as one choice
|
||||
// -- both say "debug" and "release" -- and in the sheet they
|
||||
// still did. Where a project declares modes the mode answers
|
||||
// for both, because the server serves the build of the mode a
|
||||
// component is set to; the variants are offered only where
|
||||
// nothing declares them.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3055,6 +3195,14 @@ private fun ComponentCard(
|
||||
)
|
||||
}
|
||||
|
||||
if (state is ComponentState.WrongKey) {
|
||||
WrongKeyDialog(
|
||||
mismatch = state.mismatch,
|
||||
isSelf = state.mismatch.packageName == LocalContext.current.packageName,
|
||||
onDismiss = onCancelInstall,
|
||||
)
|
||||
}
|
||||
|
||||
if (showingLog) {
|
||||
ComponentLogDialog(
|
||||
entryKey = entryKey,
|
||||
@@ -3181,23 +3329,25 @@ private fun ComponentSettingsDialog(
|
||||
title = { Text(component.name) },
|
||||
text = {
|
||||
Column(Modifier.verticalScroll(rememberScrollState())) {
|
||||
SettingsHeading("Build mode")
|
||||
// One control, not two. A build mode and a built variant
|
||||
// are different things -- one decides what the build
|
||||
// machine produces, the other which of its outputs this
|
||||
// phone takes -- but they answer to the same words, so two
|
||||
// dropdowns both offering "debug" and "release" read as one
|
||||
// choice asked twice. Where a project declares its modes
|
||||
// the mode is the whole answer: the machine builds it and
|
||||
// the download follows, because the server serves the build
|
||||
// of the mode a component is set to. The variants are only
|
||||
// offered where nothing declares them.
|
||||
SettingsHeading("Build")
|
||||
val apk = component.apk
|
||||
when {
|
||||
component.modes.isEmpty() ->
|
||||
SettingsNote(
|
||||
"This project declares one way of building ${component.name}, so there " +
|
||||
"is nothing to choose."
|
||||
)
|
||||
else -> {
|
||||
component.modes.isNotEmpty() -> {
|
||||
SettingsNote(
|
||||
"Chosen on the build machine, so it is what every phone here is " +
|
||||
"offered -- not just this one."
|
||||
"offered -- not just this one. This phone installs whatever it " +
|
||||
"builds."
|
||||
)
|
||||
// A dropdown rather than a row of radios, matching
|
||||
// the build picker below it: they are the same
|
||||
// shape of question -- one of a short list -- and
|
||||
// two different controls for that in one sheet
|
||||
// makes them look like different kinds of choice.
|
||||
ModePicker(component.modes, mode, modeSettled) { mode = it }
|
||||
if (!modeSettled) {
|
||||
SettingsNote(
|
||||
@@ -3206,31 +3356,39 @@ private fun ComponentSettingsDialog(
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only an APK has anything below this. A server is not
|
||||
// installed here, so there is no build for this phone to
|
||||
// pick and nothing to strip on the way to it.
|
||||
component.apk?.let { apk ->
|
||||
Spacer(Modifier.height(16.dp))
|
||||
SettingsHeading("Install")
|
||||
when {
|
||||
// No declared modes, so what there is to choose between
|
||||
// is whatever has been built. This one really is this
|
||||
// phone's alone: nothing on the build machine changes.
|
||||
apk == null ->
|
||||
SettingsNote(
|
||||
"This project declares one way of building ${component.name}, so " +
|
||||
"there is nothing to choose."
|
||||
)
|
||||
apk.variants.isEmpty() ->
|
||||
SettingsNote("Nothing is built yet, so there is nothing to install.")
|
||||
SettingsNote(
|
||||
"This project declares no build modes, and nothing is built yet, so " +
|
||||
"there is nothing to choose."
|
||||
)
|
||||
apk.variants.size == 1 ->
|
||||
SettingsNote(
|
||||
"One build so far (${apk.variants.first().variant}), so there is " +
|
||||
"nothing to choose between."
|
||||
"This project declares no build modes, and there is one build so far " +
|
||||
"(${apk.variants.first().variant}), so there is nothing to " +
|
||||
"choose between."
|
||||
)
|
||||
else -> {
|
||||
SettingsNote(
|
||||
"Which of the builds on the machine this phone installs. Only " +
|
||||
"this phone's."
|
||||
"This project declares no build modes, so the choice is which of the " +
|
||||
"builds already on the machine this phone installs. Only this " +
|
||||
"phone's."
|
||||
)
|
||||
VariantPicker(apk.variants, variantPath) { variantPath = it?.path }
|
||||
}
|
||||
}
|
||||
|
||||
// Only an APK has anything below this. A server is not
|
||||
// installed here, so there is nothing to strip on the way
|
||||
// to a phone.
|
||||
apk?.let { apk ->
|
||||
Spacer(Modifier.height(16.dp))
|
||||
SettingsHeading("Transfer")
|
||||
Row(
|
||||
@@ -3288,11 +3446,18 @@ private fun ComponentSettingsDialog(
|
||||
onClick = {
|
||||
// The variant is this device's, so it is written here
|
||||
// and not sent; the other two are the build machine's.
|
||||
if (variantPath != chosenVariantPath) {
|
||||
onSelectVariant(
|
||||
component.apk?.variants?.firstOrNull { it.path == variantPath }
|
||||
)
|
||||
//
|
||||
// Cleared outright once a project declares its modes,
|
||||
// rather than left where it was: the mode is then the
|
||||
// whole answer, and a pin set before the modes existed
|
||||
// would go on quietly overriding it from a sheet that
|
||||
// no longer shows it.
|
||||
val chosen =
|
||||
when {
|
||||
component.modes.isNotEmpty() -> null
|
||||
else -> component.apk?.variants?.firstOrNull { it.path == variantPath }
|
||||
}
|
||||
if (chosen?.path != chosenVariantPath) onSelectVariant(chosen)
|
||||
onApply(mode, strip)
|
||||
}
|
||||
) {
|
||||
@@ -3303,6 +3468,98 @@ private fun ComponentSettingsDialog(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Says why the build that has just arrived cannot be installed over the one already here, and
|
||||
* offers the only thing that gets past it.
|
||||
*
|
||||
* Android refuses a package signed by a different key and reports that as "App not installed", with
|
||||
* no cause -- which reads as the download having failed. So this is shown *after* the download and
|
||||
* before the installer, at the one moment there is still something on screen able to explain it.
|
||||
*
|
||||
* Removal is offered rather than hidden, and what it costs is said plainly: the old app's data goes
|
||||
* with it, and there is no way to keep it. Hiding the option would not prevent the outcome, it
|
||||
* would move it somewhere with nothing attached to explain it.
|
||||
*
|
||||
* The two digests are the machine's own words, so they are selectable like every other output here
|
||||
* -- they are what `apksigner verify --print-certs` prints on the build machine, which is where
|
||||
* working out *why* the keys differ happens.
|
||||
*/
|
||||
@Composable
|
||||
private fun WrongKeyDialog(
|
||||
mismatch: SigningMismatch,
|
||||
/** This app is the one that would be removed, which costs more than any other app's removal. */
|
||||
isSelf: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Signed with a different key") },
|
||||
text = {
|
||||
Column(
|
||||
Modifier.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
"${mismatch.packageName} is already installed, and this build was signed " +
|
||||
"with a different key. Android will not replace one with the other, so " +
|
||||
"the old app has to go first."
|
||||
)
|
||||
Text(
|
||||
if (isSelf) {
|
||||
"That app is this one. Removing it takes its enrolment with it, and the " +
|
||||
"way back is the build machine's plain download link -- not this " +
|
||||
"screen, which will be gone."
|
||||
} else {
|
||||
"Removing it deletes everything it was keeping on this phone. Nothing " +
|
||||
"here can put that back."
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = ActionTone.Destructive.color,
|
||||
)
|
||||
// Ordinary output colour, not the red the message above
|
||||
// wears: these are two facts, and colouring them the same
|
||||
// as the warning would say the digests are the problem.
|
||||
// Monospaced because a hex digest is compared character by
|
||||
// character, which is exactly what a proportional font
|
||||
// makes hard.
|
||||
val digest =
|
||||
MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace)
|
||||
SettingsNote("Installed")
|
||||
OutputText(
|
||||
mismatch.installed,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = digest,
|
||||
)
|
||||
SettingsNote("Downloaded")
|
||||
OutputText(
|
||||
mismatch.downloaded,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = digest,
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
// The red every control that takes something away wears.
|
||||
// Removing an app is the most of that on this screen.
|
||||
colors = ActionTone.Destructive.colors(),
|
||||
onClick = {
|
||||
// Not dismissed: the download stays on the phone and
|
||||
// the offer stays on screen, because the removal
|
||||
// happens in a system dialog somebody can back out of.
|
||||
// Getting through it is what installs this build --
|
||||
// see `continuePendingInstalls`.
|
||||
context.startActivity(uninstallIntent(mismatch.packageName))
|
||||
},
|
||||
) {
|
||||
Text("Remove the old app")
|
||||
}
|
||||
},
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsHeading(text: String) {
|
||||
Text(text, style = MaterialTheme.typography.titleSmall)
|
||||
@@ -3462,6 +3719,40 @@ private fun PurgeToggle(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The button a dropdown hangs from: what is chosen, and a chevron saying there is a list behind it.
|
||||
*
|
||||
* A pill with an outline rather than a bare label, because a dropdown's anchor has to read as
|
||||
* something to press. Every picker here uses this one, so "there is a choice here" looks the same
|
||||
* whether it is a build mode, a branch or a commit -- four anchors styled four ways would make the
|
||||
* differences between them look meaningful.
|
||||
*
|
||||
* The label truncates and the chevron does not move: the values are branch names and commit
|
||||
* subjects, which have no length limit, and a control that slides off the edge as its label grows
|
||||
* is one the reader cannot get back to.
|
||||
*/
|
||||
@Composable
|
||||
private fun PickerButton(label: String, enabled: Boolean, onClick: () -> Unit) {
|
||||
OutlinedButton(
|
||||
onClick = onClick,
|
||||
enabled = enabled,
|
||||
shape = RoundedCornerShape(percent = 50),
|
||||
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 6.dp),
|
||||
) {
|
||||
Text(
|
||||
label,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
// Not given a name of its own: it is beside the label rather than
|
||||
// instead of it, so what the button is called is already there for
|
||||
// anything reading the screen aloud.
|
||||
Text(CHEVRON_DOWN_GLYPH, fontFamily = NerdIcons, fontSize = 13.sp)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ModePicker(
|
||||
modes: List<String>,
|
||||
@@ -3471,12 +3762,10 @@ private fun ModePicker(
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
Box {
|
||||
TextButton(onClick = { expanded = true }, enabled = enabled) {
|
||||
// Never "none": unlike the build picker there is always an
|
||||
// answer here, because a component with modes is always being
|
||||
// built in one of them -- the first, when nobody has chosen.
|
||||
Text(chosen ?: modes.firstOrNull().orEmpty())
|
||||
}
|
||||
// Never "none": there is always an answer here, because a component
|
||||
// with modes is always being built in one of them -- the first,
|
||||
// when nobody has chosen.
|
||||
PickerButton(chosen ?: modes.firstOrNull().orEmpty(), enabled) { expanded = true }
|
||||
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||
modes.forEach { candidate ->
|
||||
DropdownMenuItem(
|
||||
@@ -3501,9 +3790,7 @@ private fun VariantPicker(
|
||||
val selected = variants.firstOrNull { it.path == chosenPath }
|
||||
|
||||
Box {
|
||||
TextButton(onClick = { expanded = true }) {
|
||||
Text("Variant: ${selected?.variant ?: "newest"}")
|
||||
}
|
||||
PickerButton(selected?.variant ?: "newest", enabled = true) { expanded = true }
|
||||
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||
// Explicitly offered, because it is the default and there has
|
||||
// to be a way back to it once a variant has been picked.
|
||||
|
||||
Binary file not shown.
@@ -28,6 +28,7 @@ GLYPHS=(
|
||||
U+F0450 # md-refresh
|
||||
U+F0A7A # md-trash_can_outline
|
||||
U+F02D # fa-book
|
||||
U+F0140 # md-chevron_down
|
||||
)
|
||||
|
||||
url=https://github.com/ryanoasis/nerd-fonts/releases/latest/download/NerdFontsSymbolsOnly.zip
|
||||
|
||||
@@ -47,8 +47,8 @@ pub enum Freshness {
|
||||
Current,
|
||||
/// Its directory has moved past the commit it was built from.
|
||||
Behind,
|
||||
/// Never built here, no checkout to compare against, or uncommitted
|
||||
/// work in its directory.
|
||||
/// Never built here, no checkout to compare against, uncommitted work
|
||||
/// in its directory, or a checkout parked on a commit somebody chose.
|
||||
Unknown,
|
||||
}
|
||||
|
||||
@@ -555,7 +555,25 @@ impl BuildState {
|
||||
/// notice the new output rather than to produce one. Rebuilding
|
||||
/// uncommitted work and sending it to a phone would also be a
|
||||
/// surprising thing to do with work its author has not committed.
|
||||
pub fn freshness(&self, component: &Component) -> Freshness {
|
||||
///
|
||||
/// `parked` says the checkout is sitting on a commit somebody chose
|
||||
/// rather than following a branch, which is the one way a phone can
|
||||
/// leave it: picking a commit in the settings sheet detaches HEAD.
|
||||
/// Saying "out of date" about a checkout somebody deliberately moved
|
||||
/// backwards is nagging about a decision already made -- and it is
|
||||
/// nothing to act on either, because every way this reads as behind
|
||||
/// while parked is a build that failed, a declaration waiting to be
|
||||
/// accepted, or a component with no build step, each of which is
|
||||
/// already said on the same card beside the button for it. So while
|
||||
/// parked a differing commit is reported as unknown: withheld, rather
|
||||
/// than claimed current, since nothing here measured the output to be
|
||||
/// what somebody wanted.
|
||||
///
|
||||
/// Passed in rather than read here. The caller has already asked git
|
||||
/// for this project's status, and asking again would be one more
|
||||
/// process per component on a path fetched on every open, resume and
|
||||
/// Refresh.
|
||||
pub fn freshness(&self, component: &Component, parked: bool) -> Freshness {
|
||||
// Behind for a reason no commit can express: what is on disk was
|
||||
// built some other way than this component is set to build now.
|
||||
// Reported before the checkout is consulted at all, because it is
|
||||
@@ -581,6 +599,7 @@ impl BuildState {
|
||||
}
|
||||
match crate::git::subtree_head(&self.project_path, &component.watched_paths()) {
|
||||
Some(current) if current == built => Freshness::Current,
|
||||
Some(_) if parked => Freshness::Unknown,
|
||||
Some(_) => Freshness::Behind,
|
||||
None => Freshness::Unknown,
|
||||
}
|
||||
@@ -1933,10 +1952,62 @@ mod tests {
|
||||
"a debug build must not satisfy a component set to build release",
|
||||
);
|
||||
assert_eq!(
|
||||
switched.freshness(&switched.components[0]),
|
||||
switched.freshness(&switched.components[0], false),
|
||||
Freshness::Behind,
|
||||
"and the card has to say so rather than reading as current",
|
||||
);
|
||||
assert_eq!(
|
||||
switched.freshness(&switched.components[0], true),
|
||||
Freshness::Behind,
|
||||
"a parked checkout says nothing about which mode was built, so this one still \
|
||||
reports -- what the parking silences is the commit comparison alone",
|
||||
);
|
||||
}
|
||||
|
||||
/// Picking a commit is a decision, and a card that answers it with
|
||||
/// "out of date" is nagging about one already made. The comparison
|
||||
/// itself is unchanged -- what changes is that a *parked* checkout
|
||||
/// withholds it rather than reporting the difference as being behind.
|
||||
///
|
||||
/// Checked against the ordinary case in the same test, because that is
|
||||
/// the half a change like this can break without looking broken: a
|
||||
/// checkout following its branch must still say when its build is
|
||||
/// older than what is checked out.
|
||||
#[test]
|
||||
fn a_checkout_parked_on_a_chosen_commit_is_not_reported_as_out_of_date() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let root = dir.path();
|
||||
let components = two_component_checkout(root);
|
||||
let first = crate::git::subtree_head(root, &[PathBuf::from("app")]).expect("a commit");
|
||||
|
||||
std::fs::write(root.join("app/main.kt"), "two").expect("write");
|
||||
run(root, &["git", "commit", "-qam", "two"]);
|
||||
|
||||
let state = state_for(root, components);
|
||||
// Built at the first commit, and the checkout has since moved on:
|
||||
// the ordinary way to be behind, and the one that must survive.
|
||||
state
|
||||
.inner
|
||||
.lock()
|
||||
.unwrap()
|
||||
.built_from
|
||||
.insert("app".to_string(), first);
|
||||
let app = state
|
||||
.components
|
||||
.iter()
|
||||
.find(|component| component.name() == "app")
|
||||
.expect("the app component");
|
||||
|
||||
assert_eq!(
|
||||
state.freshness(app, false),
|
||||
Freshness::Behind,
|
||||
"a checkout following its branch still reports a build older than it",
|
||||
);
|
||||
assert_eq!(
|
||||
state.freshness(app, true),
|
||||
Freshness::Unknown,
|
||||
"and one parked on a chosen commit withholds it rather than nagging",
|
||||
);
|
||||
}
|
||||
|
||||
/// The shape of bug a unit test that only calls `component_is_stale`
|
||||
|
||||
+153
-11
@@ -354,7 +354,7 @@ pub fn branches(project: &Path) -> Vec<Branch> {
|
||||
let head = status(project).map(|status| status.branch);
|
||||
// Detached HEAD reports the literal `HEAD`, which is not a branch
|
||||
// name, so nothing matches it and every branch reads as not current.
|
||||
let current = head.filter(|branch| branch != "HEAD");
|
||||
let current = head.filter(|branch| branch != DETACHED);
|
||||
|
||||
let mut branches: Vec<Branch> = Vec::new();
|
||||
// `for-each-ref refs/heads` rather than `git branch`, which adds a
|
||||
@@ -416,7 +416,68 @@ pub fn branches(project: &Path) -> Vec<Branch> {
|
||||
branches
|
||||
}
|
||||
|
||||
/// The most recent `limit` commits reachable from HEAD, newest first.
|
||||
/// The branch this checkout is following, or the one it would return to.
|
||||
///
|
||||
/// `Some(name)` for an ordinary checkout. A checkout parked on a commit is
|
||||
/// on no branch at all, and the way back to the tip is the branch that
|
||||
/// commit sits on -- asked of git rather than remembered here, because
|
||||
/// nothing this server writes down survives somebody moving the checkout
|
||||
/// from the build machine's own terminal.
|
||||
///
|
||||
/// Only when exactly one local branch contains it: with several there is
|
||||
/// no such thing as *the* branch, and picking one would be this server
|
||||
/// guessing which history somebody meant. The branch picker is what
|
||||
/// answers that, and it lists all of them.
|
||||
///
|
||||
/// `refs/heads` through `for-each-ref` for the same reason [`branches`]
|
||||
/// uses it: `git branch --contains` adds the `(HEAD detached at abc123)`
|
||||
/// pseudo-entry, which is not a branch and cannot be checked out.
|
||||
pub fn head_branch(project: &Path) -> Option<String> {
|
||||
let branch = git(project, &["rev-parse", "--abbrev-ref", "HEAD"]).ok()?;
|
||||
if branch != DETACHED {
|
||||
return Some(branch);
|
||||
}
|
||||
let listed = git(
|
||||
project,
|
||||
&[
|
||||
"for-each-ref",
|
||||
"--format=%(refname:short)",
|
||||
"--contains",
|
||||
"HEAD",
|
||||
"refs/heads",
|
||||
],
|
||||
)
|
||||
.ok()?;
|
||||
let mut names = listed
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|name| !name.is_empty());
|
||||
let only = names.next()?;
|
||||
names.next().is_none().then(|| only.to_string())
|
||||
}
|
||||
|
||||
/// What git calls the branch when no branch is checked out.
|
||||
///
|
||||
/// A real branch may not be named this (git refuses it), so comparing
|
||||
/// against the string is safe -- but it means "on no branch" wherever it
|
||||
/// appears, and spelling it out is what stops that being read as a name.
|
||||
pub const DETACHED: &str = "HEAD";
|
||||
|
||||
/// The most recent `limit` commits on the branch this checkout is
|
||||
/// following, newest first, plus where HEAD actually is.
|
||||
///
|
||||
/// Listed from the *branch* rather than from HEAD, which are the same
|
||||
/// thing until somebody parks the checkout on an older commit. Listed
|
||||
/// from HEAD then, the commits after it disappear from the list -- so the
|
||||
/// picker that moved the checkout back could only ever move it further
|
||||
/// back, and the way forward was a terminal on the build machine. Which
|
||||
/// is the one-way door this whole sheet exists to avoid.
|
||||
///
|
||||
/// A checkout parked further back than `limit` is not in the window at
|
||||
/// all, so its own commit is appended: it is older than everything above
|
||||
/// it, which is where it belongs by date, and without it the picker has
|
||||
/// nothing to show for where the checkout *is* -- which reads as not
|
||||
/// knowing rather than as being a long way back.
|
||||
///
|
||||
/// Local history only. What the remote has and this checkout has not
|
||||
/// fetched is not offered: Pull is what brings those in, and the card
|
||||
@@ -424,17 +485,28 @@ pub fn branches(project: &Path) -> Vec<Branch> {
|
||||
/// round trip, and opening the sheet cannot fail the way a remote check
|
||||
/// can.
|
||||
pub fn recent_commits(project: &Path, limit: usize) -> Vec<CommitSummary> {
|
||||
// A record separator that cannot occur in a subject, so a message
|
||||
// containing anything at all still parses. `%x1f` is the ASCII unit
|
||||
// separator; git writes it literally.
|
||||
let format = "--format=%H%x1f%h%x1f%at%x1f%s";
|
||||
let head = git(project, &["rev-parse", "HEAD"])
|
||||
.ok()
|
||||
.map(|sha| sha.trim().to_string());
|
||||
let Ok(out) = git(
|
||||
project,
|
||||
&["log", &format!("-n{limit}"), format, "--no-color"],
|
||||
) else {
|
||||
let from = head_branch(project).unwrap_or_else(|| DETACHED.to_string());
|
||||
let mut commits = log(project, &[&format!("-n{limit}"), &from], head.as_deref());
|
||||
if !commits.iter().any(|commit| commit.current) {
|
||||
commits.extend(log(project, &["-n1", DETACHED], head.as_deref()));
|
||||
}
|
||||
commits
|
||||
}
|
||||
|
||||
/// One `git log` read, parsed. Shared by the two calls
|
||||
/// [`recent_commits`] makes so that the format string and the parsing of
|
||||
/// it are written once -- they only work as a pair, and a second copy of
|
||||
/// either is a second thing to keep in step.
|
||||
fn log(project: &Path, args: &[&str], head: Option<&str>) -> Vec<CommitSummary> {
|
||||
// A record separator that cannot occur in a subject, so a message
|
||||
// containing anything at all still parses. `%x1f` is the ASCII unit
|
||||
// separator; git writes it literally.
|
||||
let mut command = vec!["log", "--format=%H%x1f%h%x1f%at%x1f%s", "--no-color"];
|
||||
command.extend_from_slice(args);
|
||||
let Ok(out) = git(project, &command) else {
|
||||
return Vec::new();
|
||||
};
|
||||
out.lines()
|
||||
@@ -447,7 +519,7 @@ pub fn recent_commits(project: &Path, limit: usize) -> Vec<CommitSummary> {
|
||||
// is allowed to be missing rather than dropping the commit.
|
||||
let subject = parts.next().unwrap_or_default().to_string();
|
||||
Some(CommitSummary {
|
||||
current: head.as_deref() == Some(sha.as_str()),
|
||||
current: head == Some(sha.as_str()),
|
||||
sha,
|
||||
short,
|
||||
at,
|
||||
@@ -924,6 +996,76 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The other half of the one-way door, and the one a dirty-tree fix
|
||||
/// does nothing about: listed from HEAD, a checkout parked on an old
|
||||
/// commit loses every commit after it from the picker, so the control
|
||||
/// that moved it back cannot move it forward. The list comes from the
|
||||
/// branch instead, and `head_branch` is what the "latest" entry moves
|
||||
/// to.
|
||||
#[test]
|
||||
fn a_parked_checkout_can_still_see_the_way_forward() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let (_origin, clone) = origin_and_clone(dir.path());
|
||||
for subject in ["two", "three"] {
|
||||
std::fs::write(clone.join("file"), subject).expect("write");
|
||||
run(&clone, &["git", "commit", "-qam", subject]);
|
||||
}
|
||||
assert_eq!(head_branch(&clone).as_deref(), Some("main"));
|
||||
|
||||
let first = recent_commits(&clone, 10)
|
||||
.last()
|
||||
.expect("the first commit")
|
||||
.sha
|
||||
.clone();
|
||||
checkout(&clone, &first).expect("moving back");
|
||||
|
||||
assert_eq!(
|
||||
head_branch(&clone).as_deref(),
|
||||
Some("main"),
|
||||
"the branch containing the parked commit is the way back to the tip",
|
||||
);
|
||||
let parked = recent_commits(&clone, 10);
|
||||
let subjects: Vec<&str> = parked.iter().map(|c| c.subject.as_str()).collect();
|
||||
assert_eq!(
|
||||
subjects,
|
||||
vec!["three", "two", "one"],
|
||||
"the commits after the parked one are still offered",
|
||||
);
|
||||
assert!(
|
||||
parked.iter().filter(|c| c.current).count() == 1
|
||||
&& parked.last().expect("the parked commit").current,
|
||||
"and where the checkout actually is, is marked exactly once",
|
||||
);
|
||||
|
||||
// Parked further back than the window: the commit the checkout is
|
||||
// on is not in it, and appending it is what keeps the picker from
|
||||
// having nothing to show for where it is.
|
||||
let short = recent_commits(&clone, 1);
|
||||
assert_eq!(short.len(), 2, "{short:?}");
|
||||
assert_eq!(short[0].subject, "three");
|
||||
assert!(short[1].current && short[1].subject == "one", "{short:?}");
|
||||
}
|
||||
|
||||
/// With two branches over the parked commit there is no such thing as
|
||||
/// *the* branch to return to, and choosing one would be this server
|
||||
/// deciding which history somebody meant.
|
||||
#[test]
|
||||
fn no_way_back_is_offered_when_several_branches_could_be_meant() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let (_origin, clone) = origin_and_clone(dir.path());
|
||||
std::fs::write(clone.join("file"), "two").expect("write");
|
||||
run(&clone, &["git", "commit", "-qam", "two"]);
|
||||
run(&clone, &["git", "branch", "other"]);
|
||||
|
||||
let first = recent_commits(&clone, 10)
|
||||
.last()
|
||||
.expect("the first commit")
|
||||
.sha
|
||||
.clone();
|
||||
checkout(&clone, &first).expect("moving back");
|
||||
assert_eq!(head_branch(&clone), None);
|
||||
}
|
||||
|
||||
/// An origin repo with one commit, and a clone of it.
|
||||
fn origin_and_clone(root: &Path) -> (PathBuf, PathBuf) {
|
||||
let origin = root.join("origin");
|
||||
|
||||
+16
-1
@@ -124,7 +124,17 @@ impl AppEntry {
|
||||
}
|
||||
|
||||
/// The APK to serve: `requested` if it is still one of this project's
|
||||
/// builds, else the newest one found under it.
|
||||
/// builds, else the build of the mode this component is set to, else
|
||||
/// the newest one found under it.
|
||||
///
|
||||
/// The mode is consulted before the mtime because it is an answer
|
||||
/// somebody gave: a component set to build `release` has said which of
|
||||
/// its outputs is the one that counts, and "whichever was written most
|
||||
/// recently" can disagree with that -- a debug build made by hand
|
||||
/// afterwards is newer, and serving it would put a debug app on the
|
||||
/// phone from a card saying release. Only ever a build that is already
|
||||
/// there; a mode nothing has been built in yet falls through to the
|
||||
/// newest, exactly as before.
|
||||
///
|
||||
/// Which variant a phone wants is that phone's preference, so it
|
||||
/// arrives with the request rather than being stored here -- two
|
||||
@@ -150,6 +160,11 @@ impl AppEntry {
|
||||
{
|
||||
return Some(found.clone());
|
||||
}
|
||||
if let Some(mode) = component.effective_mode()
|
||||
&& let Some(found) = variants.iter().find(|candidate| candidate.variant == mode)
|
||||
{
|
||||
return Some(found.clone());
|
||||
}
|
||||
variants.into_iter().next()
|
||||
}
|
||||
|
||||
|
||||
+33
-3
@@ -404,6 +404,11 @@ impl ManifestComponent {
|
||||
// a declaration is waiting to be accepted, which is the project's
|
||||
// answer rather than this component's.
|
||||
may_build: bool,
|
||||
// Whether the checkout is parked on a commit somebody picked,
|
||||
// which is the project's state rather than this component's --
|
||||
// read once for the card and handed down, because asking git per
|
||||
// component would be a process each on the manifest path.
|
||||
parked: bool,
|
||||
) -> Result<Self, ApiError> {
|
||||
let name = component.name().to_string();
|
||||
let is_server = matches!(component, crate::config::Component::Server { .. });
|
||||
@@ -446,7 +451,7 @@ impl ManifestComponent {
|
||||
freshness: entry
|
||||
.build
|
||||
.as_ref()
|
||||
.map(|build| build.freshness(component))
|
||||
.map(|build| build.freshness(component, parked))
|
||||
.unwrap_or(crate::build_state::Freshness::Unknown),
|
||||
data_path: state_paths
|
||||
.as_ref()
|
||||
@@ -718,14 +723,28 @@ async fn describe(state: &Arc<AppState>, entry: &AppEntry) -> Result<ManifestApp
|
||||
// declaration is exactly the command that must not run.
|
||||
let declaration = entry.declaration_state();
|
||||
let pending = declaration.pending;
|
||||
// Read before the components rather than after: each of them reports
|
||||
// its freshness against this checkout, and a checkout parked on a
|
||||
// chosen commit is not one to report as out of date.
|
||||
let git = crate::git::status(&entry.project_path);
|
||||
let parked = git
|
||||
.as_ref()
|
||||
.is_some_and(|git| git.branch == crate::git::DETACHED);
|
||||
let mut components = Vec::with_capacity(entry.components.len());
|
||||
for component in &entry.components {
|
||||
components.push(
|
||||
ManifestComponent::read(state, &entry.key, entry, component, pending.is_none()).await?,
|
||||
ManifestComponent::read(
|
||||
state,
|
||||
&entry.key,
|
||||
entry,
|
||||
component,
|
||||
pending.is_none(),
|
||||
parked,
|
||||
)
|
||||
.await?,
|
||||
);
|
||||
}
|
||||
|
||||
let git = crate::git::status(&entry.project_path);
|
||||
// An upstream is part of it: a branch that tracks nothing has nothing
|
||||
// to be pulled from, so `pull` refuses and asking a remote about it is
|
||||
// asking about a remote it doesn't have. Without this the card said so
|
||||
@@ -1556,6 +1575,16 @@ async fn build_pull(
|
||||
struct RefsResponse {
|
||||
branches: Vec<crate::git::Branch>,
|
||||
commits: Vec<crate::git::CommitSummary>,
|
||||
/// The branch the commit picker's "latest" entry moves to, so that
|
||||
/// picking a commit is something a phone can undo.
|
||||
///
|
||||
/// Absent when there is no single branch to return to -- a checkout
|
||||
/// parked on a commit that several branches contain, or none. The
|
||||
/// picker says so rather than choosing one; which history somebody
|
||||
/// meant is not something this server can work out, and the branch
|
||||
/// list above is the honest answer to it.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
head: Option<String>,
|
||||
}
|
||||
|
||||
/// How much history the commit picker offers.
|
||||
@@ -1581,6 +1610,7 @@ async fn checkout_refs(
|
||||
let refs = tokio::task::spawn_blocking(move || RefsResponse {
|
||||
branches: crate::git::branches(&project),
|
||||
commits: crate::git::recent_commits(&project, COMMIT_HISTORY),
|
||||
head: crate::git::head_branch(&project),
|
||||
})
|
||||
.await
|
||||
.context("reading the checkout's refs panicked")?;
|
||||
|
||||
Reference in new issue
Block a user