Surface sync outcomes so devices can't silently diverge

Commit 7564a65 · 2026-07-16 · lib/sync/sync_service.dart, lib/ui/capture_screen.dart + tests

1 · Background

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:

Three separate silences hid failures:

  1. _autoSync() wrapped the whole cycle in catch (_) — every automatic failure (offline, bad token, GitHub error) vanished.
  2. Inside 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.
  3. On Linux desktop, Flutter rarely delivers the 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.

2 · Intuition

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:

phone.jsonvalid log · 42 notes
merged
tablet.json{not a log
undecodable
desktop.json(your own file)
pushed

Before 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:

Synced at 18:00:35 · merged 1 device(s), skipped 1 unreadable file(s)

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:

Sync failed at 07:12:03 · GitHubSyncError: GET todo-sync/notes failed: 401

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.

The second half: the desktop never got a turn

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.

Micro-world: the new lifecycle trigger

Fire lifecycle events and step time forward one second at a time. This mirrors the real didChangeAppLifecycleState logic: paused syncs now; inactive/hidden arm (or reset) a 5-second timer; resumed does nothing — a timer already armed still fires.

t = 0 s · debounce timer: none · syncs fired: 0
— try: inactive → inactive → advance ×5 (timer resets, then fires once)

Micro-world: what the sync message reports

Set each peer file's condition and see the exact message the snackbar and status line build (the real _describe() logic over SyncResult).


3 · Code

Concern 1 — skipped peers become data, not silence (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.

Concern 2 — every outcome lands somewhere visible (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)'}';
}

Concern 3 — a sync trigger the desktop actually receives

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.

Concern 4 — tests

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).

4 · Quiz

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?