Files
Connor JohnstoneandClaude Opus 5 2bf910757a Give the laptop client the base tracking the phone already has
Without a record of the last agreement, the client cannot tell "I have
unsaved edits" from "I am simply behind", and reported the second as the
first every time the phone pushed, leaving a .remote- file in the notes
directory each time. It now keeps the pristine text and its ETag under
XDG_STATE_HOME and merges three ways, matching the phone.

Two bugs found while testing that, both of which manufacture conflicts
rather than lose data, and both of which were invisible until a real edit
happened on each device at once.

diff3 was the wrong merge tool. It reports a conflict even when both sides
make the identical change, and ticking the same box on both devices is an
ordinary thing to do. git merge-file handles that cleanly and agrees with
the Android merge about what counts as a genuine conflict.

The merge result was not being recorded as the new base, so one sync later
the base was a version behind and an already merged edit looked like a
fresh change from both sides. Every point where the two are known to agree
now records that agreement.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PU5ZFfQFtDTFqqhvMTWdGH
2026-09-09 20:50:37 -04:00

16 KiB

Findings

Empirical results that settle open questions in PLAN.md. Each entry records how it was measured, so it can be re-checked when something changes.

SFTPGo WebDAV does not honor If-Match on PUT

PLAN.md section 7 flagged this as "to verify in phase 1". Verified 2026-09-09 against the live endpoint.

PUT (create)                        -> 201
HEAD                                -> etag "18d3c65559dd89a43"
PUT with correct If-Match           -> 201
PUT with If-Match "deadbeef0000..." -> 201   <- should have been 412
GET                                 -> body from the bogus-If-Match write

The precondition is ignored, not evaluated. The client must use HEAD-compare-then-PUT, which is the fallback PLAN.md already anticipates. The remaining race window is milliseconds wide for a single user with one phone.

ETag is exactly hex(mtime_nanos) + hex(size)

Not a hash. Measured by comparing the WebDAV ETag against stat over ssh on the same file:

HEAD etag decoded   1788991618.521424162
disk mtime          1788991618.521424162   identical to the nanosecond

Two consequences:

  • Rewriting identical content still produces a new ETag. Content equality has to be checked by comparing bytes, never by comparing ETags.
  • An ETag says only "the file was written at time T and is N bytes". That is sufficient for change detection, which is all the sync design asks of it.

The ETag returned by PUT is never the file's actual ETag

Reproduced 3/3 before the clock fix, and 3/3 after it. Do not cache the PUT response ETag as the new base. HEAD after PUT to learn the authoritative value, or the next sync will diagnose a phantom remote change on every save.

SFTPGo computes the response ETag roughly a millisecond before the file's final mtime settles, and ETag is derived from mtime, so the two can never agree.

after the clock fix:
trial 1: PUT="18d3c711836b5e748"  HEAD="18d3c7118361820b8"   gap 0.646 ms
trial 2: PUT="18d3c711b9297f978"  HEAD="18d3c711b91648318"   gap 1.259 ms
trial 3: PUT="18d3c711cb79bac48"  HEAD="18d3c711cb62efba8"   gap 1.494 ms

Worth recording how this was nearly misdiagnosed. Before the NAS clock was fixed the same gap measured about 113 seconds, which looked like a serious protocol defect. It was the same one-millisecond write-path race the whole time, with a broken clock amplifying it by five orders of magnitude. Fixing the clock shrank the symptom by 100000x without changing the underlying cause, and without changing what the client has to do about it.

The NAS clock was ~124 seconds fast, now fixed

/home/connor/docs on mainframe is nas.rcjohnstone.com:/docs over NFSv4. NFS stamps mtimes with the server's clock, so every file written to that tree, by any means, was landing with an mtime about two minutes in the future.

Isolated to the filesystem, with SFTPGo entirely out of the path:

