mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-11 19:14:00 +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:
|
crdt_sync:
|
||||||
git:
|
git:
|
||||||
url: https://github.com/kuhyx/utils
|
url: https://github.com/kuhyx/utils
|
||||||
ref: crdt_sync_dart-v0.2.0
|
ref: crdt_sync_dart-v0.11.0
|
||||||
path: crdt_sync_dart
|
path: crdt_sync_dart
|
||||||
```
|
```
|
||||||
|
|
||||||
## Usage
|
## 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
|
```dart
|
||||||
import 'package:crdt_sync/crdt_sync.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
|
`<pathPrefix>/<other-device-id>/...`, merges each into the local log with
|
||||||
`mergeLogs`, then pushes this device's own merged result back up.
|
`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`)
|
## Local persistence (`LogStore`)
|
||||||
|
|
||||||
The merge scheme above is in-memory only. For an app that needs to keep its
|
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