Commit Graph
5 Commits
Author SHA1 Message Date
bvandeusen 785ebdba59 android: reminders that actually reach you (M12 step 6)
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m0s
Android / Kotlin + Rust (debug APK) (push) Failing after 5m13s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m37s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Reminders have been settable since the editor landed and have never once gone
off. The board showed them overdue in red, which tells you what you already know
by the time you are looking at the board.

**AlarmManager, not WorkManager.** The background sync is right to be on
WorkManager — nobody minds whether it runs at 3:05 or 3:19. A reminder minds
very much. WorkManager's periodic floor is fifteen minutes and it batches into
maintenance windows, so "remind me at 09:00" would routinely arrive at 09:14,
which is not a reminder, it is a rebuke.

**One alarm, not one per reminder.** Only the earliest future reminder is ever
scheduled; when it fires, everything due is announced and the next is scheduled.
A hundred reminders cost one alarm, and there is no incremental bookkeeping to
drift — `Reminders.refresh` recomputes the whole picture from the store, and is
called from everywhere anything could have changed: an edit, a foreground, a
background sync, boot, and an app update.

Boot and MY_PACKAGE_REPLACED both matter and both are easy to forget. Pending
alarms survive neither, and this app updates by APK from its own server, so
without that receiver a phone would silently stop reminding anyone of anything
after a restart — the worst kind of failure, because nothing appears wrong.

**Neither permission is treated as a prerequisite.**

SCHEDULE_EXACT_ALARM, not USE_EXACT_ALARM: the latter is granted at install with
no prompt and is reserved for apps whose whole purpose is an alarm clock or a
calendar, which this is not. Refusing the former costs precision, not the
feature — it falls back to an inexact alarm, because a reminder a few minutes
late beats no reminder.

POST_NOTIFICATIONS is asked for on the first launch where a reminder actually
exists, never at launch on an empty board. Android gives an app essentially one
chance at that dialog, and spending it before the person has any idea what this
app would send them is spending it on nothing. For anyone who refuses, or who
turns notifications off later in system settings, the Reminders view carries a
standing notice with a button to the right screen — a feature that silently does
nothing is worse than one that is plainly absent.

**A first run adopts overdue reminders silently.** The storm case is linking a
server and pulling months of history; a hundred notifications the moment someone
signs in is a good way to have the feature turned off before it is ever useful.
After that, a missed reminder is announced up to a day late — the web uses
fifteen minutes because an open tab has been polling every forty-five seconds,
but a phone can be switched off all night.

Done and Snooze act from the shade without opening the app. The dedupe key is
note id plus remind_at, the same one the web store uses, so snoozing produces a
new occurrence rather than one already dealt with.

Tapping a notification opens that note. The extra is CONSUMED when read: the
Activity keeps the intent it was launched with, so without that, rotating the
phone would replay it and reopen a note the person had already closed.

`Reminders` split into scheduling policy and `ReminderNotification` rendering
after detekt counted fourteen functions in one object — it was right, they answer
different questions and change for different reasons. `ForegroundTransitions`
moves to the ui package; the reminder notice needs it to re-read a permission the
person may have just changed in a system screen this app cannot observe.

Known gap, pre-existing and shared with every surface: `complete_reminder` in the
core clears a reminder without advancing recurrence — its own comment says so.
So tapping Done on a daily reminder ends it rather than moving it to tomorrow.
Not changed here because it is core behaviour the desktop and web also have, but
notifications make it much easier to hit, and it should be next.
2026-08-19 20:06:05 -04:00
bvandeusen 452c66c8ef 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.
2026-08-19 19:04:45 -04:00
bvandeusenandClaude Opus 5 cf0ce382a0 android: the note editor (M12 step 6)
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m0s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m26s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Android / Kotlin + Rust (debug APK) (push) Successful in 7m25s
Tapping a card now opens something. Until this commit the phone could create,
find and navigate; it could not change anything.

A FULL SCREEN, not a sheet. Capture is a sheet because the board behind it is
reassurance that the thought landed; editing is a sustained task with the
keyboard up, and a sheet would spend the whole time fighting the IME for the
bottom half of the display. Full screen also puts the actions in a bottom bar,
which is where a thumb already is. The note's colour paints the whole screen,
so opening one reads as the same object growing to fill the display.

