mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 19:07:17 +01:00
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:
parent
feaaf69103
commit
fff46de4c5
@ -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
6
crdt_sync_flutter/.gitignore
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
.dart_tool/
|
||||
.packages
|
||||
build/
|
||||
pubspec.lock
|
||||
coverage/
|
||||
*.iml
|
||||
176
crdt_sync_flutter/README.md
Normal file
176
crdt_sync_flutter/README.md
Normal 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.
|
||||
38
crdt_sync_flutter/analysis_options.yaml
Normal file
38
crdt_sync_flutter/analysis_options.yaml
Normal 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
|
||||
17
crdt_sync_flutter/lib/crdt_sync_flutter.dart
Normal file
17
crdt_sync_flutter/lib/crdt_sync_flutter.dart
Normal 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';
|
||||
170
crdt_sync_flutter/lib/src/account_store.dart
Normal file
170
crdt_sync_flutter/lib/src/account_store.dart
Normal 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);
|
||||
178
crdt_sync_flutter/lib/src/bootstrap.dart
Normal file
178
crdt_sync_flutter/lib/src/bootstrap.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
43
crdt_sync_flutter/lib/src/device_id.dart
Normal file
43
crdt_sync_flutter/lib/src/device_id.dart
Normal 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);
|
||||
}
|
||||
39
crdt_sync_flutter/lib/src/keystore.dart
Normal file
39
crdt_sync_flutter/lib/src/keystore.dart
Normal 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),
|
||||
);
|
||||
58
crdt_sync_flutter/lib/testing/fake_secure_storage.dart
Normal file
58
crdt_sync_flutter/lib/testing/fake_secure_storage.dart
Normal 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));
|
||||
}
|
||||
30
crdt_sync_flutter/pubspec.yaml
Normal file
30
crdt_sync_flutter/pubspec.yaml
Normal 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
|
||||
215
crdt_sync_flutter/test/account_store_test.dart
Normal file
215
crdt_sync_flutter/test/account_store_test.dart
Normal 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');
|
||||
});
|
||||
});
|
||||
}
|
||||
210
crdt_sync_flutter/test/bootstrap_test.dart
Normal file
210
crdt_sync_flutter/test/bootstrap_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
76
crdt_sync_flutter/test/device_id_test.dart
Normal file
76
crdt_sync_flutter/test/device_id_test.dart
Normal 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');
|
||||
});
|
||||
}
|
||||
33
crdt_sync_flutter/test/fake_secure_storage_test.dart
Normal file
33
crdt_sync_flutter/test/fake_secure_storage_test.dart
Normal 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>()));
|
||||
});
|
||||
}
|
||||
80
sync_settings_ui/README.md
Normal file
80
sync_settings_ui/README.md
Normal 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
|
||||
```
|
||||
Loading…
Reference in New Issue
Block a user