android: sync without being asked (M12 step 6)
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m3s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m15s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (debug APK) (push) Canceled after 6m32s

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.
This commit is contained in:
2026-08-19 19:04:45 -04:00
parent 64542ed6cb
commit 452c66c8ef
14 changed files with 615 additions and 11 deletions
+45 -8
View File
@@ -211,25 +211,62 @@ local run says nothing about whether the code builds. It cost a red CI run on
`750d11d`, where `android.os.Build` was lost in a file split and both analyzers
were happy.
So there is a third local check, covering exactly that one blind spot:
So there are two more local checks, each covering one blind spot:
```
python3 android/tools/check-symbols.py
python3 android/tools/check-strings.py
```
It flags any capitalised identifier that is neither imported, declared in the
same package, a type parameter, nor implicitly available. Not a type checker —
`compileDebugKotlin` in CI remains the only real one, and it is also the ONLY
lane that type-checks at all, since there is no Android SDK on the workstation.
Run all three before a push that touches Kotlin.
`check-symbols.py` flags any capitalised identifier that is neither imported,
declared in the same package, a type parameter, nor implicitly available. Not a
type checker — `compileDebugKotlin` in CI remains the only real one, and it is
also the ONLY lane that type-checks at all, since there is no Android SDK on the
workstation.
`check-strings.py` covers resources, where the compiler is no help either: `R`
is generated, so `R.string.whatever` type-checks whether or not the string
exists. It catches a missing name, `stringResource` used on a plural or the
reverse, and a format string that takes more arguments than the call passes —
the last of which renders `%2$s` as literal text rather than failing.
Run all four before a push that touches Kotlin.
A caution worth keeping, because it bit twice: a checker of this shape is itself
easy to get vacuously right. The first version stripped line comments with
`re.sub(r'//.*', src, flags=re.S)`, and DOTALL makes `//.*` swallow each file
from its first comment to EOF — so it reported everything clean by examining
almost nothing. **Test a checker against a known-bad tree before trusting a
green from it**; this one is verified by deleting the `Build` import from a copy
of the source and confirming it fails.
green from it**. `check-symbols.py` is verified by deleting the `Build` import
from a copy of the source; `check-strings.py` by introducing one of each of its
three fault kinds. Its own first version counted Kotlin's trailing commas as
arguments and reported three correct call sites as broken — the opposite failure,
and the one that teaches you to ignore the tool.
## A fourth Kotlin check: read the artifact, don't recall the API
Compose comes from a BOM (`compose-bom` in `libs.versions.toml`), so no file in
this repo states which `material3` a build actually gets. Guessing its API and
finding out from CI costs eight minutes a try. Resolve and read it instead:
```
# androidx is on Google's Maven, NOT Maven Central — repo1 returns 404
BOM=https://dl.google.com/dl/android/maven2/androidx/compose/compose-bom
curl -sS $BOM/2026.05.01/compose-bom-2026.05.01.pom | grep -A3 'material3</artifactId>'
M3=https://dl.google.com/dl/android/maven2/androidx/compose/material3/material3-android
curl -sS -o m3-src.jar $M3/1.4.0/material3-android-1.4.0-sources.jar
```
The sources jar answers what javap cannot: default arguments, parameter names,
and whether a declaration carries `@ExperimentalMaterial3Api`. That last one is
not optional trivia — an unnecessary `@OptIn` is itself a Kotlin warning, so
guessing "safely" breaks the build's zero-warning record just as surely as
omitting a required one breaks the build.
Same technique for any dependency. It is how `work-runtime-ktx` was found to be
an empty 6 KB stub as of 2.11, with `CoroutineWorker` and
`PeriodicWorkRequestBuilder` moved into `work-runtime` itself.
## Checking the Rust lane before pushing