Keep visited transcripts on the phone

Reopening a session downloaded the conversation again, every time, over
the tunnel. It now draws from a copy of what the server has already sent
and asks for one event to check that copy is still current.

Per session, under cacheDir, the server's own event lines in chunks named
for the range they cover -- so a coalesced page, whose lines do not say
what they cover, still records it. Only the contiguous run ending at the
newest chunk is served; a gap is closed by paging through it, bounded by
`after` on /transcript so the page stops where the phone's copy starts
and can therefore be kept. Nothing is derived and stored: rows are a
rendering, and a cache of them would need throwing away on every change
to the fold.

Nothing here is load-bearing. Missing, evicted, damaged or unwritable all
degrade to the cold open this screen did before, and the check before the
stream resumes -- one request, one event -- is what stops a replaced or
truncated file being spliced onto a copy of a different conversation.
What that check cannot see, a line changed mid-file with the tail intact,
is what Reload in session settings is for.

Measured on the emulator against ui-sandbox, on a 505-event session:
reopening it costs one request for one event, including scrolling the
whole conversation back; a cold open is two requests and 100 events. A
reset after falling 300 behind fetched the gap as four coalesced rows
rather than re-fetching 104 events and discarding them. Every chunk was
checked line by line against what the server says for the range its name
claims, across the reset and the gap-fill.

transcript-bench.sh, same viewport content and gestures, before and
after: p50 16.9ms both, p90 25.6 -> 23.2ms, p99 33.5 -> 36.7ms, and the
transcript's own draw accounting 0.33ms -> 0.32ms with place 0.31ms
either way. Within the emulator's noise, which is what a cache must be:
it changes what is fetched, not what is drawn.

Building it also found that the server handed out the same transcript
line two different ways. serde_json's default float parser is not
correctly rounded, so a ts written as ...0757 came back from /transcript
as ...0755 while the SSE stream sent the original -- invisible on screen,
since a ts is drawn as a relative time, and visible here only because the
cache compares a line it holds against the server's answer. Fixed with
float_roundtrip, with a test that fails the moment it is dropped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Opus 5 committed 2026-09-04 15:00:25 -04:00
1 parent 8881a40919
commit a802522039
17 files changed
+2140 -74

No files matched your search

