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,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") {}
|
||||
}
|
||||
Reference in new issue
Block a user