Take ktfmt's defaults for the Kotlin half

The server half went to rustfmt's defaults earlier today; this is the same
move for the app, and the same reasoning. Kotlin ships no formatter with
the Gradle build, so the question was which to adopt: ktfmt is Kotlin-org
owned now (it moved from facebook/ktfmt), is a formatter rather than a
configurable linter, and has essentially nothing to tune -- which is what
rule 27 is asking for. ktlint's .editorconfig surface is the thing that
rule warns against, and detekt is static analysis, whose job Android Lint
already does here.

One setting, and it is a choice between the tool's own two styles rather
than a tuning: kotlinLangStyle() is the 4-space one, which is what this
code already was. The 2-space default would have reindented every file to
say nothing.

  ./gradlew :androidApp:ktfmtFormat   to apply
  ./gradlew :androidApp:ktfmtCheck    to verify

Formatting only. The one thing worth checking by hand was the generated
PEM constant, since a leading newline there costs Android's
CertificateFactory its preamble sniff and fails at runtime nowhere near
the cause: ktfmt moved `.trimMargin()` onto its own line and left the
template alone, and the regenerated constant still starts at the opening
quotes.

Verified after: ktfmtCheck, compileDebugKotlin and lintDebug all pass, and
the APK builds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
This commit is contained in:
irisandClaude Opus 5 committed 2026-08-28 04:03:14 -04:00
1 parent dde5042b12
commit 0c13090d70
16 files changed
+585 -496

No files matched your search

