diff --git a/.claude/settings.json b/.claude/settings.json
new file mode 100644
index 0000000..d0f0a29
--- /dev/null
+++ b/.claude/settings.json
@@ -0,0 +1,8 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "env": {
+ "ANDROID_HOME": "/home/bob/Android/Sdk",
+ "ANDROID_SDK_ROOT": "/home/bob/Android/Sdk",
+ "PATH": "/home/bob/Android/Sdk/platform-tools:/usr/local/sbin:/usr/local/bin:/usr/bin:/usr/bin/site_perl:/usr/bin/vendor_perl:/usr/bin/core_perl:/usr/lib/rustup/bin:/home/bob/.local/bin"
+ }
+}
diff --git a/app/android-env.sh b/app/android-env.sh
new file mode 100755
index 0000000..e11b2be
--- /dev/null
+++ b/app/android-env.sh
@@ -0,0 +1,55 @@
+#!/bin/sh
+# Android SDK environment for this app's Gradle build: locates the SDK and
+# exports the PATH/env vars the build needs. Pure Kotlin/Gradle, so nothing
+# Rust/NDK-specific belongs here.
+#
+# Source this directly for one-off commands instead of going through the
+# full run-android.sh (which also creates/boots the emulator, builds,
+# installs, and launches):
+#
+# . ./android-env.sh
+# ./gradlew :androidApp:assembleDebug
+# adb devices
+#
+# Safe to source repeatedly. Intentionally does NOT `set -e`/`set -u`: this
+# file is meant to be sourced into whatever shell is already running --
+# including a long-lived one a session reuses for unrelated commands -- and
+# changing that shell's error-handling options as a side effect of sourcing
+# would be surprising. run-android.sh, which does want strict mode, sets its
+# own `set -eu` before sourcing this.
+
+# Hardcoded (not derived from an inherited ANDROID_HOME) so this doesn't
+# silently follow whatever that happens to be set to elsewhere -- e.g. this
+# sandbox's own profile exports ANDROID_HOME=/opt/android-sdk system-wide, a
+# root-owned install this user can't write to. Everything needed lives under
+# the path below instead, matching Android Studio's own default SDK location
+# convention on Linux.
+SDK_ROOT="$HOME/Android/Sdk"
+ANDROID_HOME="$SDK_ROOT"
+ANDROID_SDK_ROOT="$SDK_ROOT"
+# ~/.local/bin is where the `android` CLI itself installs to (see its own
+# installer); adding it here too means sourcing this script guarantees a
+# working `android` command even in a shell that hasn't picked up
+# ~/.profile yet.
+PATH="$HOME/.local/bin:$SDK_ROOT/cmdline-tools/latest/bin:$SDK_ROOT/platform-tools:$SDK_ROOT/emulator:$PATH"
+# Pin the AVD directory explicitly so avdmanager (creation) and the emulator
+# binary (lookup at start time) are guaranteed to agree on where the AVD
+# lives -- left to their own defaults they can resolve different locations
+# and disagree on whether it exists.
+ANDROID_AVD_HOME="${ANDROID_AVD_HOME:-$HOME/.android/avd}"
+mkdir -p "$ANDROID_AVD_HOME"
+export ANDROID_HOME ANDROID_SDK_ROOT ANDROID_AVD_HOME PATH
+
+echo "==> Ensuring required SDK packages are installed in $SDK_ROOT"
+# $SDK_ROOT is user-owned (unlike /opt/android-sdk), so this genuinely
+# installs anything missing rather than just probing for it -- still
+# best-effort (`|| echo`) so a transient network hiccup doesn't abort a
+# script sourcing this under `set -e`.
+#
+# build-tools is needed twice over: by Gradle for this app's own build, and
+# by ../server at runtime for `aapt2` (reading a discovered APK's package
+# name) and `llvm-strip`/`apksigner` (the slim-APK pipeline).
+android sdk install "cmdline-tools/latest" "platform-tools" "emulator" \
+ "platforms/android-37.0" "build-tools/37.0.0" \
+ "system-images/android-36/google_apis/x86_64" \
+ || echo " (non-fatal: see above)"
diff --git a/app/androidApp/build.gradle.kts b/app/androidApp/build.gradle.kts
new file mode 100644
index 0000000..35a5a9d
--- /dev/null
+++ b/app/androidApp/build.gradle.kts
@@ -0,0 +1,40 @@
+plugins {
+ alias(libs.plugins.androidApplication)
+ alias(libs.plugins.composeMultiplatform)
+ alias(libs.plugins.composeCompiler)
+}
+
+android {
+ namespace = "com.example.aiapp"
+ compileSdk = 37
+
+ defaultConfig {
+ applicationId = "com.example.aiapp"
+ minSdk = 24
+ targetSdk = 37
+ versionCode = 1
+ versionName = "1.0"
+ }
+ packaging {
+ resources {
+ excludes += "/META-INF/{AL2.0,LGPL2.1}"
+ }
+ }
+ buildTypes {
+ getByName("release") {
+ isMinifyEnabled = false
+ }
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_21
+ targetCompatibility = JavaVersion.VERSION_21
+ }
+}
+
+dependencies {
+ implementation(libs.compose.runtime)
+ implementation(libs.compose.foundation)
+ implementation(libs.compose.material3)
+ implementation(libs.compose.ui)
+ implementation(libs.androidx.activity.compose)
+}
diff --git a/app/androidApp/src/main/AndroidManifest.xml b/app/androidApp/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..c2e43e1
--- /dev/null
+++ b/app/androidApp/src/main/AndroidManifest.xml
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt
new file mode 100644
index 0000000..dba837d
--- /dev/null
+++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt
@@ -0,0 +1,157 @@
+package com.example.aiapp
+
+import org.json.JSONArray
+import org.json.JSONObject
+import java.io.IOException
+import java.net.HttpURLConnection
+import java.net.URL
+
+// The REST half of the backend's surface (see server/src/routes.rs for the
+// table); the SSE half is EventStream.kt. All blocking network calls --
+// invoke from a background dispatcher. Each throws ApiException on failure,
+// carrying the server's own explanation where it sent one, since those
+// messages are written to be read on this screen.
+
+private const val CONNECT_TIMEOUT_MS = 5000
+
+class ApiException(message: String, cause: Throwable? = null) : Exception(message, cause)
+
+/**
+ * Runs one request against the backend, with the pinned TLS setup, the
+ * bearer token, and the failure translation every call needs. [readBody]
+ * gets the connected, already-status-checked connection to read from.
+ *
+ * @param readTimeoutMs how long to wait on the response body. The SSE
+ * stream doesn't come through here -- an event stream has no bounded
+ * read time (see EventStream.kt).
+ */
+fun requestFromServer(
+ settings: ServerSettings,
+ path: String,
+ method: String = "GET",
+ jsonBody: String? = null,
+ readTimeoutMs: Int = 5000,
+ readBody: (HttpURLConnection) -> T,
+): T {
+ val connection = URL("${settings.baseUrl}$path").openConnection() as HttpURLConnection
+ try {
+ connection.applyPinnedTls()
+ connection.requestMethod = method
+ connection.connectTimeout = CONNECT_TIMEOUT_MS
+ connection.readTimeout = readTimeoutMs
+ connection.setRequestProperty("Authorization", "Bearer ${settings.token}")
+ if (jsonBody != null) {
+ connection.doOutput = true
+ connection.setRequestProperty("Content-Type", "application/json")
+ connection.outputStream.use { it.write(jsonBody.encodeToByteArray()) }
+ }
+ if (connection.responseCode !in 200..299) {
+ val detail = connection.errorStream?.bufferedReader()?.readText()?.trim()
+ throw ApiException(
+ when {
+ connection.responseCode == 401 ->
+ "The server rejected this device's token. Re-enroll by scanning " +
+ "the server's QR (or rotate with --rotate-token and scan the new one)."
+ detail.isNullOrEmpty() ->
+ "Server returned HTTP ${connection.responseCode} for $path"
+ else -> detail
+ },
+ )
+ }
+ return readBody(connection)
+ } catch (e: ApiException) {
+ throw e
+ } catch (e: IOException) {
+ // Surfacing the real exception (rather than one canned message for
+ // every failure mode) is what lets this be diagnosed on a device
+ // with no logcat access.
+ throw ApiException(
+ "Couldn't reach the server at ${settings.baseUrl} " +
+ "(${e::class.simpleName}: ${e.message}) -- is ai-server running, and is " +
+ "this device able to reach that address (WireGuard up)?",
+ e,
+ )
+ } catch (e: Exception) {
+ throw ApiException(
+ "Reached ${settings.baseUrl}$path but couldn't read its response " +
+ "(${e::class.simpleName}: ${e.message})",
+ e,
+ )
+ } finally {
+ connection.disconnect()
+ }
+}
+
+// One row of GET /sessions.
+data class SessionSummary(
+ val id: String,
+ val kind: String,
+ val title: String,
+ val host: String?,
+ val model: String?,
+ val status: String,
+ val lastActivity: Double,
+)
+
+fun fetchSessions(settings: ServerSettings): List =
+ requestFromServer(settings, "/sessions") { connection ->
+ val sessions = JSONArray(connection.inputStream.bufferedReader().readText())
+ (0 until sessions.length()).map { i ->
+ val session = sessions.getJSONObject(i)
+ SessionSummary(
+ id = session.getString("id"),
+ kind = session.getString("kind"),
+ title = session.getString("title"),
+ host = session.optString("host").ifEmpty { null },
+ model = session.optString("model").ifEmpty { null },
+ status = session.getString("status"),
+ lastActivity = session.getDouble("lastActivity"),
+ )
+ }
+ }
+
+/** Spawns a session and returns it as the list would show it. */
+fun spawnSession(settings: ServerSettings, kind: String, title: String): SessionSummary =
+ requestFromServer(
+ settings,
+ "/sessions",
+ method = "POST",
+ jsonBody = JSONObject().put("kind", kind).put("title", title).toString(),
+ ) { connection ->
+ val session = JSONObject(connection.inputStream.bufferedReader().readText())
+ SessionSummary(
+ id = session.getString("id"),
+ kind = session.getString("kind"),
+ title = session.getString("title"),
+ host = session.optString("host").ifEmpty { null },
+ model = session.optString("model").ifEmpty { null },
+ status = session.getString("status"),
+ lastActivity = session.getDouble("lastActivity"),
+ )
+ }
+
+fun sendMessage(settings: ServerSettings, sessionId: String, text: String) {
+ requestFromServer(
+ settings,
+ "/sessions/$sessionId/message",
+ method = "POST",
+ jsonBody = JSONObject().put("text", text).toString(),
+ ) {}
+}
+
+fun answerQuestion(settings: ServerSettings, sessionId: String, questionId: String, answer: String) {
+ requestFromServer(
+ settings,
+ "/sessions/$sessionId/answer",
+ method = "POST",
+ jsonBody = JSONObject().put("questionId", questionId).put("answer", answer).toString(),
+ ) {}
+}
+
+fun interruptSession(settings: ServerSettings, sessionId: String) {
+ requestFromServer(settings, "/sessions/$sessionId/interrupt", method = "POST") {}
+}
+
+fun deleteSession(settings: ServerSettings, sessionId: String) {
+ requestFromServer(settings, "/sessions/$sessionId", method = "DELETE") {}
+}
diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt
new file mode 100644
index 0000000..f5c5f6b
--- /dev/null
+++ b/app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt
@@ -0,0 +1,97 @@
+package com.example.aiapp
+
+import androidx.activity.compose.BackHandler
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+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.
+ */
+private sealed class Screen {
+ data object SessionList : Screen()
+ data class Session(val summary: SessionSummary) : Screen()
+ data object Spawn : 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.
+ */
+@Composable
+fun AppRoot(settingsVersion: Int) {
+ val context = LocalContext.current
+ var settings by remember(settingsVersion) { mutableStateOf(loadServerSettings(context)) }
+ var screen by remember { mutableStateOf(Screen.SessionList) }
+ // Bumped whenever another screen changes something the list shows, so
+ // returning to it refetches instead of showing a stale list.
+ var reloadToken by remember { mutableStateOf(0) }
+
+ val current = settings
+ if (current == null) {
+ // Not enrolled yet: settings is the only usable screen. The QR
+ // path lands in MainActivity and recomposes from the top.
+ SettingsScreen(
+ existing = null,
+ onSaved = { saved ->
+ settings = saved
+ screen = Screen.SessionList
+ },
+ onBack = null,
+ )
+ return
+ }
+
+ when (val here = screen) {
+ is Screen.SessionList -> SessionListScreen(
+ settings = current,
+ reloadToken = reloadToken,
+ onOpen = { screen = Screen.Session(it) },
+ onSpawn = { screen = Screen.Spawn },
+ onSettings = { screen = Screen.Settings },
+ )
+ is Screen.Session -> {
+ BackHandler {
+ reloadToken++
+ screen = Screen.SessionList
+ }
+ SessionScreen(
+ settings = current,
+ summary = here.summary,
+ onBack = {
+ reloadToken++
+ screen = Screen.SessionList
+ },
+ )
+ }
+ is Screen.Spawn -> {
+ BackHandler { screen = Screen.SessionList }
+ SpawnScreen(
+ settings = current,
+ onSpawned = { spawned ->
+ reloadToken++
+ screen = Screen.Session(spawned)
+ },
+ onBack = { screen = Screen.SessionList },
+ )
+ }
+ is Screen.Settings -> {
+ BackHandler { screen = Screen.SessionList }
+ SettingsScreen(
+ existing = current,
+ onSaved = { saved ->
+ settings = saved
+ reloadToken++
+ screen = Screen.SessionList
+ },
+ onBack = { screen = Screen.SessionList },
+ )
+ }
+ }
+}
diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/EventStream.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/EventStream.kt
new file mode 100644
index 0000000..e4c7083
--- /dev/null
+++ b/app/androidApp/src/main/kotlin/com/example/aiapp/EventStream.kt
@@ -0,0 +1,80 @@
+package com.example.aiapp
+
+import java.io.IOException
+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.
+ *
+ * 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
+ @Volatile private var closed = false
+
+ fun close() {
+ closed = true
+ connection?.disconnect()
+ }
+
+ /** Streams events after [after] into [onEvent] until the stream drops. */
+ fun run(after: Long, onEvent: (SeqEvent) -> Unit) {
+ val connection =
+ URL("${settings.baseUrl}/sessions/$sessionId/events?after=$after").openConnection()
+ as HttpURLConnection
+ this.connection = connection
+ try {
+ connection.applyPinnedTls()
+ connection.connectTimeout = 5000
+ // No read timeout: between events there is nothing to read for
+ // as long as the session is idle; the server's keep-alives and
+ // a dead socket erroring out are the liveness story.
+ connection.readTimeout = 0
+ connection.setRequestProperty("Authorization", "Bearer ${settings.token}")
+ connection.setRequestProperty("Accept", "text/event-stream")
+ if (connection.responseCode != 200) {
+ val detail = connection.errorStream?.bufferedReader()?.readText()?.trim()
+ throw ApiException(detail ?: "HTTP ${connection.responseCode} for the event stream")
+ }
+
+ val reader = connection.inputStream.bufferedReader()
+ // SSE framing: `data:` lines accumulate until a blank line ends
+ // the event. `id:` (the seq) is also inside the JSON payload,
+ // so only data lines matter; comment lines (keep-alives) start
+ // with ':' and are skipped.
+ val data = StringBuilder()
+ while (true) {
+ val line = reader.readLine() ?: break
+ when {
+ line.isEmpty() -> {
+ if (data.isNotEmpty()) {
+ onEvent(parseSeqEvent(data.toString()))
+ data.clear()
+ }
+ }
+ line.startsWith("data:") -> data.append(line.removePrefix("data:").trim())
+ else -> {} // id:, event:, comments -- nothing to do
+ }
+ }
+ } catch (e: ApiException) {
+ throw e
+ } catch (e: IOException) {
+ if (!closed) {
+ throw ApiException(
+ "Lost the event stream (${e::class.simpleName}: ${e.message})",
+ e,
+ )
+ }
+ } finally {
+ connection.disconnect()
+ this.connection = 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
new file mode 100644
index 0000000..73e47a7
--- /dev/null
+++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Events.kt
@@ -0,0 +1,63 @@
+package com.example.aiapp
+
+import org.json.JSONObject
+
+// The common event model, mirrored from server/src/session/driver.rs --
+// the app renders purely from this stream (replayed from the transcript by
+// cursor, then live), so there is no separate "load history" shape to keep
+// in sync with it.
+
+/** One transcript line: the event plus its resume cursor and time. */
+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 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.
+ */
+ 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)
+ }
+ return SeqEvent(seq = body.getLong("seq"), ts = body.getDouble("ts"), event = event)
+}
diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/MainActivity.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/MainActivity.kt
new file mode 100644
index 0000000..0cd2e5c
--- /dev/null
+++ b/app/androidApp/src/main/kotlin/com/example/aiapp/MainActivity.kt
@@ -0,0 +1,81 @@
+package com.example.aiapp
+
+import android.Manifest
+import android.content.Intent
+import android.os.Build
+import android.os.Bundle
+import android.widget.Toast
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.imePadding
+import androidx.compose.foundation.layout.statusBarsPadding
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.core.view.WindowCompat
+
+class MainActivity : ComponentActivity() {
+ // Bumped whenever enrollment lands via an aiapp:// intent so the
+ // composition below re-reads the stored settings.
+ private var settingsVersion by mutableStateOf(0)
+
+ // Registered up front since permission launchers must be registered
+ // before the activity reaches STARTED.
+ private val requestLocalNetworkPermission =
+ registerForActivityResult(ActivityResultContracts.RequestPermission()) {}
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ // Transparent status bar on every version; the Surface below paints
+ // through underneath it and content insets itself. Same reasoning
+ // as local-updater's MainActivity.
+ enableEdgeToEdge()
+ 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
+ // layer (it just times out).
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.CINNAMON_BUN) {
+ requestLocalNetworkPermission.launch(Manifest.permission.ACCESS_LOCAL_NETWORK)
+ }
+
+ handleEnrollment(intent)
+
+ setContent {
+ MaterialTheme {
+ Surface(modifier = Modifier.fillMaxSize()) {
+ Box(modifier = Modifier.fillMaxSize().statusBarsPadding().imePadding()) {
+ AppRoot(settingsVersion)
+ }
+ }
+ }
+ }
+ }
+
+ // launchMode="singleTop": an enrollment scan while the app is open
+ // lands here rather than in a second activity instance.
+ override fun onNewIntent(intent: Intent) {
+ super.onNewIntent(intent)
+ handleEnrollment(intent)
+ }
+
+ private fun handleEnrollment(intent: Intent?) {
+ val uri = intent?.data ?: return
+ val settings = parseEnrollmentUri(uri)
+ if (settings == null) {
+ Toast.makeText(this, "Not a valid enrollment code", Toast.LENGTH_LONG).show()
+ return
+ }
+ saveServerSettings(this, settings)
+ settingsVersion++
+ Toast.makeText(this, "Enrolled with ${settings.baseUrl}", Toast.LENGTH_LONG).show()
+ }
+}
diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/PinnedCert.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/PinnedCert.kt
new file mode 100644
index 0000000..9407cc4
--- /dev/null
+++ b/app/androidApp/src/main/kotlin/com/example/aiapp/PinnedCert.kt
@@ -0,0 +1,68 @@
+package com.example.aiapp
+
+import java.io.ByteArrayInputStream
+import java.net.HttpURLConnection
+import java.security.KeyStore
+import java.security.cert.CertificateFactory
+import javax.net.ssl.HttpsURLConnection
+import javax.net.ssl.SSLContext
+import javax.net.ssl.SSLSocketFactory
+import javax.net.ssl.TrustManagerFactory
+import javax.net.ssl.X509TrustManager
+
+/**
+ * PEM-encoded dev CA certificate this repo's `gen-dev-cert.sh` generates --
+ * the sole trust anchor for every request this app makes. The server's TLS
+ * listener presents a leaf certificate signed by this CA. Everything behind
+ * that listener *is* remote code execution on the backend, so this app
+ * trusts exactly this CA and nothing else -- not even the system store.
+ *
+ * Regenerating that CA means updating this to match; its SHA-256
+ * fingerprint is printed by the script and saved to `certs/ca-sha256.txt`.
+ * The QR enrollment deliberately does not carry the CA: trust lives here in
+ * the APK, so photographing the terminal leaks only the (rotatable) token.
+ */
+const val PINNED_CA_PEM = """-----BEGIN CERTIFICATE-----
+MIIBrjCCAVWgAwIBAgIUGomDFgIrkwxX9504lixc8kM83I0wCgYIKoZIzj0EAwIw
+LTETMBEGA1UECgwKYWktYXBwIGRldjEWMBQGA1UEAwwNYWktYXBwIGRldiBDQTAe
+Fw0yNjA4MjUwMDQ5NDBaFw0zNjA4MjIwMDQ5NDBaMC0xEzARBgNVBAoMCmFpLWFw
+cCBkZXYxFjAUBgNVBAMMDWFpLWFwcCBkZXYgQ0EwWTATBgcqhkjOPQIBBggqhkjO
+PQMBBwNCAARW8deDZhiVxUDo1TyGMIpOpvu45vei8Vd5rWFNgSOl80h8TQ8/v8fI
+tcacAGiPK0OUDOPb6iSaSMS8QEtfPB8+o1MwUTAdBgNVHQ4EFgQUFnEtKeV8et8Z
+O7/ihnMOckS45qwwHwYDVR0jBBgwFoAUFnEtKeV8et8ZO7/ihnMOckS45qwwDwYD
+VR0TAQH/BAUwAwEB/zAKBggqhkjOPQQDAgNHADBEAiBppx+X09AjEJ8X24KxHYaX
+4NRE+8OlxqJCrkuAM7tLagIgND6JvA5K0WjlfZxomla45C5Vd91j2jdIPmM+UkEa
+Wb4=
+-----END CERTIFICATE-----
+"""
+
+/**
+ * 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 {
+ val caCert = CertificateFactory.getInstance("X.509")
+ .generateCertificate(ByteArrayInputStream(PINNED_CA_PEM.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. */
+fun HttpURLConnection.applyPinnedTls() {
+ if (this is HttpsURLConnection) {
+ sslSocketFactory = pinnedSslSocketFactory
+ }
+}
diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/ServerConfig.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/ServerConfig.kt
new file mode 100644
index 0000000..a012f41
--- /dev/null
+++ b/app/androidApp/src/main/kotlin/com/example/aiapp/ServerConfig.kt
@@ -0,0 +1,118 @@
+package com.example.aiapp
+
+import android.content.Context
+import android.net.Uri
+import android.security.keystore.KeyGenParameterSpec
+import android.security.keystore.KeyProperties
+import android.util.Base64
+import java.security.KeyStore
+import javax.crypto.Cipher
+import javax.crypto.KeyGenerator
+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.
+ */
+data class ServerSettings(val host: String, val port: Int, val token: String) {
+ val baseUrl: String get() = "https://$host:$port"
+}
+
+private const val PREFS_NAME = "server"
+private const val KEY_HOST = "host"
+private const val KEY_PORT = "port"
+private const val KEY_TOKEN = "token"
+
+fun loadServerSettings(context: Context): ServerSettings? {
+ val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
+ val host = prefs.getString(KEY_HOST, null) ?: return null
+ val port = prefs.getInt(KEY_PORT, 0)
+ val sealed = prefs.getString(KEY_TOKEN, null) ?: return null
+ val token = unseal(sealed) ?: return null
+ if (port == 0 || token.isEmpty()) return null
+ return ServerSettings(host, port, token)
+}
+
+fun saveServerSettings(context: Context, settings: ServerSettings) {
+ context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
+ .edit()
+ .putString(KEY_HOST, settings.host)
+ .putInt(KEY_PORT, settings.port)
+ .putString(KEY_TOKEN, seal(settings.token))
+ .apply()
+}
+
+/**
+ * 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.
+ */
+fun parseEnrollmentUri(uri: Uri): ServerSettings? {
+ if (uri.scheme != "aiapp" || uri.host != "enroll") return null
+ val host = uri.getQueryParameter("host") ?: return null
+ val port = uri.getQueryParameter("port")?.toIntOrNull() ?: return null
+ val token = uri.getQueryParameter("token") ?: return null
+ if (host.isEmpty() || token.isEmpty()) return null
+ return ServerSettings(host, port, token)
+}
+
+// ---------------------------------------------------------------------------
+// Token sealing. The token is the credential for remote code execution on
+// the backend, so it is stored AES-GCM-encrypted under an Android Keystore
+// key (hardware-backed where the device has it) rather than in plain
+// preferences. Hand-rolled (~40 lines) instead of Jetpack's
+// EncryptedSharedPreferences because that library is deprecated with no
+// drop-in successor -- Google's own guidance is now "use Keystore directly".
+
+private const val KEYSTORE = "AndroidKeyStore"
+private const val KEY_ALIAS = "aiapp-token-key"
+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 }
+ val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE)
+ generator.init(
+ KeyGenParameterSpec.Builder(
+ KEY_ALIAS,
+ KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
+ )
+ .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
+ .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
+ .build(),
+ )
+ return generator.generateKey()
+}
+
+/** iv:ciphertext, both base64 -- the stored form of the token. */
+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) + ":" +
+ 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.
+ */
+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
+}
diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionListScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionListScreen.kt
new file mode 100644
index 0000000..1951759
--- /dev/null
+++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionListScreen.kt
@@ -0,0 +1,219 @@
+package com.example.aiapp
+
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.combinedClickable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Card
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.FloatingActionButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+// Status colors, keyed by the wire strings in Events.kt. Light theme only,
+// as in local-updater.
+private val AWAITING_COLOR = Color(0xFFB26A00)
+private val RUNNING_COLOR = Color(0xFF2E7D32)
+
+private sealed class ListState {
+ data object Loading : ListState()
+ data class Loaded(val sessions: List) : ListState()
+ data class Error(val message: String) : ListState()
+}
+
+/**
+ * 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(
+ settings: ServerSettings,
+ reloadToken: Int,
+ onOpen: (SessionSummary) -> Unit,
+ onSpawn: () -> Unit,
+ onSettings: () -> Unit,
+) {
+ val scope = rememberCoroutineScope()
+ var listState by remember { mutableStateOf(ListState.Loading) }
+ var confirmingDelete by remember { mutableStateOf(null) }
+
+ fun refresh() {
+ listState = ListState.Loading
+ scope.launch {
+ listState = try {
+ withContext(Dispatchers.IO) { ListState.Loaded(fetchSessions(settings)) }
+ } catch (e: ApiException) {
+ ListState.Error(e.message ?: "Unknown error")
+ }
+ }
+ }
+
+ LaunchedEffect(reloadToken) { refresh() }
+
+ Box(Modifier.fillMaxSize()) {
+ Column(Modifier.fillMaxSize().padding(16.dp)) {
+ Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
+ Text(
+ "AI Sessions",
+ style = MaterialTheme.typography.headlineSmall,
+ modifier = Modifier.weight(1f),
+ )
+ TextButton(onClick = onSettings) { Text("Settings") }
+ TextButton(onClick = { refresh() }) { Text("Refresh") }
+ }
+ Spacer(Modifier.height(16.dp))
+
+ when (val state = listState) {
+ is ListState.Loading -> CircularProgressIndicator()
+ is ListState.Error -> Text(
+ "Couldn't reach the server: ${state.message}",
+ color = MaterialTheme.colorScheme.error,
+ )
+ is ListState.Loaded -> {
+ if (state.sessions.isEmpty()) {
+ Text(
+ "No sessions. Tap + to spawn one.",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ // Awaiting-answer first (the point of the screen), then
+ // most recently active.
+ val ordered = state.sessions.sortedWith(
+ compareByDescending { it.status == "awaitingInput" }
+ .thenByDescending { it.lastActivity },
+ )
+ LazyColumn {
+ items(ordered, key = { it.id }) { session ->
+ SessionCard(
+ session = session,
+ onOpen = { onOpen(session) },
+ onLongPress = { confirmingDelete = session },
+ )
+ Spacer(Modifier.height(12.dp))
+ }
+ }
+ }
+ }
+ }
+
+ FloatingActionButton(
+ onClick = onSpawn,
+ modifier = Modifier.align(Alignment.BottomEnd).padding(24.dp),
+ ) { Text("+", style = MaterialTheme.typography.headlineMedium) }
+ }
+
+ confirmingDelete?.let { session ->
+ AlertDialog(
+ onDismissRequest = { confirmingDelete = null },
+ 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) {
+ listState = ListState.Error(e.message ?: "Delete failed")
+ }
+ }
+ }) { Text("Delete") }
+ },
+ dismissButton = {
+ TextButton(onClick = { confirmingDelete = null }) { Text("Cancel") }
+ },
+ )
+ }
+}
+
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+private fun SessionCard(
+ session: SessionSummary,
+ onOpen: () -> Unit,
+ onLongPress: () -> Unit,
+) {
+ Card(
+ Modifier.fillMaxWidth().combinedClickable(onClick = onOpen, onLongClick = onLongPress),
+ ) {
+ Column(Modifier.padding(16.dp)) {
+ Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
+ Text(
+ session.title,
+ style = MaterialTheme.typography.titleMedium,
+ modifier = Modifier.weight(1f),
+ )
+ StatusText(session.status)
+ }
+ Spacer(Modifier.height(4.dp))
+ Row(modifier = Modifier.fillMaxWidth()) {
+ Text(
+ listOfNotNull(session.kind, session.host, session.model).joinToString(" · "),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.weight(1f),
+ )
+ Text(
+ relativeTime(session.lastActivity),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ }
+}
+
+@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
+ }
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ if (status == "running" || status == "compacting") {
+ 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)
+ }
+}
+
+fun relativeTime(epochSeconds: Double): String {
+ val seconds = (System.currentTimeMillis() / 1000.0 - epochSeconds).toLong()
+ return when {
+ seconds < 60 -> "just now"
+ seconds < 3600 -> "${seconds / 60}m ago"
+ seconds < 86400 -> "${seconds / 3600}h ago"
+ else -> "${seconds / 86400}d ago"
+ }
+}
diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt
new file mode 100644
index 0000000..1959f97
--- /dev/null
+++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt
@@ -0,0 +1,349 @@
+package com.example.aiapp
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.material3.Button
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+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.
+ */
+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,
+ val input: String,
+ 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()
+ /** Placeholder row for events this build can't render (images, newer kinds). */
+ data class Note(val text: String) : TranscriptItem()
+}
+
+fun foldEvent(items: List, event: SessionEvent): List =
+ when (event) {
+ is SessionEvent.UserMessage -> items + TranscriptItem.UserMsg(event.text)
+ is SessionEvent.AssistantText -> {
+ // Deltas accumulate into the message they're streaming.
+ val last = items.lastOrNull()
+ if (last is TranscriptItem.AssistantMsg) {
+ items.dropLast(1) + last.copy(text = last.text + event.delta)
+ } else {
+ items + TranscriptItem.AssistantMsg(event.delta)
+ }
+ }
+ is SessionEvent.ToolStart ->
+ 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.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
+ }
+ is SessionEvent.Error -> items + TranscriptItem.ErrorMsg(event.message)
+ is SessionEvent.Image -> items + TranscriptItem.Note("[image ${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
+ }
+
+private fun updateTool(
+ items: List,
+ id: String,
+ change: (TranscriptItem.ToolRun) -> TranscriptItem.ToolRun,
+): List = items.map {
+ if (it is TranscriptItem.ToolRun && it.id == id) change(it) else it
+}
+
+@Composable
+fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: () -> Unit) {
+ val scope = rememberCoroutineScope()
+ var items by remember { mutableStateOf(listOf()) }
+ var status by remember { mutableStateOf(summary.status) }
+ var totalTokens by remember { mutableStateOf(0L) }
+ var streamError by remember { mutableStateOf(null) }
+ var actionError by remember { mutableStateOf(null) }
+ var input by remember { mutableStateOf("") }
+ var expandedTools by remember { mutableStateOf(setOf()) }
+ // The resume cursor, written from the stream's IO thread.
+ val lastSeq = remember { AtomicLong(0) }
+ val activeStream = remember { AtomicReference(null) }
+ val listState = rememberLazyListState()
+
+ fun apply(entry: SeqEvent) {
+ lastSeq.set(entry.seq)
+ when (val event = entry.event) {
+ is SessionEvent.Status -> status = event.state
+ is SessionEvent.UsageDelta -> totalTokens += event.tokens
+ else -> items = foldEvent(items, event)
+ }
+ }
+
+ // The stream lifecycle: connect, follow, and on any drop reconnect
+ // from the cursor -- so a flaky link (or a backend restart) costs
+ // nothing but the gap's latency.
+ LaunchedEffect(summary.id) {
+ while (true) {
+ val stream = EventStream(settings, summary.id)
+ activeStream.set(stream)
+ try {
+ withContext(Dispatchers.IO) {
+ stream.run(lastSeq.get()) { entry ->
+ apply(entry)
+ streamError = null
+ }
+ }
+ } catch (e: ApiException) {
+ streamError = e.message
+ } finally {
+ stream.close()
+ }
+ delay(RECONNECT_DELAY_MS)
+ }
+ }
+ // 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() }
+ }
+
+ LaunchedEffect(items.size) {
+ if (items.isNotEmpty()) listState.animateScrollToItem(items.size - 1)
+ }
+
+ fun act(action: () -> Unit) {
+ scope.launch {
+ try {
+ withContext(Dispatchers.IO) { action() }
+ actionError = null
+ } catch (e: ApiException) {
+ actionError = e.message
+ }
+ }
+ }
+
+ fun send() {
+ val text = input.trim()
+ if (text.isEmpty()) return
+ input = ""
+ act { sendMessage(settings, summary.id, text) }
+ }
+
+ Column(Modifier.fillMaxSize()) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp),
+ ) {
+ TextButton(onClick = onBack) { Text("Back") }
+ Column(Modifier.weight(1f)) {
+ Text(summary.title, style = MaterialTheme.typography.titleMedium)
+ Text(
+ listOfNotNull(
+ summary.kind,
+ summary.model,
+ if (totalTokens > 0) "$totalTokens tok" else null,
+ ).joinToString(" · "),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ StatusText(status)
+ }
+
+ (streamError ?: actionError)?.let { message ->
+ Text(
+ message,
+ color = MaterialTheme.colorScheme.error,
+ style = MaterialTheme.typography.bodySmall,
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
+ )
+ }
+
+ LazyColumn(
+ state = listState,
+ modifier = Modifier.weight(1f).fillMaxWidth(),
+ contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ itemsIndexed(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.Note -> Text(
+ item.text,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ }
+
+ // Always enabled -- a send while the session is running becomes a
+ // steering message injected at the next tool boundary, which is
+ // the point of the whole app.
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.fillMaxWidth().padding(8.dp),
+ ) {
+ OutlinedTextField(
+ value = input,
+ onValueChange = { input = it },
+ modifier = Modifier.weight(1f),
+ placeholder = { Text("Message") },
+ maxLines = 4,
+ )
+ Spacer(Modifier.width(8.dp))
+ if (status == "running" || status == "compacting") {
+ OutlinedButton(onClick = { act { interruptSession(settings, summary.id) } }) {
+ Text("Stop")
+ }
+ Spacer(Modifier.width(8.dp))
+ }
+ Button(onClick = { send() }) { Text("Send") }
+ }
+ }
+}
+
+@Composable
+private fun UserBubble(text: String) {
+ Box(Modifier.fillMaxWidth()) {
+ Card(
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.primaryContainer,
+ ),
+ modifier = Modifier.align(Alignment.CenterEnd).padding(start = 48.dp),
+ ) {
+ Text(text, modifier = Modifier.padding(12.dp))
+ }
+ }
+}
+
+/**
+ * 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))
+ if (!tool.done) {
+ CircularProgressIndicator(
+ modifier = Modifier.width(16.dp).height(16.dp),
+ strokeWidth = 2.dp,
+ )
+ }
+ }
+ if (expanded) {
+ Spacer(Modifier.height(8.dp))
+ Text("Input", style = MaterialTheme.typography.labelSmall)
+ Text(tool.input, style = MaterialTheme.typography.bodySmall)
+ if (tool.output.isNotEmpty()) {
+ Spacer(Modifier.height(8.dp))
+ Text("Output", style = MaterialTheme.typography.labelSmall)
+ Text(tool.output, style = MaterialTheme.typography.bodySmall)
+ }
+ }
+ }
+ }
+}
+
+/**
+ * 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) {
+ Card(Modifier.fillMaxWidth()) {
+ Column(Modifier.padding(12.dp)) {
+ Text(question.prompt, style = MaterialTheme.typography.bodyLarge)
+ Spacer(Modifier.height(8.dp))
+ if (question.answer != null) {
+ Text(
+ "Answered: ${question.answer}",
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ } else {
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ question.options.forEach { option ->
+ OutlinedButton(onClick = { onAnswer(option) }) { Text(option) }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SettingsScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SettingsScreen.kt
new file mode 100644
index 0000000..bcc3f06
--- /dev/null
+++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SettingsScreen.kt
@@ -0,0 +1,110 @@
+package com.example.aiapp
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Button
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.unit.dp
+
+/**
+ * Server address and token. The normal path is scanning the server's
+ * terminal QR (which lands in MainActivity and never shows this screen);
+ * 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(
+ existing: ServerSettings?,
+ onSaved: (ServerSettings) -> Unit,
+ onBack: (() -> Unit)?,
+) {
+ val context = LocalContext.current
+ var host by remember { mutableStateOf(existing?.host ?: "10.66.0.1") }
+ var port by remember { mutableStateOf((existing?.port ?: 8443).toString()) }
+ // Never pre-filled from the stored token: this screen shouldn't be a
+ // way to read the credential back off the device.
+ var token by remember { mutableStateOf("") }
+ var error by remember { mutableStateOf(null) }
+
+ Column(Modifier.fillMaxSize().padding(16.dp)) {
+ Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
+ Text(
+ "Server",
+ style = MaterialTheme.typography.headlineSmall,
+ modifier = Modifier.weight(1f),
+ )
+ if (onBack != null) {
+ TextButton(onClick = onBack) { Text("Back") }
+ }
+ }
+ Spacer(Modifier.height(8.dp))
+ Text(
+ "The easy way: run ai-server on the backend and scan the QR it prints " +
+ "with the phone's camera. Or type the same values here.",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Spacer(Modifier.height(16.dp))
+
+ OutlinedTextField(
+ value = host,
+ onValueChange = { host = it },
+ label = { Text("Host") },
+ singleLine = true,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ Spacer(Modifier.height(8.dp))
+ OutlinedTextField(
+ value = port,
+ onValueChange = { port = it },
+ label = { Text("Port") },
+ singleLine = true,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ Spacer(Modifier.height(8.dp))
+ OutlinedTextField(
+ value = token,
+ onValueChange = { token = it },
+ label = { Text(if (existing != null) "Token (unchanged if left blank)" else "Token") },
+ singleLine = true,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ Spacer(Modifier.height(24.dp))
+
+ error?.let {
+ Text(it, color = MaterialTheme.colorScheme.error)
+ 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)
+ }
+ }
+ }) { Text("Save") }
+ }
+}
diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SpawnScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SpawnScreen.kt
new file mode 100644
index 0000000..82e3b6c
--- /dev/null
+++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SpawnScreen.kt
@@ -0,0 +1,105 @@
+package com.example.aiapp
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Button
+import androidx.compose.material3.FilterChip
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+// The session kinds this build can spawn. Phase 2 adds "claude" (with
+// model / working directory / permission-mode fields), phase 4 "pi" --
+// extending this list and the per-kind fields, not adding a parallel
+// screen.
+private val KINDS = listOf("echo")
+
+/** The spawn screen: kind, title, go. */
+@Composable
+fun SpawnScreen(
+ settings: ServerSettings,
+ onSpawned: (SessionSummary) -> Unit,
+ onBack: () -> Unit,
+) {
+ val scope = rememberCoroutineScope()
+ var kind by remember { mutableStateOf(KINDS.first()) }
+ var title by remember { mutableStateOf("") }
+ var busy by remember { mutableStateOf(false) }
+ var error by remember { mutableStateOf(null) }
+
+ Column(Modifier.fillMaxSize().padding(16.dp)) {
+ Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
+ Text(
+ "New session",
+ style = MaterialTheme.typography.headlineSmall,
+ modifier = Modifier.weight(1f),
+ )
+ TextButton(onClick = onBack) { Text("Cancel") }
+ }
+ Spacer(Modifier.height(16.dp))
+
+ Text("Kind", style = MaterialTheme.typography.labelLarge)
+ Row {
+ KINDS.forEach { candidate ->
+ FilterChip(
+ selected = kind == candidate,
+ onClick = { kind = candidate },
+ label = { Text(candidate) },
+ )
+ Spacer(Modifier.height(0.dp))
+ }
+ }
+ Spacer(Modifier.height(16.dp))
+
+ OutlinedTextField(
+ value = title,
+ onValueChange = { title = it },
+ label = { Text("Title") },
+ placeholder = { Text("Echo session") },
+ singleLine = true,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ Spacer(Modifier.height(24.dp))
+
+ error?.let {
+ Text(it, color = MaterialTheme.colorScheme.error)
+ Spacer(Modifier.height(8.dp))
+ }
+
+ Button(
+ onClick = {
+ busy = true
+ scope.launch {
+ try {
+ val spawned = withContext(Dispatchers.IO) {
+ spawnSession(settings, kind, title.trim())
+ }
+ onSpawned(spawned)
+ } catch (e: ApiException) {
+ error = e.message
+ busy = false
+ }
+ }
+ },
+ enabled = !busy,
+ ) { Text(if (busy) "Spawning..." else "Spawn") }
+ }
+}
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
new file mode 100644
index 0000000..d49e685
--- /dev/null
+++ b/app/build.gradle.kts
@@ -0,0 +1,5 @@
+plugins {
+ alias(libs.plugins.androidApplication) apply false
+ alias(libs.plugins.composeMultiplatform) apply false
+ alias(libs.plugins.composeCompiler) apply false
+}
diff --git a/app/gradle.properties b/app/gradle.properties
new file mode 100644
index 0000000..35a9610
--- /dev/null
+++ b/app/gradle.properties
@@ -0,0 +1,7 @@
+org.gradle.jvmargs=-Xmx2048M -Dfile.encoding=UTF-8
+org.gradle.parallel=true
+org.gradle.caching=true
+org.gradle.configuration-cache=true
+kotlin.code.style=official
+android.useAndroidX=true
+android.nonTransitiveRClass=true
diff --git a/app/gradle/libs.versions.toml b/app/gradle/libs.versions.toml
new file mode 100644
index 0000000..9d37cf0
--- /dev/null
+++ b/app/gradle/libs.versions.toml
@@ -0,0 +1,23 @@
+# Latest stable versions as of 2026-08-24 (checked against Google Maven /
+# Maven Central; prereleases deliberately skipped).
+[versions]
+agp = "9.3.2"
+kotlin = "2.4.10"
+compose-multiplatform = "1.11.1"
+# material3 ships on its own release train, separate from the CMP version.
+compose-material3 = "1.9.0"
+androidx-activityCompose = "1.13.0"
+
+[libraries]
+androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activityCompose" }
+# Declared directly rather than through the plugin's `compose.*` accessors,
+# which are deprecated as of CMP 1.11.
+compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "compose-multiplatform" }
+compose-foundation = { module = "org.jetbrains.compose.foundation:foundation", version.ref = "compose-multiplatform" }
+compose-material3 = { module = "org.jetbrains.compose.material3:material3", version.ref = "compose-material3" }
+compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "compose-multiplatform" }
+
+[plugins]
+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" }
diff --git a/app/gradle/wrapper/gradle-wrapper.jar b/app/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..eddabd2
Binary files /dev/null and b/app/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/app/gradle/wrapper/gradle-wrapper.properties b/app/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..c42672d
--- /dev/null
+++ b/app/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/app/gradlew b/app/gradlew
new file mode 100755
index 0000000..203529c
--- /dev/null
+++ b/app/gradlew
@@ -0,0 +1,248 @@
+#!/bin/sh
+
+#
+# Copyright © 2015 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+#
+
+##############################################################################
+#
+# gradlew start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh gradlew
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
+esac
+
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD=$JAVA_HOME/jre/sh/java
+ else
+ JAVACMD=$JAVA_HOME/bin/java
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD=java
+ if ! command -v java >/dev/null 2>&1
+ then
+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
+ fi
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
+ done
+fi
+
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+# and any embedded shellness will be escaped.
+# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+# treated as '${Hostname}' itself on the command line.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/app/gradlew.bat b/app/gradlew.bat
new file mode 100644
index 0000000..7e60b72
--- /dev/null
+++ b/app/gradlew.bat
@@ -0,0 +1,82 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+@rem SPDX-License-Identifier: Apache-2.0
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem gradlew startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables, and ensure extensions are enabled
+setlocal EnableExtensions
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS=-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+"%COMSPEC%" /c exit 1
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+"%COMSPEC%" /c exit 1
+
+:execute
+@rem Setup the command line
+
+
+
+@rem Execute gradlew
+@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
+@rem which allows us to clear the local environment before executing the java command
+endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
+
+:exitWithErrorLevel
+@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
+"%COMSPEC%" /c exit %ERRORLEVEL%
diff --git a/app/run-android.sh b/app/run-android.sh
new file mode 100755
index 0000000..c85e9f5
--- /dev/null
+++ b/app/run-android.sh
@@ -0,0 +1,106 @@
+#!/bin/sh
+# Builds and runs this app on an emulator, creating/booting the AVD first if
+# it isn't already up. Same flow as local-updater's run-android.sh; see that
+# script for the reasoning behind the avd handling.
+#
+# Environment setup (SDK location, PATH, ...) lives in ./android-env.sh,
+# which can also be sourced directly for one-off commands.
+set -eu
+
+APP_ID="com.example.aiapp"
+
+# The AVD shared by this machine's Android projects -- one emulator, not
+# one per repo. Override with AVD_NAME=... elsewhere.
+AVD_NAME="${AVD_NAME:-tdep}"
+DEVICE_PROFILE="${DEVICE_PROFILE:-pixel_10}"
+SYSTEM_IMAGE="${SYSTEM_IMAGE:-system-images;android-36;google_apis;x86_64}"
+
+SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
+cd "$SCRIPT_DIR"
+
+# shellcheck source=./android-env.sh
+. ./android-env.sh
+
+# Prints the adb serial of a running instance of AVD "$1", or nothing.
+avd_serial() {
+ for s in $(adb devices | awk '$2 == "device" {print $1}'); do
+ if [ "$(adb -s "$s" emu avd name 2>/dev/null | head -n1 | tr -d '\r')" = "$1" ]; then
+ echo "$s"
+ return 0
+ fi
+ done
+}
+
+echo "==> Ensuring emulator system image is installed"
+android sdk install emulator "$SYSTEM_IMAGE" || echo " (non-fatal: see above)"
+
+if [ ! -f "$ANDROID_AVD_HOME/$AVD_NAME.ini" ]; then
+ echo "==> Creating AVD '$AVD_NAME' ($DEVICE_PROFILE, $SYSTEM_IMAGE)"
+ echo no | avdmanager create avd \
+ -n "$AVD_NAME" \
+ -k "$SYSTEM_IMAGE" \
+ --device "$DEVICE_PROFILE" \
+ --sdcard 512M
+else
+ echo "==> Reusing existing AVD '$AVD_NAME'"
+fi
+
+# Host keyboard into the emulator -- this app has text fields.
+CONFIG_INI="$ANDROID_AVD_HOME/$AVD_NAME.avd/config.ini"
+if [ -f "$CONFIG_INI" ]; then
+ grep -v '^hw\.keyboard=' "$CONFIG_INI" >"$CONFIG_INI.tmp"
+ echo "hw.keyboard=yes" >>"$CONFIG_INI.tmp"
+ mv "$CONFIG_INI.tmp" "$CONFIG_INI"
+fi
+
+SERIAL=$(avd_serial "$AVD_NAME")
+if [ -n "$SERIAL" ]; then
+ echo "==> Emulator '$AVD_NAME' already running ($SERIAL)"
+else
+ # Clean up a stray/crashed process for this AVD, if any. The bracketed
+ # first character keeps the pattern from matching the shell running
+ # this script -- unbracketed, this kills that shell mid-run.
+ pkill -f "[e]mulator.*-avd $AVD_NAME" >/dev/null 2>&1 || true
+
+ EMU_LOG="/tmp/$AVD_NAME-emulator.log"
+ : >"$EMU_LOG"
+ if [ -n "${DISPLAY:-}" ] || [ -n "${WAYLAND_DISPLAY:-}" ]; then
+ echo "==> Starting emulator '$AVD_NAME' with GPU acceleration (-gpu host)"
+ emulator -avd "$AVD_NAME" -gpu host -no-audio >"$EMU_LOG" 2>&1 &
+ else
+ echo "==> No display -- starting emulator '$AVD_NAME' headless (-gpu swiftshader_indirect)"
+ emulator -avd "$AVD_NAME" -gpu swiftshader_indirect -no-audio -no-window \
+ >"$EMU_LOG" 2>&1 &
+ fi
+ EMU_PID=$!
+
+ i=0
+ booted=""
+ while [ "$i" -lt 150 ]; do
+ if ! kill -0 "$EMU_PID" 2>/dev/null; then
+ echo "Emulator process exited unexpectedly. Log output:" >&2
+ cat "$EMU_LOG" >&2
+ exit 1
+ fi
+ SERIAL=$(avd_serial "$AVD_NAME")
+ if [ -n "$SERIAL" ]; then
+ booted=$(adb -s "$SERIAL" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')
+ [ "$booted" = "1" ] && break
+ fi
+ i=$((i + 1))
+ sleep 2
+ done
+ if [ "$booted" != "1" ]; then
+ echo "Emulator did not finish booting in time. Log output:" >&2
+ cat "$EMU_LOG" >&2
+ exit 1
+ fi
+fi
+
+echo "==> Building debug APK"
+./gradlew :androidApp:assembleDebug
+
+APK="androidApp/build/outputs/apk/debug/androidApp-debug.apk"
+echo "==> Installing and launching $APK"
+adb -s "$SERIAL" install -r "$APK"
+adb -s "$SERIAL" shell am start -n "$APP_ID/.MainActivity"
diff --git a/app/settings.gradle.kts b/app/settings.gradle.kts
new file mode 100644
index 0000000..2ead5d4
--- /dev/null
+++ b/app/settings.gradle.kts
@@ -0,0 +1,18 @@
+rootProject.name = "AiApp"
+
+pluginManagement {
+ repositories {
+ google()
+ gradlePluginPortal()
+ mavenCentral()
+ }
+}
+
+dependencyResolutionManagement {
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+include(":androidApp")