From fff46de4c52cdfb8429e8578e1bc32b2ad41b57a Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sat, 22 Aug 2026 17:26:34 +0200 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG --- crdt_sync_dart/README.md | 62 ++++- crdt_sync_flutter/.gitignore | 6 + crdt_sync_flutter/README.md | 176 ++++++++++++++ crdt_sync_flutter/analysis_options.yaml | 38 ++++ crdt_sync_flutter/lib/crdt_sync_flutter.dart | 17 ++ crdt_sync_flutter/lib/src/account_store.dart | 170 ++++++++++++++ crdt_sync_flutter/lib/src/bootstrap.dart | 178 +++++++++++++++ crdt_sync_flutter/lib/src/device_id.dart | 43 ++++ crdt_sync_flutter/lib/src/keystore.dart | 39 ++++ .../lib/testing/fake_secure_storage.dart | 58 +++++ crdt_sync_flutter/pubspec.yaml | 30 +++ .../test/account_store_test.dart | 215 ++++++++++++++++++ crdt_sync_flutter/test/bootstrap_test.dart | 210 +++++++++++++++++ crdt_sync_flutter/test/device_id_test.dart | 76 +++++++ .../test/fake_secure_storage_test.dart | 33 +++ sync_settings_ui/README.md | 80 +++++++ 16 files changed, 1430 insertions(+), 1 deletion(-) create mode 100644 crdt_sync_flutter/.gitignore create mode 100644 crdt_sync_flutter/README.md create mode 100644 crdt_sync_flutter/analysis_options.yaml create mode 100644 crdt_sync_flutter/lib/crdt_sync_flutter.dart create mode 100644 crdt_sync_flutter/lib/src/account_store.dart create mode 100644 crdt_sync_flutter/lib/src/bootstrap.dart create mode 100644 crdt_sync_flutter/lib/src/device_id.dart create mode 100644 crdt_sync_flutter/lib/src/keystore.dart create mode 100644 crdt_sync_flutter/lib/testing/fake_secure_storage.dart create mode 100644 crdt_sync_flutter/pubspec.yaml create mode 100644 crdt_sync_flutter/test/account_store_test.dart create mode 100644 crdt_sync_flutter/test/bootstrap_test.dart create mode 100644 crdt_sync_flutter/test/device_id_test.dart create mode 100644 crdt_sync_flutter/test/fake_secure_storage_test.dart create mode 100644 sync_settings_ui/README.md diff --git a/crdt_sync_dart/README.md b/crdt_sync_dart/README.md index 0889f1a..ddf9358 100644 --- a/crdt_sync_dart/README.md +++ b/crdt_sync_dart/README.md @@ -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( `//...`, 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 diff --git a/crdt_sync_flutter/.gitignore b/crdt_sync_flutter/.gitignore new file mode 100644 index 0000000..08e2419 --- /dev/null +++ b/crdt_sync_flutter/.gitignore @@ -0,0 +1,6 @@ +.dart_tool/ +.packages +build/ +pubspec.lock +coverage/ +*.iml diff --git a/crdt_sync_flutter/README.md b/crdt_sync_flutter/README.md new file mode 100644 index 0000000..78ef16e --- /dev/null +++ b/crdt_sync_flutter/README.md @@ -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. diff --git a/crdt_sync_flutter/analysis_options.yaml b/crdt_sync_flutter/analysis_options.yaml new file mode 100644 index 0000000..286e24c --- /dev/null +++ b/crdt_sync_flutter/analysis_options.yaml @@ -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 diff --git a/crdt_sync_flutter/lib/crdt_sync_flutter.dart b/crdt_sync_flutter/lib/crdt_sync_flutter.dart new file mode 100644 index 0000000..4764932 --- /dev/null +++ b/crdt_sync_flutter/lib/crdt_sync_flutter.dart @@ -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'; diff --git a/crdt_sync_flutter/lib/src/account_store.dart b/crdt_sync_flutter/lib/src/account_store.dart new file mode 100644 index 0000000..a4074b6 --- /dev/null +++ b/crdt_sync_flutter/lib/src/account_store.dart @@ -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 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 _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) 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 storedAccount({ + FlutterSecureStorage storage = kSecureStorage, +}) async => FirebaseAccount.tryParse(await storage.read(key: kAccountKey)); + +/// Stores the per-device account. Keystore only -- never prefs, never source. +Future 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 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 clearAccountMarkerOnly({ + FlutterSecureStorage storage = kSecureStorage, +}) => storage.delete(key: kAccountKey); diff --git a/crdt_sync_flutter/lib/src/bootstrap.dart b/crdt_sync_flutter/lib/src/bootstrap.dart new file mode 100644 index 0000000..ef8baff --- /dev/null +++ b/crdt_sync_flutter/lib/src/bootstrap.dart @@ -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 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 signInWithGoogle( + SyncApp app, { + required Future 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 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 _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, + ); +} diff --git a/crdt_sync_flutter/lib/src/device_id.dart b/crdt_sync_flutter/lib/src/device_id.dart new file mode 100644 index 0000000..749cc9b --- /dev/null +++ b/crdt_sync_flutter/lib/src/device_id.dart @@ -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 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); +} diff --git a/crdt_sync_flutter/lib/src/keystore.dart b/crdt_sync_flutter/lib/src/keystore.dart new file mode 100644 index 0000000..16c4c62 --- /dev/null +++ b/crdt_sync_flutter/lib/src/keystore.dart @@ -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), +); diff --git a/crdt_sync_flutter/lib/testing/fake_secure_storage.dart b/crdt_sync_flutter/lib/testing/fake_secure_storage.dart new file mode 100644 index 0000000..774965c --- /dev/null +++ b/crdt_sync_flutter/lib/testing/fake_secure_storage.dart @@ -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? initial, + bool throwing = false, +}) { + const channel = MethodChannel('plugins.it_nomads.com/flutter_secure_storage'); + final store = {...?initial}; + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + + void setHandler(Future? Function(MethodCall)? handler) => + messenger.setMockMethodCallHandler(channel, handler); + + setHandler((call) async { + if (throwing) { + throw PlatformException(code: 'unavailable'); + } + final args = (call.arguments as Map?) ?? const {}; + 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.from(store); + case 'deleteAll': + store.clear(); + return null; + default: + return null; + } + }); + + addTearDown(() => setHandler(null)); +} diff --git a/crdt_sync_flutter/pubspec.yaml b/crdt_sync_flutter/pubspec.yaml new file mode 100644 index 0000000..22221c1 --- /dev/null +++ b/crdt_sync_flutter/pubspec.yaml @@ -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 diff --git a/crdt_sync_flutter/test/account_store_test.dart b/crdt_sync_flutter/test/account_store_test.dart new file mode 100644 index 0000000..cca9fba --- /dev/null +++ b/crdt_sync_flutter/test/account_store_test.dart @@ -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 = []; + 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'); + }); + }); +} diff --git a/crdt_sync_flutter/test/bootstrap_test.dart b/crdt_sync_flutter/test/bootstrap_test.dart new file mode 100644 index 0000000..e2229b4 --- /dev/null +++ b/crdt_sync_flutter/test/bootstrap_test.dart @@ -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()), + ); + }); + }); + + 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); + }); +} diff --git a/crdt_sync_flutter/test/device_id_test.dart b/crdt_sync_flutter/test/device_id_test.dart new file mode 100644 index 0000000..6948beb --- /dev/null +++ b/crdt_sync_flutter/test/device_id_test.dart @@ -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'); + }); +} diff --git a/crdt_sync_flutter/test/fake_secure_storage_test.dart b/crdt_sync_flutter/test/fake_secure_storage_test.dart new file mode 100644 index 0000000..a194088 --- /dev/null +++ b/crdt_sync_flutter/test/fake_secure_storage_test.dart @@ -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())); + }); +} diff --git a/sync_settings_ui/README.md b/sync_settings_ui/README.md new file mode 100644 index 0000000..c6e473e --- /dev/null +++ b/sync_settings_ui/README.md @@ -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 +```