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..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 @@ -4,10 +4,13 @@ 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. @@ -33,7 +36,10 @@ final class ClientContextImpl extends ClientContext { private final PlatformState platformState; private final TaskExecutor taskExecutor; private final PersistentDataStoreWrapper.PerEnvironmentData perEnvironmentData; + @Nullable + private final TransactionalDataStore transactionalDataStore; + /** Used by FDv1 code paths that do not need a {@link TransactionalDataStore}. */ ClientContextImpl( ClientContext base, DiagnosticStore diagnosticStore, @@ -41,6 +47,23 @@ final class ClientContextImpl extends ClientContext { PlatformState platformState, TaskExecutor taskExecutor, PersistentDataStoreWrapper.PerEnvironmentData perEnvironmentData + ) { + 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, + FeatureFetcher fetcher, + PlatformState platformState, + TaskExecutor taskExecutor, + PersistentDataStoreWrapper.PerEnvironmentData perEnvironmentData, + @Nullable TransactionalDataStore transactionalDataStore ) { super(base); this.diagnosticStore = diagnosticStore; @@ -48,6 +71,7 @@ final class ClientContextImpl extends ClientContext { this.platformState = platformState; this.taskExecutor = taskExecutor; this.perEnvironmentData = perEnvironmentData; + this.transactionalDataStore = transactionalDataStore; } static ClientContextImpl fromConfig( @@ -95,12 +119,30 @@ 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, LDContext newEvaluationContext, boolean newInBackground, Boolean previouslyInBackground + ) { + return forDataSource(baseClientContext, dataSourceUpdateSink, newEvaluationContext, + 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, + LDContext newEvaluationContext, + boolean newInBackground, + Boolean previouslyInBackground, + @Nullable TransactionalDataStore transactionalDataStore ) { ClientContextImpl baseContextImpl = ClientContextImpl.get(baseClientContext); return new ClientContextImpl( @@ -123,7 +165,8 @@ public static ClientContextImpl forDataSource( baseContextImpl.getFetcher(), baseContextImpl.getPlatformState(), baseContextImpl.getTaskExecutor(), - baseContextImpl.getPerEnvironmentData() + baseContextImpl.getPerEnvironmentData(), + transactionalDataStore ); } @@ -139,7 +182,8 @@ public ClientContextImpl setEvaluationContext(LDContext context) { this.fetcher, this.platformState, this.taskExecutor, - this.perEnvironmentData + this.perEnvironmentData, + this.transactionalDataStore ); } @@ -163,6 +207,11 @@ public PersistentDataStoreWrapper.PerEnvironmentData getPerEnvironmentData() { return throwExceptionIfNull(perEnvironmentData); } + @Nullable + 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/ConnectionMode.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ConnectionMode.java new file mode 100644 index 00000000..a256ec96 --- /dev/null +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ConnectionMode.java @@ -0,0 +1,39 @@ +package com.launchdarkly.sdk.android; + +/** + * 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. + *

+ * 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. + *

+ * Package-private — not part of the public SDK API. + * + * @see ModeDefinition + * @see ModeResolutionTable + */ +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 22b09e23..bf84efb0 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; @@ -15,8 +14,10 @@ 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.fdv2.Selector; +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; @@ -25,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: // @@ -60,6 +59,7 @@ class ConnectivityManager { 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; @@ -74,6 +74,8 @@ class ConnectivityManager { private final AtomicReference previouslyInBackground = new AtomicReference<>(); private final LDLogger logger; private volatile boolean initialized = false; + private final boolean useFDv2ModeResolution; + 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 @@ -105,7 +107,7 @@ public void apply(@NonNull LDContext context, @NonNull ChangeSet { - updateDataSource(false, LDUtil.noOpCallback()); - }; + connectivityChangeListener = networkAvailable -> handleModeStateChange(); platformState.addConnectivityChangeListener(connectivityChangeListener); - foregroundListener = foreground -> { - DataSource dataSource = currentDataSource.get(); - if (dataSource == null || dataSource.needsRefresh(!foreground, - currentContext.get())) { - updateDataSource(true, LDUtil.noOpCallback()); - } - }; + foregroundListener = foreground -> handleModeStateChange(); platformState.addForegroundChangeListener(foregroundListener); } @@ -180,6 +176,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); @@ -195,25 +192,71 @@ private synchronized boolean updateDataSource( return false; } + DataSource existingDataSource = currentDataSource.get(); + boolean isFDv2ModeSwitch = false; + + // FDv2 path: resolve mode for both startup (mustReinitializeDataSource=true) and + // state-change (mustReinitializeDataSource=false) cases. + if (useFDv2ModeResolution) { + ConnectionMode newMode = resolveMode(); + 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; + } + + // 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())) { + mustReinitializeDataSource = true; + } + } + boolean forceOffline = forcedOffline.get(); boolean networkEnabled = platformState.isNetworkAvailable(); boolean inBackground = !platformState.isForeground(); LDContext context = currentContext.get(); - eventProcessor.setOffline(forceOffline || !networkEnabled); - eventProcessor.setInBackground(inBackground); - 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. + // 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) { 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; @@ -237,8 +280,15 @@ private synchronized boolean updateDataSource( dataSourceUpdateSink, context, inBackground, - previouslyInBackground.get() + previouslyInBackground.get(), + transactionalDataStore ); + + if (useFDv2ModeResolution) { + // CONNMODE 2.0.1: mode switches only transition synchronizers, not initializers. + ((FDv2DataSourceBuilder) dataSourceFactory).setActiveMode(currentFDv2Mode, !isFDv2ModeSwitch); + } + DataSource dataSource = dataSourceFactory.build(clientContext); currentDataSource.set(dataSource); previouslyInBackground.set(Boolean.valueOf(inBackground)); @@ -247,16 +297,12 @@ private synchronized boolean updateDataSource( @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); } @@ -293,7 +339,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); @@ -318,7 +364,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) { @@ -403,6 +449,8 @@ synchronized boolean startUp(@NonNull Callback onCompletion) { return false; } initialized = false; + + updateEventProcessor(forcedOffline.get(), platformState.isNetworkAvailable(), platformState.isForeground()); return updateDataSource(true, onCompletion); } @@ -418,6 +466,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); } @@ -425,7 +479,7 @@ void shutDown() { void setForceOffline(boolean forceOffline) { boolean wasForcedOffline = forcedOffline.getAndSet(forceOffline); if (forceOffline != wasForcedOffline) { - updateDataSource(false, LDUtil.noOpCallback()); + handleModeStateChange(); } } @@ -433,6 +487,42 @@ 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 synchronized void handleModeStateChange() { + boolean forceOffline = forcedOffline.get(); + boolean networkAvailable = platformState.isNetworkAvailable(); + boolean foreground = platformState.isForeground(); + + updateEventProcessor(forceOffline, networkAvailable, foreground); + updateDataSource(false, LDUtil.noOpCallback()); + } + + /** + * Resolves the current platform state to a {@link ConnectionMode} via the + * {@link ModeResolutionTable}. Force-offline is handled as a short-circuit + * so that {@link ModeState} faithfully represents actual platform state. + */ + private ConnectionMode resolveMode() { + if (forcedOffline.get()) { + return ConnectionMode.OFFLINE; + } + ModeState state = new ModeState( + platformState.isForeground(), + platformState.isNetworkAvailable(), + backgroundUpdatingDisabled + ); + return ModeResolutionTable.MOBILE.resolve(state); + } + 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 82406356..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 @@ -7,17 +7,17 @@ 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; 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; -import java.util.Map; import java.util.Collections; import java.util.List; +import java.util.Map; import java.util.concurrent.CancellationException; import java.util.concurrent.ExecutionException; import java.util.concurrent.Future; @@ -50,7 +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); - /** 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; @@ -215,6 +214,13 @@ public void stop(@NonNull Callback completionCallback) { completionCallback.onSuccess(null); } + @Override + public boolean needsRefresh(boolean newInBackground, @NonNull LDContext newEvaluationContext) { + // 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); + } + 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 new file mode 100644 index 00000000..448c94ee --- /dev/null +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilder.java @@ -0,0 +1,262 @@ +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.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.http.HttpProperties; + +import java.io.Closeable; +import java.net.URI; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +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 + * 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. + */ +class FDv2DataSourceBuilder implements ComponentConfigurer, Closeable { + + 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.startingMode = ConnectionMode.STREAMING; + } + + private Map makeDefaultModeTable() { + ComponentConfigurer pollingInitializer = ctx -> { + DataSourceSetup s = new DataSourceSetup(ctx); + FDv2Requestor requestor = makePollingRequestor(ctx, s.httpProps); + return new FDv2PollingInitializer(requestor, s.selectorSource, + sharedExecutor, ctx.getBaseLogger()); + }; + + ComponentConfigurer pollingSynchronizer = ctx -> { + 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 -> { + 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()); + return new FDv2StreamingSynchronizer( + ctx.getEvaluationContext(), s.selectorSource, streamBase, + StandardEndpoints.FDV2_STREAMING_REQUEST_BASE_PATH, + pollingRequestor, + StreamingDataSourceBuilder.DEFAULT_INITIAL_RECONNECT_DELAY_MILLIS, + ctx.isEvaluationReasons(), ctx.getHttp().isUseReport(), + s.httpProps, sharedExecutor, + ctx.getBaseLogger(), null); + }; + + ComponentConfigurer backgroundPollingSynchronizer = ctx -> { + 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()); + }; + + Map table = new LinkedHashMap<>(); + table.put(ConnectionMode.STREAMING, new ModeDefinition( + // TODO: cacheInitializer — add once implemented + Arrays.asList(/* cacheInitializer, */ pollingInitializer), + Arrays.asList(streamingSynchronizer, pollingSynchronizer) + )); + table.put(ConnectionMode.POLLING, new ModeDefinition( + // TODO: Arrays.asList(cacheInitializer) — add once implemented + Collections.>emptyList(), + Collections.singletonList(pollingSynchronizer) + )); + table.put(ConnectionMode.OFFLINE, new ModeDefinition( + // TODO: Arrays.asList(cacheInitializer) — add once implemented + Collections.>emptyList(), + Collections.>emptyList() + )); + table.put(ConnectionMode.ONE_SHOT, new ModeDefinition( + // TODO: cacheInitializer and streamingInitializer — add once implemented + Arrays.asList(/* cacheInitializer, */ pollingInitializer /*, streamingInitializer, */), + Collections.>emptyList() + )); + table.put(ConnectionMode.BACKGROUND, new ModeDefinition( + // TODO: Arrays.asList(cacheInitializer) — add once implemented + Collections.>emptyList(), + Collections.singletonList(backgroundPollingSynchronizer) + )); + return table; + } + + FDv2DataSourceBuilder( + @NonNull Map modeTable, + @NonNull ConnectionMode startingMode + ) { + this.modeTable = modeTable; + this.startingMode = startingMode; + } + + @NonNull + ConnectionMode getStartingMode() { + return startingMode; + } + + /** + * 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) { + 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(); + } + + ConnectionMode mode = activeMode != null ? activeMode : startingMode; + + ModeDefinition modeDef = modeTable.get(mode); + if (modeDef == null) { + throw new IllegalStateException( + "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"); + } + + 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(), + initFactories, + resolved.getSynchronizerFactories(), + (DataSourceUpdateSinkV2) baseSink, + sharedExecutor, + clientContext.getBaseLogger() + ); + } + + @Override + public void close() { + if (sharedExecutor != null) { + sharedExecutor.shutdownNow(); + sharedExecutor = null; + } + } + + private static ResolvedModeDefinition resolve( + ModeDefinition def, ClientContext clientContext + ) { + 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); + } + + /** + * 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/ModeDefinition.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeDefinition.java new file mode 100644 index 00000000..81d69b8f --- /dev/null +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeDefinition.java @@ -0,0 +1,48 @@ +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.Collections; +import java.util.List; + +/** + * 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. + *

+ * 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 + * @see ResolvedModeDefinition + */ +final class ModeDefinition { + + 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..80fd1982 --- /dev/null +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeResolutionEntry.java @@ -0,0 +1,43 @@ +package com.launchdarkly.sdk.android; + +import androidx.annotation.NonNull; + +/** + * 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 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); + } + + 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..64c7fb23 --- /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. + * 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. Default → {@link ConnectionMode#STREAMING}
  8. + *
+ *

+ * 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() && state.isBackgroundUpdatingDisabled(), + ConnectionMode.OFFLINE), + new ModeResolutionEntry( + state -> !state.isForeground(), + ConnectionMode.BACKGROUND) + ), ConnectionMode.STREAMING); + + private final List entries; + private final ConnectionMode defaultMode; + + 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} + */ + @NonNull + ConnectionMode resolve(@NonNull ModeState state) { + for (ModeResolutionEntry entry : entries) { + if (entry.getConditions().test(state)) { + return entry.getMode(); + } + } + return defaultMode; + } +} 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..ca86d907 --- /dev/null +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeState.java @@ -0,0 +1,37 @@ +package com.launchdarkly.sdk.android; + +/** + * Snapshot of the current platform state used as input to + * {@link ModeResolutionTable#resolve(ModeState)}. + *

+ * 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 { + + private final boolean foreground; + private final boolean networkAvailable; + private final boolean backgroundUpdatingDisabled; + + ModeState(boolean foreground, boolean networkAvailable, boolean backgroundUpdatingDisabled) { + this.foreground = foreground; + this.networkAvailable = networkAvailable; + this.backgroundUpdatingDisabled = backgroundUpdatingDisabled; + } + + boolean isForeground() { + return foreground; + } + + boolean isNetworkAvailable() { + return networkAvailable; + } + + boolean isBackgroundUpdatingDisabled() { + return backgroundUpdatingDisabled; + } +} 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..e404a5ac --- /dev/null +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ResolvedModeDefinition.java @@ -0,0 +1,45 @@ +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. + *

+ * Package-private — not part of the public SDK API. + * + * @see ModeDefinition + */ +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 c9c395e3..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 @@ -17,6 +17,10 @@ private StandardEndpoints() {} static final String ANALYTICS_EVENTS_REQUEST_PATH = "/mobile/events/bulk"; static final String DIAGNOSTIC_EVENTS_REQUEST_PATH = "/mobile/events/diagnostic"; + 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"; + /** * 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 26523e5e..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 @@ -37,8 +37,14 @@ import org.junit.Test; import org.junit.rules.Timeout; +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; @@ -565,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()); @@ -657,4 +795,247 @@ private void verifyNoMoreDataSourcesWereCreated() { private void verifyNoMoreDataSourcesWereStopped() { requireNoMoreValues(stoppedDataSources, 1, TimeUnit.SECONDS, "stopping of data source"); } + + // ==== FDv2 mode resolution tests ==== + + /** + * 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 FDv2DataSourceBuilder makeFDv2DataSourceFactory() { + 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) { + receivedClientContexts.add(clientContext); + return MockComponents.successfulDataSource(clientContext, DATA, + ConnectionMode.STREAMING, startedDataSources, stoppedDataSources); + } + }; + } + + @Test + public void fdv2_foregroundToBackground_rebuildsDataSource() throws Exception { + eventProcessor.setOffline(false); + eventProcessor.setInBackground(false); + eventProcessor.setOffline(false); + eventProcessor.setInBackground(true); + replayAll(); + + createTestManager(false, false, makeFDv2DataSourceFactory()); + awaitStartUp(); + verifyForegroundDataSourceWasCreatedAndStarted(CONTEXT); + + mockPlatformState.setAndNotifyForegroundChangeListeners(false); + + verifyDataSourceWasStopped(); + requireValue(receivedClientContexts, 1, TimeUnit.SECONDS, "new data source creation"); + requireValue(startedDataSources, 1, TimeUnit.SECONDS, "new data source started"); + verifyAll(); + } + + @Test + public void fdv2_backgroundToForeground_rebuildsDataSource() throws Exception { + eventProcessor.setOffline(false); + eventProcessor.setInBackground(false); + eventProcessor.setOffline(false); + eventProcessor.setInBackground(true); + eventProcessor.setOffline(false); + eventProcessor.setInBackground(false); + replayAll(); + + createTestManager(false, false, makeFDv2DataSourceFactory()); + awaitStartUp(); + verifyForegroundDataSourceWasCreatedAndStarted(CONTEXT); + + mockPlatformState.setAndNotifyForegroundChangeListeners(false); + verifyDataSourceWasStopped(); + requireValue(receivedClientContexts, 1, TimeUnit.SECONDS, "bg data source creation"); + requireValue(startedDataSources, 1, TimeUnit.SECONDS, "bg data source started"); + + mockPlatformState.setAndNotifyForegroundChangeListeners(true); + verifyDataSourceWasStopped(); + requireValue(receivedClientContexts, 1, TimeUnit.SECONDS, "fg data source creation"); + requireValue(startedDataSources, 1, TimeUnit.SECONDS, "fg data source started"); + + verifyAll(); + } + + @Test + public void fdv2_networkLost_rebuildsToOffline() throws Exception { + eventProcessor.setOffline(false); + eventProcessor.setInBackground(false); + eventProcessor.setOffline(true); + eventProcessor.setInBackground(false); + replayAll(); + + createTestManager(false, false, makeFDv2DataSourceFactory()); + awaitStartUp(); + verifyForegroundDataSourceWasCreatedAndStarted(CONTEXT); + + mockPlatformState.setAndNotifyConnectivityChangeListeners(false); + + 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(); + } + + @Test + public void fdv2_forceOffline_rebuildsToOffline() throws Exception { + eventProcessor.setOffline(false); + eventProcessor.setInBackground(false); + eventProcessor.setOffline(true); + eventProcessor.setInBackground(false); + replayAll(); + + createTestManager(false, false, makeFDv2DataSourceFactory()); + awaitStartUp(); + verifyForegroundDataSourceWasCreatedAndStarted(CONTEXT); + + connectivityManager.setForceOffline(true); + + verifyDataSourceWasStopped(); + requireValue(receivedClientContexts, 1, TimeUnit.SECONDS, "offline data source creation"); + requireValue(startedDataSources, 1, TimeUnit.SECONDS, "offline data source started"); + verifyAll(); + } + + @Test + public void fdv2_sameModeDoesNotRebuild() throws Exception { + eventProcessor.setOffline(false); + eventProcessor.setInBackground(false); + eventProcessor.setOffline(false); + eventProcessor.setInBackground(false); + replayAll(); + + createTestManager(false, false, makeFDv2DataSourceFactory()); + awaitStartUp(); + verifyForegroundDataSourceWasCreatedAndStarted(CONTEXT); + + mockPlatformState.setAndNotifyForegroundChangeListeners(true); + + verifyNoMoreDataSourcesWereCreated(); + verifyNoMoreDataSourcesWereStopped(); + verifyAll(); + } + + @Test + 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(true); + replayAll(); + + createTestManager(false, false, builder); + awaitStartUp(); + verifyForegroundDataSourceWasCreatedAndStarted(CONTEXT); + + // STREAMING and BACKGROUND share the same ModeDefinition object, so 5.3.8 says no rebuild + mockPlatformState.setAndNotifyForegroundChangeListeners(false); + + verifyNoMoreDataSourcesWereCreated(); + verifyNoMoreDataSourcesWereStopped(); + 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<>(); + + 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(); + + createTestManager(false, false, builder); + awaitStartUp(); + verifyForegroundDataSourceWasCreatedAndStarted(CONTEXT); + + mockPlatformState.setAndNotifyForegroundChangeListeners(false); + + 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 new file mode 100644 index 00000000..69c0859f --- /dev/null +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilderTest.java @@ -0,0 +1,244 @@ +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; + +import com.launchdarkly.sdk.LDContext; +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.Initializer; +import com.launchdarkly.sdk.android.subsystems.Synchronizer; + +import org.junit.Rule; +import org.junit.Test; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.concurrent.ScheduledExecutorService; + +public class FDv2DataSourceBuilderTest { + + private static final LDContext CONTEXT = LDContext.create("test-context"); + private static final IEnvironmentReporter ENV_REPORTER = new EnvironmentReporterBuilder().build(); + + @Rule + public LogCaptureRule logging = new LogCaptureRule(); + + private ClientContext makeClientContext() { + LDConfig config = new LDConfig.Builder(AutoEnvAttributes.Disabled).build(); + MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); + return new ClientContext( + "mobile-key", + ENV_REPORTER, + logging.logger, + config, + sink, + "default", + false, + CONTEXT, + null, + false, + null, + config.serviceEndpoints, + false + ); + } + + @Test + public void defaultBuilder_buildsFDv2DataSource() { + FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(); + DataSource ds = builder.build(makeClientContext()); + assertNotNull(ds); + assertTrue(ds instanceof FDv2DataSource); + } + + @Test + public void customModeTable_buildsCorrectly() { + Map customTable = new LinkedHashMap<>(); + customTable.put(ConnectionMode.POLLING, new ModeDefinition( + Collections.>emptyList(), + Collections.>singletonList(ctx -> null) + )); + + FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(customTable, ConnectionMode.POLLING); + DataSource ds = builder.build(makeClientContext()); + assertNotNull(ds); + } + + @Test + public void startingMode_notInTable_throws() { + Map customTable = new LinkedHashMap<>(); + customTable.put(ConnectionMode.POLLING, new ModeDefinition( + Collections.>emptyList(), + Collections.>singletonList(ctx -> null) + )); + + FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(customTable, ConnectionMode.STREAMING); + try { + builder.build(makeClientContext()); + fail("Expected IllegalStateException"); + } catch (IllegalStateException e) { + assertTrue(e.getMessage().contains("not found in mode table")); + } + } + + @Test + 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), + Collections.>singletonList(ctx -> null) + )); + + FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(customTable, ConnectionMode.STREAMING); + 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)); + } + + @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<>(); + 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 ad1e91e3..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 @@ -1420,4 +1420,23 @@ public void stopReportsOffStatus() throws Exception { DataSourceState offStatus = sink.awaitStatus(AWAIT_TIMEOUT_SECONDS, TimeUnit.SECONDS); assertEquals(DataSourceState.OFF, offStatus); } + + @Test + public void needsRefresh_sameContext_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()); + 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 new file mode 100644 index 00000000..f70990f0 --- /dev/null +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ModeResolutionTableTest.java @@ -0,0 +1,96 @@ +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; + +public class ModeResolutionTableTest { + + // ==== MOBILE table tests ==== + + @Test + public void mobile_foregroundWithNetwork_resolvesToStreaming() { + 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, 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, false); + assertSame(ConnectionMode.OFFLINE, ModeResolutionTable.MOBILE.resolve(state)); + } + + @Test + public void mobile_backgroundWithoutNetwork_resolvesToOffline() { + 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 + 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 + public void emptyTable_returnsDefault() { + ModeResolutionTable table = new ModeResolutionTable( + Collections.emptyList(), ConnectionMode.STREAMING); + assertSame(ConnectionMode.STREAMING, table.resolve(new ModeState(true, true, false))); + } + + @Test + public void noMatch_returnsDefault() { + ModeResolutionTable table = new ModeResolutionTable(Collections.singletonList( + new ModeResolutionEntry(state -> false, ConnectionMode.POLLING) + ), ConnectionMode.STREAMING); + assertSame(ConnectionMode.STREAMING, table.resolve(new ModeState(true, true, false))); + } + + // ==== ModeState tests ==== + + @Test + public void modeState_getters() { + ModeState state = new ModeState(true, false, true); + assertEquals(true, state.isForeground()); + assertEquals(false, state.isNetworkAvailable()); + assertEquals(true, state.isBackgroundUpdatingDisabled()); + } + + // ==== ModeResolutionEntry tests ==== + + @Test + 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()); + } +} 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(() -> {