utils/crdt_sync_flutter
Krzysztof kuhy Rudnicki 2298fd4ba2 Own the Google sign-in glue, on desktop as well as Android
Five apps had copied google_sign_in_backend.dart; home_inventory's copy was
byte-identical to todo's. lyricanki was about to become the sixth, which is
the mistake this package exists to prevent. So the plugin call moves here.

Desktop is the new half. google_sign_in ships android, ios and web
implementations only -- there is no google_sign_in_linux -- so lyricanki's
real GTK build could never have used the plugin, and "one tap" would have
meant "one tap on the phone, type a password on the PC". The OAuth loopback
flow covers it: bind a socket, open the system browser, catch the redirect,
exchange the code with PKCE. Both halves return a Google ID token of the same
shape, so signInWithGoogle cannot tell them apart.

googleSignInSupported takes the client id rather than reading a const. In
todo the emptiness check was a second conjunct at the call site, and moving
the fetcher here without it would have let a forgetful app ship a button that
always reports "cancelled" -- todo's own file documents that failure. Taking
the value as an argument makes the check impossible to drop.

The decision (which flow, or none) is pure and covered; the two one-line
delegations that reach a platform channel and a real browser are the only
suppressions. 83 tests, 100% coverage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
2026-08-22 18:45:48 +02:00
..
lib Own the Google sign-in glue, on desktop as well as Android 2026-08-22 18:45:48 +02:00
test Own the Google sign-in glue, on desktop as well as Android 2026-08-22 18:45:48 +02:00
.gitignore Extract the Flutter sync glue into crdt_sync_flutter, and document it 2026-08-22 17:26:34 +02:00
analysis_options.yaml Extract the Flutter sync glue into crdt_sync_flutter, and document it 2026-08-22 17:26:34 +02:00
pubspec.yaml Own the Google sign-in glue, on desktop as well as Android 2026-08-22 18:45:48 +02:00
README.md Own the Google sign-in glue, on desktop as well as Android 2026-08-22 18:45:48 +02:00

crdt_sync_flutter

The Flutter half of crdt_sync. Adds the OS-keystore adapter, a persisted per-install device id, the account store and a one-call bootstrap, so a new app reaches "syncing" without writing any of it.

Why this exists

crdt_sync is pure Dart on purpose — it runs from command-line tools and from wake_alarm's headless systemd job, so it cannot depend on flutter_secure_storage or google_sign_in. It takes closures instead.

Every Flutter app then wrote the same ~390 lines to supply those closures (firebase_backend.dart + google_sign_in_backend.dart + google_platform*), and five apps ended up carrying near-identical copies. They then drifted: todo gained an opt-out flag and a seeded-session path that home_inventory and wake_alarm never received, so the same bug was fixed in one copy and left live in the others.

This package owns that glue once.

Install

dependencies:
  crdt_sync_flutter:
    git:
      url: https://github.com/kuhyx/utils
      ref: crdt_sync_flutter-v0.2.0
      path: crdt_sync_flutter

It depends on crdt_sync itself, so you do not need to list both unless you use the merge primitives (Hlc, Record, LogStore) directly — which most apps do.

Adding sync to a new app

One const and one call.

import 'package:crdt_sync_flutter/crdt_sync_flutter.dart';

/// The shared `kuhy-syncs` project. Safe to commit: the Web API key is a
/// public identifier that already ships inside every APK, and the security
/// rules -- not its secrecy -- are what protect the data.
const kApp = SyncApp(
  project: FirebaseProject(
    apiKey: 'AIzaSyCF_sA3xCMehAYXK8eND-rAygb9NXXW_8E',
    databaseUrl:
        'https://kuhy-syncs-default-rtdb.europe-west1.firebasedatabase.app',
  ),
  expectedUid: 'OvA2REQyLIhAHOEjzwS1o877rgG3',
);

// At a sync tick:
final client = await openSync(kApp);
if (client != null) {
  // ... syncLog(client: client, deviceId: identity.deviceId, ...)
  client.close();
}

openSync returns null when this device is not set up, which is a normal state and not an error — the app keeps working against its local store and simply does not sync.

For the device id:

final identity = await loadDeviceIdentity();
// identity.deviceId -- a per-install uuid under `crdt.nodeId`

