From b82fbbf41f1a4a6f6f31dcafdc636d569ead4b58 Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Thu, 12 Mar 2026 14:03:24 -0600 Subject: [PATCH 1/2] feat: add android-sdk-framework module Add new android-sdk-framework module that provides base client functionality and configuration storage abstractions for Android SDK implementations. Key components: - AndroidBaseClient: Base class for Android SDK clients - CachingConfigurationStore: Abstract store for cached configurations - FileBackedConfigStore: File-based configuration persistence - ConfigurationCodec: Pluggable serialization/deserialization - ByteStore: Low-level byte storage abstraction This module uses the v4 Java SDK framework and is added as a standalone module without integration into the existing eppo module yet. --- android-sdk-framework/build.gradle | 154 + .../framework/EppoClientPollingTest.java | 217 ++ .../src/main/AndroidManifest.xml | 2 + .../android/framework/AndroidBaseClient.java | 421 ++ .../EppoInitializationException.java | 7 + .../exceptions/NotInitializedException.java | 7 + .../framework/storage/BaseCacheFile.java | 67 + .../android/framework/storage/ByteStore.java | 32 + .../storage/CachingConfigurationStore.java | 68 + .../framework/storage/ConfigCacheFile.java | 59 + .../framework/storage/ConfigurationCodec.java | 108 + .../storage/FileBackedByteStore.java | 59 + .../storage/FileBackedConfigStore.java | 29 + .../storage/GsonConfigurationCodec.java | 708 ++++ .../eppo/android/framework/util/Utils.java | 21 + .../CachingConfigurationStoreTest.java | 359 ++ .../storage/ConfigurationCodecTest.java | 111 + .../storage/FileBackedByteStoreTest.java | 192 + .../storage/FileBackedConfigStoreTest.java | 78 + .../src/test/resources/flags-v1.json | 3382 +++++++++++++++++ settings.gradle | 3 +- 21 files changed, 6083 insertions(+), 1 deletion(-) create mode 100644 android-sdk-framework/build.gradle create mode 100644 android-sdk-framework/src/androidTest/java/cloud/eppo/android/framework/EppoClientPollingTest.java create mode 100644 android-sdk-framework/src/main/AndroidManifest.xml create mode 100644 android-sdk-framework/src/main/java/cloud/eppo/android/framework/AndroidBaseClient.java create mode 100644 android-sdk-framework/src/main/java/cloud/eppo/android/framework/exceptions/EppoInitializationException.java create mode 100644 android-sdk-framework/src/main/java/cloud/eppo/android/framework/exceptions/NotInitializedException.java create mode 100644 android-sdk-framework/src/main/java/cloud/eppo/android/framework/storage/BaseCacheFile.java create mode 100644 android-sdk-framework/src/main/java/cloud/eppo/android/framework/storage/ByteStore.java create mode 100644 android-sdk-framework/src/main/java/cloud/eppo/android/framework/storage/CachingConfigurationStore.java create mode 100644 android-sdk-framework/src/main/java/cloud/eppo/android/framework/storage/ConfigCacheFile.java create mode 100644 android-sdk-framework/src/main/java/cloud/eppo/android/framework/storage/ConfigurationCodec.java create mode 100644 android-sdk-framework/src/main/java/cloud/eppo/android/framework/storage/FileBackedByteStore.java create mode 100644 android-sdk-framework/src/main/java/cloud/eppo/android/framework/storage/FileBackedConfigStore.java create mode 100644 android-sdk-framework/src/main/java/cloud/eppo/android/framework/storage/GsonConfigurationCodec.java create mode 100644 android-sdk-framework/src/main/java/cloud/eppo/android/framework/util/Utils.java create mode 100644 android-sdk-framework/src/test/java/cloud/eppo/android/framework/storage/CachingConfigurationStoreTest.java create mode 100644 android-sdk-framework/src/test/java/cloud/eppo/android/framework/storage/ConfigurationCodecTest.java create mode 100644 android-sdk-framework/src/test/java/cloud/eppo/android/framework/storage/FileBackedByteStoreTest.java create mode 100644 android-sdk-framework/src/test/java/cloud/eppo/android/framework/storage/FileBackedConfigStoreTest.java create mode 100644 android-sdk-framework/src/test/resources/flags-v1.json diff --git a/android-sdk-framework/build.gradle b/android-sdk-framework/build.gradle new file mode 100644 index 00000000..a4d789b1 --- /dev/null +++ b/android-sdk-framework/build.gradle @@ -0,0 +1,154 @@ +plugins { + id 'com.android.library' + id 'maven-publish' + id "com.vanniktech.maven.publish" version "0.32.0" + id 'signing' + id "com.diffplug.spotless" version "8.0.0" +} + +group = "cloud.eppo" +version = "0.1.0" + +android { + namespace "cloud.eppo.android.framework" + compileSdk 34 + + buildFeatures.buildConfig true + + defaultConfig { + minSdk 26 + targetSdk 34 + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + def FRAMEWORK_VERSION = "FRAMEWORK_VERSION" + def EPPO_VERSION = "EPPO_VERSION" + release { + minifyEnabled false + buildConfigField "String", FRAMEWORK_VERSION, "\"${project.version}\"" + buildConfigField "String", EPPO_VERSION, "\"${project.version}\"" + } + debug { + minifyEnabled false + buildConfigField "String", FRAMEWORK_VERSION, "\"${project.version}\"" + buildConfigField "String", EPPO_VERSION, "\"${project.version}\"" + } + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + testOptions { + unitTests.returnDefaultValues = true + } +} + +dependencies { + api 'cloud.eppo:eppo-sdk-framework:0.1.0-SNAPSHOT' + + api 'com.google.code.gson:gson:2.10.1' + api 'org.slf4j:slf4j-android:1.7.36' + compileOnly 'org.jetbrains:annotations:24.0.0' + + testImplementation 'cloud.eppo:sdk-common-jvm:4.0.0-SNAPSHOT' + testImplementation 'junit:junit:4.13.2' + testImplementation 'org.mockito:mockito-core:5.14.2' + testImplementation 'org.robolectric:robolectric:4.12.1' + + androidTestImplementation 'junit:junit:4.13.2' + androidTestImplementation 'org.mockito:mockito-android:5.14.2' + androidTestImplementation 'androidx.test.ext:junit:1.2.1' + androidTestImplementation 'androidx.test:core:1.6.1' + androidTestImplementation 'androidx.test:runner:1.6.2' + androidTestImplementation 'com.fasterxml.jackson.core:jackson-databind:2.19.1' +} + +spotless { + format 'misc', { + target '*.gradle', '.gitattributes', '.gitignore' + + trimTrailingWhitespace() + leadingTabsToSpaces(2) + endWithNewline() + } + java { + target '**/*.java' + + googleJavaFormat() + formatAnnotations() + } +} + +signing { + if (System.getenv("GPG_PRIVATE_KEY") && System.getenv("GPG_PASSPHRASE")) { + useInMemoryPgpKeys(System.env.GPG_PRIVATE_KEY, System.env.GPG_PASSPHRASE) + } + + sign publishing.publications +} + +tasks.withType(Sign) { + onlyIf { + (System.getenv("GPG_PRIVATE_KEY") && System.getenv("GPG_PASSPHRASE")) || + (project.hasProperty('signing.keyId') && + project.hasProperty('signing.password') && + project.hasProperty('signing.secretKeyRingFile')) + } +} + +mavenPublishing { + publishToMavenCentral(com.vanniktech.maven.publish.SonatypeHost.CENTRAL_PORTAL) + signAllPublications() + coordinates("cloud.eppo", "android-sdk-framework", project.version) + + pom { + name = 'Eppo Android SDK Framework' + description = 'Android SDK Framework for Eppo - Library-independent EppoClient and PrecomputedEppoClient (abstracts JSON, HTTP, storage)' + url = 'https://github.com/Eppo-exp/android-sdk' + licenses { + license { + name = 'MIT License' + url = 'http://www.opensource.org/licenses/mit-license.php' + } + } + developers { + developer { + name = 'Eppo' + email = 'https://www.geteppo.com' + } + } + scm { + connection = 'scm:git:git://github.com/Eppo-exp/android-sdk.git' + developerConnection = 'scm:git:ssh://github.com/Eppo-exp/android-sdk.git' + url = 'https://github.com/Eppo-exp/android-sdk/tree/main' + } + } +} + +task checkVersion { + doLast { + if (!project.hasProperty('release') && !project.hasProperty('snapshot')) { + throw new GradleException("You must specify either -Prelease or -Psnapshot") + } + if (project.hasProperty('release') && project.version.endsWith('SNAPSHOT')) { + throw new GradleException("You cannot specify -Prelease with a SNAPSHOT version") + } + if (project.hasProperty('snapshot') && !project.version.endsWith('SNAPSHOT')) { + throw new GradleException("You cannot specify -Psnapshot with a non-SNAPSHOT version") + } + project.ext.shouldPublish = true + } +} + +tasks.named('publish').configure { + dependsOn checkVersion +} + +tasks.withType(PublishToMavenRepository) { + onlyIf { + project.ext.has('shouldPublish') && project.ext.shouldPublish + } +} diff --git a/android-sdk-framework/src/androidTest/java/cloud/eppo/android/framework/EppoClientPollingTest.java b/android-sdk-framework/src/androidTest/java/cloud/eppo/android/framework/EppoClientPollingTest.java new file mode 100644 index 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..83753f67 --- /dev/null +++ b/android-sdk-framework/src/main/java/cloud/eppo/android/framework/AndroidBaseClient.java @@ -0,0 +1,421 @@ +package cloud.eppo.android.framework; + +import static cloud.eppo.android.framework.util.Utils.logTag; +import static cloud.eppo.android.framework.util.Utils.safeCacheKey; + +import android.app.Application; +import android.util.Log; +import cloud.eppo.BaseEppoClient; +import cloud.eppo.android.framework.exceptions.EppoInitializationException; +import cloud.eppo.android.framework.exceptions.NotInitializedException; +import cloud.eppo.android.framework.storage.CachingConfigurationStore; +import cloud.eppo.android.framework.storage.ConfigurationCodec; +import cloud.eppo.android.framework.storage.FileBackedConfigStore; +import cloud.eppo.api.Configuration; +import cloud.eppo.api.IAssignmentCache; +import cloud.eppo.http.EppoConfigurationClient; +import cloud.eppo.logging.AssignmentLogger; +import cloud.eppo.parser.ConfigurationParser; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Generic EppoClient that extends BaseEppoClient with JSON type parameter. + * + *

Requires callers to provide implementations of ConfigurationParser and + * EppoConfigurationClient. + * + * @param The JSON type used for JSON flag values (e.g., JsonNode, JsonElement) + */ +public class AndroidBaseClient extends BaseEppoClient { + private static final String TAG = logTag(AndroidBaseClient.class); + private static final boolean DEFAULT_IS_GRACEFUL_MODE = true; + private static final boolean DEFAULT_OBFUSCATE_CONFIG = true; + private static final long DEFAULT_POLLING_INTERVAL_MS = 5 * 60 * 1000; + private static final long DEFAULT_JITTER_INTERVAL_RATIO = 10; + + private long pollingIntervalMs; + private long pollingJitterMs; + + @Nullable private static AndroidBaseClient instance; + + /** + * Private constructor. Use Builder to construct instances. + * + * @param apiKey API key for Eppo + * @param sdkName SDK name identifier + * @param sdkVersion SDK version string + * @param apiBaseUrl Base URL for API calls + * @param assignmentLogger Logger for assignments + * @param configurationStore Store for configuration persistence + * @param isGracefulMode Whether to operate in graceful mode + * @param expectObfuscatedConfig Whether configuration is obfuscated + * @param initialConfiguration Initial configuration future + * @param assignmentCache Cache for assignments + * @param configurationParser Parser for configuration JSON + * @param configurationClient HTTP client for configuration fetching + */ + protected AndroidBaseClient( + String apiKey, + String sdkName, + String sdkVersion, + @Nullable String apiBaseUrl, + @Nullable AssignmentLogger assignmentLogger, + CachingConfigurationStore configurationStore, + boolean isGracefulMode, + boolean expectObfuscatedConfig, + @Nullable CompletableFuture initialConfiguration, + @Nullable IAssignmentCache assignmentCache, + ConfigurationParser configurationParser, + EppoConfigurationClient configurationClient) { + super( + apiKey, + sdkName, + sdkVersion, + apiBaseUrl, + assignmentLogger, + null, // banditLogger is not supported in Android + configurationStore, + isGracefulMode, + expectObfuscatedConfig, + false, // no bandits. + initialConfiguration, + assignmentCache, + null, + configurationParser, + configurationClient); + } + + /** + * Gets the singleton instance of EppoClient. + * + * @return The singleton instance + * @throws NotInitializedException if the client has not been initialized + * @param The JSON type parameter + */ + @SuppressWarnings("unchecked") + public static AndroidBaseClient getInstance() throws NotInitializedException { + if (instance == null) { + throw new NotInitializedException(); + } + return (AndroidBaseClient) instance; + } + + /** + * Builder for constructing and initializing EppoClient instances. + * + *

This is the only way to create an EppoClient. The Builder is generic on JsonFlagType and + * builds an EppoClient with the same type parameter. + * + * @param The JSON type used for JSON flag values + */ + public static class Builder { + // Required parameters + private final String apiKey; + private final Application application; + private final ConfigurationParser configurationParser; + private final EppoConfigurationClient configurationClient; + + // Optional parameters with defaults + @Nullable private String apiBaseUrl; + @Nullable private AssignmentLogger assignmentLogger; + @Nullable private CachingConfigurationStore configStore; + private boolean isGracefulMode = DEFAULT_IS_GRACEFUL_MODE; + private boolean obfuscateConfig = DEFAULT_OBFUSCATE_CONFIG; + private boolean forceReinitialize = false; + private boolean offlineMode = false; + @Nullable private CompletableFuture initialConfiguration; + private boolean ignoreCachedConfiguration = false; + private boolean pollingEnabled = false; + private long pollingIntervalMs = DEFAULT_POLLING_INTERVAL_MS; + private long pollingJitterMs = -1; + @Nullable private IAssignmentCache assignmentCache; + @Nullable private Consumer configChangeCallback; + + /** + * Creates a new Builder with required parameters. + * + * @param apiKey API key for Eppo (required) + * @param application Application context (required) + * @param configurationParser Parser for configuration JSON (required) + * @param configurationClient HTTP client for configuration fetching (required) + */ + public Builder( + @NotNull String apiKey, + @NotNull Application application, + @NotNull ConfigurationParser configurationParser, + @NotNull EppoConfigurationClient configurationClient) { + this.apiKey = apiKey; + this.application = application; + this.configurationParser = configurationParser; + this.configurationClient = configurationClient; + } + + public Builder apiBaseUrl(@Nullable String apiBaseUrl) { + this.apiBaseUrl = apiBaseUrl; + return this; + } + + public Builder assignmentLogger(@Nullable AssignmentLogger assignmentLogger) { + this.assignmentLogger = assignmentLogger; + return this; + } + + public Builder configStore(@Nullable CachingConfigurationStore configStore) { + this.configStore = configStore; + return this; + } + + public Builder isGracefulMode(boolean isGracefulMode) { + this.isGracefulMode = isGracefulMode; + return this; + } + + public Builder obfuscateConfig(boolean obfuscateConfig) { + this.obfuscateConfig = obfuscateConfig; + return this; + } + + public Builder forceReinitialize(boolean forceReinitialize) { + this.forceReinitialize = forceReinitialize; + return this; + } + + public Builder offlineMode(boolean offlineMode) { + this.offlineMode = offlineMode; + return this; + } + + public Builder initialConfiguration( + @Nullable CompletableFuture initialConfiguration) { + this.initialConfiguration = initialConfiguration; + return this; + } + + public Builder ignoreCachedConfiguration(boolean ignoreCache) { + this.ignoreCachedConfiguration = ignoreCache; + return this; + } + + public Builder pollingEnabled(boolean pollingEnabled) { + this.pollingEnabled = pollingEnabled; + return this; + } + + public Builder pollingIntervalMs(long pollingIntervalMs) { + this.pollingIntervalMs = pollingIntervalMs; + return this; + } + + public Builder pollingJitterMs(long pollingJitterMs) { + this.pollingJitterMs = pollingJitterMs; + return this; + } + + public Builder assignmentCache(@Nullable IAssignmentCache assignmentCache) { + this.assignmentCache = assignmentCache; + return this; + } + + public Builder onConfigurationChange( + @Nullable Consumer configChangeCallback) { + this.configChangeCallback = configChangeCallback; + return this; + } + + /** + * Builds and initializes the EppoClient asynchronously. + * + *

This method performs the full initialization flow: + * + *

    + *
  1. Validates required fields + *
  2. Handles singleton/reinitialize logic + *
  3. Loads initial configuration from cache if needed + *
  4. Constructs the client + *
  5. Fetches configuration if not in offline mode + *
  6. Starts polling if enabled + *
  7. Returns a CompletableFuture that completes when initialization is done + *
+ * + * @return CompletableFuture that completes with the initialized EppoClient + */ + public CompletableFuture> buildAndInitAsync() { + // Singleton handling + if (instance != null && !forceReinitialize) { + Log.w(TAG, "Eppo Client instance already initialized"); + @SuppressWarnings("unchecked") + AndroidBaseClient typedInstance = (AndroidBaseClient) instance; + return CompletableFuture.completedFuture(typedInstance); + } else if (instance != null) { + // Stop polling if reinitializing + instance.stopPolling(); + Log.i(TAG, "forceReinitialize triggered - reinitializing Eppo Client"); + } + + String sdkName = obfuscateConfig ? "android" : "android-debug"; + String sdkVersion = BuildConfig.EPPO_VERSION; + + if (configStore == null) { + configStore = + new FileBackedConfigStore( + application, + safeCacheKey(apiKey), + new ConfigurationCodec.Default<>(Configuration.class)); + } + + // Use the persisted cache as the initial configuration if none was explicitly provided. + if (initialConfiguration == null && !ignoreCachedConfiguration) { + initialConfiguration = configStore.loadFromStorage(); + } + + // Construct the client + AndroidBaseClient newInstance = + new AndroidBaseClient<>( + apiKey, + sdkName, + sdkVersion, + apiBaseUrl, + assignmentLogger, + configStore, + isGracefulMode, + obfuscateConfig, + initialConfiguration, + assignmentCache, + configurationParser, + configurationClient); + + // Set as singleton + instance = newInstance; + + // Register config change callback if provided + if (configChangeCallback != null) { + newInstance.onConfigurationChange(configChangeCallback); + } + + final CompletableFuture> ret = new CompletableFuture<>(); + AtomicInteger failCount = new AtomicInteger(0); + + if (!offlineMode) { + newInstance + .loadConfigurationAsync() + .handle( + (success, ex) -> { + if (ex == null) { + ret.complete(newInstance); + } else if (failCount.incrementAndGet() == 2 + || newInstance.getInitialConfigFuture() == null) { + ret.completeExceptionally( + new EppoInitializationException( + "Unable to initialize client; Configuration could not be loaded", ex)); + } + return null; + }); + } + + // Start polling if configured + if (pollingEnabled && pollingIntervalMs > 0) { + Log.i(TAG, "Starting poller"); + long effectiveJitter = pollingJitterMs; + if (effectiveJitter < 0) { + effectiveJitter = pollingIntervalMs / DEFAULT_JITTER_INTERVAL_RATIO; + } + + newInstance.startPolling(pollingIntervalMs, effectiveJitter); + } + + if (newInstance.getInitialConfigFuture() != null) { + newInstance + .getInitialConfigFuture() + .handle( + (success, ex) -> { + if (ex == null && Boolean.TRUE.equals(success)) { + ret.complete(newInstance); + } else if (offlineMode || failCount.incrementAndGet() == 2) { + ret.completeExceptionally( + new EppoInitializationException( + "Unable to initialize client; Configuration could not be loaded", ex)); + } else { + Log.i(TAG, "Initial config was not used."); + failCount.incrementAndGet(); + } + return null; + }); + } else if (offlineMode) { + ret.complete(newInstance); + } + + return ret.exceptionally( + e -> { + Log.e(TAG, "Exception caught during initialization: " + e.getMessage(), e); + if (!isGracefulMode) { + throw new RuntimeException(e); + } + return newInstance; + }); + } + + /** + * Builds and initializes the EppoClient synchronously (blocking). + * + *

This is a blocking wrapper around buildAndInitAsync(). + * + * @return The initialized EppoClient + */ + public AndroidBaseClient buildAndInit() { + try { + return buildAndInitAsync().get(); + } catch (ExecutionException | InterruptedException | CompletionException e) { + // If the exception was an `EppoInitializationException`, we know for sure that + // `buildAndInitAsync` logged it (and wrapped it with a RuntimeException) which was then + // wrapped by `CompletableFuture` with a `CompletionException`. + if (e instanceof CompletionException) { + Throwable cause = e.getCause(); + if (cause instanceof RuntimeException + && cause.getCause() instanceof EppoInitializationException) { + @SuppressWarnings("unchecked") + AndroidBaseClient typedInstance = + (AndroidBaseClient) instance; + return typedInstance; + } + } + Log.e(TAG, "Exception caught during initialization: " + e.getMessage(), e); + if (!isGracefulMode) { + throw new RuntimeException(e); + } + } + @SuppressWarnings("unchecked") + AndroidBaseClient typedInstance = (AndroidBaseClient) instance; + return typedInstance; + } + } + + /** + * Pauses polling for configuration updates. + * + *

Can be resumed later with resumePolling(). + */ + public void pausePolling() { + super.stopPolling(); + } + + /** + * Resumes polling for configuration updates. + * + *

Only works if polling was previously started via Builder. + */ + public void resumePolling() { + if (pollingIntervalMs <= 0) { + Log.w( + TAG, + "resumePolling called, but polling was not started due to invalid polling interval."); + return; + } + super.startPolling(pollingIntervalMs, pollingJitterMs); + } +} diff --git a/android-sdk-framework/src/main/java/cloud/eppo/android/framework/exceptions/EppoInitializationException.java b/android-sdk-framework/src/main/java/cloud/eppo/android/framework/exceptions/EppoInitializationException.java new file mode 100644 index 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..1bbf349b --- /dev/null +++ b/android-sdk-framework/src/main/java/cloud/eppo/android/framework/storage/BaseCacheFile.java @@ -0,0 +1,67 @@ +package cloud.eppo.android.framework.storage; + +import android.app.Application; +import java.io.BufferedReader; +import java.io.BufferedWriter; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.FileReader; +import java.io.FileWriter; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +/** Base class for disk cache files. */ +public class BaseCacheFile { + private final File cacheFile; + + protected BaseCacheFile(Application application, String fileName) { + File filesDir = application.getFilesDir(); + cacheFile = new File(filesDir, fileName); + } + + public boolean exists() { + return cacheFile.exists(); + } + + /** + * @noinspection ResultOfMethodCallIgnored + */ + public void delete() { + if (cacheFile.exists()) { + cacheFile.delete(); + } + } + + /** Useful for passing in as a writer for JSON serialization. */ + public BufferedWriter getWriter() throws IOException { + return new BufferedWriter(new FileWriter(cacheFile)); + } + + public OutputStream getOutputStream() throws FileNotFoundException { + return new FileOutputStream(cacheFile); + } + + public InputStream getInputStream() throws FileNotFoundException { + return new FileInputStream(cacheFile); + } + + /** Useful for passing in as a reader for JSON deserialization. */ + public BufferedReader getReader() throws IOException { + return new BufferedReader(new FileReader(cacheFile)); + } + + /** Useful for mocking caches in automated tests. */ + public void setContents(String contents) { + delete(); + try { + BufferedWriter writer = getWriter(); + writer.write(contents); + writer.close(); + } catch (IOException ex) { + throw new RuntimeException(ex); + } + } +} diff --git a/android-sdk-framework/src/main/java/cloud/eppo/android/framework/storage/ByteStore.java b/android-sdk-framework/src/main/java/cloud/eppo/android/framework/storage/ByteStore.java new file mode 100644 index 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..026ee3a3 --- /dev/null +++ b/android-sdk-framework/src/main/java/cloud/eppo/android/framework/storage/ConfigCacheFile.java @@ -0,0 +1,59 @@ +package cloud.eppo.android.framework.storage; + +import android.app.Application; +import java.util.HashMap; +import java.util.Map; +import org.jetbrains.annotations.NotNull; + +/** Disk cache file for flag configuration (used by FileBackedConfigStore). */ +public final class ConfigCacheFile extends BaseCacheFile { + private static final Map CONTENT_TYPE_TO_EXTENSION = new HashMap<>(); + private static final String DEFAULT_EXTENSION = "bin"; + + static { + CONTENT_TYPE_TO_EXTENSION.put("application/json", "json"); + CONTENT_TYPE_TO_EXTENSION.put("application/x-java-serialized-object", "ser"); + CONTENT_TYPE_TO_EXTENSION.put("text/plain", "txt"); + CONTENT_TYPE_TO_EXTENSION.put("text/xml", "xml"); + CONTENT_TYPE_TO_EXTENSION.put("application/xml", "xml"); + CONTENT_TYPE_TO_EXTENSION.put("application/octet-stream", "bin"); + } + + /** + * Creates a cache file with filename "eppo-sdk-flags-{suffix}.{ext}". Extension is derived from + * contentType. + */ + public ConfigCacheFile( + @NotNull Application application, @NotNull String suffix, @NotNull String contentType) { + super( + application, + "eppo-sdk-flags-" + + suffix + + "." + + CONTENT_TYPE_TO_EXTENSION.getOrDefault(contentType, DEFAULT_EXTENSION)); + } + + /** + * Creates a cache file with filename "eppo-sdk-flags-{configType}-{suffix}.{ext}". Used when the + * logical suffix is split into config type and suffix (e.g. for FileBackedConfigStore). + * + * @deprecated Use {@link #ConfigCacheFile(Application, String, String)} instead. + */ + ConfigCacheFile( + @NotNull Application application, + @NotNull String configType, + @NotNull String suffix, + @NotNull String contentType) { + this(application, configType + "-" + suffix, contentType); + } + + /** + * Creates a cache file with the given full file name (no prefix). Used when the caller supplies + * the complete filename (e.g. baseName + "." + extension). + * + * @deprecated Use {@link #ConfigCacheFile(Application, String, String)} instead. + */ + ConfigCacheFile(@NotNull Application application, @NotNull String fullFileName) { + super(application, fullFileName); + } +} diff --git a/android-sdk-framework/src/main/java/cloud/eppo/android/framework/storage/ConfigurationCodec.java b/android-sdk-framework/src/main/java/cloud/eppo/android/framework/storage/ConfigurationCodec.java new file mode 100644 index 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..c14f2ca2 --- /dev/null +++ b/android-sdk-framework/src/main/java/cloud/eppo/android/framework/storage/FileBackedByteStore.java @@ -0,0 +1,59 @@ +package cloud.eppo.android.framework.storage; + +import java.util.concurrent.CompletableFuture; +import org.jetbrains.annotations.NotNull; + +/** + * {@link ByteStore} implementation that reads and writes a single file via {@link BaseCacheFile}. + */ +public final class FileBackedByteStore implements ByteStore { + + private final BaseCacheFile cacheFile; + + public FileBackedByteStore(@NotNull BaseCacheFile cacheFile) { + if (cacheFile == null) { + throw new IllegalArgumentException("cacheFile must not be null"); + } + this.cacheFile = cacheFile; + } + + @Override + @NotNull public CompletableFuture read() { + return CompletableFuture.supplyAsync( + () -> { + if (!cacheFile.exists()) { + return null; + } + try (java.io.InputStream in = cacheFile.getInputStream()) { + return readAllBytes(in); + } catch (Exception e) { + throw new RuntimeException("Failed to read from cache file", e); + } + }); + } + + @Override + @NotNull public CompletableFuture write(@NotNull byte[] bytes) { + if (bytes == null) { + throw new IllegalArgumentException("bytes must not be null"); + } + return CompletableFuture.runAsync( + () -> { + try (java.io.OutputStream out = cacheFile.getOutputStream()) { + out.write(bytes); + } catch (Exception e) { + throw new RuntimeException("Failed to write to cache file", e); + } + }); + } + + private static byte[] readAllBytes(java.io.InputStream in) throws java.io.IOException { + java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream(); + byte[] buf = new byte[8192]; + int n; + while ((n = in.read(buf)) != -1) { + baos.write(buf, 0, n); + } + return baos.toByteArray(); + } +} diff --git a/android-sdk-framework/src/main/java/cloud/eppo/android/framework/storage/FileBackedConfigStore.java b/android-sdk-framework/src/main/java/cloud/eppo/android/framework/storage/FileBackedConfigStore.java new file mode 100644 index 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/storage/GsonConfigurationCodec.java b/android-sdk-framework/src/main/java/cloud/eppo/android/framework/storage/GsonConfigurationCodec.java new file mode 100644 index 00000000..6c7b1277 --- /dev/null +++ b/android-sdk-framework/src/main/java/cloud/eppo/android/framework/storage/GsonConfigurationCodec.java @@ -0,0 +1,708 @@ +package cloud.eppo.android.framework.storage; + +import android.util.Log; +import cloud.eppo.api.Configuration; +import cloud.eppo.api.EppoValue; +import cloud.eppo.api.dto.Allocation; +import cloud.eppo.api.dto.BanditCategoricalAttributeCoefficients; +import cloud.eppo.api.dto.BanditCoefficients; +import cloud.eppo.api.dto.BanditFlagVariation; +import cloud.eppo.api.dto.BanditModelData; +import cloud.eppo.api.dto.BanditNumericAttributeCoefficients; +import cloud.eppo.api.dto.BanditParameters; +import cloud.eppo.api.dto.BanditParametersResponse; +import cloud.eppo.api.dto.BanditReference; +import cloud.eppo.api.dto.FlagConfig; +import cloud.eppo.api.dto.FlagConfigResponse; +import cloud.eppo.api.dto.OperatorType; +import cloud.eppo.api.dto.Shard; +import cloud.eppo.api.dto.Split; +import cloud.eppo.api.dto.TargetingCondition; +import cloud.eppo.api.dto.TargetingRule; +import cloud.eppo.api.dto.Variation; +import cloud.eppo.api.dto.VariationType; +import cloud.eppo.model.ShardRange; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonNull; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.google.gson.JsonPrimitive; +import java.lang.reflect.Field; +import java.nio.charset.StandardCharsets; +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Date; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.TimeZone; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * A GSON-based {@link ConfigurationCodec} for {@link Configuration}. + * + *

Serializes to UTF-8 JSON rather than Java's binary serialization format. This makes cached + * configurations human-readable and immune to {@code serialVersionUID} drift between SDK versions. + * + *

Because {@link Configuration} does not expose its internal maps, this codec uses reflection to + * read the {@code flags}, {@code banditReferences}, and {@code bandits} fields. These field names + * are stable; the class declares {@code serialVersionUID = 1L} to signal serialization + * compatibility. + * + *

Usage: + * + *

{@code
+ * CachingConfigurationStore store = new FileBackedConfigStore<>(
+ *     context,
+ *     new GsonConfigurationCodec());
+ * }
+ */ +public class GsonConfigurationCodec implements ConfigurationCodec { + + private static final String TAG = GsonConfigurationCodec.class.getSimpleName(); + private static final int FORMAT_VERSION = 1; + + private static final ThreadLocal UTC_DATE_FORMAT = + ThreadLocal.withInitial( + () -> { + SimpleDateFormat fmt = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US); + fmt.setTimeZone(TimeZone.getTimeZone("UTC")); + return fmt; + }); + + // Reflection access to Configuration's private state + private static final Field FLAGS_FIELD; + private static final Field BANDIT_REFS_FIELD; + private static final Field BANDITS_FIELD; + + static { + try { + FLAGS_FIELD = Configuration.class.getDeclaredField("flags"); + FLAGS_FIELD.setAccessible(true); + BANDIT_REFS_FIELD = Configuration.class.getDeclaredField("banditReferences"); + BANDIT_REFS_FIELD.setAccessible(true); + BANDITS_FIELD = Configuration.class.getDeclaredField("bandits"); + BANDITS_FIELD.setAccessible(true); + } catch (NoSuchFieldException e) { + throw new ExceptionInInitializerError(e); + } + } + + @Override + public byte[] toBytes(@NotNull Configuration configuration) { + return serializeConfiguration(configuration).toString().getBytes(StandardCharsets.UTF_8); + } + + @Override + @NotNull public Configuration fromBytes(byte[] bytes) { + String json = new String(bytes, StandardCharsets.UTF_8); + return deserializeConfiguration(JsonParser.parseString(json).getAsJsonObject()); + } + + @Override + @NotNull public String getContentType() { + return "application/json"; + } + + // ===== Serialization ===== + + @SuppressWarnings("unchecked") + private JsonObject serializeConfiguration(Configuration config) { + JsonObject root = new JsonObject(); + root.addProperty("v", FORMAT_VERSION); + root.addProperty("isObfuscated", config.isConfigObfuscated()); + + String environmentName = config.getEnvironmentName(); + if (environmentName != null) { + root.addProperty("environmentName", environmentName); + } + + Date publishedAt = config.getConfigPublishedAt(); + if (publishedAt != null) { + root.addProperty("publishedAt", UTC_DATE_FORMAT.get().format(publishedAt)); + } + + String snapshotId = config.getFlagsSnapshotId(); + if (snapshotId != null) { + root.addProperty("snapshotId", snapshotId); + } + + try { + Map flags = (Map) FLAGS_FIELD.get(config); + Map banditRefs = + (Map) BANDIT_REFS_FIELD.get(config); + Map bandits = + (Map) BANDITS_FIELD.get(config); + + if (flags != null) { + root.add("flags", serializeFlags(flags)); + } + if (banditRefs != null && !banditRefs.isEmpty()) { + root.add("banditReferences", serializeBanditReferences(banditRefs)); + } + if (bandits != null && !bandits.isEmpty()) { + root.add("bandits", serializeBandits(bandits)); + } + } catch (IllegalAccessException e) { + throw new RuntimeException("Failed to access Configuration fields via reflection", e); + } + + return root; + } + + private JsonObject serializeFlags(Map flags) { + JsonObject obj = new JsonObject(); + for (Map.Entry entry : flags.entrySet()) { + obj.add(entry.getKey(), serializeFlag(entry.getValue())); + } + return obj; + } + + private JsonObject serializeFlag(FlagConfig flag) { + JsonObject obj = new JsonObject(); + obj.addProperty("key", flag.getKey()); + obj.addProperty("enabled", flag.isEnabled()); + obj.addProperty("totalShards", flag.getTotalShards()); + obj.addProperty("variationType", flag.getVariationType().value); + obj.add("variations", serializeVariations(flag.getVariations())); + obj.add("allocations", serializeAllocations(flag.getAllocations())); + return obj; + } + + private JsonObject serializeVariations(Map variations) { + JsonObject obj = new JsonObject(); + for (Map.Entry entry : variations.entrySet()) { + JsonObject varObj = new JsonObject(); + varObj.addProperty("key", entry.getValue().getKey()); + varObj.add("value", serializeEppoValue(entry.getValue().getValue())); + obj.add(entry.getKey(), varObj); + } + return obj; + } + + private JsonArray serializeAllocations(List allocations) { + JsonArray arr = new JsonArray(); + for (Allocation alloc : allocations) { + JsonObject obj = new JsonObject(); + obj.addProperty("key", alloc.getKey()); + obj.addProperty("doLog", alloc.doLog()); + + Date startAt = alloc.getStartAt(); + if (startAt != null) { + obj.addProperty("startAt", UTC_DATE_FORMAT.get().format(startAt)); + } + + Date endAt = alloc.getEndAt(); + if (endAt != null) { + obj.addProperty("endAt", UTC_DATE_FORMAT.get().format(endAt)); + } + + Set rules = alloc.getRules(); + obj.add("rules", rules != null ? serializeTargetingRules(rules) : new JsonArray()); + obj.add("splits", serializeSplits(alloc.getSplits())); + arr.add(obj); + } + return arr; + } + + private JsonArray serializeTargetingRules(Set rules) { + JsonArray arr = new JsonArray(); + for (TargetingRule rule : rules) { + JsonObject ruleObj = new JsonObject(); + JsonArray conditions = new JsonArray(); + for (TargetingCondition cond : rule.getConditions()) { + JsonObject condObj = new JsonObject(); + condObj.addProperty("operator", cond.getOperator().value); + condObj.addProperty("attribute", cond.getAttribute()); + condObj.add("value", serializeEppoValue(cond.getValue())); + conditions.add(condObj); + } + ruleObj.add("conditions", conditions); + arr.add(ruleObj); + } + return arr; + } + + private JsonArray serializeSplits(List splits) { + JsonArray arr = new JsonArray(); + for (Split split : splits) { + JsonObject obj = new JsonObject(); + obj.addProperty("variationKey", split.getVariationKey()); + obj.add("shards", serializeShards(split.getShards())); + Map extraLogging = split.getExtraLogging(); + if (!extraLogging.isEmpty()) { + JsonObject extraObj = new JsonObject(); + for (Map.Entry entry : extraLogging.entrySet()) { + extraObj.addProperty(entry.getKey(), entry.getValue()); + } + obj.add("extraLogging", extraObj); + } + arr.add(obj); + } + return arr; + } + + private JsonArray serializeShards(Set shards) { + JsonArray arr = new JsonArray(); + for (Shard shard : shards) { + JsonObject obj = new JsonObject(); + obj.addProperty("salt", shard.getSalt()); + JsonArray ranges = new JsonArray(); + for (ShardRange range : shard.getRanges()) { + JsonObject rangeObj = new JsonObject(); + rangeObj.addProperty("start", range.getStart()); + rangeObj.addProperty("end", range.getEnd()); + ranges.add(rangeObj); + } + obj.add("ranges", ranges); + arr.add(obj); + } + return arr; + } + + private JsonElement serializeEppoValue(@Nullable EppoValue value) { + if (value == null || value.isNull()) { + return JsonNull.INSTANCE; + } + if (value.isBoolean()) { + return new JsonPrimitive(value.booleanValue()); + } + if (value.isNumeric()) { + return new JsonPrimitive(value.doubleValue()); + } + if (value.isStringArray()) { + JsonArray arr = new JsonArray(); + for (String s : value.stringArrayValue()) { + arr.add(s); + } + return arr; + } + return new JsonPrimitive(value.stringValue()); + } + + private JsonObject serializeBanditReferences(Map banditRefs) { + JsonObject obj = new JsonObject(); + for (Map.Entry entry : banditRefs.entrySet()) { + BanditReference ref = entry.getValue(); + JsonObject refObj = new JsonObject(); + refObj.addProperty("modelVersion", ref.getModelVersion()); + JsonArray variations = new JsonArray(); + for (BanditFlagVariation fv : ref.getFlagVariations()) { + JsonObject fvObj = new JsonObject(); + // "key" matches the field name expected by GsonConfigurationParser + fvObj.addProperty("key", fv.getBanditKey()); + fvObj.addProperty("flagKey", fv.getFlagKey()); + fvObj.addProperty("allocationKey", fv.getAllocationKey()); + fvObj.addProperty("variationKey", fv.getVariationKey()); + fvObj.addProperty("variationValue", fv.getVariationValue()); + variations.add(fvObj); + } + refObj.add("flagVariations", variations); + obj.add(entry.getKey(), refObj); + } + return obj; + } + + private JsonObject serializeBandits(Map bandits) { + JsonObject obj = new JsonObject(); + for (Map.Entry entry : bandits.entrySet()) { + BanditParameters bp = entry.getValue(); + JsonObject bpObj = new JsonObject(); + bpObj.addProperty("banditKey", bp.getBanditKey()); + Date updatedAt = bp.getUpdatedAt(); + if (updatedAt != null) { + bpObj.addProperty("updatedAt", UTC_DATE_FORMAT.get().format(updatedAt)); + } + bpObj.addProperty("modelName", bp.getModelName()); + bpObj.addProperty("modelVersion", bp.getModelVersion()); + bpObj.add("modelData", serializeBanditModelData(bp.getModelData())); + obj.add(entry.getKey(), bpObj); + } + return obj; + } + + private JsonObject serializeBanditModelData(BanditModelData modelData) { + JsonObject obj = new JsonObject(); + obj.addProperty("gamma", modelData.getGamma()); + obj.addProperty("defaultActionScore", modelData.getDefaultActionScore()); + obj.addProperty("actionProbabilityFloor", modelData.getActionProbabilityFloor()); + JsonObject coefficients = new JsonObject(); + for (Map.Entry entry : modelData.getCoefficients().entrySet()) { + coefficients.add(entry.getKey(), serializeBanditCoefficients(entry.getValue())); + } + obj.add("coefficients", coefficients); + return obj; + } + + private JsonObject serializeBanditCoefficients(BanditCoefficients bc) { + JsonObject obj = new JsonObject(); + obj.addProperty("actionKey", bc.getActionKey()); + obj.addProperty("intercept", bc.getIntercept()); + obj.add( + "subjectNumericCoefficients", + serializeNumericCoefficients(bc.getSubjectNumericCoefficients())); + obj.add( + "subjectCategoricalCoefficients", + serializeCategoricalCoefficients(bc.getSubjectCategoricalCoefficients())); + obj.add( + "actionNumericCoefficients", + serializeNumericCoefficients(bc.getActionNumericCoefficients())); + obj.add( + "actionCategoricalCoefficients", + serializeCategoricalCoefficients(bc.getActionCategoricalCoefficients())); + return obj; + } + + private JsonArray serializeNumericCoefficients( + Map coefs) { + JsonArray arr = new JsonArray(); + for (BanditNumericAttributeCoefficients c : coefs.values()) { + JsonObject obj = new JsonObject(); + obj.addProperty("attributeKey", c.getAttributeKey()); + obj.addProperty("coefficient", c.getCoefficient()); + obj.addProperty("missingValueCoefficient", c.getMissingValueCoefficient()); + arr.add(obj); + } + return arr; + } + + private JsonArray serializeCategoricalCoefficients( + Map coefs) { + JsonArray arr = new JsonArray(); + for (BanditCategoricalAttributeCoefficients c : coefs.values()) { + JsonObject obj = new JsonObject(); + obj.addProperty("attributeKey", c.getAttributeKey()); + obj.addProperty("missingValueCoefficient", c.getMissingValueCoefficient()); + JsonObject valueCoefficients = new JsonObject(); + for (Map.Entry entry : c.getValueCoefficients().entrySet()) { + valueCoefficients.addProperty(entry.getKey(), entry.getValue()); + } + obj.add("valueCoefficients", valueCoefficients); + arr.add(obj); + } + return arr; + } + + // ===== Deserialization ===== + + private Configuration deserializeConfiguration(JsonObject root) { + int version = root.has("v") ? root.get("v").getAsInt() : 1; + if (version != FORMAT_VERSION) { + Log.w(TAG, "Unknown cache format version " + version + "; attempting deserialization anyway"); + } + + boolean isObfuscated = + root.has("isObfuscated") + && !root.get("isObfuscated").isJsonNull() + && root.get("isObfuscated").getAsBoolean(); + + String environmentName = stringOrNull(root, "environmentName"); + Date publishedAt = parseDateElement(root.get("publishedAt")); + String snapshotId = stringOrNull(root, "snapshotId"); + + Map flags = new HashMap<>(); + JsonElement flagsEl = root.get("flags"); + if (flagsEl != null && flagsEl.isJsonObject()) { + for (Map.Entry e : flagsEl.getAsJsonObject().entrySet()) { + flags.put(e.getKey(), deserializeFlag(e.getValue().getAsJsonObject())); + } + } + + Map banditRefs = new HashMap<>(); + JsonElement banditRefsEl = root.get("banditReferences"); + if (banditRefsEl != null && banditRefsEl.isJsonObject()) { + for (Map.Entry e : banditRefsEl.getAsJsonObject().entrySet()) { + banditRefs.put(e.getKey(), deserializeBanditReference(e.getValue().getAsJsonObject())); + } + } + + Map bandits = new HashMap<>(); + JsonElement banditsEl = root.get("bandits"); + if (banditsEl != null && banditsEl.isJsonObject()) { + for (Map.Entry e : banditsEl.getAsJsonObject().entrySet()) { + bandits.put(e.getKey(), deserializeBanditParameters(e.getValue().getAsJsonObject())); + } + } + + FlagConfigResponse.Format format = + isObfuscated ? FlagConfigResponse.Format.CLIENT : FlagConfigResponse.Format.SERVER; + FlagConfigResponse flagConfigResponse = + new FlagConfigResponse.Default(flags, banditRefs, format, environmentName, publishedAt); + + Configuration.Builder builder = new Configuration.Builder(flagConfigResponse); + if (!bandits.isEmpty()) { + builder.banditParameters(new BanditParametersResponse.Default(bandits)); + } + if (snapshotId != null) { + builder.flagsSnapshotId(snapshotId); + } + + return builder.build(); + } + + private FlagConfig deserializeFlag(JsonObject obj) { + String key = obj.get("key").getAsString(); + boolean enabled = obj.get("enabled").getAsBoolean(); + int totalShards = obj.get("totalShards").getAsInt(); + VariationType variationType = VariationType.fromString(obj.get("variationType").getAsString()); + Map variations = deserializeVariations(obj.get("variations")); + List allocations = deserializeAllocations(obj.get("allocations")); + return new FlagConfig.Default( + key, enabled, totalShards, variationType, variations, allocations); + } + + private Map deserializeVariations(JsonElement element) { + Map variations = new HashMap<>(); + if (element == null || !element.isJsonObject()) { + return variations; + } + for (Map.Entry entry : element.getAsJsonObject().entrySet()) { + JsonObject varObj = entry.getValue().getAsJsonObject(); + variations.put( + entry.getKey(), + new Variation.Default( + varObj.get("key").getAsString(), deserializeEppoValue(varObj.get("value")))); + } + return variations; + } + + private List deserializeAllocations(JsonElement element) { + List allocations = new ArrayList<>(); + if (element == null || !element.isJsonArray()) { + return allocations; + } + for (JsonElement allocationEl : element.getAsJsonArray()) { + JsonObject obj = allocationEl.getAsJsonObject(); + String key = obj.get("key").getAsString(); + Set rules = deserializeTargetingRules(obj.get("rules")); + Date startAt = parseDateElement(obj.get("startAt")); + Date endAt = parseDateElement(obj.get("endAt")); + List splits = deserializeSplits(obj.get("splits")); + boolean doLog = obj.get("doLog").getAsBoolean(); + allocations.add(new Allocation.Default(key, rules, startAt, endAt, splits, doLog)); + } + return allocations; + } + + private Set deserializeTargetingRules(JsonElement element) { + Set rules = new HashSet<>(); + if (element == null || !element.isJsonArray()) { + return rules; + } + for (JsonElement ruleEl : element.getAsJsonArray()) { + JsonObject ruleObj = ruleEl.getAsJsonObject(); + Set conditions = new HashSet<>(); + JsonElement conditionsEl = ruleObj.get("conditions"); + if (conditionsEl != null && conditionsEl.isJsonArray()) { + for (JsonElement condEl : conditionsEl.getAsJsonArray()) { + JsonObject cond = condEl.getAsJsonObject(); + OperatorType operator = OperatorType.fromString(cond.get("operator").getAsString()); + if (operator == null) { + Log.w(TAG, "Unknown operator: " + cond.get("operator").getAsString()); + continue; + } + conditions.add( + new TargetingCondition.Default( + operator, + cond.get("attribute").getAsString(), + deserializeEppoValue(cond.get("value")))); + } + } + rules.add(new TargetingRule.Default(conditions)); + } + return rules; + } + + private List deserializeSplits(JsonElement element) { + List splits = new ArrayList<>(); + if (element == null || !element.isJsonArray()) { + return splits; + } + for (JsonElement splitEl : element.getAsJsonArray()) { + JsonObject obj = splitEl.getAsJsonObject(); + String variationKey = obj.get("variationKey").getAsString(); + Set shards = deserializeShards(obj.get("shards")); + Map extraLogging = new HashMap<>(); + JsonElement extraEl = obj.get("extraLogging"); + if (extraEl != null && extraEl.isJsonObject()) { + for (Map.Entry entry : extraEl.getAsJsonObject().entrySet()) { + extraLogging.put(entry.getKey(), entry.getValue().getAsString()); + } + } + splits.add(new Split.Default(variationKey, shards, extraLogging)); + } + return splits; + } + + private Set deserializeShards(JsonElement element) { + Set shards = new HashSet<>(); + if (element == null || !element.isJsonArray()) { + return shards; + } + for (JsonElement shardEl : element.getAsJsonArray()) { + JsonObject obj = shardEl.getAsJsonObject(); + String salt = obj.get("salt").getAsString(); + Set ranges = new HashSet<>(); + JsonElement rangesEl = obj.get("ranges"); + if (rangesEl != null && rangesEl.isJsonArray()) { + for (JsonElement rangeEl : rangesEl.getAsJsonArray()) { + JsonObject range = rangeEl.getAsJsonObject(); + ranges.add(new ShardRange(range.get("start").getAsInt(), range.get("end").getAsInt())); + } + } + shards.add(new Shard.Default(salt, ranges)); + } + return shards; + } + + private BanditReference deserializeBanditReference(JsonObject obj) { + String modelVersion = obj.get("modelVersion").getAsString(); + List flagVariations = new ArrayList<>(); + JsonElement fvsEl = obj.get("flagVariations"); + if (fvsEl != null && fvsEl.isJsonArray()) { + for (JsonElement fvEl : fvsEl.getAsJsonArray()) { + JsonObject fv = fvEl.getAsJsonObject(); + flagVariations.add( + new BanditFlagVariation.Default( + fv.get("key").getAsString(), + fv.get("flagKey").getAsString(), + fv.get("allocationKey").getAsString(), + fv.get("variationKey").getAsString(), + fv.get("variationValue").getAsString())); + } + } + return new BanditReference.Default(modelVersion, flagVariations); + } + + private BanditParameters deserializeBanditParameters(JsonObject obj) { + String banditKey = obj.get("banditKey").getAsString(); + Date updatedAt = parseDateElement(obj.get("updatedAt")); + String modelName = obj.get("modelName").getAsString(); + String modelVersion = obj.get("modelVersion").getAsString(); + BanditModelData modelData = deserializeBanditModelData(obj.get("modelData").getAsJsonObject()); + return new BanditParameters.Default(banditKey, updatedAt, modelName, modelVersion, modelData); + } + + private BanditModelData deserializeBanditModelData(JsonObject obj) { + double gamma = obj.get("gamma").getAsDouble(); + double defaultActionScore = obj.get("defaultActionScore").getAsDouble(); + double actionProbabilityFloor = obj.get("actionProbabilityFloor").getAsDouble(); + Map coefficients = new HashMap<>(); + JsonElement coefsEl = obj.get("coefficients"); + if (coefsEl != null && coefsEl.isJsonObject()) { + for (Map.Entry entry : coefsEl.getAsJsonObject().entrySet()) { + coefficients.put( + entry.getKey(), deserializeBanditCoefficients(entry.getValue().getAsJsonObject())); + } + } + return new BanditModelData.Default( + gamma, defaultActionScore, actionProbabilityFloor, coefficients); + } + + private BanditCoefficients deserializeBanditCoefficients(JsonObject obj) { + String actionKey = obj.get("actionKey").getAsString(); + double intercept = obj.get("intercept").getAsDouble(); + Map subjectNumeric = + deserializeNumericCoefficients(obj.get("subjectNumericCoefficients")); + Map subjectCategorical = + deserializeCategoricalCoefficients(obj.get("subjectCategoricalCoefficients")); + Map actionNumeric = + deserializeNumericCoefficients(obj.get("actionNumericCoefficients")); + Map actionCategorical = + deserializeCategoricalCoefficients(obj.get("actionCategoricalCoefficients")); + return new BanditCoefficients.Default( + actionKey, intercept, subjectNumeric, subjectCategorical, actionNumeric, actionCategorical); + } + + private Map deserializeNumericCoefficients( + JsonElement element) { + Map result = new HashMap<>(); + if (element == null || !element.isJsonArray()) { + return result; + } + for (JsonElement item : element.getAsJsonArray()) { + JsonObject obj = item.getAsJsonObject(); + String attributeKey = obj.get("attributeKey").getAsString(); + result.put( + attributeKey, + new BanditNumericAttributeCoefficients.Default( + attributeKey, + obj.get("coefficient").getAsDouble(), + obj.get("missingValueCoefficient").getAsDouble())); + } + return result; + } + + private Map deserializeCategoricalCoefficients( + JsonElement element) { + Map result = new HashMap<>(); + if (element == null || !element.isJsonArray()) { + return result; + } + for (JsonElement item : element.getAsJsonArray()) { + JsonObject obj = item.getAsJsonObject(); + String attributeKey = obj.get("attributeKey").getAsString(); + Map valueCoefficients = new HashMap<>(); + JsonElement valuesEl = obj.get("valueCoefficients"); + if (valuesEl != null && valuesEl.isJsonObject()) { + for (Map.Entry entry : valuesEl.getAsJsonObject().entrySet()) { + valueCoefficients.put(entry.getKey(), entry.getValue().getAsDouble()); + } + } + result.put( + attributeKey, + new BanditCategoricalAttributeCoefficients.Default( + attributeKey, obj.get("missingValueCoefficient").getAsDouble(), valueCoefficients)); + } + return result; + } + + // ===== Helpers ===== + + private EppoValue deserializeEppoValue(JsonElement element) { + if (element == null || element.isJsonNull()) { + return EppoValue.nullValue(); + } + if (element.isJsonArray()) { + List arr = new ArrayList<>(); + for (JsonElement item : element.getAsJsonArray()) { + arr.add(item.getAsString()); + } + return EppoValue.valueOf(arr); + } + if (element.isJsonPrimitive()) { + if (element.getAsJsonPrimitive().isBoolean()) { + return EppoValue.valueOf(element.getAsBoolean()); + } + if (element.getAsJsonPrimitive().isNumber()) { + return EppoValue.valueOf(element.getAsDouble()); + } + return EppoValue.valueOf(element.getAsString()); + } + Log.w(TAG, "Unexpected JSON element for EppoValue: " + element); + return EppoValue.nullValue(); + } + + @Nullable private static Date parseDateElement(JsonElement element) { + if (element == null || element.isJsonNull()) { + return null; + } + try { + return UTC_DATE_FORMAT.get().parse(element.getAsString()); + } catch (ParseException e) { + Log.w(TAG, "Failed to parse date: " + element.getAsString()); + return null; + } + } + + @Nullable private static String stringOrNull(JsonObject obj, String key) { + JsonElement el = obj.get(key); + return (el != null && !el.isJsonNull()) ? el.getAsString() : null; + } +} diff --git a/android-sdk-framework/src/main/java/cloud/eppo/android/framework/util/Utils.java b/android-sdk-framework/src/main/java/cloud/eppo/android/framework/util/Utils.java new file mode 100644 index 00000000..98c4ff1a --- /dev/null +++ b/android-sdk-framework/src/main/java/cloud/eppo/android/framework/util/Utils.java @@ -0,0 +1,21 @@ +package cloud.eppo.android.framework.util; + +public class Utils { + public static String logTag(Class loggingClass) { + // Common prefix can make filtering logs easier + String logTag = ("EppoSDK:" + loggingClass.getSimpleName()); + + // Android prefers keeping log tags 23 characters or less + if (logTag.length() > 23) { + logTag = logTag.substring(0, 23); + } + + return logTag; + } + + public static String safeCacheKey(String key) { + // Take the first eight characters to avoid the key being sensitive information + // Remove non-alphanumeric characters so it plays nice with filesystem + return key.substring(0, 8).replaceAll("\\W", ""); + } +} diff --git a/android-sdk-framework/src/test/java/cloud/eppo/android/framework/storage/CachingConfigurationStoreTest.java b/android-sdk-framework/src/test/java/cloud/eppo/android/framework/storage/CachingConfigurationStoreTest.java new file mode 100644 index 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..342832d0 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://oss.sonatype.org/content/repositories/snapshots/" } } } rootProject.name = "Eppo SDK" include ':example' include ':eppo' +include ':android-sdk-framework' \ No newline at end of file From bbb000371554a3b040b7a702f959d3f084cc67cc Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Thu, 12 Mar 2026 14:27:22 -0600 Subject: [PATCH 2/2] fix: update Maven snapshot repository URL to new Maven Central The v4 framework snapshots are deployed to the new Maven Central snapshot repository at https://central.sonatype.com/repository/maven-snapshots, not the old OSSRH at https://oss.sonatype.org/content/repositories/snapshots/ --- settings.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/settings.gradle b/settings.gradle index 342832d0..1862a290 100644 --- a/settings.gradle +++ b/settings.gradle @@ -12,7 +12,7 @@ dependencyResolutionManagement { mavenCentral() mavenLocal() maven { - url "https://oss.sonatype.org/content/repositories/snapshots/" + url "https://central.sonatype.com/repository/maven-snapshots" } } }