Files
thoughtsync/android/app/build.gradle.kts
T
bvandeusenandClaude Opus 5 c8af808432
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m11s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m12s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (debug APK) (push) Successful in 7m13s
android: package only the ABIs we actually build for
The first working APK carried libjnidispatch.so for armeabi, mips and mips64 as
well as our four — JNA's .aar still ships those, and AGP packages whatever it
finds. Android dropped mips in NDK r17 and armeabi in r17 too; nothing that can
install this app can load them, so they are pure payload.

abiFilters pins the set to the four the Rust is actually cross-compiled for, so
the APK's ABI list matches the build's intent rather than the union of every
dependency's history.

Found by unpacking the artifact rather than trusting the green: the run said
"Upload debug APK ✓", which is true and says nothing about what is inside it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 16:24:37 -04:00

240 lines
8.3 KiB
Kotlin

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<String>
@get:Input
abstract val cargoProfile: Property<String>
@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<CargoNdkBuild>("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<UniffiBindgen>("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)
// 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)
}