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"), ) val jniLibsOut = layout.buildDirectory.dir("rustJniLibs") val bindingsOut = layout.buildDirectory.dir("generated/uniffi") val cargoNdkDebug = tasks.register("cargoNdkDebug") { description = "Cross-compile thoughtsync-ffi for the Android ABIs (debug)." rustSources.from(rustInputs) abis.set(androidAbis) cargoProfile.set("debug") workspaceDir.set(workspaceRoot) jniLibsDir.set(jniLibsOut) } val generateBindings = tasks.register("generateUniffiBindings") { description = "Generate the Kotlin bindings from the compiled .so." dependsOn(cargoNdkDebug) // 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 } } buildTypes { release { isMinifyEnabled = false proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") // Signing is deliberately absent. A release keystore that has passed // through an agent session or shell history is compromised by // construction (Scribe task 2136) — it has to be generated by the // operator and reach CI only as a secret. Until then a release build // is unsigned and CI builds debug. } } 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(cargoNdkDebug, 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) }