mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-10 19:07:15 +01:00
The Dart half of the credential sync: a `KuchniaCredentialService` that owns the value locally, a `sync_merge_kuchnia.dart` adapter mirroring the Python one, and a `_syncKuchniaCredential` tick alongside the banks and budget. The phone can now receive the panel password, which is the prerequisite for it fetching anything itself. One source of truth, per the answered question: the value lives in the synced document store and nowhere else. Deliberately *not* mirrored into `flutter_secure_storage` -- two homes for one value is how a stale password survives a change made on the other device, and the credential has to be readable synchronously at merge time anyway. Gated by a second shared fixture, `tests/fixtures/kuchnia_credential.json`, asserted by both suites. Two cases carry the weight: - an **unparsable** edit time must fall back to the same clock on both sides (Python catches `ValueError` from `fromisoformat`, Dart's `tryParse` returns null). Verified they agree on `wall_time_ms == 0`; mutating that expectation fails both suites. - a **blank half** contributes nothing, so a half-filled settings form cannot push an empty-string password that wins LWW against a real one. `KuchniaCredentialService.init()` is called in the WorkManager isolate as well as `main()`. A fresh isolate has its own static state and `_syncKuchniaCredential` no-ops when the singleton is missing, so without it a background tick would neither publish this device's credential nor receive a peer's -- and on the phone the background tick is most of the syncing there is. Same class of trap as the `initSyncDeviceId()` one already documented there. `.toDouble()` came off the bank record: the fields are already `double`, so it was a no-op the analyzer flags. The field *types* are what keep the encoding byte-identical to Python's, and `kuchnia_parity_test.dart` asserts that. 654 Dart tests, 1040 Python tests, 100% Python branch coverage. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QQpCiPaSbyRpwsLkH3dgsX
107 lines
5.0 KiB
Dart
107 lines
5.0 KiB
Dart
/// Connectivity-gated background push: the offline backstop for the
|
|
/// immediate-push-on-log behaviour. When a meal is logged while the device is
|
|
/// offline the in-process auto-sync fails silently; a WorkManager one-off task
|
|
/// with a `NetworkType.connected` constraint (enqueued on every log) fires on
|
|
/// reconnect and uploads the log without the app being reopened.
|
|
library;
|
|
|
|
import 'dart:developer';
|
|
import 'package:diet_guard_app/services/app_settings_service.dart';
|
|
import 'package:diet_guard_app/services/budget_history_service.dart';
|
|
import 'package:diet_guard_app/services/firebase_client.dart';
|
|
import 'package:diet_guard_app/services/foodbank_service.dart';
|
|
import 'package:diet_guard_app/services/github_client_factory.dart';
|
|
import 'package:diet_guard_app/services/kuchnia_credential_service.dart';
|
|
import 'package:diet_guard_app/services/log_storage_service.dart';
|
|
import 'package:diet_guard_app/services/meal_schedule_service.dart';
|
|
import 'package:diet_guard_app/services/sync_device_id.dart';
|
|
import 'package:diet_guard_app/services/sync_health.dart';
|
|
import 'package:diet_guard_app/services/sync_service.dart';
|
|
import 'package:diet_guard_app/services/sync_settings.dart';
|
|
import 'package:http/http.dart' as http;
|
|
|
|
/// Unique WorkManager task name for the connectivity-gated push.
|
|
const String syncPushTaskName = 'diet_guard.sync_push';
|
|
|
|
/// Loads sync settings and pushes the local log, returning WorkManager's
|
|
/// success flag: `true` when there was nothing to push or the push
|
|
/// succeeded, `false` to ask WorkManager to retry (with backoff) after a
|
|
/// transient failure -- an offline moment or a GitHub hiccup.
|
|
///
|
|
/// Extracted from the WorkManager dispatcher so it is unit-testable without
|
|
/// the real plugin (which only runs as a background isolate on-device),
|
|
/// exactly like [checkAndNotify]. [httpClient] is injectable for the same
|
|
/// reason. The service singletons are (re)initialised here because a fresh
|
|
/// background isolate has none; [LogStorageService.init] is idempotent, so
|
|
/// this is a no-op when a test has already pointed it at a temp dir.
|
|
/// [AppSettingsService.init] must run too: [runSync] now also syncs the
|
|
/// budget, and applies a merge winner via `AppSettingsService.instance`,
|
|
/// which throws if the singleton was never initialised in this isolate.
|
|
Future<bool> backgroundSyncPush({http.Client? httpClient}) async {
|
|
// Must run here too, and before anything stamps an HLC: a fresh isolate has
|
|
// its own static state, so without this `currentSyncDeviceId` falls back to
|
|
// the compile-time role constant ('phone') and this tick publishes to
|
|
// `devices/phone/` instead of the persisted uuid. That splits the device
|
|
// across two directories, and because `sync_state` holds a single
|
|
// `pushedRev` with no notion of which identity wrote it, the next
|
|
// *foreground* push then sees `unchanged` and skips writing the uuid
|
|
// directory entirely.
|
|
await initSyncDeviceId();
|
|
await LogStorageService.init();
|
|
await FoodBankService.init();
|
|
await AppSettingsService.init();
|
|
// runSync also merges the budget history through this singleton.
|
|
await BudgetHistoryService.init();
|
|
// A fresh isolate has its own static state, so the schedule must be loaded
|
|
// here too -- otherwise the due-slot check in this isolate would fall back
|
|
// to the default four meals and nag for checkpoints the user's actual
|
|
// schedule does not have.
|
|
await MealScheduleService.init();
|
|
// Same fresh-isolate reasoning: `_syncKuchniaCredential` no-ops when this
|
|
// singleton is uninitialised, so without this a background tick would
|
|
// neither publish this device's catering credential nor receive a peer's --
|
|
// and on the phone the background tick is most of the syncing there is.
|
|
await KuchniaCredentialService.init();
|
|
final SyncSettings settings;
|
|
try {
|
|
settings = await SyncSettings.load();
|
|
} on Exception catch (error, stackTrace) {
|
|
log(
|
|
'diet_guard background sync: cannot read config in-isolate',
|
|
level: 900,
|
|
error: error,
|
|
stackTrace: stackTrace,
|
|
);
|
|
return false; // let WorkManager retry
|
|
}
|
|
// Either backend counts. `isConfigured` means "has a GitHub token", so
|
|
// gating on it alone silently skips every background push on a device
|
|
// connected only to Firebase.
|
|
if (!settings.isConfigured && await openFirebase() == null) {
|
|
log(
|
|
'diet_guard background sync: no backend configured; nothing to push',
|
|
level: 900,
|
|
);
|
|
await SyncHealth.recordUnconfigured();
|
|
return true; // nothing to push; don't retry
|
|
}
|
|
final client = createGitHubClient(settings, httpClient: httpClient);
|
|
try {
|
|
await runSync(await syncBackend(client));
|
|
await SyncHealth.recordSuccess();
|
|
return true;
|
|
} on Exception catch (error, stackTrace) {
|
|
// Never silent: this is the only signal that a background push failed.
|
|
log(
|
|
'diet_guard background sync failed',
|
|
level: 1000,
|
|
error: error,
|
|
stackTrace: stackTrace,
|
|
);
|
|
await SyncHealth.recordFailure();
|
|
return false; // offline / transient error -> retry with backoff
|
|
} finally {
|
|
client.close();
|
|
}
|
|
}
|