@@ -775,15 +775,27 @@ fun fetchTranscript(
// seq). Ignored by the server for the newest window, where the live cursor needs real seqs.
// See the server's `read_window`.
coalesce: Boolean = false,
): List<SeqEvent> {
// Return nothing at or below this seq, stopping the page here instead of at [limit]. The
// phone passes the end of the run it already holds cached, so a page never overlaps that copy
// -- an overlap it cannot store, since a coalesced event cannot be cut at a seq inside its own
// delta run. Exclusive, like the SSE route's cursor. See TranscriptCache and `read_window`.
after: Long? = null,
): List<Pair<String, SeqEvent>> {
val query = buildString {
append("?limit=").append(limit)
if (before != null) append("&before=").append(before)
if (coalesce) append("&coalesce=true")
if (after != null) append("&after=").append(after)
}
return requestFromServer(settings, "/sessions/$sessionId/transcript$query") { connection ->
val body = JSONArray(connection.inputStream.bufferedReader().readText())
(0 until body.length()).map { parseSeqEvent(body.getJSONObject(it).toString()) }
// The text as well as the event: the transcript cache stores the one and the fold needs
// the other, and they have to be the same line -- a second entry point differing only in
// return type would be two answers to one question.
(0 until body.length()).map {
val line = body.getJSONObject(it).toString()
line to parseSeqEvent(line)
}
}
}
@@ -27,12 +27,19 @@ class EventStream(settings: ServerSettings, private val sessionId: String) {
* caller drops what it holds and rebuilds -- the same thing it does when the screen opens. It
* arrives before those events, so a caller that clears on it stays in order.
*/
fun run(after: Long, onOpen: () -> Unit, onReset: () -> Unit, onEvent: (SeqEvent) -> Unit) {
fun run(
after: Long,
onOpen: () -> Unit,
onReset: () -> Unit,
// The frame's own text as well as the event parsed from it: the transcript cache stores
// the one and the screen folds the other, and they have to be the same line.
onEvent: (raw: String, event: SeqEvent) -> Unit,
) {
stream.run("/sessions/$sessionId/events?after=$after", onOpen) { name, data ->
// A named frame carries no payload and a data frame has no name, so this is one or
// the other.
if (name == RESET_EVENT) onReset()
else if (data.isNotEmpty()) onEvent(parseSeqEvent(data))
else if (data.isNotEmpty()) onEvent(data, parseSeqEvent(data))
}
}
}
@@ -667,15 +667,6 @@ private fun ImportableList(
}
}
/** A byte count at the coarsest unit that still says something, so rows stay comparable. */
private fun humanSize(bytes: Long): String? =
when {
bytes <= 0L -> null
bytes >= 1_000_000L -> "${bytes / 1_000_000L} MB"
bytes >= 1_000L -> "${bytes / 1_000L} kB"
else -> "$bytes B"
}
/** What this session is: the measurements, in the order they are worth knowing. */
private fun statsOf(session: Importable): String =
listOfNotNull(
@@ -29,6 +29,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -68,6 +69,11 @@ fun SessionListScreen(
// request rather than to the session.
var deleting by remember { mutableStateOf<Set<String>>(emptySet()) }
// This phone's copies of these sessions' transcripts, pruned from here because this is where
// a session stops existing. See TranscriptCache.
val context = LocalContext.current
val transcriptCache = remember(settings) { TranscriptCache(cacheRoot(context, settings)) }
fun refresh() {
listState = LoadState.Loading
scope.launch {
@@ -76,6 +82,14 @@ fun SessionListScreen(
val loaded =
withContext(Dispatchers.IO) { LoadState.Loaded(fetchSessions(settings)) }
deleteErrors = emptyMap()
// The path out for a cached transcript whose session was deleted somewhere
// else -- from another device, or at the backend. This list is the only place
// that ever learns the full set, and what the residue costs here is megabytes
// rather than a draft's few bytes. On the answer rather than in `finally`: a
// list that failed to arrive says nothing about which sessions exist.
withContext(Dispatchers.IO) {
transcriptCache.retainOnly(loaded.value.map { it.id }.toSet())
}
loaded
} catch (e: ApiException) {
LoadState.failed(e)
@@ -223,6 +237,9 @@ fun SessionListScreen(
try {
withContext(Dispatchers.IO) {
deleteSession(settings, session.id, alsoDeleteForeign)
// After it succeeded, not before: a refused delete leaves the
// session exactly as it was, and its transcript with it.
transcriptCache.session(session.id).purge()
}
// Only this row, and only what changed. Refetching the list
// instead put every other session back through loading and
@@ -81,7 +81,6 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.repeatOnLifecycle
import java.util.concurrent.atomic.AtomicLong
import java.util.concurrent.atomic.AtomicReference
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.delay
@@ -324,7 +323,27 @@ fun SessionScreen(
val lifecycleOwner = LocalLifecycleOwner.current
// The resume cursor, written from the stream's IO thread.
val lastSeq = remember { AtomicLong(0) }
val activeStream = remember { AtomicReference<EventStream?>(null) }
// Bumped to rebuild this screen from nothing -- what Reload in the settings dialog does. It
// keys everything that describes one visit to this session: the source below, the opening
// effect, the stream, and the anchor being put back. See TRANSCRIPT_CACHE.md's decision 8.
var epoch by remember(summary.id) { mutableIntStateOf(0) }
// This server's cached transcripts, and this session's half of them. The cache is per server
// because two servers can hold a session with the same id; the source is per visit because
// Reload throws away what it was reading from.
val cache = remember(settings) { TranscriptCache(cacheRoot(context, settings)) }
val source =
remember(summary.id, epoch) {
TranscriptSource(settings, summary.id, cache.session(summary.id))
}
// Whether the cached tail has been shown to still be the server's own line. Nothing is
// resumed from a cached cursor until it has -- see [TranscriptSource.probe] -- and a probe
// that could not be made leaves this false for the stream loop to try again.
var probePassed by remember(summary.id, epoch) { mutableStateOf(false) }
// Whether the opening effect is still settling that question. It draws the cached rows and
// lifts [ready] before the answer arrives, which is the point of the cache -- so the stream
// below has to wait for this rather than for `ready`, or it asks the very same question a
// second time and races the answer.
var probing by remember(summary.id, epoch) { mutableStateOf(true) }
// The oldest sequence number loaded, and whether there is more behind
// it. Paging backwards is what keeps opening a long session cheap: the
// screen starts with the end of the conversation and fetches earlier
@@ -333,12 +352,12 @@ fun SessionScreen(
// Where this session was last being read, from this device's own store. Read once, because
// it is the question "where did I leave off" and the answer stops being interesting the
// moment the list is on screen.
val savedAnchor = remember(summary.id) { loadScrollAnchor(context, summary.id) }
val savedAnchor = remember(summary.id, epoch) { loadScrollAnchor(context, summary.id) }
// Whether the saved position is still being put back -- the history it needs fetched, and the
// scroll applied. Nothing is drawn while it is: opening at the newest end and then travelling
// to the anchor is exactly the journey a reader must never see, and this transcript is not
// allowed to move under one.
var restoring by remember(summary.id) { mutableStateOf(savedAnchor != null) }
var restoring by remember(summary.id, epoch) { mutableStateOf(savedAnchor != null) }
// Sent, but not yet read by the session -- which is when the backend
// records it and it comes back as a row. Until then it is drawn below
// the working indicator, because that is where it is in the session's
@@ -400,6 +419,31 @@ fun SessionScreen(
val currentUnits by rememberUpdatedState(units)
val lastTouch = remember { LastTouch() }
/**
* Drops everything loaded, so the screen can be rebuilt from a window that is not adjacent to
* it.
*
* One function rather than a clearing written at each of the three places that need it -- a
* stream reset, a cached transcript the server turns out not to have, and Reload -- because
* what has to go is a property of "these rows are no longer continuous with what comes next",
* not of who noticed. The two easy ones to leave out are [queued] and [waitingCommands]: both
* are folded from events, so a `messageQueued` whose resolving `userMessage` fell in the gap
* draws a bubble waiting for a message the session read long ago. [contextTokens] needs no
* clearing, because `UsageDelta.context` is absolute and the next one corrects it.
*
* The resume cursor is deliberately *not* cleared here: a reset continues from where it was,
* and only a caller that is starting the conversation again from the server says so itself.
*/
fun dropLoadedTranscript() {
items = listOf()
replies.clear()
held = listOf()
oldestSeq = 0L
moreHistory = true
queued = listOf()
waitingCommands = listOf()
}
/**
* Everything the transcript list draws, from one event.
*
@@ -614,14 +658,7 @@ fun SessionScreen(
// `items` read below happens back on the caller's thread, where the write does too.
val page =
withContext(Dispatchers.IO) {
val older =
fetchTranscript(
settings,
summary.id,
before = oldestSeq,
limit = limit,
coalesce = coalesce,
)
val older = source.page(before = oldestSeq, limit = limit, coalesce = coalesce)
if (older.isEmpty()) return@withContext null
// Folded oldest-first into a list of their own, then put in front: `foldEvent`
// merges streaming text into the item before it, so replaying an older page
@@ -694,17 +731,22 @@ fun SessionScreen(
// The stream lifecycle: connect, follow, and on any drop reconnect
// from the cursor -- so a flaky link (or a backend restart) costs
// nothing but the gap's latency.
// The newest page first, in one request, before the stream opens. The
// stream then starts from where that page ended, so it carries live
// events only -- which is what it is good at.
LaunchedEffect(summary.id) {
try {
val page = withContext(Dispatchers.IO) { fetchTranscript(settings, summary.id) }
// Warmed before the fold lands rather than after: flattening the rows into units
// splits every settled reply ([transcriptUnits]), and the flatten runs in the
// composition that first sees the rows. Folded into a scratch list off this thread
// to find out what needs warming; the real fold below also maintains the queue and
// the cursor, so it cannot be reused here.
// The newest window first, before the stream opens, so the stream starts from where that
// window ended and carries live events only -- which is what it is good at. The window comes
// from this phone's own copy when there is one, and then costs a single request to check
// that the server's transcript is still the one it came from; otherwise it is a page fetched
// as it always was. See TRANSCRIPT_CACHE.md.
LaunchedEffect(summary.id, epoch) {
/**
* One opening window onto the screen, whichever side it came from.
*
* Warmed before the fold lands rather than after: flattening the rows into units splits
* every settled reply ([transcriptUnits]), and the flatten runs in the composition that
* first sees the rows. Folded into a scratch list off this thread to find out what needs
* warming; the real fold below also maintains the queue and the cursor, so it cannot be
* reused here.
*/
suspend fun open(page: List<SeqEvent>) {
withContext(Dispatchers.IO) {
var scratch = listOf<TranscriptItem>()
page.forEach { entry ->
@@ -715,6 +757,58 @@ fun SessionScreen(
warm(replies, scratch)
}
page.forEach { apply(it) }
}
try {
// This phone's own copy first, drawn before anything is asked of the server -- which
// is the whole point of the cache. What makes it safe to draw before it is checked is
// that a failed check replaces these rows, with the same appearance as a reset.
val cached = withContext(Dispatchers.IO) { source.cachedOpening() }
if (cached != null) {
open(cached)
// A replay is as old as the last visit; the row this screen was opened from was
// fetched moments ago. So the transcript comes from the cache and everything that
// is not the transcript comes from the summary, which is the newer measurement of
// the same thing -- otherwise a session that finished an hour ago opens saying
// "working" until the stream connects, which is a status row lying for a round
// trip.
status = summary.status
model = summary.model
permissionMode = summary.permissionMode ?: "auto"
if (summary.status != "compacting") compactingSince = null
// Nothing to put back, so these rows are the screen and the probe can return
// under them. A restore still has history to fetch and is gated below.
if (savedAnchor == null) ready = true
}
// The one thing a cached cursor has to be shown before the stream resumes from it.
val usable = cached != null && withContext(Dispatchers.IO) { source.probe() }
if (usable) probePassed = true
if (!usable) {
// Either there was nothing cached, or what was cached is not what the server
// has -- the file was replaced or truncated under it. Same clearing as a reset,
// then an ordinary cold open.
if (cached != null) {
dropLoadedTranscript()
lastSeq.set(0)
}
open(withContext(Dispatchers.IO) { source.fetchOpening() })
// Refilled from the server, so the tail is the server's by construction.
probePassed = true
}
} catch (e: ApiException) {
// Not fatal: the stream below still replays from zero, which is slow but complete.
// Saying so beats silently showing nothing.
//
// It is also where a probe that could not be *made* lands -- a phone with no route to
// the server. Whatever was cached stays on screen and [probePassed] stays false, so
// the stream loop asks again before it resumes from that cursor.
streamError = e.message
} finally {
// However that went, the stream is free to take it from here.
probing = false
}
try {
// Then back where reading stopped. An anchor deeper than the newest page is exactly
// the one worth restoring -- somebody who read to the bottom has no anchor at all --
// and the cost was already paid on the way down there.
@@ -792,8 +886,8 @@ fun SessionScreen(
}
}
} catch (e: ApiException) {
// Not fatal: the stream below still replays from zero, which is
// slow but complete. Saying so beats silently showing nothing.
// A page of history that never arrived. The reader is left at the newest end rather
// than where they were, which is the state this screen opens in anyway.
streamError = e.message
}
// Whatever happened above, including a page that never arrived: an empty transcript is a
@@ -816,6 +910,13 @@ fun SessionScreen(
loadingHistory = false
}
}
// Last, and off this thread: this session is what must not be evicted, so it is marked
// as visited before the budget is applied, and both are a walk of the cache directory
// that nothing on screen is waiting for.
withContext(Dispatchers.IO) {
source.cache.touch()
cache.evictToBudget(keep = summary.id)
}
}
// Only while the screen is actually on screen. Android stops the
@@ -826,16 +927,37 @@ fun SessionScreen(
// that has been backgrounded is work nobody is watching. Stopping the
// stream deliberately makes the drop a close rather than an error (see
// EventStream.close), and resuming reconnects from the same cursor.
LaunchedEffect(summary.id, ready, lifecycleOwner) {
LaunchedEffect(summary.id, ready, epoch, lifecycleOwner) {
if (!ready) return@LaunchedEffect
// The opening effect draws cached rows and lifts `ready` *before* it has checked that the
// cursor under them is still the server's, so `ready` is no longer the whole gate: this
// waits for that check to settle. Without it the two run at once, ask the same question
// twice, and race each other's answer -- two probes per warm open in the server's log.
snapshotFlow { probing }.first { !it }
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
try {
while (true) {
val stream = EventStream(settings, summary.id)
activeStream.set(stream)
try {
// A cached cursor whose probe never got an answer, because the server
// could not be reached when the screen opened. Resuming a stream from an
// unchecked cursor is the one thing this must not do, so it is asked
// again here, on the reconnect schedule, with the cached rows still on
// screen meanwhile. False covers both answers that mean "open cold":
// the file is not the one these rows came from, and there was nothing
// cached to check.
if (!probePassed) {
if (withContext(Dispatchers.IO) { source.probe() }) {
probePassed = true
} else {
dropLoadedTranscript()
lastSeq.set(0)
withContext(Dispatchers.IO) { source.fetchOpening() }
.forEach { apply(it) }
probePassed = true
}
}
withContext(Dispatchers.IO) {
stream.run(
source.follow(
after = lastSeq.get(),
// Connected, measured rather than inferred: this is what
// takes a failure off the screen, and nothing else does.
@@ -850,11 +972,10 @@ fun SessionScreen(
// is what makes this the same as opening the
// screen -- `apply` refills them, and scrolling
// up pages the rest back in as it always does.
items = listOf()
replies.clear()
held = listOf()
oldestSeq = 0L
moreHistory = true
// The cache needs no telling: the window's first
// seq is not the seq it was expecting, which is
// what closes its live run and starts another.
dropLoadedTranscript()
},
) { entry ->
apply(entry)
@@ -870,7 +991,7 @@ fun SessionScreen(
// closing the app over. Reported on the screen either way.
streamError = e.message ?: e::class.simpleName
} finally {
stream.close()
source.close()
}
delay(RECONNECT_DELAY_MS)
}
@@ -878,14 +999,15 @@ fun SessionScreen(
// Cancellation -- going below STARTED, or leaving the screen --
// cannot interrupt a blocking socket read. Closing is what
// unblocks it, and what marks the drop deliberate.
activeStream.getAndSet(null)?.close()
source.close()
}
}
}
// The screen going away entirely, which the lifecycle scope above does
// not cover: a composable can leave the composition while the activity
// stays started.
DisposableEffect(summary.id) { onDispose { activeStream.get()?.close() } }
// stays started. Keyed on the epoch as well, so that Reload's replacement
// source is the one a later disposal closes.
DisposableEffect(summary.id, epoch) { onDispose { source.close() } }
// Nothing gets announced about the session somebody is reading; see NotificationService.
// RESUMED rather than STARTED because "looking at it" means the foreground -- a session left
@@ -1936,10 +2058,32 @@ fun SessionScreen(
UsageDialog(feed = usageFeed, onDismiss = { usageOpen = false })
}
if (settingsOpen) {
// Measured when the dialog opens rather than kept up to date: what the reader is being
// told is what pressing the button now would discard, and null until the walk of the
// directory returns is what not knowing looks like.
var cachedBytes by remember(summary.id, epoch) { mutableStateOf<Long?>(null) }
LaunchedEffect(summary.id, epoch) {
cachedBytes = withContext(Dispatchers.IO) { source.cache.bytes() }
}
SessionSettingsDialog(
settings = settings,
sessionId = summary.id,
title = title,
cachedBytes = cachedBytes,
// The purge finishes before the epoch moves, because the relaunched opening effect
// reads the same directory and would otherwise draw what is about to be deleted.
// Everything else here is the clearing a cold open needs; the epoch is what makes it
// one, by rebuilding the opening effect, the stream, and the anchor being put back.
onReload = {
settingsOpen = false
scope.launch {
withContext(Dispatchers.IO) { source.cache.purge() }
dropLoadedTranscript()
lastSeq.set(0)
ready = false
epoch++
}
},
// The header takes the new name at once and the dialog closes on it, because the
// rename has already been accepted by the server -- see [title], which is this app's
// own datum. The list behind this refetches on the way out of the session anyway.
@@ -42,9 +42,11 @@ import kotlinx.coroutines.withContext
* are changed *while* reading a turn -- "not this model, try that one" -- and a control belongs
* with the thing it acts on.
*
* Nothing here is captioned. Each control is a labelled noun with a switch or a field beside it,
* and a paragraph under every one of them made the dialog longer than the conversation it covers.
* Failures still get their words: those are what the reader cannot work out by looking.
* Captions are for what a control costs rather than for what it is. Each control is a labelled noun
* with a switch or a field beside it, and a paragraph under every one of them made the dialog
* longer than the conversation it covers -- so Notifications has none, while Move and Reload do,
* because what those two take away is not visible from here. Failures get their words for the same
* reason: they are what the reader cannot work out by looking.
*/
@Composable
fun SessionSettingsDialog(
@@ -55,6 +57,12 @@ fun SessionSettingsDialog(
*/
title: String,
onRenamed: (String) -> Unit,
/**
* What this phone is holding of the conversation, or null while that is being measured -- see
* the Reload row below, which is what would discard it.
*/
cachedBytes: Long?,
onReload: () -> Unit,
onDismiss: () -> Unit,
) {
val scope = rememberCoroutineScope()
@@ -250,6 +258,44 @@ fun SessionSettingsDialog(
style = MaterialTheme.typography.bodySmall,
)
}
Spacer(Modifier.height(8.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Text("Transcript", modifier = Modifier.weight(1f))
// The size is what the button discards, and the unknown state is drawn
// rather than guessed: a spinner while the directory is being measured, and
// words when there is nothing there, because "nothing cached" and "0 B" read
// as different claims.
when {
cachedBytes == null ->
CircularProgressIndicator(
modifier = Modifier.width(16.dp).height(16.dp),
strokeWidth = 2.dp,
)
else ->
Text(
humanSize(cachedBytes)?.let { "$it cached" } ?: "nothing cached",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(Modifier.width(12.dp))
// Enabled whether or not anything is cached: "what I see disagrees with the
// machine" is a state an empty cache can be in too, and a control that comes
// and goes makes its own presence the signal.
TextButton(onClick = onReload) { Text("Reload") }
}
// Captioned, unlike the controls above it, for the same reason Move is: what it
// costs is not visible, and neither is the case it exists for.
Text(
"Reload throws away this phone's copy and fetches the transcript from the " +
"server again. Use it when what is shown here disagrees with the file " +
"on the machine.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
error?.let {
Spacer(Modifier.height(8.dp))
Text(
@@ -0,0 +1,20 @@
package com.example.aiapp
/**
* A byte count at the coarsest unit that still says something, so two of them stay comparable.
*
* Null for nothing at all, which is a different answer from a small number and is drawn with words
* rather than a figure: an import row with no size says nothing about size, and a transcript cache
* holding nothing says "nothing cached".
*
* Here rather than beside either caller because a second copy of it would drift, and there is
* already one variant too many -- `ModelsScreen`'s `gigabytes` writes a download's size to two
* decimal places, which is a different question about a much larger number.
*/
fun humanSize(bytes: Long): String? =
when {
bytes <= 0L -> null
bytes >= 1_000_000L -> "${bytes / 1_000_000L} MB"
bytes >= 1_000L -> "${bytes / 1_000L} kB"
else -> "$bytes B"
}
@@ -0,0 +1,621 @@
package com.example.aiapp
import android.util.Log
import java.io.BufferedWriter
import java.io.File
import java.io.FileWriter
import java.io.IOException
import java.io.RandomAccessFile
/**
* This phone's copy of the transcripts it has already been sent, so reopening a session does not
* download it again.
*
* What is stored is the server's own JSON for one event per line, in transcript order -- the
* elements of a `/transcript` page and the payload of each SSE frame. Reading the cache means
* running the same [parseSeqEvent] the network path runs, so a cached transcript and a fetched one
* cannot draw differently, and an event type this build does not know ([SessionEvent.Unknown])
* keeps every field it arrived with, on disk, for the build that will. Rows are deliberately *not*
* what is stored: a row is a rendering of events, its shape changes whenever the fold does, and a
* cache of rows would need throwing away on every app update that touched `foldEvent`.
*
* See TRANSCRIPT_CACHE.md for the design. Four rules run through all of it:
* 1. what is on screen is what the server's transcript says, in order, with nothing missing -- the
* cache is a copy and is never inferred, folded or edited here;
* 2. a cached line is never ahead of the live cursor, and the cursor never ahead of the cache;
* 3. the cache is never load-bearing -- missing, evicted, damaged or unwritable all degrade to a
* cold open, never to a blank or a wrong screen;
* 4. a line already on the phone is not fetched again.
*
* A plain [File] root and no Compose, `Context` or network, so the whole of the file logic runs
* under the JVM unit tests. It is also why there is no JSON parser in here: what it needs off a
* line is the sequence number and whether the line is a streamed delta, and both are read with a
* regex over text the server wrote. A line it cannot read that way is treated as damage, which
* gives the same answer as having no cache at all.
*
* [warn] is where failures are said, for the same reason -- `android.util.Log` is a stub that
* throws under the JVM tests, and this file has to be exercisable there.
*/
class TranscriptCache(
private val root: File,
private val warn: (String) -> Unit = { Log.w("ai-app", it) },
) {
/** The cache for one session, whether or not anything has been stored for it yet. */
fun session(id: String): SessionCache = SessionCache(File(root, id), warn)
/**
* Deletes every session directory not in [ids], called after a successful list fetch.
*
* The path out for a session deleted on another device or at the backend: nothing here would
* otherwise ever hear about it, and unlike a draft's few bytes what it leaves behind is
* megabytes.
*/
fun retainOnly(ids: Set<String>) =
guardIo(Unit, warn) {
sessionDirs().forEach { if (it.name !in ids) it.deleteRecursively() }
}
/**
* Deletes least-recently-touched session directories, never [keep], until the whole of this
* server's cache is under [budget].
*
* Least-recently-touched rather than largest: what a reader is likely to open again is what
* they opened last, and evicting the big ones first would empty the cache for exactly the
* conversations it exists for.
*/
fun evictToBudget(keep: String, budget: Long = CACHE_BUDGET_BYTES) =
guardIo(Unit, warn) {
val dirs = sessionDirs().sortedBy { it.lastModified() }
var total = dirs.sumOf { sizeOf(it) }
for (dir in dirs) {
if (total <= budget) break
if (dir.name == keep) continue
val was = sizeOf(dir)
if (dir.deleteRecursively()) total -= was
}
}
fun purgeAll() = guardIo(Unit, warn) { root.deleteRecursively() }
private fun sessionDirs(): List<File> = root.listFiles()?.filter { it.isDirectory }.orEmpty()
}
/**
* How much of this phone's cache directory all of one server's transcripts may take.
*
* A dozen of the largest transcripts seen in the dev VM (21 MB for 24,000 events) and a small
* fraction of a phone. A number to revisit against real use rather than a measurement of anything.
*/
const val CACHE_BUDGET_BYTES: Long = 256L * 1000 * 1000
/**
* What the newest cached line says, which is what the probe checks against the server.
*
* Both halves are wanted together and by the same caller: the seq is what the request asks about,
* and the line is what its answer is compared with.
*/
data class CachedTail(val seq: Long, val line: String)
/**
* One session's cached lines, as a directory of chunks.
*
* A chunk is a set of lines *and a claim about what they cover*, and the two are not the same
* thing: a coalesced page joins each run of streamed deltas into one event carrying the seq of the
* run's oldest delta, so a page whose newest event is seq 1,200 may in fact cover everything up to
* the 1,650 it was fetched with, and nothing in the lines says so. So coverage is the half-open
* range in the file's name:
* ```
* <first>-<end>.rows.jsonl a coalesced page; end is the `before` it was fetched with
* <first>-<end>.raw.jsonl an uncoalesced page, or a closed live run
* <first>-open.raw.jsonl the live run; end is its last line's seq + 1
* ```
*
* Two chunks are adjacent when one's `end` is the other's `first`. Only the contiguous run of
* adjacent chunks ending at the newest chunk -- the **suffix** -- is ever served: chunks behind a
* gap are kept, because the gap is usually closed by paging back through it, but nothing is served
* across one.
*
* **The newest chunk is always raw**, which is what makes the stream cursor and the probe well
* defined -- a raw chunk's last line is a real event at a real seq, and the server never coalesces
* the newest window. It holds by construction (the opening window and every stream frame are raw)
* and is checked on read: a `.rows` chunk at the newest end can only mean this app died between
* closing one live run and opening the next, and it discards the session.
*
* Nothing here is load-bearing. Every operation that touches the disk answers as though the cache
* were empty when it cannot, and a write failure disables writing for the rest of this instance's
* life so that a full disk costs one log line rather than one per delta.
*
* Every operation is synchronized, because two of them really do run at once: the stream appends
* live events from its own IO thread while a reader scrolling back reads pages from another. The
* lock is uncontended in the ordinary case and what it buys is that the open chunk's name, its end
* and its writer are never read half-rotated -- which would show up as a page silently fetched
* again, or as a stored chunk overlapping the run it was written beside.
*/
class SessionCache(
private val dir: File,
private val warn: (String) -> Unit = { Log.w("ai-app", it) },
) {
/** Set by the first write that fails: a second would fail the same way, once per delta. */
private var disabled = false
/**
* The open chunk's writer, its file, and the seq that chunk now ends at.
*
* Buffered, and flushed on [flush], because a delta is a hundred bytes and arrives dozens of
* times a second while a reply streams -- a syscall each is the thing to avoid. What that costs
* is the unflushed tail on a crash, which is safe: a shorter cache is a longer catch-up, never
* a wrong one.
*/
private var writer: BufferedWriter? = null
private var openFile: File? = null
private var openEnd: Long = 0
/**
* The newest line of the suffix, or null when there is none or the newest chunk is not raw.
*
* This is the cursor the live stream would resume from, so it is also what has to be shown to
* still be the server's own line before anything is resumed from it -- see
* `TranscriptSource.probe`.
*/
@Synchronized
fun tail(): CachedTail? =
guard(null) {
val newest = suffix().lastOrNull() ?: return@guard null
var found: CachedTail? = null
eachLine(newest) { line ->
found = CachedTail(seqOf(line)!!, line)
false
}
found
}
/** The newest [limit] lines of the suffix, oldest first -- the opening window. */
@Synchronized
fun newest(limit: Int): List<String> =
guard(emptyList()) {
val taken = ArrayDeque<String>()
for (chunk in suffix().asReversed()) {
if (taken.size >= limit) break
eachLine(chunk) { line ->
taken.addFirst(line)
taken.size < limit
}
}
taken.toList()
}
/**
* The page of lines before [before], oldest first, or null when the cache cannot answer.
*
* Null is a miss -- the suffix does not cover the ground immediately below [before] -- and
* means the server has to be asked. It is deliberately not an empty list: an empty page is how
* the screen is told it has reached the start of the conversation, and a cache saying that of
* history it merely does not hold would stop the transcript scrolling back for good.
*
* [before] is anywhere inside the suffix, not only at a chunk boundary. The cursor a warm open
* leaves behind is in the middle of the live run -- the screen draws the newest eighty lines of
* it -- so a cache that could only answer at a boundary would send the very first backwards
* page to the server and, since that page would overlap the run, keep none of it.
*
* A short page is fine, and is what a walk that reaches the oldest chunk of the suffix returns:
* the caller already treats a short page as a page.
*
* With [rows] the count is rows rather than lines, mirroring the server's `parse_coalesced`:
* every event that is not a streamed delta is a row, and each maximal run of deltas is one row.
* The deltas are not joined here -- `foldEvent` does that, and the joined row keeps the seq of
* its first delta either way, so anchors and the next `before` land where they do today.
*/
@Synchronized
fun page(before: Long, limit: Int, rows: Boolean): List<String>? =
guard(null) {
val suffix = suffix()
val newest = suffix.lastOrNull() ?: return@guard null
// Above what is held, or at or below where it starts: either way the run the caller
// is scrolling into is not continuous with this one, and only the server has it.
if (before > newest.end || before <= suffix.first().first) return@guard null
val taken = ArrayDeque<String>()
var counted = 0
var inRun = false
var wanting = true
for (chunk in suffix.asReversed()) {
if (!wanting) break
if (chunk.first >= before) continue
eachLine(chunk) { line ->
// The page is what is *before* the cursor; the rows at or above it are the
// ones already on screen.
if (seqOf(line)!! >= before) return@eachLine true
if (rows) {
val delta = isDelta(line)
// Stop only between rows: a delta continuing the run being gathered is
// part of a row already counted, and breaking on it would drop the half
// of that row already taken.
if (counted >= limit && !(delta && inRun)) wanting = false
else {
if (!delta || !inRun) counted++
inRun = delta
}
} else if (taken.size >= limit) {
wanting = false
}
if (wanting) taken.addFirst(line)
wanting
}
}
taken.toList()
}
/**
* The `end` of the nearest chunk at or below [before], which is the floor a fetched page is
* asked with so that it stops where this phone's copy starts. Null when there is no such chunk.
*
* Any chunk, not only the suffix's: the whole point is to reach the run behind a gap, so that
* the gap is closed with exactly the bytes it is wide and the history behind it is served
* locally from then on.
*/
@Synchronized
fun coveredUpTo(before: Long): Long? =
guard(null) { chunks().map { it.end }.filter { it <= before }.maxOrNull() }
/**
* Stores a fetched page covering `[first, end)`; false when it was not stored.
*
* Refused when it overlaps a chunk already here, because there is no clean cut: a coalesced
* event cannot be split at a seq inside its own delta run. `TranscriptSource` keeps that from
* arising by bounding what it fetches, and this is the guard for a page that arrives anyway --
* from a server without the `after` parameter, say. Such a page is still drawn; it is only not
* kept.
*
* The newest chunk is never stored through here: the opening window and every live frame go
* through [append], which is what keeps the newest chunk raw and open.
*/
@Synchronized
fun storePage(lines: List<String>, first: Long, end: Long, rows: Boolean): Boolean =
guard(false) {
if (disabled || lines.isEmpty() || end <= first) return@guard false
if (chunks().any { first < it.end && it.first < end }) return@guard false
dir.mkdirs()
val kind = if (rows) "rows" else "raw"
File(dir, "$first-$end.$kind.jsonl").writeText(lines.joinToString("\n", postfix = "\n"))
true
}
/**
* Appends one live event, which is also how a freshly fetched opening window is stored.
*
* A seq equal to the open chunk's end extends it. A larger one is a gap -- which is what a
* `reset` looks like from here -- and closes the open chunk under the end it turned out to have
* before starting a new one at [seq]. A smaller one is already covered and is ignored; the SSE
* contract is `seq > after`, so that is a guard rather than a path.
*/
@Synchronized
fun append(line: String, seq: Long) =
guard(Unit) {
if (disabled) return@guard
val writer = writerFor(seq) ?: return@guard
// Written as it arrived. A newline inside it would split one event into two
// unreadable halves, but neither source can produce one: SSE framing forbids it, and
// a page's elements are re-serialized compactly, which escapes it.
writer.write(line)
writer.write("\n")
openEnd = seq + 1
}
/**
* Flushes what [append] has buffered. Called on each `Status` event -- the boundaries of a
* turn, which is the granularity a crash may as well lose -- and when the stream closes.
*/
@Synchronized fun flush() = guard(Unit) { writer?.flush() }
/** What [purge] would discard, for the reload row in session settings. */
@Synchronized fun bytes(): Long = guard(0L) { sizeOf(dir) }
/** Marks this session as visited, which is what eviction ranks by. */
@Synchronized
fun touch() =
guard(Unit) { if (dir.isDirectory) dir.setLastModified(System.currentTimeMillis()) }
@Synchronized
fun purge() =
guard(Unit) {
closeWriter()
dir.deleteRecursively()
}
// -- chunks ------------------------------------------------------------------------------
private data class Chunk(val file: File, val first: Long, val end: Long, val open: Boolean) {
val rows: Boolean
get() = file.name.endsWith(".rows.jsonl")
}
/**
* Every chunk on disk, oldest first. A name this does not recognise is not ours and is ignored.
*
* Recomputed per operation rather than kept: another operation may have changed the directory,
* and a hundred names is a directory listing.
*/
private fun chunks(): List<Chunk> {
writer?.flush()
return dir.listFiles()
.orEmpty()
.mapNotNull { file ->
val match = CHUNK_NAME.matchEntire(file.name) ?: return@mapNotNull null
val first = match.groupValues[1].toLongOrNull() ?: return@mapNotNull null
val open = match.groupValues[2] == "open"
val end = if (open) openEndOf(file, first) else match.groupValues[2].toLongOrNull()
// A chunk covering nothing is one that was created and never written to -- an
// append whose very first write failed. It says nothing, so it is not a chunk.
if (end == null || end <= first) null else Chunk(file, first, end, open)
}
.sortedBy { it.first }
}
/**
* The open chunk's end: its last line's seq plus one, or the in-memory end while this instance
* is the one writing it.
*
* An open chunk whose last line cannot be read is this app having died mid-write. That line is
* dropped and the file truncated to the last good one before anything is served from it, which
* is the one place damage is repaired rather than discarded: the tail of an append-only file is
* the only place a partial line can be.
*/
private fun openEndOf(file: File, first: Long): Long {
if (openFile == file && openEnd > 0) return openEnd
repairTail(file)
var end = first
eachLineBackwards(file) { _, line ->
seqOf(line)?.let { end = it + 1 }
false
}
return end
}
/**
* The contiguous run of adjacent chunks ending at the newest one, oldest first.
*
* A newest chunk that is not raw cannot happen while this code is the only writer, and means
* the directory is not to be trusted -- so the session is discarded rather than served across
* whatever else is wrong with it.
*/
private fun suffix(): List<Chunk> {
val all = chunks()
var index = all.size - 1
val newest = all.lastOrNull() ?: return emptyList()
if (newest.rows) throw Damaged(newest.file)
val run = ArrayDeque<Chunk>()
run.addFirst(newest)
while (index > 0 && all[index - 1].end == run.first().first) {
index--
run.addFirst(all[index])
}
return run.toList()
}
/**
* Each line of [chunk], newest first, until [take] says stop.
*
* Backwards and lazily, because every question this cache is asked is about the newest end --
* the tail, the opening window, the page before a cursor -- and a live run grows to the size of
* the conversation. Reading the file whole to answer with eighty lines of it is the cost the
* server's own reader was rewritten to stop paying.
*
* Damage anywhere but at the tail of the open chunk was not written by this code, and there is
* no honest way to say what a chunk covers with a line of it unreadable -- so it discards the
* session rather than serving what it can read.
*/
private fun eachLine(chunk: Chunk, take: (String) -> Boolean) {
eachLineBackwards(chunk.file) { _, line ->
if (seqOf(line) == null) throw Damaged(chunk.file)
take(line)
}
}
// -- writing -----------------------------------------------------------------------------
/** The writer for the chunk [seq] belongs in, opening or rotating one as it has to. */
private fun writerFor(seq: Long): BufferedWriter? {
writer?.let { held ->
if (seq == openEnd) return held
if (seq < openEnd) return null
// A gap: what this instance has written covers up to `openEnd`, and that is the name
// the chunk gets before a new one starts at the arriving seq.
closeOpenChunk(openEnd)
}
dir.mkdirs()
// An open chunk left by an earlier instance, or by an earlier screen.
chunks()
.lastOrNull { it.open }
?.let { existing ->
if (seq < existing.end) return null
if (seq == existing.end) {
openFile = existing.file
openEnd = existing.end
return FileWriter(existing.file, true).buffered().also { writer = it }
}
rename(existing.file, existing.first, existing.end)
}
// A chunk that was created and never written to would otherwise be left behind under a
// name a second one is about to want; it covers nothing, so nothing is lost with it.
dir.listFiles().orEmpty().forEach {
if (CHUNK_NAME.matchEntire(it.name)?.groupValues?.get(2) == "open" && it.length() == 0L)
it.delete()
}
val file = File(dir, "$seq-open.raw.jsonl")
openFile = file
openEnd = seq
return FileWriter(file, false).buffered().also { writer = it }
}
/** Renames the open chunk to the range it turned out to cover, so it stops being open. */
private fun closeOpenChunk(end: Long) {
val file = openFile
closeWriter()
if (file == null) return
val first = CHUNK_NAME.matchEntire(file.name)?.groupValues?.get(1)?.toLongOrNull()
if (first != null) rename(file, first, end)
}
private fun rename(file: File, first: Long, end: Long) {
file.renameTo(File(dir, "$first-$end.raw.jsonl"))
}
private fun closeWriter() {
try {
writer?.close()
} catch (_: IOException) {
// Nothing left to do about it: the file is what it is, and the read path repairs a
// half-written tail.
}
writer = null
openFile = null
openEnd = 0
}
// -- failure -----------------------------------------------------------------------------
/** A chunk that cannot be read as what its name claims. */
private class Damaged(val file: File) : RuntimeException()
/**
* Runs [body], answering [ifBroken] when the directory cannot give a real answer.
*
* None of this is reported on screen: none of it changes what the screen shows -- every read
* here has a network path beside it producing the same result -- and the reader has nothing to
* do about it. It is logged, and damage discards this session's cache, which is what makes the
* next open an ordinary cold one.
*/
private fun <T> guard(ifBroken: T, body: () -> T): T =
// A disk that refused once will refuse again, once per delta, so the first refusal is
// also the last: this instance stops writing rather than logging a line a token.
guardIo(
ifBroken,
warn,
onFailure = {
disabled = true
closeWriter()
},
) {
try {
body()
} catch (e: Damaged) {
warn("transcript cache damaged at ${e.file}; discarding ${dir.name}")
closeWriter()
dir.deleteRecursively()
ifBroken
}
}
}
/** `<first>-<end|open>.<rows|raw>.jsonl`; anything else in the directory is not ours. */
private val CHUNK_NAME = Regex("""^(\d+)-(\d+|open)\.(rows|raw)\.jsonl$""")
private val SEQ_IN_LINE = Regex(""""seq"\s*:\s*(\d+)""")
private val TYPE_IN_LINE = Regex(""""type"\s*:\s*"([^"]*)"""")
/**
* One line's sequence number, or null when the line is not one of ours.
*
* A regex rather than a JSON parse, so that this file carries no parser and runs under the JVM
* tests: the seq is the first field the server writes (`SeqEvent`'s declaration order, with the
* event flattened after it), so the first match is the top-level one.
*/
private fun seqOf(line: String): Long? = SEQ_IN_LINE.find(line)?.groupValues?.get(1)?.toLongOrNull()
/** Whether a line is one streamed piece of a reply, which is what makes a run of them one row. */
private fun isDelta(line: String): Boolean =
TYPE_IN_LINE.find(line)?.groupValues?.get(1) == "assistantText"
/**
* How much of a file is read at a time when walking it backwards. One block covers a page of a
* transcript comfortably, and the walk stops as soon as the caller has what it asked for.
*/
private const val READ_BLOCK = 64 * 1024
/**
* Calls [onLine] with each non-blank line of [file], **newest first**, along with the byte offset
* it starts at, until [onLine] answers false.
*
* Every question the cache is asked is about the newest end of a chunk, and a live run reaches the
* size of the conversation, so reading forwards means reading a transcript to answer with the last
* eighty lines of it. This reads blocks from the end and stops where the caller stops.
*
* Splitting on bytes is safe because the separator is `\n`, which cannot occur inside a multi-byte
* UTF-8 sequence; each line is decoded whole, so nothing is cut through a character. A missing file
* yields nothing, which is the same answer as an empty one.
*/
private fun eachLineBackwards(file: File, onLine: (offset: Long, line: String) -> Boolean) {
if (!file.isFile) return
RandomAccessFile(file, "r").use { handle ->
// Bytes below `unread` have not been looked at; `pending` is the oldest line so far, which
// is incomplete until a newline is found before it in an older block.
var unread = handle.length()
var pending = ByteArray(0)
while (unread > 0) {
val take = minOf(READ_BLOCK.toLong(), unread).toInt()
val start = unread - take
val block = ByteArray(take)
handle.seek(start)
handle.readFully(block)
val buffer = if (pending.isEmpty()) block else block + pending
var lineEnd = buffer.size
var at = buffer.size - 1
while (at >= 0) {
if (buffer[at] == NEWLINE) {
val line = String(buffer, at + 1, lineEnd - at - 1, Charsets.UTF_8)
if (line.isNotBlank() && !onLine(start + at + 1, line)) return
lineEnd = at
}
at--
}
pending = buffer.copyOfRange(0, lineEnd)
unread = start
}
// The first line of a file has no newline before it to be found.
val first = String(pending, Charsets.UTF_8)
if (first.isNotBlank()) onLine(0, first)
}
}
private const val NEWLINE = '\n'.code.toByte()
/**
* Drops a final line that is not one of ours, by truncating the file to where it starts.
*
* This app having died mid-write is the one kind of damage that is repaired rather than discarded:
* the tail of an append-only file is the only place a partial line can be, and everything before it
* is intact. A second bad line is not this, and is left for the read path to notice.
*/
private fun repairTail(file: File) {
var truncateTo = -1L
eachLineBackwards(file) { offset, line ->
if (seqOf(line) == null) truncateTo = offset
false
}
if (truncateTo >= 0) RandomAccessFile(file, "rw").use { it.setLength(truncateTo) }
}
private fun sizeOf(file: File): Long =
if (file.isDirectory) file.listFiles().orEmpty().sumOf { sizeOf(it) } else file.length()
/**
* The disk half of [SessionCache.guard], shared with [TranscriptCache]'s own maintenance.
*
* [onFailure] is what the caller does about it beyond answering [ifBroken] -- for a session's
* cache, giving up on writing.
*/
private fun <T> guardIo(
ifBroken: T,
warn: (String) -> Unit,
onFailure: () -> Unit = {},
body: () -> T,
): T =
try {
body()
} catch (e: IOException) {
warn("transcript cache unusable: ${e.message}")
onFailure()
ifBroken
} catch (e: SecurityException) {
warn("transcript cache unreadable: ${e.message}")
onFailure()
ifBroken
}
@@ -5,9 +5,14 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* What the transcript renders: the event stream folded into displayable rows (see [foldEvent]). The
* stream is the only data source -- opening a session screen replays from seq 0, and a reconnect
* resumes from the last seq seen, so there is no separate history fetch to drift from it.
* What the transcript renders: the event stream folded into displayable rows (see [foldEvent]).
*
* Events are the only data source, and there is deliberately no second shape for history to drift
* from: a page fetched backwards, a live frame, and a line read out of this phone's own cache are
* all the same events through the same parser. Since 2026-09-04 the cache is where most of them
* come from on a session opened again -- see [TranscriptCache], which stores the server's lines
* rather than these rows for exactly that reason: a row is a rendering, and its shape changes
* whenever this file does.
*/
@Immutable
sealed class TranscriptItem {
@@ -0,0 +1,183 @@
package com.example.aiapp
import android.content.Context
import java.io.File
import java.util.concurrent.atomic.AtomicReference
/**
* Where the session screen gets a transcript from: this phone's copy first, the server for the
* rest.
*
* One seam rather than a cache the screen has to remember to consult. Everything it fetched before
* -- the opening window, the pages it scrolls back through, the span an anchor restore reaches for
* -- is asked of this, and everything the server sends is written into the cache on the way past,
* so the screen never learns which side answered. What it does learn, through [DebugStats], is how
* often each one did, which is how the saving is measured.
*
* See TRANSCRIPT_CACHE.md. The one rule worth keeping in mind here: the cache is never
* load-bearing. Every read has a network path beside it producing the same result, so a missing,
* evicted or damaged cache degrades to exactly what this screen did before it existed.
*/
class TranscriptSource(
private val settings: ServerSettings,
private val sessionId: String,
val cache: SessionCache,
) {
private val stream = AtomicReference<EventStream?>(null)
/**
* The cached opening window, or null when there is nothing usable to draw.
*
* Drawn *before* [probe] returns, which is the whole point of the feature: the rows are on
* screen while the check that they are still the server's rows is in flight, and a failed check
* replaces them exactly as a `reset` does.
*/
fun cachedOpening(limit: Int = OPENING_WINDOW): List<SeqEvent>? {
if (cache.tail() == null) return null
val lines = cache.newest(limit)
if (lines.isEmpty()) return null
return try {
lines.map { parseSeqEvent(it) }
} catch (e: org.json.JSONException) {
// Lines this build cannot read at all, which the cache's own checks cannot see: it
// reads a seq off a line, not an event. Nothing to serve, so a cold open.
cache.purge()
null
}
}
/**
* Whether the server's event at the cached cursor is still the cached one.
*
* The screen must not resume a stream from a cached seq unless it is the same conversation. A
* transcript is append-only in ordinary use, but the file can be replaced or truncated -- a
* sandbox re-seeded with the same ids, a backup restored, a directory deleted and the session
* re-imported -- and the server's catch-up on such a file would hand this phone a continuation
* of a *different* conversation, spliced onto the cached one with no seam. That is the worst
* thing this feature can do, and it is caught with one request of a few hundred bytes, in the
* slot the opening page's request used to be in.
*
* False purges the cache and means "open cold". A throw is the server not being askable, which
* is neither: the cached rows stay on screen, the failure goes on the stream banner, and the
* caller tries again on the stream's own reconnect schedule.
*
* What this cannot see is a line changed in the middle of the file with the tail intact. That
* is what the Reload button in session settings is for, and its caption says so.
*/
suspend fun probe(): Boolean {
val tail = cache.tail() ?: return false
// `before = seq + 1` is the newest event with seq <= the cursor, which is the event *at*
// the cursor when the server still has one there.
val answer = fetchTranscript(settings, sessionId, before = tail.seq + 1, limit = 1)
val matches =
answer.size == 1 &&
try {
answer[0].second == parseSeqEvent(tail.line)
} catch (e: org.json.JSONException) {
false
}
if (!matches) cache.purge()
return matches
}
/**
* Today's opening fetch, kept as the start of the live run. Only called when the cache has
* nothing to open with, or when [probe] said what it had was not the server's.
*/
suspend fun fetchOpening(): List<SeqEvent> {
DebugStats.count("transcript page from server")
val page = fetchTranscript(settings, sessionId, limit = OPENING_WINDOW)
page.forEach { (line, entry) -> cache.append(line, entry.seq) }
cache.flush()
return page.map { it.second }
}
/**
* The page before [before]: from the cache when it holds it, otherwise from the server bounded
* by what the cache already has.
*
* The bound is what keeps the cache worth having. A coalesced page reaches back as far as its
* row count takes it -- a single reply is hundreds of lines -- so a page fetched after the
* reader has been away would run straight past the cached run and overlap it, and an
* overlapping page cannot be stored. Told where this phone's copy starts, the server stops
* there instead, the gap is closed with exactly the bytes it was wide, and the history behind
* it is served locally from then on.
*/
suspend fun page(before: Long, limit: Int, coalesce: Boolean): List<SeqEvent> {
cache.page(before, limit, rows = coalesce)?.let { lines ->
DebugStats.count("transcript page from cache")
return lines.map { parseSeqEvent(it) }
}
DebugStats.count("transcript page from server")
val page =
fetchTranscript(
settings,
sessionId,
before = before,
limit = limit,
coalesce = coalesce,
after = cache.coveredUpTo(before)?.minus(1),
)
if (page.isNotEmpty()) {
// `before` rather than the newest line's seq: a coalesced page covers everything up to
// the cursor it was asked with, and nothing in its lines says so.
cache.storePage(page.map { it.first }, page.first().second.seq, before, rows = coalesce)
}
return page.map { it.second }
}
/**
* [EventStream.run], with every frame written to the cache before [onEvent] sees it.
*
* Before, so that an event held back for a reader who is scrolled away is already on disk --
* what the cache holds is what the server sent, not what the screen has got round to drawing.
* Flushed on each status change, which is a turn's boundary and the granularity a crash may as
* well lose.
*/
fun follow(after: Long, onOpen: () -> Unit, onReset: () -> Unit, onEvent: (SeqEvent) -> Unit) {
val opened = EventStream(settings, sessionId)
stream.getAndSet(opened)?.close()
try {
opened.run(after, onOpen, onReset) { raw, entry ->
cache.append(raw, entry.seq)
if (entry.event is SessionEvent.Status) cache.flush()
onEvent(entry)
}
} finally {
cache.flush()
}
}
/** Ends the stream, from any thread, and leaves the cache with everything it was given. */
fun close() {
stream.getAndSet(null)?.close()
cache.flush()
}
}
/**
* How many events the screen opens with, cached or fetched.
*
* The server's own default for a page, named here because the cached opening has to be the same
* size as the fetched one -- a reader must not get a shorter first screen for having been here
* before.
*/
private const val OPENING_WINDOW = 80
/**
* Where this server's cached transcripts live.
*
* Under `cacheDir` because that is exactly what it is for: bytes the phone can regenerate from the
* server, which Android may delete under storage pressure without asking. Keyed by host and port
* because two servers can hold a session with the same id -- the sandbox and the real server, or a
* re-enrolment -- and a line from one shown against the other is the whole invariant broken. `v1`
* is the layout's version: a change to it bumps the segment, and a directory of another version is
* deleted the first time this is called.
*/
fun cacheRoot(context: Context, settings: ServerSettings): File {
val transcripts = File(context.cacheDir, "transcripts")
transcripts.listFiles()?.forEach { if (it.name != CACHE_VERSION) it.deleteRecursively() }
return File(transcripts, "$CACHE_VERSION/${settings.host}_${settings.port}")
}
private const val CACHE_VERSION = "v1"