$ ssh mainframe
now:             1788991551.498123146
touch .probe-ssh.md
ssh-touch mtime: 1788991665.036420629    <- +113.5s, written locally over ssh
fs type:         nfs

The offset was measured at a stable 124.125s over a 193s window. It was not drifting at a measurable rate; the apparent growth between two early spot checks was inside the ssh round-trip noise of those checks.

nas is Arch, and had no time daemon at all: systemd-timesyncd was installed but disabled, and nothing else was present. Fixed by installing chrony and enabling it. The shipped Arch chrony.conf already carries the two settings this case needed, so it was left alone rather than rewritten:

  • makestep 1.0 3, which steps a large initial offset instead of spending 25 minutes slewing it away.
  • rtcsync, which disciplines the hardware clock. This mattered here, since the RTC was fast by the same amount and would otherwise have reintroduced the offset on the next boot.

systemd-timesyncd was masked so it cannot later race chronyd.

Sep 09 18:18:29 nas chronyd[1588]: System clock was stepped by -123.824615 seconds

The step matches the independently measured 124.125s offset to within the measurement noise, which is the confirmation that the two figures describe the same thing. Verified afterwards, end to end:

mainframe now : 1788992343.649697683
NFS file mtime: 1788992343.949308287
mtime minus clock: +0.300 s   (was +113.5, and the 0.3 is the gap
                               between `date` and `touch` in one command)

Once disciplined, chrony measured the hardware clock at 2.643 ppm, about 0.23 s/day, which is a perfectly good crystal. So this was never a drift problem. At that rate 124 seconds is roughly a year and a half of accumulation, which is simply how long the machine had been running with nothing to set its clock.

The NAS was serving NFS but running no containers and no databases, so stepping the clock backward by two minutes was safe. One harmless after-effect: files written to that tree before the fix carry mtimes up to ~124s in the future, and stay that way until they are next rewritten.

This project never depended on the fix. Rollover selects the previous note by filename sort, never by mtime, and the sync design compares ETags for inequality only, never against a clock. It is recorded because the drift silently affected everything else on that mount, rsync --update and incremental backups being the obvious victims.

EDITOR=x todo cannot work, because zshenv wins

~/.zshenv exports EDITOR unconditionally, and zsh sources zshenv on every invocation, non-interactive scripts included. So a #!/bin/zsh script sees the zshenv value no matter what the caller put in the environment:

$ env EDITOR=/bin/true zsh -c 'echo $EDITOR'
/home/connorjohnstone/.local/share/bob/nvim-bin/nvim

Harmless in daily use, since the exported value is the wanted editor anyway, but it makes the edit step untestable and would quietly ignore a deliberate override. Hence TODO_EDITOR, which matches the other TODO_* knobs.

Found the hard way: a test that set EDITOR=true opened a real nvim and hung.

The Android command line tools moved, and PLAN.md section 8 is out of date

cmdline-tools 23.0 deprecates sdkmanager:

WARNING: The SDK Manager CLI tool (sdkmanager) is deprecated. Android CLI will
be used instead.

The replacement is a single android binary in the same directory, and it is a straight improvement for this project. android sdk install needs no interactive licence acceptance, which was the fiddliest part of the old flow. android create empty-activity --minSdk 36 scaffolds a working Compose project, so no Gradle files are written by hand. android run and android install build and deploy without calling adb directly.

Section 8's account of what the SDK contains, and of the three SDK versions, is still accurate. Only the tool names changed.

Current at 2026-09-09: AGP 9.0.1, Kotlin 2.3.20, Compose BOM 2026.03.01, build-tools 37.0.0, platform-tools 37.0.1. API 37 exists, so the phone at Android 16 (API 36) is one major version behind.

EncryptedSharedPreferences is deprecated, and its failure mode is a crash loop

androidx.security:security-crypto reached 1.1.0 stable and deprecated its whole surface in the same breath. EncryptedSharedPreferences and MasterKey both warn on use.

