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
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
BasicTextFieldconsume it, andwaitForUpOrCancellationreturns null for a consumed pointer, so the release never arrived. - Consuming the press in
PointerEventPass.Initialand 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.