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 a4c08a8..b684f8e 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt @@ -2,18 +2,26 @@ package com.example.aiapp import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.padding +import androidx.compose.material3.AlertDialog import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text +import androidx.compose.material3.TextButton 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.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import com.example.wgapplink.localNetworkAllowed +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext /** * One `when` rather than a navigation library: a handful of screens, with [Screen.Main] as the root @@ -40,15 +48,33 @@ private sealed class Screen { data object Settings : Screen() } +/** + * A session a notification tap asked to open, before it is a screen. + * + * The notification names an id and nothing else, so opening it means fetching the session first. + * [serial] tells two taps on the same session's notification apart, since they are two requests and + * would otherwise compare equal -- see MainActivity, which counts them. + */ +data class SessionOpenRequest(val sessionId: String, val serial: Int) + +/** A tap that could not be turned into a screen, kept with its request so Try again knows what. */ +private data class FailedOpen(val request: SessionOpenRequest, val message: String) + /** * [settingsVersion] bumps when enrollment lands via an `aiapp://` intent (see MainActivity), * re-reading the stored settings -- a plain `remember` would keep serving the pre-enrollment null. + * + * [openRequest] is the session a notification tap asked for, likewise from MainActivity. */ @Composable -fun AppRoot(settingsVersion: Int) { +fun AppRoot(settingsVersion: Int, openRequest: SessionOpenRequest?) { val context = LocalContext.current + val scope = rememberCoroutineScope() var settings by remember(settingsVersion) { mutableStateOf(loadServerSettings(context)) } var screen by remember { mutableStateOf(Screen.Main) } + // A notification tap this could not follow, and why. Null both before one is asked for and + // after one succeeds, since success is a screen rather than a message. + var failedOpen by remember { mutableStateOf(null) } // Bumped whenever another screen changes something the list shows, so // returning to it refetches instead of showing a stale list. var reloadToken by remember { mutableIntStateOf(0) } @@ -95,6 +121,38 @@ fun AppRoot(settingsVersion: Int) { BackHandler(onBack = goToMain) } + // Turning a notification into the screen it points at. The id has to be resolved to a session + // first, because that is what SessionScreen is given -- and unlike a list row, which is a + // snapshot the list already fetched, there is nothing here to seed it from. + // + // A failure is reported rather than swallowed: somebody deliberately tapped a notification, so + // an app that opens to the session list with no explanation looks like the tap missed. + val open: suspend (SessionOpenRequest) -> Unit = { request -> + failedOpen = null + try { + val session = withContext(Dispatchers.IO) { fetchSession(current, request.sessionId) } + screen = Screen.Session(session) + } catch (e: ApiException) { + failedOpen = FailedOpen(request, e.message ?: "Unknown error") + } + } + LaunchedEffect(openRequest) { openRequest?.let { open(it) } } + + val failed = failedOpen + if (failed != null) { + AlertDialog( + onDismissRequest = { failedOpen = null }, + title = { Text("Couldn't open that session") }, + text = { Text(failed.message) }, + confirmButton = { + TextButton(onClick = { scope.launch { open(failed.request) } }) { + Text("Try again") + } + }, + dismissButton = { TextButton(onClick = { failedOpen = null }) { Text("Cancel") } }, + ) + } + when (val here = screen) { is Screen.Main -> MainScreen( @@ -109,12 +167,20 @@ fun AppRoot(settingsVersion: Int) { onSettings = { screen = Screen.Settings }, ) is Screen.Session -> - SessionScreen( - settings = current, - summary = here.summary, - onBack = goToMain, - onSettings = { screen = Screen.SessionSettings(here.summary) }, - ) + // Keyed on the id, because a different session is a different screen rather than this + // one showing other rows. SessionScreen remembers a transcript, an open event stream, a + // draft and a scroll position, and without the key Compose keeps all of it across the + // change and merges two conversations -- which crashes the list on the first duplicate + // row key. Only reachable since a notification can move straight from one session to + // another; every other way here passes through [Screen.Main], which disposes it anyway. + key(here.summary.id) { + SessionScreen( + settings = current, + summary = here.summary, + onBack = goToMain, + onSettings = { screen = Screen.SessionSettings(here.summary) }, + ) + } is Screen.Spawn -> SpawnScreen( settings = current, diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Compaction.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Compaction.kt index 37eb4c2..d515496 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Compaction.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Compaction.kt @@ -10,32 +10,30 @@ import androidx.compose.ui.Modifier * now, and that is a fact about the conversation, not a turn in it. It has no collapsed form -- it * is already one line, and there is nothing behind it to open. Drawn by [TranscriptDivider], which * a clear also uses, so the two marks cannot drift apart. + * + * Blue is [commandColor]: the session acting on itself rather than working on what was asked of it, + * which is the same thing the status line says while the compaction runs. */ @Composable fun CompactedRow(item: TranscriptItem.CompactedNote, modifier: Modifier = Modifier) { - TranscriptDivider(compactionSummary(item), modifier) + TranscriptDivider(compactionSummary(item), commandColor, modifier) } /** - * What to say about a compaction, given what was measured about it. + * What to say about a compaction: the two sizes, and nothing else. * - * The counts are the whole point when they are there -- "a million tokens became ten thousand" is - * the reader's answer to why the wait was worth it. When they are not, this says nothing about size - * rather than filling in a plausible number, and when only the size before is known it says exactly - * that much. - * - * `auto` is named because it is the case the reader did not ask for, and so the one that explains a - * session going quiet on its own. Anything else -- including a trigger this build does not - * recognise -- makes no claim about who asked, which is the honest reading of not knowing. + * The counts are the whole point -- "a million tokens became ten thousand" is the reader's answer + * to why the wait was worth it -- and they are all this says, because a divider is read in passing. + * When they were not reported this says only that a compaction happened, rather than filling in a + * plausible number or explaining at length what was missing. */ fun compactionSummary(item: TranscriptItem.CompactedNote): String { - val what = if (item.trigger == "auto") "Compacted automatically" else "Compacted" val pre = item.preTokens val post = item.postTokens - return when { - pre != null && post != null -> "$what -- ${tokens(pre)} to ${tokens(post)} tokens" - pre != null -> "$what -- was ${tokens(pre)} tokens, new size not reported" - else -> what + return if (pre != null && post != null) { + "Compacted • ${tokens(pre)} → ${tokens(post)} tok" + } else { + "Compacted" } } diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Dividers.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Dividers.kt index 8f765f4..d0ba652 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Dividers.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Dividers.kt @@ -1,47 +1,55 @@ package com.example.aiapp +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp /** * A line across the transcript saying what left the session's context. * - * Centred and dim, because it is a divider rather than something anybody said. Two things produce - * one -- a compaction and a clear -- and they look the same on purpose: to a reader scrolling back, - * both mean "the session no longer has what is above this", and the difference between summarised - * and dropped is what the words say, not how they are drawn. + * Centred between two rules, because it is a divider rather than something anybody said. Two things + * produce one -- a compaction and a clear -- and they are drawn the same way on purpose: to a + * reader scrolling back, both mean "the session no longer has what is above this", and which of the + * two it was is said by the words and the colour. + * + * The rules stay the ordinary divider colour whatever [color] is. They are framing: the words are + * what carries the meaning, and colouring the lines too would make the framing look like it were + * carrying some of it. * * Written once here rather than styled at each of them, so the two cannot drift into looking like * different kinds of thing. */ @Composable -fun TranscriptDivider(text: String, modifier: Modifier = Modifier) { - Text( - text, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, +fun TranscriptDivider(text: String, color: Color, modifier: Modifier = Modifier) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = modifier.fillMaxWidth().padding(vertical = 8.dp), - ) + ) { + HorizontalDivider(Modifier.weight(1f)) + Text(text, style = MaterialTheme.typography.bodySmall, color = color) + HorizontalDivider(Modifier.weight(1f)) + } } /** * The mark a clear leaves. * - * It says the conversation is still here, because the reader can see that it is -- everything above - * stays on screen and stays scrollable, and the only thing that changed is what the session will - * send. Without that sentence the divider reads as a deletion, which is the one thing it is not. - * - * No counts, unlike a compaction: nothing was measured and nothing was summarised, so there is - * nothing to report but the fact. A plausible-looking number here would be invented. + * Red, and no counts: a clear takes the conversation out of what the session is given, and unlike a + * compaction it summarises nothing and measures nothing, so there is nothing to report but the + * fact. Everything above stays on screen and stays scrollable -- the reader can see that, which is + * why this does not say it. */ @Composable fun ClearedRow(modifier: Modifier = Modifier) { - TranscriptDivider("Cleared -- everything above stays here, and is no longer sent", modifier) + TranscriptDivider("Context cleared", clearedColor, modifier) } diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/MainActivity.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/MainActivity.kt index c986544..2f8a037 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/MainActivity.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/MainActivity.kt @@ -18,6 +18,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.luminance @@ -28,6 +29,13 @@ class MainActivity : ComponentActivity() { // composition below re-reads the stored settings. private var settingsVersion by mutableIntStateOf(0) + // The session a notification tap asked for, or null if nothing has. The + // serial is what makes a second tap on the same session's notification a + // second request: without it the two compare equal and the composition + // below has nothing to react to. + private var openRequest by mutableStateOf(null) + private var opens = 0 + // Registered up front since permission launchers must be registered // before the activity reaches STARTED. private val requestLocalNetworkPermission = @@ -69,7 +77,7 @@ class MainActivity : ComponentActivity() { requestNotificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS) } - handleEnrollment(intent) + handleIntent(intent) // After enrollment, so a first launch that arrives with a token // starts the service with something to connect to rather than // stopping it and waiting for the next launch. @@ -89,22 +97,36 @@ class MainActivity : ComponentActivity() { .navigationBarsPadding() .imePadding() ) { - AppRoot(settingsVersion) + AppRoot(settingsVersion, openRequest) } } } } } - // launchMode="singleTop": an enrollment scan while the app is open - // lands here rather than in a second activity instance. + // launchMode="singleTop": an enrollment scan, or a notification tapped + // while the app is open, lands here rather than in a second activity + // instance. override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) - handleEnrollment(intent) + handleIntent(intent) } - private fun handleEnrollment(intent: Intent?) { + /** + * The one place an incoming `aiapp://` URI is sorted into what it means. + * + * Two things arrive this way -- an enrollment code and a notification naming a session -- and + * they are told apart by the URI's host rather than by two entry points, so a third kind is a + * branch here rather than another intent to remember to handle. + */ + private fun handleIntent(intent: Intent?) { val uri = intent?.data ?: return + val sessionId = notifiedSessionId(uri) + if (sessionId != null) { + opens++ + openRequest = SessionOpenRequest(sessionId, opens) + return + } val settings = parseEnrollmentUri(uri) if (settings == null) { Toast.makeText(this, "Not a valid enrollment code", Toast.LENGTH_LONG).show() 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 07c26ff..eab3a42 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Notifications.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Notifications.kt @@ -8,6 +8,7 @@ import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.content.pm.ServiceInfo +import android.net.Uri import android.os.Build import android.os.IBinder import androidx.core.app.NotificationChannelCompat @@ -149,9 +150,7 @@ class NotificationService : Service() { PendingIntent.getActivity( this, 0, - Intent(this, MainActivity::class.java) - .setAction(Intent.ACTION_MAIN) - .addCategory(Intent.CATEGORY_LAUNCHER), + sessionIntent(this, notification.sessionId), PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, ) val built = @@ -253,6 +252,31 @@ class NotificationService : Service() { } } +/** + * The intent that opens one session, and the id it carries back out. + * + * The two halves are written together so neither can be changed without the other, and the scheme + * is enrollment's `aiapp://` under a different host so that [MainActivity] has one thing to look at + * when an intent arrives rather than two. + * + * The id rides in the intent's **data** rather than in an extra, which is not a style choice: + * PendingIntent identity is `Intent.filterEquals`, and that compares the data while ignoring + * extras. Carried as an extra, every session's notification would update one shared PendingIntent + * and every tap would open whichever session was notified last. + */ +fun sessionIntent(context: Context, sessionId: String): Intent = + Intent(context, MainActivity::class.java) + .setAction(Intent.ACTION_VIEW) + .setData( + // Built rather than concatenated so an id needing escaping survives the round trip; + // lastPathSegment below decodes what appendPath encoded. + Uri.Builder().scheme("aiapp").authority("session").appendPath(sessionId).build() + ) + +/** The session [sessionIntent] named, or null for any other URI -- enrollment's included. */ +fun notifiedSessionId(uri: Uri): String? = + if (uri.scheme == "aiapp" && uri.host == "session") uri.lastPathSegment else null + /** One frame of `GET /notifications`. */ data class SessionNotification( val sessionId: String, 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 2b832f9..1b26881 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -184,13 +184,6 @@ sealed class TranscriptItem { /** Placeholder row for events this build can't render (newer kinds). */ data class Note(override val seq: Long, val text: String) : TranscriptItem() - /** - * A compaction that happened, and what it recovered. - * - * In the transcript rather than only in the status line, because the status is gone the moment - * it finishes and this is the part worth keeping: it is the explanation for a gap in the - * conversation, and for a minute or two in which the session was busy with nothing to show. - */ /** * A clear that happened: everything above it left the session's context and stayed on screen. * @@ -198,11 +191,21 @@ sealed class TranscriptItem { */ data class ClearedNote(override val seq: Long) : TranscriptItem() + /** + * A compaction that happened, and what it recovered. + * + * In the transcript rather than only in the status line, because the status is gone the moment + * it finishes and this is the part worth keeping: it is the explanation for a gap in the + * conversation, and for a minute or two in which the session was busy with nothing to show. + * + * The wire also says what triggered it, and this deliberately does not carry that: the row says + * the two sizes and nothing else (see [compactionSummary]), so keeping the trigger here would + * be a field nothing can read. + */ data class CompactedNote( override val seq: Long, val preTokens: Long?, val postTokens: Long?, - val trigger: String?, ) : TranscriptItem() } @@ -430,13 +433,7 @@ fun foldEvent(items: List, entry: SeqEvent): List items + TranscriptItem.ClearedNote(entry.seq) is SessionEvent.Compacted -> - items + - TranscriptItem.CompactedNote( - entry.seq, - event.preTokens, - event.postTokens, - event.trigger, - ) + items + TranscriptItem.CompactedNote(entry.seq, event.preTokens, event.postTokens) is SessionEvent.Unknown -> items + TranscriptItem.Note(entry.seq, "[${event.type}]") // Screen-level state, not transcript rows -- see SessionScreen. is SessionEvent.UsageDelta -> items diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Theme.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Theme.kt index 43847e3..18146c6 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Theme.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Theme.kt @@ -123,6 +123,18 @@ val failedColor: Color val commandColor: Color @Composable get() = Mocha.Blue +/** + * A clear: the conversation taken out of what the session is given. + * + * Red because of what it does, not because anything went wrong -- somebody asked for this, and a + * deliberate choice is not a problem to report. It is the same red as [failedColor] and [stopColor] + * for a third reason, which is worth naming rather than collapsing: this is neither a fault nor a + * button, it is the mark left where something was taken away. The reader never has to tell the + * three apart, because no two of them can appear as the same kind of thing. + */ +val clearedColor: Color + @Composable get() = Mocha.Red + /** Waiting on a person: a question, a permission, a turn that is theirs. */ val awaitingColor: Color @Composable get() = Mocha.Peach