Until now every sync was a button press. Pull-to-refresh made asking cheaper; it
did not stop the app needing to be asked, which on a phone means a note written
on the bus reaches the desktop whenever you next happen to open the app.
Three moments, and they are deliberately not the same job:
* **Coming to the front**, if the last sync is over five minutes old or there
is unsent work. Not on every foreground: stepping out to copy a link and
stepping back is not a request for fresh notes, and syncing on every app
switch spends someone's mobile data telling them what they are looking at.
* **Going away with unsent work** — handed to WorkManager rather than run
inline, because the process is about to stop being a priority and a sync
started there would be killed halfway. This is the one that matters most: it
is what gets a note off a phone that then goes into a pocket for the night.
* **Every fifteen minutes**, network-constrained. Fifteen is not a preference,
it is WorkManager's floor for periodic work; asking for less gets fifteen.
**An automatic sync must not raise an error banner.** Someone who pulled the
board down is owed an answer; someone who merely opened the app did not ask a
question, and answering it with a red banner about an unreachable server makes
their own notes look broken when nothing of theirs is. So `syncNow` and
`syncQuietly` differ in exactly one thing — whether failure is announced. The
quiet channel for a persistent problem is the drawer badge, from `has_pending`,
which does not care how the attempt was made.
**There is a switch, defaulting to on.** Linking a server IS the consent; a
person who paired a device and then had to find a second toggle before anything
moved would reasonably call that broken. It lives in SharedPreferences rather
than the store: everything else in sync state describes the PAIRING and must
survive a reinstall, while this describes how one handset behaves, and someone
turning it off on their phone is not asking their laptop to stop. The copy says
what "automatically" means in minutes and says that off is not off — a switch
next to a Disconnect button invites exactly that misreading.
The schedule is DECLARED as a function of (linked, switch) in a LaunchedEffect
rather than toggled from the places that change them. There are four routes to
"should not be syncing on its own" and a call at each is four chances to leave a
phone quietly syncing after it was told to stop.
`ON_START`/`ON_STOP`, not resume/pause — the same choice the editor's save-on-
leave makes, because pause fires for anything covering the window and a sync per
notification-shade pull is not automatic sync, it is a stutter.
RECEIVE_BOOT_COMPLETED now appears in the merged manifest. WorkManager
contributes it so the schedule survives a restart; commented in AndroidManifest
because it shows in the app's permission list and nothing else in that file
would explain it.
Two things read from artifacts rather than recalled, both of which memory would
have got wrong: `work-runtime-ktx` is an empty 6 KB stub as of 2.11 with
`CoroutineWorker` and `PeriodicWorkRequestBuilder` moved into `work-runtime`, so
the dependency is on the latter alone; and `Switch` is not experimental in
material3 1.4.0, so no `@OptIn` — an unnecessary one is itself a warning.
Also adds `android/tools/check-strings.py`, after this change added three
strings: `R` is generated, so `R.string.typo` type-checks whether or not the
string exists. It catches a missing name, `stringResource` on a plural or the
reverse, and a format taking more arguments than the call passes. Verified
against a tree with one of each fault — its first version counted Kotlin's
trailing commas as arguments and called three correct sites broken, which is the
failure that teaches you to ignore a tool.
Two comments in this change were wrong when written and are corrected here
rather than left: the flag check in SyncWorker does NOT avoid opening the store,
because Application.onCreate has already run by the time any Worker starts.
242 lines
8.4 KiB
Kotlin
242 lines
8.4 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)
|
|
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)
|
|
}
|