Extract the Flutter sync glue into crdt_sync_flutter, and document it

`crdt_sync` is pure Dart so it can run from CLI tools and headless systemd
jobs, which means it takes closures instead of depending on
flutter_secure_storage and google_sign_in. Every Flutter app then wrote the
same ~390 lines to supply them, and five apps ended up carrying near-identical
copies of firebase_backend.dart.

Those copies 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 -- which is the actual cost being paid
here, not the line count.

This package owns that glue once: the keystore adapter, the persisted
per-install device id, the account store and a one-call bootstrap. A new app
needs a SyncApp const and `openSync(kApp)`.

The wrapper routes are a parameter rather than a constant, because todo's copy
hardcoded its own `~/.config/todo/firebase_auth.json`. An extraction only one
app could adopt would be worse than the copies.

Also documents what made this hard to find: crdt_sync_dart's README covered
only the *old* GitHub transport and never mentioned the Firebase surface at
all, and sync_settings_ui -- a complete drop-in settings screen -- had no
README whatsoever.

The five existing apps stay on their copies and on crdt_sync_dart-v0.10.0.
Migration is forward-only and those fallbacks are load-bearing; converging
them is a separate task, and the README says so.

35 tests, 100% line coverage, no suppressions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
This commit is contained in:
Krzysztof Rudnicki 2026-08-22 17:26:34 +02:00
parent feaaf69103
commit fff46de4c5
16 changed files with 1430 additions and 1 deletions

View File

@ -34,12 +34,17 @@ dependencies:
crdt_sync:
git:
url: https://github.com/kuhyx/utils
ref: crdt_sync_dart-v0.2.0
ref: crdt_sync_dart-v0.11.0
path: crdt_sync_dart
```
## Usage
The example below uses the **GitHub** transport, which is now the mirror
rather than the primary — see [Firebase transport](#firebase-transport-the-current-one)
for what a new consumer should use. The merge scheme itself is transport-agnostic:
`syncLog` takes any `RemoteStore`.
```dart
import 'package:crdt_sync/crdt_sync.dart';
@ -67,6 +72,61 @@ final merged = await syncLog(
`<pathPrefix>/<other-device-id>/...`, merges each into the local log with
`mergeLogs`, then pushes this device's own merged result back up.
## Firebase transport (the current one)
**If you are adding sync to a new Flutter app, use
[`crdt_sync_flutter`](../crdt_sync_flutter) and stop reading here.** It wraps
everything below in one call. The rest of this section is what that package is
built on, and what a non-Flutter consumer (a CLI tool, a systemd job) uses
directly.
The `GitHubClient` shown above is the **original** transport and is now a
mirror. The primary is Firebase Realtime Database over plain REST:
```dart
final client = await firebaseClientFor(
config: kProject.configFor(email),
store: credentialStore, // where the refresh token lives
password: password, // or googleIdToken, on a fresh install
expectedUid: kSyncUid,
);
final merged = await syncLog(client: client, /* ... as above ... */);
```
**Plain REST, not FlutterFire, deliberately.** The official `firebase_auth`
and `firebase_database` plugins have no Linux desktop support, and one
consumer runs headless under systemd. The REST endpoints are ordinary HTTPS
and behave identically on Android, Linux and headless. RTDB rather than
Firestore because the Spark plan bills only storage and bandwidth, with no
per-operation quota that a misbehaving sync loop could exhaust.
What the pieces are:
| Type | Role |
|---|---|
| `FirebaseProject` | `apiKey` + `databaseUrl`. Safe to commit. |
| `FirebaseAccount` | `email` + `password`. Never commit; keystore only. |
| `FirebaseCredentials` | The refresh token and its expiry. |
| `FirebaseCredentialStore` | Where that token is persisted. |
| `SecureCredentialStore` | A store over three injected closures. |
| `FirebaseTokenProvider` | Signs in and refreshes; 5-minute skew. |
| `FirebaseRestClient` | The `RemoteStore` itself. |
| `MirrorStore` | Firebase primary + GitHub mirror, during a cutover. |
The public/private split is not cosmetic: every repo in this fleet is public.
`apiKey` and `databaseUrl` identify the *project* and already ship inside every
APK — the security rules, not secrecy, protect the data. The account email and
password identify *a person* and are entered once per device.
### `password: ''` is legal
The Google one-tap path and the seeded-session path both store an empty
password, because on those devices the refresh token *is* the credential.
`FirebaseAccount.tryParse` used to reject that blob, so such a device wrote an
account marker it could never read back and reported "not configured" on every
later launch. Fixed in **v0.11.0**; callers guard `password.isEmpty` and pass
null, meaning "no password on this device".
## Local persistence (`LogStore`)
The merge scheme above is in-memory only. For an app that needs to keep its

6
crdt_sync_flutter/.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
.dart_tool/
.packages
build/
pubspec.lock
coverage/
*.iml

176
crdt_sync_flutter/README.md Normal file
View File

@ -0,0 +1,176 @@
# crdt_sync_flutter
The Flutter half of [`crdt_sync`](../crdt_sync_dart). 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
```yaml
dependencies:
crdt_sync_flutter:
git:
url: https://github.com/kuhyx/utils
ref: crdt_sync_flutter-v0.1.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.**
```dart
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:
```dart
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`](../sync_settings_ui) for that screen rather than
building one; it takes the closures this package provides:
```dart
SyncSettingsScreen(
accountLoader: () => loadAccount(),
accountSaver: saveAccount,
accountClearer: clearAccount,
sessionProbe: () => isSyncConfigured(kApp),
firebaseFactory: () => openSync(kApp),
googleFirebaseFactory: () => signInWithGoogle(
kApp,
tokenFetcher: googleIdToken, // the app's own google_sign_in call
),
googleAvailable: googleSignInSupported,
)
```
`signInWithGoogle` is the interactive path and takes the token fetcher as a
closure — `google_sign_in` is Android/iOS/web only, and this package must keep
working on Linux desktop.
**`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:
```dart
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:
```dart
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.
```bash
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.

