android: emit uniffi bindings as generated sources, not into the source root
Build Debug APK / build (push) Successful in 20m53s

uniffi's Kotlin bindings were written into
android/app/src/main/java/com/gregshuflin/synchronicity/uniffi/ — an untracked
build output living in a tracked source root. Everything around it was a
workaround for that: a .gitignore entry, and three separate tooling exclusions
(the ktfmt SourceTask filter, Detekt, DetektCreateBaselineTask) to keep
formatters and linters off a file none of them should touch. `gradlew clean`
also could not remove it, so a file generated from an older .so could survive
indefinitely.

Emit them under the module's build/ directory instead, handed to AGP through
the Variant API: a GenerateUniffiBindings task with a DirectoryProperty output,
wired per variant with variant.sources.kotlin.addGeneratedSourceDirectory. That
carries the task dependency with it, so preBuild no longer needs to list the
task. The Android SourceSet API was the obvious route but AGP 9 rejects a
Provider there outright, pointing at the Variant API precisely so Studio can
tell generated from static sources.

The justfile recipe now takes the output directory as a parameter; Gradle
passes the per-variant path AGP chose. The task is named
generate<Variant>UniffiBindings now, since addGeneratedSourceDirectory owns the
output location and so cannot share one task between variants.

Verified that ktfmtCheck and detektDebug no longer see the generated file at
all (0 matching source entries) with the exclusions deleted, that clean removes
it, and that a from-scratch assembleDebug regenerates it.

