New xtask/ crate (no deps) runs cargo ndk -> javac -> d8 -> aapt2 -> zipalign -> apksigner directly, signed with the same key build-apk.sh uses. Both pass conditions proved on the ai-app-2 emulator: the xtask APK installs over the Gradle-built shellApp, and the notification service starts and posts a real notification while backgrounded. Adds one printRuntimeClasspathJars task to shellApp/build.gradle.kts (and a matching signingConfig) -- the one disclosed Gradle call the xtask still makes, to resolve the AndroidX/:link dependency graph. That call's Kotlin compilation of :link as a side effect also answers E3's open kotlinc question, so no Java port of ServerStore was needed. Wires a second Apk component into .dev-updater.ron beside the existing one. Full writeup in RUST.md's E5 box. Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
164 lines
6.6 KiB
Kotlin
164 lines
6.6 KiB
Kotlin
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<String>
|
|
|
|
@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>("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) --
|
|
// 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)
|
|
}
|