ae2053d2edb63b31d41dc93e7167d53b23d00834
16
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
1e2b42af25 |
android: say nothing until the update is downloaded, and only fetch on wifi
Both corrections to what I built, and the second changes the first. NAG ONLY WHEN READY. The banner is now gated on the bytes being on disk. I had it appearing as soon as a build was FOUND, with Install downloading on demand — which turns one tap into an unplanned download, and is exactly the surprise the wifi gate was meant to avoid. Off wifi the app now stays quiet and picks it up later. ONLY ON WIFI, and both halves of that. `isActiveNetworkMetered` alone would download over an unmetered cellular plan, which is not what "on wifi" means. TRANSPORT_WIFI alone would download over a tethered hotspot, which is mobile data wearing a different hat and the precise bill this avoids. It now requires both. Found while making the first change: gating the nag on `ready` broke the nag. The background path returns early once a build is fetched, so `nagDismissed` would never be cleared again and a single "Later" would have silenced the update permanently — the exact "lost" this whole path exists to prevent. Coming forward with a fetched build now clears the dismissal instead of returning. Also: a build found off wifi retries its FETCH on the next foreground rather than waiting out the six-hour check interval. Found on the train, downloaded at home. The banner loses its two-state text with the change, and BoardUpdate loses `ready` — it is implied now. It stays visible while installing, deliberately: that is the one moment it has something to report, and hiding it would look like the tap did nothing. |
||
|
|
ee47a61270 |
android: find updates without being asked, fetch them, then nag
`check()` had exactly one caller: a button on the sync screen. So a new build was found only by someone who went looking for one — and having to remember to go looking is the same as not being told. The operator has been doing that by hand every time. Three parts. FIND. The app checks when it comes forward, which is the moment the person is present. Rate-limited to six hours in the view model, so flicking between two apps is not a re-check, and skipped entirely on an unlinked device — updates come from a linked server and there is nothing to ask. Same ForegroundTransitions shape as AutomaticSync, for the same reason. FETCH. Finding one downloads it, so the nag is a one-tap install rather than the start of a wait. NOT over mobile data: fifty-odd megabytes is a bill nobody agreed to, so this is gated on an unmetered connection (new ACCESS_NETWORK_STATE permission — normal, no prompt). On a metered link the update is still found and still nags; Install downloads it then, which is a choice rather than a surprise. NAG. A banner on the board, under the error banners — an update is worth saying and never worth saying before a note failed to save. "Later" clears it for this sitting only: the next time the app comes forward the check finds the same build and says so again. That is the difference between a reminder and a notice you can lose. downloadAndInstall now skips the download when the background fetch already did it, so the sync screen's button and the banner's are the same action with the same name — whether the bytes are already there is this class's problem, not the person's. |
||
|
|
68b2a5dc8d |
android: a checklist is lines of the note here too
M304 step 6, and the surface with the least room to hide: Android has no markdown renderer at all, so the card was about to show every list twice — once as literal `- [ ] milk` in the body preview, and again as the glyph rows underneath. Same bug the web had, one commit later. The card now renders the body LINE BY LINE and draws a checkbox where one belongs, which is what puts a list between two paragraphs instead of always after them. The glyphs became tappable while they were being rewritten: ticking something off from the board without opening the note is the common gesture, and the web just gained it. The tap target is the glyph, not the row — tapping the TEXT still opens the note, the way tapping anywhere else on a card does. Kotlin gets no parser. Three implementations of the grammar is the price already paid; a fourth in Compose would be a fourth place for a checklist to change shape when it syncs. So the core exposes three pure functions instead — `checklist_lines`, `checklist_continuation`, `checklist_toggle_at` — and Kotlin does the caret arithmetic around them. Those are FREE functions, not methods, and that is the interesting constraint. The editor's body field is LOCAL state on an idle-debounced autosave, so anything that edits a checklist there has to rewrite the text the field is holding, not a row the store would hand back a moment later. Going through the store would overwrite whatever was being typed. The BOARD has no such problem — nothing there is holding a half-typed body — so the card's toggle goes through the store as usual. `toggle_at` addresses an item by LINE and COLUMN rather than a text offset, because the two sides do not count the same way: Compose measures in UTF-16 units and Rust in bytes, so the same number means different places in a note with an emoji in it. A line number is identical in every encoding, and so is a column inside the marker, which is ASCII at the start of its line. In the editor: the toolbar button inserts `- [ ] ` at the caret — the only toolbar action needing no saved note, so it works on an empty compose box the moment it opens — and Enter continues the list, or ends it on an empty item. Continuation is recognised by SHAPE inside onValueChange (exactly one more character, and it is a newline) rather than by a key event, so a paste or an autocorrect falls through untouched. EditorChecklist.kt and the four item actions are gone (rule 22). Adding, renaming, ticking or deleting an item is editing text now, and the editor already does that — through SaveText, with the same autosave and the same revision window as any other edit. KNOWN GAP, not an oversight: tapping a checkbox inside the EDITOR does nothing yet. Material3's TextField does not expose onTextLayout, so mapping a tap to a character offset means either moving the body to BasicTextField or intercepting pointer events ahead of the field — both real changes to the surface this operator uses most, and neither verifiable without a device. `checklist_toggle_at` lands here, tested, so that task is pure UI. Ticking from the board works today. |
||
|
|
8257e1035c |
android: put the way out of a note back within reach
Moving the toolbar to the top took the back arrow with it, and left the only exit from a full-screen editor in the top-left corner — the furthest point on the display from a right-handed thumb, reached over the whole note to get to. Reported on the first device pass, and correctly. So the footer carries a Done as well as the timestamp. With the keyboard up it sits directly above the thumb, which is where a hand already is for every other part of writing a note. The top-left arrow stays. Two affordances for one action is usually clutter, but this is the case that earns it: the arrow is what habit, the system back gesture and TalkBack all expect of a full-screen surface, and removing it would strand the reflex to strike a duplicate costing one icon slot. A word rather than a checkmark, on the same argument the overflow menu makes: a tick in a notes app is a checklist item to anyone who has used one, and "Done" cannot be misread, including aloud. EditorSavedLine is now EditorFooter, since it is no longer only a line. |
||
|
|
9ea2a2f9b6 |
android: the toolbar moves to the top, and the note says when it saved
The capture sheet's drag handle cost a strip of screen and did nothing a back gesture does not already do. The toolbar takes that strip instead, which is where it belonged once one surface served both writing and editing: the keyboard owns the bottom of the display for most of a note's life, so a bar down there spends its time riding on the IME. The bottom is now the answer to "did that land". There is no save button — writes are continuous, so a button offering to do what already happened would be a lie with a tap attached — but that left nothing on screen saying the work was safe. Not saved yet → Saving… → Edited just now is the whole lifecycle in the corner, and someone who watches it once never has to be told that closing a note keeps it. DateUtils formats the relative part, so plurals and "yesterday" are not this app's problem to solve twice. Shape: the screen keeps the sheet's rounded top and its gap below the status bar, so opening a note still reads as something rising over the board. Full height rather than a real ModalBottomSheet — a sheet spends a writing session negotiating with the IME for the bottom half of the display, and the swipe-down it buys is a gesture back already does. Both content colours on the card are spelled out. Surface and Scaffold each default theirs to contentColorFor(their container), which returns Unspecified for anything that is not a colour-scheme role; a note tint never is. That is the same default that made the last toolbar invisible in dark mode, latent in two more places. Also: the running LinearProgressIndicator is gone, since the corner line now says the same thing without moving the text; and the SaveText comment in BoardViewModel still claimed saves happened on close. |
||
|
|
ce6a1093a3 |
android: writing a note and editing one are the same surface
The + button raised a capture sheet with a single text field. The editor is
a screen with a toolbar. So a note being WRITTEN could not be given a
colour, a reminder or a checklist — those live on the toolbar, and the sheet
had none. To make a checklist you wrote a note, saved it, reopened it, and
found a control you had never seen.
ComposeSheet is deleted. + opens the editor on an unsaved draft.
A draft is a real Note carrying DRAFT_ID (the empty string) rather than a
null. Note has eighteen fields and the editor reads eight of them; threading
nullability through all of that to express "not saved yet" would spread the
concept across a screen that should not have to know about it. A real id is
a uuid, so the sentinel cannot collide.
It becomes a row on its first save, and the first save is now an autosave:
the editor writes a second after typing stops. That is affordable because
|
||
|
|
95aa10c2c3 |
Remove the title field — a note is named by its first line
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Failing after 7s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 7s
CI & Build / Python tests (push) Successful in 11s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Failing after 31s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 6m45s
Operator (note 2897): "notes shouldn't have a title field." The concept of a NAME stays — search results, export filenames and the command palette all need one — but nothing is typed into it any more. `display_title` is now the first non-empty line of the body, falling back to the first checklist item. That fallback is what step 2 bought, and the reason this could not go first: a checklist had no body to be named from, so the title was its only name. Now every note has a body, and a note that is only a checklist is named by its first item. Gone everywhere: the column and note_revisions.title (0026), the field on the core's Note/NoteCreateInput/NoteRevision and its SQLite columns (user_version 7), `normalize_title`, the wire field, the FFI record and `NoteEdit::Title` / `ClearTitle`, the web editor's "Title (optional)" input and the card's <h3>, and the Android title field in both the compose sheet and the editor. **The search vector had to be rebuilt, not just left alone.** `notes.search_vector` is a STORED GENERATED column whose expression names `title` — Postgres refuses to drop a column another generated column depends on. It is dropped and recreated over `display_title` at weight A, which keeps the original intent: a note's NAME ranks above the rest of its body. **An imported title becomes the note's first body line.** Keep notes carry one, and so does any ThoughtSync export taken before this. Dropping it would silently lose text someone wrote; folding it in puts it exactly where a name now lives, so the note arrives named as it was. Skipped when the body already opens with that line, so re-importing an export this code produced doesn't stack duplicates. Two smaller things fell out. The Android editor loses its bold first field — one weight throughout, because the first line is the note's name but not a different KIND of text, which is most of step 4 arriving early. And `ClearTitle`'s justification comment moved to `ClearRemindAt`, which is now the surviving example of why NoteEdit is a list rather than a struct of options. Protocol note corrected to say what actually shipped: v2 is "no kind, no title", one bump for the pair. Verified with the local Rust gate this time, not by CI: fmt, clippy and 116 tests all green before pushing. It caught four things — orphaned serde attributes where fields were removed, a `wire::Preview.title` I deleted by mistake (a link preview still has one), nine retention fixtures inserting a dropped column, and four rustfmt diffs. |
||
|
|
c46a4a7709 |
A checklist is something a note has, not something a note is
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 9s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Failing after 8s
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / Python tests (push) Successful in 13s
Android / Kotlin + Rust (APK) (push) Failing after 1m43s
`kind` was never a type. A plain TEXT column with no enum and no CHECK behind
it, compared against a hardcoded ("text", "list") tuple in six places;
`note_items` was always an ordinary child table keyed by note_id; serialization
already emitted `items` whatever the kind; and the Android editor already
toggled between the two losslessly, saying so in a comment. The storage has
modelled "a body plus optional checkable items" the whole time. This deletes the
gates that forbade it.
Every surface: the create/PATCH gates, the ?kind= filter and its saved-filter
facet, the three import/export branches, the column (alembic 0025); the core's
`kind` field, its SQLite column (user_version 6), the sync wire, push and pull;
the FFI records and `NoteEdit::Kind`; and on Android `NoteKind.kt`, `DraftKind`,
the compose sheet's Note/List switch, and the branches in the card, the editor
and the chrome.
The editor's note⇄list toggle becomes "Add a checklist" — on both the web and
Android. It is not a conversion any more: nothing moves, nothing is swapped, the
body stays exactly where it is and the note gains somewhere to put items. The
card renders both, in order.
Two things that fell out of the merge rather than being aimed at:
- The Keep importer was DISCARDING `textContent` whenever a note also had
`listContent`, because the target could only hold one. Both survive now, and
the test says so.
- Markdown export wrote the body OR the checklist. It writes both.
Protocol goes to v2, floor included: dropping a field a v1 client sends and
expects back is breaking. `title` leaves in step 3 and lands in the same
generation, so it needs no further bump. This is the change that will make the
0.1.227 build on the operator's phone refuse to sync — the in-app updater is
independent of the handshake and remains the recovery path.
The V1 SQLite schema deliberately KEEPS the kind column. V1 is the historical
schema and every later block alters it, so removing it there would make a fresh
database run V1 without the column and then v6's DROP COLUMN against a column
that never existed — "no such column: kind" on every new install.
|
||
|
|
81695fa0c8 |
android: update the app from the server it syncs with (2727, M12 step 7)
Closes M12. The phone can now notice that its server has a newer build and install it, instead of the operator copying an APK to a device by hand. **A PackageInstaller session, not an install intent.** The obvious route — ACTION_VIEW on the APK — is exactly what on-device install heuristics are tuned against, and it is what produced the "bypassing Android security" warning on Minstrel (Scribe note 2437). It also never tells the OS that this app is the legitimate updater of its own package, and it returns nothing: a failed install is indistinguishable from someone dismissing the dialog. The session says who is doing what, and on Android 12+ declares no user action required — which, with UPDATE_PACKAGES_WITHOUT_USER_ACTION, removes the confirmation entirely on the UPDATE path. Only there: Android will not let an app quietly put a NEW package on a device, which is right. It also only applies when the new build carries the same signing key as the installed one, which is why signing had to land first. Two things from that research deliberately NOT done: `setRequestUpdateOwnership` was chased and turned out to be a red herring, and REQUEST_INSTALL_PACKAGES is not the differentiator either — Mihon declares it too. The mechanism was the whole difference. **The outcome comes back.** `commit` takes an IntentSender and the result lands at `UpdateReceiver`, so a failure can be shown rather than guessed at, and STATUS_PENDING_USER_ACTION is handled — that is the ordinary path below API 31 and still possible above it, since the OS is entitled to ask anyway. Someone declining is reported as no error at all: calling a deliberate choice a failure is how an app sounds broken when it is not. **The network work stays in Rust.** Two FFI additions — `clientUpdate` and `downloadClientUpdate` — because the device token lives in the core, and pulling it into Kotlin to make an HTTP call would spread the one secret this app holds across two languages for nothing. The core also owns the comparison, so the rule "version CODE decides, never the name" lives in the layer that has to get it right for every surface. The download is streamed to disk, not buffered: 55 MiB in memory on a phone is how an update gets killed halfway through. It lands in `update.apk.part` and is renamed only once size and sha256 both match, so an interrupted download can never be mistaken for a finished one. The digest is not a trust anchor — the signature is, and Android checks it — but it catches a truncated transfer before the installer is bothered with it. The advertised path is joined to the base URL this device is LINKED to rather than followed as given, so a server cannot point the download at a host nobody agreed to. **Updates are linked-only, and it says so.** An unlinked install has no update path, so it gets one sentence explaining where updates come from rather than a Check button that silently finds nothing — the same lesson as the desktop's unlink copy (issue 2110). And the "install unknown apps" grant is asked for BEFORE downloading, so nobody spends 55 MiB to be told no. Every Android API here was read out of `android-36/android.jar` with javap first, and the two new FFI methods out of freshly generated bindings, rather than recalled: `suspend fun clientUpdate(installedVersionCode: Long): ClientUpdate?` and `downloadClientUpdate(destPath: String)`. Also fixes `check-symbols.py`, which reported four false positives on `UpdateOutcome.Result` — its object-member index collected functions and properties but not nested TYPES, and a data class inside an object is an ordinary member. |
||
|
|
785ebdba59 |
android: reminders that actually reach you (M12 step 6)
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. |
||
|
|
39170b715c |
android: leaving the composer keeps the note, and the board loses its dead space
Two things the operator hit on a real device. **Capture threw work away.** Every exit from the compose sheet except Save discarded it — tapping the board behind, swiping down, back, backgrounding the app, and rotating the phone. That is the wrong default anywhere and the worst possible one here: a sheet that loses a typed thought because you touched outside it teaches people not to trust the app with a thought, and capture is the one place this product cannot afford that. Now every way out saves, which is the shape the editor already settled on. The difference is that capture also has to be abandonable — tapping + and changing your mind is normal — so Discard exists and is the only path that loses anything. It is called Discard rather than Cancel because "cancel" means "undo what I am doing", which is precisely what leaving no longer does; the word would have described the one button it is not attached to. An empty draft needs neither and is simply dropped: a blank note nobody asked for is worse than none. Backgrounding persists but does NOT close an empty sheet. Someone who tapped + and got distracted should find the composer where they left it. Rotation was losing it twice over: the draft was `remember`, and so was the flag saying the sheet is open. Both are `rememberSaveable` now, along with the sync screen's — the editor never had the bug because the note it sits on lives in a view model, and these were the only screen state that did not. `FlushOnStop` moves out of NoteEditorScreen into its own file; the editor and the capture sheet want the identical thing for the identical reason, and it was about to be copied. **The board had a centimetre of nothing above the search field.** `SearchBar` applied `statusBarsPadding()` inside a `Scaffold` whose content padding already carries the system-bar insets — `ScaffoldDefaults.contentWindowInsets` is `systemBarsForVisualComponents`, checked in the material3 sources rather than assumed. So the status bar height was reserved twice on the first screen anyone sees. Insets get consumed once, by whichever component owns the edge. |
||
|
|
452c66c8ef |
android: sync without being asked (M12 step 6)
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.
|
||
|
|
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>
|
||
|
|
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>
|