|
Some checks are pending
crdt_sync_flutter tests / test (push) Waiting to run
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
Android now uses google_account_token (no googleid or play-services-auth). Desktop PKCE sends client_secret. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzxdWY1k4muZfdVJfb7YGJ |
||
|---|---|---|
| .. | ||
| lib | ||
| scripts | ||
| test | ||
| .gitignore | ||
| analysis_options.yaml | ||
| pubspec.lock | ||
| pubspec.yaml | ||
| README.md | ||
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 the Android Google plugin. 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.1
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';
/// That Desktop client's secret. Google rejects the token exchange without
/// it, but treats it as non-confidential for installed apps: inject it at
/// build time (`--dart-define`), never commit it. '' sends none.
const kDesktopClientSecret = String.fromEnvironment('DESKTOP_CLIENT_SECRET');
SyncSettingsScreen(
accountLoader: () => loadAccount(),
accountSaver: saveAccount,
accountClearer: clearAccount,
sessionProbe: () => isSyncConfigured(kApp),
firebaseFactory: () => openSync(kApp),
googleFirebaseFactory: () => signInWithGoogle(
kApp,
tokenFetcher: () => googleAnyIdToken(
serverClientId: kServerClientId,
desktopClientId: kDesktopClientId,
desktopClientSecret: kDesktopClientSecret,
),
),
googleAvailable: googleAnySignInSupported(
serverClientId: kServerClientId,
desktopClientId: kDesktopClientId,
),
)
One tap on every platform
google_account_token wraps Android's AccountManager
(no Google client library in the APK), so a real GTK desktop build cannot use
it. This package covers both halves and picks between them:
| Platform | Flow | Entry point |
|---|---|---|
| Android | google_account_token plugin, OS account chooser |
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.