From 0c13090d70ee70fb701d7ff5c567445e49260c72 Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Fri, 28 Aug 2026 04:03:14 -0400 Subject: [PATCH] Take ktfmt's defaults for the Kotlin half The server half went to rustfmt's defaults earlier today; this is the same move for the app, and the same reasoning. Kotlin ships no formatter with the Gradle build, so the question was which to adopt: ktfmt is Kotlin-org owned now (it moved from facebook/ktfmt), is a formatter rather than a configurable linter, and has essentially nothing to tune -- which is what rule 27 is asking for. ktlint's .editorconfig surface is the thing that rule warns against, and detekt is static analysis, whose job Android Lint already does here. One setting, and it is a choice between the tool's own two styles rather than a tuning: kotlinLangStyle() is the 4-space one, which is what this code already was. The 2-space default would have reindented every file to say nothing. ./gradlew :androidApp:ktfmtFormat to apply ./gradlew :androidApp:ktfmtCheck to verify Formatting only. The one thing worth checking by hand was the generated PEM constant, since a leading newline there costs Android's CertificateFactory its preamble sniff and fails at runtime nowhere near the cause: ktfmt moved `.trimMargin()` onto its own line and left the template alone, and the regenerated constant still starts at the opening quotes. Verified after: ktfmtCheck, compileDebugKotlin and lintDebug all pass, and the APK builds. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw --- app/androidApp/build.gradle.kts | 58 ++--- .../src/main/kotlin/com/example/aiapp/Api.kt | 100 +++++---- .../main/kotlin/com/example/aiapp/AppRoot.kt | 75 ++++--- .../example/aiapp/EnrollmentScanActivity.kt | 29 ++- .../kotlin/com/example/aiapp/EventStream.kt | 14 +- .../main/kotlin/com/example/aiapp/Events.kt | 74 ++++--- .../kotlin/com/example/aiapp/LoadState.kt | 22 +- .../kotlin/com/example/aiapp/MainActivity.kt | 4 +- .../kotlin/com/example/aiapp/PinnedCert.kt | 37 ++-- .../kotlin/com/example/aiapp/ServerConfig.kt | 65 +++--- .../com/example/aiapp/SessionListScreen.kt | 110 ++++++---- .../kotlin/com/example/aiapp/SessionScreen.kt | 203 ++++++++++-------- .../com/example/aiapp/SettingsScreen.kt | 110 +++++----- .../kotlin/com/example/aiapp/SpawnScreen.kt | 93 ++++---- .../kotlin/com/example/aiapp/UsageScreen.kt | 82 +++---- app/gradle/libs.versions.toml | 5 + 16 files changed, 585 insertions(+), 496 deletions(-) diff --git a/app/androidApp/build.gradle.kts b/app/androidApp/build.gradle.kts index 6f9b853..dc49ee2 100644 --- a/app/androidApp/build.gradle.kts +++ b/app/androidApp/build.gradle.kts @@ -2,8 +2,18 @@ plugins { alias(libs.plugins.androidApplication) alias(libs.plugins.composeMultiplatform) alias(libs.plugins.composeCompiler) + alias(libs.plugins.ktfmt) } +// Formatting is the formatter's. The one setting is which of ktfmt's two +// styles: kotlinlang is the 4-space one, which is what this code already +// is -- picking the 2-space default would have reindented every file to +// say nothing. Everything else stays at ktfmt's defaults, deliberately. +// +// ./gradlew :androidApp:ktfmtFormat to apply +// ./gradlew :androidApp:ktfmtCheck to verify +ktfmt { kotlinLangStyle() } + // The CA this app pins is baked in at build time from the certificates on // the machine doing the build -- `$XDG_CONFIG_HOME/ai-app/certs/ca.pem`, // which the server generates on first start. AI_APP_CA overrides the path. @@ -15,19 +25,18 @@ plugins { // is no second trust anchor to get wrong, and no stale paste to notice // three days later. It also means the private key never has to exist // anywhere near this repo. -val pinnedCaPath: String = System.getenv("AI_APP_CA") - ?: "${System.getenv("XDG_CONFIG_HOME") ?: "${System.getProperty("user.home")}/.config"}" + - "/ai-app/certs/ca.pem" +val pinnedCaPath: String = + System.getenv("AI_APP_CA") + ?: "${System.getenv("XDG_CONFIG_HOME") ?: "${System.getProperty("user.home")}/.config"}" + + "/ai-app/certs/ca.pem" abstract class GeneratePinnedCert : DefaultTask() { /** Where the certificate is looked for, reported in failures. */ - @get:Input - abstract val caPath: Property + @get:Input abstract val caPath: Property /** - * The certificate itself, set only when it exists -- so a missing one - * produces this task's own instructions rather than Gradle's "no such - * input file", which doesn't say what to run. + * The certificate itself, set only when it exists -- so a missing one produces this task's own + * instructions rather than Gradle's "no such input file", which doesn't say what to run. */ @get:InputFile @get:Optional @@ -35,8 +44,7 @@ abstract class GeneratePinnedCert : DefaultTask() { abstract val caCertificate: RegularFileProperty /** Wired by AGP through `addGeneratedSourceDirectory`. */ - @get:OutputDirectory - abstract val outputDir: DirectoryProperty + @get:OutputDirectory abstract val outputDir: DirectoryProperty @TaskAction fun generate() { @@ -47,7 +55,7 @@ abstract class GeneratePinnedCert : DefaultTask() { "No CA certificate at $path.\n" + "Start ai-server once on this machine first -- it generates the CA the " + "app pins, and the certificate has to exist before an APK can embed it.\n" + - "Set AI_APP_CA=/path/to/ca.pem to build against a different one.", + "Set AI_APP_CA=/path/to/ca.pem to build against a different one." ) } val pem = ca.readText().trim() @@ -69,18 +77,20 @@ abstract class GeneratePinnedCert : DefaultTask() { |const val PINNED_CA_PEM = ""${'"'}$pem |""${'"'} | - """.trimMargin(), + """ + .trimMargin() ) } } -val generatePinnedCert = tasks.register("generatePinnedCert") { - val ca = file(pinnedCaPath) - caPath.set(pinnedCaPath) - if (ca.isFile) { - caCertificate.set(ca) +val generatePinnedCert = + tasks.register("generatePinnedCert") { + val ca = file(pinnedCaPath) + caPath.set(pinnedCaPath) + if (ca.isFile) { + caCertificate.set(ca) + } } -} android { namespace = "com.example.aiapp" @@ -93,16 +103,8 @@ android { versionCode = 1 versionName = "1.0" } - packaging { - resources { - excludes += "/META-INF/{AL2.0,LGPL2.1}" - } - } - buildTypes { - getByName("release") { - isMinifyEnabled = false - } - } + packaging { resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" } } + buildTypes { getByName("release") { isMinifyEnabled = false } } compileOptions { sourceCompatibility = JavaVersion.VERSION_21 targetCompatibility = JavaVersion.VERSION_21 diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt index 536e517..8f2230e 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt @@ -1,10 +1,10 @@ package com.example.aiapp -import org.json.JSONArray -import org.json.JSONObject 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 -- @@ -20,13 +20,12 @@ 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. + * 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). + * @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, @@ -64,7 +63,7 @@ fun requestFromServer( detail.isNullOrEmpty() -> "Server returned HTTP ${connection.responseCode} for $path" else -> detail - }, + } ) } return readBody(connection) @@ -116,15 +115,16 @@ data class SessionSummary( val lastActivity: Double, ) -private fun parseSession(session: JSONObject) = SessionSummary( - id = session.getString("id"), - provider = session.getString("provider"), - title = session.getString("title"), - host = session.optString("host").ifEmpty { null }, - model = session.optString("model").ifEmpty { null }, - status = session.getString("status"), - lastActivity = session.getDouble("lastActivity"), -) +private fun parseSession(session: JSONObject) = + SessionSummary( + id = session.getString("id"), + provider = session.getString("provider"), + 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 fetchSessions(settings: ServerSettings): List = requestFromServer(settings, "/sessions") { it.jsonObjects(::parseSession) } @@ -156,8 +156,8 @@ fun fetchHosts(settings: ServerSettings): List = } /** - * Spawns a session and returns it as the list would show it. [host] is the - * name of a configured host, or null to run on the backend machine itself. + * Spawns a session and returns it as the list would show it. [host] is the name of a configured + * host, or null to run on the backend machine itself. */ fun spawnSession( settings: ServerSettings, @@ -172,12 +172,17 @@ fun spawnSession( settings, "/sessions", method = "POST", - jsonBody = JSONObject().put("provider", provider).put("title", title).apply { - if (!host.isNullOrBlank()) put("host", host) - if (!model.isNullOrBlank()) put("model", model) - if (!cwd.isNullOrBlank()) put("cwd", cwd) - if (!permissionMode.isNullOrBlank()) put("permissionMode", permissionMode) - }.toString(), + jsonBody = + JSONObject() + .put("provider", provider) + .put("title", title) + .apply { + if (!host.isNullOrBlank()) put("host", host) + if (!model.isNullOrBlank()) put("model", model) + if (!cwd.isNullOrBlank()) put("cwd", cwd) + if (!permissionMode.isNullOrBlank()) put("permissionMode", permissionMode) + } + .toString(), readTimeoutMs = 30000, ) { connection -> parseSession(connection.jsonObject()) @@ -193,10 +198,11 @@ fun sendMessage( settings, "/sessions/$sessionId/message", method = "POST", - jsonBody = JSONObject() - .put("text", text) - .put("attachmentIds", JSONArray(attachmentIds)) - .toString(), + jsonBody = + JSONObject() + .put("text", text) + .put("attachmentIds", JSONArray(attachmentIds)) + .toString(), ) {} } @@ -208,11 +214,11 @@ fun uploadAttachment( 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 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, @@ -254,19 +260,25 @@ fun fetchUsage(settings: ServerSettings): List = 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"), - ) - }, + 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) { +fun answerQuestion( + settings: ServerSettings, + sessionId: String, + questionId: String, + answer: String, +) { requestFromServer( settings, "/sessions/$sessionId/answer", diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt index b8b867e..d3fcd9f 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt @@ -10,21 +10,24 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext /** - * One `when` rather than a navigation library: four screens, with the list - * as the root and the back button the only other way between them. + * One `when` rather than a navigation library: four screens, with the list as the root and the back + * button the only other way between them. */ private sealed class Screen { data object SessionList : Screen() + data class Session(val summary: SessionSummary) : Screen() + data object Spawn : Screen() + data object Usage : Screen() + data object Settings : Screen() } /** - * [settingsVersion] bumps when enrollment lands via an `aiapp://` intent - * (see MainActivity), re-reading the stored settings -- a plain `remember` - * would keep serving the pre-enrollment null. + * [settingsVersion] bumps when enrollment lands via an `aiapp://` intent (see MainActivity), + * re-reading the stored settings -- a plain `remember` would keep serving the pre-enrollment null. */ @Composable fun AppRoot(settingsVersion: Int) { @@ -63,35 +66,39 @@ fun AppRoot(settingsVersion: Int) { } when (val here = screen) { - is Screen.SessionList -> SessionListScreen( - settings = current, - reloadToken = reloadToken, - onOpen = { screen = Screen.Session(it) }, - onSpawn = { screen = Screen.Spawn }, - onUsage = { screen = Screen.Usage }, - onSettings = { screen = Screen.Settings }, - ) - is Screen.Session -> SessionScreen( - settings = current, - summary = here.summary, - onBack = goToList, - ) - is Screen.Spawn -> SpawnScreen( - settings = current, - onSpawned = { spawned -> - reloadToken++ - screen = Screen.Session(spawned) - }, - onBack = goToList, - ) + is Screen.SessionList -> + SessionListScreen( + settings = current, + reloadToken = reloadToken, + onOpen = { screen = Screen.Session(it) }, + onSpawn = { screen = Screen.Spawn }, + onUsage = { screen = Screen.Usage }, + onSettings = { screen = Screen.Settings }, + ) + is Screen.Session -> + SessionScreen( + settings = current, + summary = here.summary, + onBack = goToList, + ) + is Screen.Spawn -> + SpawnScreen( + settings = current, + onSpawned = { spawned -> + reloadToken++ + screen = Screen.Session(spawned) + }, + onBack = goToList, + ) is Screen.Usage -> UsageScreen(settings = current, onBack = goToList) - is Screen.Settings -> SettingsScreen( - existing = current, - onSaved = { saved -> - settings = saved - goToList() - }, - onBack = goToList, - ) + is Screen.Settings -> + SettingsScreen( + existing = current, + onSaved = { saved -> + settings = saved + goToList() + }, + onBack = goToList, + ) } } diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/EnrollmentScanActivity.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/EnrollmentScanActivity.kt index bd91ac1..ae2f102 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/EnrollmentScanActivity.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/EnrollmentScanActivity.kt @@ -5,25 +5,20 @@ import com.journeyapps.barcodescanner.CaptureActivity import com.journeyapps.barcodescanner.DecoratedBarcodeView /** - * The screen behind the Settings screen's "Scan QR code" button: - * zxing-android-embedded's capture activity with two of its defaults taken - * off, both because they put work on the person holding the phone. + * The screen behind the Settings screen's "Scan QR code" button: zxing-android-embedded's capture + * activity with two of its defaults taken off, both because they put work on the person holding the + * phone. * - * - **It decodes only the framing rectangle**, which - * `CameraPreview.marginFraction` insets by 10% from every edge and - * `DecoderThread` crops each frame to before the decoder sees it. A code - * that fills the viewfinder keeps decoding -- measured, not assumed -- - * but it does so having spent its quiet zone and margin for error on - * the crop, and anything further out is simply not looked at. Decoding - * the whole preview costs nothing and means the framing is never the - * user's problem. - * - **It decorates the preview** with a red laser line and the dots the - * detector scatters wherever it finds a candidate pattern. That is the - * library's house style; a plain preview is ours. + * - **It decodes only the framing rectangle**, which `CameraPreview.marginFraction` insets by 10% + * from every edge and `DecoderThread` crops each frame to before the decoder sees it. A code that + * fills the viewfinder keeps decoding -- measured, not assumed -- but it does so having spent its + * quiet zone and margin for error on the crop, and anything further out is simply not looked at. + * Decoding the whole preview costs nothing and means the framing is never the user's problem. + * - **It decorates the preview** with a red laser line and the dots the detector scatters wherever + * it finds a candidate pattern. That is the library's house style; a plain preview is ours. * - * Orientation is left to the sensor rather than pinned to landscape as the - * library's own manifest entry pins it, so the phone can be held whichever - * way the code is in front of it. + * Orientation is left to the sensor rather than pinned to landscape as the library's own manifest + * entry pins it, so the phone can be held whichever way the code is in front of it. */ class EnrollmentScanActivity : CaptureActivity() { override fun initializeContent(): DecoratedBarcodeView { diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/EventStream.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/EventStream.kt index 8366f07..f214c10 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/EventStream.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/EventStream.kt @@ -5,15 +5,13 @@ 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. + * 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. + * 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 diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Events.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Events.kt index 73e47a7..9c1ff09 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Events.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Events.kt @@ -12,52 +12,66 @@ data class SeqEvent(val seq: Long, val ts: Double, val event: SessionEvent) sealed class SessionEvent { data class UserMessage(val text: String) : SessionEvent() + data class AssistantText(val delta: String) : SessionEvent() + data class ToolStart(val id: String, val tool: String, val input: String) : SessionEvent() + data class ToolUpdate(val id: String, val output: String) : SessionEvent() + data class ToolEnd(val id: String, val output: String) : SessionEvent() + data class Image(val ref: String) : SessionEvent() - data class Question(val id: String, val prompt: String, val options: List) : SessionEvent() + + data class Question(val id: String, val prompt: String, val options: List) : + SessionEvent() + data class Answered(val id: String, val answer: String) : SessionEvent() + data class Status(val state: String) : SessionEvent() + data class UsageDelta(val tokens: Long) : SessionEvent() + data class Error(val message: String) : SessionEvent() /** - * An event type this app build doesn't know -- a newer server. Kept - * (not thrown) so one new event kind degrades to a placeholder row - * instead of killing the stream. + * An event type this app build doesn't know -- a newer server. Kept (not thrown) so one new + * event kind degrades to a placeholder row instead of killing the stream. */ data class Unknown(val type: String) : SessionEvent() } fun parseSeqEvent(json: String): SeqEvent { val body = JSONObject(json) - val event = when (val type = body.getString("type")) { - "userMessage" -> SessionEvent.UserMessage(body.getString("text")) - "assistantText" -> SessionEvent.AssistantText(body.getString("delta")) - "toolStart" -> SessionEvent.ToolStart( - id = body.getString("id"), - tool = body.getString("tool"), - // Kept as raw JSON text: the input shape is the tool's own - // business, and the UI only ever shows it verbatim. - input = body.get("input").toString(), - ) - "toolUpdate" -> SessionEvent.ToolUpdate(body.getString("id"), body.getString("output")) - "toolEnd" -> SessionEvent.ToolEnd(body.getString("id"), body.getString("output")) - "image" -> SessionEvent.Image(body.getString("ref")) - "question" -> SessionEvent.Question( - id = body.getString("id"), - prompt = body.getString("prompt"), - options = body.getJSONArray("options").let { options -> - (0 until options.length()).map { options.getString(it) } - }, - ) - "answered" -> SessionEvent.Answered(body.getString("id"), body.getString("answer")) - "status" -> SessionEvent.Status(body.getString("state")) - "usageDelta" -> SessionEvent.UsageDelta(body.getLong("tokens")) - "error" -> SessionEvent.Error(body.getString("message")) - else -> SessionEvent.Unknown(type) - } + val event = + when (val type = body.getString("type")) { + "userMessage" -> SessionEvent.UserMessage(body.getString("text")) + "assistantText" -> SessionEvent.AssistantText(body.getString("delta")) + "toolStart" -> + SessionEvent.ToolStart( + id = body.getString("id"), + tool = body.getString("tool"), + // Kept as raw JSON text: the input shape is the tool's own + // business, and the UI only ever shows it verbatim. + input = body.get("input").toString(), + ) + "toolUpdate" -> SessionEvent.ToolUpdate(body.getString("id"), body.getString("output")) + "toolEnd" -> SessionEvent.ToolEnd(body.getString("id"), body.getString("output")) + "image" -> SessionEvent.Image(body.getString("ref")) + "question" -> + SessionEvent.Question( + id = body.getString("id"), + prompt = body.getString("prompt"), + options = + body.getJSONArray("options").let { options -> + (0 until options.length()).map { options.getString(it) } + }, + ) + "answered" -> SessionEvent.Answered(body.getString("id"), body.getString("answer")) + "status" -> SessionEvent.Status(body.getString("state")) + "usageDelta" -> SessionEvent.UsageDelta(body.getLong("tokens")) + "error" -> SessionEvent.Error(body.getString("message")) + else -> SessionEvent.Unknown(type) + } return SeqEvent(seq = body.getLong("seq"), ts = body.getDouble("ts"), event = event) } diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/LoadState.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/LoadState.kt index cb9dfb3..25362c0 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/LoadState.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/LoadState.kt @@ -1,17 +1,14 @@ package com.example.aiapp /** - * What a screen knows about something it had to fetch: still finding out, - * got it, or couldn't. + * What a screen knows about something it had to fetch: still finding out, got it, or couldn't. * - * Three states rather than a value alongside a nullable error, because - * "we couldn't find out" must not share a representation with "there is - * nothing" -- a failed fetch would otherwise render as an empty list, - * which is the one wrong answer that looks like a right one. + * Three states rather than a value alongside a nullable error, because "we couldn't find out" must + * not share a representation with "there is nothing" -- a failed fetch would otherwise render as an + * empty list, which is the one wrong answer that looks like a right one. * - * [Loading] and [Error] carry no payload, so they are `LoadState` - * and this is covariant in [T]: one `LoadState.Loading` serves every - * screen rather than each needing its own. + * [Loading] and [Error] carry no payload, so they are `LoadState` and this is covariant in + * [T]: one `LoadState.Loading` serves every screen rather than each needing its own. */ sealed class LoadState { data object Loading : LoadState() @@ -22,10 +19,9 @@ sealed class LoadState { companion object { /** - * The failure a fetch produces. Api.kt writes its messages to be - * read on this screen, so this passes one through rather than - * replacing it; the fallback covers only a throwable with no - * message at all, which [ApiException] never is. + * The failure a fetch produces. Api.kt writes its messages to be read on this screen, so + * this passes one through rather than replacing it; the fallback covers only a throwable + * with no message at all, which [ApiException] never is. */ fun failed(e: ApiException): Error = Error(e.message ?: "Unknown error") } diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/MainActivity.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/MainActivity.kt index e73f82d..898acfa 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/MainActivity.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/MainActivity.kt @@ -17,7 +17,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.core.view.WindowCompat @@ -39,7 +38,8 @@ class MainActivity : ComponentActivity() { // through underneath it and content insets itself. Same reasoning // as dev-updater's MainActivity. enableEdgeToEdge() - WindowCompat.getInsetsController(window, window.decorView).isAppearanceLightStatusBars = true + WindowCompat.getInsetsController(window, window.decorView).isAppearanceLightStatusBars = + true // Android 17+ silently drops local-network traffic without this; // requested up front because a denial is invisible at the socket diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/PinnedCert.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/PinnedCert.kt index 43f7d79..7ff73e6 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/PinnedCert.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/PinnedCert.kt @@ -25,30 +25,29 @@ import javax.net.ssl.X509TrustManager // so photographing the terminal leaks only the (rotatable) token. /** - * Trusts only [PINNED_CA_PEM], not the device's system trust store, so a - * real CA-issued cert for some other host wouldn't be accepted either. - * Built once and cached -- every SSE reconnect would otherwise redo the - * KeyStore/TrustManager setup from scratch. + * Trusts only [PINNED_CA_PEM], not the device's system trust store, so a real CA-issued cert for + * some other host wouldn't be accepted either. Built once and cached -- every SSE reconnect would + * otherwise redo the KeyStore/TrustManager setup from scratch. */ val pinnedSslSocketFactory: SSLSocketFactory by lazy { // Trimmed because CertificateFactory only recognises PEM when the // "-----BEGIN" preamble is the very first thing it sees; surrounding // whitespace sends it down the DER path instead. - val caCert = CertificateFactory.getInstance("X.509") - .generateCertificate(ByteArrayInputStream(PINNED_CA_PEM.trim().encodeToByteArray())) - val keyStore = KeyStore.getInstance(KeyStore.getDefaultType()).apply { - load(null, null) - setCertificateEntry("ai-app-dev-ca", caCert) - } - val trustManager = TrustManagerFactory - .getInstance(TrustManagerFactory.getDefaultAlgorithm()) - .apply { init(keyStore) } - .trustManagers - .filterIsInstance() - .first() - SSLContext.getInstance("TLS").apply { - init(null, arrayOf(trustManager), null) - }.socketFactory + val caCert = + CertificateFactory.getInstance("X.509") + .generateCertificate(ByteArrayInputStream(PINNED_CA_PEM.trim().encodeToByteArray())) + val keyStore = + KeyStore.getInstance(KeyStore.getDefaultType()).apply { + load(null, null) + setCertificateEntry("ai-app-dev-ca", caCert) + } + val trustManager = + TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) + .apply { init(keyStore) } + .trustManagers + .filterIsInstance() + .first() + SSLContext.getInstance("TLS").apply { init(null, arrayOf(trustManager), null) }.socketFactory } /** Every request this app makes goes through this -- there is no unpinned path. */ diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/ServerConfig.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/ServerConfig.kt index 15f6fb0..abb5a22 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/ServerConfig.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/ServerConfig.kt @@ -2,10 +2,10 @@ package com.example.aiapp import android.content.Context import android.net.Uri -import androidx.core.content.edit import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyProperties import android.util.Base64 +import androidx.core.content.edit import java.security.KeyStore import javax.crypto.Cipher import javax.crypto.KeyGenerator @@ -13,13 +13,13 @@ import javax.crypto.SecretKey import javax.crypto.spec.GCMParameterSpec /** - * Where the backend is and how to authenticate to it. Absent until the - * phone is enrolled -- by scanning the server's terminal QR (an - * `aiapp://enroll` URI the camera app hands to MainActivity) or by typing - * the fields into the settings screen. + * Where the backend is and how to authenticate to it. Absent until the phone is enrolled -- by + * scanning the server's terminal QR (an `aiapp://enroll` URI the camera app hands to MainActivity) + * or by typing the fields into the settings screen. */ data class ServerSettings(val host: String, val port: Int, val token: String) { - val baseUrl: String get() = "https://$host:$port" + val baseUrl: String + get() = "https://$host:$port" } private const val PREFS_NAME = "server" @@ -47,8 +47,8 @@ fun saveServerSettings(context: Context, settings: ServerSettings) { /** * Parses the enrollment URI the server's QR carries: - * `aiapp://enroll?host=10.66.0.1&port=8443&token=...`. Null if any part is - * missing -- a malformed scan shouldn't clobber a working enrollment. + * `aiapp://enroll?host=10.66.0.1&port=8443&token=...`. Null if any part is missing -- a malformed + * scan shouldn't clobber a working enrollment. */ fun parseEnrollmentUri(uri: Uri): ServerSettings? { if (uri.scheme != "aiapp" || uri.host != "enroll") return null @@ -73,16 +73,18 @@ private const val GCM_TAG_BITS = 128 private fun tokenKey(): SecretKey { val keyStore = KeyStore.getInstance(KEYSTORE).apply { load(null) } - (keyStore.getKey(KEY_ALIAS, null) as? SecretKey)?.let { return it } + (keyStore.getKey(KEY_ALIAS, null) as? SecretKey)?.let { + return it + } val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE) generator.init( KeyGenParameterSpec.Builder( - KEY_ALIAS, - KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT, - ) + KEY_ALIAS, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT, + ) .setBlockModes(KeyProperties.BLOCK_MODE_GCM) .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) - .build(), + .build() ) return generator.generateKey() } @@ -92,27 +94,28 @@ private fun seal(token: String): String { val cipher = Cipher.getInstance("AES/GCM/NoPadding") cipher.init(Cipher.ENCRYPT_MODE, tokenKey()) val ciphertext = cipher.doFinal(token.encodeToByteArray()) - return Base64.encodeToString(cipher.iv, Base64.NO_WRAP) + ":" + + return Base64.encodeToString(cipher.iv, Base64.NO_WRAP) + + ":" + Base64.encodeToString(ciphertext, Base64.NO_WRAP) } /** - * Null on any failure -- e.g. the Keystore key was lost to a device reset - * or the app's data was restored onto another device, where the key never - * travels. The caller treats that as "not enrolled"; re-scanning the QR - * (or `--rotate-token`) is the recovery, so failing soft here is right. + * Null on any failure -- e.g. the Keystore key was lost to a device reset or the app's data was + * restored onto another device, where the key never travels. The caller treats that as "not + * enrolled"; re-scanning the QR (or `--rotate-token`) is the recovery, so failing soft here is + * right. */ -private fun unseal(sealed: String): String? = try { - val (ivB64, dataB64) = sealed.split(":", limit = 2).let { - if (it.size != 2) return null else it[0] to it[1] +private fun unseal(sealed: String): String? = + try { + val (ivB64, dataB64) = + sealed.split(":", limit = 2).let { if (it.size != 2) return null else it[0] to it[1] } + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init( + Cipher.DECRYPT_MODE, + tokenKey(), + GCMParameterSpec(GCM_TAG_BITS, Base64.decode(ivB64, Base64.NO_WRAP)), + ) + cipher.doFinal(Base64.decode(dataB64, Base64.NO_WRAP)).decodeToString() + } catch (_: Exception) { + null } - val cipher = Cipher.getInstance("AES/GCM/NoPadding") - cipher.init( - Cipher.DECRYPT_MODE, - tokenKey(), - GCMParameterSpec(GCM_TAG_BITS, Base64.decode(ivB64, Base64.NO_WRAP)), - ) - cipher.doFinal(Base64.decode(dataB64, Base64.NO_WRAP)).decodeToString() -} catch (_: Exception) { - null -} diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionListScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionListScreen.kt index e702d75..c34113f 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionListScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionListScreen.kt @@ -41,8 +41,8 @@ private val AWAITING_COLOR = Color(0xFFB26A00) private val RUNNING_COLOR = Color(0xFF2E7D32) /** - * The session list -- the app's root screen. Sessions awaiting an answer - * sort to the top: that's the "your turn" inbox. + * The session list -- the app's root screen. Sessions awaiting an answer sort to the top: that's + * the "your turn" inbox. */ @Composable fun SessionListScreen( @@ -71,14 +71,15 @@ fun SessionListScreen( fun refresh() { listState = LoadState.Loading scope.launch { - listState = try { - val loaded = - withContext(Dispatchers.IO) { LoadState.Loaded(fetchSessions(settings)) } - deleteErrors = emptyMap() - loaded - } catch (e: ApiException) { - LoadState.failed(e) - } + listState = + try { + val loaded = + withContext(Dispatchers.IO) { LoadState.Loaded(fetchSessions(settings)) } + deleteErrors = emptyMap() + loaded + } catch (e: ApiException) { + LoadState.failed(e) + } } } @@ -86,7 +87,10 @@ fun SessionListScreen( Box(Modifier.fillMaxSize()) { Column(Modifier.fillMaxSize().padding(16.dp)) { - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { Text( "AI Sessions", style = MaterialTheme.typography.headlineSmall, @@ -106,10 +110,11 @@ fun SessionListScreen( // Couldn't reach the server at ...". It was also a guess -- // a delete that the server itself refused had reached it // fine. - is LoadState.Error -> Text( - state.message, - color = MaterialTheme.colorScheme.error, - ) + is LoadState.Error -> + Text( + state.message, + color = MaterialTheme.colorScheme.error, + ) is LoadState.Loaded -> { if (state.value.isEmpty()) { Text( @@ -120,10 +125,11 @@ fun SessionListScreen( } // Awaiting-answer first (the point of the screen), then // most recently active. - val ordered = state.value.sortedWith( - compareByDescending { it.status == "awaitingInput" } - .thenByDescending { it.lastActivity }, - ) + val ordered = + state.value.sortedWith( + compareByDescending { it.status == "awaitingInput" } + .thenByDescending { it.lastActivity } + ) LazyColumn { items(ordered, key = { it.id }) { session -> SessionCard( @@ -142,7 +148,9 @@ fun SessionListScreen( FloatingActionButton( onClick = onSpawn, modifier = Modifier.align(Alignment.BottomEnd).padding(24.dp), - ) { Text("+", style = MaterialTheme.typography.headlineMedium) } + ) { + Text("+", style = MaterialTheme.typography.headlineMedium) + } } confirmingDelete?.let { session -> @@ -151,18 +159,22 @@ fun SessionListScreen( title = { Text("Delete \"${session.title}\"?") }, text = { Text("Kills the process and deletes its transcript. This can't be undone.") }, confirmButton = { - TextButton(onClick = { - confirmingDelete = null - scope.launch { - try { - withContext(Dispatchers.IO) { deleteSession(settings, session.id) } - refresh() - } catch (e: ApiException) { - deleteErrors = - deleteErrors + (session.id to (e.message ?: "Delete failed")) + TextButton( + onClick = { + confirmingDelete = null + scope.launch { + try { + withContext(Dispatchers.IO) { deleteSession(settings, session.id) } + refresh() + } catch (e: ApiException) { + deleteErrors = + deleteErrors + (session.id to (e.message ?: "Delete failed")) + } } } - }) { Text("Delete") } + ) { + Text("Delete") + } }, dismissButton = { TextButton(onClick = { confirmingDelete = null }) { Text("Cancel") } @@ -180,11 +192,12 @@ private fun SessionCard( onOpen: () -> Unit, onLongPress: () -> Unit, ) { - Card( - Modifier.fillMaxWidth().combinedClickable(onClick = onOpen, onLongClick = onLongPress), - ) { + Card(Modifier.fillMaxWidth().combinedClickable(onClick = onOpen, onLongClick = onLongPress)) { Column(Modifier.padding(16.dp)) { - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { Text( session.title, style = MaterialTheme.typography.titleMedium, @@ -198,10 +211,11 @@ private fun SessionCard( // Provider, then where it runs -- "on " rather // than a bare name, so a host isn't mistaken for a model. listOfNotNull( - session.provider, - session.host?.let { "on $it" }, - session.model, - ).joinToString(" · "), + session.provider, + session.host?.let { "on $it" }, + session.model, + ) + .joinToString(" · "), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.weight(1f), @@ -228,16 +242,20 @@ private fun SessionCard( @Composable fun StatusText(status: String) { - val (label, color) = when (status) { - "awaitingInput" -> "your turn" to AWAITING_COLOR - "running" -> "running" to RUNNING_COLOR - "compacting" -> "compacting" to RUNNING_COLOR - "exited" -> "exited" to MaterialTheme.colorScheme.onSurfaceVariant - else -> status to MaterialTheme.colorScheme.onSurfaceVariant - } + val (label, color) = + when (status) { + "awaitingInput" -> "your turn" to AWAITING_COLOR + "running" -> "running" to RUNNING_COLOR + "compacting" -> "compacting" to RUNNING_COLOR + "exited" -> "exited" to MaterialTheme.colorScheme.onSurfaceVariant + else -> status to MaterialTheme.colorScheme.onSurfaceVariant + } Row(verticalAlignment = Alignment.CenterVertically) { if (status == "running" || status == "compacting") { - CircularProgressIndicator(modifier = Modifier.width(14.dp).height(14.dp), strokeWidth = 2.dp) + CircularProgressIndicator( + modifier = Modifier.width(14.dp).height(14.dp), + strokeWidth = 2.dp, + ) Spacer(Modifier.width(6.dp)) } Text(label, style = MaterialTheme.typography.labelLarge, color = color) diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt index a1c0285..d522be7 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -44,24 +44,25 @@ import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp +import java.util.concurrent.atomic.AtomicLong +import java.util.concurrent.atomic.AtomicReference import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import java.util.concurrent.atomic.AtomicLong -import java.util.concurrent.atomic.AtomicReference private const val RECONNECT_DELAY_MS = 1500L /** - * What the transcript renders: the event stream folded into displayable - * rows (see [foldEvent]). The stream is the only data source -- opening - * this screen replays from seq 0, and a reconnect resumes from the last - * seq seen, so there is no separate history fetch to drift from it. + * What the transcript renders: the event stream folded into displayable rows (see [foldEvent]). The + * stream is the only data source -- opening this screen replays from seq 0, and a reconnect resumes + * from the last seq seen, so there is no separate history fetch to drift from it. */ sealed class TranscriptItem { data class UserMsg(val text: String) : TranscriptItem() + data class AssistantMsg(val text: String) : TranscriptItem() + data class ToolRun( val id: String, val tool: String, @@ -69,15 +70,19 @@ sealed class TranscriptItem { val output: String, val done: Boolean, ) : TranscriptItem() + data class QuestionCard( val id: String, val prompt: String, val options: List, val answer: String?, ) : TranscriptItem() + data class ErrorMsg(val message: String) : TranscriptItem() + /** An image by server-side ref, fetched from the session's files route. */ data class ImageItem(val ref: String) : TranscriptItem() + /** Placeholder row for events this build can't render (newer kinds). */ data class Note(val text: String) : TranscriptItem() } @@ -96,20 +101,24 @@ fun foldEvent(items: List, event: SessionEvent): List items + TranscriptItem.ToolRun(event.id, event.tool, event.input, "", done = false) - is SessionEvent.ToolUpdate -> - updateTool(items, event.id) { it.copy(output = event.output) } + is SessionEvent.ToolUpdate -> updateTool(items, event.id) { it.copy(output = event.output) } is SessionEvent.ToolEnd -> updateTool(items, event.id) { it.copy(output = event.output, done = true) } is SessionEvent.Question -> - items + TranscriptItem.QuestionCard(event.id, event.prompt, event.options, answer = null) - is SessionEvent.Answered -> items.map { - if (it is TranscriptItem.QuestionCard && it.id == event.id) it.copy(answer = event.answer) else it - } + items + + TranscriptItem.QuestionCard(event.id, event.prompt, event.options, answer = null) + is SessionEvent.Answered -> + items.map { + if (it is TranscriptItem.QuestionCard && it.id == event.id) + it.copy(answer = event.answer) + else it + } is SessionEvent.Error -> items + TranscriptItem.ErrorMsg(event.message) is SessionEvent.Image -> items + TranscriptItem.ImageItem(event.ref) is SessionEvent.Unknown -> items + TranscriptItem.Note("[${event.type}]") // Screen-level state, not transcript rows -- see SessionScreen. - is SessionEvent.Status, is SessionEvent.UsageDelta -> items + is SessionEvent.Status, + is SessionEvent.UsageDelta -> items } private fun updateTool( @@ -171,9 +180,7 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: () } // Coroutine cancellation can't interrupt a blocking socket read; // closing the stream is what unblocks it when this screen goes away. - DisposableEffect(summary.id) { - onDispose { activeStream.get()?.close() } - } + DisposableEffect(summary.id) { onDispose { activeStream.get()?.close() } } LaunchedEffect(items.size) { if (items.isNotEmpty()) listState.animateScrollToItem(items.size - 1) @@ -201,27 +208,28 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: () // The system photo picker; the image uploads as soon as it's chosen, // so Send only has ids to reference. - val pickImage = rememberLauncherForActivityResult( - ActivityResultContracts.PickVisualMedia(), - ) { uri -> - if (uri != null) { - scope.launch { - try { - val id = withContext(Dispatchers.IO) { - val bytes = context.contentResolver.openInputStream(uri) - ?.use { it.readBytes() } - ?: throw ApiException("couldn't read the picked image") - val mime = context.contentResolver.getType(uri) ?: "image/jpeg" - uploadAttachment(settings, summary.id, bytes, mime) + val pickImage = + rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri -> + if (uri != null) { + scope.launch { + try { + val id = + withContext(Dispatchers.IO) { + val bytes = + context.contentResolver.openInputStream(uri)?.use { + it.readBytes() + } ?: throw ApiException("couldn't read the picked image") + val mime = context.contentResolver.getType(uri) ?: "image/jpeg" + uploadAttachment(settings, summary.id, bytes, mime) + } + pendingAttachments = pendingAttachments + id + actionError = null + } catch (e: ApiException) { + actionError = e.message } - pendingAttachments = pendingAttachments + id - actionError = null - } catch (e: ApiException) { - actionError = e.message } } } - } Column(Modifier.fillMaxSize()) { Row( @@ -233,11 +241,12 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: () Text(summary.title, style = MaterialTheme.typography.titleMedium) Text( listOfNotNull( - summary.provider, - summary.host?.let { "on $it" }, - summary.model, - if (totalTokens > 0) "$totalTokens tok" else null, - ).joinToString(" · "), + summary.provider, + summary.host?.let { "on $it" }, + summary.model, + if (totalTokens > 0) "$totalTokens tok" else null, + ) + .joinToString(" · "), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -263,30 +272,35 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: () items(items) { item -> when (item) { is TranscriptItem.UserMsg -> UserBubble(item.text) - is TranscriptItem.AssistantMsg -> Text(item.text, style = MaterialTheme.typography.bodyLarge) - is TranscriptItem.ToolRun -> ToolCard( - tool = item, - expanded = item.id in expandedTools, - onToggle = { - expandedTools = - if (item.id in expandedTools) expandedTools - item.id - else expandedTools + item.id - }, - ) - is TranscriptItem.QuestionCard -> QuestionRow(item) { answer -> - act { answerQuestion(settings, summary.id, item.id, answer) } - } - is TranscriptItem.ErrorMsg -> Text( - item.message, - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodyMedium, - ) + is TranscriptItem.AssistantMsg -> + Text(item.text, style = MaterialTheme.typography.bodyLarge) + is TranscriptItem.ToolRun -> + ToolCard( + tool = item, + expanded = item.id in expandedTools, + onToggle = { + expandedTools = + if (item.id in expandedTools) expandedTools - item.id + else expandedTools + item.id + }, + ) + is TranscriptItem.QuestionCard -> + QuestionRow(item) { answer -> + act { answerQuestion(settings, summary.id, item.id, answer) } + } + is TranscriptItem.ErrorMsg -> + Text( + item.message, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium, + ) is TranscriptItem.ImageItem -> SessionImage(settings, summary.id, item.ref) - is TranscriptItem.Note -> Text( - item.text, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + is TranscriptItem.Note -> + Text( + item.text, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } } } @@ -298,18 +312,22 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: () verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(8.dp), ) { - TextButton(onClick = { - pickImage.launch( - PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly), - ) - }) { + TextButton( + onClick = { + pickImage.launch( + PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly) + ) + } + ) { Text(if (pendingAttachments.isEmpty()) "+" else "+${pendingAttachments.size}") } OutlinedTextField( value = input, onValueChange = { input = it }, modifier = Modifier.weight(1f), - placeholder = { Text(if (pendingAttachments.isEmpty()) "Message" else "Message (+image)") }, + placeholder = { + Text(if (pendingAttachments.isEmpty()) "Message" else "Message (+image)") + }, maxLines = 4, ) Spacer(Modifier.width(8.dp)) @@ -325,9 +343,8 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: () } /** - * An inline transcript image, fetched (authenticated, pinned) from the - * session's files route. The bitmap is remembered per ref, so scrolling - * doesn't refetch. + * An inline transcript image, fetched (authenticated, pinned) from the session's files route. The + * bitmap is remembered per ref, so scrolling doesn't refetch. */ @Composable private fun SessionImage(settings: ServerSettings, sessionId: String, ref: String) { @@ -343,16 +360,18 @@ private fun SessionImage(settings: ServerSettings, sessionId: String, ref: Strin } } when (val image = bitmap) { - null -> Text( - if (failed) "[image $ref unavailable]" else "[loading image…]", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - else -> Image( - bitmap = image, - contentDescription = "session image", - modifier = Modifier.fillMaxWidth(), - ) + null -> + Text( + if (failed) "[image $ref unavailable]" else "[loading image…]", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + else -> + Image( + bitmap = image, + contentDescription = "session image", + modifier = Modifier.fillMaxWidth(), + ) } } @@ -360,9 +379,10 @@ private fun SessionImage(settings: ServerSettings, sessionId: String, ref: Strin private fun UserBubble(text: String) { Box(Modifier.fillMaxWidth()) { Card( - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - ), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer + ), modifier = Modifier.align(Alignment.CenterEnd).padding(start = 48.dp), ) { Text(text, modifier = Modifier.padding(12.dp)) @@ -371,16 +391,19 @@ private fun UserBubble(text: String) { } /** - * Collapsed by default: name plus a spinner while running, expandable to - * the input and output. The spinner-while-unfinished is exactly "ToolStart - * with no matching ToolEnd yet". + * Collapsed by default: name plus a spinner while running, expandable to the input and output. The + * spinner-while-unfinished is exactly "ToolStart with no matching ToolEnd yet". */ @Composable private fun ToolCard(tool: TranscriptItem.ToolRun, expanded: Boolean, onToggle: () -> Unit) { Card(Modifier.fillMaxWidth().clickable(onClick = onToggle)) { Column(Modifier.padding(12.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { - Text(tool.tool, style = MaterialTheme.typography.titleSmall, modifier = Modifier.weight(1f)) + Text( + tool.tool, + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.weight(1f), + ) if (!tool.done) { CircularProgressIndicator( modifier = Modifier.width(16.dp).height(16.dp), @@ -403,9 +426,9 @@ private fun ToolCard(tool: TranscriptItem.ToolRun, expanded: Boolean, onToggle: } /** - * A question (or permission request -- same shape) inline in the - * transcript. Option buttons until answered; then the chosen answer, which - * the `answered` event also resolves on every other connected device. + * A question (or permission request -- same shape) inline in the transcript. Option buttons until + * answered; then the chosen answer, which the `answered` event also resolves on every other + * connected device. */ @Composable private fun QuestionRow(question: TranscriptItem.QuestionCard, onAnswer: (String) -> Unit) { diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SettingsScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SettingsScreen.kt index 6cf1ccb..5140b05 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SettingsScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SettingsScreen.kt @@ -2,7 +2,8 @@ package com.example.aiapp import android.Manifest import android.content.pm.PackageManager -import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -26,18 +27,15 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.core.net.toUri -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts import com.google.zxing.client.android.Intents import com.journeyapps.barcodescanner.ScanContract import com.journeyapps.barcodescanner.ScanIntentResult import com.journeyapps.barcodescanner.ScanOptions /** - * Server address and token. The normal path is the "Scan QR code" button - * below, which decodes the server's terminal QR itself; these fields are - * the fallback for typing the same three values by hand. [onBack] is null - * on first run, when there is nothing to go back to. + * Server address and token. The normal path is the "Scan QR code" button below, which decodes the + * server's terminal QR itself; these fields are the fallback for typing the same three values by + * hand. [onBack] is null on first run, when there is nothing to go back to. */ @Composable fun SettingsScreen( @@ -53,29 +51,30 @@ fun SettingsScreen( var token by remember { mutableStateOf("") } var error by remember { mutableStateOf(null) } - val scanLauncher = rememberLauncherForActivityResult(ScanContract()) { result: ScanIntentResult -> - // Null contents means the user backed out of the scanner -- not an - // error, so nothing to report. - val contents = result.contents ?: return@rememberLauncherForActivityResult - val settings = parseEnrollmentUri(contents.toUri()) - if (settings == null) { - error = "Not a valid enrollment code" - } else { - saveServerSettings(context, settings) - onSaved(settings) + val scanLauncher = + rememberLauncherForActivityResult(ScanContract()) { result: ScanIntentResult -> + // Null contents means the user backed out of the scanner -- not an + // error, so nothing to report. + val contents = result.contents ?: return@rememberLauncherForActivityResult + val settings = parseEnrollmentUri(contents.toUri()) + if (settings == null) { + error = "Not a valid enrollment code" + } else { + saveServerSettings(context, settings) + onSaved(settings) + } } - } - val requestCamera = rememberLauncherForActivityResult( - ActivityResultContracts.RequestPermission() - ) { granted -> - if (granted) { - scanLauncher.launch(enrollmentScanOptions()) - } else { - error = "Scanning needs the camera. Grant it in the system settings, " + - "or type the host, port and token in below." + val requestCamera = + rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + if (granted) { + scanLauncher.launch(enrollmentScanOptions()) + } else { + error = + "Scanning needs the camera. Grant it in the system settings, " + + "or type the host, port and token in below." + } } - } Column(Modifier.fillMaxSize().padding(16.dp)) { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { @@ -107,8 +106,9 @@ fun SettingsScreen( // encountered a problem" over it, and works on the second // try. Nothing is wrong with the camera, so nothing should // say there is. - if (context.checkSelfPermission(Manifest.permission.CAMERA) - == PackageManager.PERMISSION_GRANTED + if ( + context.checkSelfPermission(Manifest.permission.CAMERA) == + PackageManager.PERMISSION_GRANTED ) { scanLauncher.launch(enrollmentScanOptions()) } else { @@ -116,7 +116,9 @@ fun SettingsScreen( } }, modifier = Modifier.fillMaxWidth(), - ) { Text("Scan QR code") } + ) { + Text("Scan QR code") + } Spacer(Modifier.height(16.dp)) OutlinedTextField( @@ -149,35 +151,39 @@ fun SettingsScreen( Spacer(Modifier.height(8.dp)) } - Button(onClick = { - val portNumber = port.trim().toIntOrNull() - val effectiveToken = token.trim().ifEmpty { existing?.token ?: "" } - when { - host.isBlank() -> error = "Host is required" - portNumber == null || portNumber !in 1..65535 -> error = "Port must be 1-65535" - effectiveToken.isEmpty() -> error = "Token is required -- scan the server's QR or paste it" - else -> { - val settings = ServerSettings(host.trim(), portNumber, effectiveToken) - saveServerSettings(context, settings) - onSaved(settings) + Button( + onClick = { + val portNumber = port.trim().toIntOrNull() + val effectiveToken = token.trim().ifEmpty { existing?.token ?: "" } + when { + host.isBlank() -> error = "Host is required" + portNumber == null || portNumber !in 1..65535 -> error = "Port must be 1-65535" + effectiveToken.isEmpty() -> + error = "Token is required -- scan the server's QR or paste it" + else -> { + val settings = ServerSettings(host.trim(), portNumber, effectiveToken) + saveServerSettings(context, settings) + onSaved(settings) + } } } - }) { Text("Save") } + ) { + Text("Save") + } } } /** - * How the enrollment QR is scanned, in one place because two callers reach - * it -- straight from the button when the camera permission is already - * held, and from the permission result when it has just been granted. + * How the enrollment QR is scanned, in one place because two callers reach it -- straight from the + * button when the camera permission is already held, and from the permission result when it has + * just been granted. * - * MIXED_SCAN is the load-bearing part: ZXing otherwise looks only for a - * dark code on a light ground, and ai-server's QR is block characters in - * the terminal's foreground colour, so on a dark-themed terminal it comes - * out as a photographic negative the scanner silently never matches. Which - * way round it renders is the terminal's business, not something this app - * should depend on. The mixed decoder alternates normal and inverted - * frames, costing half the frame rate at each polarity and nothing else. + * MIXED_SCAN is the load-bearing part: ZXing otherwise looks only for a dark code on a light + * ground, and ai-server's QR is block characters in the terminal's foreground colour, so on a + * dark-themed terminal it comes out as a photographic negative the scanner silently never matches. + * Which way round it renders is the terminal's business, not something this app should depend on. + * The mixed decoder alternates normal and inverted frames, costing half the frame rate at each + * polarity and nothing else. */ private fun enrollmentScanOptions(): ScanOptions = ScanOptions() diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SpawnScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SpawnScreen.kt index 63cee19..0b4461a 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SpawnScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SpawnScreen.kt @@ -44,9 +44,8 @@ private const val LOCAL_HOST_LABEL = "backend" /** * The spawn screen: what to run, where to run it, and the per-kind fields. * - * Providers and hosts both come from the server, so adding either to its - * config.ron shows up here with no app rebuild -- and because they are - * independent, any provider can be sent to any host. + * Providers and hosts both come from the server, so adding either to its config.ron shows up here + * with no app rebuild -- and because they are independent, any provider can be sent to any host. */ @Composable fun SpawnScreen( @@ -73,15 +72,17 @@ fun SpawnScreen( var spawnError by remember { mutableStateOf(null) } LaunchedEffect(Unit) { - options = try { - val fetched = withContext(Dispatchers.IO) { - SpawnOptions(fetchProviders(settings), fetchHosts(settings)) + options = + try { + val fetched = + withContext(Dispatchers.IO) { + SpawnOptions(fetchProviders(settings), fetchHosts(settings)) + } + provider = fetched.providers.firstOrNull() + LoadState.Loaded(fetched) + } catch (e: ApiException) { + LoadState.failed(e) } - provider = fetched.providers.firstOrNull() - LoadState.Loaded(fetched) - } catch (e: ApiException) { - LoadState.failed(e) - } } val current = provider @@ -105,17 +106,18 @@ fun SpawnScreen( // failure to fetch them leaves no form worth showing -- so this // reports and stops, rather than offering empty pickers under an // error message. - val (providers, hosts) = when (val state = options) { - is LoadState.Loading -> { - CircularProgressIndicator() - return@Column + val (providers, hosts) = + when (val state = options) { + is LoadState.Loading -> { + CircularProgressIndicator() + return@Column + } + is LoadState.Error -> { + Text(state.message, color = MaterialTheme.colorScheme.error) + return@Column + } + is LoadState.Loaded -> state.value } - is LoadState.Error -> { - Text(state.message, color = MaterialTheme.colorScheme.error) - return@Column - } - is LoadState.Loaded -> state.value - } ChipGroup( label = "Provider", @@ -133,13 +135,15 @@ fun SpawnScreen( onSelect = { name -> host = name.takeIf { it != LOCAL_HOST_LABEL } }, ) host?.let { chosen -> - hosts.firstOrNull { it.name == chosen }?.let { - Text( - it.address, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } + hosts + .firstOrNull { it.name == chosen } + ?.let { + Text( + it.address, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } Spacer(Modifier.height(16.dp)) @@ -202,17 +206,18 @@ fun SpawnScreen( busy = true scope.launch { try { - val spawned = withContext(Dispatchers.IO) { - spawnSession( - settings, - provider = chosen.name, - title = title.trim(), - host = host, - model = model.trim().takeIf { isClaude }, - cwd = cwd.trim().takeIf { isClaude }, - permissionMode = permissionMode.takeIf { isClaude }, - ) - } + val spawned = + withContext(Dispatchers.IO) { + spawnSession( + settings, + provider = chosen.name, + title = title.trim(), + host = host, + model = model.trim().takeIf { isClaude }, + cwd = cwd.trim().takeIf { isClaude }, + permissionMode = permissionMode.takeIf { isClaude }, + ) + } onSpawned(spawned) } catch (e: ApiException) { spawnError = e.message @@ -221,7 +226,9 @@ fun SpawnScreen( } }, enabled = !busy && current != null, - ) { Text(if (busy) "Spawning..." else "Spawn") } + ) { + Text(if (busy) "Spawning..." else "Spawn") + } } } @@ -231,9 +238,9 @@ private data class SpawnOptions(val providers: List, val hosts: List Unit) { fun refresh() { state = LoadState.Loading scope.launch { - state = try { - withContext(Dispatchers.IO) { LoadState.Loaded(fetchUsage(settings)) } - } catch (e: ApiException) { - LoadState.failed(e) - } + state = + try { + withContext(Dispatchers.IO) { LoadState.Loaded(fetchUsage(settings)) } + } catch (e: ApiException) { + LoadState.failed(e) + } } } LaunchedEffect(Unit) { refresh() } @@ -68,37 +69,39 @@ fun UsageScreen(settings: ServerSettings, onBack: () -> Unit) { when (val current = state) { is LoadState.Loading -> CircularProgressIndicator() is LoadState.Error -> Text(current.message, color = MaterialTheme.colorScheme.error) - is LoadState.Loaded -> current.value.forEach { snapshot -> - Card(Modifier.fillMaxWidth()) { - Column(Modifier.padding(16.dp)) { - Text(snapshot.provider, style = MaterialTheme.typography.titleMedium) - Spacer(Modifier.height(8.dp)) - if (!snapshot.available) { - Text( - snapshot.error ?: "Unavailable", - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodyMedium, - ) - } - snapshot.windows.forEach { window -> - WindowBar(window) - Spacer(Modifier.height(12.dp)) + is LoadState.Loaded -> + current.value.forEach { snapshot -> + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(16.dp)) { + Text(snapshot.provider, style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(8.dp)) + if (!snapshot.available) { + Text( + snapshot.error ?: "Unavailable", + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium, + ) + } + snapshot.windows.forEach { window -> + WindowBar(window) + Spacer(Modifier.height(12.dp)) + } } } + Spacer(Modifier.height(12.dp)) } - Spacer(Modifier.height(12.dp)) - } } } } @Composable private fun WindowBar(window: UsageWindow) { - val color = when { - window.percent >= 95 -> OVER_COLOR - window.percent >= 75 -> WARN_COLOR - else -> MaterialTheme.colorScheme.primary - } + val color = + when { + window.percent >= 95 -> OVER_COLOR + window.percent >= 75 -> WARN_COLOR + else -> MaterialTheme.colorScheme.primary + } Column { Row(modifier = Modifier.fillMaxWidth()) { Text( @@ -126,14 +129,15 @@ private fun WindowBar(window: UsageWindow) { } /** "in 3h 12m" -- close enough for deciding whether to start a big task. */ -private fun formatReset(resetsAt: String): String = try { - val until = Duration.between(OffsetDateTime.now(), OffsetDateTime.parse(resetsAt)) - when { - until.isNegative -> "soon" - until.toHours() >= 24 -> "in ${until.toDays()}d ${until.toHours() % 24}h" - until.toHours() > 0 -> "in ${until.toHours()}h ${until.toMinutes() % 60}m" - else -> "in ${until.toMinutes()}m" +private fun formatReset(resetsAt: String): String = + try { + val until = Duration.between(OffsetDateTime.now(), OffsetDateTime.parse(resetsAt)) + when { + until.isNegative -> "soon" + until.toHours() >= 24 -> "in ${until.toDays()}d ${until.toHours() % 24}h" + until.toHours() > 0 -> "in ${until.toHours()}h ${until.toMinutes() % 60}m" + else -> "in ${until.toMinutes()}m" + } + } catch (_: Exception) { + "at $resetsAt" } -} catch (_: Exception) { - "at $resetsAt" -} diff --git a/app/gradle/libs.versions.toml b/app/gradle/libs.versions.toml index 6a90864..7d87faf 100644 --- a/app/gradle/libs.versions.toml +++ b/app/gradle/libs.versions.toml @@ -13,6 +13,10 @@ androidx-activityCompose = "1.13.0" # .toUri), and a transitive it merely inherited could change under it. androidx-core-ktx = "1.19.0" zxing-embedded = "4.3.0" +# The Kotlin formatter, run at its defaults (see CODE_RULES rule 27). ktfmt +# itself is Kotlin-org owned and has almost nothing to configure, which is +# the point; this is the Gradle wrapper for it. Checked 2026-08-28. +ktfmt-gradle = "0.27.0" # Backports java.time (and more) to API 24, which UsageScreen needs: its # reset countdown is OffsetDateTime/Duration, both API 26. Checked # 2026-08-28 against Google Maven. @@ -38,3 +42,4 @@ compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "compose-mu androidApplication = { id = "com.android.application", version.ref = "agp" } composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" } composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +ktfmt = { id = "com.ncorti.ktfmt.gradle", version.ref = "ktfmt-gradle" }