Give each component build modes and a settings sheet
A component now declares its ways of being built in one list -- `modes: ["release", "debug"]`, the first the default -- and every command it runs is handed the mode as its last argument, so a project whose script takes `release` or `debug` names that script once. A field may instead be written per mode, which is the escape hatch for the commands that cannot take the word: cargo takes `--release` or nothing, and its profile for the unoptimised build is called `dev` while the directory it writes is called `debug`, so no single word serves as both the flag and the path. A command written per mode is not handed the word as well; it already is the answer, and a stray argument to a service binary is a process that will not start. One list rather than gathering names from whichever fields happened to mention them is what makes a mode missing from one part unsayable: every per-mode map is checked against it, so a gap is named rather than resolved to some other mode's command. Which mode to build in and whether to strip are the build machine's -- there is one checkout and one set of outputs, so a per-device mode would have two phones rebuilding over each other silently. Which of the finished builds a phone installs stays that phone's. Neither choice is part of the acceptance gate, so both have to be carried across the components list being rewritten, by acceptance and by the self entry's startup reconciliation alike; without the second a mode chosen for this server's own component would not survive the restart that applies it. A mode switch moves no commit, so `builtMode` is recorded beside `builtFrom`. Without it a component built in debug and switched to release reads as current and serves the debug build for ever -- and for an APK nothing else notices, because the "never built at all" check finds any variant under the component's directory. Each component card gains a settings sheet behind a gear at the row's right-hand end, holding the mode, which build to install, strip, and an Enrol button. The variant picker moved into it: on the card the two read as one choice, both saying debug and release, and they are not. The row's text is now bounded and truncates, so a long status can no longer push the log and settings buttons off the edge. `enroll:` is a declared command whose one line of stdout is a URL for the phone to open after installing -- generic on purpose, run per press since such a link is one-shot and carries a credential, and in the acceptance gate because it runs on the build machine. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0b7164bb30
commit
17d873ab7c
16 files changed
+2225
-220
No files matched your search
@@ -203,6 +203,53 @@ data class ComponentLog(
|
||||
* Given the manifest's timeout rather than the default: a large log is a real request that takes
|
||||
* real time, and timing it out would report the server as unreachable when it is merely reading.
|
||||
*/
|
||||
/**
|
||||
* This machine's preferences for one component: which declared build mode to build it in, and
|
||||
* whether to strip what it serves.
|
||||
*
|
||||
* On the build machine rather than on this device, unlike the chosen variant. A mode decides what
|
||||
* gets *built*, and there is one checkout there -- two phones holding different answers would
|
||||
* rebuild over each other with nothing on either screen to say why. Which of the finished builds
|
||||
* this phone installs stays this phone's business, and stays in SharedPreferences.
|
||||
*
|
||||
* A null mode means "no choice, take the first the project declared", and a null strip means the
|
||||
* same about the project's own declaration. Both are real answers rather than missing fields.
|
||||
*/
|
||||
fun setComponentSettings(key: String, component: String, mode: String?, strip: Boolean?) {
|
||||
// Explicit JSON nulls rather than omitted keys, so "put this back to
|
||||
// what the project says" is something this can express at all.
|
||||
val body =
|
||||
JSONObject().put("mode", mode ?: JSONObject.NULL).put("strip", strip ?: JSONObject.NULL)
|
||||
requestFromServer(
|
||||
"/apps/$key/components/$component/settings",
|
||||
method = "PUT",
|
||||
jsonBody = body.toString(),
|
||||
) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks the build machine to run this component's `enroll:` command and hand back the URL it
|
||||
* printed, for this phone to open.
|
||||
*
|
||||
* Run on every press rather than fetched once with the manifest: the link a project mints is
|
||||
* ordinarily one-shot and carries a credential, so a cached one would be both stale and a secret
|
||||
* sitting in a list this app redraws constantly.
|
||||
*/
|
||||
fun enrollmentLink(key: String, component: String): String =
|
||||
requestFromServer(
|
||||
"/apps/$key/components/$component/enroll-link",
|
||||
method = "POST",
|
||||
readTimeoutMs = ENROLL_TIMEOUT_MS,
|
||||
) { connection ->
|
||||
JSONObject(connection.inputStream.bufferedReader().readText()).getString("url")
|
||||
}
|
||||
|
||||
// Longer than an ordinary call: the command runs on the build machine and
|
||||
// may have to touch a keystore or write a file on the way. Timing it out
|
||||
// early would report "couldn't reach the server" about a server that is
|
||||
// working.
|
||||
private const val ENROLL_TIMEOUT_MS = 25000
|
||||
|
||||
fun componentLog(
|
||||
key: String,
|
||||
component: String,
|
||||
|
||||
@@ -143,6 +143,24 @@ data class ProjectComponent(
|
||||
// The last is the ordinary case and not a fault.
|
||||
val resourcesChecking: Boolean,
|
||||
val resourcesError: String?,
|
||||
// Every way this component can be built, in the order the project
|
||||
// declared them, with the first the default. Empty for a component
|
||||
// that declares one way of building, which is not a choice -- the
|
||||
// settings sheet says so rather than drawing a picker holding one
|
||||
// entry that cannot be changed.
|
||||
val modes: List<String>,
|
||||
// Which of them the build machine is set to build it in. Null exactly
|
||||
// when [modes] is empty. Not a per-device preference like the chosen
|
||||
// variant: there is one checkout and one set of build outputs on that
|
||||
// machine, so this is what *everyone* gets, which is why the sheet
|
||||
// says so out loud.
|
||||
val mode: String?,
|
||||
// The project declares a command that prints a link for this phone to
|
||||
// open after installing -- an enrolment link, for the apps that need
|
||||
// one. What the link does is the project's business; the button just
|
||||
// opens it. False while a declaration is waiting to be accepted, when
|
||||
// the command would not run.
|
||||
val hasEnrollLink: Boolean,
|
||||
// What there is to install, for a component that produces one. Null
|
||||
// for a server, which builds nothing this phone installs.
|
||||
val apk: ComponentApk?,
|
||||
@@ -197,6 +215,14 @@ data class ComponentApk(
|
||||
// build freshness is the only meaningful signal, not a version code.
|
||||
val mtime: Double,
|
||||
val size: Long,
|
||||
// Whether the build machine serves a stripped copy: what it settled
|
||||
// on, which is what a download will actually do.
|
||||
val strip: Boolean,
|
||||
// What the *project* asks for, which differs only when somebody
|
||||
// overrode it on that machine. Both are sent so the sheet can say the
|
||||
// two disagree, rather than showing a switch that silently contradicts
|
||||
// the checkout.
|
||||
val stripDeclared: Boolean,
|
||||
// Every build discovered under this component, so a different one can
|
||||
// be selected without another round trip.
|
||||
val variants: List<ApkVariant>,
|
||||
@@ -370,6 +396,12 @@ private fun readEntry(entry: JSONObject): ManifestEntry {
|
||||
configPresent = component.optBoolean("configPresent", false),
|
||||
resourcesChecking = component.optBoolean("resourcesChecking", false),
|
||||
resourcesError = component.optString("resourcesError").ifEmpty { null },
|
||||
modes =
|
||||
component.optJSONArray("modes").let { modes ->
|
||||
(0 until (modes?.length() ?: 0)).map { modes!!.getString(it) }
|
||||
},
|
||||
mode = component.optString("mode").ifEmpty { null },
|
||||
hasEnrollLink = component.optBoolean("hasEnrollLink", false),
|
||||
apk = component.optJSONObject("apk")?.let(::readApk),
|
||||
)
|
||||
},
|
||||
@@ -386,6 +418,8 @@ private fun readApk(apk: JSONObject): ComponentApk {
|
||||
built = apk.getBoolean("built"),
|
||||
mtime = apk.getDouble("mtime"),
|
||||
size = apk.getLong("size"),
|
||||
strip = apk.optBoolean("strip", false),
|
||||
stripDeclared = apk.optBoolean("stripDeclared", false),
|
||||
variants =
|
||||
(0 until variants.length()).map { j ->
|
||||
val variant = variants.getJSONObject(j)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.example.devupdater
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.os.SystemClock
|
||||
@@ -219,6 +221,16 @@ private sealed class ComponentState {
|
||||
/** [progress] is null when the response gave no length to measure against. */
|
||||
data class Downloading(val progress: Float?) : ComponentState()
|
||||
|
||||
/**
|
||||
* A short call to the build machine that is neither a build nor a download -- fetching an
|
||||
* enrolment link is the one so far.
|
||||
*
|
||||
* Carries its own words because the card has no other way to say which call it is waiting on,
|
||||
* and "working" over a bar that could be any of three things is what makes a screen feel like
|
||||
* it is doing something at random.
|
||||
*/
|
||||
data class Busy(val what: String) : ComponentState()
|
||||
|
||||
/** Why the last thing this component was asked to do stopped. */
|
||||
data class Error(val message: String) : ComponentState()
|
||||
}
|
||||
@@ -1013,6 +1025,61 @@ private fun AppListScreen(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The same as [manage], for a call that is about one component rather than the whole project.
|
||||
*
|
||||
* A failure belongs where the press happened: reported on the card, one component's settings
|
||||
* write would blame the project and go quiet on the row somebody was actually looking at.
|
||||
*/
|
||||
fun manageComponent(entry: ManifestEntry, component: String, action: () -> Unit) {
|
||||
scope.launch {
|
||||
try {
|
||||
withContext(Dispatchers.IO) { action() }
|
||||
setComponent(entry.key, component, null)
|
||||
applyOne(entry.key)
|
||||
} catch (e: DownloadServerException) {
|
||||
setComponent(entry.key, component, failure(e)?.let(ComponentState::Error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks the build machine for this component's link and opens it here.
|
||||
*
|
||||
* Fetched on every press rather than carried on the manifest: what a project mints is
|
||||
* ordinarily one-shot and carries a credential, so a link this app had been holding since the
|
||||
* last refresh would be the wrong one.
|
||||
*
|
||||
* Nothing here inspects the URL. Which app answers it is Android's business, and a link the
|
||||
* phone has nothing to open is reported as such rather than swallowed -- an action whose whole
|
||||
* effect is elsewhere has to say when it did not happen.
|
||||
*/
|
||||
fun openEnrollmentLink(entry: ManifestEntry, component: String) {
|
||||
scope.launch {
|
||||
setComponent(entry.key, component, ComponentState.Busy("Getting the link"))
|
||||
try {
|
||||
val url = withContext(Dispatchers.IO) { enrollmentLink(entry.key, component) }
|
||||
try {
|
||||
context.startActivity(
|
||||
Intent(Intent.ACTION_VIEW, Uri.parse(url))
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
)
|
||||
setComponent(entry.key, component, null)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
setComponent(
|
||||
entry.key,
|
||||
component,
|
||||
ComponentState.Error(
|
||||
"Nothing on this phone opens $url" + (e.message?.let { " ($it)" } ?: "")
|
||||
),
|
||||
)
|
||||
}
|
||||
} catch (e: DownloadServerException) {
|
||||
setComponent(entry.key, component, failure(e)?.let(ComponentState::Error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs one of a server component's actions on the build machine.
|
||||
*
|
||||
@@ -1356,6 +1423,24 @@ private fun AppListScreen(
|
||||
(component to variant.path)
|
||||
})
|
||||
},
|
||||
// The build machine's, not this
|
||||
// device's: a mode decides what
|
||||
// gets built there, so it is a
|
||||
// round trip and a refetch rather
|
||||
// than a preference written here.
|
||||
onComponentSettings = { component, mode, strip ->
|
||||
manageComponent(entry, component) {
|
||||
setComponentSettings(
|
||||
entry.key,
|
||||
component,
|
||||
mode,
|
||||
strip,
|
||||
)
|
||||
}
|
||||
},
|
||||
onEnroll = { component ->
|
||||
openEnrollmentLink(entry, component)
|
||||
},
|
||||
serviceBusy = serviceBusy[entry.key],
|
||||
onServiceAction = { component, action, purge ->
|
||||
runServiceAction(entry, component, action, purge)
|
||||
@@ -1471,6 +1556,10 @@ private fun AppCard(
|
||||
onApprove: () -> Unit,
|
||||
onRemove: () -> Unit,
|
||||
onSelectVariant: (component: String, ApkVariant?) -> Unit,
|
||||
/** This machine's settings for one component: which mode it builds in, and whether to strip. */
|
||||
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,
|
||||
// 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.
|
||||
@@ -1692,6 +1781,10 @@ private fun AppCard(
|
||||
},
|
||||
chosenVariantPath = chosenVariantPath,
|
||||
onSelectVariant = { onSelectVariant(component.name, it) },
|
||||
onSettings = { mode, strip ->
|
||||
onComponentSettings(component.name, mode, strip)
|
||||
},
|
||||
onEnroll = { onEnroll(component.name) },
|
||||
// This app reaches the server through this server.
|
||||
// Stopping or uninstalling it is the one action
|
||||
// here that cannot be undone from the phone.
|
||||
@@ -2020,6 +2113,12 @@ private fun ApkProgress(state: ComponentState?) {
|
||||
Text("Preparing the download...")
|
||||
}
|
||||
|
||||
is ComponentState.Busy -> {
|
||||
ProgressBar()
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text("${state.what}...")
|
||||
}
|
||||
|
||||
is ComponentState.Downloading -> {
|
||||
val progress = state.progress
|
||||
if (progress == null) {
|
||||
@@ -2356,6 +2455,10 @@ private fun ComponentCard(
|
||||
/** Which of this component's builds this device is pinned to, if any. */
|
||||
chosenVariantPath: String? = null,
|
||||
onSelectVariant: (ApkVariant?) -> Unit = {},
|
||||
/** Save this machine's settings for this component. */
|
||||
onSettings: (mode: String?, strip: Boolean?) -> Unit = { _, _ -> },
|
||||
/** Ask the build machine for this component's link and open it. */
|
||||
onEnroll: () -> Unit = {},
|
||||
isOwnServer: Boolean,
|
||||
/** This component's part of a build in progress, if it has one. */
|
||||
build: ComponentBuild?,
|
||||
@@ -2387,6 +2490,7 @@ private fun ComponentCard(
|
||||
// a second thing to remember to clear.
|
||||
var confirming by remember { mutableStateOf<String?>(null) }
|
||||
var showingLog by remember { mutableStateOf(false) }
|
||||
var showingSettings by remember { mutableStateOf(false) }
|
||||
// What Uninstall has been asked to take away as well. Logs start
|
||||
// ticked and the other two do not: the dialog's defaults, deliberately
|
||||
// different from `Purge()`'s, which is what a caller with no dialog
|
||||
@@ -2407,116 +2511,149 @@ private fun ComponentCard(
|
||||
// same size as a line of this text anyway, so the row comes out
|
||||
// the same height without being told.
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
// Both kinds get the same square, so a server's glyph and
|
||||
// an app's icon are the same size as each other -- one
|
||||
// drawn smaller than the other reads as the row meaning
|
||||
// less, rather than as a different kind of thing.
|
||||
Box(
|
||||
Modifier.size(COMPONENT_ICON_SIZE),
|
||||
contentAlignment = Alignment.Center,
|
||||
// Everything the row *says* lives inside one weighted
|
||||
// child, so it can only ever have the space the controls
|
||||
// on the right are not using. Without it a long name or a
|
||||
// long status pushed the log and settings buttons off the
|
||||
// edge -- and a control that leaves because the text grew
|
||||
// is one the reader cannot get back to.
|
||||
Row(
|
||||
Modifier.weight(1f),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (component.isServer) {
|
||||
Text(
|
||||
SERVER_GLYPH,
|
||||
fontFamily = NerdIcons,
|
||||
// Larger than the square in font terms: a glyph
|
||||
// is drawn well inside its line box, so matching
|
||||
// the numbers would draw it noticeably smaller
|
||||
// than the icon beside it.
|
||||
fontSize = COMPONENT_GLYPH_SIZE,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
// Which means its line box is taller than the
|
||||
// square, and text clips to the height it is
|
||||
// given. Measured unbounded and drawn centred
|
||||
// instead: the row keeps the icon's height and
|
||||
// the glyph keeps all of itself.
|
||||
modifier = Modifier.wrapContentSize(unbounded = true),
|
||||
)
|
||||
} else {
|
||||
// The app's own icon, the same one the project card
|
||||
// shows: this row is about the thing that gets
|
||||
// installed, and that is what it looks like.
|
||||
AppIcon(
|
||||
packageName,
|
||||
size = COMPONENT_ICON_SIZE,
|
||||
glyphSize = COMPONENT_GLYPH_SIZE,
|
||||
)
|
||||
// Both kinds get the same square, so a server's glyph and
|
||||
// an app's icon are the same size as each other -- one
|
||||
// drawn smaller than the other reads as the row meaning
|
||||
// less, rather than as a different kind of thing.
|
||||
Box(
|
||||
Modifier.size(COMPONENT_ICON_SIZE),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (component.isServer) {
|
||||
Text(
|
||||
SERVER_GLYPH,
|
||||
fontFamily = NerdIcons,
|
||||
// Larger than the square in font terms: a glyph
|
||||
// is drawn well inside its line box, so matching
|
||||
// the numbers would draw it noticeably smaller
|
||||
// than the icon beside it.
|
||||
fontSize = COMPONENT_GLYPH_SIZE,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
// Which means its line box is taller than the
|
||||
// square, and text clips to the height it is
|
||||
// given. Measured unbounded and drawn centred
|
||||
// instead: the row keeps the icon's height and
|
||||
// the glyph keeps all of itself.
|
||||
modifier = Modifier.wrapContentSize(unbounded = true),
|
||||
)
|
||||
} else {
|
||||
// The app's own icon, the same one the project card
|
||||
// shows: this row is about the thing that gets
|
||||
// installed, and that is what it looks like.
|
||||
AppIcon(
|
||||
packageName,
|
||||
size = COMPONENT_ICON_SIZE,
|
||||
glyphSize = COMPONENT_GLYPH_SIZE,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(
|
||||
component.name,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
// Nothing at all until its script has been asked -- an
|
||||
// unknown state is not a state, and a dot introducing
|
||||
// nothing is worse than no dot.
|
||||
// Said in words, not by colour alone: "behind the
|
||||
// checkout" is a difference in kind from "running", and a
|
||||
// reader has no way to learn a colour that means it.
|
||||
val behind = component.isBehind
|
||||
val status =
|
||||
when {
|
||||
!component.isServer -> null
|
||||
component.error != null -> "couldn't check"
|
||||
component.state == null -> null
|
||||
component.isRunning -> "running"
|
||||
// Not folded into "stopped": stopped is a state
|
||||
// somebody chose, and calling a crash that sends
|
||||
// the reader looking for who chose it.
|
||||
component.isFailed -> "failed"
|
||||
component.isInstalled -> "stopped"
|
||||
// Nothing for a service that isn't installed: the row
|
||||
// offers Install and nothing else, which says it more
|
||||
// plainly than a state would, and saying both makes the
|
||||
// absence of a thing look like a condition it is in.
|
||||
else -> null
|
||||
}
|
||||
status?.let {
|
||||
Separator()
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color =
|
||||
when {
|
||||
component.isRunning -> runningColor
|
||||
component.isFailed -> failedColor
|
||||
else -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
}
|
||||
if (behind) {
|
||||
Separator()
|
||||
Text(
|
||||
"out of date",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
// The colour Pull & Build wears, because that is
|
||||
// the button this is telling you to press.
|
||||
color = ActionTone.Primary.color,
|
||||
)
|
||||
}
|
||||
sizeText?.let {
|
||||
Separator()
|
||||
Text(
|
||||
it,
|
||||
component.name,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
// One line and an ellipsis, on this and on every
|
||||
// reading beside it: a row that wraps grows the
|
||||
// card, and one that overflows silently loses its
|
||||
// right-hand end without saying it was cut.
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
// Nothing at all until its script has been asked -- an
|
||||
// unknown state is not a state, and a dot introducing
|
||||
// nothing is worse than no dot.
|
||||
// Said in words, not by colour alone: "behind the
|
||||
// checkout" is a difference in kind from "running", and a
|
||||
// reader has no way to learn a colour that means it.
|
||||
val behind = component.isBehind
|
||||
val status =
|
||||
when {
|
||||
!component.isServer -> null
|
||||
component.error != null -> "couldn't check"
|
||||
component.state == null -> null
|
||||
component.isRunning -> "running"
|
||||
// Not folded into "stopped": stopped is a state
|
||||
// somebody chose, and calling a crash that sends
|
||||
// the reader looking for who chose it.
|
||||
component.isFailed -> "failed"
|
||||
component.isInstalled -> "stopped"
|
||||
// Nothing for a service that isn't installed: the row
|
||||
// offers Install and nothing else, which says it more
|
||||
// plainly than a state would, and saying both makes the
|
||||
// absence of a thing look like a condition it is in.
|
||||
else -> null
|
||||
}
|
||||
status?.let {
|
||||
Separator()
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color =
|
||||
when {
|
||||
component.isRunning -> runningColor
|
||||
component.isFailed -> failedColor
|
||||
else -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
if (behind) {
|
||||
Separator()
|
||||
Text(
|
||||
"out of date",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
// The colour Pull & Build wears, because that is
|
||||
// the button this is telling you to press.
|
||||
color = ActionTone.Primary.color,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
sizeText?.let {
|
||||
Separator()
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
// Outside the weighted text, so the spinner is never the
|
||||
// thing that gets truncated: it is the one mark on the row
|
||||
// that says something is happening right now.
|
||||
if (working || busy || component.checking) {
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Working()
|
||||
}
|
||||
// These belong to the component, not to whatever the row
|
||||
// happens to say about it, so they hold the same corner
|
||||
// whatever the text does. Unconditional, so the settings
|
||||
// button sits in the same place whether or not there is a
|
||||
// log beside it -- the log's own absence must not move it.
|
||||
if (component.hasLogs) {
|
||||
// Pushed to the far edge rather than following the
|
||||
// text: it belongs to the component, not to whatever
|
||||
// the row happens to say about it, and a control that
|
||||
// slides about as the state changes is harder to find
|
||||
// than one always in the same corner.
|
||||
Spacer(Modifier.weight(1f))
|
||||
IconGlyphButton(LOG_GLYPH, "Show ${component.name}'s log") { showingLog = true }
|
||||
}
|
||||
// Always drawn, including for a component with a single
|
||||
// build mode and nothing to strip: what a component can be
|
||||
// told is part of what it is, and a control that comes and
|
||||
// goes makes its own presence the signal. What it opens
|
||||
// says which of its settings this component has.
|
||||
IconGlyphButton(SETTINGS_GLYPH, "${component.name} settings") {
|
||||
showingSettings = true
|
||||
}
|
||||
}
|
||||
|
||||
// The service script's own words about why it could not answer,
|
||||
@@ -2663,27 +2800,35 @@ private fun ComponentCard(
|
||||
}
|
||||
}
|
||||
|
||||
// Only worth a row when there is actually a choice, which is
|
||||
// rare -- the usual case is a single debug build, and an empty
|
||||
// row here was leaving a band of space at the foot of every
|
||||
// card for a control almost none of them have.
|
||||
//
|
||||
// Beside the build it picks, which is what makes it answerable
|
||||
// for a project with two clients: the choice is this
|
||||
// component's, and a picker at the foot of the card could only
|
||||
// have been the project's.
|
||||
val variants = component.apk?.variants.orEmpty()
|
||||
if (variants.size > 1) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.End,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
VariantPicker(variants, chosenVariantPath, onSelectVariant)
|
||||
}
|
||||
}
|
||||
// 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.
|
||||
}
|
||||
}
|
||||
|
||||
if (showingSettings) {
|
||||
ComponentSettingsDialog(
|
||||
component = component,
|
||||
chosenVariantPath = chosenVariantPath,
|
||||
// A mode changes what the build machine runs, and the server
|
||||
// replaces the state a running build reports through. So the
|
||||
// picker goes quiet exactly while this component cannot be
|
||||
// acted on anyway -- the same pair of conditions that disables
|
||||
// its Update button and withholds its freshness.
|
||||
modeSettled = !(busy || working || projectState.busy),
|
||||
onSelectVariant = onSelectVariant,
|
||||
onEnroll = onEnroll,
|
||||
onApply = { mode, strip ->
|
||||
showingSettings = false
|
||||
onSettings(mode, strip)
|
||||
},
|
||||
onDismiss = { showingSettings = false },
|
||||
)
|
||||
}
|
||||
|
||||
if (showingLog) {
|
||||
ComponentLogDialog(
|
||||
entryKey = entryKey,
|
||||
@@ -2764,6 +2909,194 @@ private fun ComponentCard(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything one component can be told, in one sheet: how the build machine builds it, which of the
|
||||
* finished builds this phone installs, whether it is stripped on the way, and the link a project
|
||||
* hands over after an install.
|
||||
*
|
||||
* The two "debug or release" choices in here are deliberately separated and labelled, because they
|
||||
* are not the same question and they used to sit on the card looking as though they were. **Build
|
||||
* mode** is the build machine's: there is one checkout and one set of outputs there, so choosing it
|
||||
* changes what *every* enrolled phone is offered, which is why the sheet says so rather than
|
||||
* leaving it to be discovered. **Install** is this device's alone, and picks among builds that
|
||||
* already exist.
|
||||
*
|
||||
* Applied on Save rather than as each control moves, matching the project's own settings sheet: a
|
||||
* settings write is a round trip that rebuilds the entry on the server, and one per control touched
|
||||
* while making up your mind is a lot of them. Enrol is the exception and is not a setting -- it is
|
||||
* an action, it happens on the press, and it is drawn apart from the rest for that reason.
|
||||
*
|
||||
* A component with nothing to choose still gets its sections, saying so. Removing them would make
|
||||
* the sheet's shape the signal, and "this project declares one way of building" and "we could not
|
||||
* tell" would then look identical -- which is the whole reason the empty cases are written out here
|
||||
* rather than skipped.
|
||||
*/
|
||||
@Composable
|
||||
private fun ComponentSettingsDialog(
|
||||
component: ProjectComponent,
|
||||
chosenVariantPath: String?,
|
||||
/** False while a build is in flight, when the mode must not be changed underneath it. */
|
||||
modeSettled: Boolean,
|
||||
onSelectVariant: (ApkVariant?) -> Unit,
|
||||
onEnroll: () -> Unit,
|
||||
onApply: (mode: String?, strip: Boolean?) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
// Keyed on what the server last said, so reopening after a save shows
|
||||
// the saved values rather than stale local ones.
|
||||
var mode by remember(component.mode) { mutableStateOf(component.mode) }
|
||||
var strip by remember(component.apk?.strip) { mutableStateOf(component.apk?.strip ?: false) }
|
||||
// The variant is this device's, so it is applied locally on Save
|
||||
// rather than sent anywhere; held here so Cancel really cancels.
|
||||
var variantPath by remember(chosenVariantPath) { mutableStateOf(chosenVariantPath) }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(component.name) },
|
||||
text = {
|
||||
Column(Modifier.verticalScroll(rememberScrollState())) {
|
||||
SettingsHeading("Build mode")
|
||||
when {
|
||||
component.modes.isEmpty() ->
|
||||
SettingsNote(
|
||||
"This project declares one way of building ${component.name}, so there " +
|
||||
"is nothing to choose."
|
||||
)
|
||||
else -> {
|
||||
SettingsNote(
|
||||
"Chosen on the build machine, so it is what every phone here is " +
|
||||
"offered -- not just this one."
|
||||
)
|
||||
// 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(
|
||||
"Not while this component is busy: changing it now would leave " +
|
||||
"the build that is running building the other one."
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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 }
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
SettingsHeading("Transfer")
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
"Strip debug symbols",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Switch(checked = strip, onCheckedChange = { strip = it })
|
||||
}
|
||||
// Said only when the two disagree. A machine that
|
||||
// follows the checkout has nothing to report, and a
|
||||
// line under every switch saying "as declared" is one
|
||||
// more thing to read on every card.
|
||||
if (strip != apk.stripDeclared) {
|
||||
SettingsNote(
|
||||
if (apk.stripDeclared) {
|
||||
"The project asks for stripping; this machine is set not to."
|
||||
} else {
|
||||
"The project does not ask for stripping; this machine is set to."
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (component.hasEnrollLink) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
SettingsHeading("Enrolment")
|
||||
SettingsNote(
|
||||
"Asks the build machine for a fresh link and opens it here. Do this after " +
|
||||
"installing, or after reinstalling over a different signing key -- " +
|
||||
"either loses whatever enrolment the app had."
|
||||
)
|
||||
// Outside the Save/Cancel bargain the rest of the
|
||||
// sheet makes, because it is not a setting: pressing
|
||||
// it does the thing, now, and there is nothing about
|
||||
// it left to save.
|
||||
TextButton(
|
||||
onClick = {
|
||||
onDismiss()
|
||||
onEnroll()
|
||||
}
|
||||
) {
|
||||
Text("Enrol this app")
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
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 }
|
||||
)
|
||||
}
|
||||
onApply(mode, strip)
|
||||
}
|
||||
) {
|
||||
Text("Save")
|
||||
}
|
||||
},
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsHeading(text: String) {
|
||||
Text(text, style = MaterialTheme.typography.titleSmall)
|
||||
}
|
||||
|
||||
/**
|
||||
* A line of explanation under a control, or in place of one that has nothing to offer.
|
||||
*
|
||||
* Its own composable so that "there is nothing to choose here" looks the same wherever it is said
|
||||
* -- four sections each phrasing their empty case differently is how a reader learns to skip them.
|
||||
*/
|
||||
@Composable
|
||||
private fun SettingsNote(text: String) {
|
||||
Text(
|
||||
text,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The three things Uninstall can take away besides the service.
|
||||
*
|
||||
@@ -2903,6 +3236,35 @@ private fun PurgeToggle(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ModePicker(
|
||||
modes: List<String>,
|
||||
chosen: String?,
|
||||
enabled: Boolean,
|
||||
onSelect: (String) -> Unit,
|
||||
) {
|
||||
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())
|
||||
}
|
||||
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||
modes.forEach { candidate ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(if (candidate == chosen) "$candidate ✓" else candidate) },
|
||||
onClick = {
|
||||
expanded = false
|
||||
onSelect(candidate)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VariantPicker(
|
||||
variants: List<ApkVariant>,
|
||||
|
||||
Reference in new issue
Block a user