mirror of
https://github.com/kuhyx/todo-app.git
synced 2026-11-10 19:27:01 +01:00
Sync through Firebase, mirroring to GitHub, and stop re-downloading peers
Some checks are pending
release-apk / release (APK) (push) Waiting to run
Some checks are pending
release-apk / release (APK) (push) Waiting to run
Firebase becomes the primary backend when this device has been set up for it;
GitHub stays as a mirror so a device that has not moved yet still converges.
Not being set up is a normal state, not an error -- sync runs over GitHub
exactly as before, which is also the rollback.
This app hand-rolled its own pull-merge-push instead of calling syncLog, so it
had no revision tracking at all: every sync downloaded all four peer note logs
-- roughly 150 KB each -- whether or not anything had changed. It now reads one
small revision map first and skips any peer whose hash is unchanged, and
suppresses its own push when its log is unchanged. That is the single largest
traffic saving in the fleet and what keeps this inside the free tier.
sync() now takes a RemoteStore rather than a GitHubClient, so choosing a
backend is a change at the call site and nothing more.
runSync() is the one place that decides how a sync runs. The capture screen
syncs on a timer and on demand and the settings screen on a button; each used
to build its own client, so a backend change meant editing four call sites and
hoping none was missed.
Credentials are split by what is safe to publish, because this repo is public:
the Web API key and database URL ship in source (both already ship inside the
APK, and the security rules protect the data), while the account email and
password are entered per device and live in the OS keystore.
247 tests at 100% line coverage. Two assertions changed from `contains('PUT')`
to `isNotEmpty`: those tests sync twice, and the second push is now correctly
suppressed as a no-op -- that suppression is the point.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SJ9sxEw3ymzXNjL7SHNpJc
This commit is contained in:
parent
4d350f2476
commit
f933b4baa4
84
lib/sync/firebase_backend.dart
Normal file
84
lib/sync/firebase_backend.dart
Normal file
@ -0,0 +1,84 @@
|
||||
/// Wiring for the Firebase backend, during and after the GitHub cutover.
|
||||
///
|
||||
/// Split by what is safe to publish, because this repo is public:
|
||||
///
|
||||
/// * [kProject] holds the Web API key and database URL. Both are public
|
||||
/// identifiers that already ship inside the APK; the security rules, not
|
||||
/// their secrecy, are what protect the data.
|
||||
/// * The account email and password are entered once per device and kept in
|
||||
/// the OS keystore, next to the GitHub token this app already stores there.
|
||||
///
|
||||
/// Nothing here reads `~/.config/crdt-sync/` — that is the desktop/Python
|
||||
/// half. On Android there is no such file.
|
||||
library;
|
||||
|
||||
import 'package:crdt_sync/crdt_sync.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
/// The shared `kuhy-syncs` project.
|
||||
///
|
||||
/// `databaseUrl` is the **regional** host. The plain `*.firebaseio.com` form
|
||||
/// answers 404 with a `correctUrl` body rather than an obvious error, which
|
||||
/// reads like an auth failure and wastes a debugging session.
|
||||
const kProject = FirebaseProject(
|
||||
apiKey: 'AIzaSyCF_sA3xCMehAYXK8eND-rAygb9NXXW_8E',
|
||||
databaseUrl:
|
||||
'https://kuhy-syncs-default-rtdb.europe-west1.firebasedatabase.app',
|
||||
);
|
||||
|
||||
/// Same options the GitHub token uses: off Android's deprecated
|
||||
/// `encryptedSharedPreferences` path, and libsecret on Linux.
|
||||
const _secure = FlutterSecureStorage();
|
||||
|
||||
// Everything below reaches the OS keystore through a platform channel, which
|
||||
// `flutter test` has no binding for -- the same reason `main.dart` and
|
||||
// `openRepository()` are excluded. The logic these wrap (parsing, the
|
||||
// public/private split, sign-in) lives in `crdt_sync` and is covered there at
|
||||
// 100%; what is left here is the two-line adapter.
|
||||
// coverage:ignore-start
|
||||
|
||||
/// The keystore-backed home for the Firebase refresh token.
|
||||
SecureCredentialStore credentialStore() => SecureCredentialStore(
|
||||
read: (key) => _secure.read(key: key),
|
||||
write: (key, value) => _secure.write(key: key, value: value),
|
||||
delete: (key) => _secure.delete(key: key),
|
||||
);
|
||||
|
||||
/// Reads the per-device account, or null when sync has not been set up.
|
||||
Future<FirebaseAccount?> loadAccount() async {
|
||||
try {
|
||||
return FirebaseAccount.tryParse(
|
||||
await _secure.read(key: kFirebaseAccountKey),
|
||||
);
|
||||
} on Exception {
|
||||
// No secret service available: behave as "not configured" rather than
|
||||
// crashing the settings screen.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Stores the per-device account. Keystore only — never prefs, never source.
|
||||
Future<void> saveAccount(FirebaseAccount account) =>
|
||||
_secure.write(key: kFirebaseAccountKey, value: account.toJsonString());
|
||||
|
||||
/// Forgets the account and any cached session.
|
||||
Future<void> clearAccount() async {
|
||||
await _secure.delete(key: kFirebaseAccountKey);
|
||||
await credentialStore().clear();
|
||||
}
|
||||
|
||||
/// Returns a signed-in Firebase client, or null when not configured.
|
||||
///
|
||||
/// Signs in with the stored password only when there is no cached refresh
|
||||
/// token, so the usual path costs no authentication round trip.
|
||||
Future<FirebaseRestClient?> openFirebase() async {
|
||||
final account = await loadAccount();
|
||||
if (account == null) return null;
|
||||
return firebaseClientFor(
|
||||
config: kProject.configFor(account.email),
|
||||
store: credentialStore(),
|
||||
password: account.password,
|
||||
);
|
||||
}
|
||||
|
||||
// coverage:ignore-end
|
||||
62
lib/sync/run_sync.dart
Normal file
62
lib/sync/run_sync.dart
Normal file
@ -0,0 +1,62 @@
|
||||
/// One place that decides *how* a sync runs, for every screen that
|
||||
/// triggers one.
|
||||
///
|
||||
/// The capture screen syncs on a timer and on demand, the settings screen on a
|
||||
/// button; before this they each built their own client, so a change to the
|
||||
/// backend meant editing four call sites and hoping none was missed.
|
||||
library;
|
||||
|
||||
import 'package:crdt_sync/crdt_sync.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:todo/data/note_repository.dart';
|
||||
import 'package:todo/sync/firebase_backend.dart';
|
||||
import 'package:todo/sync/sync_service.dart';
|
||||
import 'package:todo/sync/sync_settings.dart';
|
||||
import 'package:todo/sync/sync_state_factory.dart';
|
||||
|
||||
/// Runs one sync, choosing the backend and carrying the revision cache.
|
||||
///
|
||||
/// Firebase is the primary when this device has been set up for it, with
|
||||
/// GitHub kept as a mirror so a device that has not moved yet still
|
||||
/// converges. Not being set up is a normal state, not an error — sync runs
|
||||
/// over GitHub exactly as it did before.
|
||||
///
|
||||
/// [firebaseFactory] and [stateStore] exist for tests, which must not reach
|
||||
/// the OS keystore or an application-support directory.
|
||||
Future<SyncResult> runSync(
|
||||
NoteRepository repository,
|
||||
SyncSettings settings, {
|
||||
http.Client? httpClient,
|
||||
Future<FirebaseRestClient?> Function()? firebaseFactory,
|
||||
SyncStateStore? stateStore,
|
||||
}) async {
|
||||
final github = GitHubClient(
|
||||
owner: settings.owner,
|
||||
repo: settings.repo,
|
||||
token: settings.token,
|
||||
httpClient: httpClient,
|
||||
);
|
||||
final factory = firebaseFactory ?? openFirebase;
|
||||
final firebase = await factory();
|
||||
try {
|
||||
final service = SyncService(
|
||||
// The production default resolves a real application-support directory
|
||||
// through path_provider, which has no binding under `flutter test`;
|
||||
// every test injects a store instead.
|
||||
// coverage:ignore-start
|
||||
stateStore: stateStore ?? await openSyncStateStore(),
|
||||
// coverage:ignore-end
|
||||
);
|
||||
return await service.sync(
|
||||
repository,
|
||||
firebase == null
|
||||
? github
|
||||
: MirrorStore(primary: firebase, mirror: github),
|
||||
);
|
||||
} finally {
|
||||
// Close both: unlike the long-lived desktop callers, each screen builds
|
||||
// these per run.
|
||||
github.close();
|
||||
firebase?.close();
|
||||
}
|
||||
}
|
||||
@ -9,6 +9,7 @@ class SyncResult {
|
||||
required this.mergedDevices,
|
||||
required this.pushed,
|
||||
this.skippedFiles = const [],
|
||||
this.skippedUnchanged = 0,
|
||||
});
|
||||
|
||||
/// How many other devices' logs were pulled and merged.
|
||||
@ -22,6 +23,12 @@ class SyncResult {
|
||||
/// Surfaced so a device silently dropping out of sync is visible.
|
||||
final List<String> skippedFiles;
|
||||
|
||||
/// Peers whose logs were skipped because their revision was unchanged.
|
||||
///
|
||||
/// Distinct from [skippedFiles]: this is the optimisation working, not a
|
||||
/// device dropping out.
|
||||
final int skippedUnchanged;
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'SyncResult(mergedDevices: $mergedDevices, pushed: $pushed, '
|
||||
@ -42,50 +49,118 @@ class SyncResult {
|
||||
/// two never share a path and an old client can't misread a new file.
|
||||
class SyncService {
|
||||
/// Creates a [SyncService], optionally overriding [notesDir].
|
||||
const SyncService({this.notesDir = 'todo-sync/notes'});
|
||||
const SyncService({this.notesDir = 'todo-sync/notes', this.stateStore});
|
||||
|
||||
/// Directory in the repo under which per-device note-log files live.
|
||||
final String notesDir;
|
||||
|
||||
/// Remembers each peer's last-seen revision between runs.
|
||||
///
|
||||
/// Without one, every sync downloads every peer's whole log — four files of
|
||||
/// roughly 150 KB here — whether or not anything changed. With one, an
|
||||
/// unchanged peer costs nothing beyond the shared revision map, which is
|
||||
/// what keeps this inside the Firebase free tier's monthly budget.
|
||||
final SyncStateStore? stateStore;
|
||||
|
||||
/// Where revisions live, as a sibling of [notesDir].
|
||||
String get revsDir => defaultRevsPath(notesDir);
|
||||
|
||||
/// Runs a full pull-merge-push cycle. Safe to call repeatedly.
|
||||
Future<SyncResult> sync(NoteRepository repo, GitHubClient github) async {
|
||||
///
|
||||
/// [remote] is any [RemoteStore]: GitHub alone before the cutover, or a
|
||||
/// [MirrorStore] with Firebase primary during it. Swapping backends is a
|
||||
/// change at the call site and nothing more.
|
||||
Future<SyncResult> sync(NoteRepository repo, RemoteStore remote) async {
|
||||
final nodeId = repo.nodeId;
|
||||
final ownFileName = '$nodeId.json';
|
||||
|
||||
final state = await stateStore?.load() ?? const SyncState();
|
||||
final remoteRevs = stateStore == null
|
||||
? const <String, String>{}
|
||||
: await _revisions(remote);
|
||||
final seenRevs = <String, String>{};
|
||||
|
||||
// 1. Pull: list all device log files, merge everyone else's.
|
||||
final names = await github.listDirectory(notesDir);
|
||||
final names = await remote.listDirectory(notesDir);
|
||||
var merged = 0;
|
||||
var skippedUnchanged = 0;
|
||||
final skipped = <String>[];
|
||||
for (final name in names) {
|
||||
if (name == ownFileName) continue; // our own file; skip it.
|
||||
final text = await github.getFileText('$notesDir/$name');
|
||||
final remote = text == null ? null : _decodeLog(text);
|
||||
if (remote == null) {
|
||||
final peer = name.endsWith('.json')
|
||||
? name.substring(0, name.length - 5)
|
||||
: name;
|
||||
final remoteRev = remoteRevs[peer];
|
||||
if (remoteRev != null && remoteRev == state.peerRevs[peer]) {
|
||||
// Unchanged since we last merged it, and that merge is already in the
|
||||
// local log — so the ~150 KB download is pure waste. Carry the
|
||||
// revision forward so it stays skipped next time.
|
||||
seenRevs[peer] = remoteRev;
|
||||
skippedUnchanged++;
|
||||
continue;
|
||||
}
|
||||
final text = await remote.getFileText('$notesDir/$name');
|
||||
final remoteLog = text == null ? null : _decodeLog(text);
|
||||
if (remoteLog == null) {
|
||||
// A vanished or undecodable peer file must not abort the sync, but
|
||||
// it must not vanish silently either — that's how a device drops
|
||||
// out of sync unnoticed. Report it in the result instead.
|
||||
// Deliberately not recorded as seen: a corrupt push must be retried
|
||||
// next run, not remembered as merged.
|
||||
skipped.add(name);
|
||||
continue;
|
||||
}
|
||||
await repo.importLog(remote);
|
||||
await repo.importLog(remoteLog);
|
||||
seenRevs[peer] = remoteRev ?? revisionOf(text!);
|
||||
merged++;
|
||||
}
|
||||
|
||||
// 2. Push: upload our own (now-merged) log under our node id.
|
||||
// putFileText resolves our file's current sha itself.
|
||||
await github.putFileText(
|
||||
final encoded = logToJson(repo.exportLog());
|
||||
final revision = revisionOf(encoded);
|
||||
final unchanged = stateStore != null && revision == state.pushedRev;
|
||||
if (!unchanged) {
|
||||
await remote.putFileText(
|
||||
'$notesDir/$ownFileName',
|
||||
logToJson(repo.exportLog()),
|
||||
encoded,
|
||||
message: 'sync: $nodeId @ ${DateTime.now().toUtc().toIso8601String()}',
|
||||
);
|
||||
// Published after the log, never before: a peer that cached "seen rev X"
|
||||
// against a log it never received would skip it forever.
|
||||
if (stateStore != null) {
|
||||
await remote.putFileText(
|
||||
'$revsDir/$nodeId',
|
||||
revision,
|
||||
message: 'sync: revision $nodeId',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (stateStore != null) {
|
||||
await stateStore!.save(
|
||||
SyncState(pushedRev: revision, peerRevs: seenRevs),
|
||||
);
|
||||
}
|
||||
|
||||
return SyncResult(
|
||||
mergedDevices: merged,
|
||||
pushed: true,
|
||||
pushed: !unchanged,
|
||||
skippedFiles: skipped,
|
||||
skippedUnchanged: skippedUnchanged,
|
||||
);
|
||||
}
|
||||
|
||||
/// Reads every peer's published revision in one request where possible.
|
||||
///
|
||||
/// Degrades to an empty map — meaning "fetch everything", the old
|
||||
/// behaviour — on a backend without a bulk-map read, so correctness never
|
||||
/// depends on the optimisation being available.
|
||||
Future<Map<String, String>> _revisions(RemoteStore remote) async {
|
||||
if (remote is! BulkMapReader) return const {};
|
||||
return (remote as BulkMapReader).getStringMap(revsDir);
|
||||
}
|
||||
|
||||
/// Parses a remote note log, returning `null` for a corrupt or wrong-shape
|
||||
/// payload so one bad device file never aborts the whole sync.
|
||||
Log? _decodeLog(String text) {
|
||||
|
||||
9
lib/sync/sync_state_factory.dart
Normal file
9
lib/sync/sync_state_factory.dart
Normal file
@ -0,0 +1,9 @@
|
||||
/// Platform entry point for the sync revision cache.
|
||||
///
|
||||
/// A conditional export because `dart:io` cannot even be *imported* in a web
|
||||
/// compile: Android gets a file, the Chrome-wrapper desktop build gets a
|
||||
/// `SharedPreferences` entry.
|
||||
library;
|
||||
|
||||
export 'sync_state_factory_io.dart'
|
||||
if (dart.library.js_interop) 'sync_state_factory_web.dart';
|
||||
29
lib/sync/sync_state_factory_io.dart
Normal file
29
lib/sync/sync_state_factory_io.dart
Normal file
@ -0,0 +1,29 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:crdt_sync/crdt_sync.dart';
|
||||
import 'package:crdt_sync/crdt_sync_io.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
/// File holding the revision cache, beside the note log it describes.
|
||||
const kSyncStateFileName = 'sync_state.json';
|
||||
|
||||
/// Opens the revision cache on a `dart:io` platform (Android).
|
||||
// coverage:ignore-start
|
||||
// Resolves the real per-platform application-support directory, so it cannot
|
||||
// run under test; [openSyncStateStoreIn] holds the logic and is covered.
|
||||
Future<SyncStateStore> openSyncStateStore() async {
|
||||
final dir = await getApplicationSupportDirectory();
|
||||
return openSyncStateStoreIn(dir.path);
|
||||
}
|
||||
// coverage:ignore-end
|
||||
|
||||
/// Opens the revision cache rooted at [dirPath].
|
||||
///
|
||||
/// Lives next to the log it describes and must be cleared with it: skipping
|
||||
/// an unchanged peer is only sound because that peer's records are already
|
||||
/// merged into the local log, so state that outlived its log would skip peers
|
||||
/// whose data had been lost.
|
||||
SyncStateStore openSyncStateStoreIn(String dirPath) => PersistedSyncStateStore(
|
||||
FileLogPersistence(File(p.join(dirPath, kSyncStateFileName))),
|
||||
);
|
||||
27
lib/sync/sync_state_factory_web.dart
Normal file
27
lib/sync/sync_state_factory_web.dart
Normal file
@ -0,0 +1,27 @@
|
||||
import 'package:crdt_sync/crdt_sync.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// Key holding the revision cache in `SharedPreferences`.
|
||||
const kSyncStateKey = 'sync.state';
|
||||
|
||||
/// Opens the revision cache on web (the Chrome-wrapper desktop build).
|
||||
///
|
||||
/// `SharedPreferences` rather than IndexedDB: the payload is two short hashes
|
||||
/// per peer, so a key-value entry is the whole requirement, and the note
|
||||
/// log already owns the IndexedDB store.
|
||||
Future<SyncStateStore> openSyncStateStore() async => PersistedSyncStateStore(
|
||||
_PrefsPersistence(await SharedPreferences.getInstance()),
|
||||
);
|
||||
|
||||
class _PrefsPersistence implements LogPersistence {
|
||||
_PrefsPersistence(this._prefs);
|
||||
|
||||
final SharedPreferences _prefs;
|
||||
|
||||
@override
|
||||
Future<String?> read() async => _prefs.getString(kSyncStateKey);
|
||||
|
||||
@override
|
||||
Future<void> write(String text) async =>
|
||||
_prefs.setString(kSyncStateKey, text);
|
||||
}
|
||||
@ -10,6 +10,7 @@ import 'package:todo/data/note_template.dart';
|
||||
import 'package:todo/frame_stats.dart';
|
||||
import 'package:todo/sync/local_backup.dart';
|
||||
import 'package:todo/sync/local_backup_factory.dart';
|
||||
import 'package:todo/sync/run_sync.dart';
|
||||
import 'package:todo/sync/sync_service.dart';
|
||||
import 'package:todo/sync/sync_settings.dart';
|
||||
import 'package:todo/ui/note_editor.dart';
|
||||
@ -30,12 +31,21 @@ class CaptureScreen extends StatefulWidget {
|
||||
required this.repository,
|
||||
this.httpClient,
|
||||
this.localBackup,
|
||||
this.firebaseFactory,
|
||||
this.stateStore,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The store the captured note is persisted to on every keystroke.
|
||||
final NoteRepository repository;
|
||||
|
||||
/// Builds the Firebase backend. Injected so tests can supply a fake, or
|
||||
/// null to assert the pre-migration GitHub-only path still works.
|
||||
final Future<FirebaseRestClient?> Function()? firebaseFactory;
|
||||
|
||||
/// Revision cache. Injected so tests need no application-support directory.
|
||||
final SyncStateStore? stateStore;
|
||||
|
||||
/// Injectable HTTP client for the sync path. Production leaves this null
|
||||
/// (the GitHubClient creates its own); tests pass a mock so the configured
|
||||
/// sync flow can be exercised without real network access.
|
||||
@ -87,7 +97,6 @@ class _CaptureScreenState extends State<CaptureScreen>
|
||||
Priority _draftPriority = Priority.defaultValue;
|
||||
Status _draftStatus = Status.todo;
|
||||
|
||||
final SyncService _syncService = const SyncService();
|
||||
SyncSettings? _settings;
|
||||
bool _syncing = false;
|
||||
|
||||
@ -224,21 +233,20 @@ class _CaptureScreenState extends State<CaptureScreen>
|
||||
final settings = _settings;
|
||||
if (_autoSyncing || settings == null || !settings.isConfigured) return;
|
||||
_autoSyncing = true;
|
||||
final client = GitHubClient(
|
||||
owner: settings.owner,
|
||||
repo: settings.repo,
|
||||
token: settings.token,
|
||||
httpClient: widget.httpClient,
|
||||
);
|
||||
try {
|
||||
final result = await _syncService.sync(widget.repository, client);
|
||||
final result = await runSync(
|
||||
widget.repository,
|
||||
settings,
|
||||
httpClient: widget.httpClient,
|
||||
firebaseFactory: widget.firebaseFactory,
|
||||
stateStore: widget.stateStore,
|
||||
);
|
||||
await _recordSyncStatus(ok: true, detail: _describe(result));
|
||||
} on Exception catch (e) {
|
||||
// Offline or a transient GitHub error: still no snackbar (this path
|
||||
// must never interrupt capture), but the status line shows it.
|
||||
await _recordSyncStatus(ok: false, detail: '$e');
|
||||
} finally {
|
||||
client.close();
|
||||
_autoSyncing = false;
|
||||
}
|
||||
}
|
||||
@ -283,14 +291,14 @@ class _CaptureScreenState extends State<CaptureScreen>
|
||||
return;
|
||||
}
|
||||
setState(() => _syncing = true);
|
||||
final client = GitHubClient(
|
||||
owner: settings.owner,
|
||||
repo: settings.repo,
|
||||
token: settings.token,
|
||||
httpClient: widget.httpClient,
|
||||
);
|
||||
try {
|
||||
final result = await _syncService.sync(widget.repository, client);
|
||||
final result = await runSync(
|
||||
widget.repository,
|
||||
settings,
|
||||
httpClient: widget.httpClient,
|
||||
firebaseFactory: widget.firebaseFactory,
|
||||
stateStore: widget.stateStore,
|
||||
);
|
||||
final detail = _describe(result);
|
||||
await _recordSyncStatus(ok: true, detail: detail);
|
||||
_showSnack('Synced: $detail');
|
||||
@ -298,7 +306,6 @@ class _CaptureScreenState extends State<CaptureScreen>
|
||||
await _recordSyncStatus(ok: false, detail: '$e');
|
||||
_showSnack('Sync failed: $e');
|
||||
} finally {
|
||||
client.close();
|
||||
if (mounted) setState(() => _syncing = false);
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,7 +9,7 @@ import 'package:todo/data/note_repository.dart';
|
||||
import 'package:todo/sync/backlog_export.dart';
|
||||
import 'package:todo/sync/github_device_auth.dart';
|
||||
import 'package:todo/sync/notes_markdown.dart';
|
||||
import 'package:todo/sync/sync_service.dart';
|
||||
import 'package:todo/sync/run_sync.dart';
|
||||
import 'package:todo/sync/sync_settings.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
@ -25,6 +25,8 @@ class SettingsScreen extends StatefulWidget {
|
||||
required this.initial,
|
||||
required this.repository,
|
||||
this.httpClient,
|
||||
this.firebaseFactory,
|
||||
this.stateStore,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@ -34,6 +36,13 @@ class SettingsScreen extends StatefulWidget {
|
||||
/// The store backup export/import reads from and writes to.
|
||||
final NoteRepository repository;
|
||||
|
||||
/// Builds the Firebase backend. Injected so tests can supply a fake, or
|
||||
/// null to assert the pre-migration GitHub-only path still works.
|
||||
final Future<FirebaseRestClient?> Function()? firebaseFactory;
|
||||
|
||||
/// Revision cache. Injected so tests need no application-support directory.
|
||||
final SyncStateStore? stateStore;
|
||||
|
||||
/// Optional HTTP client for the GitHub calls (test-connection and device
|
||||
/// flow). Injected by tests; production uses each client's default.
|
||||
final http.Client? httpClient;
|
||||
@ -113,14 +122,14 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
/// immediately and they get clear confirmation it worked.
|
||||
Future<void> _syncAfterConnect() async {
|
||||
final s = _current;
|
||||
final client = GitHubClient(
|
||||
owner: s.owner,
|
||||
repo: s.repo,
|
||||
token: s.token,
|
||||
httpClient: widget.httpClient,
|
||||
);
|
||||
try {
|
||||
final result = await const SyncService().sync(widget.repository, client);
|
||||
final result = await runSync(
|
||||
widget.repository,
|
||||
s,
|
||||
httpClient: widget.httpClient,
|
||||
firebaseFactory: widget.firebaseFactory,
|
||||
stateStore: widget.stateStore,
|
||||
);
|
||||
if (mounted) {
|
||||
setState(
|
||||
() => _status =
|
||||
@ -130,8 +139,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
}
|
||||
} on Exception catch (e) {
|
||||
if (mounted) setState(() => _status = 'Connected, but sync failed: $e');
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -101,11 +101,11 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: crdt_sync_dart
|
||||
ref: "crdt_sync_dart-v0.3.0"
|
||||
resolved-ref: "5480e723354c31e2a38dd2757d5d2c75a04e9618"
|
||||
ref: "crdt_sync_dart-v0.6.0"
|
||||
resolved-ref: fc99eff00599a4c3195e4c1573ba08f15909a10d
|
||||
url: "https://github.com/kuhyx/utils"
|
||||
source: git
|
||||
version: "0.3.0"
|
||||
version: "0.6.0"
|
||||
cross_file:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@ -33,7 +33,7 @@ dependencies:
|
||||
crdt_sync:
|
||||
git:
|
||||
url: https://github.com/kuhyx/utils
|
||||
ref: crdt_sync_dart-v0.3.0
|
||||
ref: crdt_sync_dart-v0.6.0
|
||||
path: crdt_sync_dart
|
||||
|
||||
# The following adds the Cupertino Icons font to your application.
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:crdt_sync/crdt_sync.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
@ -27,6 +28,7 @@ void main() {
|
||||
List<Note> seed = const [],
|
||||
FakeNoteRepository? repository,
|
||||
LocalBackup? localBackup,
|
||||
Future<FirebaseRestClient?> Function()? firebaseFactory,
|
||||
}) async {
|
||||
SharedPreferences.setMockInitialValues(prefs);
|
||||
installFakeSecureStorage();
|
||||
@ -53,6 +55,11 @@ void main() {
|
||||
home: CaptureScreen(
|
||||
repository: repo,
|
||||
httpClient: httpClient,
|
||||
// Both injected so the widget never reaches for the platform: the
|
||||
// real factories want the OS keystore and an application-support
|
||||
// directory, neither of which exists under `flutter test`.
|
||||
firebaseFactory: firebaseFactory ?? () async => null,
|
||||
stateStore: InMemorySyncStateStore(),
|
||||
localBackup: backup,
|
||||
),
|
||||
),
|
||||
@ -343,7 +350,10 @@ void main() {
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
expect(methods, contains('PUT'));
|
||||
// A sync ran. Not `contains('PUT')`: the launch sync above already pushed
|
||||
// this log, so revision tracking correctly suppresses the second, unchanged
|
||||
// push -- that suppression is the point of the revision cache.
|
||||
expect(methods, isNotEmpty);
|
||||
});
|
||||
|
||||
testWidgets('auto-sync failure shows no snackbar but flags the status line', (
|
||||
@ -418,7 +428,10 @@ void main() {
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
expect(methods, contains('PUT'));
|
||||
// A sync ran. Not `contains('PUT')`: the first sync above already pushed
|
||||
// this log, so revision tracking correctly suppresses the second, unchanged
|
||||
// push -- that suppression is the point of the revision cache.
|
||||
expect(methods, isNotEmpty);
|
||||
});
|
||||
|
||||
testWidgets('recovers notes from the local backup into an empty DB', (
|
||||
|
||||
95
test/run_sync_test.dart
Normal file
95
test/run_sync_test.dart
Normal file
@ -0,0 +1,95 @@
|
||||
/// Tests for the one place that decides *how* a sync runs.
|
||||
///
|
||||
/// The branch that matters is which backend ends up authoritative: a rollback
|
||||
/// depends on getting exactly that right.
|
||||
@TestOn('vm')
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:crdt_sync/crdt_sync.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart' as http_testing;
|
||||
import 'package:todo/data/note_repository.dart';
|
||||
import 'package:todo/sync/run_sync.dart';
|
||||
import 'package:todo/sync/sync_settings.dart';
|
||||
|
||||
const _settings = SyncSettings(owner: 'kuhyx', repo: 'syncs', token: 'tok');
|
||||
|
||||
void main() {
|
||||
late NoteRepository repo;
|
||||
|
||||
setUp(() async {
|
||||
repo = await NoteRepository.openInMemory(nodeId: 'me');
|
||||
});
|
||||
|
||||
tearDown(() async => repo.close());
|
||||
|
||||
test('syncs over GitHub alone when Firebase is not set up', () async {
|
||||
// The pre-migration path, and the rollback: no Firebase, no mirror.
|
||||
final paths = <String>[];
|
||||
final result = await runSync(
|
||||
repo,
|
||||
_settings,
|
||||
httpClient: http_testing.MockClient((request) async {
|
||||
paths.add('${request.method} ${request.url.host}');
|
||||
return http.Response(jsonEncode({'content': '', 'sha': 'x'}), 200);
|
||||
}),
|
||||
firebaseFactory: () async => null,
|
||||
stateStore: InMemorySyncStateStore(),
|
||||
);
|
||||
|
||||
expect(result.pushed, isTrue);
|
||||
expect(paths.every((p) => p.contains('api.github.com')), isTrue);
|
||||
});
|
||||
|
||||
test('makes Firebase primary and still mirrors to GitHub', () async {
|
||||
// The cutover guarantee: both backends receive the write.
|
||||
final githubPaths = <String>[];
|
||||
final firebasePaths = <String>[];
|
||||
final firebase = FirebaseRestClient(
|
||||
databaseUrl: 'https://x-rtdb.europe-west1.firebasedatabase.app',
|
||||
auth: FirebaseTokenProvider(
|
||||
apiKey: 'AIzaKey',
|
||||
store: InMemoryCredentialStore(
|
||||
FirebaseCredentials(
|
||||
idToken: 'id',
|
||||
refreshToken: 'refresh',
|
||||
// Real clock: the provider compares against DateTime.now(), so a
|
||||
// fixture-dated session would look expired and force a refresh.
|
||||
expiresAt: DateTime.now().add(const Duration(hours: 1)),
|
||||
),
|
||||
),
|
||||
),
|
||||
httpClient: http_testing.MockClient((request) async {
|
||||
firebasePaths.add('${request.method} ${request.url.path}');
|
||||
if (request.method == 'PUT') return http.Response(request.body, 200);
|
||||
return http.Response('null', 200);
|
||||
}),
|
||||
);
|
||||
|
||||
final result = await runSync(
|
||||
repo,
|
||||
_settings,
|
||||
httpClient: http_testing.MockClient((request) async {
|
||||
githubPaths.add(request.method);
|
||||
return http.Response(jsonEncode({'content': '', 'sha': 'x'}), 200);
|
||||
}),
|
||||
firebaseFactory: () async => firebase,
|
||||
stateStore: InMemorySyncStateStore(),
|
||||
);
|
||||
|
||||
expect(result.pushed, isTrue);
|
||||
expect(
|
||||
firebasePaths.any((p) => p.startsWith('PUT')),
|
||||
isTrue,
|
||||
reason: 'Firebase is primary and must receive the write',
|
||||
);
|
||||
expect(
|
||||
githubPaths,
|
||||
contains('PUT'),
|
||||
reason: 'GitHub must still be mirrored during the cutover',
|
||||
);
|
||||
});
|
||||
}
|
||||
@ -2,6 +2,7 @@ import 'dart:io';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:file_selector_platform_interface/file_selector_platform_interface.dart';
|
||||
import 'package:crdt_sync/crdt_sync.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
@ -104,6 +105,11 @@ void main() {
|
||||
initial: initial,
|
||||
repository: repo,
|
||||
httpClient: httpClient,
|
||||
// Both injected so the widget never reaches for the platform: the
|
||||
// real factories want the OS keystore and an application-support
|
||||
// directory, neither of which exists under `flutter test`.
|
||||
firebaseFactory: () async => null,
|
||||
stateStore: InMemorySyncStateStore(),
|
||||
),
|
||||
),
|
||||
);
|
||||
@ -365,6 +371,8 @@ void main() {
|
||||
token: 'tok',
|
||||
),
|
||||
repository: repo,
|
||||
firebaseFactory: () async => null,
|
||||
stateStore: InMemorySyncStateStore(),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@ -55,6 +55,7 @@ Note _note(String id, String text) => Note(
|
||||
);
|
||||
|
||||
void main() {
|
||||
_revisionTrackingTests();
|
||||
test('pulls and merges another device, then pushes its own', () async {
|
||||
final other = await NoteRepository.openInMemory(nodeId: 'otherNode');
|
||||
await other.upsert(_note('x', 'from other device'));
|
||||
@ -153,3 +154,182 @@ void main() {
|
||||
expect(putCalls, hasLength(1)); // the push still happened
|
||||
});
|
||||
}
|
||||
|
||||
/// An in-memory [RemoteStore] that also serves revision maps in one read,
|
||||
/// standing in for Firebase. Counts reads so tests can assert on *traffic*
|
||||
/// rather than on the merge result.
|
||||
class _FakeRemote implements RemoteStore, BulkMapReader {
|
||||
_FakeRemote(this.files);
|
||||
|
||||
final Map<String, String> files;
|
||||
final List<String> reads = [];
|
||||
final List<String> writes = [];
|
||||
|
||||
@override
|
||||
Future<List<String>> listDirectory(String path) async => files.keys
|
||||
.where((k) => k.startsWith('$path/'))
|
||||
.map((k) => k.substring(path.length + 1).split('/').first)
|
||||
.toSet()
|
||||
.toList();
|
||||
|
||||
@override
|
||||
Future<String?> getFileText(String path) async {
|
||||
reads.add(path);
|
||||
return files[path];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> putFileText(
|
||||
String path,
|
||||
String text, {
|
||||
required String message,
|
||||
}) async {
|
||||
writes.add(path);
|
||||
files[path] = text;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Map<String, String>> getStringMap(String path) async => {
|
||||
for (final e in files.entries)
|
||||
if (e.key.startsWith('$path/')) e.key.substring(path.length + 1): e.value,
|
||||
};
|
||||
|
||||
@override
|
||||
Future<void> deleteFile(String path, {String message = ''}) async =>
|
||||
files.remove(path);
|
||||
|
||||
@override
|
||||
Future<bool> canAccessRemote() async => true;
|
||||
|
||||
@override
|
||||
void close() {}
|
||||
}
|
||||
|
||||
/// A [RemoteStore] with no bulk-map read, standing in for GitHub.
|
||||
///
|
||||
/// Delegates rather than extends: a subclass of [_FakeRemote] would inherit
|
||||
/// `BulkMapReader`, the very capability this fake exists to lack.
|
||||
class _FakeRemoteWithoutBulkRead implements RemoteStore {
|
||||
_FakeRemoteWithoutBulkRead(Map<String, String> files)
|
||||
: _inner = _FakeRemote(files);
|
||||
|
||||
final _FakeRemote _inner;
|
||||
|
||||
List<String> get reads => _inner.reads;
|
||||
|
||||
@override
|
||||
Future<List<String>> listDirectory(String path) => _inner.listDirectory(path);
|
||||
|
||||
@override
|
||||
Future<String?> getFileText(String path) => _inner.getFileText(path);
|
||||
|
||||
@override
|
||||
Future<void> putFileText(
|
||||
String path,
|
||||
String text, {
|
||||
required String message,
|
||||
}) => _inner.putFileText(path, text, message: message);
|
||||
|
||||
@override
|
||||
Future<void> deleteFile(String path, {String message = ''}) =>
|
||||
_inner.deleteFile(path, message: message);
|
||||
|
||||
@override
|
||||
Future<bool> canAccessRemote() => _inner.canAccessRemote();
|
||||
|
||||
@override
|
||||
void close() => _inner.close();
|
||||
}
|
||||
|
||||
void _revisionTrackingTests() {
|
||||
group('revision tracking', () {
|
||||
late NoteRepository repo;
|
||||
|
||||
setUp(() async {
|
||||
repo = await NoteRepository.openInMemory(nodeId: 'me');
|
||||
});
|
||||
|
||||
tearDown(() async => repo.close());
|
||||
|
||||
test('skips a peer whose revision has not changed', () async {
|
||||
// The saving the free-tier budget depends on: an unchanged ~150 KB peer
|
||||
// log must not be downloaded again.
|
||||
final peerLog = logToJson(await _peerLog());
|
||||
final remote = _FakeRemote({
|
||||
'todo-sync/notes/phone.json': peerLog,
|
||||
'todo-sync/revs/phone': revisionOf(peerLog),
|
||||
});
|
||||
final store = InMemorySyncStateStore();
|
||||
|
||||
await SyncService(stateStore: store).sync(repo, remote);
|
||||
remote.reads.clear();
|
||||
final second = await SyncService(stateStore: store).sync(repo, remote);
|
||||
|
||||
expect(remote.reads, isNot(contains('todo-sync/notes/phone.json')));
|
||||
expect(second.skippedUnchanged, 1);
|
||||
expect(second.mergedDevices, 0);
|
||||
});
|
||||
|
||||
test('downloads again once the peer publishes a new revision', () async {
|
||||
final first = logToJson(await _peerLog());
|
||||
final remote = _FakeRemote({
|
||||
'todo-sync/notes/phone.json': first,
|
||||
'todo-sync/revs/phone': revisionOf(first),
|
||||
});
|
||||
final store = InMemorySyncStateStore();
|
||||
await SyncService(stateStore: store).sync(repo, remote);
|
||||
remote.reads.clear();
|
||||
|
||||
final changed = logToJson(await _peerLog(title: 'changed'));
|
||||
remote.files['todo-sync/notes/phone.json'] = changed;
|
||||
remote.files['todo-sync/revs/phone'] = revisionOf(changed);
|
||||
final second = await SyncService(stateStore: store).sync(repo, remote);
|
||||
|
||||
expect(remote.reads, contains('todo-sync/notes/phone.json'));
|
||||
expect(second.mergedDevices, 1);
|
||||
});
|
||||
|
||||
test('suppresses an unchanged push and publishes a revision', () async {
|
||||
final remote = _FakeRemote({});
|
||||
final store = InMemorySyncStateStore();
|
||||
|
||||
final first = await SyncService(stateStore: store).sync(repo, remote);
|
||||
expect(first.pushed, isTrue);
|
||||
expect(remote.writes, [
|
||||
'todo-sync/notes/me.json',
|
||||
'todo-sync/revs/me',
|
||||
]);
|
||||
remote.writes.clear();
|
||||
|
||||
final second = await SyncService(stateStore: store).sync(repo, remote);
|
||||
|
||||
expect(second.pushed, isFalse);
|
||||
expect(remote.writes, isEmpty);
|
||||
});
|
||||
|
||||
test('still syncs on a backend without a bulk-map read', () async {
|
||||
// GitHub has no cheap revision map; correctness must not depend on the
|
||||
// optimisation being available.
|
||||
final peerLog = logToJson(await _peerLog());
|
||||
final remote = _FakeRemoteWithoutBulkRead({
|
||||
'todo-sync/notes/phone.json': peerLog,
|
||||
});
|
||||
|
||||
final result = await SyncService(
|
||||
stateStore: InMemorySyncStateStore(),
|
||||
).sync(repo, remote);
|
||||
|
||||
expect(result.mergedDevices, 1);
|
||||
expect(remote.reads, contains('todo-sync/notes/phone.json'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Builds a one-note log as it would arrive from another device.
|
||||
Future<Log> _peerLog({String title = 'from phone'}) async {
|
||||
final peer = await NoteRepository.openInMemory(nodeId: 'phone');
|
||||
await peer.upsert(_note('peer-note', title));
|
||||
final log = peer.exportLog();
|
||||
await peer.close();
|
||||
return log;
|
||||
}
|
||||
|
||||
54
test/sync_state_factory_io_test.dart
Normal file
54
test/sync_state_factory_io_test.dart
Normal file
@ -0,0 +1,54 @@
|
||||
/// Tests for the file-backed revision cache.
|
||||
///
|
||||
/// The cache is what keeps a sync tick from re-downloading every peer's whole
|
||||
/// log when nothing has changed, so "does it actually survive a restart" is
|
||||
/// the behaviour worth pinning.
|
||||
@TestOn('vm')
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:crdt_sync/crdt_sync.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:todo/sync/sync_state_factory_io.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
void main() {
|
||||
late Directory dir;
|
||||
|
||||
setUp(() async {
|
||||
dir = await Directory.systemTemp.createTemp('todo_state_');
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
if (dir.existsSync()) await dir.delete(recursive: true);
|
||||
});
|
||||
|
||||
test('persists revisions across a restart', () async {
|
||||
// A fresh store instance stands in for the next app launch: an
|
||||
// in-memory cache would forget every peer and re-download everything.
|
||||
await openSyncStateStoreIn(dir.path).save(
|
||||
const SyncState(pushedRev: 'mine', peerRevs: {'phone': 'theirs'}),
|
||||
);
|
||||
|
||||
final reloaded = await openSyncStateStoreIn(dir.path).load();
|
||||
|
||||
expect(reloaded.pushedRev, 'mine');
|
||||
expect(reloaded.peerRevs, {'phone': 'theirs'});
|
||||
});
|
||||
|
||||
test('writes beside the log it describes', () async {
|
||||
await openSyncStateStoreIn(
|
||||
dir.path,
|
||||
).save(const SyncState(pushedRev: 'x'));
|
||||
|
||||
expect(File(p.join(dir.path, kSyncStateFileName)).existsSync(), isTrue);
|
||||
});
|
||||
|
||||
test('reports nothing remembered before the first sync', () async {
|
||||
final state = await openSyncStateStoreIn(dir.path).load();
|
||||
|
||||
expect(state.pushedRev, isNull);
|
||||
expect(state.peerRevs, isEmpty);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user