package com.example.aiapp import java.io.IOException import java.net.HttpURLConnection import java.net.URL import org.json.JSONArray import org.json.JSONObject // 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. // Shared with EventStream.kt, which connects the same way but then reads // without a deadline. const val CONNECT_TIMEOUT_MS = 5000 private const val READ_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 requestFromServer( settings: ServerSettings, path: String, method: String = "GET", jsonBody: String? = null, /** Raw request body as content-type to bytes -- the upload path. */ binaryBody: Pair? = null, readTimeoutMs: Int = READ_TIMEOUT_MS, 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()) } } else if (binaryBody != null) { connection.doOutput = true connection.setRequestProperty("Content-Type", binaryBody.first) connection.outputStream.use { it.write(binaryBody.second) } } 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() } } /** The response body as one JSON object. */ private fun HttpURLConnection.jsonObject(): JSONObject = JSONObject(inputStream.bufferedReader().readText()) /** The response body as a JSON array of objects, each mapped through [parse]. */ private fun HttpURLConnection.jsonObjects(parse: (JSONObject) -> T): List = JSONArray(inputStream.bufferedReader().readText()).mapObjects(parse) private fun JSONArray.mapObjects(parse: (JSONObject) -> T): List = (0 until length()).map { parse(getJSONObject(it)) } private fun JSONArray.strings(): List = (0 until length()).map { getString(it) } /** Percent-encodes a value going into a query string. */ private fun String.urlEncoded(): String = java.net.URLEncoder.encode(this, Charsets.UTF_8.name()) // One row of GET /sessions. A session names the machine it runs on and // which of that machine's providers it runs. data class SessionSummary( val id: String, /** * The machine's current label. The id is deliberately not carried: nothing here addresses a * setup, and holding both invites showing the wrong one, which is what happened. */ val setupName: String, val provider: String, val title: String, val model: String?, /** How much the session asks before acting; null when it was never set. */ val permissionMode: String?, val status: String, val lastActivity: Double, ) private fun parseSession(session: JSONObject) = SessionSummary( id = session.getString("id"), setupName = session.getString("setupName"), provider = session.getString("provider"), title = session.getString("title"), model = session.optString("model").ifEmpty { null }, permissionMode = session.optString("permissionMode").ifEmpty { null }, status = session.getString("status"), lastActivity = session.getDouble("lastActivity"), ) fun fetchSessions(settings: ServerSettings): List = requestFromServer(settings, "/sessions") { it.jsonObjects(::parseSession) } // What the server offers, so the spawn screen has no hardcoded lists: a // setup added to the server's config.ron appears here with no app rebuild. // // One list rather than two. A provider only exists on a machine that has // it installed, so offering machines and providers as independent choices // would offer pairs that cannot work. data class Provider(val name: String, val kind: String, val models: List) /** * A machine, and what it can run. [address] is absent for the backend itself. * * [id] is stable and [name] is not: renaming a machine keeps its sessions, so everything that * refers to a setup uses the id and everything a person reads uses the name. */ data class Setup( val id: String, val name: String, val address: String?, val providers: List, ) private fun parseProvider(provider: JSONObject) = Provider( name = provider.getString("name"), kind = provider.getString("kind"), // Omitted entirely when the provider offers none. models = provider.optJSONArray("models")?.strings().orEmpty(), ) private fun parseSetup(setup: JSONObject) = Setup( id = setup.getString("id"), name = setup.getString("name"), address = setup.optString("address").ifEmpty { null }, providers = setup.getJSONArray("providers").mapObjects(::parseProvider), ) fun fetchSetups(settings: ServerSettings): List = requestFromServer(settings, "/setups") { it.jsonObjects(::parseSetup) } /** * A Claude Code session already on a machine, which can be continued here. * * Identified by [id] and never by a path. The server resolves which file that is, so this app has * no way to ask it to read one -- the same rule that keeps a provider's command out of this client. */ data class Importable( val id: String, val cwd: String, val title: String, val modified: Double, val lines: Int, /** Whether [title] is a name somebody chose rather than the last thing said in the session. */ val named: Boolean, ) fun fetchImportable(settings: ServerSettings, setup: String): List = requestFromServer(settings, "/setups/$setup/importable") { it.jsonObjects { session -> Importable( id = session.getString("id"), cwd = session.optString("cwd"), title = session.optString("title"), modified = session.optDouble("modified", 0.0), lines = session.optInt("lines", 0), named = session.optBoolean("named", false), ) } } /** * How to reach a machine. Deliberately carries no command: the server discovers what a machine can * run by asking it, so this app has no way to introduce something to run. * * [identityFile] is a path on the *backend*, not a key -- private keys do not travel. */ data class SshDetails( val address: String, val port: Int? = null, val identityFile: String? = null, ) private fun SshDetails.toJson() = JSONObject().put("address", address).apply { if (port != null) put("port", port) if (!identityFile.isNullOrBlank()) put("identityFile", identityFile) } /** What a machine turns out to have, without saving anything. */ fun probeSetup(settings: ServerSettings, ssh: SshDetails?): List = requestFromServer( settings, "/setups/probe", method = "POST", jsonBody = JSONObject().apply { if (ssh != null) put("ssh", ssh.toJson()) }.toString(), readTimeoutMs = 40000, ) { it.jsonObjects(::parseProvider) } fun addSetup(settings: ServerSettings, name: String, ssh: SshDetails?): Setup = requestFromServer( settings, "/setups", method = "POST", jsonBody = JSONObject() .put("name", name) .apply { if (ssh != null) put("ssh", ssh.toJson()) } .toString(), readTimeoutMs = 40000, ) { parseSetup(it.jsonObject()) } /** Renames a machine, and optionally asks it again what it has. */ fun updateSetup( settings: ServerSettings, id: String, name: String? = null, rediscover: Boolean = false, ): Setup = requestFromServer( settings, "/setups/${id.urlEncoded()}", method = "PUT", jsonBody = JSONObject() .apply { if (name != null) put("name", name) if (rediscover) put("rediscover", true) } .toString(), readTimeoutMs = 40000, ) { parseSetup(it.jsonObject()) } fun deleteSetup(settings: ServerSettings, id: String) { requestFromServer(settings, "/setups/${id.urlEncoded()}", method = "DELETE") {} } /** * Spawns a session and returns it as the list would show it. [setup] names the machine and * [provider] one of the things that machine offers. */ fun spawnSession( settings: ServerSettings, setup: String, provider: String, title: String, model: String? = null, cwd: String? = null, permissionMode: String? = null, params: Map = emptyMap(), /** Continue this Claude Code session instead of starting an empty one. */ import: String? = null, ): SessionSummary = requestFromServer( settings, "/sessions", method = "POST", jsonBody = JSONObject() .put("setup", setup) .put("provider", provider) .put("title", title) .apply { if (!model.isNullOrBlank()) put("model", model) if (!cwd.isNullOrBlank()) put("cwd", cwd) if (!permissionMode.isNullOrBlank()) put("permissionMode", permissionMode) if (!import.isNullOrBlank()) put("import", import) if (params.isNotEmpty()) { put("params", JSONObject(params.toMap())) } } .toString(), readTimeoutMs = 30000, ) { connection -> parseSession(connection.jsonObject()) } fun sendMessage( settings: ServerSettings, sessionId: String, text: String, attachmentIds: List = emptyList(), ) { requestFromServer( settings, "/sessions/$sessionId/message", method = "POST", jsonBody = JSONObject() .put("text", text) .put("attachmentIds", JSONArray(attachmentIds)) .toString(), ) {} } /** Uploads one picked image; the returned id goes into [sendMessage]. */ fun uploadAttachment( settings: ServerSettings, sessionId: String, bytes: ByteArray, mime: String, ): String { val boundary = "----aiapp-${System.currentTimeMillis()}" val head = ("--$boundary\r\n" + "Content-Disposition: form-data; name=\"file\"; filename=\"image\"\r\n" + "Content-Type: $mime\r\n\r\n") .encodeToByteArray() val tail = "\r\n--$boundary--\r\n".encodeToByteArray() return requestFromServer( settings, "/sessions/$sessionId/attachments", method = "POST", binaryBody = "multipart/form-data; boundary=$boundary" to (head + bytes + tail), readTimeoutMs = 60000, ) { connection -> connection.jsonObject().getString("id") } } /** Fetches an image the transcript references (produced or uploaded). */ fun fetchSessionFile(settings: ServerSettings, sessionId: String, name: String): ByteArray = requestFromServer(settings, "/sessions/$sessionId/files/$name", readTimeoutMs = 30000) { it.inputStream.readBytes() } // One rate-limit window, rendered as a labeled bar on the usage screen. data class UsageWindow( val label: String, val percent: Double, val resetsAt: String?, val active: Boolean, ) data class UsageSnapshot( val provider: String, val available: Boolean, val windows: List, val error: String?, ) /** The backend caches; refreshing more often than its poll interval just re-reads the cache. */ fun fetchUsage(settings: ServerSettings): List = requestFromServer(settings, "/usage", readTimeoutMs = 30000) { connection -> connection.jsonObjects { snapshot -> UsageSnapshot( provider = snapshot.getString("provider"), available = snapshot.getBoolean("available"), error = snapshot.optString("error").ifEmpty { null }, windows = snapshot.getJSONArray("windows").mapObjects { window -> UsageWindow( label = window.getString("label"), percent = window.getDouble("percent"), resetsAt = window.optString("resetsAt").ifEmpty { null }, active = window.getBoolean("active"), ) }, ) } } 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") {} } /** * Removes a Claude Code session from the machine. * * The transcript *is* the session, so this ends any chance of resuming that conversation. The * caller confirms first; see ImportScreen. */ fun deleteImportable(settings: ServerSettings, setup: String, sessionId: String) { requestFromServer(settings, "/setups/$setup/importable/$sessionId", method = "DELETE") {} } /** Switches a running session's model; the CLI changes it in place. */ fun setSessionModel(settings: ServerSettings, sessionId: String, model: String) { requestFromServer( settings, "/sessions/$sessionId/model", method = "POST", jsonBody = JSONObject().put("model", model).toString(), ) {} } /** Switches how much a running session asks before acting, also in place. */ fun setSessionPermissionMode(settings: ServerSettings, sessionId: String, mode: String) { requestFromServer( settings, "/sessions/$sessionId/permission-mode", method = "POST", jsonBody = JSONObject().put("mode", mode).toString(), ) {} } fun deleteSession(settings: ServerSettings, sessionId: String) { requestFromServer(settings, "/sessions/$sessionId", method = "DELETE") {} } // Models: what this backend has downloaded, what it is downloading, and // what HuggingFace offers. Browsing is proxied by the server rather than // done here, because this app trusts exactly one certificate and has no // general internet trust to spend on huggingface.co. data class LocalModel(val key: String, val repo: String, val file: String, val bytes: Long) /** * A download in flight or finished. [total] is null when the server never said how big the file is * -- which must render as "not known", never as a bar at some invented position. */ data class Download( val key: String, val run: Long, val repo: String, val file: String, val state: String, val done: Long, val total: Long?, val error: String?, ) data class Models(val local: List, val downloads: List) data class RemoteRepo(val id: String, val downloads: Long, val likes: Long) data class RemoteFile(val path: String, val bytes: Long, val have: Boolean) private fun parseDownload(o: JSONObject) = Download( key = o.getString("key"), run = o.getLong("run"), repo = o.getString("repo"), file = o.getString("file"), state = o.getString("state"), done = o.getLong("done"), // Absent rather than zero when unknown; see the field's comment. total = if (o.has("total")) o.getLong("total") else null, error = if (o.has("error")) o.getString("error") else null, ) fun fetchModels(settings: ServerSettings): Models = requestFromServer(settings, "/models") { connection -> val body = JSONObject(connection.inputStream.bufferedReader().readText()) Models( local = body.getJSONArray("local").mapObjects { m -> LocalModel( key = m.getString("key"), repo = m.getString("repo"), file = m.getString("file"), bytes = m.getLong("bytes"), ) }, downloads = body.getJSONArray("downloads").mapObjects(::parseDownload), ) } fun searchModels(settings: ServerSettings, query: String): List = requestFromServer(settings, "/models/search?q=${query.urlEncoded()}") { connection -> connection.jsonObjects { r -> RemoteRepo( id = r.getString("id"), downloads = r.getLong("downloads"), likes = r.getLong("likes"), ) } } fun fetchRepoFiles(settings: ServerSettings, repo: String): List = requestFromServer(settings, "/models/files?repo=${repo.urlEncoded()}") { connection -> connection.jsonObjects { f -> RemoteFile( path = f.getString("path"), bytes = f.getLong("bytes"), have = f.getBoolean("have"), ) } } fun startDownload(settings: ServerSettings, repo: String, file: String): Download = requestFromServer( settings, "/models/download", method = "POST", jsonBody = JSONObject().put("repo", repo).put("file", file).toString(), ) { connection -> parseDownload(JSONObject(connection.inputStream.bufferedReader().readText())) } fun cancelDownload(settings: ServerSettings, key: String) { requestFromServer( settings, "/models/cancel", method = "POST", jsonBody = JSONObject().put("key", key).toString(), ) {} } fun deleteModel(settings: ServerSettings, key: String) { requestFromServer( settings, "/models/delete", method = "POST", jsonBody = JSONObject().put("key", key).toString(), ) {} }