Text saves ONCE, on close — plus on ON_STOP, so app-switching mid-paragraph
doesn't lose it. Not debounced autosave: the core snapshots a revision on every
title/body change, so saving per typing pause would fill version history with
near-identical entries. A baseline check means opening a note and backing out
writes nothing at all, rather than bumping updated_at and marking it dirty for
sync. Same shape the web editor settled on, for the same reason.

The editor speaks in ACTIONS, not callbacks. The first version passed a bundle
of twenty lambdas and the doc comment on it was already worrying about two of
the same-shaped ones getting swapped, with nothing to catch it. `EditorAction`
plus one `(EditorAction) -> Unit` costs a `when` at the far end and buys
exhaustiveness: adding a variant breaks the dispatcher until it is handled.

Checklist rows are live here — real checkboxes, editable text, remove, and an
add row that keeps focus so a list types straight through. That is the answer
to the open question about list entry: the capture sheet stays one-item-per-
line because at capture time the list is already in your head and a tap per row
is the slow part; the editor is where a list is REVISED, and revising is
item-at-a-time. Row text commits on focus loss, not per keystroke — each commit
is a store write that reloads the note.

Colour, labels and reminders are bottom sheets. Reminders lead with presets
(later today / tomorrow / next week) and keep the exact picker one tap down:
the web's raw datetime-local is right for a desktop and three taps too many for
the common case on a phone. Recurrence only appears once there is a reminder to
recur from. The date picker reports UTC midnight of the calendar day tapped and
is read back in UTC — reading it in the device zone is the classic off-by-a-day
in that control.

Pin, labels, archive and delete live in the overflow as WORDS.
`material-icons-core` has no pin, archive or label glyph, and the alternatives
were pulling in the ~1,000-vector extended set for four icons or pressing
unrelated ones into service — a star meaning "pin" is a star meaning "favourite"
to everyone who has used another app. The colour button is a dot in the note's
current colour, which says what the colour IS as well as what the button does.

A trashed note renders read-only. Editing one would silently resurrect work
that was meant to be thrown away; Restore and Delete forever are the only
things to do with it. Deleting for good is the one irreversible action in the
app and gets the one confirmation in it.

`#tag` labels are never sent to `set_labels` and get no remove button. They are
owned by the body text and the core re-derives them on the next edit, so a
cross that undid itself a second later would look broken.

FFI additions: delete_note_forever, add_item, set_item_text, set_item_checked,
delete_item, complete_reminder, snooze_reminder, set_note_labels, create_label.
`set_item_text`/`set_item_checked` are split rather than exposing the core's
{text?, checked?} patch, for the same reason NoteEdit is a list — an
optional-field struct cannot say "leave this alone" in Kotlin without colliding
with "set it to null". Four new tests (11 total in the crate).

Found while extracting shared helpers: the card painted EVERY reminder blue,
so "you missed this" and "coming up Friday" looked identical. Now red when
overdue and neutral otherwise, matching the web card's exact pairs. And the
error banner was renderable only by the board — the one screen that needed it,
where the writes happen, was the one screen without it.

DRY, since three copies each had appeared: PlainTextField (the undecorated
field used by capture, editor, checklist rows and the search bar), Time.kt (the
RFC3339 seam), NoteKind.kt, ErrorBanner.

detekt: LongMethod and LongParameterList now ignore @Composable. Compose breaks
those rules' PREMISE, not just their thresholds — a composable's parameters are
its UI contract and its length tracks how many elements are on screen, not
branching. Two suppressions carry their reasoning at the site instead:
onEditorAction is sixty lines because EditorAction has twenty variants, and
splitting it would need an `else` that throws away the exhaustiveness; and
BoardViewModel stays one class because every editor mutation has to reload the
board behind it.

Verified locally before pushing, per ci-requirements.md: fmt/clippy/test in
ci-tauri:1.97 (89 + 11 + 11 tests, four crates present), ktlint and detekt in
ci-rust-android:1.97, uniffi bindings generated from a host build and read to
confirm every method and field name the Kotlin calls.

Still unbuilt: attachments, link previews, version history, and label
management (rename/recolour/delete). Setting up a server from the phone is next.

