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
|
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
@@ -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}) {
|
||||||
|
|||||||
Reference in New Issue
Block a user