Gate auto sync with network reachability checks

This commit is contained in:
Rijad Zuzo
2026-02-15 23:31:34 +01:00
parent 54321e8b64
commit 04d269ae10
10 changed files with 145 additions and 2 deletions
+3
View File
@@ -27,6 +27,9 @@ flutter run --dart-define=USE_FAKE_BACKEND=true
Background sync (startup + resume + periodic ticks while authenticated) is Background sync (startup + resume + periodic ticks while authenticated) is
enabled by default. enabled by default.
In REST mode, auto-triggers are reachability-gated to avoid unnecessary sync
attempts while offline.
Control flags: Control flags:
```bash ```bash
+29 -1
View File
@@ -2,6 +2,33 @@
Updated: 2026-02-15 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 ## Latest Milestone (2026-02-15): App Session Flow Test Coverage
Added higher-level widget coverage for a core authenticated user journey: Added higher-level widget coverage for a core authenticated user journey:
@@ -132,7 +159,8 @@ Validation for this milestone:
2. Conflict resolution UX: 2. Conflict resolution UX:
- reject/requeue is implemented; still missing guided per-entity repair UI. - reject/requeue is implemented; still missing guided per-entity repair UI.
3. Sync trigger maturity: 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: 4. Test depth:
- add integration tests for auth + sync + CRUD end-to-end app flows. - add integration tests for auth + sync + CRUD end-to-end app flows.
@@ -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<bool> isReachable({Duration timeout = const Duration(seconds: 2)});
}
/// Returns platform implementation for network reachability probing.
NetworkReachability createNetworkReachability() =>
createPlatformNetworkReachability();
@@ -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<bool> isReachable({
Duration timeout = const Duration(seconds: 2),
}) async {
try {
final List<InternetAddress> 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();
@@ -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<NetworkReachability> networkReachabilityProvider =
Provider<NetworkReachability>((Ref ref) {
return createNetworkReachability();
});
@@ -0,0 +1,13 @@
import 'package:relationship_saver/core/network/reachability/network_reachability.dart';
class StubNetworkReachability implements NetworkReachability {
@override
Future<bool> isReachable({
Duration timeout = const Duration(seconds: 2),
}) async {
return true;
}
}
NetworkReachability createPlatformNetworkReachability() =>
StubNetworkReachability();
@@ -0,0 +1,14 @@
import 'package:relationship_saver/core/network/reachability/network_reachability.dart';
class WebNetworkReachability implements NetworkReachability {
@override
Future<bool> isReachable({
Duration timeout = const Duration(seconds: 2),
}) async {
// Web environments vary in available probes; keep optimistic fallback.
return true;
}
}
NetworkReachability createPlatformNetworkReachability() =>
WebNetworkReachability();
@@ -4,18 +4,22 @@ import 'package:flutter/foundation.dart';
import 'package:relationship_saver/features/sync/sync_coordinator.dart'; import 'package:relationship_saver/features/sync/sync_coordinator.dart';
typedef SyncNowRunner = Future<SyncRunResult> Function(); typedef SyncNowRunner = Future<SyncRunResult> Function();
typedef TriggerGate = Future<bool> Function();
/// Coordinates auto-triggered sync calls with cooldown and concurrency guards. /// Coordinates auto-triggered sync calls with cooldown and concurrency guards.
class SyncAutoTriggerController { class SyncAutoTriggerController {
SyncAutoTriggerController({ SyncAutoTriggerController({
required SyncNowRunner syncNow, required SyncNowRunner syncNow,
required DateTime Function() now, required DateTime Function() now,
TriggerGate? canTrigger,
this.minimumInterval = const Duration(minutes: 3), this.minimumInterval = const Duration(minutes: 3),
}) : _syncNow = syncNow, }) : _syncNow = syncNow,
_now = now; _now = now,
_canTrigger = canTrigger ?? _alwaysAllowed;
final SyncNowRunner _syncNow; final SyncNowRunner _syncNow;
final DateTime Function() _now; final DateTime Function() _now;
final TriggerGate _canTrigger;
final Duration minimumInterval; final Duration minimumInterval;
bool _running = false; bool _running = false;
@@ -45,6 +49,10 @@ class SyncAutoTriggerController {
return false; return false;
} }
if (!await _canTrigger()) {
return false;
}
_running = true; _running = true;
_lastTriggeredAt = now; _lastTriggeredAt = now;
try { try {
@@ -71,4 +79,6 @@ class SyncAutoTriggerController {
final int multiplier = 1 << cappedFailures; final int multiplier = 1 << cappedFailures;
return minimumInterval * multiplier; return minimumInterval * multiplier;
} }
static Future<bool> _alwaysAllowed() async => true;
} }
@@ -3,6 +3,7 @@ import 'dart:async';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:relationship_saver/core/config/app_config.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_auto_trigger_controller.dart';
import 'package:relationship_saver/features/sync/sync_coordinator.dart'; import 'package:relationship_saver/features/sync/sync_coordinator.dart';
@@ -31,6 +32,12 @@ class _SyncBackgroundRunnerState extends ConsumerState<SyncBackgroundRunner>
minimumInterval: Duration( minimumInterval: Duration(
seconds: AppConfig.backgroundSyncIntervalSeconds, seconds: AppConfig.backgroundSyncIntervalSeconds,
), ),
canTrigger: () async {
if (AppConfig.useFakeBackend) {
return true;
}
return ref.read(networkReachabilityProvider).isReachable();
},
); );
WidgetsBinding.instance.addObserver(this); WidgetsBinding.instance.addObserver(this);
_configurePeriodicRunner(); _configurePeriodicRunner();
@@ -68,6 +68,11 @@ void main() {
); );
final Future<bool> first = controller.trigger(); final Future<bool> first = controller.trigger();
for (int i = 0; i < 20 && !controller.isRunning; i += 1) {
await Future<void>.delayed(const Duration(milliseconds: 1));
}
expect(controller.isRunning, isTrue);
final bool second = await controller.trigger(); final bool second = await controller.trigger();
completer.complete(); completer.complete();
final bool firstCompleted = await first; final bool firstCompleted = await first;
@@ -76,6 +81,23 @@ void main() {
expect(second, isFalse); expect(second, isFalse);
expect(callCount, 1); 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}) { SyncRunResult _result({required bool success}) {