diff --git a/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/sdk/android/ContextHasherTest.java b/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/sdk/android/ContextHasherTest.java deleted file mode 100644 index db2922fd..00000000 --- a/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/sdk/android/ContextHasherTest.java +++ /dev/null @@ -1,39 +0,0 @@ -package com.launchdarkly.sdk.android; - -import androidx.test.ext.junit.runners.AndroidJUnit4; - -import org.junit.Test; -import org.junit.runner.RunWith; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotEquals; - -@RunWith(AndroidJUnit4.class) -public class ContextHasherTest { - - @Test - public void testContextHasherReturnsUniqueResults(){ - ContextHasher contextHasher1 = new ContextHasher(); - - String input1 = "{'key':'userKey1'}"; - String input2 = "{'key':'userKey2'}"; - - assertNotEquals("Expected different results! instead got the same.", contextHasher1.hash(input1), contextHasher1.hash(input2)); - } - - @Test - public void testDifferentContextHashersReturnSameResults(){ - ContextHasher contextHasher1 = new ContextHasher(); - ContextHasher contextHasher2 = new ContextHasher(); - ContextHasher contextHasher3 = new ContextHasher(); - - String input1 = "{'key':'userKey1','email':'fake@example.com'}"; - String output1 = contextHasher1.hash(input1); - String output2 = contextHasher2.hash(input1); - String output3 = contextHasher3.hash(input1); - - assertEquals("Expected the same, but got different!", output1, output2); - assertEquals("Expected the same, but got different!", output1, output3); - assertEquals("Expected the same, but got different!", output2, output3); - } -} \ No newline at end of file diff --git a/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/sdk/android/LDClientEndToEndTest.java b/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/sdk/android/LDClientEndToEndTest.java index 0440be88..2126360c 100644 --- a/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/sdk/android/LDClientEndToEndTest.java +++ b/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/sdk/android/LDClientEndToEndTest.java @@ -38,7 +38,7 @@ public class LDClientEndToEndTest { private Application application; private MockWebServer mockPollingServer; private URI mockPollingServerUri; - private final PersistentDataStore store = new InMemoryPersistentDataStore(); + private PersistentDataStore store; @Rule public final ActivityScenarioRule testScenario = @@ -65,6 +65,11 @@ public void setUp() { }); } + @Before + public void before() { + store = new InMemoryPersistentDataStore(); + } + @After public void after() throws IOException { mockPollingServer.close(); 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 134b04dc..85066fbe 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 @@ -32,26 +32,29 @@ final class ClientContextImpl extends ClientContext { private final FeatureFetcher fetcher; private final PlatformState platformState; private final TaskExecutor taskExecutor; + private final PersistentDataStoreWrapper.PerEnvironmentData perEnvironmentData; ClientContextImpl( ClientContext base, DiagnosticStore diagnosticStore, FeatureFetcher fetcher, PlatformState platformState, - TaskExecutor taskExecutor + TaskExecutor taskExecutor, + PersistentDataStoreWrapper.PerEnvironmentData perEnvironmentData ) { super(base); this.diagnosticStore = diagnosticStore; this.fetcher = fetcher; this.platformState = platformState; this.taskExecutor = taskExecutor; + this.perEnvironmentData = perEnvironmentData; } static ClientContextImpl fromConfig( LDConfig config, String mobileKey, String environmentName, - FeatureFetcher fetcher, + PersistentDataStoreWrapper.PerEnvironmentData perEnvironmentData, FeatureFetcher fetcher, LDContext initialContext, LDLogger logger, PlatformState platformState, @@ -82,14 +85,14 @@ static ClientContextImpl fromConfig( if (!config.getDiagnosticOptOut()) { diagnosticStore = new DiagnosticStore(EventUtil.makeDiagnosticParams(baseClientContext)); } - return new ClientContextImpl(baseClientContext, diagnosticStore, fetcher, platformState, taskExecutor); + return new ClientContextImpl(baseClientContext, diagnosticStore, fetcher, platformState, taskExecutor, perEnvironmentData); } public static ClientContextImpl get(ClientContext context) { if (context instanceof ClientContextImpl) { return (ClientContextImpl)context; } - return new ClientContextImpl(context, null, null, null, null); + return new ClientContextImpl(context, null, null, null, null, null); } public static ClientContextImpl forDataSource( @@ -119,7 +122,8 @@ public static ClientContextImpl forDataSource( baseContextImpl.getDiagnosticStore(), baseContextImpl.getFetcher(), baseContextImpl.getPlatformState(), - baseContextImpl.getTaskExecutor() + baseContextImpl.getTaskExecutor(), + baseContextImpl.getPerEnvironmentData() ); } @@ -134,7 +138,8 @@ public ClientContextImpl setEvaluationContext(LDContext context) { this.diagnosticStore, this.fetcher, this.platformState, - this.taskExecutor + this.taskExecutor, + this.perEnvironmentData ); } @@ -154,6 +159,10 @@ public TaskExecutor getTaskExecutor() { return throwExceptionIfNull(taskExecutor); } + public PersistentDataStoreWrapper.PerEnvironmentData getPerEnvironmentData() { + return throwExceptionIfNull(perEnvironmentData); + } + private static T throwExceptionIfNull(T o) { if (o == null) { throw new IllegalStateException( diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ComponentsImpl.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ComponentsImpl.java index 605d98c3..1a59f007 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ComponentsImpl.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ComponentsImpl.java @@ -244,30 +244,36 @@ static final class PollingDataSourceBuilderImpl extends PollingDataSourceBuilder implements DiagnosticDescription, DataSourceRequiresFeatureFetcher { @Override public DataSource build(ClientContext clientContext) { - clientContext.getDataSourceUpdateSink().setStatus( - clientContext.isInBackground() ? ConnectionInformation.ConnectionMode.BACKGROUND_POLLING : + ClientContextImpl clientContextImpl = ClientContextImpl.get(clientContext); + clientContextImpl.getDataSourceUpdateSink().setStatus( + clientContextImpl.isInBackground() ? ConnectionInformation.ConnectionMode.BACKGROUND_POLLING : ConnectionInformation.ConnectionMode.POLLING, null ); - int actualPollIntervalMillis = clientContext.isInBackground() ? backgroundPollIntervalMillis : + + int pollInterval = clientContextImpl.isInBackground() ? backgroundPollIntervalMillis : pollIntervalMillis; - int initialDelayMillis; - if (clientContext.isInBackground() && Boolean.FALSE.equals(clientContext.getPreviouslyInBackground())) { - // If we're transitioning from foreground to background, then we don't want to do a - // poll right away because we already have recent flag data. Start polling *after* - // the first background poll interval. - initialDelayMillis = backgroundPollIntervalMillis; - } else { - // If we're in the foreground-- or, if we're in the background but we started out - // that way rather than transitioning-- then we should do the first poll right away. - initialDelayMillis = 0; + + // get the last updated timestamp for this context + PersistentDataStoreWrapper.PerEnvironmentData perEnvironmentData = clientContextImpl.getPerEnvironmentData(); + String hashedContextId = LDUtil.urlSafeBase64HashedContextId(clientContextImpl.getEvaluationContext()); + String fingerprint = LDUtil.urlSafeBase64Hash(clientContextImpl.getEvaluationContext()); + Long lastUpdated = perEnvironmentData.getLastUpdated(hashedContextId, fingerprint); + if (lastUpdated == null) { + lastUpdated = 0L; // default to beginning of time } - ClientContextImpl clientContextImpl = ClientContextImpl.get(clientContext); + + // To avoid unnecessarily frequent polling requests due to process or application lifecycle, we have added + // this initial delay logic. Calculate how much time has passed since the last update, if that is less than + // the polling interval, delay by the difference, otherwise 0 delay. + long elapsedSinceUpdate = System.currentTimeMillis() - lastUpdated; + long initialDelayMillis = Math.max(pollInterval - elapsedSinceUpdate, 0); + return new PollingDataSource( - clientContext.getEvaluationContext(), - clientContext.getDataSourceUpdateSink(), + clientContextImpl.getEvaluationContext(), + clientContextImpl.getDataSourceUpdateSink(), initialDelayMillis, - actualPollIntervalMillis, + pollInterval, clientContextImpl.getFetcher(), clientContextImpl.getPlatformState(), clientContextImpl.getTaskExecutor(), 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 f99cb042..b6dc6915 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 @@ -447,7 +447,7 @@ public void onSuccess(String flagsJson) { @Override public void onError(Throwable e) { logger.error("Error when attempting to get flag data: [{}] [{}]: {}", - LDUtil.base64Url(contextToFetch), + LDUtil.urlSafeBase64(contextToFetch), contextToFetch, LogValues.exceptionSummary(e)); resultCallback.onError(e); 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 e997f033..f466b38d 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 @@ -39,8 +39,6 @@ * deferred listener calls are done via the {@link TaskExecutor} abstraction. */ final class ContextDataManager { - static final ContextHasher HASHER = new ContextHasher(); - private final PersistentDataStoreWrapper.PerEnvironmentData environmentStore; private final int maxCachedContexts; private final TaskExecutor taskExecutor; @@ -57,7 +55,7 @@ final class ContextDataManager { @NonNull private volatile LDContext currentContext; @NonNull private volatile EnvironmentData flags = new EnvironmentData(); - @NonNull private volatile ContextIndex index = null; + @NonNull private volatile ContextIndex index; ContextDataManager( @NonNull ClientContext clientContext, @@ -65,6 +63,7 @@ final class ContextDataManager { int maxCachedContexts ) { this.environmentStore = environmentStore; + this.index = environmentStore.getIndex(); this.maxCachedContexts = maxCachedContexts; this.taskExecutor = ClientContextImpl.get(clientContext).getTaskExecutor(); this.logger = clientContext.getBaseLogger(); @@ -120,8 +119,9 @@ private void initDataInternal( @NonNull EnvironmentData newData, boolean writeFlagsToPersistentStore ) { - List removedContextIds = new ArrayList<>(); - String contextId = hashedContextId(context); + + String contextId = LDUtil.urlSafeBase64HashedContextId(context); + String fingerprint = LDUtil.urlSafeBase64Hash(context); EnvironmentData oldData; ContextIndex newIndex; @@ -133,26 +133,27 @@ private void initDataInternal( oldData = flags; flags = newData; - if (index == null) { - index = environmentStore.getIndex(); - } - 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); - } - if (writeFlagsToPersistentStore && maxCachedContexts != 0) { - environmentStore.setContextData(contextId, 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); } - environmentStore.setIndex(newIndex); - } - - if (logger.isEnabled(LDLogLevel.DEBUG)) { - logger.debug("Stored context index is now: {}", newIndex.toJson()); } // Determine which flags were updated and notify listeners, if any @@ -241,8 +242,11 @@ public boolean upsert(@NonNull LDContext context, @NonNull Flag flag) { updatedFlags = flags.withFlagUpdatedOrAdded(flag); flags = updatedFlags; - String contextId = hashedContextId(context); - environmentStore.setContextData(contextId, updatedFlags); + String hashedContextId = LDUtil.urlSafeBase64HashedContextId(context); + String fingerprint = LDUtil.urlSafeBase64Hash(context); + environmentStore.setContextData(hashedContextId, fingerprint, updatedFlags); + index = index.updateTimestamp(hashedContextId, System.currentTimeMillis()); + environmentStore.setIndex(index); } Collection updatedFlag = Collections.singletonList(flag.getKey()); @@ -292,10 +296,6 @@ public Collection getListenersByKey(String key) { return res == null ? new HashSet<>() : res; } - public static String hashedContextId(final LDContext context) { - return HASHER.hash(context.getFullyQualifiedKey()); - } - /** * Attempts to retrieve data for the specified context, if any, from the persistent store. This * does not affect the current context/flags state. @@ -305,7 +305,7 @@ public static String hashedContextId(final LDContext context) { */ @VisibleForTesting public @Nullable EnvironmentData getStoredData(LDContext context) { - return environmentStore.getContextData(hashedContextId(context)); + return environmentStore.getContextData(LDUtil.urlSafeBase64HashedContextId(context)); } private void notifyFlagListeners(Collection updatedFlagKeys) { diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ContextHasher.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ContextHasher.java deleted file mode 100644 index 9b3ac2a8..00000000 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ContextHasher.java +++ /dev/null @@ -1,30 +0,0 @@ -package com.launchdarkly.sdk.android; - -import android.util.Base64; - -import java.nio.charset.Charset; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; - -/** - * Provides a single hash method that takes a String and returns a unique filename-safe hash of it. - * It exists as a separate class so we can unit test it and assert that different instances - * produce the same output given the same input. - */ -class ContextHasher { - - String hash(String toHash) { - try { - MessageDigest messageDigest = MessageDigest.getInstance("SHA-256"); - messageDigest.reset(); - // All instances of the JVM are required to support UTF-8 charset - byte[] hash = messageDigest.digest(toHash.getBytes(Charset.forName("UTF-8"))); - return Base64.encodeToString(hash, Base64.URL_SAFE + Base64.NO_WRAP); - } catch (NoSuchAlgorithmException ignored) { - // SHA-256 should be supported on all devices. This exception case is because Java - // can't statically verify that the string "SHA-256" is always a valid MessageDigest. - // We return a string of the correct length in case anything depends on it. - return "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="; - } - } -} diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/HttpFeatureFlagFetcher.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/HttpFeatureFlagFetcher.java index eca1c9c3..c6129e48 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/HttpFeatureFlagFetcher.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/HttpFeatureFlagFetcher.java @@ -138,7 +138,7 @@ private Request getDefaultRequest(LDContext ldContext) throws IOException { // and methods like Uri.withAppendedPath, simply to minimize the amount of code that relies on // Android-specific APIs so our components are more easily unit-testable. URI uri = HttpHelpers.concatenateUriPath(pollUri, StandardEndpoints.POLLING_REQUEST_GET_BASE_PATH); - uri = HttpHelpers.concatenateUriPath(uri, LDUtil.base64Url(ldContext)); + uri = HttpHelpers.concatenateUriPath(uri, LDUtil.urlSafeBase64(ldContext)); if (evaluationReasons) { uri = URI.create(uri.toString() + "?withReasons=true"); } 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 0129032e..60eb7028 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 @@ -330,7 +330,7 @@ protected LDClient( FeatureFetcher fetcher = null; if (config.dataSource instanceof ComponentsImpl.DataSourceRequiresFeatureFetcher) { ClientContextImpl minimalContext = ClientContextImpl.fromConfig(config, mobileKey, - environmentName, null, initialContext, logger, platformState, environmentReporter, taskExecutor + environmentName, environmentStore, null, initialContext, logger, platformState, environmentReporter, taskExecutor ); fetcher = new HttpFeatureFlagFetcher(minimalContext); } @@ -339,7 +339,7 @@ protected LDClient( config, mobileKey, environmentName, - fetcher, + environmentStore, fetcher, initialContext, logger, platformState, 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 a5301e94..163ea5bb 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 @@ -145,7 +145,15 @@ static String urlSafeBase64Hash(String input) { } } - static String base64Url(final LDContext context) { + public static String urlSafeBase64HashedContextId(LDContext context) { + return urlSafeBase64Hash(context.getFullyQualifiedKey()); + } + + static String urlSafeBase64Hash(LDContext context) { + return urlSafeBase64Hash(JsonSerialization.serialize(context)); + } + + static String urlSafeBase64(LDContext context) { return Base64.encodeToString(JsonSerialization.serialize(context).getBytes(), Base64.URL_SAFE + Base64.NO_WRAP); } diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/PersistentDataStoreWrapper.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/PersistentDataStoreWrapper.java index d12574e0..8289ab5c 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/PersistentDataStoreWrapper.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/PersistentDataStoreWrapper.java @@ -3,6 +3,7 @@ import static com.launchdarkly.sdk.internal.GsonHelpers.gsonInstance; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import com.launchdarkly.logging.LDLogger; import com.launchdarkly.sdk.ContextKind; @@ -11,6 +12,7 @@ import java.util.HashMap; import java.util.Map; +import java.util.Objects; import java.util.UUID; import java.util.concurrent.atomic.AtomicBoolean; @@ -53,6 +55,7 @@ static class SavedConnectionInfo { private static final String ANON_CONTEXT_KEY_PREFIX = "anonKey_"; private static final String ENVIRONMENT_METADATA_KEY = "index"; private static final String ENVIRONMENT_CONTEXT_DATA_KEY_PREFIX = "flags_"; + private static final String ENVIRONMENT_CONTEXT_FINGERPRINT_KEY_PREFIX = "contextFingerprint_"; private static final String ENVIRONMENT_LAST_SUCCESS_TIME_KEY = "lastSuccessfulConnection"; private static final String ENVIRONMENT_LAST_FAILURE_TIME_KEY = "lastFailedConnection"; private static final String ENVIRONMENT_LAST_FAILURE_KEY = "lastFailure"; @@ -155,7 +158,7 @@ final class PerEnvironmentData { /** * Returns the stored flag data, if any, for a specific context. * - * @param hashedContextId the hashed key of the context + * @param hashedContextId the hashed canonical key of the context * @return the {@link EnvironmentData}, or null if not found */ public EnvironmentData getContextData(String hashedContextId) { @@ -171,20 +174,23 @@ public EnvironmentData getContextData(String hashedContextId) { /** * Stores flag data for a specific context, overwriting any previous data for that context. * - * @param hashedContextId the hashed key of the context + * @param hashedContextId the hashed canonical key of the context + * @param fingerprint that is unique for the given context and considers all attributes as part of its calculation * @param allData the flag data */ - public void setContextData(String hashedContextId, EnvironmentData allData) { + public void setContextData(String hashedContextId, String fingerprint, EnvironmentData allData) { trySetValue(environmentNamespace, keyForContextId(hashedContextId), allData.toJson()); + trySetValue(environmentNamespace, keyForContextFingerprint(hashedContextId), fingerprint); } /** * Removes the stored flag data, if any, for a specific context. * - * @param hashedContextId the hashed key of the context + * @param hashedContextId the hashed canonical key of the context */ public void removeContextData(String hashedContextId) { trySetValue(environmentNamespace, keyForContextId(hashedContextId), null); + trySetValue(environmentNamespace, keyForContextFingerprint(hashedContextId), null); } /** @@ -212,6 +218,29 @@ public void setIndex(@NonNull ContextIndex contextIndex) { trySetValue(environmentNamespace, ENVIRONMENT_METADATA_KEY, contextIndex.toJson()); } + /** + * @param hashedContextId the hashed canonical key of the context + * @param fingerprint that is unique for the given context and considers all attributes as part of its calculation + * @return the timestamp in millis that the context data was last updated, null if no data is stored for the fingerprint + */ + @Nullable + public Long getLastUpdated(String hashedContextId, String fingerprint) { + String storedFingerprint = tryGetValue(environmentNamespace, keyForContextFingerprint(hashedContextId)); + if (!Objects.equals(storedFingerprint, fingerprint)) { + // we don't a timestamp stored for this fingerprint + return null; + } + + for (ContextIndex.IndexEntry entry : getIndex().data) { + if (entry.contextId.equals(hashedContextId)) { + return entry.timestamp; + } + } + + // no match found + return null; + } + /** * Retrieves stored connection status properties. * @@ -252,6 +281,10 @@ private String keyForContextId(String hashedContextId) { return ENVIRONMENT_CONTEXT_DATA_KEY_PREFIX + hashedContextId; } + private String keyForContextFingerprint(String hashedContextId) { + return ENVIRONMENT_CONTEXT_FINGERPRINT_KEY_PREFIX + hashedContextId; + } + private String tryGetValue(String namespace, String key) { try { synchronized (storeLock) { diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/PollingDataSource.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/PollingDataSource.java index 371d31f0..bee3789e 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/PollingDataSource.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/PollingDataSource.java @@ -18,10 +18,10 @@ * ComponentsImpl.PollingDataSourceBuilderImpl and ComponentsImpl.StreamingDataSourceBuilderImpl. */ final class PollingDataSource implements DataSource { - private final LDContext currentContext; + private final LDContext context; private final DataSourceUpdateSink dataSourceUpdateSink; - final int initialDelayMillis; // visible for testing - final int pollIntervalMillis; // visible for testing + final long initialDelayMillis; // visible for testing + final long pollIntervalMillis; // visible for testing private final FeatureFetcher fetcher; private final PlatformState platformState; private final TaskExecutor taskExecutor; @@ -29,17 +29,29 @@ final class PollingDataSource implements DataSource { private final AtomicReference> currentPollTask = new AtomicReference<>(); + /** + * @param context that this data source will fetch data for + * @param dataSourceUpdateSink to send data to + * @param initialDelayMillis delays when the data source begins polling. If this is greater than 0, the polling data + * source will report success immediately as it is now running even if data has not been + * fetched. + * @param pollIntervalMillis interval in millis between each polling request + * @param fetcher that will be used for each fetch + * @param platformState used for making decisions based on platform state + * @param taskExecutor that will be used to schedule the polling tasks + * @param logger for logging + */ PollingDataSource( - LDContext currentContext, + LDContext context, DataSourceUpdateSink dataSourceUpdateSink, - int initialDelayMillis, - int pollIntervalMillis, + long initialDelayMillis, + long pollIntervalMillis, FeatureFetcher fetcher, PlatformState platformState, TaskExecutor taskExecutor, LDLogger logger ) { - this.currentContext = currentContext; + this.context = context; this.dataSourceUpdateSink = dataSourceUpdateSink; this.initialDelayMillis = initialDelayMillis; this.pollIntervalMillis = pollIntervalMillis; @@ -51,15 +63,16 @@ final class PollingDataSource implements DataSource { @Override public void start(final Callback resultCallback) { - Runnable trigger = new Runnable() { - @Override - public void run() { - triggerPoll(resultCallback); - } - }; + + if (initialDelayMillis > 0) { + // if there is an initial delay, we will immediately report the successful start of the data source + resultCallback.onSuccess(true); + } + + Runnable pollRunnable = () -> poll(resultCallback); logger.debug("Scheduling polling task with interval of {}ms, starting after {}ms", pollIntervalMillis, initialDelayMillis); - ScheduledFuture task = taskExecutor.startRepeatingTask(trigger, + ScheduledFuture task = taskExecutor.startRepeatingTask(pollRunnable, initialDelayMillis, pollIntervalMillis); currentPollTask.set(task); } @@ -73,8 +86,8 @@ public void stop(Callback completionCallback) { completionCallback.onSuccess(null); } - private void triggerPoll(Callback resultCallback) { - ConnectivityManager.fetchAndSetData(fetcher, currentContext, dataSourceUpdateSink, + private void poll(Callback resultCallback) { + ConnectivityManager.fetchAndSetData(fetcher, context, dataSourceUpdateSink, resultCallback, logger); } } diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/StreamingDataSource.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/StreamingDataSource.java index fd19005f..1f74b601 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/StreamingDataSource.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/StreamingDataSource.java @@ -190,7 +190,7 @@ private URI getUri(@Nullable LDContext context) { StandardEndpoints.STREAMING_REQUEST_BASE_PATH); if (!useReport && context != null) { - uri = HttpHelpers.concatenateUriPath(uri, LDUtil.base64Url(context)); + uri = HttpHelpers.concatenateUriPath(uri, LDUtil.urlSafeBase64(context)); } if (evaluationReasons) { 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 290a90d1..26523e5e 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 @@ -4,7 +4,6 @@ import static com.launchdarkly.sdk.android.TestUtil.requireValue; import static org.easymock.EasyMock.anyObject; import static org.easymock.EasyMock.expectLastCall; -import static org.easymock.EasyMock.replay; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotEquals; @@ -111,7 +110,7 @@ private void createTestManager( config, MOBILE_KEY, "", - null, + environmentStore, null, CONTEXT, logging.logger, mockPlatformState, 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 b2606e25..665154ba 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 @@ -6,6 +6,7 @@ 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.assertNotEquals; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertSame; @@ -163,6 +164,40 @@ public void upsertUpdatesFlag() { assertDataSetsEqual(expectedData, manager.getStoredData(CONTEXT)); } + @Test + public void switchDoesNotUpdateIndexTimestamp() throws Exception { + EnvironmentData data = new DataSetBuilder().add(new FlagBuilder("flag1").build()).build(); + ContextDataManager manager1 = createDataManager(); + manager1.switchToContext(INITIAL_CONTEXT); + 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); + manager1.switchToContext(INITIAL_CONTEXT); + Long secondTimestamp = environmentStore.getLastUpdated(LDUtil.urlSafeBase64HashedContextId(INITIAL_CONTEXT), LDUtil.urlSafeBase64Hash(INITIAL_CONTEXT)); + + assertEquals(firstTimestamp, secondTimestamp); + } + + @Test + public void upsertUpdatesIndexTimestamp() throws Exception { + Flag flag1a = new FlagBuilder("flag1").version(1).value(true).build(), + flag1b = new FlagBuilder(flag1a.getKey()).version(2).value(false).build(); + EnvironmentData initialData = new DataSetBuilder().add(flag1a).build(); + ContextDataManager manager = createDataManager(); + manager.switchToContext(CONTEXT); + manager.initData(CONTEXT, initialData); + long firstTimestamp = environmentStore.getLastUpdated(LDUtil.urlSafeBase64HashedContextId(CONTEXT), LDUtil.urlSafeBase64Hash(CONTEXT)); + + Thread.sleep(2); // sleep for an amount that is greater than precision of System.currentTimeMillis so the change can be detected + + manager.upsert(CONTEXT, flag1b); + long secondTimestamp = environmentStore.getLastUpdated(LDUtil.urlSafeBase64HashedContextId(CONTEXT), LDUtil.urlSafeBase64Hash(CONTEXT)); + assertNotEquals(firstTimestamp, secondTimestamp); + } + @Test public void upsertDoesNotUpdateFlagWithSameVersion() { upsertDoesNotUpdateFlag( diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ContextDataManagerTestBase.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ContextDataManagerTestBase.java index d403a2ac..545e97c4 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ContextDataManagerTestBase.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ContextDataManagerTestBase.java @@ -50,7 +50,7 @@ protected ContextDataManager createDataManager(int maxCachedContexts) { new LDConfig.Builder(AutoEnvAttributes.Disabled).build(), "mobile-key", "", - null, + environmentStore, null, INITIAL_CONTEXT, logging.logger, null, @@ -69,7 +69,7 @@ protected ContextDataManager createDataManager() { } protected void assertContextIsCached(LDContext context, EnvironmentData expectedData) { - String contextHash = ContextDataManager.hashedContextId(context); + String contextHash = LDUtil.urlSafeBase64HashedContextId(context); EnvironmentData data = environmentStore.getContextData(contextHash); assertNotNull("flag data for context " + contextHash + " not found in store", data); assertDataSetsEqual(expectedData, data); @@ -85,7 +85,7 @@ protected void assertContextIsCached(LDContext context, EnvironmentData expected } protected void assertContextIsNotCached(LDContext context) { - String contextHash = ContextDataManager.hashedContextId(context); + String contextHash = LDUtil.urlSafeBase64HashedContextId(context); assertNull("flag data for " + context.getKey() + " should not have been in store", environmentStore.getContextData(contextHash)); diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/DiagnosticConfigTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/DiagnosticConfigTest.java index ed9507e4..8243052b 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/DiagnosticConfigTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/DiagnosticConfigTest.java @@ -142,7 +142,7 @@ public void customDiagnosticConfigurationHttp() throws Exception { private static LDValue makeDiagnosticJson(LDConfig config) throws Exception { ClientContext clientContext = ClientContextImpl.fromConfig(config, "", "", - null, null, LDLogger.none(), null, new EnvironmentReporterBuilder().build(), null); + null, null, null, LDLogger.none(), null, new EnvironmentReporterBuilder().build(), null); DiagnosticStore.SdkDiagnosticParams params = EventUtil.makeDiagnosticParams(clientContext); DiagnosticStore diagnosticStore = new DiagnosticStore(params); MockDiagnosticEventSender mockSender = new MockDiagnosticEventSender(); diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/LDConfigTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/LDConfigTest.java index f5328cd3..c0684882 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/LDConfigTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/LDConfigTest.java @@ -75,7 +75,7 @@ Map headersToMap(Headers headers) { public void headersForEnvironment() { LDConfig config = new LDConfig.Builder(AutoEnvAttributes.Disabled).mobileKey("test-key").build(); ClientContext clientContext = ClientContextImpl.fromConfig(config, "test-key", "", - null, null, null, null, new EnvironmentReporterBuilder().build(), null); + null, null, null, null, null, new EnvironmentReporterBuilder().build(), null); Map headers = headersToMap( LDUtil.makeHttpProperties(clientContext).toHeadersBuilder().build() ); @@ -96,7 +96,7 @@ public void headersForEnvironmentWithTransform() { })) .build(); ClientContext clientContext = ClientContextImpl.fromConfig(config, "test-key", "", - null, null, null, null, new EnvironmentReporterBuilder().build(), null); + null, null, null, null, null, new EnvironmentReporterBuilder().build(), null); expected.put("User-Agent", LDUtil.USER_AGENT_HEADER_VALUE); expected.put("Authorization", "api_key test-key"); diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/PersistentDataStoreWrapperTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/PersistentDataStoreWrapperTest.java index 1706bd0c..cdca5e0a 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/PersistentDataStoreWrapperTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/PersistentDataStoreWrapperTest.java @@ -17,10 +17,10 @@ import static org.easymock.EasyMock.eq; import static org.easymock.EasyMock.expect; import static org.easymock.EasyMock.expectLastCall; -import static org.easymock.EasyMock.verify; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; public class PersistentDataStoreWrapperTest extends EasyMockSupport { // This verifies non-platform-dependent behavior, such as what keys we store particular @@ -32,7 +32,9 @@ public class PersistentDataStoreWrapperTest extends EasyMockSupport { private static final String EXPECTED_ENVIRONMENT_NAMESPACE = "LaunchDarkly_" + MOBILE_KEY_HASH; private static final String CONTEXT_KEY = "context-key"; private static final String CONTEXT_KEY_HASH = LDUtil.urlSafeBase64Hash(CONTEXT_KEY); + private static final String CONTEXT_FINGERPRINT = "mock-context-fingerprint"; private static final String EXPECTED_CONTEXT_FLAGS_KEY = "flags_" + CONTEXT_KEY_HASH; + private static final String EXPECTED_CONTEXT_FINGERPRINT_KEY = "contextFingerprint_" + CONTEXT_KEY_HASH; private static final String EXPECTED_INDEX_KEY = "index"; private static final String EXPECTED_GENERATED_CONTEXT_KEY_PREFIX = "anonKey_"; private static final Flag FLAG = new Flag("flagkey", LDValue.of(true), 1, @@ -100,10 +102,12 @@ public void setContextData() { EnvironmentData data = new DataSetBuilder().add(FLAG).build(); mockPersistentStore.setValue(EXPECTED_ENVIRONMENT_NAMESPACE, EXPECTED_CONTEXT_FLAGS_KEY, data.toJson()); + mockPersistentStore.setValue(EXPECTED_ENVIRONMENT_NAMESPACE, + EXPECTED_CONTEXT_FINGERPRINT_KEY, CONTEXT_FINGERPRINT); expectLastCall(); replayAll(); - envWrapper.setContextData(CONTEXT_KEY_HASH, data); + envWrapper.setContextData(CONTEXT_KEY_HASH, CONTEXT_FINGERPRINT, data); verifyAll(); logging.assertNothingLogged(); } @@ -113,10 +117,12 @@ public void setContextDataWhenStoreThrowsException() { EnvironmentData data = new DataSetBuilder().add(FLAG).build(); mockPersistentStore.setValue(EXPECTED_ENVIRONMENT_NAMESPACE, EXPECTED_CONTEXT_FLAGS_KEY, data.toJson()); + mockPersistentStore.setValue(EXPECTED_ENVIRONMENT_NAMESPACE, + EXPECTED_CONTEXT_FINGERPRINT_KEY, CONTEXT_FINGERPRINT); expectLastCall().andThrow(makeException()); replayAll(); - envWrapper.setContextData(CONTEXT_KEY_HASH, data); + envWrapper.setContextData(CONTEXT_KEY_HASH, CONTEXT_FINGERPRINT, data); verifyAll(); assertStoreErrorWasLogged(); } @@ -124,6 +130,7 @@ public void setContextDataWhenStoreThrowsException() { @Test public void removeContextData() { mockPersistentStore.setValue(EXPECTED_ENVIRONMENT_NAMESPACE, EXPECTED_CONTEXT_FLAGS_KEY, null); + mockPersistentStore.setValue(EXPECTED_ENVIRONMENT_NAMESPACE, EXPECTED_CONTEXT_FINGERPRINT_KEY, null); expectLastCall(); replayAll(); @@ -135,6 +142,7 @@ public void removeContextData() { @Test public void removeContextDataWhenStoreThrowsException() { mockPersistentStore.setValue(EXPECTED_ENVIRONMENT_NAMESPACE, EXPECTED_CONTEXT_FLAGS_KEY, null); + mockPersistentStore.setValue(EXPECTED_ENVIRONMENT_NAMESPACE, EXPECTED_CONTEXT_FINGERPRINT_KEY, null); expectLastCall().andThrow(makeException()); replayAll(); @@ -207,6 +215,41 @@ public void setIndexWhenStoreThrowsException() { assertStoreErrorWasLogged(); } + @Test + public void getLastUpdated() { + ContextIndex expectedIndex = new ContextIndex().updateTimestamp(CONTEXT_KEY_HASH, 1000); + expect(mockPersistentStore.getValue(EXPECTED_ENVIRONMENT_NAMESPACE, EXPECTED_CONTEXT_FINGERPRINT_KEY)).andReturn(CONTEXT_FINGERPRINT); + expect(mockPersistentStore.getValue(EXPECTED_ENVIRONMENT_NAMESPACE, EXPECTED_INDEX_KEY)) + .andReturn(expectedIndex.toJson()); + replayAll(); + + long lastUpdated = envWrapper.getLastUpdated(CONTEXT_KEY_HASH, CONTEXT_FINGERPRINT); + verifyAll(); + assertEquals(lastUpdated, 1000); + } + + @Test + public void getLastUpdatedNoMatchingHashedContextId() { + ContextIndex expectedIndex = new ContextIndex().updateTimestamp("ImABogusContextHash", 1000); + expect(mockPersistentStore.getValue(EXPECTED_ENVIRONMENT_NAMESPACE, EXPECTED_CONTEXT_FINGERPRINT_KEY)).andReturn(CONTEXT_FINGERPRINT); + expect(mockPersistentStore.getValue(EXPECTED_ENVIRONMENT_NAMESPACE, EXPECTED_INDEX_KEY)) + .andReturn(expectedIndex.toJson()); + replayAll(); + + assertNull(envWrapper.getLastUpdated(CONTEXT_KEY_HASH, CONTEXT_FINGERPRINT)); + } + + @Test + public void getLastUpdatedNoMatchingFingerprint() { + ContextIndex expectedIndex = new ContextIndex().updateTimestamp(CONTEXT_KEY_HASH, 1000); + expect(mockPersistentStore.getValue(EXPECTED_ENVIRONMENT_NAMESPACE, EXPECTED_CONTEXT_FINGERPRINT_KEY)).andReturn(null); + expect(mockPersistentStore.getValue(EXPECTED_ENVIRONMENT_NAMESPACE, EXPECTED_INDEX_KEY)) + .andReturn(expectedIndex.toJson()); + replayAll(); + + assertNull(envWrapper.getLastUpdated(CONTEXT_KEY_HASH, CONTEXT_FINGERPRINT)); + } + @Test public void getOrGenerateContextKeyExisting() { expect(mockPersistentStore.getValue(EXPECTED_GLOBAL_NAMESPACE, diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/PollingDataSourceTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/PollingDataSourceTest.java index c022a8c3..35f739b6 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/PollingDataSourceTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/PollingDataSourceTest.java @@ -3,6 +3,7 @@ import static com.launchdarkly.sdk.android.AssertHelpers.requireNoMoreValues; import static com.launchdarkly.sdk.android.AssertHelpers.requireValue; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; import com.launchdarkly.sdk.LDContext; import com.launchdarkly.sdk.LDValue; @@ -14,7 +15,7 @@ import com.launchdarkly.sdk.android.subsystems.ClientContext; import com.launchdarkly.sdk.android.subsystems.DataSource; -import org.easymock.EasyMockSupport; +import org.junit.Before; import org.junit.Rule; import org.junit.Test; @@ -24,8 +25,9 @@ import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.TimeUnit; -public class PollingDataSourceTest extends EasyMockSupport { +public class PollingDataSourceTest { private static final LDContext CONTEXT = LDContext.create("context-key"); + private static final String MOBILE_KEY = "test-mobile-key"; private static final LDConfig EMPTY_CONFIG = new LDConfig.Builder(AutoEnvAttributes.Disabled).build(); private final MockComponents.MockDataSourceUpdateSink dataSourceUpdateSink = new MockComponents.MockDataSourceUpdateSink(); @@ -34,13 +36,19 @@ public class PollingDataSourceTest extends EasyMockSupport { private final IEnvironmentReporter environmentReporter = new EnvironmentReporterBuilder().build(); private final SimpleTestTaskExecutor taskExecutor = new SimpleTestTaskExecutor(); + private PersistentDataStoreWrapper.PerEnvironmentData perEnvironmentData; @Rule public LogCaptureRule logging = new LogCaptureRule(); - private ClientContext makeClientContext(boolean inBackground, Boolean previouslyInBackground) { - ClientContext baseClientContext = ClientContextImpl.fromConfig( - EMPTY_CONFIG, "", "", fetcher, CONTEXT, + @Before + public void before() { + perEnvironmentData = TestUtil.makeSimplePersistentDataStoreWrapper().perEnvironmentData(MOBILE_KEY); + } + + private ClientContextImpl makeClientContext(boolean inBackground, Boolean previouslyInBackground) { + ClientContextImpl baseClientContext = ClientContextImpl.fromConfig( + EMPTY_CONFIG, "", "", perEnvironmentData, fetcher, CONTEXT, logging.logger, platformState, environmentReporter, taskExecutor); return ClientContextImpl.forDataSource( baseClientContext, @@ -116,26 +124,27 @@ public void firstPollIsImmediateWhenStartingInBackground() throws Exception { } @Test - public void firstPollHappensAfterBackgroundPollingIntervalWhenTransitioningToBackground() throws Exception { - ClientContext clientContext = makeClientContext(true, false); + public void pollingIntervalHonoredAcrossMultipleBuildCalls() throws Exception { + ClientContextImpl clientContext = makeClientContext(true, null); PollingDataSourceBuilder builder = Components.pollingDataSource() - .pollIntervalMillis(100000); - ((ComponentsImpl.PollingDataSourceBuilderImpl)builder).backgroundPollIntervalMillisNoMinimum(200); - DataSource ds = builder.build(clientContext); - fetcher.setupSuccessResponse("{}"); - - try { - ds.start(LDUtil.noOpCallback()); - - requireNoMoreValues(fetcher.receivedContexts, 10, TimeUnit.MILLISECONDS); - - Thread.sleep(300); + .pollIntervalMillis(100000) + .backgroundPollIntervalMillis(100000); - LDContext context = requireValue(fetcher.receivedContexts, 100, TimeUnit.MILLISECONDS); - assertEquals(CONTEXT, context); - } finally { - ds.stop(LDUtil.noOpCallback()); - } + // first build should have no delay + PollingDataSource ds1 = (PollingDataSource) builder.build(clientContext); + assertEquals(0, ds1.initialDelayMillis); + + // simulate successful update of context index timestamp + String hashedContextId = LDUtil.urlSafeBase64HashedContextId(CONTEXT); + String fingerPrint = LDUtil.urlSafeBase64Hash(CONTEXT); + PersistentDataStoreWrapper.PerEnvironmentData perEnvironmentData = clientContext.getPerEnvironmentData(); + perEnvironmentData.setContextData(hashedContextId, fingerPrint, new EnvironmentData()); + ContextIndex newIndex = perEnvironmentData.getIndex().updateTimestamp(hashedContextId, System.currentTimeMillis()); + perEnvironmentData.setIndex(newIndex); + + // second build should have a non-zero delay due to simulated response storing a recent timestamp + PollingDataSource ds2 = (PollingDataSource) builder.build(clientContext); + assertNotEquals(0, ds2.initialDelayMillis); } @Test diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/StreamingDataSourceTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/StreamingDataSourceTest.java index 8346dee5..ee83015d 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/StreamingDataSourceTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/StreamingDataSourceTest.java @@ -11,6 +11,7 @@ import com.launchdarkly.sdk.android.subsystems.ClientContext; import com.launchdarkly.sdk.android.subsystems.DataSource; +import org.junit.Before; import org.junit.Rule; import org.junit.Test; @@ -23,19 +24,23 @@ public class StreamingDataSourceTest { // the tests here cover other aspects of how the streaming data source component behaves. private static final LDContext CONTEXT = LDContext.create("context-key"); - + private static final String MOBILE_KEY = "test-mobile-key"; @Rule public LogCaptureRule logging = new LogCaptureRule(); - private final MockComponents.MockDataSourceUpdateSink dataSourceUpdateSink = new MockComponents.MockDataSourceUpdateSink(); private final MockPlatformState platformState = new MockPlatformState(); - private final IEnvironmentReporter environmentReporter = new EnvironmentReporterBuilder().build(); private final SimpleTestTaskExecutor taskExecutor = new SimpleTestTaskExecutor(); + private PersistentDataStoreWrapper.PerEnvironmentData perEnvironmentData; + + @Before + public void before() { + perEnvironmentData = TestUtil.makeSimplePersistentDataStoreWrapper().perEnvironmentData(MOBILE_KEY); + } private ClientContext makeClientContext(boolean inBackground, Boolean previouslyInBackground) { ClientContext baseClientContext = ClientContextImpl.fromConfig( - new LDConfig.Builder(AutoEnvAttributes.Disabled).build(), "", "", null, CONTEXT, + new LDConfig.Builder(AutoEnvAttributes.Disabled).build(), "", "", perEnvironmentData, null, CONTEXT, logging.logger, platformState, environmentReporter, taskExecutor); return ClientContextImpl.forDataSource( baseClientContext, @@ -52,7 +57,7 @@ private ClientContext makeClientContext(boolean inBackground, Boolean previously // that has a fetcher private ClientContext makeClientContextWithFetcher() { ClientContext baseClientContext = ClientContextImpl.fromConfig( - new LDConfig.Builder(AutoEnvAttributes.Disabled).build(), "", "", makeFeatureFetcher(), CONTEXT, + new LDConfig.Builder(AutoEnvAttributes.Disabled).build(), "", "", perEnvironmentData, makeFeatureFetcher(), CONTEXT, logging.logger, platformState, environmentReporter, taskExecutor); return ClientContextImpl.forDataSource( baseClientContext, @@ -106,19 +111,6 @@ public void builderCreatesPollingDataSourceWhenStartingInBackground() { assertEquals(0L, ((PollingDataSource)ds).initialDelayMillis); } - @Test - public void pollingDataSourceHasInitialDelayWhenTransitioningToBackground() { - ClientContext clientContext = makeClientContext(true, false); - DataSource ds = Components.streamingDataSource() - .backgroundPollIntervalMillis(999999) - .build(clientContext); - - assertEquals(PollingDataSource.class, ds.getClass()); - - assertEquals(999999L, ((PollingDataSource)ds).pollIntervalMillis); - assertEquals(999999L, ((PollingDataSource)ds).initialDelayMillis); - } - @Test public void builderCreatesStreamingDataSourceWhenStartingInBackgroundWithOverride() { ClientContext clientContext = makeClientContext(true, null); diff --git a/shared-test-code/src/main/java/com/launchdarkly/sdk/android/TestUtil.java b/shared-test-code/src/main/java/com/launchdarkly/sdk/android/TestUtil.java index 9ed8acbf..5e828f60 100644 --- a/shared-test-code/src/main/java/com/launchdarkly/sdk/android/TestUtil.java +++ b/shared-test-code/src/main/java/com/launchdarkly/sdk/android/TestUtil.java @@ -48,9 +48,9 @@ public static void writeFlagUpdateToStore( ) { PersistentDataStoreWrapper.PerEnvironmentData environmentStore = new PersistentDataStoreWrapper(store, LDLogger.none()).perEnvironmentData(mobileKey); - EnvironmentData data = environmentStore.getContextData(ContextDataManager.hashedContextId(context)); + EnvironmentData data = environmentStore.getContextData(LDUtil.urlSafeBase64HashedContextId(context)); EnvironmentData newData = (data == null ? new EnvironmentData() : data).withFlagUpdatedOrAdded(flag); - environmentStore.setContextData(ContextDataManager.hashedContextId(context), newData); + environmentStore.setContextData(LDUtil.urlSafeBase64HashedContextId(context), LDUtil.urlSafeBase64Hash(context), newData); } public static void doSynchronouslyOnNewThread(Runnable action) {