diff --git a/AGENTS.md b/AGENTS.md index e4b6e9b..3b9faab 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -865,14 +865,26 @@ mutable at runtime from the phone. 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. + **There is one notification per thing running, not one for all of + them**, grouped so they arrive together -- which is what the shade is + for, and what lets a component that finishes take its own row down while + its sibling carries on. Rows are keyed by a *tag* (`/`) + rather than by an id, so an update replaces the row it is about and two + components cannot collide; `drawn` is the path out, since nothing + removes a row by itself and one left behind would sit there reporting a + build that is over. + The foreground service needs one notification that outlives every row, + so **the service's own notification is the row itself while one thing is + running, and the group's summary once there are more.** Measured on API + 36: a group of one is collapsed to its header, which is the line of text + with the bar left out of it -- so grouping a single row costs it its + progress. Measured there too: **a collapsed row drops the content text + the moment there is a bar to draw**, which is why a row's title carries + both the component and what is happening to it (`Test Tablet — tablet: + building 14/40`) with nothing set beside it, that being the same words + twice once expanded. The summary draws no bar at all, because the only + number it could show is an average of the rows' and that is nobody's + measurement. **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 diff --git a/README.md b/README.md index f12e183..0975bce 100644 --- a/README.md +++ b/README.md @@ -125,8 +125,8 @@ 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: +per thing, grouped together, each with its own build's 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 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 25197b1..319caf9 100644 --- a/app/androidApp/src/main/kotlin/com/example/devupdater/UpdaterScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/devupdater/UpdaterScreen.kt @@ -4361,13 +4361,19 @@ private fun workItems( (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) + WorkItem( + entry.key, + 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) + WorkItem(entry.key, entry.label, component.name, it.text, it.fraction) } } listOfNotNull(project) + components diff --git a/app/androidApp/src/main/kotlin/com/example/devupdater/WorkNotice.kt b/app/androidApp/src/main/kotlin/com/example/devupdater/WorkNotice.kt index b083aab..a0c7f9a 100644 --- a/app/androidApp/src/main/kotlin/com/example/devupdater/WorkNotice.kt +++ b/app/androidApp/src/main/kotlin/com/example/devupdater/WorkNotice.kt @@ -38,6 +38,8 @@ import kotlinx.coroutines.launch * are looking at. */ data class WorkItem( + /** Which card's work it is. The key rather than the label, since two projects may share one. */ + val key: String, /** 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. */ @@ -45,7 +47,17 @@ data class WorkItem( /** What it is doing, in the words that component's own row is using for it. */ val line: String, val fraction: Float?, -) +) { + /** + * What this row's notification is filed under, so an update replaces the row it is about and + * never adds a second one beside it. + * + * A tag rather than an id per item: the ids would have to come from hashing this same pair, and + * two components whose hashes met would quietly share one notification. + */ + internal val tag: String + get() = "$key/${part.orEmpty()}" +} /** * The ongoing notification for whatever this app has been asked to do, and the foreground service @@ -54,10 +66,14 @@ data class WorkItem( * 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 + * is the only way on Android to say "there is work here"; the 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. * + * There is one of those per thing running rather than one for all of them, grouped so they arrive + * together -- which is what the shade is for, and what lets a component that finishes take its own + * row down while its sibling carries on. + * * 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 @@ -103,8 +119,19 @@ object WorkNotice { 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 +/** + * The notification the service itself is held up by. It has to exist for as long as the service + * does, where a row comes and goes with the work it is about -- so it is the row itself while there + * is one thing running, and the summary the rows are grouped under once there are more. + */ +private const val FOREGROUND_ID = 1 + +/** + * One id for every row: a notification is keyed by the pair, and [WorkItem.tag] is the other half. + */ +private const val ROW_ID = 2 + +private const val GROUP_KEY = "work" private const val CHANNEL_ID = "work" @@ -121,10 +148,11 @@ 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. + * It runs nothing itself: it collects the list, draws a row for each thing in it under one summary, + * 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()) @@ -137,6 +165,15 @@ class WorkNoticeService : Service() { */ private var lastStart = 0 + /** + * The rows drawn as of the last update, so that one whose work is over can be taken down. + * + * The path out. Rows are keyed by what they are about rather than by position, so nothing + * removes them by itself: a component that finished while others carry on would otherwise sit + * in the shade reporting a build that is over, at whatever it last said. + */ + private var drawn = emptySet() + override fun onBind(intent: Intent?): IBinder? = null override fun onCreate() { @@ -150,8 +187,8 @@ class WorkNoticeService : Service() { // by now, or the system kills the process for not having done it. ServiceCompat.startForeground( this, - NOTICE_ID, - notice(WorkNotice.items.value), + FOREGROUND_ID, + foregroundNotice(WorkNotice.items.value), ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC, ) WorkNotice.showing = true @@ -187,6 +224,12 @@ class WorkNoticeService : Service() { override fun onDestroy() { WorkNotice.showing = false scope.cancel() + // Rows are ordinary notifications: stopping the service takes + // away the one it was held up by and leaves them where they are, + // describing work that has stopped with it. + val notifications = NotificationManagerCompat.from(this) + drawn.forEach { notifications.cancel(it, ROW_ID) } + drawn = emptySet() ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE) super.onDestroy() } @@ -209,69 +252,85 @@ class WorkNoticeService : Service() { ) { return } - NotificationManagerCompat.from(this).notify(NOTICE_ID, notice(items)) + val notifications = NotificationManagerCompat.from(this) + // The summary before the rows: posted the other way round, each + // row arrives as a group of one and is drawn on its own for the + // frame before the summary gathers them up. + notifications.notify(FOREGROUND_ID, foregroundNotice(items)) + // One thing running has no row of its own -- the notification + // above is that row. A group of one is drawn as its header in the + // shade, which is the line of text with the bar left out of it. + val rows = if (items.size > 1) items else emptyList() + rows.forEach { + notifications.notify(it.tag, ROW_ID, row(it, ongoing().setGroup(GROUP_KEY))) + } + val tags = rows.map { it.tag }.toSet() + (drawn - tags).forEach { notifications.cancel(it, ROW_ID) } + drawn = tags } /** - * What the shade shows for [items]. + * One row of the shade, about one thing being worked on. * - * 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. + * The title says both which component it is and what is happening to it, because the collapsed + * row drops the text under it the moment there is a bar to draw (measured on API 36) -- so + * anything said there is said only to somebody who has already opened the group. Nothing is set + * beside it for that reason as well: expanded it would be the same words twice. */ - private fun notice(items: List): Notification { + private fun row(item: WorkItem, into: NotificationCompat.Builder): Notification { + into.setContentTitle("${heading(item)}: ${item.line}") + // A bar it can fill only where this component's own command + // reported a count; otherwise it says that something is happening + // and nothing about how far along, which is all anybody measured. + val fraction = item.fraction + if (fraction == null) { + into.setProgress(0, 0, true) + } else { + into.setProgress(PROGRESS_STEPS, (fraction * PROGRESS_STEPS).toInt(), false) + } + return into.build() + } + + /** + * The one the service is held up by: the single row while one thing is running, and the summary + * its rows are grouped under once there are more. + * + * As a summary it names the projects and draws no bar of its own, because the rows underneath + * carry the counts and a bar here could only be an average of theirs -- something that moves + * like a measurement while being nobody's. + */ + private fun foregroundNotice(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) + ongoing().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() + if (single != null) return row(single, builder) + return builder + .setGroup(GROUP_KEY) + .setGroupSummary(true) + .setContentTitle(items.map { it.project }.distinct().joinToString(", ")) + .build() } + /** + * What every one of them is: the same icon, the same tap, and not dismissible while it runs. + */ + private fun ongoing(): NotificationCompat.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) + /** 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 + 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