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>
241 lines
8.3 KiB
Kotlin
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)
|
|
}
|