4379 lines
213 KiB
Kotlin
4379 lines
213 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 each APK whose built copy is newer than what is on this phone.
|
|
*
|
|
* 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.
|
|
val installed = installedTimes[fresh.key].orEmpty()
|
|
val variants = chosenVariants[fresh.key].orEmpty()
|
|
for (component in
|
|
fresh.components.filter {
|
|
needsInstall(it, installed[it.name], variants[it.name])
|
|
}) {
|
|
// 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)
|
|
forgetDevLogCursors(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.
|
|
// Unconditional like the gear beside it, and for the
|
|
// same reason: a component always has both kinds of log
|
|
// to ask about, and a button that comes and goes makes
|
|
// its own absence the answer. There is nothing here yet
|
|
// and nobody has looked are different things, and the
|
|
// dialog is where the difference gets said.
|
|
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 ||
|
|
needsInstall(
|
|
component,
|
|
installedTimes[component.name],
|
|
chosenVariants[component.name],
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Whether this component has an APK that would change what is installed on this phone.
|
|
*
|
|
* Shared by the card's Update button and the project-wide Update loop: once a pull has left an APK
|
|
* current, the button's reason for being enabled may belong to a sibling server, and that must not
|
|
* make this APK an install candidate anyway.
|
|
*/
|
|
private fun needsInstall(
|
|
component: ProjectComponent,
|
|
installedLastUpdateTimeMillis: Long?,
|
|
chosenVariantPath: String?,
|
|
): Boolean =
|
|
component.apk?.let { !isUpToDate(it, installedLastUpdateTimeMillis, chosenVariantPath) } == 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)
|