Gate auto sync with network reachability checks
This commit is contained in:
@@ -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
|
||||
|
||||
+29
-1
@@ -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.
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
typedef SyncNowRunner = Future<SyncRunResult> Function();
|
||||
typedef TriggerGate = Future<bool> 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<bool> _alwaysAllowed() async => true;
|
||||
}
|
||||
|
||||
@@ -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<SyncBackgroundRunner>
|
||||
minimumInterval: Duration(
|
||||
seconds: AppConfig.backgroundSyncIntervalSeconds,
|
||||
),
|
||||
canTrigger: () async {
|
||||
if (AppConfig.useFakeBackend) {
|
||||
return true;
|
||||
}
|
||||
return ref.read(networkReachabilityProvider).isReachable();
|
||||
},
|
||||
);
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
_configurePeriodicRunner();
|
||||
|
||||
@@ -68,6 +68,11 @@ void main() {
|
||||
);
|
||||
|
||||
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();
|
||||
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}) {
|
||||
|
||||
Reference in New Issue
Block a user