Picking an old commit from the phone detaches HEAD, so every rollback is parked by construction -- and a parked checkout withheld the commit comparison entirely. The card answered the move with "up to date" and nothing to press, which left a rollback unfinishable from the phone. The withholding was right for one direction only. A checkout parked past what was built is somebody looking, and nagging about it is nagging about a decision. A checkout parked *behind* it is somebody rolling back, and there the point of the move is to get that commit built and installed. So `git::is_ancestor` asks which of the two commits is the later one -- one extra process, and only where they already differ -- and `Freshness::BuiltAhead` says the build is. It is stale like `Behind`, so the card leaves "Up to date" and both Update buttons come alive; `updateWord` then says Downgrade, at both scales and by one rule, the way `buildWord` does. Nothing else about the press differs, which is what was asked for. The row says "newer than the checkout" rather than "out of date", which is the wrong direction. Two things fell out of it. `UpdateButton` asks about the direction before comparing file times, since what is on disk stays older than the phone's copy right up until the press rebuilds it -- otherwise a card with a rollback waiting says "Reinstall". And a project-wide Update now does the build-and-install half alone when there is nothing to pull *from* -- a parked checkout, a branch tracking nothing, or `gitPull` off -- rather than failing on a pull it was never going to make, which is what a downgrade press used to hit. Verified end to end against a throwaway checkout driven through a scratch server: `current` on the built commit, `builtAhead` once parked back on the one before it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KuRzBWJuEGaWmMhDuX3X3p
4353 lines
212 KiB
Kotlin
4353 lines
212 KiB
Kotlin
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
|
|
import androidx.activity.compose.BackHandler
|
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
|
import androidx.activity.result.contract.ActivityResultContracts
|
|
import androidx.compose.foundation.Image
|
|
import androidx.compose.foundation.background
|
|
import androidx.compose.foundation.clickable
|
|
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
|
|
import androidx.compose.foundation.layout.fillMaxWidth
|
|
import androidx.compose.foundation.layout.height
|
|
import androidx.compose.foundation.layout.padding
|
|
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
|
|
import androidx.compose.material3.Card
|
|
import androidx.compose.material3.Checkbox
|
|
import androidx.compose.material3.CircularProgressIndicator
|
|
import androidx.compose.material3.DropdownMenu
|
|
import androidx.compose.material3.DropdownMenuItem
|
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
import androidx.compose.material3.FloatingActionButton
|
|
import androidx.compose.material3.IconButton
|
|
import androidx.compose.material3.LocalContentColor
|
|
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
|
|
import androidx.compose.material3.Text
|
|
import androidx.compose.material3.TextButton
|
|
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
|
import androidx.compose.runtime.Composable
|
|
import androidx.compose.runtime.DisposableEffect
|
|
import androidx.compose.runtime.LaunchedEffect
|
|
import androidx.compose.runtime.getValue
|
|
import androidx.compose.runtime.mutableStateMapOf
|
|
import androidx.compose.runtime.mutableStateOf
|
|
import androidx.compose.runtime.remember
|
|
import androidx.compose.runtime.rememberCoroutineScope
|
|
import androidx.compose.runtime.rememberUpdatedState
|
|
import androidx.compose.runtime.setValue
|
|
import androidx.compose.runtime.withFrameNanos
|
|
import androidx.compose.ui.Alignment
|
|
import androidx.compose.ui.Modifier
|
|
import androidx.compose.ui.graphics.asImageBitmap
|
|
import androidx.compose.ui.layout.onGloballyPositioned
|
|
import androidx.compose.ui.layout.onSizeChanged
|
|
import androidx.compose.ui.layout.positionInParent
|
|
import androidx.compose.ui.platform.LocalContext
|
|
import androidx.compose.ui.platform.LocalDensity
|
|
import androidx.compose.ui.semantics.contentDescription
|
|
import androidx.compose.ui.semantics.semantics
|
|
import androidx.compose.ui.text.font.FontFamily
|
|
import androidx.compose.ui.text.style.TextOverflow
|
|
import androidx.compose.ui.unit.Dp
|
|
import androidx.compose.ui.unit.TextUnit
|
|
import androidx.compose.ui.unit.dp
|
|
import androidx.compose.ui.unit.sp
|
|
import androidx.core.graphics.drawable.toBitmap
|
|
import androidx.lifecycle.compose.LifecycleResumeEffect
|
|
import com.google.zxing.client.android.Intents
|
|
import com.journeyapps.barcodescanner.ScanContract
|
|
import com.journeyapps.barcodescanner.ScanIntentResult
|
|
import com.journeyapps.barcodescanner.ScanOptions
|
|
import java.io.File
|
|
import kotlin.math.roundToInt
|
|
import kotlinx.coroutines.Dispatchers
|
|
import kotlinx.coroutines.delay
|
|
import kotlinx.coroutines.launch
|
|
import kotlinx.coroutines.withContext
|
|
|
|
/** The launcher icon beside a card's name, about the height of its two lines. */
|
|
private val APP_ICON_SIZE = 40.dp
|
|
|
|
/** The icon on a component's row, sized to the text beside it. */
|
|
private val COMPONENT_ICON_SIZE = 20.dp
|
|
|
|
/** What a glyph needs to be to look the size of [COMPONENT_ICON_SIZE]. */
|
|
private val COMPONENT_GLYPH_SIZE = 20.sp
|
|
|
|
/**
|
|
* The drawn size of a glyph button: its own square, rather than the 48dp state layer an IconButton
|
|
* reserves by default.
|
|
*
|
|
* That default is what made these hard to place. A 48dp box centres its glyph, so the mark sits
|
|
* 24dp down whatever the box is aligned to, and every attempt to fix it from outside -- an offset,
|
|
* a baseline, aligning the glyph to the box's top -- moved or mispositioned something else. The
|
|
* last of those left the pressed-state ripple 24dp below the icon it belonged to, since the ripple
|
|
* follows the box and not the mark.
|
|
*
|
|
* A box that *is* the glyph has none of those problems: it aligns like any other content, and its
|
|
* ripple is centred on the thing pressed. The cost is the touch target, which is now this size
|
|
* rather than 48dp -- `minimumInteractiveComponentSize` is applied inside the caller's modifier, so
|
|
* a size set here wins over it.
|
|
*/
|
|
private val GLYPH_BUTTON_SIZE = 28.dp
|
|
|
|
/**
|
|
* Restores the separation the 48dp boxes used to provide, now that the boxes are the size of what
|
|
* they draw: the two together come to the same 48dp centre-to-centre spacing these had before.
|
|
*/
|
|
private val GLYPH_BUTTON_GAP = 48.dp - GLYPH_BUTTON_SIZE
|
|
|
|
/**
|
|
* How much of a card's top line the corner controls occupy, so the title beside them can stop
|
|
* before they start. Derived from the two of them rather than measured, so it cannot drift from
|
|
* what is actually drawn.
|
|
*/
|
|
private val CORNER_CONTROLS_WIDTH = GLYPH_BUTTON_SIZE * 2 + GLYPH_BUTTON_GAP
|
|
|
|
private const val BUILD_POLL_INTERVAL_MS = 1500L
|
|
|
|
/**
|
|
* How persistently to chase a remote check the server started in the background. The spinner stays
|
|
* up until the check is genuinely finished: stopping early left the branch line reading as though
|
|
* the remote had been asked and had nothing, which is a different and much worse thing to say than
|
|
* "still looking".
|
|
*
|
|
* The budget is not what decides when to stop -- the server does, and it already bounds every
|
|
* remote command it runs (`git.rs`'s REMOTE_HARD_TIMEOUT, 30s, on top of a 5s ssh connect timeout).
|
|
* This covers that with room for the local git calls either side of it, so reaching it means the
|
|
* server stopped answering rather than a remote being slow, and the card says so instead of
|
|
* guessing.
|
|
*
|
|
* The interval is what the "new commits" badge actually costs after a push, since the check itself
|
|
* is one `ls-remote` and lands well inside it. It is not lower still because each look re-reads
|
|
* every app's git status on the server, which is several subprocesses per app.
|
|
*/
|
|
private const val CHECK_POLL_BUDGET_MS = 45_000L
|
|
private const val CHECK_POLL_INTERVAL_MS = 700L
|
|
|
|
// A pull that restarts the server leaves it unreachable for well under a
|
|
// second; these cover that with room to spare without making a genuine
|
|
// outage take long to report.
|
|
private const val REFRESH_ATTEMPTS_AFTER_PULL = 4
|
|
|
|
/**
|
|
* How long to wait before the one retry a resume gets.
|
|
*
|
|
* Short, because this is not waiting for a server to come up -- it is giving a link that woke with
|
|
* the screen a moment to finish doing so.
|
|
*/
|
|
private const val WAKE_RETRY_MS = 600L
|
|
private const val RESTART_WAIT_MS = 1000L
|
|
|
|
/**
|
|
* How many of those to wait through when the built-in project's own update is what is happening.
|
|
*
|
|
* Longer than [REFRESH_ATTEMPTS_AFTER_PULL] because this is not a retry of a read that might
|
|
* succeed anyway — it is waiting out a restart that is definitely happening, and one going through
|
|
* a service manager is a stop and a start rather than an exec. Bounded all the same: the APK is
|
|
* already downloaded by the second of the two waits, and never installing it would be the worse
|
|
* failure.
|
|
*/
|
|
private const val RESTART_ATTEMPTS = 15
|
|
|
|
private sealed class ManifestState {
|
|
data object Loading : ManifestState()
|
|
|
|
// A List, not a Map -- see UpdateManifest.kt -- so the card order below
|
|
// matches whatever order the server sent, with no separate hardcoded
|
|
// app list here to keep in sync with it.
|
|
data class Loaded(val manifest: Manifest) : ManifestState()
|
|
|
|
data class Error(val message: String) : ManifestState()
|
|
}
|
|
|
|
/**
|
|
* What a whole *project* is being made to do, as opposed to one of its components.
|
|
*
|
|
* Only the things that act on the checkout every component is built from, which is why they are the
|
|
* project's: a pull rewrites it, and Rebuild runs every component's command. Anything that belongs
|
|
* to one component is a [ComponentState] instead — held in a map keyed by component name, so
|
|
* starting one component's work cannot disable, describe or fail another's.
|
|
*
|
|
* Two hierarchies rather than one keyed by a pair, because they are not the same set of states: a
|
|
* download has no project-wide meaning and a pull has no component-wide one, and keeping them apart
|
|
* is what stops a component's progress being stored where every component would read it. That was
|
|
* the bug — one map keyed by project alone, so pressing Update on one client of a two-client
|
|
* project disabled the other's button and drew this one's download bar under it.
|
|
*/
|
|
private sealed class ProjectState {
|
|
/**
|
|
* Something is running against the whole checkout, and [what] is what to call it.
|
|
*
|
|
* [status] is the server's live progress, refreshed on every poll, so the card can say which
|
|
* step is running rather than showing an unchanging spinner for the length of a build.
|
|
*
|
|
* The words are carried rather than derived from a variant per action, because the three things
|
|
* that produce this state -- Pull, Update, and moving the checkout -- differ in nothing else:
|
|
* same lock, same polling, same row. What they must not share is the label, which said "Pulling
|
|
* and building" for all of them and so described two of the three wrongly. A small lie in a
|
|
* progress bar is the kind that makes a reader stop trusting the rest of the card.
|
|
*/
|
|
data class Working(val what: String, val status: BuildStatus?) : ProjectState()
|
|
|
|
/**
|
|
* Why the last thing asked of the whole project stopped.
|
|
*
|
|
* There is no "retry" recorded with it, because the control that would redo it is the one
|
|
* sitting beside the message: a project's actions are Pull and Update, both in the card's own
|
|
* row, and both re-enabled by a failure. A component's failure is a [ComponentState.Error] and
|
|
* gets Retry on its own row, which is what stops a failed pull being retried as a download.
|
|
*/
|
|
data class Error(val message: String) : ProjectState()
|
|
}
|
|
|
|
/** What one component of a project is being made to do. */
|
|
private sealed class ComponentState {
|
|
/** The build machine is producing this component's build, before it can be downloaded. */
|
|
data class Preparing(val status: BuildStatus?) : ComponentState()
|
|
|
|
/**
|
|
* The request is out and the server hasn't started sending. For an app served as a stripped
|
|
* copy that is the strip pipeline running -- zip walk, llvm-strip, re-sign -- which takes
|
|
* seconds, and used to show as a download frozen at 0%.
|
|
*
|
|
* Named for the transport rather than "preparing", which is taken by the configured build step
|
|
* above; what a person reads is "preparing" either way.
|
|
*/
|
|
data object Fetching : 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()
|
|
|
|
/**
|
|
* 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()
|
|
|
|
/**
|
|
* Downloaded, and waiting for the system installer to be free.
|
|
*
|
|
* Only a project-wide Update produces it, and only for the second and later APKs of a project
|
|
* that builds more than one: an install intent is modal and this app is not on screen while it
|
|
* is up, so firing two together means the second replaces the first and one component is
|
|
* silently never installed. Held in the component's own row instead, and offered again by
|
|
* [continuePendingInstalls] when this screen comes back -- the same machinery, and the same two
|
|
* triggers, as a download waiting for a wrongly-signed copy to be removed.
|
|
*/
|
|
data class ReadyToInstall(val file: File) : ComponentState()
|
|
|
|
/** Why the last thing this component was asked to do stopped. */
|
|
data class Error(val message: String) : ComponentState()
|
|
}
|
|
|
|
/**
|
|
* Whether this is work in progress, as opposed to the record of work that has stopped.
|
|
*
|
|
* The distinction every "should this control be disabled" question wants, and the reason a failure
|
|
* is a state here rather than a field on one: an error is something to read, not something to wait
|
|
* for, so a card holding one is idle. Held as a property of the state rather than tested at each
|
|
* site, because there are five of those and a missed one leaves a button dead until the screen is
|
|
* reloaded.
|
|
*/
|
|
private val ProjectState?.busy: Boolean
|
|
get() = this is ProjectState.Working
|
|
|
|
private val ComponentState?.busy: Boolean
|
|
get() = this != null && this !is ComponentState.Error
|
|
|
|
/** How far the list is dimmed behind a modal. One value, since two of them dim it. */
|
|
private const val SCRIM_ALPHA = 0.6f
|
|
|
|
/**
|
|
* The app list, and the Add screen reachable from it.
|
|
*
|
|
* One `if` rather than a navigation library: there are two screens and one level between them,
|
|
* which is not enough to need one.
|
|
*/
|
|
@Composable
|
|
fun UpdaterScreen(settingsVersion: Int) {
|
|
// A second re-check trigger alongside settingsVersion: that one only
|
|
// bumps for a devupdater://enroll intent landing in MainActivity, but
|
|
// the in-app scanner below enrolls without ever going through an intent.
|
|
var inAppEnrollTick by remember { mutableStateOf(0) }
|
|
// Re-read on every enrollment: a plain remember would keep serving the
|
|
// pre-enrollment answer.
|
|
val enrolled = remember(settingsVersion, inAppEnrollTick) { serverSettings() != null }
|
|
if (!enrolled) {
|
|
NotEnrolled(onEnrolled = { inAppEnrollTick++ })
|
|
return
|
|
}
|
|
|
|
var adding by remember { mutableStateOf(false) }
|
|
// A newer build of *this app* on the build machine, if there is one.
|
|
// Asked over the rescue routes rather than read off the list, so it
|
|
// still answers when a manifest this app is too old to parse would
|
|
// not -- which is exactly when replacing this app matters most.
|
|
var selfUpdate by remember { mutableStateOf<SelfBuild?>(null) }
|
|
val selfUpdateContext = LocalContext.current
|
|
// Offered when, and only when, the list cannot offer it: the built-in
|
|
// card is not there, or there is no list at all. That card's own
|
|
// Update now pulls, builds and installs this app like any other, and
|
|
// a dialog appearing over the card that is already doing it says the
|
|
// same thing twice -- worse, it says it in the one place a person
|
|
// cannot see what it is about. What is left is the case this screen
|
|
// was written for: a server that has changed what the manifest says
|
|
// into something this app is too old to read, which is exactly when
|
|
// replacing this app matters most and exactly when nothing on the
|
|
// list can say so.
|
|
var selfCardMissing by remember { mutableStateOf(false) }
|
|
LaunchedEffect(selfCardMissing) {
|
|
selfUpdate = if (selfCardMissing) selfUpdateAvailable(selfUpdateContext) else null
|
|
}
|
|
// Keys of apps added on the Add screen, waiting to be taken into the
|
|
// list one at a time.
|
|
var added by remember { mutableStateOf<List<String>>(emptyList()) }
|
|
|
|
// The Add screen goes *over* the list rather than replacing it, so the
|
|
// list keeps its state and its place. Swapped out it would be built
|
|
// again from nothing on the way back, and rebuilding it means fetching
|
|
// it -- which is the whole-list refresh that adding an app has no
|
|
// business causing.
|
|
Box(Modifier.fillMaxSize()) {
|
|
AppListScreen(
|
|
added = added,
|
|
onAddedApplied = { added = emptyList() },
|
|
onAdd = { adding = true },
|
|
onSelfCardMissing = { selfCardMissing = it },
|
|
)
|
|
selfUpdate?.let { build ->
|
|
// Over the list for the same reason the Add screen is, and
|
|
// dismissible for a stronger one: this appears unprompted, and
|
|
// an update that cannot be postponed is a demand rather than
|
|
// an offer.
|
|
BackHandler { selfUpdate = null }
|
|
Box(
|
|
Modifier.fillMaxSize()
|
|
.background(MaterialTheme.colorScheme.scrim.copy(alpha = SCRIM_ALPHA))
|
|
.clickable(
|
|
interactionSource = remember { MutableInteractionSource() },
|
|
indication = null,
|
|
) {
|
|
selfUpdate = null
|
|
},
|
|
contentAlignment = Alignment.Center,
|
|
) {
|
|
Surface(
|
|
shape = MaterialTheme.shapes.extraLarge,
|
|
tonalElevation = 6.dp,
|
|
modifier =
|
|
Modifier.fillMaxWidth().padding(24.dp).clickable(
|
|
interactionSource = remember { MutableInteractionSource() },
|
|
indication = null,
|
|
) {},
|
|
) {
|
|
SelfUpdateScreen(build = build, onDismiss = { selfUpdate = null })
|
|
}
|
|
}
|
|
}
|
|
if (adding) {
|
|
// Back is the same gesture as Done here. Without this it falls
|
|
// through to the activity and closes the app, which reads as a
|
|
// crash when all somebody meant was to go back to the list.
|
|
BackHandler { adding = false }
|
|
// A modal over the list rather than a screen replacing it:
|
|
// this is a detour, and the dimmed list behind says the place
|
|
// you came from is still there. Tapping the dim dismisses it,
|
|
// which is what a modal is expected to do.
|
|
Box(
|
|
Modifier.fillMaxSize()
|
|
.background(MaterialTheme.colorScheme.scrim.copy(alpha = SCRIM_ALPHA))
|
|
.clickable(
|
|
interactionSource = remember { MutableInteractionSource() },
|
|
indication = null,
|
|
) {
|
|
adding = false
|
|
}
|
|
) {
|
|
Surface(
|
|
shape = MaterialTheme.shapes.extraLarge,
|
|
tonalElevation = 6.dp,
|
|
modifier =
|
|
Modifier.fillMaxSize()
|
|
.padding(16.dp)
|
|
// Swallows taps that land on the sheet, so they
|
|
// don't reach the dismissing scrim underneath.
|
|
.clickable(
|
|
interactionSource = remember { MutableInteractionSource() },
|
|
indication = null,
|
|
) {},
|
|
) {
|
|
AddAppScreen(
|
|
onAdded = { key -> added = added + key },
|
|
onBack = { adding = false },
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Shown until this device has a token. There is no manual-entry form: the token is 256 random bits,
|
|
* so scanning is the only reasonable way in, and the server prints the QR every time it generates
|
|
* or rotates one.
|
|
*/
|
|
@Composable
|
|
private fun NotEnrolled(onEnrolled: () -> Unit) {
|
|
val context = LocalContext.current
|
|
var scanError by remember { mutableStateOf<String?>(null) }
|
|
val scanLauncher =
|
|
rememberLauncherForActivityResult(ScanContract()) { result: ScanIntentResult ->
|
|
// Null contents means the user backed out of the scanner -- not an
|
|
// error, so nothing to report.
|
|
val contents = result.contents ?: return@rememberLauncherForActivityResult
|
|
val settings = serverStore.parseEnrollmentUri(Uri.parse(contents))
|
|
if (settings == null) {
|
|
scanError = "Not a valid enrollment code"
|
|
} else {
|
|
serverStore.save(context, settings)
|
|
useServer(settings)
|
|
onEnrolled()
|
|
}
|
|
}
|
|
|
|
val requestCamera =
|
|
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
|
if (granted) {
|
|
scanLauncher.launch(enrollmentScanOptions())
|
|
} else {
|
|
scanError =
|
|
"Scanning needs the camera, and there is no other way in -- " +
|
|
"the token is 256 random bits. Grant it in the system settings."
|
|
}
|
|
}
|
|
|
|
Column(Modifier.fillMaxSize().padding(24.dp)) {
|
|
Text("Dev Updater", style = MaterialTheme.typography.headlineSmall)
|
|
Spacer(Modifier.height(16.dp))
|
|
Text(
|
|
"This device isn't enrolled yet.",
|
|
style = MaterialTheme.typography.titleMedium,
|
|
)
|
|
Spacer(Modifier.height(8.dp))
|
|
Text(
|
|
"Start dev-updater on the build machine, then scan the QR it prints. " +
|
|
"It carries the address and this device's token.\n\n" +
|
|
"If it doesn't print one, the machine already has a token enrolled -- " +
|
|
"run it once with --rotate-token to issue a fresh one.",
|
|
style = MaterialTheme.typography.bodyMedium,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
)
|
|
Spacer(Modifier.height(16.dp))
|
|
Button(
|
|
onClick = {
|
|
// Hold the camera permission before the scanner starts.
|
|
// Letting its activity ask on our behalf is what the
|
|
// library does by default, and it opens the camera without
|
|
// waiting for the answer: the first-ever scan comes up as
|
|
// a live preview with "Sorry, the Android camera
|
|
// encountered a problem" over it, and works on the second
|
|
// try. Nothing is wrong with the camera, so nothing should
|
|
// say there is.
|
|
// Qualified: this package has its own Manifest, the
|
|
// /manifest response model.
|
|
if (
|
|
context.checkSelfPermission(android.Manifest.permission.CAMERA) ==
|
|
PackageManager.PERMISSION_GRANTED
|
|
) {
|
|
scanLauncher.launch(enrollmentScanOptions())
|
|
} else {
|
|
requestCamera.launch(android.Manifest.permission.CAMERA)
|
|
}
|
|
},
|
|
modifier = Modifier.fillMaxWidth(),
|
|
) {
|
|
Text("Scan QR code")
|
|
}
|
|
scanError?.let {
|
|
Spacer(Modifier.height(8.dp))
|
|
Text(it, color = MaterialTheme.colorScheme.error)
|
|
}
|
|
}
|
|
}
|
|
|
|
// PullToRefreshBox is still marked experimental in Material3. Opted into
|
|
// for that one component rather than reimplemented: the alternative is a
|
|
// hand-rolled nested-scroll connection and indicator, which is a great
|
|
// deal more code to arrive at the same gesture, and worse at matching what
|
|
// the platform does elsewhere.
|
|
@OptIn(ExperimentalMaterial3Api::class)
|
|
@Composable
|
|
private fun AppListScreen(
|
|
added: List<String>,
|
|
onAddedApplied: () -> Unit,
|
|
onAdd: () -> Unit,
|
|
/** Whether the built-in project's card is unavailable to offer this app's own update. */
|
|
onSelfCardMissing: (Boolean) -> Unit,
|
|
) {
|
|
val context = LocalContext.current
|
|
val scope = rememberCoroutineScope()
|
|
|
|
var manifestState by remember { mutableStateOf<ManifestState>(ManifestState.Loading) }
|
|
// Whether the built-in card is unavailable to offer this app's own
|
|
// update, which is the whole of what the screen above needs from this
|
|
// one. Derived here rather than asked for, so the rule sits beside the
|
|
// state it reads. Loading counts as present: not having found out yet
|
|
// is not the same as an answer, and a dialog that flashed up during
|
|
// every load would be exactly the noise it is there to avoid.
|
|
val selfCardMissing =
|
|
when (val state = manifestState) {
|
|
is ManifestState.Loaded -> state.manifest.entries.none { it.builtIn }
|
|
is ManifestState.Error -> true
|
|
ManifestState.Loading -> false
|
|
}
|
|
LaunchedEffect(selfCardMissing) { onSelfCardMissing(selfCardMissing) }
|
|
// What each project is doing, and separately what each of its
|
|
// components is: two levels, like installedTimes below and for the
|
|
// same reason. A project can build two clients, and one of them being
|
|
// updated says nothing about the other -- keyed by project alone, it
|
|
// said it about both.
|
|
var projectStates by remember { mutableStateOf<Map<String, ProjectState>>(emptyMap()) }
|
|
var componentStates by remember {
|
|
mutableStateOf<Map<String, Map<String, ComponentState>>>(emptyMap())
|
|
}
|
|
// Written only by updateInstalledState below, called from either of the
|
|
// two effects that follow -- so a fresh manifest, a package-change
|
|
// broadcast, and a return from the system installer all go through the
|
|
// same one path rather than each refreshing these their own way.
|
|
//
|
|
// Two levels: a project, then a component of it. A project can build
|
|
// two clients, and they install over different packages -- one map
|
|
// keyed by project alone would answer for whichever was asked about
|
|
// last, on both rows.
|
|
var installedTimes by remember { mutableStateOf<Map<String, Map<String, Long?>>>(emptyMap()) }
|
|
var installedSizes by remember { mutableStateOf<Map<String, Map<String, Long?>>>(emptyMap()) }
|
|
// Which build each component is pinned to on *this* device, read from
|
|
// local storage rather than the manifest. Held as state so picking one
|
|
// redraws the card without a round trip.
|
|
var chosenVariants by remember { mutableStateOf<Map<String, Map<String, String>>>(emptyMap()) }
|
|
// The project whose pull came back saying its checkout and its remote
|
|
// share no history, waiting on an answer about throwing that history
|
|
// away. One slot rather than one per card: it is a modal, so only one
|
|
// can be open, and the entry in it says which card asked.
|
|
var forcePull by remember { mutableStateOf<ManifestEntry?>(null) }
|
|
val listScroll = rememberScrollState()
|
|
// Where each card is in that scrolling column, filled in as they are
|
|
// laid out. Only [followCard] reads it.
|
|
val cardPlaces = remember { mutableStateMapOf<String, CardPlace>() }
|
|
// Which component of which project has a service action in flight.
|
|
// Per project, because that is the granularity of a card, and the row
|
|
// that is busy is the one that shows it.
|
|
var serviceBusy by remember { mutableStateOf<Map<String, String>>(emptyMap()) }
|
|
|
|
// Whether this screen is actually in front of somebody. Set by the
|
|
// resume effect further down, and read only by `failure` below.
|
|
var foreground by remember { mutableStateOf(true) }
|
|
|
|
/**
|
|
* What to show for a failed request, or null when there is nobody to show it to.
|
|
*
|
|
* Work started before the app went away keeps running — a poll loop, a download — and when the
|
|
* device sleeps or the link drops it fails. Reporting that means coming back an hour later is
|
|
* greeted by a five-second read timeout that says nothing about the server and that there is
|
|
* nothing left to do about, because the thing it describes is over. So a failure that lands
|
|
* while nothing is on screen is dropped, and the re-read on the way back in is what produces
|
|
* the truth instead.
|
|
*
|
|
* Null means *clear*, never *leave*, at every call site: a dropped failure that left the card
|
|
* as it was would leave a spinner up for an operation that has already stopped.
|
|
*
|
|
* A failure of something somebody pressed is never dropped, because pressing it is what put
|
|
* them in front of the screen.
|
|
*/
|
|
fun failure(e: DownloadServerException): String? = (e.message ?: "Failed").takeIf { foreground }
|
|
|
|
/**
|
|
* Puts one project's state, or takes it away for null.
|
|
*
|
|
* The pair of [setComponent], so the two levels are written the same way and a caller with a
|
|
* message that may or may not be worth showing can hand the answer straight over.
|
|
*/
|
|
fun setProject(key: String, state: ProjectState?) {
|
|
projectStates =
|
|
when (state) {
|
|
null -> projectStates - key
|
|
else -> projectStates + (key to state)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Puts one component's state, or takes it away for null.
|
|
*
|
|
* The one place the two-level map is written, so a write for one component cannot drop the
|
|
* entry another one is keeping.
|
|
*/
|
|
fun setComponent(key: String, component: String, state: ComponentState?) {
|
|
val forProject = componentStates[key] ?: emptyMap()
|
|
val updated =
|
|
when (state) {
|
|
null -> forProject - component
|
|
else -> forProject + (component to state)
|
|
}
|
|
componentStates =
|
|
when {
|
|
updated.isEmpty() -> componentStates - key
|
|
else -> componentStates + (key to updated)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Replaces the list without showing it as loading, and keeps looking while the server says a
|
|
* remote check is still running.
|
|
*
|
|
* The server answers immediately rather than waiting on the git remotes, so "new commits"
|
|
* arrives a moment after the list does. That is the trade being made: the list is never held up
|
|
* by a round trip, and the badge catches up on its own instead of on a tap.
|
|
*/
|
|
// [recheck] false starts the poll without asking any remote, for a
|
|
// caller that has already started the one check it cares about.
|
|
suspend fun load(recheck: Boolean = true): ManifestState {
|
|
val deadline = SystemClock.elapsedRealtime() + CHECK_POLL_BUDGET_MS
|
|
var first = recheck
|
|
while (true) {
|
|
// Only the first fetch asks the remotes; the rest are looking
|
|
// for the answer that one started.
|
|
val loaded = withContext(Dispatchers.IO) { fetchManifest(recheck = first) }
|
|
first = false
|
|
if (!loaded.checksPending) {
|
|
return ManifestState.Loaded(loaded)
|
|
}
|
|
if (SystemClock.elapsedRealtime() >= deadline) {
|
|
// Past the point where the server's own bound should have
|
|
// ended the check, so nothing is coming. The spinners have
|
|
// to come down with the polling that fed them -- one still
|
|
// turning would promise an answer nothing is collecting --
|
|
// but they are replaced by a card that says the check
|
|
// didn't finish, not by one that reads as "nothing new".
|
|
return ManifestState.Loaded(loaded.checksUnfinished())
|
|
}
|
|
manifestState = ManifestState.Loaded(loaded)
|
|
delay(CHECK_POLL_INTERVAL_MS)
|
|
}
|
|
}
|
|
|
|
// Whether a pull-to-refresh is still running, which is what keeps its
|
|
// indicator up. Separate from ManifestState.Loading because the point
|
|
// of the gesture is that the list stays where it is.
|
|
var pulling by remember { mutableStateOf(false) }
|
|
|
|
// Whether a whole-list read is already running, so the resume below
|
|
// cannot start a second one beside the one that is already going.
|
|
var loadingList by remember { mutableStateOf(false) }
|
|
|
|
/**
|
|
* Reads the whole list into [manifestState], however it was asked for.
|
|
*
|
|
* A failure that lands while nothing is on screen leaves the list on the spinner rather than
|
|
* replacing it with a timeout nobody is there to read — see [failure]. That is only safe
|
|
* because the resume effect re-reads from *any* state, spinner included, so the way back into
|
|
* the app is what resolves it.
|
|
*/
|
|
// Clears the flag rather than setting it: the callers set it before
|
|
// they launch, because two of them can run in the same frame and both
|
|
// would pass the guard if the first one only claimed it once its
|
|
// coroutine got going.
|
|
//
|
|
// [afterAGap] is the resume's, and nothing else passes it. The link may
|
|
// have been asleep as long as the app was, and the first request across
|
|
// one that is still coming back times out at the five seconds every
|
|
// request here gets -- which is the server being reported unreachable
|
|
// for no reason except that somebody had the app closed. One more try
|
|
// is the difference between saying that and giving it a second. It
|
|
// costs nothing when the server really is down, because a refused
|
|
// connection comes back at once rather than waiting out the timeout.
|
|
suspend fun loadInto(afterAGap: Boolean = false) {
|
|
val attempts = if (afterAGap) 2 else 1
|
|
try {
|
|
repeat(attempts) { attempt ->
|
|
try {
|
|
manifestState = load()
|
|
return
|
|
} catch (e: DownloadServerException) {
|
|
if (attempt == attempts - 1) {
|
|
failure(e)?.let { manifestState = ManifestState.Error(it) }
|
|
return
|
|
}
|
|
delay(WAKE_RETRY_MS)
|
|
}
|
|
}
|
|
} finally {
|
|
loadingList = false
|
|
}
|
|
}
|
|
|
|
fun refreshByPull() {
|
|
if (pulling || loadingList) return
|
|
pulling = true
|
|
loadingList = true
|
|
projectStates = emptyMap()
|
|
componentStates = emptyMap()
|
|
scope.launch {
|
|
loadInto()
|
|
pulling = false
|
|
}
|
|
}
|
|
|
|
fun refresh(afterAGap: Boolean = false) {
|
|
if (loadingList) return
|
|
loadingList = true
|
|
manifestState = ManifestState.Loading
|
|
projectStates = emptyMap()
|
|
componentStates = emptyMap()
|
|
scope.launch { loadInto(afterAGap) }
|
|
}
|
|
|
|
/**
|
|
* Puts one entry into the list: in place if it is already there, at the end if it isn't, which
|
|
* is where a just-added app belongs.
|
|
*/
|
|
fun putEntry(entry: ManifestEntry) {
|
|
val current = manifestState as? ManifestState.Loaded ?: return
|
|
val entries = current.manifest.entries
|
|
manifestState =
|
|
ManifestState.Loaded(
|
|
current.manifest.copy(
|
|
entries =
|
|
when {
|
|
entries.none { it.key == entry.key } -> entries + entry
|
|
else -> entries.map { if (it.key == entry.key) entry else it }
|
|
}
|
|
)
|
|
)
|
|
}
|
|
|
|
/** Takes one entry out, for the one action that ends a card. */
|
|
fun dropEntry(key: String) {
|
|
val current = manifestState as? ManifestState.Loaded ?: return
|
|
manifestState =
|
|
ManifestState.Loaded(
|
|
current.manifest.copy(
|
|
entries = current.manifest.entries.filterNot { it.key == key }
|
|
)
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Puts a card back on screen after something moved it.
|
|
*
|
|
* Switching a project's branch makes its build out of date, which moves the card out of "Up to
|
|
* date" and into the group above -- somewhere else entirely on a list of ten. The reader
|
|
* pressed something on that card and is owed the answer, so the list follows it. Iris asked for
|
|
* this on 2026-09-02, having watched a card she had just acted on disappear upwards.
|
|
*
|
|
* Only when it is actually off screen: a card that moved a little, or not at all, is left where
|
|
* it is, because scrolling something the reader can already see is a jump they did not ask for.
|
|
*
|
|
* The two frames are the load-bearing part. The entry has just been applied, so the card is
|
|
* about to be composed in its new place; the first frame carries that composition and the
|
|
* second reports the layout it produced. Reading the position before both have passed gives
|
|
* where the card *was*.
|
|
*/
|
|
suspend fun followCard(key: String) {
|
|
withFrameNanos {}
|
|
withFrameNanos {}
|
|
val place = cardPlaces[key] ?: return
|
|
val viewTop = listScroll.value
|
|
if (place.top >= viewTop && place.top + place.height <= viewTop + listScroll.viewportSize) {
|
|
return
|
|
}
|
|
listScroll.animateScrollTo(place.top.coerceIn(0, listScroll.maxValue))
|
|
}
|
|
|
|
/**
|
|
* Take the server's view of *one* card and leave every other card exactly as it was, returning
|
|
* the entry that landed.
|
|
*
|
|
* Three things it deliberately doesn't do, each of which the whole-list refresh does and each
|
|
* of which is wrong for an action on one card. It doesn't drop the list to a spinner, which
|
|
* would take every card away to report on one. It doesn't ask any remote: nothing happened to
|
|
* those checkouts, and the one acted on records its own result as it goes
|
|
* (`RemoteChecks::mark_current` on the server), so asking again would set every card spinning
|
|
* to re-learn what it already knew. And it doesn't wait for anyone else's outstanding check
|
|
* before applying what it came for.
|
|
*
|
|
* One app is one request: `GET /apps/{key}` describes it through the same code the whole list
|
|
* is built from, so a card fetched on its own can't come to say something different from the
|
|
* same card in a list.
|
|
*/
|
|
suspend fun applyOne(key: String): ManifestEntry {
|
|
val fetched = withContext(Dispatchers.IO) { fetchApp(key) }
|
|
putEntry(fetched)
|
|
return fetched
|
|
}
|
|
|
|
/**
|
|
* Ask one checkout's remote and wait for that one answer.
|
|
*
|
|
* Waits on nothing else: the whole-list poll finishes when *no* check is outstanding, which
|
|
* would make one card's refresh sit behind another card's.
|
|
*/
|
|
suspend fun awaitCheck(key: String) {
|
|
withContext(Dispatchers.IO) { recheckApp(key) }
|
|
val deadline = SystemClock.elapsedRealtime() + CHECK_POLL_BUDGET_MS
|
|
while (true) {
|
|
val landed = applyOne(key)
|
|
if (!landed.checksOutstanding) return
|
|
if (SystemClock.elapsedRealtime() >= deadline) {
|
|
// Past the point where the server's own bound should have
|
|
// ended it, so nothing is coming. The card says the check
|
|
// didn't finish rather than falling back to the blank line
|
|
// that means "the remote answered and had nothing".
|
|
putEntry(landed.checkUnfinished())
|
|
return
|
|
}
|
|
delay(CHECK_POLL_INTERVAL_MS)
|
|
}
|
|
}
|
|
|
|
// One card's worth of what Refresh does, for the control in its corner.
|
|
// Unlike everything else here this one does ask a remote, because
|
|
// asking is the whole point of it -- but for one checkout, and it waits
|
|
// only on that checkout's answer.
|
|
fun refreshOne(entry: ManifestEntry) {
|
|
scope.launch {
|
|
try {
|
|
awaitCheck(entry.key)
|
|
} catch (e: DownloadServerException) {
|
|
setProject(entry.key, failure(e)?.let(ProjectState::Error))
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Waits until the server is answering again, for a bounded time, and says whether it is.
|
|
*
|
|
* Only the built-in project needs it, and it needs it twice. Building this project rebuilds
|
|
* this server's binary and restarts it a moment later, so the two things that come after that
|
|
* build — fetching the new APK, and handing it to the system installer — both happen while the
|
|
* process on the other end may be exec-ing into its replacement. A download that dies mid
|
|
* transfer reads as the update having failed at the moment it was working, and an install
|
|
* offered during the restart replaces this app while the server it must talk to is down.
|
|
*
|
|
* Asked over `/self`, the same frozen route the rescue check uses: it is two numbers and no
|
|
* nesting, so it answers as soon as the new process is listening whatever else changed.
|
|
*
|
|
* Gives up rather than waiting for ever, and the caller carries on anyway: the APK is already
|
|
* on the phone by then, and refusing to install it because the server is down would strand
|
|
* somebody at the one moment a newer copy might be the fix.
|
|
*/
|
|
suspend fun awaitServerBack(): Boolean {
|
|
repeat(RESTART_ATTEMPTS) {
|
|
if (withContext(Dispatchers.IO) { runCatching { selfBuild() }.isSuccess }) return true
|
|
delay(RESTART_WAIT_MS)
|
|
}
|
|
return false
|
|
}
|
|
|
|
fun install(file: File) {
|
|
if (!canRequestInstall(context)) {
|
|
context.startActivity(requestInstallPermissionIntent(context))
|
|
return
|
|
}
|
|
context.startActivity(installApkIntent(context, file))
|
|
}
|
|
|
|
/**
|
|
* Re-reads one entry once a build has finished, whatever asked for the build.
|
|
*
|
|
* The APK's mtime is what decides "update available", so the list has to come from the server
|
|
* again rather than be guessed at here.
|
|
*
|
|
* Retried, because building this server's own project rebuilds its binary and restarts it: the
|
|
* socket goes away for a moment, and reporting that as a failure would be telling somebody
|
|
* their update broke at the moment it worked.
|
|
*
|
|
* The card stays marked as being worked on until that entry lands, which is a second or several
|
|
* while this server restarts — [settle] is what clears it, and runs only once there is a fresh
|
|
* entry to clear it against. Clearing at the end of the build instead left the card reading as
|
|
* idle against a list still describing the state before it.
|
|
*/
|
|
suspend fun readBackAfterBuilding(entry: ManifestEntry, settle: () -> Unit) {
|
|
repeat(REFRESH_ATTEMPTS_AFTER_PULL) { attempt ->
|
|
try {
|
|
applyOne(entry.key)
|
|
settle()
|
|
return
|
|
} catch (e: DownloadServerException) {
|
|
if (attempt == REFRESH_ATTEMPTS_AFTER_PULL - 1) {
|
|
settle()
|
|
failure(e)?.let { manifestState = ManifestState.Error(it) }
|
|
} else {
|
|
delay(RESTART_WAIT_MS)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Pull acts on the build machine: fetch, fast-forward, rebuild. */
|
|
/**
|
|
* Runs a build on the build machine and follows it to the end, whichever button asked for it.
|
|
*
|
|
* One body for Pull and Rebuild because everything after "start it" is the same: poll while it
|
|
* runs, report a failure on the card, and re-read the entry once the server is answering again.
|
|
* They differ only in what they call and in what the progress bar is allowed to claim it is
|
|
* doing.
|
|
*/
|
|
suspend fun followBuild(
|
|
entry: ManifestEntry,
|
|
start: suspend () -> BuildStatus,
|
|
progress: (BuildStatus?) -> ProjectState,
|
|
): BuildStatus? {
|
|
setProject(entry.key, progress(null))
|
|
try {
|
|
var status = withContext(Dispatchers.IO) { start() }
|
|
while (status.building) {
|
|
setProject(entry.key, progress(status))
|
|
delay(BUILD_POLL_INTERVAL_MS)
|
|
status = withContext(Dispatchers.IO) { buildStatus(entry.key) }
|
|
}
|
|
// A failure is reported where it happened. The project's own
|
|
// error is the pull's -- there is one checkout and one thing
|
|
// that could have gone wrong with it -- while a build that
|
|
// fell over belongs to the component whose command it was,
|
|
// since a run builds every component at once and two of them
|
|
// can fail differently.
|
|
val pullFailure = status.error
|
|
if (pullFailure != null) {
|
|
setProject(entry.key, ProjectState.Error(pullFailure))
|
|
// The card keeps the message either way -- the dialog is
|
|
// dismissible, and a failure that vanished with it would
|
|
// leave the card looking as though nothing had happened.
|
|
if (status.unrelatedHistories) forcePull = entry
|
|
return null
|
|
}
|
|
// Kept after the project's own state is cleared below, so a
|
|
// component that failed still says so once the run it was
|
|
// part of is over.
|
|
for (component in status.components) {
|
|
component.error?.let {
|
|
setComponent(entry.key, component.name, ComponentState.Error(it))
|
|
}
|
|
}
|
|
readBackAfterBuilding(entry) {
|
|
// The project's own state only. A component that failed
|
|
// inside this run has just been given its own, and
|
|
// clearing that here would take away the only thing
|
|
// saying so.
|
|
setProject(entry.key, null)
|
|
}
|
|
// The run itself, for whatever wants to carry on from it.
|
|
// A component that failed inside it is not a failed run --
|
|
// the others built, and Update installs those -- which is why
|
|
// this answers with the whole status rather than a yes or no.
|
|
return status
|
|
} catch (e: DownloadServerException) {
|
|
setProject(entry.key, failure(e)?.let(ProjectState::Error))
|
|
return null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The body of [startUpdate], as a suspending call so that a project-wide Update can run it for
|
|
* each of its APKs in turn rather than starting them all at once.
|
|
*
|
|
* [offerInstall] false stops at the downloaded file, leaving it in the component's own row for
|
|
* [continuePendingInstalls] to offer once the installer is free. Only ever false for the second
|
|
* and later APKs of one press.
|
|
*
|
|
* Answers whether it handed something to the installer, which is how a project-wide Update
|
|
* knows the installer is taken. Working that out by reading the component states instead cannot
|
|
* work, and did not: handing a file to the installer clears that component's state, so the next
|
|
* one round the loop found nothing waiting and went to the installer as well -- both intents
|
|
* fired, and the second replaced the first on screen. Caught by pressing Update on a project
|
|
* that builds two APKs, which is the only place it shows.
|
|
*/
|
|
suspend fun updateComponent(
|
|
entry: ManifestEntry,
|
|
component: String,
|
|
offerInstall: Boolean = true,
|
|
): Boolean {
|
|
return run {
|
|
if (entry.needsBuild) {
|
|
setComponent(entry.key, component, ComponentState.Preparing(null))
|
|
try {
|
|
var status = withContext(Dispatchers.IO) { prepareBuild(entry.key, component) }
|
|
// This component's own step, not the project's
|
|
// `building`: a sibling being built at the same time
|
|
// says yes to that one, and waiting on it would put
|
|
// this download behind a build it has nothing to do
|
|
// with -- which is the coupling this is here to end.
|
|
var started = false
|
|
while (status.component(component)?.running == true) {
|
|
started = true
|
|
setComponent(entry.key, component, ComponentState.Preparing(status))
|
|
delay(BUILD_POLL_INTERVAL_MS)
|
|
status = withContext(Dispatchers.IO) { buildStatus(entry.key) }
|
|
}
|
|
// Only from a build this press actually started. The
|
|
// server keeps a component's last outcome until it is
|
|
// built again, so an older failure is still sitting
|
|
// there -- and reading that one would refuse a
|
|
// download because of something already dealt with.
|
|
val buildError = status.component(component)?.error?.takeIf { started }
|
|
if (buildError != null) {
|
|
setComponent(entry.key, component, ComponentState.Error(buildError))
|
|
return@run false
|
|
}
|
|
} catch (e: DownloadServerException) {
|
|
setComponent(entry.key, component, failure(e)?.let(ComponentState::Error))
|
|
return@run false
|
|
}
|
|
}
|
|
|
|
// The build that just ran rebuilt this server, so what is
|
|
// about to be asked for the APK is a process that may be
|
|
// replacing itself. Waited for here rather than retried after
|
|
// the fact: a download that dies partway is reported as a
|
|
// failure, and this is the update where that failure reads as
|
|
// the update itself having broken.
|
|
if (entry.builtIn) {
|
|
setComponent(entry.key, component, ComponentState.Busy("Waiting for the server"))
|
|
awaitServerBack()
|
|
}
|
|
|
|
// Not "downloading" until something is actually coming down:
|
|
// the server may still be producing what it is about to send.
|
|
setComponent(entry.key, component, ComponentState.Fetching)
|
|
val file =
|
|
try {
|
|
withContext(Dispatchers.IO) {
|
|
downloadApk(context, entry, component) { read, total ->
|
|
val progress = if (total > 0) read.toFloat() / total else null
|
|
setComponent(
|
|
entry.key,
|
|
component,
|
|
ComponentState.Downloading(progress),
|
|
)
|
|
}
|
|
}
|
|
} catch (e: DownloadServerException) {
|
|
setComponent(entry.key, component, failure(e)?.let(ComponentState::Error))
|
|
return@run false
|
|
}
|
|
// 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@run false
|
|
}
|
|
if (!offerInstall) {
|
|
// Downloaded and waiting its turn at the installer, said
|
|
// in this component's own row rather than by a dialog
|
|
// covering the card the other install is about.
|
|
setComponent(entry.key, component, ComponentState.ReadyToInstall(file))
|
|
return@run false
|
|
}
|
|
// The other half of the wait above: the download itself holds
|
|
// the restart off while it runs -- the server counts what it
|
|
// is sending -- so the exec lands, if it lands at all, in the
|
|
// moment between the last byte and this install. Replacing
|
|
// this app then leaves the copy that starts next unable to
|
|
// reach anything, which reads as the update having broken it.
|
|
if (entry.builtIn) {
|
|
setComponent(entry.key, component, ComponentState.Busy("Waiting for the server"))
|
|
awaitServerBack()
|
|
}
|
|
setComponent(entry.key, component, null)
|
|
install(file)
|
|
true
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Pull takes the commits and stops there.
|
|
*
|
|
* The cheap half of Update, and the one somebody presses while looking: it acts on the build
|
|
* machine's checkout and changes nothing on this phone. What it leaves behind is a component
|
|
* the card reports as out of date, with its own Build beside it -- and Update, which does the
|
|
* whole thing.
|
|
*
|
|
* [force] is the answer to the dialog below, and nothing else ever passes it: it abandons
|
|
* whatever history that checkout has of its own.
|
|
*/
|
|
fun startPull(entry: ManifestEntry, force: Boolean = false) {
|
|
scope.launch {
|
|
followBuild(
|
|
entry,
|
|
start = { pullProject(entry.key, force, build = false) },
|
|
progress = { ProjectState.Working("Pulling", it) },
|
|
)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update is the whole way from the remote to this phone: pull, build what that brought in, and
|
|
* install every APK it produced.
|
|
*
|
|
* Written as the two halves in order rather than as a route of its own, because each half is
|
|
* already a thing this app does and reports: the pull-and-build reports in the project's row
|
|
* exactly as Pull does, and each install reports in its own component's row exactly as that
|
|
* component's own Update does. A third path would be a third thing to keep in step with them.
|
|
*
|
|
* The entry is read again between the halves ([followBuild] ends by re-reading it), because
|
|
* what to install is decided by what the build produced -- sizes, variants and packages all
|
|
* move under it.
|
|
*/
|
|
fun startProjectUpdate(entry: ManifestEntry, force: Boolean = false) {
|
|
scope.launch {
|
|
// A checkout with nothing to pull *from* is the second half
|
|
// alone: build what is there and install it. That covers a
|
|
// branch tracking nothing and a project told never to be moved
|
|
// from a phone -- and it is the whole of what a rollback
|
|
// wants, since picking an old commit detaches HEAD. Pulling
|
|
// regardless is how this answered a parked checkout with
|
|
// "branch HEAD tracks no upstream", from the button the card
|
|
// had just enabled to offer the downgrade.
|
|
val built =
|
|
if (entry.canPull) {
|
|
followBuild(
|
|
entry,
|
|
start = { pullProject(entry.key, force) },
|
|
progress = { ProjectState.Working("Updating", it) },
|
|
) ?: return@launch
|
|
} else {
|
|
null
|
|
}
|
|
val fresh =
|
|
(manifestState as? ManifestState.Loaded)?.manifest?.entries?.firstOrNull {
|
|
it.key == entry.key
|
|
} ?: return@launch
|
|
var installerTaken = false
|
|
// One at a time, and the installer is only offered the first:
|
|
// the rest wait in their own rows until the system's installer
|
|
// has been dealt with, which is what `continuePendingInstalls`
|
|
// picks up when this screen comes back. Two install intents
|
|
// fired together means the second replaces the first on
|
|
// screen, and a component silently not installed is worse than
|
|
// one that says it is waiting.
|
|
for (component in fresh.components.filter { it.apk != null }) {
|
|
// Not the one whose build just failed: its row already
|
|
// says so, and asking for it again would run the same
|
|
// failing command a second time to say it twice.
|
|
if (built?.component(component.name)?.error != null) continue
|
|
val handedOver =
|
|
updateComponent(fresh, component.name, offerInstall = !installerTaken)
|
|
installerTaken = installerTaken || handedOver
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Moves this project's checkout onto a branch or a commit on the build machine, and builds
|
|
* nothing.
|
|
*
|
|
* Followed exactly as a pull is, because it is the same act on the same single checkout: one
|
|
* progress path, reported in the project's own row, rather than a second one to keep in step.
|
|
* It is over as soon as git is, though, since there is no build behind it -- what the move
|
|
* leaves behind is a component the card reports as out of date, with its own Build beside it.
|
|
*/
|
|
fun startCheckout(entry: ManifestEntry, target: String) {
|
|
scope.launch {
|
|
followBuild(
|
|
entry,
|
|
start = { checkoutTarget(entry.key, target) },
|
|
progress = { ProjectState.Working("Moving the checkout", it) },
|
|
) ?: return@launch
|
|
// The card has just moved, most likely into the group above:
|
|
// its build is now behind the checkout. Take the reader with
|
|
// it, before waiting on the remote below, so the list follows
|
|
// the press rather than a round trip.
|
|
followCard(entry.key)
|
|
// And then the answer that the move invalidated. The build
|
|
// machine drops what it knew about this checkout's remote and
|
|
// asks again -- the last answer was about the branch being
|
|
// left -- but that lands *after* the response, so without
|
|
// this the card keeps a pending check for ever: no commit
|
|
// count, and Pull disabled, on a branch with commits waiting.
|
|
// Reading it back can move the card again, hence the second
|
|
// follow.
|
|
try {
|
|
awaitCheck(entry.key)
|
|
} catch (e: DownloadServerException) {
|
|
setProject(entry.key, failure(e)?.let(ProjectState::Error))
|
|
return@launch
|
|
}
|
|
followCard(entry.key)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Builds one component because somebody pressed its Rebuild, and installs nothing.
|
|
*
|
|
* The counterpart to [startUpdate] that does not end at the phone: Update is "get me this
|
|
* build", which builds only when the rules say the output is behind, while this is "build it
|
|
* again" for the cases those rules cannot see — a command that reads files nobody declared, an
|
|
* output changed underneath this server, a signing key replaced since the APK was made. None of
|
|
* them move a commit, so nothing reads as stale and Update does nothing at all.
|
|
*
|
|
* Recorded against the component like every other action on one, so a sibling stays pressable
|
|
* and silent throughout.
|
|
*/
|
|
fun startComponentBuild(entry: ManifestEntry, component: String) {
|
|
scope.launch {
|
|
setComponent(entry.key, component, ComponentState.Preparing(null))
|
|
try {
|
|
var status = withContext(Dispatchers.IO) { buildNow(entry.key, component) }
|
|
// This component's own step, not the project's `building`,
|
|
// for the same reason the download path reads it: a
|
|
// sibling being built at the same time says yes to that
|
|
// one.
|
|
var started = false
|
|
while (status.component(component)?.running == true) {
|
|
started = true
|
|
setComponent(entry.key, component, ComponentState.Preparing(status))
|
|
delay(BUILD_POLL_INTERVAL_MS)
|
|
status = withContext(Dispatchers.IO) { buildStatus(entry.key) }
|
|
}
|
|
val buildError = status.component(component)?.error?.takeIf { started }
|
|
if (buildError != null) {
|
|
setComponent(entry.key, component, ComponentState.Error(buildError))
|
|
return@launch
|
|
}
|
|
// What changed is this component's build: its size, its
|
|
// freshness, and whether the phone's copy is now the old
|
|
// one. Only the card can say, so it is read back rather
|
|
// than guessed at -- and the row stays busy until it
|
|
// lands, which for this server's own component is the
|
|
// second or two it spends restarting.
|
|
readBackAfterBuilding(entry) { setComponent(entry.key, component, null) }
|
|
} catch (e: DownloadServerException) {
|
|
setComponent(entry.key, component, failure(e)?.let(ComponentState::Error))
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Builds one component if it needs it, downloads it, and hands it to the installer.
|
|
*
|
|
* Every step of it is recorded against that component and nothing else. This is the action the
|
|
* whole two-level map exists for: a project can build two independent clients, and updating one
|
|
* of them must leave the other's button pressable and its row silent — with one slot per
|
|
* project, this one's bar was drawn under both and both buttons went dead.
|
|
*/
|
|
fun startUpdate(entry: ManifestEntry, component: String) {
|
|
scope.launch { updateComponent(entry, component) }
|
|
}
|
|
|
|
/**
|
|
* 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() {
|
|
// One at a time, for the reason each of these states exists: the
|
|
// installer is modal and takes the screen, so offering two puts
|
|
// the second over the first and loses it.
|
|
var offered = false
|
|
componentStates.forEach { (key, states) ->
|
|
states.forEach { (component, state) ->
|
|
val ready =
|
|
when (state) {
|
|
// Removing the old app is what this was waiting
|
|
// for; still installed means the removal was
|
|
// backed out of, and the offer stays as it was.
|
|
is ComponentState.WrongKey ->
|
|
!isInstalled(context, state.mismatch.packageName)
|
|
is ComponentState.ReadyToInstall -> true
|
|
else -> false
|
|
}
|
|
if (ready && !offered) {
|
|
offered = true
|
|
setComponent(key, component, null)
|
|
install(
|
|
when (state) {
|
|
is ComponentState.WrongKey -> state.file
|
|
is ComponentState.ReadyToInstall -> state.file
|
|
else -> return@forEach
|
|
}
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Runs a management call, then refetches so the list reflects the server rather than a guess.
|
|
*/
|
|
fun manage(entry: ManifestEntry, removes: Boolean = false, action: () -> Unit) {
|
|
scope.launch {
|
|
try {
|
|
withContext(Dispatchers.IO) { action() }
|
|
// This card's state only: another card's error is its own
|
|
// and has nothing to do with what just happened here.
|
|
setProject(entry.key, null)
|
|
if (removes) dropEntry(entry.key) else applyOne(entry.key)
|
|
} catch (e: DownloadServerException) {
|
|
setProject(entry.key, failure(e)?.let(ProjectState::Error))
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*
|
|
* Refreshes afterwards rather than trusting the reply alone: the reply says what that component
|
|
* ended up doing, and the list is what says whether anything else changed with it -- a service
|
|
* that starts may be the reason a card stops offering an update.
|
|
*/
|
|
fun runServiceAction(
|
|
entry: ManifestEntry,
|
|
component: String,
|
|
action: String,
|
|
purge: Purge = Purge(),
|
|
) {
|
|
serviceBusy = serviceBusy + (entry.key to component)
|
|
scope.launch {
|
|
try {
|
|
val result =
|
|
withContext(Dispatchers.IO) {
|
|
serviceAction(entry.key, component, action, purge)
|
|
}
|
|
// Reported in the row the button was pressed in, because
|
|
// the request succeeded -- the service is gone -- and what
|
|
// is left to say is that something is still on the build
|
|
// machine, which nobody can see from here otherwise. On
|
|
// the component rather than the card: a project can run
|
|
// more than one service, and this is about one of them.
|
|
setComponent(
|
|
entry.key,
|
|
component,
|
|
when {
|
|
result.leftBehind.isEmpty() -> null
|
|
else -> ComponentState.Error(result.leftBehind.joinToString("\n"))
|
|
},
|
|
)
|
|
applyOne(entry.key)
|
|
} catch (e: DownloadServerException) {
|
|
setComponent(entry.key, component, failure(e)?.let(ComponentState::Error))
|
|
} finally {
|
|
serviceBusy = serviceBusy - entry.key
|
|
}
|
|
}
|
|
}
|
|
|
|
fun updateInstalledState(entries: List<ManifestEntry>) {
|
|
// A component with no build yet has no package, so there is
|
|
// nothing installed to ask about -- which is exactly what null
|
|
// already means to the callers of these two maps.
|
|
fun <T> byComponent(read: (String) -> T?): Map<String, Map<String, T?>> =
|
|
entries.associate { entry ->
|
|
entry.key to
|
|
entry.components
|
|
.filter { !it.isServer }
|
|
.associate { component ->
|
|
component.name to component.apk?.packageName?.let(read)
|
|
}
|
|
}
|
|
installedTimes = byComponent { installedLastUpdateTimeMillis(context, it) }
|
|
installedSizes = byComponent { installedApkSizeBytes(context, it) }
|
|
chosenVariants = entries.associate { entry ->
|
|
entry.key to
|
|
entry.components
|
|
.filter { !it.isServer }
|
|
.mapNotNull { component ->
|
|
chosenVariant(context, entry.key, component.name)?.let {
|
|
component.name to it
|
|
}
|
|
}
|
|
.toMap()
|
|
}
|
|
}
|
|
|
|
// The first load is the resume effect's too, rather than a
|
|
// LaunchedEffect(Unit) beside it: `LifecycleResumeEffect` runs when the
|
|
// screen first reaches RESUMED, so arriving and returning are the same
|
|
// event and there is one thing that decides when the list is read.
|
|
|
|
// An app added on the Add screen is fetched on its own and appended.
|
|
// Reloading the list instead would put every other card back through
|
|
// its checks to learn about one that isn't in it yet.
|
|
//
|
|
// Its remote is asked straight afterwards, because a card that has
|
|
// never been checked reports no new commits -- which is the same thing
|
|
// it would say if the remote had been asked and had nothing, and there
|
|
// would be no way to tell the two apart.
|
|
LaunchedEffect(added) {
|
|
if (added.isEmpty()) return@LaunchedEffect
|
|
for (key in added) {
|
|
try {
|
|
applyOne(key)
|
|
awaitCheck(key)
|
|
} catch (e: DownloadServerException) {
|
|
// It is on the server either way; the next refresh or
|
|
// resume will bring it in.
|
|
}
|
|
}
|
|
onAddedApplied()
|
|
}
|
|
|
|
// The packages to look up come from the manifest itself, not a
|
|
// hardcoded local list, so this can only run once the fetch above has
|
|
// landed -- hence keying on the entries rather than on Unit.
|
|
//
|
|
// Re-run on every resume because an install finishes in the *system
|
|
// installer's* activity, with this screen stopped: PackageManager only
|
|
// reports the new lastUpdateTime once that's done, so querying at
|
|
// download time would always read the pre-install value and leave the
|
|
// card claiming an update is still available until someone hit Refresh.
|
|
// Resuming is exactly the moment the answer can have changed, and these
|
|
// are cheap local PackageManager lookups with no network involved (the
|
|
// manifest is deliberately left alone -- that's what Refresh is for).
|
|
//
|
|
// This is a fallback for cases the broadcast receiver below can't cover
|
|
// (this app's own process wasn't alive to receive it -- e.g. it was
|
|
// backgrounded and killed during the install, or the install happened
|
|
// via a plain `adb install` while this app wasn't running at all), not
|
|
// the primary path in the common case of installing through this
|
|
// screen's own Update button.
|
|
val loadedEntries = (manifestState as? ManifestState.Loaded)?.manifest?.entries
|
|
LifecycleResumeEffect(loadedEntries) {
|
|
loadedEntries?.let(::updateInstalledState)
|
|
onPauseOrDispose {}
|
|
}
|
|
|
|
// The manifest is refetched on resume too, not just the local install
|
|
// state: this screen is often left open for a long time, and commit
|
|
// counts come from the server, so returning to it should show what is
|
|
// true now rather than whenever it was last opened. A checkout already
|
|
// being asked about is not asked again (RemoteChecks::refresh), so a
|
|
// burst of resumes doesn't pile up round trips.
|
|
//
|
|
// From *any* state, not only a loaded one, and that is load-bearing
|
|
// twice over. It is what makes dropping a background failure safe:
|
|
// that leaves the list on its spinner, and this is the only thing that
|
|
// will take it off. And a state that failed is the one most worth
|
|
// retrying on the way back in -- guarded on "loaded", a card that had
|
|
// gone red stayed red until somebody found the Refresh button, which
|
|
// is the opposite of what returning to an app should do.
|
|
//
|
|
// `refresh` declines when a read is already running, so this and the
|
|
// 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
|
|
// somebody is in another app is a download that worked. This only
|
|
// says there is nobody to report a failure to.
|
|
onPauseOrDispose { foreground = false }
|
|
}
|
|
|
|
// PackageManager reports a completed install as soon as it happens,
|
|
// regardless of whether this app's own activity is currently visible --
|
|
// see registerPackageChangeReceiver's own comment for why that's
|
|
// earlier than the resume above ever can be. rememberUpdatedState keeps
|
|
// the callback (registered once, not re-registered per manifest fetch)
|
|
// seeing the latest loaded entries without needing them as a key.
|
|
val currentEntries = rememberUpdatedState(loadedEntries)
|
|
DisposableEffect(Unit) {
|
|
val receiver =
|
|
registerPackageChangeReceiver(context) { packageName ->
|
|
currentEntries.value
|
|
?.takeIf { entries ->
|
|
entries.any { entry ->
|
|
entry.components.any { it.apk?.packageName == packageName }
|
|
}
|
|
}
|
|
?.let(::updateInstalledState)
|
|
// The removal this app asked for, arriving before the
|
|
// resume above when somebody is quick about it.
|
|
continuePendingInstalls()
|
|
}
|
|
onDispose { context.unregisterReceiver(receiver) }
|
|
}
|
|
|
|
// How much room the floating button takes at the foot of the list,
|
|
// measured from the button rather than written down: a card's own
|
|
// controls run to the bottom edge of the last card, and a number
|
|
// guessed here would either cover one of them or leave a gap.
|
|
var addButtonSpace by remember { mutableStateOf(0.dp) }
|
|
val density = LocalDensity.current
|
|
|
|
Box(Modifier.fillMaxSize()) {
|
|
Column(Modifier.fillMaxSize().padding(16.dp)) {
|
|
Row(
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
modifier = Modifier.fillMaxWidth(),
|
|
) {
|
|
Text(
|
|
"Dev Updater",
|
|
style = MaterialTheme.typography.headlineSmall,
|
|
modifier = Modifier.weight(1f),
|
|
)
|
|
// A glyph rather than the word: it never changes, and a
|
|
// refresh arrow is read faster than it is spelled. Add is not
|
|
// here -- it is the button floating over the list.
|
|
IconGlyphButton(REFRESH_GLYPH, "Refresh", { refresh() })
|
|
}
|
|
Spacer(Modifier.height(16.dp))
|
|
|
|
when (val state = manifestState) {
|
|
is ManifestState.Loading -> CircularProgressIndicator()
|
|
is ManifestState.Error ->
|
|
Column {
|
|
// The message alone. It arrives from
|
|
// DownloadServerException already saying what failed,
|
|
// where, and what to try -- so a prefix here would
|
|
// restate half of it in front of itself, and would
|
|
// have to guess at which action produced it.
|
|
Text(state.message, color = MaterialTheme.colorScheme.error)
|
|
// A missing local-network permission looks identical to an
|
|
// unreachable server from the socket's point of view, so
|
|
// call it out rather than leaving it to be guessed at.
|
|
if (!com.example.wgapplink.localNetworkAllowed(context)) {
|
|
Spacer(Modifier.height(12.dp))
|
|
Text(
|
|
"This app doesn't have the local network permission, which " +
|
|
"Android 17+ requires to reach an address like this one. " +
|
|
"That alone would explain the failure above: without it " +
|
|
"the connection is dropped rather than refused, so it " +
|
|
"just times out. Grant it under Settings > Apps > " +
|
|
"Dev Updater > Permissions.",
|
|
style = MaterialTheme.typography.bodyMedium,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
)
|
|
}
|
|
}
|
|
// The same thing the Refresh button does, minus the blank: the
|
|
// gesture shows its own indicator, so dropping the list to a
|
|
// spinner underneath it would say the same thing twice and take
|
|
// the list away while saying it.
|
|
is ManifestState.Loaded ->
|
|
PullToRefreshBox(
|
|
isRefreshing = pulling,
|
|
onRefresh = ::refreshByPull,
|
|
modifier = Modifier.fillMaxSize(),
|
|
) {
|
|
Column(Modifier.fillMaxSize().verticalScroll(listScroll)) {
|
|
val entries = state.manifest.entries
|
|
if (entries.isEmpty()) {
|
|
Text(
|
|
"No apps yet. Tap Add to point this at a project on the build machine.",
|
|
style = MaterialTheme.typography.bodyMedium,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
)
|
|
}
|
|
|
|
// "Up to date" means there is nothing waiting anywhere: no
|
|
// commits to pull, and the build that is here is the one
|
|
// installed. Counting the commits is what stops a card
|
|
// moving groups the moment a pull finishes -- a checkout
|
|
// that is behind was never up to date, so the pull that
|
|
// rebuilds it changes what the card *says* without changing
|
|
// where it sits. An app with nothing built is here too; it
|
|
// is not up to date either.
|
|
//
|
|
// A build running on the build machine counts for the same
|
|
// reason, and it has to be asked here rather than inferred
|
|
// from the entry: a list that lands mid-pull reports a
|
|
// checkout with nothing left to fetch and an APK nobody has
|
|
// rebuilt yet, which is "up to date" in every term below,
|
|
// and sent the card to the bottom group until the build
|
|
// finished and brought it back. A card being made current
|
|
// is not current yet.
|
|
val (needAttention, upToDate) =
|
|
entries.partition { entry ->
|
|
isBuilding(
|
|
projectStates[entry.key],
|
|
componentStates[entry.key],
|
|
) ||
|
|
// Any client of the project being
|
|
// behind is the project being
|
|
// behind: a card with one of two
|
|
// apps waiting has something
|
|
// waiting.
|
|
hasWorkWaiting(
|
|
entry,
|
|
installedTimes[entry.key].orEmpty(),
|
|
chosenVariants[entry.key].orEmpty(),
|
|
)
|
|
}
|
|
|
|
// The two groups render identically; only the up-to-date one
|
|
// gets a heading above it, since the ones needing an update
|
|
// are the point of the screen and lead without one.
|
|
for ((heading, group) in
|
|
listOf(null to needAttention, "Up to date" to upToDate)) {
|
|
if (group.isEmpty()) continue
|
|
if (heading != null) {
|
|
Text(heading, style = MaterialTheme.typography.titleSmall)
|
|
Spacer(Modifier.height(8.dp))
|
|
}
|
|
group.forEach { entry ->
|
|
Box(
|
|
// Where this card sits in the scrolling
|
|
// column, so an action that moves it
|
|
// between groups can take the reader
|
|
// with it. Recorded per card rather
|
|
// than worked out from the order,
|
|
// because the cards are different
|
|
// heights and the headings count too.
|
|
Modifier.onGloballyPositioned { placed ->
|
|
cardPlaces[entry.key] =
|
|
CardPlace(
|
|
placed.positionInParent().y.roundToInt(),
|
|
placed.size.height,
|
|
)
|
|
}
|
|
) {
|
|
AppCard(
|
|
entry = entry,
|
|
installedTimes =
|
|
installedTimes[entry.key] ?: emptyMap(),
|
|
chosenVariants =
|
|
chosenVariants[entry.key] ?: emptyMap(),
|
|
installedSizes =
|
|
installedSizes[entry.key] ?: emptyMap(),
|
|
projectState = projectStates[entry.key],
|
|
componentStates =
|
|
componentStates[entry.key] ?: emptyMap(),
|
|
onUpdate = { updated, component ->
|
|
startUpdate(updated, component)
|
|
},
|
|
onComponentBuild = { built, component ->
|
|
startComponentBuild(built, component)
|
|
},
|
|
onPull = { startPull(entry) },
|
|
onProjectUpdate = { startProjectUpdate(entry) },
|
|
onRefresh = { refreshOne(entry) },
|
|
onSettings = { gitIpv4 ->
|
|
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) }
|
|
},
|
|
onRemove = {
|
|
forgetVariants(context, entry.key)
|
|
manage(entry, removes = true) {
|
|
removeApp(entry.key)
|
|
}
|
|
},
|
|
// Written here rather than sent to the server:
|
|
// it is this device's preference. Bumping the
|
|
// reload token is what redraws the card with
|
|
// the new choice and the mtime that goes with
|
|
// it.
|
|
onSelectVariant = { component, variant ->
|
|
chooseVariant(
|
|
context,
|
|
entry.key,
|
|
component,
|
|
variant?.path,
|
|
)
|
|
val forProject =
|
|
chosenVariants[entry.key] ?: emptyMap()
|
|
chosenVariants =
|
|
chosenVariants +
|
|
(entry.key to
|
|
when (variant) {
|
|
null -> forProject - component
|
|
else ->
|
|
forProject +
|
|
(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)
|
|
},
|
|
)
|
|
}
|
|
Spacer(Modifier.height(12.dp))
|
|
}
|
|
}
|
|
// Room for the floating button, so the last card's controls
|
|
// can still be reached.
|
|
Spacer(Modifier.height(addButtonSpace))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Bottom right, over the list. Add is the one thing here that is
|
|
// not about a particular card, and it was competing for the top bar
|
|
// with Refresh, which is.
|
|
FloatingActionButton(
|
|
onClick = onAdd,
|
|
containerColor = MaterialTheme.colorScheme.primary,
|
|
contentColor = MaterialTheme.colorScheme.onPrimary,
|
|
modifier =
|
|
Modifier.align(Alignment.BottomEnd)
|
|
// Before the padding, so what is measured is the whole
|
|
// space the button occupies rather than the button alone --
|
|
// that is what the list has to keep clear of.
|
|
.onSizeChanged { addButtonSpace = with(density) { it.height.toDp() } }
|
|
.padding(16.dp),
|
|
) {
|
|
Text(PLUS_GLYPH, fontFamily = NerdIcons, fontSize = 22.sp)
|
|
}
|
|
|
|
forcePull?.let { entry ->
|
|
ForcePullDialog(
|
|
entry = entry,
|
|
onDismiss = { forcePull = null },
|
|
onForce = {
|
|
forcePull = null
|
|
startPull(entry, force = true)
|
|
},
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Offered when a pull comes back saying the checkout and its remote share no history at all.
|
|
*
|
|
* There is no fast-forward between two unrelated histories and there never will be, so without this
|
|
* the card is one that can never be pulled again — and the way out is on the build machine, which
|
|
* is exactly where the person holding the phone isn't. So the capability is shown rather than
|
|
* withheld, with what it costs said in front of it: the alternative to a destructive button here is
|
|
* not safety, it is a dead card.
|
|
*
|
|
* The branch and its upstream are named because they are what is about to be overwritten, and this
|
|
* is the only place they are seen before it happens.
|
|
*/
|
|
@Composable
|
|
private fun ForcePullDialog(entry: ManifestEntry, onDismiss: () -> Unit, onForce: () -> Unit) {
|
|
// Named where they are known. Pull is only offered for a checkout
|
|
// with an upstream, so the fallbacks are for a list that has moved on
|
|
// since the failure rather than for the ordinary case.
|
|
val branch = entry.git?.branch ?: "the branch"
|
|
val upstream = entry.git?.upstream ?: "the remote"
|
|
AlertDialog(
|
|
onDismissRequest = onDismiss,
|
|
title = { Text("${entry.label} shares no history with $upstream") },
|
|
text = {
|
|
Text(
|
|
"Its checkout on the build machine has no commit in common with $upstream, so " +
|
|
"there is no fast-forward to make and Pull can go no further.\n\n" +
|
|
"Forcing resets $branch onto $upstream. Every commit the build machine has " +
|
|
"that the remote doesn't is abandoned -- they stay in that checkout's " +
|
|
"reflog, but nothing on this phone will bring them back.\n\n" +
|
|
"Uncommitted changes are not touched: a pull refuses over those before it " +
|
|
"ever gets this far."
|
|
)
|
|
},
|
|
confirmButton = {
|
|
TextButton(
|
|
// The red every control that takes something away wears,
|
|
// so this doesn't read as the ordinary way past a message.
|
|
colors = ActionTone.Destructive.colors(),
|
|
onClick = onForce,
|
|
) {
|
|
Text("Force pull")
|
|
}
|
|
},
|
|
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
|
|
)
|
|
}
|
|
|
|
@Composable
|
|
private fun AppCard(
|
|
entry: ManifestEntry,
|
|
/** By component name, for the project's own APKs. */
|
|
installedTimes: Map<String, Long?>,
|
|
installedSizes: Map<String, Long?>,
|
|
chosenVariants: Map<String, String>,
|
|
/** What the whole project is doing: pulling, rebuilding, or why one of those failed. */
|
|
projectState: ProjectState?,
|
|
/** What each of its components is doing, by component name. */
|
|
componentStates: Map<String, ComponentState>,
|
|
onUpdate: (ManifestEntry, component: String) -> Unit,
|
|
/** Build this one component again, whatever the staleness rules make of it. */
|
|
onComponentBuild: (ManifestEntry, component: String) -> Unit,
|
|
onPull: () -> Unit,
|
|
/** Pull, build what that brought in, and install every APK it produced. */
|
|
onProjectUpdate: () -> Unit,
|
|
onRefresh: () -> Unit,
|
|
onSettings: (gitIpv4: Boolean) -> Unit,
|
|
/** Move this project's checkout onto a branch or commit, and build what that leaves behind. */
|
|
onCheckout: (target: String) -> Unit,
|
|
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,
|
|
/** 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.
|
|
serviceBusy: String?,
|
|
onServiceAction: (component: String, action: String, purge: Purge) -> Unit,
|
|
) {
|
|
var settingsOpen by remember { mutableStateOf(false) }
|
|
// Until the build step this project asks for has been accepted, the
|
|
// card is about that request and nothing else: no size, no components,
|
|
// no button offering to install something whose build nobody has
|
|
// agreed to run yet. The one thing kept is Remove, which is how you
|
|
// decline by getting rid of the card.
|
|
val awaitingApproval = entry.pendingDeclaration != null
|
|
|
|
Card(Modifier.fillMaxWidth()) {
|
|
Column(Modifier.padding(16.dp)) {
|
|
// The two corner controls belong to the card, not to its title,
|
|
// so they sit beside the whole of what names it -- title and
|
|
// path together -- rather than in the title's own row, where
|
|
// they pushed the path onto a line of its own with nothing to
|
|
// its right. Aligned to the top of that pair, not centred on
|
|
// it: a corner control that drifts down as the block below it
|
|
// grows has stopped being in the corner.
|
|
// The corner controls are laid over the header rather than
|
|
// taking a column in it, so that only the line they actually
|
|
// sit beside gives up room for them. In a row of their own they
|
|
// shortened the whole block, and the path -- which is under
|
|
// them, not next to them -- was cut off well before the card's
|
|
// edge with nothing in the way.
|
|
Box(Modifier.fillMaxWidth()) {
|
|
Row(verticalAlignment = Alignment.Top, modifier = Modifier.fillMaxWidth()) {
|
|
// The project's icon is its client's, and only when
|
|
// it has exactly one: a project building two of them
|
|
// has no single answer, and showing the first would
|
|
// label the whole card with one of its two apps.
|
|
// AppIcon draws its own placeholder for null, which is
|
|
// what a project with two gets -- the same mark a
|
|
// project with nothing built yet gets, because in both
|
|
// cases there is no one icon to show.
|
|
AppIcon(
|
|
entry.components.mapNotNull { it.apk?.packageName }.singleOrNull(),
|
|
Modifier.align(Alignment.CenterVertically),
|
|
)
|
|
Spacer(Modifier.width(10.dp))
|
|
Column(Modifier.weight(1f)) {
|
|
// The one line that makes room for them: they are beside
|
|
// it, and a title running under them is the thing that
|
|
// would be in the way.
|
|
Row(
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
modifier = Modifier.fillMaxWidth().padding(end = CORNER_CONTROLS_WIDTH),
|
|
) {
|
|
Text(
|
|
entry.label,
|
|
style = MaterialTheme.typography.titleMedium,
|
|
modifier = Modifier.weight(1f),
|
|
)
|
|
}
|
|
// Which project on the build machine this is, so two apps
|
|
// with similar labels can be told apart and a wrong path
|
|
// spotted -- and beside it the branch it is on.
|
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
Text(
|
|
FOLDER_GLYPH,
|
|
fontFamily = NerdIcons,
|
|
fontSize = 13.sp,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
)
|
|
Spacer(Modifier.width(5.dp))
|
|
Text(
|
|
// The checkout, not the directory underneath it
|
|
// that this server watches: the repository is
|
|
// what a person calls the project, and it is
|
|
// what Pull acts on.
|
|
entry.git?.root ?: entry.projectPath,
|
|
style = MaterialTheme.typography.bodySmall,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
maxLines = 1,
|
|
// A path is identified by its tail, so one too
|
|
// long for the row gives up its head rather than
|
|
// the directory name that says which project
|
|
// this is.
|
|
overflow = TextOverflow.StartEllipsis,
|
|
)
|
|
entry.git?.let { git ->
|
|
// Blue rather than a word: "new commits" spelled
|
|
// out doubled the length of the line to say what
|
|
// the Pull button below already offers, so the
|
|
// branch is simply the colour of that button
|
|
// when pressing it would do something.
|
|
val branchColor =
|
|
when {
|
|
entry.newCommits -> ActionTone.Primary.color
|
|
else -> MaterialTheme.colorScheme.onSurfaceVariant
|
|
}
|
|
Spacer(Modifier.width(10.dp))
|
|
Text(
|
|
BRANCH_GLYPH,
|
|
fontFamily = NerdIcons,
|
|
fontSize = 13.sp,
|
|
color = branchColor,
|
|
)
|
|
Spacer(Modifier.width(4.dp))
|
|
Text(
|
|
buildString {
|
|
// git answers `HEAD` when no branch
|
|
// is checked out, which beside a
|
|
// branch icon reads as a branch
|
|
// somebody named HEAD. Worth saying
|
|
// in words now that the settings
|
|
// sheet can put a checkout in that
|
|
// state -- before this it was only
|
|
// reachable on the build machine.
|
|
append(
|
|
if (git.branch == DETACHED_HEAD) "no branch"
|
|
else git.branch
|
|
)
|
|
// Not "new commits", which the colour
|
|
// says. And not the lack of an
|
|
// upstream either: plenty of
|
|
// checkouts have no remote on
|
|
// purpose, and saying so on every
|
|
// card is nagging about a choice
|
|
// somebody made. What is left is
|
|
// the one case where the colour
|
|
// would be read as an answer it
|
|
// isn't -- a check that gave none.
|
|
if (entry.checkError != null) {
|
|
append(" \u00b7 couldn't check")
|
|
}
|
|
// Not whether the working tree is dirty.
|
|
// It is true of a checkout somebody is
|
|
// working in nearly all the time, so it
|
|
// said nothing while being long enough
|
|
// to push the rest of the line out of
|
|
// the row. The server still reads it --
|
|
// a pull refuses over uncommitted
|
|
// changes -- and says so if you press
|
|
// Pull, which is the moment it matters.
|
|
},
|
|
style = MaterialTheme.typography.bodySmall,
|
|
color = branchColor,
|
|
maxLines = 1,
|
|
overflow = TextOverflow.Ellipsis,
|
|
)
|
|
if (entry.checkPending) {
|
|
Spacer(Modifier.width(6.dp))
|
|
Working()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Row(
|
|
modifier = Modifier.align(Alignment.TopEnd),
|
|
horizontalArrangement = Arrangement.spacedBy(GLYPH_BUTTON_GAP),
|
|
) {
|
|
// Refreshes this project alone -- the same checks the
|
|
// list does on open, for one card, so a card that is out
|
|
// of date doesn't cost a round trip for every other one.
|
|
IconGlyphButton(REFRESH_GLYPH, "Refresh this project", onRefresh)
|
|
IconGlyphButton(SETTINGS_GLYPH, "Settings", { settingsOpen = true })
|
|
}
|
|
}
|
|
// Every component gets a card, including a project that has
|
|
// only one. The alternative was to draw a lone APK flat and
|
|
// move its size and its button up to the project card, which
|
|
// meant two layouts to keep in step and a size sitting next to
|
|
// the corner controls, where it looked like it belonged to
|
|
// them.
|
|
if (!awaitingApproval) {
|
|
Spacer(Modifier.height(8.dp))
|
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
// What you install is what the card is for, so it comes
|
|
// first however the project declared it. The declared
|
|
// order is the *build* order -- dev-updater builds its
|
|
// server before its APK on purpose -- and that stays as
|
|
// it is; this sort is stable, so anything else keeps it.
|
|
//
|
|
// Freshness is withheld from a component being worked
|
|
// on, because it is the one thing on the card that the
|
|
// work is *about* and nothing re-reads it while the
|
|
// work runs: the entry was fetched before the button
|
|
// was pressed and is read again only once the run is
|
|
// over. So "out of date" beside the bar that is making
|
|
// it current is not a measurement, it is the answer
|
|
// from before the press -- so it is replaced by a word
|
|
// that says nothing, and both readers of it (the row's
|
|
// own note, and the sibling warning) go quiet without
|
|
// either having to know why.
|
|
// Deliberately the same pair of conditions that
|
|
// disables the Update button: what cannot be acted on
|
|
// is exactly what cannot be measured just now.
|
|
val components =
|
|
entry.components
|
|
.sortedBy { it.isServer }
|
|
.map { component ->
|
|
if (projectState.busy || componentStates[component.name].busy)
|
|
component.copy(freshness = FRESHNESS_WITHHELD)
|
|
else component
|
|
}
|
|
components.forEach { component ->
|
|
val installed = installedTimes[component.name]
|
|
val installedSize = installedSizes[component.name]
|
|
val chosenVariantPath = chosenVariants[component.name]
|
|
// This component's own, so nothing below can
|
|
// reach for a sibling's by accident.
|
|
val componentState = componentStates[component.name]
|
|
val upToDate =
|
|
component.apk?.let { isUpToDate(it, installed, chosenVariantPath) } ==
|
|
true
|
|
ComponentCard(
|
|
entryKey = entry.key,
|
|
component = component,
|
|
packageName = component.apk?.packageName,
|
|
// What the download would cost, and what it
|
|
// replaces. An APK's size sits where a
|
|
// server's state does: the one thing worth
|
|
// knowing about it besides its name.
|
|
sizeText =
|
|
component.apk
|
|
?.takeIf { it.built }
|
|
?.let { apk ->
|
|
when {
|
|
!upToDate && installedSize != null ->
|
|
"${formatSize(installedSize)} \u2192 " +
|
|
formatSize(apk.size)
|
|
else -> formatSize(apk.size)
|
|
}
|
|
},
|
|
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.
|
|
isOwnServer = entry.builtIn,
|
|
build =
|
|
componentBuild(
|
|
projectState,
|
|
componentState,
|
|
component.name,
|
|
),
|
|
// Being worked on right now, which the
|
|
// component's own slice of the build says
|
|
// directly rather than being inferred from a
|
|
// project-wide phase name.
|
|
working =
|
|
componentBuild(projectState, componentState, component.name)
|
|
?.running == true,
|
|
busy = serviceBusy == component.name,
|
|
state = componentState,
|
|
projectState = projectState,
|
|
onAction = { action, purge ->
|
|
onServiceAction(component.name, action, purge)
|
|
},
|
|
onBuild = { onComponentBuild(entry, component.name) },
|
|
onCancelInstall = { onCancelInstall(component.name) },
|
|
controls = {
|
|
if (!component.isServer) {
|
|
// Said before the button, because it
|
|
// qualifies what pressing it gets you.
|
|
MismatchedPairNote(
|
|
self = component,
|
|
others = components,
|
|
)
|
|
// The buttons that asked for it, then
|
|
// how far along it is: a bar reports on
|
|
// the controls above it.
|
|
//
|
|
// Laid out as the project's own row is
|
|
// -- what you would press first on the
|
|
// left, Rebuild held to the right edge
|
|
// -- so the two scales of the same
|
|
// action are in the same place at both.
|
|
Row(
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
modifier = Modifier.fillMaxWidth(),
|
|
) {
|
|
UpdateButton(
|
|
built = component.apk?.built == true,
|
|
needsBuild = entry.needsBuild,
|
|
installed = installed != null,
|
|
upToDate = upToDate,
|
|
freshness = component.freshness,
|
|
state = componentState,
|
|
projectState = projectState,
|
|
onUpdate = { onUpdate(entry, component.name) },
|
|
)
|
|
Spacer(Modifier.weight(1f))
|
|
ComponentBuildButton(
|
|
component = component,
|
|
state = componentState,
|
|
projectState = projectState,
|
|
onBuild = { onComponentBuild(entry, component.name) },
|
|
)
|
|
}
|
|
ApkProgress(componentState)
|
|
}
|
|
},
|
|
)
|
|
}
|
|
}
|
|
}
|
|
// What there is to read before pressing anything, directly
|
|
// above the row that acts on it.
|
|
entry.pendingDeclaration?.let { requested -> PendingDeclaration(requested) }
|
|
|
|
// The card's own actions, as opposed to a component's: what
|
|
// acts on the whole project goes here, left to right in the
|
|
// order you would do it, with Remove held to the right edge.
|
|
//
|
|
// One row rather than a button wherever each happened to be
|
|
// needed. Remove sat above the pending declaration before this,
|
|
// which put the most destructive control on the card *over* the
|
|
// thing it would be destroying, and left the card with no
|
|
// settled place for its last line.
|
|
//
|
|
// Pull acts on the build machine, Update on this phone -- so
|
|
// both are offered, rather than one button meaning different
|
|
// things depending on what is stale. Below the components,
|
|
// because it acts on the whole checkout they are all built
|
|
// from, and reading it after them is reading it as the thing
|
|
// that covers all of them.
|
|
//
|
|
// Disabled rather than absent when there is nothing to pull: a
|
|
// button that comes and goes makes its presence the signal, and
|
|
// then a card with no Pull reads the same whether the remote
|
|
// had nothing or was never asked. A failed check enables it
|
|
// again, since pressing Pull is how you find out.
|
|
// Both of the buttons below act on the whole checkout, so they
|
|
// really do wait on everything: a pull rewrites the files every
|
|
// component is built from, and Rebuild runs every command. That
|
|
// is the project's own coupling rather than the components' --
|
|
// it is why the server refuses them while anything is running,
|
|
// and why disabling them here is honest where disabling a
|
|
// component's own Update button was not.
|
|
val projectBusy = projectState.busy || componentStates.values.any { it.busy }
|
|
Row(
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
modifier = Modifier.fillMaxWidth(),
|
|
) {
|
|
// Not while a declaration is waiting to be read. The
|
|
// button says Build and would do no such thing -- the
|
|
// server pulls and stops for an unaccepted project -- and
|
|
// a card asking permission should not also be offering to
|
|
// act on the thing it is asking about. Hidden rather than
|
|
// disabled, which is the one place that beats the rule
|
|
// just below: absence is only ambiguous when nothing says
|
|
// why, and this card says why in the paragraph above it.
|
|
// Pull and Update in that order: the same act at two
|
|
// lengths, so the cheap one reads first. Both are the
|
|
// colour of bringing something in, because that is what
|
|
// each of them does -- how far it goes is the word, not
|
|
// the colour.
|
|
//
|
|
// Not while a declaration is waiting to be read. Both
|
|
// buttons would build, and the server pulls and stops for
|
|
// an unaccepted project -- and a card asking permission
|
|
// should not also be offering to act on the thing it is
|
|
// asking about. Hidden rather than disabled, which is the
|
|
// one place that beats the rule just below: absence is
|
|
// only ambiguous when nothing says why, and this card says
|
|
// why in the paragraph above it.
|
|
if (entry.canPull && !awaitingApproval) {
|
|
TextButton(
|
|
onClick = onPull,
|
|
// A failed check enables it, since pressing Pull is
|
|
// how you find out -- and so does a failed pull,
|
|
// for the same reason and because this is now the
|
|
// only control that redoes one. A fetch that fell
|
|
// over leaves `newCommits` saying whatever the last
|
|
// successful check said, which for a project nobody
|
|
// had checked yet is "nothing", so without this the
|
|
// message would sit above a button that could not
|
|
// be pressed to answer it.
|
|
enabled =
|
|
(entry.newCommits ||
|
|
entry.checkError != null ||
|
|
projectState is ProjectState.Error) && !projectBusy,
|
|
colors = ActionTone.Primary.colors(),
|
|
) {
|
|
Text("Pull")
|
|
}
|
|
}
|
|
if (!awaitingApproval) {
|
|
Spacer(Modifier.weight(1f))
|
|
TextButton(
|
|
onClick = onProjectUpdate,
|
|
// Exactly when the card is not up to date, which is
|
|
// the same question the heading above the list
|
|
// answers: this is the button that clears it.
|
|
// Disabled rather than absent, so what the card can
|
|
// do stays visible when there is nothing to do.
|
|
enabled =
|
|
!projectBusy && hasWorkWaiting(entry, installedTimes, chosenVariants),
|
|
colors = ActionTone.Primary.colors(),
|
|
) {
|
|
Text(updateWord(entry.components.map { it.freshness }))
|
|
}
|
|
}
|
|
if (awaitingApproval) {
|
|
// Green, like Start and Install: this turns on a build
|
|
// step that was not running before. Left untinted it
|
|
// would take the scheme's primary and so read as
|
|
// Restart's colour, which is a different consequence.
|
|
TextButton(onClick = onApprove, colors = ActionTone.Go.colors()) {
|
|
Text("Accept build step")
|
|
}
|
|
}
|
|
Spacer(Modifier.weight(1f))
|
|
// The server's own app keeps the button and loses the
|
|
// ability to press it: removing it would strand this app
|
|
// with no route left to update itself, and a control that
|
|
// vanishes on one card teaches less than one that is
|
|
// visibly not available.
|
|
TextButton(
|
|
onClick = onRemove,
|
|
enabled = !entry.builtIn,
|
|
colors = ActionTone.Destructive.colors(),
|
|
) {
|
|
Text("Remove")
|
|
}
|
|
}
|
|
// Directly under the row Pull is on, for the same reason the
|
|
// APK's bar sits under its button: a bar reports on a control,
|
|
// and one placed away from it belongs to nothing in particular.
|
|
// This had gone missing entirely when Pull moved up here.
|
|
if (projectState is ProjectState.Working) {
|
|
BuildProgress(projectState.what, projectState.status)
|
|
}
|
|
|
|
// What is left to say about the card once its components have
|
|
// said their own part: a failure of the project's own, or that
|
|
// there is no build to talk about yet. Progress is not here --
|
|
// it belongs beside the button that asked for it, which is in
|
|
// the APK's own card. Nor is a component's failure, which is
|
|
// drawn in that component's row for the same reason.
|
|
//
|
|
// A failure is checked first: a project being built for the
|
|
// first time is both at once, and the failure is the more
|
|
// useful of the two.
|
|
when {
|
|
// Git's own words, most of the time. Selectable, like every
|
|
// other message here that this app did not write.
|
|
projectState is ProjectState.Error -> OutputText(projectState.message)
|
|
|
|
!entry.built && !awaitingApproval ->
|
|
Text(
|
|
if (entry.needsBuild) {
|
|
"Not built yet -- Update runs this project's build step and " +
|
|
"installs what it produces."
|
|
} else {
|
|
"No build found under this project yet."
|
|
},
|
|
style = MaterialTheme.typography.bodyMedium,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
)
|
|
}
|
|
// Why, under the branch line rather than in it: "couldn't
|
|
// check" up there is the part that changes how the line is
|
|
// read, and this is the part that says what to do about it.
|
|
// Whoever is looking at this card is on a phone and has no way
|
|
// to read the server's log.
|
|
//
|
|
// Not while the card is reporting a failure of its own, though:
|
|
// Pull asks the same remote the check asked, so the two say the
|
|
// same paragraph twice, and the one that ran because somebody
|
|
// pressed a button is the one they are waiting to read.
|
|
if (projectState !is ProjectState.Error) {
|
|
entry.checkError?.let { reason ->
|
|
OutputText(reason, style = MaterialTheme.typography.bodySmall)
|
|
}
|
|
}
|
|
// Said here, with the project's identity, because it is about
|
|
// this project's own file rather than about any one component
|
|
// -- and because what it costs is invisible further down: the
|
|
// file is ignored *whole*, so the card quietly becomes an
|
|
// emptier project, with components, strip and resources it
|
|
// asked for simply not there. Nothing else on the card can say
|
|
// that, since a project that declares nothing looks exactly
|
|
// the same.
|
|
//
|
|
// Two parts, and the split is the usual one: this app's own
|
|
// sentence for what it means and what to do, then the parser's
|
|
// own words -- which name the file, the line and the field --
|
|
// selectable, because fixing it happens on the other machine.
|
|
entry.declarationError?.let { reason ->
|
|
Text(
|
|
"This project's own file was ignored, so nothing it asks for is being " +
|
|
"read. Fix it on the build machine, or update Dev Updater if the file " +
|
|
"uses something newer than it knows.",
|
|
style = MaterialTheme.typography.bodySmall,
|
|
color = ActionTone.Caution.color,
|
|
)
|
|
OutputText(reason, style = MaterialTheme.typography.bodySmall)
|
|
}
|
|
}
|
|
}
|
|
|
|
if (settingsOpen) {
|
|
ProjectSettingsDialog(
|
|
// Nothing can be moved while the checkout is being worked on:
|
|
// the server refuses it, and offering it anyway would be a
|
|
// control whose only answer is a refusal.
|
|
busy = projectState.busy,
|
|
onCheckout = { target ->
|
|
settingsOpen = false
|
|
onCheckout(target)
|
|
},
|
|
entry = entry,
|
|
onDismiss = { settingsOpen = false },
|
|
onApply = { gitIpv4 ->
|
|
settingsOpen = false
|
|
onSettings(gitIpv4)
|
|
},
|
|
)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* What this machine has chosen about one project, as opposed to what the project declares about
|
|
* itself.
|
|
*
|
|
* A dialog rather than a screen: it belongs to one card, and there is a card behind it to come back
|
|
* to. It will grow the rest of what the Add screen asks -- a label, which build to serve -- so it
|
|
* is a list of settings from the start, even holding one.
|
|
*
|
|
* Applied on Save rather than as each control moves, because a settings write is a round trip that
|
|
* rebuilds the entry on the server, and one per toggle flipped while making up your mind is a lot
|
|
* of them.
|
|
*/
|
|
@Composable
|
|
private fun ProjectSettingsDialog(
|
|
entry: ManifestEntry,
|
|
/** Something is already running on this checkout, so it must not be moved. */
|
|
busy: Boolean,
|
|
onCheckout: (target: String) -> Unit,
|
|
onDismiss: () -> Unit,
|
|
onApply: (gitIpv4: Boolean) -> Unit,
|
|
) {
|
|
// Keyed on what the server last said, so reopening after a save shows
|
|
// the saved value rather than a stale local one.
|
|
var gitIpv4 by remember(entry.gitIpv4) { mutableStateOf(entry.gitIpv4) }
|
|
|
|
AlertDialog(
|
|
onDismissRequest = onDismiss,
|
|
title = { Text(entry.label) },
|
|
text = {
|
|
Column(Modifier.verticalScroll(rememberScrollState())) {
|
|
Row(
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
modifier = Modifier.fillMaxWidth(),
|
|
) {
|
|
Text(
|
|
"Force IPv4 for git",
|
|
style = MaterialTheme.typography.bodyLarge,
|
|
modifier = Modifier.weight(1f),
|
|
)
|
|
Spacer(Modifier.width(12.dp))
|
|
Switch(checked = gitIpv4, onCheckedChange = { gitIpv4 = it })
|
|
}
|
|
Spacer(Modifier.height(16.dp))
|
|
CheckoutSection(entry = entry, busy = busy, onCheckout = onCheckout)
|
|
}
|
|
},
|
|
confirmButton = { TextButton(onClick = { onApply(gitIpv4) }) { Text("Save") } },
|
|
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Where this project's checkout is, and the two ways to move it.
|
|
*
|
|
* Both pickers act on the press rather than waiting for Save, and the sheet closes behind them:
|
|
* moving the checkout is not a setting, it is a job that starts on the build machine and reports in
|
|
* the project's own row like a pull. Save is left meaning what it meant, which is the switch above.
|
|
*
|
|
* Reading the refs is a round trip, so it happens when the sheet opens rather than riding on the
|
|
* manifest -- listing branches and commits spawns git, and the manifest is fetched on every open,
|
|
* resume and Refresh. It is deliberately a *local* read on that side, so this cannot sit waiting on
|
|
* a network round trip before the sheet is usable.
|
|
*
|
|
* Every reason the pickers cannot be used is said rather than left to be inferred from a control
|
|
* that does nothing: not a checkout at all, something already running on it, uncommitted work in
|
|
* the way, still reading, or the read failed. Those are five different things to do next, and a
|
|
* disabled dropdown with no sentence beside it is the same picture for all of them.
|
|
*
|
|
* Fetch is the third control and the only one that touches the network. It is what puts a branch
|
|
* pushed from another machine into the picker above it -- without it the list is whatever the last
|
|
* pull happened to bring in, so a project already up to date on its own branch could never be moved
|
|
* onto a new one. It stays a press rather than something the sheet does on opening, because a fetch
|
|
* writes into the checkout and pulls down objects: opening a sheet should not do either.
|
|
*/
|
|
@Composable
|
|
private fun CheckoutSection(
|
|
entry: ManifestEntry,
|
|
busy: Boolean,
|
|
onCheckout: (target: String) -> Unit,
|
|
) {
|
|
var refs by remember(entry.key) { mutableStateOf<CheckoutRefs?>(null) }
|
|
var failure by remember(entry.key) { mutableStateOf<String?>(null) }
|
|
var fetching by remember(entry.key) { mutableStateOf(false) }
|
|
val scope = rememberCoroutineScope()
|
|
val git = entry.git
|
|
|
|
// Only for a project actually in a checkout; asking git about a
|
|
// directory that is not one would fail for a reason that is not a
|
|
// fault.
|
|
if (git != null) {
|
|
LaunchedEffect(entry.key) {
|
|
try {
|
|
refs = withContext(Dispatchers.IO) { checkoutRefs(entry.key) }
|
|
} catch (e: DownloadServerException) {
|
|
failure = e.message ?: "couldn't read this checkout's branches"
|
|
}
|
|
}
|
|
}
|
|
|
|
when {
|
|
git == null -> {
|
|
SettingsNote("This project is not in a git repository, so there is nothing to move.")
|
|
return
|
|
}
|
|
// Said before the pickers are drawn, because it is the reason
|
|
// they will refuse rather than something to discover by pressing.
|
|
git.dirty ->
|
|
SettingsNote(
|
|
"The checkout has uncommitted changes. Moving it would go over them, so commit " +
|
|
"or stash on the build machine first."
|
|
)
|
|
busy -> SettingsNote("Something is already running on this checkout.")
|
|
}
|
|
|
|
// A fetch replaces the very lists these two are showing, so they are
|
|
// disabled while it runs rather than left offering the old answer.
|
|
val enabled = !busy && !git.dirty && refs != null && !fetching
|
|
val loaded = refs
|
|
|
|
SettingRow("Branch") {
|
|
BranchPicker(
|
|
branches = loaded?.branches.orEmpty(),
|
|
// The branch line on the card is the same answer, and it is
|
|
// the one already on screen -- taking it from there rather
|
|
// than working one out keeps the two from disagreeing.
|
|
current = git.branch,
|
|
enabled = enabled,
|
|
onSelect = onCheckout,
|
|
)
|
|
}
|
|
SettingRow("Commit") {
|
|
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,
|
|
)
|
|
}
|
|
Row(
|
|
horizontalArrangement = Arrangement.End,
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
modifier = Modifier.fillMaxWidth(),
|
|
) {
|
|
TextButton(
|
|
onClick = {
|
|
fetching = true
|
|
failure = null
|
|
scope.launch {
|
|
try {
|
|
refs = withContext(Dispatchers.IO) { fetchRefs(entry.key) }
|
|
} catch (e: DownloadServerException) {
|
|
failure = e.message ?: "the fetch failed"
|
|
} finally {
|
|
fetching = false
|
|
}
|
|
}
|
|
},
|
|
// Not while the checkout is being worked on: a pull is
|
|
// writing the same refs, and git would refuse one of the two
|
|
// for a reason nobody pressed anything to hear. Uncommitted
|
|
// work is deliberately *not* a reason -- a fetch touches no
|
|
// file in the working tree, so this is the one control in
|
|
// here that still does its job on a dirty checkout.
|
|
enabled = !busy && !fetching,
|
|
colors = ActionTone.Primary.colors(),
|
|
) {
|
|
if (fetching) {
|
|
// The button's own content colour, which while it is
|
|
// running is the disabled one -- a full-strength mark
|
|
// beside a dimmed label reads as two things happening
|
|
// rather than as one button that is busy.
|
|
Working(color = LocalContentColor.current)
|
|
Spacer(Modifier.width(8.dp))
|
|
}
|
|
Text("Fetch")
|
|
}
|
|
}
|
|
// Both of these belong *below* the controls rather than above them.
|
|
// Above, each one appears and disappears in the middle of the sheet
|
|
// and shoves the two pickers down the screen as it does -- so the row
|
|
// somebody is reaching for moves while they reach, and the failure
|
|
// ends up nowhere near the button that produced it.
|
|
if (loaded == null && failure == null) {
|
|
SettingsNote("Reading this checkout's branches...")
|
|
}
|
|
// The server's own words, so selectable like every other machine
|
|
// output here -- the fix is on the other machine.
|
|
failure?.let { OutputText(it, style = MaterialTheme.typography.bodySmall) }
|
|
}
|
|
|
|
/**
|
|
* One setting: what it is on the left, the control for it on the right.
|
|
*
|
|
* The two get [Modifier.weight] rather than the control being left to take whatever width it likes,
|
|
* because a picker's label is a branch name and those run long: unweighted, the pill grows to fit
|
|
* `second-branch-from-elsewhere` and squeezes "Branch" down to a column three characters wide,
|
|
* which then wraps one letter per line. Both sides truncate instead -- the control at its own end,
|
|
* since a pill that has to be cut is still recognisably a control, and the name it is showing is
|
|
* the thing the reader can open it to see in full.
|
|
*/
|
|
@Composable
|
|
private fun SettingRow(label: String, control: @Composable () -> Unit) {
|
|
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
|
Text(
|
|
label,
|
|
style = MaterialTheme.typography.bodyLarge,
|
|
maxLines = 1,
|
|
overflow = TextOverflow.Ellipsis,
|
|
modifier = Modifier.weight(1f),
|
|
)
|
|
Spacer(Modifier.width(8.dp))
|
|
Box(Modifier.weight(2f), contentAlignment = Alignment.CenterEnd) { control() }
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
private fun BranchPicker(
|
|
branches: List<GitBranch>,
|
|
current: String,
|
|
enabled: Boolean,
|
|
onSelect: (String) -> Unit,
|
|
) {
|
|
var expanded by remember { mutableStateOf(false) }
|
|
Box {
|
|
// "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 ->
|
|
DropdownMenuItem(
|
|
text = {
|
|
Text(
|
|
buildString {
|
|
append(branch.name)
|
|
if (branch.current) append(" ✓")
|
|
// Worth saying: moving to one of these
|
|
// starts a local branch rather than
|
|
// returning to one.
|
|
if (branch.remoteOnly) append(" (on the remote)")
|
|
}
|
|
)
|
|
},
|
|
onClick = {
|
|
expanded = false
|
|
if (!branch.current) onSelect(branch.name)
|
|
},
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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>,
|
|
head: String?,
|
|
detached: Boolean,
|
|
enabled: Boolean,
|
|
onSelect: (String) -> Unit,
|
|
) {
|
|
var expanded by remember { mutableStateOf(false) }
|
|
val here = commits.firstOrNull { it.current }
|
|
Box {
|
|
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 = {
|
|
// The hash identifies it and the subject is what
|
|
// a person recognises, so both -- and the subject
|
|
// is the half that truncates, since a cut hash
|
|
// names nothing.
|
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
Text(
|
|
commit.short,
|
|
style = MaterialTheme.typography.bodyMedium,
|
|
fontFamily = FontFamily.Monospace,
|
|
)
|
|
Spacer(Modifier.width(8.dp))
|
|
Text(
|
|
// 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,
|
|
)
|
|
}
|
|
},
|
|
onClick = {
|
|
expanded = false
|
|
// 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)
|
|
},
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Where one card sits in the scrolling list, in the column's own coordinates. */
|
|
private data class CardPlace(val top: Int, val height: Int)
|
|
|
|
/** What git reports as the branch when no branch is checked out. */
|
|
private const val DETACHED_HEAD = "HEAD"
|
|
|
|
/**
|
|
* How far along whatever is on its way to this phone is.
|
|
*
|
|
* Drawn where its button is, inside the APK's component card: a bar reports on the control that
|
|
* started it, and one placed away from that control belongs to nothing in particular.
|
|
*/
|
|
@Composable
|
|
private fun ApkProgress(state: ComponentState?) {
|
|
when (state) {
|
|
is ComponentState.Preparing -> BuildProgress("Building for phone", state.status)
|
|
|
|
is ComponentState.Fetching -> {
|
|
ProgressBar()
|
|
Spacer(Modifier.height(4.dp))
|
|
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) {
|
|
ProgressBar()
|
|
} else {
|
|
ProgressBar(fraction = { progress })
|
|
}
|
|
Spacer(Modifier.height(4.dp))
|
|
Text(
|
|
if (progress == null) {
|
|
"Downloading..."
|
|
} else {
|
|
"Downloading... ${(progress * 100).toInt()}%"
|
|
}
|
|
)
|
|
}
|
|
|
|
// Downloaded, and waiting for the installer to be free. No bar:
|
|
// nothing is happening to it, and a bar would say otherwise for as
|
|
// long as the other install takes.
|
|
is ComponentState.ReadyToInstall -> Text("Downloaded, waiting to install...")
|
|
|
|
// A pull is the project's, not the APK's, and is drawn at the foot
|
|
// of the card. Everything else here has nothing to show.
|
|
else -> {}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Install / Update / Reinstall / Retry: the one control here that puts something on this phone.
|
|
*
|
|
* Its own composable rather than inline in the component card, because what it should say is four
|
|
* cases of its own -- and deciding the label and the colour together, in one place, is what keeps
|
|
* the two from disagreeing.
|
|
*/
|
|
@Composable
|
|
private fun UpdateButton(
|
|
built: Boolean,
|
|
needsBuild: Boolean,
|
|
/** Whether this phone has the app at all, which decides "Install". */
|
|
installed: Boolean,
|
|
upToDate: Boolean,
|
|
/**
|
|
* This component's build against the build machine's checkout, which is what tells an update
|
|
* from a downgrade. The freshness word itself rather than a flag, so there is one place that
|
|
* knows which values mean which.
|
|
*/
|
|
freshness: String,
|
|
/** What this component is doing, which is what decides whether the button can be pressed. */
|
|
state: ComponentState?,
|
|
/**
|
|
* What the whole project is doing, which also decides it — but only for the two things that act
|
|
* on the whole checkout. A sibling component being built is deliberately not in here.
|
|
*/
|
|
projectState: ProjectState?,
|
|
onUpdate: () -> Unit,
|
|
) {
|
|
// A project with nothing built still gets a button when its (accepted)
|
|
// build step is what would produce the first APK -- otherwise adding it
|
|
// before that first build, which the server now allows, would leave no
|
|
// way to do it.
|
|
if (!built && !needsBuild) return
|
|
|
|
// Retry redoes what failed, and what failed here was this component's
|
|
// own update -- a pull's failure is the project's and is retried from
|
|
// the project's own row.
|
|
if (state is ComponentState.Error) {
|
|
TextButton(onClick = onUpdate, colors = ActionTone.Primary.colors()) { Text("Retry") }
|
|
return
|
|
}
|
|
|
|
// Label and colour decided together: they are two ways of saying the
|
|
// same thing, and picking them apart is how they come to disagree.
|
|
val (label, tone) =
|
|
when {
|
|
// Nothing built here yet, so pressing this builds it *and*
|
|
// installs it -- named for what you end up with, like the case
|
|
// below, because that is the half this button has that the
|
|
// Build beside it does not. It said "Build" until the
|
|
// right-hand button started saying that too for a component
|
|
// that is not current: one row, two buttons, one word, two
|
|
// meanings. Neither "Update" nor "Reinstall" is available to
|
|
// fall back on here, since there is no build to compare the
|
|
// installed copy against.
|
|
!built -> "Install" to ActionTone.Go
|
|
// Nothing here to replace, so this is a first arrival rather than a
|
|
// newer one -- the same thing Start and Install are elsewhere, and
|
|
// coloured to match them.
|
|
!installed -> "Install" to ActionTone.Go
|
|
// Before the "already have this" case below, and deliberately:
|
|
// what is on disk here is older than this phone's copy right up
|
|
// until the press rebuilds it at the checkout's commit, so
|
|
// comparing the two files answers "Reinstall" to a card that has
|
|
// a rollback waiting. Coloured like Update, because it is Update
|
|
// -- the direction is the word, and going back to last week's
|
|
// build is a thing somebody chose rather than one to warn about.
|
|
freshness == "builtAhead" -> updateWord(listOf(freshness)) to ActionTone.Primary
|
|
// Reinstalling replaces a build with the same build -- the same "are
|
|
// you sure that's what you meant" as a Restart, and coloured to
|
|
// match it.
|
|
upToDate -> "Reinstall" to ActionTone.Caution
|
|
else -> updateWord(listOf(freshness)) to ActionTone.Primary
|
|
}
|
|
// Disabled while something is running, rather than replaced by the bar
|
|
// that reports it. A control that disappears takes the reader's
|
|
// bearings with it, and what it says is still what pressing it would
|
|
// have done.
|
|
//
|
|
// What counts as "running" is this component's own work, plus the two
|
|
// project-wide actions that would rebuild it out from under this
|
|
// press. A *sibling* component being built is deliberately not in
|
|
// here: that was the coupling this whole split is for, and with it
|
|
// included, updating one client of a two-client project killed the
|
|
// other's button for the length of a build it shares nothing with.
|
|
TextButton(
|
|
onClick = onUpdate,
|
|
enabled = !state.busy && !projectState.busy,
|
|
colors = tone.colors(),
|
|
) {
|
|
Text(label)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Builds this one component again, whatever the staleness rules make of it.
|
|
*
|
|
* The project row's Rebuild at one component's scale, so it takes that button's word and its
|
|
* colour: the same consequence should look the same wherever it is, and what this leaves behind is
|
|
* no more obvious than what that one does. It sits at the right-hand end of the component's own
|
|
* action row for the same reason Rebuild sits at the right-hand end of the project's.
|
|
*
|
|
* Why it exists beside Update, which also builds: Update asks the server whether the output is
|
|
* behind and builds only then, which is right for "get me this build" and blind to everything a
|
|
* commit does not describe — a command that reads files nobody declared, an output changed
|
|
* underneath this server, a signing key replaced since the APK was made. In all of those nothing is
|
|
* stale, so Update does nothing, and before this the only force was the project-wide Rebuild.
|
|
*
|
|
* Absent rather than disabled for a component with no command, which is the one thing here that is
|
|
* not a state it is in: the project row hides its Rebuild on the same grounds, and a permanently
|
|
* dead button on every row of every hand-built project teaches nothing. Anything that *is* a state
|
|
* — busy, or the whole checkout busy — disables it instead, the same pair that disables Update.
|
|
*/
|
|
@Composable
|
|
private fun ComponentBuildButton(
|
|
component: ProjectComponent,
|
|
state: ComponentState?,
|
|
projectState: ProjectState?,
|
|
onBuild: () -> Unit,
|
|
) {
|
|
if (!component.hasBuild) return
|
|
TextButton(
|
|
onClick = onBuild,
|
|
enabled = !state.busy && !projectState.busy,
|
|
colors = ActionTone.Caution.colors(),
|
|
) {
|
|
Text(buildWord(listOf(component)))
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The dot between a component's name and whatever is said about it.
|
|
*
|
|
* Its own composable, and always the ordinary text colour, because it is the card's punctuation
|
|
* rather than part of what it separates -- a green dot in front of "running" makes the separator
|
|
* look like it is carrying some of the meaning.
|
|
*/
|
|
@Composable
|
|
private fun Separator() {
|
|
Spacer(Modifier.width(6.dp))
|
|
Text(
|
|
"\u00b7",
|
|
style = MaterialTheme.typography.bodyMedium,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
)
|
|
Spacer(Modifier.width(6.dp))
|
|
}
|
|
|
|
/**
|
|
* The app's own launcher icon, taken from the copy installed on this phone.
|
|
*
|
|
* Whatever the launcher would show, including the system default for an app that declares no icon
|
|
* of its own -- this is meant to be the same picture somebody already recognises from their home
|
|
* screen, so second- guessing the platform's answer would make it a different one.
|
|
*
|
|
* A question mark only where there is genuinely nothing: the app is not installed here, and there
|
|
* is nowhere else to look, since the APK it would come from is on the build machine and the
|
|
* manifest carries no image.
|
|
*/
|
|
@Composable
|
|
private fun AppIcon(
|
|
packageName: String?,
|
|
modifier: Modifier = Modifier,
|
|
size: Dp = APP_ICON_SIZE,
|
|
glyphSize: TextUnit = 24.sp,
|
|
) {
|
|
val context = LocalContext.current
|
|
val pixels = with(LocalDensity.current) { size.roundToPx() }
|
|
val icon =
|
|
remember(packageName, pixels) {
|
|
packageName
|
|
// Not installed is the ordinary case for a card, not a fault.
|
|
?.let { name ->
|
|
runCatching { context.packageManager.getApplicationIcon(name) }.getOrNull()
|
|
}
|
|
?.toBitmap(pixels, pixels)
|
|
?.asImageBitmap()
|
|
}
|
|
Box(modifier.size(size), contentAlignment = Alignment.Center) {
|
|
if (icon != null) {
|
|
Image(icon, contentDescription = null, modifier = Modifier.fillMaxSize())
|
|
} else {
|
|
Text(
|
|
"?",
|
|
fontSize = glyphSize,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
// See the server glyph: a character's line box is taller
|
|
// than the square it is being matched to, and text clips to
|
|
// what it is given.
|
|
modifier = Modifier.wrapContentSize(unbounded = true),
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A glyph you can press: the icon equivalent of a TextButton.
|
|
*
|
|
* Its own composable so that Add, Refresh and a card's two corner controls are the same size and
|
|
* colour without each of them having to say so, and so the label none of them shows is still there
|
|
* for a screen reader.
|
|
*/
|
|
@Composable
|
|
private fun IconGlyphButton(glyph: String, label: String, onClick: () -> Unit) {
|
|
IconButton(
|
|
onClick = onClick,
|
|
modifier = Modifier.size(GLYPH_BUTTON_SIZE).semantics { contentDescription = label },
|
|
) {
|
|
Text(
|
|
glyph,
|
|
fontFamily = NerdIcons,
|
|
fontSize = 20.sp,
|
|
color = MaterialTheme.colorScheme.primary,
|
|
)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A build step this project asks for that nobody has accepted yet, shown in full for a person to
|
|
* read -- the way an AUR helper shows you a PKGBUILD before it builds anything.
|
|
*
|
|
* Shown rather than diffed against what was accepted before: these are a handful of lines, so the
|
|
* whole thing is quicker to take in than a diff would be, and reading it whole doesn't depend on
|
|
* having understood the previous version.
|
|
*
|
|
* Until Accept is pressed the server runs no build step for this app, so there is no hurry and
|
|
* nothing breaks by ignoring it -- Pull and Update keep working, they just don't run the project's
|
|
* command.
|
|
*
|
|
* The text only. Accept lives in the card's action row with Pull and Remove, so that the row is
|
|
* where every whole-project control is and Remove has one place it is always found.
|
|
*/
|
|
@Composable
|
|
private fun PendingDeclaration(requested: String) {
|
|
Spacer(Modifier.height(8.dp))
|
|
Text(
|
|
"This project asks to run a build step on the build machine:",
|
|
style = MaterialTheme.typography.bodyMedium,
|
|
)
|
|
Spacer(Modifier.height(4.dp))
|
|
Text(
|
|
requested,
|
|
style = MaterialTheme.typography.bodySmall,
|
|
fontFamily = FontFamily.Monospace,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
)
|
|
Spacer(Modifier.height(4.dp))
|
|
Text(
|
|
"Nothing runs until you accept it, and a change to what it asks " +
|
|
"for brings it back here.",
|
|
style = MaterialTheme.typography.bodySmall,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
)
|
|
}
|
|
|
|
/**
|
|
* One component of a project: what it is called, and for a server, what it is doing and what can be
|
|
* done to it.
|
|
*
|
|
* Drawn as its own card inside the project's, indented by the caller. Two components' worth of
|
|
* name, state and buttons run together as flat rows, and the buttons are the reason it matters --
|
|
* Restart under a list of lines is ambiguous about what it restarts, while Restart inside a
|
|
* bordered box is not.
|
|
*
|
|
* The controls follow the state rather than being greyed out: a service that isn't installed offers
|
|
* Install and nothing else, because Start on something with no unit file is a button whose only
|
|
* outcome is an error message. Uninstall confirms first -- it can stop something that is serving,
|
|
* and unlike the APK side there is no system dialog in the way.
|
|
*/
|
|
/**
|
|
* One component's slice of whatever build is running for its project, if any.
|
|
*
|
|
* The card states that carry a build all carry the same thing; asking here rather than at each of
|
|
* them keeps the three in step, and there is nothing to show for the states that are not builds --
|
|
* a download or an install belongs to the APK's own controls.
|
|
*/
|
|
private fun componentBuild(
|
|
projectState: ProjectState?,
|
|
state: ComponentState?,
|
|
component: String,
|
|
): ComponentBuild? =
|
|
// Its own first: a build this component was asked for directly is the
|
|
// one being watched, and a project-wide run is only what to fall back
|
|
// on. Both carry the same shape, and either way the slice taken is
|
|
// this component's, so a run covering several never draws one
|
|
// component's work in another's row.
|
|
(state as? ComponentState.Preparing)?.status?.component(component)
|
|
?: (projectState as? ProjectState.Working)?.status?.component(component)
|
|
|
|
/**
|
|
* Says that installing this build would pair it with something older.
|
|
*
|
|
* Components of a project are built at once and delivered as each finishes, so a build that stopped
|
|
* at one of them can leave this one rebuilt and its sibling not — a new app against the old server
|
|
* it talks to. Building in order used to make that impossible by never reaching the later component
|
|
* at all; building in parallel buys the time back and gives that up, so it is said instead.
|
|
*
|
|
* Only when *this* component is current and another is on a different commit -- behind the
|
|
* checkout, or ahead of it because somebody moved the checkout back. Both on the same wrong commit
|
|
* is the ordinary state of a project nobody has built yet, and those two still match each other —
|
|
* warning about it would fire on every card with work waiting, which is how a warning stops being
|
|
* read.
|
|
*
|
|
* Said rather than prevented, and the button is left alone: sometimes the mismatch is exactly what
|
|
* somebody wants to install, and hiding the control would not stop them so much as leave them
|
|
* wondering where it went.
|
|
*/
|
|
@Composable
|
|
private fun MismatchedPairNote(self: ProjectComponent, others: List<ProjectComponent>) {
|
|
if (self.freshness != "current") return
|
|
val differing = others.filter { it.name != self.name && it.commitDiffers }
|
|
if (differing.isEmpty()) return
|
|
// Each one said in `freshnessNote`'s own words rather than in words of
|
|
// this note's -- a second word for the same measurement reads as a
|
|
// second, weaker signal rather than the same one said twice, and the
|
|
// two are drawn from the same field. Non-null for every freshness in
|
|
// `commitDiffers`, which is the whole of what got here.
|
|
val said = differing.joinToString(", ") { "${it.name} is ${it.freshnessNote}" }
|
|
Text(
|
|
if (differing.size == 1) "$said, so the two would not match."
|
|
else "$said, so they would not match.",
|
|
style = MaterialTheme.typography.bodySmall,
|
|
color = ActionTone.Caution.color,
|
|
)
|
|
}
|
|
|
|
@Composable
|
|
private fun ComponentCard(
|
|
entryKey: String,
|
|
component: ProjectComponent,
|
|
/** The package an APK component installs, for its icon. */
|
|
packageName: String?,
|
|
sizeText: String?,
|
|
/** 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?,
|
|
working: Boolean,
|
|
busy: Boolean,
|
|
/**
|
|
* What this component has been asked to do, or why the last thing stopped.
|
|
*
|
|
* Here as well as inside [controls] because a failure has to be reported for both kinds, and
|
|
* only an APK has controls — a service action that failed would otherwise have nowhere to say
|
|
* so on the row it was pressed in.
|
|
*/
|
|
state: ComponentState?,
|
|
/**
|
|
* What the whole project is doing, which disables this component's Rebuild as well: a pull
|
|
* rewrites the files it would build from, and the server refuses a build while one is running.
|
|
*/
|
|
projectState: ProjectState?,
|
|
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.
|
|
controls: @Composable () -> Unit = {},
|
|
) {
|
|
// The action awaiting confirmation, or null. One slot rather than a
|
|
// flag per action: they are mutually exclusive, and a second flag is
|
|
// 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
|
|
// sends. Reset every time the dialog opens, so a choice made once and
|
|
// cancelled is not still ticked the next time.
|
|
var purge by remember { mutableStateOf(Purge(logs = true)) }
|
|
LaunchedEffect(confirming) { if (confirming != null) purge = Purge(logs = component.hasLogs) }
|
|
|
|
// Outlined rather than tinted. The tinted surfaces sit a step apart
|
|
// from the project card's own, which on a screen is close enough to
|
|
// read as one flat block -- tried, and the nesting was invisible. A
|
|
// border says "this is a thing" at any surface colour.
|
|
OutlinedCard(Modifier.fillMaxWidth()) {
|
|
Column(Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) {
|
|
// No height set on the row. Capping it at the icon's size cut
|
|
// the descenders off the text beside it -- a Text clips to the
|
|
// height it is given, and "running" has a g. The icon is the
|
|
// same size as a line of this text anyway, so the row comes out
|
|
// the same height without being told.
|
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
// 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,
|
|
) {
|
|
// 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,
|
|
// 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 freshnessNote = component.freshnessNote
|
|
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,
|
|
)
|
|
}
|
|
freshnessNote?.let {
|
|
Separator()
|
|
Text(
|
|
it,
|
|
style = MaterialTheme.typography.bodyMedium,
|
|
// Update's colour for the states with something
|
|
// to do, because that is the button this is
|
|
// telling you to press -- and the ordinary text
|
|
// colour for the ones that only say what could
|
|
// not be measured, which is not a call to act.
|
|
color =
|
|
if (component.isStale) ActionTone.Primary.color
|
|
else MaterialTheme.colorScheme.onSurfaceVariant,
|
|
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) {
|
|
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,
|
|
// so selectable for the same reason.
|
|
component.error?.let { reason ->
|
|
OutputText(reason, style = MaterialTheme.typography.bodySmall)
|
|
}
|
|
|
|
// The caller's content and the service's own never appear
|
|
// together -- controls belong to an APK, service buttons to a
|
|
// server -- so each gets the card's full width instead of
|
|
// sharing a row. A progress bar in particular wants all of it.
|
|
controls()
|
|
// Nothing to offer until its script has been asked: the buttons
|
|
// depend on the answer, and guessing which to show would mean
|
|
// showing one that fails.
|
|
//
|
|
// Once asked they stay, and go dim for the second or two the
|
|
// script takes rather than leaving: a row that vanishes on
|
|
// every press makes its own presence the signal, and takes the
|
|
// card's height with it on the way out and back.
|
|
//
|
|
// Rebuild is in this row but outside that condition, because
|
|
// it is about the build rather than the service: a server
|
|
// whose script cannot be reached is still one this machine can
|
|
// compile, and hiding the way to do that until an unrelated
|
|
// question is answered would be the same absence-as-signal.
|
|
if (component.isServer && (component.state != null || component.hasBuild)) {
|
|
Row(
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
modifier = Modifier.fillMaxWidth(),
|
|
) {
|
|
if (component.state == null) {
|
|
// Nothing on the left yet, so the one button there
|
|
// is holds the right edge it will keep once the
|
|
// service answers.
|
|
} else if (!component.isInstalled) {
|
|
TextButton(
|
|
onClick = { onAction("install", Purge()) },
|
|
enabled = !busy,
|
|
colors = ActionTone.Go.colors(),
|
|
) {
|
|
Text("Install")
|
|
}
|
|
} else {
|
|
if (component.isRunning) {
|
|
// Restart is not confirmed even for this app's own
|
|
// server: it comes back, and comes back as the build
|
|
// that is on disk.
|
|
TextButton(
|
|
onClick = {
|
|
if (isOwnServer) confirming = "stop"
|
|
else onAction("stop", Purge())
|
|
},
|
|
enabled = !busy,
|
|
colors = ActionTone.Destructive.colors(),
|
|
) {
|
|
Text("Stop")
|
|
}
|
|
TextButton(
|
|
onClick = { onAction("restart", Purge()) },
|
|
enabled = !busy,
|
|
colors = ActionTone.Caution.colors(),
|
|
) {
|
|
Text("Restart")
|
|
}
|
|
} else {
|
|
TextButton(
|
|
onClick = { onAction("start", Purge()) },
|
|
enabled = !busy,
|
|
colors = ActionTone.Go.colors(),
|
|
) {
|
|
Text("Start")
|
|
}
|
|
}
|
|
TextButton(
|
|
onClick = { confirming = "uninstall" },
|
|
enabled = !busy,
|
|
colors = ActionTone.Destructive.colors(),
|
|
) {
|
|
Text("Uninstall")
|
|
}
|
|
}
|
|
Spacer(Modifier.weight(1f))
|
|
ComponentBuildButton(
|
|
component = component,
|
|
state = state,
|
|
projectState = projectState,
|
|
onBuild = onBuild,
|
|
)
|
|
}
|
|
}
|
|
|
|
// Under the buttons, and inside this component's own card. It
|
|
// reports on the press that started it, so it reads in the
|
|
// order it happened -- and putting it above meant the buttons
|
|
// moved down the moment a build began, so the row somebody had
|
|
// just pressed slid out from under their finger.
|
|
//
|
|
// A component that has finished shows nothing at all: the
|
|
// control above has gone back to being pressable, which is
|
|
// what says the work is over, and a line reporting on it
|
|
// afterwards is one more thing to read that nobody acts on.
|
|
build
|
|
?.takeIf { it.running }
|
|
?.let {
|
|
Spacer(Modifier.height(6.dp))
|
|
ComponentBuildProgress(it)
|
|
}
|
|
// The other half of "a failure is reported where it happened":
|
|
// whatever this component was last asked to do and could not,
|
|
// whether that was its build, its download, or a service
|
|
// action. In its own row rather than at the foot of the card,
|
|
// which could only ever have been the project's.
|
|
//
|
|
// Selectable, because this is the build's own output: the tail
|
|
// of what the compiler said, which is the thing somebody
|
|
// actually needs to copy somewhere.
|
|
(state as? ComponentState.Error)?.let {
|
|
OutputText(it.message, style = MaterialTheme.typography.bodySmall)
|
|
}
|
|
|
|
// Only while the old app is actually still there. The build
|
|
// machine remembers the name this component was renamed from;
|
|
// whether anything answers to it is this device's question,
|
|
// and asking it here is what saves the server needing to be
|
|
// told when it stops being true.
|
|
//
|
|
// In this component's card rather than the project's: with two
|
|
// clients, only one of them was renamed, and the offer has to
|
|
// sit with the one it is about.
|
|
val cardContext = LocalContext.current
|
|
val orphan = component.apk?.previousPackageName?.takeIf { isInstalled(cardContext, it) }
|
|
if (orphan != null) {
|
|
Spacer(Modifier.height(4.dp))
|
|
Text(
|
|
"Renamed from $orphan. Android treats that as a different " +
|
|
"app, so it is still installed and nothing will replace it.",
|
|
style = MaterialTheme.typography.bodySmall,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
)
|
|
TextButton(onClick = { cardContext.startActivity(uninstallIntent(orphan)) }) {
|
|
Text("Remove the old app")
|
|
}
|
|
}
|
|
|
|
// 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.
|
|
}
|
|
}
|
|
|
|
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 (state is ComponentState.WrongKey) {
|
|
WrongKeyDialog(
|
|
mismatch = state.mismatch,
|
|
isSelf = state.mismatch.packageName == LocalContext.current.packageName,
|
|
onDismiss = onCancelInstall,
|
|
)
|
|
}
|
|
|
|
if (showingLog) {
|
|
ComponentLogDialog(
|
|
entryKey = entryKey,
|
|
component = component,
|
|
onDismiss = { showingLog = false },
|
|
)
|
|
}
|
|
|
|
confirming?.let { action ->
|
|
val stopping = action == "stop"
|
|
AlertDialog(
|
|
onDismissRequest = { confirming = null },
|
|
title = {
|
|
Text(
|
|
if (stopping) {
|
|
"Stop ${component.name}?"
|
|
} else {
|
|
"Uninstall ${component.name}?"
|
|
}
|
|
)
|
|
},
|
|
text = {
|
|
Column {
|
|
Text(
|
|
buildString {
|
|
if (stopping) {
|
|
append("This stops the service on the build machine.")
|
|
} else {
|
|
append(
|
|
"This removes the service from the build machine and stops " +
|
|
"it if it is running. The built files stay where they are."
|
|
)
|
|
}
|
|
// Said plainly rather than by disabling the button:
|
|
// there are good reasons to do this from here, and
|
|
// the one thing that matters is knowing beforehand
|
|
// that the way back is the build machine.
|
|
if (isOwnServer) {
|
|
append(
|
|
"\n\nThis app talks to that server. Once it is down, nothing " +
|
|
"here can bring it back -- you will need to start it on " +
|
|
"the build machine yourself."
|
|
)
|
|
}
|
|
}
|
|
)
|
|
if (!stopping) {
|
|
Spacer(Modifier.height(16.dp))
|
|
PurgeToggles(
|
|
component = component,
|
|
purge = purge,
|
|
onChange = { purge = it },
|
|
)
|
|
}
|
|
}
|
|
},
|
|
confirmButton = {
|
|
TextButton(
|
|
// The same red the button that opened this dialog
|
|
// wears. What a control does is said in colour here,
|
|
// so the one that actually takes something away must
|
|
// not be the only place that says it in words --
|
|
// beside a plain Cancel, two identically coloured
|
|
// buttons make the destructive one the easier
|
|
// mis-tap.
|
|
colors = ActionTone.Destructive.colors(),
|
|
onClick = {
|
|
val asked = if (stopping) Purge() else purge
|
|
confirming = null
|
|
onAction(action, asked)
|
|
},
|
|
) {
|
|
Text(if (stopping) "Stop" else "Uninstall")
|
|
}
|
|
},
|
|
dismissButton = { TextButton(onClick = { confirming = null }) { Text("Cancel") } },
|
|
)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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())) {
|
|
// 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.isNotEmpty() -> {
|
|
SettingsNote(
|
|
"Chosen on the build machine, so it is what every phone here is " +
|
|
"offered -- not just this one. This phone installs whatever it " +
|
|
"builds."
|
|
)
|
|
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."
|
|
)
|
|
}
|
|
}
|
|
// 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 nothing to strip on the way
|
|
// to a phone.
|
|
apk?.let { apk ->
|
|
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.
|
|
//
|
|
// 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)
|
|
}
|
|
) {
|
|
Text("Save")
|
|
}
|
|
},
|
|
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
|
|
)
|
|
}
|
|
|
|
/**
|
|
* 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)
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*
|
|
* Separate toggles rather than one "and clean up" switch, because what losing each costs is
|
|
* different: logs are a record of what already happened, data is what the thing produced while it
|
|
* ran, and config is what somebody sat down and typed. So only logs start ticked.
|
|
*
|
|
* Each row names the **exact path** that will be removed. That is not detail for the curious: the
|
|
* build machine deletes the path wherever it points, with no check that it sits under the usual
|
|
* directories, so this is the only place it is ever seen before it goes.
|
|
*
|
|
* A row that cannot be used says which of four reasons it is, because they are different things to
|
|
* do next: the build machine is still reading the project's resources; it could not read them; the
|
|
* project does not say where this lives; or it says, and there is nothing there. An earlier version
|
|
* derived the directory from the checkout's name, which made "we guessed wrong" and "there is
|
|
* nothing here" the same sentence.
|
|
*/
|
|
@Composable
|
|
private fun PurgeToggles(component: ProjectComponent, purge: Purge, onChange: (Purge) -> Unit) {
|
|
// Why a path is missing, when the reason is about reading this
|
|
// project's resources rather than about the path itself. Shared by
|
|
// both rows, because one read answers for both.
|
|
val unreadable: String? =
|
|
when {
|
|
component.resourcesChecking -> "still finding out where this project keeps things"
|
|
component.resourcesError != null -> "couldn't read this project's resources"
|
|
else -> null
|
|
}
|
|
|
|
Column {
|
|
PurgeToggle(
|
|
label = "Remove logs",
|
|
// The logs are the build machine's own files, so they can go
|
|
// whatever the project says about itself. Removing the data
|
|
// takes them too -- a service that writes its log inside its
|
|
// own data directory loses it either way -- so the row says
|
|
// so rather than being left ticked in a way the build machine
|
|
// would quietly override.
|
|
detail =
|
|
when {
|
|
purge.data -> "included with the data"
|
|
component.hasLogs -> "build and runtime logs"
|
|
else -> "no logs on the build machine"
|
|
},
|
|
checked = purge.logs || purge.data,
|
|
enabled = component.hasLogs && !purge.data,
|
|
onCheckedChange = { onChange(purge.copy(logs = it)) },
|
|
)
|
|
PurgeToggle(
|
|
label = "Remove data",
|
|
detail =
|
|
component.dataPath ?: unreadable ?: "this project doesn't say where data lives",
|
|
note =
|
|
if (component.dataPath != null && !component.dataPresent) "nothing there" else null,
|
|
dimmed = component.dataPath == null || !component.dataPresent,
|
|
checked = purge.data,
|
|
enabled = component.dataPresent,
|
|
onCheckedChange = { onChange(purge.copy(data = it)) },
|
|
)
|
|
PurgeToggle(
|
|
label = "Remove config",
|
|
detail =
|
|
component.configPath ?: unreadable ?: "this project doesn't say where config lives",
|
|
note =
|
|
if (component.configPath != null && !component.configPresent) "nothing there"
|
|
else null,
|
|
dimmed = component.configPath == null || !component.configPresent,
|
|
checked = purge.config,
|
|
enabled = component.configPresent,
|
|
onCheckedChange = { onChange(purge.copy(config = it)) },
|
|
)
|
|
// Said once, under the rows it explains, rather than inside both
|
|
// of them: the complaint is one sentence about the project, not
|
|
// about either directory, and repeating it would make two rows
|
|
// look like two problems.
|
|
component.resourcesError?.let {
|
|
Text(
|
|
it,
|
|
style = MaterialTheme.typography.bodySmall,
|
|
color = MaterialTheme.colorScheme.error,
|
|
modifier = Modifier.padding(top = 8.dp),
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* One row of [PurgeToggles].
|
|
*
|
|
* The path wraps rather than truncating. A path is identified by its tail, so cutting the end
|
|
* removes exactly what somebody checking it is looking for -- and this is the one moment that
|
|
* checking matters.
|
|
*/
|
|
@Composable
|
|
private fun PurgeToggle(
|
|
label: String,
|
|
detail: String,
|
|
checked: Boolean,
|
|
enabled: Boolean,
|
|
onCheckedChange: (Boolean) -> Unit,
|
|
/** A second line under [detail], for a reason the detail does not itself carry. */
|
|
note: String? = null,
|
|
/** Recede the detail: it stands in for a path, or names one with nothing at it. */
|
|
dimmed: Boolean = false,
|
|
) {
|
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
Checkbox(checked = checked, enabled = enabled, onCheckedChange = onCheckedChange)
|
|
Column(Modifier.padding(start = 4.dp)) {
|
|
Text(
|
|
label,
|
|
style = MaterialTheme.typography.bodyMedium,
|
|
color =
|
|
if (enabled) MaterialTheme.colorScheme.onSurface
|
|
else MaterialTheme.colorScheme.onSurfaceVariant,
|
|
)
|
|
Text(
|
|
detail,
|
|
style = MaterialTheme.typography.bodySmall,
|
|
color =
|
|
// The path itself is the thing being confirmed, so it
|
|
// reads as ordinary text; a row with nothing to remove
|
|
// is the one that recedes.
|
|
if (dimmed) MaterialTheme.colorScheme.outline
|
|
else MaterialTheme.colorScheme.onSurfaceVariant,
|
|
)
|
|
// In words, not only in the dimming. Greyed-out says "you
|
|
// can't have this" and leaves why to be guessed, where the
|
|
// reasons a row is greyed are different things to do next.
|
|
note?.let {
|
|
Text(
|
|
it,
|
|
style = MaterialTheme.typography.bodySmall,
|
|
color = MaterialTheme.colorScheme.outline,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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>,
|
|
chosen: String?,
|
|
enabled: Boolean,
|
|
onSelect: (String) -> Unit,
|
|
) {
|
|
var expanded by remember { mutableStateOf(false) }
|
|
Box {
|
|
// 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(
|
|
text = { Text(if (candidate == chosen) "$candidate ✓" else candidate) },
|
|
onClick = {
|
|
expanded = false
|
|
onSelect(candidate)
|
|
},
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
private fun VariantPicker(
|
|
variants: List<ApkVariant>,
|
|
chosenPath: String?,
|
|
onSelect: (ApkVariant?) -> Unit,
|
|
) {
|
|
var expanded by remember { mutableStateOf(false) }
|
|
val selected = variants.firstOrNull { it.path == chosenPath }
|
|
|
|
Box {
|
|
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.
|
|
DropdownMenuItem(
|
|
text = { Text(if (selected == null) "newest ✓" else "newest") },
|
|
onClick = {
|
|
expanded = false
|
|
onSelect(null)
|
|
},
|
|
)
|
|
variants.forEach { variant ->
|
|
DropdownMenuItem(
|
|
text = {
|
|
Text(
|
|
if (variant.path == chosenPath) "${variant.variant} ✓"
|
|
else variant.variant
|
|
)
|
|
},
|
|
onClick = {
|
|
expanded = false
|
|
onSelect(variant)
|
|
},
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* What the *project* is doing right now: fetching, pulling.
|
|
*
|
|
* Only the steps that belong to the whole project. Work belonging to a component is drawn in that
|
|
* component's own row by [ComponentBuildProgress], because every component builds at once and a bar
|
|
* under the card could only say that something, somewhere, was happening.
|
|
*/
|
|
@Composable
|
|
private fun BuildProgress(label: String, status: BuildStatus?) {
|
|
// Nothing at all once the work belongs to components: they each draw
|
|
// their own, and a second bar under them saying "building" reports the
|
|
// same thing again in the one place that cannot say which component it
|
|
// means. Between pressing the button and the first component starting
|
|
// there is no phase either, and a bar for that moment would flash.
|
|
val phase = status?.phase ?: return
|
|
ProgressBar()
|
|
Spacer(Modifier.height(4.dp))
|
|
Text("$label: $phase")
|
|
}
|
|
|
|
/**
|
|
* One component's own progress, inside its row.
|
|
*
|
|
* The bar fills only when the command reports a count of its own. There is no estimate to fall back
|
|
* on, deliberately: a bar drawn from how long the last run took looks exactly like one drawn from
|
|
* real work, and being right most of the time is not something the person watching it can check. A
|
|
* command that says nothing gets a bar that says nothing.
|
|
*
|
|
* A component that has finished shows nothing here at all -- the caller draws this only while
|
|
* [ComponentBuild.running]. Everything below is about work in flight: the bar, the step it is in,
|
|
* the count and the last line it printed all describe something happening now, and every one of
|
|
* them outlives the work it describes if left up. A full bar and a frozen line of output sit there
|
|
* looking live beside a sibling that genuinely is. What says the build is over is the button above
|
|
* going back to being pressable, and the card's own freshness saying the new build landed.
|
|
*
|
|
* The failure is the exception, and it is drawn by the caller rather than here: it is an outcome
|
|
* rather than residue, and it is the same message a download or a service action would leave, so it
|
|
* belongs in the one place that reports this component's failures.
|
|
*/
|
|
@Composable
|
|
private fun ComponentBuildProgress(build: ComponentBuild) {
|
|
val counted = build.progress?.takeIf { it.total > 0 }
|
|
val step = build.step ?: return
|
|
if (counted == null) {
|
|
ProgressBar()
|
|
} else {
|
|
ProgressBar(fraction = { counted.done.toFloat() / counted.total })
|
|
}
|
|
Spacer(Modifier.height(4.dp))
|
|
Text(
|
|
buildString {
|
|
append(step)
|
|
if (counted != null) append(" ${counted.done}/${counted.total}")
|
|
},
|
|
style = MaterialTheme.typography.bodySmall,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
)
|
|
build.lastLine()?.let {
|
|
Text(
|
|
it,
|
|
style = MaterialTheme.typography.bodySmall,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
maxLines = 2,
|
|
overflow = TextOverflow.Ellipsis,
|
|
)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Whether the build machine is producing a new build for this card.
|
|
*
|
|
* The distinction the grouping needs. A pull or a prepare changes what the card will say about
|
|
* itself, so it belongs with the ones still waiting; a download or an install only delivers what
|
|
* the card already said was waiting, and a Reinstall deliberately changes nothing. Counting those
|
|
* would take a card out of "Up to date" for the length of a download and put it straight back --
|
|
* the same jump this is here to stop.
|
|
*/
|
|
private fun isBuilding(
|
|
projectState: ProjectState?,
|
|
componentStates: Map<String, ComponentState>?,
|
|
): Boolean =
|
|
projectState is ProjectState.Working ||
|
|
// Any one component being built is the card having something in
|
|
// flight, the same way any one component being behind is.
|
|
componentStates?.values.orEmpty().any { it is ComponentState.Preparing }
|
|
|
|
/**
|
|
* Whether anything about this card is waiting to be done -- which is both what puts it above the
|
|
* "Up to date" heading and what makes its Update button pressable.
|
|
*
|
|
* One definition for the two, because they are the same question: Update is the button that clears
|
|
* everything below, so a card it would change is a card that is not up to date, and a heading
|
|
* saying otherwise above a pressable Update is the list disagreeing with itself.
|
|
*
|
|
* Three ways to have work waiting, and the third was missing for a while. Commits on the remote; an
|
|
* APK on the build machine newer than the copy installed here; and a component whose build no
|
|
* longer matches the checkout -- which is what a branch switch produces, and what nothing here used
|
|
* to notice, so moving the checkout left the card filed under "Up to date" with only a small note
|
|
* in one component's row to say otherwise.
|
|
*/
|
|
private fun hasWorkWaiting(
|
|
entry: ManifestEntry,
|
|
installedTimes: Map<String, Long?>,
|
|
chosenVariants: Map<String, String>,
|
|
): Boolean =
|
|
!entry.built ||
|
|
entry.newCommits ||
|
|
entry.components.any { component ->
|
|
component.isStale ||
|
|
component.apk?.let {
|
|
!isUpToDate(it, installedTimes[component.name], chosenVariants[component.name])
|
|
} == true
|
|
}
|
|
|
|
private fun isUpToDate(
|
|
apk: ComponentApk,
|
|
installedLastUpdateTimeMillis: Long?,
|
|
chosenVariantPath: String?,
|
|
): Boolean =
|
|
installedLastUpdateTimeMillis != null &&
|
|
installedLastUpdateTimeMillis >= apk.mtimeMillisFor(chosenVariantPath)
|
|
|
|
internal fun formatSize(bytes: Long): String {
|
|
val mb = bytes / 1024.0 / 1024.0
|
|
return "%.1f MB".format(mb)
|
|
}
|
|
|
|
/**
|
|
* How the enrollment QR is scanned, in one place because two callers reach it -- straight from the
|
|
* button when the camera permission is already held, and from the permission result when it has
|
|
* just been granted.
|
|
*
|
|
* MIXED_SCAN is the load-bearing part: ZXing otherwise looks only for a dark code on a light
|
|
* ground, and the QR this server prints is block characters in the terminal's foreground colour
|
|
* (auth.rs, Dense1x2), so on a dark-themed terminal it comes out as a photographic negative the
|
|
* scanner silently never matches. Which way round it renders is the terminal's business, not
|
|
* something this app should depend on. The mixed decoder alternates normal and inverted frames,
|
|
* costing half the frame rate at each polarity and nothing else.
|
|
*/
|
|
private fun enrollmentScanOptions(): ScanOptions =
|
|
ScanOptions()
|
|
.setDesiredBarcodeFormats(ScanOptions.QR_CODE)
|
|
.setCaptureActivity(com.example.wgapplink.EnrollmentScanActivity::class.java)
|
|
// Follow the phone, not the library's landscape pin.
|
|
.setOrientationLocked(false)
|
|
.addExtra(Intents.Scan.SCAN_TYPE, Intents.Scan.MIXED_SCAN)
|