The todo app is an offline-first notes app that runs on two devices — a Linux
desktop and an Android phone — and keeps them in sync through a private GitHub
repo used as dumb storage. There is no server: each device owns exactly one
file, todo-sync/notes/<nodeId>.json, holding its full CRDT
log of notes. A sync is a pull-merge-push cycle: list the directory, merge
every other device's file into the local store, then upload your own
merged log. Because merging is commutative and idempotent, any two devices that
both complete a sync end up with the same notes.
That last sentence carries a hidden assumption: that syncs actually complete, and that you'd know if they didn't. Before this change, the capture screen triggered sync in three places:
paused,Three separate silences hid failures:
_autoSync() wrapped the whole cycle in catch (_)
— every automatic failure (offline, bad token, GitHub error) vanished.SyncService.sync(), a peer file that failed to fetch or
parse was skipped with a bare continue. The sync then
succeeded from the caller's point of view — it just quietly merged
nothing from that device.paused lifecycle
event at all, so the desktop effectively only synced at launch or on a
manual button press.These silences had just produced a real incident: after the July 11 storage migration (which moved the sync location to a new, incompatible directory), the phone got the new build but the desktop kept running a bundle built two hours before the migration commit. For five days each device synced happily into a directory the other never read — desktop showed 39 notes, phone 30 — and no pixel anywhere said anything was wrong.
The fix is not smarter syncing — the CRDT merge was never the problem. The fix is making every sync outcome observable, and making sure the desktop actually gets chances to sync. One sentence: a sync system that can fail silently will eventually diverge silently; a status line is the cheapest possible alarm.
Concretely, imagine the sync repo contains your own file plus two peers, one of them corrupt:
{not a logBefore the change both outcomes below rendered identically as
"Synced: merged 1 device(s)" — you could not tell a healthy fleet
from one silently losing a device. Now the same run reports:
and an automatic sync that fails outright no longer disappears into
catch (_) — it lands in a small persistent status line under the
editor, in the error color:
The status is also written to SharedPreferences, so a failure
that happened just before the app closed is still visible the next morning.
Had this line existed on July 11, the desktop would have shown a stale
"Synced at …" timestamp days old — the divergence would have been visible on
day one instead of day five.
Reporting outcomes only helps if syncs actually run. On Android,
paused fires reliably when you switch apps. On Linux desktop it
rarely fires — but inactive and hidden (focus loss)
do. The catch: focus flickers constantly on a desktop (alt-tab, clicking
another window), and firing a GitHub round-trip per flicker would be abusive.
So the new trigger is debounced: focus must stay lost for 5
seconds before a sync fires, and every new lifecycle event resets the timer.
paused still syncs immediately — Android may kill a paused app
before any timer fires, so waiting would risk losing the push.
sync_service.dart)SyncResult gains a skippedFiles list, and the pull
loop collects instead of discarding. Note the two failure shapes it now covers
with one expression: a file that was listed but whose GET returned nothing
(text == null), and one that fetched but didn't parse as a log:
- final text = await github.getFileText('$notesDir/$name'); - if (text == null) continue; - final remote = _decodeLog(text); - if (remote == null) continue; // Skip a corrupt/foreign file.+ final text = await github.getFileText('$notesDir/$name'); + final remote = text == null ? null : _decodeLog(text); + if (remote == null) { + // A vanished or undecodable peer file must not abort the sync, but + // it must not vanish silently either — that's how a device drops + // out of sync unnoticed. Report it in the result instead. + skipped.add(name); + continue; + }
The tolerance is unchanged — one bad file still never aborts the cycle, and the push still happens — but the caller now knows the merge was partial.
capture_screen.dart)A tiny SyncStatus value (time, ok, detail) flows into a
ValueNotifier — the same pattern the screen already used for the
"Saved locally at…" indicator, so one status change repaints one line of text,
not the whole screen. _recordSyncStatus() also persists the three
fields to SharedPreferences, and _restoreSyncStatus()
reloads them in initState.
Future<void> _autoSync() async {
...
try {
- await _syncService.sync(widget.repository, client);
- } catch (_) {
- // Best-effort: ignore (offline, transient GitHub errors, etc.).+ final result = await _syncService.sync(widget.repository, client);
+ await _recordSyncStatus(ok: true, detail: _describe(result));
+ } catch (e) {
+ // Offline or a transient GitHub error: still no snackbar (this path
+ // must never interrupt capture), but the status line shows it.
+ await _recordSyncStatus(ok: false, detail: '$e');
} finally { ... }
}The design line to notice: auto-sync is still quiet (no snackbar — capture must never be interrupted), but it is no longer silent. The manual Sync button records the same status and shares the same message builder, so the snackbar and the status line can never drift apart:
static String _describe(SyncResult result) {
final skipped = result.skippedFiles.length;
return 'merged ${result.mergedDevices} device(s)'
'${skipped == 0 ? '' : ', skipped $skipped unreadable file(s)'}';
}if (state == AppLifecycleState.paused) {
// Immediate: Android may kill the app before a debounce timer fires.
_autoSyncDebounce?.cancel();
_autoSync();
} else if (state == AppLifecycleState.inactive ||
state == AppLifecycleState.hidden) {
// Focus loss — the only background signal Flutter Linux delivers
// reliably (`paused` rarely arrives on desktop). Debounced so alt-tab
// flicker doesn't fire a request per focus change.
_autoSyncDebounce?.cancel();
_autoSyncDebounce = Timer(CaptureScreen.autoSyncDebounce, _autoSync);
}The timer is cancelled in dispose(), and the existing
single-flight guard (_autoSyncing) still prevents overlapping
cycles if a debounced sync lands while another is running. The 5-second
constant lives on the public widget class so tests can pump exactly past it.
The suite grew from 173 to 174 tests, still at 100% line coverage. The old test "auto-sync failure is silent (no snackbar)" flipped meaning: it now asserts no snackbar but a visible error status line. New tests cover the skipped-file message, restoring a persisted failure on launch, and the debounce (two focus events reset the timer; the sync fires once, only after the full window elapses).
Five questions, click an answer to check it. Score: 0 / 0
1. Why did the desktop/phone divergence stay invisible for five days?
2. A peer file appears in the directory listing but its GET returns 404. What does the new sync do?
3. Why does paused sync immediately while inactive/hidden are debounced?
4. Right after a fresh app launch, before any sync runs, where does the status line's content come from?
5. Manual Sync reports "merged 1 device(s), skipped 1 unreadable file(s)". What should you conclude?