Merge branch 'main' of git.arirex.me:iris/ai-app
This commit is contained in:
commit
7190eac6f3
6 files changed
+296
-11
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) } })
|
||||
}
|
||||
@@ -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<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. */
|
||||
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(
|
||||
|
||||
@@ -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. */
|
||||
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<String>, 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<String>, 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 ->
|
||||
|
||||
Reference in new issue
Block a user