The key matches what the existing apps already wrote, so an app adopting this package keeps its identity rather than appearing as a brand-new device and re-merging its own history as a stranger's. An app migrating off a role constant passes legacyId: 'phone' so skip-own-writes stays correct.

databaseUrl must be the regional host

The plain *.firebaseio.com form answers 404 with a correctUrl body rather than an obvious error, so a wrong value reads like an auth failure and wastes a debugging session.

expectedUid is load-bearing

signInWithIdp signs in or signs up. An unlinked Google account is accepted as a new uid, authenticates fine, and is then denied every read and write — a sync that silently never syncs. expectedUid catches it at sign-in instead.

Signing in

There is no way to make a fresh install sync with zero interaction, and that is deliberate: these repos are public, so the account's email and password cannot be committed. The best achievable is install → sign in once → syncing forever, and the refresh token then lives in the OS keystore.

Use sync_settings_ui for that screen rather than building one; it takes the closures this package provides:

/// The project's **Web** OAuth client id, used as the audience for Android's
/// ID tokens. Public by design, exactly like the `apiKey`.
const kServerClientId = '845446124781-...apps.googleusercontent.com';

/// A **Desktop**-type OAuth client, for the loopback flow. A Web client id
/// will not work here; a Desktop client is the only type allowed to redirect
/// to loopback.
const kDesktopClientId = '845446124781-...apps.googleusercontent.com';

SyncSettingsScreen(
  accountLoader: () => loadAccount(),
  accountSaver: saveAccount,
  accountClearer: clearAccount,
  sessionProbe: () => isSyncConfigured(kApp),
  firebaseFactory: () => openSync(kApp),
  googleFirebaseFactory: () => signInWithGoogle(
    kApp,
    tokenFetcher: () => googleAnyIdToken(
      serverClientId: kServerClientId,
      desktopClientId: kDesktopClientId,
    ),
  ),
  googleAvailable: googleAnySignInSupported(
    serverClientId: kServerClientId,
    desktopClientId: kDesktopClientId,
  ),
)

One tap on every platform

google_sign_in ships android, ios and web implementations only — there is no google_sign_in_linux — so a real GTK desktop build cannot use the plugin at all. This package covers both halves and picks between them:

Platform Flow Entry point
Android / iOS google_sign_in plugin, OS account picker googleIdToken
Linux / Windows / macOS OAuth loopback + PKCE, system browser googleDesktopIdToken
Web none (GIS renders its own button) falls back to password

Both return a Google ID token of the same shape, so signInWithGoogle cannot tell them apart.

googleSignInSupported(clientId) takes the client id rather than reading a const so its second, easily-lost conjunct — is a client id actually compiled in? — cannot be dropped by a consuming app. A build that forgot the constant would otherwise show a button that always reports "cancelled".

openSync deliberately never offers Google. It runs from background ticks and, in some apps, before runApp; offering Google there would raise the OS account picker with no user action behind it.

Desktop provisioning

An app whose desktop build is the web build behind a local wrapper can let that wrapper hand over a seeded session, so the account is not retyped per machine:

const kApp = SyncApp(
  project: ...,
  expectedUid: ...,
  routes: WrapperRoutes(
    credentialsPath: '/sync-credentials',
    accountPath: '/sync-account', // optional legacy fallback
  ),
);

routes is per-app because each wrapper serves its own seeded file (the todo app's is ~/.config/todo/firebase_auth.json, written by seed_session.py). Pass nothing and the whole fallback is skipped — which is what Android does in practice, since Uri.base there is file:///.

Nothing in this package reads ~/.config/crdt-sync/ directly. That is the desktop/Python half, reached over the wrapper's HTTP routes.

Testing

The keystore fake ships with the package, so a consuming app does not rebuild it:

import 'package:crdt_sync_flutter/testing/fake_secure_storage.dart';

installFakeSecureStorage(); // auto-removes on tear down
installFakeSecureStorage(throwing: true); // a host with no secret service

Nothing in this package's own suite reaches the network or a real keystore.

flutter test --coverage   # 35 tests, 100% line coverage
flutter analyze

Not yet migrated

todo, home_inventory, diet_guard, workout_app and wake_alarm still carry their own copies and stay pinned to crdt_sync_dart-v0.10.0. That is deliberate — those fallbacks are load-bearing and the migration is forward-only — but it means a fix made here does not reach them. Converging them is a separate task; see the drift note above for what each copy is missing.