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 b6dc6915..98da3c31 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,11 +6,14 @@ import com.launchdarkly.logging.LogValues; import com.launchdarkly.sdk.LDContext; import com.launchdarkly.sdk.android.subsystems.Callback; +import com.launchdarkly.sdk.android.subsystems.ChangeSet; 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.EventProcessor; +import com.launchdarkly.sdk.internal.fdv2.sources.Selector; import java.lang.ref.WeakReference; import java.util.ArrayList; @@ -74,7 +77,7 @@ class ConnectivityManager { // 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." - private class DataSourceUpdateSinkImpl implements DataSourceUpdateSink { + private class DataSourceUpdateSinkImpl implements DataSourceUpdateSink, DataSourceUpdateSinkV2 { private final ContextDataManager contextDataManager; DataSourceUpdateSinkImpl(ContextDataManager contextDataManager) { @@ -93,6 +96,12 @@ public void upsert(LDContext context, DataModel.Flag item) { // Currently, contextDataManager is responsible for firing any necessary flag change events. } + @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. + } + @Override public void setStatus(ConnectionMode newConnectionMode, Throwable error) { if (error == 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 48118ee9..7f7ab7dd 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 @@ -7,8 +7,12 @@ import com.launchdarkly.logging.LDLogLevel; import com.launchdarkly.logging.LDLogger; import com.launchdarkly.sdk.LDContext; +import com.launchdarkly.sdk.android.subsystems.ChangeSet; +import com.launchdarkly.sdk.android.subsystems.ChangeSetType; import com.launchdarkly.sdk.android.subsystems.ClientContext; +import com.launchdarkly.sdk.android.subsystems.TransactionalDataStore; import com.launchdarkly.sdk.android.DataModel.Flag; +import com.launchdarkly.sdk.internal.fdv2.sources.Selector; import java.util.ArrayList; import java.util.Collection; @@ -38,7 +42,7 @@ * implementation of PersistentDataStore was used to create the PersistentDataStoreWrapper, and * deferred listener calls are done via the {@link TaskExecutor} abstraction. */ -final class ContextDataManager { +final class ContextDataManager implements TransactionalDataStore { private final PersistentDataStoreWrapper.PerEnvironmentData environmentStore; private final int maxCachedContexts; private final TaskExecutor taskExecutor; @@ -57,6 +61,9 @@ final class ContextDataManager { @NonNull private volatile EnvironmentData flags = new EnvironmentData(); @NonNull private volatile ContextIndex index; + /** Selector from the last applied changeset that carried one; in-memory only, not persisted. */ + @NonNull private volatile Selector currentSelector = Selector.EMPTY; + ContextDataManager( @NonNull ClientContext clientContext, @NonNull PersistentDataStoreWrapper.PerEnvironmentData environmentStore, @@ -86,7 +93,7 @@ public void switchToContext(@NonNull LDContext context) { currentContext = context; } - EnvironmentData storedData = getStoredData(currentContext); + 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 @@ -96,7 +103,8 @@ public void switchToContext(@NonNull LDContext context) { } logger.debug("Using stored flag data for this context"); - initDataInternal(context, storedData, false); + // 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); } /** @@ -111,73 +119,8 @@ public void initData( @NonNull EnvironmentData newData ) { logger.debug("Initializing with new flag data for this context"); - initDataInternal(context, newData, true); - } - - private void initDataInternal( - @NonNull LDContext context, - @NonNull EnvironmentData newData, - boolean writeFlagsToPersistentStore - ) { - - String contextId = LDUtil.urlSafeBase64HashedContextId(context); - String fingerprint = LDUtil.urlSafeBase64Hash(context); - EnvironmentData oldData; - ContextIndex newIndex; - - synchronized (lock) { - if (!context.equals(currentContext)) { - // if incoming new data is not for the current context, reject it. - return; - } - - oldData = flags; - flags = newData; - - if (writeFlagsToPersistentStore) { - List removedContextIds = new ArrayList<>(); - newIndex = index.updateTimestamp(contextId, System.currentTimeMillis()) - .prune(maxCachedContexts, removedContextIds); - index = newIndex; - - for (String removedContextId: removedContextIds) { - environmentStore.removeContextData(removedContextId); - logger.debug("Removed flag data for context {} from persistent store", removedContextId); - } - - environmentStore.setContextData(contextId, fingerprint, newData); - environmentStore.setIndex(newIndex); - - if (logger.isEnabled(LDLogLevel.DEBUG)) { - logger.debug("Stored context index is now: {}", newIndex.toJson()); - } - - logger.debug("Updated flag data for context {} in persistent store", contextId); - } - } - - // Determine which flags were updated and notify listeners, if any - Set updatedFlagKeys = new HashSet<>(); - for (Flag newFlag: newData.values()) { - Flag oldFlag = oldData.getFlag(newFlag.getKey()); - if (oldFlag == null || !oldFlag.getValue().equals(newFlag.getValue())) { - // if the flag is new or the value has changed, notify. This logic can be run if - // the context changes, which can result in an evaluation change even if the version - // of the flag stays the same. You will notice this logic slightly differs from - // upsert. Upsert should only be calling to listeners if the value has changed, - // but we left upsert alone out of fear of that we'd uncover bugs in customer code - // if we added conditionals in upsert - updatedFlagKeys.add(newFlag.getKey()); - } - } - for (Flag oldFlag: oldData.values()) { - // if old flag is no longer appearing, notify - if (newData.getFlag(oldFlag.getKey()) == null) { - updatedFlagKeys.add(oldFlag.getKey()); - } - } - notifyAllFlagsListeners(updatedFlagKeys); - notifyFlagListeners(updatedFlagKeys); + // init data is called in the FDv1 path, which does not have a selector + applyFullData(context, Selector.EMPTY, newData.getAll(), true); } /** @@ -260,6 +203,134 @@ public boolean upsert(@NonNull LDContext context, @NonNull Flag flag) { return true; } + @Override + public void apply(@NonNull LDContext context, @NonNull ChangeSet changeSet) { + switch (changeSet.getType()) { + case Full: + applyFullData(context, changeSet.getSelector(), changeSet.getItems(), changeSet.shouldPersist()); + break; + case Partial: + applyPartialData(context, changeSet.getSelector(), changeSet.getItems(), changeSet.shouldPersist()); + break; + case None: + default: + break; + } + } + + private void applyFullData( + @NonNull LDContext context, + @NonNull Selector selector, + Map items, + boolean shouldPersist + ) { + EnvironmentData newData = EnvironmentData.usingExistingFlagsMap(items); + EnvironmentData oldData; + + synchronized (lock) { + if (!context.equals(currentContext)) { + return; + } + if (!selector.isEmpty()) { + currentSelector = selector; + } + oldData = flags; + flags = newData; + + if (shouldPersist) { + String contextId = LDUtil.urlSafeBase64HashedContextId(context); + String fingerprint = LDUtil.urlSafeBase64Hash(context); + List removedContextIds = new ArrayList<>(); + ContextIndex newIndex = index.updateTimestamp(contextId, System.currentTimeMillis()) + .prune(maxCachedContexts, removedContextIds); + index = newIndex; + + for (String removedContextId : removedContextIds) { + environmentStore.removeContextData(removedContextId); + logger.debug("Removed flag data for context {} from persistent store", removedContextId); + } + + environmentStore.setContextData(contextId, fingerprint, newData); + environmentStore.setIndex(newIndex); + + if (logger.isEnabled(LDLogLevel.DEBUG)) { + logger.debug("Stored context index is now: {}", newIndex.toJson()); + } + logger.debug("Updated flag data for context {} in persistent store", contextId); + } + } + + // Determine which flags were updated and notify listeners, if any. + // If the flag is new or the value has changed, notify. This logic can be run if + // the context changes, which can result in an evaluation change even if the version + // of the flag stays the same. You will notice this logic slightly differs from + // upsert. Upsert should only be calling to listeners if the value has changed, + // but we left upsert alone out of fear that we'd uncover bugs in customer code + // if we added conditionals in upsert. + Set updatedFlagKeys = new HashSet<>(); + for (Flag newFlag : newData.values()) { + Flag oldFlag = oldData.getFlag(newFlag.getKey()); + if (oldFlag == null || !oldFlag.getValue().equals(newFlag.getValue())) { + updatedFlagKeys.add(newFlag.getKey()); + } + } + for (Flag oldFlag : oldData.values()) { + if (newData.getFlag(oldFlag.getKey()) == null) { + updatedFlagKeys.add(oldFlag.getKey()); + } + } + notifyAllFlagsListeners(updatedFlagKeys); + notifyFlagListeners(updatedFlagKeys); + } + + private void applyPartialData( + @NonNull LDContext context, + @NonNull Selector selector, + Map items, + boolean shouldPersist + ) { + EnvironmentData updatedFlags; + Set updatedFlagKeys = new HashSet<>(); + + synchronized (lock) { + if (!context.equals(currentContext)) { + return; + } + if (!selector.isEmpty()) { + currentSelector = selector; + } + Map merged = new HashMap<>(flags.getAll()); + for (Map.Entry entry : items.entrySet()) { + String key = entry.getKey(); + Flag incoming = entry.getValue(); + Flag existing = merged.get(key); + if (existing == null || existing.getVersion() < incoming.getVersion()) { + merged.put(key, incoming); + updatedFlagKeys.add(key); + } + } + updatedFlags = EnvironmentData.usingExistingFlagsMap(merged); + flags = updatedFlags; + + if (shouldPersist) { + String hashedContextId = LDUtil.urlSafeBase64HashedContextId(context); + String fingerprint = LDUtil.urlSafeBase64Hash(context); + environmentStore.setContextData(hashedContextId, fingerprint, updatedFlags); + index = index.updateTimestamp(hashedContextId, System.currentTimeMillis()); + environmentStore.setIndex(index); + } + } + + notifyAllFlagsListeners(updatedFlagKeys); + notifyFlagListeners(updatedFlagKeys); + } + + @Override + @NonNull + public Selector getSelector() { + return currentSelector; + } + public void registerListener(String key, FeatureFlagChangeListener listener) { Map backingMap = new ConcurrentHashMap<>(); Set newSet = Collections.newSetFromMap(backingMap); diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/subsystems/ChangeSet.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/subsystems/ChangeSet.java new file mode 100644 index 00000000..08ef5a51 --- /dev/null +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/subsystems/ChangeSet.java @@ -0,0 +1,127 @@ +package com.launchdarkly.sdk.android.subsystems; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.launchdarkly.sdk.android.DataModel; +import com.launchdarkly.sdk.internal.fdv2.sources.Selector; + +import java.util.Collections; +import java.util.Map; +import java.util.Objects; + +/** + * Represents a set of changes to apply to the flag store. + *

+ * A changeset has a type ({@link ChangeSetType#Full}, {@link ChangeSetType#Partial}, or + * {@link ChangeSetType#None}), an optional {@link Selector} + * to store in memory for the next request (e.g. as basis), and for Full/Partial types a map of + * flag key to flag data. + */ +public final class ChangeSet { + private final ChangeSetType type; + private final Selector selector; + @Nullable + private final String environmentId; + private final Map items; + private final boolean shouldPersist; + + /** + * Creates a changeset. + * + * @param type the type of the changeset + * @param selector the selector for this change (may be {@link Selector#EMPTY}) + * @param items map of flag key to flag (empty for None; used for Full and Partial) + * @param environmentId optional environment identifier, or null + * @param shouldPersist true if the data should be persisted + */ + public ChangeSet( + @NonNull ChangeSetType type, + @NonNull Selector selector, + @NonNull Map items, + @Nullable String environmentId, + boolean shouldPersist + ) { + this.type = type; + this.selector = selector != null ? selector : Selector.EMPTY; + this.environmentId = environmentId; + this.items = items != null ? Collections.unmodifiableMap(items) : Collections.emptyMap(); + this.shouldPersist = shouldPersist; + } + + /** + * Returns the type of the changeset. + * + * @return the changeset type + */ + @NonNull + public ChangeSetType getType() { + return type; + } + + /** + * Returns the selector for this change. Will not be null; may be {@link Selector#EMPTY}. + * + * @return the selector + */ + @NonNull + public Selector getSelector() { + return selector; + } + + /** + * Returns the environment ID associated with the change, or null if not available. + * + * @return the environment ID, or null if not available + */ + @Nullable + public String getEnvironmentId() { + return environmentId; + } + + /** + * Returns the flag items in this changeset. For Full and Partial types, map of flag key to flag; + * for None, empty. The returned map is unmodifiable. + * + * @return the flag items; may be empty but will not be null + */ + @NonNull + public Map getItems() { + return items; + } + + /** + * Returns whether this data should be persisted to persistent stores. + * + * @return true if the data should be persisted, false otherwise + */ + public boolean shouldPersist() { + return shouldPersist; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + ChangeSet that = (ChangeSet) o; + return type == that.type + && shouldPersist == that.shouldPersist + && Objects.equals(selector, that.selector) + && Objects.equals(environmentId, that.environmentId) + && Objects.equals(items, that.items); + } + + @Override + public int hashCode() { + return Objects.hash(type, selector, environmentId, items, shouldPersist); + } + + @Override + public String toString() { + return "ChangeSet(" + type + "," + selector + "," + environmentId + "," + items + "," + shouldPersist + ")"; + } +} diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/subsystems/ChangeSetType.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/subsystems/ChangeSetType.java new file mode 100644 index 00000000..942c3fad --- /dev/null +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/subsystems/ChangeSetType.java @@ -0,0 +1,23 @@ +package com.launchdarkly.sdk.android.subsystems; + +/** + * Indicates the type of a {@link ChangeSet}. + * + * @see ChangeSet + */ +public enum ChangeSetType { + /** + * Represents a full store update which replaces all flag data currently in the store. + */ + Full, + + /** + * Represents an incremental set of changes to be applied to the existing data in the store. + */ + Partial, + + /** + * Indicates that there are no flag changes; the changeset may still carry a selector to store in memory. + */ + None +} diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/subsystems/DataSourceUpdateSinkV2.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/subsystems/DataSourceUpdateSinkV2.java new file mode 100644 index 00000000..517f1f14 --- /dev/null +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/subsystems/DataSourceUpdateSinkV2.java @@ -0,0 +1,41 @@ +package com.launchdarkly.sdk.android.subsystems; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.launchdarkly.sdk.android.ConnectionInformation; +import com.launchdarkly.sdk.internal.fdv2.sources.Selector; + +/** + * Interfaces required by data source updates implementations in FDv2. + *

+ * This interface extends {@link TransactionalDataSourceUpdateSink} to add + * status tracking and status update capabilities required for FDv2 data sources. + */ +public interface DataSourceUpdateSinkV2 extends TransactionalDataSourceUpdateSink { + + /** + * Informs the SDK of a change in the data source's status or the connection + * mode. + * + * @param connectionMode the value that should be reported by + * {@link ConnectionInformation#getConnectionMode()} + * @param failure if non-null, represents an error/exception that caused + * data source + * initialization to fail + */ + void setStatus(@NonNull ConnectionInformation.ConnectionMode connectionMode, @Nullable Throwable failure); + + /** + * Informs the SDK that the data source is being permanently shut down due to an + * unrecoverable + * problem reported by LaunchDarkly, such as the mobile key being invalid. + *

+ * This implies that the SDK should also stop other components that communicate + * with + * LaunchDarkly, such as the event processor. It also changes the connection + * mode to + * {@link com.launchdarkly.sdk.android.ConnectionInformation.ConnectionMode#SHUTDOWN}. + */ + void shutDown(); +} diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/subsystems/TransactionalDataSourceUpdateSink.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/subsystems/TransactionalDataSourceUpdateSink.java new file mode 100644 index 00000000..d3fbc885 --- /dev/null +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/subsystems/TransactionalDataSourceUpdateSink.java @@ -0,0 +1,31 @@ +package com.launchdarkly.sdk.android.subsystems; + +import androidx.annotation.NonNull; + +import com.launchdarkly.sdk.LDContext; + +/** + * Interface that an implementation of {@link DataSource} will use to push data into the SDK transactionally. + *

+ * The data source interacts with this object, rather than manipulating the store directly, so + * that the SDK can perform any other necessary operations that must happen when data is updated. + *

+ * Component factories for {@link DataSource} implementations receive a sink that implements + * this interface (and {@link DataSourceUpdateSink}) via {@link ClientContext#getDataSourceUpdateSink()}. + * + * @see DataSource + * @see ClientContext + * @see DataSourceUpdateSink + * @see DataSourceUpdateSinkV2 + */ +public interface TransactionalDataSourceUpdateSink { + /** + * Apply the given change set to the store. This should be done atomically if possible. + * The context is the one used by the data source to obtain the changeset; the store + * will not modify data if that context is no longer the active context. + * + * @param context the context that was used to get the changeset (must still be active for apply to succeed) + * @param changeSet the changeset to apply + */ + void apply(@NonNull LDContext context, @NonNull ChangeSet changeSet); +} diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/subsystems/TransactionalDataStore.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/subsystems/TransactionalDataStore.java new file mode 100644 index 00000000..9dcf5c3b --- /dev/null +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/subsystems/TransactionalDataStore.java @@ -0,0 +1,37 @@ +package com.launchdarkly.sdk.android.subsystems; + +import androidx.annotation.NonNull; + +import com.launchdarkly.sdk.LDContext; +import com.launchdarkly.sdk.internal.fdv2.sources.Selector; + +/** + * Interface for a data store that holds feature flags and related data received by the SDK. + * This interface supports updating the store transactionally using {@link ChangeSet}s. + *

+ * Ordinarily, the only implementation of this interface is the default in-memory + * implementation, which holds references to actual SDK data model objects. + *

+ * Implementations must be thread-safe. + * + * @see TransactionalDataSourceUpdateSink + */ +public interface TransactionalDataStore { + + /** + * Apply the given change set to the store. This should be done atomically if possible. + * Implementations may ignore the update if the given context is no longer the active context. + * + * @param context the context that was used to obtain the changeset (e.g. for staleness checks) + * @param changeSet the changeset to apply + */ + void apply(@NonNull LDContext context, @NonNull ChangeSet changeSet); + + /** + * Returns the selector for the currently stored data. The selector will be non-null but may be empty. + * + * @return the selector for the currently stored data + */ + @NonNull + Selector getSelector(); +} 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 new file mode 100644 index 00000000..48675b41 --- /dev/null +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ContextDataManagerApplyTest.java @@ -0,0 +1,228 @@ +package com.launchdarkly.sdk.android; + +import com.launchdarkly.sdk.android.DataModel.Flag; + +import static com.launchdarkly.sdk.android.AssertHelpers.assertDataSetsEqual; +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.assertNull; +import static org.junit.Assert.assertTrue; + +import com.launchdarkly.sdk.android.subsystems.ChangeSet; +import com.launchdarkly.sdk.android.subsystems.ChangeSetType; +import com.launchdarkly.sdk.android.subsystems.DataSourceUpdateSink; +import com.launchdarkly.sdk.android.subsystems.DataSourceUpdateSinkV2; +import com.launchdarkly.sdk.LDContext; +import com.launchdarkly.sdk.internal.fdv2.sources.Selector; + +import org.junit.Test; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +public class ContextDataManagerApplyTest extends ContextDataManagerTestBase { + + @Test + public void applyFullReplacesDataAndPersists() { + Flag flag1 = new FlagBuilder("flag1").version(1).build(); + Flag flag2 = new FlagBuilder("flag2").version(2).build(); + Map fullItems = new HashMap<>(); + fullItems.put(flag1.getKey(), flag1); + fullItems.put(flag2.getKey(), flag2); + + ContextDataManager manager = createDataManager(); + manager.switchToContext(CONTEXT); + ChangeSet changeSet = new ChangeSet( + ChangeSetType.Full, + Selector.EMPTY, + fullItems, + null, + true + ); + manager.apply(CONTEXT, changeSet); + + assertFlagsEqual(flag1, manager.getNonDeletedFlag(flag1.getKey())); + assertFlagsEqual(flag2, manager.getNonDeletedFlag(flag2.getKey())); + EnvironmentData expected = EnvironmentData.usingExistingFlagsMap(fullItems); + assertDataSetsEqual(expected, manager.getAllNonDeleted()); + assertContextIsCached(CONTEXT, expected); + } + + @Test + public void applyFullWithShouldPersistFalseUpdatesMemoryOnly() { + Flag flag1 = new FlagBuilder("flag1").version(1).build(); + EnvironmentData initialData = new DataSetBuilder().add(flag1).build(); + ContextDataManager manager = createDataManager(); + manager.switchToContext(CONTEXT); + manager.initData(CONTEXT, initialData); + + Flag flag2 = new FlagBuilder("flag2").version(2).build(); + Map fullItems = new HashMap<>(); + fullItems.put(flag2.getKey(), flag2); + ChangeSet changeSet = new ChangeSet( + ChangeSetType.Full, + Selector.EMPTY, + fullItems, + null, + false + ); + manager.apply(CONTEXT, changeSet); + + assertNull(manager.getNonDeletedFlag(flag1.getKey())); + assertFlagsEqual(flag2, manager.getNonDeletedFlag(flag2.getKey())); + assertDataSetsEqual(EnvironmentData.usingExistingFlagsMap(fullItems), manager.getAllNonDeleted()); + assertContextIsCached(CONTEXT, initialData); + } + + @Test + public void applyPartialMergesAndPersists() { + Flag flag1 = new FlagBuilder("flag1").version(1).build(); + EnvironmentData initialData = new DataSetBuilder().add(flag1).build(); + ContextDataManager manager = createDataManager(); + manager.switchToContext(CONTEXT); + manager.initData(CONTEXT, initialData); + + Flag flag2 = new FlagBuilder("flag2").version(2).build(); + Flag flag1v2 = new FlagBuilder("flag1").version(2).value(false).build(); + Map partialItems = new HashMap<>(); + partialItems.put(flag2.getKey(), flag2); + partialItems.put(flag1v2.getKey(), flag1v2); + ChangeSet changeSet = new ChangeSet( + ChangeSetType.Partial, + Selector.EMPTY, + partialItems, + null, + true + ); + manager.apply(CONTEXT, changeSet); + + assertFlagsEqual(flag1v2, manager.getNonDeletedFlag(flag1.getKey())); + assertFlagsEqual(flag2, manager.getNonDeletedFlag(flag2.getKey())); + EnvironmentData expected = new DataSetBuilder().add(flag1v2).add(flag2).build(); + assertDataSetsEqual(expected, manager.getAllNonDeleted()); + assertContextIsCached(CONTEXT, expected); + } + + @Test + public void applyPartialRespectsVersion() { + Flag flag1 = new FlagBuilder("flag1").version(2).build(); + EnvironmentData initialData = new DataSetBuilder().add(flag1).build(); + ContextDataManager manager = createDataManager(); + manager.switchToContext(CONTEXT); + manager.initData(CONTEXT, initialData); + + Flag flag1Lower = new FlagBuilder("flag1").version(1).value(false).build(); + ChangeSet changeSet = new ChangeSet( + ChangeSetType.Partial, + Selector.EMPTY, + Collections.singletonMap(flag1Lower.getKey(), flag1Lower), + null, + true + ); + manager.apply(CONTEXT, changeSet); + + assertFlagsEqual(flag1, manager.getNonDeletedFlag(flag1.getKey())); + assertDataSetsEqual(initialData, manager.getAllNonDeleted()); + assertContextIsCached(CONTEXT, initialData); + } + + @Test + public void applyNoneDoesNotChangeFlags() { + Flag flag1 = new FlagBuilder("flag1").version(1).build(); + EnvironmentData initialData = new DataSetBuilder().add(flag1).build(); + ContextDataManager manager = createDataManager(); + manager.switchToContext(CONTEXT); + manager.initData(CONTEXT, initialData); + + ChangeSet changeSet = new ChangeSet( + ChangeSetType.None, + Selector.EMPTY, + Collections.emptyMap(), + null, + false + ); + manager.apply(CONTEXT, changeSet); + + assertFlagsEqual(flag1, manager.getNonDeletedFlag(flag1.getKey())); + assertDataSetsEqual(initialData, manager.getAllNonDeleted()); + assertContextIsCached(CONTEXT, initialData); + } + + @Test + public void applyStoresSelectorInMemory() { + ContextDataManager manager = createDataManager(); + manager.switchToContext(CONTEXT); + assertTrue(manager.getSelector().isEmpty()); + + Selector selector = Selector.make(42, "state-42"); + Flag flag = new FlagBuilder("flag1").version(1).build(); + ChangeSet changeSet = new ChangeSet( + ChangeSetType.Full, + selector, + Collections.singletonMap(flag.getKey(), flag), + null, + false + ); + manager.apply(CONTEXT, changeSet); + + assertFalse(manager.getSelector().isEmpty()); + assertEquals(42, manager.getSelector().getVersion()); + assertEquals("state-42", manager.getSelector().getState()); + } + + @Test + public void applyWithEmptySelectorDoesNotOverwriteStoredSelector() { + ContextDataManager manager = createDataManager(); + manager.switchToContext(CONTEXT); + Selector first = Selector.make(1, "state1"); + Flag flag = new FlagBuilder("flag1").version(1).build(); + manager.apply(CONTEXT, new ChangeSet( + ChangeSetType.Full, first, Collections.singletonMap(flag.getKey(), flag), null, false)); + assertEquals(1, manager.getSelector().getVersion()); + + manager.apply(CONTEXT, new ChangeSet( + ChangeSetType.Full, Selector.EMPTY, Collections.singletonMap(flag.getKey(), flag), null, false)); + assertEquals(1, manager.getSelector().getVersion()); + } + + @Test + public void applyDoesNothingWhenContextMismatch() { + Flag flag1 = new FlagBuilder("flag1").version(1).build(); + EnvironmentData initialData = new DataSetBuilder().add(flag1).build(); + ContextDataManager manager = createDataManager(); + manager.switchToContext(CONTEXT); + manager.initData(CONTEXT, initialData); + + LDContext otherContext = LDContext.create("other-context"); + Map fullItems = Collections.singletonMap( + "flag2", new FlagBuilder("flag2").version(1).build()); + ChangeSet changeSet = new ChangeSet( + ChangeSetType.Full, + Selector.EMPTY, + fullItems, + null, + true + ); + manager.apply(otherContext, changeSet); + + assertFlagsEqual(flag1, manager.getNonDeletedFlag(flag1.getKey())); + assertNull(manager.getNonDeletedFlag("flag2")); + assertDataSetsEqual(initialData, manager.getAllNonDeleted()); + assertContextIsCached(CONTEXT, initialData); + assertContextIsNotCached(otherContext); + } + + @Test + public void mockSinkImplementsBothDataSourceUpdateSinkAndV2() { + MockComponents.MockDataSourceUpdateSink mock = new MockComponents.MockDataSourceUpdateSink(); + assertNotNull(DataSourceUpdateSink.class.cast(mock)); + DataSourceUpdateSinkV2 v2 = DataSourceUpdateSinkV2.class.cast(mock); + assertNotNull(v2); + v2.apply(CONTEXT, new ChangeSet( + ChangeSetType.None, Selector.EMPTY, Collections.emptyMap(), null, false)); + assertNotNull(mock.expectApply()); + } +} diff --git a/shared-test-code/build.gradle b/shared-test-code/build.gradle index 15c8dd43..f0c0ee65 100644 --- a/shared-test-code/build.gradle +++ b/shared-test-code/build.gradle @@ -13,6 +13,7 @@ ext.versions = [ "androidAnnotation": "1.2.0", "gson": "2.13.2", "junit": "4.13", + "launchdarklyJavaSdkInternal": "1.8.0", "launchdarklyLogging": "1.1.1", ] @@ -33,7 +34,7 @@ configurations.all { dependencies { implementation(project(":launchdarkly-android-client-sdk")) - + implementation("com.launchdarkly:launchdarkly-java-sdk-internal:${versions.launchdarklyJavaSdkInternal}") implementation("androidx.annotation:annotation:${versions.androidAnnotation}") implementation("com.google.code.gson:gson:${versions.gson}") implementation("junit:junit:${versions.junit}") diff --git a/shared-test-code/src/main/java/com/launchdarkly/sdk/android/MockComponents.java b/shared-test-code/src/main/java/com/launchdarkly/sdk/android/MockComponents.java index 23eb3e07..6bdf8f67 100644 --- a/shared-test-code/src/main/java/com/launchdarkly/sdk/android/MockComponents.java +++ b/shared-test-code/src/main/java/com/launchdarkly/sdk/android/MockComponents.java @@ -17,8 +17,11 @@ import com.launchdarkly.sdk.android.subsystems.Callback; import com.launchdarkly.sdk.android.subsystems.ClientContext; import com.launchdarkly.sdk.android.subsystems.ComponentConfigurer; +import com.launchdarkly.sdk.android.subsystems.ChangeSet; 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.internal.fdv2.sources.Selector; import java.util.Map; import java.util.concurrent.BlockingQueue; @@ -57,9 +60,12 @@ public ClientContext requireReceivedClientContext() { } } - public static class MockDataSourceUpdateSink implements DataSourceUpdateSink { + public static class MockDataSourceUpdateSink implements DataSourceUpdateSink, DataSourceUpdateSinkV2 { public final BlockingQueue> inits = new LinkedBlockingQueue<>(); public final BlockingQueue upserts = new LinkedBlockingQueue<>(); + public final BlockingQueue appliedChangeSets = new LinkedBlockingQueue<>(); + + private volatile Selector lastSelector = Selector.EMPTY; @Override public void init(@NonNull LDContext context, @NonNull Map items) { @@ -81,6 +87,18 @@ public DataModel.Flag expectUpsert(String flagKey) { return flag; } + @Override + public void apply(@NonNull LDContext context, @NonNull ChangeSet changeSet) { + appliedChangeSets.add(changeSet); + if (!changeSet.getSelector().isEmpty()) { + lastSelector = changeSet.getSelector(); + } + } + + public ChangeSet expectApply() { + return requireValue(appliedChangeSets, 1, TimeUnit.SECONDS); + } + @Override public void setStatus(@NonNull ConnectionInformation.ConnectionMode connectionMode, @Nullable Throwable failure) {