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 6903168e..2bee3cdc 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 @@ -6,7 +6,6 @@ import com.launchdarkly.sdk.android.subsystems.ClientContext; 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; @@ -37,9 +36,9 @@ final class ClientContextImpl extends ClientContext { private final TaskExecutor taskExecutor; private final PersistentDataStoreWrapper.PerEnvironmentData perEnvironmentData; @Nullable - private final TransactionalDataStore transactionalDataStore; + private final SelectorSource selectorSource; - /** Used by FDv1 code paths that do not need a {@link TransactionalDataStore}. */ + /** Used by FDv1 code paths that do not need a {@link SelectorSource}. */ ClientContextImpl( ClientContext base, DiagnosticStore diagnosticStore, @@ -52,9 +51,8 @@ final class ClientContextImpl extends ClientContext { } /** - * 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. + * Used by FDv2 code paths. The {@code selectorSource} provides selector state to + * initializers and synchronizers via the {@link ContextDataManager.ContextDataManagerView}. */ ClientContextImpl( ClientContext base, @@ -63,7 +61,7 @@ final class ClientContextImpl extends ClientContext { PlatformState platformState, TaskExecutor taskExecutor, PersistentDataStoreWrapper.PerEnvironmentData perEnvironmentData, - @Nullable TransactionalDataStore transactionalDataStore + @Nullable SelectorSource selectorSource ) { super(base); this.diagnosticStore = diagnosticStore; @@ -71,7 +69,7 @@ final class ClientContextImpl extends ClientContext { this.platformState = platformState; this.taskExecutor = taskExecutor; this.perEnvironmentData = perEnvironmentData; - this.transactionalDataStore = transactionalDataStore; + this.selectorSource = selectorSource; } static ClientContextImpl fromConfig( @@ -119,7 +117,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}. */ + /** Creates a context for FDv1 data sources that do not need a {@link SelectorSource}. */ public static ClientContextImpl forDataSource( ClientContext baseClientContext, DataSourceUpdateSink dataSourceUpdateSink, @@ -132,9 +130,9 @@ public static ClientContextImpl forDataSource( } /** - * 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}. + * Creates a context for data sources, optionally including a {@link SelectorSource}. + * FDv2 data sources require the selector source so that {@link FDv2DataSourceBuilder} can + * provide selector state to initializers and synchronizers. */ public static ClientContextImpl forDataSource( ClientContext baseClientContext, @@ -142,7 +140,7 @@ public static ClientContextImpl forDataSource( LDContext newEvaluationContext, boolean newInBackground, Boolean previouslyInBackground, - @Nullable TransactionalDataStore transactionalDataStore + @Nullable SelectorSource selectorSource ) { ClientContextImpl baseContextImpl = ClientContextImpl.get(baseClientContext); return new ClientContextImpl( @@ -166,7 +164,7 @@ public static ClientContextImpl forDataSource( baseContextImpl.getPlatformState(), baseContextImpl.getTaskExecutor(), baseContextImpl.getPerEnvironmentData(), - transactionalDataStore + selectorSource ); } @@ -183,7 +181,7 @@ public ClientContextImpl setEvaluationContext(LDContext context) { this.platformState, this.taskExecutor, this.perEnvironmentData, - this.transactionalDataStore + this.selectorSource ); } @@ -211,10 +209,10 @@ public PersistentDataStoreWrapper.PerEnvironmentData getPerEnvironmentData() { public PersistentDataStoreWrapper.PerEnvironmentData getPerEnvironmentDataIfAvailable() { return perEnvironmentData; } - + @Nullable - public TransactionalDataStore getTransactionalDataStore() { - return transactionalDataStore; + public SelectorSource getSelectorSource() { + return selectorSource; } private static T throwExceptionIfNull(T o) { 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 ece78a1a..a89a62ff 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 @@ -1,6 +1,7 @@ package com.launchdarkly.sdk.android; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import com.launchdarkly.logging.LDLogger; import com.launchdarkly.logging.LogValues; @@ -15,19 +16,19 @@ 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 java.io.Closeable; import java.io.IOException; import java.lang.ref.WeakReference; import java.util.ArrayList; +import java.util.Arrays; import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; -class ConnectivityManager { +class ConnectivityManager implements ContextDataManager.ContextSwitchListener { // Implementation notes: // // 1. This class has no direct interactions with Android APIs. All logic related to detecting @@ -56,11 +57,9 @@ class ConnectivityManager { private final ClientContext baseClientContext; private final PlatformState platformState; private final ComponentConfigurer dataSourceFactory; - 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; @@ -71,7 +70,6 @@ class ConnectivityManager { private final AtomicBoolean started = new AtomicBoolean(); private final AtomicBoolean closed = new AtomicBoolean(); private final AtomicReference currentDataSource = new AtomicReference<>(); - private final AtomicReference currentContext = new AtomicReference<>(); private final AtomicReference previousModeState = new AtomicReference<>(); private final LDLogger logger; private volatile boolean initialized = false; @@ -80,33 +78,35 @@ class ConnectivityManager { private volatile ConnectionMode currentFDv2Mode; private final AutomaticModeSwitchingConfig autoModeSwitchingConfig; + private final AtomicReference currentContext = new AtomicReference<>(); + private volatile ContextDataManager.ContextDataManagerView currentView; + @Nullable private volatile Callback pendingStartUpCallback; + // 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 - // data is stored; 2. to implement additional logic that does not depend on what kind of data - // source we're using, like "if there was an error, update the ConnectionInformation." + // Data operations (init, upsert, apply) are routed through a ContextDataManager.ContextDataManagerView + // which gates them with a validity flag — invalidated views silently discard writes. + // Status operations (setStatus, shutDown) are routed to ConnectivityManager directly. + // A new instance is created for each data source, using the view that was current at the time. private class DataSourceUpdateSinkImpl implements DataSourceUpdateSink, DataSourceUpdateSinkV2 { - private final ContextDataManager contextDataManager; + private final ContextDataManager.ContextDataManagerView view; - DataSourceUpdateSinkImpl(ContextDataManager contextDataManager) { - this.contextDataManager = contextDataManager; + DataSourceUpdateSinkImpl(ContextDataManager.ContextDataManagerView view) { + this.view = view; } @Override public void init(LDContext context, Map items) { - contextDataManager.initData(context, EnvironmentData.usingExistingFlagsMap(items)); - // Currently, contextDataManager is responsible for firing any necessary flag change events. + view.init(context, items); } @Override public void upsert(LDContext context, DataModel.Flag item) { - contextDataManager.upsert(context, item); - // Currently, contextDataManager is responsible for firing any necessary flag change events. + view.upsert(context, item); } @Override public void apply(@NonNull LDContext context, @NonNull ChangeSet> changeSet) { - contextDataManager.apply(context, changeSet); - // Currently, contextDataManager is responsible for firing any necessary flag change events. + view.apply(context, changeSet); } @Override @@ -144,20 +144,16 @@ public void shutDown() { ConnectivityManager(@NonNull final ClientContext clientContext, @NonNull final ComponentConfigurer dataSourceFactory, @NonNull final EventProcessor eventProcessor, - @NonNull final ContextDataManager contextDataManager, @NonNull final PersistentDataStoreWrapper.PerEnvironmentData environmentStore ) { this.baseClientContext = clientContext; this.dataSourceFactory = dataSourceFactory; - 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(); - currentContext.set(clientContext.getEvaluationContext()); forcedOffline.set(clientContext.isSetOffline()); LDConfig ldConfig = clientContext.getConfig(); @@ -189,32 +185,29 @@ public void shutDown() { platformState.addForegroundChangeListener(foregroundListener); } - /** - * Switches the {@link ConnectivityManager} to begin fetching/receiving information - * relevant to the context provided. This is likely to result in the teardown of existing - * connections, but the timing of that is not guaranteed. - * - * @param context to swtich to - * @param onCompletion callback that indicates when the switching is done - */ - void switchToContext(@NonNull LDContext context, @NonNull Callback onCompletion) { - DataSource dataSource = currentDataSource.get(); - LDContext oldContext = currentContext.getAndSet(context); + @Override + public synchronized void onContextChanged( + @NonNull LDContext context, + @NonNull ContextDataManager.ContextDataManagerView view, + @NonNull Callback onCompletion + ) { + this.currentContext.set(context); + this.currentView = view; - if (oldContext == context || oldContext.equals(context)) { - onCompletion.onSuccess(null); + Callback effectiveCallback; + if (pendingStartUpCallback != null) { + effectiveCallback = LDUtil.compositeCallback(Arrays.asList(pendingStartUpCallback, onCompletion)); + pendingStartUpCallback = null; } else { - ModeState state = snapshotModeState(); - if (dataSource == null || dataSource.needsRefresh(!state.isForeground(), context)) { - updateEventProcessor(forcedOffline.get(), state.isNetworkAvailable(), state.isForeground()); - updateDataSource(true, state, onCompletion); - } else { - onCompletion.onSuccess(null); - } + effectiveCallback = onCompletion; } + + ModeState state = snapshotModeState(); + updateEventProcessor(forcedOffline.get(), state.isNetworkAvailable(), state.isForeground()); + updateDataSource(true, state, effectiveCallback); } - private synchronized boolean updateDataSource( + private boolean updateDataSource( boolean mustReinitializeDataSource, @NonNull ModeState newState, @NonNull Callback onCompletion @@ -257,7 +250,7 @@ private synchronized boolean updateDataSource( // Only consult needsRefresh() when the platform state has actually changed since the // last data source was built. Duplicate notifications (e.g. a connectivity event that // doesn't change the network state) are filtered out, preventing unnecessary rebuilds. - // Context changes are handled by switchToContext(), which passes + // Context changes are handled via onContextChanged(), which passes // mustReinitializeDataSource=true directly. if (!mustReinitializeDataSource && existingDataSource != null) { boolean inBackground = !newState.isForeground(); @@ -287,11 +280,11 @@ private synchronized boolean updateDataSource( } else if (forceOffline) { logger.debug("Initialized in offline mode"); initialized = true; - dataSourceUpdateSink.setStatus(ConnectionInformation.ConnectionMode.SET_OFFLINE, null); + updateConnectionInfoForSuccess(ConnectionInformation.ConnectionMode.SET_OFFLINE); } else if (!newState.isNetworkAvailable()) { - dataSourceUpdateSink.setStatus(ConnectionInformation.ConnectionMode.OFFLINE, null); + updateConnectionInfoForSuccess(ConnectionInformation.ConnectionMode.OFFLINE); } else if (!newState.isForeground() && newState.isBackgroundUpdatingDisabled()) { - dataSourceUpdateSink.setStatus(ConnectionInformation.ConnectionMode.BACKGROUND_DISABLED, null); + updateConnectionInfoForSuccess(ConnectionInformation.ConnectionMode.BACKGROUND_DISABLED); } else { shouldStopExistingDataSource = mustReinitializeDataSource; shouldStartDataSourceIfStopped = true; @@ -309,6 +302,9 @@ private synchronized boolean updateDataSource( return false; } + ContextDataManager.ContextDataManagerView view = currentView; + DataSourceUpdateSink dataSourceUpdateSink = new DataSourceUpdateSinkImpl(view); + logger.debug("Creating data source (background={})", !newState.isForeground()); ClientContext clientContext = ClientContextImpl.forDataSource( baseClientContext, @@ -316,7 +312,7 @@ private synchronized boolean updateDataSource( context, !newState.isForeground(), previousModeState.get() != null ? !previousModeState.get().isForeground() : null, - transactionalDataStore + view // view will serve as the selector source ); if (useFDv2ModeResolution) { @@ -510,22 +506,33 @@ private void updateListenersOnFailure(final LDFailure ldFailure) { /** * Attempts to start the data source if possible. *

- * If we are configured to be offline or the network is unavailable, it immediately calls the - * completion listener and returns. Otherwise, it continues initialization asynchronously and - * the listener will be called when the data source successfully starts up or permanently fails. + * If we are configured to be offline or the network is unavailable, the callback + * is completed immediately with success. Otherwise, it continues initialization + * asynchronously and the callback will be called when the data source successfully + * starts up or permanently fails. * + * @param contextDataManager the context data manager to listen to + * @param onCompletion callback that indicates when startup is done * @return true if we are online, or false if we are offline (this determines whether we should * try to send an identify event on startup) */ - synchronized boolean startUp(@NonNull Callback onCompletion) { - if (closed.get() || started.getAndSet(true)) { + synchronized boolean startUp( + @NonNull ContextDataManager contextDataManager, + @NonNull Callback onCompletion + ) { + if (closed.get() || started.get()) { return false; } initialized = false; - ModeState state = snapshotModeState(); - updateEventProcessor(forcedOffline.get(), state.isNetworkAvailable(), state.isForeground()); - return updateDataSource(true, state, onCompletion); + pendingStartUpCallback = onCompletion; + started.set(true); + + // CDM immediately calls onContextChanged(...) after registration and that will + // handle creating the first data source synchronously. + contextDataManager.setContextSwitchListener(this); + + return currentDataSource.get() != null; } /** diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ContextDataManager.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ContextDataManager.java index 0245178d..020db726 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ContextDataManager.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ContextDataManager.java @@ -13,6 +13,8 @@ import com.launchdarkly.sdk.android.DataModel.Flag; import com.launchdarkly.sdk.fdv2.Selector; +import com.launchdarkly.sdk.android.subsystems.Callback; + import java.util.ArrayList; import java.util.Collection; import java.util.Collections; @@ -41,7 +43,7 @@ * implementation of PersistentDataStore was used to create the PersistentDataStoreWrapper, and * deferred listener calls are done via the {@link TaskExecutor} abstraction. */ -final class ContextDataManager implements TransactionalDataStore { +final class ContextDataManager { private final PersistentDataStoreWrapper.PerEnvironmentData environmentStore; private final int maxCachedContexts; private final TaskExecutor taskExecutor; @@ -49,6 +51,8 @@ final class ContextDataManager implements TransactionalDataStore { new ConcurrentHashMap<>(); private final CopyOnWriteArrayList allFlagsListeners = new CopyOnWriteArrayList<>(); + + @Nullable private volatile ContextSwitchListener contextSwitchListener; private final LDLogger logger; /** @@ -59,6 +63,7 @@ final class ContextDataManager implements TransactionalDataStore { @NonNull private volatile LDContext currentContext; @NonNull private volatile EnvironmentData flags = new EnvironmentData(); @NonNull private volatile ContextIndex index; + @NonNull private volatile ContextDataManagerView currentView; /** Selector from the last applied changeset that carried one; in-memory only, not persisted. */ @NonNull private Selector currentSelector = Selector.EMPTY; @@ -79,43 +84,78 @@ final class ContextDataManager implements TransactionalDataStore { this.maxCachedContexts = maxCachedContexts; this.taskExecutor = ClientContextImpl.get(clientContext).getTaskExecutor(); this.logger = clientContext.getBaseLogger(); - switchToContext(clientContext.getEvaluationContext(), skipCacheLoad); + this.currentView = new ContextDataManagerView(); + switchToContext(clientContext.getEvaluationContext(), skipCacheLoad, LDUtil.noOpCallback()); } /** * Switches to providing flag data for the provided context. *

- * If the context provided is different than the current state, switches to internally - * stored flag data and notifies flag listeners. + * If the context provided is different than the current context, the previous + * {@link ContextDataManagerView} is invalidated, a new view is created, stored flag + * data is loaded (if available and {@code skipCacheLoad} is false), and the registered + * {@link ContextSwitchListener} is notified with the new context, view, and completion + * callback. + *

+ * If the context is the same as the current context, the callback is completed + * immediately with success. * * @param context the context to switch to - * @param skipCacheLoad true to only set the current context without loading cached data - * (used in the FDv2 path where the cache initializer handles loading) + * @param skipCacheLoad true to skip loading cached data from the persistent store here + * (FDv2: cache initializer loads it); the listener and completion + * callback are still invoked + * @param onCompletion callback for when downstream work is complete */ - public void switchToContext(@NonNull LDContext context, boolean skipCacheLoad) { + public void switchToContext(@NonNull LDContext context, boolean skipCacheLoad, @NonNull Callback onCompletion) { + ContextDataManagerView newView; synchronized (lock) { if (context.equals(currentContext)) { + onCompletion.onSuccess(null); return; } + // this call to invalidate disables data operations being performed through the view + currentView.invalidate(); currentContext = context; + currentSelector = Selector.EMPTY; + newView = new ContextDataManagerView(); + currentView = newView; } - if (skipCacheLoad) { - return; + if (!skipCacheLoad) { + EnvironmentData storedData = getStoredData(context); + if (storedData == null) { + logger.debug("No stored flag data is available for this context"); + } else { + logger.debug("Using stored flag data for this context"); + applyFullData(context, Selector.EMPTY, storedData.getAll(), false); + } } - EnvironmentData storedData = getStoredData(context); - if (storedData == null) { - logger.debug("No stored flag data is available for this context"); - // here we return to not alter current in memory flag state as - // current flag state is better than empty flag state in most - // customer use cases. - return; + // At the time of writing this, we only needed one listener (the ConnectivityManager) and the + // code was simpler to support a single listener. If you need to support multiple listeners, + // you must consider how to handle the onComplete callback being send to many listeners. + ContextSwitchListener listener = contextSwitchListener; + if (listener != null) { + listener.onContextChanged(context, newView, onCompletion); + } else { + onCompletion.onSuccess(null); } + } + + /** + * Sets the listener that will be notified on every context switch. The listener + * is immediately called with the current context and view (with a no-op callback) + * so it can initialize its state. + * + * @param listener the listener to set + */ + public void setContextSwitchListener(@NonNull ContextSwitchListener listener) { + this.contextSwitchListener = listener; + listener.onContextChanged(currentContext, currentView, LDUtil.noOpCallback()); + } - logger.debug("Using stored flag data for this context"); - // when we switch context, we don't have a selector because we don't currently support persisting the selector. - applyFullData(context, Selector.EMPTY, storedData.getAll(), false); + public void removeContextSwitchListener() { + this.contextSwitchListener = null; } /** @@ -125,7 +165,8 @@ public void switchToContext(@NonNull LDContext context, boolean skipCacheLoad) { * @param context the new context * @param newData the new flag data */ - public void initData( + @VisibleForTesting + void initData( @NonNull LDContext context, @NonNull EnvironmentData newData ) { @@ -181,7 +222,8 @@ public void initData( * @param flag the updated flag data or deleted item placeholder * @return true if the update was done; false if it was not done */ - public boolean upsert(@NonNull LDContext context, @NonNull Flag flag) { + @VisibleForTesting + boolean upsert(@NonNull LDContext context, @NonNull Flag flag) { EnvironmentData updatedFlags; synchronized (lock) { if (!context.equals(currentContext)) { @@ -214,8 +256,8 @@ public boolean upsert(@NonNull LDContext context, @NonNull Flag flag) { return true; } - @Override - public void apply(@NonNull LDContext context, @NonNull ChangeSet> changeSet) { + @VisibleForTesting + void apply(@NonNull LDContext context, @NonNull ChangeSet> changeSet) { switch (changeSet.getType()) { case Full: applyFullData(context, changeSet.getSelector(), changeSet.getData(), changeSet.shouldPersist()); @@ -327,9 +369,9 @@ private void applyPartialData( notifyFlagListeners(updatedFlagKeys); } - @Override + @VisibleForTesting @NonNull - public Selector getSelector() { + Selector getSelector() { synchronized (lock) { return currentSelector; } @@ -427,4 +469,93 @@ private void notifyAllFlagsListeners(Collection updatedFlagKeys) { } }); } + + /** + * Listener interface for context switch events. + *

+ * Implementations receive notifications when the managed context changes. Each + * notification includes the new context, a valid {@link ContextDataManagerView} + * scoped to that context, and a completion callback. + *

+ * {@link #onContextChanged} is also called immediately when a listener is registered + * via {@link ContextDataManager#setContextSwitchListener}, with the current + * context and view (and a no-op callback). This allows late-registering listeners + * to receive the current state without a separate interaction. + */ + interface ContextSwitchListener { + /** + * Called when the managed context changes, or immediately when listener is + * registered, with the current context and view. + * + * @param context the new (or current) evaluation context + * @param view a valid {@link ContextDataManagerView} scoped to this context; + * any previously issued views have already been invalidated + * @param onCompletion callback to invoke when the downstream work triggered by + * this context switch is complete; a no-op callback at + * registration time + */ + void onContextChanged( + @NonNull LDContext context, + @NonNull ContextDataManagerView view, + @NonNull Callback onCompletion + ); + } + + /** + * A scoped, invalidatable view of {@link ContextDataManager} that gates all data + * operations through the enclosing ContextDataManager's locking protections. + *

+ * Each time the managed context changes, the previous view is permanently invalidated + * and a new view is created. Once invalidated, all write operations become no-ops and + * {@link #getSelector()} returns {@link Selector#EMPTY}. This prevents old data sources + * (which may still be running asynchronously after a context switch) from writing stale + * data or reading selectors that belong to a different context. + */ + final class ContextDataManagerView implements TransactionalDataStore, SelectorSource { + + private boolean valid = true; + + void invalidate() { + valid = false; + } + + public void init(@NonNull LDContext context, @NonNull Map items) { + synchronized (lock) { + if (!valid) { + return; + } + initData(context, EnvironmentData.usingExistingFlagsMap(items)); + } + } + + public boolean upsert(@NonNull LDContext context, @NonNull Flag flag) { + synchronized (lock) { + if (!valid) { + return false; + } + return ContextDataManager.this.upsert(context, flag); + } + } + + @Override + public void apply(@NonNull LDContext context, @NonNull ChangeSet> changeSet) { + synchronized (lock) { + if (!valid) { + return; + } + ContextDataManager.this.apply(context, changeSet); + } + } + + @Override + @NonNull + public Selector getSelector() { + synchronized (lock) { + if (!valid) { + return Selector.EMPTY; + } + return ContextDataManager.this.getSelector(); + } + } + } } 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 be9485dc..803e26ed 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 @@ -12,7 +12,6 @@ import com.launchdarkly.sdk.android.subsystems.Initializer; import com.launchdarkly.sdk.android.subsystems.InitializerFromCache; import com.launchdarkly.sdk.android.subsystems.Synchronizer; -import com.launchdarkly.sdk.android.subsystems.TransactionalDataStore; import java.io.Closeable; import java.util.ArrayList; @@ -151,11 +150,10 @@ public void close() { private DataSourceBuildInputsInternal makeInputs(ClientContext clientContext) { ClientContextImpl impl = ClientContextImpl.get(clientContext); - TransactionalDataStore store = impl.getTransactionalDataStore(); - SelectorSource selectorSource = store != null - ? new SelectorSourceFacade(store) - : () -> com.launchdarkly.sdk.fdv2.Selector.EMPTY; - + SelectorSource selectorSource = impl.getSelectorSource(); + if (selectorSource == null) { + selectorSource = () -> com.launchdarkly.sdk.fdv2.Selector.EMPTY; + } return new DataSourceBuildInputsInternal( clientContext.getEvaluationContext(), clientContext.getServiceEndpoints(), diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDClient.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDClient.java index 8bd1aca4..c79a0a6d 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDClient.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDClient.java @@ -274,7 +274,7 @@ public void onError(Throwable e) { // Start up all instances for (final LDClient instance : instances.values()) { HookRunner.AfterIdentifyMethod afterIdentify = instance.hookRunner.identify(modifiedContext, null); - if (instance.connectivityManager.startUp(new CompleteWhenCounterZero(afterIdentify))) { + if (instance.connectivityManager.startUp(instance.contextDataManager, new CompleteWhenCounterZero(afterIdentify))) { instance.eventProcessor.recordIdentifyEvent(modifiedContext); } } @@ -435,7 +435,6 @@ protected LDClient( clientContextImpl, config.dataSource, eventProcessor, - contextDataManager, environmentStore ); @@ -503,8 +502,7 @@ private void identifyInternal(@NonNull LDContext context, // times out or otherwise fails. This does not short-circuit initialization — the data // source still performs its network request regardless. boolean usingFDv2 = config.dataSource instanceof FDv2DataSourceBuilder; - contextDataManager.switchToContext(context, usingFDv2); - connectivityManager.switchToContext(context, onCompleteListener); + contextDataManager.switchToContext(context, usingFDv2, onCompleteListener); eventProcessor.recordIdentifyEvent(context); } diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDUtil.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDUtil.java index de799286..69ef6079 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDUtil.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDUtil.java @@ -48,6 +48,28 @@ public void onError(Throwable error) { }; } + /** + * Returns a callback that forwards {@code onSuccess} and {@code onError} to all + * delegates in iteration order. + */ + static Callback compositeCallback(@NonNull List> callbacks) { + return new Callback() { + @Override + public void onSuccess(T result) { + for (Callback cb : callbacks) { + cb.onSuccess(result); + } + } + + @Override + public void onError(Throwable error) { + for (Callback cb : callbacks) { + cb.onError(error); + } + } + }; + } + // Key, kind, tag, and several other system values must not be empty, contain only letters, // numbers, `.`, `_`, or `-`. private static final Pattern VALID_CHARS_REGEX = Pattern.compile("^[-a-zA-Z0-9._]+$"); diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/SelectorSourceFacade.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/SelectorSourceFacade.java deleted file mode 100644 index 46fa32d6..00000000 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/SelectorSourceFacade.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.launchdarkly.sdk.android; - -import androidx.annotation.NonNull; - -import com.launchdarkly.sdk.fdv2.Selector; -import com.launchdarkly.sdk.android.subsystems.TransactionalDataStore; - -/** - * Adapts a {@link TransactionalDataStore} to the {@link SelectorSource} interface. - *

- * Analogous to {@code SelectorSourceFacade} in the java-server SDK (java-core). Created - * alongside the update sink in the component wiring; passed independently to FDv2 streaming - * and polling implementations so they can read the current selector without coupling to the - * update-sink interface. - */ -final class SelectorSourceFacade implements SelectorSource { - private final TransactionalDataStore store; - - SelectorSourceFacade(@NonNull TransactionalDataStore store) { - this.store = store; - } - - @Override - @NonNull - public Selector getSelector() { - return store.getSelector(); - } -} 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 ac49d639..54d039d8 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 @@ -142,14 +142,13 @@ private void createTestManager( clientContext, dataSourceConfigurer, eventProcessor, - contextDataManager, environmentStore ); } private void awaitStartUp() { AwaitableCallback awaitableCallback = new AwaitableCallback<>(); - connectivityManager.startUp(awaitableCallback); + connectivityManager.startUp(contextDataManager, awaitableCallback); try { awaitableCallback.await(); } catch (ExecutionException e) { @@ -509,9 +508,8 @@ public void refreshDataSourceForNewContext() throws Exception { long connectionTimeBeforeSwitch = connectivityManager.getConnectionInformation().getLastSuccessfulConnection(); LDContext context2 = LDContext.create("context2"); - contextDataManager.switchToContext(context2, false); AwaitableCallback done = new AwaitableCallback<>(); - connectivityManager.switchToContext(context2, done); + contextDataManager.switchToContext(context2, false, done); done.await(); long connectionTimeAfterSwitch = connectivityManager.getConnectionInformation().getLastSuccessfulConnection(); @@ -541,8 +539,7 @@ public void refreshDataSourceWhileOffline() { replayAll(); LDContext context2 = LDContext.create("context2"); - contextDataManager.switchToContext(context2, false); - connectivityManager.switchToContext(context2, LDUtil.noOpCallback()); + contextDataManager.switchToContext(context2, false, LDUtil.noOpCallback()); verifyAll(); // verifies eventProcessor calls verifyNoMoreDataSourcesWereCreated(); @@ -569,8 +566,7 @@ public void refreshDataSourceWhileInBackgroundWithBackgroundPollingDisabled() { replayAll(); LDContext context2 = LDContext.create("context2"); - contextDataManager.switchToContext(context2, false); - connectivityManager.switchToContext(context2, LDUtil.noOpCallback()); + contextDataManager.switchToContext(context2, false, LDUtil.noOpCallback()); verifyAll(); // verifies eventProcessor calls verifyNoMoreDataSourcesWereCreated(); @@ -738,7 +734,7 @@ public void fdv1_foregroundToBackground_rebuildsPollingDataSource() throws Excep } @Test - public void fdv1_forDataSource_transactionalDataStoreIsPassedThrough() throws Exception { + public void fdv1_forDataSource_selectorSourceIsPassedThrough() throws Exception { eventProcessor.setOffline(false); eventProcessor.setInBackground(false); replayAll(); @@ -746,7 +742,7 @@ public void fdv1_forDataSource_transactionalDataStoreIsPassedThrough() throws Ex createTestManager(defaultTestConfig(false, false), clientContext -> { receivedClientContexts.add(clientContext); ClientContextImpl impl = ClientContextImpl.get(clientContext); - assertNotNull(impl.getTransactionalDataStore()); + assertNotNull(impl.getSelectorSource()); return MockComponents.successfulDataSource(clientContext, DATA, ConnectionMode.POLLING, startedDataSources, stoppedDataSources); }); @@ -784,13 +780,81 @@ public void onInternalFailure(LDFailure ldFailure) { }); LDContext context2 = LDContext.create("context2"); - contextDataManager.switchToContext(context2, false); - connectivityManager.switchToContext(context2, new AwaitableCallback<>()); + contextDataManager.switchToContext(context2, false, new AwaitableCallback<>()); latch.await(500, TimeUnit.MILLISECONDS); verifyAll(); } + // ==== View-based data source gating tests ==== + + @Test + public void startUpRegistersListenerAndCreatesDataSource() throws ExecutionException { + eventProcessor.setOffline(false); + eventProcessor.setInBackground(false); + replayAll(); + + createTestManager(defaultTestConfig(false, false), makeSuccessfulDataSourceFactory()); + awaitStartUp(); + + verifyForegroundDataSourceWasCreatedAndStarted(CONTEXT); + verifyNoMoreDataSourcesWereCreated(); + } + + @Test + public void contextSwitchStopsOldDataSourceAndCreatesNew() throws Exception { + eventProcessor.setOffline(false); + eventProcessor.setInBackground(false); + eventProcessor.setOffline(false); + eventProcessor.setInBackground(false); + replayAll(); + + createTestManager(defaultTestConfig(false, false), makeSuccessfulDataSourceFactory()); + awaitStartUp(); + verifyForegroundDataSourceWasCreatedAndStarted(CONTEXT); + + LDContext context2 = LDContext.create("context2"); + AwaitableCallback done = new AwaitableCallback<>(); + contextDataManager.switchToContext(context2, false, done); + done.await(); + + verifyDataSourceWasStopped(); + verifyForegroundDataSourceWasCreatedAndStarted(context2); + } + + @Test + public void dataSourceReceivesViewAsSelectorSource() throws ExecutionException { + eventProcessor.setOffline(false); + eventProcessor.setInBackground(false); + replayAll(); + + createTestManager(defaultTestConfig(false, false), clientContext -> { + receivedClientContexts.add(clientContext); + ClientContextImpl impl = ClientContextImpl.get(clientContext); + SelectorSource selectorSource = impl.getSelectorSource(); + assertNotNull(selectorSource); + assertTrue("SelectorSource should be a ContextDataManagerView", + selectorSource instanceof ContextDataManager.ContextDataManagerView); + return MockComponents.successfulDataSource(clientContext, DATA, + ConnectionMode.POLLING, startedDataSources, stoppedDataSources); + }); + awaitStartUp(); + verifyForegroundDataSourceWasCreatedAndStarted(CONTEXT); + } + + @Test + public void startupCallbackIsInvokedOnCompletion() throws ExecutionException { + eventProcessor.setOffline(false); + eventProcessor.setInBackground(false); + replayAll(); + + createTestManager(defaultTestConfig(false, false), makeSuccessfulDataSourceFactory()); + + AwaitableCallback callback = new AwaitableCallback<>(); + connectivityManager.startUp(contextDataManager, callback); + callback.await(); + } + private ComponentConfigurer makeSuccessfulDataSourceFactory() { return clientContext -> makeSuccessfulDataSource(clientContext); } @@ -1123,9 +1187,8 @@ public void fdv2_contextChange_rebuildsDataSource() throws Exception { verifyForegroundDataSourceWasCreatedAndStarted(CONTEXT); LDContext context2 = LDContext.create("context2"); - contextDataManager.switchToContext(context2, false); AwaitableCallback done = new AwaitableCallback<>(); - connectivityManager.switchToContext(context2, done); + contextDataManager.switchToContext(context2, false, done); done.await(); verifyDataSourceWasStopped(); diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ContextDataManagerApplyTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ContextDataManagerApplyTest.java index b4e9eae1..0a7761d9 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ContextDataManagerApplyTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ContextDataManagerApplyTest.java @@ -1,6 +1,7 @@ package com.launchdarkly.sdk.android; import com.launchdarkly.sdk.android.DataModel.Flag; +import com.launchdarkly.sdk.android.LDUtil; import static com.launchdarkly.sdk.android.AssertHelpers.assertDataSetsEqual; import static com.launchdarkly.sdk.android.AssertHelpers.assertFlagsEqual; @@ -34,7 +35,7 @@ public void applyFullReplacesDataAndPersists() { fullItems.put(flag2.getKey(), flag2); ContextDataManager manager = createDataManager(); - manager.switchToContext(CONTEXT, false); + manager.switchToContext(CONTEXT, false, LDUtil.noOpCallback()); ChangeSet> changeSet = new ChangeSet<>( ChangeSetType.Full, Selector.EMPTY, @@ -56,7 +57,7 @@ public void applyFullWithShouldPersistFalseUpdatesMemoryOnly() { Flag flag1 = new FlagBuilder("flag1").version(1).build(); EnvironmentData initialData = new DataSetBuilder().add(flag1).build(); ContextDataManager manager = createDataManager(); - manager.switchToContext(CONTEXT, false); + manager.switchToContext(CONTEXT, false, LDUtil.noOpCallback()); manager.initData(CONTEXT, initialData); Flag flag2 = new FlagBuilder("flag2").version(2).build(); @@ -82,7 +83,7 @@ public void applyPartialMergesAndPersists() { Flag flag1 = new FlagBuilder("flag1").version(1).build(); EnvironmentData initialData = new DataSetBuilder().add(flag1).build(); ContextDataManager manager = createDataManager(); - manager.switchToContext(CONTEXT, false); + manager.switchToContext(CONTEXT, false, LDUtil.noOpCallback()); manager.initData(CONTEXT, initialData); Flag flag2 = new FlagBuilder("flag2").version(2).build(); @@ -112,7 +113,7 @@ public void applyPartialOverwritesEvenWhenIncomingVersionIsLower() { Flag flag1 = new FlagBuilder("flag1").version(2).build(); EnvironmentData initialData = new DataSetBuilder().add(flag1).build(); ContextDataManager manager = createDataManager(); - manager.switchToContext(CONTEXT, false); + manager.switchToContext(CONTEXT, false, LDUtil.noOpCallback()); manager.initData(CONTEXT, initialData); Flag flag1LowerVersion = new FlagBuilder("flag1").version(1).value(false).build(); @@ -136,7 +137,7 @@ public void applyNoneDoesNotChangeFlags() { Flag flag1 = new FlagBuilder("flag1").version(1).build(); EnvironmentData initialData = new DataSetBuilder().add(flag1).build(); ContextDataManager manager = createDataManager(); - manager.switchToContext(CONTEXT, false); + manager.switchToContext(CONTEXT, false, LDUtil.noOpCallback()); manager.initData(CONTEXT, initialData); ChangeSet> changeSet = new ChangeSet<>( @@ -156,7 +157,7 @@ public void applyNoneDoesNotChangeFlags() { @Test public void applyStoresSelectorInMemory() { ContextDataManager manager = createDataManager(); - manager.switchToContext(CONTEXT, false); + manager.switchToContext(CONTEXT, false, LDUtil.noOpCallback()); assertTrue(manager.getSelector().isEmpty()); Selector selector = Selector.make(42, "state-42"); @@ -178,7 +179,7 @@ public void applyStoresSelectorInMemory() { @Test public void applyFullWithEmptySelectorClearsStoredSelector() { ContextDataManager manager = createDataManager(); - manager.switchToContext(CONTEXT, false); + manager.switchToContext(CONTEXT, false, LDUtil.noOpCallback()); Selector first = Selector.make(1, "state1"); Flag flag = new FlagBuilder("flag1").version(1).build(); manager.apply(CONTEXT, new ChangeSet<>( @@ -193,7 +194,7 @@ public void applyFullWithEmptySelectorClearsStoredSelector() { @Test public void applyPartialWithEmptySelectorClearsStoredSelector() { ContextDataManager manager = createDataManager(); - manager.switchToContext(CONTEXT, false); + manager.switchToContext(CONTEXT, false, LDUtil.noOpCallback()); Selector first = Selector.make(1, "state1"); Flag flag = new FlagBuilder("flag1").version(1).build(); manager.apply(CONTEXT, new ChangeSet<>( @@ -212,7 +213,7 @@ public void applyDoesNothingWhenContextMismatch() { Flag flag1 = new FlagBuilder("flag1").version(1).build(); EnvironmentData initialData = new DataSetBuilder().add(flag1).build(); ContextDataManager manager = createDataManager(); - manager.switchToContext(CONTEXT, false); + manager.switchToContext(CONTEXT, false, LDUtil.noOpCallback()); manager.initData(CONTEXT, initialData); LDContext otherContext = LDContext.create("other-context"); diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ContextDataManagerContextCachingTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ContextDataManagerContextCachingTest.java index eebbccb9..102d3286 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ContextDataManagerContextCachingTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ContextDataManagerContextCachingTest.java @@ -22,7 +22,7 @@ public void canCacheManyContextsWithNegativeMaxCachedContexts() { int numContexts = 20; for (int i = 1; i <= numContexts; i++) { - manager.switchToContext(makeContext(i), false); + manager.switchToContext(makeContext(i), false, LDUtil.noOpCallback()); manager.initData(makeContext(i), makeFlagData(i)); } @@ -38,7 +38,7 @@ public void deletesExcessContexts() { ContextDataManager manager = createDataManager(maxCachedContexts); for (int i = 1; i <= maxCachedContexts + excess; i++) { - manager.switchToContext(makeContext(i), false); + manager.switchToContext(makeContext(i), false, LDUtil.noOpCallback()); manager.initData(makeContext(i), makeFlagData(i)); } @@ -55,13 +55,13 @@ public void deletesExcessContextsFromPreviousManagerInstance() { ContextDataManager manager = createDataManager(1); for (int i = 1; i <= 2; i++) { - manager.switchToContext(makeContext(i), false); + manager.switchToContext(makeContext(i), false, LDUtil.noOpCallback()); manager.initData(makeContext(i), makeFlagData(i)); assertContextIsCached(makeContext(i), makeFlagData(i)); } ContextDataManager newManagerInstance = createDataManager(1); - newManagerInstance.switchToContext(makeContext(3), false); + newManagerInstance.switchToContext(makeContext(3), false, LDUtil.noOpCallback()); newManagerInstance.initData(makeContext(3), makeFlagData(3)); assertContextIsNotCached(makeContext(1)); diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ContextDataManagerFlagDataTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ContextDataManagerFlagDataTest.java index 988f615b..e3c6fd08 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ContextDataManagerFlagDataTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ContextDataManagerFlagDataTest.java @@ -1,6 +1,7 @@ package com.launchdarkly.sdk.android; import com.launchdarkly.sdk.android.DataModel.Flag; +import com.launchdarkly.sdk.android.LDUtil; import static com.launchdarkly.sdk.android.AssertHelpers.assertDataSetsEqual; import static com.launchdarkly.sdk.android.AssertHelpers.assertFlagsEqual; @@ -22,7 +23,7 @@ public void getStoredDataNotFound() { public void initDataUpdatesStoredData() { EnvironmentData data = new DataSetBuilder().add(new FlagBuilder("flag1").build()).build(); ContextDataManager manager = createDataManager(); - manager.switchToContext(CONTEXT, false); + manager.switchToContext(CONTEXT, false, LDUtil.noOpCallback()); manager.initData(CONTEXT, data); assertDataSetsEqual(data, createDataManager().getStoredData(CONTEXT)); } @@ -31,18 +32,18 @@ public void initDataUpdatesStoredData() { public void initFromStoredData() { EnvironmentData data = new DataSetBuilder().add(new FlagBuilder("flag1").build()).build(); ContextDataManager manager1 = createDataManager(); - manager1.switchToContext(CONTEXT, false); + manager1.switchToContext(CONTEXT, false, LDUtil.noOpCallback()); manager1.initData(CONTEXT, data); ContextDataManager manager2 = createDataManager(); - manager2.switchToContext(CONTEXT, false); + manager2.switchToContext(CONTEXT, false, LDUtil.noOpCallback()); assertDataSetsEqual(data, manager2.getAllNonDeleted()); } @Test public void initFromStoredDataNotFound() { ContextDataManager manager = createDataManager(); - manager.switchToContext(CONTEXT, false); + manager.switchToContext(CONTEXT, false, LDUtil.noOpCallback()); } @Test @@ -64,7 +65,7 @@ public void getKnownFlag() { Flag flag = new FlagBuilder("flag1").build(); EnvironmentData data = new DataSetBuilder().add(flag).build(); ContextDataManager manager = createDataManager(); - manager.switchToContext(CONTEXT, false); + manager.switchToContext(CONTEXT, false, LDUtil.noOpCallback()); manager.initData(CONTEXT, data); assertSame(flag, manager.getNonDeletedFlag(flag.getKey())); @@ -105,7 +106,7 @@ public void getAllReturnsFlags() { flag2 = new FlagBuilder("flag2").version(2).build(); EnvironmentData initialData = new DataSetBuilder().add(flag1).add(flag2).build(); ContextDataManager manager = createDataManager(); - manager.switchToContext(CONTEXT, false); + manager.switchToContext(CONTEXT, false, LDUtil.noOpCallback()); manager.initData(CONTEXT, initialData); EnvironmentData actualData = manager.getAllNonDeleted(); @@ -119,7 +120,7 @@ public void getAllFiltersOutDeletedFlags() { deletedFlag = Flag.deletedItemPlaceholder("flag2", 2); EnvironmentData initialData = new DataSetBuilder().add(flag1).add(deletedFlag).build(); ContextDataManager manager = createDataManager(); - manager.switchToContext(CONTEXT, false); + manager.switchToContext(CONTEXT, false, LDUtil.noOpCallback()); manager.initData(CONTEXT, initialData); EnvironmentData expectedData = new DataSetBuilder().add(flag1).build(); @@ -132,7 +133,7 @@ public void upsertAddsFlag() { flag2 = new FlagBuilder("flag2").version(2).build(); EnvironmentData initialData = new DataSetBuilder().add(flag1).build(); ContextDataManager manager = createDataManager(); - manager.switchToContext(CONTEXT, false); + manager.switchToContext(CONTEXT, false, LDUtil.noOpCallback()); manager.initData(CONTEXT, initialData); manager.upsert(CONTEXT, flag2); @@ -150,7 +151,7 @@ public void upsertUpdatesFlag() { flag1b = new FlagBuilder(flag1a.getKey()).version(2).value(false).build(); EnvironmentData initialData = new DataSetBuilder().add(flag1a).build(); ContextDataManager manager = createDataManager(); - manager.switchToContext(CONTEXT, false); + manager.switchToContext(CONTEXT, false, LDUtil.noOpCallback()); manager.initData(CONTEXT, initialData); manager.upsert(CONTEXT, flag1b); @@ -166,14 +167,14 @@ public void upsertUpdatesFlag() { public void switchDoesNotUpdateIndexTimestamp() throws Exception { EnvironmentData data = new DataSetBuilder().add(new FlagBuilder("flag1").build()).build(); ContextDataManager manager1 = createDataManager(); - manager1.switchToContext(INITIAL_CONTEXT, false); + manager1.switchToContext(INITIAL_CONTEXT, false,LDUtil.noOpCallback()); manager1.initData(INITIAL_CONTEXT, data); Long firstTimestamp = environmentStore.getLastUpdated(LDUtil.urlSafeBase64HashedContextId(INITIAL_CONTEXT), LDUtil.urlSafeBase64Hash(INITIAL_CONTEXT)); Thread.sleep(2); // sleep for an amount that is greater than precision of System.currentTimeMillis so the change can be detected - manager1.switchToContext(CONTEXT, false); - manager1.switchToContext(INITIAL_CONTEXT, false); + manager1.switchToContext(CONTEXT, false,LDUtil.noOpCallback()); + manager1.switchToContext(INITIAL_CONTEXT, false, LDUtil.noOpCallback()); Long secondTimestamp = environmentStore.getLastUpdated(LDUtil.urlSafeBase64HashedContextId(INITIAL_CONTEXT), LDUtil.urlSafeBase64Hash(INITIAL_CONTEXT)); assertEquals(firstTimestamp, secondTimestamp); @@ -185,7 +186,7 @@ public void upsertUpdatesIndexTimestamp() throws Exception { flag1b = new FlagBuilder(flag1a.getKey()).version(2).value(false).build(); EnvironmentData initialData = new DataSetBuilder().add(flag1a).build(); ContextDataManager manager = createDataManager(); - manager.switchToContext(CONTEXT, false); + manager.switchToContext(CONTEXT, false, LDUtil.noOpCallback()); manager.initData(CONTEXT, initialData); long firstTimestamp = environmentStore.getLastUpdated(LDUtil.urlSafeBase64HashedContextId(CONTEXT), LDUtil.urlSafeBase64Hash(CONTEXT)); @@ -219,7 +220,7 @@ public void upsertDeletesFlag() { deletedFlag2 = Flag.deletedItemPlaceholder(flag2.getKey(), 2); EnvironmentData initialData = new DataSetBuilder().add(flag1).add(flag2).build(); ContextDataManager manager = createDataManager(); - manager.switchToContext(CONTEXT, false); + manager.switchToContext(CONTEXT, false, LDUtil.noOpCallback()); manager.initData(CONTEXT, initialData); manager.upsert(CONTEXT, deletedFlag2); @@ -252,7 +253,7 @@ public void upsertDoesNotDeleteFlagWithLowerVersion() { private void upsertDoesNotUpdateFlag(Flag initialFlag, Flag updatedFlag) { EnvironmentData initialData = new DataSetBuilder().add(initialFlag).build(); ContextDataManager manager = createDataManager(); - manager.switchToContext(CONTEXT, false); + manager.switchToContext(CONTEXT, false, LDUtil.noOpCallback()); manager.initData(CONTEXT, initialData); manager.upsert(CONTEXT, updatedFlag); diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ContextDataManagerListenersTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ContextDataManagerListenersTest.java index 56361e0c..ac187e74 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ContextDataManagerListenersTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ContextDataManagerListenersTest.java @@ -7,6 +7,7 @@ import com.launchdarkly.sdk.LDContext; import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.android.DataModel.Flag; +import com.launchdarkly.sdk.android.LDUtil; import com.launchdarkly.sdk.fdv2.ChangeSet; import com.launchdarkly.sdk.fdv2.ChangeSetType; import com.launchdarkly.sdk.fdv2.Selector; @@ -48,7 +49,7 @@ public void listenerIsCalledOnUpdate() throws InterruptedException { manager.registerListener(flag.getKey(), listener); manager.registerAllFlagsListener(allFlagsListener); - manager.switchToContext(CONTEXT, false); + manager.switchToContext(CONTEXT, false, LDUtil.noOpCallback()); manager.upsert(CONTEXT, flag); assertEquals(flag.getKey(), listener.expectUpdate(5, TimeUnit.SECONDS)); @@ -65,7 +66,7 @@ public void listenerIsCalledOnDelete() throws InterruptedException { manager.registerListener(flag.getKey(), listener); manager.registerAllFlagsListener(allFlagsListener); - manager.switchToContext(CONTEXT, false); + manager.switchToContext(CONTEXT, false, LDUtil.noOpCallback()); manager.upsert(CONTEXT, flag); assertEquals(flag.getKey(), listener.expectUpdate(5, TimeUnit.SECONDS)); @@ -115,7 +116,7 @@ public void listenerIsCalledAfterInitData() { manager.registerAllFlagsListener(all1); // change the data - manager.switchToContext(context1, false); + manager.switchToContext(context1, false, LDUtil.noOpCallback()); manager.upsert(context1, flagState1); // verify callbacks @@ -131,7 +132,7 @@ public void listenerIsCalledAfterInitData() { // simulate switching context Flag flagState2 = new FlagBuilder(FLAG_KEY).value(LDValue.of(2)).build(); EnvironmentData envData = new EnvironmentData().withFlagUpdatedOrAdded(flagState2); - manager.switchToContext(context2, false); + manager.switchToContext(context2, false, LDUtil.noOpCallback()); manager.initData(context2, envData); // verify callbacks @@ -144,7 +145,7 @@ public void partialApplyNotifiesListenersForEachKeyEvenWhenEvaluatedValueUnchang throws InterruptedException { Flag initial = new FlagBuilder("flag").version(1).value(true).build(); ContextDataManager manager = createDataManager(); - manager.switchToContext(CONTEXT, false); + manager.switchToContext(CONTEXT, false, LDUtil.noOpCallback()); manager.initData(CONTEXT, new DataSetBuilder().add(initial).build()); AwaitableFlagListener listener = new AwaitableFlagListener(); @@ -177,7 +178,7 @@ public void listenerIsCalledOnMainThread() throws InterruptedException { manager.registerListener(flag.getKey(), listener); manager.registerAllFlagsListener(allFlagsListener); - manager.switchToContext(CONTEXT, false); + manager.switchToContext(CONTEXT, false, LDUtil.noOpCallback()); manager.upsert(CONTEXT, flag); listener.expectUpdate(5, TimeUnit.SECONDS); diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ContextDataManagerTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ContextDataManagerTest.java new file mode 100644 index 00000000..153fd17b --- /dev/null +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ContextDataManagerTest.java @@ -0,0 +1,64 @@ +package com.launchdarkly.sdk.android; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import com.launchdarkly.sdk.LDContext; +import com.launchdarkly.sdk.android.subsystems.Callback; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; + +/** + * Tests for {@link ContextDataManager} behavior that is not covered by the more specialized + * test classes (flag data, apply, listeners on flag changes, etc.). + */ +public class ContextDataManagerTest extends ContextDataManagerTestBase { + + /** + * FDv2 identify passes {@code skipCacheLoad=true} so cached flags are loaded by the data + * source initializer instead of here. The context switch must still notify + * {@link ContextDataManager.ContextSwitchListener} and complete the {@code switchToContext} + * callback; otherwise ConnectivityManager never rebuilds the data source and identify hangs. + */ + @Test + public void switchToContextWithSkipCacheLoadStillNotifiesListenerAndCompletesCallback() { + ContextDataManager manager = createDataManager(); + + List contextsSeenByListener = new ArrayList<>(); + manager.setContextSwitchListener((context, view, onCompletion) -> { + contextsSeenByListener.add(context); + onCompletion.onSuccess(null); + }); + assertEquals( + "setContextSwitchListener should immediately notify with the current context", + 1, + contextsSeenByListener.size()); + assertEquals(INITIAL_CONTEXT, contextsSeenByListener.get(0)); + + final boolean[] switchCompletionCalled = {false}; + manager.switchToContext(CONTEXT, true, new Callback() { + @Override + public void onSuccess(Void result) { + switchCompletionCalled[0] = true; + } + + @Override + public void onError(Throwable e) { + fail("switchToContext completion should not error: " + e); + } + }); + + assertEquals( + "listener must be notified on context change even when skipCacheLoad is true (FDv2)", + 2, + contextsSeenByListener.size()); + assertEquals(CONTEXT, contextsSeenByListener.get(1)); + assertTrue( + "switchToContext completion must run after listener finishes (regression: early return skipped this)", + switchCompletionCalled[0]); + } +} diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ContextDataManagerViewTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ContextDataManagerViewTest.java new file mode 100644 index 00000000..f4a61319 --- /dev/null +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ContextDataManagerViewTest.java @@ -0,0 +1,326 @@ +package com.launchdarkly.sdk.android; + +import static com.launchdarkly.sdk.android.AssertHelpers.assertFlagsEqual; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNotSame; +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.subsystems.Callback; +import com.launchdarkly.sdk.fdv2.ChangeSet; +import com.launchdarkly.sdk.fdv2.ChangeSetType; +import com.launchdarkly.sdk.fdv2.Selector; + +import org.junit.Test; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +public class ContextDataManagerViewTest extends ContextDataManagerTestBase { + + private static final LDContext CONTEXT_A = LDContext.create("context-a"); + private static final LDContext CONTEXT_B = LDContext.create("context-b"); + + private ContextDataManager.ContextDataManagerView captureView(ContextDataManager manager) { + AtomicReference ref = new AtomicReference<>(); + manager.setContextSwitchListener((context, view, onCompletion) -> { + ref.set(view); + onCompletion.onSuccess(null); + }); + return ref.get(); + } + + @Test + public void viewInitWritesDataWhenValid() { + ContextDataManager manager = createDataManager(); + manager.switchToContext(CONTEXT, false, LDUtil.noOpCallback()); + ContextDataManager.ContextDataManagerView view = captureView(manager); + + Flag flag = new FlagBuilder("flag1").version(1).build(); + view.init(CONTEXT, Collections.singletonMap(flag.getKey(), flag)); + + assertFlagsEqual(flag, manager.getNonDeletedFlag("flag1")); + } + + @Test + public void viewUpsertWritesDataWhenValid() { + ContextDataManager manager = createDataManager(); + manager.switchToContext(CONTEXT, false, LDUtil.noOpCallback()); + ContextDataManager.ContextDataManagerView view = captureView(manager); + + Flag flag = new FlagBuilder("flag1").version(1).build(); + boolean result = view.upsert(CONTEXT, flag); + + assertTrue(result); + assertFlagsEqual(flag, manager.getNonDeletedFlag("flag1")); + } + + @Test + public void viewApplyWritesDataWhenValid() { + ContextDataManager manager = createDataManager(); + manager.switchToContext(CONTEXT, false, LDUtil.noOpCallback()); + ContextDataManager.ContextDataManagerView view = captureView(manager); + + Flag flag = new FlagBuilder("flag1").version(1).build(); + Map items = Collections.singletonMap(flag.getKey(), flag); + ChangeSet> changeSet = new ChangeSet<>( + ChangeSetType.Full, Selector.EMPTY, items, null, false); + view.apply(CONTEXT, changeSet); + + assertFlagsEqual(flag, manager.getNonDeletedFlag("flag1")); + } + + @Test + public void viewGetSelectorReturnsValueWhenValid() { + ContextDataManager manager = createDataManager(); + manager.switchToContext(CONTEXT, false, LDUtil.noOpCallback()); + ContextDataManager.ContextDataManagerView view = captureView(manager); + + Selector selector = Selector.make(1, "state-1"); + Flag flag = new FlagBuilder("flag1").version(1).build(); + Map items = Collections.singletonMap(flag.getKey(), flag); + ChangeSet> changeSet = new ChangeSet<>( + ChangeSetType.Full, selector, items, null, false); + view.apply(CONTEXT, changeSet); + + assertEquals(1, view.getSelector().getVersion()); + assertEquals("state-1", view.getSelector().getState()); + } + + @Test + public void invalidatedViewInitIsNoOp() { + ContextDataManager manager = createDataManager(); + manager.switchToContext(CONTEXT, false, LDUtil.noOpCallback()); + ContextDataManager.ContextDataManagerView view = captureView(manager); + + view.invalidate(); + + Flag flag = new FlagBuilder("flag1").version(1).build(); + view.init(CONTEXT, Collections.singletonMap(flag.getKey(), flag)); + + assertNull(manager.getNonDeletedFlag("flag1")); + } + + @Test + public void invalidatedViewUpsertReturnsFalse() { + ContextDataManager manager = createDataManager(); + manager.switchToContext(CONTEXT, false, LDUtil.noOpCallback()); + ContextDataManager.ContextDataManagerView view = captureView(manager); + + view.invalidate(); + + Flag flag = new FlagBuilder("flag1").version(1).build(); + boolean result = view.upsert(CONTEXT, flag); + + assertFalse(result); + assertNull(manager.getNonDeletedFlag("flag1")); + } + + @Test + public void invalidatedViewApplyIsNoOp() { + ContextDataManager manager = createDataManager(); + manager.switchToContext(CONTEXT, false, LDUtil.noOpCallback()); + ContextDataManager.ContextDataManagerView view = captureView(manager); + + view.invalidate(); + + Flag flag = new FlagBuilder("flag1").version(1).build(); + Map items = Collections.singletonMap(flag.getKey(), flag); + ChangeSet> changeSet = new ChangeSet<>( + ChangeSetType.Full, Selector.EMPTY, items, null, false); + view.apply(CONTEXT, changeSet); + + assertNull(manager.getNonDeletedFlag("flag1")); + } + + @Test + public void invalidatedViewGetSelectorReturnsEmpty() { + ContextDataManager manager = createDataManager(); + manager.switchToContext(CONTEXT, false, LDUtil.noOpCallback()); + ContextDataManager.ContextDataManagerView view = captureView(manager); + + Selector selector = Selector.make(1, "state-1"); + Flag flag = new FlagBuilder("flag1").version(1).build(); + Map items = Collections.singletonMap(flag.getKey(), flag); + view.apply(CONTEXT, new ChangeSet<>(ChangeSetType.Full, selector, items, null, false)); + assertEquals(1, view.getSelector().getVersion()); + + view.invalidate(); + + assertTrue(view.getSelector().isEmpty()); + } + + @Test + public void sameContextDoesNotInvalidateView() { + ContextDataManager manager = createDataManager(); + manager.switchToContext(CONTEXT, false, LDUtil.noOpCallback()); + ContextDataManager.ContextDataManagerView view = captureView(manager); + + manager.switchToContext(CONTEXT, false, LDUtil.noOpCallback()); + + Flag flag = new FlagBuilder("flag1").version(1).build(); + boolean result = view.upsert(CONTEXT, flag); + assertTrue("View should still be valid after same-context switch", result); + } + + @Test + public void sameContextCallsOnCompletionImmediately() { + ContextDataManager manager = createDataManager(); + manager.switchToContext(CONTEXT, false, LDUtil.noOpCallback()); + + AtomicInteger completionCount = new AtomicInteger(0); + manager.switchToContext(CONTEXT, false, new Callback() { + @Override + public void onSuccess(Void result) { + completionCount.incrementAndGet(); + } + + @Override + public void onError(Throwable error) {} + }); + + assertEquals(1, completionCount.get()); + } + + @Test + public void differentContextInvalidatesOldView() { + ContextDataManager manager = createDataManager(); + manager.switchToContext(CONTEXT_A, false, LDUtil.noOpCallback()); + ContextDataManager.ContextDataManagerView viewA = captureView(manager); + + manager.switchToContext(CONTEXT_B, false, LDUtil.noOpCallback()); + + Flag flag = new FlagBuilder("flag1").version(1).build(); + assertFalse("Old view should be invalid", viewA.upsert(CONTEXT_A, flag)); + assertTrue("getSelector should return EMPTY for invalid view", + viewA.getSelector().isEmpty()); + } + + @Test + public void differentContextCreatesNewValidView() { + ContextDataManager manager = createDataManager(); + manager.switchToContext(CONTEXT_A, false, LDUtil.noOpCallback()); + ContextDataManager.ContextDataManagerView viewA = captureView(manager); + + manager.switchToContext(CONTEXT_B, false, LDUtil.noOpCallback()); + ContextDataManager.ContextDataManagerView viewB = captureView(manager); + + assertNotSame(viewA, viewB); + Flag flag = new FlagBuilder("flag1").version(1).build(); + assertTrue("New view should be valid", viewB.upsert(CONTEXT_B, flag)); + } + + @Test + public void abaScenarioOldViewStaysInvalid() { + ContextDataManager manager = createDataManager(); + manager.switchToContext(CONTEXT_A, false, LDUtil.noOpCallback()); + ContextDataManager.ContextDataManagerView viewA1 = captureView(manager); + + manager.switchToContext(CONTEXT_B, false, LDUtil.noOpCallback()); + manager.switchToContext(CONTEXT_A, false, LDUtil.noOpCallback()); + + ContextDataManager.ContextDataManagerView viewA2 = captureView(manager); + + assertNotSame("Should be distinct view instances", viewA1, viewA2); + assertFalse("Original A view should be invalid", + viewA1.upsert(CONTEXT_A, new FlagBuilder("flag1").version(1).build())); + assertTrue("New A view should be valid", + viewA2.upsert(CONTEXT_A, new FlagBuilder("flag1").version(1).build())); + } + + @Test + public void onCompletionCalledWhenNoListenerSet() { + ContextDataManager manager = createDataManager(); + + AtomicInteger completionCount = new AtomicInteger(0); + manager.switchToContext(CONTEXT, false, new Callback() { + @Override + public void onSuccess(Void result) { + completionCount.incrementAndGet(); + } + + @Override + public void onError(Throwable error) {} + }); + + assertEquals("Callback should be invoked even without a listener", 1, completionCount.get()); + } + + @Test + public void onCompletionCalledExactlyOnceWithListener() { + ContextDataManager manager = createDataManager(); + manager.setContextSwitchListener((context, view, onCompletion) -> onCompletion.onSuccess(null)); + + AtomicInteger completionCount = new AtomicInteger(0); + manager.switchToContext(CONTEXT, false, new Callback() { + @Override + public void onSuccess(Void result) { + completionCount.incrementAndGet(); + } + + @Override + public void onError(Throwable error) {} + }); + + assertEquals("Callback should be invoked exactly once", 1, completionCount.get()); + } + + @Test + public void setListenerImmediatelyCallsOnContextChanged() { + ContextDataManager manager = createDataManager(); + manager.switchToContext(CONTEXT, false, LDUtil.noOpCallback()); + + AtomicReference receivedContext = new AtomicReference<>(); + AtomicReference receivedView = new AtomicReference<>(); + + manager.setContextSwitchListener((context, view, onCompletion) -> { + receivedContext.set(context); + receivedView.set(view); + onCompletion.onSuccess(null); + }); + + assertEquals(CONTEXT, receivedContext.get()); + assertNotNull(receivedView.get()); + Flag flag = new FlagBuilder("flag1").version(1).build(); + assertTrue("View provided at registration should be valid", + receivedView.get().upsert(CONTEXT, flag)); + } + + @Test + public void removeListenerStopsNotifications() { + ContextDataManager manager = createDataManager(); + AtomicInteger callCount = new AtomicInteger(0); + manager.setContextSwitchListener((context, view, onCompletion) -> { + callCount.incrementAndGet(); + onCompletion.onSuccess(null); + }); + + int countAfterRegistration = callCount.get(); + assertEquals("Should have been called once at registration", 1, countAfterRegistration); + + manager.removeContextSwitchListener(); + + AtomicInteger completionCount = new AtomicInteger(0); + manager.switchToContext(CONTEXT, false, new Callback() { + @Override + public void onSuccess(Void result) { + completionCount.incrementAndGet(); + } + + @Override + public void onError(Throwable error) {} + }); + + assertEquals("Listener should not have been called again", countAfterRegistration, callCount.get()); + assertEquals("Callback should still be invoked", 1, completionCount.get()); + } +}