One notification per thing running, grouped, and an em dash between the two names
Two builds at once were two lines inside one notification; they are now two notifications in one group, each with its own title and its own bar, 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 (<key>/<component>) rather than an id, so an update replaces the row it is about and two components cannot collide, and `drawn` is the path out for one whose work is over. A foreground service needs one notification that outlives every row, so the service's own 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 would cost it the progress it had. Verified on the emulator through all four states: one row with its count, two grouped rows with a bar each, the fast one finishing and leaving the other as a single row again, and everything gone when the last build lands. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
bb65526230
commit
ded6df559d
4 files changed
+147
-70
No files matched your search
@@ -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* (`<key>/<component>`)
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<String>()
|
||||
|
||||
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<WorkItem>): 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<WorkItem>): 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
|
||||
|
||||
Reference in new issue
Block a user