From 43c106074f5c60c581d94faa8c920901406d3a62 Mon Sep 17 00:00:00 2001 From: Heath Borders Date: Thu, 9 Jul 2026 16:09:59 -0500 Subject: [PATCH 01/10] Updates for Configuration Generification See https://github.com/Eppo-exp/sdk-common-jdk/pull/241 --- .../framework/EppoClientPollingTest.java | 54 ++++- .../android/framework/AndroidBaseClient.java | 213 +++++++++++------- .../storage/CachingConfigurationStore.java | 20 +- .../framework/storage/ConfigurationCodec.java | 48 ++-- .../storage/FileBackedConfigStore.java | 14 +- .../CachingConfigurationStoreTest.java | 8 +- .../storage/ConfigurationCodecTest.java | 9 +- .../storage/FileBackedConfigStoreTest.java | 12 +- .../cloud/eppo/android/EppoClientTest.java | 7 +- .../java/cloud/eppo/android/EppoClient.java | 22 +- .../android/JacksonConfigurationParser.java | 22 +- .../androidexample/CustomClientActivity.java | 35 ++- .../GsonConfigurationCodec.java | 9 + .../GsonConfigurationParser.java | 22 +- 14 files changed, 333 insertions(+), 162 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 3c09851d..8fde5fc4 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,16 +1,24 @@ package cloud.eppo.android.framework; import static cloud.eppo.android.framework.util.Utils.logTag; +import static cloud.eppo.android.framework.util.Utils.safeCacheKey; import static org.junit.Assert.assertNotNull; +import android.app.Application; import android.util.Log; import androidx.test.core.app.ApplicationProvider; + +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.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.jetbrains.annotations.NotNull; import org.junit.Before; import org.junit.Test; import org.mockito.Mock; @@ -27,30 +35,53 @@ 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 ConfigurationParser mockConfigParser; @Mock private EppoConfigurationClient mockConfigClient; + private CachingConfigurationStore configurationStore = + new FileBackedConfigStore<>( + ApplicationProvider.getApplicationContext(), + safeCacheKey(DUMMY_API_KEY), + new ConfigurationCodec.Default()); + @Before public void setUp() { MockitoAnnotations.openMocks(this); } + private static class TestBuilder extends AndroidBaseClient.Builder< + TestBuilder, + Configuration, + Configuration.Builder, + JsonNode + > { + protected TestBuilder( + @NotNull String apiKey, + @NotNull Application application, + @NotNull ConfigurationParser configurationParser, + @NotNull CachingConfigurationStore configStore, + @NotNull EppoConfigurationClient configurationClient) { + super(TestBuilder.class, apiKey, application, configurationParser, configStore, configurationClient); + } + } + /** * Builds a client in offline mode with polling enabled. * * @param pollingIntervalMs Polling interval in milliseconds * @return Initialized EppoClient */ - private AndroidBaseClient buildOfflineClientWithPolling(long pollingIntervalMs) + 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<>( + return new TestBuilder( DUMMY_API_KEY, ApplicationProvider.getApplicationContext(), mockConfigParser, + configurationStore, mockConfigClient) .forceReinitialize(true) .offlineMode(true) @@ -67,15 +98,16 @@ private AndroidBaseClient buildOfflineClientWithPolling(long pollingIn * * @return Initialized EppoClient */ - private AndroidBaseClient buildOfflineClientWithoutPolling() + private AndroidBaseClient buildOfflineClientWithoutPolling() throws ExecutionException, InterruptedException { CompletableFuture initialConfig = CompletableFuture.completedFuture(Configuration.emptyConfig()); - return new AndroidBaseClient.Builder<>( + return new TestBuilder( DUMMY_API_KEY, ApplicationProvider.getApplicationContext(), mockConfigParser, + configurationStore, mockConfigClient) .forceReinitialize(true) .offlineMode(true) @@ -88,7 +120,7 @@ private AndroidBaseClient buildOfflineClientWithoutPolling() @Test public void testPauseAndResumePolling() throws ExecutionException, InterruptedException { - AndroidBaseClient androidBaseClient = buildOfflineClientWithPolling(100); + AndroidBaseClient androidBaseClient = buildOfflineClientWithPolling(100); assertNotNull("Client should be initialized", androidBaseClient); // Test pause @@ -111,7 +143,7 @@ public void testPauseAndResumePolling() throws ExecutionException, InterruptedEx @Test public void testResumePollingWithoutStarting() throws ExecutionException, InterruptedException { - AndroidBaseClient androidBaseClient = buildOfflineClientWithoutPolling(); + AndroidBaseClient androidBaseClient = buildOfflineClientWithoutPolling(); assertNotNull("Client should be initialized", androidBaseClient); // Try to resume polling (should log warning and not crash per EppoClient.java:436-441) @@ -126,7 +158,7 @@ public void testResumePollingWithoutStarting() throws ExecutionException, Interr @Test public void testMultiplePauseResumeCycles() throws ExecutionException, InterruptedException { - AndroidBaseClient androidBaseClient = buildOfflineClientWithPolling(100); + AndroidBaseClient androidBaseClient = buildOfflineClientWithPolling(100); assertNotNull("Client should be initialized", androidBaseClient); // First cycle @@ -160,7 +192,7 @@ public void testMultiplePauseResumeCycles() throws ExecutionException, Interrupt @Test public void testPauseResumeSequenceDoesNotCrash() throws ExecutionException, InterruptedException { - AndroidBaseClient androidBaseClient = buildOfflineClientWithPolling(50); + AndroidBaseClient androidBaseClient = buildOfflineClientWithPolling(50); // Various sequences that should all work without crashing androidBaseClient.pausePolling(); @@ -182,7 +214,7 @@ public void testPauseResumeSequenceDoesNotCrash() @Test public void testPollingNotEnabledAndResume() throws ExecutionException, InterruptedException { - AndroidBaseClient androidBaseClient = buildOfflineClientWithoutPolling(); + AndroidBaseClient androidBaseClient = buildOfflineClientWithoutPolling(); // Pause should be safe even if not polling androidBaseClient.pausePolling(); @@ -200,7 +232,7 @@ public void testPollingNotEnabledAndResume() throws ExecutionException, Interrup @Test public void testPauseAfterInitDoesNotCrash() throws ExecutionException, InterruptedException { - AndroidBaseClient androidBaseClient = buildOfflineClientWithPolling(100); + AndroidBaseClient androidBaseClient = buildOfflineClientWithPolling(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 18267540..19da41d8 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 @@ -13,6 +13,7 @@ import cloud.eppo.android.framework.storage.FileBackedConfigStore; import cloud.eppo.api.Configuration; import cloud.eppo.api.IAssignmentCache; +import cloud.eppo.api.SerializableEppoConfiguration; import cloud.eppo.http.EppoConfigurationClient; import cloud.eppo.logging.AssignmentLogger; import cloud.eppo.parser.ConfigurationParser; @@ -32,7 +33,18 @@ * * @param The JSON type used for JSON flag values (e.g., JsonNode, JsonElement) */ -public class AndroidBaseClient extends BaseEppoClient { +public class AndroidBaseClient< + ConfigurationType extends SerializableEppoConfiguration, + ConfigurationBuilderType extends SerializableEppoConfiguration.AbstractBuilder< + ConfigurationBuilderType, + ConfigurationType + >, + JsonFlagType +> extends BaseEppoClient< + ConfigurationType, + ConfigurationBuilderType, + JsonFlagType +> { 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; @@ -42,7 +54,7 @@ public class AndroidBaseClient extends BaseEppoClient instance; + @Nullable private static AndroidBaseClient instance; /** * Private constructor. Use Builder to construct instances. @@ -66,12 +78,12 @@ protected AndroidBaseClient( String sdkVersion, @Nullable String apiBaseUrl, @Nullable AssignmentLogger assignmentLogger, - CachingConfigurationStore configurationStore, + CachingConfigurationStore configurationStore, boolean isGracefulMode, boolean expectObfuscatedConfig, - @Nullable CompletableFuture initialConfiguration, + @Nullable CompletableFuture initialConfiguration, @Nullable IAssignmentCache assignmentCache, - ConfigurationParser configurationParser, + ConfigurationParser configurationParser, EppoConfigurationClient configurationClient) { super( apiKey, @@ -96,14 +108,23 @@ protected AndroidBaseClient( * * @return The singleton instance * @throws NotInitializedException if the client has not been initialized - * @param The JSON type parameter + * @param The Configuration type parameter + * @param The Configuration Builder type parameter + * @param The JSON type parameter */ @SuppressWarnings("unchecked") - public static AndroidBaseClient getInstance() throws NotInitializedException { + public static < + ConfigurationType extends SerializableEppoConfiguration, + ConfigurationBuilderType extends SerializableEppoConfiguration.AbstractBuilder< + ConfigurationBuilderType, + ConfigurationType + >, + JsonFlagType + > AndroidBaseClient getInstance() throws NotInitializedException { if (instance == null) { throw new NotInitializedException(); } - return (AndroidBaseClient) instance; + return (AndroidBaseClient) instance; } /** @@ -114,118 +135,152 @@ public static AndroidBaseClient getInstance() throws NotInitializedExcept * * @param The JSON type used for JSON flag values */ - public static class Builder { + public abstract static class Builder< + SelfType extends Builder< + SelfType, + ConfigurationType, + ConfigurationBuilderType, + JsonFlagType + >, + ConfigurationType extends SerializableEppoConfiguration, + ConfigurationBuilderType extends SerializableEppoConfiguration.AbstractBuilder< + ConfigurationBuilderType, + ConfigurationType + >, + JsonFlagType + > { // Required parameters - private final String apiKey; - private final Application application; - private final ConfigurationParser configurationParser; - private final EppoConfigurationClient configurationClient; + protected final Class selfClass; + protected final String apiKey; + protected final Application application; + protected final ConfigurationParser configurationParser; + protected final CachingConfigurationStore configStore; + protected 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; + @Nullable protected String apiBaseUrl; + @Nullable protected AssignmentLogger assignmentLogger; + protected boolean isGracefulMode = DEFAULT_IS_GRACEFUL_MODE; + protected boolean obfuscateConfig = DEFAULT_OBFUSCATE_CONFIG; + protected boolean forceReinitialize = false; + protected boolean offlineMode = false; + @Nullable protected CompletableFuture initialConfiguration; + protected boolean ignoreCachedConfiguration = false; + protected boolean pollingEnabled = false; + protected long pollingIntervalMs = DEFAULT_POLLING_INTERVAL_MS; + protected long pollingJitterMs = -1; + @Nullable protected IAssignmentCache assignmentCache; + @Nullable protected Consumer configChangeCallback; /** * Creates a new Builder with required parameters. * + * @param selfClass The class of the instance you're instantiating so that builder methods + * can return the right type. This is only for sublcasses. * @param apiKey API key for Eppo (required) * @param application Application context (required) * @param configurationParser Parser for configuration JSON (required) + * @param configStore Store for configurations (required) * @param configurationClient HTTP client for configuration fetching (required) */ - public Builder( + protected Builder( + @NotNull Class selfClass, @NotNull String apiKey, @NotNull Application application, - @NotNull ConfigurationParser configurationParser, + @NotNull ConfigurationParser configurationParser, + @NotNull CachingConfigurationStore configStore, @NotNull EppoConfigurationClient configurationClient) { + if (selfClass == null) { + throw new IllegalArgumentException("Missing self class. Bad subclass"); + } + if (apiKey == null) { + throw new IllegalArgumentException("Missing API Key"); + } + if (application == null) { + throw new IllegalArgumentException("Missing Application"); + } + if (configurationParser == null) { + throw new IllegalArgumentException("Missing ConfigurationParser"); + } + if (configStore == null) { + throw new IllegalArgumentException("Missing CachingConfigurationStore"); + } + if (configurationClient == null) { + throw new IllegalArgumentException("Missing EppoConfigurationClient"); + } + this.selfClass = selfClass; this.apiKey = apiKey; this.application = application; this.configurationParser = configurationParser; + this.configStore = configStore; this.configurationClient = configurationClient; } - public Builder apiBaseUrl(@Nullable String apiBaseUrl) { + public SelfType apiBaseUrl(@Nullable String apiBaseUrl) { this.apiBaseUrl = apiBaseUrl; - return this; + return selfClass.cast(this); } - public Builder assignmentLogger(@Nullable AssignmentLogger assignmentLogger) { + public SelfType assignmentLogger(@Nullable AssignmentLogger assignmentLogger) { this.assignmentLogger = assignmentLogger; - return this; - } - - public Builder configStore(@Nullable CachingConfigurationStore configStore) { - this.configStore = configStore; - return this; + return selfClass.cast(this); } - public Builder isGracefulMode(boolean isGracefulMode) { + public SelfType isGracefulMode(boolean isGracefulMode) { this.isGracefulMode = isGracefulMode; - return this; + return selfClass.cast(this); } - public Builder obfuscateConfig(boolean obfuscateConfig) { + public SelfType obfuscateConfig(boolean obfuscateConfig) { this.obfuscateConfig = obfuscateConfig; - return this; + return selfClass.cast(this); } - public Builder forceReinitialize(boolean forceReinitialize) { + public SelfType forceReinitialize(boolean forceReinitialize) { this.forceReinitialize = forceReinitialize; - return this; + return selfClass.cast(this); } - public Builder offlineMode(boolean offlineMode) { + public SelfType offlineMode(boolean offlineMode) { this.offlineMode = offlineMode; - return this; + return selfClass.cast(this); } - public Builder initialConfiguration( - @Nullable CompletableFuture initialConfiguration) { + public SelfType initialConfiguration( + @Nullable CompletableFuture initialConfiguration) { this.initialConfiguration = initialConfiguration; - return this; + return selfClass.cast(this); } - public Builder ignoreCachedConfiguration(boolean ignoreCache) { + public SelfType ignoreCachedConfiguration(boolean ignoreCache) { this.ignoreCachedConfiguration = ignoreCache; - return this; + return selfClass.cast(this); } - public Builder pollingEnabled(boolean pollingEnabled) { + public SelfType pollingEnabled(boolean pollingEnabled) { this.pollingEnabled = pollingEnabled; - return this; + return selfClass.cast(this); } - public Builder pollingIntervalMs(long pollingIntervalMs) { + public SelfType pollingIntervalMs(long pollingIntervalMs) { this.pollingIntervalMs = pollingIntervalMs; - return this; + return selfClass.cast(this); } - public Builder pollingJitterMs(long pollingJitterMs) { + public SelfType pollingJitterMs(long pollingJitterMs) { this.pollingJitterMs = pollingJitterMs; - return this; + return selfClass.cast(this); } - public Builder assignmentCache(@Nullable IAssignmentCache assignmentCache) { + public SelfType assignmentCache(@Nullable IAssignmentCache assignmentCache) { this.assignmentCache = assignmentCache; - return this; + return selfClass.cast(this); } - public Builder onConfigurationChange( - @Nullable Consumer configChangeCallback) { + public SelfType onConfigurationChange( + @Nullable Consumer configChangeCallback) { this.configChangeCallback = configChangeCallback; - return this; + return selfClass.cast(this); } /** @@ -245,12 +300,18 @@ public Builder onConfigurationChange( * * @return CompletableFuture that completes with the initialized EppoClient */ - public CompletableFuture> buildAndInitAsync() { + public CompletableFuture< + AndroidBaseClient< + ConfigurationType, + ConfigurationBuilderType, + JsonFlagType + > + > buildAndInitAsync() { // Singleton handling if (instance != null && !forceReinitialize) { Log.w(TAG, "Eppo Client instance already initialized"); @SuppressWarnings("unchecked") - AndroidBaseClient typedInstance = (AndroidBaseClient) instance; + AndroidBaseClient typedInstance = (AndroidBaseClient) instance; return CompletableFuture.completedFuture(typedInstance); } else if (instance != null) { // Stop polling if reinitializing @@ -261,21 +322,13 @@ public CompletableFuture> buildAndInitAsync() { 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 = + AndroidBaseClient newInstance = new AndroidBaseClient<>( apiKey, sdkName, @@ -298,7 +351,7 @@ public CompletableFuture> buildAndInitAsync() { newInstance.onConfigurationChange(configChangeCallback); } - final CompletableFuture> ret = new CompletableFuture<>(); + final CompletableFuture> ret = new CompletableFuture<>(); AtomicInteger failCount = new AtomicInteger(0); if (!offlineMode) { @@ -366,7 +419,11 @@ public CompletableFuture> buildAndInitAsync() { * * @return The initialized EppoClient */ - public AndroidBaseClient buildAndInit() { + public AndroidBaseClient< + ConfigurationType, + ConfigurationBuilderType, + JsonFlagType + > buildAndInit() { try { return buildAndInitAsync().get(); } catch (ExecutionException | InterruptedException | CompletionException e) { @@ -378,8 +435,8 @@ public AndroidBaseClient buildAndInit() { if (cause instanceof RuntimeException && cause.getCause() instanceof EppoInitializationException) { @SuppressWarnings("unchecked") - AndroidBaseClient typedInstance = - (AndroidBaseClient) instance; + AndroidBaseClient typedInstance = + (AndroidBaseClient) instance; return typedInstance; } } @@ -389,7 +446,7 @@ public AndroidBaseClient buildAndInit() { } } @SuppressWarnings("unchecked") - AndroidBaseClient typedInstance = (AndroidBaseClient) instance; + AndroidBaseClient typedInstance = (AndroidBaseClient) 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 9eea7415..c3027a7f 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 @@ -1,7 +1,8 @@ package cloud.eppo.android.framework.storage; import cloud.eppo.IConfigurationStore; -import cloud.eppo.api.Configuration; +import cloud.eppo.api.SerializableEppoConfiguration; + import java.util.concurrent.CompletableFuture; import org.jetbrains.annotations.NotNull; @@ -9,21 +10,24 @@ * 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 { +public class CachingConfigurationStore< + ConfigurationType extends SerializableEppoConfiguration +> implements IConfigurationStore { - private final ConfigurationCodec codec; + private final ConfigurationCodec codec; private final ByteStore byteStore; - private volatile Configuration configuration = Configuration.emptyConfig(); + private volatile ConfigurationType configuration; protected CachingConfigurationStore( - @NotNull ConfigurationCodec codec, @NotNull ByteStore byteStore) { + @NotNull ConfigurationCodec codec, @NotNull ByteStore byteStore) { + this.configuration = codec.emptyConfiguration(); this.codec = codec; this.byteStore = byteStore; } /** Returns the current in-memory configuration. */ @Override - @NotNull public Configuration getConfiguration() { + @NotNull public ConfigurationType getConfiguration() { return configuration; } @@ -35,7 +39,7 @@ protected CachingConfigurationStore( * @throws IllegalArgumentException if config is null */ @Override - @NotNull public CompletableFuture saveConfiguration(@NotNull Configuration config) { + @NotNull public CompletableFuture saveConfiguration(@NotNull ConfigurationType config) { if (config == null) { throw new IllegalArgumentException("config must not be null"); } @@ -54,7 +58,7 @@ protected CachingConfigurationStore( * @return a future that completes with the loaded configuration, or null if storage is empty or * missing */ - @NotNull public CompletableFuture loadFromStorage() { + @NotNull public CompletableFuture loadFromStorage() { return byteStore .read() .thenApply( 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..08c3aad2 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 @@ -1,5 +1,6 @@ package cloud.eppo.android.framework.storage; +import cloud.eppo.api.Configuration; import cloud.eppo.api.SerializableEppoConfiguration; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; @@ -13,9 +14,9 @@ * *

