diff --git a/PLAN.md b/PLAN.md index c3228a5..bdd9483 100644 --- a/PLAN.md +++ b/PLAN.md @@ -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 diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Notifications.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Notifications.kt index 050b6eb..2e81765 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Notifications.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Notifications.kt @@ -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(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 diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionAlerts.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionAlerts.kt index 611bf79..7d498a0 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionAlerts.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionAlerts.kt @@ -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.