diff --git a/AGENTS.md b/AGENTS.md
index 6acb59b..e4b6e9b 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -845,6 +845,53 @@ mutable at runtime from the phone.
Reading the output means splitting on carriage returns as well as
newlines: a tool redrawing a counter in place puts several updates and
then real output inside one `\n`-delimited line.
+- **Work in flight holds a foreground service, and the notification is
+ what that costs and what it is for** (`WorkNotice.kt`). Everything this
+ app asks for is minutes of the build machine's time followed by a
+ download, and all of it runs in the list screen's own coroutines -- so
+ with the app in the background the process is an ordinary cached one and
+ Android is free to take it away in the middle of a build. A foreground
+ service is the only way to say otherwise; measured on the emulator, the
+ process sits at `fg-service-act` (oom adj 50) with the app at the home
+ screen, where it would otherwise be cached, and the builds it started
+ landed while it was there.
+ The work itself stays in the screen. The service runs nothing: it
+ collects a list of what is running and reposts one notification, and
+ stops as soon as the list is empty. That list is **derived** by
+ `workItems` from the same `projectStates`/`componentStates` the cards
+ are drawn from, rather than posted by each action that starts something,
+ so there is nothing to forget to post and nothing to forget to take
+ back, and the shade cannot come to claim something the card does not.
+ The words are shared rather than written twice: `componentWorkLine` and
+ `buildLine` are read by the card's own bars and by the notification, for
+ the reason the note about two words for one measurement gives.
+ Three things measured on API 36 rather than assumed. **The collapsed row
+ drops the content text the moment there is a progress bar**, so with one
+ thing running the title carries both the component and what is happening
+ to it (`Test Tablet / tablet: building 14/30`) and nothing is set
+ beside it, which expanded would be the same words twice. **Several at
+ once get an indeterminate bar** and a line each in the expanded view:
+ there is no honest single number for two builds, and averaging them
+ draws something that moves like a measurement without being one.
+ **An app may only start a foreground service while it is in front of
+ somebody**, which is ordinarily where this one is -- work begins with a
+ button, and the service outlives the press; everything after the start
+ is an update to a `StateFlow` the service is already collecting, so a
+ build that finishes while the phone is in a pocket still reports. What
+ is left is work *reappearing* after the list has been empty for a moment
+ with the app by then behind something else, and Android answers that
+ with an exception rather than a return value -- so the start is asked
+ for whenever there is work and nothing showing it, the refusal is
+ caught, and the next thing the run reports asks again. Stops go through
+ `stopSelf(lastStart)` rather than `stopSelf()`, so work starting in the
+ moment between deciding to stop and stopping is not taken away with it.
+ `POST_NOTIFICATIONS` is asked for at startup beside the local-network
+ one, and refusing it costs the watching rather than the work: the
+ service still runs, and there is simply nothing to draw. The type is
+ `dataSync`, whose 6-hour daily budget on Android 15+ arrives as
+ `Service.onTimeout` and is answered by stopping -- unreachable for a
+ build, but a build machine that says "building" and never stops is
+ polled for as long as it keeps saying it.
- **Discovery must stay side-effect free.** It runs on every manifest
request and every suggestion scan.
- **An app in the list is a *project*, and what it produces is its
diff --git a/README.md b/README.md
index 255ab8a..f12e183 100644
--- a/README.md
+++ b/README.md
@@ -123,6 +123,15 @@ Better still, let the project carry that itself — see [below](#letting-a-proje
Set `gitPull: false` on a checkout that should never be moved from a
phone. Paths accept `~`, and are shown that way.
+While anything is building, downloading or installing, the app keeps an
+ongoing notification saying what is running and how far along it is — one
+line per component, with each build's own count where the command reports
+one. It is there to be watched, and it is also what keeps the work alive:
+without it the app is an ordinary backgrounded process, and Android is
+free to reclaim it partway through a build. It goes away by itself when
+the last thing finishes. Refusing the notification permission costs the
+watching and not the work.
+
### Updating this server itself
The **Dev Updater** card pulls and builds like any other, and is
diff --git a/app/androidApp/build.gradle.kts b/app/androidApp/build.gradle.kts
index 17c24cb..248c466 100644
--- a/app/androidApp/build.gradle.kts
+++ b/app/androidApp/build.gradle.kts
@@ -119,6 +119,11 @@ dependencies {
implementation(libs.compose.material3)
implementation(libs.compose.ui)
implementation(libs.androidx.activity.compose)
+ // NotificationCompat and ServiceCompat, which are how the
+ // version-specific parts of a foreground service get written once
+ // rather than behind a check per call. Declared rather than inherited
+ // transitively, since this module calls it directly.
+ implementation(libs.androidx.core.ktx)
implementation(libs.zxing.embedded)
// The half of this app that is the same as ai-app's: pinned TLS, the
// enrollment store, and the scanner.
diff --git a/app/androidApp/src/main/AndroidManifest.xml b/app/androidApp/src/main/AndroidManifest.xml
index cd03d5c..99870f6 100644
--- a/app/androidApp/src/main/AndroidManifest.xml
+++ b/app/androidApp/src/main/AndroidManifest.xml
@@ -26,6 +26,24 @@
MainActivity.kt's runtime request. -->
+
+
+
+
+
+
+
diff --git a/app/androidApp/src/main/kotlin/com/example/devupdater/MainActivity.kt b/app/androidApp/src/main/kotlin/com/example/devupdater/MainActivity.kt
index 1993765..2537e21 100644
--- a/app/androidApp/src/main/kotlin/com/example/devupdater/MainActivity.kt
+++ b/app/androidApp/src/main/kotlin/com/example/devupdater/MainActivity.kt
@@ -26,9 +26,11 @@ class MainActivity : ComponentActivity() {
private var settingsVersion by mutableStateOf(0)
// Registered up front since permission launchers must be registered
- // before the activity reaches STARTED.
- private val requestLocalNetworkPermission =
- registerForActivityResult(ActivityResultContracts.RequestPermission()) {}
+ // before the activity reaches STARTED. One launcher for the lot of
+ // them, so the ones below are asked for as a list rather than as two
+ // launches racing each other for the same dialog.
+ private val requestPermissions =
+ registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) {}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -74,9 +76,22 @@ class MainActivity : ComponentActivity() {
// on is the one it was measured on. Nothing local can check it --
// the emulator this is developed against is API 36, where the
// permission is not enforced at all.
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.CINNAMON_BUN) {
- requestLocalNetworkPermission.launch(Manifest.permission.ACCESS_LOCAL_NETWORK)
- }
+ //
+ // POST_NOTIFICATIONS is asked for beside it rather than when work
+ // starts, since a dialog over the list the instant somebody
+ // presses Update is a question about what they have just asked
+ // for. Denied, the work and the service keeping it alive run
+ // exactly as before; what is lost is watching from the shade.
+ val wanted =
+ listOfNotNull(
+ Manifest.permission.ACCESS_LOCAL_NETWORK.takeIf {
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.CINNAMON_BUN
+ },
+ Manifest.permission.POST_NOTIFICATIONS.takeIf {
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
+ },
+ )
+ if (wanted.isNotEmpty()) requestPermissions.launch(wanted.toTypedArray())
// Before the first composition, so the screens can call the server
// straight away rather than racing a load.
diff --git a/app/androidApp/src/main/kotlin/com/example/devupdater/UpdaterScreen.kt b/app/androidApp/src/main/kotlin/com/example/devupdater/UpdaterScreen.kt
index 2241b07..25197b1 100644
--- a/app/androidApp/src/main/kotlin/com/example/devupdater/UpdaterScreen.kt
+++ b/app/androidApp/src/main/kotlin/com/example/devupdater/UpdaterScreen.kt
@@ -1578,6 +1578,17 @@ private fun AppListScreen(
onDispose { context.unregisterReceiver(receiver) }
}
+ // What the shade says while any of this is running, and the
+ // foreground service under it that keeps the process alive to finish
+ // -- a build is minutes of somebody else's machine, and a cached
+ // process is one Android may take away in the middle of it.
+ val running = workItems(loadedEntries, projectStates, componentStates)
+ LaunchedEffect(running) { WorkNotice.post(context, running) }
+ // Leaving the composition cancels the work with it -- all of it runs
+ // in this screen's own scope -- so the notification goes too, rather
+ // than reporting on something that has already stopped.
+ DisposableEffect(Unit) { onDispose { WorkNotice.post(context, emptyList()) } }
+
// 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
@@ -2824,36 +2835,9 @@ private const val DETACHED_HEAD = "HEAD"
@Composable
private fun ApkProgress(state: ComponentState?) {
when (state) {
- is ComponentState.Preparing -> BuildProgress("Building for phone", state.status)
-
- is ComponentState.Fetching -> {
- ProgressBar()
- Spacer(Modifier.height(4.dp))
- Text("Preparing the download...")
- }
-
- is ComponentState.Busy -> {
- ProgressBar()
- Spacer(Modifier.height(4.dp))
- Text("${state.what}...")
- }
-
- is ComponentState.Downloading -> {
- val progress = state.progress
- if (progress == null) {
- ProgressBar()
- } else {
- ProgressBar(fraction = { progress })
- }
- Spacer(Modifier.height(4.dp))
- Text(
- if (progress == null) {
- "Downloading..."
- } else {
- "Downloading... ${(progress * 100).toInt()}%"
- }
- )
- }
+ // The project's phase rather than this component's step, which is
+ // drawn in the component's own row by ComponentBuildProgress.
+ is ComponentState.Preparing -> BuildProgress(BUILDING_LABEL, state.status)
// Downloaded, and waiting for the installer to be free. No bar:
// nothing is happening to it, and a bar would say otherwise for as
@@ -2861,11 +2845,78 @@ private fun ApkProgress(state: ComponentState?) {
is ComponentState.ReadyToInstall -> Text("Downloaded, waiting to install...")
// A pull is the project's, not the APK's, and is drawn at the foot
- // of the card. Everything else here has nothing to show.
- else -> {}
+ // of the card. Everything else this phone could be doing is one
+ // line and a bar, in the words componentWorkLine decides -- the
+ // same ones the notification is using for it at the same moment.
+ else -> {
+ val line = componentWorkLine(state, null) ?: return
+ val fraction = line.fraction
+ if (fraction == null) {
+ ProgressBar()
+ } else {
+ ProgressBar(fraction = { fraction })
+ }
+ Spacer(Modifier.height(4.dp))
+ Text(line.text)
+ }
}
}
+private const val BUILDING_LABEL = "Building for phone"
+
+/** One line about work in flight, and how far through it is where something measured that. */
+private data class WorkLine(val text: String, val fraction: Float?)
+
+/**
+ * What is being done to one component right now, or null if nothing is.
+ *
+ * The one place those words are decided, read by the component's own row and by the ongoing
+ * notification, because a second vocabulary for a state the reader has already been told about
+ * reads as a second, weaker signal rather than as the same one said twice.
+ *
+ * [build] is the build machine's own account of it and wins where there is one: while a command is
+ * running, its step and its count are the most specific thing anybody has about it. The rest is
+ * what this phone is doing -- a transfer, or a short call to the server.
+ *
+ * Deliberately not here is the pair of states where nothing is running: a download waiting for the
+ * installer, and a failure. Both are things to read rather than things to wait for.
+ */
+private fun componentWorkLine(state: ComponentState?, build: ComponentBuild?): WorkLine? {
+ build?.progressText()?.let {
+ return WorkLine(it, build.fraction())
+ }
+ return when (state) {
+ // The fallback is the moment between the press and the server
+ // reporting a phase, which the card draws nothing for.
+ is ComponentState.Preparing ->
+ WorkLine(buildLine(BUILDING_LABEL, state.status) ?: "$BUILDING_LABEL...", null)
+ is ComponentState.Fetching -> WorkLine("Preparing the download...", null)
+ is ComponentState.Busy -> WorkLine("${state.what}...", null)
+ is ComponentState.Downloading -> {
+ val progress = state.progress
+ WorkLine(
+ if (progress == null) "Downloading..."
+ else "Downloading... ${(progress * 100).toInt()}%",
+ progress,
+ )
+ }
+ else -> null
+ }
+}
+
+/** This component's step and the count it reported, as its row and the notification both say it. */
+private fun ComponentBuild.progressText(): String? {
+ val step = step ?: return null
+ val counted = counted() ?: return step
+ return "$step ${counted.done}/${counted.total}"
+}
+
+/** How far through that step it is, where the command reported a count of its own. */
+private fun ComponentBuild.fraction(): Float? = counted()?.let { it.done.toFloat() / it.total }
+
+/** The count, when there is one worth dividing by. */
+private fun ComponentBuild.counted(): BuildProgressCount? = progress?.takeIf { it.total > 0 }
+
/**
* Install / Update / Reinstall / Retry: the one control here that puts something on this phone.
*
@@ -4228,12 +4279,22 @@ private fun BuildProgress(label: String, status: BuildStatus?) {
// 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
+ val line = buildLine(label, status) ?: return
ProgressBar()
Spacer(Modifier.height(4.dp))
- Text("$label: $phase")
+ Text(line)
}
+/**
+ * What a whole project is doing, in one line, for the card and for the notification alike.
+ *
+ * Null while the server has reported no phase, which is the moment between the press and the run
+ * starting. The card draws nothing for it; the notification, which has to say something for as long
+ * as it is up, says the action's own word instead.
+ */
+private fun buildLine(label: String, status: BuildStatus?): String? =
+ status?.phase?.let { "$label: $it" }
+
/**
* One component's own progress, inside its row.
*
@@ -4255,19 +4316,16 @@ private fun BuildProgress(label: String, status: BuildStatus?) {
*/
@Composable
private fun ComponentBuildProgress(build: ComponentBuild) {
- val counted = build.progress?.takeIf { it.total > 0 }
- val step = build.step ?: return
- if (counted == null) {
+ val text = build.progressText() ?: return
+ val fraction = build.fraction()
+ if (fraction == null) {
ProgressBar()
} else {
- ProgressBar(fraction = { counted.done.toFloat() / counted.total })
+ ProgressBar(fraction = { fraction })
}
Spacer(Modifier.height(4.dp))
Text(
- buildString {
- append(step)
- if (counted != null) append(" ${counted.done}/${counted.total}")
- },
+ text,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -4282,6 +4340,39 @@ private fun ComponentBuildProgress(build: ComponentBuild) {
}
}
+/**
+ * Everything running right now, a line each, for the ongoing notification.
+ *
+ * A second reading of the two state maps the cards are drawn from rather than a record of its own,
+ * which is what keeps the shade from ever claiming something the screen does not: there is nothing
+ * to forget to post, and nothing to forget to take back.
+ *
+ * Order is decided here, because the maps have none and lines that reshuffled themselves between
+ * updates would be unreadable: the manifest's own order, each project's line before its components.
+ */
+private fun workItems(
+ entries: List?,
+ projectStates: Map,
+ componentStates: Map>,
+): List =
+ entries.orEmpty().flatMap { entry ->
+ val projectState = projectStates[entry.key]
+ val project =
+ (projectState as? ProjectState.Working)?.let {
+ // No fraction: what a pull or a checkout reports is a
+ // phase, which is a word rather than a count of anything.
+ WorkItem(entry.label, null, buildLine(it.what, it.status) ?: "${it.what}...", null)
+ }
+ val components =
+ entry.components.mapNotNull { component ->
+ val state = componentStates[entry.key]?.get(component.name)
+ componentWorkLine(state, componentBuild(projectState, state, component.name))?.let {
+ WorkItem(entry.label, component.name, it.text, it.fraction)
+ }
+ }
+ listOfNotNull(project) + components
+ }
+
/**
* Whether the build machine is producing a new build for this card.
*
diff --git a/app/androidApp/src/main/kotlin/com/example/devupdater/WorkNotice.kt b/app/androidApp/src/main/kotlin/com/example/devupdater/WorkNotice.kt
new file mode 100644
index 0000000..b083aab
--- /dev/null
+++ b/app/androidApp/src/main/kotlin/com/example/devupdater/WorkNotice.kt
@@ -0,0 +1,311 @@
+package com.example.devupdater
+
+import android.Manifest
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.app.Service
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.content.pm.ServiceInfo
+import android.os.Build
+import android.os.IBinder
+import android.util.Log
+import androidx.annotation.RequiresApi
+import androidx.core.app.NotificationCompat
+import androidx.core.app.NotificationManagerCompat
+import androidx.core.app.ServiceCompat
+import androidx.core.content.ContextCompat
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+
+/**
+ * One thing being worked on right now, as the notification says it.
+ *
+ * [fraction] is only ever a count something actually reported -- a build's own done/total, or the
+ * bytes of a download against the length the server sent. There is no estimate to fall back on, for
+ * the same reason the card's own bar has none: a bar drawn from how long the last run took looks
+ * exactly like one drawn from real work, and the person watching it has no way to tell which they
+ * are looking at.
+ */
+data class WorkItem(
+ /** Whose work it is: the project's label, which is what the card is called. */
+ val project: String,
+ /** Which component of it, or null for work on the whole checkout. */
+ val part: String?,
+ /** What it is doing, in the words that component's own row is using for it. */
+ val line: String,
+ val fraction: Float?,
+)
+
+/**
+ * The ongoing notification for whatever this app has been asked to do, and the foreground service
+ * that keeps the process alive while it happens.
+ *
+ * Everything here is minutes of a build machine's time followed by a download, and all of it runs
+ * in the list screen's own coroutines -- so with the app in the background the process is an
+ * ordinary cached one, and the system is free to take it away halfway through. A foreground service
+ * is the only way on Android to say "there is work here"; its notification is both the price of
+ * that and the point of it, since the work is then visible from the shade while it runs and one tap
+ * comes back to the card that started it.
+ *
+ * The work itself deliberately stays where it is. Every card already reports its own progress and
+ * its own failures through one path, and moving the running of it into the service would be a
+ * second path to keep in step with that one -- the thing this project avoids everywhere else. What
+ * crosses over is a summary, derived from the same two state maps the cards are drawn from, so the
+ * notification cannot come to say something the card does not.
+ */
+object WorkNotice {
+ private val work = MutableStateFlow>(emptyList())
+ internal val items: StateFlow> = work.asStateFlow()
+
+ /**
+ * Whether the service is up, so that [post] asks for a start exactly when nothing is showing
+ * what is running. Written by the service's callbacks and read from the composition, both on
+ * the main thread.
+ */
+ internal var showing = false
+
+ /**
+ * Says what is running now. An empty list ends the notification, and the service with it.
+ *
+ * Everything after the first start is an update to [items], which the service is already
+ * collecting, so a build that finishes while the phone is in a pocket still reports.
+ */
+ fun post(context: Context, items: List) {
+ work.value = items
+ if (items.isEmpty() || showing) return
+ val app = context.applicationContext
+ try {
+ ContextCompat.startForegroundService(app, Intent(app, WorkNoticeService::class.java))
+ } catch (refused: IllegalStateException) {
+ // Android refuses a foreground service started from the
+ // background, and throws rather than answering. Ordinarily
+ // this is nowhere near it -- work begins with a button -- but
+ // work reappearing after the list has been empty for a moment
+ // can land there, and it must not be a crash. Nothing else is
+ // owed: the work runs regardless, and the next thing it
+ // reports asks again, so the notification appears as soon as
+ // somebody comes back to the app.
+ Log.i(TAG, "not in the foreground, so nothing will show this run: $refused")
+ }
+ }
+}
+
+private const val TAG = "WorkNotice"
+
+/** One notification, replaced in place as the work moves on rather than added to. */
+private const val NOTICE_ID = 1
+
+private const val CHANNEL_ID = "work"
+
+/**
+ * How many steps a determinate bar has.
+ *
+ * A notification's progress is an integer out of a maximum, so the maximum is what its resolution
+ * is: 100 would quantise a download to whole percent, which is visible as a bar that moves in
+ * steps.
+ */
+private const val PROGRESS_STEPS = 1000
+
+/**
+ * Keeps the process alive for as long as [WorkNotice] says something is running, and draws what
+ * that is.
+ *
+ * It runs nothing itself: it collects the list and reposts the notification, and stops as soon as
+ * the list is empty. Named for the notice rather than for the work because "service" already means
+ * something else throughout this project -- the unit a `Server` component is driven through on the
+ * build machine -- and two meanings for one word costs more than the longer name does.
+ */
+class WorkNoticeService : Service() {
+ private val scope = CoroutineScope(Dispatchers.Main.immediate + SupervisorJob())
+ private var watching: Job? = null
+
+ /**
+ * The most recent start, so that a stop cannot take away work that arrived after it was decided
+ * on. Written and read on the main thread only, which is where both service callbacks and the
+ * collector below run.
+ */
+ private var lastStart = 0
+
+ override fun onBind(intent: Intent?): IBinder? = null
+
+ override fun onCreate() {
+ super.onCreate()
+ createChannel()
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ lastStart = startId
+ // Has to happen within seconds of the start whatever the list says
+ // by now, or the system kills the process for not having done it.
+ ServiceCompat.startForeground(
+ this,
+ NOTICE_ID,
+ notice(WorkNotice.items.value),
+ ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
+ )
+ WorkNotice.showing = true
+ if (watching == null) {
+ watching = scope.launch {
+ WorkNotice.items.collect { items ->
+ // stopSelf(lastStart) rather than stopSelf(): work
+ // starting again in the moment between deciding to
+ // stop and stopping hands out a newer start id, and
+ // this call is then refused rather than leaving a
+ // started service that has already died.
+ if (items.isEmpty()) stopSelf(lastStart) else show(items)
+ }
+ }
+ }
+ return START_NOT_STICKY
+ }
+
+ /**
+ * The 6-hour daily budget Android 15 puts on a `dataSync` service, reported before it is
+ * enforced.
+ *
+ * Nothing here should come anywhere near it -- a build and a download are minutes -- but a
+ * build machine that says "building" and never stops is polled for as long as it keeps saying
+ * it, and that is a wedge nobody would notice from the phone. Stopping is the whole of what is
+ * owed here; ignoring it is an app the system kills instead.
+ */
+ @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
+ override fun onTimeout(startId: Int, fgsType: Int) {
+ stopSelf(startId)
+ }
+
+ override fun onDestroy() {
+ WorkNotice.showing = false
+ scope.cancel()
+ ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
+ super.onDestroy()
+ }
+
+ private fun show(items: List) {
+ // Notifications refused is not a reason to stop: the service is
+ // what keeps the build alive, and that runs either way -- there is
+ // simply nothing to draw. Asked rather than left to notify(), which
+ // does nothing at all without it and says so only in the log.
+ //
+ // The version is half the question: below API 33 the permission
+ // does not exist, and asking about one the platform has never heard
+ // of answers "denied" -- which would take the notification away on
+ // every older phone to guard against something that cannot happen
+ // there.
+ if (
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
+ ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) !=
+ PackageManager.PERMISSION_GRANTED
+ ) {
+ return
+ }
+ NotificationManagerCompat.from(this).notify(NOTICE_ID, notice(items))
+ }
+
+ /**
+ * What the shade shows for [items].
+ *
+ * One of them is a title saying which component and what is happening to it, and a bar filled
+ * from that component's own count. Several are a title naming the projects, a line each when
+ * the notification is opened, and an indeterminate bar.
+ */
+ private fun notice(items: List): Notification {
+ val builder =
+ NotificationCompat.Builder(this, CHANNEL_ID)
+ .setSmallIcon(R.drawable.ic_updating)
+ .setContentIntent(openApp())
+ .setOngoing(true)
+ // The work moves every second or so; alerting on each of
+ // those would make a build a stream of interruptions.
+ .setOnlyAlertOnce(true)
+ .setCategory(NotificationCompat.CATEGORY_PROGRESS)
+ .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
+ // Only between the last thing finishing and this service stopping,
+ // which is milliseconds -- but the notification has to say something
+ // for that moment, and it must not be a claim that work is running.
+ if (items.isEmpty()) return builder.setContentTitle(getString(R.string.work_done)).build()
+
+ val single = items.singleOrNull()
+ if (single == null) {
+ builder
+ .setContentTitle(items.map { it.project }.distinct().joinToString(", "))
+ .setContentText(items.joinToString(" \u00b7 ") { it.line })
+ // Expanded, each one says which part of which project it is
+ // about, counts included -- which is the question somebody
+ // opens this for while two things are building at once.
+ val style = NotificationCompat.InboxStyle()
+ for (item in items) {
+ style.addLine(item.part?.let { "$it: ${item.line}" } ?: item.line)
+ }
+ builder.setStyle(style)
+ } else {
+ // One line, and it is the title: the collapsed row drops the
+ // text under it the moment there is a bar to draw (measured on
+ // API 36), so what is happening has to be said in the line
+ // that survives. Nothing beside it, since expanded that would
+ // be the same words twice.
+ builder.setContentTitle("${heading(single)}: ${single.line}")
+ }
+ // A count only where one thing reported one. Several at once get an
+ // indeterminate bar: there is no honest single number for two
+ // builds, and averaging them or picking one draws something that
+ // moves like a measurement without being one.
+ val fraction = single?.fraction
+ if (fraction == null) {
+ builder.setProgress(0, 0, true)
+ } else {
+ builder.setProgress(PROGRESS_STEPS, (fraction * PROGRESS_STEPS).toInt(), false)
+ }
+ return builder.build()
+ }
+
+ /** Which card, and which row of it, the work is in: the project's label and the component's. */
+ private fun heading(item: WorkItem): String =
+ item.part?.let { "${item.project} / $it" } ?: item.project
+
+ /**
+ * Low importance, so a build does not arrive as a sound and a heads-up every time somebody
+ * presses Update. It is an ongoing report of something they just asked for, not news.
+ */
+ private fun createChannel() {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
+ getSystemService(NotificationManager::class.java)
+ .createNotificationChannel(
+ NotificationChannel(
+ CHANNEL_ID,
+ getString(R.string.work_channel_name),
+ NotificationManager.IMPORTANCE_LOW,
+ )
+ .apply { setShowBadge(false) }
+ )
+ }
+
+ /**
+ * Back to the list, and to the card that is doing the work.
+ *
+ * The launcher's own intent rather than a bare component one, so it resumes the task that is
+ * already there instead of putting a second copy of the screen on top of it.
+ */
+ private fun openApp(): PendingIntent =
+ PendingIntent.getActivity(
+ this,
+ 0,
+ Intent(this, MainActivity::class.java)
+ .setAction(Intent.ACTION_MAIN)
+ .addCategory(Intent.CATEGORY_LAUNCHER)
+ .setFlags(
+ Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED
+ ),
+ PendingIntent.FLAG_IMMUTABLE,
+ )
+}
diff --git a/app/androidApp/src/main/res/drawable/ic_updating.xml b/app/androidApp/src/main/res/drawable/ic_updating.xml
new file mode 100644
index 0000000..e9fa289
--- /dev/null
+++ b/app/androidApp/src/main/res/drawable/ic_updating.xml
@@ -0,0 +1,23 @@
+
+
+
+
+
+
diff --git a/app/androidApp/src/main/res/values/strings.xml b/app/androidApp/src/main/res/values/strings.xml
index 2b6661a..049de75 100644
--- a/app/androidApp/src/main/res/values/strings.xml
+++ b/app/androidApp/src/main/res/values/strings.xml
@@ -8,4 +8,11 @@
Lets this app read the recent log lines that
another locally-built app is keeping about itself, so they can be shown and sent to the
build machine.
+
+ Work in progress
+
+ Finished