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
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user