test #21

Merged
brian merged 2 commits from test into main 2026-08-27 06:26:57 +00:00
Owner
No description provided.
MainActivity has never declared uiMode in configChanges, and the comment above
it said why: a dark-mode switch is rare. That was true when it was written. It
stopped being true in #19, which gave the tablet a theme setting whose default
is to follow the system — so a tablet on Samsung's scheduled dark mode now
crosses that boundary every sunset and every sunrise, unprompted, and each
crossing destroys and recreates the activity under whoever is writing.

That is the same failure the rotation fix went after, with the same cost: the
ink surfaces being written on are torn down, and the debounced save of a typed
document is cancelled with the composition's scope. The Done button's 400ms
wait exists precisely because losing the last sentence that way is not
theoretical.

Declaring uiMode is also what keeps the theme working rather than freezing it.
isSystemInDarkTheme() reads LocalConfiguration, and Compose refreshes that from
onConfigurationChanged — a callback the activity only receives *because* it
declares the change. Handling it here means the paper and the chrome both
repaint on the next recomposition instead of via a restart.

The rest stay absent deliberately, and the route still survives them through
RouteSaver.

ConfigChangesTest pins the list, because nothing did. RouteSaverTest and the
other savers cover what happens *after* a restart, which is the belt to this
brace — they would all have stayed green through this bug, and did for six
days. A configuration change left out of the manifest fails silently by
construction: nothing stops building, no test goes red, and the app simply
restarts under someone mid-sentence. The test reads the manifest and asserts
what is declared, so removing one is a red build rather than a bug report.

Not yet on the tablet: android-release.yml is main-only, by design.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fix: sync a notebook by what changed, not by fetching all of it
All checks were successful
Build and Deploy / test (push) Successful in 26s
Build and Deploy / build (push) Successful in 1m7s
Build and Deploy / deploy-main (push) Has been skipped
Build and Deploy / deploy (push) Successful in 27s
0111ea0c2a
The tablet had been killing itself with an OutOfMemoryError, in the foreground,
while being written in. The pull asked each notebook for every page it had,
content and all, and a page's content is every stroke on it. Retrofit parses a
response whole before any of it reaches the database, and kotlinx inflates the
content field into a JsonObject tree on the way — a boxed JsonPrimitive per
coordinate — which SyncEngine then immediately re-encoded straight back to a
string for Room. Text in, tree built, text out, the tree pure waste.

So peak memory during a sync tracked the size of the journal. It crossed a
256MB heap somewhere around the middle of August and the app started dying
several times a night. Nothing about it was a regression: exit-info has the
same illness on the 17th as a LOW_MEMORY kill at 1.1GB resident, four days
before the release that was first blamed for it. It was always going to happen
eventually, it gets worse the more you write, and it cannot be fixed by using
the app less.

The pull is now driven by an index. GET /notebooks/{id}/page-index returns
every page without its content, so the response tracks how many pages there are
rather than how much has been written on them, and the client compares
updated_at against what it holds to find the stale ones. Those are fetched by
explicit id through GET /pages?ids=, at most PAGE_FETCH_CHUNK of them per call,
which is what bounds a response — and therefore the peak — independently of how
big the journal gets. The server refuses a larger ask rather than trusting the
client to keep to it.

Asking by id rather than for everything-since-a-timestamp is deliberate. There
is no ordering for the two ends to agree on, no tie to break when two pages
share a timestamp, and no stored cursor that can go stale or skip a record after
a pass that half-failed. The client's own copy of a page is the cursor, which
means a page missed for any reason is simply picked up next time.

Position, title, day and paper all travel in the index, so a page that was
renamed or re-filed — the copy/move case from #19 — is applied without fetching
anything. That is also a fix: it used to sit unapplied until something happened
to rewrite the ink.

The old unfiltered endpoint stays exactly as it was. The web client still calls
it, and so does the build currently on the tablet, so the server can deploy
ahead of the app without either of them noticing.

Two things found on the way, both in the code being replaced.

A failed page listing deleted every clean page in that notebook. The comment
said it skipped the prune to avoid precisely that; nothing did. The ids simply
never reached the set prune consults, and absent from a list we never read is
indistinguishable there from deleted on the server. Only a notebook whose index
we actually read may now have its pages pruned. The test for it fails against
the old prune — checked, not assumed.

And that failure went to Log.w and nowhere else, which is the thing CLAUDE.md
says not to do and for the reason it gives: half a pull leaves no trace at all,
the screen goes on showing yesterday's pages, and nothing says it is behind.
Read failures now collect like push failures do and reach the badge.

Ten new tests in SyncEngineTest, all by running a real sync against fakes: that
a steady state fetches no content, that a changed page and an unseen page both
do, that a large notebook comes in bounded chunks, that a move costs no fetch,
that a failed index neither prunes nor stays quiet, and that a failed content
fetch keeps what we have. integration_page_sync.py drives both new endpoints
against a real Postgres, including that neither leaks another account's pages.

Backend suite 54 pass, frontend 5 pass, Android 323 pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
brian merged commit 628b259f45 into main 2026-08-27 06:26:57 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
brian/Journal!21
No description provided.