Trigger background sync on connectivity recovery

This commit is contained in:
Rijad Zuzo
2026-02-17 23:52:32 +01:00
parent d76b31d37d
commit 9bb484e95d
13 changed files with 268 additions and 3 deletions
+3
View File
@@ -30,6 +30,9 @@ enabled by default.
In REST mode, auto-triggers are reachability-gated to avoid unnecessary sync In REST mode, auto-triggers are reachability-gated to avoid unnecessary sync
attempts while offline. attempts while offline.
When connectivity returns (offline -> online), sync now triggers immediately in
addition to startup/resume/periodic ticks.
Control flags: Control flags:
```bash ```bash
+30 -2
View File
@@ -2,6 +2,34 @@
Updated: 2026-02-17 Updated: 2026-02-17
## Latest Milestone (2026-02-17): Connectivity Event-Driven Background Sync
Implemented online-transition-triggered sync so background sync reacts faster
when network returns:
- Added connectivity stream support to reachability abstraction:
- `lib/core/network/reachability/network_reachability.dart`
- new `watch(...)` API for reachability change events
- Added IO implementation using connectivity plugin + internet probe:
- `lib/core/network/reachability/network_reachability_io.dart`
- `connectivity_plus` integrated as transport signal source
- Updated background runner:
- `lib/features/sync/sync_background_runner.dart`
- now listens for offline -> online transitions and triggers immediate sync
- keeps existing startup/resume/periodic behavior
- Added coverage:
- `test/features/sync/sync_background_runner_test.dart`
- verifies transition-trigger behavior and avoids duplicate triggers when
already online
- Updated setup docs:
- `docs/SETUP.md` background sync section now includes connectivity-return
behavior note
Validation for this milestone:
- `flutter analyze` -> pass
- `flutter test` -> pass
## Latest Milestone (2026-02-17): Sync Repair Test Stability + Mobile CRUD/Sync Flow Coverage ## Latest Milestone (2026-02-17): Sync Repair Test Stability + Mobile CRUD/Sync Flow Coverage
Completed two test-depth upgrades: Completed two test-depth upgrades:
@@ -313,8 +341,8 @@ Validation for this milestone:
- future enhancement: auto-select and highlight the exact row in main feature - future enhancement: auto-select and highlight the exact row in main feature
screens after save. screens after save.
3. Sync trigger maturity: 3. Sync trigger maturity:
- implemented via reachability gating; future improvement is explicit OS - implemented via reachability gating + online-transition event triggers.
connectivity event subscription. - future: add richer platform-specific network quality awareness if needed.
4. Test depth: 4. Test depth:
- expanded with mobile compact CRUD+sync flow and rejected-repair flow. - expanded with mobile compact CRUD+sync flow and rejected-repair flow.
- future: add desktop + web-specific end-to-end sync error/retry scenario. - future: add desktop + web-specific end-to-end sync error/retry scenario.
@@ -6,6 +6,9 @@ import 'package:relationship_saver/core/network/reachability/network_reachabilit
abstract class NetworkReachability { abstract class NetworkReachability {
/// Returns `true` when network looks reachable. /// Returns `true` when network looks reachable.
Future<bool> isReachable({Duration timeout = const Duration(seconds: 2)}); Future<bool> isReachable({Duration timeout = const Duration(seconds: 2)});
/// Emits reachability updates when platform network status changes.
Stream<bool> watch({Duration timeout = const Duration(seconds: 2)});
} }
/// Returns platform implementation for network reachability probing. /// Returns platform implementation for network reachability probing.
@@ -1,9 +1,15 @@
import 'dart:async'; import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:relationship_saver/core/network/reachability/network_reachability.dart'; import 'package:relationship_saver/core/network/reachability/network_reachability.dart';
class IoNetworkReachability implements NetworkReachability { class IoNetworkReachability implements NetworkReachability {
IoNetworkReachability({Connectivity? connectivity})
: _connectivity = connectivity ?? Connectivity();
final Connectivity _connectivity;
@override @override
Future<bool> isReachable({ Future<bool> isReachable({
Duration timeout = const Duration(seconds: 2), Duration timeout = const Duration(seconds: 2),
@@ -19,6 +25,35 @@ class IoNetworkReachability implements NetworkReachability {
return false; return false;
} }
} }
@override
Stream<bool> watch({Duration timeout = const Duration(seconds: 2)}) {
final Stream<dynamic> changes =
_connectivity.onConnectivityChanged as Stream<dynamic>;
return changes
.asyncMap((dynamic event) async {
final Iterable<ConnectivityResult> results =
_normalizeConnectivityResults(event);
final bool hasTransport = results.any(
(ConnectivityResult result) => result != ConnectivityResult.none,
);
if (!hasTransport) {
return false;
}
return isReachable(timeout: timeout);
})
.distinct();
}
Iterable<ConnectivityResult> _normalizeConnectivityResults(dynamic event) {
if (event is ConnectivityResult) {
return <ConnectivityResult>[event];
}
if (event is List<ConnectivityResult>) {
return event;
}
return const <ConnectivityResult>[];
}
} }
NetworkReachability createPlatformNetworkReachability() => NetworkReachability createPlatformNetworkReachability() =>
@@ -7,6 +7,11 @@ class StubNetworkReachability implements NetworkReachability {
}) async { }) async {
return true; return true;
} }
@override
Stream<bool> watch({Duration timeout = const Duration(seconds: 2)}) {
return const Stream<bool>.empty();
}
} }
NetworkReachability createPlatformNetworkReachability() => NetworkReachability createPlatformNetworkReachability() =>
@@ -8,6 +8,11 @@ class WebNetworkReachability implements NetworkReachability {
// Web environments vary in available probes; keep optimistic fallback. // Web environments vary in available probes; keep optimistic fallback.
return true; return true;
} }
@override
Stream<bool> watch({Duration timeout = const Duration(seconds: 2)}) {
return const Stream<bool>.empty();
}
} }
NetworkReachability createPlatformNetworkReachability() => NetworkReachability createPlatformNetworkReachability() =>
+25 -1
View File
@@ -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.dart';
import 'package:relationship_saver/core/network/reachability/network_reachability_provider.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';
@@ -22,10 +23,13 @@ class _SyncBackgroundRunnerState extends ConsumerState<SyncBackgroundRunner>
with WidgetsBindingObserver { with WidgetsBindingObserver {
late final SyncAutoTriggerController _controller; late final SyncAutoTriggerController _controller;
Timer? _periodicTimer; Timer? _periodicTimer;
StreamSubscription<bool>? _reachabilitySubscription;
bool? _lastReachable;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
final reachability = ref.read(networkReachabilityProvider);
_controller = SyncAutoTriggerController( _controller = SyncAutoTriggerController(
syncNow: () => ref.read(syncCoordinatorProvider).syncNow(), syncNow: () => ref.read(syncCoordinatorProvider).syncNow(),
now: DateTime.now, now: DateTime.now,
@@ -36,11 +40,12 @@ class _SyncBackgroundRunnerState extends ConsumerState<SyncBackgroundRunner>
if (AppConfig.useFakeBackend) { if (AppConfig.useFakeBackend) {
return true; return true;
} }
return ref.read(networkReachabilityProvider).isReachable(); return reachability.isReachable();
}, },
); );
WidgetsBinding.instance.addObserver(this); WidgetsBinding.instance.addObserver(this);
_configurePeriodicRunner(); _configurePeriodicRunner();
_configureReachabilityRunner(reachability);
if (AppConfig.enableBackgroundSync) { if (AppConfig.enableBackgroundSync) {
unawaited(_controller.trigger(ignoreCooldown: true)); unawaited(_controller.trigger(ignoreCooldown: true));
} }
@@ -60,6 +65,7 @@ class _SyncBackgroundRunnerState extends ConsumerState<SyncBackgroundRunner>
void dispose() { void dispose() {
WidgetsBinding.instance.removeObserver(this); WidgetsBinding.instance.removeObserver(this);
_periodicTimer?.cancel(); _periodicTimer?.cancel();
_reachabilitySubscription?.cancel();
super.dispose(); super.dispose();
} }
@@ -80,4 +86,22 @@ class _SyncBackgroundRunnerState extends ConsumerState<SyncBackgroundRunner>
unawaited(_controller.trigger()); unawaited(_controller.trigger());
}); });
} }
void _configureReachabilityRunner(NetworkReachability reachability) {
if (!AppConfig.enableBackgroundSync) {
return;
}
_reachabilitySubscription = reachability.watch().listen((bool reachable) {
final bool? previous = _lastReachable;
_lastReachable = reachable;
if (reachable && previous == false) {
unawaited(_controller.trigger(ignoreCooldown: true));
}
});
unawaited(_primeReachabilityState(reachability));
}
Future<void> _primeReachabilityState(NetworkReachability reachability) async {
_lastReachable = await reachability.isReachable();
}
} }
@@ -5,11 +5,13 @@
import FlutterMacOS import FlutterMacOS
import Foundation import Foundation
import connectivity_plus
import flutter_local_notifications import flutter_local_notifications
import flutter_secure_storage_darwin import flutter_secure_storage_darwin
import shared_preferences_foundation import shared_preferences_foundation
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin"))
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin")) FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
+24
View File
@@ -145,6 +145,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.19.1" version: "1.19.1"
connectivity_plus:
dependency: "direct main"
description:
name: connectivity_plus
sha256: "33bae12a398f841c6cda09d1064212957265869104c478e5ad51e2fb26c3973c"
url: "https://pub.dev"
source: hosted
version: "7.0.0"
connectivity_plus_platform_interface:
dependency: transitive
description:
name: connectivity_plus_platform_interface
sha256: "42657c1715d48b167930d5f34d00222ac100475f73d10162ddf43e714932f204"
url: "https://pub.dev"
source: hosted
version: "2.0.1"
convert: convert:
dependency: transitive dependency: transitive
description: description:
@@ -560,6 +576,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.17.4" version: "0.17.4"
nm:
dependency: transitive
description:
name: nm
sha256: "2c9aae4127bdc8993206464fcc063611e0e36e72018696cd9631023a31b24254"
url: "https://pub.dev"
source: hosted
version: "0.5.0"
node_preamble: node_preamble:
dependency: transitive dependency: transitive
description: description:
+1
View File
@@ -43,6 +43,7 @@ dependencies:
flutter_riverpod: ^3.2.1 flutter_riverpod: ^3.2.1
shared_preferences: ^2.5.4 shared_preferences: ^2.5.4
hive_flutter: ^1.1.0 hive_flutter: ^1.1.0
connectivity_plus: ^7.0.0
timezone: ^0.10.1 timezone: ^0.10.1
flutter_local_notifications: ^20.1.0 flutter_local_notifications: ^20.1.0
@@ -0,0 +1,131 @@
import 'dart:async';
import 'package:flutter/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:relationship_saver/core/network/reachability/network_reachability.dart';
import 'package:relationship_saver/core/network/reachability/network_reachability_provider.dart';
import 'package:relationship_saver/features/sync/sync_background_runner.dart';
import 'package:relationship_saver/features/sync/sync_coordinator.dart';
void main() {
testWidgets('triggers sync when reachability transitions from offline to online', (
WidgetTester tester,
) async {
final _TestReachability reachability = _TestReachability(initial: false);
addTearDown(reachability.dispose);
int syncCalls = 0;
await tester.pumpWidget(
ProviderScope(
overrides: [
networkReachabilityProvider.overrideWithValue(reachability),
syncCoordinatorProvider.overrideWith(
(Ref ref) =>
_TestSyncCoordinator(ref, onSyncNow: () async {
syncCalls += 1;
return _okResult();
}),
),
],
child: const Directionality(
textDirection: TextDirection.ltr,
child: SyncBackgroundRunner(child: SizedBox()),
),
),
);
await tester.pumpAndSettle(const Duration(milliseconds: 60));
// Startup trigger always runs once when background sync is enabled.
expect(syncCalls, 1);
reachability.emit(false);
await tester.pumpAndSettle(const Duration(milliseconds: 40));
expect(syncCalls, 1);
reachability.emit(true);
await tester.pumpAndSettle(const Duration(milliseconds: 60));
expect(syncCalls, 2);
});
testWidgets('does not trigger extra sync while connectivity stays online', (
WidgetTester tester,
) async {
final _TestReachability reachability = _TestReachability(initial: true);
addTearDown(reachability.dispose);
int syncCalls = 0;
await tester.pumpWidget(
ProviderScope(
overrides: [
networkReachabilityProvider.overrideWithValue(reachability),
syncCoordinatorProvider.overrideWith(
(Ref ref) =>
_TestSyncCoordinator(ref, onSyncNow: () async {
syncCalls += 1;
return _okResult();
}),
),
],
child: const Directionality(
textDirection: TextDirection.ltr,
child: SyncBackgroundRunner(child: SizedBox()),
),
),
);
await tester.pumpAndSettle(const Duration(milliseconds: 60));
expect(syncCalls, 1);
reachability.emit(true);
await tester.pumpAndSettle(const Duration(milliseconds: 60));
expect(syncCalls, 1);
});
}
class _TestSyncCoordinator extends SyncCoordinator {
_TestSyncCoordinator(super.ref, {required this.onSyncNow});
final Future<SyncRunResult> Function() onSyncNow;
@override
Future<SyncRunResult> syncNow() => onSyncNow();
}
class _TestReachability implements NetworkReachability {
_TestReachability({required bool initial}) : _current = initial;
final StreamController<bool> _controller = StreamController<bool>.broadcast();
bool _current;
@override
Future<bool> isReachable({Duration timeout = const Duration(seconds: 2)}) async {
return _current;
}
@override
Stream<bool> watch({Duration timeout = const Duration(seconds: 2)}) {
return _controller.stream;
}
void emit(bool reachable) {
_current = reachable;
_controller.add(reachable);
}
Future<void> dispose() async {
await _controller.close();
}
}
SyncRunResult _okResult() {
return const SyncRunResult(
success: true,
operation: 'sync',
message: 'ok',
pendingAfter: 0,
);
}
@@ -6,9 +6,12 @@
#include "generated_plugin_registrant.h" #include "generated_plugin_registrant.h"
#include <connectivity_plus/connectivity_plus_windows_plugin.h>
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h> #include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
void RegisterPlugins(flutter::PluginRegistry* registry) { void RegisterPlugins(flutter::PluginRegistry* registry) {
ConnectivityPlusWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin"));
FlutterSecureStorageWindowsPluginRegisterWithRegistrar( FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
} }
+1
View File
@@ -3,6 +3,7 @@
# #
list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_PLUGIN_LIST
connectivity_plus
flutter_secure_storage_windows flutter_secure_storage_windows
) )