The one argument for the old arrangement — reading the Kotlin FFI surface on a
machine with no Android toolchain — was already moot: the directory was
gitignored, so a fresh clone never had it either.
This commit is contained in:
Greg Shuflin
2026-08-21 20:13:35 -07:00
parent aa91e3266a
commit 80bc85d745
6 changed files with 83 additions and 73 deletions
-4
View File
@@ -53,10 +53,6 @@ obj/
gen/
out/
# Generated uniffi Kotlin bindings — regenerated from the compiled .so by the
# Android `generateUniffiBindings` preBuild task (`just uniffi-bindgen-generate`).
/android/app/src/main/java/com/gregshuflin/synchronicity/uniffi/
# Kotlin
*.class
*.log
+1 -1
View File
@@ -96,7 +96,7 @@ developers build.
with `nix develop --command`. `flake.nix`'s devShell supplies the JDK,
SDK/NDK, and Rust toolchain. `just build-android-nix` (=
`nix develop --command just android-build`) builds the debug APK,
including `buildRustLib`/`generateUniffiBindings` — a hermetic Rust
including `buildRustLib`/`generateDebugUniffiBindings` — a hermetic Rust
cross-build that regenerates the uniffi Kotlin bindings. Budget for it:
that cross-build makes `gradlew test` ~14 min cold (`ktfmt` doesn't need
it and finishes in seconds); `just lint-kotlin` is in the same slow group
-23
View File
@@ -106,29 +106,6 @@ was sketched here.
`watch_open_note`/`render_incoming_body_banner`. Surfaced while wiring the live-state slot's
Notes publisher, which has nothing to hook into until this exists.
### Emit the generated uniffi bindings into a build dir, not the source root
- **Description**: `synchronicity.kt` is *not* version-controlled — `.gitignore:58`
already ignores the whole `.../synchronicity/uniffi/` directory, and no generated
Kotlin is tracked. What's left is that uniffi still *writes* it into
`android/app/src/main/java/com/gregshuflin/synchronicity/uniffi/`: an untracked build
output sitting in a tracked source root, which is the arrangement everything below
works around.
- **Details**: The costs are visible in three places today.
- Three separate exclusions exist only to keep tooling off it — the `ktfmt`
`SourceTask` filter, `Detekt`, and `DetektCreateBaselineTask` (each needs its own,
since they're distinct task types) in `android/app/build.gradle.kts` — plus
`--no-format` in the `uniffi-bindgen-generate` justfile recipe so uniffi's ktlint
pass doesn't fight ktfmt over a file neither should touch.
- Living outside `build/` means `gradlew clean` never removes it, so a stale file
generated from an older `.so` can survive indefinitely.
- On a machine with no Android toolchain (per `just doctor`), the Kotlin FFI surface
can't be read at all without a ~14-minute `nix develop` cross-build. That's the one
real argument for keeping a copy in the tree, and worth weighing before moving it.
- **Fix**: emit to `app/build/generated/source/uniffi/`, register it via `sourceSets`
(the shape KSP/protobuf/Room already use), and delete the three lint exclusions and
the `.gitignore` entry. `generateUniffiBindings` is already wired into `preBuild`
with proper `inputs`/`outputs`, so the task itself needs only a path change.
### Android's notes sidebar has a search box that does nothing
- **Description**: `NotebookSidebar` renders a `SynSearchBar` bound to
+62 -36
View File
@@ -1,3 +1,4 @@
import javax.inject.Inject
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
@@ -123,30 +124,73 @@ tasks.register("copySharedFonts") {
}
}
// Custom task to regenerate uniffi Kotlin bindings from the compiled .so.
// Must run after buildRustLib (which produces the .so) and before Kotlin compilation.
tasks.register<Exec>("generateUniffiBindings") {
group = "build"
description = "Regenerates Kotlin uniffi bindings from the compiled Rust library"
dependsOn("buildRustLib")
// Regenerates the uniffi Kotlin bindings from the compiled .so, delegating to
// `just uniffi-bindgen-generate` for the same reason buildRustLib delegates: the
// justfile is the single source of truth for the invocation.
//
// The bindings are generated code, so they are written into the module's build
// directory and handed to AGP as a *generated* source directory rather than into
// the tracked source root. `clean` then removes them like any other build output,
// and neither ktfmt nor detekt sees them, so none of the exclusions that used to
// keep those tools off the file are needed.
abstract class GenerateUniffiBindings : DefaultTask() {
// The compiled .so uniffi reads the FFI surface out of. Re-running is only
// needed when it changes.
@get:InputFile
@get:PathSensitive(PathSensitivity.NONE)
abstract val rustLibrary: RegularFileProperty
val cargoTargetDir =
System.getenv("CARGO_TARGET_DIR") ?: "${project.rootDir.parentFile}/rust/target"
val soFile = "$cargoTargetDir/aarch64-linux-android/release/libsynchronicity.so"
val outDir = "${project.projectDir}/src/main/java/com/gregshuflin/synchronicity/uniffi/"
// Repo root: `just` is invoked from there, and the recipe's paths are
// relative to it.
@get:Internal abstract val repoRoot: DirectoryProperty
workingDir = file("${project.rootDir.parentFile}")
commandLine("just", "uniffi-bindgen-generate")
// Set by AGP via addGeneratedSourceDirectory, which picks the per-variant
// location under build/generated/.
@get:OutputDirectory abstract val outputDir: DirectoryProperty
// Incremental: only re-run when the .so changes.
inputs.file(soFile)
outputs.dir(outDir)
@get:Inject abstract val execOperations: ExecOperations
@TaskAction
fun generate() {
val outDir = outputDir.get().asFile
outDir.mkdirs()
execOperations.exec {
workingDir = repoRoot.get().asFile
commandLine("just", "uniffi-bindgen-generate", outDir.absolutePath)
}
}
}
// One generator per variant: addGeneratedSourceDirectory owns the output
// location, so it cannot be shared between variants. The Rust build underneath
// is the expensive half and cargo caches it, so the second variant is cheap.
androidComponents {
onVariants { variant ->
val variantName = variant.name.replaceFirstChar { it.uppercase() }
val generate =
tasks.register<GenerateUniffiBindings>("generate${variantName}UniffiBindings") {
group = "build"
description = "Regenerates Kotlin uniffi bindings from the compiled Rust library"
dependsOn("buildRustLib")
val cargoTargetDir =
System.getenv("CARGO_TARGET_DIR") ?: "${project.rootDir.parentFile}/rust/target"
rustLibrary.set(
file("$cargoTargetDir/aarch64-linux-android/release/libsynchronicity.so")
)
repoRoot.set(file("${project.rootDir.parentFile}"))
}
// Wiring the task rather than a path carries the dependency with it, so
// every consumer of the variant's Kotlin sources builds the bindings first.
variant.sources.kotlin?.addGeneratedSourceDirectory(
generate,
GenerateUniffiBindings::outputDir,
)
}
}
// Make the Rust build task run before Java compilation
tasks.named("preBuild") {
dependsOn("buildRustLib", "generateUniffiBindings", "copySharedAssets", "copySharedFonts")
}
tasks.named("preBuild") { dependsOn("buildRustLib", "copySharedAssets", "copySharedFonts") }
dependencies {
// JNA — required by uniffi-generated Kotlin bindings (5.16+ for 16 KB page size / Android 15+)
@@ -218,23 +262,9 @@ detekt {
// ktfmt configuration
ktfmt { kotlinLangStyle() }
// The uniffi bindings are generated code: uniffi emits them with its own
// formatting and we skip its ktlint pass (--no-format, see the
// uniffi-bindgen-generate recipe in the justfile). Keep ktfmt off the generated
// dir so the two formatters don't fight over the file and so the
// ktfmt tasks don't pick up generateUniffiBindings' output without a declared
// dependency. We don't enforce a Kotlin style on generated code.
tasks.withType<org.gradle.api.tasks.SourceTask>().configureEach {
if (name.startsWith("ktfmt")) {
exclude("**/uniffi/**")
}
}
// Configure Detekt tasks to use correct JVM target and set up reports
tasks.withType<io.gitlab.arturbosch.detekt.Detekt>().configureEach {
jvmTarget = "11"
// Generated uniffi bindings are not subject to our lint rules (see above).
exclude("**/uniffi/**")
reports {
html.required.set(true)
xml.required.set(true)
@@ -244,10 +274,6 @@ tasks.withType<io.gitlab.arturbosch.detekt.Detekt>().configureEach {
}
}
// The baseline-creation task is a separate task type from the check task above,
// so it needs its own exclude — otherwise it records phantom debt for the
// generated uniffi bindings that the actual check already ignores.
tasks.withType<io.gitlab.arturbosch.detekt.DetektCreateBaselineTask>().configureEach {
jvmTarget = "11"
exclude("**/uniffi/**")
}
@@ -40,7 +40,7 @@ build-android-nix:
```
Scope it to the full `./gradlew assembleDebug` (which already chains
`buildRustLib` + `generateUniffiBindings`) rather than a narrower `.so`-only
`buildRustLib` + `generate<Variant>UniffiBindings`) rather than a narrower `.so`-only
build, matching what CI produces — the Android analogue of `build-gui-nix`.
`just doctor` now points at it as the fallback when the local Android
toolchain is missing but `nix` is present.
+19 -8
View File
@@ -92,19 +92,30 @@ build-synchronicity-lib-android:
# Generate Kotlin types using uniffi. Depends on build-synchronicity-lib-android
# so it never silently regenerates bindings from a stale .so.
#
# The bindings are a build output, not source. Gradle's per-variant
# `generate<Variant>UniffiBindings` task (see android/app/build.gradle.kts) calls
# this recipe with the output path AGP chose for it, under the Android module's
# `build/generated/`. The default below is only for running the recipe
# standalone — e.g. to read the Kotlin FFI surface without a Gradle build — and
# is deliberately under `build/` too, so `gradlew clean` clears it.
[doc: "Generate Kotlin types from the Rust FFI surface using uniffi"]
[group: "build"]
uniffi-bindgen-generate: build-synchronicity-lib-android
mkdir -p android/app/src/main/java/com/gregshuflin/synchronicity/uniffi/
uniffi-bindgen-generate out_dir="android/app/build/generated/source/uniffi": build-synchronicity-lib-android
#!/usr/bin/env bash
set -euo pipefail
mkdir -p "{{out_dir}}"
# Absolute, because the cargo invocation below runs from rust/.
out_abs="$(cd "{{out_dir}}" && pwd)"
# --no-format: skip uniffi's built-in ktlint pass. The project formats Kotlin
# with ktfmt, and the generated bindings are excluded from ktfmt/detekt (see
# android/app/build.gradle.kts), so we leave uniffi's raw output as-is rather
# than running a second, conflicting formatter over it.
# with ktfmt, which never sees this directory (it is under build/), so we
# leave uniffi's raw output as-is rather than running a second, conflicting
# formatter over it.
cd rust && cargo run --bin uniffi-bindgen -- generate \
--library ${CARGO_TARGET_DIR:-target}/aarch64-linux-android/release/libsynchronicity.so \
--library "${CARGO_TARGET_DIR:-target}/aarch64-linux-android/release/libsynchronicity.so" \
--language kotlin \
--no-format \
--out-dir ../android/app/src/main/java/com/gregshuflin/synchronicity/uniffi/
--out-dir "$out_abs"
# JAVA_HOME defaults to a local Android Studio install but is left alone if
# already set, so this also works unmodified under `nix develop` — see
@@ -130,7 +141,7 @@ android-build:
# broke Studio's own SDK/AVD resolution. `build-apk-ci` uses the same
# toolchain but does write local.properties itself, which is fine there: it
# runs in a throwaway CI checkout with no Studio session to clobber. Runs
# `buildRustLib`/`generateUniffiBindings` (via android-build's `./gradlew
# `buildRustLib`/`generate<Variant>UniffiBindings` (via android-build's `./gradlew
# assembleDebug`, which depends on them) inside that shell, so this also
# regenerates the uniffi Kotlin bindings from a hermetically-built .so.
# Output lands at android/app/build/outputs/apk/debug/app-debug.apk.