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
+895
-111
No files matched your search
@@ -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(
|
||||
if (current == DETACHED_HEAD) "none" else current,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
// "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,
|
||||
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(
|
||||
)
|
||||
}
|
||||
}
|
||||
// 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(
|
||||
"This project declares no build modes, and nothing is built yet, so " +
|
||||
"there is nothing to choose."
|
||||
)
|
||||
apk.variants.size == 1 ->
|
||||
SettingsNote(
|
||||
"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(
|
||||
"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 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 {
|
||||
apk.variants.isEmpty() ->
|
||||
SettingsNote("Nothing is built yet, so there is nothing to install.")
|
||||
apk.variants.size == 1 ->
|
||||
SettingsNote(
|
||||
"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."
|
||||
)
|
||||
VariantPicker(apk.variants, variantPath) { variantPath = it?.path }
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
Reference in new issue
Block a user