A bash permission request arrived as a second card repeating the tool
call's input verbatim, so the same command appeared twice and the reader
had to work out it was one event. `Event::Question` now carries `about`:
the `tool_use_id` the CLI's `can_use_tool` request already names. That
makes the pairing a measured fact rather than a match on input text --
and it stays `Option`, because AskUserQuestion is not permission for
anything and an echo session's question is about no tool at all. Those
still draw as their own card, which is what every question did before.
The card also reads the input instead of dumping it. Every tool's input
is JSON, and showing it raw makes the reader parse `{"command":"…",
"timeout":5000}` to find the line they care about. A small table says
which field is the subject of which tool -- Bash's `command`, Read's
`file_path` -- and the rest is still listed, since dropping a field
would claim the tool had no other input when it might. The subject is
syntax-highlighted with dev.snipme:highlights, for the reason the
markdown renderer is a library: lexical rules are somebody else's
specification. Its theme is Catppuccin, mapped in Theme.kt beside the
rest of the palette rather than taken from the library's defaults.
The input shows whether or not the card is expanded. A row that says
only "Bash" says nothing anyone can act on, least of all when it is
asking to run something.
Verified on the emulator against a real haiku session: one card, the
description, `grep -rn "needle" /tmp | head -3` highlighted, `timeout:
5000` pulled out, and "Allow Bash?" with its buttons inside the card --
then Allow, which resolved in place and ran.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
150 lines
5.8 KiB
Kotlin
150 lines
5.8 KiB
Kotlin
plugins {
|
|
alias(libs.plugins.androidApplication)
|
|
alias(libs.plugins.composeMultiplatform)
|
|
alias(libs.plugins.composeCompiler)
|
|
alias(libs.plugins.ktfmt)
|
|
}
|
|
|
|
// Formatting is the formatter's. The one setting is which of ktfmt's two
|
|
// styles: kotlinlang is the 4-space one, which is what this code already
|
|
// is -- picking the 2-space default would have reindented every file to
|
|
// say nothing. Everything else stays at ktfmt's defaults, deliberately.
|
|
//
|
|
// ./gradlew :androidApp:ktfmtFormat to apply
|
|
// ./gradlew :androidApp:ktfmtCheck to verify
|
|
ktfmt { kotlinLangStyle() }
|
|
|
|
// The CA this app pins is baked in at build time from the certificates on
|
|
// the machine doing the build -- `$XDG_CONFIG_HOME/ai-app/certs/ca.pem`,
|
|
// which the server generates on first start. AI_APP_CA overrides the path.
|
|
//
|
|
// Reading it rather than keeping a pasted copy in the source is what makes
|
|
// the trust boundary follow the build: an APK built on the backend host
|
|
// pins the host's CA and never sees any other, while one built in the dev
|
|
// VM pins that VM's throwaway CA and is only good for its emulator. There
|
|
// is no second trust anchor to get wrong, and no stale paste to notice
|
|
// three days later. It also means the private key never has to exist
|
|
// anywhere near this repo.
|
|
val pinnedCaPath: String =
|
|
System.getenv("AI_APP_CA")
|
|
?: "${System.getenv("XDG_CONFIG_HOME") ?: "${System.getProperty("user.home")}/.config"}" +
|
|
"/ai-app/certs/ca.pem"
|
|
|
|
abstract class GeneratePinnedCert : DefaultTask() {
|
|
/** Where the certificate is looked for, reported in failures. */
|
|
@get:Input abstract val caPath: Property<String>
|
|
|
|
/**
|
|
* The certificate itself, set only when it exists -- so a missing one produces this task's own
|
|
* instructions rather than Gradle's "no such input file", which doesn't say what to run.
|
|
*/
|
|
@get:InputFile
|
|
@get:Optional
|
|
@get:PathSensitive(PathSensitivity.NONE)
|
|
abstract val caCertificate: RegularFileProperty
|
|
|
|
/** Wired by AGP through `addGeneratedSourceDirectory`. */
|
|
@get:OutputDirectory abstract val outputDir: DirectoryProperty
|
|
|
|
@TaskAction
|
|
fun generate() {
|
|
val path = caPath.get()
|
|
val ca = File(path)
|
|
if (!ca.isFile) {
|
|
throw GradleException(
|
|
"No CA certificate at $path.\n" +
|
|
"Start ai-server once on this machine first -- it generates the CA the " +
|
|
"app pins, and the certificate has to exist before an APK can embed it.\n" +
|
|
"Set AI_APP_CA=/path/to/ca.pem to build against a different one."
|
|
)
|
|
}
|
|
val pem = ca.readText().trim()
|
|
if (!pem.startsWith("-----BEGIN CERTIFICATE-----")) {
|
|
throw GradleException("$path is not a PEM certificate.")
|
|
}
|
|
val file = outputDir.get().file("PinnedCaCertificate.kt").asFile
|
|
file.parentFile.mkdirs()
|
|
// The PEM must start immediately after the opening quotes: a
|
|
// leading newline makes Android's CertificateFactory stop
|
|
// recognising the "-----BEGIN" preamble and try to parse the whole
|
|
// thing as DER, which fails with an ASN.1 decode error at runtime
|
|
// rather than anywhere near this file.
|
|
file.writeText(
|
|
"""
|
|
|// Generated from $path by the generatePinnedCert task. Do not edit.
|
|
|package com.example.aiapp
|
|
|
|
|
|const val PINNED_CA_PEM = ""${'"'}$pem
|
|
|""${'"'}
|
|
|
|
|
"""
|
|
.trimMargin()
|
|
)
|
|
}
|
|
}
|
|
|
|
val generatePinnedCert =
|
|
tasks.register<GeneratePinnedCert>("generatePinnedCert") {
|
|
val ca = file(pinnedCaPath)
|
|
caPath.set(pinnedCaPath)
|
|
if (ca.isFile) {
|
|
caCertificate.set(ca)
|
|
}
|
|
}
|
|
|
|
android {
|
|
namespace = "com.example.aiapp"
|
|
compileSdk = 37
|
|
|
|
defaultConfig {
|
|
applicationId = "com.example.aiapp"
|
|
minSdk = 24
|
|
targetSdk = 37
|
|
versionCode = 1
|
|
versionName = "1.0"
|
|
}
|
|
packaging { resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" } }
|
|
buildTypes { getByName("release") { isMinifyEnabled = false } }
|
|
compileOptions {
|
|
sourceCompatibility = JavaVersion.VERSION_21
|
|
targetCompatibility = JavaVersion.VERSION_21
|
|
// minSdk is 24 and UsageScreen formats its countdown with
|
|
// java.time, which the platform only has from 26. Without this it
|
|
// is a NoClassDefFoundError on 24 and 25 -- an Error, so the
|
|
// catch around that code does not stop it.
|
|
isCoreLibraryDesugaringEnabled = true
|
|
}
|
|
}
|
|
|
|
// AGP 9 wants generated sources registered through the variant API rather
|
|
// than added to a source set, so the task dependency is carried properly.
|
|
androidComponents {
|
|
onVariants { variant ->
|
|
variant.sources.java?.addGeneratedSourceDirectory(
|
|
generatePinnedCert,
|
|
GeneratePinnedCert::outputDir,
|
|
)
|
|
}
|
|
}
|
|
|
|
dependencies {
|
|
// The link both this app and Dev Updater's need in order to reach a
|
|
// machine they were enrolled against: the pinned CA, the enrollment
|
|
// store, and the QR capture activity. See wg-app-link's README.
|
|
implementation(project(":link"))
|
|
// Not a library this code calls: it is what `isCoreLibraryDesugaring
|
|
// Enabled` above rewrites java.time against, so API 24 and 25 have it.
|
|
coreLibraryDesugaring(libs.desugar.jdk.libs)
|
|
|
|
implementation(libs.compose.runtime)
|
|
implementation(libs.compose.foundation)
|
|
implementation(libs.compose.material3)
|
|
implementation(libs.compose.ui)
|
|
implementation(libs.androidx.activity.compose)
|
|
implementation(libs.androidx.core.ktx)
|
|
implementation(libs.androidx.lifecycle.runtime.compose)
|
|
implementation(libs.zxing.embedded)
|
|
implementation(libs.markdown.renderer)
|
|
implementation(libs.highlights)
|
|
}
|