plugins { alias(libs.plugins.androidApplication) } // E3 (RUST.md): the Kotlin/Java shell being replaced by a thin JNI bridge // into Rust (`../../app-rust`, the `shell` feature). 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 `app-rust/src/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 `app-rust/src/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" } // Same reasoning and same key as androidApp's (see that module's comment): E5 (RUST.md) // signs its own, Gradle-free build with this same keystore, and the two can only // `adb install -r` over each other if they carry the same certificate. 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 } } // E5 (RUST.md): the xtask dexes and packages this module's Java sources itself, but it does // not resolve Maven dependencies -- reimplementing a dependency resolver was out of scope for a // packaging step, so this one task is the single place Gradle still runs in that pipeline. It // asks the dependency graph for the *post-transform* jars (AARs already unpacked to a classes // jar, the same artifact type AGP's own dexing task consumes) rather than the raw configuration, // which would hand back .aar files d8 cannot read directly. val artifactType = Attribute.of("artifactType", String::class.java) tasks.register("printRuntimeClasspathJars") { description = "Writes the resolved release runtime classpath jars, one per line, for xtask." val outputFile = layout.buildDirectory.file("xtask/runtime-classpath.txt") outputs.file(outputFile) val jars = configurations .getByName("releaseRuntimeClasspath") .incoming .artifactView { attributes.attribute(artifactType, "android-classes-jar") } .files // Captured as a plain FileCollection (not the ArtifactView itself, which the // configuration cache cannot serialize) so this task is still cacheable. inputs.files(jars) doLast { val file = outputFile.get().asFile file.parentFile.mkdirs() file.writeText(jars.joinToString("\n") { it.absolutePath }) } } androidComponents { onVariants { variant -> variant.sources.java?.addGeneratedSourceDirectory(generatePinnedCa, GeneratePinnedCa::outputDir) } } dependencies { // The Keystore-sealed enrollment (ServerStore/ServerSettings) -- // the shell bridge'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 -- the shell bridge'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) }