Commit Graph
7 Commits
Author SHA1 Message Date
bvandeusen 64542ed6cb android: pull the board down to sync (M12 step 6)
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m18s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m35s
Desktop (Tauri) / Update manifest (push) Successful in 6s
Android / Kotlin + Rust (debug APK) (push) Successful in 7m56s
Every sync so far has been a button press on a screen you have to navigate to.
On a phone the gesture for "check if there's anything new" is a pull, and not
having it is the kind of absence people read as the app not syncing at all.

**The gesture is INERT when this device has no server.** `Modifier.pullToRefresh`
takes an `enabled`, which is why the modifier and the indicator are wired by hand
instead of using `PullToRefreshBox` — that wrapper is less code and offers no way
to turn the gesture off. An unlinked device has nowhere to pull from, and a
gesture that always comes back empty is how people learn a control is broken.
Same reasoning as the drawer badge staying silent when unlinked: local-only is
this app's resting state, not a fault.

**A failed refresh reaches the board.** Otherwise the spinner retracts and
nothing happens, which is indistinguishable from "you were already up to date" —
the one outcome it must not be confused with. It renders as a second banner
rather than replacing the store-error one: those are different facts about
different halves of the app, and hiding either behind the other reports the
wrong problem. Dismissing is honest — the note is still pending, `hasPending`
still says so, and the next cycle reports the same fault if it persists.

**The empty board is now a `LazyColumn` holding one centred item.** Pull-to-
refresh works through nested scroll, and a layout that never scrolls never
dispatches any, so on the old plain `Column` the gesture would have been dead on
exactly the screen where it matters most: linked, board empty, notes still on the
server. Looks identical.

The five sync facts the board needs arrive as one `BoardSync` rather than five
parameters, for the reason `EditorAction` exists: `summary` and `error` are both
`String?` and both about sync, so positionally they could be swapped with nothing
to catch it.

Still no automatic sync — no background cycle, no sync-on-resume. This is a
faster way to ask, not a decision to stop asking. TalkBack users cannot perform
a pull; the drawer's Sync → Sync now remains the accessible path, unchanged.

Verified against the real artifact rather than from memory, since `material3`
resolves through the BOM: 1.4.0's sources confirm `pullToRefresh` has `enabled`,
and that none of `pullToRefresh`, `rememberPullToRefreshState`, `Indicator` or
`PullToRefreshBox` is `@ExperimentalMaterial3Api` there — only two deprecated
members are. So no `@OptIn`, which is what keeps the build at zero warnings.
2026-08-19 16:36:56 -04:00
bvandeusen 65d8f5f9c6 android: the import ktlint and detekt cannot see
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m31s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m6s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Android / Kotlin + Rust (debug APK) (push) Successful in 7m26s
`750d11d` failed CI at `compileDebugKotlin` with `Unresolved reference 'Build'`.
`defaultDeviceName()` reads `android.os.Build`, and the import was lost when
`SyncPairing.kt` was split out of `SyncScreen.kt`. One line to fix.

The interesting part is that ktlint and detekt had both passed it, locally and
in CI. Neither resolves symbols — they parse — so a file that cannot compile is
indistinguishable to them from one that can. A clean analyzer run is not
evidence the code builds, and on this repo `compileDebugKotlin` is the only
gate that type-checks at all, since there is no Android SDK on the workstation.

So: `android/tools/check-symbols.py`, covering that one blind spot. It flags any
capitalised identifier that is neither imported, declared in the same package, a
type parameter, nor implicitly available. Not a type checker and not pretending
to be — a pre-push filter for the single mistake that survives every other local
gate, erring toward false positives.

Verified against a known-bad tree rather than trusted on a green: deleting the
`Build` import from a copy makes it fail with the same two references the Kotlin
compiler reported. That step is not ceremony. An earlier attempt at this check
stripped line comments with `re.S`, where `//.*` eats each file from its first
comment to EOF — it examined almost nothing and reported everything clean.

ci-requirements.md now documents all three Kotlin checks, and its claim that no
workflow consumes the Android image yet is gone; the lane has been running since
step 5.
2026-08-19 15:51:12 -04:00
bvandeusenandClaude Opus 5 750d11d32e android: connect a server from the phone (M12 step 6)
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m0s
Android / Kotlin + Rust (debug APK) (push) Failing after 4m57s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m21s
Desktop (Tauri) / Update manifest (push) Successful in 4s
The plumbing has been bound since step 4 — probe, link by password or token,
unlink, sync — with nothing on top of it. Until this commit the phone was a
good standalone notes app that could not be the SAME notes as the desktop,
which is the point of the project.

