Status: Living — the as-built sync reference. Decision record: ADR-0009; cross-version rules:
compatibility.md+ ADR-0016. Tracks #9 (G5). Companion doc toARCHITECTURE.md. Sync ships opt-in (off by default); users enable it from the onboarding sync step or Settings — there is no default-on flip (see “Migration / rollout”). The one-time platform setup (iCloud entitlement / Google Cloud OAuth) and two-device soak live insync-icloud-setup.mdandsync-googledrive-setup.md.
In scope. Cross-device sync of (a) the puzzle library — every imported
or downloaded puzzle, (b) the per-puzzle solve session (in-progress + latest),
(c) the immutable puzzle_completions history that drives streaks/PBs, and
(d) a small allowlist of user-facing settings users expect to follow them
across devices (theme, haptics, sounds, colorblind, Crosshare auto-download
config).
Out of scope. Real-time multiplayer / co-solve. Server-mediated leaderboards. Cross-account sharing. Migrating data between iCloud and Google Drive when a user changes platforms — the existing privacy-screen export/import is the supported bridge.
Three layers, replacing today’s two-method SyncAdapter interface with an
orchestration model:
SyncOrchestrator ← top-level: schedules pushes/pulls, exposes status
├── NamespaceSyncAdapter ← per-namespace logic (puzzles, sessions, completions, settings)
│ uses
└── SyncTransport ← platform-specific blob store (iCloud / Drive / Fake)
SyncTransport is the only platform-aware piece. It exposes a tiny
CRUD-on-named-blobs API: read(key), write(key, bytes, ifMatch),
list(prefix), delete(key), plus account management:
account() (silent), signIn() (interactive where the platform needs it),
and the supportsInteractiveSignIn capability flag. (As built there is no
changes() stream — sync is driven by the app-resume / post-write triggers
below, not a push channel.) The iCloud impl uses the app’s iCloud Documents
container for blobs; the Google Drive impl uses the AppData scope (hidden,
per-app, no user file picker needed).NamespaceSyncAdapter per namespace owns the entity shape, conflict rule,
and merge function. Keeps platform concerns out of domain code.SyncOrchestrator is what core_providers exposes (replacing today’s
syncAdapterProvider). It owns the foreground-resume trigger and a
debounced “after local write” trigger.Transport is picked via Platform.isIOS ? ICloudTransport : GoogleDriveTransport,
with FakeSyncTransport for tests. NoOpSyncTransport keeps the local-only
build viable until platform adapters land.
Sync state already exists on solve_sessions (isSynced, syncVersion,
createdAt, updatedAt). Extend the same triple to the other synced tables
in schema v5:
| Table | New columns | Why |
|---|---|---|
puzzles |
syncVersion, isSynced (already has createdAt/updatedAt) |
Library replication |
puzzle_completions |
clientUuid (UUID v4), deviceId |
Append-only — dedupe on clientUuid; deviceId for provenance |
app_settings |
syncVersion |
LWW per key (already has updatedAt) |
cell_progress |
(none) | Travels inside the session blob, not synced independently |
clientUuid on completions is the key insight: it’s append-only history, so
each device generates a UUID at insert time, and merge becomes a set union
with clientUuid as the dedupe key — no conflict logic needed.
One blob per entity, in an app-private folder (Documents/sync/ on iCloud,
AppData root on Drive). Keys:
puzzles/<puzzleId>.json
sessions/<puzzleId>.json # latest session + its cell_progress, denormalized
completions/<clientUuid>.json # immutable
settings/<key>.json
manifest.json # index: {namespace → [{key, syncVersion, updatedAt, etag}]}
manifest.json is the only thing read on every sync tick. Entity blobs are
fetched only when the manifest says the remote version is newer. Keeps a
typical incremental sync to one small GET.
Each blob is {schemaVersion, deviceId, syncVersion, updatedAt, payload}.
schemaVersion lets us evolve formats; readers ignore unknown fields and
skip blobs with newer schemas than they understand (forward-compat).
Per namespace, picked for what the data actually means:
local:<sha256-prefix>, checksum
stored separately). Identical content → no conflict. Different content
under the same id is impossible by construction. Merge = union.clientUuid. No conflicts possible.(updatedAt, deviceId), with a “best-progress” override: if remote is
completed and local is in_progress, keep remote regardless of clock.
Avoids the case where a stale clock erases a real completion.updatedAt. Keys are independent.deviceId is the tiebreaker on equal updatedAt, so two devices can’t
deadlock if clocks match. syncVersion is incremented locally on every
write; remote syncVersion ≤ local means “we already have this or newer,
skip download.”
abstract class SyncOrchestrator {
Stream<SyncState> get state; // disabled | signedOut | idle | syncing | error
Future<SyncAccount?> currentAccount();
Future<void> enable(); // iOS: check iCloud token; Android: sign in
Future<void> disable({bool wipeRemote = false});
Future<SyncResult> syncNow(); // manual trigger from settings
DateTime? get lastSyncedAt;
}
SyncState is a hand-written sealed class (SyncDisabled / SyncSignedOut /
SyncIdle / SyncRunning / SyncError) — exhaustively switchable like a
Freezed union, but plain Dart. SyncResult reports {pushed, pulled,
conflicts, duration} for the settings UI to render.
The legacy two-method SyncAdapter (just sync() + isSyncEnabled) is
removed once SyncOrchestrator is wired — no compatibility shim, since the
only caller is the provider.
Mirror the Crosshare auto-download pattern in app.dart — a
WidgetsBindingObserver calling ref.read(syncOrchestratorProvider).syncNow()
on resumed. Add a debounced (5s) trigger from SolveRepositoryImpl.saveProgress
/ markComplete so an active solver gets near-realtime cross-device updates
without hammering the transport on every keystroke.
No background fetch / WorkManager in v1. App-resume + post-write is sufficient for the daily-mini use case and avoids the platform background- execution rabbit hole.
com.apple.developer.icloud-container-identifiers). Account is implicit;
FileManager.url(forUbiquityContainerIdentifier:) returns nil if the
user has iCloud Drive off for the app — that’s the signedOut state. No
google_sign_in equivalent dialog needed.google_sign_in 7.x +
extension_google_sign_in_as_googleapis_auth + drive.appdata scope.
AppData folder is hidden from the user’s Drive UI and per-app — exactly
the trust model we want. Sign-in is explicit on first enable (the toggle
drives the prompt); google_sign_in owns token storage/refresh internally
(no separate flutter_secure_storage). Needs an Android OAuth client plus
a web client whose ID is passed as serverClientId — see
sync-googledrive-setup.md.SyncState.signedOut (user needs
to act) vs. SyncState.error(transient) (orchestrator retries on next
trigger). Never block UI.New /settings/sync route under the existing settings shell, alongside
/settings/sources and /settings/privacy:
enable()).disable(wipeRemote: true)).Use the existing SettingsSwitchRow / SettingsNavRow widgets so it
matches the rest of settings visually.
disable(wipeRemote: false) so a wipe doesn’t unexpectedly delete the
user’s other device’s data. Add a second confirm step for the “also delete
cloud copy” path.FakeSyncTransport (in-memory map) lets every adapter test run hermetically
with no platform code.AppDatabase.forTesting instances sharing one
FakeSyncTransport, drive a solve on one, sync, assert the other sees
the completion.app_database_test.dart.clientUuid backfill for existing
completions). ✅SyncTransport + FakeSyncTransport + SyncOrchestrator with
NoOpSyncTransport still wired — no behavior change. ✅getSyncEnabled() defaults to false. The
onboarding enablement screen makes an automatic default-on flip
unnecessary, so it has been dropped from the plan.All steps have landed. Remaining work is operational, not code: the one-time iCloud-entitlement / Google-Drive-OAuth platform setup and the two-device soaks, both documented in the setup guides above. Background sync and a server-side bridge remain out of scope (see “Open questions”).