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 /** * 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") { 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}" } // 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") } } 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().configureEach { useJUnitPlatform() }