From 04d269ae10c216cd2229ff242bf32acb4c30d9ca Mon Sep 17 00:00:00 2001 From: Rijad Zuzo Date: Sun, 15 Feb 2026 23:31:34 +0100 Subject: [PATCH] Gate auto sync with network reachability checks --- docs/SETUP.md | 3 ++ docs/progress.md | 30 ++++++++++++++++++- .../reachability/network_reachability.dart | 13 ++++++++ .../reachability/network_reachability_io.dart | 25 ++++++++++++++++ .../network_reachability_provider.dart | 8 +++++ .../network_reachability_stub.dart | 13 ++++++++ .../network_reachability_web.dart | 14 +++++++++ .../sync/sync_auto_trigger_controller.dart | 12 +++++++- lib/features/sync/sync_background_runner.dart | 7 +++++ .../sync_auto_trigger_controller_test.dart | 22 ++++++++++++++ 10 files changed, 145 insertions(+), 2 deletions(-) create mode 100644 lib/core/network/reachability/network_reachability.dart create mode 100644 lib/core/network/reachability/network_reachability_io.dart create mode 100644 lib/core/network/reachability/network_reachability_provider.dart create mode 100644 lib/core/network/reachability/network_reachability_stub.dart create mode 100644 lib/core/network/reachability/network_reachability_web.dart diff --git a/docs/SETUP.md b/docs/SETUP.md index 0f86274..234580b 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -27,6 +27,9 @@ flutter run --dart-define=USE_FAKE_BACKEND=true Background sync (startup + resume + periodic ticks while authenticated) is enabled by default. +In REST mode, auto-triggers are reachability-gated to avoid unnecessary sync +attempts while offline. + Control flags: ```bash diff --git a/docs/progress.md b/docs/progress.md index 89977a9..d5a72ab 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -2,6 +2,33 @@ Updated: 2026-02-15 +## Latest Milestone (2026-02-15): Reachability-Gated Auto Sync + +Added connectivity-aware gating to background sync auto-triggers: + +- Added network reachability abstraction: + - `lib/core/network/reachability/network_reachability.dart` + - `lib/core/network/reachability/network_reachability_provider.dart` + - platform impls: + - `network_reachability_io.dart` + - `network_reachability_web.dart` + - `network_reachability_stub.dart` +- Integrated into background sync runner: + - `lib/features/sync/sync_background_runner.dart` + - auto-triggers now skip when reachability gate reports offline (REST mode) + - fake backend mode bypasses reachability gate +- Extended sync trigger controller: + - `lib/features/sync/sync_auto_trigger_controller.dart` + - added trigger gate callback support +- Added tests: + - `test/features/sync/sync_auto_trigger_controller_test.dart` + - validates denied-trigger behavior + +Validation for this milestone: + +- `flutter analyze` -> pass +- `flutter test` -> pass + ## Latest Milestone (2026-02-15): App Session Flow Test Coverage Added higher-level widget coverage for a core authenticated user journey: @@ -132,7 +159,8 @@ Validation for this milestone: 2. Conflict resolution UX: - reject/requeue is implemented; still missing guided per-entity repair UI. 3. Sync trigger maturity: - - connectivity-aware triggers are still missing. + - implemented via reachability gating; future improvement is explicit OS + connectivity event subscription. 4. Test depth: - add integration tests for auth + sync + CRUD end-to-end app flows. diff --git a/lib/core/network/reachability/network_reachability.dart b/lib/core/network/reachability/network_reachability.dart new file mode 100644 index 0000000..39a5401 --- /dev/null +++ b/lib/core/network/reachability/network_reachability.dart @@ -0,0 +1,13 @@ +import 'package:relationship_saver/core/network/reachability/network_reachability_stub.dart' + if (dart.library.html) 'package:relationship_saver/core/network/reachability/network_reachability_web.dart' + if (dart.library.io) 'package:relationship_saver/core/network/reachability/network_reachability_io.dart'; + +/// Runtime network reachability probe. +abstract class NetworkReachability { + /// Returns `true` when network looks reachable. + Future isReachable({Duration timeout = const Duration(seconds: 2)}); +} + +/// Returns platform implementation for network reachability probing. +NetworkReachability createNetworkReachability() => + createPlatformNetworkReachability(); diff --git a/lib/core/network/reachability/network_reachability_io.dart b/lib/core/network/reachability/network_reachability_io.dart new file mode 100644 index 0000000..e616181 --- /dev/null +++ b/lib/core/network/reachability/network_reachability_io.dart @@ -0,0 +1,25 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:relationship_saver/core/network/reachability/network_reachability.dart'; + +class IoNetworkReachability implements NetworkReachability { + @override + Future isReachable({ + Duration timeout = const Duration(seconds: 2), + }) async { + try { + final List result = await InternetAddress.lookup( + 'example.com', + ).timeout(timeout); + return result.isNotEmpty && result.first.rawAddress.isNotEmpty; + } on SocketException { + return false; + } on TimeoutException { + return false; + } + } +} + +NetworkReachability createPlatformNetworkReachability() => + IoNetworkReachability(); diff --git a/lib/core/network/reachability/network_reachability_provider.dart b/lib/core/network/reachability/network_reachability_provider.dart new file mode 100644 index 0000000..d4fb1a3 --- /dev/null +++ b/lib/core/network/reachability/network_reachability_provider.dart @@ -0,0 +1,8 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:relationship_saver/core/network/reachability/network_reachability.dart'; + +/// Provides runtime reachability implementation used for sync triggers. +final Provider networkReachabilityProvider = + Provider((Ref ref) { + return createNetworkReachability(); + }); diff --git a/lib/core/network/reachability/network_reachability_stub.dart b/lib/core/network/reachability/network_reachability_stub.dart new file mode 100644 index 0000000..8926156 --- /dev/null +++ b/lib/core/network/reachability/network_reachability_stub.dart @@ -0,0 +1,13 @@ +import 'package:relationship_saver/core/network/reachability/network_reachability.dart'; + +class StubNetworkReachability implements NetworkReachability { + @override + Future isReachable({ + Duration timeout = const Duration(seconds: 2), + }) async { + return true; + } +} + +NetworkReachability createPlatformNetworkReachability() => + StubNetworkReachability(); diff --git a/lib/core/network/reachability/network_reachability_web.dart b/lib/core/network/reachability/network_reachability_web.dart new file mode 100644 index 0000000..4842d88 --- /dev/null +++ b/lib/core/network/reachability/network_reachability_web.dart @@ -0,0 +1,14 @@ +import 'package:relationship_saver/core/network/reachability/network_reachability.dart'; + +class WebNetworkReachability implements NetworkReachability { + @override + Future isReachable({ + Duration timeout = const Duration(seconds: 2), + }) async { + // Web environments vary in available probes; keep optimistic fallback. + return true; + } +} + +NetworkReachability createPlatformNetworkReachability() => + WebNetworkReachability(); diff --git a/lib/features/sync/sync_auto_trigger_controller.dart b/lib/features/sync/sync_auto_trigger_controller.dart index bc620ad..eb8af6e 100644 --- a/lib/features/sync/sync_auto_trigger_controller.dart +++ b/lib/features/sync/sync_auto_trigger_controller.dart @@ -4,18 +4,22 @@ import 'package:flutter/foundation.dart'; import 'package:relationship_saver/features/sync/sync_coordinator.dart'; typedef SyncNowRunner = Future Function(); +typedef TriggerGate = Future Function(); /// Coordinates auto-triggered sync calls with cooldown and concurrency guards. class SyncAutoTriggerController { SyncAutoTriggerController({ required SyncNowRunner syncNow, required DateTime Function() now, + TriggerGate? canTrigger, this.minimumInterval = const Duration(minutes: 3), }) : _syncNow = syncNow, - _now = now; + _now = now, + _canTrigger = canTrigger ?? _alwaysAllowed; final SyncNowRunner _syncNow; final DateTime Function() _now; + final TriggerGate _canTrigger; final Duration minimumInterval; bool _running = false; @@ -45,6 +49,10 @@ class SyncAutoTriggerController { return false; } + if (!await _canTrigger()) { + return false; + } + _running = true; _lastTriggeredAt = now; try { @@ -71,4 +79,6 @@ class SyncAutoTriggerController { final int multiplier = 1 << cappedFailures; return minimumInterval * multiplier; } + + static Future _alwaysAllowed() async => true; } diff --git a/lib/features/sync/sync_background_runner.dart b/lib/features/sync/sync_background_runner.dart index f5d78d2..9686dea 100644 --- a/lib/features/sync/sync_background_runner.dart +++ b/lib/features/sync/sync_background_runner.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter/widgets.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:relationship_saver/core/config/app_config.dart'; +import 'package:relationship_saver/core/network/reachability/network_reachability_provider.dart'; import 'package:relationship_saver/features/sync/sync_auto_trigger_controller.dart'; import 'package:relationship_saver/features/sync/sync_coordinator.dart'; @@ -31,6 +32,12 @@ class _SyncBackgroundRunnerState extends ConsumerState minimumInterval: Duration( seconds: AppConfig.backgroundSyncIntervalSeconds, ), + canTrigger: () async { + if (AppConfig.useFakeBackend) { + return true; + } + return ref.read(networkReachabilityProvider).isReachable(); + }, ); WidgetsBinding.instance.addObserver(this); _configurePeriodicRunner(); diff --git a/test/features/sync/sync_auto_trigger_controller_test.dart b/test/features/sync/sync_auto_trigger_controller_test.dart index d0ac079..0c759dc 100644 --- a/test/features/sync/sync_auto_trigger_controller_test.dart +++ b/test/features/sync/sync_auto_trigger_controller_test.dart @@ -68,6 +68,11 @@ void main() { ); final Future first = controller.trigger(); + for (int i = 0; i < 20 && !controller.isRunning; i += 1) { + await Future.delayed(const Duration(milliseconds: 1)); + } + expect(controller.isRunning, isTrue); + final bool second = await controller.trigger(); completer.complete(); final bool firstCompleted = await first; @@ -76,6 +81,23 @@ void main() { expect(second, isFalse); expect(callCount, 1); }); + + test('skips trigger when connectivity gate denies run', () async { + int callCount = 0; + final SyncAutoTriggerController controller = SyncAutoTriggerController( + syncNow: () async { + callCount += 1; + return _result(success: true); + }, + now: DateTime.now, + canTrigger: () async => false, + minimumInterval: Duration.zero, + ); + + final bool executed = await controller.trigger(); + expect(executed, isFalse); + expect(callCount, 0); + }); } SyncRunResult _result({required bool success}) {