crosscue

Sync Adapter Design (G5 — iCloud / Google Drive)

Status: Living — the as-built sync reference. Decision record: ADR-0009; cross-version rules: compatibility.md + ADR-0016. Tracks #9 (G5). Companion doc to ARCHITECTURE.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 in sync-icloud-setup.md and sync-googledrive-setup.md.

Goals / non-goals

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.

High-level shape

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)

Transport is picked via Platform.isIOS ? ICloudTransport : GoogleDriveTransport, with FakeSyncTransport for tests. NoOpSyncTransport keeps the local-only build viable until platform adapters land.

Data model

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.

Wire format

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

Conflict resolution

Per namespace, picked for what the data actually means:

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

API surface

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.

Trigger model

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.

Platform specifics

Settings UX

New /settings/sync route under the existing settings shell, alongside /settings/sources and /settings/privacy:

Use the existing SettingsSwitchRow / SettingsNavRow widgets so it matches the rest of settings visually.

Privacy / data model fit

Test strategy

Migration / rollout

  1. Schema v5 (additive columns + clientUuid backfill for existing completions). ✅
  2. Land SyncTransport + FakeSyncTransport + SyncOrchestrator with NoOpSyncTransport still wired — no behavior change. ✅
  3. iCloud transport behind an off-by-default settings entry on iOS. ✅ (#142)
  4. Google Drive transport on Android. ✅ (#145 transport, #157 sign-in UI; inert until the OAuth clients exist).
  5. Ship opt-in (off by default) — no default-on flip. ✅ (decided) Sync stays disabled until the user turns it on from the onboarding sync step (#142) or Settings; 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”).

Open questions