View File

@ -0,0 +1,38 @@
include: package:very_good_analysis/analysis_options.yaml
analyzer:
language:
strict-casts: true
strict-inference: true
strict-raw-types: true
errors:
# Promote key lints to errors so they fail CI, not just warn.
missing_required_param: error
unnecessary_null_comparison: error
dead_code: error
invalid_annotation_target: error
exclude:
- build/**
- "**/*.g.dart"
- "**/*.freezed.dart"
- "test/**"
- android/**
- ios/**
- web/**
- windows/**
- macos/**
- linux/**
linter:
rules:
# very_good_analysis enables most rules; add extras it doesn't include.
always_use_package_imports: true
avoid_print: true
avoid_relative_lib_imports: true
cancel_subscriptions: true
close_sinks: true
comment_references: false # conflicts with typical Flutter API docs style
directives_ordering: true
lines_longer_than_80_chars: true
public_member_api_docs: true
unawaited_futures: true

View File

@ -0,0 +1,17 @@
/// The Flutter half of `crdt_sync`.
///
/// `crdt_sync` is pure Dart so it can run from CLI tools and headless
/// systemd jobs. That leaves every Flutter app to write the same glue:
/// a `flutter_secure_storage` adapter, a persisted device id, an account
/// store, and a client-opening function. Four apps had copied it, and the
/// copies had drifted -- `todo` carried an opt-out flag and a seeded-session
/// path the others never received.
///
/// This package owns that glue. A new app needs a [SyncApp] and one call to
/// [openSync]; see the README.
library;
export 'src/account_store.dart';
export 'src/bootstrap.dart';
export 'src/device_id.dart';
export 'src/keystore.dart';

View File

@ -0,0 +1,170 @@
/// Reading, writing and forgetting the per-device Firebase account.
///
/// This is the file that had drifted. Four apps carried near-identical copies
/// of it; `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. Owning it here means the
/// next fix lands everywhere at once.
///
/// Nothing here reads `~/.config/crdt-sync/` directly -- that is the
/// desktop/Python half, reached over the local wrapper's HTTP routes. On
/// Android no such file exists.
library;
import 'dart:convert';
import 'dart:developer';
import 'package:crdt_sync/crdt_sync.dart';
import 'package:crdt_sync_flutter/src/keystore.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:http/http.dart' as http;
/// Where a desktop install can fetch a seeded session from its own wrapper.
///
/// Per-app, because each app's wrapper serves its own seeded file: the
/// `todo` app's lives at `~/.config/todo/firebase_auth.json`, written by
/// `seed_session.py`. An app with no desktop wrapper passes null and the
/// whole fallback
/// is skipped -- which is what Android does in practice, since `Uri.base`
/// there is `file:///` and has no host to request from.
class WrapperRoutes {
/// Creates a route set.
const WrapperRoutes({required this.credentialsPath, this.accountPath});
/// Path serving the seeded Firebase session, e.g. `/sync-credentials`.
final String credentialsPath;
/// Path serving the legacy email/password pair, e.g. `/sync-account`.
///
/// Null where the app never had one. The shared account's password grant is
/// retired fleet-wide, so this exists only for machines not yet re-seeded.
final String? accountPath;
}
/// Reads the per-device account, or null when sync is not set up here.
///
/// Order: the keystore, then -- only when [routes] is given and the user has
/// not disconnected -- the seeded session, then the legacy password route.
/// Whichever succeeds is written to the keystore, so a fallback is consulted
/// once rather than staying load-bearing forever.
Future<FirebaseAccount?> loadAccount({
WrapperRoutes? routes,
Uri? base,
http.Client? httpClient,
FlutterSecureStorage storage = kSecureStorage,
}) async {
try {
final stored = FirebaseAccount.tryParse(
await storage.read(key: kAccountKey),
);
if (stored != null) return stored;
// Disconnect must stick: without this the next launch silently re-adopts
// the account and the disconnect button looks broken.
if (await storage.read(key: kOptOutKey) != null) return null;
if (routes == null) return null;
final origin = base ?? Uri.base;
final seeded = await _adoptSeededSession(
origin,
routes,
client: httpClient,
storage: storage,
);
if (seeded != null) return seeded;
final path = routes.accountPath;
if (path == null) return null;
final provisioned = await accountFromWrapper(origin, client: httpClient);
if (provisioned != null) {
await saveAccount(provisioned, storage: storage);
}
return provisioned;
// Broader than Exception on purpose: on Android `Uri.base` is `file:///`
// and the request raises ArgumentError ("No host specified in URI") --
// an Error, which `on Exception` does not catch. That escaped the caller
// and killed the whole sync tick, so a signed-in phone pulled nothing.
} on Object catch (error, stackTrace) {
// Still "not configured" rather than a crash -- but never silent: hiding
// why provisioning failed makes it indistinguishable from "no account".
log(
'loadAccount failed; treating this device as not configured',
level: 1000,
error: error,
stackTrace: stackTrace,
);
return null;
}
}
/// Adopts a seeded session from the wrapper: stores the refresh token and
/// writes an account marker so settings shows an address, not a blank.
///
/// Returns null on any failure -- route disabled, file absent, malformed
/// body, no wrapper at all -- because the caller's fallback is simply "not
/// configured".
Future<FirebaseAccount?> _adoptSeededSession(
Uri base,
WrapperRoutes routes, {
http.Client? client,
FlutterSecureStorage storage = kSecureStorage,
}) async {
final httpClient = client ?? http.Client();
try {
final response = await httpClient.get(
base.resolve(routes.credentialsPath),
);
if (response.statusCode != 200) return null;
final body = jsonDecode(utf8.decode(response.bodyBytes));
if (body is! Map<String, dynamic>) return null;
if (body['id_token'] is! String ||
body['refresh_token'] is! String ||
body['expires_at'] is! String) {
return null;
}
await keystoreCredentialStore(
storage: storage,
).save(FirebaseCredentials.fromJson(body));
final email = body['email'];
final account = FirebaseAccount(
email: email is String ? email : '',
password: '',
);
await saveAccount(account, storage: storage);
return account;
} on Exception {
return null;
} finally {
if (client == null) httpClient.close();
}
}
/// Reads the account from the keystore only, with no wrapper fallback.
///
/// Callers that only want to read back an account they just wrote use this:
/// on Android the fallback resolves to `file:///` and throws, which turned a
/// successful sign-in into a reported failure.
Future<FirebaseAccount?> storedAccount({
FlutterSecureStorage storage = kSecureStorage,
}) async => FirebaseAccount.tryParse(await storage.read(key: kAccountKey));
/// Stores the per-device account. Keystore only -- never prefs, never source.
Future<void> saveAccount(
FirebaseAccount account, {
FlutterSecureStorage storage = kSecureStorage,
}) => storage.write(key: kAccountKey, value: account.toJsonString());
/// Forgets the account and any cached session, and suppresses re-adoption.
Future<void> clearAccount({
FlutterSecureStorage storage = kSecureStorage,
}) async {
await storage.delete(key: kAccountKey);
await storage.write(key: kOptOutKey, value: 'true');
await keystoreCredentialStore(storage: storage).clear();
}
/// Drops the account marker alone, leaving the opt-out flag unset.
///
/// For a marker found with no session behind it: [clearAccount] would also
/// set the opt-out flag and stop the wrapper re-provisioning after the next
/// sign-in.
Future<void> clearAccountMarkerOnly({
FlutterSecureStorage storage = kSecureStorage,
}) => storage.delete(key: kAccountKey);

View File

@ -0,0 +1,178 @@
/// Opening a signed-in Firebase client, in one call.
///
/// This is the file an adopting app no longer writes. Give it the project
/// identifiers and it handles the rest: the keystore, the stored refresh
/// token, the sign-in that only happens when there is no session, and the
/// recovery path for a device holding a session with no account marker.
library;
import 'dart:developer';
import 'package:crdt_sync/crdt_sync.dart';
import 'package:crdt_sync_flutter/src/account_store.dart';
import 'package:crdt_sync_flutter/src/keystore.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:http/http.dart' as http;
/// Everything an app must supply to sync: the public project identifiers,
/// the uid the security rules pin, and where its desktop wrapper serves
/// seeded credentials from.
///
/// [project] and [expectedUid] are safe to hold in source. The repos are
/// public and the Web API key ships inside every APK: the security rules,
/// not secrecy, are what protect the data. The account email and password
/// are not here, and never belong in source.
class SyncApp {
/// Creates an app descriptor.
const SyncApp({
required this.project,
required this.expectedUid,
this.routes,
});
/// Public project identifiers (API key + regional database URL).
final FirebaseProject project;
/// The uid the rules pin.
///
/// Load-bearing: `signInWithIdp` signs in *or signs up*, so 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.
final String expectedUid;
/// This app's desktop wrapper routes, or null when it has none.
final WrapperRoutes? routes;
}
/// Returns a signed-in client, or null when this device is not configured.
///
/// Not being set up is a normal state, not an error: the caller keeps working
/// against its local store and simply does not sync.
///
/// Deliberately does **not** offer Google sign-in. This 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. Interactive
/// sign-in goes through [signInWithGoogle].
Future<FirebaseRestClient?> openSync(
SyncApp app, {
http.Client? httpClient,
FlutterSecureStorage storage = kSecureStorage,
}) async {
final account = await loadAccount(
routes: app.routes,
httpClient: httpClient,
storage: storage,
);
if (account == null) {
// A stored refresh token IS a signed-in device, even with no account
// marker beside it. Treating the marker as the source of truth made a
// phone with a live session sync over the mirror and fail forever.
return _clientFromStoredSession(app, storage: storage);
}
return firebaseClientFor(
config: app.project.configFor(account.email),
store: keystoreCredentialStore(storage: storage),
// A Google-provisioned account stores an empty password. Passing ''
// would make firebaseClientFor treat it as usable and sign in with it,
// which fails; null correctly means "no password on this device".
password: account.password.isEmpty ? null : account.password,
expectedUid: app.expectedUid,
httpClient: httpClient,
);
}
/// Signs in with Google alone, for a device with no account stored yet.
///
/// The one-tap path: [openSync] needs an account to know which email to use,
/// but a fresh install has none. [tokenFetcher] is the app's `google_sign_in`
/// call -- a closure because that plugin is Android/iOS/web only and this
/// package must keep working on Linux desktop.
///
/// Returns null when the user dismisses the picker; throws
/// [FirebaseAuthError] when Google succeeds but resolves to the wrong uid,
/// which is a misconfiguration worth surfacing rather than swallowing.
Future<FirebaseRestClient?> signInWithGoogle(
SyncApp app, {
required Future<String?> Function() tokenFetcher,
http.Client? httpClient,
FlutterSecureStorage storage = kSecureStorage,
}) async {
final token = await tokenFetcher();
if (token == null) return null;
final auth = FirebaseTokenProvider(
apiKey: app.project.apiKey,
store: keystoreCredentialStore(storage: storage),
httpClient: httpClient,
);
final email = await auth.signInWithGoogle(
idToken: token,
expectedUid: app.expectedUid,
);
// Saved unconditionally, and deliberately not gated on `email`:
// signInWithIdp omits that field whenever the Google account hides it, and
// gating the write on it returned a working client while persisting
// nothing, so the next launch looked unconfigured. The refresh token, not
// the address, is the credential -- and it is already durable here.
await saveAccount(
FirebaseAccount(email: email ?? '', password: ''),
storage: storage,
);
return FirebaseRestClient(
databaseUrl: app.project.databaseUrl,
auth: auth,
httpClient: httpClient,
);
}
/// Whether this device can actually authenticate.
///
/// True when a session exists. A marker alone is not enough: a revoked
/// refresh token makes the library clear the session, and a marker left
/// behind would report "Connected" while every sync failed. So when a marker
/// outlives its session, the marker is the stale half -- drop it.
Future<bool> isSyncConfigured(
SyncApp app, {
FlutterSecureStorage storage = kSecureStorage,
}) async {
try {
final auth = FirebaseTokenProvider(
apiKey: app.project.apiKey,
store: keystoreCredentialStore(storage: storage),
);
if (await auth.hasSession()) return true;
if (await storedAccount(storage: storage) == null) return false;
await clearAccountMarkerOnly(storage: storage);
return false;
} on Object catch (error, stackTrace) {
log(
'session probe failed; reporting this device as not configured',
level: 1000,
error: error,
stackTrace: stackTrace,
);
return false;
}
}
/// Builds a client from the keystore's refresh token alone, or null.
///
/// Deliberately has no catch of its own. Both steps it takes are already
/// total: `hasSession` reads through [SecureCredentialStore.load], which
/// converts a throwing keystore into a null session, and the client's
/// constructor does no I/O -- a bad database URL surfaces on the first
/// request, not here. A `catch` around this would be a branch no input can
/// reach, and therefore one no test can cover.
Future<FirebaseRestClient?> _clientFromStoredSession(
SyncApp app, {
FlutterSecureStorage storage = kSecureStorage,
}) async {
final auth = FirebaseTokenProvider(
apiKey: app.project.apiKey,
store: keystoreCredentialStore(storage: storage),
);
if (!await auth.hasSession()) return null;
return FirebaseRestClient(
databaseUrl: app.project.databaseUrl,
auth: auth,
);
}

View File

@ -0,0 +1,43 @@
/// The per-install device id, persisted in `SharedPreferences`.
///
/// Every device that pushes into a namespace needs an id that is its own: it
/// is the directory segment its log is pushed under, the revision key, and
/// the node component of every HLC stamp it writes. Two devices sharing an id
/// overwrite each other's pushed file on every tick, which is why a role
/// constant (`phone`, `pc`) is wrong and a per-install uuid is right.
///
/// Not the keystore: this is an identifier, not a secret, and the keystore is
/// slower and can be cleared independently of app data. `SharedPreferences`
/// shares the app-data lifetime the log itself has.
library;
import 'package:crdt_sync/crdt_sync.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:uuid/uuid.dart';
/// Preferences key holding this install's uuid.
///
/// Named to match what the existing apps already wrote, so an app adopting
/// this package keeps its identity instead of appearing as a new device and
/// re-merging its own history as a stranger's.
const String kNodeIdKey = 'crdt.nodeId';
/// Returns this install's [DeviceIdentity], generating one on first call.
///
/// [legacyId] is the fixed role string an app pushed under before it moved to
/// a persisted uuid. Passing it keeps skip-own-writes correct: stamps already
/// written carry the old id, and a device that does not recognise its former
/// id re-downloads its own pre-migration history as though a peer wrote it.
/// Pass null for a new app, which has no history to be confused by.
Future<DeviceIdentity> loadDeviceIdentity({
String? legacyId,
SharedPreferences? preferences,
}) async {
final prefs = preferences ?? await SharedPreferences.getInstance();
var nodeId = prefs.getString(kNodeIdKey) ?? '';
if (nodeId.isEmpty) {
nodeId = const Uuid().v4();
await prefs.setString(kNodeIdKey, nodeId);
}
return DeviceIdentity(deviceId: nodeId, legacyId: legacyId);
}

View File

@ -0,0 +1,39 @@
/// The OS-keystore adapter every consuming app was writing by hand.
///
/// `crdt_sync` is pure Dart on purpose -- it runs from command-line tools and
/// headless systemd jobs -- so it cannot depend on `flutter_secure_storage`
/// and instead takes read/write/delete closures. Every Flutter app then wrote
/// the same three-line adapter, and four of them now differ only in which
/// bugs they have had fixed.
///
/// This file owns that adapter once.
library;
import 'package:crdt_sync/crdt_sync.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
/// Storage options shared with the GitHub token these apps already keep.
///
/// Off Android's deprecated `encryptedSharedPreferences` path, and libsecret
/// on Linux.
const FlutterSecureStorage kSecureStorage = FlutterSecureStorage();
/// Key the account blob (email + password) is stored under.
const String kAccountKey = kFirebaseAccountKey;
/// Key set when the user disconnects, to stop silent re-provisioning.
///
/// Without it the next launch re-adopts the account from whatever fallback is
/// available and the disconnect button looks broken.
const String kOptOutKey = kSyncAccountOptOutKey;
/// A [FirebaseCredentialStore] over the platform keystore.
///
/// Pass [storage] only in tests; production uses [kSecureStorage].
FirebaseCredentialStore keystoreCredentialStore({
FlutterSecureStorage storage = kSecureStorage,
}) => SecureCredentialStore(
read: (key) => storage.read(key: key),
write: (key, value) => storage.write(key: key, value: value),
delete: (key) => storage.delete(key: key),
);

View File

@ -0,0 +1,58 @@
/// An in-memory stand-in for the `flutter_secure_storage` platform channel.
///
/// Shipped from the package rather than copied into each app's `test/`: the
/// glue this package owns is exactly the glue that cannot be tested without
/// it, and a consumer adopting the package should not have to reinvent the
/// fake to test its own sync wiring.
library;
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
/// Installs the fake for the duration of the current test.
///
/// Auto-removes on tear down. Pass [initial] to pre-seed stored values, and
/// [throwing] to simulate a host with no secret service -- every call then
/// raises a [PlatformException], which is what a Linux box with no libsecret
/// actually does.
void installFakeSecureStorage({
Map<String, String>? initial,
bool throwing = false,
}) {
const channel = MethodChannel('plugins.it_nomads.com/flutter_secure_storage');
final store = <String, String>{...?initial};
final messenger =
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
void setHandler(Future<Object?>? Function(MethodCall)? handler) =>
messenger.setMockMethodCallHandler(channel, handler);
setHandler((call) async {
if (throwing) {
throw PlatformException(code: 'unavailable');
}
final args = (call.arguments as Map?) ?? const <Object?, Object?>{};
final key = args['key'] as String?;
switch (call.method) {
case 'read':
return store[key];
case 'write':
store[key!] = args['value']! as String;
return null;
case 'delete':
store.remove(key);
return null;
case 'containsKey':
return store.containsKey(key);
case 'readAll':
return Map<String, String>.from(store);
case 'deleteAll':
store.clear();
return null;
default:
return null;
}
});
addTearDown(() => setHandler(null));
}

View File

@ -0,0 +1,30 @@
name: crdt_sync_flutter
description: >-
The Flutter half of crdt_sync: keystore-backed Firebase credentials, a
persisted per-install device id, and a one-call bootstrap. Removes the
~150 lines of firebase_backend.dart every consuming app was copying.
publish_to: 'none'
version: 0.1.0
environment:
sdk: ^3.12.2
dependencies:
crdt_sync:
git:
url: https://github.com/kuhyx/utils
ref: crdt_sync_dart-v0.11.0
path: crdt_sync_dart
flutter:
sdk: flutter
flutter_secure_storage: ^9.2.4
# A real dependency, not a dev one: lib/testing/ ships the keystore fake so
# consuming apps can test their own sync wiring without rebuilding it.
flutter_test:
sdk: flutter
http: ^1.6.0
shared_preferences: ^2.5.3
uuid: ^4.5.1
dev_dependencies:
very_good_analysis: ^10.2.0

View File

@ -0,0 +1,215 @@
import 'dart:convert';
import 'dart:io';
import 'package:crdt_sync/crdt_sync.dart';
import 'package:crdt_sync_flutter/crdt_sync_flutter.dart';
import 'package:crdt_sync_flutter/testing/fake_secure_storage.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
const routes = WrapperRoutes(
credentialsPath: '/sync-credentials',
accountPath: '/sync-account',
);
final base = Uri.parse('http://127.0.0.1:8080/');
group('loadAccount', () {
test('returns the stored account without touching the wrapper', () async {
const stored = FirebaseAccount(email: 'a@b.c', password: 'pw');
installFakeSecureStorage(
initial: {kAccountKey: stored.toJsonString()},
);
var requests = 0;
final account = await loadAccount(
routes: routes,
base: base,
httpClient: MockClient((_) async {
requests += 1;
return http.Response('', 404);
}),
);
expect(account?.email, 'a@b.c');
expect(requests, 0, reason: 'the keystore already answered');
});
test('returns null with no account and no routes', () async {
installFakeSecureStorage();
expect(await loadAccount(), isNull);
});
test('honours the opt-out flag instead of re-provisioning', () async {
installFakeSecureStorage(initial: {kOptOutKey: 'true'});
var requests = 0;
final account = await loadAccount(
routes: routes,
base: base,
httpClient: MockClient((_) async {
requests += 1;
return http.Response('', 200);
}),
);
expect(account, isNull);
expect(requests, 0, reason: 'disconnect must stick');
});
test('adopts a seeded session and persists it', () async {
installFakeSecureStorage();
final body = jsonEncode({
'id_token': 'id',
'refresh_token': 'refresh',
'expires_at': DateTime.now()
.add(const Duration(hours: 1))
.toIso8601String(),
'email': 'seeded@b.c',
});
final account = await loadAccount(
routes: routes,
base: base,
httpClient: MockClient(
(request) async => request.url.path == '/sync-credentials'
? http.Response(body, 200)
: http.Response('', 404),
),
);
expect(account?.email, 'seeded@b.c');
expect(account?.password, isEmpty);
// Persisted, so the route is consulted once rather than forever.
expect((await storedAccount())?.email, 'seeded@b.c');
});
test('ignores a seeded body missing a required field', () async {
installFakeSecureStorage();
final account = await loadAccount(
routes: routes,
base: base,
httpClient: MockClient(
(_) async => http.Response(jsonEncode({'id_token': 'id'}), 200),
),
);
expect(account, isNull);
});
test('ignores a seeded body that is not a JSON object', () async {
installFakeSecureStorage();
final account = await loadAccount(
routes: routes,
base: base,
httpClient: MockClient((_) async => http.Response('[]', 200)),
);
expect(account, isNull);
});
test('skips the legacy route when the app declares none', () async {
installFakeSecureStorage();
var paths = <String>[];
final account = await loadAccount(
routes: const WrapperRoutes(credentialsPath: '/sync-credentials'),
base: base,
httpClient: MockClient((request) async {
paths.add(request.url.path);
return http.Response('', 404);
}),
);
expect(account, isNull);
expect(paths, ['/sync-credentials']);
});
test('reports not-configured when the keystore itself throws', () async {
installFakeSecureStorage(throwing: true);
expect(await loadAccount(routes: routes, base: base), isNull);
});
test('falls back to the legacy route and persists what it finds', () async {
// The un-reseeded machine: no seeded session, but the wrapper still
// serves an email/password pair from ~/.config/crdt-sync.
installFakeSecureStorage();
final account = await loadAccount(
routes: routes,
base: base,
httpClient: MockClient(
(request) async => request.url.path == '/sync-account'
? http.Response(
jsonEncode({'email': 'legacy@b.c', 'password': 'pw'}),
200,
)
: http.Response('', 404),
),
);
expect(account?.email, 'legacy@b.c');
// Written through, so the route is consulted once and not forever.
expect((await storedAccount())?.password, 'pw');
});
test('defaults to Uri.base and a real client with no wrapper', () async {
// The production call shape: no base, no injected client. Under
// `flutter test` Uri.base is a file:// URL with no host, so resolving
// the route raises an ArgumentError -- an Error, not an Exception,
// which is exactly the Android case the broad catch exists for.
installFakeSecureStorage();
expect(await loadAccount(routes: routes), isNull);
});
test('survives a wrapper that drops the connection', () async {
installFakeSecureStorage();
final account = await loadAccount(
routes: routes,
base: base,
httpClient: MockClient((_) async => throw const SocketException('x')),
);
expect(account, isNull);
});
});
group('account lifecycle', () {
test('saveAccount then storedAccount round-trips', () async {
installFakeSecureStorage();
await saveAccount(const FirebaseAccount(email: 'x@y.z', password: 'p'));
expect((await storedAccount())?.password, 'p');
});
test('clearAccount forgets the account and sets the opt-out', () async {
installFakeSecureStorage();
await saveAccount(const FirebaseAccount(email: 'x@y.z', password: 'p'));
await clearAccount();
expect(await storedAccount(), isNull);
// The flag is what stops the wrapper silently re-adopting it.
expect(await loadAccount(routes: routes, base: base), isNull);
});
test('clearAccountMarkerOnly leaves re-provisioning possible', () async {
installFakeSecureStorage();
await saveAccount(const FirebaseAccount(email: 'x@y.z', password: 'p'));
await clearAccountMarkerOnly();
expect(await storedAccount(), isNull);
final body = jsonEncode({
'id_token': 'id',
'refresh_token': 'refresh',
'expires_at': DateTime.now()
.add(const Duration(hours: 1))
.toIso8601String(),
'email': 'again@b.c',
});
final account = await loadAccount(
routes: routes,
base: base,
httpClient: MockClient((_) async => http.Response(body, 200)),
);
expect(account?.email, 'again@b.c');
});
});
}

View File

@ -0,0 +1,210 @@
import 'dart:convert';
import 'package:crdt_sync/crdt_sync.dart';
import 'package:crdt_sync_flutter/crdt_sync_flutter.dart';
import 'package:crdt_sync_flutter/testing/fake_secure_storage.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
const _app = SyncApp(
project: FirebaseProject(
apiKey: 'test-key',
databaseUrl: 'https://example-rtdb.europe-west1.firebasedatabase.app',
),
expectedUid: 'uid-123',
);
/// A live session, so no sign-in round trip is attempted.
String _session({Duration valid = const Duration(hours: 1)}) => jsonEncode({
'id_token': 'id',
'refresh_token': 'refresh',
'expires_at': DateTime.now().add(valid).toIso8601String(),
});
/// Answers Identity Toolkit as though [email] signed in.
MockClient _signsInAs(String? email, {String uid = 'uid-123'}) => MockClient(
(_) async => http.Response(
jsonEncode({
'idToken': 'id',
'refreshToken': 'refresh',
'expiresIn': '3600',
'email': email,
'localId': uid,
}),
200,
),
);
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('openSync', () {
test('returns null when nothing is configured', () async {
installFakeSecureStorage();
expect(await openSync(_app), isNull);
});
test('recovers a client from a stored session with no marker', () async {
// The state a Google sign-in used to leave behind. The refresh token is
// the credential, so this device can sync even with no account blob.
installFakeSecureStorage(
initial: {SecureCredentialStore.defaultKey: _session()},
);
final client = await openSync(_app);
expect(client, isNotNull);
client?.close();
});
test('uses a stored account with a live session', () async {
installFakeSecureStorage(
initial: {
kAccountKey: const FirebaseAccount(
email: 'a@b.c',
password: 'pw',
).toJsonString(),
SecureCredentialStore.defaultKey: _session(),
},
);
final client = await openSync(_app);
expect(client, isNotNull);
client?.close();
});
test('treats a Google account (empty password) as configured', () async {
// The regression this package's crdt_sync bump fixed: the marker parses
// now, so this takes the account path rather than falling through.
installFakeSecureStorage(
initial: {
kAccountKey: const FirebaseAccount(
email: 'g@b.c',
password: '',
).toJsonString(),
SecureCredentialStore.defaultKey: _session(),
},
);
expect(await storedAccount(), isNotNull);
final client = await openSync(_app);
expect(client, isNotNull);
client?.close();
});
});
group('signInWithGoogle', () {
test('returns null when the user dismisses the picker', () async {
installFakeSecureStorage();
final client = await signInWithGoogle(
_app,
tokenFetcher: () async => null,
);
expect(client, isNull);
expect(await storedAccount(), isNull);
});
test('persists the email Firebase reports', () async {
installFakeSecureStorage();
final client = await signInWithGoogle(
_app,
tokenFetcher: () async => 'google-token',
httpClient: _signsInAs('reported@b.c'),
);
expect(client, isNotNull);
expect((await storedAccount())?.email, 'reported@b.c');
client?.close();
});
test('stays configured when Google hides the address', () async {
// signInWithIdp omits `email` for a hidden address, so the marker is
// written as `email: ''` -- which `tryParse` rejects, deliberately: an
// address-less marker carries no identity. The device is still fully
// signed in, because the refresh token is the credential and it is
// durable. This is exactly why openSync falls back to stored-session
// recovery instead of trusting the marker.
installFakeSecureStorage();
final client = await signInWithGoogle(
_app,
tokenFetcher: () async => 'google-token',
httpClient: _signsInAs(null),
);
client?.close();
expect(client, isNotNull);
expect(await isSyncConfigured(_app), isTrue);
final recovered = await openSync(_app);
expect(recovered, isNotNull, reason: 'the session alone is enough');
recovered?.close();
});
test('throws when Google resolves to the wrong uid', () async {
installFakeSecureStorage();
await expectLater(
signInWithGoogle(
_app,
tokenFetcher: () async => 'google-token',
httpClient: _signsInAs('a@b.c', uid: 'someone-else'),
),
throwsA(isA<FirebaseAuthError>()),
);
});
});
group('isSyncConfigured', () {
test('is true with a live session', () async {
installFakeSecureStorage(
initial: {SecureCredentialStore.defaultKey: _session()},
);
expect(await isSyncConfigured(_app), isTrue);
});
test('is false with nothing stored', () async {
installFakeSecureStorage();
expect(await isSyncConfigured(_app), isFalse);
});
test('drops a marker whose session is gone', () async {
// A revoked refresh token clears the session; a marker left behind
// would report "Connected" while every sync failed.
installFakeSecureStorage(
initial: {
kAccountKey: const FirebaseAccount(
email: 'a@b.c',
password: 'pw',
).toJsonString(),
},
);
expect(await isSyncConfigured(_app), isFalse);
expect(
await storedAccount(),
isNull,
reason: 'the stale half is dropped so settings offers a sign-in',
);
});
test('reports not-configured when the keystore throws', () async {
installFakeSecureStorage(throwing: true);
expect(await isSyncConfigured(_app), isFalse);
});
});
test('openSync survives a keystore that cannot be read at all', () async {
// A Linux box with no libsecret: every keystore call raises. There is no
// account and no session to be had, so this device simply does not sync
// -- but it must not take the caller down with it.
installFakeSecureStorage(throwing: true);
expect(await openSync(_app), isNull);
});
}

View File

@ -0,0 +1,76 @@
import 'package:crdt_sync_flutter/crdt_sync_flutter.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() => SharedPreferences.setMockInitialValues({}));
test('generates a uuid on first call and persists it', () async {
final prefs = await SharedPreferences.getInstance();
final first = await loadDeviceIdentity(preferences: prefs);
expect(first.deviceId, isNotEmpty);
expect(prefs.getString(kNodeIdKey), first.deviceId);
});
test('returns the same id on every later call', () async {
final prefs = await SharedPreferences.getInstance();
final first = await loadDeviceIdentity(preferences: prefs);
final second = await loadDeviceIdentity(preferences: prefs);
// A changing id would push under a new path every launch and re-merge
// this device's own history as though a peer had written it.
expect(second.deviceId, first.deviceId);
});
test('gives two installs different ids', () async {
final a = await loadDeviceIdentity(
preferences: await SharedPreferences.getInstance(),
);
SharedPreferences.setMockInitialValues({});
final b = await loadDeviceIdentity(
preferences: await SharedPreferences.getInstance(),
);
// Two devices sharing an id overwrite each other's pushed file on every
// tick, which is the whole reason this is a uuid and not a role constant.
expect(b.deviceId, isNot(a.deviceId));
});
test('carries a legacy id so a migrated device knows itself', () async {
final identity = await loadDeviceIdentity(
legacyId: 'phone',
preferences: await SharedPreferences.getInstance(),
);
expect(identity.isOwn('phone'), isTrue);
expect(identity.isOwn(identity.deviceId), isTrue);
expect(identity.isOwn('some-other-device'), isFalse);
});
test('resolves SharedPreferences itself when none is passed', () async {
// The production call site passes nothing; the mock binding stands in for
// the real plugin, so the default path is exercised rather than assumed.
SharedPreferences.setMockInitialValues({kNodeIdKey: 'ambient-uuid'});
final identity = await loadDeviceIdentity();
expect(identity.deviceId, 'ambient-uuid');
});
test('adopts an id already written by a pre-package app', () async {
// The key matches what the existing apps wrote, so adopting this package
// keeps a device's identity instead of making it look brand new.
SharedPreferences.setMockInitialValues({kNodeIdKey: 'existing-uuid'});
final identity = await loadDeviceIdentity(
preferences: await SharedPreferences.getInstance(),
);
expect(identity.deviceId, 'existing-uuid');
});
}

View File

@ -0,0 +1,33 @@
import 'package:crdt_sync_flutter/testing/fake_secure_storage.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
const storage = FlutterSecureStorage();
test('serves the whole channel surface the plugin exposes', () async {
// The fake ships from lib/, so consuming apps rely on it for their own
// sync tests: every method the plugin can call has to answer.
installFakeSecureStorage(initial: {'seeded': 'value'});
expect(await storage.read(key: 'seeded'), 'value');
expect(await storage.containsKey(key: 'seeded'), isTrue);
await storage.write(key: 'k', value: 'v');
expect(await storage.readAll(), {'seeded': 'value', 'k': 'v'});
await storage.delete(key: 'k');
expect(await storage.containsKey(key: 'k'), isFalse);
await storage.deleteAll();
expect(await storage.readAll(), isEmpty);
});
test('throwing mode simulates a host with no secret service', () async {
installFakeSecureStorage(throwing: true);
await expectLater(storage.read(key: 'anything'), throwsA(isA<Object>()));
});
}

View File

@ -0,0 +1,80 @@
# sync_settings_ui
The shared **Sync settings** screen: Firebase sign-in (email/password or
Google one-tap), Disconnect, a live status line, and an optional Backup
section. Reused across every one of kuhy's Flutter apps that syncs, so the
screen behaves identically everywhere and is fixed in one place.
Generalized from the near-identical `_connectFirebase` / `_connectGoogle` /
`_disconnectFirebase` / `_load` logic that had been duplicated into every
app's settings screen.
## Install
```yaml
dependencies:
sync_settings_ui:
git:
url: https://github.com/kuhyx/utils
ref: sync_settings_ui-v0.1.0
path: sync_settings_ui
```
## Usage
This package **never touches `flutter_secure_storage` or `google_sign_in`
itself** — every keystore read/write and every platform sign-in arrives as an
already-built closure. [`crdt_sync_flutter`](../crdt_sync_flutter) provides
all of them, so the two fit together directly:
```dart
import 'package:crdt_sync_flutter/crdt_sync_flutter.dart';
import 'package:sync_settings_ui/sync_settings_ui.dart';
SyncSettingsScreen(
accountLoader: () => loadAccount(),
accountSaver: saveAccount,
accountClearer: clearAccount,
sessionProbe: () => isSyncConfigured(kApp),
firebaseFactory: () => openSync(kApp),
googleFirebaseFactory: () => signInWithGoogle(
kApp,
tokenFetcher: googleIdToken, // the app's own google_sign_in call
),
googleAvailable: googleSignInSupported,
backup: BackupSlot(label: 'notes', export: _export, import: _import),
)
```
Pass `backup: null` to omit the Backup section entirely.
## What it gets right, so you don't re-derive it
**"Connected" means the session probe says so** — never that a factory
returned a non-null client. `FirebaseSyncController` returns a
`FirebaseConnectResult` carrying a six-case outcome:
| Outcome | Meaning |
|---|---|
| `connected` | Signed in, and the probe confirms a live session. |
| `rejected` | The credentials were refused. |
| `cancelled` | The user dismissed the picker. |
| `signedInButNotPersisted` | Sign-in worked; nothing durable was stored. |
| `wrongAccount` | Authenticated as a uid the rules do not allow. |
| `failed` | Anything else. |
`signedInButNotPersisted` and `wrongAccount` exist because both looked
identical to success from the call site and both produced a device that
appeared connected and synced nothing.
**The Google button is double-gated** on `supportsGoogle && googleAvailable`,
so a button that cannot possibly succeed is never rendered — a visible control
that always reports "cancelled" is worse than no control.
## Development
```bash
flutter pub get
flutter analyze
flutter test
```