Merge branch 'main' of git.arirex.me:iris/ai-app

This commit is contained in:
iris committed 2026-08-30 02:52:51 -04:00
commit 7190eac6f3
6 files changed
+296 -11

No files matched your search

+5
View File
@@ -78,6 +78,11 @@ repo is in PLAN.md's "Backend layout" section.
the REST + SSE clients; `Events.kt` the event model mirror; the REST + SSE clients; `Events.kt` the event model mirror;
`ServerConfig.kt` settings + Keystore-sealed token; screens in `ServerConfig.kt` settings + Keystore-sealed token; screens in
`SessionListScreen/SessionScreen/SpawnScreen/SettingsScreen`. `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 **Icons are Nerd Fonts glyphs from a committed subset**, not vector assets
and not ordinary Unicode — `NerdIcons.kt` declares each codepoint and 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 `app/build-icon-font.sh` subsets the font. The two lists have to agree: a
+31
View File
@@ -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 backend's transcript is the source of truth; the app caches only for the
screen it's showing. 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 ### Deferred polish
Noticed and deliberately not fixed yet, so they are not re-found from Noticed and deliberately not fixed yet, so they are not re-found from
@@ -212,4 +212,11 @@ fun AppRoot(settingsVersion: Int, openRequest: SessionOpenRequest?) {
onBack = goToMain, 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) } })
} }
@@ -20,6 +20,9 @@ import java.io.IOException
import java.net.HttpURLConnection import java.net.HttpURLConnection
import java.net.URL import java.net.URL
import kotlin.concurrent.thread import kotlin.concurrent.thread
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import org.json.JSONObject 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 // them is already saying it, and a sound over the top of it would be this app announcing
// what the screen is showing. // what the screen is showing.
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. 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) 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. Neither // refused, and notifications switched off for the app in Android's own settings. Neither
@@ -160,15 +167,7 @@ class NotificationService : Service() {
val built = val built =
NotificationCompat.Builder(this, ALERT_CHANNEL) NotificationCompat.Builder(this, ALERT_CHANNEL)
.setContentTitle(notification.title) .setContentTitle(notification.title)
.setContentText( .setContentText(attentionLine(notification.kind))
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"
}
)
.setSmallIcon(android.R.drawable.stat_notify_chat) .setSmallIcon(android.R.drawable.stat_notify_chat)
.setContentIntent(open) .setContentIntent(open)
.setAutoCancel(true) .setAutoCancel(true)
@@ -262,6 +261,23 @@ class NotificationService : Service() {
private fun isOnScreen(sessionId: String) = onScreen == sessionId 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<SessionNotification>(extraBufferCapacity = 8)
/** Everything meant for the screen rather than the drawer; see [toApp]. */
val forTheScreen: SharedFlow<SessionNotification> = 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. */ /** Somebody is looking at [sessionId]; nothing is posted about it until they stop. */
fun showing(context: Context, sessionId: String) { fun showing(context: Context, sessionId: String) {
onScreen = sessionId onScreen = sessionId
@@ -319,6 +335,20 @@ data class SessionNotification(
val at: Double, 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 { fun parseNotification(json: String): SessionNotification {
val body = JSONObject(json) val body = JSONObject(json)
return SessionNotification( return SessionNotification(
@@ -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<SessionAlert>() }
// 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
@@ -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. */ /** The modes the CLI accepts, in the order they give up asking. */
private val PERMISSION_MODES = listOf("manual", "acceptEdits", "auto", "bypassPermissions", "plan") private val PERMISSION_MODES = listOf("manual", "acceptEdits", "auto", "bypassPermissions", "plan")
@@ -1698,8 +1708,21 @@ private val PERMISSION_MODES = listOf("manual", "acceptEdits", "auto", "bypassPe
@Composable @Composable
private fun PickerButton(current: String, options: List<String>, onPick: (String) -> Unit) { private fun PickerButton(current: String, options: List<String>, onPick: (String) -> Unit) {
var open by remember { mutableStateOf(false) } 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 { 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 // One line, truncated rather than wrapped: this sits in a row
// whose height is the buttons beside it, and a second line // whose height is the buttons beside it, and a second line
// would move them. // would move them.
@@ -1731,7 +1754,10 @@ private fun PickerButton(current: String, options: List<String>, onPick: (String
// this menu takes no parameter for. // this menu takes no parameter for.
DropdownMenu( DropdownMenu(
expanded = open, expanded = open,
onDismissRequest = { open = false }, onDismissRequest = {
open = false
closedAt = SystemClock.uptimeMillis()
},
properties = PopupProperties(focusable = false, clippingEnabled = false), properties = PopupProperties(focusable = false, clippingEnabled = false),
) { ) {
options.forEach { option -> options.forEach { option ->