plugins { alias(libs.plugins.androidApplication) } // E3 (RUST.md): the Kotlin/Java shell being replaced by a thin JNI bridge // into Rust (`../../android-shell`). Deliberately its own module rather // than a rewrite of `:androidApp` in place -- that module is ~13,000 lines // of working Compose UI this experiment does not touch, and the two can be // installed side by side on the same development device (see // `settings.SCHEME`'s doc in `android-shell` for why the deep-link scheme // and Keystore alias are not the production app's). No Compose plugin, no // Kotlin source of its own: `MainActivity`/`NotificationService` are plain // Java, and the CA constant below is generated as Java too. // // The CA this build pins is baked in the same way `androidApp`'s does -- // see that module's `build.gradle.kts` comment for the reasoning (the // trust boundary follows the machine that builds, never a pasted copy). // `PinnedCa.java`'s package must match `android-shell`'s // `settings::load_pinned_ca` lookup (`com/example/aiapp/shell/PinnedCa`). 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 GeneratePinnedCa : DefaultTask() { @get:Input abstract val caPath: Property @get:InputFile @get:Optional @get:PathSensitive(PathSensitivity.NONE) abstract val caCertificate: RegularFileProperty @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 (or app/ui-sandbox.sh) once on this machine first -- it " + "generates the CA this build pins.\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 dir = outputDir.get().dir("com/example/aiapp/shell").asFile dir.mkdirs() // Same reasoning as androidApp's generatePinnedCert: the text block // must start immediately after the opening `"""`, or // CertificateFactory stops recognising the "-----BEGIN" preamble. File(dir, "PinnedCa.java") .writeText( """ |// Generated from $path by the generatePinnedCa task. Do not edit. |package com.example.aiapp.shell; | |public final class PinnedCa { | private PinnedCa() {} | public static final String PINNED_CA_PEM = ""${'"'} |$pem""${'"'}; |} |""" .trimMargin() ) } } val generatePinnedCa = tasks.register("generatePinnedCa") { val ca = file(pinnedCaPath) caPath.set(pinnedCaPath) if (ca.isFile) { caCertificate.set(ca) } } android { namespace = "com.example.aiapp.shell" compileSdk = 37 defaultConfig { applicationId = "com.example.aiapp.shell" minSdk = 24 targetSdk = 37 versionCode = 1 versionName = "1.0" } compileOptions { sourceCompatibility = JavaVersion.VERSION_21 targetCompatibility = JavaVersion.VERSION_21 } } androidComponents { onVariants { variant -> variant.sources.java?.addGeneratedSourceDirectory(generatePinnedCa, GeneratePinnedCa::outputDir) } } dependencies { // The Keystore-sealed enrollment (ServerStore/ServerSettings) -- // android-shell's settings.rs calls into this Kotlin class directly // over JNI rather than re-sealing the token in Rust; see that file's // module doc. implementation(project(":link")) // NotificationCompat/NotificationManagerCompat/NotificationChannelCompat/ // ServiceCompat -- android-shell's notify.rs calls these classes over // JNI so the pre-26 fallback behaviour (no channels) lives once, in // the library that already has it, rather than being re-derived as a // set of Build.VERSION.SDK_INT branches in Rust. implementation(libs.androidx.core.ktx) }