A build waiting to be installed arrives with a sound

Every notification this app posts was on one IMPORTANCE_LOW channel, so
all of them were silent -- including the row for a build that landed
while nobody was looking, which is the one that carries the Install
button and is the only thing here that is news rather than a report of
something somebody just pressed a button for.

A second channel rather than raising that one, because Android lets an
app only ever lower an existing channel's importance: raising "work" in
code would do nothing whatever on a phone that had already run this app,
and the whole fix would be somebody finding it in Android's settings.

The waiting row also has to undo the setOnlyAlertOnce every other row
wants. It replaces the running row it grew out of -- same tag, same id
-- and an update to a notification posted alert-once is silent however
loud its channel is, which is exactly the arrival nobody is watching
for.

Measured on the API 36 emulator: pressing Update on a test project and
going to the home screen posts the row on channel=ready, importance 3,
with mSound resolved and mIsInterruptive=true, and the shade files it
above its own "Silent" divider.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
iris-aiandClaude Opus 5 committed 2026-09-19 22:32:18 -04:00
1 parent adaeb0c0ad
commit 7bf10061e2
4 files changed
+64 -17

No files matched your search

+14
View File
@@ -935,6 +935,20 @@ mutable at runtime from the phone.
`autoCancel`, so the tap that opens the app takes it away, and the app
is where the truth about that build is -- which is the cheap answer to a
case that costs a stale line in the shade.
**There are two channels, and the difference is the sound.** Work in
progress is `IMPORTANCE_LOW`, because a build is an ongoing report of
something somebody just pressed a button for and the screen they
pressed it on already says the same thing; the row waiting to be
installed is `IMPORTANCE_DEFAULT`, because it is the arrival of
something nobody was watching for and a silent one is a button nobody
is told about. Two channels rather than one raised, because Android lets
an app only ever *lower* an existing channel's importance -- raising
the one that shipped would do nothing at all on a phone that had
already run this app, and the whole fix would be somebody finding it
in Android's settings. The waiting row also undoes the
`setOnlyAlertOnce` every other row wants: it replaces the running row
it grew out of, same tag and same id, and an update to a notification
posted alert-once is silent however loud its channel is.
`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
+3 -1
View File
@@ -135,7 +135,9 @@ A build that finishes while you are somewhere else leaves its notification
up with an **Install** button on it, because Android will not let the app
put the installer on screen from the background. Pressing it installs, and
the notification goes away by itself — as the rest do when their work is
over.
over. That one arrives with a sound, and the progress ones do not: they
are two channels, *Ready to install* and *Work in progress*, so either can
be turned down on its own.
### Updating this server itself
@@ -186,7 +186,7 @@ object WorkNotice {
/** What the shade should show for [items], against what it is showing now. */
private fun draw(context: Context, items: List<WorkItem>) {
createChannel(context)
createChannels(context)
// Notifications refused is not a reason to stop: the service is
// what keeps a build alive, and that runs either way -- there is
// simply nothing to draw. Asked rather than left to notify(),
@@ -224,12 +224,13 @@ object WorkNotice {
val rows = (if (running.size > 1) running else emptyList()) + waiting
rows.forEach { item ->
if (drawn[item.tag] == item) return@forEach
val into = base(context)
val inFlight = item.kind is WorkKind.Running
val into = base(context, if (inFlight) CHANNEL_ID else READY_CHANNEL_ID)
// Running rows are grouped under the summary above. One
// waiting to be installed is not: the group is the service's
// and outlives neither it nor, at one row, its own collapsing
// -- and a collapsed group hides the button this row is for.
if (item.kind is WorkKind.Running) into.setGroup(GROUP_KEY)
if (inFlight) into.setGroup(GROUP_KEY)
notifications.notify(item.tag, ROW_ID, row(context, item, into))
}
val tags = rows.map { it.tag }.toSet()
@@ -256,6 +257,9 @@ private const val GROUP_KEY = "work"
private const val CHANNEL_ID = "work"
/** The other channel: a build that has arrived, which is the one thing here that makes a sound. */
private const val READY_CHANNEL_ID = "ready"
/**
* How many steps a determinate bar has.
*
@@ -297,6 +301,11 @@ private fun row(context: Context, item: WorkItem, into: NotificationCompat.Build
// app itself may not start that screen from the background.
is WorkKind.Waiting ->
into
// Undone here alone: this row replaces the running one
// it grew out of, same tag and same id, and an update to a
// notification posted alert-once is silent however loud
// its channel is.
.setOnlyAlertOnce(false)
.setAutoCancel(true)
.addAction(
R.drawable.ic_updating,
@@ -317,7 +326,7 @@ private fun row(context: Context, item: WorkItem, into: NotificationCompat.Build
*/
private fun foregroundNotice(context: Context, running: List<WorkItem>): Notification {
val builder =
base(context)
base(context, CHANNEL_ID)
.setOngoing(true)
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
// Only between the last thing finishing and this service stopping,
@@ -336,8 +345,8 @@ private fun foregroundNotice(context: Context, running: List<WorkItem>): Notific
}
/** What every one of them is: the same icon, and the same tap back to the card it is about. */
private fun base(context: Context): NotificationCompat.Builder =
NotificationCompat.Builder(context, CHANNEL_ID)
private fun base(context: Context, channel: String): NotificationCompat.Builder =
NotificationCompat.Builder(context, channel)
.setSmallIcon(R.drawable.ic_updating)
.setContentIntent(openApp(context))
// The work moves every second or so; alerting on each of those
@@ -384,17 +393,27 @@ private fun openApp(context: Context): PendingIntent =
PendingIntent.FLAG_IMMUTABLE,
)
private var channelMade = false
private var channelsMade = false
/**
* 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.
* The two channels, which differ in the one thing: whether it makes a sound.
*
* Work in progress is 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, and the screen they pressed it on is already saying the same thing. A build that has landed
* and is waiting to be installed is the opposite case: it is the arrival of something nobody was
* watching for, which is why that row carries the Install button at all, and a silent one is a
* button nobody is told about. It keeps its badge for the same reason.
*
* Two channels rather than one channel raised to default, because an app may only ever *lower* an
* existing channel's importance. Raising [CHANNEL_ID] in code would do nothing whatever on a phone
* that has already run this app, and the whole fix would be somebody finding the channel in
* Android's settings.
*/
private fun createChannel(context: Context) {
if (channelMade || Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
context
.getSystemService(NotificationManager::class.java)
.createNotificationChannel(
private fun createChannels(context: Context) {
if (channelsMade || Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val manager = context.getSystemService(NotificationManager::class.java)
manager.createNotificationChannel(
NotificationChannel(
CHANNEL_ID,
context.getString(R.string.work_channel_name),
@@ -402,7 +421,14 @@ private fun createChannel(context: Context) {
)
.apply { setShowBadge(false) }
)
channelMade = true
manager.createNotificationChannel(
NotificationChannel(
READY_CHANNEL_ID,
context.getString(R.string.work_ready_channel_name),
NotificationManager.IMPORTANCE_DEFAULT,
)
)
channelsMade = true
}
/**
@@ -429,7 +455,7 @@ class WorkNoticeService : Service() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
lastStart = startId
createChannel(this)
createChannels(this)
// 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(
@@ -12,6 +12,11 @@
downloaded" notification belongs to. Named for what the person
sees in it rather than for the foreground service underneath. -->
<string name="work_channel_name">Work in progress</string>
<!-- The channel the "this build has arrived, press to install it"
notification belongs to. A separate one because it is the only
one that makes a sound; named for what is waiting rather than
for the notification. -->
<string name="work_ready_channel_name">Ready to install</string>
<!-- Shown for the moment between the last thing finishing and the
notification going away. -->
<string name="work_done">Finished</string>