diff --git a/android-sdk-framework/build.gradle b/android-sdk-framework/build.gradle
new file mode 100644
index 0000000..a4d789b
--- /dev/null
+++ b/android-sdk-framework/build.gradle
@@ -0,0 +1,154 @@
+plugins {
+ id 'com.android.library'
+ id 'maven-publish'
+ id "com.vanniktech.maven.publish" version "0.32.0"
+ id 'signing'
+ id "com.diffplug.spotless" version "8.0.0"
+}
+
+group = "cloud.eppo"
+version = "0.1.0"
+
+android {
+ namespace "cloud.eppo.android.framework"
+ compileSdk 34
+
+ buildFeatures.buildConfig true
+
+ defaultConfig {
+ minSdk 26
+ targetSdk 34
+ testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
+ }
+
+ buildTypes {
+ def FRAMEWORK_VERSION = "FRAMEWORK_VERSION"
+ def EPPO_VERSION = "EPPO_VERSION"
+ release {
+ minifyEnabled false
+ buildConfigField "String", FRAMEWORK_VERSION, "\"${project.version}\""
+ buildConfigField "String", EPPO_VERSION, "\"${project.version}\""
+ }
+ debug {
+ minifyEnabled false
+ buildConfigField "String", FRAMEWORK_VERSION, "\"${project.version}\""
+ buildConfigField "String", EPPO_VERSION, "\"${project.version}\""
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_1_8
+ targetCompatibility JavaVersion.VERSION_1_8
+ }
+
+ testOptions {
+ unitTests.returnDefaultValues = true
+ }
+}
+
+dependencies {
+ api 'cloud.eppo:eppo-sdk-framework:0.1.0-SNAPSHOT'
+
+ api 'com.google.code.gson:gson:2.10.1'
+ api 'org.slf4j:slf4j-android:1.7.36'
+ compileOnly 'org.jetbrains:annotations:24.0.0'
+
+ testImplementation 'cloud.eppo:sdk-common-jvm:4.0.0-SNAPSHOT'
+ testImplementation 'junit:junit:4.13.2'
+ testImplementation 'org.mockito:mockito-core:5.14.2'
+ testImplementation 'org.robolectric:robolectric:4.12.1'
+
+ androidTestImplementation 'junit:junit:4.13.2'
+ androidTestImplementation 'org.mockito:mockito-android:5.14.2'
+ androidTestImplementation 'androidx.test.ext:junit:1.2.1'
+ androidTestImplementation 'androidx.test:core:1.6.1'
+ androidTestImplementation 'androidx.test:runner:1.6.2'
+ androidTestImplementation 'com.fasterxml.jackson.core:jackson-databind:2.19.1'
+}
+
+spotless {
+ format 'misc', {
+ target '*.gradle', '.gitattributes', '.gitignore'
+
+ trimTrailingWhitespace()
+ leadingTabsToSpaces(2)
+ endWithNewline()
+ }
+ java {
+ target '**/*.java'
+
+ googleJavaFormat()
+ formatAnnotations()
+ }
+}
+
+signing {
+ if (System.getenv("GPG_PRIVATE_KEY") && System.getenv("GPG_PASSPHRASE")) {
+ useInMemoryPgpKeys(System.env.GPG_PRIVATE_KEY, System.env.GPG_PASSPHRASE)
+ }
+
+ sign publishing.publications
+}
+
+tasks.withType(Sign) {
+ onlyIf {
+ (System.getenv("GPG_PRIVATE_KEY") && System.getenv("GPG_PASSPHRASE")) ||
+ (project.hasProperty('signing.keyId') &&
+ project.hasProperty('signing.password') &&
+ project.hasProperty('signing.secretKeyRingFile'))
+ }
+}
+
+mavenPublishing {
+ publishToMavenCentral(com.vanniktech.maven.publish.SonatypeHost.CENTRAL_PORTAL)
+ signAllPublications()
+ coordinates("cloud.eppo", "android-sdk-framework", project.version)
+
+ pom {
+ name = 'Eppo Android SDK Framework'
+ description = 'Android SDK Framework for Eppo - Library-independent EppoClient and PrecomputedEppoClient (abstracts JSON, HTTP, storage)'
+ url = 'https://github.com/Eppo-exp/android-sdk'
+ licenses {
+ license {
+ name = 'MIT License'
+ url = 'http://www.opensource.org/licenses/mit-license.php'
+ }
+ }
+ developers {
+ developer {
+ name = 'Eppo'
+ email = 'https://www.geteppo.com'
+ }
+ }
+ scm {
+ connection = 'scm:git:git://github.com/Eppo-exp/android-sdk.git'
+ developerConnection = 'scm:git:ssh://github.com/Eppo-exp/android-sdk.git'
+ url = 'https://github.com/Eppo-exp/android-sdk/tree/main'
+ }
+ }
+}
+
+task checkVersion {
+ doLast {
+ if (!project.hasProperty('release') && !project.hasProperty('snapshot')) {
+ throw new GradleException("You must specify either -Prelease or -Psnapshot")
+ }
+ if (project.hasProperty('release') && project.version.endsWith('SNAPSHOT')) {
+ throw new GradleException("You cannot specify -Prelease with a SNAPSHOT version")
+ }
+ if (project.hasProperty('snapshot') && !project.version.endsWith('SNAPSHOT')) {
+ throw new GradleException("You cannot specify -Psnapshot with a non-SNAPSHOT version")
+ }
+ project.ext.shouldPublish = true
+ }
+}
+
+tasks.named('publish').configure {
+ dependsOn checkVersion
+}
+
+tasks.withType(PublishToMavenRepository) {
+ onlyIf {
+ project.ext.has('shouldPublish') && project.ext.shouldPublish
+ }
+}
diff --git a/android-sdk-framework/src/androidTest/java/cloud/eppo/android/framework/EppoClientPollingTest.java b/android-sdk-framework/src/androidTest/java/cloud/eppo/android/framework/EppoClientPollingTest.java
new file mode 100644
index 0000000..3c09851
--- /dev/null
+++ b/android-sdk-framework/src/androidTest/java/cloud/eppo/android/framework/EppoClientPollingTest.java
@@ -0,0 +1,217 @@
+package cloud.eppo.android.framework;
+
+import static cloud.eppo.android.framework.util.Utils.logTag;
+import static org.junit.Assert.assertNotNull;
+
+import android.util.Log;
+import androidx.test.core.app.ApplicationProvider;
+import cloud.eppo.api.Configuration;
+import cloud.eppo.http.EppoConfigurationClient;
+import cloud.eppo.parser.ConfigurationParser;
+import com.fasterxml.jackson.databind.JsonNode;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ExecutionException;
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+/**
+ * Tests for EppoClient polling pause/resume functionality.
+ *
+ *
These tests use offline mode to avoid needing to mock complex configuration loading behavior.
+ * They focus on verifying that pausePolling() and resumePolling() can be called safely in various
+ * sequences.
+ */
+public class EppoClientPollingTest {
+ private static final String TAG = logTag(EppoClientPollingTest.class);
+ private static final String DUMMY_API_KEY = "mock-api-key";
+
+ @Mock private ConfigurationParser mockConfigParser;
+ @Mock private EppoConfigurationClient mockConfigClient;
+
+ @Before
+ public void setUp() {
+ MockitoAnnotations.openMocks(this);
+ }
+
+ /**
+ * Builds a client in offline mode with polling enabled.
+ *
+ * @param pollingIntervalMs Polling interval in milliseconds
+ * @return Initialized EppoClient
+ */
+ private AndroidBaseClient buildOfflineClientWithPolling(long pollingIntervalMs)
+ throws ExecutionException, InterruptedException {
+ // Use an empty configuration for offline mode
+ CompletableFuture initialConfig =
+ CompletableFuture.completedFuture(Configuration.emptyConfig());
+
+ return new AndroidBaseClient.Builder<>(
+ DUMMY_API_KEY,
+ ApplicationProvider.getApplicationContext(),
+ mockConfigParser,
+ mockConfigClient)
+ .forceReinitialize(true)
+ .offlineMode(true)
+ .initialConfiguration(initialConfig)
+ .pollingEnabled(true)
+ .pollingIntervalMs(pollingIntervalMs)
+ .isGracefulMode(true) // Enable graceful mode to handle initialization issues
+ .buildAndInitAsync()
+ .get();
+ }
+
+ /**
+ * Builds a client in offline mode without polling enabled.
+ *
+ * @return Initialized EppoClient
+ */
+ private AndroidBaseClient buildOfflineClientWithoutPolling()
+ throws ExecutionException, InterruptedException {
+ CompletableFuture initialConfig =
+ CompletableFuture.completedFuture(Configuration.emptyConfig());
+
+ return new AndroidBaseClient.Builder<>(
+ DUMMY_API_KEY,
+ ApplicationProvider.getApplicationContext(),
+ mockConfigParser,
+ mockConfigClient)
+ .forceReinitialize(true)
+ .offlineMode(true)
+ .initialConfiguration(initialConfig)
+ .pollingEnabled(false)
+ .isGracefulMode(true) // Enable graceful mode to handle initialization issues
+ .buildAndInitAsync()
+ .get();
+ }
+
+ @Test
+ public void testPauseAndResumePolling() throws ExecutionException, InterruptedException {
+ AndroidBaseClient androidBaseClient = buildOfflineClientWithPolling(100);
+ assertNotNull("Client should be initialized", androidBaseClient);
+
+ // Test pause
+ androidBaseClient.pausePolling();
+ Log.d(TAG, "Polling paused");
+
+ // Wait a bit to ensure no crashes
+ Thread.sleep(50);
+
+ // Test resume
+ androidBaseClient.resumePolling();
+ Log.d(TAG, "Polling resumed");
+
+ // Wait a bit to ensure no crashes
+ Thread.sleep(50);
+
+ // Final pause for cleanup
+ androidBaseClient.pausePolling();
+ }
+
+ @Test
+ public void testResumePollingWithoutStarting() throws ExecutionException, InterruptedException {
+ AndroidBaseClient androidBaseClient = buildOfflineClientWithoutPolling();
+ assertNotNull("Client should be initialized", androidBaseClient);
+
+ // Try to resume polling (should log warning and not crash per EppoClient.java:436-441)
+ androidBaseClient.resumePolling();
+ Log.d(TAG, "Resume called without starting - should log warning");
+
+ // Wait a bit to ensure no crashes
+ Thread.sleep(50);
+
+ // Should not crash or throw exception
+ }
+
+ @Test
+ public void testMultiplePauseResumeCycles() throws ExecutionException, InterruptedException {
+ AndroidBaseClient androidBaseClient = buildOfflineClientWithPolling(100);
+ assertNotNull("Client should be initialized", androidBaseClient);
+
+ // First cycle
+ androidBaseClient.pausePolling();
+ Log.d(TAG, "First pause");
+ Thread.sleep(50);
+ androidBaseClient.resumePolling();
+ Log.d(TAG, "First resume");
+ Thread.sleep(50);
+
+ // Second cycle
+ androidBaseClient.pausePolling();
+ Log.d(TAG, "Second pause");
+ Thread.sleep(50);
+ androidBaseClient.resumePolling();
+ Log.d(TAG, "Second resume");
+ Thread.sleep(50);
+
+ // Third cycle
+ androidBaseClient.pausePolling();
+ Log.d(TAG, "Third pause");
+ Thread.sleep(50);
+ androidBaseClient.resumePolling();
+ Log.d(TAG, "Third resume");
+ Thread.sleep(50);
+
+ // Final cleanup
+ androidBaseClient.pausePolling();
+ }
+
+ @Test
+ public void testPauseResumeSequenceDoesNotCrash()
+ throws ExecutionException, InterruptedException {
+ AndroidBaseClient androidBaseClient = buildOfflineClientWithPolling(50);
+
+ // Various sequences that should all work without crashing
+ androidBaseClient.pausePolling();
+ androidBaseClient.pausePolling(); // Double pause
+ Thread.sleep(50);
+
+ androidBaseClient.resumePolling();
+ Thread.sleep(50);
+
+ androidBaseClient.resumePolling(); // Double resume
+ Thread.sleep(50);
+
+ androidBaseClient.pausePolling();
+ androidBaseClient.resumePolling();
+ Thread.sleep(50);
+
+ androidBaseClient.pausePolling(); // Final pause for cleanup
+ }
+
+ @Test
+ public void testPollingNotEnabledAndResume() throws ExecutionException, InterruptedException {
+ AndroidBaseClient androidBaseClient = buildOfflineClientWithoutPolling();
+
+ // Pause should be safe even if not polling
+ androidBaseClient.pausePolling();
+ Thread.sleep(50);
+
+ // Resume should log warning per EppoClient.java:436-441
+ androidBaseClient.resumePolling();
+ Thread.sleep(50);
+
+ // Multiple calls should all be safe
+ androidBaseClient.pausePolling();
+ androidBaseClient.resumePolling();
+ Thread.sleep(50);
+ }
+
+ @Test
+ public void testPauseAfterInitDoesNotCrash() throws ExecutionException, InterruptedException {
+ AndroidBaseClient androidBaseClient = buildOfflineClientWithPolling(100);
+
+ // Immediately pause after initialization
+ androidBaseClient.pausePolling();
+ Log.d(TAG, "Paused immediately after init");
+ Thread.sleep(200);
+
+ // Resume
+ androidBaseClient.resumePolling();
+ Thread.sleep(200);
+
+ // Final pause
+ androidBaseClient.pausePolling();
+ }
+}
diff --git a/android-sdk-framework/src/main/AndroidManifest.xml b/android-sdk-framework/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..b2d3ea1
--- /dev/null
+++ b/android-sdk-framework/src/main/AndroidManifest.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/android-sdk-framework/src/main/java/cloud/eppo/android/framework/AndroidBaseClient.java b/android-sdk-framework/src/main/java/cloud/eppo/android/framework/AndroidBaseClient.java
new file mode 100644
index 0000000..83753f6
--- /dev/null
+++ b/android-sdk-framework/src/main/java/cloud/eppo/android/framework/AndroidBaseClient.java
@@ -0,0 +1,421 @@
+package cloud.eppo.android.framework;
+
+import static cloud.eppo.android.framework.util.Utils.logTag;
+import static cloud.eppo.android.framework.util.Utils.safeCacheKey;
+
+import android.app.Application;
+import android.util.Log;
+import cloud.eppo.BaseEppoClient;
+import cloud.eppo.android.framework.exceptions.EppoInitializationException;
+import cloud.eppo.android.framework.exceptions.NotInitializedException;
+import cloud.eppo.android.framework.storage.CachingConfigurationStore;
+import cloud.eppo.android.framework.storage.ConfigurationCodec;
+import cloud.eppo.android.framework.storage.FileBackedConfigStore;
+import cloud.eppo.api.Configuration;
+import cloud.eppo.api.IAssignmentCache;
+import cloud.eppo.http.EppoConfigurationClient;
+import cloud.eppo.logging.AssignmentLogger;
+import cloud.eppo.parser.ConfigurationParser;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionException;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.Consumer;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+/**
+ * Generic EppoClient that extends BaseEppoClient with JSON type parameter.
+ *
+ * Requires callers to provide implementations of ConfigurationParser and
+ * EppoConfigurationClient.
+ *
+ * @param The JSON type used for JSON flag values (e.g., JsonNode, JsonElement)
+ */
+public class AndroidBaseClient extends BaseEppoClient {
+ private static final String TAG = logTag(AndroidBaseClient.class);
+ private static final boolean DEFAULT_IS_GRACEFUL_MODE = true;
+ private static final boolean DEFAULT_OBFUSCATE_CONFIG = true;
+ private static final long DEFAULT_POLLING_INTERVAL_MS = 5 * 60 * 1000;
+ private static final long DEFAULT_JITTER_INTERVAL_RATIO = 10;
+
+ private long pollingIntervalMs;
+ private long pollingJitterMs;
+
+ @Nullable private static AndroidBaseClient> instance;
+
+ /**
+ * Private constructor. Use Builder to construct instances.
+ *
+ * @param apiKey API key for Eppo
+ * @param sdkName SDK name identifier
+ * @param sdkVersion SDK version string
+ * @param apiBaseUrl Base URL for API calls
+ * @param assignmentLogger Logger for assignments
+ * @param configurationStore Store for configuration persistence
+ * @param isGracefulMode Whether to operate in graceful mode
+ * @param expectObfuscatedConfig Whether configuration is obfuscated
+ * @param initialConfiguration Initial configuration future
+ * @param assignmentCache Cache for assignments
+ * @param configurationParser Parser for configuration JSON
+ * @param configurationClient HTTP client for configuration fetching
+ */
+ protected AndroidBaseClient(
+ String apiKey,
+ String sdkName,
+ String sdkVersion,
+ @Nullable String apiBaseUrl,
+ @Nullable AssignmentLogger assignmentLogger,
+ CachingConfigurationStore configurationStore,
+ boolean isGracefulMode,
+ boolean expectObfuscatedConfig,
+ @Nullable CompletableFuture initialConfiguration,
+ @Nullable IAssignmentCache assignmentCache,
+ ConfigurationParser configurationParser,
+ EppoConfigurationClient configurationClient) {
+ super(
+ apiKey,
+ sdkName,
+ sdkVersion,
+ apiBaseUrl,
+ assignmentLogger,
+ null, // banditLogger is not supported in Android
+ configurationStore,
+ isGracefulMode,
+ expectObfuscatedConfig,
+ false, // no bandits.
+ initialConfiguration,
+ assignmentCache,
+ null,
+ configurationParser,
+ configurationClient);
+ }
+
+ /**
+ * Gets the singleton instance of EppoClient.
+ *
+ * @return The singleton instance
+ * @throws NotInitializedException if the client has not been initialized
+ * @param The JSON type parameter
+ */
+ @SuppressWarnings("unchecked")
+ public static AndroidBaseClient getInstance() throws NotInitializedException {
+ if (instance == null) {
+ throw new NotInitializedException();
+ }
+ return (AndroidBaseClient) instance;
+ }
+
+ /**
+ * Builder for constructing and initializing EppoClient instances.
+ *
+ * This is the only way to create an EppoClient. The Builder is generic on JsonFlagType and
+ * builds an EppoClient with the same type parameter.
+ *
+ * @param The JSON type used for JSON flag values
+ */
+ public static class Builder {
+ // Required parameters
+ private final String apiKey;
+ private final Application application;
+ private final ConfigurationParser configurationParser;
+ private final EppoConfigurationClient configurationClient;
+
+ // Optional parameters with defaults
+ @Nullable private String apiBaseUrl;
+ @Nullable private AssignmentLogger assignmentLogger;
+ @Nullable private CachingConfigurationStore configStore;
+ private boolean isGracefulMode = DEFAULT_IS_GRACEFUL_MODE;
+ private boolean obfuscateConfig = DEFAULT_OBFUSCATE_CONFIG;
+ private boolean forceReinitialize = false;
+ private boolean offlineMode = false;
+ @Nullable private CompletableFuture initialConfiguration;
+ private boolean ignoreCachedConfiguration = false;
+ private boolean pollingEnabled = false;
+ private long pollingIntervalMs = DEFAULT_POLLING_INTERVAL_MS;
+ private long pollingJitterMs = -1;
+ @Nullable private IAssignmentCache assignmentCache;
+ @Nullable private Consumer configChangeCallback;
+
+ /**
+ * Creates a new Builder with required parameters.
+ *
+ * @param apiKey API key for Eppo (required)
+ * @param application Application context (required)
+ * @param configurationParser Parser for configuration JSON (required)
+ * @param configurationClient HTTP client for configuration fetching (required)
+ */
+ public Builder(
+ @NotNull String apiKey,
+ @NotNull Application application,
+ @NotNull ConfigurationParser configurationParser,
+ @NotNull EppoConfigurationClient configurationClient) {
+ this.apiKey = apiKey;
+ this.application = application;
+ this.configurationParser = configurationParser;
+ this.configurationClient = configurationClient;
+ }
+
+ public Builder apiBaseUrl(@Nullable String apiBaseUrl) {
+ this.apiBaseUrl = apiBaseUrl;
+ return this;
+ }
+
+ public Builder assignmentLogger(@Nullable AssignmentLogger assignmentLogger) {
+ this.assignmentLogger = assignmentLogger;
+ return this;
+ }
+
+ public Builder configStore(@Nullable CachingConfigurationStore configStore) {
+ this.configStore = configStore;
+ return this;
+ }
+
+ public Builder isGracefulMode(boolean isGracefulMode) {
+ this.isGracefulMode = isGracefulMode;
+ return this;
+ }
+
+ public Builder obfuscateConfig(boolean obfuscateConfig) {
+ this.obfuscateConfig = obfuscateConfig;
+ return this;
+ }
+
+ public Builder forceReinitialize(boolean forceReinitialize) {
+ this.forceReinitialize = forceReinitialize;
+ return this;
+ }
+
+ public Builder offlineMode(boolean offlineMode) {
+ this.offlineMode = offlineMode;
+ return this;
+ }
+
+ public Builder initialConfiguration(
+ @Nullable CompletableFuture initialConfiguration) {
+ this.initialConfiguration = initialConfiguration;
+ return this;
+ }
+
+ public Builder ignoreCachedConfiguration(boolean ignoreCache) {
+ this.ignoreCachedConfiguration = ignoreCache;
+ return this;
+ }
+
+ public Builder pollingEnabled(boolean pollingEnabled) {
+ this.pollingEnabled = pollingEnabled;
+ return this;
+ }
+
+ public Builder pollingIntervalMs(long pollingIntervalMs) {
+ this.pollingIntervalMs = pollingIntervalMs;
+ return this;
+ }
+
+ public Builder pollingJitterMs(long pollingJitterMs) {
+ this.pollingJitterMs = pollingJitterMs;
+ return this;
+ }
+
+ public Builder assignmentCache(@Nullable IAssignmentCache assignmentCache) {
+ this.assignmentCache = assignmentCache;
+ return this;
+ }
+
+ public Builder onConfigurationChange(
+ @Nullable Consumer configChangeCallback) {
+ this.configChangeCallback = configChangeCallback;
+ return this;
+ }
+
+ /**
+ * Builds and initializes the EppoClient asynchronously.
+ *
+ * This method performs the full initialization flow:
+ *
+ *
+ * - Validates required fields
+ *
- Handles singleton/reinitialize logic
+ *
- Loads initial configuration from cache if needed
+ *
- Constructs the client
+ *
- Fetches configuration if not in offline mode
+ *
- Starts polling if enabled
+ *
- Returns a CompletableFuture that completes when initialization is done
+ *
+ *
+ * @return CompletableFuture that completes with the initialized EppoClient
+ */
+ public CompletableFuture> buildAndInitAsync() {
+ // Singleton handling
+ if (instance != null && !forceReinitialize) {
+ Log.w(TAG, "Eppo Client instance already initialized");
+ @SuppressWarnings("unchecked")
+ AndroidBaseClient typedInstance = (AndroidBaseClient) instance;
+ return CompletableFuture.completedFuture(typedInstance);
+ } else if (instance != null) {
+ // Stop polling if reinitializing
+ instance.stopPolling();
+ Log.i(TAG, "forceReinitialize triggered - reinitializing Eppo Client");
+ }
+
+ String sdkName = obfuscateConfig ? "android" : "android-debug";
+ String sdkVersion = BuildConfig.EPPO_VERSION;
+
+ if (configStore == null) {
+ configStore =
+ new FileBackedConfigStore(
+ application,
+ safeCacheKey(apiKey),
+ new ConfigurationCodec.Default<>(Configuration.class));
+ }
+
+ // Use the persisted cache as the initial configuration if none was explicitly provided.
+ if (initialConfiguration == null && !ignoreCachedConfiguration) {
+ initialConfiguration = configStore.loadFromStorage();
+ }
+
+ // Construct the client
+ AndroidBaseClient newInstance =
+ new AndroidBaseClient<>(
+ apiKey,
+ sdkName,
+ sdkVersion,
+ apiBaseUrl,
+ assignmentLogger,
+ configStore,
+ isGracefulMode,
+ obfuscateConfig,
+ initialConfiguration,
+ assignmentCache,
+ configurationParser,
+ configurationClient);
+
+ // Set as singleton
+ instance = newInstance;
+
+ // Register config change callback if provided
+ if (configChangeCallback != null) {
+ newInstance.onConfigurationChange(configChangeCallback);
+ }
+
+ final CompletableFuture> ret = new CompletableFuture<>();
+ AtomicInteger failCount = new AtomicInteger(0);
+
+ if (!offlineMode) {
+ newInstance
+ .loadConfigurationAsync()
+ .handle(
+ (success, ex) -> {
+ if (ex == null) {
+ ret.complete(newInstance);
+ } else if (failCount.incrementAndGet() == 2
+ || newInstance.getInitialConfigFuture() == null) {
+ ret.completeExceptionally(
+ new EppoInitializationException(
+ "Unable to initialize client; Configuration could not be loaded", ex));
+ }
+ return null;
+ });
+ }
+
+ // Start polling if configured
+ if (pollingEnabled && pollingIntervalMs > 0) {
+ Log.i(TAG, "Starting poller");
+ long effectiveJitter = pollingJitterMs;
+ if (effectiveJitter < 0) {
+ effectiveJitter = pollingIntervalMs / DEFAULT_JITTER_INTERVAL_RATIO;
+ }
+
+ newInstance.startPolling(pollingIntervalMs, effectiveJitter);
+ }
+
+ if (newInstance.getInitialConfigFuture() != null) {
+ newInstance
+ .getInitialConfigFuture()
+ .handle(
+ (success, ex) -> {
+ if (ex == null && Boolean.TRUE.equals(success)) {
+ ret.complete(newInstance);
+ } else if (offlineMode || failCount.incrementAndGet() == 2) {
+ ret.completeExceptionally(
+ new EppoInitializationException(
+ "Unable to initialize client; Configuration could not be loaded", ex));
+ } else {
+ Log.i(TAG, "Initial config was not used.");
+ failCount.incrementAndGet();
+ }
+ return null;
+ });
+ } else if (offlineMode) {
+ ret.complete(newInstance);
+ }
+
+ return ret.exceptionally(
+ e -> {
+ Log.e(TAG, "Exception caught during initialization: " + e.getMessage(), e);
+ if (!isGracefulMode) {
+ throw new RuntimeException(e);
+ }
+ return newInstance;
+ });
+ }
+
+ /**
+ * Builds and initializes the EppoClient synchronously (blocking).
+ *
+ * This is a blocking wrapper around buildAndInitAsync().
+ *
+ * @return The initialized EppoClient
+ */
+ public AndroidBaseClient buildAndInit() {
+ try {
+ return buildAndInitAsync().get();
+ } catch (ExecutionException | InterruptedException | CompletionException e) {
+ // If the exception was an `EppoInitializationException`, we know for sure that
+ // `buildAndInitAsync` logged it (and wrapped it with a RuntimeException) which was then
+ // wrapped by `CompletableFuture` with a `CompletionException`.
+ if (e instanceof CompletionException) {
+ Throwable cause = e.getCause();
+ if (cause instanceof RuntimeException
+ && cause.getCause() instanceof EppoInitializationException) {
+ @SuppressWarnings("unchecked")
+ AndroidBaseClient typedInstance =
+ (AndroidBaseClient) instance;
+ return typedInstance;
+ }
+ }
+ Log.e(TAG, "Exception caught during initialization: " + e.getMessage(), e);
+ if (!isGracefulMode) {
+ throw new RuntimeException(e);
+ }
+ }
+ @SuppressWarnings("unchecked")
+ AndroidBaseClient typedInstance = (AndroidBaseClient) instance;
+ return typedInstance;
+ }
+ }
+
+ /**
+ * Pauses polling for configuration updates.
+ *
+ * Can be resumed later with resumePolling().
+ */
+ public void pausePolling() {
+ super.stopPolling();
+ }
+
+ /**
+ * Resumes polling for configuration updates.
+ *
+ *
Only works if polling was previously started via Builder.
+ */
+ public void resumePolling() {
+ if (pollingIntervalMs <= 0) {
+ Log.w(
+ TAG,
+ "resumePolling called, but polling was not started due to invalid polling interval.");
+ return;
+ }
+ super.startPolling(pollingIntervalMs, pollingJitterMs);
+ }
+}
diff --git a/android-sdk-framework/src/main/java/cloud/eppo/android/framework/exceptions/EppoInitializationException.java b/android-sdk-framework/src/main/java/cloud/eppo/android/framework/exceptions/EppoInitializationException.java
new file mode 100644
index 0000000..8300fa3
--- /dev/null
+++ b/android-sdk-framework/src/main/java/cloud/eppo/android/framework/exceptions/EppoInitializationException.java
@@ -0,0 +1,7 @@
+package cloud.eppo.android.framework.exceptions;
+
+public class EppoInitializationException extends Exception {
+ public EppoInitializationException(String s, Throwable ex) {
+ super(s, ex);
+ }
+}
diff --git a/android-sdk-framework/src/main/java/cloud/eppo/android/framework/exceptions/NotInitializedException.java b/android-sdk-framework/src/main/java/cloud/eppo/android/framework/exceptions/NotInitializedException.java
new file mode 100644
index 0000000..cc8566b
--- /dev/null
+++ b/android-sdk-framework/src/main/java/cloud/eppo/android/framework/exceptions/NotInitializedException.java
@@ -0,0 +1,7 @@
+package cloud.eppo.android.framework.exceptions;
+
+public class NotInitializedException extends RuntimeException {
+ public NotInitializedException() {
+ super("Eppo client is not initialized");
+ }
+}
diff --git a/android-sdk-framework/src/main/java/cloud/eppo/android/framework/storage/BaseCacheFile.java b/android-sdk-framework/src/main/java/cloud/eppo/android/framework/storage/BaseCacheFile.java
new file mode 100644
index 0000000..1bbf349
--- /dev/null
+++ b/android-sdk-framework/src/main/java/cloud/eppo/android/framework/storage/BaseCacheFile.java
@@ -0,0 +1,67 @@
+package cloud.eppo.android.framework.storage;
+
+import android.app.Application;
+import java.io.BufferedReader;
+import java.io.BufferedWriter;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileNotFoundException;
+import java.io.FileOutputStream;
+import java.io.FileReader;
+import java.io.FileWriter;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+
+/** Base class for disk cache files. */
+public class BaseCacheFile {
+ private final File cacheFile;
+
+ protected BaseCacheFile(Application application, String fileName) {
+ File filesDir = application.getFilesDir();
+ cacheFile = new File(filesDir, fileName);
+ }
+
+ public boolean exists() {
+ return cacheFile.exists();
+ }
+
+ /**
+ * @noinspection ResultOfMethodCallIgnored
+ */
+ public void delete() {
+ if (cacheFile.exists()) {
+ cacheFile.delete();
+ }
+ }
+
+ /** Useful for passing in as a writer for JSON serialization. */
+ public BufferedWriter getWriter() throws IOException {
+ return new BufferedWriter(new FileWriter(cacheFile));
+ }
+
+ public OutputStream getOutputStream() throws FileNotFoundException {
+ return new FileOutputStream(cacheFile);
+ }
+
+ public InputStream getInputStream() throws FileNotFoundException {
+ return new FileInputStream(cacheFile);
+ }
+
+ /** Useful for passing in as a reader for JSON deserialization. */
+ public BufferedReader getReader() throws IOException {
+ return new BufferedReader(new FileReader(cacheFile));
+ }
+
+ /** Useful for mocking caches in automated tests. */
+ public void setContents(String contents) {
+ delete();
+ try {
+ BufferedWriter writer = getWriter();
+ writer.write(contents);
+ writer.close();
+ } catch (IOException ex) {
+ throw new RuntimeException(ex);
+ }
+ }
+}
diff --git a/android-sdk-framework/src/main/java/cloud/eppo/android/framework/storage/ByteStore.java b/android-sdk-framework/src/main/java/cloud/eppo/android/framework/storage/ByteStore.java
new file mode 100644
index 0000000..d719536
--- /dev/null
+++ b/android-sdk-framework/src/main/java/cloud/eppo/android/framework/storage/ByteStore.java
@@ -0,0 +1,32 @@
+package cloud.eppo.android.framework.storage;
+
+import java.util.concurrent.CompletableFuture;
+import org.jetbrains.annotations.NotNull;
+
+/**
+ * Abstraction for asynchronous byte-level I/O operations.
+ *
+ *
Implementations handle reading and writing raw bytes to/from persistent storage. This
+ * interface is agnostic of serialization format and storage medium.
+ */
+public interface ByteStore {
+
+ /**
+ * Reads bytes from storage asynchronously.
+ *
+ * @return a CompletableFuture that completes with the read bytes, or null if the storage does not
+ * exist
+ * @throws RuntimeException (via CompletableFuture) if an I/O error occurs during read
+ */
+ @NotNull CompletableFuture read();
+
+ /**
+ * Writes bytes to storage asynchronously.
+ *
+ * @param bytes the bytes to write (must not be null)
+ * @return a CompletableFuture that completes when the write operation finishes
+ * @throws IllegalArgumentException if bytes is null
+ * @throws RuntimeException (via CompletableFuture) if an I/O error occurs during write
+ */
+ @NotNull CompletableFuture write(@NotNull byte[] bytes);
+}
diff --git a/android-sdk-framework/src/main/java/cloud/eppo/android/framework/storage/CachingConfigurationStore.java b/android-sdk-framework/src/main/java/cloud/eppo/android/framework/storage/CachingConfigurationStore.java
new file mode 100644
index 0000000..9eea741
--- /dev/null
+++ b/android-sdk-framework/src/main/java/cloud/eppo/android/framework/storage/CachingConfigurationStore.java
@@ -0,0 +1,68 @@
+package cloud.eppo.android.framework.storage;
+
+import cloud.eppo.IConfigurationStore;
+import cloud.eppo.api.Configuration;
+import java.util.concurrent.CompletableFuture;
+import org.jetbrains.annotations.NotNull;
+
+/**
+ * Abstract config store that keeps an in-memory configuration and can persist it via a {@link
+ * ByteStore} and {@link ConfigurationCodec}.
+ */
+public class CachingConfigurationStore implements IConfigurationStore {
+
+ private final ConfigurationCodec codec;
+ private final ByteStore byteStore;
+ private volatile Configuration configuration = Configuration.emptyConfig();
+
+ protected CachingConfigurationStore(
+ @NotNull ConfigurationCodec codec, @NotNull ByteStore byteStore) {
+ this.codec = codec;
+ this.byteStore = byteStore;
+ }
+
+ /** Returns the current in-memory configuration. */
+ @Override
+ @NotNull public Configuration getConfiguration() {
+ return configuration;
+ }
+
+ /**
+ * Saves the configuration to storage and updates the in-memory cache.
+ *
+ * @param config the configuration to save (must not be null)
+ * @return a future that completes when the write finishes
+ * @throws IllegalArgumentException if config is null
+ */
+ @Override
+ @NotNull public CompletableFuture saveConfiguration(@NotNull Configuration config) {
+ if (config == null) {
+ throw new IllegalArgumentException("config must not be null");
+ }
+ byte[] bytes = codec.toBytes(config);
+ return byteStore
+ .write(bytes)
+ .thenRun(
+ () -> {
+ this.configuration = config;
+ });
+ }
+
+ /**
+ * Loads the configuration from storage without updating the in-memory cache.
+ *
+ * @return a future that completes with the loaded configuration, or null if storage is empty or
+ * missing
+ */
+ @NotNull public CompletableFuture loadFromStorage() {
+ return byteStore
+ .read()
+ .thenApply(
+ bytes -> {
+ if (bytes == null || bytes.length == 0) {
+ return null;
+ }
+ return codec.fromBytes(bytes);
+ });
+ }
+}
diff --git a/android-sdk-framework/src/main/java/cloud/eppo/android/framework/storage/ConfigCacheFile.java b/android-sdk-framework/src/main/java/cloud/eppo/android/framework/storage/ConfigCacheFile.java
new file mode 100644
index 0000000..026ee3a
--- /dev/null
+++ b/android-sdk-framework/src/main/java/cloud/eppo/android/framework/storage/ConfigCacheFile.java
@@ -0,0 +1,59 @@
+package cloud.eppo.android.framework.storage;
+
+import android.app.Application;
+import java.util.HashMap;
+import java.util.Map;
+import org.jetbrains.annotations.NotNull;
+
+/** Disk cache file for flag configuration (used by FileBackedConfigStore). */
+public final class ConfigCacheFile extends BaseCacheFile {
+ private static final Map CONTENT_TYPE_TO_EXTENSION = new HashMap<>();
+ private static final String DEFAULT_EXTENSION = "bin";
+
+ static {
+ CONTENT_TYPE_TO_EXTENSION.put("application/json", "json");
+ CONTENT_TYPE_TO_EXTENSION.put("application/x-java-serialized-object", "ser");
+ CONTENT_TYPE_TO_EXTENSION.put("text/plain", "txt");
+ CONTENT_TYPE_TO_EXTENSION.put("text/xml", "xml");
+ CONTENT_TYPE_TO_EXTENSION.put("application/xml", "xml");
+ CONTENT_TYPE_TO_EXTENSION.put("application/octet-stream", "bin");
+ }
+
+ /**
+ * Creates a cache file with filename "eppo-sdk-flags-{suffix}.{ext}". Extension is derived from
+ * contentType.
+ */
+ public ConfigCacheFile(
+ @NotNull Application application, @NotNull String suffix, @NotNull String contentType) {
+ super(
+ application,
+ "eppo-sdk-flags-"
+ + suffix
+ + "."
+ + CONTENT_TYPE_TO_EXTENSION.getOrDefault(contentType, DEFAULT_EXTENSION));
+ }
+
+ /**
+ * Creates a cache file with filename "eppo-sdk-flags-{configType}-{suffix}.{ext}". Used when the
+ * logical suffix is split into config type and suffix (e.g. for FileBackedConfigStore).
+ *
+ * @deprecated Use {@link #ConfigCacheFile(Application, String, String)} instead.
+ */
+ ConfigCacheFile(
+ @NotNull Application application,
+ @NotNull String configType,
+ @NotNull String suffix,
+ @NotNull String contentType) {
+ this(application, configType + "-" + suffix, contentType);
+ }
+
+ /**
+ * Creates a cache file with the given full file name (no prefix). Used when the caller supplies
+ * the complete filename (e.g. baseName + "." + extension).
+ *
+ * @deprecated Use {@link #ConfigCacheFile(Application, String, String)} instead.
+ */
+ ConfigCacheFile(@NotNull Application application, @NotNull String fullFileName) {
+ super(application, fullFileName);
+ }
+}
diff --git a/android-sdk-framework/src/main/java/cloud/eppo/android/framework/storage/ConfigurationCodec.java b/android-sdk-framework/src/main/java/cloud/eppo/android/framework/storage/ConfigurationCodec.java
new file mode 100644
index 0000000..a5ea4d4
--- /dev/null
+++ b/android-sdk-framework/src/main/java/cloud/eppo/android/framework/storage/ConfigurationCodec.java
@@ -0,0 +1,108 @@
+package cloud.eppo.android.framework.storage;
+
+import cloud.eppo.api.SerializableEppoConfiguration;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.ObjectInputStream;
+import java.io.ObjectOutputStream;
+import org.jetbrains.annotations.NotNull;
+
+/**
+ * Interface for serializing and deserializing configurations to and from bytes.
+ *
+ * Used for persisting configurations to storage.
+ *
+ * @param the configuration type, must extend SerializableEppoConfiguration
+ */
+public interface ConfigurationCodec {
+ /**
+ * Serializes a configuration to bytes for storage.
+ *
+ * @param configuration the configuration to serialize
+ * @return serialized bytes (must not be null)
+ * @throws RuntimeException if the configuration cannot be serialized
+ */
+ byte[] toBytes(@NotNull T configuration);
+
+ /**
+ * Deserializes a configuration from bytes produced by {@link #toBytes}.
+ *
+ * @param bytes serialized configuration (must not be null)
+ * @return the deserialized configuration
+ * @throws RuntimeException if the bytes cannot be deserialized to a configuration
+ */
+ @NotNull T fromBytes(byte[] bytes);
+
+ /**
+ * Returns the MIME content type of the serialized form (e.g. {@code
+ * application/x-java-serialized-object}). The codec is agnostic of storage; callers that need a
+ * file extension can map this to one locally.
+ */
+ @NotNull String getContentType();
+
+ /**
+ * Default implementation using Java serialization.
+ *
+ * Security Note: Java serialization is used for local storage. Do not use
+ * this codec to deserialize data from untrusted sources, as Java deserialization has known
+ * security vulnerabilities.
+ *
+ * @param the configuration type, must extend SerializableEppoConfiguration
+ */
+ public static class Default
+ implements ConfigurationCodec {
+ private final Class configClass;
+
+ /**
+ * Creates a default codec for the specified configuration class.
+ *
+ * @param configClass the class of the configuration type
+ */
+ public Default(@NotNull Class configClass) {
+ this.configClass = configClass;
+ }
+
+ @Override
+ public byte[] toBytes(@NotNull T configuration) {
+ if (configuration == null) {
+ throw new IllegalArgumentException("Configuration must not be null");
+ }
+ try (ByteArrayOutputStream baos = new ByteArrayOutputStream();
+ ObjectOutputStream oos = new ObjectOutputStream(baos)) {
+ oos.writeObject(configuration);
+ return baos.toByteArray();
+ } catch (IOException e) {
+ throw new RuntimeException("Failed to serialize configuration", e);
+ }
+ }
+
+ @Override
+ @SuppressWarnings("unchecked") // Safe cast - verified by configClass.isInstance() check
+ public @NotNull T fromBytes(byte[] bytes) {
+ if (bytes == null) {
+ throw new IllegalArgumentException("Bytes must not be null");
+ }
+ try (ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(bytes))) {
+ Object obj = ois.readObject();
+ if (!configClass.isInstance(obj)) {
+ throw new RuntimeException(
+ "Deserialized object is not a "
+ + configClass.getSimpleName()
+ + ": "
+ + obj.getClass().getName());
+ }
+ return (T) obj;
+ } catch (IOException e) {
+ throw new RuntimeException("Failed to deserialize configuration", e);
+ } catch (ClassNotFoundException e) {
+ throw new RuntimeException("Configuration class not found", e);
+ }
+ }
+
+ @Override
+ public @NotNull String getContentType() {
+ return "application/x-java-serialized-object";
+ }
+ }
+}
diff --git a/android-sdk-framework/src/main/java/cloud/eppo/android/framework/storage/FileBackedByteStore.java b/android-sdk-framework/src/main/java/cloud/eppo/android/framework/storage/FileBackedByteStore.java
new file mode 100644
index 0000000..c14f2ca
--- /dev/null
+++ b/android-sdk-framework/src/main/java/cloud/eppo/android/framework/storage/FileBackedByteStore.java
@@ -0,0 +1,59 @@
+package cloud.eppo.android.framework.storage;
+
+import java.util.concurrent.CompletableFuture;
+import org.jetbrains.annotations.NotNull;
+
+/**
+ * {@link ByteStore} implementation that reads and writes a single file via {@link BaseCacheFile}.
+ */
+public final class FileBackedByteStore implements ByteStore {
+
+ private final BaseCacheFile cacheFile;
+
+ public FileBackedByteStore(@NotNull BaseCacheFile cacheFile) {
+ if (cacheFile == null) {
+ throw new IllegalArgumentException("cacheFile must not be null");
+ }
+ this.cacheFile = cacheFile;
+ }
+
+ @Override
+ @NotNull public CompletableFuture read() {
+ return CompletableFuture.supplyAsync(
+ () -> {
+ if (!cacheFile.exists()) {
+ return null;
+ }
+ try (java.io.InputStream in = cacheFile.getInputStream()) {
+ return readAllBytes(in);
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to read from cache file", e);
+ }
+ });
+ }
+
+ @Override
+ @NotNull public CompletableFuture write(@NotNull byte[] bytes) {
+ if (bytes == null) {
+ throw new IllegalArgumentException("bytes must not be null");
+ }
+ return CompletableFuture.runAsync(
+ () -> {
+ try (java.io.OutputStream out = cacheFile.getOutputStream()) {
+ out.write(bytes);
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to write to cache file", e);
+ }
+ });
+ }
+
+ private static byte[] readAllBytes(java.io.InputStream in) throws java.io.IOException {
+ java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream();
+ byte[] buf = new byte[8192];
+ int n;
+ while ((n = in.read(buf)) != -1) {
+ baos.write(buf, 0, n);
+ }
+ return baos.toByteArray();
+ }
+}
diff --git a/android-sdk-framework/src/main/java/cloud/eppo/android/framework/storage/FileBackedConfigStore.java b/android-sdk-framework/src/main/java/cloud/eppo/android/framework/storage/FileBackedConfigStore.java
new file mode 100644
index 0000000..39e4af9
--- /dev/null
+++ b/android-sdk-framework/src/main/java/cloud/eppo/android/framework/storage/FileBackedConfigStore.java
@@ -0,0 +1,29 @@
+package cloud.eppo.android.framework.storage;
+
+import android.app.Application;
+import cloud.eppo.api.Configuration;
+import org.jetbrains.annotations.NotNull;
+
+public class FileBackedConfigStore extends CachingConfigurationStore {
+
+ /**
+ * Creates a FileBackedStore with the specified configuration.
+ *
+ * @param application the Android application context
+ * @param cacheFileSuffix suffix for the cache file name (e.g. "v4-flags-abc123")
+ * @param codec the codec for serializing/deserializing configurations
+ */
+ public FileBackedConfigStore(
+ @NotNull Application application,
+ @NotNull String cacheFileSuffix,
+ @NotNull ConfigurationCodec codec) {
+ super(codec, createByteStore(application, cacheFileSuffix, codec));
+ }
+
+ private static ByteStore createByteStore(
+ Application application, String cacheFileSuffix, ConfigurationCodec codec) {
+ ConfigCacheFile cacheFile =
+ new ConfigCacheFile(application, cacheFileSuffix, codec.getContentType());
+ return new FileBackedByteStore(cacheFile);
+ }
+}
diff --git a/android-sdk-framework/src/main/java/cloud/eppo/android/framework/storage/GsonConfigurationCodec.java b/android-sdk-framework/src/main/java/cloud/eppo/android/framework/storage/GsonConfigurationCodec.java
new file mode 100644
index 0000000..6c7b127
--- /dev/null
+++ b/android-sdk-framework/src/main/java/cloud/eppo/android/framework/storage/GsonConfigurationCodec.java
@@ -0,0 +1,708 @@
+package cloud.eppo.android.framework.storage;
+
+import android.util.Log;
+import cloud.eppo.api.Configuration;
+import cloud.eppo.api.EppoValue;
+import cloud.eppo.api.dto.Allocation;
+import cloud.eppo.api.dto.BanditCategoricalAttributeCoefficients;
+import cloud.eppo.api.dto.BanditCoefficients;
+import cloud.eppo.api.dto.BanditFlagVariation;
+import cloud.eppo.api.dto.BanditModelData;
+import cloud.eppo.api.dto.BanditNumericAttributeCoefficients;
+import cloud.eppo.api.dto.BanditParameters;
+import cloud.eppo.api.dto.BanditParametersResponse;
+import cloud.eppo.api.dto.BanditReference;
+import cloud.eppo.api.dto.FlagConfig;
+import cloud.eppo.api.dto.FlagConfigResponse;
+import cloud.eppo.api.dto.OperatorType;
+import cloud.eppo.api.dto.Shard;
+import cloud.eppo.api.dto.Split;
+import cloud.eppo.api.dto.TargetingCondition;
+import cloud.eppo.api.dto.TargetingRule;
+import cloud.eppo.api.dto.Variation;
+import cloud.eppo.api.dto.VariationType;
+import cloud.eppo.model.ShardRange;
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonNull;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import com.google.gson.JsonPrimitive;
+import java.lang.reflect.Field;
+import java.nio.charset.StandardCharsets;
+import java.text.ParseException;
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.TimeZone;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+/**
+ * A GSON-based {@link ConfigurationCodec} for {@link Configuration}.
+ *
+ * Serializes to UTF-8 JSON rather than Java's binary serialization format. This makes cached
+ * configurations human-readable and immune to {@code serialVersionUID} drift between SDK versions.
+ *
+ *
Because {@link Configuration} does not expose its internal maps, this codec uses reflection to
+ * read the {@code flags}, {@code banditReferences}, and {@code bandits} fields. These field names
+ * are stable; the class declares {@code serialVersionUID = 1L} to signal serialization
+ * compatibility.
+ *
+ *
Usage:
+ *
+ *
{@code
+ * CachingConfigurationStore store = new FileBackedConfigStore<>(
+ * context,
+ * new GsonConfigurationCodec());
+ * }
+ */
+public class GsonConfigurationCodec implements ConfigurationCodec {
+
+ private static final String TAG = GsonConfigurationCodec.class.getSimpleName();
+ private static final int FORMAT_VERSION = 1;
+
+ private static final ThreadLocal UTC_DATE_FORMAT =
+ ThreadLocal.withInitial(
+ () -> {
+ SimpleDateFormat fmt = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US);
+ fmt.setTimeZone(TimeZone.getTimeZone("UTC"));
+ return fmt;
+ });
+
+ // Reflection access to Configuration's private state
+ private static final Field FLAGS_FIELD;
+ private static final Field BANDIT_REFS_FIELD;
+ private static final Field BANDITS_FIELD;
+
+ static {
+ try {
+ FLAGS_FIELD = Configuration.class.getDeclaredField("flags");
+ FLAGS_FIELD.setAccessible(true);
+ BANDIT_REFS_FIELD = Configuration.class.getDeclaredField("banditReferences");
+ BANDIT_REFS_FIELD.setAccessible(true);
+ BANDITS_FIELD = Configuration.class.getDeclaredField("bandits");
+ BANDITS_FIELD.setAccessible(true);
+ } catch (NoSuchFieldException e) {
+ throw new ExceptionInInitializerError(e);
+ }
+ }
+
+ @Override
+ public byte[] toBytes(@NotNull Configuration configuration) {
+ return serializeConfiguration(configuration).toString().getBytes(StandardCharsets.UTF_8);
+ }
+
+ @Override
+ @NotNull public Configuration fromBytes(byte[] bytes) {
+ String json = new String(bytes, StandardCharsets.UTF_8);
+ return deserializeConfiguration(JsonParser.parseString(json).getAsJsonObject());
+ }
+
+ @Override
+ @NotNull public String getContentType() {
+ return "application/json";
+ }
+
+ // ===== Serialization =====
+
+ @SuppressWarnings("unchecked")
+ private JsonObject serializeConfiguration(Configuration config) {
+ JsonObject root = new JsonObject();
+ root.addProperty("v", FORMAT_VERSION);
+ root.addProperty("isObfuscated", config.isConfigObfuscated());
+
+ String environmentName = config.getEnvironmentName();
+ if (environmentName != null) {
+ root.addProperty("environmentName", environmentName);
+ }
+
+ Date publishedAt = config.getConfigPublishedAt();
+ if (publishedAt != null) {
+ root.addProperty("publishedAt", UTC_DATE_FORMAT.get().format(publishedAt));
+ }
+
+ String snapshotId = config.getFlagsSnapshotId();
+ if (snapshotId != null) {
+ root.addProperty("snapshotId", snapshotId);
+ }
+
+ try {
+ Map flags = (Map) FLAGS_FIELD.get(config);
+ Map banditRefs =
+ (Map) BANDIT_REFS_FIELD.get(config);
+ Map bandits =
+ (Map) BANDITS_FIELD.get(config);
+
+ if (flags != null) {
+ root.add("flags", serializeFlags(flags));
+ }
+ if (banditRefs != null && !banditRefs.isEmpty()) {
+ root.add("banditReferences", serializeBanditReferences(banditRefs));
+ }
+ if (bandits != null && !bandits.isEmpty()) {
+ root.add("bandits", serializeBandits(bandits));
+ }
+ } catch (IllegalAccessException e) {
+ throw new RuntimeException("Failed to access Configuration fields via reflection", e);
+ }
+
+ return root;
+ }
+
+ private JsonObject serializeFlags(Map flags) {
+ JsonObject obj = new JsonObject();
+ for (Map.Entry entry : flags.entrySet()) {
+ obj.add(entry.getKey(), serializeFlag(entry.getValue()));
+ }
+ return obj;
+ }
+
+ private JsonObject serializeFlag(FlagConfig flag) {
+ JsonObject obj = new JsonObject();
+ obj.addProperty("key", flag.getKey());
+ obj.addProperty("enabled", flag.isEnabled());
+ obj.addProperty("totalShards", flag.getTotalShards());
+ obj.addProperty("variationType", flag.getVariationType().value);
+ obj.add("variations", serializeVariations(flag.getVariations()));
+ obj.add("allocations", serializeAllocations(flag.getAllocations()));
+ return obj;
+ }
+
+ private JsonObject serializeVariations(Map variations) {
+ JsonObject obj = new JsonObject();
+ for (Map.Entry entry : variations.entrySet()) {
+ JsonObject varObj = new JsonObject();
+ varObj.addProperty("key", entry.getValue().getKey());
+ varObj.add("value", serializeEppoValue(entry.getValue().getValue()));
+ obj.add(entry.getKey(), varObj);
+ }
+ return obj;
+ }
+
+ private JsonArray serializeAllocations(List allocations) {
+ JsonArray arr = new JsonArray();
+ for (Allocation alloc : allocations) {
+ JsonObject obj = new JsonObject();
+ obj.addProperty("key", alloc.getKey());
+ obj.addProperty("doLog", alloc.doLog());
+
+ Date startAt = alloc.getStartAt();
+ if (startAt != null) {
+ obj.addProperty("startAt", UTC_DATE_FORMAT.get().format(startAt));
+ }
+
+ Date endAt = alloc.getEndAt();
+ if (endAt != null) {
+ obj.addProperty("endAt", UTC_DATE_FORMAT.get().format(endAt));
+ }
+
+ Set rules = alloc.getRules();
+ obj.add("rules", rules != null ? serializeTargetingRules(rules) : new JsonArray());
+ obj.add("splits", serializeSplits(alloc.getSplits()));
+ arr.add(obj);
+ }
+ return arr;
+ }
+
+ private JsonArray serializeTargetingRules(Set rules) {
+ JsonArray arr = new JsonArray();
+ for (TargetingRule rule : rules) {
+ JsonObject ruleObj = new JsonObject();
+ JsonArray conditions = new JsonArray();
+ for (TargetingCondition cond : rule.getConditions()) {
+ JsonObject condObj = new JsonObject();
+ condObj.addProperty("operator", cond.getOperator().value);
+ condObj.addProperty("attribute", cond.getAttribute());
+ condObj.add("value", serializeEppoValue(cond.getValue()));
+ conditions.add(condObj);
+ }
+ ruleObj.add("conditions", conditions);
+ arr.add(ruleObj);
+ }
+ return arr;
+ }
+
+ private JsonArray serializeSplits(List splits) {
+ JsonArray arr = new JsonArray();
+ for (Split split : splits) {
+ JsonObject obj = new JsonObject();
+ obj.addProperty("variationKey", split.getVariationKey());
+ obj.add("shards", serializeShards(split.getShards()));
+ Map extraLogging = split.getExtraLogging();
+ if (!extraLogging.isEmpty()) {
+ JsonObject extraObj = new JsonObject();
+ for (Map.Entry entry : extraLogging.entrySet()) {
+ extraObj.addProperty(entry.getKey(), entry.getValue());
+ }
+ obj.add("extraLogging", extraObj);
+ }
+ arr.add(obj);
+ }
+ return arr;
+ }
+
+ private JsonArray serializeShards(Set shards) {
+ JsonArray arr = new JsonArray();
+ for (Shard shard : shards) {
+ JsonObject obj = new JsonObject();
+ obj.addProperty("salt", shard.getSalt());
+ JsonArray ranges = new JsonArray();
+ for (ShardRange range : shard.getRanges()) {
+ JsonObject rangeObj = new JsonObject();
+ rangeObj.addProperty("start", range.getStart());
+ rangeObj.addProperty("end", range.getEnd());
+ ranges.add(rangeObj);
+ }
+ obj.add("ranges", ranges);
+ arr.add(obj);
+ }
+ return arr;
+ }
+
+ private JsonElement serializeEppoValue(@Nullable EppoValue value) {
+ if (value == null || value.isNull()) {
+ return JsonNull.INSTANCE;
+ }
+ if (value.isBoolean()) {
+ return new JsonPrimitive(value.booleanValue());
+ }
+ if (value.isNumeric()) {
+ return new JsonPrimitive(value.doubleValue());
+ }
+ if (value.isStringArray()) {
+ JsonArray arr = new JsonArray();
+ for (String s : value.stringArrayValue()) {
+ arr.add(s);
+ }
+ return arr;
+ }
+ return new JsonPrimitive(value.stringValue());
+ }
+
+ private JsonObject serializeBanditReferences(Map banditRefs) {
+ JsonObject obj = new JsonObject();
+ for (Map.Entry entry : banditRefs.entrySet()) {
+ BanditReference ref = entry.getValue();
+ JsonObject refObj = new JsonObject();
+ refObj.addProperty("modelVersion", ref.getModelVersion());
+ JsonArray variations = new JsonArray();
+ for (BanditFlagVariation fv : ref.getFlagVariations()) {
+ JsonObject fvObj = new JsonObject();
+ // "key" matches the field name expected by GsonConfigurationParser
+ fvObj.addProperty("key", fv.getBanditKey());
+ fvObj.addProperty("flagKey", fv.getFlagKey());
+ fvObj.addProperty("allocationKey", fv.getAllocationKey());
+ fvObj.addProperty("variationKey", fv.getVariationKey());
+ fvObj.addProperty("variationValue", fv.getVariationValue());
+ variations.add(fvObj);
+ }
+ refObj.add("flagVariations", variations);
+ obj.add(entry.getKey(), refObj);
+ }
+ return obj;
+ }
+
+ private JsonObject serializeBandits(Map bandits) {
+ JsonObject obj = new JsonObject();
+ for (Map.Entry entry : bandits.entrySet()) {
+ BanditParameters bp = entry.getValue();
+ JsonObject bpObj = new JsonObject();
+ bpObj.addProperty("banditKey", bp.getBanditKey());
+ Date updatedAt = bp.getUpdatedAt();
+ if (updatedAt != null) {
+ bpObj.addProperty("updatedAt", UTC_DATE_FORMAT.get().format(updatedAt));
+ }
+ bpObj.addProperty("modelName", bp.getModelName());
+ bpObj.addProperty("modelVersion", bp.getModelVersion());
+ bpObj.add("modelData", serializeBanditModelData(bp.getModelData()));
+ obj.add(entry.getKey(), bpObj);
+ }
+ return obj;
+ }
+
+ private JsonObject serializeBanditModelData(BanditModelData modelData) {
+ JsonObject obj = new JsonObject();
+ obj.addProperty("gamma", modelData.getGamma());
+ obj.addProperty("defaultActionScore", modelData.getDefaultActionScore());
+ obj.addProperty("actionProbabilityFloor", modelData.getActionProbabilityFloor());
+ JsonObject coefficients = new JsonObject();
+ for (Map.Entry entry : modelData.getCoefficients().entrySet()) {
+ coefficients.add(entry.getKey(), serializeBanditCoefficients(entry.getValue()));
+ }
+ obj.add("coefficients", coefficients);
+ return obj;
+ }
+
+ private JsonObject serializeBanditCoefficients(BanditCoefficients bc) {
+ JsonObject obj = new JsonObject();
+ obj.addProperty("actionKey", bc.getActionKey());
+ obj.addProperty("intercept", bc.getIntercept());
+ obj.add(
+ "subjectNumericCoefficients",
+ serializeNumericCoefficients(bc.getSubjectNumericCoefficients()));
+ obj.add(
+ "subjectCategoricalCoefficients",
+ serializeCategoricalCoefficients(bc.getSubjectCategoricalCoefficients()));
+ obj.add(
+ "actionNumericCoefficients",
+ serializeNumericCoefficients(bc.getActionNumericCoefficients()));
+ obj.add(
+ "actionCategoricalCoefficients",
+ serializeCategoricalCoefficients(bc.getActionCategoricalCoefficients()));
+ return obj;
+ }
+
+ private JsonArray serializeNumericCoefficients(
+ Map coefs) {
+ JsonArray arr = new JsonArray();
+ for (BanditNumericAttributeCoefficients c : coefs.values()) {
+ JsonObject obj = new JsonObject();
+ obj.addProperty("attributeKey", c.getAttributeKey());
+ obj.addProperty("coefficient", c.getCoefficient());
+ obj.addProperty("missingValueCoefficient", c.getMissingValueCoefficient());
+ arr.add(obj);
+ }
+ return arr;
+ }
+
+ private JsonArray serializeCategoricalCoefficients(
+ Map coefs) {
+ JsonArray arr = new JsonArray();
+ for (BanditCategoricalAttributeCoefficients c : coefs.values()) {
+ JsonObject obj = new JsonObject();
+ obj.addProperty("attributeKey", c.getAttributeKey());
+ obj.addProperty("missingValueCoefficient", c.getMissingValueCoefficient());
+ JsonObject valueCoefficients = new JsonObject();
+ for (Map.Entry entry : c.getValueCoefficients().entrySet()) {
+ valueCoefficients.addProperty(entry.getKey(), entry.getValue());
+ }
+ obj.add("valueCoefficients", valueCoefficients);
+ arr.add(obj);
+ }
+ return arr;
+ }
+
+ // ===== Deserialization =====
+
+ private Configuration deserializeConfiguration(JsonObject root) {
+ int version = root.has("v") ? root.get("v").getAsInt() : 1;
+ if (version != FORMAT_VERSION) {
+ Log.w(TAG, "Unknown cache format version " + version + "; attempting deserialization anyway");
+ }
+
+ boolean isObfuscated =
+ root.has("isObfuscated")
+ && !root.get("isObfuscated").isJsonNull()
+ && root.get("isObfuscated").getAsBoolean();
+
+ String environmentName = stringOrNull(root, "environmentName");
+ Date publishedAt = parseDateElement(root.get("publishedAt"));
+ String snapshotId = stringOrNull(root, "snapshotId");
+
+ Map flags = new HashMap<>();
+ JsonElement flagsEl = root.get("flags");
+ if (flagsEl != null && flagsEl.isJsonObject()) {
+ for (Map.Entry e : flagsEl.getAsJsonObject().entrySet()) {
+ flags.put(e.getKey(), deserializeFlag(e.getValue().getAsJsonObject()));
+ }
+ }
+
+ Map banditRefs = new HashMap<>();
+ JsonElement banditRefsEl = root.get("banditReferences");
+ if (banditRefsEl != null && banditRefsEl.isJsonObject()) {
+ for (Map.Entry e : banditRefsEl.getAsJsonObject().entrySet()) {
+ banditRefs.put(e.getKey(), deserializeBanditReference(e.getValue().getAsJsonObject()));
+ }
+ }
+
+ Map bandits = new HashMap<>();
+ JsonElement banditsEl = root.get("bandits");
+ if (banditsEl != null && banditsEl.isJsonObject()) {
+ for (Map.Entry e : banditsEl.getAsJsonObject().entrySet()) {
+ bandits.put(e.getKey(), deserializeBanditParameters(e.getValue().getAsJsonObject()));
+ }
+ }
+
+ FlagConfigResponse.Format format =
+ isObfuscated ? FlagConfigResponse.Format.CLIENT : FlagConfigResponse.Format.SERVER;
+ FlagConfigResponse flagConfigResponse =
+ new FlagConfigResponse.Default(flags, banditRefs, format, environmentName, publishedAt);
+
+ Configuration.Builder builder = new Configuration.Builder(flagConfigResponse);
+ if (!bandits.isEmpty()) {
+ builder.banditParameters(new BanditParametersResponse.Default(bandits));
+ }
+ if (snapshotId != null) {
+ builder.flagsSnapshotId(snapshotId);
+ }
+
+ return builder.build();
+ }
+
+ private FlagConfig deserializeFlag(JsonObject obj) {
+ String key = obj.get("key").getAsString();
+ boolean enabled = obj.get("enabled").getAsBoolean();
+ int totalShards = obj.get("totalShards").getAsInt();
+ VariationType variationType = VariationType.fromString(obj.get("variationType").getAsString());
+ Map variations = deserializeVariations(obj.get("variations"));
+ List allocations = deserializeAllocations(obj.get("allocations"));
+ return new FlagConfig.Default(
+ key, enabled, totalShards, variationType, variations, allocations);
+ }
+
+ private Map deserializeVariations(JsonElement element) {
+ Map variations = new HashMap<>();
+ if (element == null || !element.isJsonObject()) {
+ return variations;
+ }
+ for (Map.Entry entry : element.getAsJsonObject().entrySet()) {
+ JsonObject varObj = entry.getValue().getAsJsonObject();
+ variations.put(
+ entry.getKey(),
+ new Variation.Default(
+ varObj.get("key").getAsString(), deserializeEppoValue(varObj.get("value"))));
+ }
+ return variations;
+ }
+
+ private List deserializeAllocations(JsonElement element) {
+ List allocations = new ArrayList<>();
+ if (element == null || !element.isJsonArray()) {
+ return allocations;
+ }
+ for (JsonElement allocationEl : element.getAsJsonArray()) {
+ JsonObject obj = allocationEl.getAsJsonObject();
+ String key = obj.get("key").getAsString();
+ Set rules = deserializeTargetingRules(obj.get("rules"));
+ Date startAt = parseDateElement(obj.get("startAt"));
+ Date endAt = parseDateElement(obj.get("endAt"));
+ List splits = deserializeSplits(obj.get("splits"));
+ boolean doLog = obj.get("doLog").getAsBoolean();
+ allocations.add(new Allocation.Default(key, rules, startAt, endAt, splits, doLog));
+ }
+ return allocations;
+ }
+
+ private Set deserializeTargetingRules(JsonElement element) {
+ Set rules = new HashSet<>();
+ if (element == null || !element.isJsonArray()) {
+ return rules;
+ }
+ for (JsonElement ruleEl : element.getAsJsonArray()) {
+ JsonObject ruleObj = ruleEl.getAsJsonObject();
+ Set conditions = new HashSet<>();
+ JsonElement conditionsEl = ruleObj.get("conditions");
+ if (conditionsEl != null && conditionsEl.isJsonArray()) {
+ for (JsonElement condEl : conditionsEl.getAsJsonArray()) {
+ JsonObject cond = condEl.getAsJsonObject();
+ OperatorType operator = OperatorType.fromString(cond.get("operator").getAsString());
+ if (operator == null) {
+ Log.w(TAG, "Unknown operator: " + cond.get("operator").getAsString());
+ continue;
+ }
+ conditions.add(
+ new TargetingCondition.Default(
+ operator,
+ cond.get("attribute").getAsString(),
+ deserializeEppoValue(cond.get("value"))));
+ }
+ }
+ rules.add(new TargetingRule.Default(conditions));
+ }
+ return rules;
+ }
+
+ private List deserializeSplits(JsonElement element) {
+ List splits = new ArrayList<>();
+ if (element == null || !element.isJsonArray()) {
+ return splits;
+ }
+ for (JsonElement splitEl : element.getAsJsonArray()) {
+ JsonObject obj = splitEl.getAsJsonObject();
+ String variationKey = obj.get("variationKey").getAsString();
+ Set shards = deserializeShards(obj.get("shards"));
+ Map extraLogging = new HashMap<>();
+ JsonElement extraEl = obj.get("extraLogging");
+ if (extraEl != null && extraEl.isJsonObject()) {
+ for (Map.Entry entry : extraEl.getAsJsonObject().entrySet()) {
+ extraLogging.put(entry.getKey(), entry.getValue().getAsString());
+ }
+ }
+ splits.add(new Split.Default(variationKey, shards, extraLogging));
+ }
+ return splits;
+ }
+
+ private Set deserializeShards(JsonElement element) {
+ Set shards = new HashSet<>();
+ if (element == null || !element.isJsonArray()) {
+ return shards;
+ }
+ for (JsonElement shardEl : element.getAsJsonArray()) {
+ JsonObject obj = shardEl.getAsJsonObject();
+ String salt = obj.get("salt").getAsString();
+ Set ranges = new HashSet<>();
+ JsonElement rangesEl = obj.get("ranges");
+ if (rangesEl != null && rangesEl.isJsonArray()) {
+ for (JsonElement rangeEl : rangesEl.getAsJsonArray()) {
+ JsonObject range = rangeEl.getAsJsonObject();
+ ranges.add(new ShardRange(range.get("start").getAsInt(), range.get("end").getAsInt()));
+ }
+ }
+ shards.add(new Shard.Default(salt, ranges));
+ }
+ return shards;
+ }
+
+ private BanditReference deserializeBanditReference(JsonObject obj) {
+ String modelVersion = obj.get("modelVersion").getAsString();
+ List flagVariations = new ArrayList<>();
+ JsonElement fvsEl = obj.get("flagVariations");
+ if (fvsEl != null && fvsEl.isJsonArray()) {
+ for (JsonElement fvEl : fvsEl.getAsJsonArray()) {
+ JsonObject fv = fvEl.getAsJsonObject();
+ flagVariations.add(
+ new BanditFlagVariation.Default(
+ fv.get("key").getAsString(),
+ fv.get("flagKey").getAsString(),
+ fv.get("allocationKey").getAsString(),
+ fv.get("variationKey").getAsString(),
+ fv.get("variationValue").getAsString()));
+ }
+ }
+ return new BanditReference.Default(modelVersion, flagVariations);
+ }
+
+ private BanditParameters deserializeBanditParameters(JsonObject obj) {
+ String banditKey = obj.get("banditKey").getAsString();
+ Date updatedAt = parseDateElement(obj.get("updatedAt"));
+ String modelName = obj.get("modelName").getAsString();
+ String modelVersion = obj.get("modelVersion").getAsString();
+ BanditModelData modelData = deserializeBanditModelData(obj.get("modelData").getAsJsonObject());
+ return new BanditParameters.Default(banditKey, updatedAt, modelName, modelVersion, modelData);
+ }
+
+ private BanditModelData deserializeBanditModelData(JsonObject obj) {
+ double gamma = obj.get("gamma").getAsDouble();
+ double defaultActionScore = obj.get("defaultActionScore").getAsDouble();
+ double actionProbabilityFloor = obj.get("actionProbabilityFloor").getAsDouble();
+ Map coefficients = new HashMap<>();
+ JsonElement coefsEl = obj.get("coefficients");
+ if (coefsEl != null && coefsEl.isJsonObject()) {
+ for (Map.Entry entry : coefsEl.getAsJsonObject().entrySet()) {
+ coefficients.put(
+ entry.getKey(), deserializeBanditCoefficients(entry.getValue().getAsJsonObject()));
+ }
+ }
+ return new BanditModelData.Default(
+ gamma, defaultActionScore, actionProbabilityFloor, coefficients);
+ }
+
+ private BanditCoefficients deserializeBanditCoefficients(JsonObject obj) {
+ String actionKey = obj.get("actionKey").getAsString();
+ double intercept = obj.get("intercept").getAsDouble();
+ Map subjectNumeric =
+ deserializeNumericCoefficients(obj.get("subjectNumericCoefficients"));
+ Map subjectCategorical =
+ deserializeCategoricalCoefficients(obj.get("subjectCategoricalCoefficients"));
+ Map actionNumeric =
+ deserializeNumericCoefficients(obj.get("actionNumericCoefficients"));
+ Map actionCategorical =
+ deserializeCategoricalCoefficients(obj.get("actionCategoricalCoefficients"));
+ return new BanditCoefficients.Default(
+ actionKey, intercept, subjectNumeric, subjectCategorical, actionNumeric, actionCategorical);
+ }
+
+ private Map deserializeNumericCoefficients(
+ JsonElement element) {
+ Map result = new HashMap<>();
+ if (element == null || !element.isJsonArray()) {
+ return result;
+ }
+ for (JsonElement item : element.getAsJsonArray()) {
+ JsonObject obj = item.getAsJsonObject();
+ String attributeKey = obj.get("attributeKey").getAsString();
+ result.put(
+ attributeKey,
+ new BanditNumericAttributeCoefficients.Default(
+ attributeKey,
+ obj.get("coefficient").getAsDouble(),
+ obj.get("missingValueCoefficient").getAsDouble()));
+ }
+ return result;
+ }
+
+ private Map deserializeCategoricalCoefficients(
+ JsonElement element) {
+ Map result = new HashMap<>();
+ if (element == null || !element.isJsonArray()) {
+ return result;
+ }
+ for (JsonElement item : element.getAsJsonArray()) {
+ JsonObject obj = item.getAsJsonObject();
+ String attributeKey = obj.get("attributeKey").getAsString();
+ Map valueCoefficients = new HashMap<>();
+ JsonElement valuesEl = obj.get("valueCoefficients");
+ if (valuesEl != null && valuesEl.isJsonObject()) {
+ for (Map.Entry entry : valuesEl.getAsJsonObject().entrySet()) {
+ valueCoefficients.put(entry.getKey(), entry.getValue().getAsDouble());
+ }
+ }
+ result.put(
+ attributeKey,
+ new BanditCategoricalAttributeCoefficients.Default(
+ attributeKey, obj.get("missingValueCoefficient").getAsDouble(), valueCoefficients));
+ }
+ return result;
+ }
+
+ // ===== Helpers =====
+
+ private EppoValue deserializeEppoValue(JsonElement element) {
+ if (element == null || element.isJsonNull()) {
+ return EppoValue.nullValue();
+ }
+ if (element.isJsonArray()) {
+ List arr = new ArrayList<>();
+ for (JsonElement item : element.getAsJsonArray()) {
+ arr.add(item.getAsString());
+ }
+ return EppoValue.valueOf(arr);
+ }
+ if (element.isJsonPrimitive()) {
+ if (element.getAsJsonPrimitive().isBoolean()) {
+ return EppoValue.valueOf(element.getAsBoolean());
+ }
+ if (element.getAsJsonPrimitive().isNumber()) {
+ return EppoValue.valueOf(element.getAsDouble());
+ }
+ return EppoValue.valueOf(element.getAsString());
+ }
+ Log.w(TAG, "Unexpected JSON element for EppoValue: " + element);
+ return EppoValue.nullValue();
+ }
+
+ @Nullable private static Date parseDateElement(JsonElement element) {
+ if (element == null || element.isJsonNull()) {
+ return null;
+ }
+ try {
+ return UTC_DATE_FORMAT.get().parse(element.getAsString());
+ } catch (ParseException e) {
+ Log.w(TAG, "Failed to parse date: " + element.getAsString());
+ return null;
+ }
+ }
+
+ @Nullable private static String stringOrNull(JsonObject obj, String key) {
+ JsonElement el = obj.get(key);
+ return (el != null && !el.isJsonNull()) ? el.getAsString() : null;
+ }
+}
diff --git a/android-sdk-framework/src/main/java/cloud/eppo/android/framework/util/Utils.java b/android-sdk-framework/src/main/java/cloud/eppo/android/framework/util/Utils.java
new file mode 100644
index 0000000..98c4ff1
--- /dev/null
+++ b/android-sdk-framework/src/main/java/cloud/eppo/android/framework/util/Utils.java
@@ -0,0 +1,21 @@
+package cloud.eppo.android.framework.util;
+
+public class Utils {
+ public static String logTag(Class loggingClass) {
+ // Common prefix can make filtering logs easier
+ String logTag = ("EppoSDK:" + loggingClass.getSimpleName());
+
+ // Android prefers keeping log tags 23 characters or less
+ if (logTag.length() > 23) {
+ logTag = logTag.substring(0, 23);
+ }
+
+ return logTag;
+ }
+
+ public static String safeCacheKey(String key) {
+ // Take the first eight characters to avoid the key being sensitive information
+ // Remove non-alphanumeric characters so it plays nice with filesystem
+ return key.substring(0, 8).replaceAll("\\W", "");
+ }
+}
diff --git a/android-sdk-framework/src/test/java/cloud/eppo/android/framework/storage/CachingConfigurationStoreTest.java b/android-sdk-framework/src/test/java/cloud/eppo/android/framework/storage/CachingConfigurationStoreTest.java
new file mode 100644
index 0000000..7d3d546
--- /dev/null
+++ b/android-sdk-framework/src/test/java/cloud/eppo/android/framework/storage/CachingConfigurationStoreTest.java
@@ -0,0 +1,359 @@
+package cloud.eppo.android.framework.storage;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import cloud.eppo.JacksonConfigurationParser;
+import cloud.eppo.api.Configuration;
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+
+/** Unit tests for {@link CachingConfigurationStore}. */
+@RunWith(RobolectricTestRunner.class)
+public class CachingConfigurationStoreTest {
+
+ private ByteStore mockByteStore;
+ private ConfigurationCodec spyCodec;
+ private CachingConfigurationStore testedStore;
+
+ /** One shared non-empty configuration used across tests (built once in setUp). */
+ private Configuration sampleConfiguration;
+
+ /** Serialized form of sampleConfiguration from the real codec (for verify when needed). */
+ private byte[] sampleConfigurationBytes;
+
+ @Before
+ public void setUp() throws Exception {
+ mockByteStore = mock(ByteStore.class);
+ spyCodec = spy(new ConfigurationCodec.Default<>(Configuration.class));
+ testedStore = new CachingConfigurationStore(spyCodec, mockByteStore);
+ // Parse flags-v1.json from test resources using sdk-common-jvm JacksonConfigurationParser.
+ sampleConfiguration = loadSampleConfigurationFromResource();
+ ConfigurationCodec realCodec =
+ new ConfigurationCodec.Default<>(Configuration.class);
+ sampleConfigurationBytes = realCodec.toBytes(sampleConfiguration);
+ }
+
+ private static Configuration loadSampleConfigurationFromResource() throws Exception {
+ try (InputStream in =
+ Objects.requireNonNull(
+ CachingConfigurationStoreTest.class.getResourceAsStream("/flags-v1.json"),
+ "flags-v1.json not found on test classpath")) {
+ byte[] jsonBytes = in.readAllBytes();
+ JacksonConfigurationParser parser = new JacksonConfigurationParser();
+ return new Configuration.Builder(parser.parseFlagConfig(jsonBytes)).build();
+ }
+ }
+
+ @Test
+ public void testGetConfiguration_returnsEmptyConfigByDefault() {
+ Configuration config = testedStore.getConfiguration();
+ assertNotNull("Configuration should not be null", config);
+ assertEquals("Should return empty config by default", Configuration.emptyConfig(), config);
+ }
+
+ @Test(expected = IllegalArgumentException.class)
+ public void testSaveConfiguration_nullConfiguration_throwsException() {
+ testedStore.saveConfiguration(null);
+ }
+
+ @Test
+ public void testSaveConfiguration_updatesInMemoryCache() throws Exception {
+ when(mockByteStore.write(any())).thenReturn(CompletableFuture.completedFuture(null));
+
+ testedStore.saveConfiguration(sampleConfiguration).get(5, TimeUnit.SECONDS);
+
+ assertEquals(
+ "In-memory config should be updated", sampleConfiguration, testedStore.getConfiguration());
+
+ verify(spyCodec, times(1)).toBytes(sampleConfiguration);
+ verify(mockByteStore, times(1)).write(sampleConfigurationBytes);
+ }
+
+ @Test
+ public void testLoadFromStorage_whenExists() throws Exception {
+
+ // Mock IO read
+ when(mockByteStore.read())
+ .thenReturn(CompletableFuture.completedFuture(sampleConfigurationBytes));
+
+ // Load from storage
+ Configuration loaded = testedStore.loadFromStorage().get(5, TimeUnit.SECONDS);
+
+ // Verify loaded config
+ assertEquals("Should return stored configuration", sampleConfiguration, loaded);
+
+ // Verify in-memory cache was NOT updated
+ assertEquals(
+ "In-memory config should still be empty",
+ Configuration.emptyConfig(),
+ testedStore.getConfiguration());
+
+ // Verify IO and codec were called
+ verify(mockByteStore, times(1)).read();
+ verify(spyCodec, times(1)).fromBytes(sampleConfigurationBytes);
+ }
+
+ @Test
+ public void testLoadFromStorage_whenNotExists() throws Exception {
+ // Mock IO read returning null (file doesn't exist)
+ when(mockByteStore.read()).thenReturn(CompletableFuture.completedFuture(null));
+
+ // Load from storage
+ Configuration loaded = testedStore.loadFromStorage().get(5, TimeUnit.SECONDS);
+
+ // Verify null is returned
+ assertNull("Should return null when storage doesn't exist", loaded);
+
+ // Verify IO was called but codec was not
+ verify(mockByteStore, times(1)).read();
+ verify(spyCodec, times(0)).fromBytes(any());
+ }
+
+ @Test
+ public void testLoadFromStorage_emptyBytes() throws Exception {
+ // Mock byte store read returning empty array
+ when(mockByteStore.read()).thenReturn(CompletableFuture.completedFuture(new byte[0]));
+
+ // Load from storage
+ Configuration loaded = testedStore.loadFromStorage().get(5, TimeUnit.SECONDS);
+
+ // Verify null is returned for empty bytes
+ assertNull("Should return null for empty bytes", loaded);
+
+ // Verify IO was called but codec was not
+ verify(mockByteStore, times(1)).read();
+ verify(spyCodec, times(0)).fromBytes(any());
+ }
+
+ @Test
+ public void testSaveConfiguration_codecException() {
+ Configuration beforeSave = testedStore.getConfiguration();
+
+ // Mock codec to throw exception (may throw synchronously before returning a future)
+ when(spyCodec.toBytes(sampleConfiguration))
+ .thenThrow(new RuntimeException("Serialization failed"));
+
+ // Save configuration should propagate exception (sync from codec or via ExecutionException)
+ try {
+ testedStore.saveConfiguration(sampleConfiguration).get(5, TimeUnit.SECONDS);
+ fail("Expected exception");
+ } catch (ExecutionException e) {
+ assertTrue("Should contain RuntimeException", e.getCause() instanceof RuntimeException);
+ assertTrue(
+ "Should contain error message",
+ e.getCause().getMessage().contains("Serialization failed"));
+ } catch (Exception e) {
+ assertTrue(
+ "Should be serialization failure: " + e.getMessage(),
+ e.getMessage() != null && e.getMessage().contains("Serialization failed"));
+ }
+
+ // Verify in-memory cache was NOT updated
+ assertSame(
+ "In-memory config should be unchanged after codec failure",
+ beforeSave,
+ testedStore.getConfiguration());
+ }
+
+ @Test
+ public void testSaveConfiguration_ioException() {
+ Configuration beforeSave = testedStore.getConfiguration();
+ byte[] serializedBytes = new byte[] {1, 2, 3, 4};
+
+ // Mock codec serialization
+ when(spyCodec.toBytes(sampleConfiguration)).thenReturn(serializedBytes);
+
+ // Mock IO to throw exception
+ CompletableFuture failedFuture = new CompletableFuture<>();
+ failedFuture.completeExceptionally(new RuntimeException("Write failed"));
+ when(mockByteStore.write(serializedBytes)).thenReturn(failedFuture);
+
+ // Save configuration should propagate exception
+ try {
+ testedStore.saveConfiguration(sampleConfiguration).get(5, TimeUnit.SECONDS);
+ fail("Expected ExecutionException");
+ } catch (ExecutionException e) {
+ assertTrue("Should contain RuntimeException", e.getCause() instanceof RuntimeException);
+ assertTrue(
+ "Should contain error message", e.getCause().getMessage().contains("Write failed"));
+ } catch (Exception e) {
+ fail("Unexpected exception: " + e.getMessage());
+ }
+
+ // Verify in-memory cache was NOT updated
+ assertSame(
+ "In-memory config should be unchanged after IO failure",
+ beforeSave,
+ testedStore.getConfiguration());
+ }
+
+ @Test
+ public void testLoadFromStorage_codecException() {
+ byte[] storedBytes = new byte[] {1, 2, 3, 4};
+
+ // Mock IO read
+ when(mockByteStore.read()).thenReturn(CompletableFuture.completedFuture(storedBytes));
+
+ // Load should propagate exception
+ try {
+ testedStore.loadFromStorage().get(5, TimeUnit.SECONDS);
+ fail("Expected ExecutionException");
+ } catch (ExecutionException e) {
+ assertTrue("Should contain RuntimeException", e.getCause() instanceof RuntimeException);
+ assertEquals("Failed to deserialize configuration", e.getCause().getMessage());
+ } catch (Exception e) {
+ fail("Unexpected exception: " + e.getMessage());
+ }
+ }
+
+ @Test
+ public void testLoadFromStorage_ioException() {
+ // Mock IO to throw exception
+ CompletableFuture failedFuture = new CompletableFuture<>();
+ failedFuture.completeExceptionally(new RuntimeException("Read failed"));
+ when(mockByteStore.read()).thenReturn(failedFuture);
+
+ // Load should propagate exception
+ try {
+ testedStore.loadFromStorage().get(5, TimeUnit.SECONDS);
+ fail("Expected ExecutionException");
+ } catch (ExecutionException e) {
+ assertTrue("Should contain RuntimeException", e.getCause() instanceof RuntimeException);
+ assertTrue("Should contain error message", e.getCause().getMessage().contains("Read failed"));
+ } catch (Exception e) {
+ fail("Unexpected exception: " + e.getMessage());
+ }
+ }
+
+ @Test
+ public void testThreadSafety_concurrentSaves() throws Exception {
+ int numThreads = 10;
+ CountDownLatch startLatch = new CountDownLatch(1);
+ CountDownLatch doneLatch = new CountDownLatch(numThreads);
+ List> futures = new ArrayList<>();
+ AtomicInteger errorCount = new AtomicInteger(0);
+
+ // Mock codec and IO for successful operations
+ when(mockByteStore.write(any())).thenReturn(CompletableFuture.completedFuture(null));
+
+ // Start multiple threads saving configurations concurrently
+ for (int i = 0; i < numThreads; i++) {
+ final int threadId = i;
+ Thread thread =
+ new Thread(
+ () -> {
+ try {
+ startLatch.await(); // Wait for all threads to be ready
+ Configuration config = Configuration.emptyConfig();
+ CompletableFuture future = testedStore.saveConfiguration(config);
+ synchronized (futures) {
+ futures.add(future);
+ }
+ } catch (Exception e) {
+ errorCount.incrementAndGet();
+ fail("Unexpected exception in thread " + threadId + ": " + e.getMessage());
+ } finally {
+ doneLatch.countDown();
+ }
+ });
+ thread.start();
+ }
+
+ // Start all threads at once
+ startLatch.countDown();
+
+ // Wait for all threads to complete
+ doneLatch.await();
+
+ // Wait for all futures to complete
+ for (CompletableFuture future : futures) {
+ future.get(5, TimeUnit.SECONDS);
+ }
+
+ // Verify all operations completed without errors
+ assertEquals("Should have no errors", 0, errorCount.get());
+ Configuration finalConfig = testedStore.getConfiguration();
+ assertNotNull("Final configuration should not be null", finalConfig);
+ assertEquals("Final config should be empty config", Configuration.emptyConfig(), finalConfig);
+ }
+
+ @Test
+ public void testThreadSafety_concurrentLoadAndSave() throws Exception {
+ // Mock byte store
+ when(mockByteStore.read())
+ .thenReturn(CompletableFuture.completedFuture(sampleConfigurationBytes));
+
+ when(mockByteStore.write(sampleConfigurationBytes))
+ .thenReturn(CompletableFuture.completedFuture(null));
+
+ // Run concurrent load and save operations (reduced iterations for performance)
+ CountDownLatch startLatch = new CountDownLatch(1);
+ CountDownLatch doneLatch = new CountDownLatch(2);
+ AtomicInteger errorCount = new AtomicInteger(0);
+
+ Thread loadThread =
+ new Thread(
+ () -> {
+ try {
+ startLatch.await();
+ for (int i = 0; i < 20; i++) {
+ testedStore.loadFromStorage().get(5, TimeUnit.SECONDS);
+ }
+ } catch (Exception e) {
+ errorCount.incrementAndGet();
+ fail("Unexpected exception in load thread: " + e.getMessage());
+ } finally {
+ doneLatch.countDown();
+ }
+ });
+
+ Thread saveThread =
+ new Thread(
+ () -> {
+ try {
+ startLatch.await();
+ for (int i = 0; i < 20; i++) {
+ testedStore.saveConfiguration(sampleConfiguration).get(5, TimeUnit.SECONDS);
+ }
+ } catch (Exception e) {
+ errorCount.incrementAndGet();
+ fail("Unexpected exception in save thread: " + e.getMessage());
+ } finally {
+ doneLatch.countDown();
+ }
+ });
+
+ loadThread.start();
+ saveThread.start();
+ startLatch.countDown();
+
+ // Wait for completion
+ doneLatch.await();
+
+ // Verify no exceptions and store is in valid state
+ assertEquals("Should have no errors", 0, errorCount.get());
+ assertNotNull("Store should have valid configuration", testedStore.getConfiguration());
+ }
+}
diff --git a/android-sdk-framework/src/test/java/cloud/eppo/android/framework/storage/ConfigurationCodecTest.java b/android-sdk-framework/src/test/java/cloud/eppo/android/framework/storage/ConfigurationCodecTest.java
new file mode 100644
index 0000000..f154cf8
--- /dev/null
+++ b/android-sdk-framework/src/test/java/cloud/eppo/android/framework/storage/ConfigurationCodecTest.java
@@ -0,0 +1,111 @@
+package cloud.eppo.android.framework.storage;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import cloud.eppo.api.Configuration;
+import cloud.eppo.api.SerializableEppoConfiguration;
+import java.io.ByteArrayOutputStream;
+import java.io.ObjectOutputStream;
+import java.nio.charset.StandardCharsets;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+
+/** Unit tests for {@link ConfigurationCodec} and {@link ConfigurationCodec.Default}. */
+@RunWith(RobolectricTestRunner.class)
+public class ConfigurationCodecTest {
+
+ private ConfigurationCodec codec;
+
+ @Before
+ public void setUp() {
+ codec = new ConfigurationCodec.Default<>(SerializableEppoConfiguration.class);
+ }
+
+ @Test
+ public void getContentType_returnsJavaSerializedObject() {
+ assertEquals("application/x-java-serialized-object", codec.getContentType());
+ }
+
+ @Test
+ public void toBytes_nullConfiguration_throwsIllegalArgumentException() {
+ try {
+ codec.toBytes(null);
+ fail("Expected IllegalArgumentException");
+ } catch (IllegalArgumentException e) {
+ assertTrue(
+ "Exception should mention null constraint", e.getMessage().contains("must not be null"));
+ }
+ }
+
+ @Test
+ public void fromBytes_nullBytes_throwsIllegalArgumentException() {
+ try {
+ codec.fromBytes(null);
+ fail("Expected IllegalArgumentException");
+ } catch (IllegalArgumentException e) {
+ assertTrue(
+ "Exception should mention null constraint", e.getMessage().contains("must not be null"));
+ }
+ }
+
+ @Test
+ public void fromBytes_invalidData_throwsRuntimeException() {
+ byte[] invalid = "not java serialized data".getBytes(StandardCharsets.UTF_8);
+ try {
+ codec.fromBytes(invalid);
+ fail("Expected RuntimeException");
+ } catch (RuntimeException e) {
+ assertTrue(
+ "Exception should mention deserialization failure",
+ e.getMessage().contains("deserialize"));
+ }
+ }
+
+ @Test
+ public void fromBytes_emptyArray_throwsRuntimeException() {
+ try {
+ codec.fromBytes(new byte[0]);
+ fail("Expected RuntimeException");
+ } catch (RuntimeException e) {
+ assertNotNull("Exception message should not be null", e.getMessage());
+ assertTrue(
+ "Exception should mention deserialization failure",
+ e.getMessage().contains("deserialize"));
+ }
+ }
+
+ @Test
+ public void fromBytes_javaSerializedWrongType_throwsRuntimeException() throws Exception {
+ ByteArrayOutputStream baos = new ByteArrayOutputStream();
+ try (ObjectOutputStream oos = new ObjectOutputStream(baos)) {
+ oos.writeObject("not a Configuration");
+ }
+ byte[] bytes = baos.toByteArray();
+ try {
+ codec.fromBytes(bytes);
+ fail("Expected RuntimeException (deserialized object is not correct type)");
+ } catch (RuntimeException e) {
+ assertTrue(
+ "Exception should mention type mismatch",
+ e.getMessage().contains("not a SerializableEppoConfiguration"));
+ }
+ }
+
+ @Test
+ public void roundTrip_serializeAndDeserialize_succeeds() {
+ SerializableEppoConfiguration original =
+ (SerializableEppoConfiguration) Configuration.emptyConfig();
+ byte[] bytes = codec.toBytes(original);
+ assertNotNull(bytes);
+ assertTrue(bytes.length > 0);
+
+ SerializableEppoConfiguration deserialized = codec.fromBytes(bytes);
+ assertNotNull(deserialized);
+ assertEquals("Deserialized should equal original", original, deserialized);
+ }
+}
diff --git a/android-sdk-framework/src/test/java/cloud/eppo/android/framework/storage/FileBackedByteStoreTest.java b/android-sdk-framework/src/test/java/cloud/eppo/android/framework/storage/FileBackedByteStoreTest.java
new file mode 100644
index 0000000..9b28d9f
--- /dev/null
+++ b/android-sdk-framework/src/test/java/cloud/eppo/android/framework/storage/FileBackedByteStoreTest.java
@@ -0,0 +1,192 @@
+package cloud.eppo.android.framework.storage;
+
+import static org.junit.Assert.assertArrayEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+import android.app.Application;
+import java.nio.charset.StandardCharsets;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.TimeUnit;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.RuntimeEnvironment;
+
+/** Unit tests for {@link FileBackedByteStore}. */
+@RunWith(RobolectricTestRunner.class)
+public class FileBackedByteStoreTest {
+
+ private Application application;
+ private ConfigCacheFile cacheFile;
+ private FileBackedByteStore byteStore;
+
+ @Before
+ public void setUp() {
+ application = RuntimeEnvironment.getApplication();
+ cacheFile = new ConfigCacheFile(application, "test-cache-file", "dat");
+ byteStore = new FileBackedByteStore(cacheFile);
+
+ cacheFile.delete();
+ }
+
+ @After
+ public void tearDown() {
+ cacheFile.delete();
+ }
+
+ @Test(expected = IllegalArgumentException.class)
+ public void testConstructorNullCacheFile() {
+ new FileBackedByteStore(null);
+ }
+
+ @Test(expected = IllegalArgumentException.class)
+ public void testWriteNullBytes() {
+ byteStore.write(null);
+ }
+
+ @Test
+ public void testReadNonExistentReturnsNull() throws Exception {
+ CompletableFuture future = byteStore.read();
+ byte[] result = future.get(5, TimeUnit.SECONDS);
+ assertNull("Expected null for non-existent file", result);
+ }
+
+ @Test
+ public void testWriteThenRead() throws Exception {
+ byte[] testData = "Hello, World!".getBytes(StandardCharsets.UTF_8);
+
+ // Write data
+ CompletableFuture writeFuture = byteStore.write(testData);
+ writeFuture.get(5, TimeUnit.SECONDS); // Wait for write to complete
+
+ // Read data back
+ CompletableFuture readFuture = byteStore.read();
+ byte[] result = readFuture.get(5, TimeUnit.SECONDS);
+
+ assertNotNull("Expected non-null result", result);
+ assertArrayEquals("Data should match", testData, result);
+ }
+
+ @Test
+ public void testReadWriteRoundTrip() throws Exception {
+ byte[] originalData = "Test data for round trip".getBytes(StandardCharsets.UTF_8);
+
+ // Write
+ byteStore.write(originalData).get(5, TimeUnit.SECONDS);
+
+ // Read
+ byte[] readData = byteStore.read().get(5, TimeUnit.SECONDS);
+
+ assertNotNull("Read data should not be null", readData);
+ assertArrayEquals("Round trip should preserve data", originalData, readData);
+ }
+
+ @Test
+ public void testAsyncReadWrite() throws Exception {
+ byte[] data1 = "First write".getBytes(StandardCharsets.UTF_8);
+ byte[] data2 = "Second write".getBytes(StandardCharsets.UTF_8);
+
+ // First write
+ byteStore.write(data1).get(5, TimeUnit.SECONDS);
+ byte[] read1 = byteStore.read().get(5, TimeUnit.SECONDS);
+ assertArrayEquals("First read should match first write", data1, read1);
+
+ // Second write (overwrite)
+ byteStore.write(data2).get(5, TimeUnit.SECONDS);
+ byte[] read2 = byteStore.read().get(5, TimeUnit.SECONDS);
+ assertArrayEquals("Second read should match second write", data2, read2);
+ }
+
+ @Test
+ public void testOverwriteExistingData() throws Exception {
+ byte[] initialData = "Initial content".getBytes(StandardCharsets.UTF_8);
+ byte[] newData = "New content".getBytes(StandardCharsets.UTF_8);
+
+ // Write initial data
+ byteStore.write(initialData).get(5, TimeUnit.SECONDS);
+
+ // Verify initial data
+ byte[] readInitial = byteStore.read().get(5, TimeUnit.SECONDS);
+ assertArrayEquals("Initial data should be readable", initialData, readInitial);
+
+ // Overwrite with new data
+ byteStore.write(newData).get(5, TimeUnit.SECONDS);
+
+ // Verify new data
+ byte[] readNew = byteStore.read().get(5, TimeUnit.SECONDS);
+ assertArrayEquals("New data should overwrite old data", newData, readNew);
+ }
+
+ @Test
+ public void testWriteEmptyByteArray() throws Exception {
+ byte[] emptyData = new byte[0];
+
+ // Write empty array
+ byteStore.write(emptyData).get(5, TimeUnit.SECONDS);
+
+ // Read back
+ byte[] result = byteStore.read().get(5, TimeUnit.SECONDS);
+ assertNotNull("Should be able to read empty array", result);
+ assertArrayEquals("Empty array should round trip", emptyData, result);
+ }
+
+ @Test
+ public void testWriteLargeData() throws Exception {
+ // Create 1MB of test data
+ byte[] largeData = new byte[1024 * 1024];
+ for (int i = 0; i < largeData.length; i++) {
+ largeData[i] = (byte) (i % 256);
+ }
+
+ // Write large data
+ byteStore.write(largeData).get(10, TimeUnit.SECONDS);
+
+ // Read back
+ byte[] result = byteStore.read().get(10, TimeUnit.SECONDS);
+ assertNotNull("Should be able to read large data", result);
+ assertArrayEquals("Large data should round trip", largeData, result);
+ }
+
+ @Test
+ public void testReadAfterDelete() throws Exception {
+ byte[] testData = "Test data".getBytes(StandardCharsets.UTF_8);
+
+ // Write data
+ byteStore.write(testData).get(5, TimeUnit.SECONDS);
+
+ // Verify write
+ byte[] read1 = byteStore.read().get(5, TimeUnit.SECONDS);
+ assertNotNull("Data should exist", read1);
+
+ // Delete file
+ cacheFile.delete();
+
+ // Read after delete should return null
+ byte[] read2 = byteStore.read().get(5, TimeUnit.SECONDS);
+ assertNull("Read after delete should return null", read2);
+ }
+
+ @Test
+ public void testConcurrentWrites() throws Exception {
+ byte[] data1 = "Data 1".getBytes(StandardCharsets.UTF_8);
+ byte[] data2 = "Data 2".getBytes(StandardCharsets.UTF_8);
+
+ // Start two writes concurrently
+ CompletableFuture write1 = byteStore.write(data1);
+ CompletableFuture write2 = byteStore.write(data2);
+
+ // Wait for both to complete
+ CompletableFuture.allOf(write1, write2).get(5, TimeUnit.SECONDS);
+
+ // Read result - should be one of the two writes
+ byte[] result = byteStore.read().get(5, TimeUnit.SECONDS);
+ assertNotNull("Result should not be null", result);
+ assertTrue(
+ "Result should be one of the written values",
+ java.util.Arrays.equals(data1, result) || java.util.Arrays.equals(data2, result));
+ }
+}
diff --git a/android-sdk-framework/src/test/java/cloud/eppo/android/framework/storage/FileBackedConfigStoreTest.java b/android-sdk-framework/src/test/java/cloud/eppo/android/framework/storage/FileBackedConfigStoreTest.java
new file mode 100644
index 0000000..0218d54
--- /dev/null
+++ b/android-sdk-framework/src/test/java/cloud/eppo/android/framework/storage/FileBackedConfigStoreTest.java
@@ -0,0 +1,78 @@
+package cloud.eppo.android.framework.storage;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+
+import android.app.Application;
+import cloud.eppo.api.Configuration;
+import java.util.concurrent.TimeUnit;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.RuntimeEnvironment;
+
+/** Unit tests for {@link FileBackedConfigStore}. */
+@RunWith(RobolectricTestRunner.class)
+public class FileBackedConfigStoreTest {
+
+ private Application application;
+ private ConfigurationCodec codec;
+ private String cacheFileSuffix;
+
+ @Before
+ public void setUp() {
+ application = RuntimeEnvironment.getApplication();
+ codec = new ConfigurationCodec.Default<>(Configuration.class);
+ cacheFileSuffix = "test-" + System.currentTimeMillis();
+ }
+
+ @Test
+ public void construct_withValidArgs_succeeds() {
+ FileBackedConfigStore store = new FileBackedConfigStore(application, cacheFileSuffix, codec);
+
+ assertNotNull(store);
+ }
+
+ @Test
+ public void getConfiguration_beforeAnySave_returnsEmptyConfig() {
+ FileBackedConfigStore store = new FileBackedConfigStore(application, cacheFileSuffix, codec);
+
+ Configuration config = store.getConfiguration();
+
+ assertNotNull(config);
+ assertEquals(Configuration.emptyConfig(), config);
+ }
+
+ @Test
+ public void saveConfiguration_thenGetConfiguration_returnsSavedConfig() throws Exception {
+ FileBackedConfigStore store = new FileBackedConfigStore(application, cacheFileSuffix, codec);
+ Configuration toSave = Configuration.emptyConfig();
+
+ store.saveConfiguration(toSave).get(5, TimeUnit.SECONDS);
+
+ assertEquals(toSave, store.getConfiguration());
+ }
+
+ @Test
+ public void loadFromStorage_whenNothingSaved_returnsNull() throws Exception {
+ FileBackedConfigStore store = new FileBackedConfigStore(application, cacheFileSuffix, codec);
+
+ Configuration loaded = store.loadFromStorage().get(5, TimeUnit.SECONDS);
+
+ assertNull(loaded);
+ }
+
+ @Test
+ public void saveConfiguration_thenLoadFromStorage_returnsSameConfig() throws Exception {
+ FileBackedConfigStore store = new FileBackedConfigStore(application, cacheFileSuffix, codec);
+ Configuration toSave = Configuration.emptyConfig();
+
+ store.saveConfiguration(toSave).get(5, TimeUnit.SECONDS);
+ Configuration loaded = store.loadFromStorage().get(5, TimeUnit.SECONDS);
+
+ assertNotNull(loaded);
+ assertEquals(toSave, loaded);
+ }
+}
diff --git a/android-sdk-framework/src/test/resources/flags-v1.json b/android-sdk-framework/src/test/resources/flags-v1.json
new file mode 100644
index 0000000..b882934
--- /dev/null
+++ b/android-sdk-framework/src/test/resources/flags-v1.json
@@ -0,0 +1,3382 @@
+{
+ "createdAt": "2024-04-17T19:40:53.716Z",
+ "format": "SERVER",
+ "environment": {
+ "name": "Test"
+ },
+ "flags": {
+ "empty_flag": {
+ "key": "empty_flag",
+ "enabled": true,
+ "variationType": "STRING",
+ "variations": {},
+ "allocations": [],
+ "totalShards": 10000
+ },
+ "disabled_flag": {
+ "key": "disabled_flag",
+ "enabled": false,
+ "variationType": "INTEGER",
+ "variations": {},
+ "allocations": [],
+ "totalShards": 10000
+ },
+ "no_allocations_flag": {
+ "key": "no_allocations_flag",
+ "enabled": true,
+ "variationType": "JSON",
+ "variations": {
+ "control": {
+ "key": "control",
+ "value": "{\"variant\": \"control\"}"
+ },
+ "treatment": {
+ "key": "treatment",
+ "value": "{\"variant\": \"treatment\"}"
+ }
+ },
+ "allocations": [],
+ "totalShards": 10000
+ },
+ "numeric_flag": {
+ "key": "numeric_flag",
+ "enabled": true,
+ "variationType": "NUMERIC",
+ "variations": {
+ "e": {
+ "key": "e",
+ "value": 2.7182818
+ },
+ "pi": {
+ "key": "pi",
+ "value": 3.1415926
+ }
+ },
+ "allocations": [
+ {
+ "key": "rollout",
+ "splits": [
+ {
+ "variationKey": "pi",
+ "shards": []
+ }
+ ],
+ "doLog": true
+ }
+ ],
+ "totalShards": 10000
+ },
+ "invalid-value-flag": {
+ "key": "invalid-value-flag",
+ "enabled": true,
+ "variationType": "INTEGER",
+ "variations": {
+ "one": {
+ "key": "one",
+ "value": 1
+ },
+ "pi": {
+ "key": "pi",
+ "value": 3.1415926
+ }
+ },
+ "allocations": [
+ {
+ "key": "valid",
+ "rules": [
+ {
+ "conditions": [
+ {
+ "attribute": "country",
+ "operator": "ONE_OF",
+ "value": [
+ "Canada"
+ ]
+ }
+ ]
+ }
+ ],
+ "splits": [
+ {
+ "variationKey": "one",
+ "shards": []
+ }
+ ],
+ "doLog": true
+ },
+ {
+ "key": "invalid",
+ "rules": [],
+ "splits": [
+ {
+ "variationKey": "pi",
+ "shards": []
+ }
+ ],
+ "doLog": true
+ }
+ ],
+ "totalShards": 10000
+ },
+ "regex-flag": {
+ "key": "regex-flag",
+ "enabled": true,
+ "variationType": "STRING",
+ "variations": {
+ "partial-example": {
+ "key": "partial-example",
+ "value": "partial-example"
+ },
+ "test": {
+ "key": "test",
+ "value": "test"
+ }
+ },
+ "allocations": [
+ {
+ "key": "partial-example",
+ "rules": [
+ {
+ "conditions": [
+ {
+ "attribute": "email",
+ "operator": "MATCHES",
+ "value": "@example\\.com"
+ }
+ ]
+ }
+ ],
+ "splits": [
+ {
+ "variationKey": "partial-example",
+ "shards": []
+ }
+ ],
+ "doLog": true
+ },
+ {
+ "key": "test",
+ "rules": [
+ {
+ "conditions": [
+ {
+ "attribute": "email",
+ "operator": "MATCHES",
+ "value": ".*@test\\.com"
+ }
+ ]
+ }
+ ],
+ "splits": [
+ {
+ "variationKey": "test",
+ "shards": []
+ }
+ ],
+ "doLog": true
+ }
+ ],
+ "totalShards": 10000
+ },
+ "numeric-one-of": {
+ "key": "numeric-one-of",
+ "enabled": true,
+ "variationType": "INTEGER",
+ "variations": {
+ "1": {
+ "key": "1",
+ "value": 1
+ },
+ "2": {
+ "key": "2",
+ "value": 2
+ },
+ "3": {
+ "key": "3",
+ "value": 3
+ }
+ },
+ "allocations": [
+ {
+ "key": "1-for-1",
+ "rules": [
+ {
+ "conditions": [
+ {
+ "attribute": "number",
+ "operator": "ONE_OF",
+ "value": [
+ "1"
+ ]
+ }
+ ]
+ }
+ ],
+ "splits": [
+ {
+ "variationKey": "1",
+ "shards": []
+ }
+ ],
+ "doLog": true
+ },
+ {
+ "key": "2-for-123456789",
+ "rules": [
+ {
+ "conditions": [
+ {
+ "attribute": "number",
+ "operator": "ONE_OF",
+ "value": [
+ "123456789"
+ ]
+ }
+ ]
+ }
+ ],
+ "splits": [
+ {
+ "variationKey": "2",
+ "shards": []
+ }
+ ],
+ "doLog": true
+ },
+ {
+ "key": "3-for-not-2",
+ "rules": [
+ {
+ "conditions": [
+ {
+ "attribute": "number",
+ "operator": "NOT_ONE_OF",
+ "value": [
+ "2"
+ ]
+ }
+ ]
+ }
+ ],
+ "splits": [
+ {
+ "variationKey": "3",
+ "shards": []
+ }
+ ],
+ "doLog": true
+ }
+ ],
+ "totalShards": 10000
+ },
+ "boolean-one-of-matches": {
+ "key": "boolean-one-of-matches",
+ "enabled": true,
+ "variationType": "INTEGER",
+ "variations": {
+ "1": {
+ "key": "1",
+ "value": 1
+ },
+ "2": {
+ "key": "2",
+ "value": 2
+ },
+ "3": {
+ "key": "3",
+ "value": 3
+ },
+ "4": {
+ "key": "4",
+ "value": 4
+ },
+ "5": {
+ "key": "5",
+ "value": 5
+ }
+ },
+ "allocations": [
+ {
+ "key": "1-for-one-of",
+ "rules": [
+ {
+ "conditions": [
+ {
+ "attribute": "one_of_flag",
+ "operator": "ONE_OF",
+ "value": [
+ "true"
+ ]
+ }
+ ]
+ }
+ ],
+ "splits": [
+ {
+ "variationKey": "1",
+ "shards": []
+ }
+ ],
+ "doLog": true
+ },
+ {
+ "key": "2-for-matches",
+ "rules": [
+ {
+ "conditions": [
+ {
+ "attribute": "matches_flag",
+ "operator": "MATCHES",
+ "value": "true"
+ }
+ ]
+ }
+ ],
+ "splits": [
+ {
+ "variationKey": "2",
+ "shards": []
+ }
+ ],
+ "doLog": true
+ },
+ {
+ "key": "3-for-not-one-of",
+ "rules": [
+ {
+ "conditions": [
+ {
+ "attribute": "not_one_of_flag",
+ "operator": "NOT_ONE_OF",
+ "value": [
+ "false"
+ ]
+ }
+ ]
+ }
+ ],
+ "splits": [
+ {
+ "variationKey": "3",
+ "shards": []
+ }
+ ],
+ "doLog": true
+ },
+ {
+ "key": "4-for-not-matches",
+ "rules": [
+ {
+ "conditions": [
+ {
+ "attribute": "not_matches_flag",
+ "operator": "NOT_MATCHES",
+ "value": "false"
+ }
+ ]
+ }
+ ],
+ "splits": [
+ {
+ "variationKey": "4",
+ "shards": []
+ }
+ ],
+ "doLog": true
+ },
+ {
+ "key": "5-for-matches-null",
+ "rules": [
+ {
+ "conditions": [
+ {
+ "attribute": "null_flag",
+ "operator": "ONE_OF",
+ "value": [
+ "null"
+ ]
+ }
+ ]
+ }
+ ],
+ "splits": [
+ {
+ "variationKey": "5",
+ "shards": []
+ }
+ ],
+ "doLog": true
+ }
+ ],
+ "totalShards": 10000
+ },
+ "boolean-false-assignment": {
+ "key": "boolean-false-assignment",
+ "enabled": true,
+ "variationType": "BOOLEAN",
+ "variations": {
+ "false-variation": {
+ "key": "false-variation",
+ "value": false
+ },
+ "true-variation": {
+ "key": "true-variation",
+ "value": true
+ }
+ },
+ "allocations": [
+ {
+ "key": "disable-feature",
+ "rules": [
+ {
+ "conditions": [
+ {
+ "attribute": "should_disable_feature",
+ "operator": "ONE_OF",
+ "value": [
+ "true"
+ ]
+ }
+ ]
+ }
+ ],
+ "splits": [
+ {
+ "variationKey": "false-variation",
+ "shards": []
+ }
+ ],
+ "doLog": true
+ },
+ {
+ "key": "enable-feature",
+ "rules": [
+ {
+ "conditions": [
+ {
+ "attribute": "should_disable_feature",
+ "operator": "ONE_OF",
+ "value": [
+ "false"
+ ]
+ }
+ ]
+ }
+ ],
+ "splits": [
+ {
+ "variationKey": "true-variation",
+ "shards": []
+ }
+ ],
+ "doLog": true
+ }
+ ],
+ "totalShards": 10000
+ },
+ "empty-string-variation": {
+ "key": "empty-string-variation",
+ "enabled": true,
+ "variationType": "STRING",
+ "variations": {
+ "empty-content": {
+ "key": "empty-content",
+ "value": ""
+ },
+ "detailed-content": {
+ "key": "detailed-content",
+ "value": "detailed_content"
+ }
+ },
+ "allocations": [
+ {
+ "key": "minimal-content",
+ "rules": [
+ {
+ "conditions": [
+ {
+ "attribute": "content_type",
+ "operator": "ONE_OF",
+ "value": [
+ "minimal"
+ ]
+ }
+ ]
+ }
+ ],
+ "splits": [
+ {
+ "variationKey": "empty-content",
+ "shards": []
+ }
+ ],
+ "doLog": true
+ },
+ {
+ "key": "full-content",
+ "rules": [
+ {
+ "conditions": [
+ {
+ "attribute": "content_type",
+ "operator": "ONE_OF",
+ "value": [
+ "full"
+ ]
+ }
+ ]
+ }
+ ],
+ "splits": [
+ {
+ "variationKey": "detailed-content",
+ "shards": []
+ }
+ ],
+ "doLog": true
+ }
+ ],
+ "totalShards": 10000
+ },
+ "empty_string_flag": {
+ "key": "empty_string_flag",
+ "enabled": true,
+ "comment": "Testing the empty string as a variation value",
+ "variationType": "STRING",
+ "variations": {
+ "empty_string": {
+ "key": "empty_string",
+ "value": ""
+ },
+ "non_empty": {
+ "key": "non_empty",
+ "value": "non_empty"
+ }
+ },
+ "allocations": [
+ {
+ "key": "allocation-empty",
+ "rules": [
+ {
+ "conditions": [
+ {
+ "attribute": "country",
+ "operator": "MATCHES",
+ "value": "US"
+ }
+ ]
+ }
+ ],
+ "splits": [
+ {
+ "variationKey": "empty_string",
+ "shards": [
+ {
+ "salt": "allocation-empty-shards",
+ "ranges": [
+ {
+ "start": 0,
+ "end": 10000
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "doLog": true
+ },
+ {
+ "key": "allocation-test",
+ "rules": [],
+ "splits": [
+ {
+ "variationKey": "non_empty",
+ "shards": [
+ {
+ "salt": "allocation-empty-shards",
+ "ranges": [
+ {
+ "start": 0,
+ "end": 10000
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "doLog": true
+ }
+ ],
+ "totalShards": 10000
+ },
+ "kill-switch": {
+ "key": "kill-switch",
+ "enabled": true,
+ "variationType": "BOOLEAN",
+ "variations": {
+ "on": {
+ "key": "on",
+ "value": true
+ },
+ "off": {
+ "key": "off",
+ "value": false
+ }
+ },
+ "allocations": [
+ {
+ "key": "on-for-NA",
+ "rules": [
+ {
+ "conditions": [
+ {
+ "attribute": "country",
+ "operator": "ONE_OF",
+ "value": [
+ "US",
+ "Canada",
+ "Mexico"
+ ]
+ }
+ ]
+ }
+ ],
+ "splits": [
+ {
+ "variationKey": "on",
+ "shards": [
+ {
+ "salt": "some-salt",
+ "ranges": [
+ {
+ "start": 0,
+ "end": 10000
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "doLog": true
+ },
+ {
+ "key": "on-for-age-50+",
+ "rules": [
+ {
+ "conditions": [
+ {
+ "attribute": "age",
+ "operator": "GTE",
+ "value": 50
+ }
+ ]
+ }
+ ],
+ "splits": [
+ {
+ "variationKey": "on",
+ "shards": [
+ {
+ "salt": "some-salt",
+ "ranges": [
+ {
+ "start": 0,
+ "end": 10000
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "doLog": true
+ },
+ {
+ "key": "off-for-all",
+ "rules": [],
+ "splits": [
+ {
+ "variationKey": "off",
+ "shards": []
+ }
+ ],
+ "doLog": true
+ }
+ ],
+ "totalShards": 10000
+ },
+ "semver-test": {
+ "key": "semver-test",
+ "enabled": true,
+ "variationType": "STRING",
+ "variations": {
+ "old": {
+ "key": "old",
+ "value": "old"
+ },
+ "current": {
+ "key": "current",
+ "value": "current"
+ },
+ "new": {
+ "key": "new",
+ "value": "new"
+ }
+ },
+ "allocations": [
+ {
+ "key": "old-versions",
+ "rules": [
+ {
+ "conditions": [
+ {
+ "attribute": "version",
+ "operator": "LT",
+ "value": "1.5.0"
+ }
+ ]
+ }
+ ],
+ "splits": [
+ {
+ "variationKey": "old",
+ "shards": []
+ }
+ ],
+ "doLog": true
+ },
+ {
+ "key": "current-versions",
+ "rules": [
+ {
+ "conditions": [
+ {
+ "attribute": "version",
+ "operator": "GTE",
+ "value": "1.5.0"
+ },
+ {
+ "attribute": "version",
+ "operator": "LTE",
+ "value": "2.2.13"
+ }
+ ]
+ }
+ ],
+ "splits": [
+ {
+ "variationKey": "current",
+ "shards": []
+ }
+ ],
+ "doLog": true
+ },
+ {
+ "key": "new-versions",
+ "rules": [
+ {
+ "conditions": [
+ {
+ "attribute": "version",
+ "operator": "GT",
+ "value": "3.1.0"
+ }
+ ]
+ }
+ ],
+ "splits": [
+ {
+ "variationKey": "new",
+ "shards": []
+ }
+ ],
+ "doLog": true
+ }
+ ],
+ "totalShards": 10000
+ },
+ "comparator-operator-test": {
+ "key": "comparator-operator-test",
+ "enabled": true,
+ "variationType": "STRING",
+ "variations": {
+ "small": {
+ "key": "small",
+ "value": "small"
+ },
+ "medium": {
+ "key": "medium",
+ "value": "medium"
+ },
+ "large": {
+ "key": "large",
+ "value": "large"
+ }
+ },
+ "allocations": [
+ {
+ "key": "small-size",
+ "rules": [
+ {
+ "conditions": [
+ {
+ "attribute": "size",
+ "operator": "LT",
+ "value": 10
+ }
+ ]
+ }
+ ],
+ "splits": [
+ {
+ "variationKey": "small",
+ "shards": []
+ }
+ ],
+ "doLog": true
+ },
+ {
+ "key": "medum-size",
+ "rules": [
+ {
+ "conditions": [
+ {
+ "attribute": "size",
+ "operator": "GTE",
+ "value": 10
+ },
+ {
+ "attribute": "size",
+ "operator": "LTE",
+ "value": 20
+ }
+ ]
+ }
+ ],
+ "splits": [
+ {
+ "variationKey": "medium",
+ "shards": []
+ }
+ ],
+ "doLog": true
+ },
+ {
+ "key": "large-size",
+ "rules": [
+ {
+ "conditions": [
+ {
+ "attribute": "size",
+ "operator": "GT",
+ "value": 25
+ }
+ ]
+ }
+ ],
+ "splits": [
+ {
+ "variationKey": "large",
+ "shards": []
+ }
+ ],
+ "doLog": true
+ }
+ ],
+ "totalShards": 10000
+ },
+ "start-and-end-date-test": {
+ "key": "start-and-end-date-test",
+ "enabled": true,
+ "variationType": "STRING",
+ "variations": {
+ "old": {
+ "key": "old",
+ "value": "old"
+ },
+ "current": {
+ "key": "current",
+ "value": "current"
+ },
+ "new": {
+ "key": "new",
+ "value": "new"
+ }
+ },
+ "allocations": [
+ {
+ "key": "old-versions",
+ "splits": [
+ {
+ "variationKey": "old",
+ "shards": []
+ }
+ ],
+ "endAt": "2002-10-31T09:00:00.594Z",
+ "doLog": true
+ },
+ {
+ "key": "future-versions",
+ "splits": [
+ {
+ "variationKey": "future",
+ "shards": []
+ }
+ ],
+ "startAt": "2052-10-31T09:00:00.594Z",
+ "doLog": true
+ },
+ {
+ "key": "current-versions",
+ "splits": [
+ {
+ "variationKey": "current",
+ "shards": []
+ }
+ ],
+ "startAt": "2022-10-31T09:00:00.594Z",
+ "endAt": "2050-10-31T09:00:00.594Z",
+ "doLog": true
+ }
+ ],
+ "totalShards": 10000
+ },
+ "null-operator-test": {
+ "key": "null-operator-test",
+ "enabled": true,
+ "variationType": "STRING",
+ "variations": {
+ "old": {
+ "key": "old",
+ "value": "old"
+ },
+ "new": {
+ "key": "new",
+ "value": "new"
+ }
+ },
+ "allocations": [
+ {
+ "key": "null-operator",
+ "rules": [
+ {
+ "conditions": [
+ {
+ "attribute": "size",
+ "operator": "IS_NULL",
+ "value": true
+ }
+ ]
+ },
+ {
+ "conditions": [
+ {
+ "attribute": "size",
+ "operator": "LT",
+ "value": 10
+ }
+ ]
+ }
+ ],
+ "splits": [
+ {
+ "variationKey": "old",
+ "shards": []
+ }
+ ],
+ "doLog": true
+ },
+ {
+ "key": "not-null-operator",
+ "rules": [
+ {
+ "conditions": [
+ {
+ "attribute": "size",
+ "operator": "IS_NULL",
+ "value": false
+ }
+ ]
+ }
+ ],
+ "splits": [
+ {
+ "variationKey": "new",
+ "shards": []
+ }
+ ],
+ "doLog": true
+ }
+ ],
+ "totalShards": 10000
+ },
+ "new-user-onboarding": {
+ "key": "new-user-onboarding",
+ "enabled": true,
+ "variationType": "STRING",
+ "variations": {
+ "control": {
+ "key": "control",
+ "value": "control"
+ },
+ "red": {
+ "key": "red",
+ "value": "red"
+ },
+ "blue": {
+ "key": "blue",
+ "value": "blue"
+ },
+ "green": {
+ "key": "green",
+ "value": "green"
+ },
+ "yellow": {
+ "key": "yellow",
+ "value": "yellow"
+ },
+ "purple": {
+ "key": "purple",
+ "value": "purple"
+ }
+ },
+ "allocations": [
+ {
+ "key": "id rule",
+ "rules": [
+ {
+ "conditions": [
+ {
+ "attribute": "id",
+ "operator": "MATCHES",
+ "value": "zach"
+ }
+ ]
+ }
+ ],
+ "splits": [
+ {
+ "variationKey": "purple",
+ "shards": []
+ }
+ ],
+ "doLog": false
+ },
+ {
+ "key": "internal users",
+ "rules": [
+ {
+ "conditions": [
+ {
+ "attribute": "email",
+ "operator": "MATCHES",
+ "value": "@mycompany.com"
+ }
+ ]
+ }
+ ],
+ "splits": [
+ {
+ "variationKey": "green",
+ "shards": []
+ }
+ ],
+ "doLog": false
+ },
+ {
+ "key": "experiment",
+ "rules": [
+ {
+ "conditions": [
+ {
+ "attribute": "country",
+ "operator": "NOT_ONE_OF",
+ "value": [
+ "US",
+ "Canada",
+ "Mexico"
+ ]
+ }
+ ]
+ }
+ ],
+ "splits": [
+ {
+ "variationKey": "control",
+ "shards": [
+ {
+ "salt": "traffic-new-user-onboarding-experiment",
+ "ranges": [
+ {
+ "start": 0,
+ "end": 6000
+ }
+ ]
+ },
+ {
+ "salt": "split-new-user-onboarding-experiment",
+ "ranges": [
+ {
+ "start": 0,
+ "end": 5000
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "variationKey": "red",
+ "shards": [
+ {
+ "salt": "traffic-new-user-onboarding-experiment",
+ "ranges": [
+ {
+ "start": 0,
+ "end": 6000
+ }
+ ]
+ },
+ {
+ "salt": "split-new-user-onboarding-experiment",
+ "ranges": [
+ {
+ "start": 5000,
+ "end": 8000
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "variationKey": "yellow",
+ "shards": [
+ {
+ "salt": "traffic-new-user-onboarding-experiment",
+ "ranges": [
+ {
+ "start": 0,
+ "end": 6000
+ }
+ ]
+ },
+ {
+ "salt": "split-new-user-onboarding-experiment",
+ "ranges": [
+ {
+ "start": 8000,
+ "end": 10000
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "doLog": true
+ },
+ {
+ "key": "rollout",
+ "rules": [
+ {
+ "conditions": [
+ {
+ "attribute": "country",
+ "operator": "ONE_OF",
+ "value": [
+ "US",
+ "Canada",
+ "Mexico"
+ ]
+ }
+ ]
+ }
+ ],
+ "splits": [
+ {
+ "variationKey": "blue",
+ "shards": [
+ {
+ "salt": "split-new-user-onboarding-rollout",
+ "ranges": [
+ {
+ "start": 0,
+ "end": 8000
+ }
+ ]
+ }
+ ],
+ "extraLogging": {
+ "allocationvalue_type": "rollout",
+ "owner": "hippo"
+ }
+ }
+ ],
+ "doLog": true
+ }
+ ],
+ "totalShards": 10000
+ },
+ "falsy-value-assignments": {
+ "key": "falsy-value-assignments",
+ "enabled": true,
+ "variationType": "INTEGER",
+ "variations": {
+ "zero-limit": {
+ "key": "zero-limit",
+ "value": 0
+ },
+ "premium-limit": {
+ "key": "premium-limit",
+ "value": 100
+ }
+ },
+ "allocations": [
+ {
+ "key": "free-tier-limit",
+ "rules": [
+ {
+ "conditions": [
+ {
+ "attribute": "plan_tier",
+ "operator": "ONE_OF",
+ "value": [
+ "free"
+ ]
+ }
+ ]
+ }
+ ],
+ "splits": [
+ {
+ "variationKey": "zero-limit",
+ "shards": []
+ }
+ ],
+ "doLog": true
+ },
+ {
+ "key": "premium-tier-limit",
+ "rules": [
+ {
+ "conditions": [
+ {
+ "attribute": "plan_tier",
+ "operator": "ONE_OF",
+ "value": [
+ "premium"
+ ]
+ }
+ ]
+ }
+ ],
+ "splits": [
+ {
+ "variationKey": "premium-limit",
+ "shards": []
+ }
+ ],
+ "doLog": true
+ }
+ ],
+ "totalShards": 10000
+ },
+ "integer-flag": {
+ "key": "integer-flag",
+ "enabled": true,
+ "variationType": "INTEGER",
+ "variations": {
+ "one": {
+ "key": "one",
+ "value": 1
+ },
+ "two": {
+ "key": "two",
+ "value": 2
+ },
+ "three": {
+ "key": "three",
+ "value": 3
+ }
+ },
+ "allocations": [
+ {
+ "key": "targeted allocation",
+ "rules": [
+ {
+ "conditions": [
+ {
+ "attribute": "country",
+ "operator": "ONE_OF",
+ "value": [
+ "US",
+ "Canada",
+ "Mexico"
+ ]
+ }
+ ]
+ },
+ {
+ "conditions": [
+ {
+ "attribute": "email",
+ "operator": "MATCHES",
+ "value": ".*@example.com"
+ }
+ ]
+ }
+ ],
+ "splits": [
+ {
+ "variationKey": "three",
+ "shards": [
+ {
+ "salt": "full-range-salt",
+ "ranges": [
+ {
+ "start": 0,
+ "end": 10000
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "doLog": true
+ },
+ {
+ "key": "50/50 split",
+ "rules": [],
+ "splits": [
+ {
+ "variationKey": "one",
+ "shards": [
+ {
+ "salt": "split-numeric-flag-some-allocation",
+ "ranges": [
+ {
+ "start": 0,
+ "end": 5000
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "variationKey": "two",
+ "shards": [
+ {
+ "salt": "split-numeric-flag-some-allocation",
+ "ranges": [
+ {
+ "start": 5000,
+ "end": 10000
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "doLog": true
+ }
+ ],
+ "totalShards": 10000
+ },
+ "json-config-flag": {
+ "key": "json-config-flag",
+ "enabled": true,
+ "variationType": "JSON",
+ "variations": {
+ "one": {
+ "key": "one",
+ "value": "{ \"integer\": 1, \"string\": \"one\", \"float\": 1.0 }"
+ },
+ "two": {
+ "key": "two",
+ "value": "{ \"integer\": 2, \"string\": \"two\", \"float\": 2.0 }"
+ },
+ "empty": {
+ "key": "empty",
+ "value": "{}"
+ }
+ },
+ "allocations": [
+ {
+ "key": "Optionally Force Empty",
+ "rules": [
+ {
+ "conditions": [
+ {
+ "attribute": "Force Empty",
+ "operator": "ONE_OF",
+ "value": [
+ "true"
+ ]
+ }
+ ]
+ }
+ ],
+ "splits": [
+ {
+ "variationKey": "empty",
+ "shards": [
+ {
+ "salt": "full-range-salt",
+ "ranges": [
+ {
+ "start": 0,
+ "end": 10000
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "doLog": true
+ },
+ {
+ "key": "50/50 split",
+ "rules": [],
+ "splits": [
+ {
+ "variationKey": "one",
+ "shards": [
+ {
+ "salt": "traffic-json-flag",
+ "ranges": [
+ {
+ "start": 0,
+ "end": 10000
+ }
+ ]
+ },
+ {
+ "salt": "split-json-flag",
+ "ranges": [
+ {
+ "start": 0,
+ "end": 5000
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "variationKey": "two",
+ "shards": [
+ {
+ "salt": "traffic-json-flag",
+ "ranges": [
+ {
+ "start": 0,
+ "end": 10000
+ }
+ ]
+ },
+ {
+ "salt": "split-json-flag",
+ "ranges": [
+ {
+ "start": 5000,
+ "end": 10000
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "doLog": true
+ }
+ ],
+ "totalShards": 10000
+ },
+ "special-characters": {
+ "key": "special-characters",
+ "enabled": true,
+ "variationType": "JSON",
+ "variations": {
+ "de": {
+ "key": "de",
+ "value": "{\"a\": \"kümmert\", \"b\": \"schön\"}"
+ },
+ "ua": {
+ "key": "ua",
+ "value": "{\"a\": \"піклуватися\", \"b\": \"любов\"}"
+ },
+ "zh": {
+ "key": "zh",
+ "value": "{\"a\": \"照顾\", \"b\": \"漂亮\"}"
+ },
+ "emoji": {
+ "key": "emoji",
+ "value": "{\"a\": \"🤗\", \"b\": \"🌸\"}"
+ }
+ },
+ "totalShards": 10000,
+ "allocations": [
+ {
+ "key": "allocation-test",
+ "splits": [
+ {
+ "variationKey": "de",
+ "shards": [
+ {
+ "salt": "split-json-flag",
+ "ranges": [
+ {
+ "start": 0,
+ "end": 2500
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "variationKey": "ua",
+ "shards": [
+ {
+ "salt": "split-json-flag",
+ "ranges": [
+ {
+ "start": 2500,
+ "end": 5000
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "variationKey": "zh",
+ "shards": [
+ {
+ "salt": "split-json-flag",
+ "ranges": [
+ {
+ "start": 5000,
+ "end": 7500
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "variationKey": "emoji",
+ "shards": [
+ {
+ "salt": "split-json-flag",
+ "ranges": [
+ {
+ "start": 7500,
+ "end": 10000
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "doLog": true
+ },
+ {
+ "key": "allocation-default",
+ "splits": [
+ {
+ "variationKey": "de",
+ "shards": []
+ }
+ ],
+ "doLog": false
+ }
+ ]
+ },
+ "string_flag_with_special_characters": {
+ "key": "string_flag_with_special_characters",
+ "enabled": true,
+ "comment": "Testing the string with special characters and spaces",
+ "variationType": "STRING",
+ "variations": {
+ "string_with_spaces": {
+ "key": "string_with_spaces",
+ "value": " a b c d e f "
+ },
+ "string_with_only_one_space": {
+ "key": "string_with_only_one_space",
+ "value": " "
+ },
+ "string_with_only_multiple_spaces": {
+ "key": "string_with_only_multiple_spaces",
+ "value": " "
+ },
+ "string_with_dots": {
+ "key": "string_with_dots",
+ "value": ".a.b.c.d.e.f."
+ },
+ "string_with_only_one_dot": {
+ "key": "string_with_only_one_dot",
+ "value": "."
+ },
+ "string_with_only_multiple_dots": {
+ "key": "string_with_only_multiple_dots",
+ "value": "......."
+ },
+ "string_with_comas": {
+ "key": "string_with_comas",
+ "value": ",a,b,c,d,e,f,"
+ },
+ "string_with_only_one_coma": {
+ "key": "string_with_only_one_coma",
+ "value": ","
+ },
+ "string_with_only_multiple_comas": {
+ "key": "string_with_only_multiple_comas",
+ "value": ",,,,,,,"
+ },
+ "string_with_colons": {
+ "key": "string_with_colons",
+ "value": ":a:b:c:d:e:f:"
+ },
+ "string_with_only_one_colon": {
+ "key": "string_with_only_one_colon",
+ "value": ":"
+ },
+ "string_with_only_multiple_colons": {
+ "key": "string_with_only_multiple_colons",
+ "value": ":::::::"
+ },
+ "string_with_semicolons": {
+ "key": "string_with_semicolons",
+ "value": ";a;b;c;d;e;f;"
+ },
+ "string_with_only_one_semicolon": {
+ "key": "string_with_only_one_semicolon",
+ "value": ";"
+ },
+ "string_with_only_multiple_semicolons": {
+ "key": "string_with_only_multiple_semicolons",
+ "value": ";;;;;;;"
+ },
+ "string_with_slashes": {
+ "key": "string_with_slashes",
+ "value": "/a/b/c/d/e/f/"
+ },
+ "string_with_only_one_slash": {
+ "key": "string_with_only_one_slash",
+ "value": "/"
+ },
+ "string_with_only_multiple_slashes": {
+ "key": "string_with_only_multiple_slashes",
+ "value": "///////"
+ },
+ "string_with_dashes": {
+ "key": "string_with_dashes",
+ "value": "-a-b-c-d-e-f-"
+ },
+ "string_with_only_one_dash": {
+ "key": "string_with_only_one_dash",
+ "value": "-"
+ },
+ "string_with_only_multiple_dashes": {
+ "key": "string_with_only_multiple_dashes",
+ "value": "-------"
+ },
+ "string_with_underscores": {
+ "key": "string_with_underscores",
+ "value": "_a_b_c_d_e_f_"
+ },
+ "string_with_only_one_underscore": {
+ "key": "string_with_only_one_underscore",
+ "value": "_"
+ },
+ "string_with_only_multiple_underscores": {
+ "key": "string_with_only_multiple_underscores",
+ "value": "_______"
+ },
+ "string_with_plus_signs": {
+ "key": "string_with_plus_signs",
+ "value": "+a+b+c+d+e+f+"
+ },
+ "string_with_only_one_plus_sign": {
+ "key": "string_with_only_one_plus_sign",
+ "value": "+"
+ },
+ "string_with_only_multiple_plus_signs": {
+ "key": "string_with_only_multiple_plus_signs",
+ "value": "+++++++"
+ },
+ "string_with_equal_signs": {
+ "key": "string_with_equal_signs",
+ "value": "=a=b=c=d=e=f="
+ },
+ "string_with_only_one_equal_sign": {
+ "key": "string_with_only_one_equal_sign",
+ "value": "="
+ },
+ "string_with_only_multiple_equal_signs": {
+ "key": "string_with_only_multiple_equal_signs",
+ "value": "======="
+ },
+ "string_with_dollar_signs": {
+ "key": "string_with_dollar_signs",
+ "value": "$a$b$c$d$e$f$"
+ },
+ "string_with_only_one_dollar_sign": {
+ "key": "string_with_only_one_dollar_sign",
+ "value": "$"
+ },
+ "string_with_only_multiple_dollar_signs": {
+ "key": "string_with_only_multiple_dollar_signs",
+ "value": "$$$$$$$"
+ },
+ "string_with_at_signs": {
+ "key": "string_with_at_signs",
+ "value": "@a@b@c@d@e@f@"
+ },
+ "string_with_only_one_at_sign": {
+ "key": "string_with_only_one_at_sign",
+ "value": "@"
+ },
+ "string_with_only_multiple_at_signs": {
+ "key": "string_with_only_multiple_at_signs",
+ "value": "@@@@@@@"
+ },
+ "string_with_amp_signs": {
+ "key": "string_with_amp_signs",
+ "value": "&a&b&c&d&e&f&"
+ },
+ "string_with_only_one_amp_sign": {
+ "key": "string_with_only_one_amp_sign",
+ "value": "&"
+ },
+ "string_with_only_multiple_amp_signs": {
+ "key": "string_with_only_multiple_amp_signs",
+ "value": "&&&&&&&"
+ },
+ "string_with_hash_signs": {
+ "key": "string_with_hash_signs",
+ "value": "#a#b#c#d#e#f#"
+ },
+ "string_with_only_one_hash_sign": {
+ "key": "string_with_only_one_hash_sign",
+ "value": "#"
+ },
+ "string_with_only_multiple_hash_signs": {
+ "key": "string_with_only_multiple_hash_signs",
+ "value": "#######"
+ },
+ "string_with_percentage_signs": {
+ "key": "string_with_percentage_signs",
+ "value": "%a%b%c%d%e%f%"
+ },
+ "string_with_only_one_percentage_sign": {
+ "key": "string_with_only_one_percentage_sign",
+ "value": "%"
+ },
+ "string_with_only_multiple_percentage_signs": {
+ "key": "string_with_only_multiple_percentage_signs",
+ "value": "%%%%%%%"
+ },
+ "string_with_tilde_signs": {
+ "key": "string_with_tilde_signs",
+ "value": "~a~b~c~d~e~f~"
+ },
+ "string_with_only_one_tilde_sign": {
+ "key": "string_with_only_one_tilde_sign",
+ "value": "~"
+ },
+ "string_with_only_multiple_tilde_signs": {
+ "key": "string_with_only_multiple_tilde_signs",
+ "value": "~~~~~~~"
+ },
+ "string_with_asterix_signs": {
+ "key": "string_with_asterix_signs",
+ "value": "*a*b*c*d*e*f*"
+ },
+ "string_with_only_one_asterix_sign": {
+ "key": "string_with_only_one_asterix_sign",
+ "value": "*"
+ },
+ "string_with_only_multiple_asterix_signs": {
+ "key": "string_with_only_multiple_asterix_signs",
+ "value": "*******"
+ },
+ "string_with_single_quotes": {
+ "key": "string_with_single_quotes",
+ "value": "'a'b'c'd'e'f'"
+ },
+ "string_with_only_one_single_quote": {
+ "key": "string_with_only_one_single_quote",
+ "value": "'"
+ },
+ "string_with_only_multiple_single_quotes": {
+ "key": "string_with_only_multiple_single_quotes",
+ "value": "'''''''"
+ },
+ "string_with_question_marks": {
+ "key": "string_with_question_marks",
+ "value": "?a?b?c?d?e?f?"
+ },
+ "string_with_only_one_question_mark": {
+ "key": "string_with_only_one_question_mark",
+ "value": "?"
+ },
+ "string_with_only_multiple_question_marks": {
+ "key": "string_with_only_multiple_question_marks",
+ "value": "???????"
+ },
+ "string_with_exclamation_marks": {
+ "key": "string_with_exclamation_marks",
+ "value": "!a!b!c!d!e!f!"
+ },
+ "string_with_only_one_exclamation_mark": {
+ "key": "string_with_only_one_exclamation_mark",
+ "value": "!"
+ },
+ "string_with_only_multiple_exclamation_marks": {
+ "key": "string_with_only_multiple_exclamation_marks",
+ "value": "!!!!!!!"
+ },
+ "string_with_opening_parentheses": {
+ "key": "string_with_opening_parentheses",
+ "value": "(a(b(c(d(e(f("
+ },
+ "string_with_only_one_opening_parenthese": {
+ "key": "string_with_only_one_opening_parenthese",
+ "value": "("
+ },
+ "string_with_only_multiple_opening_parentheses": {
+ "key": "string_with_only_multiple_opening_parentheses",
+ "value": "((((((("
+ },
+ "string_with_closing_parentheses": {
+ "key": "string_with_closing_parentheses",
+ "value": ")a)b)c)d)e)f)"
+ },
+ "string_with_only_one_closing_parenthese": {
+ "key": "string_with_only_one_closing_parenthese",
+ "value": ")"
+ },
+ "string_with_only_multiple_closing_parentheses": {
+ "key": "string_with_only_multiple_closing_parentheses",
+ "value": ")))))))"
+ }
+ },
+ "totalShards": 10000,
+ "allocations": [
+ {
+ "key": "allocation-test-string_with_spaces",
+ "rules": [
+ {
+ "conditions": [
+ {
+ "attribute": "string_with_spaces",
+ "operator": "ONE_OF",
+ "value": [
+ "true"
+ ]
+ }
+ ]
+ }
+ ],
+ "splits": [
+ {
+ "variationKey": "string_with_spaces",
+ "shards": []
+ }
+ ],
+ "doLog": true
+ },
+ {
+ "key": "allocation-test-string_with_only_one_space",
+ "rules": [
+ {
+ "conditions": [
+ {
+ "attribute": "string_with_only_one_space",
+ "operator": "ONE_OF",
+ "value": [
+ "true"
+ ]
+ }
+ ]
+ }
+ ],
+ "splits": [
+ {
+ "variationKey": "string_with_only_one_space",
+ "shards": []
+ }
+ ],
+ "doLog": true
+ },
+ {
+ "key": "allocation-test-string_with_only_multiple_spaces",
+ "rules": [
+ {
+ "conditions": [
+ {
+ "attribute": "string_with_only_multiple_spaces",
+ "operator": "ONE_OF",
+ "value": [
+ "true"
+ ]
+ }
+ ]
+ }
+ ],
+ "splits": [
+ {
+ "variationKey": "string_with_only_multiple_spaces",
+ "shards": []
+ }
+ ],
+ "doLog": true
+ },
+ {
+ "key": "allocation-test-string_with_dots",
+ "rules": [
+ {
+ "conditions": [
+ {
+ "attribute": "string_with_dots",
+ "operator": "ONE_OF",
+ "value": [
+ "true"
+ ]
+ }
+ ]
+ }
+ ],
+ "splits": [
+ {
+ "variationKey": "string_with_dots",
+ "shards": []
+ }
+ ],
+ "doLog": true
+ },
+ {
+ "key": "allocation-test-string_with_only_one_dot",
+ "rules": [
+ {
+ "conditions": [
+ {
+ "attribute": "string_with_only_one_dot",
+ "operator": "ONE_OF",
+ "value": [
+ "true"
+ ]
+ }
+ ]
+ }
+ ],
+ "splits": [
+ {
+ "variationKey": "string_with_only_one_dot",
+ "shards": []
+ }
+ ],
+ "doLog": true
+ },
+ {
+ "key": "allocation-test-string_with_only_multiple_dots",
+ "rules": [
+ {
+ "conditions": [
+ {
+ "attribute": "string_with_only_multiple_dots",
+ "operator": "ONE_OF",
+ "value": [
+ "true"
+ ]
+ }
+ ]
+ }
+ ],
+ "splits": [
+ {
+ "variationKey": "string_with_only_multiple_dots",
+ "shards": []
+ }
+ ],
+ "doLog": true
+ },
+ {
+ "key": "allocation-test-string_with_comas",
+ "rules": [
+ {
+ "conditions": [
+ {
+ "attribute": "string_with_comas",
+ "operator": "ONE_OF",
+ "value": [
+ "true"
+ ]
+ }
+ ]
+ }
+ ],
+ "splits": [
+ {
+ "variationKey": "string_with_comas",
+ "shards": []
+ }
+ ],
+ "doLog": true
+ },
+ {
+ "key": "allocation-test-string_with_only_one_coma",
+ "rules": [
+ {
+ "conditions": [
+ {
+ "attribute": "string_with_only_one_coma",
+ "operator": "ONE_OF",
+ "value": [
+ "true"
+ ]
+ }
+ ]
+ }
+ ],
+ "splits": [
+ {
+ "variationKey": "string_with_only_one_coma",
+ "shards": []
+ }
+ ],
+ "doLog": true
+ },
+ {
+ "key": "allocation-test-string_with_only_multiple_comas",
+ "rules": [
+ {
+ "conditions": [
+ {
+ "attribute": "string_with_only_multiple_comas",
+ "operator": "ONE_OF",
+ "value": [
+ "true"
+ ]
+ }
+ ]
+ }
+ ],
+ "splits": [
+ {
+ "variationKey": "string_with_only_multiple_comas",
+ "shards": []
+ }
+ ],
+ "doLog": true
+ },
+ {
+ "key": "allocation-test-string_with_colons",
+ "rules": [
+ {
+ "conditions": [
+ {
+ "attribute": "string_with_colons",
+ "operator": "ONE_OF",
+ "value": [
+ "true"
+ ]
+ }
+ ]
+ }
+ ],
+ "splits": [
+ {
+ "variationKey": "string_with_colons",
+ "shards": []
+ }
+ ],
+ "doLog": true
+ },
+ {
+ "key": "allocation-test-string_with_only_one_colon",
+ "rules": [
+ {
+ "conditions": [
+ {
+ "attribute": "string_with_only_one_colon",
+ "operator": "ONE_OF",
+ "value": [
+ "true"
+ ]
+ }
+ ]
+ }
+ ],
+ "splits": [
+ {
+ "variationKey": "string_with_only_one_colon",
+ "shards": []
+ }
+ ],
+ "doLog": true
+ },
+ {
+ "key": "allocation-test-string_with_only_multiple_colons",
+ "rules": [
+ {
+ "conditions": [
+ {
+ "attribute": "string_with_only_multiple_colons",
+ "operator": "ONE_OF",
+ "value": [
+ "true"
+ ]
+ }
+ ]
+ }
+ ],
+ "splits": [
+ {
+ "variationKey": "string_with_only_multiple_colons",
+ "shards": []
+ }
+ ],
+ "doLog": true
+ },
+ {
+ "key": "allocation-test-string_with_semicolons",
+ "rules": [
+ {
+ "conditions": [
+ {
+ "attribute": "string_with_semicolons",
+ "operator": "ONE_OF",
+ "value": [
+ "true"
+ ]
+ }
+ ]
+ }
+ ],
+ "splits": [
+ {
+ "variationKey": "string_with_semicolons",
+ "shards": []
+ }
+ ],
+ "doLog": true
+ },
+ {
+ "key": "allocation-test-string_with_only_one_semicolon",
+ "rules": [
+ {
+ "conditions": [
+ {
+ "attribute": "string_with_only_one_semicolon",
+ "operator": "ONE_OF",
+ "value": [
+ "true"
+ ]
+ }
+ ]
+ }
+ ],
+ "splits": [
+ {
+ "variationKey": "string_with_only_one_semicolon",
+ "shards": []
+ }
+ ],
+ "doLog": true
+ },
+ {
+ "key": "allocation-test-string_with_only_multiple_semicolons",
+ "rules": [
+ {
+ "conditions": [
+ {
+ "attribute": "string_with_only_multiple_semicolons",
+ "operator": "ONE_OF",
+ "value": [
+ "true"
+ ]
+ }
+ ]
+ }
+ ],
+ "splits": [
+ {
+ "variationKey": "string_with_only_multiple_semicolons",
+ "shards": []
+ }
+ ],
+ "doLog": true
+ },
+ {
+ "key": "allocation-test-string_with_slashes",
+ "rules": [
+ {
+ "conditions": [
+ {
+ "attribute": "string_with_slashes",
+ "operator": "ONE_OF",
+ "value": [
+ "true"
+ ]
+ }
+ ]
+ }
+ ],
+ "splits": [
+ {
+ "variationKey": "string_with_slashes",
+ "shards": []
+ }
+ ],
+ "doLog": true
+ },
+ {
+ "key": "allocation-test-string_with_only_one_slash",
+ "rules": [
+ {
+ "conditions": [
+ {
+ "attribute": "string_with_only_one_slash",
+ "operator": "ONE_OF",
+ "value": [
+ "true"
+ ]
+ }
+ ]
+ }
+ ],
+ "splits": [
+ {
+ "variationKey": "string_with_only_one_slash",
+ "shards": []
+ }
+ ],
+ "doLog": true
+ },
+ {
+ "key": "allocation-test-string_with_only_multiple_slashes",
+ "rules": [
+ {
+ "conditions": [
+ {
+ "attribute": "string_with_only_multiple_slashes",
+ "operator": "ONE_OF",
+ "value": [
+ "true"
+ ]
+ }
+ ]
+ }
+ ],
+ "splits": [
+ {
+ "variationKey": "string_with_only_multiple_slashes",
+ "shards": []
+ }
+ ],
+ "doLog": true
+ },
+ {
+ "key": "allocation-test-string_with_dashes",
+ "rules": [
+ {
+ "conditions": [
+ {
+ "attribute": "string_with_dashes",
+ "operator": "ONE_OF",
+ "value": [
+ "true"
+ ]
+ }
+ ]
+ }
+ ],
+ "splits": [
+ {
+ "variationKey": "string_with_dashes",
+ "shards": []
+ }
+ ],
+ "doLog": true
+ },
+ {
+ "key": "allocation-test-string_with_only_one_dash",
+ "rules": [
+ {
+ "conditions": [
+ {
+ "attribute": "string_with_only_one_dash",
+ "operator": "ONE_OF",
+ "value": [
+ "true"
+ ]
+ }
+ ]
+ }
+ ],
+ "splits": [
+ {
+ "variationKey": "string_with_only_one_dash",
+ "shards": []
+ }
+ ],
+ "doLog": true
+ },
+ {
+ "key": "allocation-test-string_with_only_multiple_dashes",
+ "rules": [
+ {
+ "conditions": [
+ {
+ "attribute": "string_with_only_multiple_dashes",
+ "operator": "ONE_OF",
+ "value": [
+ "true"
+ ]
+ }
+ ]
+ }
+ ],
+ "splits": [
+ {
+ "variationKey": "string_with_only_multiple_dashes",
+ "shards": []
+ }
+ ],
+ "doLog": true
+ },
+ {
+ "key": "allocation-test-string_with_underscores",
+ "rules": [
+ {
+ "conditions": [
+ {
+ "attribute": "string_with_underscores",
+ "operator": "ONE_OF",
+ "value": [
+ "true"
+ ]
+ }
+ ]
+ }
+ ],
+ "splits": [
+ {
+ "variationKey": "string_with_underscores",
+ "shards": []
+ }
+ ],
+ "doLog": true
+ },
+ {
+ "key": "allocation-test-string_with_only_one_underscore",
+ "rules": [
+ {
+ "conditions": [
+ {
+ "attribute": "string_with_only_one_underscore",
+ "operator": "ONE_OF",
+ "value": [
+ "true"
+ ]
+ }
+ ]
+ }
+ ],
+ "splits": [
+ {
+ "variationKey": "string_with_only_one_underscore",
+ "shards": []
+ }
+ ],
+ "doLog": true
+ },
+ {
+ "key": "allocation-test-string_with_only_multiple_underscores",
+ "rules": [
+ {
+ "conditions": [
+ {
+ "attribute": "string_with_only_multiple_underscores",
+ "operator": "ONE_OF",
+ "value": [
+ "true"
+ ]
+ }
+ ]
+ }
+ ],
+ "splits": [
+ {
+ "variationKey": "string_with_only_multiple_underscores",
+ "shards": []
+ }
+ ],
+ "doLog": true
+ },
+ {
+ "key": "allocation-test-string_with_plus_signs",
+ "rules": [
+ {
+ "conditions": [
+ {
+ "attribute": "string_with_plus_signs",
+ "operator": "ONE_OF",
+ "value": [
+ "true"
+ ]
+ }
+ ]
+ }
+ ],
+ "splits": [
+ {
+ "variationKey": "string_with_plus_signs",
+ "shards": []
+ }
+ ],
+ "doLog": true
+ },
+ {
+ "key": "allocation-test-string_with_only_one_plus_sign",
+ "rules": [
+ {
+ "conditions": [
+ {
+ "attribute": "string_with_only_one_plus_sign",
+ "operator": "ONE_OF",
+ "value": [
+ "true"
+ ]
+ }
+ ]
+ }
+ ],
+ "splits": [
+ {
+ "variationKey": "string_with_only_one_plus_sign",
+ "shards": []
+ }
+ ],
+ "doLog": true
+ },
+ {
+ "key": "allocation-test-string_with_only_multiple_plus_signs",
+ "rules": [
+ {
+ "conditions": [
+ {
+ "attribute": "string_with_only_multiple_plus_signs",
+ "operator": "ONE_OF",
+ "value": [
+ "true"
+ ]
+ }
+ ]
+ }
+ ],
+ "splits": [
+ {
+ "variationKey": "string_with_only_multiple_plus_signs",
+ "shards": []
+ }
+ ],
+ "doLog": true
+ },
+ {
+ "key": "allocation-test-string_with_equal_signs",
+ "rules": [
+ {
+ "conditions": [
+ {
+ "attribute": "string_with_equal_signs",
+ "operator": "ONE_OF",
+ "value": [
+ "true"
+ ]
+ }
+ ]
+ }
+ ],
+ "splits": [
+ {
+ "variationKey": "string_with_equal_signs",
+ "shards": []
+ }
+ ],
+ "doLog": true
+ },
+ {
+ "key": "allocation-test-string_with_only_one_equal_sign",
+ "rules": [
+ {
+ "conditions": [
+ {
+ "attribute": "string_with_only_one_equal_sign",
+ "operator": "ONE_OF",
+ "value": [
+ "true"
+ ]
+ }
+ ]
+ }
+ ],
+ "splits": [
+ {
+ "variationKey": "string_with_only_one_equal_sign",
+ "shards": []
+ }
+ ],
+ "doLog": true
+ },
+ {
+ "key": "allocation-test-string_with_only_multiple_equal_signs",
+ "rules": [
+ {
+ "conditions": [
+ {
+ "attribute": "string_with_only_multiple_equal_signs",
+ "operator": "ONE_OF",
+ "value": [
+ "true"
+ ]
+ }
+ ]
+ }
+ ],
+ "splits": [
+ {
+ "variationKey": "string_with_only_multiple_equal_signs",
+ "shards": []
+ }
+ ],
+ "doLog": true
+ },
+ {
+ "key": "allocation-test-string_with_dollar_signs",
+ "rules": [
+ {
+ "conditions": [
+ {
+ "attribute": "string_with_dollar_signs",
+ "operator": "ONE_OF",
+ "value": [
+ "true"
+ ]
+ }
+ ]
+ }
+ ],
+ "splits": [
+ {
+ "variationKey": "string_with_dollar_signs",
+ "shards": []
+ }
+ ],
+ "doLog": true
+ },
+ {
+ "key": "allocation-test-string_with_only_one_dollar_sign",
+ "rules": [
+ {
+ "conditions": [
+ {
+ "attribute": "string_with_only_one_dollar_sign",
+ "operator": "ONE_OF",
+ "value": [
+ "true"
+ ]
+ }
+ ]
+ }
+ ],
+ "splits": [
+ {
+ "variationKey": "string_with_only_one_dollar_sign",
+ "shards": []
+ }
+ ],
+ "doLog": true
+ },
+ {
+ "key": "allocation-test-string_with_only_multiple_dollar_signs",
+ "rules": [
+ {
+ "conditions": [
+ {
+ "attribute": "string_with_only_multiple_dollar_signs",
+ "operator": "ONE_OF",
+ "value": [
+ "true"
+ ]
+ }
+ ]
+ }
+ ],
+ "splits": [
+ {
+ "variationKey": "string_with_only_multiple_dollar_signs",
+ "shards": []
+ }
+ ],
+ "doLog": true
+ },
+ {
+ "key": "allocation-test-string_with_at_signs",
+ "rules": [
+ {
+ "conditions": [
+ {
+ "attribute": "string_with_at_signs",
+ "operator": "ONE_OF",
+ "value": [
+ "true"
+ ]
+ }
+ ]
+ }
+ ],
+ "splits": [
+ {
+ "variationKey": "string_with_at_signs",
+ "shards": []
+ }
+ ],
+ "doLog": true
+ },
+ {
+ "key": "allocation-test-string_with_only_one_at_sign",
+ "rules": [
+ {
+ "conditions": [
+ {
+ "attribute": "string_with_only_one_at_sign",
+ "operator": "ONE_OF",
+ "value": [
+ "true"
+ ]
+ }
+ ]
+ }
+ ],
+ "splits": [
+ {
+ "variationKey": "string_with_only_one_at_sign",
+ "shards": []
+ }
+ ],
+ "doLog": true
+ },
+ {
+ "key": "allocation-test-string_with_only_multiple_at_signs",
+ "rules": [
+ {
+ "conditions": [
+ {
+ "attribute": "string_with_only_multiple_at_signs",
+ "operator": "ONE_OF",
+ "value": [
+ "true"
+ ]
+ }
+ ]
+ }
+ ],
+ "splits": [
+ {
+ "variationKey": "string_with_only_multiple_at_signs",
+ "shards": []
+ }
+ ],
+ "doLog": true
+ },
+ {
+ "key": "allocation-test-string_with_amp_signs",
+ "rules": [
+ {
+ "conditions": [
+ {
+ "attribute": "string_with_amp_signs",
+ "operator": "ONE_OF",
+ "value": [
+ "true"
+ ]
+ }
+ ]
+ }
+ ],
+ "splits": [
+ {
+ "variationKey": "string_with_amp_signs",
+ "shards": []
+ }
+ ],
+ "doLog": true
+ },
+ {
+ "key": "allocation-test-string_with_only_one_amp_sign",
+ "rules": [
+ {
+ "conditions": [
+ {
+ "attribute": "string_with_only_one_amp_sign",
+ "operator": "ONE_OF",
+ "value": [
+ "true"
+ ]
+ }
+ ]
+ }
+ ],
+ "splits": [
+ {
+ "variationKey": "string_with_only_one_amp_sign",
+ "shards": []
+ }
+ ],
+ "doLog": true
+ },
+ {
+ "key": "allocation-test-string_with_only_multiple_amp_signs",
+ "rules": [
+ {
+ "conditions": [
+ {
+ "attribute": "string_with_only_multiple_amp_signs",
+ "operator": "ONE_OF",
+ "value": [
+ "true"
+ ]
+ }
+ ]
+ }
+ ],
+ "splits": [
+ {
+ "variationKey": "string_with_only_multiple_amp_signs",
+ "shards": []
+ }
+ ],
+ "doLog": true
+ },
+ {
+ "key": "allocation-test-string_with_hash_signs",
+ "rules": [
+ {
+ "conditions": [
+ {
+ "attribute": "string_with_hash_signs",
+ "operator": "ONE_OF",
+ "value": [
+ "true"
+ ]
+ }
+ ]
+ }
+ ],
+ "splits": [
+ {
+ "variationKey": "string_with_hash_signs",
+ "shards": []
+ }
+ ],
+ "doLog": true
+ },
+ {
+ "key": "allocation-test-string_with_only_one_hash_sign",
+ "rules": [
+ {
+ "conditions": [
+ {
+ "attribute": "string_with_only_one_hash_sign",
+ "operator": "ONE_OF",
+ "value": [
+ "true"
+ ]
+ }
+ ]
+ }
+ ],
+ "splits": [
+ {
+ "variationKey": "string_with_only_one_hash_sign",
+ "shards": []
+ }
+ ],
+ "doLog": true
+ },
+ {
+ "key": "allocation-test-string_with_only_multiple_hash_signs",
+ "rules": [
+ {
+ "conditions": [
+ {
+ "attribute": "string_with_only_multiple_hash_signs",
+ "operator": "ONE_OF",
+ "value": [
+ "true"
+ ]
+ }
+ ]
+ }
+ ],
+ "splits": [
+ {
+ "variationKey": "string_with_only_multiple_hash_signs",
+ "shards": []
+ }
+ ],
+ "doLog": true
+ },
+ {
+ "key": "allocation-test-string_with_percentage_signs",
+ "rules": [
+ {
+ "conditions": [
+ {
+ "attribute": "string_with_percentage_signs",
+ "operator": "ONE_OF",
+ "value": [
+ "true"
+ ]
+ }
+ ]
+ }
+ ],
+ "splits": [
+ {
+ "variationKey": "string_with_percentage_signs",
+ "shards": []
+ }
+ ],
+ "doLog": true
+ },
+ {
+ "key": "allocation-test-string_with_only_one_percentage_sign",
+ "rules": [
+ {
+ "conditions": [
+ {
+ "attribute": "string_with_only_one_percentage_sign",
+ "operator": "ONE_OF",
+ "value": [
+ "true"
+ ]
+ }
+ ]
+ }
+ ],
+ "splits": [
+ {
+ "variationKey": "string_with_only_one_percentage_sign",
+ "shards": []
+ }
+ ],
+ "doLog": true
+ },
+ {
+ "key": "allocation-test-string_with_only_multiple_percentage_signs",
+ "rules": [
+ {
+ "conditions": [
+ {
+ "attribute": "string_with_only_multiple_percentage_signs",
+ "operator": "ONE_OF",
+ "value": [
+ "true"
+ ]
+ }
+ ]
+ }
+ ],
+ "splits": [
+ {
+ "variationKey": "string_with_only_multiple_percentage_signs",
+ "shards": []
+ }
+ ],
+ "doLog": true
+ },
+ {
+ "key": "allocation-test-string_with_tilde_signs",
+ "rules": [
+ {
+ "conditions": [
+ {
+ "attribute": "string_with_tilde_signs",
+ "operator": "ONE_OF",
+ "value": [
+ "true"
+ ]
+ }
+ ]
+ }
+ ],
+ "splits": [
+ {
+ "variationKey": "string_with_tilde_signs",
+ "shards": []
+ }
+ ],
+ "doLog": true
+ },
+ {
+ "key": "allocation-test-string_with_only_one_tilde_sign",
+ "rules": [
+ {
+ "conditions": [
+ {
+ "attribute": "string_with_only_one_tilde_sign",
+ "operator": "ONE_OF",
+ "value": [
+ "true"
+ ]
+ }
+ ]
+ }
+ ],
+ "splits": [
+ {
+ "variationKey": "string_with_only_one_tilde_sign",
+ "shards": []
+ }
+ ],
+ "doLog": true
+ },
+ {
+ "key": "allocation-test-string_with_only_multiple_tilde_signs",
+ "rules": [
+ {
+ "conditions": [
+ {
+ "attribute": "string_with_only_multiple_tilde_signs",
+ "operator": "ONE_OF",
+ "value": [
+ "true"
+ ]
+ }
+ ]
+ }
+ ],
+ "splits": [
+ {
+ "variationKey": "string_with_only_multiple_tilde_signs",
+ "shards": []
+ }
+ ],
+ "doLog": true
+ },
+ {
+ "key": "allocation-test-string_with_asterix_signs",
+ "rules": [
+ {
+ "conditions": [
+ {
+ "attribute": "string_with_asterix_signs",
+ "operator": "ONE_OF",
+ "value": [
+ "true"
+ ]
+ }
+ ]
+ }
+ ],
+ "splits": [
+ {
+ "variationKey": "string_with_asterix_signs",
+ "shards": []
+ }
+ ],
+ "doLog": true
+ },
+ {
+ "key": "allocation-test-string_with_only_one_asterix_sign",
+ "rules": [
+ {
+ "conditions": [
+ {
+ "attribute": "string_with_only_one_asterix_sign",
+ "operator": "ONE_OF",
+ "value": [
+ "true"
+ ]
+ }
+ ]
+ }
+ ],
+ "splits": [
+ {
+ "variationKey": "string_with_only_one_asterix_sign",
+ "shards": []
+ }
+ ],
+ "doLog": true
+ },
+ {
+ "key": "allocation-test-string_with_only_multiple_asterix_signs",
+ "rules": [
+ {
+ "conditions": [
+ {
+ "attribute": "string_with_only_multiple_asterix_signs",
+ "operator": "ONE_OF",
+ "value": [
+ "true"
+ ]
+ }
+ ]
+ }
+ ],
+ "splits": [
+ {
+ "variationKey": "string_with_only_multiple_asterix_signs",
+ "shards": []
+ }
+ ],
+ "doLog": true
+ },
+ {
+ "key": "allocation-test-string_with_single_quotes",
+ "rules": [
+ {
+ "conditions": [
+ {
+ "attribute": "string_with_single_quotes",
+ "operator": "ONE_OF",
+ "value": [
+ "true"
+ ]
+ }
+ ]
+ }
+ ],
+ "splits": [
+ {
+ "variationKey": "string_with_single_quotes",
+ "shards": []
+ }
+ ],
+ "doLog": true
+ },
+ {
+ "key": "allocation-test-string_with_only_one_single_quote",
+ "rules": [
+ {
+ "conditions": [
+ {
+ "attribute": "string_with_only_one_single_quote",
+ "operator": "ONE_OF",
+ "value": [
+ "true"
+ ]
+ }
+ ]
+ }
+ ],
+ "splits": [
+ {
+ "variationKey": "string_with_only_one_single_quote",
+ "shards": []
+ }
+ ],
+ "doLog": true
+ },
+ {
+ "key": "allocation-test-string_with_only_multiple_single_quotes",
+ "rules": [
+ {
+ "conditions": [
+ {
+ "attribute": "string_with_only_multiple_single_quotes",
+ "operator": "ONE_OF",
+ "value": [
+ "true"
+ ]
+ }
+ ]
+ }
+ ],
+ "splits": [
+ {
+ "variationKey": "string_with_only_multiple_single_quotes",
+ "shards": []
+ }
+ ],
+ "doLog": true
+ },
+ {
+ "key": "allocation-test-string_with_question_marks",
+ "rules": [
+ {
+ "conditions": [
+ {
+ "attribute": "string_with_question_marks",
+ "operator": "ONE_OF",
+ "value": [
+ "true"
+ ]
+ }
+ ]
+ }
+ ],
+ "splits": [
+ {
+ "variationKey": "string_with_question_marks",
+ "shards": []
+ }
+ ],
+ "doLog": true
+ },
+ {
+ "key": "allocation-test-string_with_only_one_question_mark",
+ "rules": [
+ {
+ "conditions": [
+ {
+ "attribute": "string_with_only_one_question_mark",
+ "operator": "ONE_OF",
+ "value": [
+ "true"
+ ]
+ }
+ ]
+ }
+ ],
+ "splits": [
+ {
+ "variationKey": "string_with_only_one_question_mark",
+ "shards": []
+ }
+ ],
+ "doLog": true
+ },
+ {
+ "key": "allocation-test-string_with_only_multiple_question_marks",
+ "rules": [
+ {
+ "conditions": [
+ {
+ "attribute": "string_with_only_multiple_question_marks",
+ "operator": "ONE_OF",
+ "value": [
+ "true"
+ ]
+ }
+ ]
+ }
+ ],
+ "splits": [
+ {
+ "variationKey": "string_with_only_multiple_question_marks",
+ "shards": []
+ }
+ ],
+ "doLog": true
+ },
+ {
+ "key": "allocation-test-string_with_exclamation_marks",
+ "rules": [
+ {
+ "conditions": [
+ {
+ "attribute": "string_with_exclamation_marks",
+ "operator": "ONE_OF",
+ "value": [
+ "true"
+ ]
+ }
+ ]
+ }
+ ],
+ "splits": [
+ {
+ "variationKey": "string_with_exclamation_marks",
+ "shards": []
+ }
+ ],
+ "doLog": true
+ },
+ {
+ "key": "allocation-test-string_with_only_one_exclamation_mark",
+ "rules": [
+ {
+ "conditions": [
+ {
+ "attribute": "string_with_only_one_exclamation_mark",
+ "operator": "ONE_OF",
+ "value": [
+ "true"
+ ]
+ }
+ ]
+ }
+ ],
+ "splits": [
+ {
+ "variationKey": "string_with_only_one_exclamation_mark",
+ "shards": []
+ }
+ ],
+ "doLog": true
+ },
+ {
+ "key": "allocation-test-string_with_only_multiple_exclamation_marks",
+ "rules": [
+ {
+ "conditions": [
+ {
+ "attribute": "string_with_only_multiple_exclamation_marks",
+ "operator": "ONE_OF",
+ "value": [
+ "true"
+ ]
+ }
+ ]
+ }
+ ],
+ "splits": [
+ {
+ "variationKey": "string_with_only_multiple_exclamation_marks",
+ "shards": []
+ }
+ ],
+ "doLog": true
+ },
+ {
+ "key": "allocation-test-string_with_opening_parentheses",
+ "rules": [
+ {
+ "conditions": [
+ {
+ "attribute": "string_with_opening_parentheses",
+ "operator": "ONE_OF",
+ "value": [
+ "true"
+ ]
+ }
+ ]
+ }
+ ],
+ "splits": [
+ {
+ "variationKey": "string_with_opening_parentheses",
+ "shards": []
+ }
+ ],
+ "doLog": true
+ },
+ {
+ "key": "allocation-test-string_with_only_one_opening_parenthese",
+ "rules": [
+ {
+ "conditions": [
+ {
+ "attribute": "string_with_only_one_opening_parenthese",
+ "operator": "ONE_OF",
+ "value": [
+ "true"
+ ]
+ }
+ ]
+ }
+ ],
+ "splits": [
+ {
+ "variationKey": "string_with_only_one_opening_parenthese",
+ "shards": []
+ }
+ ],
+ "doLog": true
+ },
+ {
+ "key": "allocation-test-string_with_only_multiple_opening_parentheses",
+ "rules": [
+ {
+ "conditions": [
+ {
+ "attribute": "string_with_only_multiple_opening_parentheses",
+ "operator": "ONE_OF",
+ "value": [
+ "true"
+ ]
+ }
+ ]
+ }
+ ],
+ "splits": [
+ {
+ "variationKey": "string_with_only_multiple_opening_parentheses",
+ "shards": []
+ }
+ ],
+ "doLog": true
+ },
+ {
+ "key": "allocation-test-string_with_closing_parentheses",
+ "rules": [
+ {
+ "conditions": [
+ {
+ "attribute": "string_with_closing_parentheses",
+ "operator": "ONE_OF",
+ "value": [
+ "true"
+ ]
+ }
+ ]
+ }
+ ],
+ "splits": [
+ {
+ "variationKey": "string_with_closing_parentheses",
+ "shards": []
+ }
+ ],
+ "doLog": true
+ },
+ {
+ "key": "allocation-test-string_with_only_one_closing_parenthese",
+ "rules": [
+ {
+ "conditions": [
+ {
+ "attribute": "string_with_only_one_closing_parenthese",
+ "operator": "ONE_OF",
+ "value": [
+ "true"
+ ]
+ }
+ ]
+ }
+ ],
+ "splits": [
+ {
+ "variationKey": "string_with_only_one_closing_parenthese",
+ "shards": []
+ }
+ ],
+ "doLog": true
+ },
+ {
+ "key": "allocation-test-string_with_only_multiple_closing_parentheses",
+ "rules": [
+ {
+ "conditions": [
+ {
+ "attribute": "string_with_only_multiple_closing_parentheses",
+ "operator": "ONE_OF",
+ "value": [
+ "true"
+ ]
+ }
+ ]
+ }
+ ],
+ "splits": [
+ {
+ "variationKey": "string_with_only_multiple_closing_parentheses",
+ "shards": []
+ }
+ ],
+ "doLog": true
+ }
+ ]
+ }
+ }
+}
diff --git a/settings.gradle b/settings.gradle
index fc9b6ed..1862a29 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -12,10 +12,11 @@ dependencyResolutionManagement {
mavenCentral()
mavenLocal()
maven {
- url "https://central.sonatype.com/repository/maven-snapshots/"
+ url "https://central.sonatype.com/repository/maven-snapshots"
}
}
}
rootProject.name = "Eppo SDK"
include ':example'
include ':eppo'
+include ':android-sdk-framework'
\ No newline at end of file