Files
thoughtsync/android/app/build.gradle.kts
T
bvandeusenandClaude Opus 5 64e016f32d
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m47s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m52s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Android / Kotlin + Rust (debug APK) (push) Successful in 7m2s
android: phone-shaped chrome and the real note card (M12 step 6)
Two things at once, because they answer one question: what should this look like,
and what should it look like ON A PHONE.

IDENTITY IS SHARED, INTERACTION IS NOT. The card now renders exactly what the web
and desktop render — note colour, checklists, label chips, reminders — using the
same palette values, so a note looks like your note on every surface. The chrome
does not: the desktop's title bar and sidebar are wrong for a thumb.

  * NoteTint.kt carries the Tailwind colours from frontend/src/notes/colors.ts
    VALUE FOR VALUE, generated from tailwindcss 3.4 rather than eyeballed. Dark
    tints keep the web's alpha (dark:bg-*-950/40) instead of a precomputed blend,
    because Compose composites translucency over the background exactly as CSS
    does.
  * Dynamic colour is GONE. It was the more Android-native choice and it made the
    app look like a different product — on a stock emulator with no wallpaper it
    renders as undifferentiated grey, which is what the operator saw. Three peer
    surfaces share one identity; the brand #F5C518 is the same value the web
    manifest and the launcher icon already use.
  * The board is a two-column staggered grid, the Compose equivalent of the CSS
    multi-column NoteGrid.vue uses.

PHONE ERGONOMICS, chosen with the operator:
  * Search IS the top bar. After writing a note, finding one is the most common
    thing you do, and burying it behind an icon costs a tap every time. Debounced
    180ms and cancelled per keystroke — without that a fast typist queues one
    full-text query per character and results land out of order.
  * A + button is the only way in. One obvious target beat a capture bar and a
    button competing for the same job.
  * Navigation moved into a drawer behind the search bar's menu icon, which is
    where archive/trash/labels/reminders now live. They had nowhere to go once
    search took the top bar, and would otherwise have been unreachable.
  * The compose sheet asks note-or-list up front. On a phone those are different
    typing tasks and switching halfway is worse than choosing at the start. A
    list takes one item per line — fast to type, versus a tap per row.

Three new bindings the UI needed: search_notes, reminder_notes, list_labels.
Search goes through the CORE so "what matches" cannot drift between surfaces;
filtering the loaded list in Kotlin would have been less code and a different
product. reminder_notes is its own call because the core models it that way —
"has a reminder" cuts across archived and active alike.

Empty states are per-destination. "Nothing here yet" is encouraging on an empty
board, wrong in Trash, and misleading after a search where the notes exist but
did not match.

Verified locally before pushing: bindings generated from a host .so and read back,
ktlint and detekt clean from the image's pinned CLIs, cargo fmt/clippy/test green
(107 tests). Two detekt findings were fixed by extraction rather than by relaxing
the rules — this is the first Compose code in the repo and the thresholds should
have to earn their exceptions.

Still unbuilt: tapping a card does nothing. The editor is next.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 09:23:00 -04:00

241 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.material.icons.core)
implementation(libs.compose.ui.tooling.preview)
debugImplementation(libs.compose.ui.tooling)
testImplementation(libs.junit)
}