From d92af88255d72eaf46d23d52a7d401c3e2745c9e Mon Sep 17 00:00:00 2001 From: Aaron Zeisler Date: Mon, 9 Mar 2026 12:21:46 -0700 Subject: [PATCH 01/17] feat: Add FDv2 connection mode types and mode resolution table Introduces the core types for FDv2 mode resolution (CONNMODE spec): - ConnectionMode: enum for streaming, polling, offline, one-shot, background - ModeDefinition: initializer/synchronizer lists per mode with stubbed configurers - ModeState: platform state snapshot (foreground, networkAvailable) - ModeResolutionEntry: condition + mode pair for resolution table entries - ModeResolutionTable: ordered first-match-wins resolver with MOBILE default table - ModeAware: interface for DataSources that support runtime switchMode() All types are package-private. No changes to existing code. --- .../sdk/android/ConnectionMode.java | 17 ++++ .../launchdarkly/sdk/android/ModeAware.java | 28 ++++++ .../sdk/android/ModeDefinition.java | 85 +++++++++++++++++ .../sdk/android/ModeResolutionEntry.java | 48 ++++++++++ .../sdk/android/ModeResolutionTable.java | 65 +++++++++++++ .../launchdarkly/sdk/android/ModeState.java | 30 ++++++ .../sdk/android/ModeResolutionTableTest.java | 94 +++++++++++++++++++ 7 files changed, 367 insertions(+) create mode 100644 launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ConnectionMode.java create mode 100644 launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeAware.java create mode 100644 launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeDefinition.java create mode 100644 launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeResolutionEntry.java create mode 100644 launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeResolutionTable.java create mode 100644 launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeState.java create mode 100644 launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ModeResolutionTableTest.java diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ConnectionMode.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ConnectionMode.java new file mode 100644 index 00000000..789b4efb --- /dev/null +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ConnectionMode.java @@ -0,0 +1,17 @@ +package com.launchdarkly.sdk.android; + +/** + * Named connection modes for the FDv2 data system. Each mode maps to a + * {@link ModeDefinition} that specifies which initializers and synchronizers to run. + *

+ * Package-private — not part of the public SDK API. + * + * @see ModeDefinition + */ +enum ConnectionMode { + STREAMING, + POLLING, + OFFLINE, + ONE_SHOT, + BACKGROUND +} diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeAware.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeAware.java new file mode 100644 index 00000000..14825583 --- /dev/null +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeAware.java @@ -0,0 +1,28 @@ +package com.launchdarkly.sdk.android; + +import androidx.annotation.NonNull; + +import com.launchdarkly.sdk.android.subsystems.DataSource; + +/** + * A {@link DataSource} that supports runtime connection mode switching. + *

+ * {@link ConnectivityManager} checks {@code instanceof ModeAware} to decide + * whether to use mode resolution (FDv2) or legacy teardown/rebuild behavior (FDv1). + *

+ * Package-private — not part of the public SDK API. + * + * @see ConnectionMode + * @see ModeResolutionTable + */ +interface ModeAware extends DataSource { + + /** + * Switches the data source to the specified connection mode. The implementation + * stops the current synchronizers and starts the new mode's synchronizers without + * re-running initializers (per CONNMODE spec 2.0.1). + * + * @param newMode the target connection mode + */ + void switchMode(@NonNull ConnectionMode newMode); +} diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeDefinition.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeDefinition.java new file mode 100644 index 00000000..70c0202b --- /dev/null +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeDefinition.java @@ -0,0 +1,85 @@ +package com.launchdarkly.sdk.android; + +import androidx.annotation.NonNull; + +import com.launchdarkly.sdk.android.subsystems.ComponentConfigurer; +import com.launchdarkly.sdk.android.subsystems.Initializer; +import com.launchdarkly.sdk.android.subsystems.Synchronizer; + +import java.util.Arrays; +import java.util.Collections; +import java.util.EnumMap; +import java.util.List; +import java.util.Map; + +/** + * Defines the initializer and synchronizer pipelines for a {@link ConnectionMode}. + *

+ * Each mode in the {@link #DEFAULT_MODE_TABLE} maps to a {@code ModeDefinition} that + * describes which data source components to create. At build time, + * {@code FDv2DataSourceBuilder} resolves each {@link ComponentConfigurer} into a + * {@link FDv2DataSource.DataSourceFactory} by applying the {@code ClientContext}. + *

+ * The configurers in {@link #DEFAULT_MODE_TABLE} are currently stubbed (return null). + * Real {@link ComponentConfigurer} implementations will be wired in when + * {@code FDv2DataSourceBuilder} is created. + *

+ * Package-private — not part of the public SDK API. + */ +final class ModeDefinition { + + // Stubbed configurer — will be replaced with real ComponentConfigurer implementations + // in FDv2DataSourceBuilder when concrete types are wired up. + private static final ComponentConfigurer STUB_INITIALIZER = clientContext -> null; + private static final ComponentConfigurer STUB_SYNCHRONIZER = clientContext -> null; + + static final Map DEFAULT_MODE_TABLE; + + static { + Map table = new EnumMap<>(ConnectionMode.class); + // Initializer/synchronizer lists per CONNMODE spec and js-core ConnectionModeConfig.ts. + // Stubs will be replaced with real factories (cache, polling, streaming) in FDv2DataSourceBuilder. + table.put(ConnectionMode.STREAMING, new ModeDefinition( + Arrays.asList(STUB_INITIALIZER, STUB_INITIALIZER), // cache, polling + Arrays.asList(STUB_SYNCHRONIZER, STUB_SYNCHRONIZER) // streaming, polling + )); + table.put(ConnectionMode.POLLING, new ModeDefinition( + Collections.singletonList(STUB_INITIALIZER), // cache + Collections.singletonList(STUB_SYNCHRONIZER) // polling + )); + table.put(ConnectionMode.OFFLINE, new ModeDefinition( + Collections.singletonList(STUB_INITIALIZER), // cache + Collections.>emptyList() + )); + table.put(ConnectionMode.ONE_SHOT, new ModeDefinition( + Arrays.asList(STUB_INITIALIZER, STUB_INITIALIZER, STUB_INITIALIZER), // cache, polling, streaming + Collections.>emptyList() + )); + table.put(ConnectionMode.BACKGROUND, new ModeDefinition( + Collections.singletonList(STUB_INITIALIZER), // cache + Collections.singletonList(STUB_SYNCHRONIZER) // polling (LDConfig.DEFAULT_BACKGROUND_POLL_INTERVAL_MILLIS) + )); + DEFAULT_MODE_TABLE = Collections.unmodifiableMap(table); + } + + private final List> initializers; + private final List> synchronizers; + + ModeDefinition( + @NonNull List> initializers, + @NonNull List> synchronizers + ) { + this.initializers = Collections.unmodifiableList(initializers); + this.synchronizers = Collections.unmodifiableList(synchronizers); + } + + @NonNull + List> getInitializers() { + return initializers; + } + + @NonNull + List> getSynchronizers() { + return synchronizers; + } +} diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeResolutionEntry.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeResolutionEntry.java new file mode 100644 index 00000000..9b3a1c49 --- /dev/null +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeResolutionEntry.java @@ -0,0 +1,48 @@ +package com.launchdarkly.sdk.android; + +import androidx.annotation.NonNull; + +/** + * A single entry in a {@link ModeResolutionTable}. Pairs a condition with a + * target {@link ConnectionMode}. If {@link Condition#test(ModeState)} returns + * {@code true} for a given {@link ModeState}, this entry's {@code mode} is the + * resolved result. + *

+ * When user-configurable mode selection is added, {@code mode} can be replaced + * with a resolver function to support indirection (e.g., returning a + * user-configured foreground mode from {@code ModeState}). + *

+ * Package-private — not part of the public SDK API. + */ +final class ModeResolutionEntry { + + /** + * Functional interface for evaluating whether a {@link ModeResolutionEntry} + * matches a given {@link ModeState}. Defined here to avoid a dependency on + * {@code java.util.function.Predicate} (requires API 24+; SDK minimum is 21). + */ + interface Condition { + boolean test(@NonNull ModeState state); + } + + private final Condition conditions; + private final ConnectionMode mode; + + ModeResolutionEntry( + @NonNull Condition conditions, + @NonNull ConnectionMode mode + ) { + this.conditions = conditions; + this.mode = mode; + } + + @NonNull + Condition getConditions() { + return conditions; + } + + @NonNull + ConnectionMode getMode() { + return mode; + } +} diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeResolutionTable.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeResolutionTable.java new file mode 100644 index 00000000..f6b1817a --- /dev/null +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeResolutionTable.java @@ -0,0 +1,65 @@ +package com.launchdarkly.sdk.android; + +import androidx.annotation.NonNull; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +/** + * An ordered list of {@link ModeResolutionEntry} values that maps a {@link ModeState} + * to a {@link ConnectionMode}. The first entry whose condition matches wins. + *

+ * The {@link #MOBILE} constant defines the Android default resolution table: + *

    + *
  1. No network → {@link ConnectionMode#OFFLINE}
  2. + *
  3. Background → {@link ConnectionMode#BACKGROUND}
  4. + *
  5. Foreground → {@link ConnectionMode#STREAMING}
  6. + *
+ *

+ * Package-private — not part of the public SDK API. + * + * @see ModeState + * @see ModeResolutionEntry + */ +final class ModeResolutionTable { + + static final ModeResolutionTable MOBILE = new ModeResolutionTable(Arrays.asList( + new ModeResolutionEntry( + state -> !state.isNetworkAvailable(), + ConnectionMode.OFFLINE), + new ModeResolutionEntry( + state -> !state.isForeground(), + ConnectionMode.BACKGROUND), + new ModeResolutionEntry( + state -> true, + ConnectionMode.STREAMING) + )); + + private final List entries; + + ModeResolutionTable(@NonNull List entries) { + this.entries = Collections.unmodifiableList(entries); + } + + /** + * Evaluates the table against the given state and returns the first matching mode. + * + * @param state the current platform state + * @return the resolved {@link ConnectionMode} + * @throws IllegalStateException if no entry matches (should not happen with a + * well-formed table that has a catch-all final entry) + */ + @NonNull + ConnectionMode resolve(@NonNull ModeState state) { + for (ModeResolutionEntry entry : entries) { + if (entry.getConditions().test(state)) { + return entry.getMode(); + } + } + throw new IllegalStateException( + "ModeResolutionTable has no matching entry for state: " + + "foreground=" + state.isForeground() + ", networkAvailable=" + state.isNetworkAvailable() + ); + } +} diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeState.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeState.java new file mode 100644 index 00000000..f3942927 --- /dev/null +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeState.java @@ -0,0 +1,30 @@ +package com.launchdarkly.sdk.android; + +/** + * Snapshot of platform state used as input to {@link ModeResolutionTable#resolve(ModeState)}. + *

+ * In this initial implementation, {@code ModeState} carries only platform state with + * hardcoded Android defaults for foreground/background modes. When user-configurable + * mode selection is added (CONNMODE 2.2.2), {@code foregroundMode} and + * {@code backgroundMode} fields will be introduced here. + *

+ * Package-private — not part of the public SDK API. + */ +final class ModeState { + + private final boolean foreground; + private final boolean networkAvailable; + + ModeState(boolean foreground, boolean networkAvailable) { + this.foreground = foreground; + this.networkAvailable = networkAvailable; + } + + boolean isForeground() { + return foreground; + } + + boolean isNetworkAvailable() { + return networkAvailable; + } +} diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ModeResolutionTableTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ModeResolutionTableTest.java new file mode 100644 index 00000000..2beec0fc --- /dev/null +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ModeResolutionTableTest.java @@ -0,0 +1,94 @@ +package com.launchdarkly.sdk.android; + +import static org.junit.Assert.assertEquals; + +import org.junit.Test; + +import java.util.Arrays; +import java.util.Collections; + +/** + * Unit tests for {@link ModeResolutionTable} and the {@link ModeResolutionTable#MOBILE} constant. + */ +public class ModeResolutionTableTest { + + // ==== MOBILE table — standard Android resolution ==== + + @Test + public void mobile_foregroundWithNetwork_resolvesToStreaming() { + ModeState state = new ModeState(true, true); + assertEquals(ConnectionMode.STREAMING, ModeResolutionTable.MOBILE.resolve(state)); + } + + @Test + public void mobile_backgroundWithNetwork_resolvesToBackground() { + ModeState state = new ModeState(false, true); + assertEquals(ConnectionMode.BACKGROUND, ModeResolutionTable.MOBILE.resolve(state)); + } + + @Test + public void mobile_foregroundNoNetwork_resolvesToOffline() { + ModeState state = new ModeState(true, false); + assertEquals(ConnectionMode.OFFLINE, ModeResolutionTable.MOBILE.resolve(state)); + } + + @Test + public void mobile_backgroundNoNetwork_resolvesToOffline() { + ModeState state = new ModeState(false, false); + assertEquals(ConnectionMode.OFFLINE, ModeResolutionTable.MOBILE.resolve(state)); + } + + // ==== resolve() — first match wins ==== + + @Test + public void resolve_firstMatchWins_evenIfLaterEntryAlsoMatches() { + ModeResolutionTable table = new ModeResolutionTable(Arrays.asList( + new ModeResolutionEntry(state -> true, ConnectionMode.POLLING), + new ModeResolutionEntry(state -> true, ConnectionMode.STREAMING) + )); + assertEquals(ConnectionMode.POLLING, table.resolve(new ModeState(true, true))); + } + + @Test + public void resolve_skipsNonMatchingEntries() { + ModeResolutionTable table = new ModeResolutionTable(Arrays.asList( + new ModeResolutionEntry(state -> false, ConnectionMode.POLLING), + new ModeResolutionEntry(state -> true, ConnectionMode.STREAMING) + )); + assertEquals(ConnectionMode.STREAMING, table.resolve(new ModeState(true, true))); + } + + @Test + public void resolve_singleEntry() { + ModeResolutionTable table = new ModeResolutionTable(Collections.singletonList( + new ModeResolutionEntry(state -> true, ConnectionMode.OFFLINE) + )); + assertEquals(ConnectionMode.OFFLINE, table.resolve(new ModeState(false, false))); + } + + @Test(expected = IllegalStateException.class) + public void resolve_noMatchingEntry_throws() { + ModeResolutionTable table = new ModeResolutionTable(Collections.singletonList( + new ModeResolutionEntry(state -> false, ConnectionMode.OFFLINE) + )); + table.resolve(new ModeState(true, true)); + } + + @Test(expected = IllegalStateException.class) + public void resolve_emptyTable_throws() { + ModeResolutionTable table = new ModeResolutionTable( + Collections.emptyList() + ); + table.resolve(new ModeState(true, true)); + } + + // ==== Network takes priority over lifecycle ==== + + @Test + public void mobile_networkUnavailable_alwaysResolvesToOffline_regardlessOfForeground() { + assertEquals(ConnectionMode.OFFLINE, + ModeResolutionTable.MOBILE.resolve(new ModeState(true, false))); + assertEquals(ConnectionMode.OFFLINE, + ModeResolutionTable.MOBILE.resolve(new ModeState(false, false))); + } +} From 8b8bd7ef4655b04551af2ca52bbc49f7273ce8d0 Mon Sep 17 00:00:00 2001 From: Aaron Zeisler Date: Tue, 10 Mar 2026 12:01:23 -0700 Subject: [PATCH 02/17] feat: Implement switchMode() on FDv2DataSource Add ResolvedModeDefinition and mode-table constructors so FDv2DataSource can look up initializer/synchronizer factories per ConnectionMode. switchMode() tears down the current SourceManager, builds a new one with the target mode's synchronizers (skipping initializers per CONNMODE 2.0.1), and schedules execution on the background executor. runSynchronizers() now takes an explicit SourceManager parameter to prevent a race where the finally block could close a SourceManager swapped in by a concurrent switchMode(). --- .../sdk/android/FDv2DataSource.java | 196 +++++++++++++- .../sdk/android/FDv2DataSourceTest.java | 252 ++++++++++++++++++ 2 files changed, 435 insertions(+), 13 deletions(-) diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSource.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSource.java index 82406356..b52b15da 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSource.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSource.java @@ -7,7 +7,6 @@ import com.launchdarkly.sdk.android.subsystems.Callback; import com.launchdarkly.sdk.android.DataModel; import com.launchdarkly.sdk.fdv2.ChangeSet; -import com.launchdarkly.sdk.android.subsystems.DataSource; import com.launchdarkly.sdk.android.subsystems.DataSourceState; import com.launchdarkly.sdk.android.subsystems.DataSourceUpdateSinkV2; import com.launchdarkly.sdk.android.subsystems.FDv2SourceResult; @@ -15,9 +14,10 @@ import com.launchdarkly.sdk.android.subsystems.Synchronizer; import java.util.ArrayList; -import java.util.Map; import java.util.Collections; +import java.util.EnumMap; import java.util.List; +import java.util.Map; import java.util.concurrent.CancellationException; import java.util.concurrent.ExecutionException; import java.util.concurrent.Future; @@ -30,7 +30,7 @@ * switch to next synchronizer) and recovery (when on non-prime synchronizer, try * to return to the first after timeout). */ -final class FDv2DataSource implements DataSource { +final class FDv2DataSource implements ModeAware { /** * Factory for creating Initializer or Synchronizer instances. @@ -39,10 +39,38 @@ public interface DataSourceFactory { T build(); } + /** + * A resolved mode definition holding factories that are ready to use (already bound to + * their ClientContext). Produced by FDv2DataSourceBuilder from ComponentConfigurer entries. + */ + static final class ResolvedModeDefinition { + private final List> initializers; + private final List> synchronizers; + + ResolvedModeDefinition( + @NonNull List> initializers, + @NonNull List> synchronizers + ) { + this.initializers = Collections.unmodifiableList(new ArrayList<>(initializers)); + this.synchronizers = Collections.unmodifiableList(new ArrayList<>(synchronizers)); + } + + @NonNull + List> getInitializers() { + return initializers; + } + + @NonNull + List> getSynchronizers() { + return synchronizers; + } + } + private final LDLogger logger; private final LDContext evaluationContext; private final DataSourceUpdateSinkV2 dataSourceUpdateSink; - private final SourceManager sourceManager; + private final Map modeTable; + private volatile SourceManager sourceManager; private final long fallbackTimeoutSeconds; private final long recoveryTimeoutSeconds; private final ScheduledExecutorService sharedExecutor; @@ -101,6 +129,7 @@ public interface DataSourceFactory { this.evaluationContext = evaluationContext; this.dataSourceUpdateSink = dataSourceUpdateSink; this.logger = logger; + this.modeTable = null; List synchronizerFactoriesWithState = new ArrayList<>(); for (DataSourceFactory factory : synchronizers) { synchronizerFactoriesWithState.add(new SynchronizerFactoryWithState(factory)); @@ -111,6 +140,74 @@ public interface DataSourceFactory { this.sharedExecutor = sharedExecutor; } + /** + * Mode-aware convenience constructor using default fallback and recovery timeouts. + * + * @param evaluationContext the context to evaluate flags for + * @param modeTable resolved mode definitions keyed by ConnectionMode + * @param startingMode the initial connection mode + * @param dataSourceUpdateSink sink to apply changesets and status updates to + * @param sharedExecutor executor used for internal background tasks + * @param logger logger + */ + FDv2DataSource( + @NonNull LDContext evaluationContext, + @NonNull Map modeTable, + @NonNull ConnectionMode startingMode, + @NonNull DataSourceUpdateSinkV2 dataSourceUpdateSink, + @NonNull ScheduledExecutorService sharedExecutor, + @NonNull LDLogger logger + ) { + this(evaluationContext, modeTable, startingMode, dataSourceUpdateSink, sharedExecutor, logger, + FDv2DataSourceConditions.DEFAULT_FALLBACK_TIMEOUT_SECONDS, + FDv2DataSourceConditions.DEFAULT_RECOVERY_TIMEOUT_SECONDS); + } + + /** + * Mode-aware constructor. The mode table maps each {@link ConnectionMode} to a + * {@link ResolvedModeDefinition} containing pre-built factories. The starting mode + * determines the initial set of initializers and synchronizers. + * + * @param evaluationContext the context to evaluate flags for + * @param modeTable resolved mode definitions keyed by ConnectionMode + * @param startingMode the initial connection mode + * @param dataSourceUpdateSink sink to apply changesets and status updates to + * @param sharedExecutor executor used for internal background tasks; must have + * at least 2 threads + * @param logger logger + * @param fallbackTimeoutSeconds seconds of INTERRUPTED state before falling back + * @param recoveryTimeoutSeconds seconds before attempting to recover to the primary + * synchronizer + */ + FDv2DataSource( + @NonNull LDContext evaluationContext, + @NonNull Map modeTable, + @NonNull ConnectionMode startingMode, + @NonNull DataSourceUpdateSinkV2 dataSourceUpdateSink, + @NonNull ScheduledExecutorService sharedExecutor, + @NonNull LDLogger logger, + long fallbackTimeoutSeconds, + long recoveryTimeoutSeconds + ) { + this.evaluationContext = evaluationContext; + this.dataSourceUpdateSink = dataSourceUpdateSink; + this.logger = logger; + this.modeTable = Collections.unmodifiableMap(new EnumMap<>(modeTable)); + this.fallbackTimeoutSeconds = fallbackTimeoutSeconds; + this.recoveryTimeoutSeconds = recoveryTimeoutSeconds; + this.sharedExecutor = sharedExecutor; + + ResolvedModeDefinition startDef = modeTable.get(startingMode); + if (startDef == null) { + throw new IllegalArgumentException("No mode definition for starting mode: " + startingMode); + } + List syncFactories = new ArrayList<>(); + for (DataSourceFactory factory : startDef.getSynchronizers()) { + syncFactories.add(new SynchronizerFactoryWithState(factory)); + } + this.sourceManager = new SourceManager(syncFactories, new ArrayList<>(startDef.getInitializers())); + } + @Override public void start(@NonNull Callback resultCallback) { synchronized (startResultLock) { @@ -159,8 +256,13 @@ public void start(@NonNull Callback resultCallback) { return; } - runSynchronizers(context, dataSourceUpdateSink); - maybeReportUnexpectedExhaustion("All data source acquisition methods have been exhausted."); + SourceManager sm = sourceManager; + runSynchronizers(sm, context, dataSourceUpdateSink); + // Only report exhaustion if the SourceManager was NOT replaced by a + // concurrent switchMode() call; a mode switch is not an error. + if (sourceManager == sm) { + maybeReportUnexpectedExhaustion("All data source acquisition methods have been exhausted."); + } tryCompleteStart(false, null); } catch (Throwable t) { logger.warn("FDv2DataSource error: {}", t.toString()); @@ -215,6 +317,73 @@ public void stop(@NonNull Callback completionCallback) { completionCallback.onSuccess(null); } + @Override + public boolean needsRefresh(boolean newInBackground, @NonNull LDContext newEvaluationContext) { + // Mode-aware data sources handle foreground/background transitions via switchMode(), + // so only a context change requires a full teardown/rebuild (to re-run initializers). + return !newEvaluationContext.equals(evaluationContext); + } + + /** + * Switches to a new connection mode by tearing down the current synchronizers and + * starting the new mode's synchronizers on the background executor. Initializers are + * NOT re-run (spec CONNMODE 2.0.1). + *

+ * Expected to be called from a single thread (ConnectivityManager's listener). The + * field swap is not atomic; concurrent calls from multiple threads could leave an + * intermediate SourceManager unclosed. + */ + @Override + public void switchMode(@NonNull ConnectionMode newMode) { + if (modeTable == null) { + logger.warn("switchMode({}) called but no mode table configured", newMode); + return; + } + if (stopped.get()) { + return; + } + ResolvedModeDefinition def = modeTable.get(newMode); + if (def == null) { + logger.error("switchMode({}) failed: no definition found", newMode); + return; + } + + // Build new SourceManager with the mode's synchronizer factories. + // Initializers are NOT included — spec 2.0.1: mode switch does not re-run initializers. + List syncFactories = new ArrayList<>(); + for (DataSourceFactory factory : def.getSynchronizers()) { + syncFactories.add(new SynchronizerFactoryWithState(factory)); + } + SourceManager newManager = new SourceManager( + syncFactories, Collections.>emptyList()); + + // Swap the source manager and close the old one to interrupt its active source. + SourceManager oldManager = sourceManager; + sourceManager = newManager; + if (oldManager != null) { + oldManager.close(); + } + + // Run the new mode's synchronizers on the background thread. + LDContext context = evaluationContext; + sharedExecutor.execute(() -> { + try { + if (!newManager.hasAvailableSynchronizers()) { + logger.debug("Mode {} has no synchronizers; data source idle", newMode); + return; + } + runSynchronizers(newManager, context, dataSourceUpdateSink); + // Report exhaustion only if we weren't replaced by another switchMode(). + if (sourceManager == newManager && !stopped.get()) { + maybeReportUnexpectedExhaustion( + "All synchronizers exhausted after mode switch to " + newMode); + } + } catch (Throwable t) { + logger.warn("FDv2DataSource error after mode switch to {}: {}", newMode, t.toString()); + } + }); + } + private void runInitializers( @NonNull LDContext context, @NonNull DataSourceUpdateSinkV2 sink @@ -295,17 +464,18 @@ private List getConditions(int synchronizerC } private void runSynchronizers( + @NonNull SourceManager sm, @NonNull LDContext context, @NonNull DataSourceUpdateSinkV2 sink ) { try { - Synchronizer synchronizer = sourceManager.getNextAvailableSynchronizerAndSetActive(); + Synchronizer synchronizer = sm.getNextAvailableSynchronizerAndSetActive(); while (synchronizer != null) { if (stopped.get()) { return; } - int synchronizerCount = sourceManager.getAvailableSynchronizerCount(); - boolean isPrime = sourceManager.isPrimeSynchronizer(); + int synchronizerCount = sm.getAvailableSynchronizerCount(); + boolean isPrime = sm.isPrimeSynchronizer(); try { boolean running = true; try (FDv2DataSourceConditions.Conditions conditions = @@ -325,7 +495,7 @@ private void runSynchronizers( break; case RECOVERY: logger.debug("The data source is attempting to recover to a higher priority synchronizer."); - sourceManager.resetSourceIndex(); + sm.resetSourceIndex(); break; } running = false; @@ -365,7 +535,7 @@ private void runSynchronizers( case TERMINAL_ERROR: // This synchronizer cannot recover; block it so the outer // loop advances to the next available synchronizer. - sourceManager.blockCurrentSynchronizer(); + sm.blockCurrentSynchronizer(); running = false; sink.setStatus(DataSourceState.INTERRUPTED, status.getError()); break; @@ -391,10 +561,10 @@ private void runSynchronizers( sink.setStatus(DataSourceState.INTERRUPTED, e); return; } - synchronizer = sourceManager.getNextAvailableSynchronizerAndSetActive(); + synchronizer = sm.getNextAvailableSynchronizerAndSetActive(); } } finally { - sourceManager.close(); + sm.close(); } } } diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceTest.java index ad1e91e3..2d8ba4a9 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceTest.java @@ -33,6 +33,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; +import java.util.EnumMap; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -1400,6 +1401,257 @@ public void statusTransitionsFromValidToOffWhenAllSynchronizersFail() throws Exc assertNotNull(sink.getLastError()); } + // ============================================================================ + // needsRefresh — ModeAware behavior + // ============================================================================ + + @Test + public void needsRefresh_sameContextDifferentBackground_returnsFalse() { + MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); + FDv2DataSource dataSource = buildDataSource(sink, Collections.emptyList(), Collections.emptyList()); + + assertFalse(dataSource.needsRefresh(true, CONTEXT)); + assertFalse(dataSource.needsRefresh(false, CONTEXT)); + } + + @Test + public void needsRefresh_differentContext_returnsTrue() { + MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); + FDv2DataSource dataSource = buildDataSource(sink, Collections.emptyList(), Collections.emptyList()); + + LDContext otherContext = LDContext.create("other-context"); + assertTrue(dataSource.needsRefresh(false, otherContext)); + assertTrue(dataSource.needsRefresh(true, otherContext)); + } + + @Test + public void needsRefresh_differentContextAndBackground_returnsTrue() { + MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); + FDv2DataSource dataSource = buildDataSource(sink, Collections.emptyList(), Collections.emptyList()); + + LDContext otherContext = LDContext.create("other-context"); + assertTrue(dataSource.needsRefresh(true, otherContext)); + } + + @Test + public void needsRefresh_equalContextInstance_returnsFalse() { + MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); + LDContext context = LDContext.create("test-context"); + FDv2DataSource dataSource = new FDv2DataSource( + context, Collections.emptyList(), Collections.emptyList(), + sink, executor, logging.logger); + + LDContext sameValueContext = LDContext.create("test-context"); + assertFalse(dataSource.needsRefresh(false, sameValueContext)); + assertFalse(dataSource.needsRefresh(true, sameValueContext)); + } + + // ============================================================================ + // switchMode — ModeAware behavior + // ============================================================================ + + private FDv2DataSource buildModeAwareDataSource( + MockComponents.MockDataSourceUpdateSink sink, + Map modeTable, + ConnectionMode startingMode) { + return new FDv2DataSource( + CONTEXT, modeTable, startingMode, + sink, executor, logging.logger); + } + + @Test + public void switchMode_activatesNewModeSynchronizer() throws Exception { + MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); + + CountDownLatch pollingCreated = new CountDownLatch(1); + + Map modeTable = new EnumMap<>(ConnectionMode.class); + modeTable.put(ConnectionMode.STREAMING, new FDv2DataSource.ResolvedModeDefinition( + Collections.emptyList(), + Collections.singletonList(() -> new MockQueuedSynchronizer( + FDv2SourceResult.changeSet(makeChangeSet(false)))) + )); + modeTable.put(ConnectionMode.POLLING, new FDv2DataSource.ResolvedModeDefinition( + Collections.emptyList(), + Collections.singletonList(() -> { + pollingCreated.countDown(); + return new MockQueuedSynchronizer( + FDv2SourceResult.changeSet(makeChangeSet(false))); + }) + )); + + FDv2DataSource dataSource = buildModeAwareDataSource(sink, modeTable, ConnectionMode.STREAMING); + AwaitableCallback startCallback = startDataSource(dataSource); + assertTrue(startCallback.await(2000)); + + dataSource.switchMode(ConnectionMode.POLLING); + assertTrue(pollingCreated.await(2, TimeUnit.SECONDS)); + + // Both streaming and polling changesets should have been applied + sink.awaitApplyCount(2, 2, TimeUnit.SECONDS); + assertEquals(2, sink.getApplyCount()); + + stopDataSource(dataSource); + } + + @Test + public void switchMode_doesNotReRunInitializers() throws Exception { + MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); + + AtomicInteger initializerBuildCount = new AtomicInteger(0); + CountDownLatch pollingSyncCreated = new CountDownLatch(1); + + Map modeTable = new EnumMap<>(ConnectionMode.class); + modeTable.put(ConnectionMode.STREAMING, new FDv2DataSource.ResolvedModeDefinition( + Collections.singletonList(() -> { + initializerBuildCount.incrementAndGet(); + return new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true))); + }), + Collections.singletonList(() -> new MockQueuedSynchronizer( + FDv2SourceResult.changeSet(makeChangeSet(false)))) + )); + modeTable.put(ConnectionMode.POLLING, new FDv2DataSource.ResolvedModeDefinition( + Collections.emptyList(), + Collections.singletonList(() -> { + pollingSyncCreated.countDown(); + return new MockQueuedSynchronizer( + FDv2SourceResult.changeSet(makeChangeSet(false))); + }) + )); + + FDv2DataSource dataSource = buildModeAwareDataSource(sink, modeTable, ConnectionMode.STREAMING); + AwaitableCallback startCallback = startDataSource(dataSource); + assertTrue(startCallback.await(2000)); + assertEquals(1, initializerBuildCount.get()); + + dataSource.switchMode(ConnectionMode.POLLING); + assertTrue(pollingSyncCreated.await(2, TimeUnit.SECONDS)); + + // Initializer count should still be 1 — mode switch skips initializers (spec 2.0.1) + assertEquals(1, initializerBuildCount.get()); + + stopDataSource(dataSource); + } + + @Test + public void switchMode_toModeWithNoSynchronizers_doesNotCrash() throws Exception { + MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); + + Map modeTable = new EnumMap<>(ConnectionMode.class); + modeTable.put(ConnectionMode.STREAMING, new FDv2DataSource.ResolvedModeDefinition( + Collections.emptyList(), + Collections.singletonList(() -> new MockQueuedSynchronizer( + FDv2SourceResult.changeSet(makeChangeSet(false)))) + )); + modeTable.put(ConnectionMode.OFFLINE, new FDv2DataSource.ResolvedModeDefinition( + Collections.emptyList(), + Collections.emptyList() + )); + + FDv2DataSource dataSource = buildModeAwareDataSource(sink, modeTable, ConnectionMode.STREAMING); + AwaitableCallback startCallback = startDataSource(dataSource); + assertTrue(startCallback.await(2000)); + + dataSource.switchMode(ConnectionMode.OFFLINE); + Thread.sleep(200); // allow mode switch to complete + + stopDataSource(dataSource); + } + + @Test + public void switchMode_fromOfflineBackToStreaming_resumesSynchronizers() throws Exception { + MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); + + AtomicInteger streamingSyncBuildCount = new AtomicInteger(0); + + Map modeTable = new EnumMap<>(ConnectionMode.class); + modeTable.put(ConnectionMode.STREAMING, new FDv2DataSource.ResolvedModeDefinition( + Collections.emptyList(), + Collections.singletonList(() -> { + streamingSyncBuildCount.incrementAndGet(); + return new MockQueuedSynchronizer( + FDv2SourceResult.changeSet(makeChangeSet(false))); + }) + )); + modeTable.put(ConnectionMode.OFFLINE, new FDv2DataSource.ResolvedModeDefinition( + Collections.emptyList(), + Collections.emptyList() + )); + + FDv2DataSource dataSource = buildModeAwareDataSource(sink, modeTable, ConnectionMode.STREAMING); + AwaitableCallback startCallback = startDataSource(dataSource); + assertTrue(startCallback.await(2000)); + assertEquals(1, streamingSyncBuildCount.get()); + + // Switch to offline — no synchronizers + dataSource.switchMode(ConnectionMode.OFFLINE); + Thread.sleep(200); + + // Switch back to streaming — new synchronizer should be created + dataSource.switchMode(ConnectionMode.STREAMING); + Thread.sleep(500); + + // A new streaming sync was created (2 total: one from start, one from mode switch back) + assertEquals(2, streamingSyncBuildCount.get()); + + // Both streaming changesets (initial + resumed) should have been applied + sink.awaitApplyCount(2, 2, TimeUnit.SECONDS); + assertEquals(2, sink.getApplyCount()); + + stopDataSource(dataSource); + } + + @Test + public void switchMode_withNoModeTable_isNoOp() throws Exception { + MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); + + // Use legacy constructor (no mode table) + FDv2DataSource dataSource = buildDataSource(sink, + Collections.emptyList(), + Collections.singletonList(() -> new MockQueuedSynchronizer( + FDv2SourceResult.changeSet(makeChangeSet(false))))); + + AwaitableCallback startCallback = startDataSource(dataSource); + assertTrue(startCallback.await(2000)); + + // Should not crash; logs a warning and returns + dataSource.switchMode(ConnectionMode.POLLING); + Thread.sleep(100); + + stopDataSource(dataSource); + } + + @Test + public void switchMode_afterStop_isNoOp() throws Exception { + MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); + + Map modeTable = new EnumMap<>(ConnectionMode.class); + modeTable.put(ConnectionMode.STREAMING, new FDv2DataSource.ResolvedModeDefinition( + Collections.emptyList(), + Collections.singletonList(() -> new MockQueuedSynchronizer( + FDv2SourceResult.changeSet(makeChangeSet(false)))) + )); + modeTable.put(ConnectionMode.POLLING, new FDv2DataSource.ResolvedModeDefinition( + Collections.emptyList(), + Collections.singletonList(() -> new MockQueuedSynchronizer( + FDv2SourceResult.changeSet(makeChangeSet(false)))) + )); + + FDv2DataSource dataSource = buildModeAwareDataSource(sink, modeTable, ConnectionMode.STREAMING); + AwaitableCallback startCallback = startDataSource(dataSource); + assertTrue(startCallback.await(2000)); + + stopDataSource(dataSource); + + // Should not crash or schedule new work after stop + dataSource.switchMode(ConnectionMode.POLLING); + Thread.sleep(100); + } + + // ============================================================================ + // Status Reporting + // ============================================================================ + @Test public void stopReportsOffStatus() throws Exception { MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); From 8b1af405bcfcc4b489dc72e4c18923cd5636673e Mon Sep 17 00:00:00 2001 From: Aaron Zeisler Date: Tue, 10 Mar 2026 13:24:33 -0700 Subject: [PATCH 03/17] feat: Add FDv2DataSourceBuilder with stub configurer resolution Introduces FDv2DataSourceBuilder, a ComponentConfigurer that resolves the ModeDefinition table's ComponentConfigurers into DataSourceFactories at build time by capturing the ClientContext. The configurers are currently stubbed (return null); real wiring of concrete initializer/synchronizer types will follow in a subsequent commit. --- .../sdk/android/FDv2DataSourceBuilder.java | 119 +++++++++++++++ .../android/FDv2DataSourceBuilderTest.java | 142 ++++++++++++++++++ 2 files changed, 261 insertions(+) create mode 100644 launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilder.java create mode 100644 launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilderTest.java diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilder.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilder.java new file mode 100644 index 00000000..014fbd5e --- /dev/null +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilder.java @@ -0,0 +1,119 @@ +package com.launchdarkly.sdk.android; + +import androidx.annotation.NonNull; + +import com.launchdarkly.sdk.android.subsystems.ClientContext; +import com.launchdarkly.sdk.android.subsystems.ComponentConfigurer; +import com.launchdarkly.sdk.android.subsystems.DataSource; +import com.launchdarkly.sdk.android.subsystems.DataSourceUpdateSinkV2; +import com.launchdarkly.sdk.android.subsystems.Initializer; +import com.launchdarkly.sdk.android.subsystems.Synchronizer; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.EnumMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; + +/** + * Builds a mode-aware {@link FDv2DataSource} from a {@link ModeDefinition} table. + *

+ * At build time, each {@link ComponentConfigurer} in the mode table is resolved into a + * {@link FDv2DataSource.DataSourceFactory} by partially applying the {@link ClientContext}: + *

{@code
+ * DataSourceFactory factory = () -> configurer.build(clientContext);
+ * }
+ * This bridges the SDK's {@link ComponentConfigurer} pattern (used in the mode table) with + * the {@link FDv2DataSource.DataSourceFactory} pattern (used inside {@link FDv2DataSource}). + *

+ * The configurers in {@link ModeDefinition#DEFAULT_MODE_TABLE} are currently stubbed. A + * subsequent commit will replace them with real implementations that create + * {@link FDv2PollingInitializer}, {@link FDv2PollingSynchronizer}, + * {@link FDv2StreamingSynchronizer}, etc. + *

+ * Package-private — not part of the public SDK API. + * + * @see ModeDefinition + * @see FDv2DataSource.ResolvedModeDefinition + */ +final class FDv2DataSourceBuilder implements ComponentConfigurer { + + private final Map modeTable; + private final ConnectionMode startingMode; + + /** + * Creates a builder using the {@link ModeDefinition#DEFAULT_MODE_TABLE} and + * {@link ConnectionMode#STREAMING} as the starting mode. + */ + FDv2DataSourceBuilder() { + this(ModeDefinition.DEFAULT_MODE_TABLE, ConnectionMode.STREAMING); + } + + /** + * @param modeTable the mode definitions to resolve at build time + * @param startingMode the initial connection mode for the data source + */ + FDv2DataSourceBuilder( + @NonNull Map modeTable, + @NonNull ConnectionMode startingMode + ) { + this.modeTable = Collections.unmodifiableMap(new EnumMap<>(modeTable)); + this.startingMode = startingMode; + } + + @Override + public DataSource build(ClientContext clientContext) { + Map resolved = + resolveModeTable(clientContext); + + DataSourceUpdateSinkV2 sinkV2 = + (DataSourceUpdateSinkV2) clientContext.getDataSourceUpdateSink(); + + // TODO: executor lifecycle — FDv2DataSource does not shut down its executor. + // In a future commit, this should be replaced with an executor obtained from + // ClientContextImpl or managed by ConnectivityManager. + ScheduledExecutorService executor = Executors.newScheduledThreadPool(2); + + return new FDv2DataSource( + clientContext.getEvaluationContext(), + resolved, + startingMode, + sinkV2, + executor, + clientContext.getBaseLogger() + ); + } + + /** + * Resolves every {@link ComponentConfigurer} in the mode table into a + * {@link FDv2DataSource.DataSourceFactory} by capturing the {@code clientContext}. + * The actual component is not created until the factory's {@code build()} is called. + */ + private Map resolveModeTable( + ClientContext clientContext + ) { + Map resolved = + new EnumMap<>(ConnectionMode.class); + + for (Map.Entry entry : modeTable.entrySet()) { + ModeDefinition def = entry.getValue(); + + List> initFactories = new ArrayList<>(); + for (ComponentConfigurer configurer : def.getInitializers()) { + initFactories.add(() -> configurer.build(clientContext)); + } + + List> syncFactories = new ArrayList<>(); + for (ComponentConfigurer configurer : def.getSynchronizers()) { + syncFactories.add(() -> configurer.build(clientContext)); + } + + resolved.put(entry.getKey(), new FDv2DataSource.ResolvedModeDefinition( + initFactories, syncFactories)); + } + + return resolved; + } +} diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilderTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilderTest.java new file mode 100644 index 00000000..4b03c8ab --- /dev/null +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilderTest.java @@ -0,0 +1,142 @@ +package com.launchdarkly.sdk.android; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import com.launchdarkly.sdk.LDContext; +import com.launchdarkly.sdk.android.subsystems.ClientContext; +import com.launchdarkly.sdk.android.subsystems.ComponentConfigurer; +import com.launchdarkly.sdk.android.subsystems.DataSource; +import com.launchdarkly.sdk.android.subsystems.Initializer; +import com.launchdarkly.sdk.android.subsystems.Synchronizer; + +import org.junit.Rule; +import org.junit.Test; + +import java.util.Collections; +import java.util.EnumMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; + +public class FDv2DataSourceBuilderTest { + + private static final LDContext CONTEXT = LDContext.create("builder-test-key"); + + @Rule + public LogCaptureRule logging = new LogCaptureRule(); + + private ClientContext makeClientContext() { + MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); + return new ClientContext( + "mobile-key", null, logging.logger, null, sink, + "", false, CONTEXT, null, false, null, null, false + ); + } + + @Test + public void build_returnsNonNullDataSource() { + FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(); + DataSource ds = builder.build(makeClientContext()); + assertNotNull(ds); + } + + @Test + public void build_returnsModeAwareDataSource() { + FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(); + DataSource ds = builder.build(makeClientContext()); + assertTrue(ds instanceof ModeAware); + } + + @Test + public void build_resolvesConfigurersViaClientContext() { + AtomicReference capturedContext = new AtomicReference<>(); + ComponentConfigurer trackingConfigurer = ctx -> { + capturedContext.set(ctx); + return null; + }; + + Map customTable = new EnumMap<>(ConnectionMode.class); + customTable.put(ConnectionMode.STREAMING, new ModeDefinition( + Collections.>emptyList(), + Collections.singletonList(trackingConfigurer) + )); + + FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(customTable, ConnectionMode.STREAMING); + ClientContext ctx = makeClientContext(); + DataSource ds = builder.build(ctx); + + // The factory hasn't been invoked yet (lazy resolution). + // Trigger it by starting the data source — but for a unit test, we can verify + // through the resolved mode definition structure instead. We rely on the fact + // that construction succeeded, meaning the starting mode was found in the table. + assertNotNull(ds); + + // Verify that the configurer is callable with the right context. The factory + // wraps `() -> configurer.build(clientContext)`, so we verify the configurer + // itself is wired correctly by calling it directly. + assertNull(trackingConfigurer.build(ctx)); + assertEquals(ctx, capturedContext.get()); + } + + @Test + public void build_usesProvidedStartingMode() { + Map customTable = new EnumMap<>(ConnectionMode.class); + customTable.put(ConnectionMode.POLLING, new ModeDefinition( + Collections.>emptyList(), + Collections.>emptyList() + )); + + FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(customTable, ConnectionMode.POLLING); + DataSource ds = builder.build(makeClientContext()); + + // If the starting mode wasn't found in the table, construction would throw + // IllegalArgumentException. A successful build confirms the mode was resolved. + assertNotNull(ds); + } + + @Test(expected = IllegalArgumentException.class) + public void build_throwsWhenStartingModeNotInTable() { + Map customTable = new EnumMap<>(ConnectionMode.class); + customTable.put(ConnectionMode.POLLING, new ModeDefinition( + Collections.>emptyList(), + Collections.>emptyList() + )); + + // Starting mode is STREAMING, but table only has POLLING + FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(customTable, ConnectionMode.STREAMING); + builder.build(makeClientContext()); + } + + @Test + public void build_defaultConstructorUsesDefaultModeTable() { + FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(); + DataSource ds = builder.build(makeClientContext()); + + // The default table has all 5 modes and starts with STREAMING. + // Successful construction confirms both the table and starting mode are valid. + assertNotNull(ds); + assertTrue(ds instanceof ModeAware); + } + + @Test + public void build_resolvesAllModesFromTable() { + Map customTable = new EnumMap<>(ConnectionMode.class); + customTable.put(ConnectionMode.STREAMING, new ModeDefinition( + Collections.>emptyList(), + Collections.singletonList(ctx -> null) + )); + customTable.put(ConnectionMode.OFFLINE, new ModeDefinition( + Collections.>emptyList(), + Collections.>emptyList() + )); + + FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(customTable, ConnectionMode.STREAMING); + DataSource ds = builder.build(makeClientContext()); + assertNotNull(ds); + + // Verify that switchMode to a different mode in the table works (doesn't throw) + ((ModeAware) ds).switchMode(ConnectionMode.OFFLINE); + } +} From 2d1b776be0d804f5054847abefb90c5365f45fcb Mon Sep 17 00:00:00 2001 From: Aaron Zeisler Date: Tue, 10 Mar 2026 15:28:00 -0700 Subject: [PATCH 04/17] feat: wire real FDv2 ComponentConfigurer implementations in FDv2DataSourceBuilder Replace stub configurers with concrete factories that create FDv2PollingInitializer, FDv2PollingSynchronizer, and FDv2StreamingSynchronizer. Shared dependencies (SelectorSource, ScheduledExecutorService) are created once per build() call; each factory creates a fresh DefaultFDv2Requestor for lifecycle isolation. Add FDv2 endpoint path constants to StandardEndpoints. Thread TransactionalDataStore through ClientContextImpl and ConnectivityManager so the builder can construct SelectorSourceFacade from ClientContext. --- .../sdk/android/ClientContextImpl.java | 42 ++++- .../sdk/android/ConnectivityManager.java | 4 + .../sdk/android/FDv2DataSourceBuilder.java | 171 +++++++++++++++--- .../sdk/android/StandardEndpoints.java | 6 + .../android/FDv2DataSourceBuilderTest.java | 117 ++++++++---- 5 files changed, 272 insertions(+), 68 deletions(-) diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ClientContextImpl.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ClientContextImpl.java index 85066fbe..b02dccd5 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ClientContextImpl.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ClientContextImpl.java @@ -1,11 +1,14 @@ package com.launchdarkly.sdk.android; +import androidx.annotation.Nullable; + import com.launchdarkly.logging.LDLogger; import com.launchdarkly.sdk.LDContext; import com.launchdarkly.sdk.android.env.IEnvironmentReporter; import com.launchdarkly.sdk.android.subsystems.ClientContext; import com.launchdarkly.sdk.android.subsystems.HttpConfiguration; import com.launchdarkly.sdk.android.subsystems.DataSourceUpdateSink; +import com.launchdarkly.sdk.android.subsystems.TransactionalDataStore; import com.launchdarkly.sdk.internal.events.DiagnosticStore; /** @@ -33,6 +36,7 @@ final class ClientContextImpl extends ClientContext { private final PlatformState platformState; private final TaskExecutor taskExecutor; private final PersistentDataStoreWrapper.PerEnvironmentData perEnvironmentData; + private final TransactionalDataStore transactionalDataStore; ClientContextImpl( ClientContext base, @@ -41,6 +45,18 @@ final class ClientContextImpl extends ClientContext { PlatformState platformState, TaskExecutor taskExecutor, PersistentDataStoreWrapper.PerEnvironmentData perEnvironmentData + ) { + this(base, diagnosticStore, fetcher, platformState, taskExecutor, perEnvironmentData, null); + } + + ClientContextImpl( + ClientContext base, + DiagnosticStore diagnosticStore, + FeatureFetcher fetcher, + PlatformState platformState, + TaskExecutor taskExecutor, + PersistentDataStoreWrapper.PerEnvironmentData perEnvironmentData, + TransactionalDataStore transactionalDataStore ) { super(base); this.diagnosticStore = diagnosticStore; @@ -48,6 +64,7 @@ final class ClientContextImpl extends ClientContext { this.platformState = platformState; this.taskExecutor = taskExecutor; this.perEnvironmentData = perEnvironmentData; + this.transactionalDataStore = transactionalDataStore; } static ClientContextImpl fromConfig( @@ -101,8 +118,23 @@ public static ClientContextImpl forDataSource( LDContext newEvaluationContext, boolean newInBackground, Boolean previouslyInBackground + ) { + return forDataSource(baseClientContext, dataSourceUpdateSink, null, + newEvaluationContext, newInBackground, previouslyInBackground); + } + + public static ClientContextImpl forDataSource( + ClientContext baseClientContext, + DataSourceUpdateSink dataSourceUpdateSink, + @Nullable TransactionalDataStore transactionalDataStore, + LDContext newEvaluationContext, + boolean newInBackground, + Boolean previouslyInBackground ) { ClientContextImpl baseContextImpl = ClientContextImpl.get(baseClientContext); + TransactionalDataStore store = transactionalDataStore != null + ? transactionalDataStore + : baseContextImpl.transactionalDataStore; return new ClientContextImpl( new ClientContext( baseClientContext.getMobileKey(), @@ -123,7 +155,8 @@ public static ClientContextImpl forDataSource( baseContextImpl.getFetcher(), baseContextImpl.getPlatformState(), baseContextImpl.getTaskExecutor(), - baseContextImpl.getPerEnvironmentData() + baseContextImpl.getPerEnvironmentData(), + store ); } @@ -139,7 +172,8 @@ public ClientContextImpl setEvaluationContext(LDContext context) { this.fetcher, this.platformState, this.taskExecutor, - this.perEnvironmentData + this.perEnvironmentData, + this.transactionalDataStore ); } @@ -163,6 +197,10 @@ public PersistentDataStoreWrapper.PerEnvironmentData getPerEnvironmentData() { return throwExceptionIfNull(perEnvironmentData); } + public TransactionalDataStore getTransactionalDataStore() { + return transactionalDataStore; + } + private static T throwExceptionIfNull(T o) { if (o == null) { throw new IllegalStateException( diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ConnectivityManager.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ConnectivityManager.java index 22b09e23..180be3bf 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ConnectivityManager.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ConnectivityManager.java @@ -15,6 +15,7 @@ import com.launchdarkly.sdk.android.subsystems.DataSourceUpdateSink; import com.launchdarkly.sdk.android.subsystems.DataSourceUpdateSinkV2; import com.launchdarkly.sdk.android.subsystems.EventProcessor; +import com.launchdarkly.sdk.android.subsystems.TransactionalDataStore; import com.launchdarkly.sdk.fdv2.Selector; import java.lang.ref.WeakReference; @@ -56,6 +57,7 @@ class ConnectivityManager { private final ClientContext baseClientContext; private final PlatformState platformState; private final ComponentConfigurer dataSourceFactory; + private final TransactionalDataStore transactionalDataStore; private final DataSourceUpdateSink dataSourceUpdateSink; private final ConnectionInformationState connectionInformation; private final PersistentDataStoreWrapper.PerEnvironmentData environmentStore; @@ -135,6 +137,7 @@ public void shutDown() { ) { this.baseClientContext = clientContext; this.dataSourceFactory = dataSourceFactory; + this.transactionalDataStore = contextDataManager; this.dataSourceUpdateSink = new DataSourceUpdateSinkImpl(contextDataManager); this.platformState = ClientContextImpl.get(clientContext).getPlatformState(); this.eventProcessor = eventProcessor; @@ -235,6 +238,7 @@ private synchronized boolean updateDataSource( ClientContext clientContext = ClientContextImpl.forDataSource( baseClientContext, dataSourceUpdateSink, + transactionalDataStore, context, inBackground, previouslyInBackground.get() diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilder.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilder.java index 014fbd5e..7cb5c7d8 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilder.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilder.java @@ -1,15 +1,25 @@ package com.launchdarkly.sdk.android; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import com.launchdarkly.logging.LDLogger; +import com.launchdarkly.sdk.LDContext; +import com.launchdarkly.sdk.android.integrations.PollingDataSourceBuilder; +import com.launchdarkly.sdk.android.integrations.StreamingDataSourceBuilder; import com.launchdarkly.sdk.android.subsystems.ClientContext; import com.launchdarkly.sdk.android.subsystems.ComponentConfigurer; import com.launchdarkly.sdk.android.subsystems.DataSource; import com.launchdarkly.sdk.android.subsystems.DataSourceUpdateSinkV2; import com.launchdarkly.sdk.android.subsystems.Initializer; import com.launchdarkly.sdk.android.subsystems.Synchronizer; +import com.launchdarkly.sdk.android.subsystems.TransactionalDataStore; +import com.launchdarkly.sdk.internal.events.DiagnosticStore; +import com.launchdarkly.sdk.internal.http.HttpProperties; +import java.net.URI; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; import java.util.EnumMap; import java.util.List; @@ -18,20 +28,19 @@ import java.util.concurrent.ScheduledExecutorService; /** - * Builds a mode-aware {@link FDv2DataSource} from a {@link ModeDefinition} table. + * Builds a mode-aware {@link FDv2DataSource} from either a custom {@link ModeDefinition} table + * or the built-in default mode definitions. *

- * At build time, each {@link ComponentConfigurer} in the mode table is resolved into a - * {@link FDv2DataSource.DataSourceFactory} by partially applying the {@link ClientContext}: - *

{@code
- * DataSourceFactory factory = () -> configurer.build(clientContext);
- * }
- * This bridges the SDK's {@link ComponentConfigurer} pattern (used in the mode table) with - * the {@link FDv2DataSource.DataSourceFactory} pattern (used inside {@link FDv2DataSource}). + * When no custom table is supplied, the builder creates concrete {@link FDv2PollingInitializer}, + * {@link FDv2PollingSynchronizer}, and {@link FDv2StreamingSynchronizer} factories using + * dependencies extracted from the {@link ClientContext}. Shared dependencies (executor, + * {@link SelectorSource}) are created once and captured by all factory closures. Each factory + * call creates fresh instances of requestors and concrete sources to ensure proper lifecycle + * management. *

- * The configurers in {@link ModeDefinition#DEFAULT_MODE_TABLE} are currently stubbed. A - * subsequent commit will replace them with real implementations that create - * {@link FDv2PollingInitializer}, {@link FDv2PollingSynchronizer}, - * {@link FDv2StreamingSynchronizer}, etc. + * When a custom table is supplied (for testing), each {@link ComponentConfigurer} is resolved + * into a {@link FDv2DataSource.DataSourceFactory} by partially applying the + * {@link ClientContext}. *

* Package-private — not part of the public SDK API. * @@ -40,42 +49,50 @@ */ final class FDv2DataSourceBuilder implements ComponentConfigurer { + @Nullable private final Map modeTable; private final ConnectionMode startingMode; /** - * Creates a builder using the {@link ModeDefinition#DEFAULT_MODE_TABLE} and + * Creates a builder using the built-in default mode definitions and * {@link ConnectionMode#STREAMING} as the starting mode. */ FDv2DataSourceBuilder() { - this(ModeDefinition.DEFAULT_MODE_TABLE, ConnectionMode.STREAMING); + this(null, ConnectionMode.STREAMING); } /** - * @param modeTable the mode definitions to resolve at build time + * @param modeTable custom mode definitions to resolve at build time, or {@code null} + * to use the built-in defaults * @param startingMode the initial connection mode for the data source */ FDv2DataSourceBuilder( - @NonNull Map modeTable, + @Nullable Map modeTable, @NonNull ConnectionMode startingMode ) { - this.modeTable = Collections.unmodifiableMap(new EnumMap<>(modeTable)); + this.modeTable = modeTable != null + ? Collections.unmodifiableMap(new EnumMap<>(modeTable)) + : null; this.startingMode = startingMode; } @Override public DataSource build(ClientContext clientContext) { - Map resolved = - resolveModeTable(clientContext); - - DataSourceUpdateSinkV2 sinkV2 = - (DataSourceUpdateSinkV2) clientContext.getDataSourceUpdateSink(); - // TODO: executor lifecycle — FDv2DataSource does not shut down its executor. // In a future commit, this should be replaced with an executor obtained from // ClientContextImpl or managed by ConnectivityManager. ScheduledExecutorService executor = Executors.newScheduledThreadPool(2); + Map resolved; + if (modeTable != null) { + resolved = resolveCustomModeTable(clientContext); + } else { + resolved = buildDefaultModeTable(clientContext, executor); + } + + DataSourceUpdateSinkV2 sinkV2 = + (DataSourceUpdateSinkV2) clientContext.getDataSourceUpdateSink(); + return new FDv2DataSource( clientContext.getEvaluationContext(), resolved, @@ -87,11 +104,95 @@ public DataSource build(ClientContext clientContext) { } /** - * Resolves every {@link ComponentConfigurer} in the mode table into a - * {@link FDv2DataSource.DataSourceFactory} by capturing the {@code clientContext}. - * The actual component is not created until the factory's {@code build()} is called. + * Builds the default mode table with real factories. Shared dependencies are created + * once and captured by factory closures; each factory call creates fresh instances of + * requestors and concrete sources. + */ + private Map buildDefaultModeTable( + ClientContext clientContext, + ScheduledExecutorService executor + ) { + ClientContextImpl impl = ClientContextImpl.get(clientContext); + HttpProperties httpProperties = LDUtil.makeHttpProperties(clientContext); + LDContext evalContext = clientContext.getEvaluationContext(); + LDLogger logger = clientContext.getBaseLogger(); + boolean useReport = clientContext.getHttp().isUseReport(); + boolean evaluationReasons = clientContext.isEvaluationReasons(); + URI pollingBaseUri = clientContext.getServiceEndpoints().getPollingBaseUri(); + URI streamingBaseUri = clientContext.getServiceEndpoints().getStreamingBaseUri(); + DiagnosticStore diagnosticStore = impl.getDiagnosticStore(); + TransactionalDataStore txnStore = impl.getTransactionalDataStore(); + SelectorSource selectorSource = new SelectorSourceFacade(txnStore); + + // Each factory creates a fresh requestor so that lifecycle (close/shutdown) is isolated + // per initializer/synchronizer instance. + FDv2DataSource.DataSourceFactory pollingInitFactory = () -> + new FDv2PollingInitializer( + newRequestor(evalContext, pollingBaseUri, httpProperties, + useReport, evaluationReasons, logger), + selectorSource, executor, logger); + + FDv2DataSource.DataSourceFactory streamingSyncFactory = () -> + new FDv2StreamingSynchronizer( + httpProperties, streamingBaseUri, + StandardEndpoints.FDV2_STREAMING_REQUEST_BASE_PATH, + evalContext, useReport, evaluationReasons, selectorSource, + newRequestor(evalContext, pollingBaseUri, httpProperties, + useReport, evaluationReasons, logger), + StreamingDataSourceBuilder.DEFAULT_INITIAL_RECONNECT_DELAY_MILLIS, + diagnosticStore, logger); + + FDv2DataSource.DataSourceFactory foregroundPollSyncFactory = () -> + new FDv2PollingSynchronizer( + newRequestor(evalContext, pollingBaseUri, httpProperties, + useReport, evaluationReasons, logger), + selectorSource, executor, + 0, PollingDataSourceBuilder.DEFAULT_POLL_INTERVAL_MILLIS, logger); + + FDv2DataSource.DataSourceFactory backgroundPollSyncFactory = () -> + new FDv2PollingSynchronizer( + newRequestor(evalContext, pollingBaseUri, httpProperties, + useReport, evaluationReasons, logger), + selectorSource, executor, + 0, LDConfig.DEFAULT_BACKGROUND_POLL_INTERVAL_MILLIS, logger); + + Map resolved = + new EnumMap<>(ConnectionMode.class); + + // STREAMING: poll once for initial data, then stream (with polling fallback) + resolved.put(ConnectionMode.STREAMING, new FDv2DataSource.ResolvedModeDefinition( + Collections.singletonList(pollingInitFactory), + Arrays.asList(streamingSyncFactory, foregroundPollSyncFactory))); + + // POLLING: poll once for initial data, then poll periodically + resolved.put(ConnectionMode.POLLING, new FDv2DataSource.ResolvedModeDefinition( + Collections.singletonList(pollingInitFactory), + Collections.singletonList(foregroundPollSyncFactory))); + + // OFFLINE: no network activity + resolved.put(ConnectionMode.OFFLINE, new FDv2DataSource.ResolvedModeDefinition( + Collections.>emptyList(), + Collections.>emptyList())); + + // ONE_SHOT: poll once, then stop + resolved.put(ConnectionMode.ONE_SHOT, new FDv2DataSource.ResolvedModeDefinition( + Collections.singletonList(pollingInitFactory), + Collections.>emptyList())); + + // BACKGROUND: poll at reduced frequency (no re-initialization) + resolved.put(ConnectionMode.BACKGROUND, new FDv2DataSource.ResolvedModeDefinition( + Collections.>emptyList(), + Collections.singletonList(backgroundPollSyncFactory))); + + return resolved; + } + + /** + * Resolves a custom {@link ModeDefinition} table by wrapping each {@link ComponentConfigurer} + * in a {@link FDv2DataSource.DataSourceFactory} that defers to + * {@code configurer.build(clientContext)}. */ - private Map resolveModeTable( + private Map resolveCustomModeTable( ClientContext clientContext ) { Map resolved = @@ -116,4 +217,20 @@ private Map resolveModeTa return resolved; } + + private static DefaultFDv2Requestor newRequestor( + LDContext evalContext, + URI pollingBaseUri, + HttpProperties httpProperties, + boolean useReport, + boolean evaluationReasons, + LDLogger logger + ) { + return new DefaultFDv2Requestor( + evalContext, pollingBaseUri, + StandardEndpoints.FDV2_POLLING_REQUEST_GET_BASE_PATH, + StandardEndpoints.FDV2_POLLING_REQUEST_REPORT_BASE_PATH, + httpProperties, useReport, evaluationReasons, + null, logger); + } } diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/StandardEndpoints.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/StandardEndpoints.java index c9c395e3..2f2e60bd 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/StandardEndpoints.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/StandardEndpoints.java @@ -14,6 +14,12 @@ private StandardEndpoints() {} static final String STREAMING_REQUEST_BASE_PATH = "/meval"; static final String POLLING_REQUEST_GET_BASE_PATH = "/msdk/evalx/contexts"; static final String POLLING_REQUEST_REPORT_BASE_PATH = "/msdk/evalx/context"; + + // FDv2 paths per CSFDV2 Requirement 2.1.1 (unified for all client-side platforms). + // Context is appended as a base64 path segment for GET, or sent in the request body for REPORT/POST. + static final String FDV2_POLLING_REQUEST_GET_BASE_PATH = "/sdk/poll/eval"; + static final String FDV2_POLLING_REQUEST_REPORT_BASE_PATH = "/sdk/poll/eval"; + static final String FDV2_STREAMING_REQUEST_BASE_PATH = "/sdk/stream/eval"; static final String ANALYTICS_EVENTS_REQUEST_PATH = "/mobile/events/bulk"; static final String DIAGNOSTIC_EVENTS_REQUEST_PATH = "/mobile/events/diagnostic"; diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilderTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilderTest.java index 4b03c8ab..a6b8f518 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilderTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilderTest.java @@ -5,12 +5,22 @@ import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; +import androidx.annotation.NonNull; + import com.launchdarkly.sdk.LDContext; +import com.launchdarkly.sdk.android.DataModel.Flag; +import com.launchdarkly.sdk.android.LDConfig.Builder.AutoEnvAttributes; +import com.launchdarkly.sdk.android.env.EnvironmentReporterBuilder; +import com.launchdarkly.sdk.android.env.IEnvironmentReporter; import com.launchdarkly.sdk.android.subsystems.ClientContext; import com.launchdarkly.sdk.android.subsystems.ComponentConfigurer; import com.launchdarkly.sdk.android.subsystems.DataSource; +import com.launchdarkly.sdk.android.subsystems.HttpConfiguration; import com.launchdarkly.sdk.android.subsystems.Initializer; import com.launchdarkly.sdk.android.subsystems.Synchronizer; +import com.launchdarkly.sdk.android.subsystems.TransactionalDataStore; +import com.launchdarkly.sdk.fdv2.ChangeSet; +import com.launchdarkly.sdk.fdv2.Selector; import org.junit.Rule; import org.junit.Test; @@ -23,11 +33,17 @@ public class FDv2DataSourceBuilderTest { private static final LDContext CONTEXT = LDContext.create("builder-test-key"); + private static final IEnvironmentReporter ENV_REPORTER = new EnvironmentReporterBuilder().build(); @Rule public LogCaptureRule logging = new LogCaptureRule(); - private ClientContext makeClientContext() { + /** + * Creates a minimal ClientContext for tests that use a custom mode table. + * No TransactionalDataStore or HTTP config needed — the custom path + * only wraps ComponentConfigurers in DataSourceFactory lambdas. + */ + private ClientContext makeMinimalClientContext() { MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); return new ClientContext( "mobile-key", null, logging.logger, null, sink, @@ -35,22 +51,70 @@ private ClientContext makeClientContext() { ); } + /** + * Creates a ClientContext backed by a real ClientContextImpl with HTTP config, + * ServiceEndpoints, and a TransactionalDataStore. Used by tests that exercise + * the default (real-wiring) build path. + */ + private ClientContext makeFullClientContext() { + LDConfig config = new LDConfig.Builder(AutoEnvAttributes.Disabled).build(); + MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); + + // Two-phase ClientContext creation: first without HTTP config to bootstrap it, + // then with the resolved HTTP config — mirrors ClientContextImpl.fromConfig(). + ClientContext bootstrap = new ClientContext( + "mobile-key", ENV_REPORTER, logging.logger, config, + null, "", false, CONTEXT, null, false, null, + config.serviceEndpoints, false + ); + HttpConfiguration httpConfig = config.http.build(bootstrap); + + ClientContext base = new ClientContext( + "mobile-key", ENV_REPORTER, logging.logger, config, + sink, "", false, CONTEXT, httpConfig, false, null, + config.serviceEndpoints, false + ); + + TransactionalDataStore mockStore = new TransactionalDataStore() { + @Override + public void apply(@NonNull LDContext context, + @NonNull ChangeSet> changeSet) { } + + @NonNull + @Override + public Selector getSelector() { + return Selector.EMPTY; + } + }; + + return new ClientContextImpl(base, null, null, null, null, null, mockStore); + } + + // --- Default constructor tests (real wiring path) --- + @Test - public void build_returnsNonNullDataSource() { + public void build_defaultConstructor_returnsNonNullModeAwareDataSource() { FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(); - DataSource ds = builder.build(makeClientContext()); + DataSource ds = builder.build(makeFullClientContext()); assertNotNull(ds); + assertTrue(ds instanceof ModeAware); } @Test - public void build_returnsModeAwareDataSource() { + public void build_defaultConstructor_allModesResolved() { FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(); - DataSource ds = builder.build(makeClientContext()); - assertTrue(ds instanceof ModeAware); + DataSource ds = builder.build(makeFullClientContext()); + + ModeAware modeAware = (ModeAware) ds; + for (ConnectionMode mode : ConnectionMode.values()) { + modeAware.switchMode(mode); + } } + // --- Custom mode table tests (configurer resolution path) --- + @Test - public void build_resolvesConfigurersViaClientContext() { + public void build_customTable_resolvesConfigurersViaClientContext() { AtomicReference capturedContext = new AtomicReference<>(); ComponentConfigurer trackingConfigurer = ctx -> { capturedContext.set(ctx); @@ -64,24 +128,16 @@ public void build_resolvesConfigurersViaClientContext() { )); FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(customTable, ConnectionMode.STREAMING); - ClientContext ctx = makeClientContext(); + ClientContext ctx = makeMinimalClientContext(); DataSource ds = builder.build(ctx); - // The factory hasn't been invoked yet (lazy resolution). - // Trigger it by starting the data source — but for a unit test, we can verify - // through the resolved mode definition structure instead. We rely on the fact - // that construction succeeded, meaning the starting mode was found in the table. assertNotNull(ds); - - // Verify that the configurer is callable with the right context. The factory - // wraps `() -> configurer.build(clientContext)`, so we verify the configurer - // itself is wired correctly by calling it directly. assertNull(trackingConfigurer.build(ctx)); assertEquals(ctx, capturedContext.get()); } @Test - public void build_usesProvidedStartingMode() { + public void build_customTable_usesProvidedStartingMode() { Map customTable = new EnumMap<>(ConnectionMode.class); customTable.put(ConnectionMode.POLLING, new ModeDefinition( Collections.>emptyList(), @@ -89,39 +145,24 @@ public void build_usesProvidedStartingMode() { )); FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(customTable, ConnectionMode.POLLING); - DataSource ds = builder.build(makeClientContext()); - - // If the starting mode wasn't found in the table, construction would throw - // IllegalArgumentException. A successful build confirms the mode was resolved. + DataSource ds = builder.build(makeMinimalClientContext()); assertNotNull(ds); } @Test(expected = IllegalArgumentException.class) - public void build_throwsWhenStartingModeNotInTable() { + public void build_customTable_throwsWhenStartingModeNotInTable() { Map customTable = new EnumMap<>(ConnectionMode.class); customTable.put(ConnectionMode.POLLING, new ModeDefinition( Collections.>emptyList(), Collections.>emptyList() )); - // Starting mode is STREAMING, but table only has POLLING FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(customTable, ConnectionMode.STREAMING); - builder.build(makeClientContext()); + builder.build(makeMinimalClientContext()); } @Test - public void build_defaultConstructorUsesDefaultModeTable() { - FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(); - DataSource ds = builder.build(makeClientContext()); - - // The default table has all 5 modes and starts with STREAMING. - // Successful construction confirms both the table and starting mode are valid. - assertNotNull(ds); - assertTrue(ds instanceof ModeAware); - } - - @Test - public void build_resolvesAllModesFromTable() { + public void build_customTable_resolvesAllModesAndSupportsSwitchMode() { Map customTable = new EnumMap<>(ConnectionMode.class); customTable.put(ConnectionMode.STREAMING, new ModeDefinition( Collections.>emptyList(), @@ -133,10 +174,8 @@ public void build_resolvesAllModesFromTable() { )); FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(customTable, ConnectionMode.STREAMING); - DataSource ds = builder.build(makeClientContext()); + DataSource ds = builder.build(makeMinimalClientContext()); assertNotNull(ds); - - // Verify that switchMode to a different mode in the table works (doesn't throw) ((ModeAware) ds).switchMode(ConnectionMode.OFFLINE); } } From 05f9536d19400a136425a8144d83114f4da8c7ae Mon Sep 17 00:00:00 2001 From: Aaron Zeisler Date: Tue, 10 Mar 2026 16:52:42 -0700 Subject: [PATCH 05/17] feat: Add FDv2 mode resolution to ConnectivityManager ConnectivityManager now detects ModeAware data sources and routes foreground, connectivity, and force-offline state changes through resolveAndSwitchMode() instead of the legacy teardown/rebuild cycle. --- .../sdk/android/ConnectivityManager.java | 70 +++- .../sdk/android/ConnectivityManagerTest.java | 309 ++++++++++++++++++ .../sdk/android/MockPlatformState.java | 9 + 3 files changed, 376 insertions(+), 12 deletions(-) diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ConnectivityManager.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ConnectivityManager.java index 180be3bf..82541128 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ConnectivityManager.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ConnectivityManager.java @@ -26,8 +26,6 @@ import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; -import static com.launchdarkly.sdk.android.ConnectionInformation.ConnectionMode; - class ConnectivityManager { // Implementation notes: // @@ -76,6 +74,7 @@ class ConnectivityManager { private final AtomicReference previouslyInBackground = new AtomicReference<>(); private final LDLogger logger; private volatile boolean initialized = false; + private volatile ConnectionMode currentFDv2Mode; // The DataSourceUpdateSinkImpl receives flag updates and status updates from the DataSource. // This has two purposes: 1. to decouple the data source implementation from the details of how @@ -107,7 +106,7 @@ public void apply(@NonNull LDContext context, @NonNull ChangeSet { - updateDataSource(false, LDUtil.noOpCallback()); + DataSource dataSource = currentDataSource.get(); + if (dataSource instanceof ModeAware) { + eventProcessor.setOffline(forcedOffline.get() || !networkAvailable); + resolveAndSwitchMode((ModeAware) dataSource); + } else { + updateDataSource(false, LDUtil.noOpCallback()); + } }; platformState.addConnectivityChangeListener(connectivityChangeListener); foregroundListener = foreground -> { DataSource dataSource = currentDataSource.get(); - if (dataSource == null || dataSource.needsRefresh(!foreground, + if (dataSource instanceof ModeAware) { + eventProcessor.setInBackground(!foreground); + resolveAndSwitchMode((ModeAware) dataSource); + } else if (dataSource == null || dataSource.needsRefresh(!foreground, currentContext.get())) { updateDataSource(true, LDUtil.noOpCallback()); } @@ -212,11 +220,11 @@ private synchronized boolean updateDataSource( if (forceOffline) { logger.debug("Initialized in offline mode"); initialized = true; - dataSourceUpdateSink.setStatus(ConnectionMode.SET_OFFLINE, null); + dataSourceUpdateSink.setStatus(ConnectionInformation.ConnectionMode.SET_OFFLINE, null); } else if (!networkEnabled) { - dataSourceUpdateSink.setStatus(ConnectionMode.OFFLINE, null); + dataSourceUpdateSink.setStatus(ConnectionInformation.ConnectionMode.OFFLINE, null); } else if (inBackground && backgroundUpdatingDisabled) { - dataSourceUpdateSink.setStatus(ConnectionMode.BACKGROUND_DISABLED, null); + dataSourceUpdateSink.setStatus(ConnectionInformation.ConnectionMode.BACKGROUND_DISABLED, null); } else { shouldStopExistingDataSource = mustReinitializeDataSource; shouldStartDataSourceIfStopped = true; @@ -266,6 +274,13 @@ public void onError(Throwable error) { } }); + // Resolve the initial mode after start() so that switchMode() can safely replace + // the source manager without conflicting with the start() task submission. + if (dataSource instanceof ModeAware) { + currentFDv2Mode = ConnectionMode.STREAMING; + resolveAndSwitchMode((ModeAware) dataSource); + } + return true; } @@ -297,7 +312,7 @@ void unregisterStatusListener(LDStatusListener LDStatusListener) { } } - private void updateConnectionInfoForSuccess(ConnectionMode connectionMode) { + private void updateConnectionInfoForSuccess(ConnectionInformation.ConnectionMode connectionMode) { boolean updated = false; if (connectionInformation.getConnectionMode() != connectionMode) { connectionInformation.setConnectionMode(connectionMode); @@ -322,7 +337,7 @@ private void updateConnectionInfoForSuccess(ConnectionMode connectionMode) { } } - private void updateConnectionInfoForError(ConnectionMode connectionMode, Throwable error) { + private void updateConnectionInfoForError(ConnectionInformation.ConnectionMode connectionMode, Throwable error) { LDFailure failure = null; if (error != null) { if (error instanceof LDFailure) { @@ -410,6 +425,31 @@ synchronized boolean startUp(@NonNull Callback onCompletion) { return updateDataSource(true, onCompletion); } + /** + * Resolves the current platform state to a {@link ConnectionMode} using the mode resolution + * table, and calls {@link ModeAware#switchMode} if the resolved mode differs from the current + * mode. This replaces the legacy teardown/rebuild cycle for FDv2 data sources. + */ + private void resolveAndSwitchMode(ModeAware modeAware) { + ConnectionMode resolvedMode; + if (forcedOffline.get()) { + resolvedMode = ConnectionMode.OFFLINE; + } else { + ModeState state = new ModeState( + platformState.isForeground(), + platformState.isNetworkAvailable() + ); + resolvedMode = ModeResolutionTable.MOBILE.resolve(state); + } + + ConnectionMode previousMode = currentFDv2Mode; + if (previousMode != resolvedMode) { + logger.debug("Switching FDv2 data source mode: {} -> {}", previousMode, resolvedMode); + currentFDv2Mode = resolvedMode; + modeAware.switchMode(resolvedMode); + } + } + /** * Permanently stops data updating for the current client instance. We call this if the client * is being closed, or if we receive an error that indicates the mobile key is invalid. @@ -429,7 +469,13 @@ void shutDown() { void setForceOffline(boolean forceOffline) { boolean wasForcedOffline = forcedOffline.getAndSet(forceOffline); if (forceOffline != wasForcedOffline) { - updateDataSource(false, LDUtil.noOpCallback()); + DataSource dataSource = currentDataSource.get(); + if (dataSource instanceof ModeAware) { + eventProcessor.setOffline(forceOffline || !platformState.isNetworkAvailable()); + resolveAndSwitchMode((ModeAware) dataSource); + } else { + updateDataSource(false, LDUtil.noOpCallback()); + } } } diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ConnectivityManagerTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ConnectivityManagerTest.java index 26523e5e..1697e5c9 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ConnectivityManagerTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ConnectivityManagerTest.java @@ -657,4 +657,313 @@ private void verifyNoMoreDataSourcesWereCreated() { private void verifyNoMoreDataSourcesWereStopped() { requireNoMoreValues(stoppedDataSources, 1, TimeUnit.SECONDS, "stopping of data source"); } + + // --- ModeAware (FDv2) tests --- + + /** + * A minimal {@link ModeAware} data source that tracks {@code switchMode()} calls. + * Mimics the threading behavior of real data sources by calling the start callback + * on a background thread after reporting initial status. + */ + private static class MockModeAwareDataSource implements ModeAware { + final BlockingQueue switchModeCalls = + new LinkedBlockingQueue<>(); + final BlockingQueue startedQueue; + final BlockingQueue stoppedQueue; + final ClientContext clientContext; + + MockModeAwareDataSource( + ClientContext clientContext, + BlockingQueue startedQueue, + BlockingQueue stoppedQueue + ) { + this.clientContext = clientContext; + this.startedQueue = startedQueue; + this.stoppedQueue = stoppedQueue; + } + + @Override + public void start(@NonNull Callback resultCallback) { + if (startedQueue != null) { + startedQueue.add(this); + } + new Thread(() -> { + clientContext.getDataSourceUpdateSink().setStatus( + ConnectionMode.STREAMING, null); + resultCallback.onSuccess(true); + }).start(); + } + + @Override + public void stop(@NonNull Callback completionCallback) { + if (stoppedQueue != null) { + stoppedQueue.add(this); + } + completionCallback.onSuccess(null); + } + + @Override + public boolean needsRefresh(boolean newInBackground, @NonNull LDContext newEvaluationContext) { + return false; + } + + @Override + public void switchMode(@NonNull com.launchdarkly.sdk.android.ConnectionMode newMode) { + switchModeCalls.add(newMode); + } + + com.launchdarkly.sdk.android.ConnectionMode requireSwitchMode() { + return requireValue(switchModeCalls, 1, TimeUnit.SECONDS, + "switchMode call"); + } + + void requireNoMoreSwitchModeCalls() { + requireNoMoreValues(switchModeCalls, 100, TimeUnit.MILLISECONDS, + "unexpected switchMode call"); + } + } + + private ComponentConfigurer makeModeAwareDataSourceFactory( + MockModeAwareDataSource[] holder + ) { + return clientContext -> { + receivedClientContexts.add(clientContext); + MockModeAwareDataSource ds = new MockModeAwareDataSource( + clientContext, startedDataSources, stoppedDataSources); + holder[0] = ds; + return ds; + }; + } + + @Test + public void modeAwareForegroundToBackgroundSwitchesMode() throws Exception { + eventProcessor.setOffline(false); + eventProcessor.setInBackground(false); + eventProcessor.setInBackground(true); + replayAll(); + + MockModeAwareDataSource[] holder = new MockModeAwareDataSource[1]; + createTestManager(false, false, makeModeAwareDataSourceFactory(holder)); + awaitStartUp(); + + // Initial mode: STREAMING (foreground + network available). + // resolveAndSwitchMode was called after start() but resolved STREAMING = no-op. + MockModeAwareDataSource ds = holder[0]; + assertNotNull(ds); + ds.requireNoMoreSwitchModeCalls(); + + mockPlatformState.setAndNotifyForegroundChangeListeners(false); + + com.launchdarkly.sdk.android.ConnectionMode newMode = ds.requireSwitchMode(); + assertEquals(com.launchdarkly.sdk.android.ConnectionMode.BACKGROUND, newMode); + + verifyAll(); + verifyNoMoreDataSourcesWereStopped(); + } + + @Test + public void modeAwareBackgroundToForegroundSwitchesMode() throws Exception { + mockPlatformState.setForeground(false); + + eventProcessor.setOffline(false); + eventProcessor.setInBackground(true); + eventProcessor.setInBackground(false); + replayAll(); + + MockModeAwareDataSource[] holder = new MockModeAwareDataSource[1]; + createTestManager(false, false, makeModeAwareDataSourceFactory(holder)); + awaitStartUp(); + + MockModeAwareDataSource ds = holder[0]; + assertNotNull(ds); + // Initial resolution: background → switchMode(BACKGROUND) + com.launchdarkly.sdk.android.ConnectionMode initialMode = ds.requireSwitchMode(); + assertEquals(com.launchdarkly.sdk.android.ConnectionMode.BACKGROUND, initialMode); + + mockPlatformState.setAndNotifyForegroundChangeListeners(true); + + com.launchdarkly.sdk.android.ConnectionMode newMode = ds.requireSwitchMode(); + assertEquals(com.launchdarkly.sdk.android.ConnectionMode.STREAMING, newMode); + + verifyAll(); + verifyNoMoreDataSourcesWereStopped(); + } + + @Test + public void modeAwareNetworkLostSwitchesMode() throws Exception { + eventProcessor.setOffline(false); + eventProcessor.setInBackground(false); + eventProcessor.setOffline(true); + replayAll(); + + MockModeAwareDataSource[] holder = new MockModeAwareDataSource[1]; + createTestManager(false, false, makeModeAwareDataSourceFactory(holder)); + awaitStartUp(); + + MockModeAwareDataSource ds = holder[0]; + assertNotNull(ds); + ds.requireNoMoreSwitchModeCalls(); + + mockPlatformState.setAndNotifyConnectivityChangeListeners(false); + + com.launchdarkly.sdk.android.ConnectionMode newMode = ds.requireSwitchMode(); + assertEquals(com.launchdarkly.sdk.android.ConnectionMode.OFFLINE, newMode); + + verifyAll(); + verifyNoMoreDataSourcesWereStopped(); + } + + @Test + public void modeAwareNetworkRestoredSwitchesMode() throws Exception { + eventProcessor.setOffline(false); + eventProcessor.setInBackground(false); + eventProcessor.setOffline(true); + eventProcessor.setOffline(false); + replayAll(); + + MockModeAwareDataSource[] holder = new MockModeAwareDataSource[1]; + createTestManager(false, false, makeModeAwareDataSourceFactory(holder)); + awaitStartUp(); + + MockModeAwareDataSource ds = holder[0]; + assertNotNull(ds); + ds.requireNoMoreSwitchModeCalls(); + + // Lose network + mockPlatformState.setAndNotifyConnectivityChangeListeners(false); + com.launchdarkly.sdk.android.ConnectionMode offlineMode = ds.requireSwitchMode(); + assertEquals(com.launchdarkly.sdk.android.ConnectionMode.OFFLINE, offlineMode); + + // Restore network + mockPlatformState.setAndNotifyConnectivityChangeListeners(true); + com.launchdarkly.sdk.android.ConnectionMode restoredMode = ds.requireSwitchMode(); + assertEquals(com.launchdarkly.sdk.android.ConnectionMode.STREAMING, restoredMode); + + verifyAll(); + verifyNoMoreDataSourcesWereStopped(); + } + + @Test + public void modeAwareSetForceOfflineSwitchesMode() throws Exception { + eventProcessor.setOffline(false); + eventProcessor.setInBackground(false); + eventProcessor.setOffline(true); + replayAll(); + + MockModeAwareDataSource[] holder = new MockModeAwareDataSource[1]; + createTestManager(false, false, makeModeAwareDataSourceFactory(holder)); + awaitStartUp(); + + MockModeAwareDataSource ds = holder[0]; + assertNotNull(ds); + ds.requireNoMoreSwitchModeCalls(); + + connectivityManager.setForceOffline(true); + + com.launchdarkly.sdk.android.ConnectionMode newMode = ds.requireSwitchMode(); + assertEquals(com.launchdarkly.sdk.android.ConnectionMode.OFFLINE, newMode); + + verifyAll(); + verifyNoMoreDataSourcesWereStopped(); + } + + @Test + public void modeAwareUnsetForceOfflineResolvesMode() throws Exception { + eventProcessor.setOffline(false); + eventProcessor.setInBackground(false); + eventProcessor.setOffline(true); + eventProcessor.setOffline(false); + replayAll(); + + MockModeAwareDataSource[] holder = new MockModeAwareDataSource[1]; + createTestManager(false, false, makeModeAwareDataSourceFactory(holder)); + awaitStartUp(); + + MockModeAwareDataSource ds = holder[0]; + assertNotNull(ds); + ds.requireNoMoreSwitchModeCalls(); + + connectivityManager.setForceOffline(true); + com.launchdarkly.sdk.android.ConnectionMode offlineMode = ds.requireSwitchMode(); + assertEquals(com.launchdarkly.sdk.android.ConnectionMode.OFFLINE, offlineMode); + + connectivityManager.setForceOffline(false); + com.launchdarkly.sdk.android.ConnectionMode restoredMode = ds.requireSwitchMode(); + assertEquals(com.launchdarkly.sdk.android.ConnectionMode.STREAMING, restoredMode); + + verifyAll(); + verifyNoMoreDataSourcesWereStopped(); + } + + @Test + public void modeAwareDoesNotSwitchWhenModeUnchanged() throws Exception { + eventProcessor.setOffline(false); + eventProcessor.setInBackground(false); + eventProcessor.setOffline(false); + replayAll(); + + MockModeAwareDataSource[] holder = new MockModeAwareDataSource[1]; + createTestManager(false, false, makeModeAwareDataSourceFactory(holder)); + awaitStartUp(); + + MockModeAwareDataSource ds = holder[0]; + assertNotNull(ds); + ds.requireNoMoreSwitchModeCalls(); + + // Network is already available; re-notifying should not trigger switchMode + mockPlatformState.setAndNotifyConnectivityChangeListeners(true); + + ds.requireNoMoreSwitchModeCalls(); + verifyAll(); + verifyNoMoreDataSourcesWereStopped(); + } + + @Test + public void modeAwareDoesNotTearDownOnForegroundChange() throws Exception { + eventProcessor.setOffline(false); + eventProcessor.setInBackground(false); + eventProcessor.setInBackground(true); + replayAll(); + + MockModeAwareDataSource[] holder = new MockModeAwareDataSource[1]; + createTestManager(false, false, makeModeAwareDataSourceFactory(holder)); + awaitStartUp(); + + MockModeAwareDataSource ds = holder[0]; + assertNotNull(ds); + ds.requireNoMoreSwitchModeCalls(); + + verifyForegroundDataSourceWasCreatedAndStarted(CONTEXT); + + mockPlatformState.setAndNotifyForegroundChangeListeners(false); + + ds.requireSwitchMode(); + verifyNoMoreDataSourcesWereCreated(); + verifyNoMoreDataSourcesWereStopped(); + verifyAll(); + } + + @Test + public void modeAwareStartsInBackgroundResolvesToBackground() throws Exception { + mockPlatformState.setForeground(false); + + eventProcessor.setOffline(false); + eventProcessor.setInBackground(true); + replayAll(); + + MockModeAwareDataSource[] holder = new MockModeAwareDataSource[1]; + createTestManager(false, false, makeModeAwareDataSourceFactory(holder)); + awaitStartUp(); + + MockModeAwareDataSource ds = holder[0]; + assertNotNull(ds); + + // Builder default is STREAMING, but we start in background, so + // resolveAndSwitchMode should immediately switch to BACKGROUND + com.launchdarkly.sdk.android.ConnectionMode initialMode = ds.requireSwitchMode(); + assertEquals(com.launchdarkly.sdk.android.ConnectionMode.BACKGROUND, initialMode); + ds.requireNoMoreSwitchModeCalls(); + + verifyAll(); + } } \ No newline at end of file diff --git a/shared-test-code/src/main/java/com/launchdarkly/sdk/android/MockPlatformState.java b/shared-test-code/src/main/java/com/launchdarkly/sdk/android/MockPlatformState.java index 4d99a282..7c0d12d3 100644 --- a/shared-test-code/src/main/java/com/launchdarkly/sdk/android/MockPlatformState.java +++ b/shared-test-code/src/main/java/com/launchdarkly/sdk/android/MockPlatformState.java @@ -63,6 +63,15 @@ public void removeForegroundChangeListener(ForegroundChangeListener listener) { foregroundChangeListeners.remove(listener); } + public void setAndNotifyConnectivityChangeListeners(boolean networkAvailable) { + this.networkAvailable = networkAvailable; + new Thread(() -> { + for (ConnectivityChangeListener listener: connectivityChangeListeners) { + listener.onConnectivityChanged(networkAvailable); + } + }).start(); + } + public void setAndNotifyForegroundChangeListeners(boolean foreground) { this.foreground = foreground; new Thread(() -> { From c67b5507a8307908083a42bf39777dacacac43f2 Mon Sep 17 00:00:00 2001 From: Aaron Zeisler Date: Wed, 11 Mar 2026 17:06:53 -0700 Subject: [PATCH 06/17] [SDK-1956] clean up unused code --- .../sdk/android/ConnectivityManager.java | 2 - .../sdk/android/ModeDefinition.java | 52 +++---------------- 2 files changed, 7 insertions(+), 47 deletions(-) diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ConnectivityManager.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ConnectivityManager.java index 82541128..199daae4 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ConnectivityManager.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ConnectivityManager.java @@ -6,7 +6,6 @@ import com.launchdarkly.logging.LogValues; import com.launchdarkly.sdk.LDContext; import com.launchdarkly.sdk.android.subsystems.Callback; -import com.launchdarkly.sdk.android.DataModel; import com.launchdarkly.sdk.fdv2.ChangeSet; import com.launchdarkly.sdk.android.subsystems.ClientContext; import com.launchdarkly.sdk.android.subsystems.ComponentConfigurer; @@ -16,7 +15,6 @@ import com.launchdarkly.sdk.android.subsystems.DataSourceUpdateSinkV2; import com.launchdarkly.sdk.android.subsystems.EventProcessor; import com.launchdarkly.sdk.android.subsystems.TransactionalDataStore; -import com.launchdarkly.sdk.fdv2.Selector; import java.lang.ref.WeakReference; import java.util.ArrayList; diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeDefinition.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeDefinition.java index 70c0202b..cd33312f 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeDefinition.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeDefinition.java @@ -6,62 +6,24 @@ import com.launchdarkly.sdk.android.subsystems.Initializer; import com.launchdarkly.sdk.android.subsystems.Synchronizer; -import java.util.Arrays; import java.util.Collections; -import java.util.EnumMap; import java.util.List; -import java.util.Map; /** * Defines the initializer and synchronizer pipelines for a {@link ConnectionMode}. + * Each instance is a pure data holder — it stores {@link ComponentConfigurer} factories + * but does not create any concrete initializer or synchronizer objects. *

- * Each mode in the {@link #DEFAULT_MODE_TABLE} maps to a {@code ModeDefinition} that - * describes which data source components to create. At build time, - * {@code FDv2DataSourceBuilder} resolves each {@link ComponentConfigurer} into a - * {@link FDv2DataSource.DataSourceFactory} by applying the {@code ClientContext}. - *

- * The configurers in {@link #DEFAULT_MODE_TABLE} are currently stubbed (return null). - * Real {@link ComponentConfigurer} implementations will be wired in when - * {@code FDv2DataSourceBuilder} is created. + * At build time, {@code FDv2DataSourceBuilder} resolves each {@link ComponentConfigurer} + * into a {@link FDv2DataSource.DataSourceFactory} by partially applying the + * {@link com.launchdarkly.sdk.android.subsystems.ClientContext}. *

* Package-private — not part of the public SDK API. + * + * @see ConnectionMode */ final class ModeDefinition { - // Stubbed configurer — will be replaced with real ComponentConfigurer implementations - // in FDv2DataSourceBuilder when concrete types are wired up. - private static final ComponentConfigurer STUB_INITIALIZER = clientContext -> null; - private static final ComponentConfigurer STUB_SYNCHRONIZER = clientContext -> null; - - static final Map DEFAULT_MODE_TABLE; - - static { - Map table = new EnumMap<>(ConnectionMode.class); - // Initializer/synchronizer lists per CONNMODE spec and js-core ConnectionModeConfig.ts. - // Stubs will be replaced with real factories (cache, polling, streaming) in FDv2DataSourceBuilder. - table.put(ConnectionMode.STREAMING, new ModeDefinition( - Arrays.asList(STUB_INITIALIZER, STUB_INITIALIZER), // cache, polling - Arrays.asList(STUB_SYNCHRONIZER, STUB_SYNCHRONIZER) // streaming, polling - )); - table.put(ConnectionMode.POLLING, new ModeDefinition( - Collections.singletonList(STUB_INITIALIZER), // cache - Collections.singletonList(STUB_SYNCHRONIZER) // polling - )); - table.put(ConnectionMode.OFFLINE, new ModeDefinition( - Collections.singletonList(STUB_INITIALIZER), // cache - Collections.>emptyList() - )); - table.put(ConnectionMode.ONE_SHOT, new ModeDefinition( - Arrays.asList(STUB_INITIALIZER, STUB_INITIALIZER, STUB_INITIALIZER), // cache, polling, streaming - Collections.>emptyList() - )); - table.put(ConnectionMode.BACKGROUND, new ModeDefinition( - Collections.singletonList(STUB_INITIALIZER), // cache - Collections.singletonList(STUB_SYNCHRONIZER) // polling (LDConfig.DEFAULT_BACKGROUND_POLL_INTERVAL_MILLIS) - )); - DEFAULT_MODE_TABLE = Collections.unmodifiableMap(table); - } - private final List> initializers; private final List> synchronizers; From 8976bfb4efee69c5daaf017d39eda409bf52cc91 Mon Sep 17 00:00:00 2001 From: Aaron Zeisler Date: Tue, 17 Mar 2026 14:56:28 -0700 Subject: [PATCH 07/17] [SDK-1956] refactor: switch to Approach 2 for FDv2 mode resolution and switching Replace Approach 1 implementation with Approach 2, which the team preferred for its cleaner architecture: - ConnectivityManager owns the resolved mode table and performs ModeState -> ConnectionMode -> ResolvedModeDefinition lookup - FDv2DataSource receives ResolvedModeDefinition via switchMode() and has no internal mode table - FDv2DataSourceBuilder uses a unified ComponentConfigurer-based code path for both production and test mode tables - ResolvedModeDefinition is a top-level class rather than an inner class of FDv2DataSource - ConnectionMode is a final class with static instances instead of a Java enum Made-with: Cursor --- .../sdk/android/ClientContextImpl.java | 23 +- .../sdk/android/ConnectionMode.java | 33 +- .../sdk/android/ConnectivityManager.java | 78 ++-- .../sdk/android/FDv2DataSource.java | 179 +-------- .../sdk/android/FDv2DataSourceBuilder.java | 370 +++++++++--------- .../launchdarkly/sdk/android/ModeAware.java | 19 +- .../sdk/android/ModeDefinition.java | 3 +- .../sdk/android/ModeResolutionEntry.java | 25 +- .../launchdarkly/sdk/android/ModeState.java | 11 +- .../sdk/android/ResolvedModeDefinition.java | 48 +++ .../sdk/android/StandardEndpoints.java | 6 +- .../sdk/android/ConnectivityManagerTest.java | 298 ++++++-------- .../android/FDv2DataSourceBuilderTest.java | 170 +++----- .../sdk/android/FDv2DataSourceTest.java | 328 +++++----------- .../sdk/android/ModeResolutionTableTest.java | 72 ++-- 15 files changed, 681 insertions(+), 982 deletions(-) create mode 100644 launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ResolvedModeDefinition.java diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ClientContextImpl.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ClientContextImpl.java index b02dccd5..7993acdc 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ClientContextImpl.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ClientContextImpl.java @@ -1,16 +1,16 @@ package com.launchdarkly.sdk.android; -import androidx.annotation.Nullable; - import com.launchdarkly.logging.LDLogger; import com.launchdarkly.sdk.LDContext; import com.launchdarkly.sdk.android.env.IEnvironmentReporter; import com.launchdarkly.sdk.android.subsystems.ClientContext; -import com.launchdarkly.sdk.android.subsystems.HttpConfiguration; import com.launchdarkly.sdk.android.subsystems.DataSourceUpdateSink; +import com.launchdarkly.sdk.android.subsystems.HttpConfiguration; import com.launchdarkly.sdk.android.subsystems.TransactionalDataStore; import com.launchdarkly.sdk.internal.events.DiagnosticStore; +import androidx.annotation.Nullable; + /** * This package-private subclass of {@link ClientContext} contains additional non-public SDK objects * that may be used by our internal components. @@ -36,6 +36,7 @@ final class ClientContextImpl extends ClientContext { private final PlatformState platformState; private final TaskExecutor taskExecutor; private final PersistentDataStoreWrapper.PerEnvironmentData perEnvironmentData; + @Nullable private final TransactionalDataStore transactionalDataStore; ClientContextImpl( @@ -56,7 +57,7 @@ final class ClientContextImpl extends ClientContext { PlatformState platformState, TaskExecutor taskExecutor, PersistentDataStoreWrapper.PerEnvironmentData perEnvironmentData, - TransactionalDataStore transactionalDataStore + @Nullable TransactionalDataStore transactionalDataStore ) { super(base); this.diagnosticStore = diagnosticStore; @@ -119,22 +120,19 @@ public static ClientContextImpl forDataSource( boolean newInBackground, Boolean previouslyInBackground ) { - return forDataSource(baseClientContext, dataSourceUpdateSink, null, - newEvaluationContext, newInBackground, previouslyInBackground); + return forDataSource(baseClientContext, dataSourceUpdateSink, newEvaluationContext, + newInBackground, previouslyInBackground, null); } public static ClientContextImpl forDataSource( ClientContext baseClientContext, DataSourceUpdateSink dataSourceUpdateSink, - @Nullable TransactionalDataStore transactionalDataStore, LDContext newEvaluationContext, boolean newInBackground, - Boolean previouslyInBackground + Boolean previouslyInBackground, + @Nullable TransactionalDataStore transactionalDataStore ) { ClientContextImpl baseContextImpl = ClientContextImpl.get(baseClientContext); - TransactionalDataStore store = transactionalDataStore != null - ? transactionalDataStore - : baseContextImpl.transactionalDataStore; return new ClientContextImpl( new ClientContext( baseClientContext.getMobileKey(), @@ -156,7 +154,7 @@ public static ClientContextImpl forDataSource( baseContextImpl.getPlatformState(), baseContextImpl.getTaskExecutor(), baseContextImpl.getPerEnvironmentData(), - store + transactionalDataStore ); } @@ -197,6 +195,7 @@ public PersistentDataStoreWrapper.PerEnvironmentData getPerEnvironmentData() { return throwExceptionIfNull(perEnvironmentData); } + @Nullable public TransactionalDataStore getTransactionalDataStore() { return transactionalDataStore; } diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ConnectionMode.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ConnectionMode.java index 789b4efb..31777cef 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ConnectionMode.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ConnectionMode.java @@ -1,17 +1,34 @@ package com.launchdarkly.sdk.android; /** - * Named connection modes for the FDv2 data system. Each mode maps to a - * {@link ModeDefinition} that specifies which initializers and synchronizers to run. + * Enumerates the built-in FDv2 connection modes. Each mode maps to a + * {@link ModeDefinition} that specifies which initializers and synchronizers + * are active when the SDK is operating in that mode. + *

+ * This is a closed enum — custom connection modes (spec 5.3.5 TBD) are not + * supported in this release. *

* Package-private — not part of the public SDK API. * * @see ModeDefinition + * @see ModeResolutionTable */ -enum ConnectionMode { - STREAMING, - POLLING, - OFFLINE, - ONE_SHOT, - BACKGROUND +final class ConnectionMode { + + static final ConnectionMode STREAMING = new ConnectionMode("STREAMING"); + static final ConnectionMode POLLING = new ConnectionMode("POLLING"); + static final ConnectionMode OFFLINE = new ConnectionMode("OFFLINE"); + static final ConnectionMode ONE_SHOT = new ConnectionMode("ONE_SHOT"); + static final ConnectionMode BACKGROUND = new ConnectionMode("BACKGROUND"); + + private final String name; + + private ConnectionMode(String name) { + this.name = name; + } + + @Override + public String toString() { + return name; + } } diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ConnectivityManager.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ConnectivityManager.java index 199daae4..b8998b45 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ConnectivityManager.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ConnectivityManager.java @@ -53,11 +53,11 @@ class ConnectivityManager { private final ClientContext baseClientContext; private final PlatformState platformState; private final ComponentConfigurer dataSourceFactory; - private final TransactionalDataStore transactionalDataStore; private final DataSourceUpdateSink dataSourceUpdateSink; private final ConnectionInformationState connectionInformation; private final PersistentDataStoreWrapper.PerEnvironmentData environmentStore; private final EventProcessor eventProcessor; + private final TransactionalDataStore transactionalDataStore; private final PlatformState.ForegroundChangeListener foregroundListener; private final PlatformState.ConnectivityChangeListener connectivityChangeListener; private final TaskExecutor taskExecutor; @@ -72,6 +72,7 @@ class ConnectivityManager { private final AtomicReference previouslyInBackground = new AtomicReference<>(); private final LDLogger logger; private volatile boolean initialized = false; + private volatile Map resolvedModeTable; private volatile ConnectionMode currentFDv2Mode; // The DataSourceUpdateSinkImpl receives flag updates and status updates from the DataSource. @@ -134,11 +135,11 @@ public void shutDown() { ) { this.baseClientContext = clientContext; this.dataSourceFactory = dataSourceFactory; - this.transactionalDataStore = contextDataManager; this.dataSourceUpdateSink = new DataSourceUpdateSinkImpl(contextDataManager); this.platformState = ClientContextImpl.get(clientContext).getPlatformState(); this.eventProcessor = eventProcessor; this.environmentStore = environmentStore; + this.transactionalDataStore = contextDataManager; this.taskExecutor = ClientContextImpl.get(clientContext).getTaskExecutor(); this.logger = clientContext.getBaseLogger(); @@ -153,7 +154,7 @@ public void shutDown() { connectivityChangeListener = networkAvailable -> { DataSource dataSource = currentDataSource.get(); if (dataSource instanceof ModeAware) { - eventProcessor.setOffline(forcedOffline.get() || !networkAvailable); + eventProcessor.setOffline(!networkAvailable); resolveAndSwitchMode((ModeAware) dataSource); } else { updateDataSource(false, LDUtil.noOpCallback()); @@ -244,15 +245,21 @@ private synchronized boolean updateDataSource( ClientContext clientContext = ClientContextImpl.forDataSource( baseClientContext, dataSourceUpdateSink, - transactionalDataStore, context, inBackground, - previouslyInBackground.get() + previouslyInBackground.get(), + transactionalDataStore ); DataSource dataSource = dataSourceFactory.build(clientContext); currentDataSource.set(dataSource); previouslyInBackground.set(Boolean.valueOf(inBackground)); + if (dataSourceFactory instanceof FDv2DataSourceBuilder) { + FDv2DataSourceBuilder fdv2Builder = (FDv2DataSourceBuilder) dataSourceFactory; + resolvedModeTable = fdv2Builder.getResolvedModeTable(); + currentFDv2Mode = fdv2Builder.getStartingMode(); + } + dataSource.start(new Callback() { @Override public void onSuccess(Boolean result) { @@ -272,10 +279,9 @@ public void onError(Throwable error) { } }); - // Resolve the initial mode after start() so that switchMode() can safely replace - // the source manager without conflicting with the start() task submission. + // If the app starts in the background, the builder creates the data source with + // STREAMING as the starting mode. Perform an initial mode resolution to correct this. if (dataSource instanceof ModeAware) { - currentFDv2Mode = ConnectionMode.STREAMING; resolveAndSwitchMode((ModeAware) dataSource); } @@ -423,31 +429,6 @@ synchronized boolean startUp(@NonNull Callback onCompletion) { return updateDataSource(true, onCompletion); } - /** - * Resolves the current platform state to a {@link ConnectionMode} using the mode resolution - * table, and calls {@link ModeAware#switchMode} if the resolved mode differs from the current - * mode. This replaces the legacy teardown/rebuild cycle for FDv2 data sources. - */ - private void resolveAndSwitchMode(ModeAware modeAware) { - ConnectionMode resolvedMode; - if (forcedOffline.get()) { - resolvedMode = ConnectionMode.OFFLINE; - } else { - ModeState state = new ModeState( - platformState.isForeground(), - platformState.isNetworkAvailable() - ); - resolvedMode = ModeResolutionTable.MOBILE.resolve(state); - } - - ConnectionMode previousMode = currentFDv2Mode; - if (previousMode != resolvedMode) { - logger.debug("Switching FDv2 data source mode: {} -> {}", previousMode, resolvedMode); - currentFDv2Mode = resolvedMode; - modeAware.switchMode(resolvedMode); - } - } - /** * Permanently stops data updating for the current client instance. We call this if the client * is being closed, or if we receive an error that indicates the mobile key is invalid. @@ -481,6 +462,37 @@ boolean isForcedOffline() { return forcedOffline.get(); } + /** + * Resolves the current platform state to a ConnectionMode via the ModeResolutionTable, + * looks up the ResolvedModeDefinition from the resolved mode table, and calls + * switchMode() on the data source if the mode has changed. + */ + private void resolveAndSwitchMode(@NonNull ModeAware modeAware) { + Map table = resolvedModeTable; + if (table == null) { + return; + } + boolean forceOffline = forcedOffline.get(); + boolean networkAvailable = platformState.isNetworkAvailable(); + boolean foreground = platformState.isForeground(); + ModeState state = new ModeState( + foreground && !forceOffline, + networkAvailable && !forceOffline + ); + ConnectionMode newMode = ModeResolutionTable.MOBILE.resolve(state); + if (newMode == currentFDv2Mode) { + return; + } + currentFDv2Mode = newMode; + ResolvedModeDefinition def = table.get(newMode); + if (def == null) { + logger.warn("No resolved definition for mode {}; skipping switchMode", newMode); + return; + } + logger.debug("Switching FDv2 mode to {}", newMode); + modeAware.switchMode(def); + } + synchronized ConnectionInformation getConnectionInformation() { return connectionInformation; } diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSource.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSource.java index b52b15da..ca1dc1ed 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSource.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSource.java @@ -15,7 +15,6 @@ import java.util.ArrayList; import java.util.Collections; -import java.util.EnumMap; import java.util.List; import java.util.Map; import java.util.concurrent.CancellationException; @@ -39,37 +38,9 @@ public interface DataSourceFactory { T build(); } - /** - * A resolved mode definition holding factories that are ready to use (already bound to - * their ClientContext). Produced by FDv2DataSourceBuilder from ComponentConfigurer entries. - */ - static final class ResolvedModeDefinition { - private final List> initializers; - private final List> synchronizers; - - ResolvedModeDefinition( - @NonNull List> initializers, - @NonNull List> synchronizers - ) { - this.initializers = Collections.unmodifiableList(new ArrayList<>(initializers)); - this.synchronizers = Collections.unmodifiableList(new ArrayList<>(synchronizers)); - } - - @NonNull - List> getInitializers() { - return initializers; - } - - @NonNull - List> getSynchronizers() { - return synchronizers; - } - } - private final LDLogger logger; private final LDContext evaluationContext; private final DataSourceUpdateSinkV2 dataSourceUpdateSink; - private final Map modeTable; private volatile SourceManager sourceManager; private final long fallbackTimeoutSeconds; private final long recoveryTimeoutSeconds; @@ -129,7 +100,6 @@ List> getSynchronizers() { this.evaluationContext = evaluationContext; this.dataSourceUpdateSink = dataSourceUpdateSink; this.logger = logger; - this.modeTable = null; List synchronizerFactoriesWithState = new ArrayList<>(); for (DataSourceFactory factory : synchronizers) { synchronizerFactoriesWithState.add(new SynchronizerFactoryWithState(factory)); @@ -140,74 +110,6 @@ List> getSynchronizers() { this.sharedExecutor = sharedExecutor; } - /** - * Mode-aware convenience constructor using default fallback and recovery timeouts. - * - * @param evaluationContext the context to evaluate flags for - * @param modeTable resolved mode definitions keyed by ConnectionMode - * @param startingMode the initial connection mode - * @param dataSourceUpdateSink sink to apply changesets and status updates to - * @param sharedExecutor executor used for internal background tasks - * @param logger logger - */ - FDv2DataSource( - @NonNull LDContext evaluationContext, - @NonNull Map modeTable, - @NonNull ConnectionMode startingMode, - @NonNull DataSourceUpdateSinkV2 dataSourceUpdateSink, - @NonNull ScheduledExecutorService sharedExecutor, - @NonNull LDLogger logger - ) { - this(evaluationContext, modeTable, startingMode, dataSourceUpdateSink, sharedExecutor, logger, - FDv2DataSourceConditions.DEFAULT_FALLBACK_TIMEOUT_SECONDS, - FDv2DataSourceConditions.DEFAULT_RECOVERY_TIMEOUT_SECONDS); - } - - /** - * Mode-aware constructor. The mode table maps each {@link ConnectionMode} to a - * {@link ResolvedModeDefinition} containing pre-built factories. The starting mode - * determines the initial set of initializers and synchronizers. - * - * @param evaluationContext the context to evaluate flags for - * @param modeTable resolved mode definitions keyed by ConnectionMode - * @param startingMode the initial connection mode - * @param dataSourceUpdateSink sink to apply changesets and status updates to - * @param sharedExecutor executor used for internal background tasks; must have - * at least 2 threads - * @param logger logger - * @param fallbackTimeoutSeconds seconds of INTERRUPTED state before falling back - * @param recoveryTimeoutSeconds seconds before attempting to recover to the primary - * synchronizer - */ - FDv2DataSource( - @NonNull LDContext evaluationContext, - @NonNull Map modeTable, - @NonNull ConnectionMode startingMode, - @NonNull DataSourceUpdateSinkV2 dataSourceUpdateSink, - @NonNull ScheduledExecutorService sharedExecutor, - @NonNull LDLogger logger, - long fallbackTimeoutSeconds, - long recoveryTimeoutSeconds - ) { - this.evaluationContext = evaluationContext; - this.dataSourceUpdateSink = dataSourceUpdateSink; - this.logger = logger; - this.modeTable = Collections.unmodifiableMap(new EnumMap<>(modeTable)); - this.fallbackTimeoutSeconds = fallbackTimeoutSeconds; - this.recoveryTimeoutSeconds = recoveryTimeoutSeconds; - this.sharedExecutor = sharedExecutor; - - ResolvedModeDefinition startDef = modeTable.get(startingMode); - if (startDef == null) { - throw new IllegalArgumentException("No mode definition for starting mode: " + startingMode); - } - List syncFactories = new ArrayList<>(); - for (DataSourceFactory factory : startDef.getSynchronizers()) { - syncFactories.add(new SynchronizerFactoryWithState(factory)); - } - this.sourceManager = new SourceManager(syncFactories, new ArrayList<>(startDef.getInitializers())); - } - @Override public void start(@NonNull Callback resultCallback) { synchronized (startResultLock) { @@ -235,20 +137,21 @@ public void start(@NonNull Callback resultCallback) { // race with a concurrent stop() and could undo it, causing a spurious OFF/exhaustion report. LDContext context = evaluationContext; + final SourceManager sm = sourceManager; sharedExecutor.execute(() -> { try { - if (!sourceManager.hasAvailableSources()) { + if (!sm.hasAvailableSources()) { logger.info("No initializers or synchronizers; data source will not connect."); dataSourceUpdateSink.setStatus(DataSourceState.VALID, null); tryCompleteStart(true, null); return; } - if (sourceManager.hasInitializers()) { + if (sm.hasInitializers()) { runInitializers(context, dataSourceUpdateSink); } - if (!sourceManager.hasAvailableSynchronizers()) { + if (!sm.hasAvailableSynchronizers()) { if (!startCompleted.get()) { maybeReportUnexpectedExhaustion("All initializers exhausted and there are no available synchronizers."); } @@ -256,10 +159,9 @@ public void start(@NonNull Callback resultCallback) { return; } - SourceManager sm = sourceManager; - runSynchronizers(sm, context, dataSourceUpdateSink); - // Only report exhaustion if the SourceManager was NOT replaced by a - // concurrent switchMode() call; a mode switch is not an error. + runSynchronizers(context, dataSourceUpdateSink, sm); + // Only report exhaustion if this SourceManager is still the active one + // (a concurrent switchMode() may have replaced it). if (sourceManager == sm) { maybeReportUnexpectedExhaustion("All data source acquisition methods have been exhausted."); } @@ -319,68 +221,29 @@ public void stop(@NonNull Callback completionCallback) { @Override public boolean needsRefresh(boolean newInBackground, @NonNull LDContext newEvaluationContext) { - // Mode-aware data sources handle foreground/background transitions via switchMode(), - // so only a context change requires a full teardown/rebuild (to re-run initializers). - return !newEvaluationContext.equals(evaluationContext); + // Mode-aware data sources handle background/foreground transitions via switchMode(), + // so only request a full rebuild when the evaluation context changes. + return !evaluationContext.equals(newEvaluationContext); } - /** - * Switches to a new connection mode by tearing down the current synchronizers and - * starting the new mode's synchronizers on the background executor. Initializers are - * NOT re-run (spec CONNMODE 2.0.1). - *

- * Expected to be called from a single thread (ConnectivityManager's listener). The - * field swap is not atomic; concurrent calls from multiple threads could leave an - * intermediate SourceManager unclosed. - */ @Override - public void switchMode(@NonNull ConnectionMode newMode) { - if (modeTable == null) { - logger.warn("switchMode({}) called but no mode table configured", newMode); - return; - } - if (stopped.get()) { - return; - } - ResolvedModeDefinition def = modeTable.get(newMode); - if (def == null) { - logger.error("switchMode({}) failed: no definition found", newMode); - return; - } - - // Build new SourceManager with the mode's synchronizer factories. - // Initializers are NOT included — spec 2.0.1: mode switch does not re-run initializers. - List syncFactories = new ArrayList<>(); - for (DataSourceFactory factory : def.getSynchronizers()) { - syncFactories.add(new SynchronizerFactoryWithState(factory)); + public void switchMode(@NonNull ResolvedModeDefinition newDefinition) { + List newSyncFactories = new ArrayList<>(); + for (DataSourceFactory factory : newDefinition.getSynchronizerFactories()) { + newSyncFactories.add(new SynchronizerFactoryWithState(factory)); } + // Per CONNMODE 2.0.1: mode switches only transition synchronizers, no initializers. SourceManager newManager = new SourceManager( - syncFactories, Collections.>emptyList()); - - // Swap the source manager and close the old one to interrupt its active source. + newSyncFactories, + Collections.>emptyList() + ); SourceManager oldManager = sourceManager; sourceManager = newManager; if (oldManager != null) { oldManager.close(); } - - // Run the new mode's synchronizers on the background thread. - LDContext context = evaluationContext; sharedExecutor.execute(() -> { - try { - if (!newManager.hasAvailableSynchronizers()) { - logger.debug("Mode {} has no synchronizers; data source idle", newMode); - return; - } - runSynchronizers(newManager, context, dataSourceUpdateSink); - // Report exhaustion only if we weren't replaced by another switchMode(). - if (sourceManager == newManager && !stopped.get()) { - maybeReportUnexpectedExhaustion( - "All synchronizers exhausted after mode switch to " + newMode); - } - } catch (Throwable t) { - logger.warn("FDv2DataSource error after mode switch to {}: {}", newMode, t.toString()); - } + runSynchronizers(evaluationContext, dataSourceUpdateSink, newManager); }); } @@ -464,9 +327,9 @@ private List getConditions(int synchronizerC } private void runSynchronizers( - @NonNull SourceManager sm, @NonNull LDContext context, - @NonNull DataSourceUpdateSinkV2 sink + @NonNull DataSourceUpdateSinkV2 sink, + @NonNull SourceManager sm ) { try { Synchronizer synchronizer = sm.getNextAvailableSynchronizerAndSetActive(); diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilder.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilder.java index 7cb5c7d8..1b786dc5 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilder.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilder.java @@ -1,236 +1,244 @@ package com.launchdarkly.sdk.android; import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import com.launchdarkly.logging.LDLogger; -import com.launchdarkly.sdk.LDContext; -import com.launchdarkly.sdk.android.integrations.PollingDataSourceBuilder; -import com.launchdarkly.sdk.android.integrations.StreamingDataSourceBuilder; import com.launchdarkly.sdk.android.subsystems.ClientContext; import com.launchdarkly.sdk.android.subsystems.ComponentConfigurer; import com.launchdarkly.sdk.android.subsystems.DataSource; +import com.launchdarkly.sdk.android.subsystems.DataSourceUpdateSink; import com.launchdarkly.sdk.android.subsystems.DataSourceUpdateSinkV2; import com.launchdarkly.sdk.android.subsystems.Initializer; import com.launchdarkly.sdk.android.subsystems.Synchronizer; +import com.launchdarkly.sdk.android.integrations.PollingDataSourceBuilder; +import com.launchdarkly.sdk.android.integrations.StreamingDataSourceBuilder; import com.launchdarkly.sdk.android.subsystems.TransactionalDataStore; -import com.launchdarkly.sdk.internal.events.DiagnosticStore; import com.launchdarkly.sdk.internal.http.HttpProperties; import java.net.URI; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; -import java.util.EnumMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; /** - * Builds a mode-aware {@link FDv2DataSource} from either a custom {@link ModeDefinition} table - * or the built-in default mode definitions. - *

- * When no custom table is supplied, the builder creates concrete {@link FDv2PollingInitializer}, - * {@link FDv2PollingSynchronizer}, and {@link FDv2StreamingSynchronizer} factories using - * dependencies extracted from the {@link ClientContext}. Shared dependencies (executor, - * {@link SelectorSource}) are created once and captured by all factory closures. Each factory - * call creates fresh instances of requestors and concrete sources to ensure proper lifecycle - * management. + * Builds an {@link FDv2DataSource} and resolves the mode table from + * {@link ComponentConfigurer} factories into zero-arg {@link FDv2DataSource.DataSourceFactory} + * instances. The resolved table is stored and exposed via {@link #getResolvedModeTable()} + * so that {@link ConnectivityManager} can perform mode→definition lookups when switching modes. *

- * When a custom table is supplied (for testing), each {@link ComponentConfigurer} is resolved - * into a {@link FDv2DataSource.DataSourceFactory} by partially applying the - * {@link ClientContext}. + * This is the key architectural difference in Approach 2: the builder owns the resolved + * table rather than the data source itself. *

* Package-private — not part of the public SDK API. - * - * @see ModeDefinition - * @see FDv2DataSource.ResolvedModeDefinition */ -final class FDv2DataSourceBuilder implements ComponentConfigurer { +class FDv2DataSourceBuilder implements ComponentConfigurer { - @Nullable private final Map modeTable; private final ConnectionMode startingMode; - /** - * Creates a builder using the built-in default mode definitions and - * {@link ConnectionMode#STREAMING} as the starting mode. - */ + private Map resolvedModeTable; + FDv2DataSourceBuilder() { - this(null, ConnectionMode.STREAMING); + this(makeDefaultModeTable(), ConnectionMode.STREAMING); + } + + private static Map makeDefaultModeTable() { + ComponentConfigurer pollingInitializer = ctx -> { + ClientContextImpl impl = ClientContextImpl.get(ctx); + TransactionalDataStore store = impl.getTransactionalDataStore(); + SelectorSource selectorSource = store != null + ? new SelectorSourceFacade(store) + : () -> com.launchdarkly.sdk.fdv2.Selector.EMPTY; + URI pollingBase = StandardEndpoints.selectBaseUri( + ctx.getServiceEndpoints().getPollingBaseUri(), + StandardEndpoints.DEFAULT_POLLING_BASE_URI, + "polling", ctx.getBaseLogger()); + HttpProperties httpProps = LDUtil.makeHttpProperties(ctx); + FDv2Requestor requestor = new DefaultFDv2Requestor( + ctx.getEvaluationContext(), pollingBase, + StandardEndpoints.FDV2_POLLING_REQUEST_GET_BASE_PATH, + StandardEndpoints.FDV2_POLLING_REQUEST_REPORT_BASE_PATH, + httpProps, ctx.getHttp().isUseReport(), + ctx.isEvaluationReasons(), null, ctx.getBaseLogger()); + return new FDv2PollingInitializer(requestor, selectorSource, + Executors.newSingleThreadExecutor(), ctx.getBaseLogger()); + }; + + ComponentConfigurer pollingSynchronizer = ctx -> { + ClientContextImpl impl = ClientContextImpl.get(ctx); + TransactionalDataStore store = impl.getTransactionalDataStore(); + SelectorSource selectorSource = store != null + ? new SelectorSourceFacade(store) + : () -> com.launchdarkly.sdk.fdv2.Selector.EMPTY; + URI pollingBase = StandardEndpoints.selectBaseUri( + ctx.getServiceEndpoints().getPollingBaseUri(), + StandardEndpoints.DEFAULT_POLLING_BASE_URI, + "polling", ctx.getBaseLogger()); + HttpProperties httpProps = LDUtil.makeHttpProperties(ctx); + FDv2Requestor requestor = new DefaultFDv2Requestor( + ctx.getEvaluationContext(), pollingBase, + StandardEndpoints.FDV2_POLLING_REQUEST_GET_BASE_PATH, + StandardEndpoints.FDV2_POLLING_REQUEST_REPORT_BASE_PATH, + httpProps, ctx.getHttp().isUseReport(), + ctx.isEvaluationReasons(), null, ctx.getBaseLogger()); + ScheduledExecutorService exec = Executors.newScheduledThreadPool(1); + return new FDv2PollingSynchronizer(requestor, selectorSource, exec, + 0, PollingDataSourceBuilder.DEFAULT_POLL_INTERVAL_MILLIS, ctx.getBaseLogger()); + }; + + ComponentConfigurer streamingSynchronizer = ctx -> { + ClientContextImpl impl = ClientContextImpl.get(ctx); + TransactionalDataStore store = impl.getTransactionalDataStore(); + SelectorSource selectorSource = store != null + ? new SelectorSourceFacade(store) + : () -> com.launchdarkly.sdk.fdv2.Selector.EMPTY; + URI streamBase = StandardEndpoints.selectBaseUri( + ctx.getServiceEndpoints().getStreamingBaseUri(), + StandardEndpoints.DEFAULT_STREAMING_BASE_URI, + "streaming", ctx.getBaseLogger()); + URI pollingBase = StandardEndpoints.selectBaseUri( + ctx.getServiceEndpoints().getPollingBaseUri(), + StandardEndpoints.DEFAULT_POLLING_BASE_URI, + "polling", ctx.getBaseLogger()); + HttpProperties httpProps = LDUtil.makeHttpProperties(ctx); + FDv2Requestor requestor = new DefaultFDv2Requestor( + ctx.getEvaluationContext(), pollingBase, + StandardEndpoints.FDV2_POLLING_REQUEST_GET_BASE_PATH, + StandardEndpoints.FDV2_POLLING_REQUEST_REPORT_BASE_PATH, + httpProps, ctx.getHttp().isUseReport(), + ctx.isEvaluationReasons(), null, ctx.getBaseLogger()); + return new FDv2StreamingSynchronizer( + ctx.getEvaluationContext(), selectorSource, streamBase, + StandardEndpoints.FDV2_STREAMING_REQUEST_BASE_PATH, + requestor, + StreamingDataSourceBuilder.DEFAULT_INITIAL_RECONNECT_DELAY_MILLIS, + ctx.isEvaluationReasons(), ctx.getHttp().isUseReport(), + httpProps, Executors.newSingleThreadExecutor(), + ctx.getBaseLogger(), null); + }; + + ComponentConfigurer backgroundPollingSynchronizer = ctx -> { + ClientContextImpl impl = ClientContextImpl.get(ctx); + TransactionalDataStore store = impl.getTransactionalDataStore(); + SelectorSource selectorSource = store != null + ? new SelectorSourceFacade(store) + : () -> com.launchdarkly.sdk.fdv2.Selector.EMPTY; + URI pollingBase = StandardEndpoints.selectBaseUri( + ctx.getServiceEndpoints().getPollingBaseUri(), + StandardEndpoints.DEFAULT_POLLING_BASE_URI, + "polling", ctx.getBaseLogger()); + HttpProperties httpProps = LDUtil.makeHttpProperties(ctx); + FDv2Requestor requestor = new DefaultFDv2Requestor( + ctx.getEvaluationContext(), pollingBase, + StandardEndpoints.FDV2_POLLING_REQUEST_GET_BASE_PATH, + StandardEndpoints.FDV2_POLLING_REQUEST_REPORT_BASE_PATH, + httpProps, ctx.getHttp().isUseReport(), + ctx.isEvaluationReasons(), null, ctx.getBaseLogger()); + ScheduledExecutorService exec = Executors.newScheduledThreadPool(1); + return new FDv2PollingSynchronizer(requestor, selectorSource, exec, + 0, LDConfig.DEFAULT_BACKGROUND_POLL_INTERVAL_MILLIS, ctx.getBaseLogger()); + }; + + Map table = new LinkedHashMap<>(); + table.put(ConnectionMode.STREAMING, new ModeDefinition( + Arrays.asList(pollingInitializer, pollingInitializer), + Arrays.asList(streamingSynchronizer, pollingSynchronizer) + )); + table.put(ConnectionMode.POLLING, new ModeDefinition( + Collections.singletonList(pollingInitializer), + Collections.singletonList(pollingSynchronizer) + )); + table.put(ConnectionMode.OFFLINE, new ModeDefinition( + Collections.singletonList(pollingInitializer), + Collections.>emptyList() + )); + table.put(ConnectionMode.ONE_SHOT, new ModeDefinition( + Arrays.asList(pollingInitializer, pollingInitializer, pollingInitializer), + Collections.>emptyList() + )); + table.put(ConnectionMode.BACKGROUND, new ModeDefinition( + Collections.singletonList(pollingInitializer), + Collections.singletonList(backgroundPollingSynchronizer) + )); + return table; } - /** - * @param modeTable custom mode definitions to resolve at build time, or {@code null} - * to use the built-in defaults - * @param startingMode the initial connection mode for the data source - */ FDv2DataSourceBuilder( - @Nullable Map modeTable, + @NonNull Map modeTable, @NonNull ConnectionMode startingMode ) { - this.modeTable = modeTable != null - ? Collections.unmodifiableMap(new EnumMap<>(modeTable)) - : null; + this.modeTable = Collections.unmodifiableMap(new LinkedHashMap<>(modeTable)); this.startingMode = startingMode; } - @Override - public DataSource build(ClientContext clientContext) { - // TODO: executor lifecycle — FDv2DataSource does not shut down its executor. - // In a future commit, this should be replaced with an executor obtained from - // ClientContextImpl or managed by ConnectivityManager. - ScheduledExecutorService executor = Executors.newScheduledThreadPool(2); - - Map resolved; - if (modeTable != null) { - resolved = resolveCustomModeTable(clientContext); - } else { - resolved = buildDefaultModeTable(clientContext, executor); - } - - DataSourceUpdateSinkV2 sinkV2 = - (DataSourceUpdateSinkV2) clientContext.getDataSourceUpdateSink(); - - return new FDv2DataSource( - clientContext.getEvaluationContext(), - resolved, - startingMode, - sinkV2, - executor, - clientContext.getBaseLogger() - ); - } - /** - * Builds the default mode table with real factories. Shared dependencies are created - * once and captured by factory closures; each factory call creates fresh instances of - * requestors and concrete sources. + * Returns the resolved mode table after {@link #build} has been called. + * Each entry maps a {@link ConnectionMode} to a {@link ResolvedModeDefinition} + * containing zero-arg factories that capture the {@link ClientContext}. + * + * @return unmodifiable map of resolved mode definitions + * @throws IllegalStateException if called before {@link #build} */ - private Map buildDefaultModeTable( - ClientContext clientContext, - ScheduledExecutorService executor - ) { - ClientContextImpl impl = ClientContextImpl.get(clientContext); - HttpProperties httpProperties = LDUtil.makeHttpProperties(clientContext); - LDContext evalContext = clientContext.getEvaluationContext(); - LDLogger logger = clientContext.getBaseLogger(); - boolean useReport = clientContext.getHttp().isUseReport(); - boolean evaluationReasons = clientContext.isEvaluationReasons(); - URI pollingBaseUri = clientContext.getServiceEndpoints().getPollingBaseUri(); - URI streamingBaseUri = clientContext.getServiceEndpoints().getStreamingBaseUri(); - DiagnosticStore diagnosticStore = impl.getDiagnosticStore(); - TransactionalDataStore txnStore = impl.getTransactionalDataStore(); - SelectorSource selectorSource = new SelectorSourceFacade(txnStore); - - // Each factory creates a fresh requestor so that lifecycle (close/shutdown) is isolated - // per initializer/synchronizer instance. - FDv2DataSource.DataSourceFactory pollingInitFactory = () -> - new FDv2PollingInitializer( - newRequestor(evalContext, pollingBaseUri, httpProperties, - useReport, evaluationReasons, logger), - selectorSource, executor, logger); - - FDv2DataSource.DataSourceFactory streamingSyncFactory = () -> - new FDv2StreamingSynchronizer( - httpProperties, streamingBaseUri, - StandardEndpoints.FDV2_STREAMING_REQUEST_BASE_PATH, - evalContext, useReport, evaluationReasons, selectorSource, - newRequestor(evalContext, pollingBaseUri, httpProperties, - useReport, evaluationReasons, logger), - StreamingDataSourceBuilder.DEFAULT_INITIAL_RECONNECT_DELAY_MILLIS, - diagnosticStore, logger); - - FDv2DataSource.DataSourceFactory foregroundPollSyncFactory = () -> - new FDv2PollingSynchronizer( - newRequestor(evalContext, pollingBaseUri, httpProperties, - useReport, evaluationReasons, logger), - selectorSource, executor, - 0, PollingDataSourceBuilder.DEFAULT_POLL_INTERVAL_MILLIS, logger); - - FDv2DataSource.DataSourceFactory backgroundPollSyncFactory = () -> - new FDv2PollingSynchronizer( - newRequestor(evalContext, pollingBaseUri, httpProperties, - useReport, evaluationReasons, logger), - selectorSource, executor, - 0, LDConfig.DEFAULT_BACKGROUND_POLL_INTERVAL_MILLIS, logger); - - Map resolved = - new EnumMap<>(ConnectionMode.class); - - // STREAMING: poll once for initial data, then stream (with polling fallback) - resolved.put(ConnectionMode.STREAMING, new FDv2DataSource.ResolvedModeDefinition( - Collections.singletonList(pollingInitFactory), - Arrays.asList(streamingSyncFactory, foregroundPollSyncFactory))); - - // POLLING: poll once for initial data, then poll periodically - resolved.put(ConnectionMode.POLLING, new FDv2DataSource.ResolvedModeDefinition( - Collections.singletonList(pollingInitFactory), - Collections.singletonList(foregroundPollSyncFactory))); - - // OFFLINE: no network activity - resolved.put(ConnectionMode.OFFLINE, new FDv2DataSource.ResolvedModeDefinition( - Collections.>emptyList(), - Collections.>emptyList())); - - // ONE_SHOT: poll once, then stop - resolved.put(ConnectionMode.ONE_SHOT, new FDv2DataSource.ResolvedModeDefinition( - Collections.singletonList(pollingInitFactory), - Collections.>emptyList())); - - // BACKGROUND: poll at reduced frequency (no re-initialization) - resolved.put(ConnectionMode.BACKGROUND, new FDv2DataSource.ResolvedModeDefinition( - Collections.>emptyList(), - Collections.singletonList(backgroundPollSyncFactory))); - - return resolved; + @NonNull + ConnectionMode getStartingMode() { + return startingMode; } - /** - * Resolves a custom {@link ModeDefinition} table by wrapping each {@link ComponentConfigurer} - * in a {@link FDv2DataSource.DataSourceFactory} that defers to - * {@code configurer.build(clientContext)}. - */ - private Map resolveCustomModeTable( - ClientContext clientContext - ) { - Map resolved = - new EnumMap<>(ConnectionMode.class); + @NonNull + Map getResolvedModeTable() { + if (resolvedModeTable == null) { + throw new IllegalStateException("build() must be called before getResolvedModeTable()"); + } + return resolvedModeTable; + } + @Override + public DataSource build(ClientContext clientContext) { + Map resolved = new LinkedHashMap<>(); for (Map.Entry entry : modeTable.entrySet()) { - ModeDefinition def = entry.getValue(); - - List> initFactories = new ArrayList<>(); - for (ComponentConfigurer configurer : def.getInitializers()) { - initFactories.add(() -> configurer.build(clientContext)); - } + resolved.put(entry.getKey(), resolve(entry.getValue(), clientContext)); + } + this.resolvedModeTable = Collections.unmodifiableMap(resolved); - List> syncFactories = new ArrayList<>(); - for (ComponentConfigurer configurer : def.getSynchronizers()) { - syncFactories.add(() -> configurer.build(clientContext)); - } + ResolvedModeDefinition startDef = resolvedModeTable.get(startingMode); + if (startDef == null) { + throw new IllegalStateException( + "Starting mode " + startingMode + " not found in mode table"); + } - resolved.put(entry.getKey(), new FDv2DataSource.ResolvedModeDefinition( - initFactories, syncFactories)); + DataSourceUpdateSink baseSink = clientContext.getDataSourceUpdateSink(); + if (!(baseSink instanceof DataSourceUpdateSinkV2)) { + throw new IllegalStateException( + "FDv2DataSource requires a DataSourceUpdateSinkV2 implementation"); } - return resolved; + ScheduledExecutorService sharedExecutor = Executors.newScheduledThreadPool(2); + + return new FDv2DataSource( + clientContext.getEvaluationContext(), + startDef.getInitializerFactories(), + startDef.getSynchronizerFactories(), + (DataSourceUpdateSinkV2) baseSink, + sharedExecutor, + clientContext.getBaseLogger() + ); } - private static DefaultFDv2Requestor newRequestor( - LDContext evalContext, - URI pollingBaseUri, - HttpProperties httpProperties, - boolean useReport, - boolean evaluationReasons, - LDLogger logger + private static ResolvedModeDefinition resolve( + ModeDefinition def, ClientContext clientContext ) { - return new DefaultFDv2Requestor( - evalContext, pollingBaseUri, - StandardEndpoints.FDV2_POLLING_REQUEST_GET_BASE_PATH, - StandardEndpoints.FDV2_POLLING_REQUEST_REPORT_BASE_PATH, - httpProperties, useReport, evaluationReasons, - null, logger); + List> initFactories = new ArrayList<>(); + for (ComponentConfigurer configurer : def.getInitializers()) { + initFactories.add(() -> configurer.build(clientContext)); + } + List> syncFactories = new ArrayList<>(); + for (ComponentConfigurer configurer : def.getSynchronizers()) { + syncFactories.add(() -> configurer.build(clientContext)); + } + return new ResolvedModeDefinition(initFactories, syncFactories); } } diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeAware.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeAware.java index 14825583..d4902f33 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeAware.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeAware.java @@ -10,19 +10,26 @@ * {@link ConnectivityManager} checks {@code instanceof ModeAware} to decide * whether to use mode resolution (FDv2) or legacy teardown/rebuild behavior (FDv1). *

+ * In this approach (Approach 2), the data source receives the full + * {@link ResolvedModeDefinition} — it has no internal mode table and does not + * know which named {@link ConnectionMode} it is operating in. The mode table + * and mode-to-definition lookup live in {@link ConnectivityManager}. + *

* Package-private — not part of the public SDK API. * - * @see ConnectionMode + * @see ResolvedModeDefinition * @see ModeResolutionTable */ interface ModeAware extends DataSource { /** - * Switches the data source to the specified connection mode. The implementation - * stops the current synchronizers and starts the new mode's synchronizers without - * re-running initializers (per CONNMODE spec 2.0.1). + * Switches the data source to operate with the given mode definition. + * The implementation stops the current synchronizers and starts the new + * definition's synchronizers without re-running initializers + * (per CONNMODE spec 2.0.1). * - * @param newMode the target connection mode + * @param newDefinition the resolved initializer/synchronizer factories for + * the target mode */ - void switchMode(@NonNull ConnectionMode newMode); + void switchMode(@NonNull ResolvedModeDefinition newDefinition); } diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeDefinition.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeDefinition.java index cd33312f..81d69b8f 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeDefinition.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeDefinition.java @@ -10,7 +10,7 @@ import java.util.List; /** - * Defines the initializer and synchronizer pipelines for a {@link ConnectionMode}. + * Defines the initializers and synchronizers for a single {@link ConnectionMode}. * Each instance is a pure data holder — it stores {@link ComponentConfigurer} factories * but does not create any concrete initializer or synchronizer objects. *

@@ -21,6 +21,7 @@ * Package-private — not part of the public SDK API. * * @see ConnectionMode + * @see ResolvedModeDefinition */ final class ModeDefinition { diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeResolutionEntry.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeResolutionEntry.java index 9b3a1c49..80fd1982 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeResolutionEntry.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeResolutionEntry.java @@ -3,23 +3,21 @@ import androidx.annotation.NonNull; /** - * A single entry in a {@link ModeResolutionTable}. Pairs a condition with a - * target {@link ConnectionMode}. If {@link Condition#test(ModeState)} returns - * {@code true} for a given {@link ModeState}, this entry's {@code mode} is the - * resolved result. - *

- * When user-configurable mode selection is added, {@code mode} can be replaced - * with a resolver function to support indirection (e.g., returning a - * user-configured foreground mode from {@code ModeState}). + * A single entry in a {@link ModeResolutionTable}. Pairs a {@link Condition} + * predicate with the {@link ConnectionMode} that should be activated when the + * condition matches the current {@link ModeState}. *

* Package-private — not part of the public SDK API. + * + * @see ModeResolutionTable + * @see ModeState */ final class ModeResolutionEntry { /** - * Functional interface for evaluating whether a {@link ModeResolutionEntry} - * matches a given {@link ModeState}. Defined here to avoid a dependency on - * {@code java.util.function.Predicate} (requires API 24+; SDK minimum is 21). + * Functional interface for evaluating a {@link ModeState} against a condition. + * Defined here (rather than using {@code java.util.function.Predicate}) because + * {@code Predicate} requires API 24+ and the SDK targets minSdk 21. */ interface Condition { boolean test(@NonNull ModeState state); @@ -28,10 +26,7 @@ interface Condition { private final Condition conditions; private final ConnectionMode mode; - ModeResolutionEntry( - @NonNull Condition conditions, - @NonNull ConnectionMode mode - ) { + ModeResolutionEntry(@NonNull Condition conditions, @NonNull ConnectionMode mode) { this.conditions = conditions; this.mode = mode; } diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeState.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeState.java index f3942927..1450f052 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeState.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeState.java @@ -1,14 +1,15 @@ package com.launchdarkly.sdk.android; /** - * Snapshot of platform state used as input to {@link ModeResolutionTable#resolve(ModeState)}. + * Snapshot of the current platform state used as input to + * {@link ModeResolutionTable#resolve(ModeState)}. *

- * In this initial implementation, {@code ModeState} carries only platform state with - * hardcoded Android defaults for foreground/background modes. When user-configurable - * mode selection is added (CONNMODE 2.2.2), {@code foregroundMode} and - * {@code backgroundMode} fields will be introduced here. + * Immutable value object — all fields are set in the constructor with no setters. *

* Package-private — not part of the public SDK API. + * + * @see ModeResolutionTable + * @see ModeResolutionEntry */ final class ModeState { diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ResolvedModeDefinition.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ResolvedModeDefinition.java new file mode 100644 index 00000000..fccffc7a --- /dev/null +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ResolvedModeDefinition.java @@ -0,0 +1,48 @@ +package com.launchdarkly.sdk.android; + +import androidx.annotation.NonNull; + +import com.launchdarkly.sdk.android.subsystems.Initializer; +import com.launchdarkly.sdk.android.subsystems.Synchronizer; + +import java.util.Collections; +import java.util.List; + +/** + * A fully resolved mode definition containing zero-arg factories for initializers + * and synchronizers. This is the result of resolving a {@link ModeDefinition}'s + * {@link com.launchdarkly.sdk.android.subsystems.ComponentConfigurer} entries against + * a {@link com.launchdarkly.sdk.android.subsystems.ClientContext}. + *

+ * Instances are immutable and created by {@code FDv2DataSourceBuilder} at build time. + * {@link ConnectivityManager} passes these to {@link ModeAware#switchMode} when the + * resolved connection mode changes. + *

+ * Package-private — not part of the public SDK API. + * + * @see ModeDefinition + * @see ModeAware + */ +final class ResolvedModeDefinition { + + private final List> initializerFactories; + private final List> synchronizerFactories; + + ResolvedModeDefinition( + @NonNull List> initializerFactories, + @NonNull List> synchronizerFactories + ) { + this.initializerFactories = Collections.unmodifiableList(initializerFactories); + this.synchronizerFactories = Collections.unmodifiableList(synchronizerFactories); + } + + @NonNull + List> getInitializerFactories() { + return initializerFactories; + } + + @NonNull + List> getSynchronizerFactories() { + return synchronizerFactories; + } +} diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/StandardEndpoints.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/StandardEndpoints.java index 2f2e60bd..06c51e32 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/StandardEndpoints.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/StandardEndpoints.java @@ -14,14 +14,12 @@ private StandardEndpoints() {} static final String STREAMING_REQUEST_BASE_PATH = "/meval"; static final String POLLING_REQUEST_GET_BASE_PATH = "/msdk/evalx/contexts"; static final String POLLING_REQUEST_REPORT_BASE_PATH = "/msdk/evalx/context"; + static final String ANALYTICS_EVENTS_REQUEST_PATH = "/mobile/events/bulk"; + static final String DIAGNOSTIC_EVENTS_REQUEST_PATH = "/mobile/events/diagnostic"; - // FDv2 paths per CSFDV2 Requirement 2.1.1 (unified for all client-side platforms). - // Context is appended as a base64 path segment for GET, or sent in the request body for REPORT/POST. static final String FDV2_POLLING_REQUEST_GET_BASE_PATH = "/sdk/poll/eval"; static final String FDV2_POLLING_REQUEST_REPORT_BASE_PATH = "/sdk/poll/eval"; static final String FDV2_STREAMING_REQUEST_BASE_PATH = "/sdk/stream/eval"; - static final String ANALYTICS_EVENTS_REQUEST_PATH = "/mobile/events/bulk"; - static final String DIAGNOSTIC_EVENTS_REQUEST_PATH = "/mobile/events/diagnostic"; /** * Internal method to decide which URI a given component should connect to. diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ConnectivityManagerTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ConnectivityManagerTest.java index 1697e5c9..f9c73606 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ConnectivityManagerTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ConnectivityManagerTest.java @@ -37,8 +37,15 @@ import org.junit.Test; import org.junit.rules.Timeout; +import com.launchdarkly.sdk.android.subsystems.DataSourceState; +import com.launchdarkly.sdk.android.subsystems.Initializer; +import com.launchdarkly.sdk.android.subsystems.Synchronizer; + import java.io.IOException; +import java.util.Collections; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.concurrent.BlockingQueue; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutionException; @@ -658,47 +665,38 @@ private void verifyNoMoreDataSourcesWereStopped() { requireNoMoreValues(stoppedDataSources, 1, TimeUnit.SECONDS, "stopping of data source"); } - // --- ModeAware (FDv2) tests --- + // ==== ModeAware tests ==== /** - * A minimal {@link ModeAware} data source that tracks {@code switchMode()} calls. - * Mimics the threading behavior of real data sources by calling the start callback - * on a background thread after reporting initial status. + * A mock ModeAware data source that records switchMode calls and + * signals start success immediately. */ private static class MockModeAwareDataSource implements ModeAware { - final BlockingQueue switchModeCalls = - new LinkedBlockingQueue<>(); - final BlockingQueue startedQueue; - final BlockingQueue stoppedQueue; - final ClientContext clientContext; - - MockModeAwareDataSource( - ClientContext clientContext, - BlockingQueue startedQueue, - BlockingQueue stoppedQueue - ) { - this.clientContext = clientContext; + final BlockingQueue switchModeCalls = new LinkedBlockingQueue<>(); + private final BlockingQueue startedQueue; + private volatile com.launchdarkly.sdk.android.subsystems.DataSourceUpdateSink sink; + + MockModeAwareDataSource(BlockingQueue startedQueue) { this.startedQueue = startedQueue; - this.stoppedQueue = stoppedQueue; + } + + void setSink(com.launchdarkly.sdk.android.subsystems.DataSourceUpdateSink sink) { + this.sink = sink; } @Override public void start(@NonNull Callback resultCallback) { - if (startedQueue != null) { - startedQueue.add(this); - } + startedQueue.add(this); new Thread(() -> { - clientContext.getDataSourceUpdateSink().setStatus( - ConnectionMode.STREAMING, null); + if (sink != null) { + sink.setStatus(ConnectionMode.STREAMING, null); + } resultCallback.onSuccess(true); }).start(); } @Override public void stop(@NonNull Callback completionCallback) { - if (stoppedQueue != null) { - stoppedQueue.add(this); - } completionCallback.onSuccess(null); } @@ -708,261 +706,191 @@ public boolean needsRefresh(boolean newInBackground, @NonNull LDContext newEvalu } @Override - public void switchMode(@NonNull com.launchdarkly.sdk.android.ConnectionMode newMode) { - switchModeCalls.add(newMode); - } - - com.launchdarkly.sdk.android.ConnectionMode requireSwitchMode() { - return requireValue(switchModeCalls, 1, TimeUnit.SECONDS, - "switchMode call"); - } - - void requireNoMoreSwitchModeCalls() { - requireNoMoreValues(switchModeCalls, 100, TimeUnit.MILLISECONDS, - "unexpected switchMode call"); + public void switchMode(@NonNull ResolvedModeDefinition newDefinition) { + switchModeCalls.add(newDefinition); } } - private ComponentConfigurer makeModeAwareDataSourceFactory( - MockModeAwareDataSource[] holder + /** + * Creates a test FDv2DataSourceBuilder that returns a MockModeAwareDataSource. + * The resolved mode table contains STREAMING, BACKGROUND, and OFFLINE modes. + */ + private FDv2DataSourceBuilder makeModeAwareDataSourceFactory( + MockModeAwareDataSource mockDataSource ) { - return clientContext -> { - receivedClientContexts.add(clientContext); - MockModeAwareDataSource ds = new MockModeAwareDataSource( - clientContext, startedDataSources, stoppedDataSources); - holder[0] = ds; - return ds; + Map table = new LinkedHashMap<>(); + table.put(com.launchdarkly.sdk.android.ConnectionMode.STREAMING, new ModeDefinition( + Collections.>emptyList(), + Collections.>singletonList(ctx -> null) + )); + table.put(com.launchdarkly.sdk.android.ConnectionMode.BACKGROUND, new ModeDefinition( + Collections.>emptyList(), + Collections.>singletonList(ctx -> null) + )); + table.put(com.launchdarkly.sdk.android.ConnectionMode.OFFLINE, new ModeDefinition( + Collections.>emptyList(), + Collections.>emptyList() + )); + return new FDv2DataSourceBuilder(table, com.launchdarkly.sdk.android.ConnectionMode.STREAMING) { + @Override + public DataSource build(ClientContext clientContext) { + super.build(clientContext); + receivedClientContexts.add(clientContext); + mockDataSource.setSink(clientContext.getDataSourceUpdateSink()); + return mockDataSource; + } }; } @Test - public void modeAwareForegroundToBackgroundSwitchesMode() throws Exception { + public void modeAware_foregroundToBackground_switchesMode() throws Exception { eventProcessor.setOffline(false); eventProcessor.setInBackground(false); eventProcessor.setInBackground(true); replayAll(); - MockModeAwareDataSource[] holder = new MockModeAwareDataSource[1]; - createTestManager(false, false, makeModeAwareDataSourceFactory(holder)); + MockModeAwareDataSource mockDS = new MockModeAwareDataSource(startedDataSources); + createTestManager(false, false, makeModeAwareDataSourceFactory(mockDS)); awaitStartUp(); - - // Initial mode: STREAMING (foreground + network available). - // resolveAndSwitchMode was called after start() but resolved STREAMING = no-op. - MockModeAwareDataSource ds = holder[0]; - assertNotNull(ds); - ds.requireNoMoreSwitchModeCalls(); + verifyForegroundDataSourceWasCreatedAndStarted(CONTEXT); mockPlatformState.setAndNotifyForegroundChangeListeners(false); - com.launchdarkly.sdk.android.ConnectionMode newMode = ds.requireSwitchMode(); - assertEquals(com.launchdarkly.sdk.android.ConnectionMode.BACKGROUND, newMode); - + ResolvedModeDefinition def = mockDS.switchModeCalls.poll(2, TimeUnit.SECONDS); + assertNotNull("Expected switchMode call for background transition", def); verifyAll(); - verifyNoMoreDataSourcesWereStopped(); + verifyNoMoreDataSourcesWereCreated(); } @Test - public void modeAwareBackgroundToForegroundSwitchesMode() throws Exception { - mockPlatformState.setForeground(false); - + public void modeAware_backgroundToForeground_switchesMode() throws Exception { eventProcessor.setOffline(false); + eventProcessor.setInBackground(false); eventProcessor.setInBackground(true); eventProcessor.setInBackground(false); replayAll(); - MockModeAwareDataSource[] holder = new MockModeAwareDataSource[1]; - createTestManager(false, false, makeModeAwareDataSourceFactory(holder)); + MockModeAwareDataSource mockDS = new MockModeAwareDataSource(startedDataSources); + createTestManager(false, false, makeModeAwareDataSourceFactory(mockDS)); awaitStartUp(); + verifyForegroundDataSourceWasCreatedAndStarted(CONTEXT); - MockModeAwareDataSource ds = holder[0]; - assertNotNull(ds); - // Initial resolution: background → switchMode(BACKGROUND) - com.launchdarkly.sdk.android.ConnectionMode initialMode = ds.requireSwitchMode(); - assertEquals(com.launchdarkly.sdk.android.ConnectionMode.BACKGROUND, initialMode); + mockPlatformState.setAndNotifyForegroundChangeListeners(false); + ResolvedModeDefinition def1 = mockDS.switchModeCalls.poll(2, TimeUnit.SECONDS); + assertNotNull("Expected switchMode for background", def1); mockPlatformState.setAndNotifyForegroundChangeListeners(true); - - com.launchdarkly.sdk.android.ConnectionMode newMode = ds.requireSwitchMode(); - assertEquals(com.launchdarkly.sdk.android.ConnectionMode.STREAMING, newMode); - - verifyAll(); - verifyNoMoreDataSourcesWereStopped(); - } - - @Test - public void modeAwareNetworkLostSwitchesMode() throws Exception { - eventProcessor.setOffline(false); - eventProcessor.setInBackground(false); - eventProcessor.setOffline(true); - replayAll(); - - MockModeAwareDataSource[] holder = new MockModeAwareDataSource[1]; - createTestManager(false, false, makeModeAwareDataSourceFactory(holder)); - awaitStartUp(); - - MockModeAwareDataSource ds = holder[0]; - assertNotNull(ds); - ds.requireNoMoreSwitchModeCalls(); - - mockPlatformState.setAndNotifyConnectivityChangeListeners(false); - - com.launchdarkly.sdk.android.ConnectionMode newMode = ds.requireSwitchMode(); - assertEquals(com.launchdarkly.sdk.android.ConnectionMode.OFFLINE, newMode); + ResolvedModeDefinition def2 = mockDS.switchModeCalls.poll(2, TimeUnit.SECONDS); + assertNotNull("Expected switchMode for foreground", def2); verifyAll(); - verifyNoMoreDataSourcesWereStopped(); + verifyNoMoreDataSourcesWereCreated(); } @Test - public void modeAwareNetworkRestoredSwitchesMode() throws Exception { + public void modeAware_networkLost_switchesToOffline() throws Exception { eventProcessor.setOffline(false); eventProcessor.setInBackground(false); eventProcessor.setOffline(true); - eventProcessor.setOffline(false); replayAll(); - MockModeAwareDataSource[] holder = new MockModeAwareDataSource[1]; - createTestManager(false, false, makeModeAwareDataSourceFactory(holder)); + MockModeAwareDataSource mockDS = new MockModeAwareDataSource(startedDataSources); + createTestManager(false, false, makeModeAwareDataSourceFactory(mockDS)); awaitStartUp(); + verifyForegroundDataSourceWasCreatedAndStarted(CONTEXT); - MockModeAwareDataSource ds = holder[0]; - assertNotNull(ds); - ds.requireNoMoreSwitchModeCalls(); - - // Lose network mockPlatformState.setAndNotifyConnectivityChangeListeners(false); - com.launchdarkly.sdk.android.ConnectionMode offlineMode = ds.requireSwitchMode(); - assertEquals(com.launchdarkly.sdk.android.ConnectionMode.OFFLINE, offlineMode); - // Restore network - mockPlatformState.setAndNotifyConnectivityChangeListeners(true); - com.launchdarkly.sdk.android.ConnectionMode restoredMode = ds.requireSwitchMode(); - assertEquals(com.launchdarkly.sdk.android.ConnectionMode.STREAMING, restoredMode); + ResolvedModeDefinition def = mockDS.switchModeCalls.poll(2, TimeUnit.SECONDS); + assertNotNull("Expected switchMode call for offline", def); + assertTrue("OFFLINE mode should have no synchronizers", + def.getSynchronizerFactories().isEmpty()); verifyAll(); - verifyNoMoreDataSourcesWereStopped(); + verifyNoMoreDataSourcesWereCreated(); } @Test - public void modeAwareSetForceOfflineSwitchesMode() throws Exception { + public void modeAware_forceOffline_switchesToOffline() throws Exception { eventProcessor.setOffline(false); eventProcessor.setInBackground(false); eventProcessor.setOffline(true); replayAll(); - MockModeAwareDataSource[] holder = new MockModeAwareDataSource[1]; - createTestManager(false, false, makeModeAwareDataSourceFactory(holder)); + MockModeAwareDataSource mockDS = new MockModeAwareDataSource(startedDataSources); + createTestManager(false, false, makeModeAwareDataSourceFactory(mockDS)); awaitStartUp(); - - MockModeAwareDataSource ds = holder[0]; - assertNotNull(ds); - ds.requireNoMoreSwitchModeCalls(); + requireValue(startedDataSources, 1, TimeUnit.SECONDS, "data source started"); connectivityManager.setForceOffline(true); - com.launchdarkly.sdk.android.ConnectionMode newMode = ds.requireSwitchMode(); - assertEquals(com.launchdarkly.sdk.android.ConnectionMode.OFFLINE, newMode); + ResolvedModeDefinition def = mockDS.switchModeCalls.poll(2, TimeUnit.SECONDS); + assertNotNull("Expected switchMode call for forced offline", def); + assertTrue("OFFLINE mode should have no synchronizers", + def.getSynchronizerFactories().isEmpty()); verifyAll(); - verifyNoMoreDataSourcesWereStopped(); } @Test - public void modeAwareUnsetForceOfflineResolvesMode() throws Exception { + public void modeAware_doesNotTearDownOnForegroundChange() throws Exception { eventProcessor.setOffline(false); eventProcessor.setInBackground(false); - eventProcessor.setOffline(true); - eventProcessor.setOffline(false); + eventProcessor.setInBackground(true); replayAll(); - MockModeAwareDataSource[] holder = new MockModeAwareDataSource[1]; - createTestManager(false, false, makeModeAwareDataSourceFactory(holder)); + MockModeAwareDataSource mockDS = new MockModeAwareDataSource(startedDataSources); + createTestManager(false, false, makeModeAwareDataSourceFactory(mockDS)); awaitStartUp(); + verifyForegroundDataSourceWasCreatedAndStarted(CONTEXT); - MockModeAwareDataSource ds = holder[0]; - assertNotNull(ds); - ds.requireNoMoreSwitchModeCalls(); - - connectivityManager.setForceOffline(true); - com.launchdarkly.sdk.android.ConnectionMode offlineMode = ds.requireSwitchMode(); - assertEquals(com.launchdarkly.sdk.android.ConnectionMode.OFFLINE, offlineMode); - - connectivityManager.setForceOffline(false); - com.launchdarkly.sdk.android.ConnectionMode restoredMode = ds.requireSwitchMode(); - assertEquals(com.launchdarkly.sdk.android.ConnectionMode.STREAMING, restoredMode); + mockPlatformState.setAndNotifyForegroundChangeListeners(false); + ResolvedModeDefinition def = mockDS.switchModeCalls.poll(2, TimeUnit.SECONDS); + assertNotNull(def); + verifyNoMoreDataSourcesWereCreated(); verifyAll(); - verifyNoMoreDataSourcesWereStopped(); } @Test - public void modeAwareDoesNotSwitchWhenModeUnchanged() throws Exception { + public void modeAware_sameModeDoesNotTriggerSwitch() throws Exception { eventProcessor.setOffline(false); eventProcessor.setInBackground(false); - eventProcessor.setOffline(false); + eventProcessor.setInBackground(false); replayAll(); - MockModeAwareDataSource[] holder = new MockModeAwareDataSource[1]; - createTestManager(false, false, makeModeAwareDataSourceFactory(holder)); + MockModeAwareDataSource mockDS = new MockModeAwareDataSource(startedDataSources); + createTestManager(false, false, makeModeAwareDataSourceFactory(mockDS)); awaitStartUp(); + requireValue(startedDataSources, 1, TimeUnit.SECONDS, "data source started"); - MockModeAwareDataSource ds = holder[0]; - assertNotNull(ds); - ds.requireNoMoreSwitchModeCalls(); + // Fire a foreground event when already in foreground — should not trigger switchMode + mockPlatformState.setAndNotifyForegroundChangeListeners(true); - // Network is already available; re-notifying should not trigger switchMode - mockPlatformState.setAndNotifyConnectivityChangeListeners(true); + ResolvedModeDefinition def = mockDS.switchModeCalls.poll(500, TimeUnit.MILLISECONDS); + assertNull("Should not switchMode when mode hasn't changed", def); - ds.requireNoMoreSwitchModeCalls(); verifyAll(); - verifyNoMoreDataSourcesWereStopped(); } @Test - public void modeAwareDoesNotTearDownOnForegroundChange() throws Exception { + public void modeAware_switchModePassesResolvedDefinition() throws Exception { eventProcessor.setOffline(false); eventProcessor.setInBackground(false); eventProcessor.setInBackground(true); replayAll(); - MockModeAwareDataSource[] holder = new MockModeAwareDataSource[1]; - createTestManager(false, false, makeModeAwareDataSourceFactory(holder)); + MockModeAwareDataSource mockDS = new MockModeAwareDataSource(startedDataSources); + createTestManager(false, false, makeModeAwareDataSourceFactory(mockDS)); awaitStartUp(); - - MockModeAwareDataSource ds = holder[0]; - assertNotNull(ds); - ds.requireNoMoreSwitchModeCalls(); - - verifyForegroundDataSourceWasCreatedAndStarted(CONTEXT); + requireValue(startedDataSources, 1, TimeUnit.SECONDS, "data source started"); mockPlatformState.setAndNotifyForegroundChangeListeners(false); - ds.requireSwitchMode(); - verifyNoMoreDataSourcesWereCreated(); - verifyNoMoreDataSourcesWereStopped(); - verifyAll(); - } - - @Test - public void modeAwareStartsInBackgroundResolvesToBackground() throws Exception { - mockPlatformState.setForeground(false); - - eventProcessor.setOffline(false); - eventProcessor.setInBackground(true); - replayAll(); - - MockModeAwareDataSource[] holder = new MockModeAwareDataSource[1]; - createTestManager(false, false, makeModeAwareDataSourceFactory(holder)); - awaitStartUp(); - - MockModeAwareDataSource ds = holder[0]; - assertNotNull(ds); - - // Builder default is STREAMING, but we start in background, so - // resolveAndSwitchMode should immediately switch to BACKGROUND - com.launchdarkly.sdk.android.ConnectionMode initialMode = ds.requireSwitchMode(); - assertEquals(com.launchdarkly.sdk.android.ConnectionMode.BACKGROUND, initialMode); - ds.requireNoMoreSwitchModeCalls(); + ResolvedModeDefinition def = mockDS.switchModeCalls.poll(2, TimeUnit.SECONDS); + assertNotNull("Expected switchMode call", def); + assertNotNull("Definition should have synchronizer factories", def.getSynchronizerFactories()); + assertNotNull("Definition should have initializer factories", def.getInitializerFactories()); verifyAll(); } diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilderTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilderTest.java index a6b8f518..3e15d053 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilderTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilderTest.java @@ -2,180 +2,126 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; - -import androidx.annotation.NonNull; +import static org.junit.Assert.fail; import com.launchdarkly.sdk.LDContext; -import com.launchdarkly.sdk.android.DataModel.Flag; import com.launchdarkly.sdk.android.LDConfig.Builder.AutoEnvAttributes; import com.launchdarkly.sdk.android.env.EnvironmentReporterBuilder; import com.launchdarkly.sdk.android.env.IEnvironmentReporter; import com.launchdarkly.sdk.android.subsystems.ClientContext; import com.launchdarkly.sdk.android.subsystems.ComponentConfigurer; import com.launchdarkly.sdk.android.subsystems.DataSource; -import com.launchdarkly.sdk.android.subsystems.HttpConfiguration; import com.launchdarkly.sdk.android.subsystems.Initializer; import com.launchdarkly.sdk.android.subsystems.Synchronizer; -import com.launchdarkly.sdk.android.subsystems.TransactionalDataStore; -import com.launchdarkly.sdk.fdv2.ChangeSet; -import com.launchdarkly.sdk.fdv2.Selector; import org.junit.Rule; import org.junit.Test; import java.util.Collections; -import java.util.EnumMap; +import java.util.LinkedHashMap; import java.util.Map; -import java.util.concurrent.atomic.AtomicReference; public class FDv2DataSourceBuilderTest { - private static final LDContext CONTEXT = LDContext.create("builder-test-key"); + private static final LDContext CONTEXT = LDContext.create("test-context"); private static final IEnvironmentReporter ENV_REPORTER = new EnvironmentReporterBuilder().build(); @Rule public LogCaptureRule logging = new LogCaptureRule(); - /** - * Creates a minimal ClientContext for tests that use a custom mode table. - * No TransactionalDataStore or HTTP config needed — the custom path - * only wraps ComponentConfigurers in DataSourceFactory lambdas. - */ - private ClientContext makeMinimalClientContext() { - MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); - return new ClientContext( - "mobile-key", null, logging.logger, null, sink, - "", false, CONTEXT, null, false, null, null, false - ); - } - - /** - * Creates a ClientContext backed by a real ClientContextImpl with HTTP config, - * ServiceEndpoints, and a TransactionalDataStore. Used by tests that exercise - * the default (real-wiring) build path. - */ - private ClientContext makeFullClientContext() { + private ClientContext makeClientContext() { LDConfig config = new LDConfig.Builder(AutoEnvAttributes.Disabled).build(); MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); - - // Two-phase ClientContext creation: first without HTTP config to bootstrap it, - // then with the resolved HTTP config — mirrors ClientContextImpl.fromConfig(). - ClientContext bootstrap = new ClientContext( - "mobile-key", ENV_REPORTER, logging.logger, config, - null, "", false, CONTEXT, null, false, null, - config.serviceEndpoints, false - ); - HttpConfiguration httpConfig = config.http.build(bootstrap); - - ClientContext base = new ClientContext( - "mobile-key", ENV_REPORTER, logging.logger, config, - sink, "", false, CONTEXT, httpConfig, false, null, - config.serviceEndpoints, false + return new ClientContext( + "mobile-key", + ENV_REPORTER, + logging.logger, + config, + sink, + "default", + false, + CONTEXT, + null, + false, + null, + config.serviceEndpoints, + false ); - - TransactionalDataStore mockStore = new TransactionalDataStore() { - @Override - public void apply(@NonNull LDContext context, - @NonNull ChangeSet> changeSet) { } - - @NonNull - @Override - public Selector getSelector() { - return Selector.EMPTY; - } - }; - - return new ClientContextImpl(base, null, null, null, null, null, mockStore); } - // --- Default constructor tests (real wiring path) --- - @Test - public void build_defaultConstructor_returnsNonNullModeAwareDataSource() { + public void defaultBuilder_buildsFDv2DataSource() { FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(); - DataSource ds = builder.build(makeFullClientContext()); + DataSource ds = builder.build(makeClientContext()); assertNotNull(ds); + assertTrue(ds instanceof FDv2DataSource); assertTrue(ds instanceof ModeAware); } @Test - public void build_defaultConstructor_allModesResolved() { + public void resolvedModeTable_availableAfterBuild() { FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(); - DataSource ds = builder.build(makeFullClientContext()); - - ModeAware modeAware = (ModeAware) ds; - for (ConnectionMode mode : ConnectionMode.values()) { - modeAware.switchMode(mode); - } + builder.build(makeClientContext()); + Map table = builder.getResolvedModeTable(); + assertNotNull(table); + assertEquals(5, table.size()); } - // --- Custom mode table tests (configurer resolution path) --- - - @Test - public void build_customTable_resolvesConfigurersViaClientContext() { - AtomicReference capturedContext = new AtomicReference<>(); - ComponentConfigurer trackingConfigurer = ctx -> { - capturedContext.set(ctx); - return null; - }; - - Map customTable = new EnumMap<>(ConnectionMode.class); - customTable.put(ConnectionMode.STREAMING, new ModeDefinition( - Collections.>emptyList(), - Collections.singletonList(trackingConfigurer) - )); - - FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(customTable, ConnectionMode.STREAMING); - ClientContext ctx = makeMinimalClientContext(); - DataSource ds = builder.build(ctx); - - assertNotNull(ds); - assertNull(trackingConfigurer.build(ctx)); - assertEquals(ctx, capturedContext.get()); + @Test(expected = IllegalStateException.class) + public void resolvedModeTable_throwsBeforeBuild() { + FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(); + builder.getResolvedModeTable(); } @Test - public void build_customTable_usesProvidedStartingMode() { - Map customTable = new EnumMap<>(ConnectionMode.class); + public void customModeTable_resolvesCorrectly() { + Map customTable = new LinkedHashMap<>(); customTable.put(ConnectionMode.POLLING, new ModeDefinition( Collections.>emptyList(), - Collections.>emptyList() + Collections.>singletonList(ctx -> null) )); FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(customTable, ConnectionMode.POLLING); - DataSource ds = builder.build(makeMinimalClientContext()); + DataSource ds = builder.build(makeClientContext()); assertNotNull(ds); + + Map table = builder.getResolvedModeTable(); + assertEquals(1, table.size()); + assertTrue(table.containsKey(ConnectionMode.POLLING)); } - @Test(expected = IllegalArgumentException.class) - public void build_customTable_throwsWhenStartingModeNotInTable() { - Map customTable = new EnumMap<>(ConnectionMode.class); + @Test + public void startingMode_notInTable_throws() { + Map customTable = new LinkedHashMap<>(); customTable.put(ConnectionMode.POLLING, new ModeDefinition( Collections.>emptyList(), - Collections.>emptyList() + Collections.>singletonList(ctx -> null) )); FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(customTable, ConnectionMode.STREAMING); - builder.build(makeMinimalClientContext()); + try { + builder.build(makeClientContext()); + fail("Expected IllegalStateException"); + } catch (IllegalStateException e) { + assertTrue(e.getMessage().contains("not found in mode table")); + } } @Test - public void build_customTable_resolvesAllModesAndSupportsSwitchMode() { - Map customTable = new EnumMap<>(ConnectionMode.class); + public void resolvedDefinition_hasSameSizeAsOriginal() { + Map customTable = new LinkedHashMap<>(); customTable.put(ConnectionMode.STREAMING, new ModeDefinition( - Collections.>emptyList(), - Collections.singletonList(ctx -> null) - )); - customTable.put(ConnectionMode.OFFLINE, new ModeDefinition( - Collections.>emptyList(), - Collections.>emptyList() + Collections.>singletonList(ctx -> null), + Collections.>singletonList(ctx -> null) )); FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(customTable, ConnectionMode.STREAMING); - DataSource ds = builder.build(makeMinimalClientContext()); - assertNotNull(ds); - ((ModeAware) ds).switchMode(ConnectionMode.OFFLINE); + builder.build(makeClientContext()); + + ResolvedModeDefinition def = builder.getResolvedModeTable().get(ConnectionMode.STREAMING); + assertNotNull(def); + assertEquals(1, def.getInitializerFactories().size()); + assertEquals(1, def.getSynchronizerFactories().size()); } } diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceTest.java index 2d8ba4a9..fa569509 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceTest.java @@ -33,7 +33,6 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; -import java.util.EnumMap; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -1401,275 +1400,164 @@ public void statusTransitionsFromValidToOffWhenAllSynchronizersFail() throws Exc assertNotNull(sink.getLastError()); } - // ============================================================================ - // needsRefresh — ModeAware behavior - // ============================================================================ - - @Test - public void needsRefresh_sameContextDifferentBackground_returnsFalse() { - MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); - FDv2DataSource dataSource = buildDataSource(sink, Collections.emptyList(), Collections.emptyList()); - - assertFalse(dataSource.needsRefresh(true, CONTEXT)); - assertFalse(dataSource.needsRefresh(false, CONTEXT)); - } - - @Test - public void needsRefresh_differentContext_returnsTrue() { - MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); - FDv2DataSource dataSource = buildDataSource(sink, Collections.emptyList(), Collections.emptyList()); - - LDContext otherContext = LDContext.create("other-context"); - assertTrue(dataSource.needsRefresh(false, otherContext)); - assertTrue(dataSource.needsRefresh(true, otherContext)); - } - @Test - public void needsRefresh_differentContextAndBackground_returnsTrue() { - MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); - FDv2DataSource dataSource = buildDataSource(sink, Collections.emptyList(), Collections.emptyList()); - - LDContext otherContext = LDContext.create("other-context"); - assertTrue(dataSource.needsRefresh(true, otherContext)); - } - - @Test - public void needsRefresh_equalContextInstance_returnsFalse() { - MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); - LDContext context = LDContext.create("test-context"); - FDv2DataSource dataSource = new FDv2DataSource( - context, Collections.emptyList(), Collections.emptyList(), - sink, executor, logging.logger); - - LDContext sameValueContext = LDContext.create("test-context"); - assertFalse(dataSource.needsRefresh(false, sameValueContext)); - assertFalse(dataSource.needsRefresh(true, sameValueContext)); - } - - // ============================================================================ - // switchMode — ModeAware behavior - // ============================================================================ - - private FDv2DataSource buildModeAwareDataSource( - MockComponents.MockDataSourceUpdateSink sink, - Map modeTable, - ConnectionMode startingMode) { - return new FDv2DataSource( - CONTEXT, modeTable, startingMode, - sink, executor, logging.logger); - } - - @Test - public void switchMode_activatesNewModeSynchronizer() throws Exception { + public void stopReportsOffStatus() throws Exception { MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); - CountDownLatch pollingCreated = new CountDownLatch(1); - - Map modeTable = new EnumMap<>(ConnectionMode.class); - modeTable.put(ConnectionMode.STREAMING, new FDv2DataSource.ResolvedModeDefinition( + FDv2DataSource dataSource = buildDataSource(sink, Collections.emptyList(), Collections.singletonList(() -> new MockQueuedSynchronizer( - FDv2SourceResult.changeSet(makeChangeSet(false)))) - )); - modeTable.put(ConnectionMode.POLLING, new FDv2DataSource.ResolvedModeDefinition( - Collections.emptyList(), - Collections.singletonList(() -> { - pollingCreated.countDown(); - return new MockQueuedSynchronizer( - FDv2SourceResult.changeSet(makeChangeSet(false))); - }) - )); + FDv2SourceResult.changeSet(makeChangeSet(false))))); - FDv2DataSource dataSource = buildModeAwareDataSource(sink, modeTable, ConnectionMode.STREAMING); AwaitableCallback startCallback = startDataSource(dataSource); - assertTrue(startCallback.await(2000)); - - dataSource.switchMode(ConnectionMode.POLLING); - assertTrue(pollingCreated.await(2, TimeUnit.SECONDS)); + assertTrue(startCallback.await(AWAIT_TIMEOUT_SECONDS * 1000)); - // Both streaming and polling changesets should have been applied - sink.awaitApplyCount(2, 2, TimeUnit.SECONDS); - assertEquals(2, sink.getApplyCount()); + DataSourceState validStatus = sink.awaitStatus(AWAIT_TIMEOUT_SECONDS, TimeUnit.SECONDS); + assertEquals(DataSourceState.VALID, validStatus); stopDataSource(dataSource); + + DataSourceState offStatus = sink.awaitStatus(AWAIT_TIMEOUT_SECONDS, TimeUnit.SECONDS); + assertEquals(DataSourceState.OFF, offStatus); } + // ==== switchMode tests ==== + @Test - public void switchMode_doesNotReRunInitializers() throws Exception { + public void switchMode_replacesActiveSynchronizer() throws Exception { MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); + CountDownLatch oldSyncStarted = new CountDownLatch(1); + CountDownLatch oldSyncClosed = new CountDownLatch(1); - AtomicInteger initializerBuildCount = new AtomicInteger(0); - CountDownLatch pollingSyncCreated = new CountDownLatch(1); + MockQueuedSynchronizer oldSync = new MockQueuedSynchronizer( + FDv2SourceResult.changeSet(makeChangeSet(false)) + ) { + @Override + public LDAwaitFuture next() { + oldSyncStarted.countDown(); + return super.next(); + } + @Override + public void close() { + super.close(); + oldSyncClosed.countDown(); + } + }; - Map modeTable = new EnumMap<>(ConnectionMode.class); - modeTable.put(ConnectionMode.STREAMING, new FDv2DataSource.ResolvedModeDefinition( - Collections.singletonList(() -> { - initializerBuildCount.incrementAndGet(); - return new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true))); - }), - Collections.singletonList(() -> new MockQueuedSynchronizer( - FDv2SourceResult.changeSet(makeChangeSet(false)))) - )); - modeTable.put(ConnectionMode.POLLING, new FDv2DataSource.ResolvedModeDefinition( + FDv2DataSource dataSource = buildDataSource(sink, Collections.emptyList(), - Collections.singletonList(() -> { - pollingSyncCreated.countDown(); - return new MockQueuedSynchronizer( - FDv2SourceResult.changeSet(makeChangeSet(false))); - }) - )); - - FDv2DataSource dataSource = buildModeAwareDataSource(sink, modeTable, ConnectionMode.STREAMING); - AwaitableCallback startCallback = startDataSource(dataSource); - assertTrue(startCallback.await(2000)); - assertEquals(1, initializerBuildCount.get()); - - dataSource.switchMode(ConnectionMode.POLLING); - assertTrue(pollingSyncCreated.await(2, TimeUnit.SECONDS)); + Collections.singletonList(() -> oldSync)); + + startDataSource(dataSource); + assertTrue(oldSyncStarted.await(2, TimeUnit.SECONDS)); + assertEquals(DataSourceState.VALID, sink.awaitStatus(2, TimeUnit.SECONDS)); + + CountDownLatch newSyncStarted = new CountDownLatch(1); + MockQueuedSynchronizer newSync = new MockQueuedSynchronizer( + FDv2SourceResult.changeSet(makeChangeSet(false)) + ) { + @Override + public LDAwaitFuture next() { + newSyncStarted.countDown(); + return super.next(); + } + }; + ResolvedModeDefinition newDef = new ResolvedModeDefinition( + Collections.>emptyList(), + Collections.>singletonList(() -> newSync) + ); - // Initializer count should still be 1 — mode switch skips initializers (spec 2.0.1) - assertEquals(1, initializerBuildCount.get()); + dataSource.switchMode(newDef); - stopDataSource(dataSource); + assertTrue("Old synchronizer should be closed", oldSyncClosed.await(2, TimeUnit.SECONDS)); + assertTrue("New synchronizer should start", newSyncStarted.await(2, TimeUnit.SECONDS)); } @Test - public void switchMode_toModeWithNoSynchronizers_doesNotCrash() throws Exception { + public void switchMode_doesNotReRunInitializers() throws Exception { MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); + AtomicInteger initializerRunCount = new AtomicInteger(0); - Map modeTable = new EnumMap<>(ConnectionMode.class); - modeTable.put(ConnectionMode.STREAMING, new FDv2DataSource.ResolvedModeDefinition( - Collections.emptyList(), - Collections.singletonList(() -> new MockQueuedSynchronizer( - FDv2SourceResult.changeSet(makeChangeSet(false)))) - )); - modeTable.put(ConnectionMode.OFFLINE, new FDv2DataSource.ResolvedModeDefinition( - Collections.emptyList(), - Collections.emptyList() - )); + FDv2DataSource.DataSourceFactory initFactory = () -> { + initializerRunCount.incrementAndGet(); + return new MockInitializer( + FDv2SourceResult.changeSet(makeChangeSet(true))); + }; - FDv2DataSource dataSource = buildModeAwareDataSource(sink, modeTable, ConnectionMode.STREAMING); - AwaitableCallback startCallback = startDataSource(dataSource); - assertTrue(startCallback.await(2000)); - - dataSource.switchMode(ConnectionMode.OFFLINE); - Thread.sleep(200); // allow mode switch to complete - - stopDataSource(dataSource); - } - - @Test - public void switchMode_fromOfflineBackToStreaming_resumesSynchronizers() throws Exception { - MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); - - AtomicInteger streamingSyncBuildCount = new AtomicInteger(0); + CountDownLatch syncStarted = new CountDownLatch(1); + FDv2DataSource dataSource = buildDataSource(sink, + Collections.singletonList(initFactory), + Collections.singletonList(() -> new MockQueuedSynchronizer( + FDv2SourceResult.changeSet(makeChangeSet(false)) + ) { + @Override + public LDAwaitFuture next() { + syncStarted.countDown(); + return super.next(); + } + })); - Map modeTable = new EnumMap<>(ConnectionMode.class); - modeTable.put(ConnectionMode.STREAMING, new FDv2DataSource.ResolvedModeDefinition( - Collections.emptyList(), - Collections.singletonList(() -> { - streamingSyncBuildCount.incrementAndGet(); - return new MockQueuedSynchronizer( - FDv2SourceResult.changeSet(makeChangeSet(false))); - }) - )); - modeTable.put(ConnectionMode.OFFLINE, new FDv2DataSource.ResolvedModeDefinition( - Collections.emptyList(), - Collections.emptyList() - )); + startDataSource(dataSource); + assertTrue(syncStarted.await(2, TimeUnit.SECONDS)); + assertEquals(1, initializerRunCount.get()); - FDv2DataSource dataSource = buildModeAwareDataSource(sink, modeTable, ConnectionMode.STREAMING); - AwaitableCallback startCallback = startDataSource(dataSource); - assertTrue(startCallback.await(2000)); - assertEquals(1, streamingSyncBuildCount.get()); + ResolvedModeDefinition newDef = new ResolvedModeDefinition( + Collections.>emptyList(), + Collections.>singletonList( + () -> new MockQueuedSynchronizer( + FDv2SourceResult.changeSet(makeChangeSet(false)))) + ); - // Switch to offline — no synchronizers - dataSource.switchMode(ConnectionMode.OFFLINE); + dataSource.switchMode(newDef); Thread.sleep(200); - - // Switch back to streaming — new synchronizer should be created - dataSource.switchMode(ConnectionMode.STREAMING); - Thread.sleep(500); - - // A new streaming sync was created (2 total: one from start, one from mode switch back) - assertEquals(2, streamingSyncBuildCount.get()); - - // Both streaming changesets (initial + resumed) should have been applied - sink.awaitApplyCount(2, 2, TimeUnit.SECONDS); - assertEquals(2, sink.getApplyCount()); - - stopDataSource(dataSource); + assertEquals("Initializers should NOT run again on switchMode", 1, initializerRunCount.get()); } @Test - public void switchMode_withNoModeTable_isNoOp() throws Exception { + public void switchMode_toEmptySynchronizers_closesOld() throws Exception { MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); + CountDownLatch oldClosed = new CountDownLatch(1); - // Use legacy constructor (no mode table) FDv2DataSource dataSource = buildDataSource(sink, Collections.emptyList(), Collections.singletonList(() -> new MockQueuedSynchronizer( - FDv2SourceResult.changeSet(makeChangeSet(false))))); + FDv2SourceResult.changeSet(makeChangeSet(false)) + ) { + @Override + public void close() { + super.close(); + oldClosed.countDown(); + } + })); - AwaitableCallback startCallback = startDataSource(dataSource); - assertTrue(startCallback.await(2000)); + startDataSource(dataSource); + assertEquals(DataSourceState.VALID, sink.awaitStatus(2, TimeUnit.SECONDS)); - // Should not crash; logs a warning and returns - dataSource.switchMode(ConnectionMode.POLLING); - Thread.sleep(100); + ResolvedModeDefinition offlineDef = new ResolvedModeDefinition( + Collections.>emptyList(), + Collections.>emptyList() + ); - stopDataSource(dataSource); + dataSource.switchMode(offlineDef); + assertTrue("Old synchronizer should be closed", oldClosed.await(2, TimeUnit.SECONDS)); } @Test - public void switchMode_afterStop_isNoOp() throws Exception { + public void needsRefresh_sameContext_returnsFalse() { MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); - - Map modeTable = new EnumMap<>(ConnectionMode.class); - modeTable.put(ConnectionMode.STREAMING, new FDv2DataSource.ResolvedModeDefinition( - Collections.emptyList(), - Collections.singletonList(() -> new MockQueuedSynchronizer( - FDv2SourceResult.changeSet(makeChangeSet(false)))) - )); - modeTable.put(ConnectionMode.POLLING, new FDv2DataSource.ResolvedModeDefinition( + FDv2DataSource dataSource = buildDataSource(sink, Collections.emptyList(), - Collections.singletonList(() -> new MockQueuedSynchronizer( - FDv2SourceResult.changeSet(makeChangeSet(false)))) - )); - - FDv2DataSource dataSource = buildModeAwareDataSource(sink, modeTable, ConnectionMode.STREAMING); - AwaitableCallback startCallback = startDataSource(dataSource); - assertTrue(startCallback.await(2000)); - - stopDataSource(dataSource); - - // Should not crash or schedule new work after stop - dataSource.switchMode(ConnectionMode.POLLING); - Thread.sleep(100); + Collections.emptyList()); + assertFalse(dataSource.needsRefresh(true, CONTEXT)); + assertFalse(dataSource.needsRefresh(false, CONTEXT)); } - // ============================================================================ - // Status Reporting - // ============================================================================ - @Test - public void stopReportsOffStatus() throws Exception { + public void needsRefresh_differentContext_returnsTrue() { MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); - FDv2DataSource dataSource = buildDataSource(sink, Collections.emptyList(), - Collections.singletonList(() -> new MockQueuedSynchronizer( - FDv2SourceResult.changeSet(makeChangeSet(false))))); - - AwaitableCallback startCallback = startDataSource(dataSource); - assertTrue(startCallback.await(AWAIT_TIMEOUT_SECONDS * 1000)); - - DataSourceState validStatus = sink.awaitStatus(AWAIT_TIMEOUT_SECONDS, TimeUnit.SECONDS); - assertEquals(DataSourceState.VALID, validStatus); - - stopDataSource(dataSource); - - DataSourceState offStatus = sink.awaitStatus(AWAIT_TIMEOUT_SECONDS, TimeUnit.SECONDS); - assertEquals(DataSourceState.OFF, offStatus); + Collections.emptyList()); + assertTrue(dataSource.needsRefresh(false, LDContext.create("other-context"))); } } diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ModeResolutionTableTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ModeResolutionTableTest.java index 2beec0fc..b4c559fc 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ModeResolutionTableTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ModeResolutionTableTest.java @@ -1,94 +1,82 @@ package com.launchdarkly.sdk.android; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; import org.junit.Test; import java.util.Arrays; import java.util.Collections; -/** - * Unit tests for {@link ModeResolutionTable} and the {@link ModeResolutionTable#MOBILE} constant. - */ public class ModeResolutionTableTest { - // ==== MOBILE table — standard Android resolution ==== + // ==== MOBILE table tests ==== @Test public void mobile_foregroundWithNetwork_resolvesToStreaming() { ModeState state = new ModeState(true, true); - assertEquals(ConnectionMode.STREAMING, ModeResolutionTable.MOBILE.resolve(state)); + assertSame(ConnectionMode.STREAMING, ModeResolutionTable.MOBILE.resolve(state)); } @Test public void mobile_backgroundWithNetwork_resolvesToBackground() { ModeState state = new ModeState(false, true); - assertEquals(ConnectionMode.BACKGROUND, ModeResolutionTable.MOBILE.resolve(state)); + assertSame(ConnectionMode.BACKGROUND, ModeResolutionTable.MOBILE.resolve(state)); } @Test - public void mobile_foregroundNoNetwork_resolvesToOffline() { + public void mobile_foregroundWithoutNetwork_resolvesToOffline() { ModeState state = new ModeState(true, false); - assertEquals(ConnectionMode.OFFLINE, ModeResolutionTable.MOBILE.resolve(state)); + assertSame(ConnectionMode.OFFLINE, ModeResolutionTable.MOBILE.resolve(state)); } @Test - public void mobile_backgroundNoNetwork_resolvesToOffline() { + public void mobile_backgroundWithoutNetwork_resolvesToOffline() { ModeState state = new ModeState(false, false); - assertEquals(ConnectionMode.OFFLINE, ModeResolutionTable.MOBILE.resolve(state)); + assertSame(ConnectionMode.OFFLINE, ModeResolutionTable.MOBILE.resolve(state)); } - // ==== resolve() — first match wins ==== + // ==== Custom table tests ==== @Test - public void resolve_firstMatchWins_evenIfLaterEntryAlsoMatches() { + public void customTable_firstMatchWins() { ModeResolutionTable table = new ModeResolutionTable(Arrays.asList( new ModeResolutionEntry(state -> true, ConnectionMode.POLLING), new ModeResolutionEntry(state -> true, ConnectionMode.STREAMING) )); - assertEquals(ConnectionMode.POLLING, table.resolve(new ModeState(true, true))); + assertSame(ConnectionMode.POLLING, table.resolve(new ModeState(true, true))); } - @Test - public void resolve_skipsNonMatchingEntries() { - ModeResolutionTable table = new ModeResolutionTable(Arrays.asList( - new ModeResolutionEntry(state -> false, ConnectionMode.POLLING), - new ModeResolutionEntry(state -> true, ConnectionMode.STREAMING) - )); - assertEquals(ConnectionMode.STREAMING, table.resolve(new ModeState(true, true))); - } - - @Test - public void resolve_singleEntry() { - ModeResolutionTable table = new ModeResolutionTable(Collections.singletonList( - new ModeResolutionEntry(state -> true, ConnectionMode.OFFLINE) - )); - assertEquals(ConnectionMode.OFFLINE, table.resolve(new ModeState(false, false))); + @Test(expected = IllegalStateException.class) + public void emptyTable_throws() { + ModeResolutionTable table = new ModeResolutionTable(Collections.emptyList()); + table.resolve(new ModeState(true, true)); } @Test(expected = IllegalStateException.class) - public void resolve_noMatchingEntry_throws() { + public void noMatch_throws() { ModeResolutionTable table = new ModeResolutionTable(Collections.singletonList( - new ModeResolutionEntry(state -> false, ConnectionMode.OFFLINE) + new ModeResolutionEntry(state -> false, ConnectionMode.STREAMING) )); table.resolve(new ModeState(true, true)); } - @Test(expected = IllegalStateException.class) - public void resolve_emptyTable_throws() { - ModeResolutionTable table = new ModeResolutionTable( - Collections.emptyList() - ); - table.resolve(new ModeState(true, true)); + // ==== ModeState tests ==== + + @Test + public void modeState_getters() { + ModeState state = new ModeState(true, false); + assertEquals(true, state.isForeground()); + assertEquals(false, state.isNetworkAvailable()); } - // ==== Network takes priority over lifecycle ==== + // ==== ModeResolutionEntry tests ==== @Test - public void mobile_networkUnavailable_alwaysResolvesToOffline_regardlessOfForeground() { - assertEquals(ConnectionMode.OFFLINE, - ModeResolutionTable.MOBILE.resolve(new ModeState(true, false))); - assertEquals(ConnectionMode.OFFLINE, - ModeResolutionTable.MOBILE.resolve(new ModeState(false, false))); + public void modeResolutionEntry_getters() { + ModeResolutionEntry.Condition cond = state -> true; + ModeResolutionEntry entry = new ModeResolutionEntry(cond, ConnectionMode.OFFLINE); + assertSame(cond, entry.getConditions()); + assertSame(ConnectionMode.OFFLINE, entry.getMode()); } } From b160c58f606688ae1576b717cd6d246f2c811623 Mon Sep 17 00:00:00 2001 From: Aaron Zeisler Date: Tue, 17 Mar 2026 15:15:57 -0700 Subject: [PATCH 08/17] [SDK-1956] refactor: ModeAware no longer extends DataSource FDv2DataSource now explicitly implements both DataSource and ModeAware, keeping the two interfaces independent. Made-with: Cursor --- .../launchdarkly/sdk/android/FDv2DataSource.java | 3 ++- .../com/launchdarkly/sdk/android/ModeAware.java | 14 ++++++-------- .../sdk/android/ConnectivityManagerTest.java | 2 +- 3 files changed, 9 insertions(+), 10 deletions(-) diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSource.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSource.java index ca1dc1ed..6da69fc7 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSource.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSource.java @@ -11,6 +11,7 @@ import com.launchdarkly.sdk.android.subsystems.DataSourceUpdateSinkV2; import com.launchdarkly.sdk.android.subsystems.FDv2SourceResult; import com.launchdarkly.sdk.android.subsystems.Initializer; +import com.launchdarkly.sdk.android.subsystems.DataSource; import com.launchdarkly.sdk.android.subsystems.Synchronizer; import java.util.ArrayList; @@ -29,7 +30,7 @@ * switch to next synchronizer) and recovery (when on non-prime synchronizer, try * to return to the first after timeout). */ -final class FDv2DataSource implements ModeAware { +final class FDv2DataSource implements DataSource, ModeAware { /** * Factory for creating Initializer or Synchronizer instances. diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeAware.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeAware.java index d4902f33..929f1d4a 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeAware.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeAware.java @@ -2,25 +2,23 @@ import androidx.annotation.NonNull; -import com.launchdarkly.sdk.android.subsystems.DataSource; - /** - * A {@link DataSource} that supports runtime connection mode switching. + * Supports runtime connection mode switching. *

* {@link ConnectivityManager} checks {@code instanceof ModeAware} to decide * whether to use mode resolution (FDv2) or legacy teardown/rebuild behavior (FDv1). *

- * In this approach (Approach 2), the data source receives the full - * {@link ResolvedModeDefinition} — it has no internal mode table and does not - * know which named {@link ConnectionMode} it is operating in. The mode table - * and mode-to-definition lookup live in {@link ConnectivityManager}. + * The data source receives the full {@link ResolvedModeDefinition} — it has no + * internal mode table and does not know which named {@link ConnectionMode} it is + * operating in. The mode table and mode-to-definition lookup live in + * {@link ConnectivityManager}. *

* Package-private — not part of the public SDK API. * * @see ResolvedModeDefinition * @see ModeResolutionTable */ -interface ModeAware extends DataSource { +interface ModeAware { /** * Switches the data source to operate with the given mode definition. diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ConnectivityManagerTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ConnectivityManagerTest.java index f9c73606..d34ca129 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ConnectivityManagerTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ConnectivityManagerTest.java @@ -671,7 +671,7 @@ private void verifyNoMoreDataSourcesWereStopped() { * A mock ModeAware data source that records switchMode calls and * signals start success immediately. */ - private static class MockModeAwareDataSource implements ModeAware { + private static class MockModeAwareDataSource implements DataSource, ModeAware { final BlockingQueue switchModeCalls = new LinkedBlockingQueue<>(); private final BlockingQueue startedQueue; private volatile com.launchdarkly.sdk.android.subsystems.DataSourceUpdateSink sink; From fbcf5f62931900ddcde4ce1c489c88d232663c4e Mon Sep 17 00:00:00 2001 From: Aaron Zeisler Date: Tue, 17 Mar 2026 15:51:03 -0700 Subject: [PATCH 09/17] [SDK-1956] refactor: separate event processor and data source logic in ConnectivityManager Extract updateEventProcessor() and handleModeStateChange() so that event processor state (setOffline, setInBackground) is managed independently from data source lifecycle. Both platform listeners and setForceOffline() now route through handleModeStateChange(), which snapshots state once and updates each subsystem separately. Made-with: Cursor --- .../sdk/android/ConnectivityManager.java | 61 ++++++++++--------- .../sdk/android/ConnectivityManagerTest.java | 8 +++ 2 files changed, 40 insertions(+), 29 deletions(-) diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ConnectivityManager.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ConnectivityManager.java index b8998b45..ace1f26f 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ConnectivityManager.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ConnectivityManager.java @@ -151,27 +151,10 @@ public void shutDown() { readStoredConnectionState(); this.backgroundUpdatingDisabled = ldConfig.isDisableBackgroundPolling(); - connectivityChangeListener = networkAvailable -> { - DataSource dataSource = currentDataSource.get(); - if (dataSource instanceof ModeAware) { - eventProcessor.setOffline(!networkAvailable); - resolveAndSwitchMode((ModeAware) dataSource); - } else { - updateDataSource(false, LDUtil.noOpCallback()); - } - }; + connectivityChangeListener = networkAvailable -> handleModeStateChange(); platformState.addConnectivityChangeListener(connectivityChangeListener); - foregroundListener = foreground -> { - DataSource dataSource = currentDataSource.get(); - if (dataSource instanceof ModeAware) { - eventProcessor.setInBackground(!foreground); - resolveAndSwitchMode((ModeAware) dataSource); - } else if (dataSource == null || dataSource.needsRefresh(!foreground, - currentContext.get())) { - updateDataSource(true, LDUtil.noOpCallback()); - } - }; + foregroundListener = foreground -> handleModeStateChange(); platformState.addForegroundChangeListener(foregroundListener); } @@ -190,6 +173,7 @@ void switchToContext(@NonNull LDContext context, @NonNull Callback onCompl onCompletion.onSuccess(null); } else { if (dataSource == null || dataSource.needsRefresh(!platformState.isForeground(), context)) { + updateEventProcessor(forcedOffline.get(), platformState.isNetworkAvailable(), platformState.isForeground()); updateDataSource(true, onCompletion); } else { onCompletion.onSuccess(null); @@ -210,9 +194,6 @@ private synchronized boolean updateDataSource( boolean inBackground = !platformState.isForeground(); LDContext context = currentContext.get(); - eventProcessor.setOffline(forceOffline || !networkEnabled); - eventProcessor.setInBackground(inBackground); - boolean shouldStopExistingDataSource = true, shouldStartDataSourceIfStopped = false; @@ -426,6 +407,7 @@ synchronized boolean startUp(@NonNull Callback onCompletion) { return false; } initialized = false; + updateEventProcessor(forcedOffline.get(), platformState.isNetworkAvailable(), platformState.isForeground()); return updateDataSource(true, onCompletion); } @@ -448,13 +430,7 @@ void shutDown() { void setForceOffline(boolean forceOffline) { boolean wasForcedOffline = forcedOffline.getAndSet(forceOffline); if (forceOffline != wasForcedOffline) { - DataSource dataSource = currentDataSource.get(); - if (dataSource instanceof ModeAware) { - eventProcessor.setOffline(forceOffline || !platformState.isNetworkAvailable()); - resolveAndSwitchMode((ModeAware) dataSource); - } else { - updateDataSource(false, LDUtil.noOpCallback()); - } + handleModeStateChange(); } } @@ -462,6 +438,33 @@ boolean isForcedOffline() { return forcedOffline.get(); } + private void updateEventProcessor(boolean forceOffline, boolean networkAvailable, boolean foreground) { + eventProcessor.setOffline(forceOffline || !networkAvailable); + eventProcessor.setInBackground(!foreground); + } + + /** + * Unified handler for all platform/configuration state changes (foreground, connectivity, + * force-offline). Snapshots the current state once, updates the event processor, then + * routes to the appropriate data source update path. + */ + private void handleModeStateChange() { + boolean forceOffline = forcedOffline.get(); + boolean networkAvailable = platformState.isNetworkAvailable(); + boolean foreground = platformState.isForeground(); + + updateEventProcessor(forceOffline, networkAvailable, foreground); + + DataSource dataSource = currentDataSource.get(); + if (dataSource instanceof ModeAware) { + resolveAndSwitchMode((ModeAware) dataSource); + } else if (dataSource != null && dataSource.needsRefresh(!foreground, currentContext.get())) { + updateDataSource(true, LDUtil.noOpCallback()); + } else { + updateDataSource(false, LDUtil.noOpCallback()); + } + } + /** * Resolves the current platform state to a ConnectionMode via the ModeResolutionTable, * looks up the ResolvedModeDefinition from the resolved mode table, and calls diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ConnectivityManagerTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ConnectivityManagerTest.java index d34ca129..018a674c 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ConnectivityManagerTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ConnectivityManagerTest.java @@ -746,6 +746,7 @@ public DataSource build(ClientContext clientContext) { public void modeAware_foregroundToBackground_switchesMode() throws Exception { eventProcessor.setOffline(false); eventProcessor.setInBackground(false); + eventProcessor.setOffline(false); eventProcessor.setInBackground(true); replayAll(); @@ -766,7 +767,9 @@ public void modeAware_foregroundToBackground_switchesMode() throws Exception { public void modeAware_backgroundToForeground_switchesMode() throws Exception { eventProcessor.setOffline(false); eventProcessor.setInBackground(false); + eventProcessor.setOffline(false); eventProcessor.setInBackground(true); + eventProcessor.setOffline(false); eventProcessor.setInBackground(false); replayAll(); @@ -792,6 +795,7 @@ public void modeAware_networkLost_switchesToOffline() throws Exception { eventProcessor.setOffline(false); eventProcessor.setInBackground(false); eventProcessor.setOffline(true); + eventProcessor.setInBackground(false); replayAll(); MockModeAwareDataSource mockDS = new MockModeAwareDataSource(startedDataSources); @@ -815,6 +819,7 @@ public void modeAware_forceOffline_switchesToOffline() throws Exception { eventProcessor.setOffline(false); eventProcessor.setInBackground(false); eventProcessor.setOffline(true); + eventProcessor.setInBackground(false); replayAll(); MockModeAwareDataSource mockDS = new MockModeAwareDataSource(startedDataSources); @@ -836,6 +841,7 @@ public void modeAware_forceOffline_switchesToOffline() throws Exception { public void modeAware_doesNotTearDownOnForegroundChange() throws Exception { eventProcessor.setOffline(false); eventProcessor.setInBackground(false); + eventProcessor.setOffline(false); eventProcessor.setInBackground(true); replayAll(); @@ -856,6 +862,7 @@ public void modeAware_doesNotTearDownOnForegroundChange() throws Exception { public void modeAware_sameModeDoesNotTriggerSwitch() throws Exception { eventProcessor.setOffline(false); eventProcessor.setInBackground(false); + eventProcessor.setOffline(false); eventProcessor.setInBackground(false); replayAll(); @@ -877,6 +884,7 @@ public void modeAware_sameModeDoesNotTriggerSwitch() throws Exception { public void modeAware_switchModePassesResolvedDefinition() throws Exception { eventProcessor.setOffline(false); eventProcessor.setInBackground(false); + eventProcessor.setOffline(false); eventProcessor.setInBackground(true); replayAll(); From 4def25e59a2850ee34a393771031cc3fea526891 Mon Sep 17 00:00:00 2001 From: Aaron Zeisler Date: Tue, 17 Mar 2026 16:25:39 -0700 Subject: [PATCH 10/17] [SDK-1956] refactor: move synchronizer switching into SourceManager to prevent race condition SourceManager now owns a switchSynchronizers() method that atomically swaps the synchronizer list under the existing lock, eliminating the window where two runSynchronizers() loops could push data into the update sink concurrently. FDv2DataSource keeps a single final SourceManager and uses an AtomicBoolean guard to ensure only one execution loop runs at a time. Made-with: Cursor --- .../sdk/android/FDv2DataSource.java | 207 ++++++++---------- .../sdk/android/SourceManager.java | 20 +- 2 files changed, 115 insertions(+), 112 deletions(-) diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSource.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSource.java index 6da69fc7..e47cd80f 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSource.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSource.java @@ -42,7 +42,7 @@ public interface DataSourceFactory { private final LDLogger logger; private final LDContext evaluationContext; private final DataSourceUpdateSinkV2 dataSourceUpdateSink; - private volatile SourceManager sourceManager; + private final SourceManager sourceManager; private final long fallbackTimeoutSeconds; private final long recoveryTimeoutSeconds; private final ScheduledExecutorService sharedExecutor; @@ -50,6 +50,7 @@ public interface DataSourceFactory { private final AtomicBoolean started = new AtomicBoolean(false); private final AtomicBoolean startCompleted = new AtomicBoolean(false); private final AtomicBoolean stopped = new AtomicBoolean(false); + private final AtomicBoolean executionLoopRunning = new AtomicBoolean(false); /** Result of the first start (null = not yet completed). Used so second start() gets the same result. */ private volatile Boolean startResult = null; @@ -138,21 +139,21 @@ public void start(@NonNull Callback resultCallback) { // race with a concurrent stop() and could undo it, causing a spurious OFF/exhaustion report. LDContext context = evaluationContext; - final SourceManager sm = sourceManager; sharedExecutor.execute(() -> { + executionLoopRunning.set(true); try { - if (!sm.hasAvailableSources()) { + if (!sourceManager.hasAvailableSources()) { logger.info("No initializers or synchronizers; data source will not connect."); dataSourceUpdateSink.setStatus(DataSourceState.VALID, null); tryCompleteStart(true, null); return; } - if (sm.hasInitializers()) { + if (sourceManager.hasInitializers()) { runInitializers(context, dataSourceUpdateSink); } - if (!sm.hasAvailableSynchronizers()) { + if (!sourceManager.hasAvailableSynchronizers()) { if (!startCompleted.get()) { maybeReportUnexpectedExhaustion("All initializers exhausted and there are no available synchronizers."); } @@ -160,16 +161,14 @@ public void start(@NonNull Callback resultCallback) { return; } - runSynchronizers(context, dataSourceUpdateSink, sm); - // Only report exhaustion if this SourceManager is still the active one - // (a concurrent switchMode() may have replaced it). - if (sourceManager == sm) { - maybeReportUnexpectedExhaustion("All data source acquisition methods have been exhausted."); - } + runSynchronizers(context, dataSourceUpdateSink); + maybeReportUnexpectedExhaustion("All data source acquisition methods have been exhausted."); tryCompleteStart(false, null); } catch (Throwable t) { logger.warn("FDv2DataSource error: {}", t.toString()); tryCompleteStart(false, t); + } finally { + executionLoopRunning.set(false); } }); } @@ -233,18 +232,17 @@ public void switchMode(@NonNull ResolvedModeDefinition newDefinition) { for (DataSourceFactory factory : newDefinition.getSynchronizerFactories()) { newSyncFactories.add(new SynchronizerFactoryWithState(factory)); } - // Per CONNMODE 2.0.1: mode switches only transition synchronizers, no initializers. - SourceManager newManager = new SourceManager( - newSyncFactories, - Collections.>emptyList() - ); - SourceManager oldManager = sourceManager; - sourceManager = newManager; - if (oldManager != null) { - oldManager.close(); - } + sourceManager.switchSynchronizers(newSyncFactories); + sharedExecutor.execute(() -> { - runSynchronizers(evaluationContext, dataSourceUpdateSink, newManager); + if (!executionLoopRunning.compareAndSet(false, true)) { + return; + } + try { + runSynchronizers(evaluationContext, dataSourceUpdateSink); + } finally { + executionLoopRunning.set(false); + } }); } @@ -329,106 +327,93 @@ private List getConditions(int synchronizerC private void runSynchronizers( @NonNull LDContext context, - @NonNull DataSourceUpdateSinkV2 sink, - @NonNull SourceManager sm + @NonNull DataSourceUpdateSinkV2 sink ) { - try { - Synchronizer synchronizer = sm.getNextAvailableSynchronizerAndSetActive(); - while (synchronizer != null) { - if (stopped.get()) { - return; - } - int synchronizerCount = sm.getAvailableSynchronizerCount(); - boolean isPrime = sm.isPrimeSynchronizer(); - try { - boolean running = true; - try (FDv2DataSourceConditions.Conditions conditions = - new FDv2DataSourceConditions.Conditions(getConditions(synchronizerCount, isPrime))) { - while (running) { - Future nextFuture = synchronizer.next(); - // Race the next synchronizer result against any active conditions - // (fallback/recovery timers). Whichever resolves first wins. - Object res = LDFutures.anyOf(conditions.getFuture(), nextFuture).get(); + Synchronizer synchronizer = sourceManager.getNextAvailableSynchronizerAndSetActive(); + while (synchronizer != null) { + if (stopped.get()) { + return; + } + int synchronizerCount = sourceManager.getAvailableSynchronizerCount(); + boolean isPrime = sourceManager.isPrimeSynchronizer(); + try { + boolean running = true; + try (FDv2DataSourceConditions.Conditions conditions = + new FDv2DataSourceConditions.Conditions(getConditions(synchronizerCount, isPrime))) { + while (running) { + Future nextFuture = synchronizer.next(); + Object res = LDFutures.anyOf(conditions.getFuture(), nextFuture).get(); - if (res instanceof FDv2DataSourceConditions.ConditionType) { - FDv2DataSourceConditions.ConditionType ct = (FDv2DataSourceConditions.ConditionType) res; - switch (ct) { - case FALLBACK: - logger.debug("Synchronizer {} experienced an interruption; falling back to next synchronizer.", - synchronizer.getClass().getSimpleName()); - break; - case RECOVERY: - logger.debug("The data source is attempting to recover to a higher priority synchronizer."); - sm.resetSourceIndex(); - break; - } - running = false; - break; + if (res instanceof FDv2DataSourceConditions.ConditionType) { + FDv2DataSourceConditions.ConditionType ct = (FDv2DataSourceConditions.ConditionType) res; + switch (ct) { + case FALLBACK: + logger.debug("Synchronizer {} experienced an interruption; falling back to next synchronizer.", + synchronizer.getClass().getSimpleName()); + break; + case RECOVERY: + logger.debug("The data source is attempting to recover to a higher priority synchronizer."); + sourceManager.resetSourceIndex(); + break; } + running = false; + break; + } - if (!(res instanceof FDv2SourceResult)) { - logger.error("Unexpected result type from synchronizer: {}", res != null ? res.getClass().getName() : "null"); - continue; - } + if (!(res instanceof FDv2SourceResult)) { + logger.error("Unexpected result type from synchronizer: {}", res != null ? res.getClass().getName() : "null"); + continue; + } - FDv2SourceResult result = (FDv2SourceResult) res; - // Let conditions observe the result before we act on it so - // they can update their internal state (e.g. reset interruption timers). - conditions.inform(result); + FDv2SourceResult result = (FDv2SourceResult) res; + conditions.inform(result); - switch (result.getResultType()) { - case CHANGE_SET: - ChangeSet> changeSet = result.getChangeSet(); - if (changeSet != null) { - sink.apply(context, changeSet); - sink.setStatus(DataSourceState.VALID, null); - tryCompleteStart(true, null); - } - break; - case STATUS: - FDv2SourceResult.Status status = result.getStatus(); - if (status != null) { - switch (status.getState()) { - case INTERRUPTED: - sink.setStatus(DataSourceState.INTERRUPTED, status.getError()); - break; - case SHUTDOWN: - // This synchronizer is shutting down cleanly/intentionally - running = false; - break; - case TERMINAL_ERROR: - // This synchronizer cannot recover; block it so the outer - // loop advances to the next available synchronizer. - sm.blockCurrentSynchronizer(); - running = false; - sink.setStatus(DataSourceState.INTERRUPTED, status.getError()); - break; - case GOODBYE: - // We let the synchronizer handle this internally. - break; - default: - break; - } + switch (result.getResultType()) { + case CHANGE_SET: + ChangeSet> changeSet = result.getChangeSet(); + if (changeSet != null) { + sink.apply(context, changeSet); + sink.setStatus(DataSourceState.VALID, null); + tryCompleteStart(true, null); + } + break; + case STATUS: + FDv2SourceResult.Status status = result.getStatus(); + if (status != null) { + switch (status.getState()) { + case INTERRUPTED: + sink.setStatus(DataSourceState.INTERRUPTED, status.getError()); + break; + case SHUTDOWN: + running = false; + break; + case TERMINAL_ERROR: + sourceManager.blockCurrentSynchronizer(); + running = false; + sink.setStatus(DataSourceState.INTERRUPTED, status.getError()); + break; + case GOODBYE: + break; + default: + break; } - break; - } + } + break; } } - } catch (ExecutionException e) { - logger.warn("Synchronizer error: {}", e.getCause() != null ? e.getCause().toString() : e.toString()); - sink.setStatus(DataSourceState.INTERRUPTED, e.getCause() != null ? e.getCause() : e); - } catch (CancellationException e) { - logger.warn("Synchronizer cancelled: {}", e.toString()); - sink.setStatus(DataSourceState.INTERRUPTED, e); - } catch (InterruptedException e) { - logger.warn("Synchronizer interrupted: {}", e.toString()); - sink.setStatus(DataSourceState.INTERRUPTED, e); - return; } - synchronizer = sm.getNextAvailableSynchronizerAndSetActive(); + } catch (ExecutionException e) { + logger.warn("Synchronizer error: {}", e.getCause() != null ? e.getCause().toString() : e.toString()); + sink.setStatus(DataSourceState.INTERRUPTED, e.getCause() != null ? e.getCause() : e); + } catch (CancellationException e) { + logger.warn("Synchronizer cancelled: {}", e.toString()); + sink.setStatus(DataSourceState.INTERRUPTED, e); + } catch (InterruptedException e) { + logger.warn("Synchronizer interrupted: {}", e.toString()); + sink.setStatus(DataSourceState.INTERRUPTED, e); + return; } - } finally { - sm.close(); + synchronizer = sourceManager.getNextAvailableSynchronizerAndSetActive(); } } } diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/SourceManager.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/SourceManager.java index 4d945eef..f3b307b0 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/SourceManager.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/SourceManager.java @@ -18,7 +18,7 @@ */ final class SourceManager implements Closeable { - private final List synchronizerFactories; + private List synchronizerFactories; private final List> initializers; private final Object activeSourceLock = new Object(); @@ -49,6 +49,24 @@ void resetSourceIndex() { } } + /** + * Atomically replaces the synchronizer list, closing any active source and resetting + * the synchronizer index. Used by {@link FDv2DataSource#switchMode} to swap synchronizers + * without creating a new SourceManager, preventing concurrent loops from pushing data + * into the update sink simultaneously. + */ + void switchSynchronizers(@NonNull List newFactories) { + synchronized (activeSourceLock) { + if (activeSource != null) { + safeClose(activeSource); + activeSource = null; + } + synchronizerFactories = newFactories; + synchronizerIndex = -1; + currentSynchronizerFactory = null; + } + } + /** True if any synchronizer is marked as FDv1 fallback (Android: not used yet). */ boolean hasFDv1Fallback() { for (SynchronizerFactoryWithState s : synchronizerFactories) { From e623fd6e380f82c57f1d1a10abab245f798ef3a5 Mon Sep 17 00:00:00 2001 From: Aaron Zeisler Date: Wed, 18 Mar 2026 15:40:32 -0700 Subject: [PATCH 11/17] [SDK-1956] refactor: move needsRefresh and FDv1/FDv2 branching into updateDataSource handleModeStateChange() now simply updates the event processor and delegates to updateDataSource(). The FDv2 ModeAware early-return and FDv1 needsRefresh() check both live inside updateDataSource, keeping the branching logic in one place. Made-with: Cursor --- .../sdk/android/ConnectivityManager.java | 28 +++++++++++++------ .../sdk/android/FDv2DataSource.java | 1 + 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ConnectivityManager.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ConnectivityManager.java index ace1f26f..40e51609 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ConnectivityManager.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ConnectivityManager.java @@ -189,6 +189,24 @@ private synchronized boolean updateDataSource( return false; } + DataSource existingDataSource = currentDataSource.get(); + + // FDv2 ModeAware data sources handle all state transitions (including + // offline/background) via mode resolution rather than teardown/rebuild. + if (!mustReinitializeDataSource && existingDataSource instanceof ModeAware) { + resolveAndSwitchMode((ModeAware) existingDataSource); + onCompletion.onSuccess(null); + return false; + } + + // FDv1 path: check whether the data source needs a full rebuild. + if (!mustReinitializeDataSource && existingDataSource != null) { + boolean inBackground = !platformState.isForeground(); + if (existingDataSource.needsRefresh(inBackground, currentContext.get())) { + mustReinitializeDataSource = true; + } + } + boolean forceOffline = forcedOffline.get(); boolean networkEnabled = platformState.isNetworkAvailable(); boolean inBackground = !platformState.isForeground(); @@ -454,15 +472,7 @@ private void handleModeStateChange() { boolean foreground = platformState.isForeground(); updateEventProcessor(forceOffline, networkAvailable, foreground); - - DataSource dataSource = currentDataSource.get(); - if (dataSource instanceof ModeAware) { - resolveAndSwitchMode((ModeAware) dataSource); - } else if (dataSource != null && dataSource.needsRefresh(!foreground, currentContext.get())) { - updateDataSource(true, LDUtil.noOpCallback()); - } else { - updateDataSource(false, LDUtil.noOpCallback()); - } + updateDataSource(false, LDUtil.noOpCallback()); } /** diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSource.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSource.java index e47cd80f..b76be9fa 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSource.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSource.java @@ -232,6 +232,7 @@ public void switchMode(@NonNull ResolvedModeDefinition newDefinition) { for (DataSourceFactory factory : newDefinition.getSynchronizerFactories()) { newSyncFactories.add(new SynchronizerFactoryWithState(factory)); } + // Per CONNMODE 2.0.1: mode switches only transition synchronizers, no initializers. sourceManager.switchSynchronizers(newSyncFactories); sharedExecutor.execute(() -> { From 422bea21a5f899d9b534a93acda909a9b16941bc Mon Sep 17 00:00:00 2001 From: Aaron Zeisler Date: Thu, 19 Mar 2026 14:57:46 -0700 Subject: [PATCH 12/17] [SDK-1956] Replace internal switchMode() with teardown/rebuild at ConnectivityManager level Per updated CSFDV2 spec and JS implementation, mode switching now tears down the old data source and builds a new one rather than swapping internal synchronizers. Delete ModeAware interface, remove switchMode() from FDv2DataSource and switchSynchronizers() from SourceManager. FDv2DataSourceBuilder becomes the sole owner of mode resolution via setActiveMode()/build(), with ConnectivityManager using a useFDv2ModeResolution flag to route FDv2 through the new path while preserving FDv1 behavior. Implements CSFDV2 5.3.8 (retain data source when old and new modes share the same ModeDefinition). Made-with: Cursor --- .../sdk/android/ConnectivityManager.java | 90 ++++---- .../sdk/android/FDv2DataSource.java | 32 +-- .../sdk/android/FDv2DataSourceBuilder.java | 76 ++++--- .../launchdarkly/sdk/android/ModeAware.java | 33 --- .../sdk/android/ResolvedModeDefinition.java | 3 - .../sdk/android/SourceManager.java | 20 +- .../sdk/android/ConnectivityManagerTest.java | 204 ++++++++---------- .../android/FDv2DataSourceBuilderTest.java | 119 +++++++--- .../sdk/android/FDv2DataSourceTest.java | 121 ----------- 9 files changed, 277 insertions(+), 421 deletions(-) delete mode 100644 launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeAware.java diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ConnectivityManager.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ConnectivityManager.java index 40e51609..79d8c2f7 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ConnectivityManager.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ConnectivityManager.java @@ -72,7 +72,7 @@ class ConnectivityManager { private final AtomicReference previouslyInBackground = new AtomicReference<>(); private final LDLogger logger; private volatile boolean initialized = false; - private volatile Map resolvedModeTable; + private final boolean useFDv2ModeResolution; private volatile ConnectionMode currentFDv2Mode; // The DataSourceUpdateSinkImpl receives flag updates and status updates from the DataSource. @@ -150,6 +150,7 @@ public void shutDown() { connectionInformation = new ConnectionInformationState(); readStoredConnectionState(); this.backgroundUpdatingDisabled = ldConfig.isDisableBackgroundPolling(); + this.useFDv2ModeResolution = (dataSourceFactory instanceof FDv2DataSourceBuilder); connectivityChangeListener = networkAvailable -> handleModeStateChange(); platformState.addConnectivityChangeListener(connectivityChangeListener); @@ -190,13 +191,27 @@ private synchronized boolean updateDataSource( } DataSource existingDataSource = currentDataSource.get(); + boolean isFDv2ModeSwitch = false; - // FDv2 ModeAware data sources handle all state transitions (including - // offline/background) via mode resolution rather than teardown/rebuild. - if (!mustReinitializeDataSource && existingDataSource instanceof ModeAware) { - resolveAndSwitchMode((ModeAware) existingDataSource); - onCompletion.onSuccess(null); - return false; + // FDv2 path: resolve mode and determine if a teardown/rebuild is needed. + if (useFDv2ModeResolution && !mustReinitializeDataSource) { + ConnectionMode newMode = resolveMode(); + if (newMode == currentFDv2Mode) { + onCompletion.onSuccess(null); + return false; + } + // CSFDV2 5.3.8: retain active data source if old and new modes have equivalent config. + FDv2DataSourceBuilder fdv2Builder = (FDv2DataSourceBuilder) dataSourceFactory; + ModeDefinition oldDef = fdv2Builder.getModeDefinition(currentFDv2Mode); + ModeDefinition newDef = fdv2Builder.getModeDefinition(newMode); + if (oldDef != null && oldDef == newDef) { + currentFDv2Mode = newMode; + onCompletion.onSuccess(null); + return false; + } + currentFDv2Mode = newMode; + isFDv2ModeSwitch = true; + mustReinitializeDataSource = true; } // FDv1 path: check whether the data source needs a full rebuild. @@ -215,7 +230,12 @@ private synchronized boolean updateDataSource( boolean shouldStopExistingDataSource = true, shouldStartDataSourceIfStopped = false; - if (forceOffline) { + if (useFDv2ModeResolution) { + // FDv2 mode resolution already accounts for offline/background states via + // the ModeResolutionTable, so we always rebuild when the mode changed. + shouldStopExistingDataSource = mustReinitializeDataSource; + shouldStartDataSourceIfStopped = true; + } else if (forceOffline) { logger.debug("Initialized in offline mode"); initialized = true; dataSourceUpdateSink.setStatus(ConnectionInformation.ConnectionMode.SET_OFFLINE, null); @@ -249,41 +269,32 @@ private synchronized boolean updateDataSource( previouslyInBackground.get(), transactionalDataStore ); - DataSource dataSource = dataSourceFactory.build(clientContext); - currentDataSource.set(dataSource); - previouslyInBackground.set(Boolean.valueOf(inBackground)); - if (dataSourceFactory instanceof FDv2DataSourceBuilder) { + if (useFDv2ModeResolution) { FDv2DataSourceBuilder fdv2Builder = (FDv2DataSourceBuilder) dataSourceFactory; - resolvedModeTable = fdv2Builder.getResolvedModeTable(); - currentFDv2Mode = fdv2Builder.getStartingMode(); + // CONNMODE 2.0.1: mode switches only transition synchronizers, not initializers. + fdv2Builder.setActiveMode(currentFDv2Mode, !isFDv2ModeSwitch); } + DataSource dataSource = dataSourceFactory.build(clientContext); + currentDataSource.set(dataSource); + previouslyInBackground.set(Boolean.valueOf(inBackground)); + dataSource.start(new Callback() { @Override public void onSuccess(Boolean result) { initialized = true; - // passing the current connection mode since we don't want to change the mode, just trigger - // the logic to update the last connection success. updateConnectionInfoForSuccess(connectionInformation.getConnectionMode()); onCompletion.onSuccess(null); } @Override public void onError(Throwable error) { - // passing the current connection mode since we don't want to change the mode, just trigger - // the logic to update the last connection failure. updateConnectionInfoForError(connectionInformation.getConnectionMode(), error); onCompletion.onSuccess(null); } }); - // If the app starts in the background, the builder creates the data source with - // STREAMING as the starting mode. Perform an initial mode resolution to correct this. - if (dataSource instanceof ModeAware) { - resolveAndSwitchMode((ModeAware) dataSource); - } - return true; } @@ -425,6 +436,13 @@ synchronized boolean startUp(@NonNull Callback onCompletion) { return false; } initialized = false; + + if (useFDv2ModeResolution) { + currentFDv2Mode = resolveMode(); + FDv2DataSourceBuilder fdv2Builder = (FDv2DataSourceBuilder) dataSourceFactory; + fdv2Builder.setActiveMode(currentFDv2Mode, true); + } + updateEventProcessor(forcedOffline.get(), platformState.isNetworkAvailable(), platformState.isForeground()); return updateDataSource(true, onCompletion); } @@ -476,15 +494,10 @@ private void handleModeStateChange() { } /** - * Resolves the current platform state to a ConnectionMode via the ModeResolutionTable, - * looks up the ResolvedModeDefinition from the resolved mode table, and calls - * switchMode() on the data source if the mode has changed. + * Resolves the current platform state to a {@link ConnectionMode} via the + * {@link ModeResolutionTable}. */ - private void resolveAndSwitchMode(@NonNull ModeAware modeAware) { - Map table = resolvedModeTable; - if (table == null) { - return; - } + private ConnectionMode resolveMode() { boolean forceOffline = forcedOffline.get(); boolean networkAvailable = platformState.isNetworkAvailable(); boolean foreground = platformState.isForeground(); @@ -492,18 +505,7 @@ private void resolveAndSwitchMode(@NonNull ModeAware modeAware) { foreground && !forceOffline, networkAvailable && !forceOffline ); - ConnectionMode newMode = ModeResolutionTable.MOBILE.resolve(state); - if (newMode == currentFDv2Mode) { - return; - } - currentFDv2Mode = newMode; - ResolvedModeDefinition def = table.get(newMode); - if (def == null) { - logger.warn("No resolved definition for mode {}; skipping switchMode", newMode); - return; - } - logger.debug("Switching FDv2 mode to {}", newMode); - modeAware.switchMode(def); + return ModeResolutionTable.MOBILE.resolve(state); } synchronized ConnectionInformation getConnectionInformation() { diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSource.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSource.java index b76be9fa..74f801f3 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSource.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSource.java @@ -30,7 +30,7 @@ * switch to next synchronizer) and recovery (when on non-prime synchronizer, try * to return to the first after timeout). */ -final class FDv2DataSource implements DataSource, ModeAware { +final class FDv2DataSource implements DataSource { /** * Factory for creating Initializer or Synchronizer instances. @@ -50,8 +50,6 @@ public interface DataSourceFactory { private final AtomicBoolean started = new AtomicBoolean(false); private final AtomicBoolean startCompleted = new AtomicBoolean(false); private final AtomicBoolean stopped = new AtomicBoolean(false); - private final AtomicBoolean executionLoopRunning = new AtomicBoolean(false); - /** Result of the first start (null = not yet completed). Used so second start() gets the same result. */ private volatile Boolean startResult = null; private volatile Throwable startError = null; @@ -140,7 +138,6 @@ public void start(@NonNull Callback resultCallback) { LDContext context = evaluationContext; sharedExecutor.execute(() -> { - executionLoopRunning.set(true); try { if (!sourceManager.hasAvailableSources()) { logger.info("No initializers or synchronizers; data source will not connect."); @@ -167,8 +164,6 @@ public void start(@NonNull Callback resultCallback) { } catch (Throwable t) { logger.warn("FDv2DataSource error: {}", t.toString()); tryCompleteStart(false, t); - } finally { - executionLoopRunning.set(false); } }); } @@ -221,32 +216,11 @@ public void stop(@NonNull Callback completionCallback) { @Override public boolean needsRefresh(boolean newInBackground, @NonNull LDContext newEvaluationContext) { - // Mode-aware data sources handle background/foreground transitions via switchMode(), - // so only request a full rebuild when the evaluation context changes. + // FDv2 background/foreground transitions are handled externally by ConnectivityManager + // via teardown/rebuild, so only request a rebuild when the evaluation context changes. return !evaluationContext.equals(newEvaluationContext); } - @Override - public void switchMode(@NonNull ResolvedModeDefinition newDefinition) { - List newSyncFactories = new ArrayList<>(); - for (DataSourceFactory factory : newDefinition.getSynchronizerFactories()) { - newSyncFactories.add(new SynchronizerFactoryWithState(factory)); - } - // Per CONNMODE 2.0.1: mode switches only transition synchronizers, no initializers. - sourceManager.switchSynchronizers(newSyncFactories); - - sharedExecutor.execute(() -> { - if (!executionLoopRunning.compareAndSet(false, true)) { - return; - } - try { - runSynchronizers(evaluationContext, dataSourceUpdateSink); - } finally { - executionLoopRunning.set(false); - } - }); - } - private void runInitializers( @NonNull LDContext context, @NonNull DataSourceUpdateSinkV2 sink diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilder.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilder.java index 1b786dc5..87bf7118 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilder.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilder.java @@ -25,13 +25,10 @@ import java.util.concurrent.ScheduledExecutorService; /** - * Builds an {@link FDv2DataSource} and resolves the mode table from - * {@link ComponentConfigurer} factories into zero-arg {@link FDv2DataSource.DataSourceFactory} - * instances. The resolved table is stored and exposed via {@link #getResolvedModeTable()} - * so that {@link ConnectivityManager} can perform mode→definition lookups when switching modes. - *

- * This is the key architectural difference in Approach 2: the builder owns the resolved - * table rather than the data source itself. + * Builds an {@link FDv2DataSource} by resolving {@link ComponentConfigurer} factories + * into zero-arg {@link FDv2DataSource.DataSourceFactory} instances. The builder is the + * sole owner of mode resolution; {@link ConnectivityManager} configures the target mode + * via {@link #setActiveMode} before calling the standard {@link #build}. *

* Package-private — not part of the public SDK API. */ @@ -40,7 +37,9 @@ class FDv2DataSourceBuilder implements ComponentConfigurer { private final Map modeTable; private final ConnectionMode startingMode; - private Map resolvedModeTable; + private ConnectionMode activeMode; + private boolean includeInitializers = true; // false during mode switches to skip initializers (CONNMODE 2.0.1) + private ScheduledExecutorService sharedExecutor; FDv2DataSourceBuilder() { this(makeDefaultModeTable(), ConnectionMode.STREAMING); @@ -175,53 +174,64 @@ private static Map makeDefaultModeTable() { this.startingMode = startingMode; } - /** - * Returns the resolved mode table after {@link #build} has been called. - * Each entry maps a {@link ConnectionMode} to a {@link ResolvedModeDefinition} - * containing zero-arg factories that capture the {@link ClientContext}. - * - * @return unmodifiable map of resolved mode definitions - * @throws IllegalStateException if called before {@link #build} - */ @NonNull ConnectionMode getStartingMode() { return startingMode; } - @NonNull - Map getResolvedModeTable() { - if (resolvedModeTable == null) { - throw new IllegalStateException("build() must be called before getResolvedModeTable()"); - } - return resolvedModeTable; + /** + * Configures the mode to build for and whether to include initializers. + * Called by {@link ConnectivityManager} before each {@link #build} call. + * + * @param mode the target connection mode + * @param includeInitializers true for initial startup / identify, false for mode switches + * (per CONNMODE 2.0.1: mode switches only transition synchronizers) + */ + void setActiveMode(@NonNull ConnectionMode mode, boolean includeInitializers) { + this.activeMode = mode; + this.includeInitializers = includeInitializers; + } + + /** + * Returns the raw {@link ModeDefinition} for the given mode, used by + * {@link ConnectivityManager} for the CSFDV2 5.3.8 equivalence check. + */ + ModeDefinition getModeDefinition(@NonNull ConnectionMode mode) { + return modeTable.get(mode); } @Override public DataSource build(ClientContext clientContext) { - Map resolved = new LinkedHashMap<>(); - for (Map.Entry entry : modeTable.entrySet()) { - resolved.put(entry.getKey(), resolve(entry.getValue(), clientContext)); - } - this.resolvedModeTable = Collections.unmodifiableMap(resolved); + ConnectionMode mode = activeMode != null ? activeMode : startingMode; - ResolvedModeDefinition startDef = resolvedModeTable.get(startingMode); - if (startDef == null) { + ModeDefinition modeDef = modeTable.get(mode); + if (modeDef == null) { throw new IllegalStateException( - "Starting mode " + startingMode + " not found in mode table"); + "Mode " + mode + " not found in mode table"); } + ResolvedModeDefinition resolved = resolve(modeDef, clientContext); + DataSourceUpdateSink baseSink = clientContext.getDataSourceUpdateSink(); if (!(baseSink instanceof DataSourceUpdateSinkV2)) { throw new IllegalStateException( "FDv2DataSource requires a DataSourceUpdateSinkV2 implementation"); } - ScheduledExecutorService sharedExecutor = Executors.newScheduledThreadPool(2); + if (sharedExecutor == null) { + sharedExecutor = Executors.newScheduledThreadPool(2); + } + + List> initFactories = + includeInitializers ? resolved.getInitializerFactories() : Collections.>emptyList(); + + // Reset includeInitializers to default after each build to prevent stale state. + includeInitializers = true; return new FDv2DataSource( clientContext.getEvaluationContext(), - startDef.getInitializerFactories(), - startDef.getSynchronizerFactories(), + initFactories, + resolved.getSynchronizerFactories(), (DataSourceUpdateSinkV2) baseSink, sharedExecutor, clientContext.getBaseLogger() diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeAware.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeAware.java deleted file mode 100644 index 929f1d4a..00000000 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeAware.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.launchdarkly.sdk.android; - -import androidx.annotation.NonNull; - -/** - * Supports runtime connection mode switching. - *

- * {@link ConnectivityManager} checks {@code instanceof ModeAware} to decide - * whether to use mode resolution (FDv2) or legacy teardown/rebuild behavior (FDv1). - *

- * The data source receives the full {@link ResolvedModeDefinition} — it has no - * internal mode table and does not know which named {@link ConnectionMode} it is - * operating in. The mode table and mode-to-definition lookup live in - * {@link ConnectivityManager}. - *

- * Package-private — not part of the public SDK API. - * - * @see ResolvedModeDefinition - * @see ModeResolutionTable - */ -interface ModeAware { - - /** - * Switches the data source to operate with the given mode definition. - * The implementation stops the current synchronizers and starts the new - * definition's synchronizers without re-running initializers - * (per CONNMODE spec 2.0.1). - * - * @param newDefinition the resolved initializer/synchronizer factories for - * the target mode - */ - void switchMode(@NonNull ResolvedModeDefinition newDefinition); -} diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ResolvedModeDefinition.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ResolvedModeDefinition.java index fccffc7a..e404a5ac 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ResolvedModeDefinition.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ResolvedModeDefinition.java @@ -15,13 +15,10 @@ * a {@link com.launchdarkly.sdk.android.subsystems.ClientContext}. *

* Instances are immutable and created by {@code FDv2DataSourceBuilder} at build time. - * {@link ConnectivityManager} passes these to {@link ModeAware#switchMode} when the - * resolved connection mode changes. *

* Package-private — not part of the public SDK API. * * @see ModeDefinition - * @see ModeAware */ final class ResolvedModeDefinition { diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/SourceManager.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/SourceManager.java index f3b307b0..4d945eef 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/SourceManager.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/SourceManager.java @@ -18,7 +18,7 @@ */ final class SourceManager implements Closeable { - private List synchronizerFactories; + private final List synchronizerFactories; private final List> initializers; private final Object activeSourceLock = new Object(); @@ -49,24 +49,6 @@ void resetSourceIndex() { } } - /** - * Atomically replaces the synchronizer list, closing any active source and resetting - * the synchronizer index. Used by {@link FDv2DataSource#switchMode} to swap synchronizers - * without creating a new SourceManager, preventing concurrent loops from pushing data - * into the update sink simultaneously. - */ - void switchSynchronizers(@NonNull List newFactories) { - synchronized (activeSourceLock) { - if (activeSource != null) { - safeClose(activeSource); - activeSource = null; - } - synchronizerFactories = newFactories; - synchronizerIndex = -1; - currentSynchronizerFactory = null; - } - } - /** True if any synchronizer is marked as FDv1 fallback (Android: not used yet). */ boolean hasFDv1Fallback() { for (SynchronizerFactoryWithState s : synchronizerFactories) { diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ConnectivityManagerTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ConnectivityManagerTest.java index 018a674c..3980f36b 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ConnectivityManagerTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ConnectivityManagerTest.java @@ -37,7 +37,6 @@ import org.junit.Test; import org.junit.rules.Timeout; -import com.launchdarkly.sdk.android.subsystems.DataSourceState; import com.launchdarkly.sdk.android.subsystems.Initializer; import com.launchdarkly.sdk.android.subsystems.Synchronizer; @@ -665,59 +664,14 @@ private void verifyNoMoreDataSourcesWereStopped() { requireNoMoreValues(stoppedDataSources, 1, TimeUnit.SECONDS, "stopping of data source"); } - // ==== ModeAware tests ==== + // ==== FDv2 mode resolution tests ==== /** - * A mock ModeAware data source that records switchMode calls and - * signals start success immediately. + * Creates a test FDv2DataSourceBuilder that returns mock data sources + * which track start/stop via the shared queues. Each build() call creates + * a new mock data source. */ - private static class MockModeAwareDataSource implements DataSource, ModeAware { - final BlockingQueue switchModeCalls = new LinkedBlockingQueue<>(); - private final BlockingQueue startedQueue; - private volatile com.launchdarkly.sdk.android.subsystems.DataSourceUpdateSink sink; - - MockModeAwareDataSource(BlockingQueue startedQueue) { - this.startedQueue = startedQueue; - } - - void setSink(com.launchdarkly.sdk.android.subsystems.DataSourceUpdateSink sink) { - this.sink = sink; - } - - @Override - public void start(@NonNull Callback resultCallback) { - startedQueue.add(this); - new Thread(() -> { - if (sink != null) { - sink.setStatus(ConnectionMode.STREAMING, null); - } - resultCallback.onSuccess(true); - }).start(); - } - - @Override - public void stop(@NonNull Callback completionCallback) { - completionCallback.onSuccess(null); - } - - @Override - public boolean needsRefresh(boolean newInBackground, @NonNull LDContext newEvaluationContext) { - return false; - } - - @Override - public void switchMode(@NonNull ResolvedModeDefinition newDefinition) { - switchModeCalls.add(newDefinition); - } - } - - /** - * Creates a test FDv2DataSourceBuilder that returns a MockModeAwareDataSource. - * The resolved mode table contains STREAMING, BACKGROUND, and OFFLINE modes. - */ - private FDv2DataSourceBuilder makeModeAwareDataSourceFactory( - MockModeAwareDataSource mockDataSource - ) { + private FDv2DataSourceBuilder makeFDv2DataSourceFactory() { Map table = new LinkedHashMap<>(); table.put(com.launchdarkly.sdk.android.ConnectionMode.STREAMING, new ModeDefinition( Collections.>emptyList(), @@ -734,37 +688,35 @@ private FDv2DataSourceBuilder makeModeAwareDataSourceFactory( return new FDv2DataSourceBuilder(table, com.launchdarkly.sdk.android.ConnectionMode.STREAMING) { @Override public DataSource build(ClientContext clientContext) { - super.build(clientContext); receivedClientContexts.add(clientContext); - mockDataSource.setSink(clientContext.getDataSourceUpdateSink()); - return mockDataSource; + return MockComponents.successfulDataSource(clientContext, DATA, + ConnectionMode.STREAMING, startedDataSources, stoppedDataSources); } }; } @Test - public void modeAware_foregroundToBackground_switchesMode() throws Exception { + public void fdv2_foregroundToBackground_rebuildsDataSource() throws Exception { eventProcessor.setOffline(false); eventProcessor.setInBackground(false); eventProcessor.setOffline(false); eventProcessor.setInBackground(true); replayAll(); - MockModeAwareDataSource mockDS = new MockModeAwareDataSource(startedDataSources); - createTestManager(false, false, makeModeAwareDataSourceFactory(mockDS)); + createTestManager(false, false, makeFDv2DataSourceFactory()); awaitStartUp(); verifyForegroundDataSourceWasCreatedAndStarted(CONTEXT); mockPlatformState.setAndNotifyForegroundChangeListeners(false); - ResolvedModeDefinition def = mockDS.switchModeCalls.poll(2, TimeUnit.SECONDS); - assertNotNull("Expected switchMode call for background transition", def); + verifyDataSourceWasStopped(); + requireValue(receivedClientContexts, 1, TimeUnit.SECONDS, "new data source creation"); + requireValue(startedDataSources, 1, TimeUnit.SECONDS, "new data source started"); verifyAll(); - verifyNoMoreDataSourcesWereCreated(); } @Test - public void modeAware_backgroundToForeground_switchesMode() throws Exception { + public void fdv2_backgroundToForeground_rebuildsDataSource() throws Exception { eventProcessor.setOffline(false); eventProcessor.setInBackground(false); eventProcessor.setOffline(false); @@ -773,133 +725,161 @@ public void modeAware_backgroundToForeground_switchesMode() throws Exception { eventProcessor.setInBackground(false); replayAll(); - MockModeAwareDataSource mockDS = new MockModeAwareDataSource(startedDataSources); - createTestManager(false, false, makeModeAwareDataSourceFactory(mockDS)); + createTestManager(false, false, makeFDv2DataSourceFactory()); awaitStartUp(); verifyForegroundDataSourceWasCreatedAndStarted(CONTEXT); mockPlatformState.setAndNotifyForegroundChangeListeners(false); - ResolvedModeDefinition def1 = mockDS.switchModeCalls.poll(2, TimeUnit.SECONDS); - assertNotNull("Expected switchMode for background", def1); + verifyDataSourceWasStopped(); + requireValue(receivedClientContexts, 1, TimeUnit.SECONDS, "bg data source creation"); + requireValue(startedDataSources, 1, TimeUnit.SECONDS, "bg data source started"); mockPlatformState.setAndNotifyForegroundChangeListeners(true); - ResolvedModeDefinition def2 = mockDS.switchModeCalls.poll(2, TimeUnit.SECONDS); - assertNotNull("Expected switchMode for foreground", def2); + verifyDataSourceWasStopped(); + requireValue(receivedClientContexts, 1, TimeUnit.SECONDS, "fg data source creation"); + requireValue(startedDataSources, 1, TimeUnit.SECONDS, "fg data source started"); verifyAll(); - verifyNoMoreDataSourcesWereCreated(); } @Test - public void modeAware_networkLost_switchesToOffline() throws Exception { + public void fdv2_networkLost_rebuildsToOffline() throws Exception { eventProcessor.setOffline(false); eventProcessor.setInBackground(false); eventProcessor.setOffline(true); eventProcessor.setInBackground(false); replayAll(); - MockModeAwareDataSource mockDS = new MockModeAwareDataSource(startedDataSources); - createTestManager(false, false, makeModeAwareDataSourceFactory(mockDS)); + createTestManager(false, false, makeFDv2DataSourceFactory()); awaitStartUp(); verifyForegroundDataSourceWasCreatedAndStarted(CONTEXT); mockPlatformState.setAndNotifyConnectivityChangeListeners(false); - ResolvedModeDefinition def = mockDS.switchModeCalls.poll(2, TimeUnit.SECONDS); - assertNotNull("Expected switchMode call for offline", def); - assertTrue("OFFLINE mode should have no synchronizers", - def.getSynchronizerFactories().isEmpty()); - + verifyDataSourceWasStopped(); + // OFFLINE mode should still build a new data source (with no synchronizers) + requireValue(receivedClientContexts, 1, TimeUnit.SECONDS, "offline data source creation"); + requireValue(startedDataSources, 1, TimeUnit.SECONDS, "offline data source started"); verifyAll(); - verifyNoMoreDataSourcesWereCreated(); } @Test - public void modeAware_forceOffline_switchesToOffline() throws Exception { + public void fdv2_forceOffline_rebuildsToOffline() throws Exception { eventProcessor.setOffline(false); eventProcessor.setInBackground(false); eventProcessor.setOffline(true); eventProcessor.setInBackground(false); replayAll(); - MockModeAwareDataSource mockDS = new MockModeAwareDataSource(startedDataSources); - createTestManager(false, false, makeModeAwareDataSourceFactory(mockDS)); + createTestManager(false, false, makeFDv2DataSourceFactory()); awaitStartUp(); - requireValue(startedDataSources, 1, TimeUnit.SECONDS, "data source started"); + verifyForegroundDataSourceWasCreatedAndStarted(CONTEXT); connectivityManager.setForceOffline(true); - ResolvedModeDefinition def = mockDS.switchModeCalls.poll(2, TimeUnit.SECONDS); - assertNotNull("Expected switchMode call for forced offline", def); - assertTrue("OFFLINE mode should have no synchronizers", - def.getSynchronizerFactories().isEmpty()); - + verifyDataSourceWasStopped(); + requireValue(receivedClientContexts, 1, TimeUnit.SECONDS, "offline data source creation"); + requireValue(startedDataSources, 1, TimeUnit.SECONDS, "offline data source started"); verifyAll(); } @Test - public void modeAware_doesNotTearDownOnForegroundChange() throws Exception { + public void fdv2_sameModeDoesNotRebuild() throws Exception { eventProcessor.setOffline(false); eventProcessor.setInBackground(false); eventProcessor.setOffline(false); - eventProcessor.setInBackground(true); + eventProcessor.setInBackground(false); replayAll(); - MockModeAwareDataSource mockDS = new MockModeAwareDataSource(startedDataSources); - createTestManager(false, false, makeModeAwareDataSourceFactory(mockDS)); + createTestManager(false, false, makeFDv2DataSourceFactory()); awaitStartUp(); verifyForegroundDataSourceWasCreatedAndStarted(CONTEXT); - mockPlatformState.setAndNotifyForegroundChangeListeners(false); + mockPlatformState.setAndNotifyForegroundChangeListeners(true); - ResolvedModeDefinition def = mockDS.switchModeCalls.poll(2, TimeUnit.SECONDS); - assertNotNull(def); verifyNoMoreDataSourcesWereCreated(); + verifyNoMoreDataSourcesWereStopped(); verifyAll(); } @Test - public void modeAware_sameModeDoesNotTriggerSwitch() throws Exception { + public void fdv2_equivalentConfigDoesNotRebuild() throws Exception { + ModeDefinition sharedDef = new ModeDefinition( + Collections.>emptyList(), + Collections.>singletonList(ctx -> null) + ); + Map table = new LinkedHashMap<>(); + table.put(com.launchdarkly.sdk.android.ConnectionMode.STREAMING, sharedDef); + table.put(com.launchdarkly.sdk.android.ConnectionMode.BACKGROUND, sharedDef); + + FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(table, com.launchdarkly.sdk.android.ConnectionMode.STREAMING) { + @Override + public DataSource build(ClientContext clientContext) { + receivedClientContexts.add(clientContext); + return MockComponents.successfulDataSource(clientContext, DATA, + ConnectionMode.STREAMING, startedDataSources, stoppedDataSources); + } + }; + eventProcessor.setOffline(false); eventProcessor.setInBackground(false); eventProcessor.setOffline(false); - eventProcessor.setInBackground(false); + eventProcessor.setInBackground(true); replayAll(); - MockModeAwareDataSource mockDS = new MockModeAwareDataSource(startedDataSources); - createTestManager(false, false, makeModeAwareDataSourceFactory(mockDS)); + createTestManager(false, false, builder); awaitStartUp(); - requireValue(startedDataSources, 1, TimeUnit.SECONDS, "data source started"); - - // Fire a foreground event when already in foreground — should not trigger switchMode - mockPlatformState.setAndNotifyForegroundChangeListeners(true); + verifyForegroundDataSourceWasCreatedAndStarted(CONTEXT); - ResolvedModeDefinition def = mockDS.switchModeCalls.poll(500, TimeUnit.MILLISECONDS); - assertNull("Should not switchMode when mode hasn't changed", def); + // STREAMING and BACKGROUND share the same ModeDefinition object, so 5.3.8 says no rebuild + mockPlatformState.setAndNotifyForegroundChangeListeners(false); + verifyNoMoreDataSourcesWereCreated(); + verifyNoMoreDataSourcesWereStopped(); verifyAll(); } @Test - public void modeAware_switchModePassesResolvedDefinition() throws Exception { + public void fdv2_modeSwitchDoesNotIncludeInitializers() throws Exception { + BlockingQueue initializerIncluded = new LinkedBlockingQueue<>(); + + Map table = new LinkedHashMap<>(); + table.put(com.launchdarkly.sdk.android.ConnectionMode.STREAMING, new ModeDefinition( + Collections.>singletonList(ctx -> null), + Collections.>singletonList(ctx -> null) + )); + table.put(com.launchdarkly.sdk.android.ConnectionMode.BACKGROUND, new ModeDefinition( + Collections.>singletonList(ctx -> null), + Collections.>singletonList(ctx -> null) + )); + + FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(table, com.launchdarkly.sdk.android.ConnectionMode.STREAMING) { + @Override + public DataSource build(ClientContext clientContext) { + // After setActiveMode(mode, includeInitializers), build() resets includeInitializers + // to true. We can observe this by checking what build() would produce. The super.build() + // uses the includeInitializers flag internally. + receivedClientContexts.add(clientContext); + return MockComponents.successfulDataSource(clientContext, DATA, + ConnectionMode.STREAMING, startedDataSources, stoppedDataSources); + } + }; + eventProcessor.setOffline(false); eventProcessor.setInBackground(false); eventProcessor.setOffline(false); eventProcessor.setInBackground(true); replayAll(); - MockModeAwareDataSource mockDS = new MockModeAwareDataSource(startedDataSources); - createTestManager(false, false, makeModeAwareDataSourceFactory(mockDS)); + createTestManager(false, false, builder); awaitStartUp(); - requireValue(startedDataSources, 1, TimeUnit.SECONDS, "data source started"); + verifyForegroundDataSourceWasCreatedAndStarted(CONTEXT); mockPlatformState.setAndNotifyForegroundChangeListeners(false); - ResolvedModeDefinition def = mockDS.switchModeCalls.poll(2, TimeUnit.SECONDS); - assertNotNull("Expected switchMode call", def); - assertNotNull("Definition should have synchronizer factories", def.getSynchronizerFactories()); - assertNotNull("Definition should have initializer factories", def.getInitializerFactories()); - + verifyDataSourceWasStopped(); + requireValue(receivedClientContexts, 1, TimeUnit.SECONDS, "bg data source creation"); + requireValue(startedDataSources, 1, TimeUnit.SECONDS, "bg data source started"); verifyAll(); } } \ No newline at end of file diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilderTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilderTest.java index 3e15d053..fe5928ce 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilderTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilderTest.java @@ -2,6 +2,7 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; @@ -56,26 +57,10 @@ public void defaultBuilder_buildsFDv2DataSource() { DataSource ds = builder.build(makeClientContext()); assertNotNull(ds); assertTrue(ds instanceof FDv2DataSource); - assertTrue(ds instanceof ModeAware); } @Test - public void resolvedModeTable_availableAfterBuild() { - FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(); - builder.build(makeClientContext()); - Map table = builder.getResolvedModeTable(); - assertNotNull(table); - assertEquals(5, table.size()); - } - - @Test(expected = IllegalStateException.class) - public void resolvedModeTable_throwsBeforeBuild() { - FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(); - builder.getResolvedModeTable(); - } - - @Test - public void customModeTable_resolvesCorrectly() { + public void customModeTable_buildsCorrectly() { Map customTable = new LinkedHashMap<>(); customTable.put(ConnectionMode.POLLING, new ModeDefinition( Collections.>emptyList(), @@ -85,10 +70,6 @@ public void customModeTable_resolvesCorrectly() { FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(customTable, ConnectionMode.POLLING); DataSource ds = builder.build(makeClientContext()); assertNotNull(ds); - - Map table = builder.getResolvedModeTable(); - assertEquals(1, table.size()); - assertTrue(table.containsKey(ConnectionMode.POLLING)); } @Test @@ -109,7 +90,25 @@ public void startingMode_notInTable_throws() { } @Test - public void resolvedDefinition_hasSameSizeAsOriginal() { + public void setActiveMode_buildUsesSpecifiedMode() { + Map customTable = new LinkedHashMap<>(); + customTable.put(ConnectionMode.STREAMING, new ModeDefinition( + Collections.>singletonList(ctx -> null), + Collections.>singletonList(ctx -> null) + )); + customTable.put(ConnectionMode.POLLING, new ModeDefinition( + Collections.>emptyList(), + Collections.>singletonList(ctx -> null) + )); + + FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(customTable, ConnectionMode.STREAMING); + builder.setActiveMode(ConnectionMode.POLLING, true); + DataSource ds = builder.build(makeClientContext()); + assertNotNull(ds); + } + + @Test + public void setActiveMode_withoutInitializers_buildsWithEmptyInitializers() { Map customTable = new LinkedHashMap<>(); customTable.put(ConnectionMode.STREAMING, new ModeDefinition( Collections.>singletonList(ctx -> null), @@ -117,11 +116,77 @@ public void resolvedDefinition_hasSameSizeAsOriginal() { )); FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(customTable, ConnectionMode.STREAMING); - builder.build(makeClientContext()); + builder.setActiveMode(ConnectionMode.STREAMING, false); + DataSource ds = builder.build(makeClientContext()); + assertNotNull(ds); + } + + @Test + public void defaultBehavior_usesStartingMode() { + Map customTable = new LinkedHashMap<>(); + customTable.put(ConnectionMode.STREAMING, new ModeDefinition( + Collections.>singletonList(ctx -> null), + Collections.>singletonList(ctx -> null) + )); + + FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(customTable, ConnectionMode.STREAMING); + DataSource ds = builder.build(makeClientContext()); + assertNotNull(ds); + } + + @Test + public void getModeDefinition_returnsCorrectDefinition() { + ModeDefinition streamingDef = new ModeDefinition( + Collections.>singletonList(ctx -> null), + Collections.>singletonList(ctx -> null) + ); + ModeDefinition pollingDef = new ModeDefinition( + Collections.>emptyList(), + Collections.>singletonList(ctx -> null) + ); + + Map customTable = new LinkedHashMap<>(); + customTable.put(ConnectionMode.STREAMING, streamingDef); + customTable.put(ConnectionMode.POLLING, pollingDef); + + FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(customTable, ConnectionMode.STREAMING); + assertEquals(streamingDef, builder.getModeDefinition(ConnectionMode.STREAMING)); + assertEquals(pollingDef, builder.getModeDefinition(ConnectionMode.POLLING)); + assertNull(builder.getModeDefinition(ConnectionMode.OFFLINE)); + } + + @Test + public void getModeDefinition_sameObjectUsedForEquivalenceCheck() { + ModeDefinition sharedDef = new ModeDefinition( + Collections.>emptyList(), + Collections.>singletonList(ctx -> null) + ); + + Map customTable = new LinkedHashMap<>(); + customTable.put(ConnectionMode.STREAMING, sharedDef); + customTable.put(ConnectionMode.POLLING, sharedDef); + + FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(customTable, ConnectionMode.STREAMING); + // Identity check: same ModeDefinition object shared across modes enables 5.3.8 equivalence + assertTrue(builder.getModeDefinition(ConnectionMode.STREAMING) + == builder.getModeDefinition(ConnectionMode.POLLING)); + } - ResolvedModeDefinition def = builder.getResolvedModeTable().get(ConnectionMode.STREAMING); - assertNotNull(def); - assertEquals(1, def.getInitializerFactories().size()); - assertEquals(1, def.getSynchronizerFactories().size()); + @Test + public void setActiveMode_notInTable_throws() { + Map customTable = new LinkedHashMap<>(); + customTable.put(ConnectionMode.STREAMING, new ModeDefinition( + Collections.>emptyList(), + Collections.>singletonList(ctx -> null) + )); + + FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(customTable, ConnectionMode.STREAMING); + builder.setActiveMode(ConnectionMode.POLLING, true); + try { + builder.build(makeClientContext()); + fail("Expected IllegalStateException"); + } catch (IllegalStateException e) { + assertTrue(e.getMessage().contains("not found in mode table")); + } } } diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceTest.java index fa569509..cd730bf9 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceTest.java @@ -1421,127 +1421,6 @@ public void stopReportsOffStatus() throws Exception { assertEquals(DataSourceState.OFF, offStatus); } - // ==== switchMode tests ==== - - @Test - public void switchMode_replacesActiveSynchronizer() throws Exception { - MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); - CountDownLatch oldSyncStarted = new CountDownLatch(1); - CountDownLatch oldSyncClosed = new CountDownLatch(1); - - MockQueuedSynchronizer oldSync = new MockQueuedSynchronizer( - FDv2SourceResult.changeSet(makeChangeSet(false)) - ) { - @Override - public LDAwaitFuture next() { - oldSyncStarted.countDown(); - return super.next(); - } - @Override - public void close() { - super.close(); - oldSyncClosed.countDown(); - } - }; - - FDv2DataSource dataSource = buildDataSource(sink, - Collections.emptyList(), - Collections.singletonList(() -> oldSync)); - - startDataSource(dataSource); - assertTrue(oldSyncStarted.await(2, TimeUnit.SECONDS)); - assertEquals(DataSourceState.VALID, sink.awaitStatus(2, TimeUnit.SECONDS)); - - CountDownLatch newSyncStarted = new CountDownLatch(1); - MockQueuedSynchronizer newSync = new MockQueuedSynchronizer( - FDv2SourceResult.changeSet(makeChangeSet(false)) - ) { - @Override - public LDAwaitFuture next() { - newSyncStarted.countDown(); - return super.next(); - } - }; - ResolvedModeDefinition newDef = new ResolvedModeDefinition( - Collections.>emptyList(), - Collections.>singletonList(() -> newSync) - ); - - dataSource.switchMode(newDef); - - assertTrue("Old synchronizer should be closed", oldSyncClosed.await(2, TimeUnit.SECONDS)); - assertTrue("New synchronizer should start", newSyncStarted.await(2, TimeUnit.SECONDS)); - } - - @Test - public void switchMode_doesNotReRunInitializers() throws Exception { - MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); - AtomicInteger initializerRunCount = new AtomicInteger(0); - - FDv2DataSource.DataSourceFactory initFactory = () -> { - initializerRunCount.incrementAndGet(); - return new MockInitializer( - FDv2SourceResult.changeSet(makeChangeSet(true))); - }; - - CountDownLatch syncStarted = new CountDownLatch(1); - FDv2DataSource dataSource = buildDataSource(sink, - Collections.singletonList(initFactory), - Collections.singletonList(() -> new MockQueuedSynchronizer( - FDv2SourceResult.changeSet(makeChangeSet(false)) - ) { - @Override - public LDAwaitFuture next() { - syncStarted.countDown(); - return super.next(); - } - })); - - startDataSource(dataSource); - assertTrue(syncStarted.await(2, TimeUnit.SECONDS)); - assertEquals(1, initializerRunCount.get()); - - ResolvedModeDefinition newDef = new ResolvedModeDefinition( - Collections.>emptyList(), - Collections.>singletonList( - () -> new MockQueuedSynchronizer( - FDv2SourceResult.changeSet(makeChangeSet(false)))) - ); - - dataSource.switchMode(newDef); - Thread.sleep(200); - assertEquals("Initializers should NOT run again on switchMode", 1, initializerRunCount.get()); - } - - @Test - public void switchMode_toEmptySynchronizers_closesOld() throws Exception { - MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); - CountDownLatch oldClosed = new CountDownLatch(1); - - FDv2DataSource dataSource = buildDataSource(sink, - Collections.emptyList(), - Collections.singletonList(() -> new MockQueuedSynchronizer( - FDv2SourceResult.changeSet(makeChangeSet(false)) - ) { - @Override - public void close() { - super.close(); - oldClosed.countDown(); - } - })); - - startDataSource(dataSource); - assertEquals(DataSourceState.VALID, sink.awaitStatus(2, TimeUnit.SECONDS)); - - ResolvedModeDefinition offlineDef = new ResolvedModeDefinition( - Collections.>emptyList(), - Collections.>emptyList() - ); - - dataSource.switchMode(offlineDef); - assertTrue("Old synchronizer should be closed", oldClosed.await(2, TimeUnit.SECONDS)); - } - @Test public void needsRefresh_sameContext_returnsFalse() { MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); From 5473232cd7595d9fb0fcfe8d6b7167cb866f2d2e Mon Sep 17 00:00:00 2001 From: Aaron Zeisler Date: Fri, 20 Mar 2026 13:06:38 -0700 Subject: [PATCH 13/17] [SDK-1956] Address PR review feedback - Short-circuit forceOffline in resolveMode() so ModeState reflects actual platform state - Match ConnectionMode string values to cross-SDK spec (lowercase, hyphenated) - Add Javadoc to ConnectionMode, ClientContextImpl overloads, and FDv2DataSource internals - Inline FDv2DataSourceBuilder casts in ConnectivityManager - Restore try/finally and explanatory comments in runSynchronizers Made-with: Cursor --- .../sdk/android/ClientContextImpl.java | 12 ++ .../sdk/android/ConnectionMode.java | 15 +- .../sdk/android/ConnectivityManager.java | 21 ++- .../sdk/android/FDv2DataSource.java | 162 ++++++++++-------- .../sdk/android/FDv2DataSourceBuilder.java | 17 +- .../sdk/android/ConnectivityManagerTest.java | 24 +++ 6 files changed, 154 insertions(+), 97 deletions(-) diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ClientContextImpl.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ClientContextImpl.java index 7993acdc..948b56f6 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ClientContextImpl.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ClientContextImpl.java @@ -39,6 +39,7 @@ final class ClientContextImpl extends ClientContext { @Nullable private final TransactionalDataStore transactionalDataStore; + /** Used by FDv1 code paths that do not need a {@link TransactionalDataStore}. */ ClientContextImpl( ClientContext base, DiagnosticStore diagnosticStore, @@ -50,6 +51,11 @@ final class ClientContextImpl extends ClientContext { this(base, diagnosticStore, fetcher, platformState, taskExecutor, perEnvironmentData, null); } + /** + * Used by FDv2 code paths. The {@code transactionalDataStore} is needed by + * {@link FDv2DataSourceBuilder} to create {@link SelectorSourceFacade} instances + * that provide selector state to initializers and synchronizers. + */ ClientContextImpl( ClientContext base, DiagnosticStore diagnosticStore, @@ -113,6 +119,7 @@ public static ClientContextImpl get(ClientContext context) { return new ClientContextImpl(context, null, null, null, null, null); } + /** Creates a context for FDv1 data sources that do not need a {@link TransactionalDataStore}. */ public static ClientContextImpl forDataSource( ClientContext baseClientContext, DataSourceUpdateSink dataSourceUpdateSink, @@ -124,6 +131,11 @@ public static ClientContextImpl forDataSource( newInBackground, previouslyInBackground, null); } + /** + * Creates a context for data sources, optionally including a {@link TransactionalDataStore}. + * FDv2 data sources require the store so that {@link FDv2DataSourceBuilder} can provide + * selector state to initializers and synchronizers via {@link SelectorSourceFacade}. + */ public static ClientContextImpl forDataSource( ClientContext baseClientContext, DataSourceUpdateSink dataSourceUpdateSink, diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ConnectionMode.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ConnectionMode.java index 31777cef..a256ec96 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ConnectionMode.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ConnectionMode.java @@ -5,6 +5,11 @@ * {@link ModeDefinition} that specifies which initializers and synchronizers * are active when the SDK is operating in that mode. *

+ * Not to be confused with {@link ConnectionInformation.ConnectionMode}, which + * is the public FDv1 enum representing the SDK's current connection state + * (e.g. POLLING, STREAMING, SET_OFFLINE). This class is an internal FDv2 + * concept describing the desired data-acquisition pipeline. + *

* This is a closed enum — custom connection modes (spec 5.3.5 TBD) are not * supported in this release. *

@@ -15,11 +20,11 @@ */ final class ConnectionMode { - static final ConnectionMode STREAMING = new ConnectionMode("STREAMING"); - static final ConnectionMode POLLING = new ConnectionMode("POLLING"); - static final ConnectionMode OFFLINE = new ConnectionMode("OFFLINE"); - static final ConnectionMode ONE_SHOT = new ConnectionMode("ONE_SHOT"); - static final ConnectionMode BACKGROUND = new ConnectionMode("BACKGROUND"); + static final ConnectionMode STREAMING = new ConnectionMode("streaming"); + static final ConnectionMode POLLING = new ConnectionMode("polling"); + static final ConnectionMode OFFLINE = new ConnectionMode("offline"); + static final ConnectionMode ONE_SHOT = new ConnectionMode("one-shot"); + static final ConnectionMode BACKGROUND = new ConnectionMode("background"); private final String name; diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ConnectivityManager.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ConnectivityManager.java index 79d8c2f7..5200db6d 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ConnectivityManager.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ConnectivityManager.java @@ -214,7 +214,7 @@ private synchronized boolean updateDataSource( mustReinitializeDataSource = true; } - // FDv1 path: check whether the data source needs a full rebuild. + // Check whether the existing data source needs a rebuild (e.g. evaluation context changed). if (!mustReinitializeDataSource && existingDataSource != null) { boolean inBackground = !platformState.isForeground(); if (existingDataSource.needsRefresh(inBackground, currentContext.get())) { @@ -271,9 +271,8 @@ private synchronized boolean updateDataSource( ); if (useFDv2ModeResolution) { - FDv2DataSourceBuilder fdv2Builder = (FDv2DataSourceBuilder) dataSourceFactory; // CONNMODE 2.0.1: mode switches only transition synchronizers, not initializers. - fdv2Builder.setActiveMode(currentFDv2Mode, !isFDv2ModeSwitch); + ((FDv2DataSourceBuilder) dataSourceFactory).setActiveMode(currentFDv2Mode, !isFDv2ModeSwitch); } DataSource dataSource = dataSourceFactory.build(clientContext); @@ -439,8 +438,7 @@ synchronized boolean startUp(@NonNull Callback onCompletion) { if (useFDv2ModeResolution) { currentFDv2Mode = resolveMode(); - FDv2DataSourceBuilder fdv2Builder = (FDv2DataSourceBuilder) dataSourceFactory; - fdv2Builder.setActiveMode(currentFDv2Mode, true); + ((FDv2DataSourceBuilder) dataSourceFactory).setActiveMode(currentFDv2Mode, true); } updateEventProcessor(forcedOffline.get(), platformState.isNetworkAvailable(), platformState.isForeground()); @@ -495,15 +493,16 @@ private void handleModeStateChange() { /** * Resolves the current platform state to a {@link ConnectionMode} via the - * {@link ModeResolutionTable}. + * {@link ModeResolutionTable}. Force-offline is handled as a short-circuit + * so that {@link ModeState} faithfully represents actual platform state. */ private ConnectionMode resolveMode() { - boolean forceOffline = forcedOffline.get(); - boolean networkAvailable = platformState.isNetworkAvailable(); - boolean foreground = platformState.isForeground(); + if (forcedOffline.get()) { + return ConnectionMode.OFFLINE; + } ModeState state = new ModeState( - foreground && !forceOffline, - networkAvailable && !forceOffline + platformState.isForeground(), + platformState.isNetworkAvailable() ); return ModeResolutionTable.MOBILE.resolve(state); } diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSource.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSource.java index 74f801f3..5a5ff5b3 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSource.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSource.java @@ -304,91 +304,103 @@ private void runSynchronizers( @NonNull LDContext context, @NonNull DataSourceUpdateSinkV2 sink ) { - Synchronizer synchronizer = sourceManager.getNextAvailableSynchronizerAndSetActive(); - while (synchronizer != null) { - if (stopped.get()) { - return; - } - int synchronizerCount = sourceManager.getAvailableSynchronizerCount(); - boolean isPrime = sourceManager.isPrimeSynchronizer(); - try { - boolean running = true; - try (FDv2DataSourceConditions.Conditions conditions = - new FDv2DataSourceConditions.Conditions(getConditions(synchronizerCount, isPrime))) { - while (running) { - Future nextFuture = synchronizer.next(); - Object res = LDFutures.anyOf(conditions.getFuture(), nextFuture).get(); + try { + Synchronizer synchronizer = sourceManager.getNextAvailableSynchronizerAndSetActive(); + while (synchronizer != null) { + if (stopped.get()) { + return; + } + int synchronizerCount = sourceManager.getAvailableSynchronizerCount(); + boolean isPrime = sourceManager.isPrimeSynchronizer(); + try { + boolean running = true; + try (FDv2DataSourceConditions.Conditions conditions = + new FDv2DataSourceConditions.Conditions(getConditions(synchronizerCount, isPrime))) { + while (running) { + Future nextFuture = synchronizer.next(); + // Race the next synchronizer result against any active conditions + // (fallback/recovery timers). Whichever resolves first wins. + Object res = LDFutures.anyOf(conditions.getFuture(), nextFuture).get(); - if (res instanceof FDv2DataSourceConditions.ConditionType) { - FDv2DataSourceConditions.ConditionType ct = (FDv2DataSourceConditions.ConditionType) res; - switch (ct) { - case FALLBACK: - logger.debug("Synchronizer {} experienced an interruption; falling back to next synchronizer.", - synchronizer.getClass().getSimpleName()); - break; - case RECOVERY: - logger.debug("The data source is attempting to recover to a higher priority synchronizer."); - sourceManager.resetSourceIndex(); - break; + if (res instanceof FDv2DataSourceConditions.ConditionType) { + FDv2DataSourceConditions.ConditionType ct = (FDv2DataSourceConditions.ConditionType) res; + switch (ct) { + case FALLBACK: + logger.debug("Synchronizer {} experienced an interruption; falling back to next synchronizer.", + synchronizer.getClass().getSimpleName()); + break; + case RECOVERY: + logger.debug("The data source is attempting to recover to a higher priority synchronizer."); + sourceManager.resetSourceIndex(); + break; + } + running = false; + break; } - running = false; - break; - } - if (!(res instanceof FDv2SourceResult)) { - logger.error("Unexpected result type from synchronizer: {}", res != null ? res.getClass().getName() : "null"); - continue; - } + if (!(res instanceof FDv2SourceResult)) { + logger.error("Unexpected result type from synchronizer: {}", res != null ? res.getClass().getName() : "null"); + continue; + } - FDv2SourceResult result = (FDv2SourceResult) res; - conditions.inform(result); + FDv2SourceResult result = (FDv2SourceResult) res; + // Let conditions observe the result before we act on it so + // they can update their internal state (e.g. reset interruption timers). + conditions.inform(result); - switch (result.getResultType()) { - case CHANGE_SET: - ChangeSet> changeSet = result.getChangeSet(); - if (changeSet != null) { - sink.apply(context, changeSet); - sink.setStatus(DataSourceState.VALID, null); - tryCompleteStart(true, null); - } - break; - case STATUS: - FDv2SourceResult.Status status = result.getStatus(); - if (status != null) { - switch (status.getState()) { - case INTERRUPTED: - sink.setStatus(DataSourceState.INTERRUPTED, status.getError()); - break; - case SHUTDOWN: - running = false; - break; - case TERMINAL_ERROR: - sourceManager.blockCurrentSynchronizer(); - running = false; - sink.setStatus(DataSourceState.INTERRUPTED, status.getError()); - break; - case GOODBYE: - break; - default: - break; + switch (result.getResultType()) { + case CHANGE_SET: + ChangeSet> changeSet = result.getChangeSet(); + if (changeSet != null) { + sink.apply(context, changeSet); + sink.setStatus(DataSourceState.VALID, null); + tryCompleteStart(true, null); } - } - break; + break; + case STATUS: + FDv2SourceResult.Status status = result.getStatus(); + if (status != null) { + switch (status.getState()) { + case INTERRUPTED: + sink.setStatus(DataSourceState.INTERRUPTED, status.getError()); + break; + case SHUTDOWN: + // This synchronizer is shutting down cleanly/intentionally + running = false; + break; + case TERMINAL_ERROR: + // This synchronizer cannot recover; block it so the outer + // loop advances to the next available synchronizer. + sourceManager.blockCurrentSynchronizer(); + running = false; + sink.setStatus(DataSourceState.INTERRUPTED, status.getError()); + break; + case GOODBYE: + // We let the synchronizer handle this internally. + break; + default: + break; + } + } + break; + } } } + } catch (ExecutionException e) { + logger.warn("Synchronizer error: {}", e.getCause() != null ? e.getCause().toString() : e.toString()); + sink.setStatus(DataSourceState.INTERRUPTED, e.getCause() != null ? e.getCause() : e); + } catch (CancellationException e) { + logger.warn("Synchronizer cancelled: {}", e.toString()); + sink.setStatus(DataSourceState.INTERRUPTED, e); + } catch (InterruptedException e) { + logger.warn("Synchronizer interrupted: {}", e.toString()); + sink.setStatus(DataSourceState.INTERRUPTED, e); + return; } - } catch (ExecutionException e) { - logger.warn("Synchronizer error: {}", e.getCause() != null ? e.getCause().toString() : e.toString()); - sink.setStatus(DataSourceState.INTERRUPTED, e.getCause() != null ? e.getCause() : e); - } catch (CancellationException e) { - logger.warn("Synchronizer cancelled: {}", e.toString()); - sink.setStatus(DataSourceState.INTERRUPTED, e); - } catch (InterruptedException e) { - logger.warn("Synchronizer interrupted: {}", e.toString()); - sink.setStatus(DataSourceState.INTERRUPTED, e); - return; + synchronizer = sourceManager.getNextAvailableSynchronizerAndSetActive(); } - synchronizer = sourceManager.getNextAvailableSynchronizerAndSetActive(); + } finally { + sourceManager.close(); } } } diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilder.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilder.java index 87bf7118..28b64b2a 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilder.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilder.java @@ -144,23 +144,28 @@ private static Map makeDefaultModeTable() { Map table = new LinkedHashMap<>(); table.put(ConnectionMode.STREAMING, new ModeDefinition( - Arrays.asList(pollingInitializer, pollingInitializer), + // TODO: cacheInitializer — add once implemented + Arrays.asList(/* cacheInitializer, */ pollingInitializer), Arrays.asList(streamingSynchronizer, pollingSynchronizer) )); table.put(ConnectionMode.POLLING, new ModeDefinition( - Collections.singletonList(pollingInitializer), + // TODO: Arrays.asList(cacheInitializer) — add once implemented + Collections.>emptyList(), Collections.singletonList(pollingSynchronizer) )); table.put(ConnectionMode.OFFLINE, new ModeDefinition( - Collections.singletonList(pollingInitializer), + // TODO: Arrays.asList(cacheInitializer) — add once implemented + Collections.>emptyList(), Collections.>emptyList() )); table.put(ConnectionMode.ONE_SHOT, new ModeDefinition( - Arrays.asList(pollingInitializer, pollingInitializer, pollingInitializer), + // TODO: cacheInitializer and streamingInitializer — add once implemented + Arrays.asList(/* cacheInitializer, */ pollingInitializer /*, streamingInitializer, */), Collections.>emptyList() )); table.put(ConnectionMode.BACKGROUND, new ModeDefinition( - Collections.singletonList(pollingInitializer), + // TODO: Arrays.asList(cacheInitializer) — add once implemented + Collections.>emptyList(), Collections.singletonList(backgroundPollingSynchronizer) )); return table; @@ -170,7 +175,7 @@ private static Map makeDefaultModeTable() { @NonNull Map modeTable, @NonNull ConnectionMode startingMode ) { - this.modeTable = Collections.unmodifiableMap(new LinkedHashMap<>(modeTable)); + this.modeTable = Collections.unmodifiableMap(modeTable); this.startingMode = startingMode; } diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ConnectivityManagerTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ConnectivityManagerTest.java index 3980f36b..52eb1aad 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ConnectivityManagerTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ConnectivityManagerTest.java @@ -839,6 +839,30 @@ public DataSource build(ClientContext clientContext) { verifyAll(); } + @Test + public void fdv2_contextChange_rebuildsDataSource() throws Exception { + eventProcessor.setOffline(false); + eventProcessor.setInBackground(false); + eventProcessor.setOffline(false); + eventProcessor.setInBackground(false); + replayAll(); + + createTestManager(false, false, makeFDv2DataSourceFactory()); + awaitStartUp(); + verifyForegroundDataSourceWasCreatedAndStarted(CONTEXT); + + LDContext context2 = LDContext.create("context2"); + contextDataManager.switchToContext(context2); + AwaitableCallback done = new AwaitableCallback<>(); + connectivityManager.switchToContext(context2, done); + done.await(); + + verifyDataSourceWasStopped(); + verifyForegroundDataSourceWasCreatedAndStarted(context2); + verifyNoMoreDataSourcesWereCreated(); + verifyAll(); + } + @Test public void fdv2_modeSwitchDoesNotIncludeInitializers() throws Exception { BlockingQueue initializerIncluded = new LinkedBlockingQueue<>(); From 5c6ac49234704d8477ee6f3ae2ca0bf8c084b8cf Mon Sep 17 00:00:00 2001 From: Aaron Zeisler Date: Mon, 23 Mar 2026 11:37:56 -0700 Subject: [PATCH 14/17] [SDK-1956] Address Bugbot findings in FDv2 code - Extract DataSourceSetup helper and makePollingRequestor() to eliminate duplicated configurer boilerplate in FDv2DataSourceBuilder - Share builder's sharedExecutor across all components to prevent thread pool leaks on mode switches; increase pool size from 2 to 4 - Make FDv2DataSourceBuilder implement Closeable; shut down executor in ConnectivityManager.shutDown() - Honor backgroundUpdatingDisabled in FDv2 mode resolution by adding the flag to ModeState and ModeResolutionTable Made-with: Cursor --- .../sdk/android/ConnectivityManager.java | 16 +- .../sdk/android/FDv2DataSourceBuilder.java | 165 +++++++++--------- .../sdk/android/ModeResolutionTable.java | 4 + .../launchdarkly/sdk/android/ModeState.java | 8 +- .../android/FDv2DataSourceBuilderTest.java | 52 ++++++ .../sdk/android/ModeResolutionTableTest.java | 29 ++- 6 files changed, 183 insertions(+), 91 deletions(-) diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ConnectivityManager.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ConnectivityManager.java index 5200db6d..09f2c99f 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ConnectivityManager.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ConnectivityManager.java @@ -16,6 +16,8 @@ import com.launchdarkly.sdk.android.subsystems.EventProcessor; import com.launchdarkly.sdk.android.subsystems.TransactionalDataStore; +import java.io.Closeable; +import java.io.IOException; import java.lang.ref.WeakReference; import java.util.ArrayList; import java.util.Iterator; @@ -233,6 +235,11 @@ private synchronized boolean updateDataSource( if (useFDv2ModeResolution) { // FDv2 mode resolution already accounts for offline/background states via // the ModeResolutionTable, so we always rebuild when the mode changed. + // Note: unlike FDv1's forceOffline/noNetwork branches above, initialized=true + // is not set here eagerly — it is set in the dataSource.start() callback below. + // For OFFLINE mode this creates a brief async gap (one executor task) before + // isInitialized() returns true, but the OFFLINE data source fires its callback + // nearly instantaneously since it has no initializers or synchronizers. shouldStopExistingDataSource = mustReinitializeDataSource; shouldStartDataSourceIfStopped = true; } else if (forceOffline) { @@ -457,6 +464,12 @@ void shutDown() { if (oldDataSource != null) { oldDataSource.stop(LDUtil.noOpCallback()); } + if (dataSourceFactory instanceof Closeable) { + try { + ((Closeable) dataSourceFactory).close(); + } catch (IOException ignored) { + } + } platformState.removeForegroundChangeListener(foregroundListener); platformState.removeConnectivityChangeListener(connectivityChangeListener); } @@ -502,7 +515,8 @@ private ConnectionMode resolveMode() { } ModeState state = new ModeState( platformState.isForeground(), - platformState.isNetworkAvailable() + platformState.isNetworkAvailable(), + backgroundUpdatingDisabled ); return ModeResolutionTable.MOBILE.resolve(state); } diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilder.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilder.java index 28b64b2a..ef780b74 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilder.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilder.java @@ -14,6 +14,7 @@ import com.launchdarkly.sdk.android.subsystems.TransactionalDataStore; import com.launchdarkly.sdk.internal.http.HttpProperties; +import java.io.Closeable; import java.net.URI; import java.util.ArrayList; import java.util.Arrays; @@ -32,9 +33,9 @@ *

* Package-private — not part of the public SDK API. */ -class FDv2DataSourceBuilder implements ComponentConfigurer { +class FDv2DataSourceBuilder implements ComponentConfigurer, Closeable { - private final Map modeTable; + private Map modeTable; private final ConnectionMode startingMode; private ConnectionMode activeMode; @@ -42,103 +43,50 @@ class FDv2DataSourceBuilder implements ComponentConfigurer { private ScheduledExecutorService sharedExecutor; FDv2DataSourceBuilder() { - this(makeDefaultModeTable(), ConnectionMode.STREAMING); + this.modeTable = null; // built lazily in build() so lambdas can capture sharedExecutor + this.startingMode = ConnectionMode.STREAMING; } - private static Map makeDefaultModeTable() { + private Map makeDefaultModeTable() { ComponentConfigurer pollingInitializer = ctx -> { - ClientContextImpl impl = ClientContextImpl.get(ctx); - TransactionalDataStore store = impl.getTransactionalDataStore(); - SelectorSource selectorSource = store != null - ? new SelectorSourceFacade(store) - : () -> com.launchdarkly.sdk.fdv2.Selector.EMPTY; - URI pollingBase = StandardEndpoints.selectBaseUri( - ctx.getServiceEndpoints().getPollingBaseUri(), - StandardEndpoints.DEFAULT_POLLING_BASE_URI, - "polling", ctx.getBaseLogger()); - HttpProperties httpProps = LDUtil.makeHttpProperties(ctx); - FDv2Requestor requestor = new DefaultFDv2Requestor( - ctx.getEvaluationContext(), pollingBase, - StandardEndpoints.FDV2_POLLING_REQUEST_GET_BASE_PATH, - StandardEndpoints.FDV2_POLLING_REQUEST_REPORT_BASE_PATH, - httpProps, ctx.getHttp().isUseReport(), - ctx.isEvaluationReasons(), null, ctx.getBaseLogger()); - return new FDv2PollingInitializer(requestor, selectorSource, - Executors.newSingleThreadExecutor(), ctx.getBaseLogger()); + DataSourceSetup s = new DataSourceSetup(ctx); + FDv2Requestor requestor = makePollingRequestor(ctx, s.httpProps); + return new FDv2PollingInitializer(requestor, s.selectorSource, + sharedExecutor, ctx.getBaseLogger()); }; ComponentConfigurer pollingSynchronizer = ctx -> { - ClientContextImpl impl = ClientContextImpl.get(ctx); - TransactionalDataStore store = impl.getTransactionalDataStore(); - SelectorSource selectorSource = store != null - ? new SelectorSourceFacade(store) - : () -> com.launchdarkly.sdk.fdv2.Selector.EMPTY; - URI pollingBase = StandardEndpoints.selectBaseUri( - ctx.getServiceEndpoints().getPollingBaseUri(), - StandardEndpoints.DEFAULT_POLLING_BASE_URI, - "polling", ctx.getBaseLogger()); - HttpProperties httpProps = LDUtil.makeHttpProperties(ctx); - FDv2Requestor requestor = new DefaultFDv2Requestor( - ctx.getEvaluationContext(), pollingBase, - StandardEndpoints.FDV2_POLLING_REQUEST_GET_BASE_PATH, - StandardEndpoints.FDV2_POLLING_REQUEST_REPORT_BASE_PATH, - httpProps, ctx.getHttp().isUseReport(), - ctx.isEvaluationReasons(), null, ctx.getBaseLogger()); - ScheduledExecutorService exec = Executors.newScheduledThreadPool(1); - return new FDv2PollingSynchronizer(requestor, selectorSource, exec, + DataSourceSetup s = new DataSourceSetup(ctx); + FDv2Requestor requestor = makePollingRequestor(ctx, s.httpProps); + return new FDv2PollingSynchronizer(requestor, s.selectorSource, + sharedExecutor, 0, PollingDataSourceBuilder.DEFAULT_POLL_INTERVAL_MILLIS, ctx.getBaseLogger()); }; ComponentConfigurer streamingSynchronizer = ctx -> { - ClientContextImpl impl = ClientContextImpl.get(ctx); - TransactionalDataStore store = impl.getTransactionalDataStore(); - SelectorSource selectorSource = store != null - ? new SelectorSourceFacade(store) - : () -> com.launchdarkly.sdk.fdv2.Selector.EMPTY; + DataSourceSetup s = new DataSourceSetup(ctx); + // The streaming synchronizer uses a polling requestor for its internal + // polling fallback (e.g. when the stream cannot be established). + FDv2Requestor pollingRequestor = makePollingRequestor(ctx, s.httpProps); URI streamBase = StandardEndpoints.selectBaseUri( ctx.getServiceEndpoints().getStreamingBaseUri(), StandardEndpoints.DEFAULT_STREAMING_BASE_URI, "streaming", ctx.getBaseLogger()); - URI pollingBase = StandardEndpoints.selectBaseUri( - ctx.getServiceEndpoints().getPollingBaseUri(), - StandardEndpoints.DEFAULT_POLLING_BASE_URI, - "polling", ctx.getBaseLogger()); - HttpProperties httpProps = LDUtil.makeHttpProperties(ctx); - FDv2Requestor requestor = new DefaultFDv2Requestor( - ctx.getEvaluationContext(), pollingBase, - StandardEndpoints.FDV2_POLLING_REQUEST_GET_BASE_PATH, - StandardEndpoints.FDV2_POLLING_REQUEST_REPORT_BASE_PATH, - httpProps, ctx.getHttp().isUseReport(), - ctx.isEvaluationReasons(), null, ctx.getBaseLogger()); return new FDv2StreamingSynchronizer( - ctx.getEvaluationContext(), selectorSource, streamBase, + ctx.getEvaluationContext(), s.selectorSource, streamBase, StandardEndpoints.FDV2_STREAMING_REQUEST_BASE_PATH, - requestor, + pollingRequestor, StreamingDataSourceBuilder.DEFAULT_INITIAL_RECONNECT_DELAY_MILLIS, ctx.isEvaluationReasons(), ctx.getHttp().isUseReport(), - httpProps, Executors.newSingleThreadExecutor(), + s.httpProps, sharedExecutor, ctx.getBaseLogger(), null); }; ComponentConfigurer backgroundPollingSynchronizer = ctx -> { - ClientContextImpl impl = ClientContextImpl.get(ctx); - TransactionalDataStore store = impl.getTransactionalDataStore(); - SelectorSource selectorSource = store != null - ? new SelectorSourceFacade(store) - : () -> com.launchdarkly.sdk.fdv2.Selector.EMPTY; - URI pollingBase = StandardEndpoints.selectBaseUri( - ctx.getServiceEndpoints().getPollingBaseUri(), - StandardEndpoints.DEFAULT_POLLING_BASE_URI, - "polling", ctx.getBaseLogger()); - HttpProperties httpProps = LDUtil.makeHttpProperties(ctx); - FDv2Requestor requestor = new DefaultFDv2Requestor( - ctx.getEvaluationContext(), pollingBase, - StandardEndpoints.FDV2_POLLING_REQUEST_GET_BASE_PATH, - StandardEndpoints.FDV2_POLLING_REQUEST_REPORT_BASE_PATH, - httpProps, ctx.getHttp().isUseReport(), - ctx.isEvaluationReasons(), null, ctx.getBaseLogger()); - ScheduledExecutorService exec = Executors.newScheduledThreadPool(1); - return new FDv2PollingSynchronizer(requestor, selectorSource, exec, + DataSourceSetup s = new DataSourceSetup(ctx); + FDv2Requestor requestor = makePollingRequestor(ctx, s.httpProps); + return new FDv2PollingSynchronizer(requestor, s.selectorSource, + sharedExecutor, 0, LDConfig.DEFAULT_BACKGROUND_POLL_INTERVAL_MILLIS, ctx.getBaseLogger()); }; @@ -207,6 +155,18 @@ ModeDefinition getModeDefinition(@NonNull ConnectionMode mode) { @Override public DataSource build(ClientContext clientContext) { + if (sharedExecutor == null) { + // Pool size 4: supports the FDv2DataSource main loop (1), an active synchronizer + // such as the streaming event loop (1), FDv2DataSource condition timers for + // fallback/recovery (up to 2 short-lived scheduled tasks). Only one mode is active + // at a time (teardown/rebuild), so this pool serves all components. + sharedExecutor = Executors.newScheduledThreadPool(4); + } + + if (modeTable == null) { + modeTable = Collections.unmodifiableMap(makeDefaultModeTable()); + } + ConnectionMode mode = activeMode != null ? activeMode : startingMode; ModeDefinition modeDef = modeTable.get(mode); @@ -223,10 +183,6 @@ public DataSource build(ClientContext clientContext) { "FDv2DataSource requires a DataSourceUpdateSinkV2 implementation"); } - if (sharedExecutor == null) { - sharedExecutor = Executors.newScheduledThreadPool(2); - } - List> initFactories = includeInitializers ? resolved.getInitializerFactories() : Collections.>emptyList(); @@ -243,6 +199,14 @@ public DataSource build(ClientContext clientContext) { ); } + @Override + public void close() { + if (sharedExecutor != null) { + sharedExecutor.shutdownNow(); + sharedExecutor = null; + } + } + private static ResolvedModeDefinition resolve( ModeDefinition def, ClientContext clientContext ) { @@ -256,4 +220,43 @@ private static ResolvedModeDefinition resolve( } return new ResolvedModeDefinition(initFactories, syncFactories); } + + /** + * Holds the shared infrastructure needed by all FDv2 data source components: + * a {@link SelectorSource} backed by the {@link TransactionalDataStore} (or an empty + * fallback if none is configured), and the {@link HttpProperties} for the current + * client configuration. Polling-specific setup (the {@link FDv2Requestor}) is built + * separately via {@link #makePollingRequestor}. + */ + private static final class DataSourceSetup { + final SelectorSource selectorSource; + final HttpProperties httpProps; + + DataSourceSetup(ClientContext ctx) { + TransactionalDataStore store = ClientContextImpl.get(ctx).getTransactionalDataStore(); + this.selectorSource = store != null + ? new SelectorSourceFacade(store) + : () -> com.launchdarkly.sdk.fdv2.Selector.EMPTY; + this.httpProps = LDUtil.makeHttpProperties(ctx); + } + } + + /** + * Builds a {@link DefaultFDv2Requestor} configured for polling endpoints. Used + * directly by polling components and as the fallback requestor for the streaming + * synchronizer (which needs it for internal polling fallback when the stream cannot + * be established). + */ + private static FDv2Requestor makePollingRequestor(ClientContext ctx, HttpProperties httpProps) { + URI pollingBase = StandardEndpoints.selectBaseUri( + ctx.getServiceEndpoints().getPollingBaseUri(), + StandardEndpoints.DEFAULT_POLLING_BASE_URI, + "polling", ctx.getBaseLogger()); + return new DefaultFDv2Requestor( + ctx.getEvaluationContext(), pollingBase, + StandardEndpoints.FDV2_POLLING_REQUEST_GET_BASE_PATH, + StandardEndpoints.FDV2_POLLING_REQUEST_REPORT_BASE_PATH, + httpProps, ctx.getHttp().isUseReport(), + ctx.isEvaluationReasons(), null, ctx.getBaseLogger()); + } } diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeResolutionTable.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeResolutionTable.java index f6b1817a..3797821f 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeResolutionTable.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeResolutionTable.java @@ -13,6 +13,7 @@ * The {@link #MOBILE} constant defines the Android default resolution table: *

    *
  1. No network → {@link ConnectionMode#OFFLINE}
  2. + *
  3. Background + background updating disabled → {@link ConnectionMode#OFFLINE}
  4. *
  5. Background → {@link ConnectionMode#BACKGROUND}
  6. *
  7. Foreground → {@link ConnectionMode#STREAMING}
  8. *
@@ -28,6 +29,9 @@ final class ModeResolutionTable { new ModeResolutionEntry( state -> !state.isNetworkAvailable(), ConnectionMode.OFFLINE), + new ModeResolutionEntry( + state -> !state.isForeground() && state.isBackgroundUpdatingDisabled(), + ConnectionMode.OFFLINE), new ModeResolutionEntry( state -> !state.isForeground(), ConnectionMode.BACKGROUND), diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeState.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeState.java index 1450f052..ca86d907 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeState.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeState.java @@ -15,10 +15,12 @@ final class ModeState { private final boolean foreground; private final boolean networkAvailable; + private final boolean backgroundUpdatingDisabled; - ModeState(boolean foreground, boolean networkAvailable) { + ModeState(boolean foreground, boolean networkAvailable, boolean backgroundUpdatingDisabled) { this.foreground = foreground; this.networkAvailable = networkAvailable; + this.backgroundUpdatingDisabled = backgroundUpdatingDisabled; } boolean isForeground() { @@ -28,4 +30,8 @@ boolean isForeground() { boolean isNetworkAvailable() { return networkAvailable; } + + boolean isBackgroundUpdatingDisabled() { + return backgroundUpdatingDisabled; + } } diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilderTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilderTest.java index fe5928ce..69c0859f 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilderTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilderTest.java @@ -1,8 +1,10 @@ package com.launchdarkly.sdk.android; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; @@ -22,6 +24,7 @@ import java.util.Collections; import java.util.LinkedHashMap; import java.util.Map; +import java.util.concurrent.ScheduledExecutorService; public class FDv2DataSourceBuilderTest { @@ -172,6 +175,55 @@ public void getModeDefinition_sameObjectUsedForEquivalenceCheck() { == builder.getModeDefinition(ConnectionMode.POLLING)); } + @Test + public void close_shutsDownSharedExecutor() { + FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(); + // build() lazily creates the sharedExecutor + builder.build(makeClientContext()); + + // Access the executor via reflection to verify shutdown + ScheduledExecutorService executor = getSharedExecutor(builder); + assertNotNull(executor); + assertFalse(executor.isShutdown()); + + builder.close(); + assertTrue(executor.isShutdown()); + } + + @Test + public void build_reusesSharedExecutorAcrossRebuilds() { + FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(); + builder.build(makeClientContext()); + ScheduledExecutorService first = getSharedExecutor(builder); + + builder.build(makeClientContext()); + ScheduledExecutorService second = getSharedExecutor(builder); + + assertSame(first, second); + assertFalse(first.isShutdown()); + builder.close(); + } + + @Test + public void close_isIdempotent() { + FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(); + builder.build(makeClientContext()); + + builder.close(); + // second close should not throw + builder.close(); + } + + private static ScheduledExecutorService getSharedExecutor(FDv2DataSourceBuilder builder) { + try { + java.lang.reflect.Field f = FDv2DataSourceBuilder.class.getDeclaredField("sharedExecutor"); + f.setAccessible(true); + return (ScheduledExecutorService) f.get(builder); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + @Test public void setActiveMode_notInTable_throws() { Map customTable = new LinkedHashMap<>(); diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ModeResolutionTableTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ModeResolutionTableTest.java index b4c559fc..10b369c6 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ModeResolutionTableTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ModeResolutionTableTest.java @@ -14,28 +14,40 @@ public class ModeResolutionTableTest { @Test public void mobile_foregroundWithNetwork_resolvesToStreaming() { - ModeState state = new ModeState(true, true); + ModeState state = new ModeState(true, true, false); assertSame(ConnectionMode.STREAMING, ModeResolutionTable.MOBILE.resolve(state)); } @Test public void mobile_backgroundWithNetwork_resolvesToBackground() { - ModeState state = new ModeState(false, true); + ModeState state = new ModeState(false, true, false); assertSame(ConnectionMode.BACKGROUND, ModeResolutionTable.MOBILE.resolve(state)); } + @Test + public void mobile_backgroundWithNetworkAndBackgroundDisabled_resolvesToOffline() { + ModeState state = new ModeState(false, true, true); + assertSame(ConnectionMode.OFFLINE, ModeResolutionTable.MOBILE.resolve(state)); + } + @Test public void mobile_foregroundWithoutNetwork_resolvesToOffline() { - ModeState state = new ModeState(true, false); + ModeState state = new ModeState(true, false, false); assertSame(ConnectionMode.OFFLINE, ModeResolutionTable.MOBILE.resolve(state)); } @Test public void mobile_backgroundWithoutNetwork_resolvesToOffline() { - ModeState state = new ModeState(false, false); + ModeState state = new ModeState(false, false, false); assertSame(ConnectionMode.OFFLINE, ModeResolutionTable.MOBILE.resolve(state)); } + @Test + public void mobile_foregroundWithBackgroundDisabled_resolvesToStreaming() { + ModeState state = new ModeState(true, true, true); + assertSame(ConnectionMode.STREAMING, ModeResolutionTable.MOBILE.resolve(state)); + } + // ==== Custom table tests ==== @Test @@ -44,13 +56,13 @@ public void customTable_firstMatchWins() { new ModeResolutionEntry(state -> true, ConnectionMode.POLLING), new ModeResolutionEntry(state -> true, ConnectionMode.STREAMING) )); - assertSame(ConnectionMode.POLLING, table.resolve(new ModeState(true, true))); + assertSame(ConnectionMode.POLLING, table.resolve(new ModeState(true, true, false))); } @Test(expected = IllegalStateException.class) public void emptyTable_throws() { ModeResolutionTable table = new ModeResolutionTable(Collections.emptyList()); - table.resolve(new ModeState(true, true)); + table.resolve(new ModeState(true, true, false)); } @Test(expected = IllegalStateException.class) @@ -58,16 +70,17 @@ public void noMatch_throws() { ModeResolutionTable table = new ModeResolutionTable(Collections.singletonList( new ModeResolutionEntry(state -> false, ConnectionMode.STREAMING) )); - table.resolve(new ModeState(true, true)); + table.resolve(new ModeState(true, true, false)); } // ==== ModeState tests ==== @Test public void modeState_getters() { - ModeState state = new ModeState(true, false); + ModeState state = new ModeState(true, false, true); assertEquals(true, state.isForeground()); assertEquals(false, state.isNetworkAvailable()); + assertEquals(true, state.isBackgroundUpdatingDisabled()); } // ==== ModeResolutionEntry tests ==== From 493179a0c5f8088e020f00082af601cb9026c1a0 Mon Sep 17 00:00:00 2001 From: Aaron Zeisler Date: Tue, 24 Mar 2026 09:26:27 -0700 Subject: [PATCH 15/17] [SDK-1956] Address PR review comments and add FDv1 safety tests - ConnectivityManager: use .equals() for ModeDefinition comparison, synchronize handleModeStateChange() - FDv2DataSourceBuilder: remove redundant unmodifiableMap() wrapping - ModeResolutionTable: use explicit default mode instead of catch-all entry - Add 5 FDv1 round-trip tests for offline, network, and foreground transitions --- .../sdk/android/ConnectivityManager.java | 7 +- .../sdk/android/FDv2DataSourceBuilder.java | 4 +- .../sdk/android/ModeResolutionTable.java | 22 ++- .../sdk/android/ConnectivityManagerTest.java | 132 ++++++++++++++++++ .../sdk/android/ModeResolutionTableTest.java | 21 +-- 5 files changed, 159 insertions(+), 27 deletions(-) diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ConnectivityManager.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ConnectivityManager.java index 09f2c99f..eb2a7d9b 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ConnectivityManager.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ConnectivityManager.java @@ -203,10 +203,13 @@ private synchronized boolean updateDataSource( return false; } // CSFDV2 5.3.8: retain active data source if old and new modes have equivalent config. + // ModeDefinition currently relies on Object.equals (reference equality) because + // makeDefaultModeTable() reuses the same instance for modes that share identical + // configuration. FDv2DataSourceBuilder fdv2Builder = (FDv2DataSourceBuilder) dataSourceFactory; ModeDefinition oldDef = fdv2Builder.getModeDefinition(currentFDv2Mode); ModeDefinition newDef = fdv2Builder.getModeDefinition(newMode); - if (oldDef != null && oldDef == newDef) { + if (oldDef != null && oldDef.equals(newDef)) { currentFDv2Mode = newMode; onCompletion.onSuccess(null); return false; @@ -495,7 +498,7 @@ private void updateEventProcessor(boolean forceOffline, boolean networkAvailable * force-offline). Snapshots the current state once, updates the event processor, then * routes to the appropriate data source update path. */ - private void handleModeStateChange() { + private synchronized void handleModeStateChange() { boolean forceOffline = forcedOffline.get(); boolean networkAvailable = platformState.isNetworkAvailable(); boolean foreground = platformState.isForeground(); diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilder.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilder.java index ef780b74..448c94ee 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilder.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilder.java @@ -123,7 +123,7 @@ private Map makeDefaultModeTable() { @NonNull Map modeTable, @NonNull ConnectionMode startingMode ) { - this.modeTable = Collections.unmodifiableMap(modeTable); + this.modeTable = modeTable; this.startingMode = startingMode; } @@ -164,7 +164,7 @@ public DataSource build(ClientContext clientContext) { } if (modeTable == null) { - modeTable = Collections.unmodifiableMap(makeDefaultModeTable()); + modeTable = makeDefaultModeTable(); } ConnectionMode mode = activeMode != null ? activeMode : startingMode; diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeResolutionTable.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeResolutionTable.java index 3797821f..64c7fb23 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeResolutionTable.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeResolutionTable.java @@ -9,13 +9,14 @@ /** * An ordered list of {@link ModeResolutionEntry} values that maps a {@link ModeState} * to a {@link ConnectionMode}. The first entry whose condition matches wins. + * If no entry matches, a default {@link ConnectionMode} is returned. *

* The {@link #MOBILE} constant defines the Android default resolution table: *

    *
  1. No network → {@link ConnectionMode#OFFLINE}
  2. *
  3. Background + background updating disabled → {@link ConnectionMode#OFFLINE}
  4. *
  5. Background → {@link ConnectionMode#BACKGROUND}
  6. - *
  7. Foreground → {@link ConnectionMode#STREAMING}
  8. + *
  9. Default → {@link ConnectionMode#STREAMING}
  10. *
*

* Package-private — not part of the public SDK API. @@ -34,25 +35,23 @@ final class ModeResolutionTable { ConnectionMode.OFFLINE), new ModeResolutionEntry( state -> !state.isForeground(), - ConnectionMode.BACKGROUND), - new ModeResolutionEntry( - state -> true, - ConnectionMode.STREAMING) - )); + ConnectionMode.BACKGROUND) + ), ConnectionMode.STREAMING); private final List entries; + private final ConnectionMode defaultMode; - ModeResolutionTable(@NonNull List entries) { + ModeResolutionTable(@NonNull List entries, @NonNull ConnectionMode defaultMode) { this.entries = Collections.unmodifiableList(entries); + this.defaultMode = defaultMode; } /** * Evaluates the table against the given state and returns the first matching mode. + * If no entry matches, returns the default mode. * * @param state the current platform state * @return the resolved {@link ConnectionMode} - * @throws IllegalStateException if no entry matches (should not happen with a - * well-formed table that has a catch-all final entry) */ @NonNull ConnectionMode resolve(@NonNull ModeState state) { @@ -61,9 +60,6 @@ ConnectionMode resolve(@NonNull ModeState state) { return entry.getMode(); } } - throw new IllegalStateException( - "ModeResolutionTable has no matching entry for state: " + - "foreground=" + state.isForeground() + ", networkAvailable=" + state.isNetworkAvailable() - ); + return defaultMode; } } diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ConnectivityManagerTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ConnectivityManagerTest.java index 52eb1aad..86f3a148 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ConnectivityManagerTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ConnectivityManagerTest.java @@ -571,6 +571,138 @@ public void refreshDataSourceWhileInBackgroundWithBackgroundPollingDisabled() { verifyNoMoreDataSourcesWereCreated(); } + // ==== FDv1 state-transition round-trip tests ==== + // + // These tests exercise the FDv1 code path through state transitions that were added or + // restructured alongside the FDv2 work, ensuring the FDv1 flow is unaffected. + + @Test + public void fdv1_shutDown_doesNotCallCloseOnFactory() throws Exception { + eventProcessor.setOffline(false); + eventProcessor.setInBackground(false); + replayAll(); + + createTestManager(false, false, makeSuccessfulDataSourceFactory()); + awaitStartUp(); + verifyForegroundDataSourceWasCreatedAndStarted(CONTEXT); + + connectivityManager.shutDown(); + verifyDataSourceWasStopped(); + } + + @Test + public void fdv1_setOffline_thenBackOnline_rebuildsDataSource() throws Exception { + eventProcessor.setOffline(false); + eventProcessor.setInBackground(false); + replayAll(); + + createTestManager(false, false, makeSuccessfulDataSourceFactory()); + awaitStartUp(); + verifyForegroundDataSourceWasCreatedAndStarted(CONTEXT); + + resetAll(); + eventProcessor.setOffline(true); + eventProcessor.setInBackground(false); + replayAll(); + + connectivityManager.setForceOffline(true); + ConnectionMode offlineMode = awaitConnectionModeChangedFrom(ConnectionMode.POLLING); + assertEquals(ConnectionMode.SET_OFFLINE, offlineMode); + verifyDataSourceWasStopped(); + verifyAll(); + + resetAll(); + eventProcessor.setOffline(false); + eventProcessor.setInBackground(false); + replayAll(); + + connectivityManager.setForceOffline(false); + verifyForegroundDataSourceWasCreatedAndStarted(CONTEXT); + verifyAll(); + } + + @Test + public void fdv1_networkLost_thenRestored_rebuildsDataSource() throws Exception { + eventProcessor.setOffline(false); + eventProcessor.setInBackground(false); + replayAll(); + + createTestManager(false, false, makeSuccessfulDataSourceFactory()); + awaitStartUp(); + verifyForegroundDataSourceWasCreatedAndStarted(CONTEXT); + + resetAll(); + eventProcessor.setOffline(true); + eventProcessor.setInBackground(false); + replayAll(); + + mockPlatformState.setAndNotifyConnectivityChangeListeners(false); + ConnectionMode offlineMode = awaitConnectionModeChangedFrom(ConnectionMode.POLLING); + assertEquals(ConnectionMode.OFFLINE, offlineMode); + verifyDataSourceWasStopped(); + verifyAll(); + + resetAll(); + eventProcessor.setOffline(false); + eventProcessor.setInBackground(false); + replayAll(); + + mockPlatformState.setAndNotifyConnectivityChangeListeners(true); + verifyForegroundDataSourceWasCreatedAndStarted(CONTEXT); + verifyAll(); + } + + @Test + public void fdv1_foregroundToBackground_thenBackToForeground_rebuildsDataSource() throws Exception { + eventProcessor.setOffline(false); + eventProcessor.setInBackground(false); + replayAll(); + + createTestManager(false, false, makeSuccessfulDataSourceFactory()); + awaitStartUp(); + verifyForegroundDataSourceWasCreatedAndStarted(CONTEXT); + + resetAll(); + eventProcessor.setOffline(false); + eventProcessor.setInBackground(true); + replayAll(); + + mockPlatformState.setAndNotifyForegroundChangeListeners(false); + ConnectionMode bgMode = awaitConnectionModeChangedFrom(ConnectionMode.POLLING); + assertEquals(ConnectionMode.BACKGROUND_POLLING, bgMode); + verifyDataSourceWasStopped(); + verifyBackgroundDataSourceWasCreatedAndStarted(CONTEXT); + verifyAll(); + + resetAll(); + eventProcessor.setOffline(false); + eventProcessor.setInBackground(false); + replayAll(); + + mockPlatformState.setAndNotifyForegroundChangeListeners(true); + verifyDataSourceWasStopped(); + verifyForegroundDataSourceWasCreatedAndStarted(CONTEXT); + verifyAll(); + } + + @Test + public void fdv1_forDataSource_transactionalDataStoreIsPassedThrough() throws Exception { + eventProcessor.setOffline(false); + eventProcessor.setInBackground(false); + replayAll(); + + createTestManager(false, false, clientContext -> { + receivedClientContexts.add(clientContext); + ClientContextImpl impl = ClientContextImpl.get(clientContext); + assertNotNull(impl.getTransactionalDataStore()); + return MockComponents.successfulDataSource(clientContext, DATA, + ConnectionMode.POLLING, startedDataSources, stoppedDataSources); + }); + + awaitStartUp(); + verifyForegroundDataSourceWasCreatedAndStarted(CONTEXT); + } + @Test public void notifyListenersWhenStatusChanges() throws Exception { createTestManager(false, false, makeSuccessfulDataSourceFactory()); diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ModeResolutionTableTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ModeResolutionTableTest.java index 10b369c6..f70990f0 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ModeResolutionTableTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ModeResolutionTableTest.java @@ -55,22 +55,23 @@ public void customTable_firstMatchWins() { ModeResolutionTable table = new ModeResolutionTable(Arrays.asList( new ModeResolutionEntry(state -> true, ConnectionMode.POLLING), new ModeResolutionEntry(state -> true, ConnectionMode.STREAMING) - )); + ), ConnectionMode.OFFLINE); assertSame(ConnectionMode.POLLING, table.resolve(new ModeState(true, true, false))); } - @Test(expected = IllegalStateException.class) - public void emptyTable_throws() { - ModeResolutionTable table = new ModeResolutionTable(Collections.emptyList()); - table.resolve(new ModeState(true, true, false)); + @Test + public void emptyTable_returnsDefault() { + ModeResolutionTable table = new ModeResolutionTable( + Collections.emptyList(), ConnectionMode.STREAMING); + assertSame(ConnectionMode.STREAMING, table.resolve(new ModeState(true, true, false))); } - @Test(expected = IllegalStateException.class) - public void noMatch_throws() { + @Test + public void noMatch_returnsDefault() { ModeResolutionTable table = new ModeResolutionTable(Collections.singletonList( - new ModeResolutionEntry(state -> false, ConnectionMode.STREAMING) - )); - table.resolve(new ModeState(true, true, false)); + new ModeResolutionEntry(state -> false, ConnectionMode.POLLING) + ), ConnectionMode.STREAMING); + assertSame(ConnectionMode.STREAMING, table.resolve(new ModeState(true, true, false))); } // ==== ModeState tests ==== From 42784d2d7b60f1e2d37f2431b2b8c6bc33abf7b7 Mon Sep 17 00:00:00 2001 From: Aaron Zeisler Date: Tue, 24 Mar 2026 20:44:39 -0700 Subject: [PATCH 16/17] [SDK-1956] Consolidate FDv2 executors into AndroidTaskExecutor Replace the per-builder ScheduledExecutorService with the SDK-wide AndroidTaskExecutor. Add startTask() (dynamically-pooled cached thread pool for long-running work) and startTaskWithFixedDelay() (routes to the existing single-thread scheduled pool) to the TaskExecutor interface. FDv2 components now receive TaskExecutor from ClientContextImpl instead of a dedicated executor owned by FDv2DataSourceBuilder. As a result, FDv2DataSourceBuilder no longer implements Closeable or manages its own executor, and ConnectivityManager no longer needs to close the builder on shutdown. --- .../sdk/android/AndroidTaskExecutor.java | 31 +++++++++-- .../sdk/android/ConnectivityManager.java | 8 --- .../sdk/android/FDv2DataSource.java | 24 ++++---- .../sdk/android/FDv2DataSourceBuilder.java | 34 +++--------- .../sdk/android/FDv2DataSourceConditions.java | 28 ++++------ .../sdk/android/FDv2PollingInitializer.java | 12 ++-- .../sdk/android/FDv2PollingSynchronizer.java | 12 ++-- .../android/FDv2StreamingSynchronizer.java | 13 ++--- .../sdk/android/TaskExecutor.java | 20 +++++++ .../android/FDv2DataSourceBuilderTest.java | 55 +------------------ .../android/FDv2DataSourceConditionsTest.java | 8 +-- .../sdk/android/FDv2DataSourceTest.java | 12 ++-- .../android/FDv2PollingInitializerTest.java | 8 +-- .../android/FDv2PollingSynchronizerTest.java | 8 +-- .../FDv2StreamingSynchronizerTest.java | 8 +-- .../sdk/android/SimpleTestTaskExecutor.java | 22 ++++++-- 16 files changed, 129 insertions(+), 174 deletions(-) diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/AndroidTaskExecutor.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/AndroidTaskExecutor.java index b19c204d..e7943679 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/AndroidTaskExecutor.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/AndroidTaskExecutor.java @@ -6,6 +6,7 @@ import com.launchdarkly.logging.LDLogger; +import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; @@ -15,12 +16,22 @@ * Standard implementation of {@link TaskExecutor} for use in the Android environment. Besides * enforcing correct thread usage, this class also ensures that any unchecked exceptions thrown by * asynchronous tasks are caught and logged. + *

+ * Internally uses two thread pools: + *

    + *
  • scheduledExecutor – single-thread pool for lightweight scheduled/repeating tasks + * (timers, condition callbacks, FDv1/FDv2 recurring poll scheduling).
  • + *
  • parallelExecutor – cached (dynamically-growing) pool for long-running or blocking + * tasks such as the FDv2 data-source main loop, streaming event loop, and one-shot poll + * requests. Idle threads are reclaimed after 60 seconds.
  • + *
*/ final class AndroidTaskExecutor implements TaskExecutor { private final Application application; private final Handler handler; private final LDLogger logger; - private final ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor(); + private final ScheduledExecutorService scheduledExecutor = Executors.newSingleThreadScheduledExecutor(); + private final ExecutorService parallelExecutor = Executors.newCachedThreadPool(); AndroidTaskExecutor(Application application, LDLogger logger) { this.application = application; @@ -39,18 +50,30 @@ public void executeOnMainThread(Runnable action) { @Override public ScheduledFuture scheduleTask(Runnable action, long delayMillis) { - return executor.schedule(wrapActionWithErrorHandling(action), delayMillis, TimeUnit.MILLISECONDS); + return scheduledExecutor.schedule(wrapActionWithErrorHandling(action), delayMillis, TimeUnit.MILLISECONDS); } @Override public ScheduledFuture startRepeatingTask(Runnable action, long initialDelayMillis, long intervalMillis) { - return executor.scheduleAtFixedRate(wrapActionWithErrorHandling(action), + return scheduledExecutor.scheduleAtFixedRate(wrapActionWithErrorHandling(action), initialDelayMillis, intervalMillis, TimeUnit.MILLISECONDS); } + @Override + public void startTask(Runnable action) { + parallelExecutor.execute(wrapActionWithErrorHandling(action)); + } + + @Override + public ScheduledFuture startTaskWithFixedDelay(Runnable action, long initialDelayMillis, long delayMillis) { + return scheduledExecutor.scheduleWithFixedDelay(wrapActionWithErrorHandling(action), + initialDelayMillis, delayMillis, TimeUnit.MILLISECONDS); + } + @Override public void close() { - executor.shutdownNow(); + scheduledExecutor.shutdownNow(); + parallelExecutor.shutdownNow(); } private Runnable wrapActionWithErrorHandling(Runnable action) { diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ConnectivityManager.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ConnectivityManager.java index eb2a7d9b..d234ff78 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ConnectivityManager.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ConnectivityManager.java @@ -16,8 +16,6 @@ import com.launchdarkly.sdk.android.subsystems.EventProcessor; import com.launchdarkly.sdk.android.subsystems.TransactionalDataStore; -import java.io.Closeable; -import java.io.IOException; import java.lang.ref.WeakReference; import java.util.ArrayList; import java.util.Iterator; @@ -467,12 +465,6 @@ void shutDown() { if (oldDataSource != null) { oldDataSource.stop(LDUtil.noOpCallback()); } - if (dataSourceFactory instanceof Closeable) { - try { - ((Closeable) dataSourceFactory).close(); - } catch (IOException ignored) { - } - } platformState.removeForegroundChangeListener(foregroundListener); platformState.removeConnectivityChangeListener(connectivityChangeListener); } diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSource.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSource.java index 5a5ff5b3..2ec44d9d 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSource.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSource.java @@ -21,7 +21,6 @@ import java.util.concurrent.CancellationException; import java.util.concurrent.ExecutionException; import java.util.concurrent.Future; -import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.atomic.AtomicBoolean; /** @@ -45,7 +44,7 @@ public interface DataSourceFactory { private final SourceManager sourceManager; private final long fallbackTimeoutSeconds; private final long recoveryTimeoutSeconds; - private final ScheduledExecutorService sharedExecutor; + private final TaskExecutor taskExecutor; private final AtomicBoolean started = new AtomicBoolean(false); private final AtomicBoolean startCompleted = new AtomicBoolean(false); @@ -59,17 +58,17 @@ public interface DataSourceFactory { /** * Convenience constructor using default fallback and recovery timeouts. * See {@link #FDv2DataSource(LDContext, List, List, DataSourceUpdateSinkV2, - * ScheduledExecutorService, LDLogger, long, long)} for parameter documentation. + * TaskExecutor, LDLogger, long, long)} for parameter documentation. */ FDv2DataSource( @NonNull LDContext evaluationContext, @NonNull List> initializers, @NonNull List> synchronizers, @NonNull DataSourceUpdateSinkV2 dataSourceUpdateSink, - @NonNull ScheduledExecutorService sharedExecutor, + @NonNull TaskExecutor taskExecutor, @NonNull LDLogger logger ) { - this(evaluationContext, initializers, synchronizers, dataSourceUpdateSink, sharedExecutor, logger, + this(evaluationContext, initializers, synchronizers, dataSourceUpdateSink, taskExecutor, logger, FDv2DataSourceConditions.DEFAULT_FALLBACK_TIMEOUT_SECONDS, FDv2DataSourceConditions.DEFAULT_RECOVERY_TIMEOUT_SECONDS); } @@ -79,8 +78,7 @@ public interface DataSourceFactory { * @param initializers factories for one-shot initializers, tried in order * @param synchronizers factories for recurring synchronizers, tried in order * @param dataSourceUpdateSink sink to apply changesets and status updates to - * @param sharedExecutor executor used for internal background tasks; must have at least - * 2 threads available for this data source to run properly. + * @param taskExecutor task executor used for internal background tasks * @param logger logger * @param fallbackTimeoutSeconds seconds of INTERRUPTED state before falling back to the * next synchronizer @@ -92,7 +90,7 @@ public interface DataSourceFactory { @NonNull List> initializers, @NonNull List> synchronizers, @NonNull DataSourceUpdateSinkV2 dataSourceUpdateSink, - @NonNull ScheduledExecutorService sharedExecutor, + @NonNull TaskExecutor taskExecutor, @NonNull LDLogger logger, long fallbackTimeoutSeconds, long recoveryTimeoutSeconds @@ -107,7 +105,7 @@ public interface DataSourceFactory { this.sourceManager = new SourceManager(synchronizerFactoriesWithState, new ArrayList<>(initializers)); this.fallbackTimeoutSeconds = fallbackTimeoutSeconds; this.recoveryTimeoutSeconds = recoveryTimeoutSeconds; - this.sharedExecutor = sharedExecutor; + this.taskExecutor = taskExecutor; } @Override @@ -137,7 +135,7 @@ public void start(@NonNull Callback resultCallback) { // race with a concurrent stop() and could undo it, causing a spurious OFF/exhaustion report. LDContext context = evaluationContext; - sharedExecutor.execute(() -> { + taskExecutor.startTask(() -> { try { if (!sourceManager.hasAvailableSources()) { logger.info("No initializers or synchronizers; data source will not connect."); @@ -209,7 +207,7 @@ private void tryCompleteStart(boolean success, Throwable error) { public void stop(@NonNull Callback completionCallback) { stopped.set(true); sourceManager.close(); - // Caller owns sharedExecutor; we do not shut it down. + // Caller owns taskExecutor; we do not shut it down. dataSourceUpdateSink.setStatus(DataSourceState.OFF, null); completionCallback.onSuccess(null); } @@ -293,9 +291,9 @@ private List getConditions(int synchronizerC return Collections.emptyList(); } List list = new ArrayList<>(); - list.add(new FDv2DataSourceConditions.FallbackCondition(sharedExecutor, fallbackTimeoutSeconds)); + list.add(new FDv2DataSourceConditions.FallbackCondition(taskExecutor, fallbackTimeoutSeconds)); if (!isPrime) { - list.add(new FDv2DataSourceConditions.RecoveryCondition(sharedExecutor, recoveryTimeoutSeconds)); + list.add(new FDv2DataSourceConditions.RecoveryCondition(taskExecutor, recoveryTimeoutSeconds)); } return list; } diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilder.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilder.java index 448c94ee..19ae4c4d 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilder.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilder.java @@ -14,7 +14,6 @@ import com.launchdarkly.sdk.android.subsystems.TransactionalDataStore; import com.launchdarkly.sdk.internal.http.HttpProperties; -import java.io.Closeable; import java.net.URI; import java.util.ArrayList; import java.util.Arrays; @@ -22,8 +21,6 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; -import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledExecutorService; /** * Builds an {@link FDv2DataSource} by resolving {@link ComponentConfigurer} factories @@ -33,17 +30,16 @@ *

* Package-private — not part of the public SDK API. */ -class FDv2DataSourceBuilder implements ComponentConfigurer, Closeable { +class FDv2DataSourceBuilder implements ComponentConfigurer { private Map modeTable; private final ConnectionMode startingMode; private ConnectionMode activeMode; private boolean includeInitializers = true; // false during mode switches to skip initializers (CONNMODE 2.0.1) - private ScheduledExecutorService sharedExecutor; FDv2DataSourceBuilder() { - this.modeTable = null; // built lazily in build() so lambdas can capture sharedExecutor + this.modeTable = null; // built lazily in build() this.startingMode = ConnectionMode.STREAMING; } @@ -52,14 +48,14 @@ private Map makeDefaultModeTable() { DataSourceSetup s = new DataSourceSetup(ctx); FDv2Requestor requestor = makePollingRequestor(ctx, s.httpProps); return new FDv2PollingInitializer(requestor, s.selectorSource, - sharedExecutor, ctx.getBaseLogger()); + ClientContextImpl.get(ctx).getTaskExecutor(), ctx.getBaseLogger()); }; ComponentConfigurer pollingSynchronizer = ctx -> { DataSourceSetup s = new DataSourceSetup(ctx); FDv2Requestor requestor = makePollingRequestor(ctx, s.httpProps); return new FDv2PollingSynchronizer(requestor, s.selectorSource, - sharedExecutor, + ClientContextImpl.get(ctx).getTaskExecutor(), 0, PollingDataSourceBuilder.DEFAULT_POLL_INTERVAL_MILLIS, ctx.getBaseLogger()); }; @@ -78,7 +74,7 @@ private Map makeDefaultModeTable() { pollingRequestor, StreamingDataSourceBuilder.DEFAULT_INITIAL_RECONNECT_DELAY_MILLIS, ctx.isEvaluationReasons(), ctx.getHttp().isUseReport(), - s.httpProps, sharedExecutor, + s.httpProps, ClientContextImpl.get(ctx).getTaskExecutor(), ctx.getBaseLogger(), null); }; @@ -86,7 +82,7 @@ private Map makeDefaultModeTable() { DataSourceSetup s = new DataSourceSetup(ctx); FDv2Requestor requestor = makePollingRequestor(ctx, s.httpProps); return new FDv2PollingSynchronizer(requestor, s.selectorSource, - sharedExecutor, + ClientContextImpl.get(ctx).getTaskExecutor(), 0, LDConfig.DEFAULT_BACKGROUND_POLL_INTERVAL_MILLIS, ctx.getBaseLogger()); }; @@ -155,14 +151,6 @@ ModeDefinition getModeDefinition(@NonNull ConnectionMode mode) { @Override public DataSource build(ClientContext clientContext) { - if (sharedExecutor == null) { - // Pool size 4: supports the FDv2DataSource main loop (1), an active synchronizer - // such as the streaming event loop (1), FDv2DataSource condition timers for - // fallback/recovery (up to 2 short-lived scheduled tasks). Only one mode is active - // at a time (teardown/rebuild), so this pool serves all components. - sharedExecutor = Executors.newScheduledThreadPool(4); - } - if (modeTable == null) { modeTable = makeDefaultModeTable(); } @@ -194,19 +182,11 @@ public DataSource build(ClientContext clientContext) { initFactories, resolved.getSynchronizerFactories(), (DataSourceUpdateSinkV2) baseSink, - sharedExecutor, + ClientContextImpl.get(clientContext).getTaskExecutor(), clientContext.getBaseLogger() ); } - @Override - public void close() { - if (sharedExecutor != null) { - sharedExecutor.shutdownNow(); - sharedExecutor = null; - } - } - private static ResolvedModeDefinition resolve( ModeDefinition def, ClientContext clientContext ) { diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSourceConditions.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSourceConditions.java index fd9541d2..1219cdb7 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSourceConditions.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSourceConditions.java @@ -9,9 +9,7 @@ import java.util.ArrayList; import java.util.List; import java.util.concurrent.Future; -import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; -import java.util.concurrent.TimeUnit; /** * Fallback and recovery conditions for switching between FDv2 synchronizers. @@ -34,18 +32,18 @@ interface Condition { } /** - * Base for conditions that complete after a timeout. Holds the result future, executor, + * Base for conditions that complete after a timeout. Holds the result future, task executor, * timeout, and optional timer; subclasses define when the timer is started and what completes the future. */ static abstract class TimedCondition implements Condition { protected final LDAwaitFuture resultFuture = new LDAwaitFuture<>(); - protected final ScheduledExecutorService sharedExecutor; + protected final TaskExecutor taskExecutor; protected final long timeoutSeconds; /** Future for the timeout task, if any. Null when no timeout is active. */ protected ScheduledFuture timerFuture; - TimedCondition(@NonNull ScheduledExecutorService sharedExecutor, long timeoutSeconds) { - this.sharedExecutor = sharedExecutor; + TimedCondition(@NonNull TaskExecutor taskExecutor, long timeoutSeconds) { + this.taskExecutor = taskExecutor; this.timeoutSeconds = timeoutSeconds; } @@ -68,8 +66,8 @@ public void close() { */ static final class FallbackCondition extends TimedCondition { - FallbackCondition(@NonNull ScheduledExecutorService executor, long timeoutSeconds) { - super(executor, timeoutSeconds); + FallbackCondition(@NonNull TaskExecutor taskExecutor, long timeoutSeconds) { + super(taskExecutor, timeoutSeconds); } @Override @@ -84,10 +82,9 @@ public void inform(@NonNull FDv2SourceResult result) { && result.getStatus() != null && result.getStatus().getState() == SourceSignal.INTERRUPTED) { if (timerFuture == null) { - timerFuture = sharedExecutor.schedule( + timerFuture = taskExecutor.scheduleTask( () -> resultFuture.set(ConditionType.FALLBACK), - timeoutSeconds, - TimeUnit.SECONDS); + timeoutSeconds * 1000); } } } @@ -103,12 +100,11 @@ public ConditionType getType() { */ static final class RecoveryCondition extends TimedCondition { - RecoveryCondition(@NonNull ScheduledExecutorService executor, long timeoutSeconds) { - super(executor, timeoutSeconds); - this.timerFuture = executor.schedule( + RecoveryCondition(@NonNull TaskExecutor taskExecutor, long timeoutSeconds) { + super(taskExecutor, timeoutSeconds); + this.timerFuture = taskExecutor.scheduleTask( () -> resultFuture.set(ConditionType.RECOVERY), - timeoutSeconds, - TimeUnit.SECONDS); + timeoutSeconds * 1000); } @Override diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2PollingInitializer.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2PollingInitializer.java index 550af863..5ae79811 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2PollingInitializer.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2PollingInitializer.java @@ -6,7 +6,6 @@ import com.launchdarkly.sdk.android.subsystems.FDv2SourceResult; import com.launchdarkly.sdk.android.subsystems.Initializer; -import java.util.concurrent.Executor; import java.util.concurrent.Future; /** @@ -23,24 +22,23 @@ */ final class FDv2PollingInitializer extends FDv2PollingBase implements Initializer { private final SelectorSource selectorSource; - private final Executor executor; + private final TaskExecutor taskExecutor; private final LDAwaitFuture shutdownFuture = new LDAwaitFuture<>(); /** * @param requestor the FDv2 requestor used to perform the poll * @param selectorSource source of the current selector - * @param executor executor used to run the poll task on a background thread; should use - * background-priority threads + * @param taskExecutor task executor used to run the poll on a background thread * @param logger logger */ FDv2PollingInitializer( @NonNull FDv2Requestor requestor, @NonNull SelectorSource selectorSource, - @NonNull Executor executor, + @NonNull TaskExecutor taskExecutor, @NonNull LDLogger logger) { super(requestor, logger); this.selectorSource = selectorSource; - this.executor = executor; + this.taskExecutor = taskExecutor; } @Override @@ -48,7 +46,7 @@ final class FDv2PollingInitializer extends FDv2PollingBase implements Initialize public Future run() { LDAwaitFuture pollFuture = new LDAwaitFuture<>(); - executor.execute(() -> { + taskExecutor.startTask(() -> { try { FDv2SourceResult result = doPoll(selectorSource.getSelector(), true); pollFuture.set(result); diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2PollingSynchronizer.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2PollingSynchronizer.java index c35f20e0..cfebe1ab 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2PollingSynchronizer.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2PollingSynchronizer.java @@ -9,9 +9,7 @@ import com.launchdarkly.sdk.fdv2.SourceSignal; import java.util.concurrent.Future; -import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; -import java.util.concurrent.TimeUnit; /** * FDv2 polling synchronizer: polls at a fixed interval and delivers each result via @@ -37,8 +35,7 @@ final class FDv2PollingSynchronizer extends FDv2PollingBase implements Synchroni /** * @param requestor the FDv2 requestor used to perform each poll * @param selectorSource source of the current selector, sent as the {@code basis} param - * @param executor scheduler for recurring poll tasks; should use background-priority - * threads to match the behaviour of {@link FDv2PollingInitializer} + * @param taskExecutor task executor for scheduling recurring poll tasks * @param initialDelayMillis delay before the first poll in milliseconds; use {@code 0} to * poll immediately (e.g. foreground polling). A non-zero value is * used when transitioning from streaming to background polling so @@ -50,7 +47,7 @@ final class FDv2PollingSynchronizer extends FDv2PollingBase implements Synchroni FDv2PollingSynchronizer( @NonNull FDv2Requestor requestor, @NonNull SelectorSource selectorSource, - @NonNull ScheduledExecutorService executor, + @NonNull TaskExecutor taskExecutor, long initialDelayMillis, long pollIntervalMillis, @NonNull LDLogger logger) { @@ -58,11 +55,10 @@ final class FDv2PollingSynchronizer extends FDv2PollingBase implements Synchroni this.selectorSource = selectorSource; synchronized (taskLock) { - scheduledTask = executor.scheduleWithFixedDelay( + scheduledTask = taskExecutor.startTaskWithFixedDelay( this::pollAndEnqueue, initialDelayMillis, - pollIntervalMillis, - TimeUnit.MILLISECONDS); + pollIntervalMillis); } } diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2StreamingSynchronizer.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2StreamingSynchronizer.java index 71d20339..d63c4358 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2StreamingSynchronizer.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2StreamingSynchronizer.java @@ -33,7 +33,6 @@ import java.io.IOException; import java.net.URI; import java.util.Map; -import java.util.concurrent.Executor; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; @@ -70,7 +69,7 @@ final class FDv2StreamingSynchronizer implements Synchronizer { @Nullable private final DiagnosticStore diagnosticStore; private final LDLogger logger; - private final Executor executor; + private final TaskExecutor taskExecutor; private final LDAsyncQueue resultQueue = new LDAsyncQueue<>(); private final LDAwaitFuture shutdownFuture = new LDAwaitFuture<>(); @@ -93,8 +92,8 @@ final class FDv2StreamingSynchronizer implements Synchronizer { * @param evaluationReasons true to request evaluation reasons in the stream * @param useReport true to use HTTP REPORT for the request body * @param httpProperties HTTP configuration for the stream request - * @param executor executor used to run the streaming loop on a background - * thread; should use background-priority threads + * @param executor task executor used to run the streaming loop on a + * background thread * @param logger logger * @param diagnosticStore optional store for stream diagnostics; may be null */ @@ -108,7 +107,7 @@ final class FDv2StreamingSynchronizer implements Synchronizer { boolean evaluationReasons, boolean useReport, @NonNull HttpProperties httpProperties, - @NonNull Executor executor, + @NonNull TaskExecutor executor, @NonNull LDLogger logger, @Nullable DiagnosticStore diagnosticStore ) { @@ -121,7 +120,7 @@ final class FDv2StreamingSynchronizer implements Synchronizer { this.evaluationReasons = evaluationReasons; this.useReport = useReport; this.httpProperties = httpProperties; - this.executor = executor; + this.taskExecutor = executor; this.logger = logger; this.diagnosticStore = diagnosticStore; } @@ -208,7 +207,7 @@ private void startStream() { eventSource = es; } - executor.execute(() -> { + taskExecutor.startTask(() -> { streamStarted = System.currentTimeMillis(); try { for (StreamEvent event : es.anyEvents()) { diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/TaskExecutor.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/TaskExecutor.java index eeb82181..7344472f 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/TaskExecutor.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/TaskExecutor.java @@ -37,4 +37,24 @@ interface TaskExecutor extends Closeable { * @return a ScheduledFuture that can be used to cancel the task */ ScheduledFuture startRepeatingTask(Runnable action, long initialDelayMillis, long intervalMillis); + + /** + * Submits a long-running action to the parallel (dynamically-pooled) executor. Use this for + * blocking or compute-intensive work that must not occupy the single-thread scheduled pool. + * + * @param action the action to execute + */ + void startTask(Runnable action); + + /** + * Schedules an action to run repeatedly with a fixed delay between the end of one + * execution and the start of the next, using the parallel executor. The returned + * future can be cancelled to stop the recurring task. + * + * @param action the action to execute at each interval + * @param initialDelayMillis milliseconds to wait before the first execution + * @param delayMillis milliseconds between the end of one execution and the start of the next + * @return a ScheduledFuture that can be used to cancel the task + */ + ScheduledFuture startTaskWithFixedDelay(Runnable action, long initialDelayMillis, long delayMillis); } diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilderTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilderTest.java index 69c0859f..bb4dd6d8 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilderTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilderTest.java @@ -1,10 +1,8 @@ package com.launchdarkly.sdk.android; import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; @@ -24,7 +22,6 @@ import java.util.Collections; import java.util.LinkedHashMap; import java.util.Map; -import java.util.concurrent.ScheduledExecutorService; public class FDv2DataSourceBuilderTest { @@ -37,7 +34,7 @@ public class FDv2DataSourceBuilderTest { private ClientContext makeClientContext() { LDConfig config = new LDConfig.Builder(AutoEnvAttributes.Disabled).build(); MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); - return new ClientContext( + ClientContext base = new ClientContext( "mobile-key", ENV_REPORTER, logging.logger, @@ -52,6 +49,7 @@ private ClientContext makeClientContext() { config.serviceEndpoints, false ); + return new ClientContextImpl(base, null, null, null, new SimpleTestTaskExecutor(), null); } @Test @@ -175,55 +173,6 @@ public void getModeDefinition_sameObjectUsedForEquivalenceCheck() { == builder.getModeDefinition(ConnectionMode.POLLING)); } - @Test - public void close_shutsDownSharedExecutor() { - FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(); - // build() lazily creates the sharedExecutor - builder.build(makeClientContext()); - - // Access the executor via reflection to verify shutdown - ScheduledExecutorService executor = getSharedExecutor(builder); - assertNotNull(executor); - assertFalse(executor.isShutdown()); - - builder.close(); - assertTrue(executor.isShutdown()); - } - - @Test - public void build_reusesSharedExecutorAcrossRebuilds() { - FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(); - builder.build(makeClientContext()); - ScheduledExecutorService first = getSharedExecutor(builder); - - builder.build(makeClientContext()); - ScheduledExecutorService second = getSharedExecutor(builder); - - assertSame(first, second); - assertFalse(first.isShutdown()); - builder.close(); - } - - @Test - public void close_isIdempotent() { - FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(); - builder.build(makeClientContext()); - - builder.close(); - // second close should not throw - builder.close(); - } - - private static ScheduledExecutorService getSharedExecutor(FDv2DataSourceBuilder builder) { - try { - java.lang.reflect.Field f = FDv2DataSourceBuilder.class.getDeclaredField("sharedExecutor"); - f.setAccessible(true); - return (ScheduledExecutorService) f.get(builder); - } catch (Exception e) { - throw new RuntimeException(e); - } - } - @Test public void setActiveMode_notInTable_throws() { Map customTable = new LinkedHashMap<>(); diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceConditionsTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceConditionsTest.java index 77126e47..0342988c 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceConditionsTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceConditionsTest.java @@ -18,9 +18,7 @@ import java.util.Collections; import java.util.HashMap; import java.util.concurrent.ExecutionException; -import java.util.concurrent.Executors; import java.util.concurrent.Future; -import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; @@ -36,11 +34,11 @@ public class FDv2DataSourceConditionsTest { @Rule public Timeout globalTimeout = Timeout.seconds(5); - private final ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor(); + private final TaskExecutor executor = new SimpleTestTaskExecutor(); @After - public void tearDown() { - executor.shutdownNow(); + public void tearDown() throws Exception { + executor.close(); } // ---- helpers ---- diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceTest.java index cd730bf9..dc7ff7bc 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceTest.java @@ -39,9 +39,7 @@ import java.util.concurrent.BlockingQueue; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutionException; -import java.util.concurrent.Executors; import java.util.concurrent.LinkedBlockingQueue; -import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; @@ -59,17 +57,17 @@ public class FDv2DataSourceTest { private static final long AWAIT_TIMEOUT_SECONDS = 10; - private ScheduledExecutorService executor; + private TaskExecutor executor; @Before public void setUp() { - executor = Executors.newScheduledThreadPool(2); + executor = new SimpleTestTaskExecutor(); } @After - public void tearDown() { - if (executor != null && !executor.isShutdown()) { - executor.shutdownNow(); + public void tearDown() throws Exception { + if (executor != null) { + executor.close(); } } diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2PollingInitializerTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2PollingInitializerTest.java index 3f258a02..5c4ff9e0 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2PollingInitializerTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2PollingInitializerTest.java @@ -16,8 +16,6 @@ import java.io.IOException; import java.util.List; import java.util.concurrent.ExecutionException; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; @@ -32,11 +30,11 @@ public class FDv2PollingInitializerTest { @Rule public Timeout globalTimeout = Timeout.seconds(5); - private final ExecutorService executor = Executors.newSingleThreadExecutor(); + private final TaskExecutor executor = new SimpleTestTaskExecutor(); @After - public void tearDown() { - executor.shutdownNow(); + public void tearDown() throws Exception { + executor.close(); } // ---- helpers ---- diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2PollingSynchronizerTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2PollingSynchronizerTest.java index ed982f11..1831b7db 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2PollingSynchronizerTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2PollingSynchronizerTest.java @@ -15,9 +15,7 @@ import java.io.IOException; import java.util.List; -import java.util.concurrent.Executors; import java.util.concurrent.Future; -import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import static org.junit.Assert.assertEquals; @@ -31,11 +29,11 @@ public class FDv2PollingSynchronizerTest { @Rule public Timeout globalTimeout = Timeout.seconds(5); - private final ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor(); + private final TaskExecutor executor = new SimpleTestTaskExecutor(); @After - public void tearDown() { - executor.shutdownNow(); + public void tearDown() throws Exception { + executor.close(); } // ---- helpers ---- diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2StreamingSynchronizerTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2StreamingSynchronizerTest.java index ab123fbd..a8700e2a 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2StreamingSynchronizerTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2StreamingSynchronizerTest.java @@ -21,8 +21,6 @@ import java.io.IOException; import java.net.URI; import java.util.HashMap; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; @@ -38,11 +36,11 @@ public class FDv2StreamingSynchronizerTest { @Rule public Timeout globalTimeout = Timeout.seconds(10); - private final ExecutorService executor = Executors.newCachedThreadPool(); + private final TaskExecutor executor = new SimpleTestTaskExecutor(); @After - public void tearDown() { - executor.shutdownNow(); + public void tearDown() throws Exception { + executor.close(); } private static final LDContext CONTEXT = LDContext.create("test-context"); diff --git a/shared-test-code/src/main/java/com/launchdarkly/sdk/android/SimpleTestTaskExecutor.java b/shared-test-code/src/main/java/com/launchdarkly/sdk/android/SimpleTestTaskExecutor.java index f53c0dcb..27c75a8e 100644 --- a/shared-test-code/src/main/java/com/launchdarkly/sdk/android/SimpleTestTaskExecutor.java +++ b/shared-test-code/src/main/java/com/launchdarkly/sdk/android/SimpleTestTaskExecutor.java @@ -1,5 +1,6 @@ package com.launchdarkly.sdk.android; +import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; @@ -14,7 +15,8 @@ public class SimpleTestTaskExecutor implements TaskExecutor { private static final ThreadLocal fakeMainThread = new ThreadLocal<>(); - private final ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor(); + private final ScheduledExecutorService scheduledExecutor = Executors.newSingleThreadScheduledExecutor(); + private final ExecutorService parallelExecutor = Executors.newCachedThreadPool(); @Override public void executeOnMainThread(Runnable action) { @@ -26,18 +28,30 @@ public void executeOnMainThread(Runnable action) { @Override public ScheduledFuture scheduleTask(Runnable action, long delayMillis) { - return executor.schedule(action, delayMillis, TimeUnit.MILLISECONDS); + return scheduledExecutor.schedule(action, delayMillis, TimeUnit.MILLISECONDS); } @Override public ScheduledFuture startRepeatingTask(Runnable action, long initialDelayMillis, long intervalMillis) { - return executor.scheduleAtFixedRate(action, + return scheduledExecutor.scheduleAtFixedRate(action, initialDelayMillis, intervalMillis, TimeUnit.MILLISECONDS); } + @Override + public void startTask(Runnable action) { + parallelExecutor.execute(action); + } + + @Override + public ScheduledFuture startTaskWithFixedDelay(Runnable action, long initialDelayMillis, long delayMillis) { + return scheduledExecutor.scheduleWithFixedDelay(action, + initialDelayMillis, delayMillis, TimeUnit.MILLISECONDS); + } + @Override public void close() { - executor.shutdownNow(); + scheduledExecutor.shutdownNow(); + parallelExecutor.shutdownNow(); } public boolean isThisTheFakeMainThread() { From 6fcd7ff088d45466c478abb3e6bc9c000e14c57f Mon Sep 17 00:00:00 2001 From: Aaron Zeisler Date: Tue, 24 Mar 2026 20:58:11 -0700 Subject: [PATCH 17/17] [SDK-1956] Remove redundant setActiveMode call from startUp Move resolveMode() into updateDataSource so it covers both startup and state-change paths, eliminating the duplicate call in startUp that was always overwritten. Made-with: Cursor --- .../sdk/android/ConnectivityManager.java | 47 +++++++++---------- 1 file changed, 23 insertions(+), 24 deletions(-) diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ConnectivityManager.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ConnectivityManager.java index d234ff78..d15dda4b 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ConnectivityManager.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ConnectivityManager.java @@ -193,28 +193,32 @@ private synchronized boolean updateDataSource( DataSource existingDataSource = currentDataSource.get(); boolean isFDv2ModeSwitch = false; - // FDv2 path: resolve mode and determine if a teardown/rebuild is needed. - if (useFDv2ModeResolution && !mustReinitializeDataSource) { + // FDv2 path: resolve mode for both startup (mustReinitializeDataSource=true) and + // state-change (mustReinitializeDataSource=false) cases. + if (useFDv2ModeResolution) { ConnectionMode newMode = resolveMode(); - if (newMode == currentFDv2Mode) { - onCompletion.onSuccess(null); - return false; - } - // CSFDV2 5.3.8: retain active data source if old and new modes have equivalent config. - // ModeDefinition currently relies on Object.equals (reference equality) because - // makeDefaultModeTable() reuses the same instance for modes that share identical - // configuration. - FDv2DataSourceBuilder fdv2Builder = (FDv2DataSourceBuilder) dataSourceFactory; - ModeDefinition oldDef = fdv2Builder.getModeDefinition(currentFDv2Mode); - ModeDefinition newDef = fdv2Builder.getModeDefinition(newMode); - if (oldDef != null && oldDef.equals(newDef)) { - currentFDv2Mode = newMode; - onCompletion.onSuccess(null); - return false; + if (!mustReinitializeDataSource) { + // State-change path: check for no-op or equivalent config before rebuilding. + if (newMode == currentFDv2Mode) { + onCompletion.onSuccess(null); + return false; + } + // CSFDV2 5.3.8: retain active data source if old and new modes have equivalent config. + // ModeDefinition currently relies on Object.equals (reference equality) because + // makeDefaultModeTable() reuses the same instance for modes that share identical + // configuration. + FDv2DataSourceBuilder fdv2Builder = (FDv2DataSourceBuilder) dataSourceFactory; + ModeDefinition oldDef = fdv2Builder.getModeDefinition(currentFDv2Mode); + ModeDefinition newDef = fdv2Builder.getModeDefinition(newMode); + if (oldDef != null && oldDef.equals(newDef)) { + currentFDv2Mode = newMode; + onCompletion.onSuccess(null); + return false; + } + isFDv2ModeSwitch = true; + mustReinitializeDataSource = true; } currentFDv2Mode = newMode; - isFDv2ModeSwitch = true; - mustReinitializeDataSource = true; } // Check whether the existing data source needs a rebuild (e.g. evaluation context changed). @@ -444,11 +448,6 @@ synchronized boolean startUp(@NonNull Callback onCompletion) { } initialized = false; - if (useFDv2ModeResolution) { - currentFDv2Mode = resolveMode(); - ((FDv2DataSourceBuilder) dataSourceFactory).setActiveMode(currentFDv2Mode, true); - } - updateEventProcessor(forcedOffline.get(), platformState.isNetworkAvailable(), platformState.isForeground()); return updateDataSource(true, onCompletion); }