import java.io.File import javax.inject.Inject plugins { alias(libs.plugins.android.application) alias(libs.plugins.compose.compiler) } // The Cargo workspace root — two levels up from android/app. val workspaceRoot: Directory = layout.projectDirectory.dir("../..") // The ABIs a release APK carries. arm64 is essentially every real device; armv7 // covers older 32-bit hardware; the two x86 targets are what emulators run on, and // dropping them would make the app untestable on a desktop emulator (the reason // x86_64 was added to the old Tauri lane in task 1864). val androidAbis = listOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64") /** * Cross-compile `thoughtsync-ffi` for each Android ABI and drop the resulting * `.so` into jniLibs, where AGP packages it. * * `ExecOperations` injected rather than `project.exec`: the latter was REMOVED in * Gradle 9, and reaching for `project` at execution time is also what breaks the * configuration cache this build has enabled. */ abstract class CargoNdkBuild : DefaultTask() { @get:Inject abstract val execOps: ExecOperations @get:InputFiles abstract val rustSources: ConfigurableFileCollection @get:Input abstract val abis: ListProperty @get:Input abstract val cargoProfile: Property @get:Internal abstract val workspaceDir: DirectoryProperty @get:OutputDirectory abstract val jniLibsDir: DirectoryProperty @TaskAction fun build() { val args = mutableListOf("ndk") abis.get().forEach { abi -> args += "-t" args += abi } args += listOf("-o", jniLibsDir.get().asFile.absolutePath, "build", "-p", "thoughtsync-ffi") // --locked so an Android build cannot silently re-resolve the workspace // lockfile the desktop lanes are gated on. args += "--locked" if (cargoProfile.get() == "release") args += "--release" execOps.exec { commandLine(listOf("cargo") + args) workingDir = workspaceDir.get().asFile } } } /** * Generate the Kotlin bindings FROM the freshly built `.so`. * * `--library` mode reads uniffi's metadata straight out of the compiled artifact, * so the bindings can never describe a different version of the Rust than the one * being packaged — which is the failure the whole in-workspace generator setup * exists to prevent. */ abstract class UniffiBindgen : DefaultTask() { @get:Inject abstract val execOps: ExecOperations @get:InputFile abstract val libraryFile: RegularFileProperty @get:Internal abstract val workspaceDir: DirectoryProperty @get:OutputDirectory abstract val outputDir: DirectoryProperty @TaskAction fun generate() { val out = outputDir.get().asFile out.deleteRecursively() out.mkdirs() execOps.exec { commandLine( "cargo", "run", "--locked", "-p", "thoughtsync-uniffi-bindgen", "--", "generate", "--library", libraryFile.get().asFile.absolutePath, "--language", "kotlin", "--out-dir", out.absolutePath, ) workingDir = workspaceDir.get().asFile } } } // Only the Rust that actually affects the .so. Deliberately NOT the workspace // directory: that would make Gradle hash target/, which is gigabytes. val rustInputs = files( workspaceRoot.dir("core/src"), workspaceRoot.dir("android/ffi/src"), workspaceRoot.file("core/Cargo.toml"), workspaceRoot.file("android/ffi/Cargo.toml"), workspaceRoot.file("Cargo.toml"), workspaceRoot.file("Cargo.lock"), ) /** * Which Cargo profile the `.so` is built with. * * A property rather than a debug/release task PAIR, deliberately. This runner has * no working Gradle or Cargo cache (`reserveCache failed` on every run), so a cold * cross-compile of four ABIs costs about four minutes — and a lane that both * type-checks and packages would pay that twice if the two used different * profiles. `android.yml` picks one profile and uses it for every Gradle call in * the run. * * CI currently passes `debug` even for a release APK, which is not where this * should end up: an unoptimised store and sync engine is a real difference on a * phone, not a theoretical one. The blocker is that the workspace's release * profile sets `strip = true`, which removes the symbols uniffi reads its * interface metadata from — `generateUniffiBindings` then fails with "No UniFFI * metadata found" (run 4077). Fixing it means either an Android-specific profile * that keeps symbols or generating the bindings from a separate unstripped * build, and neither is worth holding signed APKs up for. Scribe #2810. */ val rustProfile = (project.findProperty("THOUGHTSYNC_CARGO_PROFILE") as String?)?.takeIf { it.isNotBlank() } ?: "debug" val jniLibsOut = layout.buildDirectory.dir("rustJniLibs") val bindingsOut = layout.buildDirectory.dir("generated/uniffi") val cargoNdk = tasks.register("cargoNdk") { description = "Cross-compile thoughtsync-ffi for the Android ABIs." rustSources.from(rustInputs) abis.set(androidAbis) cargoProfile.set(rustProfile) workspaceDir.set(workspaceRoot) jniLibsDir.set(jniLibsOut) } val generateBindings = tasks.register("generateUniffiBindings") { description = "Generate the Kotlin bindings from the compiled .so." dependsOn(cargoNdk) // arm64 is arbitrary — every ABI carries the same uniffi metadata, and // reading one is cheaper than reading four. libraryFile.set(jniLibsOut.map { it.file("arm64-v8a/libthoughtsync_ffi.so") }) workspaceDir.set(workspaceRoot) outputDir.set(bindingsOut) } android { namespace = "com.fabledsword.thoughtsync" compileSdk = 36 defaultConfig { applicationId = "com.fabledsword.thoughtsync" // 26 (Android 8, 2017) matches Minstrel and clears the NDK's floor with // room to spare. minSdk = 26 targetSdk = 36 // Injected by CI from the git tag + commit count for a release; "dev" // locally so the About screen reads honestly rather than claiming 1.0. val nameOverride = (project.findProperty("THOUGHTSYNC_VERSION_NAME") as String?)?.takeIf { it.isNotBlank() } val codeOverride = (project.findProperty("THOUGHTSYNC_VERSION_CODE") as String?)?.toIntOrNull() versionCode = codeOverride ?: 1 versionName = nameOverride ?: "dev" // Package ONLY the ABIs we build for. // // Without this the APK also carries armeabi, mips and mips64 — dead // architectures Android dropped years ago, which arrive because JNA's // .aar still ships a libjnidispatch.so for each. They can never be // loaded on any device this app supports, so they are pure payload. ndk { abiFilters += androidAbis } } // The signing key reaches this build only through the environment: CI decodes // it from a secret into a file and points ANDROID_KEYSTORE_FILE at that path. // It is never in the repo and never in this file. Generated by the operator // and never seen by an agent session, because an Android signing key cannot be // rotated without the original — v3 lineage needs it — so a leaked or lost one // means every install has to be removed and replaced by hand. val keystoreFile = System.getenv("ANDROID_KEYSTORE_FILE")?.takeIf { it.isNotBlank() } val keystorePassword = System.getenv("ANDROID_KEYSTORE_PASSWORD")?.takeIf { it.isNotBlank() } signingConfigs { if (keystoreFile != null && keystorePassword != null) { create("release") { storeFile = File(keystoreFile) storePassword = keystorePassword // Hardcoded, and NOT a secret: the alias is fixed for the life of // this app and is written into the certificate every install // already carries. Hiding it would buy nothing and stop this file // describing its own signing setup. keyAlias = "thoughtsync" // PKCS12 cannot hold a key password distinct from the store // password — keytool refuses to set one — so this is the same // value by necessity rather than by shortcut. keyPassword = keystorePassword } } } buildTypes { release { isMinifyEnabled = false proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") // Null when no keystore reached this build, which leaves the APK // unsigned and therefore uninstallable. `android.yml` builds debug in // that case rather than producing an artifact nobody can put on a // phone. signingConfig = signingConfigs.findByName("release") } } compileOptions { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } buildFeatures { compose = true } packaging { resources.excludes += "/META-INF/{AL2.0,LGPL2.1}" } } /** * Register the `.so` and the generated bindings as GENERATED sources. * * NOT `sourceSets { ... srcDir(task) }`: AGP 9 rejects a Provider there outright, * because it cannot tell whether the directory holds generated (read-only) or * hand-written (read-write) files — a distinction the IDE needs. The Variant API * is the supported route and, unlike a bare path, `addGeneratedSourceDirectory` * carries the task dependency, so Kotlin cannot compile before the bindings * exist and the APK cannot package a stale `.so`. */ androidComponents { onVariants { variant -> variant.sources.kotlin?.addGeneratedSourceDirectory(generateBindings, UniffiBindgen::outputDir) variant.sources.jniLibs?.addGeneratedSourceDirectory(cargoNdk, CargoNdkBuild::jniLibsDir) } } dependencies { implementation(libs.androidx.core.ktx) implementation(libs.androidx.activity.compose) implementation(libs.androidx.lifecycle.viewmodel.compose) implementation(libs.androidx.lifecycle.runtime.compose) implementation(libs.kotlinx.coroutines.android) implementation(libs.androidx.work.runtime) // Required by the uniffi bindings — see the catalog note on the @aar // classifier; the plain jar builds fine and fails at runtime. implementation(variantOf(libs.jna) { artifactType("aar") }) implementation(platform(libs.compose.bom)) implementation(libs.compose.ui) implementation(libs.compose.ui.graphics) implementation(libs.compose.material3) implementation(libs.compose.material.icons.core) implementation(libs.compose.ui.tooling.preview) debugImplementation(libs.compose.ui.tooling) testImplementation(libs.junit) }