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", "--features", "bindgen", "--bin", "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" } 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 } sourceSets { getByName("main") { // The .so and the bindings are build outputs, not checked-in sources. // Passing the TASK PROVIDERS, not plain paths: Gradle reads each // task's @OutputDirectory and infers the build ordering itself. A // bare path would compile Kotlin before the bindings exist, and the // usual fix — depending on KotlinCompile by type — cannot be written // here, because AGP 9's built-in Kotlin means that class is not on // the buildscript classpath. jniLibs.srcDir(cargoNdkDebug) java.srcDir(generateBindings) } } packaging { resources.excludes += "/META-INF/{AL2.0,LGPL2.1}" } } 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) // 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.ui.tooling.preview) debugImplementation(libs.compose.ui.tooling) testImplementation(libs.junit) }