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 948b56f6..6903168e 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
@@ -207,6 +207,11 @@ public PersistentDataStoreWrapper.PerEnvironmentData getPerEnvironmentData() {
return throwExceptionIfNull(perEnvironmentData);
}
+ @Nullable
+ public PersistentDataStoreWrapper.PerEnvironmentData getPerEnvironmentDataIfAvailable() {
+ return perEnvironmentData;
+ }
+
@Nullable
public TransactionalDataStore getTransactionalDataStore() {
return transactionalDataStore;
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 102fbc27..0245178d 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
@@ -8,7 +8,6 @@
import com.launchdarkly.logging.LDLogger;
import com.launchdarkly.sdk.LDContext;
import com.launchdarkly.sdk.fdv2.ChangeSet;
-import com.launchdarkly.sdk.fdv2.ChangeSetType;
import com.launchdarkly.sdk.android.subsystems.ClientContext;
import com.launchdarkly.sdk.android.subsystems.TransactionalDataStore;
import com.launchdarkly.sdk.android.DataModel.Flag;
@@ -64,17 +63,23 @@ final class ContextDataManager implements TransactionalDataStore {
/** Selector from the last applied changeset that carried one; in-memory only, not persisted. */
@NonNull private Selector currentSelector = Selector.EMPTY;
+ /**
+ * @param skipCacheLoad true when an FDv2 cache initializer will handle loading cached
+ * flags as the first step in the initializer chain, making the
+ * cache load in {@link #switchToContext} redundant
+ */
ContextDataManager(
@NonNull ClientContext clientContext,
@NonNull PersistentDataStoreWrapper.PerEnvironmentData environmentStore,
- int maxCachedContexts
+ int maxCachedContexts,
+ boolean skipCacheLoad
) {
this.environmentStore = environmentStore;
this.index = environmentStore.getIndex();
this.maxCachedContexts = maxCachedContexts;
this.taskExecutor = ClientContextImpl.get(clientContext).getTaskExecutor();
this.logger = clientContext.getBaseLogger();
- switchToContext(clientContext.getEvaluationContext());
+ switchToContext(clientContext.getEvaluationContext(), skipCacheLoad);
}
/**
@@ -83,9 +88,11 @@ final class ContextDataManager implements TransactionalDataStore {
* If the context provided is different than the current state, switches to internally
* stored flag data and notifies flag listeners.
*
- * @param context the to switch to
+ * @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)
*/
- public void switchToContext(@NonNull LDContext context) {
+ public void switchToContext(@NonNull LDContext context, boolean skipCacheLoad) {
synchronized (lock) {
if (context.equals(currentContext)) {
return;
@@ -93,6 +100,10 @@ public void switchToContext(@NonNull LDContext context) {
currentContext = context;
}
+ if (skipCacheLoad) {
+ return;
+ }
+
EnvironmentData storedData = getStoredData(context);
if (storedData == null) {
logger.debug("No stored flag data is available for this context");
diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/DataSourceBuildInputsInternal.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/DataSourceBuildInputsInternal.java
new file mode 100644
index 00000000..1299859b
--- /dev/null
+++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/DataSourceBuildInputsInternal.java
@@ -0,0 +1,75 @@
+package com.launchdarkly.sdk.android;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import com.launchdarkly.logging.LDLogger;
+import com.launchdarkly.sdk.LDContext;
+import com.launchdarkly.sdk.android.interfaces.ServiceEndpoints;
+import com.launchdarkly.sdk.android.subsystems.DataSourceBuildInputs;
+import com.launchdarkly.sdk.android.subsystems.HttpConfiguration;
+
+import java.io.File;
+import java.util.concurrent.ScheduledExecutorService;
+
+/**
+ * Package-private subclass of {@link DataSourceBuildInputs} that carries additional
+ * internal-only dependencies not exposed in the public API.
+ *
+ * This follows the same pattern as {@link ClientContextImpl} extending
+ * {@link com.launchdarkly.sdk.android.subsystems.ClientContext}: the public base class
+ * defines the stable contract for customer-implemented components, while this subclass
+ * adds SDK-internal properties that our built-in components can access via
+ * {@link #get(DataSourceBuildInputs)}.
+ *
+ * This class is for internal SDK use only. It is not subject to any backwards
+ * compatibility guarantees.
+ */
+final class DataSourceBuildInputsInternal extends DataSourceBuildInputs {
+
+ @Nullable
+ private final PersistentDataStoreWrapper.ReadOnlyPerEnvironmentData perEnvironmentData;
+
+ DataSourceBuildInputsInternal(
+ LDContext evaluationContext,
+ ServiceEndpoints serviceEndpoints,
+ HttpConfiguration http,
+ boolean evaluationReasons,
+ SelectorSource selectorSource,
+ ScheduledExecutorService sharedExecutor,
+ @NonNull File cacheDir,
+ LDLogger baseLogger,
+ @Nullable PersistentDataStoreWrapper.ReadOnlyPerEnvironmentData perEnvironmentData
+ ) {
+ super(evaluationContext, serviceEndpoints, http, evaluationReasons,
+ selectorSource, sharedExecutor, cacheDir, baseLogger);
+ this.perEnvironmentData = perEnvironmentData;
+ }
+
+ /**
+ * Unwraps a {@link DataSourceBuildInputs} to obtain the internal subclass.
+ * If the instance is already a {@code DataSourceBuildInputsInternal}, it is
+ * returned directly. Otherwise a wrapper is created with null internal fields.
+ */
+ static DataSourceBuildInputsInternal get(DataSourceBuildInputs inputs) {
+ if (inputs instanceof DataSourceBuildInputsInternal) {
+ return (DataSourceBuildInputsInternal) inputs;
+ }
+ return new DataSourceBuildInputsInternal(
+ inputs.getEvaluationContext(),
+ inputs.getServiceEndpoints(),
+ inputs.getHttp(),
+ inputs.isEvaluationReasons(),
+ inputs.getSelectorSource(),
+ inputs.getSharedExecutor(),
+ inputs.getCacheDir(),
+ inputs.getBaseLogger(),
+ null
+ );
+ }
+
+ @Nullable
+ PersistentDataStoreWrapper.ReadOnlyPerEnvironmentData getPerEnvironmentDataIfAvailable() {
+ return perEnvironmentData;
+ }
+}
diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/DataSystemComponents.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/DataSystemComponents.java
index 0d51e68c..37428330 100644
--- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/DataSystemComponents.java
+++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/DataSystemComponents.java
@@ -10,11 +10,13 @@
import com.launchdarkly.sdk.android.subsystems.DataSourceBuildInputs;
import com.launchdarkly.sdk.android.subsystems.DataSourceBuilder;
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.internal.http.HttpProperties;
import androidx.annotation.NonNull;
+
import java.net.URI;
import java.util.Arrays;
import java.util.Collections;
@@ -138,6 +140,17 @@ public Synchronizer build(DataSourceBuildInputs inputs) {
}
}
+ static final class CacheInitializerBuilderImpl implements DataSourceBuilder, InitializerFromCache {
+ @Override
+ public Initializer build(DataSourceBuildInputs inputs) {
+ return new FDv2CacheInitializer(
+ DataSourceBuildInputsInternal.get(inputs).getPerEnvironmentDataIfAvailable(),
+ inputs.getEvaluationContext(),
+ inputs.getBaseLogger()
+ );
+ }
+ }
+
/**
* Returns a builder for a polling initializer.
*
@@ -188,6 +201,7 @@ public static StreamingSynchronizerBuilder streamingSynchronizer() {
*/
@NonNull
public static Map makeDefaultModeTable() {
+ DataSourceBuilder cacheInitializer = new CacheInitializerBuilderImpl();
DataSourceBuilder pollingInitializer = pollingInitializer();
DataSourceBuilder pollingSynchronizer = pollingSynchronizer();
DataSourceBuilder streamingSynchronizer = streamingSynchronizer();
@@ -202,32 +216,28 @@ public static Map makeDefaultModeTable() {
Map table = new LinkedHashMap<>();
table.put(ConnectionMode.STREAMING, new ModeDefinition(
- // TODO: cacheInitializer — add once implemented
- Arrays.asList(/* cacheInitializer, */ pollingInitializer),
+ Arrays.asList(cacheInitializer, pollingInitializer),
Arrays.asList(streamingSynchronizer, pollingSynchronizer),
fdv1FallbackPollingSynchronizerForeground
));
table.put(ConnectionMode.POLLING, new ModeDefinition(
- // TODO: Arrays.asList(cacheInitializer) — add once implemented
- Collections.>emptyList(),
+ Collections.singletonList(cacheInitializer),
Collections.singletonList(pollingSynchronizer),
fdv1FallbackPollingSynchronizerForeground
));
table.put(ConnectionMode.OFFLINE, new ModeDefinition(
- // TODO: Arrays.asList(cacheInitializer) — add once implemented
- Collections.>emptyList(),
+ Collections.singletonList(cacheInitializer),
Collections.>emptyList(),
null
));
table.put(ConnectionMode.ONE_SHOT, new ModeDefinition(
- // TODO: cacheInitializer and streamingInitializer — add once implemented
- Arrays.asList(/* cacheInitializer, */ pollingInitializer /*, streamingInitializer, */),
+ // TODO: streamingInitializer — add once implemented
+ Arrays.asList(cacheInitializer, pollingInitializer /*, streamingInitializer */),
Collections.>emptyList(),
null
));
table.put(ConnectionMode.BACKGROUND, new ModeDefinition(
- // TODO: Arrays.asList(cacheInitializer) — add once implemented
- Collections.>emptyList(),
+ Collections.singletonList(cacheInitializer),
Collections.singletonList(backgroundPollingSynchronizer),
fdv1FallbackPollingSynchronizerBackground
));
diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2CacheInitializer.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2CacheInitializer.java
new file mode 100644
index 00000000..e8ab3e63
--- /dev/null
+++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2CacheInitializer.java
@@ -0,0 +1,106 @@
+package com.launchdarkly.sdk.android;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import com.launchdarkly.logging.LDLogger;
+import com.launchdarkly.sdk.LDContext;
+import com.launchdarkly.sdk.android.DataModel.Flag;
+import com.launchdarkly.sdk.android.subsystems.FDv2SourceResult;
+import com.launchdarkly.sdk.android.subsystems.Initializer;
+import com.launchdarkly.sdk.fdv2.ChangeSet;
+import com.launchdarkly.sdk.fdv2.ChangeSetType;
+import com.launchdarkly.sdk.fdv2.Selector;
+
+import java.util.Collections;
+import java.util.Map;
+import java.util.concurrent.Future;
+
+/**
+ * FDv2 cache initializer: loads persisted flag data from the local cache.
+ *
+ * Per CONNMODE 4.1.2, a cache hit returns data with {@code persist=false} and
+ * {@link Selector#EMPTY} (no selector).
+ *
+ * All non-hit outcomes — cache miss, missing persistent store, and exceptions during
+ * cache read — are returned as a {@link ChangeSetType#None} changeset, signaling
+ * "no data available" rather than an error. A corrupt or unreadable cache is
+ * semantically equivalent to an empty cache: neither provides usable data.
+ *
+ * The cache read runs synchronously on the caller's thread because the underlying
+ * {@code SharedPreferences} access is fast enough that executor dispatch overhead
+ * would dominate the total time.
+ */
+final class FDv2CacheInitializer implements Initializer {
+
+ @Nullable
+ private final PersistentDataStoreWrapper.ReadOnlyPerEnvironmentData envData;
+ private final LDContext context;
+ private final LDLogger logger;
+
+ FDv2CacheInitializer(
+ @Nullable PersistentDataStoreWrapper.ReadOnlyPerEnvironmentData envData,
+ @NonNull LDContext context,
+ @NonNull LDLogger logger
+ ) {
+ this.envData = envData;
+ this.context = context;
+ this.logger = logger;
+ }
+
+ @Override
+ @NonNull
+ public Future run() {
+ FDv2SourceResult result;
+ try {
+ if (envData == null) {
+ logger.debug("No persistent store configured; skipping cache");
+ result = FDv2SourceResult.changeSet(new ChangeSet<>(
+ ChangeSetType.None,
+ Selector.EMPTY,
+ Collections.emptyMap(),
+ null,
+ false), false);
+ } else {
+ String hashedContextId = LDUtil.urlSafeBase64HashedContextId(context);
+ EnvironmentData stored = envData.getContextData(hashedContextId);
+ if (stored == null) {
+ logger.debug("Cache miss for context");
+ result = FDv2SourceResult.changeSet(new ChangeSet<>(
+ ChangeSetType.None,
+ Selector.EMPTY,
+ Collections.emptyMap(),
+ null,
+ false), false);
+ } else {
+ Map flags = stored.getAll();
+ ChangeSet