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" }