Phase 1 app: session list, session screen, spawn, QR enrollment over pinned TLS

Compose app mirroring local-updater's stack (single :androidApp module,
pinned CA, HttpURLConnection transport) plus what this app needs on top:
a bearer token sealed with an Android Keystore AES-GCM key, an
aiapp://enroll intent filter so scanning the server's terminal QR with
the stock camera enrolls the phone with no QR library, an SSE client
that resumes by transcript cursor, and a transcript renderer folding the
common event model into user bubbles, streaming text, collapsible tool
cards, and answerable question cards.

Verified on the tdep emulator against the real server: enrollment deep
link, list, spawn, streamed echo turn, question answer round trip, tool
card expansion, adjustResize keyboard behavior. Build is warning-clean
(compose.* accessors replaced with direct dependencies).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
This commit is contained in:
irisandClaude Fable 5 committed 2026-08-24 21:07:02 -04:00
1 parent 967fc814ab
commit 213bc72b64
24 files changed
+2086

No files matched your search

@@ -0,0 +1,80 @@
package com.example.aiapp
import java.io.IOException
import java.net.HttpURLConnection
import java.net.URL
/**
* The SSE half of the API: one long-lived GET per open session screen,
* replaying the transcript after a cursor and then following it live.
*
* Blocking -- run() occupies its thread until the stream ends. [close]
* (from any thread) is the cancellation path: it disconnects the socket,
* which unblocks the read; run() then returns instead of throwing, so a
* deliberate close doesn't surface as a connection error. The caller owns
* reconnecting (with the last seq it saw as the new cursor) -- see
* SessionScreen.
*/
class EventStream(private val settings: ServerSettings, private val sessionId: String) {
@Volatile private var connection: HttpURLConnection? = null
@Volatile private var closed = false
fun close() {
closed = true
connection?.disconnect()
}
/** Streams events after [after] into [onEvent] until the stream drops. */
fun run(after: Long, onEvent: (SeqEvent) -> Unit) {
val connection =
URL("${settings.baseUrl}/sessions/$sessionId/events?after=$after").openConnection()
as HttpURLConnection
this.connection = connection
try {
connection.applyPinnedTls()
connection.connectTimeout = 5000
// No read timeout: between events there is nothing to read for
// as long as the session is idle; the server's keep-alives and
// a dead socket erroring out are the liveness story.
connection.readTimeout = 0
connection.setRequestProperty("Authorization", "Bearer ${settings.token}")
connection.setRequestProperty("Accept", "text/event-stream")
if (connection.responseCode != 200) {
val detail = connection.errorStream?.bufferedReader()?.readText()?.trim()
throw ApiException(detail ?: "HTTP ${connection.responseCode} for the event stream")
}
val reader = connection.inputStream.bufferedReader()
// SSE framing: `data:` lines accumulate until a blank line ends
// the event. `id:` (the seq) is also inside the JSON payload,
// so only data lines matter; comment lines (keep-alives) start
// with ':' and are skipped.
val data = StringBuilder()
while (true) {
val line = reader.readLine() ?: break
when {
line.isEmpty() -> {
if (data.isNotEmpty()) {
onEvent(parseSeqEvent(data.toString()))
data.clear()
}
}
line.startsWith("data:") -> data.append(line.removePrefix("data:").trim())
else -> {} // id:, event:, comments -- nothing to do
}
}
} catch (e: ApiException) {
throw e
} catch (e: IOException) {
if (!closed) {
throw ApiException(
"Lost the event stream (${e::class.simpleName}: ${e.message})",
e,
)
}
} finally {
connection.disconnect()
this.connection = null
}
}
}