`d0a9c73` switched the lane to a release Cargo profile alongside the release variant. The variant was the point; the profile was mine, and it broke the build (run 4077): `generateUniffiBindings` fails with "No UniFFI metadata found" on the release `.so`. The workspace release profile sets `strip = true`, and uniffi's `--library` mode finds its interface metadata through symbols. That is the obvious suspect and it is recorded as a suspect, not a finding — `lto = true` dropping the metadata statics would print the identical message and the two have not been told apart. Backed out to the debug profile rather than guessing at a fix, because the two halves of that commit are not equally important. Signing and a rising versionCode are what make an install replace the last one instead of wiping the notes; the Rust profile only makes the result faster. The APK this produces is no worse than every previous build, all of which shipped a debug-profile `.so`. Recorded as Scribe #2810 with the four candidate fixes and, more usefully, the instruction to establish the cause on a host build before spending another four-minute cold cross-compile on a guess.
293 lines
11 KiB
Kotlin
293 lines
11 KiB
Kotlin
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<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"),
|
|
)
|
|
|
|
/**
|
|
* 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<CargoNdkBuild>("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<UniffiBindgen>("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)
|
|
}
|