RecentTags.kt declared a second epochMillis(String?): Long beside Time.kt's
epochMillis(String): Long?. A string literal resolved to the existing one, so
the test asserted 0 against its null.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A long tag list pushed the drawer's Archive, Trash and Sync off screen on
Android, where Tags still sat above them (#5915 only reordered the web rail).
Tags now come last on Android too, and both clients list the five tags most
recently used or viewed, by name, with "All tags (N)" opening the rest in
place.
Used: the newest untrashed note carrying the tag. GET /api/labels and the
core's list_labels return it as last_used, and each client also folds in the
notes it holds, so a tag applied a moment ago counts before the next listing.
Viewed: opening the tag's lens, remembered on the device (per user on the web).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The label picker and the editor's tag row each wrote the same Text: the
label_from_tag string, labelSmall, onSurfaceVariant, 8dp start padding.
Chips.kt's FromTextMark() is now both.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The web compared or keyed tag names with name.toLowerCase() at five sites:
the labels store's merge check, LabelPicker's create check, NoteCard's
colour map, and colors.ts twice. Android had three, plus TagsScreen's rename
dialog using equals(ignoreCase = true). Each now calls foldTag(name), which
says what the core's derive::fold and the server's labeling.named say.
Android's rename dialog now predicts a merge the same way the core decides
one. Per-character ignoreCase and full lowercasing disagree on letters like
İ, whose lowercase is two characters. LabelPicker's search filter keeps
its own lowercasing: that is search, not identity.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Web: one counted(n, one, other) helper. The sync screen's three "(s)"
messages (attachments that didn't download, changes refused, files that
didn't upload) now say "1 file" / "2 files". The six hand-written ternaries
(SyncView x2, ImportNotes, GroupList, ShareDialog, LabelsModal) use it too.
Android: tags_count was two strings chosen in code, and is now <plurals>.
The delete confirmation read "It is on 1 notes … removed from all of them"
and now has a one form of its own.
Operator decision on #5371, 2026-10-08.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fb95c9b moved the foreground latch into ui/ForegroundTransitions.kt, but
MainActivity lives one package up and imports what it uses from ui by name;
run 8835's compileDebugKotlin stopped on the two unresolved calls.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
enable and pushSoon each set the CONNECTED constraint and the 30-second
exponential backoff; B.online() on WorkRequest.Builder sets both.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Destination carried its English title as a field, and NoteTint its colour
name; every other word on screen is a string resource. Destination.title()
now reads nav_notes/reminders/archive/trash (a tag's destination keeps its
name), and NoteTint.label is a @StringRes. Error fallbacks raised in view
models and the install receiver stay as constants: they are made outside
composition, with no Context to read a resource from.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
FlushOnStop was ForegroundTransitions with only its ON_STOP half, so it goes
and the editor calls ForegroundTransitions(onBackground = flush); both halves
now default to nothing, dropping three onBackground = {}. AutomaticUpdate and
AutomaticSync each kept a wanted flag set on the way in and consumed in a
LaunchedEffect once ready; that latch is OnEachForeground(ready, onBackground,
act), with each caller's ready and its reason kept where they were.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
sync/mod.rs listed 5 of its 9 modules; migrate's doc sat above the v9 SQL;
client.rs had items after its test module; the ffi's sync_now doc had fused
into client_update's; complete_reminder (ffi and EditorAction) still said
recurrence advancement was to come, though the core does it; NoteQuery.view
listed views the core never matched and claimed it validated. Test scratch
dirs drop the old ts-/iw- prefixes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- LogTag.kt: the four tags the app logs under. Eight files each declared
one of them as a string.
- SNOOZE_HOUR/SNOOZE_DAY sit beside EditorAction.SnoozeReminder; the
notification's snooze uses SNOOZE_HOUR instead of its own 60.
- Reminders.at reads through ui.epochMillis, the parse the card and the
overdue check already use.
Checked with ktlint and detekt in the CI image.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ErrorBanner.kt Banner(tintKey) { … } is the rounded, palette-tinted row
that ErrorBanner (red) and UpdateBanner (blue) each built. TintChip moves
to Chips.kt with CHIP_RADIUS, which keeps NoteCard.kt under detekt's
function count. Checked with ktlint and detekt in the CI image.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
detekt's SpreadOperator flagged trimEnd(*TRAILING_PUNCTUATION) from
1c4bf56. trimEnd { it in TRAILING_PUNCTUATION } trims the same characters
without copying the array. Checked with detekt in the CI image.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- BoardScreen TitleAndBody: the title plus quieter body that the empty
board and the store-unavailable screen each wrote.
- NoteCard TintChip: the one-line palette chip that the reminder and
shared-by chips each drew in full.
- TagsScreen Swatch: the colour dot that the tag row (tappable) and the
colour picker each built.
Kept: the label chips. Each uses its tag's ink and a bigger shape, not
the card's chip pair. Checked with ktlint in the CI image.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
TrashedNoteItems, PinItem and ArchiveItem (EditorChrome.kt) are the rows
that the editor's overflow and the board's long-press menu each built: a
trashed note's restore and delete-forever, and the pin and archive
toggles. Each menu keeps its own order and its owner-only rows. Checked
with ktlint in the CI image.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Note.manualLabelIds (NoteAccess.kt): the tags attached by hand, which
the label picker, the chip's remove button and the board's create-label
each filtered out of note.labels.
- sharerName(note): who shared a note, or "Someone", which the shared-by
line and the card's chip each spelled out.
Checked with ktlint in the CI image.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
EditorPickers.kt Sheet(title, onDismiss, modifier, verticalArrangement):
a ModalBottomSheet holding a full-width column with the screen margin,
clear of the navigation bar, under SheetTitle. The filter, tag picker,
reminder and share sheets each built that. A site's extra (a scroll, ime
padding, bottom space) is now applied after the navigation-bar inset
rather than before it. The total inset is the same either way.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Panel.kt BackButton(onClick, label): the IconButton + ArrowBack that the
tags, sync and editor top bars each built. Each keeps its own spoken
label. Checked with ktlint in the CI image.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Panel.kt Hint(text, modifier): bodySmall in onSurfaceVariant. That is the
secondary line that the sync pairing form, the sync screen, the update card
and the share sheet each wrote as a full Text(...) at 14 sites. Sites that
add more than a modifier (the link preview's two-line clamp) stay as they
are. So does ShareSheet's own Muted, which is bodyMedium.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Panel.kt ConfirmDialog (moved from TagsScreen, where it was private):
the delete-forever dialog, the sync disconnect and the tag dialogs all
ask through it now.
- strings.xml: editor_cancel and tags_cancel were both "Cancel"; they are
one cancel string.
- NoteAccess carries the core's permission string (wire). The access
lookup, the share sheet's choices and the board's draft read it from
there instead of writing "owner"/"edit"/"view" again.
- hashtag(name): the #-prefixed tag name that TagsScreen, the card and the
editor chips each wrote.
Formatted and checked with ktlint in the CI image.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- ui/Failure.kt: Throwable.shownAs(fallback) and FALLBACK_ERROR. The
core's own message, else a fallback, which Share, Tags and Sync each
defined privately and Board and Update wrote inline.
- The five view-model factories use lifecycle's viewModelFactory { initializer }
instead of an unchecked-cast object each.
- BoardViewModel.beginSitting: the editingSession bump the four editor
openings each spelled out.
The fallback line stays an English constant, as it was: view models hold no
Context to read strings.xml. Formatted with ktlint in the CI image.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
16ab4a1's APK lane stopped at ktlint (chain-method-continuation) on the
one-line chain 1c4bf56 wrote. Reformatted with ktlint --format in the CI
image; no code change.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The server unfurls what detect_urls finds, trailing .,;:!? trimmed, and files the
preview under that. The web and Android cards looked a lone link's preview up
under body.trim(), punctuation included, so a note reading
"https://example.com/a." never showed its card.
- grammar.json gains a urls section: what the server finds in a body, and the
link a lone-link note is filed under.
- The web's rule moves out of NoteCard into notes/links.ts loneUrl(); Android's
LinkPreviewRow gets the same loneUrl(); both trim like the server.
- The server and web suites run the cases; Android's JVM test pins them by hand,
as it does the tint.
Fixes#5399. DRY pass #2, batch 3 (#5372).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Formatting only. portable::instant (from F5), and the ffi's link and unlink
(F2/F3), were laid out by hand without a toolchain; rustfmt splits each chain.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
"inkwell.db" and "blobs" were spelled out in the ffi and the desktop (whose copy
of DB_FILE sat in the crossover shim). The layout is the core's, the same on
every client, so the names are now core constants and both clients read them.
DRY pass #2, batch 1, F4 (#5372).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The desktop's sync_unlink and the ffi's unlink were both written out in full: try
the revoke, clear the link either way, and log the outcome. link::unlink(db, held)
now does that. Each client reads its link with state::credentials (with its seal)
before the await and passes it in.
DRY pass #2, batch 1, F3 (#5372).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The desktop's sync_link and the ffi's link_with_password/link_with_token were
the same steps written out twice: probe, refuse an incompatible server before
any credential is sent, log in or verify a pasted token, keep the link, and adopt
the server's trash retention. link::authenticate(url, Credential) does the
network half and link::store(conn, ..., seal) keeps it, sealed when the client
has a seal. Each client now only reads its input and picks its seal.
The desktop checks for a missing email/password before probing rather than after.
Same error, sooner.
DRY pass #2, batch 1, F2 (#5372).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Five places read the server address and token straight from sync_state:
sharing, autosync, sync_unlink, update's download token, and the ffi. Reading it
raw is how Android came to send its sealed token to the share routes (#5381).
state::credentials(conn, seal) now holds that read. With a seal it opens the token
(open_token), and without one (the desktop keeps it plain) it returns it as stored.
Every site calls it.
DRY pass #2, batch 1, F1 (#5372).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Android has stored its device token sealed ("sealed:…") since 8592b83, and
core's sharing calls read the token from the store themselves. The ffi opened
it in credentials() and then threw the result away, so every Share-sheet
request went out as `Bearer sealed:…` and the server refused it.
The sharing functions now take the server address and token from the caller.
The ffi passes what credentials() opened; the desktop, which stores its token
plain, reads it through sharing::stored_link. A new ffi test serves one request
on a loopback port and checks the bearer token that arrives (#5381).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Family idea #5105, practice 12, as the operator chose on 2026-10-08: the token
is encrypted, and Android backup stays on.
The core:
- Adds a TokenSeal trait in sync/state.rs, with set_sealed_link and
open_token.
- A sealed token is stored as "sealed:<value>".
- A plain token, stored before this change or while sealing failed, is sealed
in place on its next read.
- A sealed token that won't open is dropped, and the server address and cursor
are kept, so the app reads as unlinked and asks to sign in again. That is
what happens after Android restores the app onto another phone.
- The desktop passes no seal and keeps storing the token as before.
The FFI:
- Exports TokenSeal as a uniffi foreign trait (seal_token / open_token, null
rather than an exception).
- Requires it in Inkwell's constructor, so there is no moment a token could be
stored unsealed.
- Routes credentials(), unlink() and store_link() through it.
Kotlin:
- KeystoreTokenSeal is AES-GCM under an Android Keystore key, using the
SealedBox framing from Minstrel's KeystoreSessionVault (Scribe snippet #5025),
with no new dependency.
- SealedBoxTest checks the framing on the JVM.
allowBackup stays true, and the manifest says why. An unlinked phone's notes
exist only on the phone, and the backup is their one other copy. The backup
carries a token nothing can open.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Three of the idea's practices this project still owed (Scribe #5118):
Practice 3, CI fails on the wrong signer. The signing step printed the
certificate and went on. It now fails unless the APK has exactly one
signer and that signer is the release certificate (SHA-256 408a5835…,
pinned from run 8753). The steps that publish come after it in the same
job, so a wrongly signed build is never staged or published.
Practice 6, app downloads are throttled and carry a sha256 ETag.
- The download route counts per account and answers 429 with
Retry-After past the limit. The limit is a new Settings → Security
value, "App downloads per account per hour" (default 30), live like the
sign-in limits.
- The ETag is the sidecar's sha256, not Quart's mtime-and-path, so a
phone resuming a download across a redeploy is not told its partial
copy is stale. Quart's own ETag and conditional handling are off, and
the route runs the conditional pass after setting the ETag, so Range
and If-Range are judged against the content.
Practice 9, the update offer and debug builds.
- The install-permission notice re-reads the grant each time the app
comes back, as ReminderNotice does. Read once, it stayed up after
someone granted the permission in Settings and came back.
- A debuggable build says it can't update itself and checks for nothing.
Android would refuse the release-signed APK over a debug signature
anyway.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every APK so far carried a debug-profile libinkwell_ffi.so (opt-level 0),
because the workspace's release profile sets strip = true, which removes
the symbols uniffi's --library mode reads the interface from (run 4077).
The cargoNdk task now builds --release with two environment overrides, for
this build only:
- CARGO_PROFILE_RELEASE_STRIP=debuginfo keeps the symbol table. A release
build has no debug info, so this keeps symbols and nothing else.
- CARGO_PROFILE_RELEASE_PANIC=unwind keeps a core panic reaching Kotlin as
an exception, which the board shows as an error, not an app exit. Phones
have always had unwind, because debug unwinds, so this keeps what they
do.
Overrides rather than a profile of our own because cargo-ndk copies its -o
output from the release directory, and nothing says it handles another.
The desktop's binaries are unchanged. android.yml passes release on the
signed path; the unsigned debug path keeps debug.
Scribe #2810.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
As on the web, the search box is now one more facet on the board you are
looking at, rather than a separate unfiltered search. "These words, in
notes tagged grocery" works: on the main board the text is sent to the
core together with the Filters sheet's tags, attachment and shared
switches, and the core ANDs them in list_notes. Archive, Trash and a
tag's view take the text alone. A search typed on Reminders, which is not
a board view, moves to the main board, as the web does.
The Filters chip stays while you search; it was hidden before, on the
mistaken claim that the web hides its filters too. Drag-to-reorder stays
off during a search, since a filtered subset can't be renumbered against
notes it can't see.
store::search and the FFI's search_notes had no other callers, and are
removed. They also searched archived notes and ignored pinning, which the
board's query does not.
Scribe #2942.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Run 8731 failed checkAarMetadata: Coil 3.6.x requires compileSdk 37, and
it pulls in Compose 1.12, which requires AGP 9.1. This project is on
compileSdk 36 and AGP 9.0.1. Coil 3.5.0's AARs ask for 36, and its Compose
(JetBrains 1.11.1) is within this project's BOM (Compose 1.11.2).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The card draws the linked page's og:image in a strip down its left edge,
cropped to the card's height: 96dp full, 48dp compact, matching the web's
w-24 and w-12. The image is remote, so the phone fetches it from whatever
host the link points at, exactly as a browser does for the web card. The
operator chose that parity (Scribe #3307).
The image is Coil 3's AsyncImage, with OkHttp as its fetcher. Coil's disk
cache means a card scrolled past twice costs one download. When there is
no image, or it fails to load, nothing is drawn rather than an empty box,
and the card is the text card it was before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Run 8693's detekt failed both on CyclomaticComplexMethod (17 and 15 against
15) after the filters and drag-to-reorder landed.
- BoardState now carries `filterable` and `reorderable`, so the board's
rules for when the filter row shows and when a card can be carried live
with the state they read instead of as boolean chains in the screen.
- NoteCard's contents (tags, body, links, attachments, reminder, sharing)
move to their own CardContents composable, leaving NoteCard the gestures,
the frame and the menu.
No behaviour change.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Filters: a chip under the search bar opens a sheet with Has attachment, Shared
with me and tags (a note must carry all of them), applied as they are tapped,
with a count on the chip and a Clear beside it. Only the main board filters and
search spans everything, as on the web; opening another view starts it
unfiltered, a deleted tag drops out of the filters as it does from the lens, and
an empty filtered board says so.
Reorder: hold a card, then move it. The hold is the long press that opens the
card's menu, which closes as the card starts to move; lifting without moving
leaves the menu as before, and moving before the hold is a scroll. Cards trade
places live and the drop writes the order through the core's reorder, newly
exposed over the ffi as reorder_notes. Only on the plain main board, and only on
the same side of the pinned line, since the store sorts pinned first.
#5313.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The board was Fixed(2) at every width, so a tablet showed two wide columns
where the web shows three or four (#5311). The column count now follows the
web's NoteGrid breakpoints on the window's width: three from 1024dp, four
from 1280dp. A phone keeps two.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Step 18 of the audit follow-through (#5180), on the operator's decisions.
Inkwell is for capture and recall (note 2897), and these three duplicated a
surface that does the job already:
- Saved views. They lived only on the web; the desktop kept its own set that
never synced, and Android had none. Tags in the drawer already give
one-click recall. Gone from the server (routes, model, migration 0038 drops
the table), the core (store functions, schema v13 drops its table), the
desktop commands, the web adapters, the drawer's Views list and the
"Save view" link.
- The "Has reminder" facet. The Reminders page lists them, sorted by due.
- The FilterBar's "Created" range. Timeline is the date lens and keeps the
created_after/created_before query it builds from local days, which also
retires the UTC/local-day disagreement between the two (B3).
An old link that still carries the removed keys opens the plain board; a
web test pins that.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
From the audit (#5179). Ten comment blocks narrated how the code got here:
milestone numbers, earlier values, the operator's verdict on an old design.
Each now says what the code does and why, and the history stays in git,
Scribe and docs/sync.md. The protocol-version comment in sync.py points at
docs/sync.md's policy section, which already lists every bump.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
From the audit (#5178). Each was unreachable from every client:
- Checklist add-item and delete-item: REST POST /items and DELETE /items/<id>,
the Tauri commands, the store, rest and local adapters, the core's
add_item/delete_item, set_item_text and remove_item, and the FFI exports.
Adding, rewording and removing an item are body edits in every editor. The
checked toggle stays, and its rewriter is simpler without the drop branch.
- Manual unfurl: POST /unfurl and its adapters. Previews arrive in the
background after a save (unfurl_queue).
- The /api/config `android_client` key, android_release() and the
APK_NAME/MANIFEST_NAME aliases. Phones poll /api/client/android.
- users.email_verified and users.avatar_path (migration 0037). Nothing set
the first or read the second; the SMTP reset never checked verification.
- derive::extract_tags (only tests used it; the shared fixture now runs
through extract_tag_spans), the unused check and link icons, and the
unused editor_add_item string.
- The blob scheme is renamed tsblob -> inkblob. URLs are built as notes are
read, so nothing stored carries the old one.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Share sheet lists groups after people, shows a group share as its name and
how many are in it, and shares through the FFI's ShareTarget.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The card's long-press menu and the editor's overflow now open on shared notes
with Pin and Archive; Labels, Share and Move to trash stay the owner's.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The editor's menu has Share…, which opens a sheet of who the note is shared
with and lets you add someone at view or edit, change it, or stop sharing;
unlinked, it says sharing needs a server. A note shared to view opens
read-only; at edit only its text can change. Cards say who shared a note
("From Robin") or that yours is shared, and a view-only note's boxes don't
tick.
Also: two core store tests used unwrap_err on a Result<Note>, which needs
Note: Debug; they use err().expect() now.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>