mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-10 19:27:14 +01:00
app: offline backstop — connectivity-gated push on reconnect
Immediate push on log only helps when the device is online. If a meal is logged while offline, the in-process auto-sync fails silently and the meal waited for the next app lifecycle event. Now each log also enqueues a one-off WorkManager task constrained to NetworkType.connected, so the log uploads as soon as connectivity returns, without the app being reopened. - background_sync_service.dart: backgroundSyncPush() loads SyncSettings and runs runSync, returning WorkManager's success/retry flag (true when nothing to push or the push succeeded; false to retry after a transient failure). Unit-testable half; the dispatcher branch and the one-off registration are integration-only (real background isolate) and coverage-ignored, matching the existing periodic check. - log_meal_screen.dart: _enqueueSyncBackstop() registers the task (replace policy coalesces a burst of logs; linear backoff retries) from both log paths. No-op off mobile. - background_check_service.dart: dispatcher routes the new task name. - log_storage_service.dart: writeLog is now atomic (temp file + rename) — the background push's runSync can write the log in a separate isolate while the foreground app also writes, so a torn read must be impossible. Verified on device (BL9000EEA0000102): airplane mode on -> log -> background; airplane mode off -> the phone's remote food_log.json commit advanced within ~15s, app never reopened (also confirms the token loads in the background isolate). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FtSmzACzJSGyEatgK9exkd
This commit is contained in:
parent
16ca5227f6
commit
67bc98c7e0
@ -3,6 +3,7 @@
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:crdt_sync/crdt_sync.dart';
|
||||
import 'package:diet_guard_app/models/food_suggestion.dart';
|
||||
@ -12,6 +13,7 @@ import 'package:diet_guard_app/screens/food_bank_screen.dart';
|
||||
import 'package:diet_guard_app/screens/history_screen.dart';
|
||||
import 'package:diet_guard_app/screens/meal_builder_screen.dart';
|
||||
import 'package:diet_guard_app/screens/settings_screen.dart';
|
||||
import 'package:diet_guard_app/services/background_sync_service.dart';
|
||||
import 'package:diet_guard_app/services/foodbank_service.dart';
|
||||
import 'package:diet_guard_app/services/log_storage_service.dart';
|
||||
import 'package:diet_guard_app/services/sync_service.dart';
|
||||
@ -22,6 +24,7 @@ import 'package:diet_guard_app/widgets/photo_attach_field.dart';
|
||||
import 'package:diet_guard_app/widgets/slot_selector_row.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:workmanager/workmanager.dart';
|
||||
|
||||
/// Lets the user log one food item, with food-bank autocomplete and
|
||||
/// today's slot status, or hop into [MealBuilderScreen] for a composite
|
||||
@ -133,6 +136,27 @@ class _LogMealScreenState extends State<LogMealScreen>
|
||||
}
|
||||
}
|
||||
|
||||
/// Queues a connectivity-gated WorkManager push so a meal logged while
|
||||
/// offline still uploads on reconnect, without the app being reopened. The
|
||||
/// in-process [_autoSync] covers the online case; this is the backstop.
|
||||
/// [ExistingWorkPolicy.replace] coalesces a burst of logs into one pending
|
||||
/// job, and backoff retries a push that fails once connectivity returns.
|
||||
/// No-op off mobile (WorkManager ships only on Android/iOS).
|
||||
// coverage:ignore-start
|
||||
Future<void> _enqueueSyncBackstop() async {
|
||||
if (!Platform.isAndroid && !Platform.isIOS) return;
|
||||
await Workmanager().registerOneOffTask(
|
||||
syncPushTaskName,
|
||||
syncPushTaskName,
|
||||
constraints: Constraints(networkType: NetworkType.connected),
|
||||
existingWorkPolicy: ExistingWorkPolicy.replace,
|
||||
backoffPolicy: BackoffPolicy.linear,
|
||||
backoffPolicyDelay: const Duration(minutes: 1),
|
||||
);
|
||||
}
|
||||
|
||||
// coverage:ignore-end
|
||||
|
||||
Future<void> _refreshSlots() async {
|
||||
final logged = await LogStorageService.instance.loggedSlotsToday();
|
||||
if (!mounted) return;
|
||||
@ -197,6 +221,9 @@ class _LogMealScreenState extends State<LogMealScreen>
|
||||
// so the PC gate can see it in seconds. Fire-and-forget and best-effort:
|
||||
// _autoSync is single-flight and swallows offline/transient failures.
|
||||
unawaited(_autoSync());
|
||||
// Offline backstop: if the push above fails (no connectivity), a
|
||||
// connectivity-gated WorkManager task uploads the meal on reconnect.
|
||||
unawaited(_enqueueSyncBackstop());
|
||||
if (!mounted) return;
|
||||
_descController.clear();
|
||||
_macros.clear();
|
||||
@ -215,8 +242,10 @@ class _LogMealScreenState extends State<LogMealScreen>
|
||||
MaterialPageRoute(builder: (_) => const MealBuilderScreen()),
|
||||
);
|
||||
await _refreshSlots();
|
||||
// A meal built and logged in the builder should push right away too.
|
||||
// A meal built and logged in the builder should push right away too,
|
||||
// with the same offline backstop.
|
||||
unawaited(_autoSync());
|
||||
unawaited(_enqueueSyncBackstop());
|
||||
}
|
||||
|
||||
void _onOpenHistory() {
|
||||
|
||||
@ -9,6 +9,7 @@
|
||||
library;
|
||||
|
||||
import 'package:diet_guard_app/models/slot.dart';
|
||||
import 'package:diet_guard_app/services/background_sync_service.dart';
|
||||
import 'package:diet_guard_app/services/log_storage_service.dart';
|
||||
import 'package:diet_guard_app/services/notification_service.dart';
|
||||
import 'package:workmanager/workmanager.dart';
|
||||
@ -42,6 +43,11 @@ Future<void> checkAndNotify({DateTime? now}) async {
|
||||
@pragma('vm:entry-point')
|
||||
void backgroundCheckCallbackDispatcher() {
|
||||
Workmanager().executeTask((taskName, inputData) async {
|
||||
if (taskName == syncPushTaskName) {
|
||||
// Return the push's own success flag so a still-offline / failed push
|
||||
// is retried with backoff rather than silently dropped.
|
||||
return backgroundSyncPush();
|
||||
}
|
||||
if (taskName == backgroundCheckTaskName) {
|
||||
await checkAndNotify();
|
||||
}
|
||||
|
||||
53
app/lib/services/background_sync_service.dart
Normal file
53
app/lib/services/background_sync_service.dart
Normal file
@ -0,0 +1,53 @@
|
||||
/// 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 'package:crdt_sync/crdt_sync.dart';
|
||||
import 'package:diet_guard_app/services/foodbank_service.dart';
|
||||
import 'package:diet_guard_app/services/log_storage_service.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.
|
||||
Future<bool> backgroundSyncPush({http.Client? httpClient}) async {
|
||||
await LogStorageService.init();
|
||||
await FoodBankService.init();
|
||||
final SyncSettings settings;
|
||||
try {
|
||||
settings = await SyncSettings.load();
|
||||
} on Exception {
|
||||
return false; // couldn't read config in-isolate; let WorkManager retry
|
||||
}
|
||||
if (!settings.isConfigured) return true; // nothing to push; don't retry
|
||||
final client = GitHubClient(
|
||||
owner: settings.owner,
|
||||
repo: settings.repo,
|
||||
token: settings.token,
|
||||
httpClient: httpClient,
|
||||
);
|
||||
try {
|
||||
await runSync(client);
|
||||
return true;
|
||||
} on Exception {
|
||||
return false; // offline / transient GitHub error -> retry with backoff
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
@ -95,7 +95,14 @@ class LogStorageService {
|
||||
for (final mapEntry in log.entries)
|
||||
mapEntry.key: mapEntry.value.map((e) => e.toLocalJson()).toList(),
|
||||
};
|
||||
await _file.writeAsString(jsonEncode(encoded));
|
||||
// Write to a per-process temp file then atomically rename it over the
|
||||
// real one, so a concurrent reader never sees a half-written file. The
|
||||
// connectivity-gated background push (see background_sync_service.dart)
|
||||
// can `runSync` -> writeLog in a separate isolate while the foreground
|
||||
// app also writes; the pid in the temp name keeps the two from colliding.
|
||||
final tmp = File('${_file.path}.$pid.tmp');
|
||||
await tmp.writeAsString(jsonEncode(encoded));
|
||||
await tmp.rename(_file.path);
|
||||
}
|
||||
|
||||
/// Appends a signed-on-PC-eventually entry for [desc] to today's log.
|
||||
|
||||
86
app/test/services/background_sync_service_test.dart
Normal file
86
app/test/services/background_sync_service_test.dart
Normal file
@ -0,0 +1,86 @@
|
||||
// `backgroundSyncPush` is the unit-testable half of the connectivity-gated
|
||||
// WorkManager backstop; the dispatcher branch and the one-off registration
|
||||
// itself are integration-only (real WorkManager isolate, on-device smoke
|
||||
// test) and excluded from coverage, exactly like the periodic check.
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:diet_guard_app/services/background_sync_service.dart';
|
||||
import 'package:diet_guard_app/services/foodbank_service.dart';
|
||||
import 'package:diet_guard_app/services/log_storage_service.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../fake_secure_storage.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
late Directory tempDir;
|
||||
|
||||
setUp(() async {
|
||||
tempDir = await Directory.systemTemp.createTemp('diet_guard_bg_sync_');
|
||||
LogStorageService.resetForTesting(testDir: tempDir);
|
||||
FoodBankService.resetForTesting(testDir: tempDir);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
LogStorageService.resetForTesting();
|
||||
FoodBankService.resetForTesting();
|
||||
await tempDir.delete(recursive: true);
|
||||
});
|
||||
|
||||
test('does nothing and does not retry when sync is unconfigured', () async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
installFakeSecureStorage();
|
||||
var puts = 0;
|
||||
final mock = MockClient((req) async {
|
||||
if (req.method == 'PUT') puts++;
|
||||
return http.Response('', 404);
|
||||
});
|
||||
|
||||
final ok = await backgroundSyncPush(httpClient: mock);
|
||||
|
||||
expect(ok, isTrue); // nothing to do -> success, no retry
|
||||
expect(puts, 0);
|
||||
});
|
||||
|
||||
test('pushes and reports success when configured', () async {
|
||||
SharedPreferences.setMockInitialValues({
|
||||
'sync.owner': 'o',
|
||||
'sync.repo': 'r',
|
||||
});
|
||||
installFakeSecureStorage(initial: {'sync.token': 't'});
|
||||
var puts = 0;
|
||||
final mock = MockClient((req) async {
|
||||
if (req.method == 'PUT') {
|
||||
puts++;
|
||||
return http.Response('{}', 200); // Contents-API PUT succeeded
|
||||
}
|
||||
// Bare `/repos/<owner>/<repo>` GET is the repo-exists probe.
|
||||
if (req.method == 'GET' && req.url.pathSegments.length == 3) {
|
||||
return http.Response('{}', 200);
|
||||
}
|
||||
return http.Response('', 404);
|
||||
});
|
||||
|
||||
final ok = await backgroundSyncPush(httpClient: mock);
|
||||
|
||||
expect(ok, isTrue);
|
||||
expect(puts, 1);
|
||||
});
|
||||
|
||||
test('reports failure (retry) when the push errors', () async {
|
||||
SharedPreferences.setMockInitialValues({
|
||||
'sync.owner': 'o',
|
||||
'sync.repo': 'r',
|
||||
});
|
||||
installFakeSecureStorage(initial: {'sync.token': 't'});
|
||||
final mock = MockClient((_) async => http.Response('boom', 500));
|
||||
|
||||
final ok = await backgroundSyncPush(httpClient: mock);
|
||||
|
||||
expect(ok, isFalse); // transient -> WorkManager should retry
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user