Files
inkwell/android/app/build.gradle.kts
T
bvandeusenandClaude Opus 5.5 0a631219ee
CI & Build / Python lint (push) Successful in 2s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / Web typecheck and unit tests (push) Successful in 9s
CI & Build / Python tests (push) Successful in 15s
Android / Core and FFI clippy and tests (push) Successful in 27s
CI & Build / integration (push) Successful in 1m39s
CI & Build / Build & push image (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 7m55s
Android / Build the server image (push) Successful in 6s
Android comments describe the code to a stranger (#6223)
Milestone 546 "Bring the repo to the comment standard (rule 32) and
compose defaults (rule 198)", step #6223 "Comment standard sweep:
Android client".

KDoc, comments, manifest and resource notes, Gradle and TOML comments
and the ffi crate's docs now state what the code does and the
non-obvious why in the present tense. Issue, Scribe-note, milestone
(M13, M304, M315, M12) and family-idea references are gone, as are
comparisons to Minstrel and the narration of what used to be: the
capture sheet the editor replaced, the per-note colour story, the
operator's device-pass feedback, the bindgen-as-[[bin]] failure, the
0.60 edge alpha before it was re-solved, and the checker bugs behind
check-strings/check-symbols. Hazards stay as facts: the signing key that
can't be rotated, the alias that keeps the earlier name, PKCS12's single
password, unwind over abort, the UTC date-picker read, the
DerivedTint/colors.ts mirror.

One stale claim is gone: DerivedTint said the web had no test runner,
but notes/grammar.test.ts runs the same tint cases. detekt.yml said
"these two places" over a list of five.

49 files, comments only: a comment-stripping diff of every changed file
against HEAD is identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:24:52 -04:00

303 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, so the
// app stays testable on a desktop emulator.
val androidAbis = listOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64")
/**
* Cross-compile `inkwell-ffi` for each Android ABI and drop the resulting
* `.so` into jniLibs, where AGP packages it.
*
* `ExecOperations` injected rather than `project.exec`: Gradle 9 has no
* `project.exec`, and reaching for `project` at execution time 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", "inkwell-ffi")
// --locked so an Android build cannot silently re-resolve the workspace
// lockfile the desktop lanes are gated on.
args += "--locked"
val release = cargoProfile.get() == "release"
if (release) args += "--release"
execOps.exec {
commandLine(listOf("cargo") + args)
workingDir = workspaceDir.get().asFile
if (release) {
// The workspace's release profile with two settings put back, for this
// build only. Environment overrides rather than a profile of our own,
// because cargo-ndk copies its `-o` output from the `release`
// directory; the desktop's binaries are untouched either way.
//
// Keep the symbol table: `strip = true` removes the symbols uniffi's
// `--library` mode reads the interface out of, and bindgen then fails
// with "No UniFFI metadata found". A release build has no debug info to
// strip, so this keeps symbols and nothing else.
environment("CARGO_PROFILE_RELEASE_STRIP", "debuginfo")
// Unwind, so a panic in the core reaches Kotlin as an exception the
// board can show rather than ending the app. See the ffi crate's docs.
environment("CARGO_PROFILE_RELEASE_PANIC", "unwind")
}
}
}
}
/**
* 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",
"inkwell-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: this runner has no working
* Gradle or Cargo cache, so a cold cross-compile of four ABIs costs about four
* minutes, and a lane that type-checks and packages with different profiles would
* pay it twice. `android.yml` picks one profile for every Gradle call in the run.
*
* CI passes `release` for a signed APK, so a phone runs an optimised store and sync
* engine; `CargoNdkBuild` keeps the symbols bindgen needs.
*/
val rustProfile =
(project.findProperty("INKWELL_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 inkwell-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/libinkwell_ffi.so") })
workspaceDir.set(workspaceRoot)
outputDir.set(bindingsOut)
}
android {
namespace = "com.fabledsword.inkwell"
compileSdk = 36
defaultConfig {
applicationId = "com.fabledsword.inkwell"
// 26 (Android 8, 2017) 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("INKWELL_VERSION_NAME") as String?)?.takeIf { it.isNotBlank() }
val codeOverride =
(project.findProperty("INKWELL_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. 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 written into the certificate every install carries. It says
// `thoughtsync`, the app's earlier name, because it names the key
// inside the existing keystore; renaming it would stop the key being
// found.
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)
// A link preview's image (LinkPreviewRow.kt).
implementation(libs.coil.compose)
implementation(libs.coil.network.okhttp)
testImplementation(libs.junit)
}