diet-guard/app/lib/services/background_sync_service.dart
Krzysztof kuhy Rudnicki e761926df4
Some checks failed
dependency freshness / dependencies on newest stable (push) Has been cancelled
file-length / file length <= 250 lines (push) Has been cancelled
md-naming / markdown naming convention (push) Has been cancelled
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Has been cancelled
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Has been cancelled
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint) (push) Has been cancelled
Tests / test (push) Has been cancelled
release-apk / release (APK) (push) Has been cancelled
Body tab: weight/body-fat logs, BMI, ideal weight, goal-based kcal target, workouts + Health Connect steps, week planner
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-28 09:03:03 +02:00

111 lines
5.2 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/body_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();
// `syncBody` no-ops without it, and the background tick is when the
// phone ingests the morning weigh-in.
await BodyService.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();
}
}