From 2d2ff51f9d4deae485c0d60fd2984c74b3aafbd3 Mon Sep 17 00:00:00 2001 From: Todd Anderson Date: Fri, 29 May 2026 10:09:19 -0400 Subject: [PATCH 1/7] feat(SDK-2187): Wire FDv2 connection-mode resolution in common_client Wires the FDv2 mode-resolution scaffolding (merged via #274) into the common_client runtime path while keeping the FDv1 ConnectionMode enum and all existing public API signatures unchanged. LDCommonClient: - setMode(ConnectionMode) keeps its FDv1 signature and now maps the 3 legacy modes to ResolvedConnectionMode internally before forwarding to DataSourceManager. - New setResolvedMode(ResolvedConnectionMode) is the advanced FDv2 entry point. Documented as EAP / not-stable. No internal caller in this PR; the Flutter SDK's ConnectionManager invokes it in the follow-up flutter PR. - DataSourceFactoriesFn (the public, optional constructor seam) remains typed Map so external callers can still customize streaming + polling. _backgroundFactory is SDK-managed (background is FDv2-only), and _composeFactoriesForManager translates the FDv1 map into the FDv2-keyed Map consumed by DataSourceManager. DataSourceManager (internal, not publicly exported): - Active mode held as ResolvedConnectionMode. - Factory map is keyed by FDv2ConnectionMode. - ResolvedOffline branches dispatch status as setOffline / networkUnavailable / backgroundDisabled depending on OfflineDetail. Tests updated to match. --- .../src/data_sources/data_source_manager.dart | 83 ++++++------- .../lib/src/ld_common_client.dart | 111 ++++++++++++++---- .../data_source_manager_test.dart | 97 ++++++++++----- .../test/ld_dart_client_test.dart | 8 ++ 4 files changed, 194 insertions(+), 105 deletions(-) diff --git a/packages/common_client/lib/src/data_sources/data_source_manager.dart b/packages/common_client/lib/src/data_sources/data_source_manager.dart index 885d2430..5fcfc164 100644 --- a/packages/common_client/lib/src/data_sources/data_source_manager.dart +++ b/packages/common_client/lib/src/data_sources/data_source_manager.dart @@ -4,6 +4,9 @@ import 'package:launchdarkly_dart_common/launchdarkly_dart_common.dart' show LDContext, LDLogger; import '../connection_mode.dart'; +import '../fdv2_connection_mode.dart'; +import '../offline_detail.dart'; +import '../resolved_connection_mode.dart'; import 'data_source.dart'; import 'data_source_event_handler.dart'; import 'data_source_status_manager.dart'; @@ -13,16 +16,13 @@ typedef DataSourceFactory = DataSource Function(LDContext context); /// The data source manager controls which data source is connected to /// the data source status as well as the data source event handler. final class DataSourceManager { - ConnectionMode _activeMode; + ResolvedConnectionMode _activeConnectionMode; LDContext? _activeContext; final LDLogger _logger; final DataSourceStatusManager _statusManager; final DataSourceEventHandler _dataSourceEventHandler; - final Map _dataSourceFactories = {}; - - // At start we assume the network is available. - bool _networkAvailable = true; + final Map _dataSourceFactories = {}; DataSource? _activeDataSource; StreamSubscription? _subscription; @@ -35,7 +35,11 @@ final class DataSourceManager { required DataSourceStatusManager statusManager, required DataSourceEventHandler dataSourceEventHandler, required LDLogger logger, - }) : _activeMode = startingMode, + }) : _activeConnectionMode = switch (startingMode) { + ConnectionMode.streaming => const ResolvedStreaming(), + ConnectionMode.polling => const ResolvedPolling(), + ConnectionMode.offline => const ResolvedOffline(OfflineSetOffline()), + }, _logger = logger.subLogger('DataSourceManager'), _statusManager = statusManager, _dataSourceEventHandler = dataSourceEventHandler; @@ -43,7 +47,7 @@ final class DataSourceManager { /// Set the available data source factories. These factories will not apply /// until the next identify call. Currently factories will be set once during /// startup and before the first identify. - void setFactories(Map factories) { + void setFactories(Map factories) { _dataSourceFactories.clear(); _dataSourceFactories.addAll(factories); } @@ -55,25 +59,14 @@ final class DataSourceManager { _setupConnection(); } - void setMode(ConnectionMode mode) { - if (mode == _activeMode) { - _logger.debug('Mode already active: $_activeMode'); + void setMode(ResolvedConnectionMode mode) { + if (mode == _activeConnectionMode) { + _logger.debug('Mode is already set to: $mode'); return; } - _logger.debug('Changing data source mode from: $_activeMode to: $mode'); - _activeMode = mode; - _setupConnection(); - } - - void setNetworkAvailable(bool available) { - if (_networkAvailable == available) { - _logger.debug('Network availability set to same value: $available'); - return; - } - _logger.debug( - 'Network availability changed from: $_networkAvailable to: $available'); - _networkAvailable = available; + 'Changing resolved connection mode from: $_activeConnectionMode to: $mode'); + _activeConnectionMode = mode; _setupConnection(); } @@ -83,7 +76,7 @@ final class DataSourceManager { _activeDataSource = null; } - DataSource? _createDataSource(ConnectionMode mode) { + DataSource? _createDataSource(FDv2ConnectionMode mode) { if (_activeContext != null) { if (_dataSourceFactories[mode] == null) { _logger.debug('No data source factory exists for mode: $mode'); @@ -107,33 +100,25 @@ final class DataSourceManager { _stopConnection(); - // If the active mode is offline, then we do not need to setup - // a new connection. Additionally if we are offline, and the network - // is not available, our data source status should remain offline. - if (_activeMode == ConnectionMode.offline) { - _statusManager.setOffline(); - return; - } - - // We are not offline, but the network is not available, so we are going - // to set the status as unavailable and not start a new connection. - if (!_networkAvailable) { - _statusManager.setNetworkUnavailable(); - return; - } - - switch (_activeMode) { - case ConnectionMode.offline: - _statusManager.setOffline(); - case ConnectionMode.streaming: - case ConnectionMode.polling: - // default: - // We may want to consider adding another state to the data source state - // for the intermediate between switching data sources, or for identifying - // a new context. + switch (_activeConnectionMode) { + case ResolvedOffline(:final detail): + switch (detail) { + case OfflineSetOffline(): + _statusManager.setOffline(); + case OfflineNetworkUnavailable(): + _statusManager.setNetworkUnavailable(); + case OfflineBackgroundDisabled(): + _statusManager.setBackgroundDisabled(); + } + return; + case ResolvedStreaming(): + case ResolvedPolling(): + case ResolvedBackground(): + break; } - _activeDataSource = _createDataSource(_activeMode); + final mode = _activeConnectionMode.connectionMode; + _activeDataSource = _createDataSource(mode); _subscription = _activeDataSource?.events.asyncMap((event) async { if (_activeContext == null) { _logger.error( diff --git a/packages/common_client/lib/src/ld_common_client.dart b/packages/common_client/lib/src/ld_common_client.dart index 4f725ade..1b3ee565 100644 --- a/packages/common_client/lib/src/ld_common_client.dart +++ b/packages/common_client/lib/src/ld_common_client.dart @@ -8,12 +8,17 @@ import 'config/data_source_config.dart'; import 'config/defaults/credential_type.dart'; import 'config/defaults/default_config.dart'; import 'connection_mode.dart'; +import 'fdv2_connection_mode.dart'; +import 'offline_detail.dart'; +import 'resolved_connection_mode.dart'; import 'context_modifiers/anonymous_context_modifier.dart'; import 'context_modifiers/context_modifier.dart'; import 'context_modifiers/env_context_modifier.dart'; import 'hooks/hook.dart'; import 'hooks/hook_runner.dart'; +import 'data_sources/data_source.dart'; import 'data_sources/data_source_event_handler.dart'; +import 'data_sources/fdv2/built_in_modes.dart'; import 'data_sources/data_source_manager.dart'; import 'data_sources/data_source_status.dart'; import 'data_sources/data_source_status_manager.dart'; @@ -76,35 +81,76 @@ typedef DataSourceFactoriesFn = Map Function( Map _defaultFactories( LDCommonConfig config, LDLogger logger, HttpProperties httpProperties) { - final pollingDataSourceConfig = PollingDataSourceConfig( - useReport: config.dataSourceConfig.useReport, - withReasons: config.dataSourceConfig.evaluationReasons, + final useReport = config.dataSourceConfig.useReport; + final withReasons = config.dataSourceConfig.evaluationReasons; + + final foregroundPollingConfig = PollingDataSourceConfig( + useReport: useReport, + withReasons: withReasons, pollingInterval: config.dataSourceConfig.polling.pollingInterval); + + DataSource streaming(LDContext context) { + return StreamingDataSource( + credential: config.sdkCredential, + context: context, + endpoints: config.serviceEndpoints, + logger: logger, + dataSourceConfig: StreamingDataSourceConfig( + useReport: useReport, withReasons: withReasons), + pollingDataSourceConfig: foregroundPollingConfig, + httpProperties: httpProperties); + } + return { - ConnectionMode.streaming: (LDContext context) { - return StreamingDataSource( - credential: config.sdkCredential, - context: context, - endpoints: config.serviceEndpoints, - logger: logger, - dataSourceConfig: StreamingDataSourceConfig( - useReport: config.dataSourceConfig.useReport, - withReasons: config.dataSourceConfig.evaluationReasons), - pollingDataSourceConfig: pollingDataSourceConfig, - httpProperties: httpProperties); - }, + ConnectionMode.streaming: streaming, ConnectionMode.polling: (LDContext context) { return PollingDataSource( credential: config.sdkCredential, context: context, endpoints: config.serviceEndpoints, logger: logger, - dataSourceConfig: pollingDataSourceConfig, + dataSourceConfig: foregroundPollingConfig, httpProperties: httpProperties); }, }; } +/// Background factory is SDK-managed; it always uses the built-in +/// reduced-frequency polling configuration. Customizing the background +/// factory is intentionally not exposed via [DataSourceFactoriesFn]. +DataSourceFactory _backgroundFactory( + LDCommonConfig config, LDLogger logger, HttpProperties httpProperties) { + final backgroundPollingConfig = PollingDataSourceConfig( + useReport: config.dataSourceConfig.useReport, + withReasons: config.dataSourceConfig.evaluationReasons, + pollingInterval: BuiltInModes.defaultBackgroundPollInterval); + return (LDContext context) { + return PollingDataSource( + credential: config.sdkCredential, + context: context, + endpoints: config.serviceEndpoints, + logger: logger, + dataSourceConfig: backgroundPollingConfig, + httpProperties: httpProperties); + }; +} + +/// Translate the public, FDv1-keyed factory map (optionally with a custom +/// override via [DataSourceFactoriesFn]) into the FDv2-keyed map consumed by +/// [DataSourceManager], adding the SDK-managed background factory. +Map _composeFactoriesForManager({ + required Map fdv1Factories, + required DataSourceFactory backgroundFactory, +}) { + return { + if (fdv1Factories[ConnectionMode.streaming] case final f?) + const FDv2Streaming(): f, + if (fdv1Factories[ConnectionMode.polling] case final f?) + const FDv2Polling(): f, + const FDv2Background(): backgroundFactory, + }; +} + typedef EventProcessorFactory = EventProcessor Function( {required LDLogger logger, required bool indexEvents, @@ -382,16 +428,16 @@ final class LDCommonClient { _updateEventSendingState(); if (!_config.offline) { - _dataSourceManager - .setFactories(_dataSourceFactories(_config, _logger, httpProperties)); + _dataSourceManager.setFactories(_composeFactoriesForManager( + fdv1Factories: _dataSourceFactories(_config, _logger, httpProperties), + backgroundFactory: _backgroundFactory(_config, _logger, httpProperties), + )); } else { + DataSource nullSource(LDContext _) => NullDataSource(); _dataSourceManager.setFactories({ - ConnectionMode.streaming: (LDContext context) { - return NullDataSource(); - }, - ConnectionMode.polling: (LDContext context) { - return NullDataSource(); - }, + const FDv2Streaming(): nullSource, + const FDv2Polling(): nullSource, + const FDv2Background(): nullSource, }); } } @@ -754,6 +800,22 @@ final class LDCommonClient { /// Set the connection mode the SDK should use. void setMode(ConnectionMode mode) { + _dataSourceManager.setMode(switch (mode) { + ConnectionMode.streaming => const ResolvedStreaming(), + ConnectionMode.polling => const ResolvedPolling(), + ConnectionMode.offline => const ResolvedOffline(OfflineSetOffline()), + }); + } + + /// Set a resolved FDv2 connection mode directly. This is the advanced entry + /// point used by the FDv2 connection-management layer and bypasses the + /// FDv1 [ConnectionMode] mapping done by [setMode]. + /// + /// This method is not stable, and not subject to any backwards compatibility + /// guarantees or semantic versioning. It is in early access. If you want + /// access to this feature please join the EAP. + /// https://launchdarkly.com/docs/sdk/features/data-saving-mode + void setResolvedMode(ResolvedConnectionMode mode) { _dataSourceManager.setMode(mode); } @@ -764,7 +826,6 @@ final class LDCommonClient { return; } _networkAvailable = available; - _dataSourceManager.setNetworkAvailable(available); _updateEventSendingState(); } diff --git a/packages/common_client/test/data_sources/data_source_manager_test.dart b/packages/common_client/test/data_sources/data_source_manager_test.dart index 3f9624ae..e2d518da 100644 --- a/packages/common_client/test/data_sources/data_source_manager_test.dart +++ b/packages/common_client/test/data_sources/data_source_manager_test.dart @@ -39,26 +39,29 @@ final class MockDataSource implements DataSource { } } -Map defaultFactories( - Map dataSources, - {bool withBackground = false}) { - final factories = { - ConnectionMode.streaming: (context) { +Map defaultFactories( + Map dataSources) { + return { + const FDv2Streaming(): (context) { final dataSource = MockDataSource(); - dataSources[ConnectionMode.streaming] = dataSource; + dataSources[const FDv2Streaming()] = dataSource; return dataSource; }, - ConnectionMode.polling: (context) { + const FDv2Polling(): (context) { final dataSource = MockDataSource(); - dataSources[ConnectionMode.polling] = dataSource; + dataSources[const FDv2Polling()] = dataSource; return dataSource; - } + }, + const FDv2Background(): (context) { + final dataSource = MockDataSource(); + dataSources[const FDv2Background()] = dataSource; + return dataSource; + }, }; - return factories; } DataSourceManager makeManager( - LDContext context, Map factories, + LDContext context, Map factories, {DataSourceStatusManager? inStatusManager}) { final statusManager = inStatusManager ?? DataSourceStatusManager(); final logger = LDLogger(); @@ -77,13 +80,13 @@ DataSourceManager makeManager( void main() { test('it sets up an initial connection on start', () async { - final dataSources = {}; + final dataSources = {}; final context = LDContextBuilder().kind('user', 'bob').build(); final manager = makeManager(context, defaultFactories(dataSources)); final completer = Completer(); manager.identify(context, completer); - final createdDataSource = dataSources[ConnectionMode.streaming]; + final createdDataSource = dataSources[const FDv2Streaming()]; expect(createdDataSource, isNotNull); expect(createdDataSource!.controller.hasListener, isTrue); expect(createdDataSource.startCalled, isTrue); @@ -93,7 +96,7 @@ void main() { test('it forwards events to the data source event handler', () { final statusManager = DataSourceStatusManager(stamper: () => DateTime(1)); - final dataSources = {}; + final dataSources = {}; final context = LDContextBuilder().kind('user', 'bob').build(); final manager = makeManager(context, defaultFactories(dataSources), inStatusManager: statusManager); @@ -111,49 +114,84 @@ void main() { test('it can transition to offline and tear-down the previous connection', () { - final dataSources = {}; + final statusManager = DataSourceStatusManager(stamper: () => DateTime(1)); + final dataSources = {}; final context = LDContextBuilder().kind('user', 'bob').build(); - final manager = makeManager(context, defaultFactories(dataSources)); + final manager = makeManager(context, defaultFactories(dataSources), + inStatusManager: statusManager); final completer = Completer(); manager.identify(context, completer); - manager.setMode(ConnectionMode.offline); - final createdDataSource = dataSources[ConnectionMode.streaming]; + manager.setMode(const ResolvedOffline(OfflineSetOffline())); + expect(statusManager.status.state, DataSourceState.setOffline); + final createdDataSource = dataSources[const FDv2Streaming()]; expect(createdDataSource, isNotNull); expect(createdDataSource!.controller.hasListener, isFalse); expect(createdDataSource.startCalled, isTrue); expect(createdDataSource.stopCalled, isTrue); }); + test('offline with OfflineNetworkUnavailable sets networkUnavailable status', + () { + final statusManager = DataSourceStatusManager(stamper: () => DateTime(1)); + final dataSources = {}; + final context = LDContextBuilder().kind('user', 'bob').build(); + final manager = makeManager(context, defaultFactories(dataSources), + inStatusManager: statusManager); + final completer = Completer(); + + manager.identify(context, completer); + manager.setMode(const ResolvedOffline(OfflineNetworkUnavailable())); + expect(statusManager.status.state, DataSourceState.networkUnavailable); + }); + + test('offline with OfflineBackgroundDisabled sets backgroundDisabled', () { + final statusManager = DataSourceStatusManager(stamper: () => DateTime(1)); + final dataSources = {}; + final context = LDContextBuilder().kind('user', 'bob').build(); + final manager = makeManager(context, defaultFactories(dataSources), + inStatusManager: statusManager); + final completer = Completer(); + + manager.identify(context, completer); + manager.setMode(const ResolvedOffline(OfflineBackgroundDisabled())); + expect(statusManager.status.state, DataSourceState.backgroundDisabled); + }); + test('it can transition from streaming to polling', () { - final dataSources = {}; + final dataSources = {}; final context = LDContextBuilder().kind('user', 'bob').build(); final manager = makeManager(context, defaultFactories(dataSources)); final completer = Completer(); manager.identify(context, completer); - manager.setMode(ConnectionMode.polling); - final streamingDataSource = dataSources[ConnectionMode.streaming]; + manager.setMode(const ResolvedPolling()); + final streamingDataSource = dataSources[const FDv2Streaming()]; expect(streamingDataSource, isNotNull); expect(streamingDataSource!.controller.hasListener, isFalse); expect(streamingDataSource.startCalled, isTrue); expect(streamingDataSource.stopCalled, isTrue); - final pollingDataSource = dataSources[ConnectionMode.polling]; + final pollingDataSource = dataSources[const FDv2Polling()]; expect(pollingDataSource, isNotNull); expect(pollingDataSource!.controller.hasListener, isTrue); expect(pollingDataSource.startCalled, isTrue); expect(pollingDataSource.stopCalled, isFalse); }); - test('it can transition to network unavailable', () { + test( + 'ResolvedOffline(OfflineNetworkUnavailable) reports networkUnavailable and ' + 'stops the data source', () async { final statusManager = DataSourceStatusManager(stamper: () => DateTime(1)); - final dataSources = {}; + final dataSources = {}; final context = LDContextBuilder().kind('user', 'bob').build(); final manager = makeManager(context, defaultFactories(dataSources), inStatusManager: statusManager); final completer = Completer(); + manager.identify(context, completer); + await completer.future; + expectLater( statusManager.changes, emits( @@ -161,11 +199,8 @@ void main() { state: DataSourceState.networkUnavailable, stateSince: DateTime(1)), )); - - manager.identify(context, completer); - - manager.setNetworkAvailable(false); - final createdDataSource = dataSources[ConnectionMode.streaming]; + manager.setMode(const ResolvedOffline(OfflineNetworkUnavailable())); + final createdDataSource = dataSources[const FDv2Streaming()]; expect(createdDataSource, isNotNull); expect(createdDataSource!.controller.hasListener, isFalse); expect(createdDataSource.startCalled, isTrue); @@ -174,14 +209,14 @@ void main() { test('it restarts the data source on bad data', () async { final statusManager = DataSourceStatusManager(stamper: () => DateTime(1)); - final dataSources = {}; + final dataSources = {}; final context = LDContextBuilder().kind('user', 'bob').build(); final manager = makeManager(context, defaultFactories(dataSources), inStatusManager: statusManager); final completer = Completer(); manager.identify(context, completer); - final createdDataSource = dataSources[ConnectionMode.streaming]; + final createdDataSource = dataSources[const FDv2Streaming()]; expect( await statusManager.changes.first, diff --git a/packages/common_client/test/ld_dart_client_test.dart b/packages/common_client/test/ld_dart_client_test.dart index d1a0aac5..4dcfe828 100644 --- a/packages/common_client/test/ld_dart_client_test.dart +++ b/packages/common_client/test/ld_dart_client_test.dart @@ -172,6 +172,14 @@ void main() { client.setMode(ConnectionMode.polling); }); + test('can set resolved FDv2 mode', () { + // No exceptions. + client.setResolvedMode(const ResolvedOffline(OfflineSetOffline())); + client.setResolvedMode(const ResolvedStreaming()); + client.setResolvedMode(const ResolvedPolling()); + client.setResolvedMode(const ResolvedBackground()); + }); + test('can set event sending on/off', () { // No exceptions. client.setEventSendingEnabled(true); From 2e88c21cb87a93a008945a930d224fc2c04c1bf3 Mon Sep 17 00:00:00 2001 From: Todd Anderson Date: Fri, 29 May 2026 10:20:08 -0400 Subject: [PATCH 2/7] feat(SDK-2187): Wire FDv2 connection-mode resolution in flutter SDK Wires the FDv2 mode-resolution machinery (from common_client) into the Flutter SDK's ConnectionManager while preserving all existing public API signatures. ConnectionManager: - DartClientAdapter forwards ResolvedConnectionMode to LDCommonClient.setResolvedMode (the new advanced FDv2 entry point). - ConnectionManagerConfig.backgroundConnectionMode (new) is typed FDv2ConnectionMode; initialConnectionMode stays ConnectionMode (legacy). - disableAutomaticBackgroundHandling keeps its original name. - Automatic mode resolution uses resolveMode + flutterDefaultResolutionTable; setMode override path supports the 3 FDv1 modes only (background is auto-resolved, not user-selectable). Flutter umbrella re-exports cover the FDv2 types consumed by this PR (FDv2 connection-mode subtypes, ResolvedConnectionMode family, OfflineDetail family, ModeState/ModeResolutionEntry/resolveMode/ flutterDefaultResolutionTable). Platform defaults (io_config / js_config / stub_config / flutter_default_config) return FDv2ConnectionMode for the background slot. LDClient passes backgroundConnectionMode to the ConnectionManager and sets offline=true post-construction (no longer forces initial mode to offline in config). Tests updated to match. Depends on the LDCommonClient.setResolvedMode addition in the predecessor common-only PR. --- .../lib/launchdarkly_flutter_client_sdk.dart | 18 ++ .../defaults/flutter_default_config.dart | 18 +- .../lib/src/config/defaults/io_config.dart | 20 ++ .../lib/src/config/defaults/js_config.dart | 14 + .../lib/src/config/defaults/stub_config.dart | 11 + .../lib/src/config/ld_config.dart | 10 +- .../lib/src/connection_manager.dart | 151 +++++----- .../flutter_client_sdk/lib/src/ld_client.dart | 11 +- .../persistence/connection_manager_test.dart | 282 +++++++++++++++++- 9 files changed, 444 insertions(+), 91 deletions(-) diff --git a/packages/flutter_client_sdk/lib/launchdarkly_flutter_client_sdk.dart b/packages/flutter_client_sdk/lib/launchdarkly_flutter_client_sdk.dart index 820cad64..f896f38e 100644 --- a/packages/flutter_client_sdk/lib/launchdarkly_flutter_client_sdk.dart +++ b/packages/flutter_client_sdk/lib/launchdarkly_flutter_client_sdk.dart @@ -45,6 +45,24 @@ export 'package:launchdarkly_common_client/launchdarkly_common_client.dart' PersistenceConfig, ApplicationInfo, ConnectionMode, + FDv2ConnectionMode, + FDv2Streaming, + FDv2Polling, + FDv2Offline, + FDv2Background, + ResolvedConnectionMode, + ResolvedStreaming, + ResolvedPolling, + ResolvedBackground, + ResolvedOffline, + OfflineDetail, + OfflineSetOffline, + OfflineNetworkUnavailable, + OfflineBackgroundDisabled, + ModeState, + ModeResolutionEntry, + resolveMode, + flutterDefaultResolutionTable, Hook, HookMetadata, IdentifySeriesContext, diff --git a/packages/flutter_client_sdk/lib/src/config/defaults/flutter_default_config.dart b/packages/flutter_client_sdk/lib/src/config/defaults/flutter_default_config.dart index 615411a7..f727ac6f 100644 --- a/packages/flutter_client_sdk/lib/src/config/defaults/flutter_default_config.dart +++ b/packages/flutter_client_sdk/lib/src/config/defaults/flutter_default_config.dart @@ -1,20 +1,22 @@ +import 'package:launchdarkly_common_client/launchdarkly_common_client.dart'; + import 'stub_config.dart' if (dart.library.io) 'io_config.dart' if (dart.library.js_interop) 'js_config.dart'; /// Configuration common to web and mobile is contained in this file. /// -/// Configuration specific to either io targets or js targets are in io_config -/// and js_config and then exposed through this file. - -final class DefaultApplicationEventsConfig { - final defaultBackgrounding = true; - final defaultNetworkAvailability = true; -} +/// Native IO and web-specific defaults live in `io_config.dart` and +/// `js_config.dart` and are exposed through this file. final class FlutterDefaultConfig { static final ConnectionManagerConfig connectionManagerConfig = ConnectionManagerConfig(); - static final applicationEventsConfig = DefaultApplicationEventsConfig(); + /// Default automatic-resolution background slot. + static FDv2ConnectionMode get defaultBackgroundConnectionMode => + connectionManagerConfig.defaultBackgroundConnectionMode; + + static final ApplicationEventsConfig applicationEventsConfig = + ApplicationEventsConfig(); } diff --git a/packages/flutter_client_sdk/lib/src/config/defaults/io_config.dart b/packages/flutter_client_sdk/lib/src/config/defaults/io_config.dart index 63a8ddca..6e829672 100644 --- a/packages/flutter_client_sdk/lib/src/config/defaults/io_config.dart +++ b/packages/flutter_client_sdk/lib/src/config/defaults/io_config.dart @@ -1,6 +1,26 @@ import 'dart:io' show Platform; +import 'package:launchdarkly_common_client/launchdarkly_common_client.dart'; + class ConnectionManagerConfig { bool get runInBackground => Platform.isLinux || Platform.isWindows || Platform.isMacOS; + + FDv2ConnectionMode get defaultBackgroundConnectionMode => + Platform.isAndroid || Platform.isIOS || Platform.isFuchsia + ? const FDv2Background() + : const FDv2Offline(); +} + +/// Platform defaults for [ApplicationEvents] on native IO targets. +/// +/// Mobile uses application and network signals for automatic connection +/// handling; desktop IO targets do not by default. +final class ApplicationEventsConfig { + bool get _isMobile => + Platform.isAndroid || Platform.isIOS || Platform.isFuchsia; + + bool get defaultBackgrounding => _isMobile; + + bool get defaultNetworkAvailability => _isMobile; } diff --git a/packages/flutter_client_sdk/lib/src/config/defaults/js_config.dart b/packages/flutter_client_sdk/lib/src/config/defaults/js_config.dart index 23305631..fef88bb5 100644 --- a/packages/flutter_client_sdk/lib/src/config/defaults/js_config.dart +++ b/packages/flutter_client_sdk/lib/src/config/defaults/js_config.dart @@ -1,3 +1,17 @@ +import 'package:launchdarkly_common_client/launchdarkly_common_client.dart'; + class ConnectionManagerConfig { bool get runInBackground => true; + + FDv2ConnectionMode get defaultBackgroundConnectionMode => const FDv2Offline(); +} + +/// Platform defaults for [ApplicationEvents] on web. +/// +/// Web does not use application or network detector signals for automatic +/// connection handling by default. +final class ApplicationEventsConfig { + bool get defaultBackgrounding => false; + + bool get defaultNetworkAvailability => false; } diff --git a/packages/flutter_client_sdk/lib/src/config/defaults/stub_config.dart b/packages/flutter_client_sdk/lib/src/config/defaults/stub_config.dart index 2994e197..8d9fe3ba 100644 --- a/packages/flutter_client_sdk/lib/src/config/defaults/stub_config.dart +++ b/packages/flutter_client_sdk/lib/src/config/defaults/stub_config.dart @@ -1,3 +1,14 @@ +import 'package:launchdarkly_common_client/launchdarkly_common_client.dart'; + class ConnectionManagerConfig { bool get runInBackground => throw Exception('Stub implementation'); + + FDv2ConnectionMode get defaultBackgroundConnectionMode => const FDv2Offline(); +} + +/// Stub defaults for tests and unsupported compilation targets. +final class ApplicationEventsConfig { + bool get defaultBackgrounding => false; + + bool get defaultNetworkAvailability => false; } diff --git a/packages/flutter_client_sdk/lib/src/config/ld_config.dart b/packages/flutter_client_sdk/lib/src/config/ld_config.dart index 1c1cbbf7..d682c82b 100644 --- a/packages/flutter_client_sdk/lib/src/config/ld_config.dart +++ b/packages/flutter_client_sdk/lib/src/config/ld_config.dart @@ -16,13 +16,15 @@ final class ApplicationEvents { bool networkAvailability; /// Setting [backgrounding] to true allows the SDK to detect and react to - /// the application entering the background or foreground. The default - /// value is `true`. + /// the application entering the background or the foreground. The default + /// depends on the platform (typically enabled on mobile and disabled on + /// desktop and web); see [FlutterDefaultConfig.applicationEventsConfig]. /// /// Setting [networkAvailability] to true allows the SDK to detect and react /// to network connectivity changes. For instance the SDK may not try to send - /// events if it detects the network is not available. The default value is - /// `true`. + /// events if it detects the network is not available. The default depends on + /// the platform (typically enabled on mobile and disabled on desktop and web); + /// see [FlutterDefaultConfig.applicationEventsConfig]. ApplicationEvents({bool? backgrounding, bool? networkAvailability}) : backgrounding = backgrounding ?? FlutterDefaultConfig.applicationEventsConfig.defaultBackgrounding, diff --git a/packages/flutter_client_sdk/lib/src/connection_manager.dart b/packages/flutter_client_sdk/lib/src/connection_manager.dart index 010c1a69..093e982d 100644 --- a/packages/flutter_client_sdk/lib/src/connection_manager.dart +++ b/packages/flutter_client_sdk/lib/src/connection_manager.dart @@ -35,7 +35,7 @@ abstract interface class StateDetector { /// be tested. The LDCommonClient doesn't implement this, so there is a small /// private adapter. abstract interface class ConnectionDestination { - void setMode(ConnectionMode mode); + void setMode(ResolvedConnectionMode mode); void setNetworkAvailability(bool available); @@ -51,8 +51,8 @@ final class DartClientAdapter implements ConnectionDestination { DartClientAdapter(this._client); @override - void setMode(ConnectionMode mode) { - _client.setMode(mode); + void setMode(ResolvedConnectionMode mode) { + _client.setResolvedMode(mode); } @override @@ -72,9 +72,16 @@ final class DartClientAdapter implements ConnectionDestination { } final class ConnectionManagerConfig { - /// The initial connection mode the SDK should use. + /// Configured foreground connection mode used as the automatic resolution + /// foreground slot. final ConnectionMode initialConnectionMode; + /// Configured background connection mode used as the automatic resolution + /// background slot. + /// + /// Defaults to [const FDv2Offline()]. + final FDv2ConnectionMode backgroundConnectionMode; + /// Some platforms (windows, web, mac, linux) can continue executing code /// in the background. final bool runInBackground; @@ -88,24 +95,28 @@ final class ConnectionManagerConfig { final bool disableAutomaticNetworkHandling; /// Disable handling associated with transitioning between the foreground - /// and background. This means that an application will make no attempt to - /// disconnect when entering background state, and it will not attempt - /// to re-establish a connection entering the foreground, beyond the standard + /// and background. This means that an application will not automatically + /// disconnect when entering background state, and it will not automatically + /// re-establish a connection entering the foreground, beyond the standard /// retry logic. - /// - /// The application will always be treated as in the foreground. final bool disableAutomaticBackgroundHandling; - ConnectionManagerConfig( - {this.initialConnectionMode = ConnectionMode.streaming, - this.runInBackground = true, - this.disableAutomaticBackgroundHandling = false, - this.disableAutomaticNetworkHandling = false}); + ConnectionManagerConfig({ + this.initialConnectionMode = ConnectionMode.streaming, + this.backgroundConnectionMode = const FDv2Offline(), + this.runInBackground = true, + this.disableAutomaticBackgroundHandling = false, + this.disableAutomaticNetworkHandling = false, + }); } /// This class tracks the state of the application, network, configuration, /// and desired network state. It uses this information to request specific -/// data source configurations. +/// connection modes. +/// +/// Automatic resolution uses [resolveMode] with +/// [flutterDefaultResolutionTable] by default, or [resolutionTable] when +/// supplied to the constructor. /// /// This class does not contain any platform specific code. Instead platform /// specific code should be implemented in a [StateDetector]. This is primarily @@ -115,11 +126,15 @@ final class ConnectionManager { final ConnectionManagerConfig _config; final StateDetector _detector; final ConnectionDestination _destination; + final List _resolutionTable; StreamSubscription? _applicationStateSub; StreamSubscription? _networkStateSub; - ConnectionMode _currentConnectionMode; + /// When non-null, [resolveMode] is skipped and this mode is + /// applied regardless of lifecycle/network. + ConnectionMode? _modeOverride; + ApplicationState _applicationState; NetworkState _networkState; @@ -132,21 +147,24 @@ final class ConnectionManager { _handleState(); } - ConnectionManager( - {required LDLogger logger, - required ConnectionManagerConfig config, - required ConnectionDestination destination, - required StateDetector detector}) - : _logger = logger.subLogger('ConnectionManager'), + ConnectionManager({ + required LDLogger logger, + required ConnectionManagerConfig config, + required ConnectionDestination destination, + required StateDetector detector, + List? resolutionTable, + }) : _logger = logger.subLogger('ConnectionManager'), _config = config, _destination = destination, - _currentConnectionMode = config.initialConnectionMode, + _resolutionTable = resolutionTable ?? flutterDefaultResolutionTable(), _applicationState = ApplicationState.foreground, _networkState = NetworkState.available, _detector = detector { if (!_config.disableAutomaticBackgroundHandling) { _applicationStateSub = detector.applicationState.listen((applicationState) { + // TODO (SDK-2187): plumb in debouncer here + _applicationState = applicationState; _handleState(); }); @@ -154,59 +172,52 @@ final class ConnectionManager { if (!_config.disableAutomaticNetworkHandling) { _networkStateSub = detector.networkState.listen((networkState) { + // TODO (SDK-2187): plumb in debouncer here + _networkState = networkState; + _destination + .setNetworkAvailability(networkState == NetworkState.available); _handleState(); }); } } - void _setForegroundAvailableMode() { - if (offline) { - _destination.setMode(ConnectionMode.offline); - _destination.setEventSendingEnabled(false, flush: false); - return; - } - - // Currently the foreground mode will always be whatever the last active - // connection mode was. - _destination.setMode(_currentConnectionMode); - _destination.setEventSendingEnabled(true); - } - - void _setBackgroundAvailableMode() { - // flush on backgrounding as application may be killed and we don't want to lose events. - _destination.flush(); - - if (!_config.runInBackground) { - // TODO: Can we support the backgroundDisabled data source status? - // TODO: Is it acceptable for the data source status and `offline` to - // report an `offline` status? - _destination.setMode(ConnectionMode.offline); + void _handleState() { + _logger.debug('Handling state: $_applicationState:$_networkState'); - // no need to flush here, we just did up above - _destination.setEventSendingEnabled(false, flush: false); - return; + final networkAvailable = _networkState == NetworkState.available; + final inForeground = _applicationState == ApplicationState.foreground; + + final ResolvedConnectionMode resolved; + if (_offline) { + resolved = const ResolvedOffline(OfflineSetOffline()); + } else if (_modeOverride case final mode?) { + resolved = switch (mode) { + ConnectionMode.streaming => const ResolvedStreaming(), + ConnectionMode.polling => const ResolvedPolling(), + ConnectionMode.offline => const ResolvedOffline(OfflineSetOffline()), + }; + } else { + final modeState = ModeState( + networkAvailable: networkAvailable, + inForeground: inForeground, + runInBackground: _config.runInBackground, + foregroundConnectionMode: _fdv2FromFdv1(_config.initialConnectionMode), + backgroundConnectionMode: _config.backgroundConnectionMode, + ); + resolved = resolveMode(_resolutionTable, modeState); } - // If connections in the background are allowed, then use the same mode - // as is configured for the foreground. - _setForegroundAvailableMode(); - } + if (!_offline && !inForeground && networkAvailable) { + _destination.flush(); + } - void _handleState() { - _logger.debug('Handling state: $_applicationState:$_networkState'); + _destination.setMode(resolved); - switch (_networkState) { - case NetworkState.unavailable: - _destination.setNetworkAvailability(false); - case NetworkState.available: - _destination.setNetworkAvailability(true); - switch (_applicationState) { - case ApplicationState.foreground: - _setForegroundAvailableMode(); - case ApplicationState.background: - _setBackgroundAvailableMode(); - } + if (_offline || (!inForeground && !_config.runInBackground)) { + _destination.setEventSendingEnabled(false, flush: false); + } else { + _destination.setEventSendingEnabled(true); } } @@ -220,8 +231,14 @@ final class ConnectionManager { } /// Set the desired connection mode for the SDK. - void setMode(ConnectionMode mode) { - _currentConnectionMode = mode; + void setMode(ConnectionMode? mode) { + _modeOverride = mode; _handleState(); } } + +FDv2ConnectionMode _fdv2FromFdv1(ConnectionMode mode) => switch (mode) { + ConnectionMode.streaming => const FDv2Streaming(), + ConnectionMode.polling => const FDv2Polling(), + ConnectionMode.offline => const FDv2Offline(), + }; diff --git a/packages/flutter_client_sdk/lib/src/ld_client.dart b/packages/flutter_client_sdk/lib/src/ld_client.dart index ecaa3f47..3419305d 100644 --- a/packages/flutter_client_sdk/lib/src/ld_client.dart +++ b/packages/flutter_client_sdk/lib/src/ld_client.dart @@ -78,9 +78,10 @@ interface class LDClient { _connectionManager = ConnectionManager( logger: _client.logger, config: ConnectionManagerConfig( - initialConnectionMode: config.offline - ? ConnectionMode.offline - : config.dataSourceConfig.initialConnectionMode, + initialConnectionMode: + config.dataSourceConfig.initialConnectionMode, + backgroundConnectionMode: + FlutterDefaultConfig.defaultBackgroundConnectionMode, disableAutomaticBackgroundHandling: config.offline || !config.applicationEvents.backgrounding, disableAutomaticNetworkHandling: @@ -90,6 +91,10 @@ interface class LDClient { destination: DartClientAdapter(_client), detector: FlutterStateDetector()); + if (config.offline) { + _connectionManager.offline = true; + } + final sdkPluginMetadata = PluginSdkMetadata(name: sdkName, version: sdkVersion); diff --git a/packages/flutter_client_sdk/test/persistence/connection_manager_test.dart b/packages/flutter_client_sdk/test/persistence/connection_manager_test.dart index 862a0937..414c2825 100644 --- a/packages/flutter_client_sdk/test/persistence/connection_manager_test.dart +++ b/packages/flutter_client_sdk/test/persistence/connection_manager_test.dart @@ -57,6 +57,9 @@ void main() { message: '', time: DateTime.now(), logTag: '')); + registerFallbackValue(const OfflineSetOffline()); + registerFallbackValue(const ResolvedStreaming()); + registerFallbackValue(const ResolvedOffline(OfflineSetOffline())); }); test('it can set the connection offline when entering the background', @@ -80,14 +83,15 @@ void main() { // Wait for the state to propagate. await mockDetector.applicationState.first; - verify(() => destination.setMode(ConnectionMode.offline)); + verify(() => destination + .setMode(const ResolvedOffline(OfflineBackgroundDisabled()))); connectionManager.dispose(); }); group('given default connection modes', () { for (var initialMode in [ ConnectionMode.streaming, - ConnectionMode.polling + ConnectionMode.polling, ]) { test( 'it can restore the connection when entering the foreground for mode: $initialMode', @@ -112,7 +116,8 @@ void main() { // Wait for the state to propagate. await mockDetector.applicationState.first; - verify(() => destination.setMode(ConnectionMode.offline)); + verify(() => destination + .setMode(const ResolvedOffline(OfflineBackgroundDisabled()))); reset(destination); mockDetector.setApplicationState(ApplicationState.foreground); @@ -120,14 +125,20 @@ void main() { // Wait for the state to propagate. await mockDetector.applicationState.first; - verify(() => destination.setMode(initialMode)); + verify(() => destination.setMode(switch (initialMode) { + ConnectionMode.streaming => const ResolvedStreaming(), + ConnectionMode.polling => const ResolvedPolling(), + ConnectionMode.offline => + const ResolvedOffline(OfflineSetOffline()), + })); connectionManager.dispose(); }); } }); test( - 'if runInBackground is true, then it remains online when entering the background', + 'if runInBackground is true, default background slot is offline ' + '(desktop-style automatic resolution / default ConnectionManagerConfig)', () async { registerFallbackValue(ConnectionMode.streaming); @@ -149,7 +160,67 @@ void main() { await mockDetector.applicationState.first; verify(() => destination.flush()); - verify(() => destination.setMode(ConnectionMode.streaming)); + verify( + () => destination.setMode(const ResolvedOffline(OfflineSetOffline()))); + connectionManager.dispose(); + }); + + test( + 'if runInBackground is true and backgroundConnectionMode is background, ' + 'it uses that slot in the background', () async { + registerFallbackValue(ConnectionMode.streaming); + registerFallbackValue(const FDv2Background()); + + final destination = MockDestination(); + final logAdapter = MockLogAdapter(); + final logger = LDLogger(adapter: logAdapter); + final config = ConnectionManagerConfig( + runInBackground: true, + backgroundConnectionMode: const FDv2Background(), + ); + final mockDetector = MockStateDetector(); + + final connectionManager = ConnectionManager( + logger: logger, + config: config, + destination: destination, + detector: mockDetector); + + mockDetector.setApplicationState(ApplicationState.background); + + await mockDetector.applicationState.first; + + verify(() => destination.flush()); + verify(() => destination.setMode(const ResolvedBackground())); + connectionManager.dispose(); + }); + + test( + 'if runInBackground is true and backgroundConnectionMode is streaming, ' + 'it uses that slot in the background', () async { + registerFallbackValue(ConnectionMode.streaming); + + final destination = MockDestination(); + final logAdapter = MockLogAdapter(); + final logger = LDLogger(adapter: logAdapter); + final config = ConnectionManagerConfig( + runInBackground: true, + backgroundConnectionMode: const FDv2Streaming(), + ); + final mockDetector = MockStateDetector(); + + final connectionManager = ConnectionManager( + logger: logger, + config: config, + destination: destination, + detector: mockDetector); + + mockDetector.setApplicationState(ApplicationState.background); + + await mockDetector.applicationState.first; + + verify(() => destination.flush()); + verify(() => destination.setMode(const ResolvedStreaming())); connectionManager.dispose(); }); @@ -176,6 +247,8 @@ void main() { await mockDetector.networkState.first; verify(() => destination.setNetworkAvailability(false)); + verify(() => destination + .setMode(const ResolvedOffline(OfflineNetworkUnavailable()))); connectionManager.dispose(); }); @@ -213,6 +286,156 @@ void main() { connectionManager.dispose(); }); + group('network drives mode resolution and custom resolution tables', () { + test( + 'when network is unavailable in the background, mode is offline ' + 'not the background slot (first table row wins)', () async { + registerFallbackValue(ConnectionMode.streaming); + registerFallbackValue(const FDv2Background()); + + final destination = MockDestination(); + final logAdapter = MockLogAdapter(); + final logger = LDLogger(adapter: logAdapter); + final config = ConnectionManagerConfig( + runInBackground: true, + backgroundConnectionMode: const FDv2Background(), + ); + final mockDetector = MockStateDetector(); + + final connectionManager = ConnectionManager( + logger: logger, + config: config, + destination: destination, + detector: mockDetector, + ); + + mockDetector.setApplicationState(ApplicationState.background); + await mockDetector.applicationState.first; + + verify(() => destination.setMode(const ResolvedBackground())); + reset(destination); + + mockDetector.setNetworkAvailable(false); + await mockDetector.networkState.first; + + verify(() => destination.setNetworkAvailability(false)); + verify(() => destination + .setMode(const ResolvedOffline(OfflineNetworkUnavailable()))); + connectionManager.dispose(); + }); + + test( + 'when network returns while foreground, restores ' + 'initialConnectionMode from automatic resolution', () async { + registerFallbackValue(ConnectionMode.streaming); + registerFallbackValue(ConnectionMode.polling); + + final destination = MockDestination(); + final logAdapter = MockLogAdapter(); + final logger = LDLogger(adapter: logAdapter); + final config = ConnectionManagerConfig( + initialConnectionMode: ConnectionMode.polling, + runInBackground: true, + ); + final mockDetector = MockStateDetector(); + + final connectionManager = ConnectionManager( + logger: logger, + config: config, + destination: destination, + detector: mockDetector, + ); + + mockDetector.setNetworkAvailable(false); + await mockDetector.networkState.first; + + verify(() => destination + .setMode(const ResolvedOffline(OfflineNetworkUnavailable()))); + reset(destination); + + mockDetector.setNetworkAvailable(true); + await mockDetector.networkState.first; + + verify(() => destination.setNetworkAvailability(true)); + verify(() => destination.setMode(const ResolvedPolling())); + connectionManager.dispose(); + }); + + test( + 'custom resolution table: network row only then fallback to ' + 'initialConnectionMode when network is available', () async { + registerFallbackValue(ConnectionMode.streaming); + registerFallbackValue(ConnectionMode.polling); + + final destination = MockDestination(); + final logAdapter = MockLogAdapter(); + final logger = LDLogger(adapter: logAdapter); + final config = ConnectionManagerConfig( + initialConnectionMode: ConnectionMode.polling, + runInBackground: true, + ); + final mockDetector = MockStateDetector(); + + final customTable = [ + ModeResolutionEntry( + predicate: (ModeState s) => !s.networkAvailable, + resolve: (_) => const ResolvedOffline(OfflineNetworkUnavailable()), + ), + ]; + + final connectionManager = ConnectionManager( + logger: logger, + config: config, + destination: destination, + detector: mockDetector, + resolutionTable: customTable, + ); + + mockDetector.setNetworkAvailable(false); + await mockDetector.networkState.first; + + verify(() => destination + .setMode(const ResolvedOffline(OfflineNetworkUnavailable()))); + reset(destination); + + mockDetector.setNetworkAvailable(true); + await mockDetector.networkState.first; + + verify(() => destination.setMode(const ResolvedPolling())); + connectionManager.dispose(); + }); + + test( + 'custom empty resolution table falls back to initialConnectionMode ' + 'for all automatic states', () async { + registerFallbackValue(ConnectionMode.streaming); + registerFallbackValue(ConnectionMode.polling); + + final destination = MockDestination(); + final logAdapter = MockLogAdapter(); + final logger = LDLogger(adapter: logAdapter); + final config = ConnectionManagerConfig( + initialConnectionMode: ConnectionMode.polling, + runInBackground: true, + ); + final mockDetector = MockStateDetector(); + + final connectionManager = ConnectionManager( + logger: logger, + config: config, + destination: destination, + detector: mockDetector, + resolutionTable: const [], + ); + + mockDetector.setNetworkAvailable(false); + await mockDetector.networkState.first; + + verify(() => destination.setMode(const ResolvedPolling())); + connectionManager.dispose(); + }); + }); + test('when temporarily offline it ignores state changes and remains offline', () async { registerFallbackValue(ConnectionMode.streaming); @@ -231,7 +454,8 @@ void main() { connectionManager.offline = true; - verify(() => destination.setMode(ConnectionMode.offline)); + verify( + () => destination.setMode(const ResolvedOffline(OfflineSetOffline()))); verify(() => destination.setEventSendingEnabled(false, flush: false)); reset(destination); @@ -242,7 +466,8 @@ void main() { await mockDetector.applicationState.first; await mockDetector.networkState.first; - verify(() => destination.setMode(ConnectionMode.offline)); + verify( + () => destination.setMode(const ResolvedOffline(OfflineSetOffline()))); verify(() => destination.setNetworkAvailability(true)); verify(() => destination.setEventSendingEnabled(false, flush: false)); connectionManager.dispose(); @@ -306,6 +531,40 @@ void main() { connectionManager.dispose(); }); + test('setMode override: applies in background, null restores automatic table', + () async { + registerFallbackValue(ConnectionMode.streaming); + registerFallbackValue(ConnectionMode.polling); + + final destination = MockDestination(); + final logAdapter = MockLogAdapter(); + final logger = LDLogger(adapter: logAdapter); + final config = ConnectionManagerConfig(runInBackground: true); + final mockDetector = MockStateDetector(); + + final connectionManager = ConnectionManager( + logger: logger, + config: config, + destination: destination, + detector: mockDetector); + + mockDetector.setApplicationState(ApplicationState.background); + await mockDetector.applicationState.first; + + verify( + () => destination.setMode(const ResolvedOffline(OfflineSetOffline()))); + reset(destination); + + connectionManager.setMode(ConnectionMode.polling); + verify(() => destination.setMode(const ResolvedPolling())); + reset(destination); + + connectionManager.setMode(null); + verify( + () => destination.setMode(const ResolvedOffline(OfflineSetOffline()))); + connectionManager.dispose(); + }); + group('given requested connection modes', () { for (var requestedMode in [ ConnectionMode.streaming, @@ -335,7 +594,12 @@ void main() { reset(destination); connectionManager.setMode(requestedMode); - verify(() => destination.setMode(requestedMode)); + verify(() => destination.setMode(switch (requestedMode) { + ConnectionMode.streaming => const ResolvedStreaming(), + ConnectionMode.polling => const ResolvedPolling(), + ConnectionMode.offline => + const ResolvedOffline(OfflineSetOffline()), + })); verifyNever( () => destination.setEventSendingEnabled(true, flush: false)); connectionManager.dispose(); From 276b1249cbf1f5e64770a309c4be28a2461525d1 Mon Sep 17 00:00:00 2001 From: Todd Anderson Date: Fri, 29 May 2026 11:12:14 -0400 Subject: [PATCH 3/7] refactor(SDK-2187): DataSourceManager stores FDv2ConnectionMode Splits the active mode and offline detail into two fields: - _activeConnectionMode: FDv2ConnectionMode -- drives factory lookup (matches the factory map's key type directly). - _offlineDetail: OfflineDetail -- semantically meaningful only when _activeConnectionMode is FDv2Offline; carries a stale value in other modes and is intentionally only read inside the FDv2Offline arm of _setupConnection. ResolvedConnectionMode is now a true boundary type: consumed by setMode, decomposed into the two fields, then discarded. This removes the .connectionMode getter call previously needed for factory lookup. setMode dedup is rewritten to compare the FDv2 mode and (when offline) the offline detail explicitly, so a redundant call with the same effective state still short-circuits. Constructor initializes _offlineDetail to OfflineSetOffline() as a placeholder; it is overwritten the next time setMode receives a ResolvedOffline value. --- .../src/data_sources/data_source_manager.dart | 43 +++++++++++++------ 1 file changed, 29 insertions(+), 14 deletions(-) diff --git a/packages/common_client/lib/src/data_sources/data_source_manager.dart b/packages/common_client/lib/src/data_sources/data_source_manager.dart index 5fcfc164..d5cf523f 100644 --- a/packages/common_client/lib/src/data_sources/data_source_manager.dart +++ b/packages/common_client/lib/src/data_sources/data_source_manager.dart @@ -16,7 +16,15 @@ typedef DataSourceFactory = DataSource Function(LDContext context); /// The data source manager controls which data source is connected to /// the data source status as well as the data source event handler. final class DataSourceManager { - ResolvedConnectionMode _activeConnectionMode; + /// The mode that drives factory lookup and status dispatch. + FDv2ConnectionMode _activeConnectionMode; + + /// Semantically meaningful only when [_activeConnectionMode] is + /// [FDv2Offline]. Otherwise carries a stale value from the last time the + /// SDK was offline (or the construction-time default), and is intentionally + /// only read inside the [FDv2Offline] arm of [_setupConnection]. + OfflineDetail _offlineDetail; + LDContext? _activeContext; final LDLogger _logger; @@ -36,10 +44,11 @@ final class DataSourceManager { required DataSourceEventHandler dataSourceEventHandler, required LDLogger logger, }) : _activeConnectionMode = switch (startingMode) { - ConnectionMode.streaming => const ResolvedStreaming(), - ConnectionMode.polling => const ResolvedPolling(), - ConnectionMode.offline => const ResolvedOffline(OfflineSetOffline()), + ConnectionMode.streaming => const FDv2Streaming(), + ConnectionMode.polling => const FDv2Polling(), + ConnectionMode.offline => const FDv2Offline(), }, + _offlineDetail = const OfflineSetOffline(), _logger = logger.subLogger('DataSourceManager'), _statusManager = statusManager, _dataSourceEventHandler = dataSourceEventHandler; @@ -60,13 +69,20 @@ final class DataSourceManager { } void setMode(ResolvedConnectionMode mode) { - if (mode == _activeConnectionMode) { + final newConnectionMode = mode.connectionMode; + final newDetail = mode is ResolvedOffline ? mode.detail : null; + final isOffline = newConnectionMode is FDv2Offline; + if (newConnectionMode == _activeConnectionMode && + (!isOffline || newDetail == _offlineDetail)) { _logger.debug('Mode is already set to: $mode'); return; } _logger.debug( - 'Changing resolved connection mode from: $_activeConnectionMode to: $mode'); - _activeConnectionMode = mode; + 'Changing connection mode from: $_activeConnectionMode to: $mode'); + _activeConnectionMode = newConnectionMode; + if (newDetail != null) { + _offlineDetail = newDetail; + } _setupConnection(); } @@ -101,8 +117,8 @@ final class DataSourceManager { _stopConnection(); switch (_activeConnectionMode) { - case ResolvedOffline(:final detail): - switch (detail) { + case FDv2Offline(): + switch (_offlineDetail) { case OfflineSetOffline(): _statusManager.setOffline(); case OfflineNetworkUnavailable(): @@ -111,14 +127,13 @@ final class DataSourceManager { _statusManager.setBackgroundDisabled(); } return; - case ResolvedStreaming(): - case ResolvedPolling(): - case ResolvedBackground(): + case FDv2Streaming(): + case FDv2Polling(): + case FDv2Background(): break; } - final mode = _activeConnectionMode.connectionMode; - _activeDataSource = _createDataSource(mode); + _activeDataSource = _createDataSource(_activeConnectionMode); _subscription = _activeDataSource?.events.asyncMap((event) async { if (_activeContext == null) { _logger.error( From 497d3f89c7e7a898e57a4dbc64ca2a4cd6b55388 Mon Sep 17 00:00:00 2001 From: Todd Anderson Date: Fri, 29 May 2026 11:13:08 -0400 Subject: [PATCH 4/7] docs: tighten LDCommonClient.setResolvedMode docstring --- packages/common_client/lib/src/ld_common_client.dart | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/common_client/lib/src/ld_common_client.dart b/packages/common_client/lib/src/ld_common_client.dart index 1b3ee565..8d8cc8c7 100644 --- a/packages/common_client/lib/src/ld_common_client.dart +++ b/packages/common_client/lib/src/ld_common_client.dart @@ -807,9 +807,7 @@ final class LDCommonClient { }); } - /// Set a resolved FDv2 connection mode directly. This is the advanced entry - /// point used by the FDv2 connection-management layer and bypasses the - /// FDv1 [ConnectionMode] mapping done by [setMode]. + /// Set a resolved FDv2 connection mode the SDK should use. /// /// This method is not stable, and not subject to any backwards compatibility /// guarantees or semantic versioning. It is in early access. If you want From a150a7382d41ec9764a92eb5f205093d089bf409 Mon Sep 17 00:00:00 2001 From: Todd Anderson Date: Fri, 29 May 2026 16:08:41 -0400 Subject: [PATCH 5/7] refactor(SDK-2187): ConnectionManager setMode takes FDv2ConnectionMode The runtime mode-override path now uses FDv2ConnectionMode end-to-end: - ConnectionManager._modeOverride is FDv2ConnectionMode? (was ConnectionMode?). - ConnectionManager.setMode signature accepts FDv2ConnectionMode? so callers can request any FDv2 mode -- including FDv2Background() -- as the override. - _handleState pattern-matches the four FDv2 subtypes when applying the override. initialConnectionMode stays ConnectionMode (FDv1) because it is bound to LDConfig.dataSourceConfig.initialConnectionMode, an existing user-visible configuration field. The _fdv2FromFdv1 helper retains its remaining caller (translating initialConnectionMode into the ModeState slot). Tests updated. The override iteration in 'given requested connection modes' now exercises all four FDv2 modes (background included). --- .../lib/src/connection_manager.dart | 14 ++++++---- .../persistence/connection_manager_test.dart | 28 +++++++------------ 2 files changed, 18 insertions(+), 24 deletions(-) diff --git a/packages/flutter_client_sdk/lib/src/connection_manager.dart b/packages/flutter_client_sdk/lib/src/connection_manager.dart index 093e982d..142fa48f 100644 --- a/packages/flutter_client_sdk/lib/src/connection_manager.dart +++ b/packages/flutter_client_sdk/lib/src/connection_manager.dart @@ -133,7 +133,7 @@ final class ConnectionManager { /// When non-null, [resolveMode] is skipped and this mode is /// applied regardless of lifecycle/network. - ConnectionMode? _modeOverride; + FDv2ConnectionMode? _modeOverride; ApplicationState _applicationState; NetworkState _networkState; @@ -193,9 +193,10 @@ final class ConnectionManager { resolved = const ResolvedOffline(OfflineSetOffline()); } else if (_modeOverride case final mode?) { resolved = switch (mode) { - ConnectionMode.streaming => const ResolvedStreaming(), - ConnectionMode.polling => const ResolvedPolling(), - ConnectionMode.offline => const ResolvedOffline(OfflineSetOffline()), + FDv2Streaming() => const ResolvedStreaming(), + FDv2Polling() => const ResolvedPolling(), + FDv2Background() => const ResolvedBackground(), + FDv2Offline() => const ResolvedOffline(OfflineSetOffline()), }; } else { final modeState = ModeState( @@ -230,8 +231,9 @@ final class ConnectionManager { _detector.dispose(); } - /// Set the desired connection mode for the SDK. - void setMode(ConnectionMode? mode) { + /// Set the desired connection mode for the SDK. Passing null clears the + /// override and resumes automatic mode resolution. + void setMode(FDv2ConnectionMode? mode) { _modeOverride = mode; _handleState(); } diff --git a/packages/flutter_client_sdk/test/persistence/connection_manager_test.dart b/packages/flutter_client_sdk/test/persistence/connection_manager_test.dart index 414c2825..2b77d3b4 100644 --- a/packages/flutter_client_sdk/test/persistence/connection_manager_test.dart +++ b/packages/flutter_client_sdk/test/persistence/connection_manager_test.dart @@ -555,7 +555,7 @@ void main() { () => destination.setMode(const ResolvedOffline(OfflineSetOffline()))); reset(destination); - connectionManager.setMode(ConnectionMode.polling); + connectionManager.setMode(const FDv2Polling()); verify(() => destination.setMode(const ResolvedPolling())); reset(destination); @@ -566,23 +566,20 @@ void main() { }); group('given requested connection modes', () { - for (var requestedMode in [ - ConnectionMode.streaming, - ConnectionMode.polling, - ConnectionMode.offline, + for (var entry in <(FDv2ConnectionMode, ResolvedConnectionMode)>[ + (const FDv2Streaming(), const ResolvedStreaming()), + (const FDv2Polling(), const ResolvedPolling()), + (const FDv2Background(), const ResolvedBackground()), + (const FDv2Offline(), const ResolvedOffline(OfflineSetOffline())), ]) { - test('it respects changes to the desired connection mode', () { - // Get an initial mode that will be different than the requested mode. - final initialMode = - ConnectionMode.values.firstWhere((mode) => mode != requestedMode); - + final (requestedMode, expectedResolved) = entry; + test('it respects setMode($requestedMode)', () { registerFallbackValue(ConnectionMode.streaming); final destination = MockDestination(); final logAdapter = MockLogAdapter(); final logger = LDLogger(adapter: logAdapter); - final config = ConnectionManagerConfig( - runInBackground: false, initialConnectionMode: initialMode); + final config = ConnectionManagerConfig(runInBackground: false); final mockDetector = MockStateDetector(); final connectionManager = ConnectionManager( @@ -594,12 +591,7 @@ void main() { reset(destination); connectionManager.setMode(requestedMode); - verify(() => destination.setMode(switch (requestedMode) { - ConnectionMode.streaming => const ResolvedStreaming(), - ConnectionMode.polling => const ResolvedPolling(), - ConnectionMode.offline => - const ResolvedOffline(OfflineSetOffline()), - })); + verify(() => destination.setMode(expectedResolved)); verifyNever( () => destination.setEventSendingEnabled(true, flush: false)); connectionManager.dispose(); From 7c66f85425f59cf392003ba3955e85d91c1691a9 Mon Sep 17 00:00:00 2001 From: Todd Anderson Date: Wed, 3 Jun 2026 11:27:05 -0400 Subject: [PATCH 6/7] docs: clarify LDConfig.offline can be toggled at runtime --- packages/flutter_client_sdk/lib/src/config/ld_config.dart | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/flutter_client_sdk/lib/src/config/ld_config.dart b/packages/flutter_client_sdk/lib/src/config/ld_config.dart index d682c82b..90881ed0 100644 --- a/packages/flutter_client_sdk/lib/src/config/ld_config.dart +++ b/packages/flutter_client_sdk/lib/src/config/ld_config.dart @@ -64,10 +64,9 @@ final class LDConfig extends LDCommonConfig { /// /// [events] defines configuration for analytics and diagnostic events. /// - /// [offline] is used to disable all network calls from the LaunchDarkly - /// client. Setting offline here will make the SDK permanently offline. - /// You can temporarily make the SDK offline using the offline property - /// of the client. + /// [offline] is used to start the SDK with all network calls disabled. + /// The offline state can later be toggled at runtime using the offline + /// property of the client. /// /// [logger] can be used to customize the logging done by the SDK. /// From bc0a1ac9fa4edfc0439f1c38f76198536288a6d8 Mon Sep 17 00:00:00 2001 From: Todd Anderson Date: Wed, 3 Jun 2026 17:34:08 -0400 Subject: [PATCH 7/7] chore: bump launchdarkly_common_client to 1.11.0 The FDv2 connection-mode resolution wiring on this branch consumes APIs introduced in launchdarkly_common_client 1.10.0 (FDv2ConnectionMode + subtypes, ResolvedConnectionMode family) and 1.11.0 (LDCommonClient.setResolvedMode). Bump the pinned exact version to match so published flutter_client_sdk releases resolve against a common_client that actually has these symbols. --- packages/flutter_client_sdk/pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/flutter_client_sdk/pubspec.yaml b/packages/flutter_client_sdk/pubspec.yaml index 7354b7e6..348a8bc7 100644 --- a/packages/flutter_client_sdk/pubspec.yaml +++ b/packages/flutter_client_sdk/pubspec.yaml @@ -13,7 +13,7 @@ dependencies: sdk: flutter package_info_plus: ">=4.2.0 <11.0.0" device_info_plus: ">=9.1.1 <14.0.0" - launchdarkly_common_client: 1.9.0 + launchdarkly_common_client: 1.11.0 shared_preferences: ^2.2.2 connectivity_plus: ">=5.0.2 <8.0.0" web: ^1.1.1