Used for persisting configurations to storage. * - * @param the configuration type, must extend SerializableEppoConfiguration + * @param the configuration type, must extend SerializableEppoConfiguration */ -public interface ConfigurationCodec { +public interface ConfigurationCodec { /** * Serializes a configuration to bytes for storage. * @@ -23,7 +24,7 @@ public interface ConfigurationCodec { * @return serialized bytes (must not be null) * @throws RuntimeException if the configuration cannot be serialized */ - byte[] toBytes(@NotNull T configuration); + byte[] toBytes(@NotNull ConfigurationType configuration); /** * Deserializes a configuration from bytes produced by {@link #toBytes}. @@ -32,7 +33,7 @@ public interface ConfigurationCodec { * @return the deserialized configuration * @throws RuntimeException if the bytes cannot be deserialized to a configuration */ - @NotNull T fromBytes(byte[] bytes); + @NotNull ConfigurationType fromBytes(byte[] bytes); /** * Returns the MIME content type of the serialized form (e.g. {@code @@ -41,6 +42,12 @@ public interface ConfigurationCodec { */ @NotNull String getContentType(); + /** + * Generic equivalent to {@link Configuration#emptyConfig()} + * @return an empty Configuration. + */ + @NotNull ConfigurationType emptyConfiguration(); + /** * Default implementation using Java serialization. * @@ -48,23 +55,11 @@ public interface ConfigurationCodec { * this codec to deserialize data from untrusted sources, as Java deserialization has known * security vulnerabilities. * - * @param the configuration type, must extend SerializableEppoConfiguration + * @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; - } - + public static class Default implements ConfigurationCodec { @Override - public byte[] toBytes(@NotNull T configuration) { + public byte[] toBytes(@NotNull Configuration configuration) { if (configuration == null) { throw new IllegalArgumentException("Configuration must not be null"); } @@ -79,20 +74,18 @@ public byte[] toBytes(@NotNull T configuration) { @Override @SuppressWarnings("unchecked") // Safe cast - verified by configClass.isInstance() check - public @NotNull T fromBytes(byte[] bytes) { + public @NotNull Configuration 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)) { + if (!(obj instanceof Configuration)) { throw new RuntimeException( - "Deserialized object is not a " - + configClass.getSimpleName() - + ": " + "Deserialized object is not a Configuration:" + obj.getClass().getName()); } - return (T) obj; + return (Configuration) obj; } catch (IOException e) { throw new RuntimeException("Failed to deserialize configuration", e); } catch (ClassNotFoundException e) { @@ -104,5 +97,10 @@ public byte[] toBytes(@NotNull T configuration) { public @NotNull String getContentType() { return "application/x-java-serialized-object"; } + + @Override + public @NotNull Configuration emptyConfiguration() { + return Configuration.emptyConfig(); + } } } 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..00a332c7 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 @@ -2,9 +2,13 @@ import android.app.Application; import cloud.eppo.api.Configuration; +import cloud.eppo.api.SerializableEppoConfiguration; + import org.jetbrains.annotations.NotNull; -public class FileBackedConfigStore extends CachingConfigurationStore { +public class FileBackedConfigStore< + ConfigurationType extends SerializableEppoConfiguration +> extends CachingConfigurationStore { /** * Creates a FileBackedStore with the specified configuration. @@ -16,12 +20,14 @@ public class FileBackedConfigStore extends CachingConfigurationStore { public FileBackedConfigStore( @NotNull Application application, @NotNull String cacheFileSuffix, - @NotNull ConfigurationCodec codec) { + @NotNull ConfigurationCodec codec) { super(codec, createByteStore(application, cacheFileSuffix, codec)); } - private static ByteStore createByteStore( - Application application, String cacheFileSuffix, ConfigurationCodec codec) { + private static < + ConfigurationType extends SerializableEppoConfiguration + > 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/test/java/cloud/eppo/android/framework/storage/CachingConfigurationStoreTest.java b/android-sdk-framework/src/test/java/cloud/eppo/android/framework/storage/CachingConfigurationStoreTest.java index 7d3d5469..bf461c0b 100644 --- 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 @@ -35,7 +35,7 @@ public class CachingConfigurationStoreTest { private ByteStore mockByteStore; private ConfigurationCodec spyCodec; - private CachingConfigurationStore testedStore; + private CachingConfigurationStore testedStore; /** One shared non-empty configuration used across tests (built once in setUp). */ private Configuration sampleConfiguration; @@ -46,12 +46,12 @@ public class CachingConfigurationStoreTest { @Before public void setUp() throws Exception { mockByteStore = mock(ByteStore.class); - spyCodec = spy(new ConfigurationCodec.Default<>(Configuration.class)); - testedStore = new CachingConfigurationStore(spyCodec, mockByteStore); + spyCodec = spy(new ConfigurationCodec.Default()); + 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); + new ConfigurationCodec.Default(); sampleConfigurationBytes = realCodec.toBytes(sampleConfiguration); } 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 index f154cf84..bf2ba404 100644 --- 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 @@ -19,11 +19,11 @@ @RunWith(RobolectricTestRunner.class) public class ConfigurationCodecTest { - private ConfigurationCodec codec; + private ConfigurationCodec codec; @Before public void setUp() { - codec = new ConfigurationCodec.Default<>(SerializableEppoConfiguration.class); + codec = new ConfigurationCodec.Default(); } @Test @@ -92,14 +92,13 @@ public void fromBytes_javaSerializedWrongType_throwsRuntimeException() throws Ex } catch (RuntimeException e) { assertTrue( "Exception should mention type mismatch", - e.getMessage().contains("not a SerializableEppoConfiguration")); + e.getMessage().contains("not a Configuration")); } } @Test public void roundTrip_serializeAndDeserialize_succeeds() { - SerializableEppoConfiguration original = - (SerializableEppoConfiguration) Configuration.emptyConfig(); + Configuration original = Configuration.emptyConfig(); byte[] bytes = codec.toBytes(original); assertNotNull(bytes); assertTrue(bytes.length > 0); 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..e68a6ec8 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 @@ -24,20 +24,20 @@ public class FileBackedConfigStoreTest { @Before public void setUp() { application = RuntimeEnvironment.getApplication(); - codec = new ConfigurationCodec.Default<>(Configuration.class); + codec = new ConfigurationCodec.Default(); cacheFileSuffix = "test-" + System.currentTimeMillis(); } @Test public void construct_withValidArgs_succeeds() { - FileBackedConfigStore store = new FileBackedConfigStore(application, cacheFileSuffix, codec); + FileBackedConfigStore store = new FileBackedConfigStore<>(application, cacheFileSuffix, codec); assertNotNull(store); } @Test public void getConfiguration_beforeAnySave_returnsEmptyConfig() { - FileBackedConfigStore store = new FileBackedConfigStore(application, cacheFileSuffix, codec); + FileBackedConfigStore store = new FileBackedConfigStore<>(application, cacheFileSuffix, codec); Configuration config = store.getConfiguration(); @@ -47,7 +47,7 @@ public void getConfiguration_beforeAnySave_returnsEmptyConfig() { @Test public void saveConfiguration_thenGetConfiguration_returnsSavedConfig() throws Exception { - FileBackedConfigStore store = new FileBackedConfigStore(application, cacheFileSuffix, codec); + FileBackedConfigStore store = new FileBackedConfigStore<>(application, cacheFileSuffix, codec); Configuration toSave = Configuration.emptyConfig(); store.saveConfiguration(toSave).get(5, TimeUnit.SECONDS); @@ -57,7 +57,7 @@ public void saveConfiguration_thenGetConfiguration_returnsSavedConfig() throws E @Test public void loadFromStorage_whenNothingSaved_returnsNull() throws Exception { - FileBackedConfigStore store = new FileBackedConfigStore(application, cacheFileSuffix, codec); + FileBackedConfigStore store = new FileBackedConfigStore<>(application, cacheFileSuffix, codec); Configuration loaded = store.loadFromStorage().get(5, TimeUnit.SECONDS); @@ -66,7 +66,7 @@ public void loadFromStorage_whenNothingSaved_returnsNull() throws Exception { @Test public void saveConfiguration_thenLoadFromStorage_returnsSameConfig() throws Exception { - FileBackedConfigStore store = new FileBackedConfigStore(application, cacheFileSuffix, codec); + FileBackedConfigStore store = new FileBackedConfigStore<>(application, cacheFileSuffix, codec); Configuration toSave = Configuration.emptyConfig(); store.saveConfiguration(toSave).get(5, TimeUnit.SECONDS); diff --git a/eppo/src/androidTest/java/cloud/eppo/android/EppoClientTest.java b/eppo/src/androidTest/java/cloud/eppo/android/EppoClientTest.java index e9474e51..108fe64b 100644 --- a/eppo/src/androidTest/java/cloud/eppo/android/EppoClientTest.java +++ b/eppo/src/androidTest/java/cloud/eppo/android/EppoClientTest.java @@ -858,8 +858,9 @@ public CompletableFuture write(byte[] bytes) { } }; - ConfigurationCodec codec = new ConfigurationCodec.Default<>(Configuration.class); - CachingConfigurationStore slowStore = new CachingConfigurationStore(codec, slowByteStore) {}; + ConfigurationCodec codec = new ConfigurationCodec.Default(); + CachingConfigurationStore slowStore = + new CachingConfigurationStore(codec, slowByteStore) {}; initClient( TEST_HOST, true, false, false, true, null, slowStore, DUMMY_API_KEY, false, null, false); @@ -1081,7 +1082,7 @@ private Configuration configurationFromJsonBytes(byte[] jsonBytes) { * @return serialized bytes */ private byte[] serializeConfiguration(Configuration config) { - ConfigurationCodec codec = new ConfigurationCodec.Default<>(Configuration.class); + ConfigurationCodec codec = new ConfigurationCodec.Default(); return codec.toBytes(config); } } diff --git a/eppo/src/main/java/cloud/eppo/android/EppoClient.java b/eppo/src/main/java/cloud/eppo/android/EppoClient.java index ff7f6c6e..06689f5e 100644 --- a/eppo/src/main/java/cloud/eppo/android/EppoClient.java +++ b/eppo/src/main/java/cloud/eppo/android/EppoClient.java @@ -27,7 +27,7 @@ import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Consumer; -public class EppoClient extends AndroidBaseClient { +public class EppoClient extends AndroidBaseClient { private static final String TAG = logTag(EppoClient.class); private static final boolean DEFAULT_IS_GRACEFUL_MODE = true; private static final boolean DEFAULT_OBFUSCATE_CONFIG = true; @@ -44,8 +44,8 @@ private EppoClient( String sdkVersion, @Nullable String apiBaseUrl, @Nullable AssignmentLogger assignmentLogger, - CachingConfigurationStore configurationStore, - ConfigurationParser configurationParser, + CachingConfigurationStore configurationStore, + ConfigurationParser configurationParser, EppoConfigurationClient configurationClient, boolean isGracefulMode, boolean expectObfuscatedConfig, @@ -129,7 +129,7 @@ public static class Builder { private final String apiKey; @Nullable private AssignmentLogger assignmentLogger; - @Nullable private CachingConfigurationStore configStore; + @Nullable private CachingConfigurationStore configStore; private boolean isGracefulMode = DEFAULT_IS_GRACEFUL_MODE; private boolean obfuscateConfig = DEFAULT_OBFUSCATE_CONFIG; @@ -151,7 +151,7 @@ public static class Builder { @Nullable private Consumer configChangeCallback; // Batteries-included: Allow overriding default implementations - @Nullable private ConfigurationParser configurationParser; + @Nullable private ConfigurationParser configurationParser; @Nullable private EppoConfigurationClient configurationClient; public Builder(@NonNull String apiKey, @NonNull Application application) { @@ -204,7 +204,7 @@ public Builder initialConfiguration(CompletableFuture initialConf return this; } - public Builder configStore(CachingConfigurationStore configStore) { + public Builder configStore(CachingConfigurationStore configStore) { this.configStore = configStore; return this; } @@ -252,7 +252,7 @@ public Builder onConfigurationChange(Consumer configChangeCallbac * @param parser the configuration parser to use * @return this builder */ - public Builder configurationParser(ConfigurationParser parser) { + public Builder configurationParser(ConfigurationParser parser) { this.configurationParser = parser; return this; } @@ -303,7 +303,7 @@ public CompletableFuture buildAndInitAsync() { } // Create batteries-included implementations (use provided overrides or defaults) - ConfigurationParser parserToUse = + ConfigurationParser parserToUse = this.configurationParser != null ? this.configurationParser : new JacksonConfigurationParser(); @@ -413,12 +413,12 @@ public EppoClient buildAndInit() { return instance; } - private CachingConfigurationStore createDefaultConfigStore() { + private CachingConfigurationStore createDefaultConfigStore() { ConfigurationCodec codec = - new ConfigurationCodec.Default<>(Configuration.class); + new ConfigurationCodec.Default(); // Cache at a per-API key level (useful for development) - return new FileBackedConfigStore(application, safeCacheKey(apiKey), codec); + return new FileBackedConfigStore<>(application, safeCacheKey(apiKey), codec); } } diff --git a/eppo/src/main/java/cloud/eppo/android/JacksonConfigurationParser.java b/eppo/src/main/java/cloud/eppo/android/JacksonConfigurationParser.java index 9274c766..f6716f0f 100644 --- a/eppo/src/main/java/cloud/eppo/android/JacksonConfigurationParser.java +++ b/eppo/src/main/java/cloud/eppo/android/JacksonConfigurationParser.java @@ -1,6 +1,10 @@ package cloud.eppo.android; +import androidx.annotation.NonNull; + import cloud.eppo.android.dto.adapters.EppoModule; +import cloud.eppo.api.Configuration; +import cloud.eppo.api.SerializableEppoConfiguration; import cloud.eppo.api.dto.BanditParametersResponse; import cloud.eppo.api.dto.FlagConfigResponse; import cloud.eppo.parser.ConfigurationParseException; @@ -19,7 +23,11 @@ * format. The deserializers are hand-rolled to avoid reliance on annotations and method names, * which can be unreliable when ProGuard minification is in use. */ -public class JacksonConfigurationParser implements ConfigurationParser { +public class JacksonConfigurationParser implements ConfigurationParser< + Configuration, + Configuration.Builder, + JsonNode +> { private static final Logger log = LoggerFactory.getLogger(JacksonConfigurationParser.class); private final ObjectMapper objectMapper; @@ -78,4 +86,16 @@ public BanditParametersResponse parseBanditParams(byte[] banditParamsJson) throw new ConfigurationParseException("Failed to parse JSON value", e); } } + + @NonNull + @Override + public Configuration.Builder configurationBuilder(@NotNull FlagConfigResponse flagConfigResponse) { + return new Configuration.Builder(flagConfigResponse); + } + + @NonNull + @Override + public Configuration.Builder configurationBuilder(@NotNull FlagConfigResponse flagConfigResponse, boolean isConfigObfuscated) { + return new Configuration.Builder(flagConfigResponse, isConfigObfuscated); + } } diff --git a/example/src/main/java/cloud/eppo/androidexample/CustomClientActivity.java b/example/src/main/java/cloud/eppo/androidexample/CustomClientActivity.java index 6d597499..8909c601 100644 --- a/example/src/main/java/cloud/eppo/androidexample/CustomClientActivity.java +++ b/example/src/main/java/cloud/eppo/androidexample/CustomClientActivity.java @@ -3,6 +3,7 @@ import static cloud.eppo.androidexample.Constants.INITIAL_FLAG_KEY; import static cloud.eppo.androidexample.Constants.INITIAL_SUBJECT_ID; +import android.app.Application; import android.os.Bundle; import android.text.TextUtils; import android.util.Log; @@ -12,15 +13,23 @@ import android.widget.TextView; import androidx.appcompat.app.AppCompatActivity; import cloud.eppo.android.framework.AndroidBaseClient; +import cloud.eppo.android.framework.storage.CachingConfigurationStore; import cloud.eppo.android.framework.storage.FileBackedConfigStore; import cloud.eppo.android.framework.util.Utils; import cloud.eppo.api.AllocationDetails; import cloud.eppo.api.AssignmentDetails; import cloud.eppo.api.Attributes; +import cloud.eppo.api.Configuration; import cloud.eppo.api.EvaluationDetails; +import cloud.eppo.http.EppoConfigurationClient; +import cloud.eppo.parser.ConfigurationParser; + import com.geteppo.androidexample.BuildConfig; import com.geteppo.androidexample.R; import com.google.gson.JsonElement; + +import org.jetbrains.annotations.NotNull; + import java.text.SimpleDateFormat; import java.util.Date; import java.util.LinkedHashMap; @@ -48,7 +57,23 @@ public class CustomClientActivity extends AppCompatActivity { private TextView assignmentLog; private ScrollView assignmentLogScrollView; - private AndroidBaseClient client; + private AndroidBaseClient client; + + private class GsonAndroidBaseClientBuilder extends AndroidBaseClient.Builder< + GsonAndroidBaseClientBuilder, + Configuration, + Configuration.Builder, + JsonElement + > { + public GsonAndroidBaseClientBuilder( + @NotNull String apiKey, + @NotNull Application application, + @NotNull ConfigurationParser configurationParser, + @NotNull CachingConfigurationStore configStore, + @NotNull EppoConfigurationClient configurationClient) { + super(GsonAndroidBaseClientBuilder.class, apiKey, application, configurationParser, configStore, configurationClient); + } + } @Override protected void onCreate(Bundle savedInstanceState) { @@ -72,18 +97,18 @@ protected void onCreate(Bundle savedInstanceState) { // Swap in the GSON-based cache codec so the on-disk cache is human-readable JSON // rather than Java's binary serialization format. - FileBackedConfigStore gsonStore = - new FileBackedConfigStore( + FileBackedConfigStore gsonStore = + new FileBackedConfigStore<>( getApplication(), Utils.safeCacheKey(API_KEY), new GsonConfigurationCodec()); - new AndroidBaseClient.Builder<>( + new GsonAndroidBaseClientBuilder( API_KEY, getApplication(), new GsonConfigurationParser(), + gsonStore, new HeaderInjectingEppoClient(customHeaders)) .forceReinitialize(true) .isGracefulMode(false) - .configStore(gsonStore) .assignmentLogger( assignment -> Log.d( diff --git a/example/src/main/java/cloud/eppo/androidexample/GsonConfigurationCodec.java b/example/src/main/java/cloud/eppo/androidexample/GsonConfigurationCodec.java index 67d1ce9c..8461442f 100644 --- a/example/src/main/java/cloud/eppo/androidexample/GsonConfigurationCodec.java +++ b/example/src/main/java/cloud/eppo/androidexample/GsonConfigurationCodec.java @@ -1,6 +1,9 @@ package cloud.eppo.androidexample; import android.util.Log; + +import androidx.annotation.NonNull; + import cloud.eppo.android.framework.storage.ConfigurationCodec; import cloud.eppo.api.Configuration; import cloud.eppo.api.EppoValue; @@ -101,6 +104,12 @@ public byte[] toBytes(@NotNull Configuration configuration) { return "application/json"; } + @NonNull + @Override + @NotNull public Configuration emptyConfiguration() { + return Configuration.emptyConfig(); + } + // ===== Serialization ===== @SuppressWarnings("unchecked") diff --git a/example/src/main/java/cloud/eppo/androidexample/GsonConfigurationParser.java b/example/src/main/java/cloud/eppo/androidexample/GsonConfigurationParser.java index f6e584d7..00339a2a 100644 --- a/example/src/main/java/cloud/eppo/androidexample/GsonConfigurationParser.java +++ b/example/src/main/java/cloud/eppo/androidexample/GsonConfigurationParser.java @@ -3,7 +3,12 @@ import static cloud.eppo.Utils.base64Decode; import android.util.Log; + +import androidx.annotation.NonNull; + +import cloud.eppo.api.Configuration; import cloud.eppo.api.EppoValue; +import cloud.eppo.api.SerializableEppoConfiguration; import cloud.eppo.api.dto.Allocation; import cloud.eppo.api.dto.BanditCategoricalAttributeCoefficients; import cloud.eppo.api.dto.BanditCoefficients; @@ -29,6 +34,9 @@ import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.google.gson.JsonParser; + +import org.jetbrains.annotations.NotNull; + import java.nio.charset.StandardCharsets; import java.text.ParseException; import java.text.SimpleDateFormat; @@ -57,7 +65,7 @@ * to {@code ConfigurationParser}). It is provided here as a reference implementation and * for use with framework clients that are parameterised over {@link JsonElement}. */ -public class GsonConfigurationParser implements ConfigurationParser { +public class GsonConfigurationParser implements ConfigurationParser { private static final String TAG = GsonConfigurationParser.class.getSimpleName(); public GsonConfigurationParser() {} @@ -106,6 +114,18 @@ public JsonElement parseJsonValue(String jsonValue) throws ConfigurationParseExc } } + @NonNull + @Override + public Configuration.Builder configurationBuilder(@NotNull FlagConfigResponse flagConfigResponse) { + return new Configuration.Builder(flagConfigResponse); + } + + @NonNull + @Override + public Configuration.Builder configurationBuilder(@NotNull FlagConfigResponse flagConfigResponse, boolean isConfigObfuscated) { + return new Configuration.Builder(flagConfigResponse, isConfigObfuscated); + } + // ===== Flag configuration ===== private FlagConfigResponse deserializeFlagConfigResponse(JsonElement element) { From e4b9e270d6b918baefe92d750f51dcf0127bfc00 Mon Sep 17 00:00:00 2001 From: Heath Borders Date: Tue, 14 Jul 2026 14:42:47 -0500 Subject: [PATCH 02/10] Fix generics for the builder so it builds the right type --- .../android/framework/AndroidBaseClient.java | 40 ++++++++++++++----- 1 file changed, 29 insertions(+), 11 deletions(-) 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 19da41d8..abca058b 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 @@ -138,6 +138,12 @@ > AndroidBaseClient g public abstract static class Builder< SelfType extends Builder< SelfType, + AndroidBaseClientType, + ConfigurationType, + ConfigurationBuilderType, + JsonFlagType + >, + AndroidBaseClientType extends AndroidBaseClient< ConfigurationType, ConfigurationBuilderType, JsonFlagType @@ -283,6 +289,24 @@ public SelfType onConfigurationChange( return selfClass.cast(this); } + /** + * For subclasses to initialize the proper type + * @see AndroidBaseClient constructor + */ + protected abstract AndroidBaseClientType newInstance( + 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); + /** * Builds and initializes the EppoClient asynchronously. * @@ -300,18 +324,12 @@ public SelfType onConfigurationChange( * * @return CompletableFuture that completes with the initialized EppoClient */ - public CompletableFuture< - AndroidBaseClient< - ConfigurationType, - ConfigurationBuilderType, - JsonFlagType - > - > buildAndInitAsync() { + public CompletableFuture buildAndInitAsync() { // Singleton handling if (instance != null && !forceReinitialize) { Log.w(TAG, "Eppo Client instance already initialized"); @SuppressWarnings("unchecked") - AndroidBaseClient typedInstance = (AndroidBaseClient) instance; + AndroidBaseClientType typedInstance = (AndroidBaseClientType) instance; return CompletableFuture.completedFuture(typedInstance); } else if (instance != null) { // Stop polling if reinitializing @@ -328,8 +346,8 @@ > buildAndInitAsync() { } // Construct the client - AndroidBaseClient newInstance = - new AndroidBaseClient<>( + AndroidBaseClientType newInstance = + newInstance( apiKey, sdkName, sdkVersion, @@ -351,7 +369,7 @@ > buildAndInitAsync() { newInstance.onConfigurationChange(configChangeCallback); } - final CompletableFuture> ret = new CompletableFuture<>(); + final CompletableFuture ret = new CompletableFuture<>(); AtomicInteger failCount = new AtomicInteger(0); if (!offlineMode) { From f799f5b2ac6daab75208ccf78928a0106a5e8289 Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Tue, 21 Jul 2026 14:19:26 -0600 Subject: [PATCH 03/10] chore: apply spotless formatting --- .../framework/EppoClientPollingTest.java | 68 +++++----- .../android/framework/AndroidBaseClient.java | 121 ++++++++---------- .../storage/CachingConfigurationStore.java | 6 +- .../framework/storage/ConfigurationCodec.java | 4 +- .../storage/FileBackedConfigStore.java | 16 +-- .../CachingConfigurationStoreTest.java | 3 +- .../storage/ConfigurationCodecTest.java | 3 +- .../storage/FileBackedConfigStoreTest.java | 15 ++- .../java/cloud/eppo/android/EppoClient.java | 7 +- .../android/JacksonConfigurationParser.java | 21 ++- .../androidexample/CustomClientActivity.java | 33 ++--- .../GsonConfigurationCodec.java | 5 +- .../GsonConfigurationParser.java | 22 ++-- 13 files changed, 157 insertions(+), 167 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 8fde5fc4..a962e396 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 @@ -7,7 +7,6 @@ import android.app.Application; import android.util.Log; import androidx.test.core.app.ApplicationProvider; - import cloud.eppo.android.framework.storage.CachingConfigurationStore; import cloud.eppo.android.framework.storage.ConfigurationCodec; import cloud.eppo.android.framework.storage.FileBackedConfigStore; @@ -17,7 +16,6 @@ import com.fasterxml.jackson.databind.JsonNode; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; - import org.jetbrains.annotations.NotNull; import org.junit.Before; import org.junit.Test; @@ -35,33 +33,38 @@ 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 ConfigurationParser mockConfigParser; + @Mock private EppoConfigurationClient mockConfigClient; private CachingConfigurationStore configurationStore = - new FileBackedConfigStore<>( - ApplicationProvider.getApplicationContext(), - safeCacheKey(DUMMY_API_KEY), - new ConfigurationCodec.Default()); + new FileBackedConfigStore<>( + ApplicationProvider.getApplicationContext(), + safeCacheKey(DUMMY_API_KEY), + new ConfigurationCodec.Default()); @Before public void setUp() { MockitoAnnotations.openMocks(this); } - private static class TestBuilder extends AndroidBaseClient.Builder< - TestBuilder, - Configuration, - Configuration.Builder, - JsonNode - > { + private static class TestBuilder + extends AndroidBaseClient.Builder< + TestBuilder, Configuration, Configuration.Builder, JsonNode> { protected TestBuilder( - @NotNull String apiKey, - @NotNull Application application, - @NotNull ConfigurationParser configurationParser, - @NotNull CachingConfigurationStore configStore, - @NotNull EppoConfigurationClient configurationClient) { - super(TestBuilder.class, apiKey, application, configurationParser, configStore, configurationClient); + @NotNull String apiKey, + @NotNull Application application, + @NotNull ConfigurationParser configurationParser, + @NotNull CachingConfigurationStore configStore, + @NotNull EppoConfigurationClient configurationClient) { + super( + TestBuilder.class, + apiKey, + application, + configurationParser, + configStore, + configurationClient); } } @@ -71,8 +74,9 @@ protected TestBuilder( * @param pollingIntervalMs Polling interval in milliseconds * @return Initialized EppoClient */ - private AndroidBaseClient buildOfflineClientWithPolling(long pollingIntervalMs) - throws ExecutionException, InterruptedException { + private AndroidBaseClient + buildOfflineClientWithPolling(long pollingIntervalMs) + throws ExecutionException, InterruptedException { // Use an empty configuration for offline mode CompletableFuture initialConfig = CompletableFuture.completedFuture(Configuration.emptyConfig()); @@ -98,8 +102,8 @@ private AndroidBaseClient buildO * * @return Initialized EppoClient */ - private AndroidBaseClient buildOfflineClientWithoutPolling() - throws ExecutionException, InterruptedException { + private AndroidBaseClient + buildOfflineClientWithoutPolling() throws ExecutionException, InterruptedException { CompletableFuture initialConfig = CompletableFuture.completedFuture(Configuration.emptyConfig()); @@ -120,7 +124,8 @@ private AndroidBaseClient buildO @Test public void testPauseAndResumePolling() throws ExecutionException, InterruptedException { - AndroidBaseClient androidBaseClient = buildOfflineClientWithPolling(100); + AndroidBaseClient androidBaseClient = + buildOfflineClientWithPolling(100); assertNotNull("Client should be initialized", androidBaseClient); // Test pause @@ -143,7 +148,8 @@ public void testPauseAndResumePolling() throws ExecutionException, InterruptedEx @Test public void testResumePollingWithoutStarting() throws ExecutionException, InterruptedException { - AndroidBaseClient androidBaseClient = buildOfflineClientWithoutPolling(); + AndroidBaseClient androidBaseClient = + buildOfflineClientWithoutPolling(); assertNotNull("Client should be initialized", androidBaseClient); // Try to resume polling (should log warning and not crash per EppoClient.java:436-441) @@ -158,7 +164,8 @@ public void testResumePollingWithoutStarting() throws ExecutionException, Interr @Test public void testMultiplePauseResumeCycles() throws ExecutionException, InterruptedException { - AndroidBaseClient androidBaseClient = buildOfflineClientWithPolling(100); + AndroidBaseClient androidBaseClient = + buildOfflineClientWithPolling(100); assertNotNull("Client should be initialized", androidBaseClient); // First cycle @@ -192,7 +199,8 @@ public void testMultiplePauseResumeCycles() throws ExecutionException, Interrupt @Test public void testPauseResumeSequenceDoesNotCrash() throws ExecutionException, InterruptedException { - AndroidBaseClient androidBaseClient = buildOfflineClientWithPolling(50); + AndroidBaseClient androidBaseClient = + buildOfflineClientWithPolling(50); // Various sequences that should all work without crashing androidBaseClient.pausePolling(); @@ -214,7 +222,8 @@ public void testPauseResumeSequenceDoesNotCrash() @Test public void testPollingNotEnabledAndResume() throws ExecutionException, InterruptedException { - AndroidBaseClient androidBaseClient = buildOfflineClientWithoutPolling(); + AndroidBaseClient androidBaseClient = + buildOfflineClientWithoutPolling(); // Pause should be safe even if not polling androidBaseClient.pausePolling(); @@ -232,7 +241,8 @@ public void testPollingNotEnabledAndResume() throws ExecutionException, Interrup @Test public void testPauseAfterInitDoesNotCrash() throws ExecutionException, InterruptedException { - AndroidBaseClient androidBaseClient = buildOfflineClientWithPolling(100); + AndroidBaseClient androidBaseClient = + buildOfflineClientWithPolling(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 abca058b..e67635d2 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 @@ -1,7 +1,6 @@ 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; @@ -9,9 +8,6 @@ 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.api.SerializableEppoConfiguration; import cloud.eppo.http.EppoConfigurationClient; @@ -34,17 +30,12 @@ * @param The JSON type used for JSON flag values (e.g., JsonNode, JsonElement) */ public class AndroidBaseClient< - ConfigurationType extends SerializableEppoConfiguration, - ConfigurationBuilderType extends SerializableEppoConfiguration.AbstractBuilder< - ConfigurationBuilderType, - ConfigurationType - >, - JsonFlagType -> extends BaseEppoClient< - ConfigurationType, - ConfigurationBuilderType, - JsonFlagType -> { + ConfigurationType extends SerializableEppoConfiguration, + ConfigurationBuilderType extends + SerializableEppoConfiguration.AbstractBuilder< + ConfigurationBuilderType, ConfigurationType>, + JsonFlagType> + 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; @@ -83,7 +74,8 @@ protected AndroidBaseClient( boolean expectObfuscatedConfig, @Nullable CompletableFuture initialConfiguration, @Nullable IAssignmentCache assignmentCache, - ConfigurationParser configurationParser, + ConfigurationParser + configurationParser, EppoConfigurationClient configurationClient) { super( apiKey, @@ -114,13 +106,13 @@ protected AndroidBaseClient( */ @SuppressWarnings("unchecked") public static < - ConfigurationType extends SerializableEppoConfiguration, - ConfigurationBuilderType extends SerializableEppoConfiguration.AbstractBuilder< - ConfigurationBuilderType, - ConfigurationType - >, - JsonFlagType - > AndroidBaseClient getInstance() throws NotInitializedException { + ConfigurationType extends SerializableEppoConfiguration, + ConfigurationBuilderType extends + SerializableEppoConfiguration.AbstractBuilder< + ConfigurationBuilderType, ConfigurationType>, + JsonFlagType> + AndroidBaseClient getInstance() + throws NotInitializedException { if (instance == null) { throw new NotInitializedException(); } @@ -136,30 +128,26 @@ > AndroidBaseClient g * @param The JSON type used for JSON flag values */ public abstract static class Builder< - SelfType extends Builder< - SelfType, - AndroidBaseClientType, - ConfigurationType, - ConfigurationBuilderType, - JsonFlagType - >, - AndroidBaseClientType extends AndroidBaseClient< - ConfigurationType, - ConfigurationBuilderType, - JsonFlagType - >, + SelfType extends + Builder< + SelfType, + AndroidBaseClientType, + ConfigurationType, + ConfigurationBuilderType, + JsonFlagType>, + AndroidBaseClientType extends + AndroidBaseClient, ConfigurationType extends SerializableEppoConfiguration, - ConfigurationBuilderType extends SerializableEppoConfiguration.AbstractBuilder< - ConfigurationBuilderType, - ConfigurationType - >, - JsonFlagType - > { + ConfigurationBuilderType extends + SerializableEppoConfiguration.AbstractBuilder< + ConfigurationBuilderType, ConfigurationType>, + JsonFlagType> { // Required parameters protected final Class selfClass; protected final String apiKey; protected final Application application; - protected final ConfigurationParser configurationParser; + protected final ConfigurationParser + configurationParser; protected final CachingConfigurationStore configStore; protected final EppoConfigurationClient configurationClient; @@ -181,8 +169,8 @@ public abstract static class Builder< /** * Creates a new Builder with required parameters. * - * @param selfClass The class of the instance you're instantiating so that builder methods - * can return the right type. This is only for sublcasses. + * @param selfClass The class of the instance you're instantiating so that builder methods can + * return the right type. This is only for sublcasses. * @param apiKey API key for Eppo (required) * @param application Application context (required) * @param configurationParser Parser for configuration JSON (required) @@ -193,7 +181,8 @@ protected Builder( @NotNull Class selfClass, @NotNull String apiKey, @NotNull Application application, - @NotNull ConfigurationParser configurationParser, + @NotNull ConfigurationParser + configurationParser, @NotNull CachingConfigurationStore configStore, @NotNull EppoConfigurationClient configurationClient) { if (selfClass == null) { @@ -291,21 +280,23 @@ public SelfType onConfigurationChange( /** * For subclasses to initialize the proper type + * * @see AndroidBaseClient constructor */ protected abstract AndroidBaseClientType newInstance( - 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); + 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); /** * Builds and initializes the EppoClient asynchronously. @@ -437,11 +428,8 @@ public CompletableFuture buildAndInitAsync() { * * @return The initialized EppoClient */ - public AndroidBaseClient< - ConfigurationType, - ConfigurationBuilderType, - JsonFlagType - > buildAndInit() { + public AndroidBaseClient + buildAndInit() { try { return buildAndInitAsync().get(); } catch (ExecutionException | InterruptedException | CompletionException e) { @@ -453,8 +441,10 @@ > buildAndInit() { if (cause instanceof RuntimeException && cause.getCause() instanceof EppoInitializationException) { @SuppressWarnings("unchecked") - AndroidBaseClient typedInstance = - (AndroidBaseClient) instance; + AndroidBaseClient + typedInstance = + (AndroidBaseClient) + instance; return typedInstance; } } @@ -464,7 +454,8 @@ > buildAndInit() { } } @SuppressWarnings("unchecked") - AndroidBaseClient typedInstance = (AndroidBaseClient) instance; + AndroidBaseClient typedInstance = + (AndroidBaseClient) 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 c3027a7f..faa98531 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 @@ -2,7 +2,6 @@ import cloud.eppo.IConfigurationStore; import cloud.eppo.api.SerializableEppoConfiguration; - import java.util.concurrent.CompletableFuture; import org.jetbrains.annotations.NotNull; @@ -10,9 +9,8 @@ * Abstract config store that keeps an in-memory configuration and can persist it via a {@link * ByteStore} and {@link ConfigurationCodec}. */ -public class CachingConfigurationStore< - ConfigurationType extends SerializableEppoConfiguration -> implements IConfigurationStore { +public class CachingConfigurationStore + implements IConfigurationStore { private final ConfigurationCodec codec; private final ByteStore byteStore; 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 08c3aad2..c991e698 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 @@ -44,6 +44,7 @@ public interface ConfigurationCodec extends CachingConfigurationStore { +public class FileBackedConfigStore + extends CachingConfigurationStore { /** * Creates a FileBackedStore with the specified configuration. @@ -24,10 +21,11 @@ public FileBackedConfigStore( super(codec, createByteStore(application, cacheFileSuffix, codec)); } - private static < - ConfigurationType extends SerializableEppoConfiguration - > ByteStore createByteStore( - Application application, String cacheFileSuffix, ConfigurationCodec 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/test/java/cloud/eppo/android/framework/storage/CachingConfigurationStoreTest.java b/android-sdk-framework/src/test/java/cloud/eppo/android/framework/storage/CachingConfigurationStoreTest.java index bf461c0b..a170d8ce 100644 --- 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 @@ -50,8 +50,7 @@ public void setUp() throws Exception { testedStore = new CachingConfigurationStore<>(spyCodec, mockByteStore); // Parse flags-v1.json from test resources using sdk-common-jvm JacksonConfigurationParser. sampleConfiguration = loadSampleConfigurationFromResource(); - ConfigurationCodec realCodec = - new ConfigurationCodec.Default(); + ConfigurationCodec realCodec = new ConfigurationCodec.Default(); sampleConfigurationBytes = realCodec.toBytes(sampleConfiguration); } 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 index bf2ba404..40df01ae 100644 --- 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 @@ -91,8 +91,7 @@ public void fromBytes_javaSerializedWrongType_throwsRuntimeException() throws Ex fail("Expected RuntimeException (deserialized object is not correct type)"); } catch (RuntimeException e) { assertTrue( - "Exception should mention type mismatch", - e.getMessage().contains("not a Configuration")); + "Exception should mention type mismatch", e.getMessage().contains("not a Configuration")); } } 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 e68a6ec8..7d4d02bd 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 @@ -30,14 +30,16 @@ public void setUp() { @Test public void construct_withValidArgs_succeeds() { - FileBackedConfigStore store = new FileBackedConfigStore<>(application, cacheFileSuffix, codec); + FileBackedConfigStore store = + new FileBackedConfigStore<>(application, cacheFileSuffix, codec); assertNotNull(store); } @Test public void getConfiguration_beforeAnySave_returnsEmptyConfig() { - FileBackedConfigStore store = new FileBackedConfigStore<>(application, cacheFileSuffix, codec); + FileBackedConfigStore store = + new FileBackedConfigStore<>(application, cacheFileSuffix, codec); Configuration config = store.getConfiguration(); @@ -47,7 +49,8 @@ public void getConfiguration_beforeAnySave_returnsEmptyConfig() { @Test public void saveConfiguration_thenGetConfiguration_returnsSavedConfig() throws Exception { - FileBackedConfigStore store = new FileBackedConfigStore<>(application, cacheFileSuffix, codec); + FileBackedConfigStore store = + new FileBackedConfigStore<>(application, cacheFileSuffix, codec); Configuration toSave = Configuration.emptyConfig(); store.saveConfiguration(toSave).get(5, TimeUnit.SECONDS); @@ -57,7 +60,8 @@ public void saveConfiguration_thenGetConfiguration_returnsSavedConfig() throws E @Test public void loadFromStorage_whenNothingSaved_returnsNull() throws Exception { - FileBackedConfigStore store = new FileBackedConfigStore<>(application, cacheFileSuffix, codec); + FileBackedConfigStore store = + new FileBackedConfigStore<>(application, cacheFileSuffix, codec); Configuration loaded = store.loadFromStorage().get(5, TimeUnit.SECONDS); @@ -66,7 +70,8 @@ public void loadFromStorage_whenNothingSaved_returnsNull() throws Exception { @Test public void saveConfiguration_thenLoadFromStorage_returnsSameConfig() throws Exception { - FileBackedConfigStore store = new FileBackedConfigStore<>(application, cacheFileSuffix, codec); + FileBackedConfigStore store = + new FileBackedConfigStore<>(application, cacheFileSuffix, codec); Configuration toSave = Configuration.emptyConfig(); store.saveConfiguration(toSave).get(5, TimeUnit.SECONDS); diff --git a/eppo/src/main/java/cloud/eppo/android/EppoClient.java b/eppo/src/main/java/cloud/eppo/android/EppoClient.java index 06689f5e..cd5d5625 100644 --- a/eppo/src/main/java/cloud/eppo/android/EppoClient.java +++ b/eppo/src/main/java/cloud/eppo/android/EppoClient.java @@ -152,6 +152,7 @@ public static class Builder { // Batteries-included: Allow overriding default implementations @Nullable private ConfigurationParser configurationParser; + @Nullable private EppoConfigurationClient configurationClient; public Builder(@NonNull String apiKey, @NonNull Application application) { @@ -252,7 +253,8 @@ public Builder onConfigurationChange(Consumer configChangeCallbac * @param parser the configuration parser to use * @return this builder */ - public Builder configurationParser(ConfigurationParser parser) { + public Builder configurationParser( + ConfigurationParser parser) { this.configurationParser = parser; return this; } @@ -414,8 +416,7 @@ public EppoClient buildAndInit() { } private CachingConfigurationStore createDefaultConfigStore() { - ConfigurationCodec codec = - new ConfigurationCodec.Default(); + ConfigurationCodec codec = new ConfigurationCodec.Default(); // Cache at a per-API key level (useful for development) return new FileBackedConfigStore<>(application, safeCacheKey(apiKey), codec); diff --git a/eppo/src/main/java/cloud/eppo/android/JacksonConfigurationParser.java b/eppo/src/main/java/cloud/eppo/android/JacksonConfigurationParser.java index f6716f0f..98948bcd 100644 --- a/eppo/src/main/java/cloud/eppo/android/JacksonConfigurationParser.java +++ b/eppo/src/main/java/cloud/eppo/android/JacksonConfigurationParser.java @@ -1,10 +1,8 @@ package cloud.eppo.android; import androidx.annotation.NonNull; - import cloud.eppo.android.dto.adapters.EppoModule; import cloud.eppo.api.Configuration; -import cloud.eppo.api.SerializableEppoConfiguration; import cloud.eppo.api.dto.BanditParametersResponse; import cloud.eppo.api.dto.FlagConfigResponse; import cloud.eppo.parser.ConfigurationParseException; @@ -23,11 +21,8 @@ * format. The deserializers are hand-rolled to avoid reliance on annotations and method names, * which can be unreliable when ProGuard minification is in use. */ -public class JacksonConfigurationParser implements ConfigurationParser< - Configuration, - Configuration.Builder, - JsonNode -> { +public class JacksonConfigurationParser + implements ConfigurationParser { private static final Logger log = LoggerFactory.getLogger(JacksonConfigurationParser.class); private final ObjectMapper objectMapper; @@ -87,15 +82,15 @@ public BanditParametersResponse parseBanditParams(byte[] banditParamsJson) } } - @NonNull - @Override - public Configuration.Builder configurationBuilder(@NotNull FlagConfigResponse flagConfigResponse) { + @NonNull @Override + public Configuration.Builder configurationBuilder( + @NotNull FlagConfigResponse flagConfigResponse) { return new Configuration.Builder(flagConfigResponse); } - @NonNull - @Override - public Configuration.Builder configurationBuilder(@NotNull FlagConfigResponse flagConfigResponse, boolean isConfigObfuscated) { + @NonNull @Override + public Configuration.Builder configurationBuilder( + @NotNull FlagConfigResponse flagConfigResponse, boolean isConfigObfuscated) { return new Configuration.Builder(flagConfigResponse, isConfigObfuscated); } } diff --git a/example/src/main/java/cloud/eppo/androidexample/CustomClientActivity.java b/example/src/main/java/cloud/eppo/androidexample/CustomClientActivity.java index 8909c601..b45c311a 100644 --- a/example/src/main/java/cloud/eppo/androidexample/CustomClientActivity.java +++ b/example/src/main/java/cloud/eppo/androidexample/CustomClientActivity.java @@ -23,19 +23,16 @@ import cloud.eppo.api.EvaluationDetails; import cloud.eppo.http.EppoConfigurationClient; import cloud.eppo.parser.ConfigurationParser; - import com.geteppo.androidexample.BuildConfig; import com.geteppo.androidexample.R; import com.google.gson.JsonElement; - -import org.jetbrains.annotations.NotNull; - import java.text.SimpleDateFormat; import java.util.Date; import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; +import org.jetbrains.annotations.NotNull; /** * Demonstrates supplying custom implementations of both {@link @@ -59,19 +56,23 @@ public class CustomClientActivity extends AppCompatActivity { private AndroidBaseClient client; - private class GsonAndroidBaseClientBuilder extends AndroidBaseClient.Builder< - GsonAndroidBaseClientBuilder, - Configuration, - Configuration.Builder, - JsonElement - > { + private class GsonAndroidBaseClientBuilder + extends AndroidBaseClient.Builder< + GsonAndroidBaseClientBuilder, Configuration, Configuration.Builder, JsonElement> { public GsonAndroidBaseClientBuilder( - @NotNull String apiKey, - @NotNull Application application, - @NotNull ConfigurationParser configurationParser, - @NotNull CachingConfigurationStore configStore, - @NotNull EppoConfigurationClient configurationClient) { - super(GsonAndroidBaseClientBuilder.class, apiKey, application, configurationParser, configStore, configurationClient); + @NotNull String apiKey, + @NotNull Application application, + @NotNull ConfigurationParser + configurationParser, + @NotNull CachingConfigurationStore configStore, + @NotNull EppoConfigurationClient configurationClient) { + super( + GsonAndroidBaseClientBuilder.class, + apiKey, + application, + configurationParser, + configStore, + configurationClient); } } diff --git a/example/src/main/java/cloud/eppo/androidexample/GsonConfigurationCodec.java b/example/src/main/java/cloud/eppo/androidexample/GsonConfigurationCodec.java index 8461442f..e68c2919 100644 --- a/example/src/main/java/cloud/eppo/androidexample/GsonConfigurationCodec.java +++ b/example/src/main/java/cloud/eppo/androidexample/GsonConfigurationCodec.java @@ -1,9 +1,7 @@ package cloud.eppo.androidexample; import android.util.Log; - import androidx.annotation.NonNull; - import cloud.eppo.android.framework.storage.ConfigurationCodec; import cloud.eppo.api.Configuration; import cloud.eppo.api.EppoValue; @@ -104,8 +102,7 @@ public byte[] toBytes(@NotNull Configuration configuration) { return "application/json"; } - @NonNull - @Override + @NonNull @Override @NotNull public Configuration emptyConfiguration() { return Configuration.emptyConfig(); } diff --git a/example/src/main/java/cloud/eppo/androidexample/GsonConfigurationParser.java b/example/src/main/java/cloud/eppo/androidexample/GsonConfigurationParser.java index 00339a2a..30a3a3fc 100644 --- a/example/src/main/java/cloud/eppo/androidexample/GsonConfigurationParser.java +++ b/example/src/main/java/cloud/eppo/androidexample/GsonConfigurationParser.java @@ -3,12 +3,9 @@ import static cloud.eppo.Utils.base64Decode; import android.util.Log; - import androidx.annotation.NonNull; - import cloud.eppo.api.Configuration; import cloud.eppo.api.EppoValue; -import cloud.eppo.api.SerializableEppoConfiguration; import cloud.eppo.api.dto.Allocation; import cloud.eppo.api.dto.BanditCategoricalAttributeCoefficients; import cloud.eppo.api.dto.BanditCoefficients; @@ -34,9 +31,6 @@ import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.google.gson.JsonParser; - -import org.jetbrains.annotations.NotNull; - import java.nio.charset.StandardCharsets; import java.text.ParseException; import java.text.SimpleDateFormat; @@ -51,6 +45,7 @@ import java.util.Set; import java.util.TimeZone; import java.util.concurrent.ConcurrentHashMap; +import org.jetbrains.annotations.NotNull; /** * A GSON-based implementation of {@link ConfigurationParser}. @@ -65,7 +60,8 @@ * to {@code ConfigurationParser}). It is provided here as a reference implementation and * for use with framework clients that are parameterised over {@link JsonElement}. */ -public class GsonConfigurationParser implements ConfigurationParser { +public class GsonConfigurationParser + implements ConfigurationParser { private static final String TAG = GsonConfigurationParser.class.getSimpleName(); public GsonConfigurationParser() {} @@ -114,15 +110,15 @@ public JsonElement parseJsonValue(String jsonValue) throws ConfigurationParseExc } } - @NonNull - @Override - public Configuration.Builder configurationBuilder(@NotNull FlagConfigResponse flagConfigResponse) { + @NonNull @Override + public Configuration.Builder configurationBuilder( + @NotNull FlagConfigResponse flagConfigResponse) { return new Configuration.Builder(flagConfigResponse); } - @NonNull - @Override - public Configuration.Builder configurationBuilder(@NotNull FlagConfigResponse flagConfigResponse, boolean isConfigObfuscated) { + @NonNull @Override + public Configuration.Builder configurationBuilder( + @NotNull FlagConfigResponse flagConfigResponse, boolean isConfigObfuscated) { return new Configuration.Builder(flagConfigResponse, isConfigObfuscated); } From 01b456f6ddfa17e1bb3f6b4b50205c91548ce858 Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Fri, 24 Jul 2026 08:27:41 -0600 Subject: [PATCH 04/10] refactor: update to 2-param ConfigurationParser API from sdk-common-jdk #243 --- .../framework/EppoClientPollingTest.java | 57 +++++++++++++----- .../android/framework/AndroidBaseClient.java | 60 ++++++------------- .../java/cloud/eppo/android/EppoClient.java | 11 ++-- .../android/JacksonConfigurationParser.java | 58 +++++++++++------- .../androidexample/CustomClientActivity.java | 40 +++++++++++-- .../GsonConfigurationParser.java | 59 ++++++++++-------- 6 files changed, 168 insertions(+), 117 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 a962e396..b6ef8d74 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 @@ -33,8 +33,7 @@ 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 ConfigurationParser mockConfigParser; @Mock private EppoConfigurationClient mockConfigClient; @@ -51,11 +50,11 @@ public void setUp() { private static class TestBuilder extends AndroidBaseClient.Builder< - TestBuilder, Configuration, Configuration.Builder, JsonNode> { + TestBuilder, AndroidBaseClient, Configuration, JsonNode> { protected TestBuilder( @NotNull String apiKey, @NotNull Application application, - @NotNull ConfigurationParser configurationParser, + @NotNull ConfigurationParser configurationParser, @NotNull CachingConfigurationStore configStore, @NotNull EppoConfigurationClient configurationClient) { super( @@ -66,6 +65,35 @@ protected TestBuilder( configStore, configurationClient); } + + @Override + protected AndroidBaseClient newInstance( + String apiKey, + String sdkName, + String sdkVersion, + @org.jetbrains.annotations.Nullable String apiBaseUrl, + @org.jetbrains.annotations.Nullable cloud.eppo.logging.AssignmentLogger assignmentLogger, + CachingConfigurationStore configurationStore, + boolean isGracefulMode, + boolean expectObfuscatedConfig, + @org.jetbrains.annotations.Nullable java.util.concurrent.CompletableFuture initialConfiguration, + @org.jetbrains.annotations.Nullable cloud.eppo.api.IAssignmentCache assignmentCache, + cloud.eppo.parser.ConfigurationParser configurationParser, + cloud.eppo.http.EppoConfigurationClient configurationClient) { + return new AndroidBaseClient( + apiKey, + sdkName, + sdkVersion, + apiBaseUrl, + assignmentLogger, + configurationStore, + isGracefulMode, + expectObfuscatedConfig, + initialConfiguration, + assignmentCache, + configurationParser, + configurationClient) {}; + } } /** @@ -74,9 +102,8 @@ protected TestBuilder( * @param pollingIntervalMs Polling interval in milliseconds * @return Initialized EppoClient */ - private AndroidBaseClient - buildOfflineClientWithPolling(long pollingIntervalMs) - throws ExecutionException, InterruptedException { + private AndroidBaseClient buildOfflineClientWithPolling( + long pollingIntervalMs) throws ExecutionException, InterruptedException { // Use an empty configuration for offline mode CompletableFuture initialConfig = CompletableFuture.completedFuture(Configuration.emptyConfig()); @@ -102,8 +129,8 @@ protected TestBuilder( * * @return Initialized EppoClient */ - private AndroidBaseClient - buildOfflineClientWithoutPolling() throws ExecutionException, InterruptedException { + private AndroidBaseClient buildOfflineClientWithoutPolling() + throws ExecutionException, InterruptedException { CompletableFuture initialConfig = CompletableFuture.completedFuture(Configuration.emptyConfig()); @@ -124,7 +151,7 @@ protected TestBuilder( @Test public void testPauseAndResumePolling() throws ExecutionException, InterruptedException { - AndroidBaseClient androidBaseClient = + AndroidBaseClient androidBaseClient = buildOfflineClientWithPolling(100); assertNotNull("Client should be initialized", androidBaseClient); @@ -148,7 +175,7 @@ public void testPauseAndResumePolling() throws ExecutionException, InterruptedEx @Test public void testResumePollingWithoutStarting() throws ExecutionException, InterruptedException { - AndroidBaseClient androidBaseClient = + AndroidBaseClient androidBaseClient = buildOfflineClientWithoutPolling(); assertNotNull("Client should be initialized", androidBaseClient); @@ -164,7 +191,7 @@ public void testResumePollingWithoutStarting() throws ExecutionException, Interr @Test public void testMultiplePauseResumeCycles() throws ExecutionException, InterruptedException { - AndroidBaseClient androidBaseClient = + AndroidBaseClient androidBaseClient = buildOfflineClientWithPolling(100); assertNotNull("Client should be initialized", androidBaseClient); @@ -199,7 +226,7 @@ public void testMultiplePauseResumeCycles() throws ExecutionException, Interrupt @Test public void testPauseResumeSequenceDoesNotCrash() throws ExecutionException, InterruptedException { - AndroidBaseClient androidBaseClient = + AndroidBaseClient androidBaseClient = buildOfflineClientWithPolling(50); // Various sequences that should all work without crashing @@ -222,7 +249,7 @@ public void testPauseResumeSequenceDoesNotCrash() @Test public void testPollingNotEnabledAndResume() throws ExecutionException, InterruptedException { - AndroidBaseClient androidBaseClient = + AndroidBaseClient androidBaseClient = buildOfflineClientWithoutPolling(); // Pause should be safe even if not polling @@ -241,7 +268,7 @@ public void testPollingNotEnabledAndResume() throws ExecutionException, Interrup @Test public void testPauseAfterInitDoesNotCrash() throws ExecutionException, InterruptedException { - AndroidBaseClient androidBaseClient = + AndroidBaseClient androidBaseClient = buildOfflineClientWithPolling(100); // Immediately pause after initialization 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 e67635d2..360160e3 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 @@ -30,12 +30,8 @@ * @param The JSON type used for JSON flag values (e.g., JsonNode, JsonElement) */ public class AndroidBaseClient< - ConfigurationType extends SerializableEppoConfiguration, - ConfigurationBuilderType extends - SerializableEppoConfiguration.AbstractBuilder< - ConfigurationBuilderType, ConfigurationType>, - JsonFlagType> - extends BaseEppoClient { + ConfigurationType extends SerializableEppoConfiguration, JsonFlagType> + 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; @@ -45,7 +41,7 @@ public class AndroidBaseClient< private long pollingIntervalMs; private long pollingJitterMs; - @Nullable private static AndroidBaseClient instance; + @Nullable private static AndroidBaseClient instance; /** * Private constructor. Use Builder to construct instances. @@ -74,8 +70,7 @@ protected AndroidBaseClient( boolean expectObfuscatedConfig, @Nullable CompletableFuture initialConfiguration, @Nullable IAssignmentCache assignmentCache, - ConfigurationParser - configurationParser, + ConfigurationParser configurationParser, EppoConfigurationClient configurationClient) { super( apiKey, @@ -105,18 +100,13 @@ protected AndroidBaseClient( * @param The JSON type parameter */ @SuppressWarnings("unchecked") - public static < - ConfigurationType extends SerializableEppoConfiguration, - ConfigurationBuilderType extends - SerializableEppoConfiguration.AbstractBuilder< - ConfigurationBuilderType, ConfigurationType>, - JsonFlagType> - AndroidBaseClient getInstance() + public static + AndroidBaseClient getInstance() throws NotInitializedException { if (instance == null) { throw new NotInitializedException(); } - return (AndroidBaseClient) instance; + return (AndroidBaseClient) instance; } /** @@ -128,26 +118,15 @@ AndroidBaseClient get * @param The JSON type used for JSON flag values */ public abstract static class Builder< - SelfType extends - Builder< - SelfType, - AndroidBaseClientType, - ConfigurationType, - ConfigurationBuilderType, - JsonFlagType>, - AndroidBaseClientType extends - AndroidBaseClient, + SelfType extends Builder, + AndroidBaseClientType extends AndroidBaseClient, ConfigurationType extends SerializableEppoConfiguration, - ConfigurationBuilderType extends - SerializableEppoConfiguration.AbstractBuilder< - ConfigurationBuilderType, ConfigurationType>, JsonFlagType> { // Required parameters protected final Class selfClass; protected final String apiKey; protected final Application application; - protected final ConfigurationParser - configurationParser; + protected final ConfigurationParser configurationParser; protected final CachingConfigurationStore configStore; protected final EppoConfigurationClient configurationClient; @@ -181,8 +160,7 @@ protected Builder( @NotNull Class selfClass, @NotNull String apiKey, @NotNull Application application, - @NotNull ConfigurationParser - configurationParser, + @NotNull ConfigurationParser configurationParser, @NotNull CachingConfigurationStore configStore, @NotNull EppoConfigurationClient configurationClient) { if (selfClass == null) { @@ -294,8 +272,7 @@ protected abstract AndroidBaseClientType newInstance( boolean expectObfuscatedConfig, @Nullable CompletableFuture initialConfiguration, @Nullable IAssignmentCache assignmentCache, - ConfigurationParser - configurationParser, + ConfigurationParser configurationParser, EppoConfigurationClient configurationClient); /** @@ -428,8 +405,7 @@ public CompletableFuture buildAndInitAsync() { * * @return The initialized EppoClient */ - public AndroidBaseClient - buildAndInit() { + public AndroidBaseClient buildAndInit() { try { return buildAndInitAsync().get(); } catch (ExecutionException | InterruptedException | CompletionException e) { @@ -441,10 +417,8 @@ public CompletableFuture buildAndInitAsync() { if (cause instanceof RuntimeException && cause.getCause() instanceof EppoInitializationException) { @SuppressWarnings("unchecked") - AndroidBaseClient - typedInstance = - (AndroidBaseClient) - instance; + AndroidBaseClient typedInstance = + (AndroidBaseClient) instance; return typedInstance; } } @@ -454,8 +428,8 @@ public CompletableFuture buildAndInitAsync() { } } @SuppressWarnings("unchecked") - AndroidBaseClient typedInstance = - (AndroidBaseClient) instance; + AndroidBaseClient typedInstance = + (AndroidBaseClient) instance; return typedInstance; } } diff --git a/eppo/src/main/java/cloud/eppo/android/EppoClient.java b/eppo/src/main/java/cloud/eppo/android/EppoClient.java index cd5d5625..be243d93 100644 --- a/eppo/src/main/java/cloud/eppo/android/EppoClient.java +++ b/eppo/src/main/java/cloud/eppo/android/EppoClient.java @@ -27,7 +27,7 @@ import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Consumer; -public class EppoClient extends AndroidBaseClient { +public class EppoClient extends AndroidBaseClient { private static final String TAG = logTag(EppoClient.class); private static final boolean DEFAULT_IS_GRACEFUL_MODE = true; private static final boolean DEFAULT_OBFUSCATE_CONFIG = true; @@ -45,7 +45,7 @@ private EppoClient( @Nullable String apiBaseUrl, @Nullable AssignmentLogger assignmentLogger, CachingConfigurationStore configurationStore, - ConfigurationParser configurationParser, + ConfigurationParser configurationParser, EppoConfigurationClient configurationClient, boolean isGracefulMode, boolean expectObfuscatedConfig, @@ -151,7 +151,7 @@ public static class Builder { @Nullable private Consumer configChangeCallback; // Batteries-included: Allow overriding default implementations - @Nullable private ConfigurationParser configurationParser; + @Nullable private ConfigurationParser configurationParser; @Nullable private EppoConfigurationClient configurationClient; @@ -253,8 +253,7 @@ public Builder onConfigurationChange(Consumer configChangeCallbac * @param parser the configuration parser to use * @return this builder */ - public Builder configurationParser( - ConfigurationParser parser) { + public Builder configurationParser(ConfigurationParser parser) { this.configurationParser = parser; return this; } @@ -305,7 +304,7 @@ public CompletableFuture buildAndInitAsync() { } // Create batteries-included implementations (use provided overrides or defaults) - ConfigurationParser parserToUse = + ConfigurationParser parserToUse = this.configurationParser != null ? this.configurationParser : new JacksonConfigurationParser(); diff --git a/eppo/src/main/java/cloud/eppo/android/JacksonConfigurationParser.java b/eppo/src/main/java/cloud/eppo/android/JacksonConfigurationParser.java index 98948bcd..fb52335c 100644 --- a/eppo/src/main/java/cloud/eppo/android/JacksonConfigurationParser.java +++ b/eppo/src/main/java/cloud/eppo/android/JacksonConfigurationParser.java @@ -1,16 +1,20 @@ package cloud.eppo.android; -import androidx.annotation.NonNull; import cloud.eppo.android.dto.adapters.EppoModule; import cloud.eppo.api.Configuration; +import cloud.eppo.api.dto.BanditParameters; import cloud.eppo.api.dto.BanditParametersResponse; +import cloud.eppo.api.dto.BanditReference; import cloud.eppo.api.dto.FlagConfigResponse; import cloud.eppo.parser.ConfigurationParseException; import cloud.eppo.parser.ConfigurationParser; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import java.io.IOException; +import java.util.Set; +import java.util.stream.Collectors; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -21,8 +25,7 @@ * format. The deserializers are hand-rolled to avoid reliance on annotations and method names, * which can be unreliable when ProGuard minification is in use. */ -public class JacksonConfigurationParser - implements ConfigurationParser { +public class JacksonConfigurationParser implements ConfigurationParser { private static final Logger log = LoggerFactory.getLogger(JacksonConfigurationParser.class); private final ObjectMapper objectMapper; @@ -51,22 +54,43 @@ private static ObjectMapper createDefaultObjectMapper() { } @Override - public FlagConfigResponse parseFlagConfig(byte[] flagConfigJson) - throws ConfigurationParseException { + public Configuration buildConfig( + byte[] flagConfigBytes, + @Nullable String flagsSnapshotId, + @Nullable Configuration previousConfig) { try { - log.debug("Parsing flag configuration, {} bytes", flagConfigJson.length); - return objectMapper.readValue(flagConfigJson, FlagConfigResponse.class); + FlagConfigResponse flagConfigResponse = + objectMapper.readValue(flagConfigBytes, FlagConfigResponse.class); + Configuration.Builder builder = new Configuration.Builder(flagConfigResponse); + if (previousConfig != null) { + builder.banditParametersFromConfig(previousConfig); + } + builder.flagsSnapshotId(flagsSnapshotId); + return builder.build(); } catch (IOException e) { throw new ConfigurationParseException("Failed to parse flag configuration", e); } } @Override - public BanditParametersResponse parseBanditParams(byte[] banditParamsJson) - throws ConfigurationParseException { + public boolean requiresUpdatedBanditModels(Configuration config) { + Set neededModelVersions = + config.getBanditReferences().values().stream() + .map(BanditReference::getModelVersion) + .collect(Collectors.toSet()); + Set loadedModelVersions = + config.getBandits().values().stream() + .map(BanditParameters::getModelVersion) + .collect(Collectors.toSet()); + return !loadedModelVersions.containsAll(neededModelVersions); + } + + @Override + public Configuration applyBanditParameters(Configuration config, byte[] banditParamsBytes) { try { - log.debug("Parsing bandit parameters, {} bytes", banditParamsJson.length); - return objectMapper.readValue(banditParamsJson, BanditParametersResponse.class); + BanditParametersResponse response = + objectMapper.readValue(banditParamsBytes, BanditParametersResponse.class); + return config.toBuilder().banditParameters(response).build(); } catch (IOException e) { throw new ConfigurationParseException("Failed to parse bandit parameters", e); } @@ -81,16 +105,4 @@ public BanditParametersResponse parseBanditParams(byte[] banditParamsJson) throw new ConfigurationParseException("Failed to parse JSON value", e); } } - - @NonNull @Override - public Configuration.Builder configurationBuilder( - @NotNull FlagConfigResponse flagConfigResponse) { - return new Configuration.Builder(flagConfigResponse); - } - - @NonNull @Override - public Configuration.Builder configurationBuilder( - @NotNull FlagConfigResponse flagConfigResponse, boolean isConfigObfuscated) { - return new Configuration.Builder(flagConfigResponse, isConfigObfuscated); - } } diff --git a/example/src/main/java/cloud/eppo/androidexample/CustomClientActivity.java b/example/src/main/java/cloud/eppo/androidexample/CustomClientActivity.java index b45c311a..5daa61d3 100644 --- a/example/src/main/java/cloud/eppo/androidexample/CustomClientActivity.java +++ b/example/src/main/java/cloud/eppo/androidexample/CustomClientActivity.java @@ -54,16 +54,18 @@ public class CustomClientActivity extends AppCompatActivity { private TextView assignmentLog; private ScrollView assignmentLogScrollView; - private AndroidBaseClient client; + private AndroidBaseClient client; private class GsonAndroidBaseClientBuilder extends AndroidBaseClient.Builder< - GsonAndroidBaseClientBuilder, Configuration, Configuration.Builder, JsonElement> { + GsonAndroidBaseClientBuilder, + AndroidBaseClient, + Configuration, + JsonElement> { public GsonAndroidBaseClientBuilder( @NotNull String apiKey, @NotNull Application application, - @NotNull ConfigurationParser - configurationParser, + @NotNull ConfigurationParser configurationParser, @NotNull CachingConfigurationStore configStore, @NotNull EppoConfigurationClient configurationClient) { super( @@ -74,6 +76,36 @@ public GsonAndroidBaseClientBuilder( configStore, configurationClient); } + + @Override + protected AndroidBaseClient newInstance( + String apiKey, + String sdkName, + String sdkVersion, + @org.jetbrains.annotations.Nullable String apiBaseUrl, + @org.jetbrains.annotations.Nullable cloud.eppo.logging.AssignmentLogger assignmentLogger, + cloud.eppo.android.framework.storage.CachingConfigurationStore + configurationStore, + boolean isGracefulMode, + boolean expectObfuscatedConfig, + @org.jetbrains.annotations.Nullable java.util.concurrent.CompletableFuture initialConfiguration, + @org.jetbrains.annotations.Nullable cloud.eppo.api.IAssignmentCache assignmentCache, + cloud.eppo.parser.ConfigurationParser configurationParser, + cloud.eppo.http.EppoConfigurationClient configurationClient) { + return new AndroidBaseClient( + apiKey, + sdkName, + sdkVersion, + apiBaseUrl, + assignmentLogger, + configurationStore, + isGracefulMode, + expectObfuscatedConfig, + initialConfiguration, + assignmentCache, + configurationParser, + configurationClient) {}; + } } @Override diff --git a/example/src/main/java/cloud/eppo/androidexample/GsonConfigurationParser.java b/example/src/main/java/cloud/eppo/androidexample/GsonConfigurationParser.java index 30a3a3fc..585d1be1 100644 --- a/example/src/main/java/cloud/eppo/androidexample/GsonConfigurationParser.java +++ b/example/src/main/java/cloud/eppo/androidexample/GsonConfigurationParser.java @@ -3,7 +3,6 @@ import static cloud.eppo.Utils.base64Decode; import android.util.Log; -import androidx.annotation.NonNull; import cloud.eppo.api.Configuration; import cloud.eppo.api.EppoValue; import cloud.eppo.api.dto.Allocation; @@ -45,7 +44,6 @@ import java.util.Set; import java.util.TimeZone; import java.util.concurrent.ConcurrentHashMap; -import org.jetbrains.annotations.NotNull; /** * A GSON-based implementation of {@link ConfigurationParser}. @@ -60,8 +58,7 @@ * to {@code ConfigurationParser}). It is provided here as a reference implementation and * for use with framework clients that are parameterised over {@link JsonElement}. */ -public class GsonConfigurationParser - implements ConfigurationParser { +public class GsonConfigurationParser implements ConfigurationParser { private static final String TAG = GsonConfigurationParser.class.getSimpleName(); public GsonConfigurationParser() {} @@ -77,25 +74,47 @@ public GsonConfigurationParser() {} // ===== ConfigurationParser interface ===== @Override - public FlagConfigResponse parseFlagConfig(byte[] flagConfigJson) - throws ConfigurationParseException { + public Configuration buildConfig( + byte[] flagConfigBytes, + @org.jetbrains.annotations.Nullable String flagsSnapshotId, + @org.jetbrains.annotations.Nullable Configuration previousConfig) { try { - Log.d(TAG, "Parsing flag configuration, " + flagConfigJson.length + " bytes"); - JsonElement root = JsonParser.parseString(new String(flagConfigJson, StandardCharsets.UTF_8)); - return deserializeFlagConfigResponse(root); + Log.d(TAG, "Parsing flag configuration, " + flagConfigBytes.length + " bytes"); + JsonElement root = + JsonParser.parseString(new String(flagConfigBytes, StandardCharsets.UTF_8)); + FlagConfigResponse flagConfigResponse = deserializeFlagConfigResponse(root); + Configuration.Builder builder = new Configuration.Builder(flagConfigResponse); + if (previousConfig != null) { + builder.banditParametersFromConfig(previousConfig); + } + builder.flagsSnapshotId(flagsSnapshotId); + return builder.build(); } catch (Exception e) { throw new ConfigurationParseException("Failed to parse flag configuration", e); } } @Override - public BanditParametersResponse parseBanditParams(byte[] banditParamsJson) - throws ConfigurationParseException { + public boolean requiresUpdatedBanditModels(Configuration config) { + Set neededModelVersions = new HashSet<>(); + for (BanditReference ref : config.getBanditReferences().values()) { + neededModelVersions.add(ref.getModelVersion()); + } + Set loadedModelVersions = new HashSet<>(); + for (BanditParameters params : config.getBandits().values()) { + loadedModelVersions.add(params.getModelVersion()); + } + return !loadedModelVersions.containsAll(neededModelVersions); + } + + @Override + public Configuration applyBanditParameters(Configuration config, byte[] banditParamsBytes) { try { - Log.d(TAG, "Parsing bandit parameters, " + banditParamsJson.length + " bytes"); + Log.d(TAG, "Parsing bandit parameters, " + banditParamsBytes.length + " bytes"); JsonElement root = - JsonParser.parseString(new String(banditParamsJson, StandardCharsets.UTF_8)); - return deserializeBanditParametersResponse(root); + JsonParser.parseString(new String(banditParamsBytes, StandardCharsets.UTF_8)); + BanditParametersResponse response = deserializeBanditParametersResponse(root); + return config.toBuilder().banditParameters(response).build(); } catch (Exception e) { throw new ConfigurationParseException("Failed to parse bandit parameters", e); } @@ -110,18 +129,6 @@ public JsonElement parseJsonValue(String jsonValue) throws ConfigurationParseExc } } - @NonNull @Override - public Configuration.Builder configurationBuilder( - @NotNull FlagConfigResponse flagConfigResponse) { - return new Configuration.Builder(flagConfigResponse); - } - - @NonNull @Override - public Configuration.Builder configurationBuilder( - @NotNull FlagConfigResponse flagConfigResponse, boolean isConfigObfuscated) { - return new Configuration.Builder(flagConfigResponse, isConfigObfuscated); - } - // ===== Flag configuration ===== private FlagConfigResponse deserializeFlagConfigResponse(JsonElement element) { From b4ceb9255a792adcee2f3f09f6903ec818fa96b6 Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Fri, 24 Jul 2026 09:50:40 -0600 Subject: [PATCH 05/10] fix: update to new SNAPSHOT API (ShardRange interface + 2-param ConfigurationParser) --- .../android/dto/adapters/FlagConfigResponseDeserializer.java | 4 ++-- .../cloud/eppo/androidexample/GsonConfigurationParser.java | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/eppo/src/main/java/cloud/eppo/android/dto/adapters/FlagConfigResponseDeserializer.java b/eppo/src/main/java/cloud/eppo/android/dto/adapters/FlagConfigResponseDeserializer.java index 8b7a3f59..8a319384 100644 --- a/eppo/src/main/java/cloud/eppo/android/dto/adapters/FlagConfigResponseDeserializer.java +++ b/eppo/src/main/java/cloud/eppo/android/dto/adapters/FlagConfigResponseDeserializer.java @@ -10,12 +10,12 @@ import cloud.eppo.api.dto.FlagConfigResponse; import cloud.eppo.api.dto.OperatorType; import cloud.eppo.api.dto.Shard; +import cloud.eppo.api.dto.ShardRange; 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.fasterxml.jackson.core.JsonParser; import com.fasterxml.jackson.databind.DeserializationContext; import com.fasterxml.jackson.databind.JsonNode; @@ -219,7 +219,7 @@ private Set deserializeShards(JsonNode jsonNode) { for (JsonNode rangeNode : shardNode.get("ranges")) { int start = rangeNode.get("start").asInt(); int end = rangeNode.get("end").asInt(); - ranges.add(new ShardRange(start, end)); + ranges.add(new ShardRange.Default(start, end)); } shards.add(new Shard.Default(salt, ranges)); } diff --git a/example/src/main/java/cloud/eppo/androidexample/GsonConfigurationParser.java b/example/src/main/java/cloud/eppo/androidexample/GsonConfigurationParser.java index 585d1be1..9f15f335 100644 --- a/example/src/main/java/cloud/eppo/androidexample/GsonConfigurationParser.java +++ b/example/src/main/java/cloud/eppo/androidexample/GsonConfigurationParser.java @@ -18,12 +18,12 @@ import cloud.eppo.api.dto.FlagConfigResponse; import cloud.eppo.api.dto.OperatorType; import cloud.eppo.api.dto.Shard; +import cloud.eppo.api.dto.ShardRange; 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 cloud.eppo.parser.ConfigurationParseException; import cloud.eppo.parser.ConfigurationParser; import com.google.gson.JsonArray; @@ -292,7 +292,7 @@ private Set deserializeShards(JsonElement element) { JsonObject range = rangeElement.getAsJsonObject(); int start = range.get("start").getAsInt(); int end = range.get("end").getAsInt(); - ranges.add(new ShardRange(start, end)); + ranges.add(new ShardRange.Default(start, end)); } } shards.add(new Shard.Default(salt, ranges)); From 97819074b1acb0858d92ec26181f4e249f3b1e74 Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Fri, 24 Jul 2026 10:20:34 -0600 Subject: [PATCH 06/10] fix: update GsonConfigurationCodec to use ShardRange interface --- .../cloud/eppo/androidexample/GsonConfigurationCodec.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/example/src/main/java/cloud/eppo/androidexample/GsonConfigurationCodec.java b/example/src/main/java/cloud/eppo/androidexample/GsonConfigurationCodec.java index e68c2919..b1467240 100644 --- a/example/src/main/java/cloud/eppo/androidexample/GsonConfigurationCodec.java +++ b/example/src/main/java/cloud/eppo/androidexample/GsonConfigurationCodec.java @@ -23,7 +23,7 @@ import cloud.eppo.api.dto.TargetingRule; import cloud.eppo.api.dto.Variation; import cloud.eppo.api.dto.VariationType; -import cloud.eppo.model.ShardRange; +import cloud.eppo.api.dto.ShardRange; import com.google.gson.JsonArray; import com.google.gson.JsonElement; import com.google.gson.JsonNull; @@ -549,7 +549,7 @@ private Set deserializeShards(JsonElement element) { 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())); + ranges.add(new ShardRange.Default(range.get("start").getAsInt(), range.get("end").getAsInt())); } } shards.add(new Shard.Default(salt, ranges)); From d98c20c163735054f100a81368309d37d85a6cbe Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Tue, 18 Aug 2026 13:01:53 -0600 Subject: [PATCH 07/10] chore: apply spotless formatting to example module --- .../cloud/eppo/androidexample/GsonConfigurationCodec.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/example/src/main/java/cloud/eppo/androidexample/GsonConfigurationCodec.java b/example/src/main/java/cloud/eppo/androidexample/GsonConfigurationCodec.java index b1467240..4ddee58f 100644 --- a/example/src/main/java/cloud/eppo/androidexample/GsonConfigurationCodec.java +++ b/example/src/main/java/cloud/eppo/androidexample/GsonConfigurationCodec.java @@ -18,12 +18,12 @@ import cloud.eppo.api.dto.FlagConfigResponse; import cloud.eppo.api.dto.OperatorType; import cloud.eppo.api.dto.Shard; +import cloud.eppo.api.dto.ShardRange; 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.api.dto.ShardRange; import com.google.gson.JsonArray; import com.google.gson.JsonElement; import com.google.gson.JsonNull; @@ -549,7 +549,8 @@ private Set deserializeShards(JsonElement element) { if (rangesEl != null && rangesEl.isJsonArray()) { for (JsonElement rangeEl : rangesEl.getAsJsonArray()) { JsonObject range = rangeEl.getAsJsonObject(); - ranges.add(new ShardRange.Default(range.get("start").getAsInt(), range.get("end").getAsInt())); + ranges.add( + new ShardRange.Default(range.get("start").getAsInt(), range.get("end").getAsInt())); } } shards.add(new Shard.Default(salt, ranges)); From bbb670de06c8a7b2eb08c26fb80b74aadf563c43 Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Thu, 20 Aug 2026 11:55:19 -0600 Subject: [PATCH 08/10] refactor: update ConfigurationParser to post-#243 interface Adapt JacksonConfigurationParser and GsonConfigurationParser to the new 2-param ConfigurationParser interface from sdk-common-jdk #243. Old methods removed: buildConfig(byte[], ...), requiresUpdatedBanditModels, applyBanditParameters. New methods: parseFlagConfig(byte[]) and buildConfig(FlagConfigResponse, snapshotId, previousConfig, banditParamsBytes). Bump dependencies to release versions (eppo-sdk-framework 0.1.0, sdk-common-jvm 4.0.0) matching core main post-#243. --- android-sdk-framework/build.gradle | 4 +- eppo/build.gradle | 2 +- .../android/JacksonConfigurationParser.java | 62 +++++++------------ .../GsonConfigurationParser.java | 56 +++++++---------- 4 files changed, 49 insertions(+), 75 deletions(-) diff --git a/android-sdk-framework/build.gradle b/android-sdk-framework/build.gradle index bbaba380..eff69886 100644 --- a/android-sdk-framework/build.gradle +++ b/android-sdk-framework/build.gradle @@ -47,13 +47,13 @@ android { } dependencies { - api 'cloud.eppo:eppo-sdk-framework:0.1.0-SNAPSHOT' + api 'cloud.eppo:eppo-sdk-framework:0.1.0' 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 'cloud.eppo:sdk-common-jvm:4.0.0' testImplementation 'junit:junit:4.13.2' testImplementation 'org.mockito:mockito-core:5.14.2' testImplementation 'org.robolectric:robolectric:4.12.1' diff --git a/eppo/build.gradle b/eppo/build.gradle index 279b5200..8ed9b915 100644 --- a/eppo/build.gradle +++ b/eppo/build.gradle @@ -69,7 +69,7 @@ ext.versions = [ dependencies { api project(':android-sdk-framework') - api 'cloud.eppo:sdk-common-jvm:4.0.0-SNAPSHOT' + api 'cloud.eppo:sdk-common-jvm:4.0.0' implementation 'org.slf4j:slf4j-api:2.0.17' diff --git a/eppo/src/main/java/cloud/eppo/android/JacksonConfigurationParser.java b/eppo/src/main/java/cloud/eppo/android/JacksonConfigurationParser.java index fb52335c..81288fc8 100644 --- a/eppo/src/main/java/cloud/eppo/android/JacksonConfigurationParser.java +++ b/eppo/src/main/java/cloud/eppo/android/JacksonConfigurationParser.java @@ -2,21 +2,15 @@ import cloud.eppo.android.dto.adapters.EppoModule; import cloud.eppo.api.Configuration; -import cloud.eppo.api.dto.BanditParameters; import cloud.eppo.api.dto.BanditParametersResponse; -import cloud.eppo.api.dto.BanditReference; import cloud.eppo.api.dto.FlagConfigResponse; import cloud.eppo.parser.ConfigurationParseException; import cloud.eppo.parser.ConfigurationParser; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import java.io.IOException; -import java.util.Set; -import java.util.stream.Collectors; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; /** * Default implementation of {@link ConfigurationParser} using Jackson. @@ -26,8 +20,6 @@ * which can be unreliable when ProGuard minification is in use. */ public class JacksonConfigurationParser implements ConfigurationParser { - private static final Logger log = LoggerFactory.getLogger(JacksonConfigurationParser.class); - private final ObjectMapper objectMapper; /** Creates a new parser with the default ObjectMapper configuration. */ @@ -54,46 +46,36 @@ private static ObjectMapper createDefaultObjectMapper() { } @Override - public Configuration buildConfig( - byte[] flagConfigBytes, - @Nullable String flagsSnapshotId, - @Nullable Configuration previousConfig) { + public @NotNull FlagConfigResponse parseFlagConfig(@NotNull byte[] flagConfigBytes) + throws ConfigurationParseException { try { - FlagConfigResponse flagConfigResponse = - objectMapper.readValue(flagConfigBytes, FlagConfigResponse.class); - Configuration.Builder builder = new Configuration.Builder(flagConfigResponse); - if (previousConfig != null) { - builder.banditParametersFromConfig(previousConfig); - } - builder.flagsSnapshotId(flagsSnapshotId); - return builder.build(); + return objectMapper.readValue(flagConfigBytes, FlagConfigResponse.class); } catch (IOException e) { throw new ConfigurationParseException("Failed to parse flag configuration", e); } } @Override - public boolean requiresUpdatedBanditModels(Configuration config) { - Set neededModelVersions = - config.getBanditReferences().values().stream() - .map(BanditReference::getModelVersion) - .collect(Collectors.toSet()); - Set loadedModelVersions = - config.getBandits().values().stream() - .map(BanditParameters::getModelVersion) - .collect(Collectors.toSet()); - return !loadedModelVersions.containsAll(neededModelVersions); - } - - @Override - public Configuration applyBanditParameters(Configuration config, byte[] banditParamsBytes) { - try { - BanditParametersResponse response = - objectMapper.readValue(banditParamsBytes, BanditParametersResponse.class); - return config.toBuilder().banditParameters(response).build(); - } catch (IOException e) { - throw new ConfigurationParseException("Failed to parse bandit parameters", e); + public @NotNull Configuration buildConfig( + @NotNull FlagConfigResponse flags, + @Nullable String flagsSnapshotId, + @Nullable Configuration previousConfig, + @Nullable byte[] banditParamsBytes) { + Configuration.Builder builder = new Configuration.Builder(flags); + if (previousConfig != null) { + builder.banditParametersFromConfig(previousConfig); + } + if (banditParamsBytes != null) { + try { + BanditParametersResponse response = + objectMapper.readValue(banditParamsBytes, BanditParametersResponse.class); + builder.banditParameters(response); + } catch (IOException e) { + throw new ConfigurationParseException("Failed to parse bandit parameters", e); + } } + builder.flagsSnapshotId(flagsSnapshotId); + return builder.build(); } @Override diff --git a/example/src/main/java/cloud/eppo/androidexample/GsonConfigurationParser.java b/example/src/main/java/cloud/eppo/androidexample/GsonConfigurationParser.java index 9f15f335..2afebd40 100644 --- a/example/src/main/java/cloud/eppo/androidexample/GsonConfigurationParser.java +++ b/example/src/main/java/cloud/eppo/androidexample/GsonConfigurationParser.java @@ -74,50 +74,42 @@ public GsonConfigurationParser() {} // ===== ConfigurationParser interface ===== @Override - public Configuration buildConfig( - byte[] flagConfigBytes, - @org.jetbrains.annotations.Nullable String flagsSnapshotId, - @org.jetbrains.annotations.Nullable Configuration previousConfig) { + public @org.jetbrains.annotations.NotNull FlagConfigResponse parseFlagConfig( + @org.jetbrains.annotations.NotNull byte[] flagConfigBytes) + throws ConfigurationParseException { try { Log.d(TAG, "Parsing flag configuration, " + flagConfigBytes.length + " bytes"); JsonElement root = JsonParser.parseString(new String(flagConfigBytes, StandardCharsets.UTF_8)); - FlagConfigResponse flagConfigResponse = deserializeFlagConfigResponse(root); - Configuration.Builder builder = new Configuration.Builder(flagConfigResponse); - if (previousConfig != null) { - builder.banditParametersFromConfig(previousConfig); - } - builder.flagsSnapshotId(flagsSnapshotId); - return builder.build(); + return deserializeFlagConfigResponse(root); } catch (Exception e) { throw new ConfigurationParseException("Failed to parse flag configuration", e); } } @Override - public boolean requiresUpdatedBanditModels(Configuration config) { - Set neededModelVersions = new HashSet<>(); - for (BanditReference ref : config.getBanditReferences().values()) { - neededModelVersions.add(ref.getModelVersion()); - } - Set loadedModelVersions = new HashSet<>(); - for (BanditParameters params : config.getBandits().values()) { - loadedModelVersions.add(params.getModelVersion()); + public @org.jetbrains.annotations.NotNull Configuration buildConfig( + @org.jetbrains.annotations.NotNull FlagConfigResponse flags, + @org.jetbrains.annotations.Nullable String flagsSnapshotId, + @org.jetbrains.annotations.Nullable Configuration previousConfig, + @org.jetbrains.annotations.Nullable byte[] banditParamsBytes) { + Configuration.Builder builder = new Configuration.Builder(flags); + if (previousConfig != null) { + builder.banditParametersFromConfig(previousConfig); } - return !loadedModelVersions.containsAll(neededModelVersions); - } - - @Override - public Configuration applyBanditParameters(Configuration config, byte[] banditParamsBytes) { - try { - Log.d(TAG, "Parsing bandit parameters, " + banditParamsBytes.length + " bytes"); - JsonElement root = - JsonParser.parseString(new String(banditParamsBytes, StandardCharsets.UTF_8)); - BanditParametersResponse response = deserializeBanditParametersResponse(root); - return config.toBuilder().banditParameters(response).build(); - } catch (Exception e) { - throw new ConfigurationParseException("Failed to parse bandit parameters", e); + if (banditParamsBytes != null) { + try { + Log.d(TAG, "Parsing bandit parameters, " + banditParamsBytes.length + " bytes"); + JsonElement root = + JsonParser.parseString(new String(banditParamsBytes, StandardCharsets.UTF_8)); + BanditParametersResponse response = deserializeBanditParametersResponse(root); + builder.banditParameters(response); + } catch (Exception e) { + throw new ConfigurationParseException("Failed to parse bandit parameters", e); + } } + builder.flagsSnapshotId(flagsSnapshotId); + return builder.build(); } @Override From 8b6dab1fbfba2fdfd1e850f1baaf4b03d4c9a75e Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Thu, 20 Aug 2026 12:20:52 -0600 Subject: [PATCH 09/10] feat: add subscribe/unsubscribe to CachingConfigurationStore Implement IConfigurationStore subscriber methods from sdk-common-jdk #248. Subscribers are notified after saveConfiguration updates the in-memory cache. Uses CopyOnWriteArrayList for thread safety. This enables BaseEppoClient.onConfigurationChange to delegate to the store (sdk-common-jdk #252) and supports setConfiguration. --- .../storage/CachingConfigurationStore.java | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) 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 faa98531..27d60a77 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 @@ -2,7 +2,10 @@ import cloud.eppo.IConfigurationStore; import cloud.eppo.api.SerializableEppoConfiguration; +import java.util.List; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.function.Consumer; import org.jetbrains.annotations.NotNull; /** @@ -15,6 +18,7 @@ public class CachingConfigurationStore codec; private final ByteStore byteStore; private volatile ConfigurationType configuration; + private final List> subscribers = new CopyOnWriteArrayList<>(); protected CachingConfigurationStore( @NotNull ConfigurationCodec codec, @NotNull ByteStore byteStore) { @@ -47,9 +51,23 @@ protected CachingConfigurationStore( .thenRun( () -> { this.configuration = config; + for (Consumer subscriber : subscribers) { + subscriber.accept(config); + } }); } + @Override + public Runnable subscribe(Consumer callback) { + subscribers.add(callback); + return () -> subscribers.remove(callback); + } + + @Override + public boolean unsubscribe(Consumer callback) { + return subscribers.remove(callback); + } + /** * Loads the configuration from storage without updating the in-memory cache. * From 02f814c5373cb38ca942c68e805edb4001a617e0 Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Thu, 20 Aug 2026 13:01:28 -0600 Subject: [PATCH 10/10] refactor: extend AbstractConfigurationStore for subscriber management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace hand-rolled subscribe/unsubscribe with AbstractConfigurationStore from sdk-common-jdk #248. CachingConfigurationStore now implements persist() instead of saveConfiguration() — base class handles callback notification via CallbackManager. --- .../storage/CachingConfigurationStore.java | 44 ++++--------------- 1 file changed, 8 insertions(+), 36 deletions(-) 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 27d60a77..0108fa71 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 @@ -1,24 +1,22 @@ package cloud.eppo.android.framework.storage; -import cloud.eppo.IConfigurationStore; +import cloud.eppo.AbstractConfigurationStore; import cloud.eppo.api.SerializableEppoConfiguration; -import java.util.List; import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CopyOnWriteArrayList; -import java.util.function.Consumer; 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}. + * Config store that keeps an in-memory configuration and persists it via a {@link ByteStore} and + * {@link ConfigurationCodec}. + * + *

Subscriber notification is handled by {@link AbstractConfigurationStore}. */ public class CachingConfigurationStore - implements IConfigurationStore { + extends AbstractConfigurationStore { private final ConfigurationCodec codec; private final ByteStore byteStore; private volatile ConfigurationType configuration; - private final List> subscribers = new CopyOnWriteArrayList<>(); protected CachingConfigurationStore( @NotNull ConfigurationCodec codec, @NotNull ByteStore byteStore) { @@ -33,39 +31,13 @@ protected CachingConfigurationStore( 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 ConfigurationType config) { + protected CompletableFuture persist(@NotNull ConfigurationType 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; - for (Consumer subscriber : subscribers) { - subscriber.accept(config); - } - }); - } - - @Override - public Runnable subscribe(Consumer callback) { - subscribers.add(callback); - return () -> subscribers.remove(callback); - } - - @Override - public boolean unsubscribe(Consumer callback) { - return subscribers.remove(callback); + return byteStore.write(bytes).thenRun(() -> this.configuration = config); } /**