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..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 @@ -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,21 @@ 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; + /// 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; 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 +43,12 @@ final class DataSourceManager { required DataSourceStatusManager statusManager, required DataSourceEventHandler dataSourceEventHandler, required LDLogger logger, - }) : _activeMode = startingMode, + }) : _activeConnectionMode = switch (startingMode) { + ConnectionMode.streaming => const FDv2Streaming(), + ConnectionMode.polling => const FDv2Polling(), + ConnectionMode.offline => const FDv2Offline(), + }, + _offlineDetail = const OfflineSetOffline(), _logger = logger.subLogger('DataSourceManager'), _statusManager = statusManager, _dataSourceEventHandler = dataSourceEventHandler; @@ -43,7 +56,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 +68,21 @@ final class DataSourceManager { _setupConnection(); } - void setMode(ConnectionMode mode) { - if (mode == _activeMode) { - _logger.debug('Mode already active: $_activeMode'); - 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'); + void setMode(ResolvedConnectionMode mode) { + 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( - 'Network availability changed from: $_networkAvailable to: $available'); - _networkAvailable = available; + 'Changing connection mode from: $_activeConnectionMode to: $mode'); + _activeConnectionMode = newConnectionMode; + if (newDetail != null) { + _offlineDetail = newDetail; + } _setupConnection(); } @@ -83,7 +92,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 +116,24 @@ 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 FDv2Offline(): + switch (_offlineDetail) { + case OfflineSetOffline(): + _statusManager.setOffline(); + case OfflineNetworkUnavailable(): + _statusManager.setNetworkUnavailable(); + case OfflineBackgroundDisabled(): + _statusManager.setBackgroundDisabled(); + } + return; + case FDv2Streaming(): + case FDv2Polling(): + case FDv2Background(): + break; } - _activeDataSource = _createDataSource(_activeMode); + _activeDataSource = _createDataSource(_activeConnectionMode); _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..8d8cc8c7 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,20 @@ 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 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 + /// 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 +824,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); 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..90881ed0 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, @@ -62,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. /// diff --git a/packages/flutter_client_sdk/lib/src/connection_manager.dart b/packages/flutter_client_sdk/lib/src/connection_manager.dart index 010c1a69..142fa48f 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. + FDv2ConnectionMode? _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,53 @@ 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) { + FDv2Streaming() => const ResolvedStreaming(), + FDv2Polling() => const ResolvedPolling(), + FDv2Background() => const ResolvedBackground(), + FDv2Offline() => 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); } } @@ -219,9 +231,16 @@ final class ConnectionManager { _detector.dispose(); } - /// Set the desired connection mode for the SDK. - void setMode(ConnectionMode mode) { - _currentConnectionMode = 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(); } } + +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/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 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..2b77d3b4 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,24 +531,55 @@ 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(const FDv2Polling()); + 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, - 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( @@ -335,7 +591,7 @@ void main() { reset(destination); connectionManager.setMode(requestedMode); - verify(() => destination.setMode(requestedMode)); + verify(() => destination.setMode(expectedResolved)); verifyNever( () => destination.setEventSendingEnabled(true, flush: false)); connectionManager.dispose();