d9e5753dc23283ae18febb64edd6bc5219a0f8ac
59
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
750d11d32e |
android: connect a server from the phone (M12 step 6)
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> |
||
|
|
cf0ce382a0 |
android: the note editor (M12 step 6)
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>
|
||
|
|
64e016f32d |
android: phone-shaped chrome and the real note card (M12 step 6)
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>
|
||
|
|
c8af808432 |
android: package only the ABIs we actually build for
The first working APK carried libjnidispatch.so for armeabi, mips and mips64 as well as our four — JNA's .aar still ships those, and AGP packages whatever it finds. Android dropped mips in NDK r17 and armeabi in r17 too; nothing that can install this app can load them, so they are pure payload. abiFilters pins the set to the four the Rust is actually cross-compiled for, so the APK's ABI list matches the build's intent rather than the union of every dependency's history. Found by unpacking the artifact rather than trusting the green: the run said "Upload debug APK ✓", which is true and says nothing about what is inside it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
dee71dffb3 |
android: teach the linters this codebase's conventions, and fix two real nits
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>
|
||
|
|
5d0de7a682 |
android: the binding generator gets its own crate, free of the app's deps
Third Android run got further than either before it — all four ABIs cross-compiled, vendored OpenSSL and all — then the generator died: error: failed to run custom build command for `openssl-sys v0.9.117` That is the HOST build. The generator was a [[bin]] inside thoughtsync-ffi, so building it compiled that crate and therefore the core, reqwest, native-tls and openssl-sys for linux. The vendored-OpenSSL block is scoped to `cfg(target_os = "android")`, so the host build went looking for a system OpenSSL that ci-rust-android has no reason to carry. Adding libssl-dev to the image would have fixed it and been wrong: a code generator has no business linking the app's TLS stack to emit Kotlin. Splitting it into thoughtsync-uniffi-bindgen, whose only dependency is uniffi, removes the entire chain. Verified from the dependency graph rather than from a build that happened to succeed — `cargo tree -p thoughtsync-uniffi-bindgen` contains none of openssl-sys, native-tls, reqwest, thoughtsync-core or rusqlite. It stays a WORKSPACE MEMBER on purpose. Sharing one lockfile is what keeps uniffi here and uniffi linked into the .so at one version; they are two halves of one ABI, and a separate lockfile is precisely how they would drift apart. The cost is that the desktop lane now compiles ~15 generator crates it never runs — cheap next to Tauri, and better than leaving the crate unlinted. Drops the `bindgen` feature and required-features bin from thoughtsync-ffi, which existed only to keep those crates off the desktop lane and now have nothing to gate. Local fmt + clippy + test all green before pushing (107 tests). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
3d3df1beb0 |
android: register generated sources through the Variant API
Second Android run failed with AGP 9 refusing the previous fix by name: You cannot add Provider instances to the Android SourceSet API. [...] Instead you should use the Sources interface in the Variant API, in particular SourceDirectories.addGeneratedDirectory AGP cannot tell from a Provider whether the directory holds generated (read-only) or hand-written (read-write) files, which is a distinction the IDE needs. `addGeneratedSourceDirectory` is the supported route and — unlike the plain-path form the error offers as an escape hatch — it carries the task dependency, so Kotlin still cannot compile before the bindings are generated and the APK cannot package a stale .so. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
f179928c57 |
android: run ktlint and detekt from the image, not as Gradle plugins
First Android run failed at plugin resolution: Plugin [id: 'io.gitlab.arturbosch.detekt', version: '2.0.0-alpha.3'] was not found in any of the following sources That version is published to neither Maven Central nor the plugin portal — the latest detekt anywhere is 1.23.8. It was copied from Minstrel's catalog, where it presumably resolves from a cached artifact; copying a pin without checking it exists is what made it my problem. Rather than chase a working plugin version, the analyzers now run from the CLIs ci-rust-android already ships. That was the point of putting them in the image in step 3, and going through Gradle plugins would have meant a SECOND pinned version of each tool, resolved at build time, kept in lockstep with the image's by hand. One less resolution step, and step 3's decision finally earns its keep. Also replaces the source-ordering hack while here. Kotlin has to compile after the bindings are generated, and the usual `tasks.withType<KotlinCompile>` cannot be written in this build at all — AGP 9's built-in Kotlin means that class is not on the buildscript classpath. Passing the TASK PROVIDERS to srcDir instead lets Gradle read their @OutputDirectory and infer the ordering itself, which is the idiomatic form and removes the dependsOn entirely. Good news from the failed run: the Gradle wrapper check passed, so Gradle 9.1.0 on the image's JDK 25 works — the toolchain decision from step 3 holds. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
20907abf6e |
android: a Kotlin/Compose app that drives the Rust core (M12 step 5)
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>
|