Files
ai-app/app/androidApp/build.gradle.kts
T
irisandClaude Fable 5.1 a6cb9a9082 app: a bench build type for P0's benchmark gate
Own application id (.bench suffix) and label ("AI Sessions bench" via a
build-type resValue over the new @string/app_name), release
optimisations, signed with the same key build-apk.sh already generates,
FIXTURE_MODE=true wired through BuildConfig. Its asset source set points
straight at app/bench-fixture/assets rather than a copy under androidApp,
so there is one file to keep in sync with the generator, not two.

build-apk.sh bench builds it; the CA-pinning step is untouched and still
requires a real ca.pem to exist, even though this build never connects --
simplest to let it pin whatever is there rather than special-casing it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 21:03:44 -04:00

227 lines
10 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"
// Read by MainActivity to decide, at startup, whether this is the P0 benchmark build
// (docs/RUST.md's P0 box) rather than the app somebody enrolled. False everywhere except
// the `bench` build type below, which overrides it.
buildConfigField("boolean", "FIXTURE_MODE", "false")
}
buildFeatures {
// Only for FIXTURE_MODE above; nothing else here reaches for generated BuildConfig fields.
buildConfig = true
// Only for the bench build type's resValue("string", "app_name", ...) below.
resValues = true
}
packaging {
resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" }
// The one native library here is AndroidX's, a few hundred kilobytes with its symbols.
// Stripping them needs an NDK the release build would otherwise not use; keeping them
// is declared so AGP stops warning that it could not.
jniLibs { keepDebugSymbols += "**/libandroidx.graphics.path.so" }
}
// A release build must be signed, and the key is per machine rather than per repo: it is
// what the phone recognises the app by, and a secret never lives in a checkout (the mount is
// shared with an untrusted VM). build-apk.sh keeps it beside the pinned CA and points here
// through the environment; without it the release build is unsigned, which is fine for
// everything except installing.
val keystore = System.getenv("AI_APP_KEYSTORE")
signingConfigs {
if (keystore != null) {
create("release") {
storeFile = file(keystore)
storePassword = System.getenv("AI_APP_KEYSTORE_PASSWORD")
keyAlias = "ai-app"
keyPassword = storePassword
}
}
}
buildTypes {
getByName("release") {
isMinifyEnabled = false
if (keystore != null) signingConfig = signingConfigs.getByName("release")
}
// P0's benchmark build (docs/RUST.md, docs/DECISIONS.md's 2026-09-05 entry): release
// optimisations so a frame time measured here means what release means everywhere else in
// this project, its own application id so it installs beside a real enrollment rather than
// replacing it, and FIXTURE_MODE so MainActivity opens straight onto the fixture session
// instead of asking to be enrolled. Signed with the same key as release -- it never talks
// to a real backend, so there is no CA of its own to mismatch, and a second keystore would
// be one more secret to keep off this machine's shared mount for no benefit.
create("bench") {
initWith(getByName("release"))
// :link (wg-app-link) has no "bench" build type of its own -- it is a library shared
// with dev-updater and has no reason to know this project invented one -- so this says
// which of its build types to link against instead.
matchingFallbacks += listOf("release")
applicationIdSuffix = ".bench"
// "AI Sessions bench" everywhere the OS shows the app's name (launcher, recents,
// Settings): this resValue overrides res/values/strings.xml's app_name for this
// build type alone, and AndroidManifest.xml's android:label reads @string/app_name
// rather than a literal so a build type can override it without touching the
// manifest.
resValue("string", "app_name", "AI Sessions bench")
buildConfigField("boolean", "FIXTURE_MODE", "true")
if (keystore != null) signingConfig = signingConfigs.getByName("release")
}
}
sourceSets {
// The fixture both bench builds (this one and iris's) open with; see
// app/bench-fixture/README.md. Read directly from its own directory rather than copied
// into androidApp/src -- one file to keep in sync with the generator, not two.
getByName("bench").assets.directories.add("../bench-fixture/assets")
}
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.runtime.tracing)
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.androidx.exifinterface)
// The syntax scanner (Highlighter.kt) is pure logic with no Android imports, which is what
// lets it be tested on the JVM: `./gradlew :androidApp:testDebugUnitTest`. The assertions are
// `kotlin.test`, so the tests name no framework; JUnit is what runs them.
testImplementation(libs.kotlin.test.junit5)
testImplementation(libs.junit.jupiter)
testRuntimeOnly(libs.junit.platform.launcher)
}
// JUnit 6 runs on the Platform, which is not Gradle's default for a Test task.
tasks.withType<Test>().configureEach { useJUnitPlatform() }