Scribe #2777

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 11:18:24 -04:00
bvandeusenandClaude Opus 5 dee71dffb3 android: teach the linters this codebase's conventions, and fix two real nits
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m33s
Android / Kotlin + Rust (debug APK) (push) Failing after 4m49s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m59s
Fourth run got the whole native pipeline through — cargo-ndk built all four
ABIs and uniffi generated the Kotlin — and then failed on style.

Two genuine mistakes, fixed:
  * BoardViewModel's constructor parameter needed its own line.
  * PaddingValues was written fully-qualified inline, which ktlint read as a
    method chain. Importing it is what the rule was actually asking for, and
    what the line should have said anyway.

The other ten were the tools not knowing this codebase:
  * @Composable functions are PascalCase by universal Compose convention.
    Exempted in BOTH .editorconfig (ktlint) and config/detekt.yml — they have to
    agree or one of them is always wrong.
  * MagicNumber on `private val Brand = Color(0xFFF5C518)`. The rule asks for a
    well-named constant; that line IS one. ignorePropertyDeclaration.
  * TooGenericExceptionCaught in the ViewModel and Application. Deliberate and
    already commented: a note that fails to save must become a visible error
    banner rather than a crash, and the store failing to open must still let the
    app start so it can explain itself. Scoped to those two paths, not disabled
    globally — everywhere else the rule is right.

Verified locally this time, both linters clean, using the SAME pinned CLIs from
ci-android:36 that the lane runs. ktlint and detekt are a formatter and a static
analyzer — the same category as cargo fmt and clippy, which is the precedent
ci-requirements already sets. No build was run locally.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 16:04:40 -04:00
bvandeusenandClaude Opus 5 20907abf6e android: a Kotlin/Compose app that drives the Rust core (M12 step 5)
Android / Kotlin + Rust (debug APK) (push) Failing after 1m20s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m24s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m0s
Desktop (Tauri) / Update manifest (push) Successful in 5s
The skeleton, and the lane that builds it. Gradle invokes cargo-ndk to
cross-compile thoughtsync-ffi for four ABIs, generates the Kotlin bindings from
the resulting .so, and packages both.

BUILT ON MINSTREL'S TOOLCHAIN, not a fresh guess. Gradle 9.1.0 / AGP 9.0.1 /
Kotlin 2.3.21 on JDK 25 is the combination already proven in this family on
ci-android, including the JDK 22+ native-access opt-in the launcher JVM needs
and the artifact-upload action pinned by SHA (issues 2255 / 2270). It also
independently confirms the JDK 25 call made on ci-rust-android in step 3.

Gradle wiring worth noting:

  * ExecOperations, not project.exec — the latter was REMOVED in Gradle 9, and
    touching `project` at execution time is also what breaks the configuration
    cache this build enables.
  * The cargo task's inputs are the Rust SOURCES, not the workspace directory.
    Declaring the directory would make Gradle hash target/, which is gigabytes.
  * Bindings are generated with `--library` against the built .so, so they can
    never describe a different version of the Rust than the one being packaged.
  * cargo runs --locked, so an Android build cannot silently re-resolve the
    lockfile the desktop lanes are gated on.

JNA is a real dependency, with the @aar classifier. The plain jar builds fine
and fails at runtime with UnsatisfiedLinkError, which is the worst way to learn
it. R8 keep rules for JNA and the bindings are in for the same reason — that
failure would otherwise appear only in a minified release.

The UI is a working board, not a debug screen: capture field, note list, empty
state, error banner, and an honest failure screen for a store that won't open.
Rules 23/24 — a surface ships at quality from the first commit. Capture uses the
IME action key because the north star is a thought captured in under a second,
and leaves the title empty so the core derives it from the first body line.

Every core call runs on Dispatchers.IO: they are blocking FFI into synchronous
SQLite, and running them on the main thread is exactly the jank going native was
meant to avoid.

The launcher icon reuses frontend/public/icon-maskable-512.png as an adaptive
foreground on the brand #F5C518 — the same asset and colour the web app already
ships, so the three surfaces wear one face.

No signing config. A release keystore that has passed through an agent session
or shell history is compromised by construction (task 2136); it has to be
generated by the operator and reach CI only as a secret. CI builds debug.

CI can only prove this BUILDS — a Linux runner cannot execute an APK, so feel
and on-device correctness remain an operator pass on an emulator.

Scribe #2739.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 15:24:30 -04:00