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/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..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 @@ -1,16 +1,22 @@ 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 +33,86 @@ 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, AndroidBaseClient, Configuration, 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); + } + + @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) {}; + } + } + /** * Builds a client in offline mode with polling enabled. * * @param pollingIntervalMs Polling interval in milliseconds * @return Initialized EppoClient */ - private AndroidBaseClient buildOfflineClientWithPolling(long pollingIntervalMs) - throws ExecutionException, InterruptedException { + 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 +129,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 +151,8 @@ 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 +175,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) @@ -126,7 +191,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 @@ -160,7 +226,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(); @@ -182,7 +249,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(); @@ -200,7 +268,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 18267540..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 @@ -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,10 +8,8 @@ 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; import cloud.eppo.logging.AssignmentLogger; import cloud.eppo.parser.ConfigurationParser; @@ -32,7 +29,9 @@ * * @param The JSON type used for JSON flag values (e.g., JsonNode, JsonElement) */ -public class AndroidBaseClient extends BaseEppoClient { +public class AndroidBaseClient< + 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; @@ -42,7 +41,7 @@ public class AndroidBaseClient extends BaseEppoClient instance; + @Nullable private static AndroidBaseClient instance; /** * Private constructor. Use Builder to construct instances. @@ -66,12 +65,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 +95,18 @@ 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 + AndroidBaseClient getInstance() + throws NotInitializedException { if (instance == null) { throw new NotInitializedException(); } - return (AndroidBaseClient) instance; + return (AndroidBaseClient) instance; } /** @@ -114,120 +117,164 @@ 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, + AndroidBaseClientType extends AndroidBaseClient, + ConfigurationType extends SerializableEppoConfiguration, + 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); } + /** + * 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. * @@ -245,12 +292,12 @@ public Builder onConfigurationChange( * * @return CompletableFuture that completes with the initialized EppoClient */ - public CompletableFuture> buildAndInitAsync() { + public CompletableFuture buildAndInitAsync() { // Singleton handling if (instance != null && !forceReinitialize) { Log.w(TAG, "Eppo Client instance already initialized"); @SuppressWarnings("unchecked") - AndroidBaseClient typedInstance = (AndroidBaseClient) instance; + AndroidBaseClientType typedInstance = (AndroidBaseClientType) instance; return CompletableFuture.completedFuture(typedInstance); } else if (instance != null) { // Stop polling if reinitializing @@ -261,22 +308,14 @@ 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 = - new AndroidBaseClient<>( + AndroidBaseClientType newInstance = + newInstance( apiKey, sdkName, sdkVersion, @@ -298,7 +337,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 +405,7 @@ public CompletableFuture> buildAndInitAsync() { * * @return The initialized EppoClient */ - public AndroidBaseClient buildAndInit() { + public AndroidBaseClient buildAndInit() { try { return buildAndInitAsync().get(); } catch (ExecutionException | InterruptedException | CompletionException e) { @@ -378,8 +417,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 +428,8 @@ 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..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,51 +1,43 @@ package cloud.eppo.android.framework.storage; -import cloud.eppo.IConfigurationStore; -import cloud.eppo.api.Configuration; +import cloud.eppo.AbstractConfigurationStore; +import cloud.eppo.api.SerializableEppoConfiguration; import java.util.concurrent.CompletableFuture; import org.jetbrains.annotations.NotNull; /** - * Abstract config store that keeps an in-memory configuration and can persist it via a {@link - * ByteStore} and {@link ConfigurationCodec}. + * 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 { +public class CachingConfigurationStore + extends AbstractConfigurationStore { - 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; } - /** - * Saves the configuration to storage and updates the in-memory cache. - * - * @param config the configuration to save (must not be null) - * @return a future that completes when the write finishes - * @throws IllegalArgumentException if config is null - */ @Override - @NotNull public CompletableFuture saveConfiguration(@NotNull Configuration config) { + 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; - }); + return byteStore.write(bytes).thenRun(() -> this.configuration = config); } /** @@ -54,7 +46,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..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 @@ -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,13 @@ 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 +56,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 +75,17 @@ 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() - + ": " - + obj.getClass().getName()); + "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..ae40bf83 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 @@ -1,10 +1,11 @@ package cloud.eppo.android.framework.storage; 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 + extends CachingConfigurationStore { /** * Creates a FileBackedStore with the specified configuration. @@ -16,12 +17,15 @@ 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 + 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..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 @@ -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,11 @@ 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); + 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 f154cf84..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 @@ -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 @@ -91,15 +91,13 @@ 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 SerializableEppoConfiguration")); + "Exception should mention type mismatch", 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..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 @@ -24,20 +24,22 @@ 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 +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/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/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..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; @@ -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,8 @@ 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 +205,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 +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; } @@ -303,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(); @@ -413,12 +414,11 @@ public EppoClient buildAndInit() { return instance; } - private CachingConfigurationStore createDefaultConfigStore() { - ConfigurationCodec codec = - new ConfigurationCodec.Default<>(Configuration.class); + private CachingConfigurationStore createDefaultConfigStore() { + ConfigurationCodec codec = 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..81288fc8 100644 --- a/eppo/src/main/java/cloud/eppo/android/JacksonConfigurationParser.java +++ b/eppo/src/main/java/cloud/eppo/android/JacksonConfigurationParser.java @@ -1,6 +1,7 @@ package cloud.eppo.android; import cloud.eppo.android.dto.adapters.EppoModule; +import cloud.eppo.api.Configuration; import cloud.eppo.api.dto.BanditParametersResponse; import cloud.eppo.api.dto.FlagConfigResponse; import cloud.eppo.parser.ConfigurationParseException; @@ -9,8 +10,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import java.io.IOException; import org.jetbrains.annotations.NotNull; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import org.jetbrains.annotations.Nullable; /** * Default implementation of {@link ConfigurationParser} using Jackson. @@ -19,9 +19,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 { - private static final Logger log = LoggerFactory.getLogger(JacksonConfigurationParser.class); - +public class JacksonConfigurationParser implements ConfigurationParser { private final ObjectMapper objectMapper; /** Creates a new parser with the default ObjectMapper configuration. */ @@ -48,25 +46,36 @@ private static ObjectMapper createDefaultObjectMapper() { } @Override - public FlagConfigResponse parseFlagConfig(byte[] flagConfigJson) + public @NotNull FlagConfigResponse parseFlagConfig(@NotNull byte[] flagConfigBytes) throws ConfigurationParseException { try { - log.debug("Parsing flag configuration, {} bytes", flagConfigJson.length); - return objectMapper.readValue(flagConfigJson, FlagConfigResponse.class); + return objectMapper.readValue(flagConfigBytes, FlagConfigResponse.class); } catch (IOException e) { throw new ConfigurationParseException("Failed to parse flag configuration", e); } } @Override - public BanditParametersResponse parseBanditParams(byte[] banditParamsJson) - throws ConfigurationParseException { - try { - log.debug("Parsing bandit parameters, {} bytes", banditParamsJson.length); - return objectMapper.readValue(banditParamsJson, BanditParametersResponse.class); - } 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/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/CustomClientActivity.java b/example/src/main/java/cloud/eppo/androidexample/CustomClientActivity.java index 6d597499..5daa61d3 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,12 +13,16 @@ 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; @@ -27,6 +32,7 @@ import java.util.List; import java.util.Locale; import java.util.Map; +import org.jetbrains.annotations.NotNull; /** * Demonstrates supplying custom implementations of both {@link @@ -48,7 +54,59 @@ public class CustomClientActivity extends AppCompatActivity { private TextView assignmentLog; private ScrollView assignmentLogScrollView; - private AndroidBaseClient client; + private AndroidBaseClient client; + + private class GsonAndroidBaseClientBuilder + extends AndroidBaseClient.Builder< + GsonAndroidBaseClientBuilder, + AndroidBaseClient, + Configuration, + 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 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 protected void onCreate(Bundle savedInstanceState) { @@ -72,18 +130,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..4ddee58f 100644 --- a/example/src/main/java/cloud/eppo/androidexample/GsonConfigurationCodec.java +++ b/example/src/main/java/cloud/eppo/androidexample/GsonConfigurationCodec.java @@ -1,6 +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; @@ -17,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 com.google.gson.JsonArray; import com.google.gson.JsonElement; import com.google.gson.JsonNull; @@ -101,6 +102,11 @@ public byte[] toBytes(@NotNull Configuration configuration) { return "application/json"; } + @NonNull @Override + @NotNull public Configuration emptyConfiguration() { + return Configuration.emptyConfig(); + } + // ===== Serialization ===== @SuppressWarnings("unchecked") @@ -543,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(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)); diff --git a/example/src/main/java/cloud/eppo/androidexample/GsonConfigurationParser.java b/example/src/main/java/cloud/eppo/androidexample/GsonConfigurationParser.java index f6e584d7..2afebd40 100644 --- a/example/src/main/java/cloud/eppo/androidexample/GsonConfigurationParser.java +++ b/example/src/main/java/cloud/eppo/androidexample/GsonConfigurationParser.java @@ -3,6 +3,7 @@ import static cloud.eppo.Utils.base64Decode; import android.util.Log; +import cloud.eppo.api.Configuration; import cloud.eppo.api.EppoValue; import cloud.eppo.api.dto.Allocation; import cloud.eppo.api.dto.BanditCategoricalAttributeCoefficients; @@ -17,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; @@ -57,7 +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() {} @@ -73,11 +74,13 @@ public GsonConfigurationParser() {} // ===== ConfigurationParser interface ===== @Override - public FlagConfigResponse parseFlagConfig(byte[] flagConfigJson) + public @org.jetbrains.annotations.NotNull FlagConfigResponse parseFlagConfig( + @org.jetbrains.annotations.NotNull byte[] flagConfigBytes) throws ConfigurationParseException { try { - Log.d(TAG, "Parsing flag configuration, " + flagConfigJson.length + " bytes"); - JsonElement root = JsonParser.parseString(new String(flagConfigJson, StandardCharsets.UTF_8)); + Log.d(TAG, "Parsing flag configuration, " + flagConfigBytes.length + " bytes"); + JsonElement root = + JsonParser.parseString(new String(flagConfigBytes, StandardCharsets.UTF_8)); return deserializeFlagConfigResponse(root); } catch (Exception e) { throw new ConfigurationParseException("Failed to parse flag configuration", e); @@ -85,16 +88,28 @@ public FlagConfigResponse parseFlagConfig(byte[] flagConfigJson) } @Override - public BanditParametersResponse parseBanditParams(byte[] banditParamsJson) - throws ConfigurationParseException { - try { - Log.d(TAG, "Parsing bandit parameters, " + banditParamsJson.length + " bytes"); - JsonElement root = - JsonParser.parseString(new String(banditParamsJson, StandardCharsets.UTF_8)); - return deserializeBanditParametersResponse(root); - } catch (Exception e) { - throw new ConfigurationParseException("Failed to parse bandit parameters", e); + 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); + } + 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 @@ -269,7 +284,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));