diff --git a/packages/common_client/lib/launchdarkly_common_client.dart b/packages/common_client/lib/launchdarkly_common_client.dart index ddc62c19..72194727 100644 --- a/packages/common_client/lib/launchdarkly_common_client.dart +++ b/packages/common_client/lib/launchdarkly_common_client.dart @@ -49,6 +49,12 @@ export 'src/config/common_platform.dart' show CommonPlatform; export 'src/config/events_config.dart' show EventsConfig; export 'src/config/credential/credential_source.dart' show CredentialSource; export 'src/connection_mode.dart' show ConnectionMode; +export 'src/data_sources/fdv2/mode_resolution.dart' + show + ModeState, + ModeResolutionEntry, + resolveConnectionMode, + flutterDefaultResolutionTable; export 'src/data_sources/data_source_status.dart' show DataSourceStatusErrorInfo, DataSourceStatus, DataSourceState; diff --git a/packages/common_client/lib/src/connection_mode.dart b/packages/common_client/lib/src/connection_mode.dart index c8ade407..52859d12 100644 --- a/packages/common_client/lib/src/connection_mode.dart +++ b/packages/common_client/lib/src/connection_mode.dart @@ -12,4 +12,8 @@ enum ConnectionMode { /// The SDK will make periodic requests to receive updates from LaunchDarkly. polling, + + /// The SDK is in a background state and will use the configured background + /// connection mode or default for the platform if not configured. + background, } 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..fbf3061f 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 @@ -127,6 +127,7 @@ final class DataSourceManager { _statusManager.setOffline(); case ConnectionMode.streaming: case ConnectionMode.polling: + case ConnectionMode.background: // default: // We may want to consider adding another state to the data source state // for the intermediate between switching data sources, or for identifying diff --git a/packages/common_client/lib/src/data_sources/fdv2/built_in_modes.dart b/packages/common_client/lib/src/data_sources/fdv2/built_in_modes.dart new file mode 100644 index 00000000..8b172bd2 --- /dev/null +++ b/packages/common_client/lib/src/data_sources/fdv2/built_in_modes.dart @@ -0,0 +1,53 @@ +import 'mode_definition.dart'; + +/// Built-in [ModeDefinition] values. +abstract final class BuiltInModes { + BuiltInModes._(); + + /// Default foreground poll interval. + static const Duration _foregroundPollInterval = Duration(seconds: 300); + + /// Default background poll interval. + static const Duration _backgroundPollInterval = Duration(seconds: 3600); + + /// Default streaming mode (mobile foreground / desktop). + static const ModeDefinition streaming = ModeDefinition( + initializers: [ + CacheInitializer(), + PollingInitializer(), + ], + synchronizers: [ + StreamingSynchronizer(), + PollingSynchronizer(), + ], + fdv1Fallback: Fdv1FallbackConfig( + pollInterval: _foregroundPollInterval, + ), + ); + + /// Polling-only mode. + static const ModeDefinition polling = ModeDefinition( + initializers: [CacheInitializer()], + synchronizers: [PollingSynchronizer()], + fdv1Fallback: Fdv1FallbackConfig( + pollInterval: _foregroundPollInterval, + ), + ); + + /// Offline: cache initializer only; no synchronizers. + static const ModeDefinition offline = ModeDefinition( + initializers: [CacheInitializer()], + synchronizers: [], + ); + + /// Mobile background: cache initializer, reduced-rate polling synchronizer (CSFDV2 §5.2.3). + static const ModeDefinition background = ModeDefinition( + initializers: [CacheInitializer()], + synchronizers: [ + PollingSynchronizer(pollInterval: _backgroundPollInterval), + ], + fdv1Fallback: Fdv1FallbackConfig( + pollInterval: _backgroundPollInterval, + ), + ); +} diff --git a/packages/common_client/lib/src/data_sources/fdv2/entry_factories.dart b/packages/common_client/lib/src/data_sources/fdv2/entry_factories.dart new file mode 100644 index 00000000..d6e4ebb5 --- /dev/null +++ b/packages/common_client/lib/src/data_sources/fdv2/entry_factories.dart @@ -0,0 +1,175 @@ +import 'dart:convert'; + +import 'package:launchdarkly_dart_common/launchdarkly_dart_common.dart' + hide ServiceEndpoints; + +import '../../config/service_endpoints.dart'; +import 'cache_initializer.dart' as cache_src; +import 'source_factory_context.dart'; +import 'mode_definition.dart' as mode; +import 'polling_base.dart'; +import 'polling_initializer.dart'; +import 'polling_synchronizer.dart'; +import 'requestor.dart'; +import 'selector.dart'; +import 'source.dart'; + +/// Merges optional per-entry [mode.EndpointConfig] overrides into [base]. +ServiceEndpoints mergeServiceEndpoints( + ServiceEndpoints base, + mode.EndpointConfig? override, +) { + if (override == null) { + return base; + } + if (override.pollingBaseUri == null && override.streamingBaseUri == null) { + return base; + } + return ServiceEndpoints.custom( + polling: override.pollingBaseUri?.toString() ?? base.polling, + streaming: override.streamingBaseUri?.toString() ?? base.streaming, + events: base.events, + ); +} + +FDv2PollingBase _sharedPollingBase({ + required mode.EndpointConfig? endpoints, + required bool usePost, + required SourceFactoryContext ctx, +}) { + final endpointsResolved = + mergeServiceEndpoints(ctx.serviceEndpoints, endpoints); + final requestor = FDv2Requestor( + logger: ctx.logger, + endpoints: endpointsResolved, + contextEncoded: base64UrlEncode(utf8.encode(ctx.contextJson)), + contextJson: ctx.contextJson, + usePost: usePost, + withReasons: ctx.withReasons, + httpProperties: ctx.httpProperties, + httpClientFactory: ctx.httpClientFactory ?? _defaultHttpClientFactory, + ); + return FDv2PollingBase( + logger: ctx.logger, + requestor: requestor, + ); +} + +HttpClient _defaultHttpClientFactory(HttpProperties httpProperties) { + return HttpClient(httpProperties: httpProperties); +} + +/// A factory for creating [Initializer] instances. +final class InitializerFactory { + /// True for cache initializers ([CONNMODE] / CSFDv2 cache-miss success rule). + final bool isCache; + + final Initializer Function(SelectorGetter selectorGetter) _create; + + InitializerFactory({ + required Initializer Function(SelectorGetter selectorGetter) create, + this.isCache = false, + }) : _create = create; + + /// Returns a **new** [Initializer] bound to [selectorGetter] (or ignores it + /// for cache, matching JS). + Initializer create(SelectorGetter selectorGetter) => _create(selectorGetter); +} + +/// A factory for creating [Synchronizer] instances. +final class SynchronizerFactory { + final Synchronizer Function(SelectorGetter selectorGetter) _create; + + SynchronizerFactory({ + required Synchronizer Function(SelectorGetter selectorGetter) create, + }) : _create = create; + + Synchronizer create(SelectorGetter selectorGetter) => _create(selectorGetter); +} + +/// Builds an [InitializerFactory] for a single [mode.InitializerEntry]. +/// +/// Throws [UnsupportedError] for unsupported entry types. +InitializerFactory createInitializerFactoryFromEntry( + mode.InitializerEntry entry, + SourceFactoryContext ctx, +) { + switch (entry) { + case mode.CacheInitializer(): + return InitializerFactory( + isCache: true, + create: (_) => cache_src.CacheInitializer( + reader: ctx.cachedFlagsReader, + context: ctx.context, + logger: ctx.logger, + ), + ); + case final mode.PollingInitializer e: + final base = _sharedPollingBase( + endpoints: e.endpoints, + usePost: e.usePost, + ctx: ctx, + ); + return InitializerFactory( + create: (SelectorGetter selectorGetter) => FDv2PollingInitializer( + poll: ({Selector basis = Selector.empty}) => + base.pollOnce(basis: basis), + selectorGetter: selectorGetter, + logger: ctx.logger, + ), + ); + case mode.StreamingInitializer(): + throw UnsupportedError( + 'FDv2 StreamingInitializer factories are not implemented yet', + ); + } +} + +/// Builds a [SynchronizerFactory] for a single [mode.SynchronizerEntry]. +/// +/// Throws [UnsupportedError] for unsupported entry types. +SynchronizerFactory createSynchronizerFactoryFromEntry( + mode.SynchronizerEntry entry, + SourceFactoryContext ctx, +) { + switch (entry) { + case final mode.PollingSynchronizer e: + final base = _sharedPollingBase( + endpoints: e.endpoints, + usePost: e.usePost, + ctx: ctx, + ); + final interval = e.pollInterval ?? ctx.defaultPollingInterval; + return SynchronizerFactory( + create: (SelectorGetter selectorGetter) => FDv2PollingSynchronizer( + poll: ({Selector basis = Selector.empty}) => + base.pollOnce(basis: basis), + selectorGetter: selectorGetter, + interval: interval, + logger: ctx.logger, + ), + ); + case mode.StreamingSynchronizer(): + throw UnsupportedError( + 'FDv2 StreamingSynchronizer factories are not implemented yet', + ); + } +} + +/// One factory per entry, in list order. +List buildInitializerFactories( + List entries, + SourceFactoryContext ctx, +) { + return entries.map((e) => createInitializerFactoryFromEntry(e, ctx)).toList(); +} + +/// One factory per entry, in list order. +List buildSynchronizerFactories( + List entries, + SourceFactoryContext ctx, +) { + return entries + .map((e) => createSynchronizerFactoryFromEntry(e, ctx)) + .toList(); +} diff --git a/packages/common_client/lib/src/data_sources/fdv2/mode_definition.dart b/packages/common_client/lib/src/data_sources/fdv2/mode_definition.dart new file mode 100644 index 00000000..242af119 --- /dev/null +++ b/packages/common_client/lib/src/data_sources/fdv2/mode_definition.dart @@ -0,0 +1,114 @@ +/// Per-source endpoint overrides. When fields are null, the client uses +/// the default [ServiceEndpoints] from config. +final class EndpointConfig { + final Uri? pollingBaseUri; + final Uri? streamingBaseUri; + + const EndpointConfig({this.pollingBaseUri, this.streamingBaseUri}); +} + +/// Marker class for separating initializers from other types of source entries. +sealed class InitializerEntry { + const InitializerEntry(); +} + +/// Marker class for separating synchronizers from other types of source entries. +sealed class SynchronizerEntry { + const SynchronizerEntry(); +} + +/// Initializer that will read data from cache. +final class CacheInitializer extends InitializerEntry { + const CacheInitializer(); +} + +/// Initializer that will make fetch data from polling endpoints. +final class PollingInitializer extends InitializerEntry { + /// Per-source endpoint overrides. + final EndpointConfig? endpoints; + + /// Whether to use the POST semantics for this source. + final bool usePost; + + const PollingInitializer({ + this.endpoints, + this.usePost = false, + }); +} + +/// Streaming initializer (e.g. first payload from a stream). +final class StreamingInitializer extends InitializerEntry { + /// Initial reconnect delay for the streaming source. + final Duration? initialReconnectDelay; + + /// Per-source endpoint overrides. + final EndpointConfig? endpoints; + + /// Whether to use the POST semantics for this source. + final bool usePost; + + const StreamingInitializer({ + this.initialReconnectDelay, + this.endpoints, + this.usePost = false, + }); +} + +/// Long-lived polling synchronizer; [pollInterval] overrides client default when set. +final class PollingSynchronizer extends SynchronizerEntry { + /// Minimum polling interval for the synchronizer. + final Duration? pollInterval; + + /// Per-source endpoint overrides. + final EndpointConfig? endpoints; + + /// Whether to use the POST semantics for this source. + final bool usePost; + + const PollingSynchronizer({ + this.pollInterval, + this.endpoints, + this.usePost = false, + }); +} + +/// Long-lived streaming synchronizer. +final class StreamingSynchronizer extends SynchronizerEntry { + final Duration? initialReconnectDelay; + + /// Per-source endpoint overrides. + final EndpointConfig? endpoints; + + /// Whether to use the POST semantics for this source. + final bool usePost; + + const StreamingSynchronizer({ + this.initialReconnectDelay, + this.endpoints, + this.usePost = false, + }); +} + +/// Defines the initializers and synchronizers for a FDv2 connection mode. +final class ModeDefinition { + final List initializers; + final List synchronizers; + final Fdv1FallbackConfig? fdv1Fallback; + + const ModeDefinition({ + required this.initializers, + required this.synchronizers, + this.fdv1Fallback, + }); +} + +/// Configuration for the FDv1 fallback tier. +final class Fdv1FallbackConfig { + /// Minimum polling interval for the fallback synchronizer + final Duration? pollInterval; + + /// Per-source endpoint overrides. + final EndpointConfig? endpoints; + + const Fdv1FallbackConfig({this.pollInterval, this.endpoints}); +} diff --git a/packages/common_client/lib/src/data_sources/fdv2/mode_resolution.dart b/packages/common_client/lib/src/data_sources/fdv2/mode_resolution.dart new file mode 100644 index 00000000..5d6192fc --- /dev/null +++ b/packages/common_client/lib/src/data_sources/fdv2/mode_resolution.dart @@ -0,0 +1,98 @@ +import '../../connection_mode.dart'; + +/// Inputs for Layer-2 **automatic** mode resolution (lifecycle, network, mode slots). +/// +/// When the client holds a connection mode override, the caller should apply +/// that mode directly and **not** invoke [resolveConnectionMode]. +final class ModeState { + final bool networkAvailable; + + /// Application lifecycle: true in foreground, false in background. + final bool inForeground; + + /// When false, the app is treated as not allowed to receive updates while + /// backgrounded (Flutter `ConnectionManagerConfig.runInBackground` uses the + /// same flag name and semantics). + final bool runInBackground; + + /// Configured foreground mode slot (CONNMODE table “foreground” column). + final ConnectionMode foregroundConnectionMode; + + /// Configured background mode slot when the table selects the background row. + final ConnectionMode backgroundConnectionMode; + + const ModeState({ + required this.networkAvailable, + required this.inForeground, + required this.runInBackground, + required this.foregroundConnectionMode, + required this.backgroundConnectionMode, + }); +} + +/// One row in an ordered mode resolution table (first match wins). +final class ModeResolutionEntry { + final bool Function(ModeState state) predicate; + + /// Resolved [ConnectionMode] for this row; may read slots from [state]. + final ConnectionMode Function(ModeState state) resolve; + + const ModeResolutionEntry({required this.predicate, required this.resolve}); +} + +/// First matching row in [table] wins. If none match, returns +/// [state.foregroundConnectionMode]. +/// +/// Only for **automatic** resolution; do not call when an explicit connection +/// mode override is active (apply the override outside this API). +ConnectionMode resolveConnectionMode( + List table, + ModeState state, +) { + for (final entry in table) { + if (entry.predicate(state)) { + return entry.resolve(state); + } + } + return state.foregroundConnectionMode; +} + +/// Default ordered table for Flutter mobile. When [ModeState.runInBackground] +/// is false while in the background, resolves to offline; +/// otherwise the background row uses [ModeState.backgroundConnectionMode] +/// (CONNMODE §2.2.1: Flutter default for that slot is [ConnectionMode.offline]). +List flutterDefaultResolutionTable() { + return const [ + ModeResolutionEntry( + predicate: _networkDown, + resolve: _offline, + ), + ModeResolutionEntry( + predicate: _backgroundWithoutUpdates, + resolve: _offline, + ), + ModeResolutionEntry( + predicate: _inBackground, + resolve: _backgroundSlot, + ), + ModeResolutionEntry( + predicate: _alwaysTrue, + resolve: _foregroundSlot, + ), + ]; +} + +ConnectionMode _offline(ModeState s) => ConnectionMode.offline; + +bool _networkDown(ModeState s) => !s.networkAvailable; + +bool _backgroundWithoutUpdates(ModeState s) => + !s.inForeground && !s.runInBackground; + +bool _inBackground(ModeState s) => !s.inForeground; + +ConnectionMode _backgroundSlot(ModeState s) => s.backgroundConnectionMode; + +bool _alwaysTrue(ModeState s) => true; + +ConnectionMode _foregroundSlot(ModeState s) => s.foregroundConnectionMode; diff --git a/packages/common_client/lib/src/data_sources/fdv2/source_factory_context.dart b/packages/common_client/lib/src/data_sources/fdv2/source_factory_context.dart new file mode 100644 index 00000000..ed8ece57 --- /dev/null +++ b/packages/common_client/lib/src/data_sources/fdv2/source_factory_context.dart @@ -0,0 +1,70 @@ +import 'dart:convert'; + +import 'package:launchdarkly_dart_common/launchdarkly_dart_common.dart' + hide ServiceEndpoints; + +import '../../config/service_endpoints.dart'; +import 'cache_initializer.dart'; +import 'requestor.dart'; + +/// Shared dependencies for building [InitializerFactory] and [SynchronizerFactory] +/// factories from [ModeDefinition] entries (see [createInitializerFactoryFromEntry], +/// [createSynchronizerFactoryFromEntry]). +final class SourceFactoryContext { + final LDContext context; + + final LDLogger logger; + + final HttpProperties httpProperties; + + final ServiceEndpoints serviceEndpoints; + + final String contextJson; + + final bool withReasons; + + /// Default synchronizer poll interval when a [PollingSynchronizer] entry + /// omits [PollingSynchronizer.pollInterval]. + final Duration defaultPollingInterval; + + final CachedFlagsReader cachedFlagsReader; + + final HttpClientFactory? httpClientFactory; + + const SourceFactoryContext({ + required this.context, + required this.logger, + required this.httpProperties, + required this.serviceEndpoints, + required this.contextJson, + required this.withReasons, + required this.defaultPollingInterval, + required this.cachedFlagsReader, + this.httpClientFactory, + }); + + factory SourceFactoryContext.fromClientConfig({ + required LDContext context, + required LDLogger logger, + required HttpProperties httpProperties, + required ServiceEndpoints serviceEndpoints, + required bool withReasons, + required Duration defaultPollingInterval, + required CachedFlagsReader cachedFlagsReader, + HttpClientFactory? httpClientFactory, + }) { + final plainContextString = + jsonEncode(LDContextSerialization.toJson(context, isEvent: false)); + return SourceFactoryContext( + context: context, + logger: logger, + httpProperties: httpProperties, + serviceEndpoints: serviceEndpoints, + contextJson: plainContextString, + withReasons: withReasons, + defaultPollingInterval: defaultPollingInterval, + cachedFlagsReader: cachedFlagsReader, + httpClientFactory: httpClientFactory, + ); + } +} diff --git a/packages/common_client/lib/src/ld_common_client.dart b/packages/common_client/lib/src/ld_common_client.dart index 4f725ade..7aa74fc5 100644 --- a/packages/common_client/lib/src/ld_common_client.dart +++ b/packages/common_client/lib/src/ld_common_client.dart @@ -13,6 +13,7 @@ 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/data_source_manager.dart'; import 'data_sources/data_source_status.dart'; @@ -80,19 +81,21 @@ Map _defaultFactories( useReport: config.dataSourceConfig.useReport, withReasons: config.dataSourceConfig.evaluationReasons, pollingInterval: config.dataSourceConfig.polling.pollingInterval); + DataSource 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); + } + 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, @@ -102,6 +105,7 @@ Map _defaultFactories( dataSourceConfig: pollingDataSourceConfig, httpProperties: httpProperties); }, + ConnectionMode.background: streaming, }; } @@ -392,6 +396,9 @@ final class LDCommonClient { ConnectionMode.polling: (LDContext context) { return NullDataSource(); }, + ConnectionMode.background: (LDContext context) { + return NullDataSource(); + }, }); } } 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..e604ea7b 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 @@ -40,9 +40,8 @@ final class MockDataSource implements DataSource { } Map defaultFactories( - Map dataSources, - {bool withBackground = false}) { - final factories = { + Map dataSources) { + return { ConnectionMode.streaming: (context) { final dataSource = MockDataSource(); dataSources[ConnectionMode.streaming] = dataSource; @@ -52,9 +51,13 @@ Map defaultFactories( final dataSource = MockDataSource(); dataSources[ConnectionMode.polling] = dataSource; return dataSource; - } + }, + ConnectionMode.background: (context) { + final dataSource = MockDataSource(); + dataSources[ConnectionMode.background] = dataSource; + return dataSource; + }, }; - return factories; } DataSourceManager makeManager( diff --git a/packages/common_client/test/data_sources/fdv2/entry_factories_test.dart b/packages/common_client/test/data_sources/fdv2/entry_factories_test.dart new file mode 100644 index 00000000..e3758646 --- /dev/null +++ b/packages/common_client/test/data_sources/fdv2/entry_factories_test.dart @@ -0,0 +1,150 @@ +import 'package:launchdarkly_common_client/src/config/service_endpoints.dart'; +import 'package:launchdarkly_common_client/src/data_sources/fdv2/built_in_modes.dart'; +import 'package:launchdarkly_common_client/src/data_sources/fdv2/cache_initializer.dart'; +import 'package:launchdarkly_common_client/src/data_sources/fdv2/entry_factories.dart'; +import 'package:launchdarkly_common_client/src/data_sources/fdv2/source_factory_context.dart'; +import 'package:launchdarkly_common_client/src/data_sources/fdv2/mode_definition.dart' + hide CacheInitializer; +import 'package:launchdarkly_common_client/src/data_sources/fdv2/source.dart'; +import 'package:launchdarkly_common_client/src/data_sources/fdv2/payload.dart'; +import 'package:launchdarkly_common_client/src/data_sources/fdv2/polling_synchronizer.dart'; +import 'package:launchdarkly_common_client/src/data_sources/fdv2/selector.dart'; +import 'package:launchdarkly_common_client/src/data_sources/fdv2/source_result.dart'; +import 'package:launchdarkly_dart_common/launchdarkly_dart_common.dart' + hide ServiceEndpoints; +import 'package:test/test.dart'; + +LDContext _context() => LDContextBuilder().kind('user', 'test-key').build(); + +final SelectorGetter _selectorGetter = () => Selector.empty; + +SourceFactoryContext _testContext({ + CachedFlagsReader? reader, + Duration? defaultPollingInterval, +}) { + return SourceFactoryContext.fromClientConfig( + context: _context(), + logger: LDLogger(level: LDLogLevel.error), + httpProperties: HttpProperties(), + serviceEndpoints: ServiceEndpoints.custom(polling: 'https://example.test'), + withReasons: false, + defaultPollingInterval: + defaultPollingInterval ?? const Duration(seconds: 300), + cachedFlagsReader: reader ?? ((_) async => null), + ); +} + +void main() { + group('mergeServiceEndpoints', () { + test('returns base when override is null', () { + final base = ServiceEndpoints.custom( + polling: 'https://poll.example', + streaming: 'https://stream.example', + ); + expect(mergeServiceEndpoints(base, null), same(base)); + }); + + test('overrides polling when entry provides pollingBaseUri', () { + final base = ServiceEndpoints.custom( + polling: 'https://poll.example', + streaming: 'https://stream.example', + ); + final merged = mergeServiceEndpoints( + base, + EndpointConfig(pollingBaseUri: Uri.parse('https://custom.poll/')), + ); + expect(merged.polling, 'https://custom.poll/'); + expect(merged.streaming, base.streaming); + }); + }); + + group('buildInitializerFactories', () { + test('offline mode is cache only', () { + final ctx = _testContext(); + final list = + buildInitializerFactories(BuiltInModes.offline.initializers, ctx); + expect(list, hasLength(1)); + expect(list.single.isCache, isTrue); + final init = list.single.create(_selectorGetter); + expect(init, isA()); + }); + + test('polling mode initializer factories are cache only', () { + final ctx = _testContext(); + final list = + buildInitializerFactories(BuiltInModes.polling.initializers, ctx); + expect(list, hasLength(1)); + expect(list.single.isCache, isTrue); + expect(list.single.create(_selectorGetter), isA()); + }); + + test('polling mode synchronizer factories are polling', () { + final ctx = + _testContext(defaultPollingInterval: const Duration(seconds: 1)); + final list = + buildSynchronizerFactories(BuiltInModes.polling.synchronizers, ctx); + expect(list, hasLength(1)); + final sync = list.single.create(_selectorGetter); + expect(sync, isA()); + sync.close(); + }); + + test('each create() returns a new initializer instance', () { + final ctx = _testContext(); + final factory = buildInitializerFactories( + BuiltInModes.offline.initializers, + ctx, + ).single; + final a = factory.create(_selectorGetter); + final b = factory.create(_selectorGetter); + expect(identical(a, b), isFalse); + }); + }); + + group('createSynchronizerFactoryFromEntry', () { + test('builds factory whose create returns FDv2PollingSynchronizer', () { + final ctx = + _testContext(defaultPollingInterval: const Duration(seconds: 1)); + final factory = createSynchronizerFactoryFromEntry( + PollingSynchronizer(pollInterval: const Duration(seconds: 42)), + ctx, + ); + final sync = factory.create(_selectorGetter); + expect(sync, isA()); + sync.close(); + }); + + test('streaming synchronizer is unsupported', () { + final ctx = _testContext(); + expect( + () => createSynchronizerFactoryFromEntry(StreamingSynchronizer(), ctx), + throwsA(isA()), + ); + }); + }); + + group('createInitializerFactoryFromEntry', () { + test('streaming initializer is unsupported', () { + final ctx = _testContext(); + expect( + () => createInitializerFactoryFromEntry(StreamingInitializer(), ctx), + throwsA(isA()), + ); + }); + }); + + test('cache initializer from factory.create runs with reader', () async { + final ctx = _testContext( + reader: (_) async => null, + ); + final factory = buildInitializerFactories( + BuiltInModes.offline.initializers, + ctx, + ).single; + final init = factory.create(_selectorGetter) as CacheInitializer; + final result = await init.run(); + expect(result, isA()); + final cs = result as ChangeSetResult; + expect(cs.payload.type, PayloadType.none); + }); +} diff --git a/packages/common_client/test/hooks/ld_common_client_hooks_test.dart b/packages/common_client/test/hooks/ld_common_client_hooks_test.dart index ce1d0dc6..82f84be5 100644 --- a/packages/common_client/test/hooks/ld_common_client_hooks_test.dart +++ b/packages/common_client/test/hooks/ld_common_client_hooks_test.dart @@ -321,6 +321,9 @@ void main() { ConnectionMode.polling: (LDContext context) { return dataSource; }, + ConnectionMode.background: (LDContext context) { + return dataSource; + }, }; }, ); diff --git a/packages/common_client/test/ld_dart_client_test.dart b/packages/common_client/test/ld_dart_client_test.dart index d1a0aac5..9a94c50d 100644 --- a/packages/common_client/test/ld_dart_client_test.dart +++ b/packages/common_client/test/ld_dart_client_test.dart @@ -170,6 +170,7 @@ void main() { client.setMode(ConnectionMode.offline); client.setMode(ConnectionMode.streaming); client.setMode(ConnectionMode.polling); + client.setMode(ConnectionMode.background); }); test('can set event sending on/off', () { @@ -208,6 +209,9 @@ void main() { ConnectionMode.polling: (LDContext context) { return TestDataSource(); }, + ConnectionMode.background: (LDContext context) { + return TestDataSource(); + }, }; }); }); @@ -350,6 +354,9 @@ void main() { ConnectionMode.polling: (LDContext context) { return TestDataSource(); }, + ConnectionMode.background: (LDContext context) { + return TestDataSource(); + }, }; }, eventProcessorFactory: ( diff --git a/packages/flutter_client_sdk/lib/src/connection_manager.dart b/packages/flutter_client_sdk/lib/src/connection_manager.dart index 010c1a69..3a75712c 100644 --- a/packages/flutter_client_sdk/lib/src/connection_manager.dart +++ b/packages/flutter_client_sdk/lib/src/connection_manager.dart @@ -72,8 +72,15 @@ final class DartClientAdapter implements ConnectionDestination { } final class ConnectionManagerConfig { - /// The initial connection mode the SDK should use. - final ConnectionMode initialConnectionMode; + /// Configured foreground connection mode used as the automatic resolution + /// foreground slot. + final ConnectionMode foregroundConnectionMode; + + /// Configured background connection mode used as the automatic resolution + /// background slot. + /// + /// Defaults to [ConnectionMode.offline] per CONNMODE §2.2.1 . + final ConnectionMode backgroundConnectionMode; /// Some platforms (windows, web, mac, linux) can continue executing code /// in the background. @@ -96,16 +103,22 @@ final class ConnectionManagerConfig { /// 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.foregroundConnectionMode = ConnectionMode.streaming, + this.backgroundConnectionMode = ConnectionMode.offline, + 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 [ConnectionMode] selection uses [resolveConnectionMode] 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 +128,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, [resolveConnectionMode] is skipped and this mode is + /// applied regardless of lifecycle/network. + ConnectionMode? _modeOverride; + ApplicationState _applicationState; NetworkState _networkState; @@ -132,21 +149,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 +174,48 @@ 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 ConnectionMode resolved; + if (_offline) { + resolved = ConnectionMode.offline; + } else if (_modeOverride != null) { + resolved = _modeOverride!; + } else { + final modeState = ModeState( + networkAvailable: networkAvailable, + inForeground: inForeground, + runInBackground: _config.runInBackground, + foregroundConnectionMode: _config.foregroundConnectionMode, + backgroundConnectionMode: _config.backgroundConnectionMode, + ); + resolved = resolveConnectionMode(_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 +229,8 @@ final class ConnectionManager { } /// Set the desired connection mode for the SDK. - void setMode(ConnectionMode mode) { - _currentConnectionMode = mode; + void setMode(ConnectionMode? mode) { + _modeOverride = mode; _handleState(); } } diff --git a/packages/flutter_client_sdk/lib/src/ld_client.dart b/packages/flutter_client_sdk/lib/src/ld_client.dart index ecaa3f47..0d414e1f 100644 --- a/packages/flutter_client_sdk/lib/src/ld_client.dart +++ b/packages/flutter_client_sdk/lib/src/ld_client.dart @@ -78,7 +78,7 @@ interface class LDClient { _connectionManager = ConnectionManager( logger: _client.logger, config: ConnectionManagerConfig( - initialConnectionMode: config.offline + foregroundConnectionMode: config.offline ? ConnectionMode.offline : config.dataSourceConfig.initialConnectionMode, disableAutomaticBackgroundHandling: 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..33255098 100644 --- a/packages/flutter_client_sdk/test/persistence/connection_manager_test.dart +++ b/packages/flutter_client_sdk/test/persistence/connection_manager_test.dart @@ -87,7 +87,8 @@ void main() { group('given default connection modes', () { for (var initialMode in [ ConnectionMode.streaming, - ConnectionMode.polling + ConnectionMode.polling, + ConnectionMode.background, ]) { test( 'it can restore the connection when entering the foreground for mode: $initialMode', @@ -98,7 +99,7 @@ void main() { final logAdapter = MockLogAdapter(); final logger = LDLogger(adapter: logAdapter); final config = ConnectionManagerConfig( - runInBackground: false, initialConnectionMode: initialMode); + runInBackground: false, foregroundConnectionMode: initialMode); final mockDetector = MockStateDetector(); final connectionManager = ConnectionManager( @@ -127,7 +128,8 @@ void main() { }); test( - 'if runInBackground is true, then it remains online when entering the background', + 'if runInBackground is true, default background slot is offline ' + '(CONNMODE §2.2.1)', () async { registerFallbackValue(ConnectionMode.streaming); @@ -148,6 +150,65 @@ void main() { // Wait for the state to propagate. await mockDetector.applicationState.first; + verify(() => destination.flush()); + verify(() => destination.setMode(ConnectionMode.offline)); + connectionManager.dispose(); + }); + + test( + 'if runInBackground is true and backgroundConnectionMode is background, ' + 'it uses that slot in the background', () async { + registerFallbackValue(ConnectionMode.streaming); + registerFallbackValue(ConnectionMode.background); + + final destination = MockDestination(); + final logAdapter = MockLogAdapter(); + final logger = LDLogger(adapter: logAdapter); + final config = ConnectionManagerConfig( + runInBackground: true, + backgroundConnectionMode: ConnectionMode.background, + ); + 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(ConnectionMode.background)); + 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: ConnectionMode.streaming, + ); + 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(ConnectionMode.streaming)); connectionManager.dispose(); @@ -306,10 +367,43 @@ 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(ConnectionMode.offline)); + reset(destination); + + connectionManager.setMode(ConnectionMode.polling); + verify(() => destination.setMode(ConnectionMode.polling)); + reset(destination); + + connectionManager.setMode(null); + verify(() => destination.setMode(ConnectionMode.offline)); + connectionManager.dispose(); + }); + group('given requested connection modes', () { for (var requestedMode in [ ConnectionMode.streaming, ConnectionMode.polling, + ConnectionMode.background, ConnectionMode.offline, ]) { test('it respects changes to the desired connection mode', () { @@ -323,7 +417,7 @@ void main() { final logAdapter = MockLogAdapter(); final logger = LDLogger(adapter: logAdapter); final config = ConnectionManagerConfig( - runInBackground: false, initialConnectionMode: initialMode); + runInBackground: false, foregroundConnectionMode: initialMode); final mockDetector = MockStateDetector(); final connectionManager = ConnectionManager(