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,157 @@
package com.example.aiapp
import org.json.JSONArray
import org.json.JSONObject
import java.io.IOException
import java.net.HttpURLConnection
import java.net.URL
// The REST half of the backend's surface (see server/src/routes.rs for the
// table); the SSE half is EventStream.kt. All blocking network calls --
// invoke from a background dispatcher. Each throws ApiException on failure,
// carrying the server's own explanation where it sent one, since those
// messages are written to be read on this screen.
private const val CONNECT_TIMEOUT_MS = 5000
class ApiException(message: String, cause: Throwable? = null) : Exception(message, cause)
/**
* Runs one request against the backend, with the pinned TLS setup, the
* bearer token, and the failure translation every call needs. [readBody]
* gets the connected, already-status-checked connection to read from.
*
* @param readTimeoutMs how long to wait on the response body. The SSE
* stream doesn't come through here -- an event stream has no bounded
* read time (see EventStream.kt).
*/
fun <T> requestFromServer(
settings: ServerSettings,
path: String,
method: String = "GET",
jsonBody: String? = null,
readTimeoutMs: Int = 5000,
readBody: (HttpURLConnection) -> T,
): T {
val connection = URL("${settings.baseUrl}$path").openConnection() as HttpURLConnection
try {
connection.applyPinnedTls()
connection.requestMethod = method
connection.connectTimeout = CONNECT_TIMEOUT_MS
connection.readTimeout = readTimeoutMs
connection.setRequestProperty("Authorization", "Bearer ${settings.token}")
if (jsonBody != null) {
connection.doOutput = true
connection.setRequestProperty("Content-Type", "application/json")
connection.outputStream.use { it.write(jsonBody.encodeToByteArray()) }
}
if (connection.responseCode !in 200..299) {
val detail = connection.errorStream?.bufferedReader()?.readText()?.trim()
throw ApiException(
when {
connection.responseCode == 401 ->
"The server rejected this device's token. Re-enroll by scanning " +
"the server's QR (or rotate with --rotate-token and scan the new one)."
detail.isNullOrEmpty() ->
"Server returned HTTP ${connection.responseCode} for $path"
else -> detail
},
)
}
return readBody(connection)
} catch (e: ApiException) {
throw e
} catch (e: IOException) {
// Surfacing the real exception (rather than one canned message for
// every failure mode) is what lets this be diagnosed on a device
// with no logcat access.
throw ApiException(
"Couldn't reach the server at ${settings.baseUrl} " +
"(${e::class.simpleName}: ${e.message}) -- is ai-server running, and is " +
"this device able to reach that address (WireGuard up)?",
e,
)
} catch (e: Exception) {
throw ApiException(
"Reached ${settings.baseUrl}$path but couldn't read its response " +
"(${e::class.simpleName}: ${e.message})",
e,
)
} finally {
connection.disconnect()
}
}
// One row of GET /sessions.
data class SessionSummary(
val id: String,
val kind: String,
val title: String,
val host: String?,
val model: String?,
val status: String,
val lastActivity: Double,
)
fun fetchSessions(settings: ServerSettings): List<SessionSummary> =
requestFromServer(settings, "/sessions") { connection ->
val sessions = JSONArray(connection.inputStream.bufferedReader().readText())
(0 until sessions.length()).map { i ->
val session = sessions.getJSONObject(i)
SessionSummary(
id = session.getString("id"),
kind = session.getString("kind"),
title = session.getString("title"),
host = session.optString("host").ifEmpty { null },
model = session.optString("model").ifEmpty { null },
status = session.getString("status"),
lastActivity = session.getDouble("lastActivity"),
)
}
}
/** Spawns a session and returns it as the list would show it. */
fun spawnSession(settings: ServerSettings, kind: String, title: String): SessionSummary =
requestFromServer(
settings,
"/sessions",
method = "POST",
jsonBody = JSONObject().put("kind", kind).put("title", title).toString(),
) { connection ->
val session = JSONObject(connection.inputStream.bufferedReader().readText())
SessionSummary(
id = session.getString("id"),
kind = session.getString("kind"),
title = session.getString("title"),
host = session.optString("host").ifEmpty { null },
model = session.optString("model").ifEmpty { null },
status = session.getString("status"),
lastActivity = session.getDouble("lastActivity"),
)
}
fun sendMessage(settings: ServerSettings, sessionId: String, text: String) {
requestFromServer(
settings,
"/sessions/$sessionId/message",
method = "POST",
jsonBody = JSONObject().put("text", text).toString(),
) {}
}
fun answerQuestion(settings: ServerSettings, sessionId: String, questionId: String, answer: String) {
requestFromServer(
settings,
"/sessions/$sessionId/answer",
method = "POST",
jsonBody = JSONObject().put("questionId", questionId).put("answer", answer).toString(),
) {}
}
fun interruptSession(settings: ServerSettings, sessionId: String) {
requestFromServer(settings, "/sessions/$sessionId/interrupt", method = "POST") {}
}
fun deleteSession(settings: ServerSettings, sessionId: String) {
requestFromServer(settings, "/sessions/$sessionId", method = "DELETE") {}
}