diff --git a/android-sdk-framework/build.gradle b/android-sdk-framework/build.gradle
new file mode 100644
index 00000000..0404efee
--- /dev/null
+++ b/android-sdk-framework/build.gradle
@@ -0,0 +1,156 @@
+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"
+ // EPPO_VERSION is used as the sdkVersion reported to Eppo. It matches FRAMEWORK_VERSION
+ // because the framework and eppo modules are versioned together.
+ 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 00000000..3c09851d
--- /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 00000000..b2d3ea12
--- /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 00000000..044f9940
--- /dev/null
+++ b/android-sdk-framework/src/main/java/cloud/eppo/android/framework/AndroidBaseClient.java
@@ -0,0 +1,425 @@
+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;
+ }
+
+ // Store interval/jitter on the instance so resumePolling() can restart with the same
+ // values.
+ newInstance.pollingIntervalMs = pollingIntervalMs;
+ newInstance.pollingJitterMs = effectiveJitter;
+ 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 00000000..8300fa35
--- /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 00000000..cc8566b7
--- /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 00000000..9c302b97
--- /dev/null
+++ b/android-sdk-framework/src/main/java/cloud/eppo/android/framework/storage/BaseCacheFile.java
@@ -0,0 +1,65 @@
+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);
+ } 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 00000000..d7195366
--- /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 00000000..9eea7415
--- /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 00000000..5d079525
--- /dev/null
+++ b/android-sdk-framework/src/main/java/cloud/eppo/android/framework/storage/ConfigCacheFile.java
@@ -0,0 +1,63 @@
+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. These
+ * package-private constructors exist only to support migration of the eppo module from v3 to
+ * v4; they will be removed once that migration is complete.
+ */
+ 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. These
+ * package-private constructors exist only to support migration of the eppo module from v3 to
+ * v4; they will be removed once that migration is complete.
+ */
+ 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 00000000..a5ea4d4a
--- /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 00000000..e0608452
--- /dev/null
+++ b/android-sdk-framework/src/main/java/cloud/eppo/android/framework/storage/FileBackedByteStore.java
@@ -0,0 +1,67 @@
+package cloud.eppo.android.framework.storage;
+
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.Executor;
+import java.util.concurrent.Executors;
+import org.jetbrains.annotations.NotNull;
+
+/**
+ * {@link ByteStore} implementation that reads and writes a single file via {@link BaseCacheFile}.
+ */
+public final class FileBackedByteStore implements ByteStore {
+
+ // Dedicated single-thread executor avoids saturating ForkJoinPool.commonPool() with blocking I/O
+ // on low-core-count Android devices.
+ private static final Executor IO_EXECUTOR = Executors.newSingleThreadExecutor();
+
+ 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);
+ }
+ },
+ IO_EXECUTOR);
+ }
+
+ @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);
+ }
+ },
+ IO_EXECUTOR);
+ }
+
+ 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 00000000..39e4af90
--- /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/util/Utils.java b/android-sdk-framework/src/main/java/cloud/eppo/android/framework/util/Utils.java
new file mode 100644
index 00000000..90db72c1
--- /dev/null
+++ b/android-sdk-framework/src/main/java/cloud/eppo/android/framework/util/Utils.java
@@ -0,0 +1,24 @@
+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) {
+ if (key == null || key.isEmpty()) {
+ return "";
+ }
+ // 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, Math.min(8, key.length())).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 00000000..7d3d5469
--- /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 00000000..f154cf84
--- /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 00000000..9b28d9f8
--- /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 00000000..0218d540
--- /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 00000000..b882934b
--- /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 fc9b6edb..1862a290 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