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:
1 parent
1e52b2910c
commit
0be15adbee
3 files changed
+45
-26
No files matched your search
@@ -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
|
the same answer. What the app does not keep is anything *derived*: the folded
|
||||||
rows are rebuilt from events every time.
|
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
|
`GET /notifications` is one SSE stream of attention-wanting moments, and the
|
||||||
app decides where each one is said. Three outcomes, in one place
|
app decides how each one is said, in one place (`NotificationService.show`):
|
||||||
(`NotificationService.show`):
|
|
||||||
|
|
||||||
- **Nothing at all** if the session is the one on screen. The transcript in
|
- **Nothing at all** if the session is the one on screen. The transcript in
|
||||||
front of the reader is already saying it.
|
front of the reader is already saying it.
|
||||||
- **A banner over the app** if the app is up — `SessionAlerts`, queued, one
|
- **A row in Android's drawer** for everything else, which is what the
|
||||||
per session replacing that session's own, dismissable by a push in either
|
foreground service exists for.
|
||||||
direction and otherwise retiring itself when the bar across its foot runs
|
- **A banner over the app as well** if the app is up — `SessionAlerts`,
|
||||||
out.
|
queued, one per session replacing that session's own, dismissable by a push
|
||||||
- **A row in Android's drawer** otherwise, which is what the foreground
|
in either direction and otherwise retiring itself when the bar across its
|
||||||
service exists for.
|
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
|
The two are not two versions of one thing, which is why both go up. A banner
|
||||||
showed you each one is a drawer nobody reads. Which of the three applies is
|
is six seconds long and reaches only somebody whose eyes were on the screen,
|
||||||
answered without a flag anybody has to keep level: the session on screen is
|
so it is what interrupts; a row waits however long it has to, so it is what
|
||||||
registered by the one composable that draws one, and "the app is up" *is* the
|
records. Until 2026-09-15 the banner suppressed the row outright, and a
|
||||||
banner queue being collected, since it collects only while it is on screen.
|
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
|
**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
|
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
|
* 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.
|
* 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
|
* 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
|
* 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
|
* 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
|
// Nothing to tell somebody about the session they are reading. The transcript in front of
|
||||||
// them is already saying it.
|
// them is already saying it.
|
||||||
if (isOnScreen(notification.sessionId)) return
|
if (isOnScreen(notification.sessionId)) return
|
||||||
// The app is up: it says this itself, as a banner over whatever screen they are on. Never
|
// The app is up, so it says this itself as a banner over whatever screen they are on --
|
||||||
// both -- one thing happened, and a drawer filling up behind an app that already showed you
|
// which interrupts, where the drawer's row records: a banner lasts seconds and reaches only
|
||||||
// each one is a drawer nobody reads.
|
// somebody already looking. Both go up, and the banner having done the interrupting is what
|
||||||
if (handOver(notification)) return
|
// makes the row a silent one.
|
||||||
|
val banner = handOver(notification)
|
||||||
val manager = NotificationManagerCompat.from(this)
|
val manager = NotificationManagerCompat.from(this)
|
||||||
// Two different noes, and both are answers rather than faults: the runtime permission
|
// 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.
|
// refused, and notifications switched off for the app in Android's own settings.
|
||||||
@@ -172,6 +175,7 @@ class NotificationService : Service() {
|
|||||||
.setAutoCancel(true)
|
.setAutoCancel(true)
|
||||||
.setWhen((notification.at * 1000).toLong())
|
.setWhen((notification.at * 1000).toLong())
|
||||||
.setShowWhen(true)
|
.setShowWhen(true)
|
||||||
|
.setSilent(banner)
|
||||||
.build()
|
.build()
|
||||||
manager.notify(notification.sessionId, ALERT_ID, built)
|
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:
|
* 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
|
* [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
|
* 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)
|
private val toApp = MutableSharedFlow<SessionNotification>(extraBufferCapacity = 8)
|
||||||
|
|
||||||
@@ -272,7 +277,11 @@ class NotificationService : Service() {
|
|||||||
private fun handOver(notification: SessionNotification) =
|
private fun handOver(notification: SessionNotification) =
|
||||||
toApp.subscriptionCount.value > 0 && toApp.tryEmit(notification)
|
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) {
|
fun showing(context: Context, sessionId: String) {
|
||||||
onScreen = sessionId
|
onScreen = sessionId
|
||||||
// Whatever was posted about it before is about to be read, so it has nothing left to
|
// 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
|
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
|
* Two places carry the same fact and they are doing different jobs: a row in the shade waits
|
||||||
* somebody looking at something else: it makes a sound, it waits however long it has to, and acting
|
* however long it has to, which makes it the record, and a banner is read now or not at all, which
|
||||||
* on it means leaving whatever they were doing. Somebody with this app open needs none of that. So
|
* makes it the interruption. So somebody with the app open gets both -- this, and a silent row
|
||||||
* while these are on screen the stream is delivered here instead, which is arranged by the
|
* behind it that is still there when they go looking and goes by itself when they open the session.
|
||||||
* collection below and nothing else.
|
* 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
|
* 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.
|
* session; pushed off either side; or left alone, in which case it goes when the bar runs out.
|
||||||
|
|||||||
Reference in new issue
Block a user