Phase 1 app: session list, session screen, spawn, QR enrollment over pinned TLS

Compose app mirroring local-updater's stack (single :androidApp module,
pinned CA, HttpURLConnection transport) plus what this app needs on top:
a bearer token sealed with an Android Keystore AES-GCM key, an
aiapp://enroll intent filter so scanning the server's terminal QR with
the stock camera enrolls the phone with no QR library, an SSE client
that resumes by transcript cursor, and a transcript renderer folding the
common event model into user bubbles, streaming text, collapsible tool
cards, and answerable question cards.

Verified on the tdep emulator against the real server: enrollment deep
link, list, spawn, streamed echo turn, question answer round trip, tool
card expansion, adjustResize keyboard behavior. Build is warning-clean
(compose.* accessors replaced with direct dependencies).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
This commit is contained in:
irisandClaude Fable 5 committed 2026-08-24 21:07:02 -04:00
1 parent 967fc814ab
commit 213bc72b64
24 files changed
+2086

No files matched your search

+55
View File
@@ -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)"
+40
View File
@@ -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)
}
@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<!-- Android 17 (API 37) made Local Network Protection mandatory: an app
targeting 37+ needs this runtime permission to reach *any* local
network address, including a plain socket to a LAN IP literal.
Without it the traffic is silently dropped, surfacing only as a
connect timeout. See MainActivity.kt's runtime request, and
local-updater's manifest for the full story. -->
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
<application
android:label="AI Sessions"
android:allowBackup="true"
android:theme="@android:style/Theme.Material.Light.NoActionBar">
<!-- adjustResize (not the system's default pan): the layout handles
the keyboard itself via imePadding(), so the window must resize
rather than slide the top bar off screen. -->
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:windowSoftInputMode="adjustResize"
android:theme="@android:style/Theme.Material.Light.NoActionBar">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Enrollment: the server prints its aiapp://enroll QR to the
terminal; the phone's camera app opens the URI here, so no
camera code or QR library is needed in this app. -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="aiapp" android:host="enroll" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -0,0 +1,157 @@
package com.example.aiapp
import org.json.JSONArray
import org.json.JSONObject
import java.io.IOException
import java.net.HttpURLConnection
import java.net.URL
// The REST half of the backend's surface (see server/src/routes.rs for the
// table); the SSE half is EventStream.kt. All blocking network calls --
// invoke from a background dispatcher. Each throws ApiException on failure,
// carrying the server's own explanation where it sent one, since those
// messages are written to be read on this screen.
private const val CONNECT_TIMEOUT_MS = 5000
class ApiException(message: String, cause: Throwable? = null) : Exception(message, cause)
/**
* Runs one request against the backend, with the pinned TLS setup, the
* bearer token, and the failure translation every call needs. [readBody]
* gets the connected, already-status-checked connection to read from.
*
* @param readTimeoutMs how long to wait on the response body. The SSE
* stream doesn't come through here -- an event stream has no bounded
* read time (see EventStream.kt).
*/
fun <T> requestFromServer(
settings: ServerSettings,
path: String,
method: String = "GET",
jsonBody: String? = null,
readTimeoutMs: Int = 5000,
readBody: (HttpURLConnection) -> T,
): T {
val connection = URL("${settings.baseUrl}$path").openConnection() as HttpURLConnection
try {
connection.applyPinnedTls()
connection.requestMethod = method
connection.connectTimeout = CONNECT_TIMEOUT_MS
connection.readTimeout = readTimeoutMs
connection.setRequestProperty("Authorization", "Bearer ${settings.token}")
if (jsonBody != null) {
connection.doOutput = true
connection.setRequestProperty("Content-Type", "application/json")
connection.outputStream.use { it.write(jsonBody.encodeToByteArray()) }
}
if (connection.responseCode !in 200..299) {
val detail = connection.errorStream?.bufferedReader()?.readText()?.trim()
throw ApiException(
when {
connection.responseCode == 401 ->
"The server rejected this device's token. Re-enroll by scanning " +
"the server's QR (or rotate with --rotate-token and scan the new one)."
detail.isNullOrEmpty() ->
"Server returned HTTP ${connection.responseCode} for $path"
else -> detail
},
)
}
return readBody(connection)
} catch (e: ApiException) {
throw e
} catch (e: IOException) {
// Surfacing the real exception (rather than one canned message for
// every failure mode) is what lets this be diagnosed on a device
// with no logcat access.
throw ApiException(
"Couldn't reach the server at ${settings.baseUrl} " +
"(${e::class.simpleName}: ${e.message}) -- is ai-server running, and is " +
"this device able to reach that address (WireGuard up)?",
e,
)
} catch (e: Exception) {
throw ApiException(
"Reached ${settings.baseUrl}$path but couldn't read its response " +
"(${e::class.simpleName}: ${e.message})",
e,
)
} finally {
connection.disconnect()
}
}
// One row of GET /sessions.
data class SessionSummary(
val id: String,
val kind: String,
val title: String,
val host: String?,
val model: String?,
val status: String,
val lastActivity: Double,
)
fun fetchSessions(settings: ServerSettings): List<SessionSummary> =
requestFromServer(settings, "/sessions") { connection ->
val sessions = JSONArray(connection.inputStream.bufferedReader().readText())
(0 until sessions.length()).map { i ->
val session = sessions.getJSONObject(i)
SessionSummary(
id = session.getString("id"),
kind = session.getString("kind"),
title = session.getString("title"),
host = session.optString("host").ifEmpty { null },
model = session.optString("model").ifEmpty { null },
status = session.getString("status"),
lastActivity = session.getDouble("lastActivity"),
)
}
}
/** Spawns a session and returns it as the list would show it. */
fun spawnSession(settings: ServerSettings, kind: String, title: String): SessionSummary =
requestFromServer(
settings,
"/sessions",
method = "POST",
jsonBody = JSONObject().put("kind", kind).put("title", title).toString(),
) { connection ->
val session = JSONObject(connection.inputStream.bufferedReader().readText())
SessionSummary(
id = session.getString("id"),
kind = session.getString("kind"),
title = session.getString("title"),
host = session.optString("host").ifEmpty { null },
model = session.optString("model").ifEmpty { null },
status = session.getString("status"),
lastActivity = session.getDouble("lastActivity"),
)
}
fun sendMessage(settings: ServerSettings, sessionId: String, text: String) {
requestFromServer(
settings,
"/sessions/$sessionId/message",
method = "POST",
jsonBody = JSONObject().put("text", text).toString(),
) {}
}
fun answerQuestion(settings: ServerSettings, sessionId: String, questionId: String, answer: String) {
requestFromServer(
settings,
"/sessions/$sessionId/answer",
method = "POST",
jsonBody = JSONObject().put("questionId", questionId).put("answer", answer).toString(),
) {}
}
fun interruptSession(settings: ServerSettings, sessionId: String) {
requestFromServer(settings, "/sessions/$sessionId/interrupt", method = "POST") {}
}
fun deleteSession(settings: ServerSettings, sessionId: String) {
requestFromServer(settings, "/sessions/$sessionId", method = "DELETE") {}
}
@@ -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>(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 },
)
}
}
}
@@ -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
}
}
}
@@ -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<String>) : 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)
}
@@ -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()
}
}
@@ -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<X509TrustManager>()
.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
}
}
@@ -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
}
@@ -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<SessionSummary>) : 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>(ListState.Loading) }
var confirmingDelete by remember { mutableStateOf<SessionSummary?>(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<SessionSummary> { 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"
}
}
@@ -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<String>,
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<TranscriptItem>, event: SessionEvent): List<TranscriptItem> =
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<TranscriptItem>,
id: String,
change: (TranscriptItem.ToolRun) -> TranscriptItem.ToolRun,
): List<TranscriptItem> = 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<TranscriptItem>()) }
var status by remember { mutableStateOf(summary.status) }
var totalTokens by remember { mutableStateOf(0L) }
var streamError by remember { mutableStateOf<String?>(null) }
var actionError by remember { mutableStateOf<String?>(null) }
var input by remember { mutableStateOf("") }
var expandedTools by remember { mutableStateOf(setOf<String>()) }
// The resume cursor, written from the stream's IO thread.
val lastSeq = remember { AtomicLong(0) }
val activeStream = remember { AtomicReference<EventStream?>(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) }
}
}
}
}
}
}
@@ -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<String?>(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") }
}
}
@@ -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<String?>(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") }
}
}
+5
View File
@@ -0,0 +1,5 @@
plugins {
alias(libs.plugins.androidApplication) apply false
alias(libs.plugins.composeMultiplatform) apply false
alias(libs.plugins.composeCompiler) apply false
}
+7
View File
@@ -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
+23
View File
@@ -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" }
Binary file not shown.
+7
View File
@@ -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
Vendored Executable
+248
View File
@@ -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" "$@"
+82
View File
@@ -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%
+106
View File
@@ -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"
+18
View File
@@ -0,0 +1,18 @@
rootProject.name = "AiApp"
pluginManagement {
repositories {
google()
gradlePluginPortal()
mavenCentral()
}
}
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
}
}
include(":androidApp")