Files
ai-app/app/androidApp/build.gradle.kts
T
iris b6b33dc9c5 Take the app half from wg-app-link as well
The four Kotlin files that were the link rather than this product now come
from the submodule: the pinned TrustManager, the enrollment store and its
Keystore sealing, the QR capture activity, and the local-network permission
check. `:link` is a subproject resolved by path, so the app half is
version-locked to the same commit the Rust half already was.

What stays here is the two facts that are actually about this app, and
both are load-bearing in a way that would fail quietly if got wrong: the
`aiapp` URI scheme, and the Keystore alias `aiapp-token-key` that every
enrolled phone's token is already sealed under. A wrong alias would leave
those phones reading as not enrolled with nothing on screen to explain it,
so the value is carried over exactly and the reason is written beside it.

Call sites are unchanged. `ServerSettings` stays available unqualified as
a typealias and `applyPinnedTls()` stays an extension, so the diff is the
three files that bind the product-specific values plus two imports --
rather than every screen that happens to use a setting.

Also clears a warning the build had been printing: `setup?.id.orEmpty()`
where the compiler already knows `setup` is non-null, because `chosen`
came from that setup's own provider list.

Verified by running the build, not only by reading it: ktfmt, Kotlin
compile and Android Lint are all clean with no warnings, and the APK still
builds -- which exercises the pinned-CA generator, since that is the step
that reads the CA off this machine.

Still unpushed, per the hold until the rebuild bug is proven fixed. Note
the submodule: a checkout of this commit needs `git submodule update
--init` before `app/` or `server/` will build.
2026-08-28 17:57:45 -04:00

147 lines
5.6 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.zxing.embedded)
}