package com.example.devupdater 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.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.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.MaterialTheme 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.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.layout.onSizeChanged 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 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 private const val RESTART_WAIT_MS = 1000L 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 { /** * [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. */ data class Pulling(val status: BuildStatus?) : ProjectState() /** * A build somebody asked for outright, as opposed to one a pull or a download brought about. * Its own state rather than reusing [Pulling], which is drawn as "Pulling and building" -- * nothing was pulled here, and a bar that says otherwise is the kind of small lie that makes a * reader stop trusting the rest. */ data class Rebuilding(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 Rebuild, 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() /** 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.Pulling || this is ProjectState.Rebuilding 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(null) } val selfUpdateContext = LocalContext.current // On arrival, and again whenever this app's own project has just been // built: the second is the moment the newer copy comes into existence, // and the moment the server it has to keep talking to has just // changed. var selfUpdateTick by remember { mutableStateOf(0) } LaunchedEffect(selfUpdateTick) { selfUpdate = selfUpdateAvailable(selfUpdateContext) } // Keys of apps added on the Add screen, waiting to be taken into the // list one at a time. var added by remember { mutableStateOf>(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 }, onOwnProjectBuilt = { selfUpdateTick++ }, ) 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(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, onAddedApplied: () -> Unit, onAdd: () -> Unit, onOwnProjectBuilt: () -> Unit, ) { val context = LocalContext.current val scope = rememberCoroutineScope() var manifestState by remember { mutableStateOf(ManifestState.Loading) } // 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>(emptyMap()) } var componentStates by remember { mutableStateOf>>(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>>(emptyMap()) } var installedSizes by remember { mutableStateOf>>(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>>(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(null) } // 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>(emptyMap()) } /** * 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) } fun refreshByPull() { if (pulling) return pulling = true projectStates = emptyMap() componentStates = emptyMap() scope.launch { manifestState = try { load() } catch (e: DownloadServerException) { ManifestState.Error(e.message ?: "Unknown error") } pulling = false } } fun refresh() { manifestState = ManifestState.Loading projectStates = emptyMap() componentStates = emptyMap() scope.launch { manifestState = try { load() } catch (e: DownloadServerException) { ManifestState.Error(e.message ?: "Unknown error") } } } /** * 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 } ) ) } /** * 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) { projectStates = projectStates + (entry.key to ProjectState.Error(e.message ?: "Failed")) } } } fun install(file: File) { if (!canRequestInstall(context)) { context.startActivity(requestInstallPermissionIntent(context)) return } context.startActivity(installApkIntent(context, file)) } /** 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, ) { projectStates = projectStates + (entry.key to progress(null)) try { var status = withContext(Dispatchers.IO) { start() } while (status.building) { projectStates = projectStates + (entry.key to 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 failure = status.error if (failure != null) { projectStates = projectStates + (entry.key to ProjectState.Error(failure)) // 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 } // 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)) } } // 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. Clearing it at the end of the build instead left // the card reading as idle against a list still describing the // state before it -- the same wrong group, arrived at from the // other side. repeat(REFRESH_ATTEMPTS_AFTER_PULL) { attempt -> try { applyOne(entry.key) // 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. projectStates = projectStates - entry.key // Building this app's own project is what produces the // newer copy of it, and restarts the server it has to // keep talking to. So this is the moment to offer it, // rather than at some later launch. if (entry.builtIn) { onOwnProjectBuilt() } return } catch (e: DownloadServerException) { if (attempt == REFRESH_ATTEMPTS_AFTER_PULL - 1) { projectStates = projectStates - entry.key manifestState = ManifestState.Error(e.message ?: "Unknown error") } else { delay(RESTART_WAIT_MS) } } } } catch (e: DownloadServerException) { projectStates = projectStates + (entry.key to ProjectState.Error(e.message ?: "Failed")) } } /** * Pull acts on the build machine: fetch, fast-forward, rebuild. * * [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 = { pullAndBuild(entry.key, force) }, progress = { ProjectState.Pulling(it) }, ) } } /** * Build because somebody asked, with nothing to pull and nothing looking stale -- which is the * only way a project already current with its checkout ever records what it was built from. */ fun startRebuild(entry: ManifestEntry) { scope.launch { followBuild( entry, start = { buildNow(entry.key) }, progress = { ProjectState.Rebuilding(it) }, ) } } /** * 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 { 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@launch } } catch (e: DownloadServerException) { setComponent( entry.key, component, ComponentState.Error(e.message ?: "Couldn't prepare phone build"), ) return@launch } } // 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, ComponentState.Error(e.message ?: "Download failed"), ) return@launch } setComponent(entry.key, component, null) install(file) } } /** * 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. projectStates = projectStates - entry.key if (removes) dropEntry(entry.key) else applyOne(entry.key) } catch (e: DownloadServerException) { projectStates = projectStates + (entry.key to ProjectState.Error(e.message ?: "Failed")) } } } /** * 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, ComponentState.Error(e.message ?: "Failed")) } finally { serviceBusy = serviceBusy - entry.key } } } fun updateInstalledState(entries: List) { // 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 byComponent(read: (String) -> T?): Map> = 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() } } LaunchedEffect(Unit) { refresh() } // 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. LifecycleResumeEffect(Unit) { if (manifestState is ManifestState.Loaded) { refresh() } onPauseOrDispose {} } // 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) } 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(rememberScrollState())) { 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], ) || !entry.built || entry.newCommits || // Any client of the project being // behind is the project being // behind: a card with one of two // apps waiting has something // waiting. entry.components.any { component -> val apk = component.apk ?: return@any false !isUpToDate( apk, installedTimes[entry.key]?.get(component.name), chosenVariants[entry.key]?.get(component.name), ) } } // 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 -> 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) }, onPull = { startPull(entry) }, onRebuild = { startRebuild(entry) }, onRefresh = { refreshOne(entry) }, onSettings = { gitIpv4 -> manage(entry) { setAppSettings(entry.key, gitIpv4) } }, onApprove = { manage(entry) { approveDeclaration(entry.key) } }, onRemove = { forgetVariants(context, entry.key) manage(entry, removes = true) { removeApp(entry.key) } }, // Written here rather than sent to the server: // it is this device's preference. Bumping the // reload token is what redraws the card with // the new choice and the mtime that goes with // it. onSelectVariant = { component, variant -> chooseVariant( context, entry.key, component, variant?.path, ) val forProject = chosenVariants[entry.key] ?: emptyMap() chosenVariants = chosenVariants + (entry.key to when (variant) { null -> forProject - component else -> forProject + (component to variant.path) }) }, 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, installedSizes: Map, chosenVariants: Map, /** 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, onUpdate: (ManifestEntry, component: String) -> Unit, onPull: () -> Unit, onRebuild: () -> Unit, onRefresh: () -> Unit, onSettings: (gitIpv4: Boolean) -> Unit, onApprove: () -> Unit, onRemove: () -> Unit, onSelectVariant: (component: String, ApkVariant?) -> 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 { append(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. entry.components .sortedBy { it.isServer } .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) }, // 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, onAction = { action, purge -> onServiceAction(component.name, action, purge) }, controls = { if (!component.isServer) { // Said before the button, because it // qualifies what pressing it gets you. MismatchedPairNote( self = component, others = entry.components, ) // The button that asked for it, then // how far along it is: a bar reports on // the control above it. UpdateButton( built = component.apk?.built == true, needsBuild = entry.needsBuild, installed = installed != null, upToDate = upToDate, state = componentState, projectState = projectState, onUpdate = { onUpdate(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. 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 & Build") } } if (!awaitingApproval && entry.needsBuild) { // Held apart from both neighbours by its own weights, // because it belongs to neither: it is not the pull, // and it is emphatically not the remove. Spacer(Modifier.weight(1f)) TextButton( onClick = onRebuild, enabled = !projectBusy, // The colour Restart and Reinstall wear: it // certainly does something, and what it leaves // behind is not obvious from here. colors = ActionTone.Caution.colors(), ) { Text("Rebuild") } } 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.Pulling) { BuildProgress("Pulling and building", projectState.status) } if (projectState is ProjectState.Rebuilding) { BuildProgress("Building", 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 { projectState is ProjectState.Error -> Text(projectState.message, color = MaterialTheme.colorScheme.error) !entry.built && !awaitingApproval -> Text( if (entry.needsBuild) { "Not built yet -- Build runs this project's build step." } 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 -> Text( reason, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error, ) } } } } if (settingsOpen) { ProjectSettingsDialog( 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, 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 { 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 }) } } }, confirmButton = { TextButton(onClick = { onApply(gitIpv4) }) { Text("Save") } }, dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, ) } /** * 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.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()}%" } ) } // 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 -> {} } } /** * Build / 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, /** 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 to compare against yet, so neither "Update" nor // "Reinstall" is the honest word for it. !built -> "Build" to ActionTone.Primary // 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 // 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 -> "Update" 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) } } /** * 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) ?: when (projectState) { is ProjectState.Pulling -> projectState.status is ProjectState.Rebuilding -> projectState.status else -> null }?.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 behind. Both behind 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) { if (self.freshness != "current") return val behind = others.filter { it.name != self.name && it.freshness == "behind" } if (behind.isEmpty()) return val names = behind.joinToString(", ") { it.name } Text( if (behind.size == 1) "$names is older than this build, so the two would not match." else "$names are older than this build, 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 = {}, 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?, onAction: (String, Purge) -> 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(null) } var showingLog 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) { // Both kinds get the same square, so a server's glyph and // an app's icon are the same size as each other -- one // drawn smaller than the other reads as the row meaning // less, rather than as a different kind of thing. Box( Modifier.size(COMPONENT_ICON_SIZE), contentAlignment = Alignment.Center, ) { if (component.isServer) { Text( SERVER_GLYPH, fontFamily = NerdIcons, // Larger than the square in font terms: a glyph // is drawn well inside its line box, so matching // the numbers would draw it noticeably smaller // than the icon beside it. fontSize = COMPONENT_GLYPH_SIZE, color = MaterialTheme.colorScheme.onSurfaceVariant, // Which means its line box is taller than the // square, and text clips to the height it is // given. Measured unbounded and drawn centred // instead: the row keeps the icon's height and // the glyph keeps all of itself. modifier = Modifier.wrapContentSize(unbounded = true), ) } else { // The app's own icon, the same one the project card // shows: this row is about the thing that gets // installed, and that is what it looks like. AppIcon( packageName, size = COMPONENT_ICON_SIZE, glyphSize = COMPONENT_GLYPH_SIZE, ) } } Spacer(Modifier.width(6.dp)) Text( component.name, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) // Nothing at all until its script has been asked -- an // unknown state is not a state, and a dot introducing // nothing is worse than no dot. // Said in words, not by colour alone: "behind the // checkout" is a difference in kind from "running", and a // reader has no way to learn a colour that means it. val behind = component.isBehind val status = when { !component.isServer -> null component.error != null -> "couldn't check" component.state == null -> null component.isRunning -> "running" // Not folded into "stopped": stopped is a state // somebody chose, and calling a crash that sends // the reader looking for who chose it. component.isFailed -> "failed" component.isInstalled -> "stopped" // Nothing for a service that isn't installed: the row // offers Install and nothing else, which says it more // plainly than a state would, and saying both makes the // absence of a thing look like a condition it is in. else -> null } status?.let { Separator() Text( it, style = MaterialTheme.typography.bodyMedium, color = when { component.isRunning -> runningColor component.isFailed -> failedColor else -> MaterialTheme.colorScheme.onSurfaceVariant }, ) } if (behind) { Separator() Text( "out of date", style = MaterialTheme.typography.bodyMedium, // The colour Pull & Build wears, because that is // the button this is telling you to press. color = ActionTone.Primary.color, ) } sizeText?.let { Separator() Text( it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } if (working || busy || component.checking) { Spacer(Modifier.width(6.dp)) Working() } if (component.hasLogs) { // Pushed to the far edge rather than following the // text: it belongs to the component, not to whatever // the row happens to say about it, and a control that // slides about as the state changes is harder to find // than one always in the same corner. Spacer(Modifier.weight(1f)) IconGlyphButton(LOG_GLYPH, "Show ${component.name}'s log") { showingLog = true } } } component.error?.let { reason -> Text( reason, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error, ) } // 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. if (component.isServer && component.state != null) { Row(verticalAlignment = Alignment.CenterVertically) { 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") } } } } // 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. (state as? ComponentState.Error)?.let { Text( it.message, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error, ) } // 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") } } // Only worth a row when there is actually a choice, which is // rare -- the usual case is a single debug build, and an empty // row here was leaving a band of space at the foot of every // card for a control almost none of them have. // // Beside the build it picks, which is what makes it answerable // for a project with two clients: the choice is this // component's, and a picker at the foot of the card could only // have been the project's. val variants = component.apk?.variants.orEmpty() if (variants.size > 1) { Row( horizontalArrangement = Arrangement.End, modifier = Modifier.fillMaxWidth(), ) { VariantPicker(variants, chosenVariantPath, onSelectVariant) } } } } 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") } }, ) } } /** * 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, ) } } } } @Composable private fun VariantPicker( variants: List, chosenPath: String?, onSelect: (ApkVariant?) -> Unit, ) { var expanded by remember { mutableStateOf(false) } val selected = variants.firstOrNull { it.path == chosenPath } Box { TextButton(onClick = { expanded = true }) { Text("Variant: ${selected?.variant ?: "newest"}") } 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?, ): Boolean = projectState is ProjectState.Pulling || projectState is ProjectState.Rebuilding || // 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 } 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)