From b82fbbf41f1a4a6f6f31dcafdc636d569ead4b58 Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Thu, 12 Mar 2026 14:03:24 -0600 Subject: [PATCH 01/27] 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 02/27] 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" } } } From 45934dbb32124b2c9a11895d49c9b28a4a3c9eab Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Mon, 23 Mar 2026 15:47:02 -0600 Subject: [PATCH 03/27] fix: address code review issues in android-sdk-framework MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - resumePolling(): store pollingIntervalMs/pollingJitterMs on instance so pause/resume works after initial polling is started via Builder - safeCacheKey(): guard against keys shorter than 8 characters with Math.min to avoid StringIndexOutOfBoundsException - BaseCacheFile.setContents(): use try-with-resources to close writer even when write() throws - FileBackedByteStore: use a dedicated single-thread executor for I/O instead of ForkJoinPool.commonPool() to avoid pool saturation on low-core-count devices - ConfigCacheFile: document why deprecated package-private constructors exist (v3→v4 migration support) - build.gradle: clarify intent of EPPO_VERSION build config field --- android-sdk-framework/build.gradle | 2 ++ .../eppo/android/framework/AndroidBaseClient.java | 3 +++ .../android/framework/storage/BaseCacheFile.java | 4 +--- .../android/framework/storage/ConfigCacheFile.java | 8 ++++++-- .../framework/storage/FileBackedByteStore.java | 12 ++++++++++-- .../cloud/eppo/android/framework/util/Utils.java | 2 +- 6 files changed, 23 insertions(+), 8 deletions(-) diff --git a/android-sdk-framework/build.gradle b/android-sdk-framework/build.gradle index a4d789b1..0404efee 100644 --- a/android-sdk-framework/build.gradle +++ b/android-sdk-framework/build.gradle @@ -23,6 +23,8 @@ android { buildTypes { def FRAMEWORK_VERSION = "FRAMEWORK_VERSION" + // EPPO_VERSION is used as the sdkVersion reported to Eppo. It matches FRAMEWORK_VERSION + // because the framework and eppo modules are versioned together. def EPPO_VERSION = "EPPO_VERSION" release { minifyEnabled false 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 index 83753f67..b129a190 100644 --- 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 @@ -326,6 +326,9 @@ public CompletableFuture> buildAndInitAsync() { effectiveJitter = pollingIntervalMs / DEFAULT_JITTER_INTERVAL_RATIO; } + // Store interval/jitter on the instance so resumePolling() can restart with the same values. + newInstance.pollingIntervalMs = pollingIntervalMs; + newInstance.pollingJitterMs = effectiveJitter; newInstance.startPolling(pollingIntervalMs, effectiveJitter); } 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 index 1bbf349b..9c302b97 100644 --- 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 @@ -56,10 +56,8 @@ public BufferedReader getReader() throws IOException { /** Useful for mocking caches in automated tests. */ public void setContents(String contents) { delete(); - try { - BufferedWriter writer = getWriter(); + 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/ConfigCacheFile.java b/android-sdk-framework/src/main/java/cloud/eppo/android/framework/storage/ConfigCacheFile.java index 026ee3a3..5d079525 100644 --- 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 @@ -37,7 +37,9 @@ public ConfigCacheFile( * 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. + * @deprecated Use {@link #ConfigCacheFile(Application, String, String)} instead. These + * package-private constructors exist only to support migration of the eppo module from v3 to + * v4; they will be removed once that migration is complete. */ ConfigCacheFile( @NotNull Application application, @@ -51,7 +53,9 @@ public ConfigCacheFile( * 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. + * @deprecated Use {@link #ConfigCacheFile(Application, String, String)} instead. These + * package-private constructors exist only to support migration of the eppo module from v3 to + * v4; they will be removed once that migration is complete. */ ConfigCacheFile(@NotNull Application application, @NotNull String fullFileName) { super(application, fullFileName); diff --git a/android-sdk-framework/src/main/java/cloud/eppo/android/framework/storage/FileBackedByteStore.java b/android-sdk-framework/src/main/java/cloud/eppo/android/framework/storage/FileBackedByteStore.java index c14f2ca2..e0608452 100644 --- 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 @@ -1,6 +1,8 @@ package cloud.eppo.android.framework.storage; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; import org.jetbrains.annotations.NotNull; /** @@ -8,6 +10,10 @@ */ public final class FileBackedByteStore implements ByteStore { + // Dedicated single-thread executor avoids saturating ForkJoinPool.commonPool() with blocking I/O + // on low-core-count Android devices. + private static final Executor IO_EXECUTOR = Executors.newSingleThreadExecutor(); + private final BaseCacheFile cacheFile; public FileBackedByteStore(@NotNull BaseCacheFile cacheFile) { @@ -29,7 +35,8 @@ public FileBackedByteStore(@NotNull BaseCacheFile cacheFile) { } catch (Exception e) { throw new RuntimeException("Failed to read from cache file", e); } - }); + }, + IO_EXECUTOR); } @Override @@ -44,7 +51,8 @@ public FileBackedByteStore(@NotNull BaseCacheFile cacheFile) { } catch (Exception e) { throw new RuntimeException("Failed to write to cache file", e); } - }); + }, + IO_EXECUTOR); } private static byte[] readAllBytes(java.io.InputStream in) throws java.io.IOException { 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 index 98c4ff1a..d244d77b 100644 --- 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 @@ -16,6 +16,6 @@ public static String logTag(Class loggingClass) { 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", ""); + return key.substring(0, Math.min(8, key.length())).replaceAll("\\W", ""); } } From 882786a07584bc152736984de2ed866ec3f3c285 Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Mon, 23 Mar 2026 23:25:06 -0600 Subject: [PATCH 04/27] fix: address Copilot review feedback on android-sdk-framework MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - safeCacheKey(): add null/empty guard returning "" to match Copilot suggestion; also prevents NPE on null key during initialization - GsonConfigurationCodec: delete from framework module; uses reflection on private fields of Configuration which R8 will rename in release builds (no proguard keep rules), causing NoSuchFieldException at runtime — flagged by both Copilot and code reviewer --- .../storage/GsonConfigurationCodec.java | 708 ------------------ .../eppo/android/framework/util/Utils.java | 3 + 2 files changed, 3 insertions(+), 708 deletions(-) delete mode 100644 android-sdk-framework/src/main/java/cloud/eppo/android/framework/storage/GsonConfigurationCodec.java 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 deleted file mode 100644 index 6c7b1277..00000000 --- a/android-sdk-framework/src/main/java/cloud/eppo/android/framework/storage/GsonConfigurationCodec.java +++ /dev/null @@ -1,708 +0,0 @@ -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 index d244d77b..90db72c1 100644 --- 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 @@ -14,6 +14,9 @@ public static String logTag(Class loggingClass) { } public static String safeCacheKey(String key) { + if (key == null || key.isEmpty()) { + return ""; + } // Take the first eight characters to avoid the key being sensitive information // Remove non-alphanumeric characters so it plays nice with filesystem return key.substring(0, Math.min(8, key.length())).replaceAll("\\W", ""); From 79448171e8275dbb2f7b47f9a4eecfc86bcc5661 Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Tue, 24 Mar 2026 09:38:02 -0600 Subject: [PATCH 05/27] style: fix spotless formatting in AndroidBaseClient --- .../java/cloud/eppo/android/framework/AndroidBaseClient.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 index b129a190..044f9940 100644 --- 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 @@ -326,7 +326,8 @@ public CompletableFuture> buildAndInitAsync() { effectiveJitter = pollingIntervalMs / DEFAULT_JITTER_INTERVAL_RATIO; } - // Store interval/jitter on the instance so resumePolling() can restart with the same values. + // Store interval/jitter on the instance so resumePolling() can restart with the same + // values. newInstance.pollingIntervalMs = pollingIntervalMs; newInstance.pollingJitterMs = effectiveJitter; newInstance.startPolling(pollingIntervalMs, effectiveJitter); From 9aa5bd6ced1230b5c02202c840a11dcb187ee257 Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Wed, 15 Apr 2026 23:27:59 -0600 Subject: [PATCH 06/27] fix: address muse review feedback on android-sdk-framework - Fix failCount double-increment hang: remove redundant increment from the else-branch in buildAndInitAsync; only the else-if path increments - Fix concurrent-save revert race in CachingConfigurationStore: replace volatile field with AtomicReference + compareAndSet so a concurrent successful save is never overwritten by a failing save's revert - Preserve HTTP exception as cause in EppoInitializationException when both the network fetch and initial config load fail; previously the cause was silently null - Add @After teardown to EppoClientPollingTest to stop polling timers between tests; replace verify(never()) with verify(atMost(1)) to tolerate one in-flight invocation after cancel(false) - Fix testConcurrentWrites assertion to assertArrayEquals(data2) since IO_EXECUTOR serializes writes in submission order - Add daemon thread factory to IO_EXECUTOR so JVM/Robolectric can exit - Add volatile to static singleton instance field - Split InterruptedException catch to restore interrupt flag - Various comments, null guards, and minor scope/naming fixes --- android-sdk-framework/build.gradle | 4 +- .../framework/EppoClientPollingTest.java | 170 ++++++++++-------- .../android/framework/AndroidBaseClient.java | 52 ++++-- .../storage/CachingConfigurationStore.java | 33 +++- .../framework/storage/ConfigCacheFile.java | 2 + .../framework/storage/ConfigurationCodec.java | 3 + .../storage/FileBackedByteStore.java | 11 +- .../storage/FileBackedConfigStore.java | 12 +- .../eppo/android/framework/util/Utils.java | 12 +- .../storage/FileBackedByteStoreTest.java | 13 +- settings.gradle | 2 +- 11 files changed, 193 insertions(+), 121 deletions(-) diff --git a/android-sdk-framework/build.gradle b/android-sdk-framework/build.gradle index 0404efee..70a261b2 100644 --- a/android-sdk-framework/build.gradle +++ b/android-sdk-framework/build.gradle @@ -51,8 +51,8 @@ android { 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' + implementation 'org.slf4j:slf4j-android:1.7.36' + testImplementation 'com.google.code.gson:gson:2.10.1' compileOnly 'org.jetbrains:annotations:24.0.0' testImplementation 'cloud.eppo:sdk-common-jvm:4.0.0-SNAPSHOT' 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 index 3c09851d..d24ab81b 100644 --- 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 @@ -2,15 +2,24 @@ import static cloud.eppo.android.framework.util.Utils.logTag; import static org.junit.Assert.assertNotNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.atMost; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; import android.util.Log; import androidx.test.core.app.ApplicationProvider; import cloud.eppo.api.Configuration; import cloud.eppo.http.EppoConfigurationClient; +import cloud.eppo.http.EppoConfigurationRequest; +import cloud.eppo.http.EppoConfigurationResponse; import cloud.eppo.parser.ConfigurationParser; import com.fasterxml.jackson.databind.JsonNode; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; +import org.junit.After; import org.junit.Before; import org.junit.Test; import org.mockito.Mock; @@ -19,9 +28,8 @@ /** * 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. + *

These tests focus on verifying that pausePolling() and resumePolling() can be called safely in + * various sequences, and that polling actually stops and resumes as expected. */ public class EppoClientPollingTest { private static final String TAG = logTag(EppoClientPollingTest.class); @@ -30,103 +38,117 @@ public class EppoClientPollingTest { @Mock private ConfigurationParser mockConfigParser; @Mock private EppoConfigurationClient mockConfigClient; + // Tracks the last built client so tearDown can stop its polling timer. + private AndroidBaseClient lastClient; + @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(); + @After + public void tearDown() { + if (lastClient != null) { + lastClient.pausePolling(); + lastClient = null; + } } /** - * Builds a client in offline mode without polling enabled. + * Builds a client in offline mode with optional polling enabled. * - * @return Initialized EppoClient + * @param pollingEnabled whether to enable polling + * @param pollingIntervalMs polling interval in milliseconds (ignored when pollingEnabled=false) + * @return initialized EppoClient */ - private AndroidBaseClient buildOfflineClientWithoutPolling() + private AndroidBaseClient buildOfflineClient( + boolean pollingEnabled, long pollingIntervalMs) 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(); + AndroidBaseClient.Builder builder = + new AndroidBaseClient.Builder<>( + DUMMY_API_KEY, + ApplicationProvider.getApplicationContext(), + mockConfigParser, + mockConfigClient) + .forceReinitialize(true) + .offlineMode(true) + .initialConfiguration(initialConfig) + .pollingEnabled(pollingEnabled) + .isGracefulMode(true); + + if (pollingEnabled) { + builder.pollingIntervalMs(pollingIntervalMs); + } + + lastClient = builder.buildAndInitAsync().get(); + return lastClient; } @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); + // Use non-offline mode with a short interval so we can observe actual polling calls. + when(mockConfigClient.execute(any(EppoConfigurationRequest.class))) + .thenReturn(CompletableFuture.completedFuture(EppoConfigurationResponse.error(503, null))); - // Test resume - androidBaseClient.resumePolling(); - Log.d(TAG, "Polling resumed"); - - // Wait a bit to ensure no crashes - Thread.sleep(50); + CompletableFuture initialConfig = + CompletableFuture.completedFuture(Configuration.emptyConfig()); - // Final pause for cleanup - androidBaseClient.pausePolling(); + lastClient = + new AndroidBaseClient.Builder<>( + DUMMY_API_KEY, + ApplicationProvider.getApplicationContext(), + mockConfigParser, + mockConfigClient) + .forceReinitialize(true) + .initialConfiguration(initialConfig) + .pollingEnabled(true) + .pollingIntervalMs(50) + .isGracefulMode(true) + .buildAndInitAsync() + .get(); + + assertNotNull("Client should be initialized", lastClient); + + // Wait for at least one polling cycle to fire (50ms interval, wait 150ms). + Thread.sleep(150); + verify(mockConfigClient, atLeastOnce()).execute(any(EppoConfigurationRequest.class)); + + // Pause: stopPolling() calls cancel(false), which does not interrupt a task already running + // on the executor thread. At most one in-flight invocation can complete after pausePolling() + // returns, so we tolerate atMost(1) rather than never(). + lastClient.pausePolling(); + reset(mockConfigClient); + when(mockConfigClient.execute(any(EppoConfigurationRequest.class))) + .thenReturn(CompletableFuture.completedFuture(EppoConfigurationResponse.error(503, null))); + Thread.sleep(200); // wait 4 intervals — polling must be stopped + verify(mockConfigClient, atMost(1)).execute(any(EppoConfigurationRequest.class)); + + // Resume: polling fires again within one interval. + lastClient.resumePolling(); + Thread.sleep(150); + verify(mockConfigClient, atLeastOnce()).execute(any(EppoConfigurationRequest.class)); + + lastClient.pausePolling(); } @Test public void testResumePollingWithoutStarting() throws ExecutionException, InterruptedException { - AndroidBaseClient androidBaseClient = buildOfflineClientWithoutPolling(); + AndroidBaseClient androidBaseClient = buildOfflineClient(false, 0); assertNotNull("Client should be initialized", androidBaseClient); - // Try to resume polling (should log warning and not crash per EppoClient.java:436-441) + // resumePolling() logs a warning when polling interval was not set and does not start polling. 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); + AndroidBaseClient androidBaseClient = buildOfflineClient(true, 100); assertNotNull("Client should be initialized", androidBaseClient); // First cycle @@ -145,14 +167,6 @@ public void testMultiplePauseResumeCycles() throws ExecutionException, Interrupt 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(); } @@ -160,7 +174,7 @@ public void testMultiplePauseResumeCycles() throws ExecutionException, Interrupt @Test public void testPauseResumeSequenceDoesNotCrash() throws ExecutionException, InterruptedException { - AndroidBaseClient androidBaseClient = buildOfflineClientWithPolling(50); + AndroidBaseClient androidBaseClient = buildOfflineClient(true, 50); // Various sequences that should all work without crashing androidBaseClient.pausePolling(); @@ -182,13 +196,13 @@ public void testPauseResumeSequenceDoesNotCrash() @Test public void testPollingNotEnabledAndResume() throws ExecutionException, InterruptedException { - AndroidBaseClient androidBaseClient = buildOfflineClientWithoutPolling(); + AndroidBaseClient androidBaseClient = buildOfflineClient(false, 0); // Pause should be safe even if not polling androidBaseClient.pausePolling(); Thread.sleep(50); - // Resume should log warning per EppoClient.java:436-441 + // resumePolling() logs a warning when polling interval was not set and does not start polling. androidBaseClient.resumePolling(); Thread.sleep(50); @@ -200,7 +214,7 @@ public void testPollingNotEnabledAndResume() throws ExecutionException, Interrup @Test public void testPauseAfterInitDoesNotCrash() throws ExecutionException, InterruptedException { - AndroidBaseClient androidBaseClient = buildOfflineClientWithPolling(100); + AndroidBaseClient androidBaseClient = buildOfflineClient(true, 100); // Immediately pause after initialization androidBaseClient.pausePolling(); 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 index 044f9940..bb71ac52 100644 --- 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 @@ -20,6 +20,7 @@ import java.util.concurrent.CompletionException; import java.util.concurrent.ExecutionException; import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -42,7 +43,7 @@ public class AndroidBaseClient extends BaseEppoClient instance; + @Nullable private static volatile AndroidBaseClient instance; /** * Private constructor. Use Builder to construct instances. @@ -300,6 +301,9 @@ public CompletableFuture> buildAndInitAsync() { final CompletableFuture> ret = new CompletableFuture<>(); AtomicInteger failCount = new AtomicInteger(0); + // Captures the HTTP exception so that when the initial-config future completes the + // combined failure path can include the original network error as the cause. + AtomicReference httpFailure = new AtomicReference<>(); if (!offlineMode) { newInstance @@ -308,11 +312,15 @@ public CompletableFuture> buildAndInitAsync() { (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)); + } else { + httpFailure.set(ex); + if (failCount.incrementAndGet() == 2 + || newInstance.getInitialConfigFuture() == null) { + ret.completeExceptionally( + new EppoInitializationException( + "Unable to initialize client; Configuration could not be loaded", + ex)); + } } return null; }); @@ -321,16 +329,16 @@ public CompletableFuture> buildAndInitAsync() { // 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; + long effectiveJitterMs = pollingJitterMs; + if (effectiveJitterMs < 0) { + effectiveJitterMs = pollingIntervalMs / DEFAULT_JITTER_INTERVAL_RATIO; } // Store interval/jitter on the instance so resumePolling() can restart with the same // values. newInstance.pollingIntervalMs = pollingIntervalMs; - newInstance.pollingJitterMs = effectiveJitter; - newInstance.startPolling(pollingIntervalMs, effectiveJitter); + newInstance.pollingJitterMs = effectiveJitterMs; + newInstance.startPolling(pollingIntervalMs, effectiveJitterMs); } if (newInstance.getInitialConfigFuture() != null) { @@ -341,12 +349,16 @@ public CompletableFuture> buildAndInitAsync() { if (ex == null && Boolean.TRUE.equals(success)) { ret.complete(newInstance); } else if (offlineMode || failCount.incrementAndGet() == 2) { + // When both the HTTP fetch and initial config load fail, prefer the HTTP + // exception as the cause since it is more actionable than a null or false + // result from the initial config handler. + Throwable cause = httpFailure.get() != null ? httpFailure.get() : ex; ret.completeExceptionally( new EppoInitializationException( - "Unable to initialize client; Configuration could not be loaded", ex)); + "Unable to initialize client; Configuration could not be loaded", + cause)); } else { Log.i(TAG, "Initial config was not used."); - failCount.incrementAndGet(); } return null; }); @@ -367,14 +379,24 @@ public CompletableFuture> buildAndInitAsync() { /** * Builds and initializes the EppoClient synchronously (blocking). * - *

This is a blocking wrapper around buildAndInitAsync(). + *

This is a blocking wrapper around buildAndInitAsync(). The underlying future has no + * deadline: if the HTTP fetch stalls permanently (e.g. due to network unavailability with no + * timeout configured on the HTTP client), this call blocks indefinitely. Callers that require a + * bounded wait should use {@link #buildAndInitAsync()} with {@code + * CompletableFuture.orTimeout()} (API 31+) or a timed {@code get(long, TimeUnit)}. * * @return The initialized EppoClient */ public AndroidBaseClient buildAndInit() { try { return buildAndInitAsync().get(); - } catch (ExecutionException | InterruptedException | CompletionException e) { + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + Log.e(TAG, "Exception caught during initialization: " + e.getMessage(), e); + if (!isGracefulMode) { + throw new RuntimeException(e); + } + } catch (ExecutionException | 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`. 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 index 9eea7415..afd98b41 100644 --- 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 @@ -3,6 +3,7 @@ import cloud.eppo.IConfigurationStore; import cloud.eppo.api.Configuration; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicReference; import org.jetbrains.annotations.NotNull; /** @@ -13,10 +14,17 @@ public class CachingConfigurationStore implements IConfigurationStore { private final ConfigurationCodec codec; private final ByteStore byteStore; - private volatile Configuration configuration = Configuration.emptyConfig(); + private final AtomicReference configuration = + new AtomicReference<>(Configuration.emptyConfig()); protected CachingConfigurationStore( @NotNull ConfigurationCodec codec, @NotNull ByteStore byteStore) { + if (codec == null) { + throw new IllegalArgumentException("codec must not be null"); + } + if (byteStore == null) { + throw new IllegalArgumentException("byteStore must not be null"); + } this.codec = codec; this.byteStore = byteStore; } @@ -24,7 +32,7 @@ protected CachingConfigurationStore( /** Returns the current in-memory configuration. */ @Override @NotNull public Configuration getConfiguration() { - return configuration; + return configuration.get(); } /** @@ -39,12 +47,27 @@ protected CachingConfigurationStore( if (config == null) { throw new IllegalArgumentException("config must not be null"); } + Configuration previousConfiguration = configuration.get(); byte[] bytes = codec.toBytes(config); + configuration.set(config); // optimistic update — in-memory reflects last submitted save return byteStore .write(bytes) - .thenRun( - () -> { - this.configuration = config; + .whenComplete( + (v, ex) -> { + if (ex != null) { + // Revert the optimistic update, but only if no later save has superseded this + // one. compareAndSet atomically checks that the in-memory value is still the + // one we wrote; if a concurrent save has already advanced it, the revert is + // skipped. + // + // Edge case: if two concurrent saves both fail their IO writes, the second + // failure's revert may land on a value that was itself never persisted (the + // first save's value). This is acceptable — the in-memory state may diverge + // from disk, but the next successful save will reconcile them. Saves are + // serialized through a single-thread IO_EXECUTOR, so true concurrent IO + // failures are unlikely in practice. + configuration.compareAndSet(config, previousConfiguration); + } }); } 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 index 5d079525..cb826f41 100644 --- 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 @@ -41,6 +41,7 @@ public ConfigCacheFile( * package-private constructors exist only to support migration of the eppo module from v3 to * v4; they will be removed once that migration is complete. */ + @Deprecated ConfigCacheFile( @NotNull Application application, @NotNull String configType, @@ -57,6 +58,7 @@ public ConfigCacheFile( * package-private constructors exist only to support migration of the eppo module from v3 to * v4; they will be removed once that migration is complete. */ + @Deprecated 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 index a5ea4d4a..6683fd14 100644 --- 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 @@ -60,6 +60,9 @@ public static class Default * @param configClass the class of the configuration type */ public Default(@NotNull Class configClass) { + if (configClass == null) { + throw new IllegalArgumentException("configClass must not be null"); + } this.configClass = configClass; } 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 index e0608452..9cd27227 100644 --- 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 @@ -11,8 +11,15 @@ public final class FileBackedByteStore implements ByteStore { // Dedicated single-thread executor avoids saturating ForkJoinPool.commonPool() with blocking I/O - // on low-core-count Android devices. - private static final Executor IO_EXECUTOR = Executors.newSingleThreadExecutor(); + // on low-core-count Android devices. Daemon thread so the executor does not block JVM/process + // exit in test environments (e.g. Robolectric). + private static final Executor IO_EXECUTOR = + Executors.newSingleThreadExecutor( + r -> { + Thread t = new Thread(r, "eppo-io"); + t.setDaemon(true); + return t; + }); private final BaseCacheFile cacheFile; 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 index 39e4af90..1b97c515 100644 --- 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 @@ -17,13 +17,9 @@ 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); + super( + codec, + new FileBackedByteStore( + new ConfigCacheFile(application, cacheFileSuffix, codec.getContentType()))); } } 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 index 90db72c1..054e626e 100644 --- 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 @@ -1,7 +1,10 @@ package cloud.eppo.android.framework.util; public class Utils { - public static String logTag(Class loggingClass) { + public static String logTag(Class loggingClass) { + if (loggingClass == null) { + return "EppoSDK"; + } // Common prefix can make filtering logs easier String logTag = ("EppoSDK:" + loggingClass.getSimpleName()); @@ -17,8 +20,11 @@ public static String safeCacheKey(String key) { if (key == null || key.isEmpty()) { return ""; } - // Take the first eight characters to avoid the key being sensitive information - // Remove non-alphanumeric characters so it plays nice with filesystem + // Take the first eight characters to avoid the key being sensitive information. + // Remove non-alphanumeric characters so it plays nice with filesystem paths. + // Note: if the first 8 characters are all non-alphanumeric the result is an empty string, + // which produces the filename "eppo-sdk-flags-.bin". Eppo-issued API keys always start + // with alphanumeric characters, so this edge case is not expected in production. return key.substring(0, Math.min(8, key.length())).replaceAll("\\W", ""); } } diff --git a/android-sdk-framework/src/test/java/cloud/eppo/android/framework/storage/FileBackedByteStoreTest.java b/android-sdk-framework/src/test/java/cloud/eppo/android/framework/storage/FileBackedByteStoreTest.java index 9b28d9f8..071280f1 100644 --- 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 @@ -3,7 +3,6 @@ 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; @@ -27,7 +26,7 @@ public class FileBackedByteStoreTest { @Before public void setUp() { application = RuntimeEnvironment.getApplication(); - cacheFile = new ConfigCacheFile(application, "test-cache-file", "dat"); + cacheFile = new ConfigCacheFile(application, "test-cache-file", "application/octet-stream"); byteStore = new FileBackedByteStore(cacheFile); cacheFile.delete(); @@ -175,18 +174,18 @@ 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 + // Submit two writes without waiting for the first to finish. IO_EXECUTOR is a single-thread + // executor, so writes are serialized and never interleaved — this tests that queued writes + // complete without error and that the last-submitted write wins (data2 is always the result). 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 + // The executor serializes writes in submission order, so data2 always wins. 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)); + assertArrayEquals("data2 should always win due to IO_EXECUTOR serialization", data2, result); } } diff --git a/settings.gradle b/settings.gradle index 1862a290..a31545d6 100644 --- a/settings.gradle +++ b/settings.gradle @@ -12,7 +12,7 @@ dependencyResolutionManagement { mavenCentral() mavenLocal() maven { - url "https://central.sonatype.com/repository/maven-snapshots" + url "https://central.sonatype.com/repository/maven-snapshots/" } } } From 264661f9276fc3b1a873a3e46817e8ebe836b962 Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Thu, 16 Apr 2026 00:42:58 -0600 Subject: [PATCH 07/27] fix: harden android-sdk-framework against deserialization and improve docs - Add ObjectInputFilter allowlist to ConfigurationCodec.Default.fromBytes() to prevent gadget-chain attacks; restricts deserialization to cloud.eppo.**, java.util collections (with dollar-named inner classes listed first per first-match-wins semantics), and java.lang types - Remove dead CompletionException catch branch from AndroidBaseClient.buildAndInit(); CompletableFuture.get() only throws ExecutionException, never CompletionException - Remove unused import java.util.concurrent.CompletionException - Add @throws RuntimeException to buildAndInit() Javadoc - Document intentional early singleton assignment in buildAndInitAsync() for graceful mode; getInstance() is callable immediately after the future is returned - Improve saveConfiguration() Javadoc to document exceptional future completion - Move sign publishing.publications inside GPG credential guard to prevent Gradle model configuration errors when credentials are absent - Fix POM developer email: replace URL with valid email address sdk@geteppo.com - Add PublishToMavenLocal checkVersion dependency guard --- android-sdk-framework/build.gradle | 11 ++++++--- .../android/framework/AndroidBaseClient.java | 23 ++++++------------- .../storage/CachingConfigurationStore.java | 3 ++- .../framework/storage/ConfigurationCodec.java | 22 ++++++++++++++++++ 4 files changed, 39 insertions(+), 20 deletions(-) diff --git a/android-sdk-framework/build.gradle b/android-sdk-framework/build.gradle index 70a261b2..23de6dba 100644 --- a/android-sdk-framework/build.gradle +++ b/android-sdk-framework/build.gradle @@ -87,9 +87,10 @@ spotless { signing { if (System.getenv("GPG_PRIVATE_KEY") && System.getenv("GPG_PASSPHRASE")) { useInMemoryPgpKeys(System.env.GPG_PRIVATE_KEY, System.env.GPG_PASSPHRASE) + sign publishing.publications + } else { + required = false } - - sign publishing.publications } tasks.withType(Sign) { @@ -119,7 +120,7 @@ mavenPublishing { developers { developer { name = 'Eppo' - email = 'https://www.geteppo.com' + email = 'sdk@geteppo.com' } } scm { @@ -149,6 +150,10 @@ tasks.named('publish').configure { dependsOn checkVersion } +tasks.withType(PublishToMavenLocal).configureEach { + dependsOn checkVersion +} + tasks.withType(PublishToMavenRepository) { onlyIf { project.ext.has('shouldPublish') && project.ext.shouldPublish 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 index bb71ac52..cb2c7448 100644 --- 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 @@ -17,7 +17,6 @@ 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.concurrent.atomic.AtomicReference; @@ -291,7 +290,11 @@ public CompletableFuture> buildAndInitAsync() { configurationParser, configurationClient); - // Set as singleton + // Set as singleton early so that getInstance() works immediately after buildAndInitAsync() + // returns (e.g. in graceful mode where callers may call getInstance() before the returned + // future completes). In graceful mode this is intentional: the client returns safe defaults + // until configuration is loaded. Callers that need a fully initialized client must await the + // CompletableFuture returned by buildAndInitAsync() before calling getInstance(). instance = newInstance; // Register config change callback if provided @@ -386,6 +389,7 @@ public CompletableFuture> buildAndInitAsync() { * CompletableFuture.orTimeout()} (API 31+) or a timed {@code get(long, TimeUnit)}. * * @return The initialized EppoClient + * @throws RuntimeException if initialization fails and {@code isGracefulMode} is false */ public AndroidBaseClient buildAndInit() { try { @@ -396,20 +400,7 @@ public AndroidBaseClient buildAndInit() { if (!isGracefulMode) { throw new RuntimeException(e); } - } catch (ExecutionException | 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; - } - } + } catch (ExecutionException e) { Log.e(TAG, "Exception caught during initialization: " + e.getMessage(), e); if (!isGracefulMode) { throw new RuntimeException(e); 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 index afd98b41..47951c8c 100644 --- 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 @@ -39,7 +39,8 @@ protected CachingConfigurationStore( * 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 + * @return a future that completes when the write finishes, or completes exceptionally if the + * underlying {@link ByteStore} write fails (e.g. with an {@link java.io.IOException}) * @throws IllegalArgumentException if config is null */ @Override 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 index 6683fd14..3acf8950 100644 --- 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 @@ -4,6 +4,7 @@ import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.io.ObjectInputFilter; import java.io.ObjectInputStream; import java.io.ObjectOutputStream; import org.jetbrains.annotations.NotNull; @@ -87,6 +88,27 @@ public byte[] toBytes(@NotNull T configuration) { throw new IllegalArgumentException("Bytes must not be null"); } try (ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(bytes))) { + // Restrict deserialization to the Eppo SDK and standard JDK types to prevent + // gadget-chain attacks. ObjectInputFilter is available from API 26 (minSdk 26). + // The allowlist covers the full transitive type graph of Configuration: cloud.eppo.** + // (all SDK packages), java.util collections and their dollar-named inner classes + // (listed before java.util.* due to first-match-wins semantics), and java.lang + // primitives/wrappers. All other classes are rejected. + ois.setObjectInputFilter( + // Dollar-named inner classes must be listed before java.util.* because + // ObjectInputFilter uses first-match-wins — java.util.* only covers top-level + // class names (no $ or sub-packages), so inner classes like + // Collections$UnmodifiableMap or HashMap$Node would otherwise hit the !* deny-all. + ObjectInputFilter.Config.createFilter( + "cloud.eppo.**" + + ";java.util.Arrays$*" // Arrays.asList() instances + + ";java.util.Collections$*" // unmodifiable/empty/singleton wrappers + + ";java.util.ImmutableCollections$*" // List.of / Set.of / Map.of (API 24+) + + ";java.util.HashMap$*" // HashMap internal nodes + + ";java.util.LinkedHashMap$*" // LinkedHashMap internal nodes + + ";java.util.*" + + ";java.lang.*" + + ";!*")); Object obj = ois.readObject(); if (!configClass.isInstance(obj)) { throw new RuntimeException( From aa31c74fe7913d26fd56c5c3665b1a37d8684e8b Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Thu, 16 Apr 2026 02:46:24 -0600 Subject: [PATCH 08/27] =?UTF-8?q?fix:=20remove=20ObjectInputFilter=20?= =?UTF-8?q?=E2=80=94=20not=20available=20in=20Android=20SDK?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit java.io.ObjectInputFilter is a Java 9+ API absent from Android's android.jar at any API level, causing a compile error. The deserialization risk is low: the cache file lives in the app's private internal storage and the bytes are produced by the SDK's own ObjectOutputStream write path. The isInstance() type check remains to prevent returning the wrong type to callers; updated comment clarifies it does not prevent gadget-chain execution (which occurs inside readObject() before the check runs). --- .../framework/storage/ConfigurationCodec.java | 29 +++++-------------- 1 file changed, 7 insertions(+), 22 deletions(-) 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 index 3acf8950..e0145b3f 100644 --- 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 @@ -4,7 +4,6 @@ import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; -import java.io.ObjectInputFilter; import java.io.ObjectInputStream; import java.io.ObjectOutputStream; import org.jetbrains.annotations.NotNull; @@ -88,27 +87,13 @@ public byte[] toBytes(@NotNull T configuration) { throw new IllegalArgumentException("Bytes must not be null"); } try (ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(bytes))) { - // Restrict deserialization to the Eppo SDK and standard JDK types to prevent - // gadget-chain attacks. ObjectInputFilter is available from API 26 (minSdk 26). - // The allowlist covers the full transitive type graph of Configuration: cloud.eppo.** - // (all SDK packages), java.util collections and their dollar-named inner classes - // (listed before java.util.* due to first-match-wins semantics), and java.lang - // primitives/wrappers. All other classes are rejected. - ois.setObjectInputFilter( - // Dollar-named inner classes must be listed before java.util.* because - // ObjectInputFilter uses first-match-wins — java.util.* only covers top-level - // class names (no $ or sub-packages), so inner classes like - // Collections$UnmodifiableMap or HashMap$Node would otherwise hit the !* deny-all. - ObjectInputFilter.Config.createFilter( - "cloud.eppo.**" - + ";java.util.Arrays$*" // Arrays.asList() instances - + ";java.util.Collections$*" // unmodifiable/empty/singleton wrappers - + ";java.util.ImmutableCollections$*" // List.of / Set.of / Map.of (API 24+) - + ";java.util.HashMap$*" // HashMap internal nodes - + ";java.util.LinkedHashMap$*" // LinkedHashMap internal nodes - + ";java.util.*" - + ";java.lang.*" - + ";!*")); + // java.io.ObjectInputFilter (Java 9+) is not part of Android's SDK even at API 26, so + // a pattern-based allowlist cannot be applied at compile time. The deserialization risk + // is low here: the cache file lives in the app's private internal storage (inaccessible + // to other apps on a non-rooted device) and the bytes are produced by the SDK's own + // ObjectOutputStream write path — not transmitted directly from any server. The type + // check below prevents the wrong type from being returned to callers; note that it does + // not prevent gadget-chain execution, which occurs inside readObject() before the check. Object obj = ois.readObject(); if (!configClass.isInstance(obj)) { throw new RuntimeException( From d4f57b6b0c91075ed7083d1e07f067906518213d Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Wed, 22 Apr 2026 01:17:40 -0600 Subject: [PATCH 09/27] docs: add CLAUDE.md, fix .gitignore credential exclusions, update Makefile publish target - CLAUDE.md: documents module layering, Android API constraints (ObjectInputFilter and other Java 9+ APIs absent from android.jar), key abstractions, build/test commands, and Sonatype Central Portal publish workflow - .gitignore: uncomment *.jks/*.keystore, add *.pem, *.p12, .env, credentials* - Makefile: update credential check from OSSRH_* to mavenCentralUsername/ mavenCentralPassword to match vanniktech Central Portal setup; use ./gradlew :eppo:publish -Prelease (checkVersion already wired as dependency) --- .gitignore | 11 +++++--- CLAUDE.md | 76 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ Makefile | 4 +-- 3 files changed, 85 insertions(+), 6 deletions(-) create mode 100644 CLAUDE.md diff --git a/.gitignore b/.gitignore index 10a5297d..637d3d1a 100644 --- a/.gitignore +++ b/.gitignore @@ -55,10 +55,13 @@ captures/ # VSCode .vscode -# Keystore files -# Uncomment the following lines if you do not want to check your keystore files in. -#*.jks -#*.keystore +# Keystore files and credentials +*.jks +*.keystore +*.pem +*.p12 +.env +credentials* # External native build folder generated in Android Studio 2.2 and later .externalNativeBuild diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..35432583 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,76 @@ +# Eppo Android SDK + +## Module Structure + +Three-layer dependency chain: + +``` +eppo-sdk-framework (external, platform-neutral flag evaluation) + └── sdk-common-jvm (external, OkHttp + Jackson + JVM defaults) + └── :android-sdk-framework (Android abstractions) + └── :eppo (batteries-included public API) +``` + +| Module | Version | Artifact | Role | +|---|---|---|---| +| `:eppo` | 4.12.1 | `cloud.eppo:android-sdk` | Public `EppoClient` / `EppoPrecomputedClient`, Moshi parser, default implementations | +| `:android-sdk-framework` | 0.1.0 | `cloud.eppo:android-sdk-framework` | `AndroidBaseClient`, `ByteStore`, `ConfigurationCodec`, `CachingConfigurationStore` | +| `eppo-sdk-framework` | 0.1.0-SNAPSHOT | external | `BaseEppoClient`, `IConfigurationStore`, `EppoConfigurationClient`, `ConfigurationParser` | +| `sdk-common-jvm` | 3.13.1 (`:eppo` api) / 4.0.0-SNAPSHOT (`:android-sdk-framework` testImplementation) | external | `OkHttpEppoClient`, `JacksonConfigurationParser`, `Configuration`, data models | + +`:eppo` and `:android-sdk-framework` are versioned together. `:example` is a sample app only. + +## Android API Constraints + +**compileSdk 34, minSdk 26.** The build target is Android's `android.jar`, not a full JDK. + +APIs that are NOT available even though they appear in Java 9+ docs: + +- `java.io.ObjectInputFilter` — Java 9+, absent from `android.jar` at all API levels. Do not use it in any module that compiles against Android. +- `java.lang.ProcessHandle` — Java 9+, absent. +- `java.util.concurrent.Flow` — Java 9+, absent. + +When a dependency or pattern requires a Java 9+ API, it will compile locally (against a full JDK) but fail CI (which uses Android's SDK). Check `android.jar` contents before adding new Java 9+ usages. + +## Key Abstractions + +`ByteStore` — read/write interface for raw bytes; Android implementation uses `FileBackedByteStore` (app private internal storage). Inaccessible to other apps on non-rooted devices. + +`ConfigurationCodec` — serialization interface; default uses Java serialization. The `Default` implementation does not apply `ObjectInputFilter` (not available in Android SDK); the deserialization risk is low because bytes come from the SDK's own write path and live in private storage. + +`CachingConfigurationStore` — wraps `ByteStore` + `ConfigurationCodec` with an in-memory `AtomicReference`; optimistic write with compareAndSet revert on IO failure. + +## Build & Test + +```bash +make test-data # clone sdk-test-data and copy UFC fixtures into androidTest/assets +make test # run test-data then ./gradlew connectedCheck (requires connected device/emulator) +./gradlew :android-sdk-framework:test # unit tests (Robolectric) +./gradlew spotlessApply # auto-fix formatting +``` + +## Publishing + +Both modules use the `vanniktech` Maven publish plugin targeting **Sonatype Central Portal** (not the legacy OSSRH). + +Required in `~/.gradle/gradle.properties`: +``` +mavenCentralUsername= +mavenCentralPassword= +``` + +Publish flow enforces `-Prelease` or `-Psnapshot` flag: +```bash +./gradlew :eppo:publish -Prelease # release build +./gradlew :android-sdk-framework:publish -Psnapshot # snapshot build +``` + +`make publish-release` runs tests then checks for `mavenCentralUsername`/`mavenCentralPassword` before publishing `:eppo`. + +GPG signing uses `GPG_PRIVATE_KEY` and `GPG_PASSPHRASE` environment variables (CI) or `signing.*` properties (local). + +## Gradle Conventions + +- Spotless (Google Java Format) enforced on all `.java` files — run `spotlessApply` before committing. +- `checkVersion` task validates the `-Prelease`/`-Psnapshot` flag before any publish task runs. +- Snapshot repo: `https://central.sonatype.com/repository/maven-snapshots/` — scope with `content { includeModule(...) }` to avoid querying it for all artifacts. diff --git a/Makefile b/Makefile index efd7160e..436b861c 100644 --- a/Makefile +++ b/Makefile @@ -54,7 +54,7 @@ test: test-data check-maven-credentials-and-publish: # $(INFO)Checking required gradle configuration(END) - @for required_property in "OSSRH_USERNAME" "OSSRH_PASSWORD"; do \ + @for required_property in "mavenCentralUsername" "mavenCentralPassword"; do \ cat ~/.gradle/gradle.properties | grep -q $$required_property; \ if [ $$? != 0 ]; then \ echo "$(ERROR)ERROR: ~/.gradle/gradle.properties file is missing property: $$required_property$(END)"; \ @@ -63,7 +63,7 @@ check-maven-credentials-and-publish: done # $(INFO)Publishing release(END) - ./gradlew :eppo:publishReleasePublicationToMavenRepository + ./gradlew :eppo:publish -Prelease .PHONY: publish-release publish-release: test check-maven-credentials-and-publish From 2099f509ae978c632936748efa2700596eec680f Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Wed, 22 Apr 2026 01:46:16 -0600 Subject: [PATCH 10/27] fix: address code review feedback on android-sdk-framework MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename AndroidBaseClient → BaseAndroidClient to match BaseEppoClient naming pattern - Fix async cache load race: seed in-memory cache from disk after loadFromStorage() so getConfiguration() returns the cached value while the network fetch is in-flight; previously it returned emptyConfig() until saveConfiguration() was called by the HTTP fetch - Fix seedCache() to use a static EMPTY_SENTINEL so compareAndSet uses reference equality against the same instance stored in the AtomicReference at construction time - Add .exceptionally() handler to storage load chain so IO failures are logged rather than propagated as an opaque null cause in offline mode - Fix slf4j-android to runtimeOnly scope; add slf4j-api as implementation dep - Add checkNoSnapshotApiDeps Gradle task to prevent SNAPSHOT api-scoped dependencies from leaking to consumers; only enforced on -Prelease builds --- CLAUDE.md | 2 +- android-sdk-framework/build.gradle | 23 ++++++++- .../framework/EppoClientPollingTest.java | 20 ++++---- ...BaseClient.java => BaseAndroidClient.java} | 47 +++++++++++++------ .../storage/CachingConfigurationStore.java | 22 ++++++++- 5 files changed, 87 insertions(+), 27 deletions(-) rename android-sdk-framework/src/main/java/cloud/eppo/android/framework/{AndroidBaseClient.java => BaseAndroidClient.java} (89%) diff --git a/CLAUDE.md b/CLAUDE.md index 35432583..076f378d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,7 +14,7 @@ eppo-sdk-framework (external, platform-neutral flag evaluation) | Module | Version | Artifact | Role | |---|---|---|---| | `:eppo` | 4.12.1 | `cloud.eppo:android-sdk` | Public `EppoClient` / `EppoPrecomputedClient`, Moshi parser, default implementations | -| `:android-sdk-framework` | 0.1.0 | `cloud.eppo:android-sdk-framework` | `AndroidBaseClient`, `ByteStore`, `ConfigurationCodec`, `CachingConfigurationStore` | +| `:android-sdk-framework` | 0.1.0 | `cloud.eppo:android-sdk-framework` | `BaseAndroidClient`, `ByteStore`, `ConfigurationCodec`, `CachingConfigurationStore` | | `eppo-sdk-framework` | 0.1.0-SNAPSHOT | external | `BaseEppoClient`, `IConfigurationStore`, `EppoConfigurationClient`, `ConfigurationParser` | | `sdk-common-jvm` | 3.13.1 (`:eppo` api) / 4.0.0-SNAPSHOT (`:android-sdk-framework` testImplementation) | external | `OkHttpEppoClient`, `JacksonConfigurationParser`, `Configuration`, data models | diff --git a/android-sdk-framework/build.gradle b/android-sdk-framework/build.gradle index 23de6dba..f6990ae8 100644 --- a/android-sdk-framework/build.gradle +++ b/android-sdk-framework/build.gradle @@ -51,7 +51,8 @@ android { dependencies { api 'cloud.eppo:eppo-sdk-framework:0.1.0-SNAPSHOT' - implementation 'org.slf4j:slf4j-android:1.7.36' + implementation 'org.slf4j:slf4j-api:1.7.36' + runtimeOnly 'org.slf4j:slf4j-android:1.7.36' testImplementation 'com.google.code.gson:gson:2.10.1' compileOnly 'org.jetbrains:annotations:24.0.0' @@ -131,7 +132,27 @@ mavenPublishing { } } +task checkNoSnapshotApiDeps { + doLast { + // Only enforce on release builds. Snapshot builds are expected to depend on SNAPSHOT + // upstream artifacts; enforcing here would block legitimate snapshot publish flows. + if (!project.hasProperty('release')) return + def apiConfig = configurations.findByName('api') + if (apiConfig != null) { + apiConfig.dependencies.each { dep -> + if (dep.version?.endsWith('SNAPSHOT')) { + throw new GradleException( + "api dependency '${dep.group}:${dep.name}:${dep.version}' is a SNAPSHOT. " + + "SNAPSHOT api dependencies leak to consumers. Use implementation scope instead." + ) + } + } + } + } +} + task checkVersion { + dependsOn checkNoSnapshotApiDeps doLast { if (!project.hasProperty('release') && !project.hasProperty('snapshot')) { throw new GradleException("You must specify either -Prelease or -Psnapshot") 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 index d24ab81b..ac666d44 100644 --- 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 @@ -39,7 +39,7 @@ public class EppoClientPollingTest { @Mock private EppoConfigurationClient mockConfigClient; // Tracks the last built client so tearDown can stop its polling timer. - private AndroidBaseClient lastClient; + private BaseAndroidClient lastClient; @Before public void setUp() { @@ -61,14 +61,14 @@ public void tearDown() { * @param pollingIntervalMs polling interval in milliseconds (ignored when pollingEnabled=false) * @return initialized EppoClient */ - private AndroidBaseClient buildOfflineClient( + private BaseAndroidClient buildOfflineClient( boolean pollingEnabled, long pollingIntervalMs) throws ExecutionException, InterruptedException { CompletableFuture initialConfig = CompletableFuture.completedFuture(Configuration.emptyConfig()); - AndroidBaseClient.Builder builder = - new AndroidBaseClient.Builder<>( + BaseAndroidClient.Builder builder = + new BaseAndroidClient.Builder<>( DUMMY_API_KEY, ApplicationProvider.getApplicationContext(), mockConfigParser, @@ -97,7 +97,7 @@ public void testPauseAndResumePolling() throws ExecutionException, InterruptedEx CompletableFuture.completedFuture(Configuration.emptyConfig()); lastClient = - new AndroidBaseClient.Builder<>( + new BaseAndroidClient.Builder<>( DUMMY_API_KEY, ApplicationProvider.getApplicationContext(), mockConfigParser, @@ -136,7 +136,7 @@ public void testPauseAndResumePolling() throws ExecutionException, InterruptedEx @Test public void testResumePollingWithoutStarting() throws ExecutionException, InterruptedException { - AndroidBaseClient androidBaseClient = buildOfflineClient(false, 0); + BaseAndroidClient androidBaseClient = buildOfflineClient(false, 0); assertNotNull("Client should be initialized", androidBaseClient); // resumePolling() logs a warning when polling interval was not set and does not start polling. @@ -148,7 +148,7 @@ public void testResumePollingWithoutStarting() throws ExecutionException, Interr @Test public void testMultiplePauseResumeCycles() throws ExecutionException, InterruptedException { - AndroidBaseClient androidBaseClient = buildOfflineClient(true, 100); + BaseAndroidClient androidBaseClient = buildOfflineClient(true, 100); assertNotNull("Client should be initialized", androidBaseClient); // First cycle @@ -174,7 +174,7 @@ public void testMultiplePauseResumeCycles() throws ExecutionException, Interrupt @Test public void testPauseResumeSequenceDoesNotCrash() throws ExecutionException, InterruptedException { - AndroidBaseClient androidBaseClient = buildOfflineClient(true, 50); + BaseAndroidClient androidBaseClient = buildOfflineClient(true, 50); // Various sequences that should all work without crashing androidBaseClient.pausePolling(); @@ -196,7 +196,7 @@ public void testPauseResumeSequenceDoesNotCrash() @Test public void testPollingNotEnabledAndResume() throws ExecutionException, InterruptedException { - AndroidBaseClient androidBaseClient = buildOfflineClient(false, 0); + BaseAndroidClient androidBaseClient = buildOfflineClient(false, 0); // Pause should be safe even if not polling androidBaseClient.pausePolling(); @@ -214,7 +214,7 @@ public void testPollingNotEnabledAndResume() throws ExecutionException, Interrup @Test public void testPauseAfterInitDoesNotCrash() throws ExecutionException, InterruptedException { - AndroidBaseClient androidBaseClient = buildOfflineClient(true, 100); + BaseAndroidClient androidBaseClient = buildOfflineClient(true, 100); // Immediately pause after initialization androidBaseClient.pausePolling(); 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/BaseAndroidClient.java similarity index 89% rename from android-sdk-framework/src/main/java/cloud/eppo/android/framework/AndroidBaseClient.java rename to android-sdk-framework/src/main/java/cloud/eppo/android/framework/BaseAndroidClient.java index cb2c7448..cc6e6aca 100644 --- a/android-sdk-framework/src/main/java/cloud/eppo/android/framework/AndroidBaseClient.java +++ b/android-sdk-framework/src/main/java/cloud/eppo/android/framework/BaseAndroidClient.java @@ -32,8 +32,8 @@ * * @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); +public class BaseAndroidClient extends BaseEppoClient { + private static final String TAG = logTag(BaseAndroidClient.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; @@ -42,7 +42,7 @@ public class AndroidBaseClient extends BaseEppoClient instance; + @Nullable private static volatile BaseAndroidClient instance; /** * Private constructor. Use Builder to construct instances. @@ -60,7 +60,7 @@ public class AndroidBaseClient extends BaseEppoClient The JSON type parameter */ @SuppressWarnings("unchecked") - public static AndroidBaseClient getInstance() throws NotInitializedException { + public static BaseAndroidClient getInstance() throws NotInitializedException { if (instance == null) { throw new NotInitializedException(); } - return (AndroidBaseClient) instance; + return (BaseAndroidClient) instance; } /** @@ -245,12 +245,12 @@ public Builder onConfigurationChange( * * @return CompletableFuture that completes with the initialized EppoClient */ - public CompletableFuture> buildAndInitAsync() { + public CompletableFuture> buildAndInitAsync() { // Singleton handling if (instance != null && !forceReinitialize) { Log.w(TAG, "Eppo Client instance already initialized"); @SuppressWarnings("unchecked") - AndroidBaseClient typedInstance = (AndroidBaseClient) instance; + BaseAndroidClient typedInstance = (BaseAndroidClient) instance; return CompletableFuture.completedFuture(typedInstance); } else if (instance != null) { // Stop polling if reinitializing @@ -270,13 +270,32 @@ public CompletableFuture> buildAndInitAsync() { } // Use the persisted cache as the initial configuration if none was explicitly provided. + // Also seed the in-memory cache so getConfiguration() returns the cached value immediately + // rather than emptyConfig() while the network fetch is in-flight. if (initialConfiguration == null && !ignoreCachedConfiguration) { - initialConfiguration = configStore.loadFromStorage(); + final CachingConfigurationStore finalConfigStore = configStore; + initialConfiguration = + configStore + .loadFromStorage() + .thenApply( + config -> { + if (config != null) { + finalConfigStore.seedCache(config); + } + return config; + }) + .exceptionally( + ex -> { + // Storage failure is non-fatal: proceed without a cached configuration. + // Losing the cause here would make offline-mode failures opaque, so log it. + Log.w(TAG, "Failed to load config from storage; starting without cache", ex); + return null; + }); } // Construct the client - AndroidBaseClient newInstance = - new AndroidBaseClient<>( + BaseAndroidClient newInstance = + new BaseAndroidClient<>( apiKey, sdkName, sdkVersion, @@ -302,7 +321,7 @@ public CompletableFuture> buildAndInitAsync() { newInstance.onConfigurationChange(configChangeCallback); } - final CompletableFuture> ret = new CompletableFuture<>(); + final CompletableFuture> ret = new CompletableFuture<>(); AtomicInteger failCount = new AtomicInteger(0); // Captures the HTTP exception so that when the initial-config future completes the // combined failure path can include the original network error as the cause. @@ -391,7 +410,7 @@ public CompletableFuture> buildAndInitAsync() { * @return The initialized EppoClient * @throws RuntimeException if initialization fails and {@code isGracefulMode} is false */ - public AndroidBaseClient buildAndInit() { + public BaseAndroidClient buildAndInit() { try { return buildAndInitAsync().get(); } catch (InterruptedException e) { @@ -407,7 +426,7 @@ public AndroidBaseClient buildAndInit() { } } @SuppressWarnings("unchecked") - AndroidBaseClient typedInstance = (AndroidBaseClient) instance; + BaseAndroidClient typedInstance = (BaseAndroidClient) instance; return typedInstance; } } 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 index 47951c8c..2b83b4f3 100644 --- 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 @@ -12,10 +12,15 @@ */ public class CachingConfigurationStore implements IConfigurationStore { + // Sentinel used by seedCache() to detect that no real configuration has been set yet. + // Captured once so that seedCache()'s compareAndSet uses reference equality against the same + // instance stored in the AtomicReference at construction time. + private static final Configuration EMPTY_SENTINEL = Configuration.emptyConfig(); + private final ConfigurationCodec codec; private final ByteStore byteStore; private final AtomicReference configuration = - new AtomicReference<>(Configuration.emptyConfig()); + new AtomicReference<>(EMPTY_SENTINEL); protected CachingConfigurationStore( @NotNull ConfigurationCodec codec, @NotNull ByteStore byteStore) { @@ -72,6 +77,21 @@ protected CachingConfigurationStore( }); } + /** + * Seeds the in-memory cache with {@code config} without writing to disk. + * + *

Uses {@code compareAndSet} so that a concurrent {@link #saveConfiguration} call always wins. + * If the in-memory value has already been updated by a save, this is a no-op. + * + * @param config the configuration to seed (must not be null) + */ + void seedCache(@NotNull Configuration config) { + if (config == null) { + throw new IllegalArgumentException("config must not be null"); + } + configuration.compareAndSet(EMPTY_SENTINEL, config); + } + /** * Loads the configuration from storage without updating the in-memory cache. * From 5bda22efa7dc3d2cbeac6f601a4e501c4a5b8819 Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Wed, 29 Apr 2026 16:56:36 -0600 Subject: [PATCH 11/27] fix: make CachingConfigurationStore.seedCache() public BaseAndroidClient (cloud.eppo.android.framework) and CachingConfigurationStore (cloud.eppo.android.framework.storage) are in different packages. Package-private visibility blocked cross-package access at compile time. --- .../android/framework/storage/CachingConfigurationStore.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 2b83b4f3..e1488b3c 100644 --- 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 @@ -85,7 +85,7 @@ protected CachingConfigurationStore( * * @param config the configuration to seed (must not be null) */ - void seedCache(@NotNull Configuration config) { + public void seedCache(@NotNull Configuration config) { if (config == null) { throw new IllegalArgumentException("config must not be null"); } From 62fc39649536731dcad03e8bce70d77f0c6c7a1e Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Wed, 29 Apr 2026 20:01:08 -0600 Subject: [PATCH 12/27] refactor: encapsulate seedCache() behind loadAndSeedFromStorage() Add CachingConfigurationStore.loadAndSeedFromStorage() which combines loadFromStorage() + seedCache() in one call. Revert seedCache() from public back to package-private; BaseAndroidClient.Builder no longer needs to reach across package boundaries to seed the in-memory cache. --- .../android/framework/BaseAndroidClient.java | 10 +------- .../storage/CachingConfigurationStore.java | 24 ++++++++++++++++++- 2 files changed, 24 insertions(+), 10 deletions(-) diff --git a/android-sdk-framework/src/main/java/cloud/eppo/android/framework/BaseAndroidClient.java b/android-sdk-framework/src/main/java/cloud/eppo/android/framework/BaseAndroidClient.java index cc6e6aca..ce31ca4f 100644 --- a/android-sdk-framework/src/main/java/cloud/eppo/android/framework/BaseAndroidClient.java +++ b/android-sdk-framework/src/main/java/cloud/eppo/android/framework/BaseAndroidClient.java @@ -273,17 +273,9 @@ public CompletableFuture> buildAndInitAsync() { // Also seed the in-memory cache so getConfiguration() returns the cached value immediately // rather than emptyConfig() while the network fetch is in-flight. if (initialConfiguration == null && !ignoreCachedConfiguration) { - final CachingConfigurationStore finalConfigStore = configStore; initialConfiguration = configStore - .loadFromStorage() - .thenApply( - config -> { - if (config != null) { - finalConfigStore.seedCache(config); - } - return config; - }) + .loadAndSeedFromStorage() .exceptionally( ex -> { // Storage failure is non-fatal: proceed without a cached configuration. 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 index e1488b3c..181cad4e 100644 --- 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 @@ -85,7 +85,7 @@ protected CachingConfigurationStore( * * @param config the configuration to seed (must not be null) */ - public void seedCache(@NotNull Configuration config) { + void seedCache(@NotNull Configuration config) { if (config == null) { throw new IllegalArgumentException("config must not be null"); } @@ -109,4 +109,26 @@ public void seedCache(@NotNull Configuration config) { return codec.fromBytes(bytes); }); } + + /** + * Loads the configuration from storage and seeds the in-memory cache if a non-null configuration + * is found. + * + *

Combines {@link #loadFromStorage()} and {@link #seedCache(Configuration)} so callers do not + * need to reach into the storage package to call the package-private {@code seedCache} method + * directly. + * + * @return a future that completes with the loaded configuration, or null if storage is empty or + * missing + */ + @NotNull public CompletableFuture loadAndSeedFromStorage() { + return loadFromStorage() + .thenApply( + config -> { + if (config != null) { + seedCache(config); + } + return config; + }); + } } From edb6b14dc445a9f346aa46a994da17854a584bf5 Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Thu, 7 May 2026 22:03:46 -0600 Subject: [PATCH 13/27] fix: address code review feedback on android-sdk-framework - Add null/empty validation for Builder required params - Synchronize singleton check-through-assign in buildAndInitAsync() - Fix safeCacheKey comment to document \W keeping underscores - Remove unused gson testImplementation dependency - Add @After cleanup to FileBackedConfigStoreTest (fix wrong .bin extension) --- android-sdk-framework/build.gradle | 1 - .../android/framework/BaseAndroidClient.java | 136 ++++++++++-------- .../eppo/android/framework/util/Utils.java | 3 +- .../storage/FileBackedConfigStoreTest.java | 13 ++ 4 files changed, 94 insertions(+), 59 deletions(-) diff --git a/android-sdk-framework/build.gradle b/android-sdk-framework/build.gradle index f6990ae8..2b8a28ce 100644 --- a/android-sdk-framework/build.gradle +++ b/android-sdk-framework/build.gradle @@ -53,7 +53,6 @@ dependencies { implementation 'org.slf4j:slf4j-api:1.7.36' runtimeOnly 'org.slf4j:slf4j-android:1.7.36' - testImplementation 'com.google.code.gson:gson:2.10.1' compileOnly 'org.jetbrains:annotations:24.0.0' testImplementation 'cloud.eppo:sdk-common-jvm:4.0.0-SNAPSHOT' diff --git a/android-sdk-framework/src/main/java/cloud/eppo/android/framework/BaseAndroidClient.java b/android-sdk-framework/src/main/java/cloud/eppo/android/framework/BaseAndroidClient.java index ce31ca4f..8caa1ca5 100644 --- a/android-sdk-framework/src/main/java/cloud/eppo/android/framework/BaseAndroidClient.java +++ b/android-sdk-framework/src/main/java/cloud/eppo/android/framework/BaseAndroidClient.java @@ -150,6 +150,18 @@ public Builder( @NotNull Application application, @NotNull ConfigurationParser configurationParser, @NotNull EppoConfigurationClient configurationClient) { + if (apiKey == null || apiKey.isEmpty()) { + throw new IllegalArgumentException("apiKey must not be null or empty"); + } + if (application == null) { + throw new IllegalArgumentException("application must not be null"); + } + if (configurationParser == null) { + throw new IllegalArgumentException("configurationParser must not be null"); + } + if (configurationClient == null) { + throw new IllegalArgumentException("configurationClient must not be null"); + } this.apiKey = apiKey; this.application = application; this.configurationParser = configurationParser; @@ -246,67 +258,77 @@ public Builder onConfigurationChange( * @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") - BaseAndroidClient typedInstance = (BaseAndroidClient) instance; - return CompletableFuture.completedFuture(typedInstance); - } else if (instance != null) { - // Stop polling if reinitializing - instance.stopPolling(); - Log.i(TAG, "forceReinitialize triggered - reinitializing Eppo Client"); - } + // Synchronized from singleton check through instance assignment to prevent two concurrent + // first-time builds from each creating separate instances. In practice Android initializes + // on the main thread, but this guard makes the contract explicit. + // All operations inside this block are synchronous and fast — the async config fetch and + // polling setup happen after the lock is released. + final BaseAndroidClient newInstance; + synchronized (BaseAndroidClient.class) { + if (instance != null && !forceReinitialize) { + Log.w(TAG, "Eppo Client instance already initialized"); + @SuppressWarnings("unchecked") + BaseAndroidClient typedInstance = + (BaseAndroidClient) 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; + 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)); - } + 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. - // Also seed the in-memory cache so getConfiguration() returns the cached value immediately - // rather than emptyConfig() while the network fetch is in-flight. - if (initialConfiguration == null && !ignoreCachedConfiguration) { - initialConfiguration = - configStore - .loadAndSeedFromStorage() - .exceptionally( - ex -> { - // Storage failure is non-fatal: proceed without a cached configuration. - // Losing the cause here would make offline-mode failures opaque, so log it. - Log.w(TAG, "Failed to load config from storage; starting without cache", ex); - return null; - }); - } + // Use the persisted cache as the initial configuration if none was explicitly provided. + // Also seed the in-memory cache so getConfiguration() returns the cached value immediately + // rather than emptyConfig() while the network fetch is in-flight. + if (initialConfiguration == null && !ignoreCachedConfiguration) { + initialConfiguration = + configStore + .loadAndSeedFromStorage() + .exceptionally( + ex -> { + // Storage failure is non-fatal: proceed without a cached config. + // Losing the cause here would make offline-mode failures opaque, so log. + Log.w( + TAG, "Failed to load config from storage; starting without cache", ex); + return null; + }); + } - // Construct the client - BaseAndroidClient newInstance = - new BaseAndroidClient<>( - apiKey, - sdkName, - sdkVersion, - apiBaseUrl, - assignmentLogger, - configStore, - isGracefulMode, - obfuscateConfig, - initialConfiguration, - assignmentCache, - configurationParser, - configurationClient); - - // Set as singleton early so that getInstance() works immediately after buildAndInitAsync() - // returns (e.g. in graceful mode where callers may call getInstance() before the returned - // future completes). In graceful mode this is intentional: the client returns safe defaults - // until configuration is loaded. Callers that need a fully initialized client must await the - // CompletableFuture returned by buildAndInitAsync() before calling getInstance(). - instance = newInstance; + // Construct the client + newInstance = + new BaseAndroidClient<>( + apiKey, + sdkName, + sdkVersion, + apiBaseUrl, + assignmentLogger, + configStore, + isGracefulMode, + obfuscateConfig, + initialConfiguration, + assignmentCache, + configurationParser, + configurationClient); + + // Set as singleton early so that getInstance() works immediately after + // buildAndInitAsync() returns (e.g. in graceful mode where callers may call + // getInstance() before the returned future completes). In graceful mode this is + // intentional: the client returns safe defaults until configuration is loaded. Callers + // that need a fully initialized client must await the CompletableFuture returned by + // buildAndInitAsync() before calling getInstance(). + instance = newInstance; + } // Register config change callback if provided if (configChangeCallback != 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 index 054e626e..923a8431 100644 --- 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 @@ -22,7 +22,8 @@ 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 paths. - // Note: if the first 8 characters are all non-alphanumeric the result is an empty string, + // \W is equivalent to [^a-zA-Z0-9_] — underscores are kept, which is fine for filenames. + // Note: if the first 8 characters are all non-word the result is an empty string, // which produces the filename "eppo-sdk-flags-.bin". Eppo-issued API keys always start // with alphanumeric characters, so this edge case is not expected in production. return key.substring(0, Math.min(8, key.length())).replaceAll("\\W", ""); diff --git a/android-sdk-framework/src/test/java/cloud/eppo/android/framework/storage/FileBackedConfigStoreTest.java b/android-sdk-framework/src/test/java/cloud/eppo/android/framework/storage/FileBackedConfigStoreTest.java index 0218d540..0e1885b7 100644 --- 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 @@ -6,7 +6,9 @@ import android.app.Application; import cloud.eppo.api.Configuration; +import java.io.File; import java.util.concurrent.TimeUnit; +import org.junit.After; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; @@ -28,6 +30,17 @@ public void setUp() { cacheFileSuffix = "test-" + System.currentTimeMillis(); } + @After + public void tearDown() { + String prefix = "eppo-sdk-flags-" + cacheFileSuffix + "."; + File[] toDelete = application.getFilesDir().listFiles(f -> f.getName().startsWith(prefix)); + if (toDelete != null) { + for (File f : toDelete) { + f.delete(); + } + } + } + @Test public void construct_withValidArgs_succeeds() { FileBackedConfigStore store = new FileBackedConfigStore(application, cacheFileSuffix, codec); From 4e501d9270f48770542de496baed70c4a69e6a34 Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Thu, 7 May 2026 22:06:18 -0600 Subject: [PATCH 14/27] refactor: remove singleton from BaseAndroidClient Singleton management (instance field, getInstance(), synchronized init guard) removed from BaseAndroidClient. Concrete subclasses (EppoClient in :eppo) declare their own singleton. Framework-only consumers hold the reference from buildAndInitAsync() directly. This prevents the architectural conflict where both BaseAndroidClient and EppoClient would have independent static instance fields, causing confusion about which getInstance() to call. --- .../android/framework/BaseAndroidClient.java | 148 +++++++----------- 1 file changed, 53 insertions(+), 95 deletions(-) diff --git a/android-sdk-framework/src/main/java/cloud/eppo/android/framework/BaseAndroidClient.java b/android-sdk-framework/src/main/java/cloud/eppo/android/framework/BaseAndroidClient.java index 8caa1ca5..8cce9633 100644 --- a/android-sdk-framework/src/main/java/cloud/eppo/android/framework/BaseAndroidClient.java +++ b/android-sdk-framework/src/main/java/cloud/eppo/android/framework/BaseAndroidClient.java @@ -7,7 +7,6 @@ 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; @@ -42,10 +41,13 @@ public class BaseAndroidClient extends BaseEppoClient instance; - /** - * Private constructor. Use Builder to construct instances. + * Protected constructor. Use Builder to construct instances. + * + *

Singleton management is intentionally absent from this class. Concrete subclasses (e.g. + * EppoClient in :eppo) declare their own {@code static volatile} instance field and {@code + * getInstance()} method. Framework-only consumers hold the reference returned by {@link + * Builder#buildAndInitAsync()} directly — typically in the Application class. * * @param apiKey API key for Eppo * @param sdkName SDK name identifier @@ -91,21 +93,6 @@ protected BaseAndroidClient( 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 BaseAndroidClient getInstance() throws NotInitializedException { - if (instance == null) { - throw new NotInitializedException(); - } - return (BaseAndroidClient) instance; - } - /** * Builder for constructing and initializing EppoClient instances. * @@ -258,78 +245,49 @@ public Builder onConfigurationChange( * @return CompletableFuture that completes with the initialized EppoClient */ public CompletableFuture> buildAndInitAsync() { - // Synchronized from singleton check through instance assignment to prevent two concurrent - // first-time builds from each creating separate instances. In practice Android initializes - // on the main thread, but this guard makes the contract explicit. - // All operations inside this block are synchronous and fast — the async config fetch and - // polling setup happen after the lock is released. - final BaseAndroidClient newInstance; - synchronized (BaseAndroidClient.class) { - if (instance != null && !forceReinitialize) { - Log.w(TAG, "Eppo Client instance already initialized"); - @SuppressWarnings("unchecked") - BaseAndroidClient typedInstance = - (BaseAndroidClient) 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. - // Also seed the in-memory cache so getConfiguration() returns the cached value immediately - // rather than emptyConfig() while the network fetch is in-flight. - if (initialConfiguration == null && !ignoreCachedConfiguration) { - initialConfiguration = - configStore - .loadAndSeedFromStorage() - .exceptionally( - ex -> { - // Storage failure is non-fatal: proceed without a cached config. - // Losing the cause here would make offline-mode failures opaque, so log. - Log.w( - TAG, "Failed to load config from storage; starting without cache", ex); - return null; - }); - } + 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)); + } - // Construct the client - newInstance = - new BaseAndroidClient<>( - apiKey, - sdkName, - sdkVersion, - apiBaseUrl, - assignmentLogger, - configStore, - isGracefulMode, - obfuscateConfig, - initialConfiguration, - assignmentCache, - configurationParser, - configurationClient); - - // Set as singleton early so that getInstance() works immediately after - // buildAndInitAsync() returns (e.g. in graceful mode where callers may call - // getInstance() before the returned future completes). In graceful mode this is - // intentional: the client returns safe defaults until configuration is loaded. Callers - // that need a fully initialized client must await the CompletableFuture returned by - // buildAndInitAsync() before calling getInstance(). - instance = newInstance; + // Use the persisted cache as the initial configuration if none was explicitly provided. + // Also seed the in-memory cache so getConfiguration() returns the cached value immediately + // rather than emptyConfig() while the network fetch is in-flight. + if (initialConfiguration == null && !ignoreCachedConfiguration) { + initialConfiguration = + configStore + .loadAndSeedFromStorage() + .exceptionally( + ex -> { + // Storage failure is non-fatal: proceed without a cached config. + // Losing the cause here would make offline-mode failures opaque, so log. + Log.w(TAG, "Failed to load config from storage; starting without cache", ex); + return null; + }); } + // Construct the client + final BaseAndroidClient newInstance = + new BaseAndroidClient<>( + apiKey, + sdkName, + sdkVersion, + apiBaseUrl, + assignmentLogger, + configStore, + isGracefulMode, + obfuscateConfig, + initialConfiguration, + assignmentCache, + configurationParser, + configurationClient); + // Register config change callback if provided if (configChangeCallback != null) { newInstance.onConfigurationChange(configChangeCallback); @@ -428,20 +386,20 @@ public BaseAndroidClient buildAndInit() { try { return buildAndInitAsync().get(); } catch (InterruptedException e) { + // Thread interruption is not a graceful-degradation scenario — the caller's thread + // was cancelled while waiting for init. Always re-throw so the caller can handle it. Thread.currentThread().interrupt(); - Log.e(TAG, "Exception caught during initialization: " + e.getMessage(), e); - if (!isGracefulMode) { - throw new RuntimeException(e); - } + throw new RuntimeException("Eppo client initialization interrupted", e); } catch (ExecutionException e) { + // In graceful mode, buildAndInitAsync()'s .exceptionally() handler converts failures + // into the client instance, so .get() should not throw ExecutionException. This catch + // is a defensive fallback for non-graceful mode or unexpected .exceptionally() failures. Log.e(TAG, "Exception caught during initialization: " + e.getMessage(), e); if (!isGracefulMode) { throw new RuntimeException(e); } + return null; } - @SuppressWarnings("unchecked") - BaseAndroidClient typedInstance = (BaseAndroidClient) instance; - return typedInstance; } } From 8d16eeb2fb7628670b58b68c856894b3f0170ce5 Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Fri, 8 May 2026 08:08:07 -0600 Subject: [PATCH 15/27] fix: deadlock in loadAndSeedFromStorage() with single-thread IO executor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit loadAndSeedFromStorage() ran its continuation on IO_EXECUTOR (inherited from byteStore.read()). ConfigurationRequestor.setInitialConfiguration() chained .thenApply on the same thread, which called saveConfiguration() → byteStore.write() → supplyAsync(IO_EXECUTOR). Since IO_EXECUTOR is a single-thread executor, the write was queued behind the .thenApply that was waiting for it — classic deadlock. Fix: use thenApplyAsync (common pool) in loadAndSeedFromStorage() to break out of IO_EXECUTOR before downstream continuations run. --- .../framework/storage/CachingConfigurationStore.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 index 181cad4e..9be213f4 100644 --- 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 @@ -122,8 +122,12 @@ void seedCache(@NotNull Configuration config) { * missing */ @NotNull public CompletableFuture loadAndSeedFromStorage() { + // thenApplyAsync (common pool) breaks out of the single-thread IO_EXECUTOR so that + // downstream continuations (e.g. ConfigurationRequestor.setInitialConfiguration, which + // calls saveConfiguration → byteStore.write on IO_EXECUTOR) do not deadlock by running + // on the same thread that is blocked waiting for them. return loadFromStorage() - .thenApply( + .thenApplyAsync( config -> { if (config != null) { seedCache(config); From 99dfdcb12641f5e755b642e1b6a2d11faaabe47a Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Fri, 8 May 2026 09:40:14 -0600 Subject: [PATCH 16/27] fix: add null checks to BaseCacheFile constructor Validate application and fileName params match the guard-clause pattern used in other storage classes (CachingConfigurationStore, FileBackedByteStore). --- .../cloud/eppo/android/framework/storage/BaseCacheFile.java | 6 ++++++ 1 file changed, 6 insertions(+) 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 index 9c302b97..fe74f84f 100644 --- 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 @@ -18,6 +18,12 @@ public class BaseCacheFile { private final File cacheFile; protected BaseCacheFile(Application application, String fileName) { + if (application == null) { + throw new IllegalArgumentException("application must not be null"); + } + if (fileName == null) { + throw new IllegalArgumentException("fileName must not be null"); + } File filesDir = application.getFilesDir(); cacheFile = new File(filesDir, fileName); } From 6bc68138fdcc6b37282ce55bd9b01d3d2c3cf901 Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Fri, 29 May 2026 00:27:36 -0600 Subject: [PATCH 17/27] fix: remove spurious failCount double-increment in buildAndInitAsync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the initial config future resolves (cache miss or bad cache data) before the HTTP fetch completes, the else branch was calling failCount.incrementAndGet() a second time. Combined with the increment already performed in the else-if condition check, failCount reached 2 before HTTP failure could trigger it. The subsequent HTTP failure then incremented to 3, skipping the == 2 check and leaving ret never completed — a hang. Fix: drop the redundant increment from the else block. The HTTP failure handler is solely responsible for completing ret exceptionally when both paths fail. Reproduces as a timeout in testCachedBadResponseRequiresFetch on API 34 where file I/O resolves faster than the HTTP round-trip. --- eppo/src/main/java/cloud/eppo/android/EppoClient.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/eppo/src/main/java/cloud/eppo/android/EppoClient.java b/eppo/src/main/java/cloud/eppo/android/EppoClient.java index 2090c2d6..827be73d 100644 --- a/eppo/src/main/java/cloud/eppo/android/EppoClient.java +++ b/eppo/src/main/java/cloud/eppo/android/EppoClient.java @@ -359,8 +359,11 @@ public CompletableFuture buildAndInitAsync() { new EppoInitializationException( "Unable to initialize client; Configuration could not be loaded", ex)); } else { + // Initial config was not used (cache miss or parse failure); HTTP fetch is + // still in flight. Do not increment failCount here — the HTTP handler + // already increments it on failure and will complete ret when both paths + // have resolved. Log.d(TAG, "Initial config was not used."); - failCount.incrementAndGet(); } return null; }); From 639dad6af6eaf81a8cb5aba8fa74f3212f48833a Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Fri, 29 May 2026 00:31:41 -0600 Subject: [PATCH 18/27] fix: null-safe Boolean comparison and clarify failCount coordination - Change `ex == null && success` to `ex == null && Boolean.TRUE.equals(success)` to guard against NPE if the future completes normally with a null Boolean value; matches the pattern already used in BaseAndroidClient - Improve comment on the else branch to clarify that failCount is already incremented to 1 by the else-if condition evaluation (side effect of `failCount.incrementAndGet() == 2` being false), so no additional increment is needed in the else block --- eppo/src/main/java/cloud/eppo/android/EppoClient.java | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/eppo/src/main/java/cloud/eppo/android/EppoClient.java b/eppo/src/main/java/cloud/eppo/android/EppoClient.java index 827be73d..2b3506d2 100644 --- a/eppo/src/main/java/cloud/eppo/android/EppoClient.java +++ b/eppo/src/main/java/cloud/eppo/android/EppoClient.java @@ -352,17 +352,18 @@ public CompletableFuture buildAndInitAsync() { .getInitialConfigFuture() .handle( (success, ex) -> { - if (ex == null && success) { + if (ex == null && Boolean.TRUE.equals(success)) { ret.complete(instance); } else if (offlineMode || failCount.incrementAndGet() == 2) { ret.completeExceptionally( new EppoInitializationException( "Unable to initialize client; Configuration could not be loaded", ex)); } else { - // Initial config was not used (cache miss or parse failure); HTTP fetch is - // still in flight. Do not increment failCount here — the HTTP handler - // already increments it on failure and will complete ret when both paths - // have resolved. + // Initial config was not used (cache miss or parse failure). failCount was + // already incremented to 1 by the else-if condition check above (side + // effect of `failCount.incrementAndGet() == 2` evaluating false). When the + // HTTP fetch also fails its handler will increment failCount to 2 and + // complete ret exceptionally. No additional increment is needed here. Log.d(TAG, "Initial config was not used."); } return null; From 3ea6600d28e0474ff4854ecc8d93ad6d39c17b6b Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Fri, 29 May 2026 00:46:41 -0600 Subject: [PATCH 19/27] fix: address threading and resource management feedback on android-sdk-framework (#255) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - volatile on pollingIntervalMs/pollingJitterMs in BaseAndroidClient (builder thread writes, polling thread reads — no synchronization guard existed) - volatile on EppoClient.instance singleton field - FileBackedByteStore: IO_EXECUTOR demoted from static final to instance-level ExecutorService; implements Closeable with shutdown() so tests can reclaim the IO thread - CachingConfigurationStore: remove optimistic update pattern in saveConfiguration(); disk write now happens first and in-memory cache is only updated on success, eliminating the window where getConfiguration() could return an unpersisted value --- .../android/framework/BaseAndroidClient.java | 4 +-- .../storage/CachingConfigurationStore.java | 24 ++++++---------- .../storage/FileBackedByteStore.java | 28 ++++++++++++++----- .../java/cloud/eppo/android/EppoClient.java | 2 +- 4 files changed, 33 insertions(+), 25 deletions(-) diff --git a/android-sdk-framework/src/main/java/cloud/eppo/android/framework/BaseAndroidClient.java b/android-sdk-framework/src/main/java/cloud/eppo/android/framework/BaseAndroidClient.java index 8cce9633..9cdf2da7 100644 --- a/android-sdk-framework/src/main/java/cloud/eppo/android/framework/BaseAndroidClient.java +++ b/android-sdk-framework/src/main/java/cloud/eppo/android/framework/BaseAndroidClient.java @@ -38,8 +38,8 @@ public class BaseAndroidClient extends BaseEppoClientThe disk write is performed first. The in-memory cache is updated only after the write + * succeeds, ensuring that the in-memory state never reflects a configuration that failed to + * persist. Concurrent saves are safe: the last write to complete successfully wins in memory. + * * @param config the configuration to save (must not be null) * @return a future that completes when the write finishes, or completes exceptionally if the * underlying {@link ByteStore} write fails (e.g. with an {@link java.io.IOException}) @@ -53,26 +57,16 @@ protected CachingConfigurationStore( if (config == null) { throw new IllegalArgumentException("config must not be null"); } - Configuration previousConfiguration = configuration.get(); byte[] bytes = codec.toBytes(config); - configuration.set(config); // optimistic update — in-memory reflects last submitted save return byteStore .write(bytes) .whenComplete( (v, ex) -> { - if (ex != null) { - // Revert the optimistic update, but only if no later save has superseded this - // one. compareAndSet atomically checks that the in-memory value is still the - // one we wrote; if a concurrent save has already advanced it, the revert is - // skipped. - // - // Edge case: if two concurrent saves both fail their IO writes, the second - // failure's revert may land on a value that was itself never persisted (the - // first save's value). This is acceptable — the in-memory state may diverge - // from disk, but the next successful save will reconcile them. Saves are - // serialized through a single-thread IO_EXECUTOR, so true concurrent IO - // failures are unlikely in practice. - configuration.compareAndSet(config, previousConfiguration); + if (ex == null) { + // Only update in-memory cache after a successful disk write. + // Use set() rather than compareAndSet() so the most-recently-persisted + // configuration always wins, regardless of submission order. + configuration.set(config); } }); } 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 index 9cd27227..2b97636f 100644 --- 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 @@ -1,19 +1,24 @@ package cloud.eppo.android.framework.storage; import java.util.concurrent.CompletableFuture; -import java.util.concurrent.Executor; +import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import org.jetbrains.annotations.NotNull; /** * {@link ByteStore} implementation that reads and writes a single file via {@link BaseCacheFile}. + * + *

Implements {@link java.io.Closeable}. Call {@link #close()} when the store is no longer needed + * to release the background IO thread. If not closed explicitly, the daemon thread will be + * reclaimed by the JVM/Android runtime on process exit. */ -public final class FileBackedByteStore implements ByteStore { +public final class FileBackedByteStore implements ByteStore, java.io.Closeable { // Dedicated single-thread executor avoids saturating ForkJoinPool.commonPool() with blocking I/O - // on low-core-count Android devices. Daemon thread so the executor does not block JVM/process - // exit in test environments (e.g. Robolectric). - private static final Executor IO_EXECUTOR = + // on low-core-count Android devices. Instance-level (not static) so it can be shut down via + // close(). The thread is a daemon so it does not block JVM/process exit when close() is omitted + // (e.g. in test environments such as Robolectric). + private final ExecutorService ioExecutor = Executors.newSingleThreadExecutor( r -> { Thread t = new Thread(r, "eppo-io"); @@ -30,6 +35,15 @@ public FileBackedByteStore(@NotNull BaseCacheFile cacheFile) { this.cacheFile = cacheFile; } + /** + * Shuts down the background IO executor. In-flight operations are allowed to complete; no new + * operations will be accepted after this call. + */ + @Override + public void close() { + ioExecutor.shutdown(); + } + @Override @NotNull public CompletableFuture read() { return CompletableFuture.supplyAsync( @@ -43,7 +57,7 @@ public FileBackedByteStore(@NotNull BaseCacheFile cacheFile) { throw new RuntimeException("Failed to read from cache file", e); } }, - IO_EXECUTOR); + ioExecutor); } @Override @@ -59,7 +73,7 @@ public FileBackedByteStore(@NotNull BaseCacheFile cacheFile) { throw new RuntimeException("Failed to write to cache file", e); } }, - IO_EXECUTOR); + ioExecutor); } private static byte[] readAllBytes(java.io.InputStream in) throws java.io.IOException { diff --git a/eppo/src/main/java/cloud/eppo/android/EppoClient.java b/eppo/src/main/java/cloud/eppo/android/EppoClient.java index 2b3506d2..8dd02355 100644 --- a/eppo/src/main/java/cloud/eppo/android/EppoClient.java +++ b/eppo/src/main/java/cloud/eppo/android/EppoClient.java @@ -34,7 +34,7 @@ public class EppoClient extends BaseEppoClient { private long pollingIntervalMs, pollingJitterMs; - @Nullable private static EppoClient instance; + @Nullable private static volatile EppoClient instance; private EppoClient( String apiKey, From 05d2ff5a64301afc39b77d575a2193d06c436b7d Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Fri, 29 May 2026 09:44:37 -0600 Subject: [PATCH 20/27] fix: return uninitialized client instead of null in graceful-mode fallback buildAndInit()'s defensive ExecutionException catch was returning null in graceful mode. In practice this path is unreachable (buildAndInitAsync()'s .exceptionally() handler already returns the instance), but as a safety net track the constructed instance on the Builder and return it here instead. --- .../eppo/android/framework/BaseAndroidClient.java | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/android-sdk-framework/src/main/java/cloud/eppo/android/framework/BaseAndroidClient.java b/android-sdk-framework/src/main/java/cloud/eppo/android/framework/BaseAndroidClient.java index 9cdf2da7..c7635656 100644 --- a/android-sdk-framework/src/main/java/cloud/eppo/android/framework/BaseAndroidClient.java +++ b/android-sdk-framework/src/main/java/cloud/eppo/android/framework/BaseAndroidClient.java @@ -123,6 +123,12 @@ public static class Builder { private long pollingJitterMs = -1; @Nullable private IAssignmentCache assignmentCache; @Nullable private Consumer configChangeCallback; + // Set during buildAndInitAsync() once the instance is constructed, before any async work begins. + // Used by buildAndInit() as a last-resort fallback so it never returns null in graceful mode. + // Safety: if the BaseAndroidClient constructor itself throws, buildAndInitAsync() propagates a + // RuntimeException synchronously (before returning a Future), so buildAndInit()'s + // ExecutionException catch is never reached and builtInstance being null is not a concern. + @Nullable private BaseAndroidClient builtInstance; /** * Creates a new Builder with required parameters. @@ -287,6 +293,8 @@ public CompletableFuture> buildAndInitAsync() { assignmentCache, configurationParser, configurationClient); + // Track the instance so buildAndInit()'s defensive catch can return it instead of null. + this.builtInstance = newInstance; // Register config change callback if provided if (configChangeCallback != null) { @@ -393,12 +401,13 @@ public BaseAndroidClient buildAndInit() { } catch (ExecutionException e) { // In graceful mode, buildAndInitAsync()'s .exceptionally() handler converts failures // into the client instance, so .get() should not throw ExecutionException. This catch - // is a defensive fallback for non-graceful mode or unexpected .exceptionally() failures. + // is a defensive fallback for unexpected .exceptionally() failures. Return the partially- + // constructed instance (which returns defaults for all flag evaluations) rather than null. Log.e(TAG, "Exception caught during initialization: " + e.getMessage(), e); if (!isGracefulMode) { throw new RuntimeException(e); } - return null; + return builtInstance; } } } From 09d03e0f021bba9c84207393db371266ce726440 Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Fri, 29 May 2026 11:38:15 -0600 Subject: [PATCH 21/27] style: fix spotless formatting in BaseAndroidClient --- .../java/cloud/eppo/android/framework/BaseAndroidClient.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/android-sdk-framework/src/main/java/cloud/eppo/android/framework/BaseAndroidClient.java b/android-sdk-framework/src/main/java/cloud/eppo/android/framework/BaseAndroidClient.java index c7635656..f9a57f47 100644 --- a/android-sdk-framework/src/main/java/cloud/eppo/android/framework/BaseAndroidClient.java +++ b/android-sdk-framework/src/main/java/cloud/eppo/android/framework/BaseAndroidClient.java @@ -123,7 +123,8 @@ public static class Builder { private long pollingJitterMs = -1; @Nullable private IAssignmentCache assignmentCache; @Nullable private Consumer configChangeCallback; - // Set during buildAndInitAsync() once the instance is constructed, before any async work begins. + // Set during buildAndInitAsync() once the instance is constructed, before any async work + // begins. // Used by buildAndInit() as a last-resort fallback so it never returns null in graceful mode. // Safety: if the BaseAndroidClient constructor itself throws, buildAndInitAsync() propagates a // RuntimeException synchronously (before returning a Future), so buildAndInit()'s From bfdc0d92a50ca7665cba6dbd14f6db828b7f8ee8 Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Fri, 29 May 2026 12:11:25 -0600 Subject: [PATCH 22/27] chore: use 0.1.0-SNAPSHOT version until upstream deps release eppo-sdk-framework and sdk-common-jvm are still SNAPSHOT-only on Maven Central (sdk-common-jdk#238 just merged but release not yet published). Set android-sdk-framework version to 0.1.0-SNAPSHOT so -Psnapshot publish works. Update CLAUDE.md version table to match. --- CLAUDE.md | 4 ++-- android-sdk-framework/build.gradle | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 076f378d..34a4b163 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,9 +14,9 @@ eppo-sdk-framework (external, platform-neutral flag evaluation) | Module | Version | Artifact | Role | |---|---|---|---| | `:eppo` | 4.12.1 | `cloud.eppo:android-sdk` | Public `EppoClient` / `EppoPrecomputedClient`, Moshi parser, default implementations | -| `:android-sdk-framework` | 0.1.0 | `cloud.eppo:android-sdk-framework` | `BaseAndroidClient`, `ByteStore`, `ConfigurationCodec`, `CachingConfigurationStore` | +| `:android-sdk-framework` | 0.1.0-SNAPSHOT | `cloud.eppo:android-sdk-framework` | `BaseAndroidClient`, `ByteStore`, `ConfigurationCodec`, `CachingConfigurationStore` | | `eppo-sdk-framework` | 0.1.0-SNAPSHOT | external | `BaseEppoClient`, `IConfigurationStore`, `EppoConfigurationClient`, `ConfigurationParser` | -| `sdk-common-jvm` | 3.13.1 (`:eppo` api) / 4.0.0-SNAPSHOT (`:android-sdk-framework` testImplementation) | external | `OkHttpEppoClient`, `JacksonConfigurationParser`, `Configuration`, data models | +| `sdk-common-jvm` | 4.0.0-SNAPSHOT | external | `OkHttpEppoClient`, `JacksonConfigurationParser`, `Configuration`, data models | `:eppo` and `:android-sdk-framework` are versioned together. `:example` is a sample app only. diff --git a/android-sdk-framework/build.gradle b/android-sdk-framework/build.gradle index 2b8a28ce..eb13e560 100644 --- a/android-sdk-framework/build.gradle +++ b/android-sdk-framework/build.gradle @@ -7,7 +7,7 @@ plugins { } group = "cloud.eppo" -version = "0.1.0" +version = "0.1.0-SNAPSHOT" android { namespace "cloud.eppo.android.framework" From cbde4f75a4ae9ad0379c8bda53c3606b9ae41a3d Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Fri, 29 May 2026 13:13:25 -0600 Subject: [PATCH 23/27] refactor: rename BaseAndroidClient to BaseAndroidEppoClient - Rename class and file to BaseAndroidEppoClient - Update EppoClientPollingTest references - Update CLAUDE.md module table --- CLAUDE.md | 2 +- .../framework/EppoClientPollingTest.java | 20 ++++----- ...Client.java => BaseAndroidEppoClient.java} | 42 +++++++++++++------ 3 files changed, 41 insertions(+), 23 deletions(-) rename android-sdk-framework/src/main/java/cloud/eppo/android/framework/{BaseAndroidClient.java => BaseAndroidEppoClient.java} (90%) diff --git a/CLAUDE.md b/CLAUDE.md index 34a4b163..0cdc342b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,7 +14,7 @@ eppo-sdk-framework (external, platform-neutral flag evaluation) | Module | Version | Artifact | Role | |---|---|---|---| | `:eppo` | 4.12.1 | `cloud.eppo:android-sdk` | Public `EppoClient` / `EppoPrecomputedClient`, Moshi parser, default implementations | -| `:android-sdk-framework` | 0.1.0-SNAPSHOT | `cloud.eppo:android-sdk-framework` | `BaseAndroidClient`, `ByteStore`, `ConfigurationCodec`, `CachingConfigurationStore` | +| `:android-sdk-framework` | 0.1.0-SNAPSHOT | `cloud.eppo:android-sdk-framework` | `BaseAndroidEppoClient`, `ByteStore`, `ConfigurationCodec`, `CachingConfigurationStore` | | `eppo-sdk-framework` | 0.1.0-SNAPSHOT | external | `BaseEppoClient`, `IConfigurationStore`, `EppoConfigurationClient`, `ConfigurationParser` | | `sdk-common-jvm` | 4.0.0-SNAPSHOT | external | `OkHttpEppoClient`, `JacksonConfigurationParser`, `Configuration`, data models | 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 index ac666d44..c397c40e 100644 --- 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 @@ -39,7 +39,7 @@ public class EppoClientPollingTest { @Mock private EppoConfigurationClient mockConfigClient; // Tracks the last built client so tearDown can stop its polling timer. - private BaseAndroidClient lastClient; + private BaseAndroidEppoClient lastClient; @Before public void setUp() { @@ -61,14 +61,14 @@ public void tearDown() { * @param pollingIntervalMs polling interval in milliseconds (ignored when pollingEnabled=false) * @return initialized EppoClient */ - private BaseAndroidClient buildOfflineClient( + private BaseAndroidEppoClient buildOfflineClient( boolean pollingEnabled, long pollingIntervalMs) throws ExecutionException, InterruptedException { CompletableFuture initialConfig = CompletableFuture.completedFuture(Configuration.emptyConfig()); - BaseAndroidClient.Builder builder = - new BaseAndroidClient.Builder<>( + BaseAndroidEppoClient.Builder builder = + new BaseAndroidEppoClient.Builder<>( DUMMY_API_KEY, ApplicationProvider.getApplicationContext(), mockConfigParser, @@ -97,7 +97,7 @@ public void testPauseAndResumePolling() throws ExecutionException, InterruptedEx CompletableFuture.completedFuture(Configuration.emptyConfig()); lastClient = - new BaseAndroidClient.Builder<>( + new BaseAndroidEppoClient.Builder<>( DUMMY_API_KEY, ApplicationProvider.getApplicationContext(), mockConfigParser, @@ -136,7 +136,7 @@ public void testPauseAndResumePolling() throws ExecutionException, InterruptedEx @Test public void testResumePollingWithoutStarting() throws ExecutionException, InterruptedException { - BaseAndroidClient androidBaseClient = buildOfflineClient(false, 0); + BaseAndroidEppoClient androidBaseClient = buildOfflineClient(false, 0); assertNotNull("Client should be initialized", androidBaseClient); // resumePolling() logs a warning when polling interval was not set and does not start polling. @@ -148,7 +148,7 @@ public void testResumePollingWithoutStarting() throws ExecutionException, Interr @Test public void testMultiplePauseResumeCycles() throws ExecutionException, InterruptedException { - BaseAndroidClient androidBaseClient = buildOfflineClient(true, 100); + BaseAndroidEppoClient androidBaseClient = buildOfflineClient(true, 100); assertNotNull("Client should be initialized", androidBaseClient); // First cycle @@ -174,7 +174,7 @@ public void testMultiplePauseResumeCycles() throws ExecutionException, Interrupt @Test public void testPauseResumeSequenceDoesNotCrash() throws ExecutionException, InterruptedException { - BaseAndroidClient androidBaseClient = buildOfflineClient(true, 50); + BaseAndroidEppoClient androidBaseClient = buildOfflineClient(true, 50); // Various sequences that should all work without crashing androidBaseClient.pausePolling(); @@ -196,7 +196,7 @@ public void testPauseResumeSequenceDoesNotCrash() @Test public void testPollingNotEnabledAndResume() throws ExecutionException, InterruptedException { - BaseAndroidClient androidBaseClient = buildOfflineClient(false, 0); + BaseAndroidEppoClient androidBaseClient = buildOfflineClient(false, 0); // Pause should be safe even if not polling androidBaseClient.pausePolling(); @@ -214,7 +214,7 @@ public void testPollingNotEnabledAndResume() throws ExecutionException, Interrup @Test public void testPauseAfterInitDoesNotCrash() throws ExecutionException, InterruptedException { - BaseAndroidClient androidBaseClient = buildOfflineClient(true, 100); + BaseAndroidEppoClient androidBaseClient = buildOfflineClient(true, 100); // Immediately pause after initialization androidBaseClient.pausePolling(); diff --git a/android-sdk-framework/src/main/java/cloud/eppo/android/framework/BaseAndroidClient.java b/android-sdk-framework/src/main/java/cloud/eppo/android/framework/BaseAndroidEppoClient.java similarity index 90% rename from android-sdk-framework/src/main/java/cloud/eppo/android/framework/BaseAndroidClient.java rename to android-sdk-framework/src/main/java/cloud/eppo/android/framework/BaseAndroidEppoClient.java index f9a57f47..b6f9acd0 100644 --- a/android-sdk-framework/src/main/java/cloud/eppo/android/framework/BaseAndroidClient.java +++ b/android-sdk-framework/src/main/java/cloud/eppo/android/framework/BaseAndroidEppoClient.java @@ -31,8 +31,8 @@ * * @param The JSON type used for JSON flag values (e.g., JsonNode, JsonElement) */ -public class BaseAndroidClient extends BaseEppoClient { - private static final String TAG = logTag(BaseAndroidClient.class); +public class BaseAndroidEppoClient extends BaseEppoClient { + private static final String TAG = logTag(BaseAndroidEppoClient.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; @@ -62,7 +62,7 @@ public class BaseAndroidClient extends BaseEppoClient { private long pollingJitterMs = -1; @Nullable private IAssignmentCache assignmentCache; @Nullable private Consumer configChangeCallback; + // Tracks the FileBackedConfigStore created by the builder so it can be closed on reinitialize. + // Only set when the builder creates the default store (not when the caller provides one). + @Nullable private FileBackedConfigStore ownedConfigStore; // Set during buildAndInitAsync() once the instance is constructed, before any async work // begins. // Used by buildAndInit() as a last-resort fallback so it never returns null in graceful mode. - // Safety: if the BaseAndroidClient constructor itself throws, buildAndInitAsync() propagates a - // RuntimeException synchronously (before returning a Future), so buildAndInit()'s + // Safety: if the BaseAndroidEppoClient constructor itself throws, buildAndInitAsync() propagates + // a RuntimeException synchronously (before returning a Future), so buildAndInit()'s // ExecutionException catch is never reached and builtInstance being null is not a concern. - @Nullable private BaseAndroidClient builtInstance; + @Nullable private BaseAndroidEppoClient builtInstance; /** * Creates a new Builder with required parameters. @@ -251,16 +254,31 @@ public Builder onConfigurationChange( * * @return CompletableFuture that completes with the initialized EppoClient */ - public CompletableFuture> buildAndInitAsync() { + public CompletableFuture> buildAndInitAsync() { String sdkName = obfuscateConfig ? "android" : "android-debug"; String sdkVersion = BuildConfig.EPPO_VERSION; + // Close any previously owned store when force-reinitializing, to release the background + // IO executor. Only the store created by this Builder is closed; caller-provided stores + // are the caller's responsibility. + if (forceReinitialize && ownedConfigStore != null) { + try { + ownedConfigStore.close(); + } catch (java.io.IOException e) { + Log.w(TAG, "Failed to close previous FileBackedConfigStore on reinitialize", e); + } + ownedConfigStore = null; + configStore = null; + } + if (configStore == null) { - configStore = + FileBackedConfigStore newStore = new FileBackedConfigStore( application, safeCacheKey(apiKey), new ConfigurationCodec.Default<>(Configuration.class)); + ownedConfigStore = newStore; + configStore = newStore; } // Use the persisted cache as the initial configuration if none was explicitly provided. @@ -280,8 +298,8 @@ public CompletableFuture> buildAndInitAsync() { } // Construct the client - final BaseAndroidClient newInstance = - new BaseAndroidClient<>( + final BaseAndroidEppoClient newInstance = + new BaseAndroidEppoClient<>( apiKey, sdkName, sdkVersion, @@ -302,7 +320,7 @@ public CompletableFuture> buildAndInitAsync() { newInstance.onConfigurationChange(configChangeCallback); } - final CompletableFuture> ret = new CompletableFuture<>(); + final CompletableFuture> ret = new CompletableFuture<>(); AtomicInteger failCount = new AtomicInteger(0); // Captures the HTTP exception so that when the initial-config future completes the // combined failure path can include the original network error as the cause. @@ -391,7 +409,7 @@ public CompletableFuture> buildAndInitAsync() { * @return The initialized EppoClient * @throws RuntimeException if initialization fails and {@code isGracefulMode} is false */ - public BaseAndroidClient buildAndInit() { + public BaseAndroidEppoClient buildAndInit() { try { return buildAndInitAsync().get(); } catch (InterruptedException e) { From c1e373cdb0012e4b8a78940c0d06d5b2ac96dfcd Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Fri, 29 May 2026 13:13:34 -0600 Subject: [PATCH 24/27] fix: propagate close() through FileBackedConfigStore to release executor on reinitialize - Make FileBackedConfigStore implement java.io.Closeable using a private two-arg constructor to capture the FileBackedByteStore reference before passing it to super() - Add ownedConfigStore field to Builder; close it when forceReinitialize is true so the background IO executor is not leaked across reinits --- .../storage/FileBackedConfigStore.java | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) 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 index 1b97c515..671ed997 100644 --- 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 @@ -4,7 +4,9 @@ import cloud.eppo.api.Configuration; import org.jetbrains.annotations.NotNull; -public class FileBackedConfigStore extends CachingConfigurationStore { +public class FileBackedConfigStore extends CachingConfigurationStore implements java.io.Closeable { + + private final FileBackedByteStore byteStore; /** * Creates a FileBackedStore with the specified configuration. @@ -17,9 +19,25 @@ public FileBackedConfigStore( @NotNull Application application, @NotNull String cacheFileSuffix, @NotNull ConfigurationCodec codec) { - super( + this( codec, new FileBackedByteStore( new ConfigCacheFile(application, cacheFileSuffix, codec.getContentType()))); } + + private FileBackedConfigStore( + @NotNull ConfigurationCodec codec, + @NotNull FileBackedByteStore byteStore) { + super(codec, byteStore); + this.byteStore = byteStore; + } + + /** + * Releases the background IO executor held by the underlying {@link FileBackedByteStore}. Call + * this when the store is no longer needed (e.g. on reinitialize) to avoid leaking threads. + */ + @Override + public void close() throws java.io.IOException { + byteStore.close(); + } } From 7f26560ec9bd43fa7089244530fc471ed1ae285b Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Fri, 29 May 2026 13:13:42 -0600 Subject: [PATCH 25/27] chore: address minor review feedback - Fix safeCacheKey comment: clarify \W preserves underscores and note the 8-char prefix assumption depends on Eppo's key format - Replace System.currentTimeMillis() suffix with UUID in FileBackedConfigStoreTest to avoid timestamp collision on fast machines - Fix "medum-size" typo to "medium-size" in flags-v1.json fixture --- .../java/cloud/eppo/android/framework/util/Utils.java | 8 +++++--- .../framework/storage/FileBackedConfigStoreTest.java | 2 +- android-sdk-framework/src/test/resources/flags-v1.json | 2 +- 3 files changed, 7 insertions(+), 5 deletions(-) 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 index 923a8431..205b2512 100644 --- 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 @@ -21,9 +21,11 @@ public static String safeCacheKey(String key) { return ""; } // Take the first eight characters to avoid the key being sensitive information. - // Remove non-alphanumeric characters so it plays nice with filesystem paths. - // \W is equivalent to [^a-zA-Z0-9_] — underscores are kept, which is fine for filenames. - // Note: if the first 8 characters are all non-word the result is an empty string, + // Eppo API keys are formatted as "<8-char-random-prefix>.", so the first 8 characters + // are the unique identifier portion and the period separator is not included. + // \W strips non-word characters (equivalent to [^a-zA-Z0-9_]); underscores are preserved, + // which is fine for filenames. Note: "non-alphanumeric" would imply underscores are removed — + // they are not. If the first 8 characters are all non-word the result is an empty string, // which produces the filename "eppo-sdk-flags-.bin". Eppo-issued API keys always start // with alphanumeric characters, so this edge case is not expected in production. return key.substring(0, Math.min(8, key.length())).replaceAll("\\W", ""); diff --git a/android-sdk-framework/src/test/java/cloud/eppo/android/framework/storage/FileBackedConfigStoreTest.java b/android-sdk-framework/src/test/java/cloud/eppo/android/framework/storage/FileBackedConfigStoreTest.java index 0e1885b7..3da11c78 100644 --- 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 @@ -27,7 +27,7 @@ public class FileBackedConfigStoreTest { public void setUp() { application = RuntimeEnvironment.getApplication(); codec = new ConfigurationCodec.Default<>(Configuration.class); - cacheFileSuffix = "test-" + System.currentTimeMillis(); + cacheFileSuffix = "test-" + java.util.UUID.randomUUID().toString().substring(0, 8); } @After diff --git a/android-sdk-framework/src/test/resources/flags-v1.json b/android-sdk-framework/src/test/resources/flags-v1.json index b882934b..5b249284 100644 --- a/android-sdk-framework/src/test/resources/flags-v1.json +++ b/android-sdk-framework/src/test/resources/flags-v1.json @@ -835,7 +835,7 @@ "doLog": true }, { - "key": "medum-size", + "key": "medium-size", "rules": [ { "conditions": [ From 0bf7884409bca5144d39eeaa402408959645c0f8 Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Fri, 29 May 2026 13:13:59 -0600 Subject: [PATCH 26/27] style: apply spotless formatting fixes --- .../cloud/eppo/android/framework/BaseAndroidEppoClient.java | 3 ++- .../eppo/android/framework/storage/FileBackedConfigStore.java | 3 +-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/android-sdk-framework/src/main/java/cloud/eppo/android/framework/BaseAndroidEppoClient.java b/android-sdk-framework/src/main/java/cloud/eppo/android/framework/BaseAndroidEppoClient.java index b6f9acd0..77684606 100644 --- a/android-sdk-framework/src/main/java/cloud/eppo/android/framework/BaseAndroidEppoClient.java +++ b/android-sdk-framework/src/main/java/cloud/eppo/android/framework/BaseAndroidEppoClient.java @@ -129,7 +129,8 @@ public static class Builder { // Set during buildAndInitAsync() once the instance is constructed, before any async work // begins. // Used by buildAndInit() as a last-resort fallback so it never returns null in graceful mode. - // Safety: if the BaseAndroidEppoClient constructor itself throws, buildAndInitAsync() propagates + // Safety: if the BaseAndroidEppoClient constructor itself throws, buildAndInitAsync() + // propagates // a RuntimeException synchronously (before returning a Future), so buildAndInit()'s // ExecutionException catch is never reached and builtInstance being null is not a concern. @Nullable private BaseAndroidEppoClient builtInstance; 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 index 671ed997..fd95fac0 100644 --- 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 @@ -26,8 +26,7 @@ public FileBackedConfigStore( } private FileBackedConfigStore( - @NotNull ConfigurationCodec codec, - @NotNull FileBackedByteStore byteStore) { + @NotNull ConfigurationCodec codec, @NotNull FileBackedByteStore byteStore) { super(codec, byteStore); this.byteStore = byteStore; } From e151d8d439a6eab18a4106e6cdae70c6b961d4ab Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Fri, 12 Jun 2026 12:04:30 -0600 Subject: [PATCH 27/27] test: consolidate polling tests and add mock verification Merge testMultiplePauseResumeCycles and testPauseResumeSequenceDoesNotCrash into a single test that verifies polling actually stops and resumes via mock verification. Upgrade testPauseAfterInit to verify behavior instead of just checking no-crash. Remove unused Log/TAG artifacts. --- .../framework/EppoClientPollingTest.java | 96 +++++++++++-------- 1 file changed, 54 insertions(+), 42 deletions(-) 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 index c397c40e..01920306 100644 --- 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 @@ -1,6 +1,5 @@ package cloud.eppo.android.framework; -import static cloud.eppo.android.framework.util.Utils.logTag; import static org.junit.Assert.assertNotNull; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.atLeastOnce; @@ -9,7 +8,6 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -import android.util.Log; import androidx.test.core.app.ApplicationProvider; import cloud.eppo.api.Configuration; import cloud.eppo.http.EppoConfigurationClient; @@ -32,7 +30,6 @@ * various sequences, and that polling actually stops and resumes as expected. */ 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; @@ -141,57 +138,63 @@ public void testResumePollingWithoutStarting() throws ExecutionException, Interr // resumePolling() logs a warning when polling interval was not set and does not start polling. androidBaseClient.resumePolling(); - Log.d(TAG, "Resume called without starting - should log warning"); Thread.sleep(50); } @Test - public void testMultiplePauseResumeCycles() throws ExecutionException, InterruptedException { - BaseAndroidEppoClient androidBaseClient = buildOfflineClient(true, 100); - assertNotNull("Client should be initialized", androidBaseClient); + public void testMultiplePauseResumeCyclesWithVerification() + throws ExecutionException, InterruptedException { + // Stub the mock so polling calls don't NPE. + when(mockConfigClient.execute(any(EppoConfigurationRequest.class))) + .thenReturn(CompletableFuture.completedFuture(EppoConfigurationResponse.error(503, null))); - // First cycle - androidBaseClient.pausePolling(); - Log.d(TAG, "First pause"); - Thread.sleep(50); - androidBaseClient.resumePolling(); - Log.d(TAG, "First resume"); - Thread.sleep(50); + BaseAndroidEppoClient androidBaseClient = buildOfflineClient(true, 50); + assertNotNull("Client should be initialized", androidBaseClient); - // Second cycle - androidBaseClient.pausePolling(); - Log.d(TAG, "Second pause"); - Thread.sleep(50); - androidBaseClient.resumePolling(); - Log.d(TAG, "Second resume"); - Thread.sleep(50); + // Let polling fire at least once. + Thread.sleep(150); + verify(mockConfigClient, atLeastOnce()).execute(any(EppoConfigurationRequest.class)); - // Final cleanup + // Pause and verify polling stops. androidBaseClient.pausePolling(); - } - - @Test - public void testPauseResumeSequenceDoesNotCrash() - throws ExecutionException, InterruptedException { - BaseAndroidEppoClient androidBaseClient = buildOfflineClient(true, 50); + reset(mockConfigClient); + when(mockConfigClient.execute(any(EppoConfigurationRequest.class))) + .thenReturn(CompletableFuture.completedFuture(EppoConfigurationResponse.error(503, null))); + Thread.sleep(200); + verify(mockConfigClient, atMost(1)).execute(any(EppoConfigurationRequest.class)); - // Various sequences that should all work without crashing + // Double pause is safe. androidBaseClient.pausePolling(); - androidBaseClient.pausePolling(); // Double pause - Thread.sleep(50); + // Resume and verify polling fires again. androidBaseClient.resumePolling(); - Thread.sleep(50); + reset(mockConfigClient); + when(mockConfigClient.execute(any(EppoConfigurationRequest.class))) + .thenReturn(CompletableFuture.completedFuture(EppoConfigurationResponse.error(503, null))); + Thread.sleep(150); + verify(mockConfigClient, atLeastOnce()).execute(any(EppoConfigurationRequest.class)); - androidBaseClient.resumePolling(); // Double resume + // Double resume is safe. + androidBaseClient.resumePolling(); Thread.sleep(50); + // Second pause/resume cycle. androidBaseClient.pausePolling(); + reset(mockConfigClient); + when(mockConfigClient.execute(any(EppoConfigurationRequest.class))) + .thenReturn(CompletableFuture.completedFuture(EppoConfigurationResponse.error(503, null))); + Thread.sleep(150); + verify(mockConfigClient, atMost(1)).execute(any(EppoConfigurationRequest.class)); + androidBaseClient.resumePolling(); - Thread.sleep(50); + reset(mockConfigClient); + when(mockConfigClient.execute(any(EppoConfigurationRequest.class))) + .thenReturn(CompletableFuture.completedFuture(EppoConfigurationResponse.error(503, null))); + Thread.sleep(150); + verify(mockConfigClient, atLeastOnce()).execute(any(EppoConfigurationRequest.class)); - androidBaseClient.pausePolling(); // Final pause for cleanup + androidBaseClient.pausePolling(); } @Test @@ -213,19 +216,28 @@ public void testPollingNotEnabledAndResume() throws ExecutionException, Interrup } @Test - public void testPauseAfterInitDoesNotCrash() throws ExecutionException, InterruptedException { - BaseAndroidEppoClient androidBaseClient = buildOfflineClient(true, 100); + public void testPauseAfterInitStopsPolling() throws ExecutionException, InterruptedException { + when(mockConfigClient.execute(any(EppoConfigurationRequest.class))) + .thenReturn(CompletableFuture.completedFuture(EppoConfigurationResponse.error(503, null))); + + BaseAndroidEppoClient androidBaseClient = buildOfflineClient(true, 50); - // Immediately pause after initialization + // Immediately pause after initialization — no polls should fire. androidBaseClient.pausePolling(); - Log.d(TAG, "Paused immediately after init"); + reset(mockConfigClient); + when(mockConfigClient.execute(any(EppoConfigurationRequest.class))) + .thenReturn(CompletableFuture.completedFuture(EppoConfigurationResponse.error(503, null))); Thread.sleep(200); + verify(mockConfigClient, atMost(1)).execute(any(EppoConfigurationRequest.class)); - // Resume + // Resume and verify polling fires. androidBaseClient.resumePolling(); - Thread.sleep(200); + reset(mockConfigClient); + when(mockConfigClient.execute(any(EppoConfigurationRequest.class))) + .thenReturn(CompletableFuture.completedFuture(EppoConfigurationResponse.error(503, null))); + Thread.sleep(150); + verify(mockConfigClient, atLeastOnce()).execute(any(EppoConfigurationRequest.class)); - // Final pause androidBaseClient.pausePolling(); } }