Worth understanding rather than just silencing, because the reason bites here. The master key lives in the Android Keystore, and Keystore keys are device bound and cannot be exported. Android's automatic backup, on the other hand, happily backs up the encrypted preferences file. Restore that file onto a new device and the key it needs is not there, so EncryptedSharedPreferences.create throws while opening, from a code path that runs at launch. The app then crashes every time it starts until someone clears its data.

The generated manifest set allowBackup="true" and referenced no rules file at all, so this was the default behaviour. Credentials and drafts are now excluded from both cloud-backup and device-transfer. A password that has to be typed again on a new phone is the correct trade for an app that starts.

backup_rules.xml was deleted rather than filled in: fullBackupContent is ignored above API 30 and minSdk here is 36, so it could never have run.

What HyperOS lets adb do, and what it does not

The phone is a Xiaomi 17 (25113PN0EG, codename pudding, EEA ROM) on Android 16 / API 36, HyperOS OS3.0, security patch 2026-07-01. Several of PLAN.md section 8's assumptions about adb do not survive contact with it.

"Build number" does not exist. Developer options are unlocked by tapping the OS version entry, and they live under Settings > Additional settings, not under System.

adb install is refused outright. Both the streamed install and pm install from a shell fail identically:

Failure [INSTALL_FAILED_USER_RESTRICTED: Install canceled by user]

This is not an AOSP restriction. dumpsys user reports Effective restrictions: none, and install_non_market_apps is already 1. It is HyperOS's own "Install via USB" gate, which shows a screen reading "Rejected Todo installation request", and which validates against Xiaomi's servers rather than against any locally readable setting. Nothing in settings list records it; the only trace is a proprietary [system] installResult=0;0;-1.

Input injection is refused too, so a dialog cannot be dismissed from the host:

SecurityException: Injecting input events requires the caller to have the
INJECT_EVENTS permission

adb install does work for updates. The gate applies to installing an unknown package, not to replacing one already present. So the manual install is a one time cost per app, and adb install -r iterates freely afterwards:

$ adb install -r app-debug.apk
Performing Streamed Install
Success

Everything else works. adb push, adb shell, dumpsys, screencap and crucially logcat are all fine. So losing adb install costs only convenience, not the ability to debug, which is what actually matters for phases 4 and 5. The working loop is: build, adb push the APK to /sdcard/Download/, install it by hand once from the phone's file manager, and read logcat from the laptop as normal.

A caution learned immediately: screencap shows whatever is on the screen, including private messages and photos. Do not screenshot the device speculatively.

Wrapping BasicTextField in verticalScroll breaks cursor tracking

Typing past the bottom of the screen left the cursor off screen, on the device though not in any preview. The cause is modifier order rather than anything subtle about text fields:

Modifier.fillMaxSize().verticalScroll(rememberScrollState())   // wrong

verticalScroll measures its content with unbounded height, so the field lays out at the full height of the text and never scrolls internally. Its own bring-the-cursor-into-view logic then has nothing to scroll, and the outer scroll container knows nothing about where the cursor is. Removing the modifier and leaving fillMaxSize() gives the field a bounded height, and it handles its own scrolling and cursor tracking.

The accessibility tree is a quick way to tell the two apart without looking at the screen. Wrapped, the field appears as a bare android.view.View inside an android.widget.ScrollView. Unwrapped, it appears as an android.widget.EditText.

A second bug in the same few lines: Modifier.safeDrawingPadding() in the activity already includes the IME inset, and the screen then applied imePadding() on top, so the keyboard inset was counted twice.

Pointer coordinates cannot locate text in a scrolling BasicTextField

PLAN.md section 7 specifies tap to toggle as onTextLayout plus pointerInput plus getOffsetForPosition. That does not work, and the reason is a coordinate mismatch: getOffsetForPosition expects layout coordinates while pointerInput reports viewport coordinates. BasicTextField scrolls its content internally and does not expose the offset, so the two agree only while the document has not been scrolled.