@@ -1,10 +1,10 @@
package com.example.aiapp
import org.json.JSONArray
import org.json.JSONObject
import java.io.IOException
import java.net.HttpURLConnection
import java.net.URL
import org.json.JSONArray
import org.json.JSONObject
// The REST half of the backend's surface (see server/src/routes.rs for the
// table); the SSE half is EventStream.kt. All blocking network calls --
@@ -20,13 +20,12 @@ private const val READ_TIMEOUT_MS = 5000
class ApiException(message: String, cause: Throwable? = null) : Exception(message, cause)
/**
* Runs one request against the backend, with the pinned TLS setup, the
* bearer token, and the failure translation every call needs. [readBody]
* gets the connected, already-status-checked connection to read from.
* Runs one request against the backend, with the pinned TLS setup, the bearer token, and the
* failure translation every call needs. [readBody] gets the connected, already-status-checked
* connection to read from.
*
* @param readTimeoutMs how long to wait on the response body. The SSE
* stream doesn't come through here -- an event stream has no bounded
* read time (see EventStream.kt).
* @param readTimeoutMs how long to wait on the response body. The SSE stream doesn't come through
* here -- an event stream has no bounded read time (see EventStream.kt).
*/
fun <T> requestFromServer(
settings: ServerSettings,
@@ -64,7 +63,7 @@ fun <T> requestFromServer(
detail.isNullOrEmpty() ->
"Server returned HTTP ${connection.responseCode} for $path"
else -> detail
},
}
)
}
return readBody(connection)
@@ -116,15 +115,16 @@ data class SessionSummary(
val lastActivity: Double,
)
private fun parseSession(session: JSONObject) = SessionSummary(
id = session.getString("id"),
provider = session.getString("provider"),
title = session.getString("title"),
host = session.optString("host").ifEmpty { null },
model = session.optString("model").ifEmpty { null },
status = session.getString("status"),
lastActivity = session.getDouble("lastActivity"),
)
private fun parseSession(session: JSONObject) =
SessionSummary(
id = session.getString("id"),
provider = session.getString("provider"),
title = session.getString("title"),
host = session.optString("host").ifEmpty { null },
model = session.optString("model").ifEmpty { null },
status = session.getString("status"),
lastActivity = session.getDouble("lastActivity"),
)
fun fetchSessions(settings: ServerSettings): List<SessionSummary> =
requestFromServer(settings, "/sessions") { it.jsonObjects(::parseSession) }
@@ -156,8 +156,8 @@ fun fetchHosts(settings: ServerSettings): List<RemoteHost> =
}
/**
* Spawns a session and returns it as the list would show it. [host] is the
* name of a configured host, or null to run on the backend machine itself.
* Spawns a session and returns it as the list would show it. [host] is the name of a configured
* host, or null to run on the backend machine itself.
*/
fun spawnSession(
settings: ServerSettings,
@@ -172,12 +172,17 @@ fun spawnSession(
settings,
"/sessions",
method = "POST",
jsonBody = JSONObject().put("provider", provider).put("title", title).apply {
if (!host.isNullOrBlank()) put("host", host)
if (!model.isNullOrBlank()) put("model", model)
if (!cwd.isNullOrBlank()) put("cwd", cwd)
if (!permissionMode.isNullOrBlank()) put("permissionMode", permissionMode)
}.toString(),
jsonBody =
JSONObject()
.put("provider", provider)
.put("title", title)
.apply {
if (!host.isNullOrBlank()) put("host", host)
if (!model.isNullOrBlank()) put("model", model)
if (!cwd.isNullOrBlank()) put("cwd", cwd)
if (!permissionMode.isNullOrBlank()) put("permissionMode", permissionMode)
}
.toString(),
readTimeoutMs = 30000,
) { connection ->
parseSession(connection.jsonObject())
@@ -193,10 +198,11 @@ fun sendMessage(
settings,
"/sessions/$sessionId/message",
method = "POST",
jsonBody = JSONObject()
.put("text", text)
.put("attachmentIds", JSONArray(attachmentIds))
.toString(),
jsonBody =
JSONObject()
.put("text", text)
.put("attachmentIds", JSONArray(attachmentIds))
.toString(),
) {}
}
@@ -208,11 +214,11 @@ fun uploadAttachment(
mime: String,
): String {
val boundary = "----aiapp-${System.currentTimeMillis()}"
val head = (
"--$boundary\r\n" +
"Content-Disposition: form-data; name=\"file\"; filename=\"image\"\r\n" +
"Content-Type: $mime\r\n\r\n"
).encodeToByteArray()
val head =
("--$boundary\r\n" +
"Content-Disposition: form-data; name=\"file\"; filename=\"image\"\r\n" +
"Content-Type: $mime\r\n\r\n")
.encodeToByteArray()
val tail = "\r\n--$boundary--\r\n".encodeToByteArray()
return requestFromServer(
settings,
@@ -254,19 +260,25 @@ fun fetchUsage(settings: ServerSettings): List<UsageSnapshot> =
provider = snapshot.getString("provider"),
available = snapshot.getBoolean("available"),
error = snapshot.optString("error").ifEmpty { null },
windows = snapshot.getJSONArray("windows").mapObjects { window ->
UsageWindow(
label = window.getString("label"),
percent = window.getDouble("percent"),
resetsAt = window.optString("resetsAt").ifEmpty { null },
active = window.getBoolean("active"),
)
},
windows =
snapshot.getJSONArray("windows").mapObjects { window ->
UsageWindow(
label = window.getString("label"),
percent = window.getDouble("percent"),
resetsAt = window.optString("resetsAt").ifEmpty { null },
active = window.getBoolean("active"),
)
},
)
}
}
fun answerQuestion(settings: ServerSettings, sessionId: String, questionId: String, answer: String) {
fun answerQuestion(
settings: ServerSettings,
sessionId: String,
questionId: String,
answer: String,
) {
requestFromServer(
settings,
"/sessions/$sessionId/answer",
@@ -10,21 +10,24 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
/**
* One `when` rather than a navigation library: four screens, with the list
* as the root and the back button the only other way between them.
* One `when` rather than a navigation library: four screens, with the list as the root and the back
* button the only other way between them.
*/
private sealed class Screen {
data object SessionList : Screen()
data class Session(val summary: SessionSummary) : Screen()
data object Spawn : Screen()
data object Usage : Screen()
data object Settings : Screen()
}
/**
* [settingsVersion] bumps when enrollment lands via an `aiapp://` intent
* (see MainActivity), re-reading the stored settings -- a plain `remember`
* would keep serving the pre-enrollment null.
* [settingsVersion] bumps when enrollment lands via an `aiapp://` intent (see MainActivity),
* re-reading the stored settings -- a plain `remember` would keep serving the pre-enrollment null.
*/
@Composable
fun AppRoot(settingsVersion: Int) {
@@ -63,35 +66,39 @@ fun AppRoot(settingsVersion: Int) {
}
when (val here = screen) {
is Screen.SessionList -> SessionListScreen(
settings = current,
reloadToken = reloadToken,
onOpen = { screen = Screen.Session(it) },
onSpawn = { screen = Screen.Spawn },
onUsage = { screen = Screen.Usage },
onSettings = { screen = Screen.Settings },
)
is Screen.Session -> SessionScreen(
settings = current,
summary = here.summary,
onBack = goToList,
)
is Screen.Spawn -> SpawnScreen(
settings = current,
onSpawned = { spawned ->
reloadToken++
screen = Screen.Session(spawned)
},
onBack = goToList,
)
is Screen.SessionList ->
SessionListScreen(
settings = current,
reloadToken = reloadToken,
onOpen = { screen = Screen.Session(it) },
onSpawn = { screen = Screen.Spawn },
onUsage = { screen = Screen.Usage },
onSettings = { screen = Screen.Settings },
)
is Screen.Session ->
SessionScreen(
settings = current,
summary = here.summary,
onBack = goToList,
)
is Screen.Spawn ->
SpawnScreen(
settings = current,
onSpawned = { spawned ->
reloadToken++
screen = Screen.Session(spawned)
},
onBack = goToList,
)
is Screen.Usage -> UsageScreen(settings = current, onBack = goToList)
is Screen.Settings -> SettingsScreen(
existing = current,
onSaved = { saved ->
settings = saved
goToList()
},
onBack = goToList,
)
is Screen.Settings ->
SettingsScreen(
existing = current,
onSaved = { saved ->
settings = saved
goToList()
},
onBack = goToList,
)
}
}
@@ -5,25 +5,20 @@ import com.journeyapps.barcodescanner.CaptureActivity
import com.journeyapps.barcodescanner.DecoratedBarcodeView
/**
* The screen behind the Settings screen's "Scan QR code" button:
* zxing-android-embedded's capture activity with two of its defaults taken
* off, both because they put work on the person holding the phone.
* The screen behind the Settings screen's "Scan QR code" button: zxing-android-embedded's capture
* activity with two of its defaults taken off, both because they put work on the person holding the
* phone.
*
* - **It decodes only the framing rectangle**, which
* `CameraPreview.marginFraction` insets by 10% from every edge and
* `DecoderThread` crops each frame to before the decoder sees it. A code
* that fills the viewfinder keeps decoding -- measured, not assumed --
* but it does so having spent its quiet zone and margin for error on
* the crop, and anything further out is simply not looked at. Decoding
* the whole preview costs nothing and means the framing is never the
* user's problem.
* - **It decorates the preview** with a red laser line and the dots the
* detector scatters wherever it finds a candidate pattern. That is the
* library's house style; a plain preview is ours.
* - **It decodes only the framing rectangle**, which `CameraPreview.marginFraction` insets by 10%
* from every edge and `DecoderThread` crops each frame to before the decoder sees it. A code that
* fills the viewfinder keeps decoding -- measured, not assumed -- but it does so having spent its
* quiet zone and margin for error on the crop, and anything further out is simply not looked at.
* Decoding the whole preview costs nothing and means the framing is never the user's problem.
* - **It decorates the preview** with a red laser line and the dots the detector scatters wherever
* it finds a candidate pattern. That is the library's house style; a plain preview is ours.
*
* Orientation is left to the sensor rather than pinned to landscape as the
* library's own manifest entry pins it, so the phone can be held whichever
* way the code is in front of it.
* Orientation is left to the sensor rather than pinned to landscape as the library's own manifest
* entry pins it, so the phone can be held whichever way the code is in front of it.
*/
class EnrollmentScanActivity : CaptureActivity() {
override fun initializeContent(): DecoratedBarcodeView {
@@ -5,15 +5,13 @@ import java.net.HttpURLConnection
import java.net.URL
/**
* The SSE half of the API: one long-lived GET per open session screen,
* replaying the transcript after a cursor and then following it live.
* The SSE half of the API: one long-lived GET per open session screen, replaying the transcript
* after a cursor and then following it live.
*
* Blocking -- run() occupies its thread until the stream ends. [close]
* (from any thread) is the cancellation path: it disconnects the socket,
* which unblocks the read; run() then returns instead of throwing, so a
* deliberate close doesn't surface as a connection error. The caller owns
* reconnecting (with the last seq it saw as the new cursor) -- see
* SessionScreen.
* Blocking -- run() occupies its thread until the stream ends. [close] (from any thread) is the
* cancellation path: it disconnects the socket, which unblocks the read; run() then returns instead
* of throwing, so a deliberate close doesn't surface as a connection error. The caller owns
* reconnecting (with the last seq it saw as the new cursor) -- see SessionScreen.
*/
class EventStream(private val settings: ServerSettings, private val sessionId: String) {
@Volatile private var connection: HttpURLConnection? = null
@@ -12,52 +12,66 @@ data class SeqEvent(val seq: Long, val ts: Double, val event: SessionEvent)
sealed class SessionEvent {
data class UserMessage(val text: String) : SessionEvent()
data class AssistantText(val delta: String) : SessionEvent()
data class ToolStart(val id: String, val tool: String, val input: String) : SessionEvent()
data class ToolUpdate(val id: String, val output: String) : SessionEvent()
data class ToolEnd(val id: String, val output: String) : SessionEvent()
data class Image(val ref: String) : SessionEvent()
data class Question(val id: String, val prompt: String, val options: List<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.
* An event type this app build doesn't know -- a newer server. Kept (not thrown) so one new
* event kind degrades to a placeholder row instead of killing the stream.
*/
data class Unknown(val type: String) : SessionEvent()
}
fun parseSeqEvent(json: String): SeqEvent {
val body = JSONObject(json)
val event = when (val type = body.getString("type")) {
"userMessage" -> SessionEvent.UserMessage(body.getString("text"))
"assistantText" -> SessionEvent.AssistantText(body.getString("delta"))
"toolStart" -> SessionEvent.ToolStart(
id = body.getString("id"),
tool = body.getString("tool"),
// Kept as raw JSON text: the input shape is the tool's own
// business, and the UI only ever shows it verbatim.
input = body.get("input").toString(),
)
"toolUpdate" -> SessionEvent.ToolUpdate(body.getString("id"), body.getString("output"))
"toolEnd" -> SessionEvent.ToolEnd(body.getString("id"), body.getString("output"))
"image" -> SessionEvent.Image(body.getString("ref"))
"question" -> SessionEvent.Question(
id = body.getString("id"),
prompt = body.getString("prompt"),
options = body.getJSONArray("options").let { options ->
(0 until options.length()).map { options.getString(it) }
},
)
"answered" -> SessionEvent.Answered(body.getString("id"), body.getString("answer"))
"status" -> SessionEvent.Status(body.getString("state"))
"usageDelta" -> SessionEvent.UsageDelta(body.getLong("tokens"))
"error" -> SessionEvent.Error(body.getString("message"))
else -> SessionEvent.Unknown(type)
}
val event =
when (val type = body.getString("type")) {
"userMessage" -> SessionEvent.UserMessage(body.getString("text"))
"assistantText" -> SessionEvent.AssistantText(body.getString("delta"))
"toolStart" ->
SessionEvent.ToolStart(
id = body.getString("id"),
tool = body.getString("tool"),
// Kept as raw JSON text: the input shape is the tool's own
// business, and the UI only ever shows it verbatim.
input = body.get("input").toString(),
)
"toolUpdate" -> SessionEvent.ToolUpdate(body.getString("id"), body.getString("output"))
"toolEnd" -> SessionEvent.ToolEnd(body.getString("id"), body.getString("output"))
"image" -> SessionEvent.Image(body.getString("ref"))
"question" ->
SessionEvent.Question(
id = body.getString("id"),
prompt = body.getString("prompt"),
options =
body.getJSONArray("options").let { options ->
(0 until options.length()).map { options.getString(it) }
},
)
"answered" -> SessionEvent.Answered(body.getString("id"), body.getString("answer"))
"status" -> SessionEvent.Status(body.getString("state"))
"usageDelta" -> SessionEvent.UsageDelta(body.getLong("tokens"))
"error" -> SessionEvent.Error(body.getString("message"))
else -> SessionEvent.Unknown(type)
}
return SeqEvent(seq = body.getLong("seq"), ts = body.getDouble("ts"), event = event)
}
@@ -1,17 +1,14 @@
package com.example.aiapp
/**
* What a screen knows about something it had to fetch: still finding out,
* got it, or couldn't.
* What a screen knows about something it had to fetch: still finding out, got it, or couldn't.
*
* Three states rather than a value alongside a nullable error, because
* "we couldn't find out" must not share a representation with "there is
* nothing" -- a failed fetch would otherwise render as an empty list,
* which is the one wrong answer that looks like a right one.
* Three states rather than a value alongside a nullable error, because "we couldn't find out" must
* not share a representation with "there is nothing" -- a failed fetch would otherwise render as an
* empty list, which is the one wrong answer that looks like a right one.
*
* [Loading] and [Error] carry no payload, so they are `LoadState<Nothing>`
* and this is covariant in [T]: one `LoadState.Loading` serves every
* screen rather than each needing its own.
* [Loading] and [Error] carry no payload, so they are `LoadState<Nothing>` and this is covariant in
* [T]: one `LoadState.Loading` serves every screen rather than each needing its own.
*/
sealed class LoadState<out T> {
data object Loading : LoadState<Nothing>()
@@ -22,10 +19,9 @@ sealed class LoadState<out T> {
companion object {
/**
* The failure a fetch produces. Api.kt writes its messages to be
* read on this screen, so this passes one through rather than
* replacing it; the fallback covers only a throwable with no
* message at all, which [ApiException] never is.
* The failure a fetch produces. Api.kt writes its messages to be read on this screen, so
* this passes one through rather than replacing it; the fallback covers only a throwable
* with no message at all, which [ApiException] never is.
*/
fun failed(e: ApiException): Error = Error(e.message ?: "Unknown error")
}
@@ -17,7 +17,6 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.core.view.WindowCompat
@@ -39,7 +38,8 @@ class MainActivity : ComponentActivity() {
// through underneath it and content insets itself. Same reasoning
// as dev-updater's MainActivity.
enableEdgeToEdge()
WindowCompat.getInsetsController(window, window.decorView).isAppearanceLightStatusBars = true
WindowCompat.getInsetsController(window, window.decorView).isAppearanceLightStatusBars =
true
// Android 17+ silently drops local-network traffic without this;
// requested up front because a denial is invisible at the socket
@@ -25,30 +25,29 @@ import javax.net.ssl.X509TrustManager
// so photographing the terminal leaks only the (rotatable) token.
/**
* Trusts only [PINNED_CA_PEM], not the device's system trust store, so a
* real CA-issued cert for some other host wouldn't be accepted either.
* Built once and cached -- every SSE reconnect would otherwise redo the
* KeyStore/TrustManager setup from scratch.
* Trusts only [PINNED_CA_PEM], not the device's system trust store, so a real CA-issued cert for
* some other host wouldn't be accepted either. Built once and cached -- every SSE reconnect would
* otherwise redo the KeyStore/TrustManager setup from scratch.
*/
val pinnedSslSocketFactory: SSLSocketFactory by lazy {
// Trimmed because CertificateFactory only recognises PEM when the
// "-----BEGIN" preamble is the very first thing it sees; surrounding
// whitespace sends it down the DER path instead.
val caCert = CertificateFactory.getInstance("X.509")
.generateCertificate(ByteArrayInputStream(PINNED_CA_PEM.trim().encodeToByteArray()))
val keyStore = KeyStore.getInstance(KeyStore.getDefaultType()).apply {
load(null, null)
setCertificateEntry("ai-app-dev-ca", caCert)
}
val trustManager = TrustManagerFactory
.getInstance(TrustManagerFactory.getDefaultAlgorithm())
.apply { init(keyStore) }
.trustManagers
.filterIsInstance<X509TrustManager>()
.first()
SSLContext.getInstance("TLS").apply {
init(null, arrayOf(trustManager), null)
}.socketFactory
val caCert =
CertificateFactory.getInstance("X.509")
.generateCertificate(ByteArrayInputStream(PINNED_CA_PEM.trim().encodeToByteArray()))
val keyStore =
KeyStore.getInstance(KeyStore.getDefaultType()).apply {
load(null, null)
setCertificateEntry("ai-app-dev-ca", caCert)
}
val trustManager =
TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
.apply { init(keyStore) }
.trustManagers
.filterIsInstance<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. */
@@ -2,10 +2,10 @@ package com.example.aiapp
import android.content.Context
import android.net.Uri
import androidx.core.content.edit
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import androidx.core.content.edit
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
@@ -13,13 +13,13 @@ import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
/**
* Where the backend is and how to authenticate to it. Absent until the
* phone is enrolled -- by scanning the server's terminal QR (an
* `aiapp://enroll` URI the camera app hands to MainActivity) or by typing
* the fields into the settings screen.
* Where the backend is and how to authenticate to it. Absent until the phone is enrolled -- by
* scanning the server's terminal QR (an `aiapp://enroll` URI the camera app hands to MainActivity)
* or by typing the fields into the settings screen.
*/
data class ServerSettings(val host: String, val port: Int, val token: String) {
val baseUrl: String get() = "https://$host:$port"
val baseUrl: String
get() = "https://$host:$port"
}
private const val PREFS_NAME = "server"
@@ -47,8 +47,8 @@ fun saveServerSettings(context: Context, settings: ServerSettings) {
/**
* Parses the enrollment URI the server's QR carries:
* `aiapp://enroll?host=10.66.0.1&port=8443&token=...`. Null if any part is
* missing -- a malformed scan shouldn't clobber a working enrollment.
* `aiapp://enroll?host=10.66.0.1&port=8443&token=...`. Null if any part is missing -- a malformed
* scan shouldn't clobber a working enrollment.
*/
fun parseEnrollmentUri(uri: Uri): ServerSettings? {
if (uri.scheme != "aiapp" || uri.host != "enroll") return null
@@ -73,16 +73,18 @@ private const val GCM_TAG_BITS = 128
private fun tokenKey(): SecretKey {
val keyStore = KeyStore.getInstance(KEYSTORE).apply { load(null) }
(keyStore.getKey(KEY_ALIAS, null) as? SecretKey)?.let { return it }
(keyStore.getKey(KEY_ALIAS, null) as? SecretKey)?.let {
return it
}
val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE)
generator.init(
KeyGenParameterSpec.Builder(
KEY_ALIAS,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
)
KEY_ALIAS,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.build(),
.build()
)
return generator.generateKey()
}
@@ -92,27 +94,28 @@ private fun seal(token: String): String {
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.ENCRYPT_MODE, tokenKey())
val ciphertext = cipher.doFinal(token.encodeToByteArray())
return Base64.encodeToString(cipher.iv, Base64.NO_WRAP) + ":" +
return Base64.encodeToString(cipher.iv, Base64.NO_WRAP) +
":" +
Base64.encodeToString(ciphertext, Base64.NO_WRAP)
}
/**
* Null on any failure -- e.g. the Keystore key was lost to a device reset
* or the app's data was restored onto another device, where the key never
* travels. The caller treats that as "not enrolled"; re-scanning the QR
* (or `--rotate-token`) is the recovery, so failing soft here is right.
* Null on any failure -- e.g. the Keystore key was lost to a device reset or the app's data was
* restored onto another device, where the key never travels. The caller treats that as "not
* enrolled"; re-scanning the QR (or `--rotate-token`) is the recovery, so failing soft here is
* right.
*/
private fun unseal(sealed: String): String? = try {
val (ivB64, dataB64) = sealed.split(":", limit = 2).let {
if (it.size != 2) return null else it[0] to it[1]
private fun unseal(sealed: String): String? =
try {
val (ivB64, dataB64) =
sealed.split(":", limit = 2).let { if (it.size != 2) return null else it[0] to it[1] }
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(
Cipher.DECRYPT_MODE,
tokenKey(),
GCMParameterSpec(GCM_TAG_BITS, Base64.decode(ivB64, Base64.NO_WRAP)),
)
cipher.doFinal(Base64.decode(dataB64, Base64.NO_WRAP)).decodeToString()
} catch (_: Exception) {
null
}
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(
Cipher.DECRYPT_MODE,
tokenKey(),
GCMParameterSpec(GCM_TAG_BITS, Base64.decode(ivB64, Base64.NO_WRAP)),
)
cipher.doFinal(Base64.decode(dataB64, Base64.NO_WRAP)).decodeToString()
} catch (_: Exception) {
null
}
@@ -41,8 +41,8 @@ private val AWAITING_COLOR = Color(0xFFB26A00)
private val RUNNING_COLOR = Color(0xFF2E7D32)
/**
* The session list -- the app's root screen. Sessions awaiting an answer
* sort to the top: that's the "your turn" inbox.
* The session list -- the app's root screen. Sessions awaiting an answer sort to the top: that's
* the "your turn" inbox.
*/
@Composable
fun SessionListScreen(
@@ -71,14 +71,15 @@ fun SessionListScreen(
fun refresh() {
listState = LoadState.Loading
scope.launch {
listState = try {
val loaded =
withContext(Dispatchers.IO) { LoadState.Loaded(fetchSessions(settings)) }
deleteErrors = emptyMap()
loaded
} catch (e: ApiException) {
LoadState.failed(e)
}
listState =
try {
val loaded =
withContext(Dispatchers.IO) { LoadState.Loaded(fetchSessions(settings)) }
deleteErrors = emptyMap()
loaded
} catch (e: ApiException) {
LoadState.failed(e)
}
}
}
@@ -86,7 +87,10 @@ fun SessionListScreen(
Box(Modifier.fillMaxSize()) {
Column(Modifier.fillMaxSize().padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Text(
"AI Sessions",
style = MaterialTheme.typography.headlineSmall,
@@ -106,10 +110,11 @@ fun SessionListScreen(
// Couldn't reach the server at ...". It was also a guess --
// a delete that the server itself refused had reached it
// fine.
is LoadState.Error -> Text(
state.message,
color = MaterialTheme.colorScheme.error,
)
is LoadState.Error ->
Text(
state.message,
color = MaterialTheme.colorScheme.error,
)
is LoadState.Loaded -> {
if (state.value.isEmpty()) {
Text(
@@ -120,10 +125,11 @@ fun SessionListScreen(
}
// Awaiting-answer first (the point of the screen), then
// most recently active.
val ordered = state.value.sortedWith(
compareByDescending<SessionSummary> { it.status == "awaitingInput" }
.thenByDescending { it.lastActivity },
)
val ordered =
state.value.sortedWith(
compareByDescending<SessionSummary> { it.status == "awaitingInput" }
.thenByDescending { it.lastActivity }
)
LazyColumn {
items(ordered, key = { it.id }) { session ->
SessionCard(
@@ -142,7 +148,9 @@ fun SessionListScreen(
FloatingActionButton(
onClick = onSpawn,
modifier = Modifier.align(Alignment.BottomEnd).padding(24.dp),
) { Text("+", style = MaterialTheme.typography.headlineMedium) }
) {
Text("+", style = MaterialTheme.typography.headlineMedium)
}
}
confirmingDelete?.let { session ->
@@ -151,18 +159,22 @@ fun SessionListScreen(
title = { Text("Delete \"${session.title}\"?") },
text = { Text("Kills the process and deletes its transcript. This can't be undone.") },
confirmButton = {
TextButton(onClick = {
confirmingDelete = null
scope.launch {
try {
withContext(Dispatchers.IO) { deleteSession(settings, session.id) }
refresh()
} catch (e: ApiException) {
deleteErrors =
deleteErrors + (session.id to (e.message ?: "Delete failed"))
TextButton(
onClick = {
confirmingDelete = null
scope.launch {
try {
withContext(Dispatchers.IO) { deleteSession(settings, session.id) }
refresh()
} catch (e: ApiException) {
deleteErrors =
deleteErrors + (session.id to (e.message ?: "Delete failed"))
}
}
}
}) { Text("Delete") }
) {
Text("Delete")
}
},
dismissButton = {
TextButton(onClick = { confirmingDelete = null }) { Text("Cancel") }
@@ -180,11 +192,12 @@ private fun SessionCard(
onOpen: () -> Unit,
onLongPress: () -> Unit,
) {
Card(
Modifier.fillMaxWidth().combinedClickable(onClick = onOpen, onLongClick = onLongPress),
) {
Card(Modifier.fillMaxWidth().combinedClickable(onClick = onOpen, onLongClick = onLongPress)) {
Column(Modifier.padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Text(
session.title,
style = MaterialTheme.typography.titleMedium,
@@ -198,10 +211,11 @@ private fun SessionCard(
// Provider, then where it runs -- "on <host>" rather
// than a bare name, so a host isn't mistaken for a model.
listOfNotNull(
session.provider,
session.host?.let { "on $it" },
session.model,
).joinToString(" · "),
session.provider,
session.host?.let { "on $it" },
session.model,
)
.joinToString(" · "),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
@@ -228,16 +242,20 @@ private fun SessionCard(
@Composable
fun StatusText(status: String) {
val (label, color) = when (status) {
"awaitingInput" -> "your turn" to AWAITING_COLOR
"running" -> "running" to RUNNING_COLOR
"compacting" -> "compacting" to RUNNING_COLOR
"exited" -> "exited" to MaterialTheme.colorScheme.onSurfaceVariant
else -> status to MaterialTheme.colorScheme.onSurfaceVariant
}
val (label, color) =
when (status) {
"awaitingInput" -> "your turn" to AWAITING_COLOR
"running" -> "running" to RUNNING_COLOR
"compacting" -> "compacting" to RUNNING_COLOR
"exited" -> "exited" to MaterialTheme.colorScheme.onSurfaceVariant
else -> status to MaterialTheme.colorScheme.onSurfaceVariant
}
Row(verticalAlignment = Alignment.CenterVertically) {
if (status == "running" || status == "compacting") {
CircularProgressIndicator(modifier = Modifier.width(14.dp).height(14.dp), strokeWidth = 2.dp)
CircularProgressIndicator(
modifier = Modifier.width(14.dp).height(14.dp),
strokeWidth = 2.dp,
)
Spacer(Modifier.width(6.dp))
}
Text(label, style = MaterialTheme.typography.labelLarge, color = color)
@@ -44,24 +44,25 @@ import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import java.util.concurrent.atomic.AtomicLong
import java.util.concurrent.atomic.AtomicReference
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.concurrent.atomic.AtomicLong
import java.util.concurrent.atomic.AtomicReference
private const val RECONNECT_DELAY_MS = 1500L
/**
* What the transcript renders: the event stream folded into displayable
* rows (see [foldEvent]). The stream is the only data source -- opening
* this screen replays from seq 0, and a reconnect resumes from the last
* seq seen, so there is no separate history fetch to drift from it.
* What the transcript renders: the event stream folded into displayable rows (see [foldEvent]). The
* stream is the only data source -- opening this screen replays from seq 0, and a reconnect resumes
* from the last seq seen, so there is no separate history fetch to drift from it.
*/
sealed class TranscriptItem {
data class UserMsg(val text: String) : TranscriptItem()
data class AssistantMsg(val text: String) : TranscriptItem()
data class ToolRun(
val id: String,
val tool: String,
@@ -69,15 +70,19 @@ sealed class TranscriptItem {
val output: String,
val done: Boolean,
) : TranscriptItem()
data class QuestionCard(
val id: String,
val prompt: String,
val options: List<String>,
val answer: String?,
) : TranscriptItem()
data class ErrorMsg(val message: String) : TranscriptItem()
/** An image by server-side ref, fetched from the session's files route. */
data class ImageItem(val ref: String) : TranscriptItem()
/** Placeholder row for events this build can't render (newer kinds). */
data class Note(val text: String) : TranscriptItem()
}
@@ -96,20 +101,24 @@ fun foldEvent(items: List<TranscriptItem>, event: SessionEvent): List<Transcript
}
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.ToolUpdate -> updateTool(items, event.id) { it.copy(output = event.output) }
is SessionEvent.ToolEnd ->
updateTool(items, event.id) { it.copy(output = event.output, done = true) }
is SessionEvent.Question ->
items + TranscriptItem.QuestionCard(event.id, event.prompt, event.options, answer = null)
is SessionEvent.Answered -> items.map {
if (it is TranscriptItem.QuestionCard && it.id == event.id) it.copy(answer = event.answer) else it
}
items +
TranscriptItem.QuestionCard(event.id, event.prompt, event.options, answer = null)
is SessionEvent.Answered ->
items.map {
if (it is TranscriptItem.QuestionCard && it.id == event.id)
it.copy(answer = event.answer)
else it
}
is SessionEvent.Error -> items + TranscriptItem.ErrorMsg(event.message)
is SessionEvent.Image -> items + TranscriptItem.ImageItem(event.ref)
is SessionEvent.Unknown -> items + TranscriptItem.Note("[${event.type}]")
// Screen-level state, not transcript rows -- see SessionScreen.
is SessionEvent.Status, is SessionEvent.UsageDelta -> items
is SessionEvent.Status,
is SessionEvent.UsageDelta -> items
}
private fun updateTool(
@@ -171,9 +180,7 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
}
// Coroutine cancellation can't interrupt a blocking socket read;
// closing the stream is what unblocks it when this screen goes away.
DisposableEffect(summary.id) {
onDispose { activeStream.get()?.close() }
}
DisposableEffect(summary.id) { onDispose { activeStream.get()?.close() } }
LaunchedEffect(items.size) {
if (items.isNotEmpty()) listState.animateScrollToItem(items.size - 1)
@@ -201,27 +208,28 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
// The system photo picker; the image uploads as soon as it's chosen,
// so Send only has ids to reference.
val pickImage = rememberLauncherForActivityResult(
ActivityResultContracts.PickVisualMedia(),
) { uri ->
if (uri != null) {
scope.launch {
try {
val id = withContext(Dispatchers.IO) {
val bytes = context.contentResolver.openInputStream(uri)
?.use { it.readBytes() }
?: throw ApiException("couldn't read the picked image")
val mime = context.contentResolver.getType(uri) ?: "image/jpeg"
uploadAttachment(settings, summary.id, bytes, mime)
val pickImage =
rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri ->
if (uri != null) {
scope.launch {
try {
val id =
withContext(Dispatchers.IO) {
val bytes =
context.contentResolver.openInputStream(uri)?.use {
it.readBytes()
} ?: throw ApiException("couldn't read the picked image")
val mime = context.contentResolver.getType(uri) ?: "image/jpeg"
uploadAttachment(settings, summary.id, bytes, mime)
}
pendingAttachments = pendingAttachments + id
actionError = null
} catch (e: ApiException) {
actionError = e.message
}
pendingAttachments = pendingAttachments + id
actionError = null
} catch (e: ApiException) {
actionError = e.message
}
}
}
}
Column(Modifier.fillMaxSize()) {
Row(
@@ -233,11 +241,12 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
Text(summary.title, style = MaterialTheme.typography.titleMedium)
Text(
listOfNotNull(
summary.provider,
summary.host?.let { "on $it" },
summary.model,
if (totalTokens > 0) "$totalTokens tok" else null,
).joinToString(" · "),
summary.provider,
summary.host?.let { "on $it" },
summary.model,
if (totalTokens > 0) "$totalTokens tok" else null,
)
.joinToString(" · "),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -263,30 +272,35 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
items(items) { item ->
when (item) {
is TranscriptItem.UserMsg -> UserBubble(item.text)
is TranscriptItem.AssistantMsg -> Text(item.text, style = MaterialTheme.typography.bodyLarge)
is TranscriptItem.ToolRun -> ToolCard(
tool = item,
expanded = item.id in expandedTools,
onToggle = {
expandedTools =
if (item.id in expandedTools) expandedTools - item.id
else expandedTools + item.id
},
)
is TranscriptItem.QuestionCard -> QuestionRow(item) { answer ->
act { answerQuestion(settings, summary.id, item.id, answer) }
}
is TranscriptItem.ErrorMsg -> Text(
item.message,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
)
is TranscriptItem.AssistantMsg ->
Text(item.text, style = MaterialTheme.typography.bodyLarge)
is TranscriptItem.ToolRun ->
ToolCard(
tool = item,
expanded = item.id in expandedTools,
onToggle = {
expandedTools =
if (item.id in expandedTools) expandedTools - item.id
else expandedTools + item.id
},
)
is TranscriptItem.QuestionCard ->
QuestionRow(item) { answer ->
act { answerQuestion(settings, summary.id, item.id, answer) }
}
is TranscriptItem.ErrorMsg ->
Text(
item.message,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
)
is TranscriptItem.ImageItem -> SessionImage(settings, summary.id, item.ref)
is TranscriptItem.Note -> Text(
item.text,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
is TranscriptItem.Note ->
Text(
item.text,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@@ -298,18 +312,22 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(8.dp),
) {
TextButton(onClick = {
pickImage.launch(
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly),
)
}) {
TextButton(
onClick = {
pickImage.launch(
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)
)
}
) {
Text(if (pendingAttachments.isEmpty()) "+" else "+${pendingAttachments.size}")
}
OutlinedTextField(
value = input,
onValueChange = { input = it },
modifier = Modifier.weight(1f),
placeholder = { Text(if (pendingAttachments.isEmpty()) "Message" else "Message (+image)") },
placeholder = {
Text(if (pendingAttachments.isEmpty()) "Message" else "Message (+image)")
},
maxLines = 4,
)
Spacer(Modifier.width(8.dp))
@@ -325,9 +343,8 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
}
/**
* An inline transcript image, fetched (authenticated, pinned) from the
* session's files route. The bitmap is remembered per ref, so scrolling
* doesn't refetch.
* An inline transcript image, fetched (authenticated, pinned) from the session's files route. The
* bitmap is remembered per ref, so scrolling doesn't refetch.
*/
@Composable
private fun SessionImage(settings: ServerSettings, sessionId: String, ref: String) {
@@ -343,16 +360,18 @@ private fun SessionImage(settings: ServerSettings, sessionId: String, ref: Strin
}
}
when (val image = bitmap) {
null -> Text(
if (failed) "[image $ref unavailable]" else "[loading image…]",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
else -> Image(
bitmap = image,
contentDescription = "session image",
modifier = Modifier.fillMaxWidth(),
)
null ->
Text(
if (failed) "[image $ref unavailable]" else "[loading image…]",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
else ->
Image(
bitmap = image,
contentDescription = "session image",
modifier = Modifier.fillMaxWidth(),
)
}
}
@@ -360,9 +379,10 @@ private fun SessionImage(settings: ServerSettings, sessionId: String, ref: Strin
private fun UserBubble(text: String) {
Box(Modifier.fillMaxWidth()) {
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
),
modifier = Modifier.align(Alignment.CenterEnd).padding(start = 48.dp),
) {
Text(text, modifier = Modifier.padding(12.dp))
@@ -371,16 +391,19 @@ private fun UserBubble(text: String) {
}
/**
* Collapsed by default: name plus a spinner while running, expandable to
* the input and output. The spinner-while-unfinished is exactly "ToolStart
* with no matching ToolEnd yet".
* Collapsed by default: name plus a spinner while running, expandable to the input and output. The
* spinner-while-unfinished is exactly "ToolStart with no matching ToolEnd yet".
*/
@Composable
private fun ToolCard(tool: TranscriptItem.ToolRun, expanded: Boolean, onToggle: () -> Unit) {
Card(Modifier.fillMaxWidth().clickable(onClick = onToggle)) {
Column(Modifier.padding(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(tool.tool, style = MaterialTheme.typography.titleSmall, modifier = Modifier.weight(1f))
Text(
tool.tool,
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.weight(1f),
)
if (!tool.done) {
CircularProgressIndicator(
modifier = Modifier.width(16.dp).height(16.dp),
@@ -403,9 +426,9 @@ private fun ToolCard(tool: TranscriptItem.ToolRun, expanded: Boolean, onToggle:
}
/**
* A question (or permission request -- same shape) inline in the
* transcript. Option buttons until answered; then the chosen answer, which
* the `answered` event also resolves on every other connected device.
* A question (or permission request -- same shape) inline in the transcript. Option buttons until
* answered; then the chosen answer, which the `answered` event also resolves on every other
* connected device.
*/
@Composable
private fun QuestionRow(question: TranscriptItem.QuestionCard, onAnswer: (String) -> Unit) {
@@ -2,7 +2,8 @@ package com.example.aiapp
import android.Manifest
import android.content.pm.PackageManager
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -26,18 +27,15 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import com.google.zxing.client.android.Intents
import com.journeyapps.barcodescanner.ScanContract
import com.journeyapps.barcodescanner.ScanIntentResult
import com.journeyapps.barcodescanner.ScanOptions
/**
* Server address and token. The normal path is the "Scan QR code" button
* below, which decodes the server's terminal QR itself; these fields are
* the fallback for typing the same three values by hand. [onBack] is null
* on first run, when there is nothing to go back to.
* Server address and token. The normal path is the "Scan QR code" button below, which decodes the
* server's terminal QR itself; these fields are the fallback for typing the same three values by
* hand. [onBack] is null on first run, when there is nothing to go back to.
*/
@Composable
fun SettingsScreen(
@@ -53,29 +51,30 @@ fun SettingsScreen(
var token by remember { mutableStateOf("") }
var error by remember { mutableStateOf<String?>(null) }
val scanLauncher = rememberLauncherForActivityResult(ScanContract()) { result: ScanIntentResult ->
// Null contents means the user backed out of the scanner -- not an
// error, so nothing to report.
val contents = result.contents ?: return@rememberLauncherForActivityResult
val settings = parseEnrollmentUri(contents.toUri())
if (settings == null) {
error = "Not a valid enrollment code"
} else {
saveServerSettings(context, settings)
onSaved(settings)
val scanLauncher =
rememberLauncherForActivityResult(ScanContract()) { result: ScanIntentResult ->
// Null contents means the user backed out of the scanner -- not an
// error, so nothing to report.
val contents = result.contents ?: return@rememberLauncherForActivityResult
val settings = parseEnrollmentUri(contents.toUri())
if (settings == null) {
error = "Not a valid enrollment code"
} else {
saveServerSettings(context, settings)
onSaved(settings)
}
}
}
val requestCamera = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted ->
if (granted) {
scanLauncher.launch(enrollmentScanOptions())
} else {
error = "Scanning needs the camera. Grant it in the system settings, " +
"or type the host, port and token in below."
val requestCamera =
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
if (granted) {
scanLauncher.launch(enrollmentScanOptions())
} else {
error =
"Scanning needs the camera. Grant it in the system settings, " +
"or type the host, port and token in below."
}
}
}
Column(Modifier.fillMaxSize().padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
@@ -107,8 +106,9 @@ fun SettingsScreen(
// encountered a problem" over it, and works on the second
// try. Nothing is wrong with the camera, so nothing should
// say there is.
if (context.checkSelfPermission(Manifest.permission.CAMERA)
== PackageManager.PERMISSION_GRANTED
if (
context.checkSelfPermission(Manifest.permission.CAMERA) ==
PackageManager.PERMISSION_GRANTED
) {
scanLauncher.launch(enrollmentScanOptions())
} else {
@@ -116,7 +116,9 @@ fun SettingsScreen(
}
},
modifier = Modifier.fillMaxWidth(),
) { Text("Scan QR code") }
) {
Text("Scan QR code")
}
Spacer(Modifier.height(16.dp))
OutlinedTextField(
@@ -149,35 +151,39 @@ fun SettingsScreen(
Spacer(Modifier.height(8.dp))
}
Button(onClick = {
val portNumber = port.trim().toIntOrNull()
val effectiveToken = token.trim().ifEmpty { existing?.token ?: "" }
when {
host.isBlank() -> error = "Host is required"
portNumber == null || portNumber !in 1..65535 -> error = "Port must be 1-65535"
effectiveToken.isEmpty() -> error = "Token is required -- scan the server's QR or paste it"
else -> {
val settings = ServerSettings(host.trim(), portNumber, effectiveToken)
saveServerSettings(context, settings)
onSaved(settings)
Button(
onClick = {
val portNumber = port.trim().toIntOrNull()
val effectiveToken = token.trim().ifEmpty { existing?.token ?: "" }
when {
host.isBlank() -> error = "Host is required"
portNumber == null || portNumber !in 1..65535 -> error = "Port must be 1-65535"
effectiveToken.isEmpty() ->
error = "Token is required -- scan the server's QR or paste it"
else -> {
val settings = ServerSettings(host.trim(), portNumber, effectiveToken)
saveServerSettings(context, settings)
onSaved(settings)
}
}
}
}) { Text("Save") }
) {
Text("Save")
}
}
}
/**
* How the enrollment QR is scanned, in one place because two callers reach
* it -- straight from the button when the camera permission is already
* held, and from the permission result when it has just been granted.
* How the enrollment QR is scanned, in one place because two callers reach it -- straight from the
* button when the camera permission is already held, and from the permission result when it has
* just been granted.
*
* MIXED_SCAN is the load-bearing part: ZXing otherwise looks only for a
* dark code on a light ground, and ai-server's QR is block characters in
* the terminal's foreground colour, so on a dark-themed terminal it comes
* out as a photographic negative the scanner silently never matches. Which
* way round it renders is the terminal's business, not something this app
* should depend on. The mixed decoder alternates normal and inverted
* frames, costing half the frame rate at each polarity and nothing else.
* MIXED_SCAN is the load-bearing part: ZXing otherwise looks only for a dark code on a light
* ground, and ai-server's QR is block characters in the terminal's foreground colour, so on a
* dark-themed terminal it comes out as a photographic negative the scanner silently never matches.
* Which way round it renders is the terminal's business, not something this app should depend on.
* The mixed decoder alternates normal and inverted frames, costing half the frame rate at each
* polarity and nothing else.
*/
private fun enrollmentScanOptions(): ScanOptions =
ScanOptions()
@@ -44,9 +44,8 @@ private const val LOCAL_HOST_LABEL = "backend"
/**
* The spawn screen: what to run, where to run it, and the per-kind fields.
*
* Providers and hosts both come from the server, so adding either to its
* config.ron shows up here with no app rebuild -- and because they are
* independent, any provider can be sent to any host.
* Providers and hosts both come from the server, so adding either to its config.ron shows up here
* with no app rebuild -- and because they are independent, any provider can be sent to any host.
*/
@Composable
fun SpawnScreen(
@@ -73,15 +72,17 @@ fun SpawnScreen(
var spawnError by remember { mutableStateOf<String?>(null) }
LaunchedEffect(Unit) {
options = try {
val fetched = withContext(Dispatchers.IO) {
SpawnOptions(fetchProviders(settings), fetchHosts(settings))
options =
try {
val fetched =
withContext(Dispatchers.IO) {
SpawnOptions(fetchProviders(settings), fetchHosts(settings))
}
provider = fetched.providers.firstOrNull()
LoadState.Loaded(fetched)
} catch (e: ApiException) {
LoadState.failed(e)
}
provider = fetched.providers.firstOrNull()
LoadState.Loaded(fetched)
} catch (e: ApiException) {
LoadState.failed(e)
}
}
val current = provider
@@ -105,17 +106,18 @@ fun SpawnScreen(
// failure to fetch them leaves no form worth showing -- so this
// reports and stops, rather than offering empty pickers under an
// error message.
val (providers, hosts) = when (val state = options) {
is LoadState.Loading -> {
CircularProgressIndicator()
return@Column
val (providers, hosts) =
when (val state = options) {
is LoadState.Loading -> {
CircularProgressIndicator()
return@Column
}
is LoadState.Error -> {
Text(state.message, color = MaterialTheme.colorScheme.error)
return@Column
}
is LoadState.Loaded -> state.value
}
is LoadState.Error -> {
Text(state.message, color = MaterialTheme.colorScheme.error)
return@Column
}
is LoadState.Loaded -> state.value
}
ChipGroup(
label = "Provider",
@@ -133,13 +135,15 @@ fun SpawnScreen(
onSelect = { name -> host = name.takeIf { it != LOCAL_HOST_LABEL } },
)
host?.let { chosen ->
hosts.firstOrNull { it.name == chosen }?.let {
Text(
it.address,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
hosts
.firstOrNull { it.name == chosen }
?.let {
Text(
it.address,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Spacer(Modifier.height(16.dp))
@@ -202,17 +206,18 @@ fun SpawnScreen(
busy = true
scope.launch {
try {
val spawned = withContext(Dispatchers.IO) {
spawnSession(
settings,
provider = chosen.name,
title = title.trim(),
host = host,
model = model.trim().takeIf { isClaude },
cwd = cwd.trim().takeIf { isClaude },
permissionMode = permissionMode.takeIf { isClaude },
)
}
val spawned =
withContext(Dispatchers.IO) {
spawnSession(
settings,
provider = chosen.name,
title = title.trim(),
host = host,
model = model.trim().takeIf { isClaude },
cwd = cwd.trim().takeIf { isClaude },
permissionMode = permissionMode.takeIf { isClaude },
)
}
onSpawned(spawned)
} catch (e: ApiException) {
spawnError = e.message
@@ -221,7 +226,9 @@ fun SpawnScreen(
}
},
enabled = !busy && current != null,
) { Text(if (busy) "Spawning..." else "Spawn") }
) {
Text(if (busy) "Spawning..." else "Spawn")
}
}
}
@@ -231,9 +238,9 @@ private data class SpawnOptions(val providers: List<Provider>, val hosts: List<R
/**
* A labeled row of choices that wraps onto as many lines as it needs.
*
* FlowRow rather than Row: a plain Row gives every chip an equal share of
* a single line, so once the options don't fit, the text inside each one
* wraps to one character per line instead of the row wrapping.
* FlowRow rather than Row: a plain Row gives every chip an equal share of a single line, so once
* the options don't fit, the text inside each one wraps to one character per line instead of the
* row wrapping.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
@@ -26,11 +26,11 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import java.time.Duration
import java.time.OffsetDateTime
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.time.Duration
import java.time.OffsetDateTime
private val WARN_COLOR = Color(0xFFB26A00)
private val OVER_COLOR = Color(0xFFB3261E)
@@ -44,11 +44,12 @@ fun UsageScreen(settings: ServerSettings, onBack: () -> Unit) {
fun refresh() {
state = LoadState.Loading
scope.launch {
state = try {
withContext(Dispatchers.IO) { LoadState.Loaded(fetchUsage(settings)) }
} catch (e: ApiException) {
LoadState.failed(e)
}
state =
try {
withContext(Dispatchers.IO) { LoadState.Loaded(fetchUsage(settings)) }
} catch (e: ApiException) {
LoadState.failed(e)
}
}
}
LaunchedEffect(Unit) { refresh() }
@@ -68,37 +69,39 @@ fun UsageScreen(settings: ServerSettings, onBack: () -> Unit) {
when (val current = state) {
is LoadState.Loading -> CircularProgressIndicator()
is LoadState.Error -> Text(current.message, color = MaterialTheme.colorScheme.error)
is LoadState.Loaded -> current.value.forEach { snapshot ->
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp)) {
Text(snapshot.provider, style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(8.dp))
if (!snapshot.available) {
Text(
snapshot.error ?: "Unavailable",
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
)
}
snapshot.windows.forEach { window ->
WindowBar(window)
Spacer(Modifier.height(12.dp))
is LoadState.Loaded ->
current.value.forEach { snapshot ->
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp)) {
Text(snapshot.provider, style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(8.dp))
if (!snapshot.available) {
Text(
snapshot.error ?: "Unavailable",
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
)
}
snapshot.windows.forEach { window ->
WindowBar(window)
Spacer(Modifier.height(12.dp))
}
}
}
Spacer(Modifier.height(12.dp))
}
Spacer(Modifier.height(12.dp))
}
}
}
}
@Composable
private fun WindowBar(window: UsageWindow) {
val color = when {
window.percent >= 95 -> OVER_COLOR
window.percent >= 75 -> WARN_COLOR
else -> MaterialTheme.colorScheme.primary
}
val color =
when {
window.percent >= 95 -> OVER_COLOR
window.percent >= 75 -> WARN_COLOR
else -> MaterialTheme.colorScheme.primary
}
Column {
Row(modifier = Modifier.fillMaxWidth()) {
Text(
@@ -126,14 +129,15 @@ private fun WindowBar(window: UsageWindow) {
}
/** "in 3h 12m" -- close enough for deciding whether to start a big task. */
private fun formatReset(resetsAt: String): String = try {
val until = Duration.between(OffsetDateTime.now(), OffsetDateTime.parse(resetsAt))
when {
until.isNegative -> "soon"
until.toHours() >= 24 -> "in ${until.toDays()}d ${until.toHours() % 24}h"
until.toHours() > 0 -> "in ${until.toHours()}h ${until.toMinutes() % 60}m"
else -> "in ${until.toMinutes()}m"
private fun formatReset(resetsAt: String): String =
try {
val until = Duration.between(OffsetDateTime.now(), OffsetDateTime.parse(resetsAt))
when {
until.isNegative -> "soon"
until.toHours() >= 24 -> "in ${until.toDays()}d ${until.toHours() % 24}h"
until.toHours() > 0 -> "in ${until.toHours()}h ${until.toMinutes() % 60}m"
else -> "in ${until.toMinutes()}m"
}
} catch (_: Exception) {
"at $resetsAt"
}
} catch (_: Exception) {
"at $resetsAt"
}