Two Java classes (MainActivity, NotificationService) hand their lifecycle to a new android-shell crate built on client-core; client-core gains notifications.rs (the /notifications SSE parse and attention_line, ported from Notifications.kt). Packaged as a new app/shellApp Gradle module rather than a rewrite of app/androidApp in place, so that module's working Compose UI is untouched. Both pass conditions held on the emulator: a notification arrived in Android's drawer with the app closed, and a shared text share landed as a real message in a sandbox session's transcript. Found and fixed three real bugs along the way (a silently-wrong JNI signature from a generic JObject parameter, a class-by-name lookup failing on this crate's own background thread for lack of an app ClassLoader, and onStartCommand opening two /notifications connections per enrollment -- the last a latent bug in Notifications.kt itself). Full account, exact commands and what was deliberately cut are in RUST.md's E3 box. Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
116 lines
4.4 KiB
Kotlin
116 lines
4.4 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"
|
|
}
|
|
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)
|
|
}
|