Measured, on a 1561 character note. Every tap resolved to an offset of 253 or less, wherever on the screen it landed:

down offset=232 span=CheckboxSpan(start=230, end=233, checked=true)   works, near the top
down offset=249 span=null      tapped well below the fold
down offset=114 span=null      tapped well below the fold

The fix is to stop using pointer coordinates. The text field resolves taps correctly because it is the thing that knows its own scroll, so it places the cursor and that offset is read from onValueChange instead. The press is watched only to tell a cursor move caused by a tap from one caused by anything else, and is never consumed, so scrolling and ordinary cursor placement are untouched.

One consequence worth writing down. After a toggle the cursor is parked past the box rather than left inside it. Left inside, a second tap on the same box resolves to the offset the cursor already holds, and a selection that does not change reports nothing, so the box could not be unticked by tapping it twice.

Two earlier attempts failed for a different reason each, both about consumption:

  • Leaving the press unconsumed let BasicTextField consume it, and waitForUpOrCancellation returns null for a consumed pointer, so the release never arrived.
  • Consuming the press in PointerEventPass.Initial and then waiting in the default Main pass fails too, because the same event reaches Main already marked consumed, which again reads as a cancellation.

A process note that cost a round trip. After the first diagnostic showed correct offsets, this coordinate problem was written off as disproved. It was not: every tap in that sample happened to be in the unscrolled top of the document, where the two coordinate systems coincide. The evidence was consistent with both explanations, and only looked decisive.

diff3 calls an identical change on both sides a conflict

The laptop client needed a three way merge, and diff3 -m mine base theirs is the obvious Unix answer. It is the wrong one here.

base:   - [ ] a          mine:   - [x] a          theirs: - [x] a
$ diff3 -m mine base theirs
<<<<<<< base
- [ ] a
=======
- [x] a
>>>>>>> theirs
exit=1

Both sides made the same edit and it still reports a conflict. Ticking the same box on the phone and the laptop is an entirely ordinary thing to do here, so this would have manufactured conflicts during normal use.

git merge-file -p mine base theirs resolves that case cleanly, and resolves edits to different boxes cleanly, and still conflicts on genuinely competing edits to adjacent lines. That last behaviour matches what the Android merge does, so the two clients agree about what a conflict is, which matters more than either being clever.

A merge is only as good as the base it merges from

The laptop client merged correctly and then failed to record the result as the new base. One sync later the base was a version behind, and an edit that had already been merged in looked like a fresh change from both sides, which produced a conflict out of nothing.

The symptom appeared far from the cause: the false conflict was reported on a line neither side had touched in that round. The rule the code now follows is that any point where local and server are known to agree records that agreement, whether the agreement came from adopting, from merging to something identical to the server, or from a successful push.

Worth stating plainly because it applies to both clients: a three way merge is only correct if the base really is the last common ancestor. A stale base does not fail loudly, it silently invents conflicts.

Reconciliation, phase 0, completed 2026-09-09

File Resolution
2024-10-21.md conflict copy was a strict superset, taken wholesale
2024-11-12.md differed only in one checkbox, kept the [x]
2025-01-17.md local was the superset, kept as-is
2025-01-16.md genuine divergence, union merged

Mainframe's copy was byte-identical to the local conflict file in both divergent cases, so each was a two-way merge, not a three-way one.

All four .sync-conflict- files deleted. 228 files remain, all YYYY-MM-DD.md. Laptop pushed to mainframe; both sides now hash identically:

laptop     228 files  b9a39c9cdf0e0ba49080c6ba03629679
mainframe  228 files  b9a39c9cdf0e0ba49080c6ba03629679

Backups taken first: ~/todo-notes-mainframe-backup-20260909.tar.gz on mainframe, and a matching archive in the session scratchpad on the laptop.