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:
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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user