From b1fc341974cc888169ce8ab52822123ef2d62ed0 Mon Sep 17 00:00:00 2001 From: Todd Anderson Date: Thu, 28 May 2026 10:56:43 -0400 Subject: [PATCH 1/7] feat(SDK-2187): Add FDv2 connection mode, resolved-mode, and resolution types Adds the FDv2 mode-resolution scaffolding to common_client without touching the FDv1 ConnectionMode enum or any existing runtime path. - FDv2ConnectionMode: closed type-safe class (private const ctor + static const instances). Modeled on the Android SDK's FDv2 ConnectionMode so new modes can be added later without forcing downstream exhaustive switches to update. - ResolvedConnectionMode (sealed) with ResolvedStreaming / ResolvedPolling / ResolvedBackground / ResolvedOffline, plus OfflineDetail subtypes (set-offline / network-unavailable / background-disabled) so callers can distinguish offline reasons. - ModeDefinition + initializer/synchronizer markers (cache, polling, streaming, polling-sync) and Fdv1FallbackConfig. - BuiltInModes: streaming / polling / offline / background mode definitions with the default background poll interval. - ModeResolutionEntry, ModeState, resolveMode, and flutterDefaultResolutionTable() (network-down, background-without- updates, background-slot, foreground-slot rows). - SourceFactoryContext and EntryFactories (cache + polling factories implemented; streaming factories throw UnsupportedError until the behavior PR wires them up). Public exports cover the names the Flutter SDK will consume in the follow-up behavior PR; internal scaffolding (ModeDefinition family, BuiltInModes, SourceFactoryContext, EntryFactories) stays in src/. --- .../lib/launchdarkly_common_client.dart | 20 ++ .../src/data_sources/fdv2/built_in_modes.dart | 52 ++++++ .../data_sources/fdv2/entry_factories.dart | 175 ++++++++++++++++++ .../data_sources/fdv2/mode_definition.dart | 114 ++++++++++++ .../data_sources/fdv2/mode_resolution.dart | 123 ++++++++++++ .../fdv2/source_factory_context.dart | 70 +++++++ .../lib/src/fdv2_connection_mode.dart | 43 +++++ .../common_client/lib/src/offline_detail.dart | 45 +++++ .../lib/src/resolved_connection_mode.dart | 65 +++++++ .../fdv2/entry_factories_test.dart | 149 +++++++++++++++ .../fdv2/mode_resolution_test.dart | 76 ++++++++ .../fdv2/source_factory_context_test.dart | 139 ++++++++++++++ .../lib/launchdarkly_flutter_client_sdk.dart | 14 ++ 13 files changed, 1085 insertions(+) create mode 100644 packages/common_client/lib/src/data_sources/fdv2/built_in_modes.dart create mode 100644 packages/common_client/lib/src/data_sources/fdv2/entry_factories.dart create mode 100644 packages/common_client/lib/src/data_sources/fdv2/mode_definition.dart create mode 100644 packages/common_client/lib/src/data_sources/fdv2/mode_resolution.dart create mode 100644 packages/common_client/lib/src/data_sources/fdv2/source_factory_context.dart create mode 100644 packages/common_client/lib/src/fdv2_connection_mode.dart create mode 100644 packages/common_client/lib/src/offline_detail.dart create mode 100644 packages/common_client/lib/src/resolved_connection_mode.dart create mode 100644 packages/common_client/test/data_sources/fdv2/entry_factories_test.dart create mode 100644 packages/common_client/test/data_sources/fdv2/mode_resolution_test.dart create mode 100644 packages/common_client/test/data_sources/fdv2/source_factory_context_test.dart diff --git a/packages/common_client/lib/launchdarkly_common_client.dart b/packages/common_client/lib/launchdarkly_common_client.dart index ddc62c19..022e1e35 100644 --- a/packages/common_client/lib/launchdarkly_common_client.dart +++ b/packages/common_client/lib/launchdarkly_common_client.dart @@ -49,6 +49,26 @@ 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/fdv2_connection_mode.dart' show FDv2ConnectionMode; +export 'src/resolved_connection_mode.dart' + show + ResolvedConnectionMode, + ResolvedStreaming, + ResolvedPolling, + ResolvedBackground, + ResolvedOffline; +export 'src/offline_detail.dart' + show + OfflineDetail, + OfflineSetOffline, + OfflineNetworkUnavailable, + OfflineBackgroundDisabled; +export 'src/data_sources/fdv2/mode_resolution.dart' + show + ModeState, + ModeResolutionEntry, + resolveMode, + flutterDefaultResolutionTable; export 'src/data_sources/data_source_status.dart' show DataSourceStatusErrorInfo, DataSourceStatus, DataSourceState; 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..8904fd92 --- /dev/null +++ b/packages/common_client/lib/src/data_sources/fdv2/built_in_modes.dart @@ -0,0 +1,52 @@ +import 'mode_definition.dart'; + +/// Built-in [ModeDefinition] values. +abstract final class BuiltInModes { + BuiltInModes._(); + + /// Default foreground poll interval. + static const Duration _foregroundPollInterval = Duration(seconds: 300); + + static const Duration defaultBackgroundPollInterval = 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: defaultBackgroundPollInterval), + ], + fdv1Fallback: Fdv1FallbackConfig( + pollInterval: defaultBackgroundPollInterval, + ), + ); +} 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..a871f34c --- /dev/null +++ b/packages/common_client/lib/src/data_sources/fdv2/mode_resolution.dart @@ -0,0 +1,123 @@ +import '../../fdv2_connection_mode.dart'; +import '../../offline_detail.dart'; +import '../../resolved_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 [resolveMode]. +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. + final FDv2ConnectionMode foregroundConnectionMode; + + /// Configured background mode slot when the table selects the background row. + final FDv2ConnectionMode 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 connection mode for this row; may read slots from [state]. + final ResolvedConnectionMode Function(ModeState state) resolve; + + const ModeResolutionEntry({ + required this.predicate, + required this.resolve, + }); +} + +/// First matching row in [table] wins. If none match, maps +/// [state.foregroundConnectionMode] to a [ResolvedConnectionMode]. +/// +/// Only for **automatic** resolution; do not call when an explicit connection +/// mode override is active (apply the override outside this API). +ResolvedConnectionMode resolveMode( + List table, + ModeState state, +) { + for (final entry in table) { + if (entry.predicate(state)) { + return entry.resolve(state); + } + } + return _resolvedFromConnectionMode(state.foregroundConnectionMode); +} + +/// Default ordered table for Flutter. When [ModeState.runInBackground] +/// is false while in the background, resolves to offline; +/// otherwise the background row uses [ModeState.backgroundConnectionMode]. +List flutterDefaultResolutionTable() { + return const [ + ModeResolutionEntry( + predicate: _networkDown, + resolve: _offlineNetworkUnavailable, + ), + ModeResolutionEntry( + predicate: _backgroundWithoutUpdates, + resolve: _offlineBackgroundDisabled, + ), + ModeResolutionEntry( + predicate: _inBackground, + resolve: _backgroundSlotResolved, + ), + ModeResolutionEntry( + predicate: _alwaysTrue, + resolve: _foregroundSlotResolved, + ), + ]; +} + +ResolvedConnectionMode _offlineNetworkUnavailable(ModeState _) => + const ResolvedOffline(OfflineNetworkUnavailable()); + +ResolvedConnectionMode _offlineBackgroundDisabled(ModeState _) => + const ResolvedOffline(OfflineBackgroundDisabled()); + +ResolvedConnectionMode _backgroundSlotResolved(ModeState s) => + s.backgroundConnectionMode == FDv2ConnectionMode.offline + ? const ResolvedOffline(OfflineBackgroundDisabled()) + : _resolvedFromConnectionMode(s.backgroundConnectionMode); + +ResolvedConnectionMode _foregroundSlotResolved(ModeState s) => + _resolvedFromConnectionMode(s.foregroundConnectionMode); + +ResolvedConnectionMode _resolvedFromConnectionMode(FDv2ConnectionMode mode) { + if (mode == FDv2ConnectionMode.streaming) { + return const ResolvedStreaming(); + } + if (mode == FDv2ConnectionMode.polling) { + return const ResolvedPolling(); + } + if (mode == FDv2ConnectionMode.background) { + return const ResolvedBackground(); + } + return const ResolvedOffline(OfflineSetOffline()); +} + +bool _networkDown(ModeState s) => !s.networkAvailable; + +bool _backgroundWithoutUpdates(ModeState s) => + !s.inForeground && !s.runInBackground; + +bool _inBackground(ModeState s) => !s.inForeground; + +bool _alwaysTrue(ModeState s) => true; 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/fdv2_connection_mode.dart b/packages/common_client/lib/src/fdv2_connection_mode.dart new file mode 100644 index 00000000..392b290e --- /dev/null +++ b/packages/common_client/lib/src/fdv2_connection_mode.dart @@ -0,0 +1,43 @@ +/// Enumerates the built-in FDv2 connection modes. Each mode maps to a +/// pipeline of initializers and synchronizers that are active when the SDK +/// is operating in that mode. +/// +/// This class 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 +/// +/// This type is intentionally a class with private constructor and a closed +/// set of `static const` instances rather than a Dart `enum`. New modes can +/// be added in a future release without forcing downstream `switch` +/// expressions or statements to update. +/// +/// Not to be confused with the FDv1 [ConnectionMode] enum +/// (`connection_mode.dart`), which is the public type used by existing +/// SDK configuration and `setMode` APIs. [FDv2ConnectionMode] is an FDv2 +/// concept describing the desired data-acquisition pipeline; the FDv1 +/// [ConnectionMode] continues to drive existing public APIs. +final class FDv2ConnectionMode { + /// Foreground streaming mode. Cache + polling initializers, then streaming + /// with polling fallback. Suitable for mobile foreground and desktop use. + static const FDv2ConnectionMode streaming = + FDv2ConnectionMode._('streaming'); + + /// Polling-only mode at the configured polling interval. + static const FDv2ConnectionMode polling = FDv2ConnectionMode._('polling'); + + /// Offline mode. Cache initializer only; no synchronizers. + static const FDv2ConnectionMode offline = FDv2ConnectionMode._('offline'); + + /// Mobile background mode. Cache initializer and a reduced-rate polling + /// synchronizer (one hour by default). + static const FDv2ConnectionMode background = + FDv2ConnectionMode._('background'); + + final String _name; + + const FDv2ConnectionMode._(this._name); + + @override + String toString() => _name; +} diff --git a/packages/common_client/lib/src/offline_detail.dart b/packages/common_client/lib/src/offline_detail.dart new file mode 100644 index 00000000..17edeb81 --- /dev/null +++ b/packages/common_client/lib/src/offline_detail.dart @@ -0,0 +1,45 @@ +/// Details for [ResolvedOffline] to help consumers make decisions (e.g. status +/// reporting). +sealed class OfflineDetail { + const OfflineDetail(); + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + return other is OfflineDetail && + switch ((this, other)) { + (OfflineSetOffline(), OfflineSetOffline()) => true, + (OfflineNetworkUnavailable(), OfflineNetworkUnavailable()) => true, + (OfflineBackgroundDisabled(), OfflineBackgroundDisabled()) => true, + _ => false, + }; + } + + @override + int get hashCode => switch (this) { + OfflineSetOffline() => 0, + OfflineNetworkUnavailable() => 1, + OfflineBackgroundDisabled() => 2, + }; +} + +/// Offline because the application or client chose not to connect (including +/// explicit SDK offline and connection mode override to offline). +/// Corresponds to [DataSourceState.setOffline]. +final class OfflineSetOffline extends OfflineDetail { + const OfflineSetOffline(); +} + +/// Offline because automatic resolution detected no usable network. +/// Corresponds to [DataSourceState.networkUnavailable]. +final class OfflineNetworkUnavailable extends OfflineDetail { + const OfflineNetworkUnavailable(); +} + +/// Offline because the app is backgrounded and background updates are +/// disabled. Corresponds to [DataSourceState.backgroundDisabled]. +final class OfflineBackgroundDisabled extends OfflineDetail { + const OfflineBackgroundDisabled(); +} diff --git a/packages/common_client/lib/src/resolved_connection_mode.dart b/packages/common_client/lib/src/resolved_connection_mode.dart new file mode 100644 index 00000000..31176fe0 --- /dev/null +++ b/packages/common_client/lib/src/resolved_connection_mode.dart @@ -0,0 +1,65 @@ +import 'fdv2_connection_mode.dart'; +import 'offline_detail.dart'; + +/// Unlike [FDv2ConnectionMode] alone, [ResolvedOffline] also carries +/// resolution details such as [OfflineDetail]. +sealed class ResolvedConnectionMode { + const ResolvedConnectionMode(); + + /// Underlying mode. + FDv2ConnectionMode get connectionMode; + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + return other is ResolvedConnectionMode && + switch ((this, other)) { + (ResolvedStreaming(), ResolvedStreaming()) => true, + (ResolvedPolling(), ResolvedPolling()) => true, + (ResolvedBackground(), ResolvedBackground()) => true, + (final ResolvedOffline r1, final ResolvedOffline r2) => + r1.detail == r2.detail, + _ => false, + }; + } + + @override + int get hashCode => switch (this) { + ResolvedStreaming() => 1, + ResolvedPolling() => 2, + ResolvedBackground() => 3, + ResolvedOffline(:final detail) => 4 ^ detail.hashCode, + }; +} + +final class ResolvedStreaming extends ResolvedConnectionMode { + const ResolvedStreaming(); + + @override + FDv2ConnectionMode get connectionMode => FDv2ConnectionMode.streaming; +} + +final class ResolvedPolling extends ResolvedConnectionMode { + const ResolvedPolling(); + + @override + FDv2ConnectionMode get connectionMode => FDv2ConnectionMode.polling; +} + +final class ResolvedBackground extends ResolvedConnectionMode { + const ResolvedBackground(); + + @override + FDv2ConnectionMode get connectionMode => FDv2ConnectionMode.background; +} + +final class ResolvedOffline extends ResolvedConnectionMode { + final OfflineDetail detail; + + const ResolvedOffline(this.detail); + + @override + FDv2ConnectionMode get connectionMode => FDv2ConnectionMode.offline; +} 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..4f86114f --- /dev/null +++ b/packages/common_client/test/data_sources/fdv2/entry_factories_test.dart @@ -0,0 +1,149 @@ +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/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(); + +Selector _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/data_sources/fdv2/mode_resolution_test.dart b/packages/common_client/test/data_sources/fdv2/mode_resolution_test.dart new file mode 100644 index 00000000..6c122aa5 --- /dev/null +++ b/packages/common_client/test/data_sources/fdv2/mode_resolution_test.dart @@ -0,0 +1,76 @@ +import 'package:launchdarkly_common_client/src/data_sources/fdv2/mode_resolution.dart'; +import 'package:launchdarkly_common_client/src/fdv2_connection_mode.dart'; +import 'package:launchdarkly_common_client/src/offline_detail.dart'; +import 'package:launchdarkly_common_client/src/resolved_connection_mode.dart'; +import 'package:test/test.dart'; + +void main() { + test( + 'flutter default table: network down yields ResolvedOffline(OfflineNetworkUnavailable)', + () { + const state = ModeState( + networkAvailable: false, + inForeground: true, + runInBackground: true, + foregroundConnectionMode: FDv2ConnectionMode.streaming, + backgroundConnectionMode: FDv2ConnectionMode.offline, + ); + final r = resolveMode( + flutterDefaultResolutionTable(), + state, + ); + expect(r, isA()); + expect((r as ResolvedOffline).detail, isA()); + expect(r.connectionMode, FDv2ConnectionMode.offline); + }); + + test( + 'flutter default table: background without updates yields ' + 'ResolvedOffline(OfflineBackgroundDisabled)', () { + const state = ModeState( + networkAvailable: true, + inForeground: false, + runInBackground: false, + foregroundConnectionMode: FDv2ConnectionMode.streaming, + backgroundConnectionMode: FDv2ConnectionMode.offline, + ); + final r = resolveMode( + flutterDefaultResolutionTable(), + state, + ); + expect(r, isA()); + expect((r as ResolvedOffline).detail, isA()); + }); + + test( + 'flutter default table: background slot offline yields ' + 'ResolvedOffline(OfflineBackgroundDisabled), not OfflineSetOffline', () { + const state = ModeState( + networkAvailable: true, + inForeground: false, + runInBackground: true, + foregroundConnectionMode: FDv2ConnectionMode.streaming, + backgroundConnectionMode: FDv2ConnectionMode.offline, + ); + final r = resolveMode( + flutterDefaultResolutionTable(), + state, + ); + expect(r, isA()); + expect((r as ResolvedOffline).detail, isA()); + }); + + test('resolveMode foreground slot exposes connectionMode', () { + const state = ModeState( + networkAvailable: true, + inForeground: true, + runInBackground: true, + foregroundConnectionMode: FDv2ConnectionMode.polling, + backgroundConnectionMode: FDv2ConnectionMode.offline, + ); + final table = flutterDefaultResolutionTable(); + final resolved = resolveMode(table, state); + expect(resolved, isA()); + expect(resolved.connectionMode, FDv2ConnectionMode.polling); + }); +} diff --git a/packages/common_client/test/data_sources/fdv2/source_factory_context_test.dart b/packages/common_client/test/data_sources/fdv2/source_factory_context_test.dart new file mode 100644 index 00000000..9f7d7262 --- /dev/null +++ b/packages/common_client/test/data_sources/fdv2/source_factory_context_test.dart @@ -0,0 +1,139 @@ +import 'dart:convert'; + +import 'package:launchdarkly_common_client/src/config/service_endpoints.dart'; +import 'package:launchdarkly_common_client/src/data_sources/fdv2/cache_initializer.dart'; +import 'package:launchdarkly_common_client/src/data_sources/fdv2/source_factory_context.dart'; +import 'package:launchdarkly_dart_common/launchdarkly_dart_common.dart' + hide ServiceEndpoints; +import 'package:test/test.dart'; + +void main() { + group('SourceFactoryContext.fromClientConfig', () { + test( + 'contextJson matches jsonEncode(LDContextSerialization.toJson ' + '(context, isEvent: false))', () { + final context = LDContextBuilder() + .kind('user', 'alice') + .name('Alice') + .setString('segment', 'beta') + .build(); + final logger = LDLogger(level: LDLogLevel.error); + final httpProperties = HttpProperties(); + final endpoints = ServiceEndpoints.custom(polling: 'https://poll.test'); + Future reader(LDContext _) async => null; + + final ctx = SourceFactoryContext.fromClientConfig( + context: context, + logger: logger, + httpProperties: httpProperties, + serviceEndpoints: endpoints, + withReasons: true, + defaultPollingInterval: const Duration(seconds: 42), + cachedFlagsReader: reader, + ); + + final expected = jsonEncode( + LDContextSerialization.toJson(context, isEvent: false), + ); + expect(ctx.contextJson, expected); + }); + + test( + 'contextJson differs from isEvent: true serialization for anonymous ' + 'context when redaction would apply', () { + final context = LDContextBuilder() + .kind('user', 'key1') + .anonymous(true) + .setString('email', 'a@b.c') + .build(); + final logger = LDLogger(level: LDLogLevel.error); + final endpoints = ServiceEndpoints.custom(polling: 'https://poll.test'); + Future reader(LDContext _) async => null; + + final ctx = SourceFactoryContext.fromClientConfig( + context: context, + logger: logger, + httpProperties: HttpProperties(), + serviceEndpoints: endpoints, + withReasons: false, + defaultPollingInterval: const Duration(seconds: 300), + cachedFlagsReader: reader, + ); + + final nonEvent = jsonEncode( + LDContextSerialization.toJson(context, isEvent: false), + ); + final asEvent = jsonEncode( + LDContextSerialization.toJson( + context, + isEvent: true, + redactAnonymous: true, + ), + ); + + expect(ctx.contextJson, nonEvent); + expect(ctx.contextJson, isNot(asEvent)); + }); + + test( + 'decoded contextJson is a multi-kind payload when context has ' + 'multiple kinds', () { + final context = + LDContextBuilder().kind('user', 'u1').kind('org', 'o1').build(); + final logger = LDLogger(level: LDLogLevel.error); + final endpoints = ServiceEndpoints.custom(polling: 'https://poll.test'); + Future reader(LDContext _) async => null; + + final ctx = SourceFactoryContext.fromClientConfig( + context: context, + logger: logger, + httpProperties: HttpProperties(), + serviceEndpoints: endpoints, + withReasons: false, + defaultPollingInterval: const Duration(seconds: 300), + cachedFlagsReader: reader, + ); + + final decoded = jsonDecode(ctx.contextJson) as Map; + expect(decoded['kind'], 'multi'); + expect(decoded['user'], isA>()); + expect(decoded['org'], isA>()); + }); + + test( + 'passes through context, logger, endpoints, flags, and optional ' + 'httpClientFactory', () { + final context = LDContextBuilder().kind('user', 'k').build(); + final logger = LDLogger(level: LDLogLevel.warn); + final httpProperties = HttpProperties(); + final endpoints = ServiceEndpoints.custom( + polling: 'https://p.example', + streaming: 'https://s.example', + ); + Future reader(LDContext _) async => null; + + HttpClient httpClientFactory(HttpProperties p) => + HttpClient(httpProperties: p); + + final ctx = SourceFactoryContext.fromClientConfig( + context: context, + logger: logger, + httpProperties: httpProperties, + serviceEndpoints: endpoints, + withReasons: false, + defaultPollingInterval: const Duration(minutes: 5), + cachedFlagsReader: reader, + httpClientFactory: httpClientFactory, + ); + + expect(ctx.context, same(context)); + expect(ctx.logger, same(logger)); + expect(ctx.httpProperties, same(httpProperties)); + expect(ctx.serviceEndpoints, same(endpoints)); + expect(ctx.withReasons, isFalse); + expect(ctx.defaultPollingInterval, const Duration(minutes: 5)); + expect(ctx.cachedFlagsReader, same(reader)); + expect(ctx.httpClientFactory, same(httpClientFactory)); + }); + }); +} 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..3b310e59 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,20 @@ export 'package:launchdarkly_common_client/launchdarkly_common_client.dart' PersistenceConfig, ApplicationInfo, ConnectionMode, + FDv2ConnectionMode, + ResolvedConnectionMode, + ResolvedStreaming, + ResolvedPolling, + ResolvedBackground, + ResolvedOffline, + OfflineDetail, + OfflineSetOffline, + OfflineNetworkUnavailable, + OfflineBackgroundDisabled, + ModeState, + ModeResolutionEntry, + resolveMode, + flutterDefaultResolutionTable, Hook, HookMetadata, IdentifySeriesContext, From dd3875b99b3cf1b265171a3066a60dbd948b4794 Mon Sep 17 00:00:00 2001 From: Todd Anderson Date: Thu, 28 May 2026 14:44:20 -0400 Subject: [PATCH 2/7] chore: dart format fdv2_connection_mode.dart --- packages/common_client/lib/src/fdv2_connection_mode.dart | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/common_client/lib/src/fdv2_connection_mode.dart b/packages/common_client/lib/src/fdv2_connection_mode.dart index 392b290e..ade23b54 100644 --- a/packages/common_client/lib/src/fdv2_connection_mode.dart +++ b/packages/common_client/lib/src/fdv2_connection_mode.dart @@ -20,8 +20,7 @@ final class FDv2ConnectionMode { /// Foreground streaming mode. Cache + polling initializers, then streaming /// with polling fallback. Suitable for mobile foreground and desktop use. - static const FDv2ConnectionMode streaming = - FDv2ConnectionMode._('streaming'); + static const FDv2ConnectionMode streaming = FDv2ConnectionMode._('streaming'); /// Polling-only mode at the configured polling interval. static const FDv2ConnectionMode polling = FDv2ConnectionMode._('polling'); From 68817359cf5c80584bbda3a08d80363c8ca8a7b7 Mon Sep 17 00:00:00 2001 From: Todd Anderson Date: Thu, 28 May 2026 14:56:21 -0400 Subject: [PATCH 3/7] self review --- .../src/data_sources/fdv2/built_in_modes.dart | 5 +++-- .../src/data_sources/fdv2/entry_factories.dart | 6 ++---- .../src/data_sources/fdv2/mode_definition.dart | 2 +- .../src/data_sources/fdv2/mode_resolution.dart | 17 +++++------------ .../fdv2/source_factory_context.dart | 2 -- 5 files changed, 11 insertions(+), 21 deletions(-) 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 index 8904fd92..4125926e 100644 --- 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 @@ -7,9 +7,10 @@ abstract final class BuiltInModes { /// Default foreground poll interval. static const Duration _foregroundPollInterval = Duration(seconds: 300); + /// Default background poll interval. static const Duration defaultBackgroundPollInterval = Duration(seconds: 3600); - /// Default streaming mode (mobile foreground / desktop). + /// Streaming: combination of cache and polling initializers, and streaming and fallback polling synchronizer. static const ModeDefinition streaming = ModeDefinition( initializers: [ CacheInitializer(), @@ -39,7 +40,7 @@ abstract final class BuiltInModes { synchronizers: [], ); - /// Mobile background: cache initializer, reduced-rate polling synchronizer (CSFDV2 §5.2.3). + /// Background: cache initializer, reduced-rate polling synchronizer static const ModeDefinition background = ModeDefinition( initializers: [CacheInitializer()], synchronizers: [ 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 index d6e4ebb5..98398f76 100644 --- a/packages/common_client/lib/src/data_sources/fdv2/entry_factories.dart +++ b/packages/common_client/lib/src/data_sources/fdv2/entry_factories.dart @@ -14,7 +14,7 @@ import 'requestor.dart'; import 'selector.dart'; import 'source.dart'; -/// Merges optional per-entry [mode.EndpointConfig] overrides into [base]. +/// Merges per-entry [mode.EndpointConfig] overrides into [base]. ServiceEndpoints mergeServiceEndpoints( ServiceEndpoints base, mode.EndpointConfig? override, @@ -61,7 +61,7 @@ HttpClient _defaultHttpClientFactory(HttpProperties httpProperties) { /// A factory for creating [Initializer] instances. final class InitializerFactory { - /// True for cache initializers ([CONNMODE] / CSFDv2 cache-miss success rule). + /// True for cache initializers. final bool isCache; final Initializer Function(SelectorGetter selectorGetter) _create; @@ -71,8 +71,6 @@ final class InitializerFactory { 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); } 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 index 242af119..2096a931 100644 --- a/packages/common_client/lib/src/data_sources/fdv2/mode_definition.dart +++ b/packages/common_client/lib/src/data_sources/fdv2/mode_definition.dart @@ -22,7 +22,7 @@ final class CacheInitializer extends InitializerEntry { const CacheInitializer(); } -/// Initializer that will make fetch data from polling endpoints. +/// Initializer that will fetch data from polling endpoints. final class PollingInitializer extends InitializerEntry { /// Per-source endpoint overrides. final EndpointConfig? 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 index a871f34c..dae950c0 100644 --- a/packages/common_client/lib/src/data_sources/fdv2/mode_resolution.dart +++ b/packages/common_client/lib/src/data_sources/fdv2/mode_resolution.dart @@ -2,19 +2,16 @@ import '../../fdv2_connection_mode.dart'; import '../../offline_detail.dart'; import '../../resolved_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 [resolveMode]. +/// Inputs for automatic mode resolution (lifecycle, network, mode slots). final class ModeState { + /// Whether the network is available. 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). + /// backgrounded. final bool runInBackground; /// Configured foreground mode slot. @@ -36,7 +33,6 @@ final class ModeState { final class ModeResolutionEntry { final bool Function(ModeState state) predicate; - /// Resolved connection mode for this row; may read slots from [state]. final ResolvedConnectionMode Function(ModeState state) resolve; const ModeResolutionEntry({ @@ -48,8 +44,7 @@ final class ModeResolutionEntry { /// First matching row in [table] wins. If none match, maps /// [state.foregroundConnectionMode] to a [ResolvedConnectionMode]. /// -/// Only for **automatic** resolution; do not call when an explicit connection -/// mode override is active (apply the override outside this API). +/// When written, this was used for automatic resolution. ResolvedConnectionMode resolveMode( List table, ModeState state, @@ -62,9 +57,7 @@ ResolvedConnectionMode resolveMode( return _resolvedFromConnectionMode(state.foregroundConnectionMode); } -/// Default ordered table for Flutter. When [ModeState.runInBackground] -/// is false while in the background, resolves to offline; -/// otherwise the background row uses [ModeState.backgroundConnectionMode]. +/// Default ordered table for Flutter. List flutterDefaultResolutionTable() { return const [ ModeResolutionEntry( 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 index ed8ece57..566f7e16 100644 --- 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 @@ -23,8 +23,6 @@ final class SourceFactoryContext { final bool withReasons; - /// Default synchronizer poll interval when a [PollingSynchronizer] entry - /// omits [PollingSynchronizer.pollInterval]. final Duration defaultPollingInterval; final CachedFlagsReader cachedFlagsReader; From 4779d27b08a28dd1bb5aa0a89a586fdf06710a45 Mon Sep 17 00:00:00 2001 From: Todd Anderson Date: Thu, 28 May 2026 15:20:10 -0400 Subject: [PATCH 4/7] fix(SDK-2187): Configured offline background slot is OfflineSetOffline When the background mode slot resolves to offline (i.e. the user configured FDv2ConnectionMode.offline for the background slot while runInBackground=true), the OfflineDetail should be OfflineSetOffline (user-chosen offline via the FDv2 background slot), not OfflineBackgroundDisabled. OfflineBackgroundDisabled remains scoped to the runInBackground=false case, matching its docstring. Collapses the special-case ternary in _backgroundSlotResolved into a direct call to the shared FDv2-mode-to-resolved mapping. Test name and assertion updated. --- .../lib/src/data_sources/fdv2/mode_resolution.dart | 4 +--- .../test/data_sources/fdv2/mode_resolution_test.dart | 6 +++--- 2 files changed, 4 insertions(+), 6 deletions(-) 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 index dae950c0..4b5a734f 100644 --- a/packages/common_client/lib/src/data_sources/fdv2/mode_resolution.dart +++ b/packages/common_client/lib/src/data_sources/fdv2/mode_resolution.dart @@ -86,9 +86,7 @@ ResolvedConnectionMode _offlineBackgroundDisabled(ModeState _) => const ResolvedOffline(OfflineBackgroundDisabled()); ResolvedConnectionMode _backgroundSlotResolved(ModeState s) => - s.backgroundConnectionMode == FDv2ConnectionMode.offline - ? const ResolvedOffline(OfflineBackgroundDisabled()) - : _resolvedFromConnectionMode(s.backgroundConnectionMode); + _resolvedFromConnectionMode(s.backgroundConnectionMode); ResolvedConnectionMode _foregroundSlotResolved(ModeState s) => _resolvedFromConnectionMode(s.foregroundConnectionMode); diff --git a/packages/common_client/test/data_sources/fdv2/mode_resolution_test.dart b/packages/common_client/test/data_sources/fdv2/mode_resolution_test.dart index 6c122aa5..f88a9f48 100644 --- a/packages/common_client/test/data_sources/fdv2/mode_resolution_test.dart +++ b/packages/common_client/test/data_sources/fdv2/mode_resolution_test.dart @@ -43,8 +43,8 @@ void main() { }); test( - 'flutter default table: background slot offline yields ' - 'ResolvedOffline(OfflineBackgroundDisabled), not OfflineSetOffline', () { + 'flutter default table: background slot configured offline yields ' + 'ResolvedOffline(OfflineSetOffline)', () { const state = ModeState( networkAvailable: true, inForeground: false, @@ -57,7 +57,7 @@ void main() { state, ); expect(r, isA()); - expect((r as ResolvedOffline).detail, isA()); + expect((r as ResolvedOffline).detail, isA()); }); test('resolveMode foreground slot exposes connectionMode', () { From 4de84076ab4a9ca45d4d5a44faa8d118df6e606a Mon Sep 17 00:00:00 2001 From: Todd Anderson Date: Thu, 28 May 2026 16:59:41 -0400 Subject: [PATCH 5/7] refactor(SDK-2187): FDv2ConnectionMode as sealed class Restructures FDv2ConnectionMode to the same sealed-class style as ResolvedConnectionMode, with named subtypes (FDv2Streaming, FDv2Polling, FDv2Offline, FDv2Background). Consumers construct via const subtype constructors and pattern-match exhaustively. This is a deliberate trade-off from the prior class-with-static-const shape: adding a future mode variant becomes source-breaking on downstream exhaustive switches. The type is documented EAP / not-stable and the sealed style is idiomatic Dart 3 plus consistent with the sibling ResolvedConnectionMode hierarchy. - mode_resolution.dart: _resolvedFromConnectionMode collapses to a pattern-matching switch over the four subtypes. - resolved_connection_mode.dart: connectionMode getters return const subtype instances instead of static-const accessors. - launchdarkly_common_client.dart export list: adds FDv2Streaming / FDv2Polling / FDv2Offline / FDv2Background. - Flutter umbrella export changes removed; they belong on the follow-up behavior PR. --- .../lib/launchdarkly_common_client.dart | 8 ++- .../data_sources/fdv2/mode_resolution.dart | 19 ++---- .../lib/src/fdv2_connection_mode.dart | 64 ++++++++++++------- .../lib/src/resolved_connection_mode.dart | 8 +-- .../fdv2/mode_resolution_test.dart | 20 +++--- .../lib/launchdarkly_flutter_client_sdk.dart | 14 ---- 6 files changed, 70 insertions(+), 63 deletions(-) diff --git a/packages/common_client/lib/launchdarkly_common_client.dart b/packages/common_client/lib/launchdarkly_common_client.dart index 022e1e35..3bd6cbbe 100644 --- a/packages/common_client/lib/launchdarkly_common_client.dart +++ b/packages/common_client/lib/launchdarkly_common_client.dart @@ -49,7 +49,13 @@ 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/fdv2_connection_mode.dart' show FDv2ConnectionMode; +export 'src/fdv2_connection_mode.dart' + show + FDv2ConnectionMode, + FDv2Streaming, + FDv2Polling, + FDv2Offline, + FDv2Background; export 'src/resolved_connection_mode.dart' show ResolvedConnectionMode, 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 index 4b5a734f..e5a22b3a 100644 --- a/packages/common_client/lib/src/data_sources/fdv2/mode_resolution.dart +++ b/packages/common_client/lib/src/data_sources/fdv2/mode_resolution.dart @@ -91,18 +91,13 @@ ResolvedConnectionMode _backgroundSlotResolved(ModeState s) => ResolvedConnectionMode _foregroundSlotResolved(ModeState s) => _resolvedFromConnectionMode(s.foregroundConnectionMode); -ResolvedConnectionMode _resolvedFromConnectionMode(FDv2ConnectionMode mode) { - if (mode == FDv2ConnectionMode.streaming) { - return const ResolvedStreaming(); - } - if (mode == FDv2ConnectionMode.polling) { - return const ResolvedPolling(); - } - if (mode == FDv2ConnectionMode.background) { - return const ResolvedBackground(); - } - return const ResolvedOffline(OfflineSetOffline()); -} +ResolvedConnectionMode _resolvedFromConnectionMode(FDv2ConnectionMode mode) => + switch (mode) { + FDv2Streaming() => const ResolvedStreaming(), + FDv2Polling() => const ResolvedPolling(), + FDv2Background() => const ResolvedBackground(), + FDv2Offline() => const ResolvedOffline(OfflineSetOffline()), + }; bool _networkDown(ModeState s) => !s.networkAvailable; diff --git a/packages/common_client/lib/src/fdv2_connection_mode.dart b/packages/common_client/lib/src/fdv2_connection_mode.dart index ade23b54..4fde6ec8 100644 --- a/packages/common_client/lib/src/fdv2_connection_mode.dart +++ b/packages/common_client/lib/src/fdv2_connection_mode.dart @@ -1,42 +1,62 @@ -/// Enumerates the built-in FDv2 connection modes. Each mode maps to a +/// Identifies a built-in FDv2 connection mode. Each variant maps to a /// pipeline of initializers and synchronizers that are active when the SDK /// is operating in that mode. /// -/// This class is not stable, and not subject to any backwards compatibility +/// This type 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 /// -/// This type is intentionally a class with private constructor and a closed -/// set of `static const` instances rather than a Dart `enum`. New modes can -/// be added in a future release without forcing downstream `switch` -/// expressions or statements to update. -/// /// Not to be confused with the FDv1 [ConnectionMode] enum /// (`connection_mode.dart`), which is the public type used by existing /// SDK configuration and `setMode` APIs. [FDv2ConnectionMode] is an FDv2 /// concept describing the desired data-acquisition pipeline; the FDv1 /// [ConnectionMode] continues to drive existing public APIs. -final class FDv2ConnectionMode { - /// Foreground streaming mode. Cache + polling initializers, then streaming - /// with polling fallback. Suitable for mobile foreground and desktop use. - static const FDv2ConnectionMode streaming = FDv2ConnectionMode._('streaming'); +sealed class FDv2ConnectionMode { + const FDv2ConnectionMode(); + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + return other is FDv2ConnectionMode && other.runtimeType == runtimeType; + } + + @override + int get hashCode => runtimeType.hashCode; +} + +/// Foreground streaming mode. Cache + polling initializers, then streaming +/// with polling fallback. Suitable for mobile foreground and desktop use. +final class FDv2Streaming extends FDv2ConnectionMode { + const FDv2Streaming(); - /// Polling-only mode at the configured polling interval. - static const FDv2ConnectionMode polling = FDv2ConnectionMode._('polling'); + @override + String toString() => 'streaming'; +} - /// Offline mode. Cache initializer only; no synchronizers. - static const FDv2ConnectionMode offline = FDv2ConnectionMode._('offline'); +/// Polling-only mode at the configured polling interval. +final class FDv2Polling extends FDv2ConnectionMode { + const FDv2Polling(); - /// Mobile background mode. Cache initializer and a reduced-rate polling - /// synchronizer (one hour by default). - static const FDv2ConnectionMode background = - FDv2ConnectionMode._('background'); + @override + String toString() => 'polling'; +} - final String _name; +/// Offline mode. Cache initializer only; no synchronizers. +final class FDv2Offline extends FDv2ConnectionMode { + const FDv2Offline(); + + @override + String toString() => 'offline'; +} - const FDv2ConnectionMode._(this._name); +/// Mobile background mode. Cache initializer and a reduced-rate polling +/// synchronizer (one hour by default). +final class FDv2Background extends FDv2ConnectionMode { + const FDv2Background(); @override - String toString() => _name; + String toString() => 'background'; } diff --git a/packages/common_client/lib/src/resolved_connection_mode.dart b/packages/common_client/lib/src/resolved_connection_mode.dart index 31176fe0..0c42e9db 100644 --- a/packages/common_client/lib/src/resolved_connection_mode.dart +++ b/packages/common_client/lib/src/resolved_connection_mode.dart @@ -38,21 +38,21 @@ final class ResolvedStreaming extends ResolvedConnectionMode { const ResolvedStreaming(); @override - FDv2ConnectionMode get connectionMode => FDv2ConnectionMode.streaming; + FDv2ConnectionMode get connectionMode => const FDv2Streaming(); } final class ResolvedPolling extends ResolvedConnectionMode { const ResolvedPolling(); @override - FDv2ConnectionMode get connectionMode => FDv2ConnectionMode.polling; + FDv2ConnectionMode get connectionMode => const FDv2Polling(); } final class ResolvedBackground extends ResolvedConnectionMode { const ResolvedBackground(); @override - FDv2ConnectionMode get connectionMode => FDv2ConnectionMode.background; + FDv2ConnectionMode get connectionMode => const FDv2Background(); } final class ResolvedOffline extends ResolvedConnectionMode { @@ -61,5 +61,5 @@ final class ResolvedOffline extends ResolvedConnectionMode { const ResolvedOffline(this.detail); @override - FDv2ConnectionMode get connectionMode => FDv2ConnectionMode.offline; + FDv2ConnectionMode get connectionMode => const FDv2Offline(); } diff --git a/packages/common_client/test/data_sources/fdv2/mode_resolution_test.dart b/packages/common_client/test/data_sources/fdv2/mode_resolution_test.dart index f88a9f48..1e429ea2 100644 --- a/packages/common_client/test/data_sources/fdv2/mode_resolution_test.dart +++ b/packages/common_client/test/data_sources/fdv2/mode_resolution_test.dart @@ -12,8 +12,8 @@ void main() { networkAvailable: false, inForeground: true, runInBackground: true, - foregroundConnectionMode: FDv2ConnectionMode.streaming, - backgroundConnectionMode: FDv2ConnectionMode.offline, + foregroundConnectionMode: FDv2Streaming(), + backgroundConnectionMode: FDv2Offline(), ); final r = resolveMode( flutterDefaultResolutionTable(), @@ -21,7 +21,7 @@ void main() { ); expect(r, isA()); expect((r as ResolvedOffline).detail, isA()); - expect(r.connectionMode, FDv2ConnectionMode.offline); + expect(r.connectionMode, const FDv2Offline()); }); test( @@ -31,8 +31,8 @@ void main() { networkAvailable: true, inForeground: false, runInBackground: false, - foregroundConnectionMode: FDv2ConnectionMode.streaming, - backgroundConnectionMode: FDv2ConnectionMode.offline, + foregroundConnectionMode: FDv2Streaming(), + backgroundConnectionMode: FDv2Offline(), ); final r = resolveMode( flutterDefaultResolutionTable(), @@ -49,8 +49,8 @@ void main() { networkAvailable: true, inForeground: false, runInBackground: true, - foregroundConnectionMode: FDv2ConnectionMode.streaming, - backgroundConnectionMode: FDv2ConnectionMode.offline, + foregroundConnectionMode: FDv2Streaming(), + backgroundConnectionMode: FDv2Offline(), ); final r = resolveMode( flutterDefaultResolutionTable(), @@ -65,12 +65,12 @@ void main() { networkAvailable: true, inForeground: true, runInBackground: true, - foregroundConnectionMode: FDv2ConnectionMode.polling, - backgroundConnectionMode: FDv2ConnectionMode.offline, + foregroundConnectionMode: FDv2Polling(), + backgroundConnectionMode: FDv2Offline(), ); final table = flutterDefaultResolutionTable(); final resolved = resolveMode(table, state); expect(resolved, isA()); - expect(resolved.connectionMode, FDv2ConnectionMode.polling); + expect(resolved.connectionMode, const FDv2Polling()); }); } 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 3b310e59..820cad64 100644 --- a/packages/flutter_client_sdk/lib/launchdarkly_flutter_client_sdk.dart +++ b/packages/flutter_client_sdk/lib/launchdarkly_flutter_client_sdk.dart @@ -45,20 +45,6 @@ export 'package:launchdarkly_common_client/launchdarkly_common_client.dart' PersistenceConfig, ApplicationInfo, ConnectionMode, - FDv2ConnectionMode, - ResolvedConnectionMode, - ResolvedStreaming, - ResolvedPolling, - ResolvedBackground, - ResolvedOffline, - OfflineDetail, - OfflineSetOffline, - OfflineNetworkUnavailable, - OfflineBackgroundDisabled, - ModeState, - ModeResolutionEntry, - resolveMode, - flutterDefaultResolutionTable, Hook, HookMetadata, IdentifySeriesContext, From b012413889f327e0ca10f43f2e458d1c156e4d87 Mon Sep 17 00:00:00 2001 From: Todd Anderson Date: Thu, 28 May 2026 17:13:30 -0400 Subject: [PATCH 6/7] fix(SDK-2187): Polling factory builds requestor per create() call FDv2Requestor holds mutable per-call state (ETag) and is documented as not safe to interleave on a single instance. The PollingInitializer and PollingSynchronizer factories previously constructed an FDv2PollingBase (and its underlying FDv2Requestor) once outside the create() lambda, so every instance produced by repeated create() invocations shared the same requestor. Moving the build inside the lambda ensures each created instance owns its own requestor and ETag state. The shared-state helper is renamed _buildPollingBase to reflect the per-invocation construction; the documentation makes the lambda-only invocation requirement explicit. --- .../data_sources/fdv2/entry_factories.dart | 56 +++++++++++-------- 1 file changed, 32 insertions(+), 24 deletions(-) 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 index 98398f76..14ca1ff0 100644 --- a/packages/common_client/lib/src/data_sources/fdv2/entry_factories.dart +++ b/packages/common_client/lib/src/data_sources/fdv2/entry_factories.dart @@ -32,7 +32,11 @@ ServiceEndpoints mergeServiceEndpoints( ); } -FDv2PollingBase _sharedPollingBase({ +/// Builds a fresh [FDv2PollingBase] (and its underlying [FDv2Requestor]) per +/// call. Must be invoked inside the factory's `create` lambda so each +/// produced instance owns its own requestor; the requestor holds mutable +/// per-call state (e.g. ETag) and cannot be safely shared across instances. +FDv2PollingBase _buildPollingBase({ required mode.EndpointConfig? endpoints, required bool usePost, required SourceFactoryContext ctx, @@ -103,18 +107,20 @@ InitializerFactory createInitializerFactoryFromEntry( ), ); 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, - ), + create: (SelectorGetter selectorGetter) { + final base = _buildPollingBase( + endpoints: e.endpoints, + usePost: e.usePost, + ctx: ctx, + ); + return FDv2PollingInitializer( + poll: ({Selector basis = Selector.empty}) => + base.pollOnce(basis: basis), + selectorGetter: selectorGetter, + logger: ctx.logger, + ); + }, ); case mode.StreamingInitializer(): throw UnsupportedError( @@ -132,20 +138,22 @@ SynchronizerFactory createSynchronizerFactoryFromEntry( ) { 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, - ), + create: (SelectorGetter selectorGetter) { + final base = _buildPollingBase( + endpoints: e.endpoints, + usePost: e.usePost, + ctx: ctx, + ); + return FDv2PollingSynchronizer( + poll: ({Selector basis = Selector.empty}) => + base.pollOnce(basis: basis), + selectorGetter: selectorGetter, + interval: interval, + logger: ctx.logger, + ); + }, ); case mode.StreamingSynchronizer(): throw UnsupportedError( From 8471c7d93a6eba93f845b1aa9c5e7469b15be9e2 Mon Sep 17 00:00:00 2001 From: Todd Anderson <127344469+tanderson-ld@users.noreply.github.com> Date: Fri, 29 May 2026 09:23:46 -0400 Subject: [PATCH 7/7] Update packages/common_client/lib/src/data_sources/fdv2/mode_resolution.dart Co-authored-by: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> --- .../common_client/lib/src/data_sources/fdv2/mode_resolution.dart | 1 - 1 file changed, 1 deletion(-) 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 index e5a22b3a..b19e420a 100644 --- a/packages/common_client/lib/src/data_sources/fdv2/mode_resolution.dart +++ b/packages/common_client/lib/src/data_sources/fdv2/mode_resolution.dart @@ -44,7 +44,6 @@ final class ModeResolutionEntry { /// First matching row in [table] wins. If none match, maps /// [state.foregroundConnectionMode] to a [ResolvedConnectionMode]. /// -/// When written, this was used for automatic resolution. ResolvedConnectionMode resolveMode( List table, ModeState state,