Skip to content

Commit c2f9216

Browse files
authored
feat: Add experimental support for data-saving mode (FDv2). (#314)
The final wiring PR for FDv2 in the Flutter SDK: surfaces the data system on the public API so applications can use it. The data system itself lives in `common_client` (already on main via #310/#311/#312); this PR is the Flutter exposure. ## Changes - **`LDConfig`** gains a `dataSystem` option (passed through to `LDCommonConfig`). Setting it opts the SDK into FDv2. Documented as early access / not semver-stable. - **`LDClient.setConnectionMode([ConnectionMode?])`** — applies a manual connection-mode override (sticks and suppresses automatic state-detection transitions; `null` clears it and resumes automatic resolution). Early access. - **Public exports** — adds the data-system configuration types (`DataSystemConfig`, `ConnectionModeId`, `ModeDefinition`, `EndpointConfig`, `InitializerEntry`/`SynchronizerEntry`, `CacheInitializer`/`PollingInitializer`/`StreamingInitializer`/`PollingSynchronizer`/`StreamingSynchronizer`, `Fdv1FallbackConfig`) and removes the internal `FDv2*` connection-mode types from the public surface (they’re used internally by `setConnectionMode`, not part of the API). ## Validation - `flutter analyze` clean; all 47 flutter package tests pass against current main. - End-to-end behavior (incl. FDv1 fallback) is exercised by the v3 contract tests — added in the stacked follow-up. ## Release note The `launchdarkly_common_client` pin is still `1.13.0` (the last released version, which predates FDv2). CI resolves it via the melos path override, so the workspace builds against the FDv2 `common_client` on main. **Before this ships, `common_client` must release with the FDv2 work and this pin must be bumped** (per `RELEASING.md`: common → common_client → flutter, in dependency order). Two drafts will be stacked on this PR: the v3 contract tests, and the FDv2 example app. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Changes how connection modes are chosen when FDv2 is enabled and adds experimental runtime overrides over networking/lifecycle behavior; impact is limited by early-access APIs and mostly additive wiring. > > **Overview** > **Surfaces FDv2 (data-saving mode) on the Flutter public API** by adding optional `dataSystem` on `LDConfig` (passed through to `common_client`) and exporting the related configuration types (`DataSystemConfig`, `ConnectionModeId`, initializer/synchronizer entries, etc.). Internal `FDv2*` connection-mode types are **removed from the public export list**; apps use `ConnectionModeId` instead. > > Adds **`LDClient.setConnectionMode([ConnectionModeId?])`** as an early-access manual override that sticks until cleared with `null`, wired through `ConnectionManager.setMode`. **`ConnectionManagerConfig.initialModeOverride`** seeds that behavior at construction (including from `dataSystem.initialConnectionMode`). > > When **`dataSystem` is configured**, foreground automatic resolution defaults to **streaming** (not FDv1 `dataSourceConfig.initialConnectionMode`), and any configured initial connection mode is applied as the sticky override. FDv1 apps without `dataSystem` keep the previous data-source-driven initial mode. > > Tests cover initial override on startup, lifecycle suppression while overridden, and resuming automatic resolution after clearing. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit f884852. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 15861f8 commit c2f9216

5 files changed

Lines changed: 172 additions & 11 deletions

File tree

packages/flutter_client_sdk/lib/launchdarkly_flutter_client_sdk.dart

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -45,11 +45,6 @@ export 'package:launchdarkly_common_client/launchdarkly_common_client.dart'
4545
PersistenceConfig,
4646
ApplicationInfo,
4747
ConnectionMode,
48-
FDv2ConnectionMode,
49-
FDv2Streaming,
50-
FDv2Polling,
51-
FDv2Offline,
52-
FDv2Background,
5348
ResolvedConnectionMode,
5449
ResolvedStreaming,
5550
ResolvedPolling,
@@ -73,7 +68,19 @@ export 'package:launchdarkly_common_client/launchdarkly_common_client.dart'
7368
PluginCredentialInfo,
7469
PluginEnvironmentMetadata,
7570
PluginMetadata,
76-
PollingConfig;
71+
PollingConfig,
72+
DataSystemConfig,
73+
ConnectionModeId,
74+
ModeDefinition,
75+
EndpointConfig,
76+
InitializerEntry,
77+
SynchronizerEntry,
78+
CacheInitializer,
79+
PollingInitializer,
80+
StreamingInitializer,
81+
PollingSynchronizer,
82+
StreamingSynchronizer,
83+
Fdv1FallbackConfig;
7784

7885
export 'src/ld_client.dart' show LDClient;
7986
export 'src/config/ld_config.dart' show LDConfig, ApplicationEvents;

packages/flutter_client_sdk/lib/src/config/ld_config.dart

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,10 @@ final class LDConfig extends LDCommonConfig {
9090
/// [plugins] can be used to add plugins to the SDK.
9191
///
9292
/// Plugin support is currently experimental and subject to change.
93+
///
94+
/// [dataSystem] opts the SDK into the FDv2 data acquisition protocol.
95+
/// This feature is not stable, and not subject to any backwards
96+
/// compatibility guarantees or semantic versioning. It is in early access.
9397
LDConfig(super.sdkCredential, super.autoEnvAttributes,
9498
{super.applicationInfo,
9599
super.httpProperties,
@@ -103,7 +107,8 @@ final class LDConfig extends LDCommonConfig {
103107
super.globalPrivateAttributes,
104108
ApplicationEvents? applicationEvents,
105109
super.hooks,
106-
List<Plugin>? plugins})
110+
List<Plugin>? plugins,
111+
super.dataSystem})
107112
: applicationEvents = applicationEvents ?? ApplicationEvents(),
108113
plugins =
109114
plugins != null ? UnmodifiableListView(List.from(plugins)) : null;

packages/flutter_client_sdk/lib/src/connection_manager.dart

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,14 @@ final class ConnectionManagerConfig {
8282
/// Defaults to [const FDv2Offline()].
8383
final FDv2ConnectionMode backgroundConnectionMode;
8484

85+
/// An initial mode override applied at construction, equivalent to calling
86+
/// [ConnectionManager.setMode] immediately after creation. When non-null
87+
/// the manager starts in this mode and suppresses automatic resolution
88+
/// until the override is cleared.
89+
///
90+
/// Defaults to null (automatic resolution from construction).
91+
final FDv2ConnectionMode? initialModeOverride;
92+
8593
/// Some platforms (windows, web, mac, linux) can continue executing code
8694
/// in the background.
8795
final bool runInBackground;
@@ -121,6 +129,7 @@ final class ConnectionManagerConfig {
121129
ConnectionManagerConfig({
122130
this.initialConnectionMode = ConnectionMode.streaming,
123131
this.backgroundConnectionMode = const FDv2Offline(),
132+
this.initialModeOverride,
124133
this.runInBackground = true,
125134
this.disableAutomaticBackgroundHandling = false,
126135
this.disableAutomaticNetworkHandling = false,
@@ -153,7 +162,8 @@ final class ConnectionManager {
153162
StreamSubscription<NetworkState>? _networkStateSub;
154163

155164
/// When non-null, [resolveMode] is skipped and this mode is
156-
/// applied regardless of lifecycle/network.
165+
/// applied regardless of lifecycle/network. Seeded from
166+
/// [ConnectionManagerConfig.initialModeOverride].
157167
FDv2ConnectionMode? _modeOverride;
158168

159169
ApplicationState _applicationState;
@@ -183,6 +193,7 @@ final class ConnectionManager {
183193
_config = config,
184194
_destination = destination,
185195
_resolutionTable = resolutionTable ?? flutterDefaultResolutionTable(),
196+
_modeOverride = config.initialModeOverride,
186197
_applicationState = config.initialApplicationState,
187198
// Network has no synchronous platform API; start optimistic. If
188199
// the network is actually unavailable, the first detector emission
@@ -195,7 +206,7 @@ final class ConnectionManager {
195206
networkAvailable: true,
196207
inForeground:
197208
config.initialApplicationState == ApplicationState.foreground,
198-
requestedMode: null,
209+
requestedMode: config.initialModeOverride,
199210
),
200211
debounceWindow: config.debounceWindow,
201212
);

packages/flutter_client_sdk/lib/src/ld_client.dart

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,11 +78,23 @@ interface class LDClient {
7878
DiagnosticSdkData(name: sdkName, version: sdkVersion),
7979
hooks: combined);
8080
final stateDetector = FlutterStateDetector();
81+
// Under the FDv2 data system the connection mode is governed by the
82+
// data system configuration, not the FDv1 data source options: the
83+
// foreground slot defaults to streaming, and an initial connection mode
84+
// (if configured) is applied as a sticky override, equivalent to calling
85+
// setConnectionMode immediately after construction.
86+
final dataSystem = config.dataSystem;
87+
final initialModeOverride = switch (dataSystem?.initialConnectionMode) {
88+
final id? => _fdv2FromConnectionModeId(id),
89+
null => null,
90+
};
8191
_connectionManager = ConnectionManager(
8292
logger: _client.logger,
8393
config: ConnectionManagerConfig(
84-
initialConnectionMode:
85-
config.dataSourceConfig.initialConnectionMode,
94+
initialConnectionMode: dataSystem != null
95+
? ConnectionMode.streaming
96+
: config.dataSourceConfig.initialConnectionMode,
97+
initialModeOverride: initialModeOverride,
8698
backgroundConnectionMode:
8799
FlutterDefaultConfig.defaultBackgroundConnectionMode,
88100
disableAutomaticBackgroundHandling:
@@ -343,6 +355,25 @@ interface class LDClient {
343355
_connectionManager.offline = offline;
344356
}
345357

358+
/// Set the connection mode the SDK uses for data acquisition.
359+
///
360+
/// The mode is applied as a manual override: it sticks and suppresses
361+
/// automatic state-detection transitions (backgrounding, network
362+
/// availability) until cleared. Call with no argument (or null) to clear
363+
/// the override and resume automatic mode resolution.
364+
///
365+
/// The mode is a [ConnectionModeId], which can identify any of the FDv2
366+
/// built-in modes (including [ConnectionModeId.background]).
367+
///
368+
/// This method is not stable, and not subject to any backwards
369+
/// compatibility guarantees or semantic versioning. It is in early
370+
/// access. If you want access to this feature please join the EAP.
371+
/// https://launchdarkly.com/docs/sdk/features/data-saving-mode
372+
void setConnectionMode([ConnectionModeId? mode]) {
373+
_connectionManager
374+
.setMode(mode == null ? null : _fdv2FromConnectionModeId(mode));
375+
}
376+
346377
/// Check if the SDK has finished initialization.
347378
///
348379
/// This does not indicate that initialization was successful, but that it is
@@ -389,3 +420,13 @@ interface class LDClient {
389420
this, _pluginEnvironmentMetadata, [plugin], _client.logger);
390421
}
391422
}
423+
424+
/// Translates a public [ConnectionModeId] into the internal
425+
/// [FDv2ConnectionMode] the connection manager drives.
426+
FDv2ConnectionMode _fdv2FromConnectionModeId(ConnectionModeId mode) =>
427+
switch (mode) {
428+
ConnectionModeId.polling => const FDv2Polling(),
429+
ConnectionModeId.background => const FDv2Background(),
430+
ConnectionModeId.offline => const FDv2Offline(),
431+
_ => const FDv2Streaming(),
432+
};

packages/flutter_client_sdk/test/persistence/connection_manager_test.dart

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -899,4 +899,101 @@ void main() {
899899
connectionManager.dispose();
900900
});
901901
});
902+
903+
group('given an initial mode override', () {
904+
test(
905+
'it applies the override on the buffered initial reconcile, without '
906+
'any detector emission', () async {
907+
registerFallbackValue(ConnectionMode.streaming);
908+
909+
final destination = MockDestination();
910+
final logger = LDLogger(adapter: MockLogAdapter());
911+
final config = ConnectionManagerConfig(
912+
initialModeOverride: const FDv2Polling(),
913+
debounceWindow: const Duration(seconds: 1),
914+
);
915+
final mockDetector = MockStateDetector();
916+
917+
final connectionManager = ConnectionManager(
918+
logger: logger,
919+
config: config,
920+
destination: destination,
921+
detector: mockDetector,
922+
);
923+
924+
// The buffered initial reconcile fires on the next microtask after
925+
// construction.
926+
await Future<void>.microtask(() {});
927+
928+
verify(() => destination.setMode(const ResolvedPolling())).called(1);
929+
930+
connectionManager.dispose();
931+
});
932+
933+
test(
934+
'the override sticks across lifecycle changes and suppresses automatic '
935+
'resolution', () async {
936+
registerFallbackValue(ConnectionMode.streaming);
937+
938+
final destination = MockDestination();
939+
final logger = LDLogger(adapter: MockLogAdapter());
940+
final config = ConnectionManagerConfig(
941+
runInBackground: true,
942+
backgroundConnectionMode: const FDv2Background(),
943+
initialModeOverride: const FDv2Polling(),
944+
debounceWindow: Duration.zero,
945+
);
946+
final mockDetector = MockStateDetector();
947+
948+
final connectionManager = ConnectionManager(
949+
logger: logger,
950+
config: config,
951+
destination: destination,
952+
detector: mockDetector,
953+
);
954+
955+
mockDetector.setApplicationState(ApplicationState.background);
956+
await mockDetector.applicationState.first;
957+
await _pumpDebouncerHop();
958+
959+
// The override is honored regardless of lifecycle; the background slot
960+
// is never resolved while the override is active.
961+
verify(() => destination.setMode(const ResolvedPolling()));
962+
verifyNever(() => destination.setMode(const ResolvedBackground()));
963+
964+
connectionManager.dispose();
965+
});
966+
967+
test('clearing the override resumes automatic resolution', () async {
968+
registerFallbackValue(ConnectionMode.streaming);
969+
970+
final destination = MockDestination();
971+
final logger = LDLogger(adapter: MockLogAdapter());
972+
final config = ConnectionManagerConfig(
973+
runInBackground: true,
974+
initialModeOverride: const FDv2Polling(),
975+
debounceWindow: Duration.zero,
976+
);
977+
final mockDetector = MockStateDetector();
978+
979+
final connectionManager = ConnectionManager(
980+
logger: logger,
981+
config: config,
982+
destination: destination,
983+
detector: mockDetector,
984+
);
985+
986+
await _pumpDebouncerHop();
987+
reset(destination);
988+
989+
// Clearing the override resumes automatic resolution; in the foreground
990+
// with network available that resolves to streaming.
991+
connectionManager.setMode(null);
992+
await _pumpDebouncerHop();
993+
994+
verify(() => destination.setMode(const ResolvedStreaming()));
995+
996+
connectionManager.dispose();
997+
});
998+
});
902999
}

0 commit comments

Comments
 (0)