Structurally a port of the desktop's SyncView.vue: same probe-then-link order,
same copy wherever the copy was already right. The two surfaces pair with the
same servers, and a difference in wording here would read as a difference in
behaviour.

BEING UNLINKED IS NOT A PROBLEM, and the screen is written around that. It
leads with "Working offline on this device" and says what connecting would
ADD. A local-first app that frames its resting state as unfinished setup is
lying about what it is. The drawer badge follows the same rule: it says
nothing at all when unlinked, rather than "Off".

Probe before credentials. A typo that reaches a stranger's server should cost
a round trip, not a password — so the address is checked first, what answered
is shown (name, version, compatibility), and only then does a sign-in form
appear. An incompatible server never gets one; the core would refuse the link
anyway, and collecting a password to throw away is worse than not asking.

CLEARTEXT IS NOW PERMITTED, deliberately and not silently. Android blocks
plain http from API 28, and the core explicitly supports a self-hosted server
on a LAN — `http://192.168.1.10:8000` is a case it has a test for. The
platform default would make this app unusable for exactly the people it is
built for, with a transport error they could do nothing about. A
network-security-config would be tighter in principle but matches domains and
IP literals, not CIDR ranges, so it cannot express "my own network". The other
half of the trade is a warning that appears the moment a probed address starts
with http:// and BEFORE any credential field: anyone on the same network can
read your password and your notes.

Credentials never enter the view model. The address, email and device name are
`rememberSaveable` so a rotation doesn't cost a retype; the password and the
token are plain `remember` on purpose — rememberSaveable persists into the
instance-state bundle, and a secret has no business being written there to
save four seconds of typing. They reach the core as a `Credentials` sealed
type and die with the composable.

That sealed type also fixed a bug detekt surfaced by complaining about a
six-parameter function: `link_with_token` takes NO device name (the token was
already minted against a named device in the web app), so the flat argument
list meant the form collected one in token mode and silently dropped it. The
field now exists only on the password path.

Threading, which differs by call and is easy to get wrong in one direction:
probe / linkWithPassword / linkWithToken / unlink / syncNow are Rust async
through uniffi, so Kotlin sees suspend functions already driven by tokio and
awaits them directly — wrapping them in Dispatchers.IO would park a thread to
wait on something that never blocks one. syncStatus and hasPending are
ordinary blocking FFI into SQLite and do need it.

A sync that changed anything tells the board to reload, because a pull can
have rewritten every note it is holding. Wired explicitly at the one place
that owns both view models rather than through a shared event bus. A no-op
sync deliberately does not, so the board never flashes its loading state for
nothing.

Sync results are kept RAW in state and turned into sentences in the UI, where
stringResource is in scope — the same split Time.kt draws for timestamps. The
summary counts what MOVED; batches, pages, noop and cursor are all real
numbers and none of them answer "are my notes in step". Rejections are
surfaced rather than swallowed: only a person can resolve them. So is a revoke
that didn't land — someone disconnecting to retire a phone has to be told a
live credential is still out there, and has to still find it when they come
back to check, so it is a persistent notice and not a toast.

Also here: `Panel`/`Notice` extracted as shared tinted chrome, drawn from the
same note palette the cards use rather than Material's errorContainer, so a
warning is the same yellow a note can be. `PlainTextField` gained a visual
transformation for the password field. `formatReminder` became `formatInstant`
now that "last synced" reads it too.

Verified locally per ci-requirements.md: ktlint and detekt clean in
ci-rust-android:1.97, uniffi bindings generated from a host build and read to
confirm ULong on the summary counters, `Compatibility.Ok`/`RevokeOutcome.
Unsupported` being objects, and all five sync calls being suspend. Every
R.string/R.plurals reference cross-checked for existence, kind and format
arity. A symbol-resolution pass over the whole package caught a composable a
bad edit had deleted — ktlint and detekt both parse without resolving, so
neither could see it.

Not done: no automatic sync. The desktop is manual-only too, so this is parity
rather than a gap, but pull-to-refresh on the board is the obvious phone-native
follow-up.

Worth an operator decision, not changed here: allowBackup is still true, so
Android's cloud backup now includes a device token as well as the notes. Good
for restoring to a new phone, and a wider blast radius than before this commit.

Scribe #2777

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 15:41:02 -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 64e016f32d android: phone-shaped chrome and the real note card (M12 step 6)
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
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
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