diff --git a/AGENTS.md b/AGENTS.md index 51d549a..a89b96e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -78,6 +78,11 @@ repo is in PLAN.md's "Backend layout" section. the REST + SSE clients; `Events.kt` the event model mirror; `ServerConfig.kt` settings + Keystore-sealed token; screens in `SessionListScreen/SessionScreen/SpawnScreen/SettingsScreen`. + `Notifications.kt` is the foreground service holding the notification + stream and the one place that decides where a notification is said -- + nothing for the session on screen, a `SessionAlerts` banner while the app + is up, Android's drawer otherwise, never two of them. See PLAN.md's + "Notifications: two places, never both". **Icons are Nerd Fonts glyphs from a committed subset**, not vector assets and not ordinary Unicode — `NerdIcons.kt` declares each codepoint and `app/build-icon-font.sh` subsets the font. The two lists have to agree: a diff --git a/PLAN.md b/PLAN.md index f343c89..d81d991 100644 --- a/PLAN.md +++ b/PLAN.md @@ -647,6 +647,37 @@ connectivity/lifecycle. The app keeps no persistent transcript store — the backend's transcript is the source of truth; the app caches only for the screen it's showing. +### Notifications: two places, never both (decided 2026-08-30) + +The backend's `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`): + +- **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. Tapping one opens the session, through the same path a tapped + notification uses. +- **A row in Android's drawer** otherwise, which is what the foreground + service exists for. + +Never two of them for one moment. A notification that has already been shown +in the app is not something to also find in the shade afterwards, and 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 alternative considered and rejected was giving the app its own +connection to `/notifications` while it is in front. That is a second stream +per device saying the same thing, and it puts the "which of these two shows +it" decision in two processes' worth of code instead of one function. + ### Deferred polish Noticed and deliberately not fixed yet, so they are not re-found from diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt index b684f8e..2a06453 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt @@ -212,4 +212,11 @@ fun AppRoot(settingsVersion: Int, openRequest: SessionOpenRequest?) { onBack = goToMain, ) } + + // Last, so it draws over the screen above rather than under it: these are stacked in the Box + // the activity puts around this, and that Box paints in the order it was given. A session + // wanting attention is not a fact about the page somebody happens to be on, so it is not the + // page's job to leave room for it. Tapping one is the same act as tapping a notification, so + // it goes through the same `open`, failure dialog included. + SessionAlerts(onOpen = { request -> scope.launch { open(request) } }) } 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 60c2f1c..656ffa6 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Notifications.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Notifications.kt @@ -20,6 +20,9 @@ import java.io.IOException import java.net.HttpURLConnection import java.net.URL import kotlin.concurrent.thread +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow import org.json.JSONObject /** @@ -139,6 +142,10 @@ class NotificationService : Service() { // them is already saying it, and a sound over the top of it would be this app announcing // what the screen is showing. if (isOnScreen(notification.sessionId)) return + // The app is up: it says this itself, as a banner over whatever screen they are on. See + // [forTheScreen]. 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 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. Neither @@ -160,15 +167,7 @@ class NotificationService : Service() { val built = NotificationCompat.Builder(this, ALERT_CHANNEL) .setContentTitle(notification.title) - .setContentText( - when (notification.kind) { - // What the reader has to do, not what the session - // did: "awaitingInput" is the wire's word and says - // nothing to somebody reading a lock screen. - "awaitingInput" -> "Waiting for you" - else -> "Finished" - } - ) + .setContentText(attentionLine(notification.kind)) .setSmallIcon(android.R.drawable.stat_notify_chat) .setContentIntent(open) .setAutoCancel(true) @@ -262,6 +261,23 @@ class NotificationService : Service() { private fun isOnScreen(sessionId: String) = onScreen == sessionId + /** + * The way a notification reaches the app instead of Android's drawer. + * + * 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, so there is nothing that + * could be left saying the app is up after it has gone. `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. + */ + private val toApp = MutableSharedFlow(extraBufferCapacity = 8) + + /** Everything meant for the screen rather than the drawer; see [toApp]. */ + val forTheScreen: SharedFlow = toApp.asSharedFlow() + + 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. */ fun showing(context: Context, sessionId: String) { onScreen = sessionId @@ -319,6 +335,20 @@ data class SessionNotification( val at: Double, ) +/** + * What a notification asks of the reader, in the words they see. + * + * What they have to do, not what the session did: "awaitingInput" is the wire's word and says + * nothing to somebody reading a lock screen. One function because the same fact is now shown in two + * places -- Android's drawer and the app's own banner -- and two mappings of one word drift. The + * banner colours the line as well, which is its own decision and stays with the drawing. + */ +fun attentionLine(kind: String): String = + when (kind) { + "awaitingInput" -> "Waiting for you" + else -> "Finished" + } + fun parseNotification(json: String): SessionNotification { val body = JSONObject(json) return SessionNotification( diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionAlerts.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionAlerts.kt new file mode 100644 index 0000000..0843418 --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionAlerts.kt @@ -0,0 +1,186 @@ +package com.example.aiapp + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.tween +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SwipeToDismissBox +import androidx.compose.material3.SwipeToDismissBoxValue +import androidx.compose.material3.Text +import androidx.compose.material3.rememberSwipeToDismissBoxState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.repeatOnLifecycle + +/** + * A session wanting attention, said over the app rather than through Android's drawer. + * + * Two places can carry the same fact and only one of them 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 -- they are already here, and what a tap on the notification would have done is what a tap + * on this does. So while these are on screen the stream is delivered here instead, which is + * arranged by the collection below and nothing else; see `NotificationService.forTheScreen`. + * + * A banner can go three ways, and each is somebody deciding something different: tapped, which + * opens the session; pushed off either side; or left alone, in which case it goes by itself when + * the bar across its foot runs out. + */ +@Composable +fun SessionAlerts(onOpen: (SessionOpenRequest) -> Unit, modifier: Modifier = Modifier) { + val queue = remember { mutableStateListOf() } + // What tells two notifications about one session apart, and what a replaced banner gets a new + // one of so its timer starts again rather than inheriting the remains of the last one's. + var arrivals by remember { mutableIntStateOf(0) } + val lifecycleOwner = LocalLifecycleOwner.current + LaunchedEffect(lifecycleOwner) { + lifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) { + try { + NotificationService.forTheScreen.collect { notification -> + arrivals++ + val alert = SessionAlert(notification, arrivals) + // One banner per session, replacing that session's own -- the same rule the + // drawer follows, and for the same reason: a session that finished and then + // asked a question is one thing to know about, the question. It keeps its + // place in the queue rather than moving to the end, because the reader may + // already be reaching for it. + val already = queue.indexOfFirst { + it.notification.sessionId == notification.sessionId + } + if (already >= 0) queue[already] = alert else queue.add(alert) + } + } finally { + // Leaving the app hands the job back to the drawer, so nothing arriving while it + // is away is lost. What would be lost is the truth of what is already up: these + // say a session wants somebody *now*, and one still sitting here on a return + // several minutes later is a claim nobody checked. Frozen, too -- Compose stops + // the clock with the window, so the timer that was going to retire it has been + // standing still the whole time. + queue.clear() + } + } + } + // Oldest at the top, so a new one appears below the ones already being read instead of + // shoving them down the screen mid-reach. + Column(modifier.fillMaxWidth().padding(8.dp)) { + queue.forEach { alert -> + key(alert.arrival) { + AlertBanner( + alert = alert, + onOpen = { + queue.remove(alert) + onOpen(SessionOpenRequest(alert.notification.sessionId, alert.arrival)) + }, + onGone = { queue.remove(alert) }, + ) + } + } + } +} + +/** One notification queued for the screen, with the arrival that tells it from its predecessor. */ +private data class SessionAlert(val notification: SessionNotification, val arrival: Int) + +/** + * One banner: what wants attention, and how long this has left to say so. + * + * The bar and the going away are one value rather than a bar beside a timer, because two of them + * would be two accounts of the same countdown and only one can be the one that fires. What is drawn + * is therefore the thing that decides, which is the only arrangement where a bar that has emptied + * cannot be sitting under a banner that is still there. + */ +@Composable +private fun AlertBanner(alert: SessionAlert, onOpen: () -> Unit, onGone: () -> Unit) { + val swipe = rememberSwipeToDismissBoxState() + val life = remember { Animatable(1f) } + LaunchedEffect(Unit) { + life.animateTo(0f, animationSpec = tween(ALERT_LIFE_MS, easing = LinearEasing)) + onGone() + } + // Settled is "still where it started"; anything else is a push that carried far enough for the + // gesture to commit, which the platform decides rather than this screen. + LaunchedEffect(swipe.currentValue) { + if (swipe.currentValue != SwipeToDismissBoxValue.Settled) onGone() + } + SwipeToDismissBox( + state = swipe, + // Nothing behind it. Pushing one of these away means the same thing whichever way it went, + // so a coloured ground with an icon would be drawing a distinction that isn't there. + backgroundContent = {}, + modifier = Modifier.padding(bottom = 8.dp), + ) { + Card( + onClick = onOpen, + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh + ), + // Outlined, because the step it needs to make is not one this palette can make with a + // tint: the card under a banner on the session list is the same surface, so a banner + // relying on colour alone reads as one more row that happens to be in the way. The + // border is the one cue, and the elevation beside it is the platform's shadow rather + // than a second tint -- Material draws no tonal overlay over a container stated here. + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline), + elevation = CardDefaults.cardElevation(defaultElevation = 6.dp), + ) { + Column(Modifier.padding(start = 12.dp, end = 12.dp, top = 12.dp, bottom = 10.dp)) { + Text( + alert.notification.title, + style = MaterialTheme.typography.titleSmall, + // One line, cut at the tail: a session is identified by the start of its + // name, and a banner that grew with the name would move the one below it. + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + attentionLine(alert.notification.kind), + style = MaterialTheme.typography.labelLarge, + // The list's own colour for a session waiting on a person, so the banner and + // the row behind it are saying one thing rather than two. + color = + if (alert.notification.kind == "awaitingInput") awaitingColor + else MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + LinearProgressIndicator( + progress = { life.value }, + // Blue because it is reporting how much of something is left rather than passing + // judgement on it -- the reason `progressColor` exists. Stated beside the track, + // which is the card's own colour so that the spent part reads as empty rather + // than as a second bar. + color = progressColor, + trackColor = MaterialTheme.colorScheme.surfaceContainerHigh, + drawStopIndicator = {}, + gapSize = 0.dp, + modifier = Modifier.fillMaxWidth(), + ) + } + } +} + +/** + * How long a banner stays if nobody touches it. + * + * Long enough to read a session name and a line, short enough that a stack of them clears itself + * while somebody is still on the screen that produced them. The bar makes the number visible, so + * this is a duration the reader can watch rather than one they have to learn. + */ +private const val ALERT_LIFE_MS = 6_000 diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt index da78ce7..fe09e52 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -1686,6 +1686,16 @@ private fun QuestionRow( } } +/** + * How long after a menu closes a press on its own button still counts as the press that closed it. + * + * Sized to one tap, because one tap is all it has to span -- [PickerButton] explains the pair of + * events it separates. Deliberately not the platform's long-press timeout, which is the longest a + * tap can legally be: half a second of ignoring the button would start swallowing a deliberate + * reopen, and a press held that long to close a menu is not worth protecting at that price. + */ +private const val ONE_TAP_MS = 250L + /** The modes the CLI accepts, in the order they give up asking. */ private val PERMISSION_MODES = listOf("manual", "acceptEdits", "auto", "bypassPermissions", "plan") @@ -1698,8 +1708,21 @@ private val PERMISSION_MODES = listOf("manual", "acceptEdits", "auto", "bypassPe @Composable private fun PickerButton(current: String, options: List, onPick: (String) -> Unit) { var open by remember { mutableStateOf(false) } + // When an outside touch last closed the menu. + // + // Pressing this button while its own menu is open is such a touch. The menu is deliberately + // not focusable (see below), which means the press that dismisses it is also delivered to the + // window underneath -- and what it lands on there is this button. The dismissal arrives with + // the press and the click with the release, measured 3ms apart on the emulator, so a button + // that simply opened on every click would reopen what the same finger had just closed, and + // the menu could only be put away by tapping somewhere else. So the moment is remembered, and + // a click that follows it within one tap is read as the second half of that tap rather than + // as a new one. + var closedAt by remember { mutableLongStateOf(0L) } Box { - TextButton(onClick = { open = true }) { + TextButton( + onClick = { if (SystemClock.uptimeMillis() - closedAt > ONE_TAP_MS) open = true } + ) { // One line, truncated rather than wrapped: this sits in a row // whose height is the buttons beside it, and a second line // would move them. @@ -1731,7 +1754,10 @@ private fun PickerButton(current: String, options: List, onPick: (String // this menu takes no parameter for. DropdownMenu( expanded = open, - onDismissRequest = { open = false }, + onDismissRequest = { + open = false + closedAt = SystemClock.uptimeMillis() + }, properties = PopupProperties(focusable = false, clippingEnabled = false), ) { options.forEach { option ->