Skip to content

Commit 4805e78

Browse files
authored
feat: Debounce lifecycle, network, and setMode signals in flutter SDK (#292)
## Summary Wires `StateDebounceManager` (from `launchdarkly_common_client`) into the Flutter `ConnectionManager`. Lifecycle and network events feed the debouncer rather than driving `_handleState()` directly; the debouncer's stream emits a reconciled snapshot after the configurable window closes (default 1s), at which point `_handleState()` runs once. Implements CSFDV2 CONNMODE section 3.5 for the Flutter SDK. ### Notable behaviour - `ConnectionManagerConfig` gains `debounceWindow` and `initialApplicationState`. - Foreground -> background flush remains synchronous (CONNMODE 3.3.1). - Network availability propagation to the destination remains synchronous; only the mode-resolution decision is debounced. - `setMode(mode)` sets the override synchronously so subsequent automatic transitions are suppressed immediately; applying the resolved mode is debounced (CONNMODE 2.0.3 / 3.5.5). - `FlutterStateDetector` exposes `initialApplicationState`, read synchronously at construction time from `SchedulerBinding.instance.lifecycleState`, so the SDK seeds the correct lifecycle assumption when launched in background. - `LDClient` wires the seed and substitutes `foreground` when the user opts out of lifecycle handling (`applicationEvents.backgrounding=false`). ## Blocked on Depends on #291 (StateDebounceManager in common_client). After #291 merges and `launchdarkly_common_client` is released, the pin in `packages/flutter_client_sdk/pubspec.yaml` will be bumped to that version and this PR can be marked ready for review. Split out of #281 to enable incremental merge / release per the dependency-order workflow in RELEASING.md. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Changes when streaming/polling/offline modes apply during rapid lifecycle and network churn; mis-seeded `initialApplicationState` could mis-resolve mode on background launch, though behavior is heavily tested. > > **Overview** > Wires **`StateDebounceManager`** from `launchdarkly_common_client` into Flutter **`ConnectionManager`** so lifecycle, network, and **`setMode`** signals coalesce before **`_handleState()`** runs (default **1s** `debounceWindow`; **`Duration.zero`** keeps prior synchronous behavior for tests). > > **`ConnectionManagerConfig`** adds **`debounceWindow`** and **`initialApplicationState`**. Foreground→background **flush** and destination **network availability** updates stay synchronous; explicit **`offline`** still bypasses debouncing. **`setMode`** records the override immediately but debounces applying the resolved mode. > > **`FlutterStateDetector`** exposes **`initialApplicationState`** from **`SchedulerBinding.instance.lifecycleState`**. **`LDClient`** reuses one detector instance, seeds lifecycle when backgrounding is enabled, and stops folding **`config.offline`** into the automatic background/network disable flags (offline is applied via **`_connectionManager.offline`** after construction). Bumps **`launchdarkly_common_client`** to **1.13.0** and extends **`connection_manager_test`** with **`fake_async`** debounce and background-launch regression coverage. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit b51ade7. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent df9415a commit 4805e78

5 files changed

Lines changed: 427 additions & 38 deletions

File tree

packages/flutter_client_sdk/lib/src/connection_manager.dart

Lines changed: 77 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -101,12 +101,31 @@ final class ConnectionManagerConfig {
101101
/// retry logic.
102102
final bool disableAutomaticBackgroundHandling;
103103

104+
/// Window across which lifecycle, network, and user-mode-override signals
105+
/// are debounced before automatic resolution runs. A value of
106+
/// [Duration.zero] disables debouncing (signals apply synchronously).
107+
/// Defaults to one second.
108+
final Duration debounceWindow;
109+
110+
/// The application's lifecycle state at construction time. Used to seed
111+
/// the manager's initial lifecycle assumption so the SDK doesn't default
112+
/// to foreground when the host platform already knows the app launched
113+
/// into the background. Callers that can query the platform synchronously
114+
/// (e.g. via [SchedulerBinding.instance.lifecycleState]) should pass the
115+
/// resolved value here; callers without that information should leave
116+
/// the default.
117+
///
118+
/// Defaults to [ApplicationState.foreground].
119+
final ApplicationState initialApplicationState;
120+
104121
ConnectionManagerConfig({
105122
this.initialConnectionMode = ConnectionMode.streaming,
106123
this.backgroundConnectionMode = const FDv2Offline(),
107124
this.runInBackground = true,
108125
this.disableAutomaticBackgroundHandling = false,
109126
this.disableAutomaticNetworkHandling = false,
127+
this.debounceWindow = const Duration(seconds: 1),
128+
this.initialApplicationState = ApplicationState.foreground,
110129
});
111130
}
112131

@@ -127,6 +146,8 @@ final class ConnectionManager {
127146
final StateDetector _detector;
128147
final ConnectionDestination _destination;
129148
final List<ModeResolutionEntry> _resolutionTable;
149+
late final StateDebounceManager _debouncer;
150+
late final StreamSubscription<DebouncedState> _debounceSub;
130151

131152
StreamSubscription<ApplicationState>? _applicationStateSub;
132153
StreamSubscription<NetworkState>? _networkStateSub;
@@ -140,6 +161,11 @@ final class ConnectionManager {
140161

141162
bool _offline = false;
142163

164+
/// Whether the SDK has been explicitly placed in the offline state.
165+
///
166+
/// Assigning this property synchronously drives the resolved mode to
167+
/// offline (or back to automatic resolution when set to `false`). It
168+
/// intentionally bypasses the debounce window.
143169
bool get offline => _offline;
144170

145171
set offline(bool offline) {
@@ -157,31 +183,60 @@ final class ConnectionManager {
157183
_config = config,
158184
_destination = destination,
159185
_resolutionTable = resolutionTable ?? flutterDefaultResolutionTable(),
160-
_applicationState = ApplicationState.foreground,
186+
_applicationState = config.initialApplicationState,
187+
// Network has no synchronous platform API; start optimistic. If
188+
// the network is actually unavailable, the first detector emission
189+
// will trigger a debounced reconcile that flips us to offline.
190+
// The common case (network available) is the best performing default.
161191
_networkState = NetworkState.available,
162192
_detector = detector {
193+
_debouncer = StateDebounceManager(
194+
initialState: DebouncedState(
195+
networkAvailable: true,
196+
inForeground:
197+
config.initialApplicationState == ApplicationState.foreground,
198+
requestedMode: null,
199+
),
200+
debounceWindow: config.debounceWindow,
201+
);
202+
_debounceSub = _debouncer.stream.listen(_onDebounceReconcile);
203+
163204
if (!_config.disableAutomaticBackgroundHandling) {
164205
_applicationStateSub =
165-
detector.applicationState.listen((applicationState) {
166-
// TODO (SDK-2187): plumb in debouncer here
167-
168-
_applicationState = applicationState;
169-
_handleState();
170-
});
206+
detector.applicationState.listen(_onApplicationStateChanged);
171207
}
172208

173209
if (!_config.disableAutomaticNetworkHandling) {
174-
_networkStateSub = detector.networkState.listen((networkState) {
175-
// TODO (SDK-2187): plumb in debouncer here
176-
177-
_networkState = networkState;
178-
_destination
179-
.setNetworkAvailability(networkState == NetworkState.available);
180-
_handleState();
181-
});
210+
_networkStateSub = detector.networkState.listen(_onNetworkStateChanged);
182211
}
183212
}
184213

214+
void _onApplicationStateChanged(ApplicationState newState) {
215+
// Flushing on transition to background must not be debounced
216+
if (newState == ApplicationState.background &&
217+
_applicationState == ApplicationState.foreground &&
218+
!_offline) {
219+
_destination.flush();
220+
}
221+
_applicationState = newState;
222+
_debouncer.setInForeground(newState == ApplicationState.foreground);
223+
}
224+
225+
void _onNetworkStateChanged(NetworkState newState) {
226+
_networkState = newState;
227+
// Network-availability propagation to the destination is not debounced.
228+
// It informs the underlying client's analytics-sending state, separate
229+
// from the mode-resolution decision that the debouncer governs.
230+
_destination.setNetworkAvailability(newState == NetworkState.available);
231+
_debouncer.setNetworkAvailable(newState == NetworkState.available);
232+
}
233+
234+
void _onDebounceReconcile(DebouncedState _) {
235+
// The debouncer's snapshot is intentionally ignored; this manager owns
236+
// the canonical view of lifecycle, network, override, and offline state.
237+
_handleState();
238+
}
239+
185240
void _handleState() {
186241
_logger.debug('Handling state: $_applicationState:$_networkState');
187242

@@ -228,14 +283,18 @@ final class ConnectionManager {
228283
void dispose() {
229284
_applicationStateSub?.cancel();
230285
_networkStateSub?.cancel();
286+
_debounceSub.cancel();
287+
_debouncer.close();
231288
_detector.dispose();
232289
}
233290

234-
/// Set the desired connection mode for the SDK. Passing null clears the
235-
/// override and resumes automatic mode resolution.
291+
/// Set the desired connection mode for the SDK. Setting an override takes
292+
/// effect synchronously so subsequent automatic transitions are suppressed
293+
/// immediately; applying the resolved mode is debounced. Passing null
294+
/// clears the override and resumes automatic mode resolution.
236295
void setMode(FDv2ConnectionMode? mode) {
237296
_modeOverride = mode;
238-
_handleState();
297+
_debouncer.setRequestedMode(mode);
239298
}
240299
}
241300

packages/flutter_client_sdk/lib/src/flutter_state_detector.dart

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,22 @@ final class FlutterStateDetector implements StateDetector {
2222
@override
2323
Stream<NetworkState> get networkState => _networkStateController.stream;
2424

25+
/// The application lifecycle state read synchronously at construction
26+
/// time. Suitable for seeding [ConnectionManagerConfig.initialApplicationState].
27+
///
28+
/// [SchedulerBinding.instance.lifecycleState] returns a cached value
29+
/// populated by the framework when the OS pushes lifecycle messages.
30+
/// The read is synchronous and depends only on
31+
/// [WidgetsFlutterBinding.ensureInitialized] having been called -- which
32+
/// the SDK already requires for [FlutterStateDetector] to function.
33+
final ApplicationState initialApplicationState;
34+
2535
late final LDAppLifecycleListener _lifecycleListener;
2636
late final StreamSubscription<dynamic> _connectivitySubscription;
2737

28-
FlutterStateDetector() {
38+
FlutterStateDetector()
39+
: initialApplicationState =
40+
_resolveLifecycleState(SchedulerBinding.instance.lifecycleState) {
2941
final initialState = SchedulerBinding.instance.lifecycleState;
3042
if (initialState != null) {
3143
_handleApplicationLifecycle(initialState);
@@ -41,6 +53,18 @@ final class FlutterStateDetector implements StateDetector {
4153
Connectivity().onConnectivityChanged.listen(_setConnectivity);
4254
}
4355

56+
static ApplicationState _resolveLifecycleState(AppLifecycleState? state) =>
57+
switch (state) {
58+
AppLifecycleState.resumed => ApplicationState.foreground,
59+
AppLifecycleState.hidden ||
60+
AppLifecycleState.paused =>
61+
ApplicationState.background,
62+
AppLifecycleState.detached ||
63+
AppLifecycleState.inactive ||
64+
null =>
65+
ApplicationState.foreground,
66+
};
67+
4468
void _setConnectivity(dynamic connectivityResult) {
4569
// TODO: This is a temporary fix to handle the breaking change in
4670
// connectivity_plus v6

packages/flutter_client_sdk/lib/src/ld_client.dart

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ interface class LDClient {
7777
_client = LDCommonClient(config, platformImplementation, context,
7878
DiagnosticSdkData(name: sdkName, version: sdkVersion),
7979
hooks: combined);
80+
final stateDetector = FlutterStateDetector();
8081
_connectionManager = ConnectionManager(
8182
logger: _client.logger,
8283
config: ConnectionManagerConfig(
@@ -85,13 +86,16 @@ interface class LDClient {
8586
backgroundConnectionMode:
8687
FlutterDefaultConfig.defaultBackgroundConnectionMode,
8788
disableAutomaticBackgroundHandling:
88-
config.offline || !config.applicationEvents.backgrounding,
89+
!config.applicationEvents.backgrounding,
8990
disableAutomaticNetworkHandling:
90-
config.offline || !config.applicationEvents.networkAvailability,
91+
!config.applicationEvents.networkAvailability,
9192
runInBackground:
92-
FlutterDefaultConfig.connectionManagerConfig.runInBackground),
93+
FlutterDefaultConfig.connectionManagerConfig.runInBackground,
94+
initialApplicationState: !config.applicationEvents.backgrounding
95+
? ApplicationState.foreground
96+
: stateDetector.initialApplicationState),
9397
destination: DartClientAdapter(_client),
94-
detector: FlutterStateDetector());
98+
detector: stateDetector);
9599

96100
if (config.offline) {
97101
_connectionManager.offline = true;

packages/flutter_client_sdk/pubspec.yaml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ dependencies:
1313
sdk: flutter
1414
package_info_plus: ">=4.2.0 <11.0.0"
1515
device_info_plus: ">=9.1.1 <14.0.0"
16-
launchdarkly_common_client: 1.12.0
16+
launchdarkly_common_client: 1.13.0
1717
shared_preferences: ^2.2.2
1818
connectivity_plus: ">=5.0.2 <8.0.0"
1919
web: ^1.1.1
@@ -24,6 +24,7 @@ dev_dependencies:
2424
test: ^1.24.3
2525
lints: ^3.0.0
2626
mocktail: ^1.0.1
27+
fake_async: ^1.3.1
2728

2829
# The following section is specific to Flutter.
2930
flutter:

0 commit comments

Comments
 (0)