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