Post the drawer's row behind the app's own banner

A notification arriving while the app was open was shown as a banner and
nowhere else, so a moment that happened while the phone was face-up on a desk
left nothing behind at all -- the banner is seconds long and reaches only
somebody already looking.

The two are not two versions of one thing: a banner interrupts and a row
records. Both go up now, and the banner having done the interrupting is what
makes the row a silent one (`setSilent`), so one moment is worth a noise once.
What keeps the drawer from filling up is the other end rather than suppression,
and already was: opening a session clears whatever is posted about it, whichever
way the reader got there.

Checked with ktfmtFormat, compileDebugKotlin, testDebugUnitTest and lintDebug,
and on the emulator against the sandbox, reading the posted record out of
dumpsys: app on the session list gives a banner and flags=AUTO_CANCEL|SILENT;
app backgrounded gives flags=AUTO_CANCEL; opening the session leaves nothing
posted about it in either case.
This commit is contained in:
iris-ai committed 2026-09-15 02:06:12 -04:00
1 parent 1e52b2910c
commit 0be15adbee
3 files changed
+45 -26

No files matched your search

+24 -14
View File
@@ -1184,26 +1184,36 @@ load-bearing** — every path that reads it has a network path beside it giving
the same answer. What the app does not keep is anything *derived*: the folded
rows are rebuilt from events every time.
### Notifications: two places, never both (2026-08-30)
### Notifications: the record and the interruption (2026-08-30, revised
2026-09-15)
`GET /notifications` is one SSE stream of attention-wanting moments, and the
app decides where each one is said. Three outcomes, in one place
(`NotificationService.show`):
app decides how each one is said, in one place (`NotificationService.show`):
- **Nothing at all** if the session is the one on screen. The transcript in
front of the reader is already saying it.
- **A banner over the app** if the app is up — `SessionAlerts`, queued, one
per session replacing that session's own, dismissable by a push in either
direction and otherwise retiring itself when the bar across its foot runs
out.
- **A row in Android's drawer** otherwise, which is what the foreground
service exists for.
- **A row in Android's drawer** for everything else, which is what the
foreground service exists for.
- **A banner over the app as well** if the app is up — `SessionAlerts`,
queued, one per session replacing that session's own, dismissable by a push
in either direction and otherwise retiring itself when the bar across its
foot runs out. The drawer's row is posted **silently** in that case
(`setSilent`), because the banner has already done the interrupting.
Never two of them for one moment. A drawer that fills up behind an app that
showed you each one is a drawer nobody reads. Which of the three applies is
answered without a flag anybody has to keep level: the session on screen is
registered by the one composable that draws one, and "the app is up" *is* the
banner queue being collected, since it collects only while it is on screen.
The two are not two versions of one thing, which is why both go up. A banner
is six seconds long and reaches only somebody whose eyes were on the screen,
so it is what interrupts; a row waits however long it has to, so it is what
records. Until 2026-09-15 the banner suppressed the row outright, and a
notification that arrived while the phone was face-up on a desk left nothing
behind at all.
What keeps the drawer from filling up is the other end rather than
suppression: **opening a session clears whatever is posted about it**
(`NotificationService.showing`), whichever way the reader got there, because
opening it is reading the notification. Which case applies is answered
without a flag anybody has to keep level: the session on screen is registered
by the one composable that draws one, and "the app is up" *is* the banner
queue being collected, since it collects only while it is on screen.
**What counts as finished** is decided in `notification_for`, and since
2026-08-31 it takes the number of messages the session has been given and not
@@ -34,6 +34,8 @@ import org.json.JSONObject
* gets a push from Google's servers, which would mean this backend talking to Google about
* somebody's coding sessions, and the whole point of the tunnel is that it does not.
*
* Every moment it hears about goes to the drawer; [show] decides what else is done with it.
*
* The cost Android charges is a notification of its own that cannot be dismissed. That is made as
* quiet as the platform allows: [ONGOING_CHANNEL] is `IMPORTANCE_MIN`, so it makes no sound, shows
* no status-bar icon, and sits at the bottom of the shade. It is not hidden outright, because it
@@ -138,10 +140,11 @@ class NotificationService : Service() {
// Nothing to tell somebody about the session they are reading. The transcript in front of
// them is already saying it.
if (isOnScreen(notification.sessionId)) return
// The app is up: it says this itself, as a banner over whatever screen they are on. Never
// both -- one thing happened, and a drawer filling up behind an app that already showed you
// each one is a drawer nobody reads.
if (handOver(notification)) return
// The app is up, so it says this itself as a banner over whatever screen they are on --
// which interrupts, where the drawer's row records: a banner lasts seconds and reaches only
// somebody already looking. Both go up, and the banner having done the interrupting is what
// makes the row a silent one.
val banner = handOver(notification)
val manager = NotificationManagerCompat.from(this)
// Two different noes, and both are answers rather than faults: the runtime permission
// refused, and notifications switched off for the app in Android's own settings.
@@ -172,6 +175,7 @@ class NotificationService : Service() {
.setAutoCancel(true)
.setWhen((notification.at * 1000).toLong())
.setShowWhen(true)
.setSilent(banner)
.build()
manager.notify(notification.sessionId, ALERT_ID, built)
}
@@ -262,7 +266,8 @@ class NotificationService : Service() {
* Whether there is an app to reach is the subscriber count rather than a flag of its own:
* [SessionAlerts] collects this exactly while it is on screen. `tryEmit` neither suspends
* nor blocks the thread reading the stream, and the buffer is there so a handful of
* sessions finishing together all land rather than the last one winning.
* sessions finishing together all land rather than the last one winning. Reaching the app
* does not stop the drawer's row; it makes it a silent one.
*/
private val toApp = MutableSharedFlow<SessionNotification>(extraBufferCapacity = 8)
@@ -272,7 +277,11 @@ class NotificationService : Service() {
private fun handOver(notification: SessionNotification) =
toApp.subscriptionCount.value > 0 && toApp.tryEmit(notification)
/** Somebody is looking at [sessionId]; nothing is posted about it until they stop. */
/**
* Somebody is looking at [sessionId]; nothing is posted about it until they stop, and
* whatever the drawer is already holding about it goes now rather than waiting to be swiped
* away. Opening the session *is* reading the notification, whichever way they got here.
*/
fun showing(context: Context, sessionId: String) {
onScreen = sessionId
// Whatever was posted about it before is about to be read, so it has nothing left to
@@ -31,13 +31,13 @@ import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.repeatOnLifecycle
/**
* A session wanting attention, said over the app rather than through Android's drawer.
* A session wanting attention, said over the app as well as in Android's drawer.
*
* Two places can carry the same fact and only one is right at a time. A row in the shade is for
* somebody looking at something else: it makes a sound, it waits however long it has to, and acting
* on it means leaving whatever they were doing. Somebody with this app open needs none of that. So
* while these are on screen the stream is delivered here instead, which is arranged by the
* collection below and nothing else.
* Two places carry the same fact and they are doing different jobs: a row in the shade waits
* however long it has to, which makes it the record, and a banner is read now or not at all, which
* makes it the interruption. So somebody with the app open gets both -- this, and a silent row
* behind it that is still there when they go looking and goes by itself when they open the session.
* Whether the app is open at all is this collection and nothing else.
*
* A banner can go three ways, each somebody deciding something different: tapped, which opens the
* session; pushed off either side; or left alone, in which case it goes when the bar runs out.