diff --git a/.gitignore b/.gitignore index 10a5297d..637d3d1a 100644 --- a/.gitignore +++ b/.gitignore @@ -55,10 +55,13 @@ captures/ # VSCode .vscode -# Keystore files -# Uncomment the following lines if you do not want to check your keystore files in. -#*.jks -#*.keystore +# Keystore files and credentials +*.jks +*.keystore +*.pem +*.p12 +.env +credentials* # External native build folder generated in Android Studio 2.2 and later .externalNativeBuild diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..0cdc342b --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,76 @@ +# Eppo Android SDK + +## Module Structure + +Three-layer dependency chain: + +``` +eppo-sdk-framework (external, platform-neutral flag evaluation) + └── sdk-common-jvm (external, OkHttp + Jackson + JVM defaults) + └── :android-sdk-framework (Android abstractions) + └── :eppo (batteries-included public API) +``` + +| Module | Version | Artifact | Role | +|---|---|---|---| +| `:eppo` | 4.12.1 | `cloud.eppo:android-sdk` | Public `EppoClient` / `EppoPrecomputedClient`, Moshi parser, default implementations | +| `:android-sdk-framework` | 0.1.0-SNAPSHOT | `cloud.eppo:android-sdk-framework` | `BaseAndroidEppoClient`, `ByteStore`, `ConfigurationCodec`, `CachingConfigurationStore` | +| `eppo-sdk-framework` | 0.1.0-SNAPSHOT | external | `BaseEppoClient`, `IConfigurationStore`, `EppoConfigurationClient`, `ConfigurationParser` | +| `sdk-common-jvm` | 4.0.0-SNAPSHOT | external | `OkHttpEppoClient`, `JacksonConfigurationParser`, `Configuration`, data models | + +`:eppo` and `:android-sdk-framework` are versioned together. `:example` is a sample app only. + +## Android API Constraints + +**compileSdk 34, minSdk 26.** The build target is Android's `android.jar`, not a full JDK. + +APIs that are NOT available even though they appear in Java 9+ docs: + +- `java.io.ObjectInputFilter` — Java 9+, absent from `android.jar` at all API levels. Do not use it in any module that compiles against Android. +- `java.lang.ProcessHandle` — Java 9+, absent. +- `java.util.concurrent.Flow` — Java 9+, absent. + +When a dependency or pattern requires a Java 9+ API, it will compile locally (against a full JDK) but fail CI (which uses Android's SDK). Check `android.jar` contents before adding new Java 9+ usages. + +## Key Abstractions + +`ByteStore` — read/write interface for raw bytes; Android implementation uses `FileBackedByteStore` (app private internal storage). Inaccessible to other apps on non-rooted devices. + +`ConfigurationCodec` — serialization interface; default uses Java serialization. The `Default` implementation does not apply `ObjectInputFilter` (not available in Android SDK); the deserialization risk is low because bytes come from the SDK's own write path and live in private storage. + +`CachingConfigurationStore` — wraps `ByteStore` + `ConfigurationCodec` with an in-memory `AtomicReference`; optimistic write with compareAndSet revert on IO failure. + +## Build & Test + +```bash +make test-data # clone sdk-test-data and copy UFC fixtures into androidTest/assets +make test # run test-data then ./gradlew connectedCheck (requires connected device/emulator) +./gradlew :android-sdk-framework:test # unit tests (Robolectric) +./gradlew spotlessApply # auto-fix formatting +``` + +## Publishing + +Both modules use the `vanniktech` Maven publish plugin targeting **Sonatype Central Portal** (not the legacy OSSRH). + +Required in `~/.gradle/gradle.properties`: +``` +mavenCentralUsername= +mavenCentralPassword= +``` + +Publish flow enforces `-Prelease` or `-Psnapshot` flag: +```bash +./gradlew :eppo:publish -Prelease # release build +./gradlew :android-sdk-framework:publish -Psnapshot # snapshot build +``` + +`make publish-release` runs tests then checks for `mavenCentralUsername`/`mavenCentralPassword` before publishing `:eppo`. + +GPG signing uses `GPG_PRIVATE_KEY` and `GPG_PASSPHRASE` environment variables (CI) or `signing.*` properties (local). + +## Gradle Conventions + +- Spotless (Google Java Format) enforced on all `.java` files — run `spotlessApply` before committing. +- `checkVersion` task validates the `-Prelease`/`-Psnapshot` flag before any publish task runs. +- Snapshot repo: `https://central.sonatype.com/repository/maven-snapshots/` — scope with `content { includeModule(...) }` to avoid querying it for all artifacts. diff --git a/Makefile b/Makefile index efd7160e..436b861c 100644 --- a/Makefile +++ b/Makefile @@ -54,7 +54,7 @@ test: test-data check-maven-credentials-and-publish: # $(INFO)Checking required gradle configuration(END) - @for required_property in "OSSRH_USERNAME" "OSSRH_PASSWORD"; do \ + @for required_property in "mavenCentralUsername" "mavenCentralPassword"; do \ cat ~/.gradle/gradle.properties | grep -q $$required_property; \ if [ $$? != 0 ]; then \ echo "$(ERROR)ERROR: ~/.gradle/gradle.properties file is missing property: $$required_property$(END)"; \ @@ -63,7 +63,7 @@ check-maven-credentials-and-publish: done # $(INFO)Publishing release(END) - ./gradlew :eppo:publishReleasePublicationToMavenRepository + ./gradlew :eppo:publish -Prelease .PHONY: publish-release publish-release: test check-maven-credentials-and-publish diff --git a/android-sdk-framework/build.gradle b/android-sdk-framework/build.gradle new file mode 100644 index 00000000..eb13e560 --- /dev/null +++ b/android-sdk-framework/build.gradle @@ -0,0 +1,181 @@ +plugins { + id 'com.android.library' + id 'maven-publish' + id "com.vanniktech.maven.publish" version "0.32.0" + id 'signing' + id "com.diffplug.spotless" version "8.0.0" +} + +group = "cloud.eppo" +version = "0.1.0-SNAPSHOT" + +android { + namespace "cloud.eppo.android.framework" + compileSdk 34 + + buildFeatures.buildConfig true + + defaultConfig { + minSdk 26 + targetSdk 34 + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + def FRAMEWORK_VERSION = "FRAMEWORK_VERSION" + // EPPO_VERSION is used as the sdkVersion reported to Eppo. It matches FRAMEWORK_VERSION + // because the framework and eppo modules are versioned together. + def EPPO_VERSION = "EPPO_VERSION" + release { + minifyEnabled false + buildConfigField "String", FRAMEWORK_VERSION, "\"${project.version}\"" + buildConfigField "String", EPPO_VERSION, "\"${project.version}\"" + } + debug { + minifyEnabled false + buildConfigField "String", FRAMEWORK_VERSION, "\"${project.version}\"" + buildConfigField "String", EPPO_VERSION, "\"${project.version}\"" + } + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + testOptions { + unitTests.returnDefaultValues = true + } +} + +dependencies { + api 'cloud.eppo:eppo-sdk-framework:0.1.0-SNAPSHOT' + + implementation 'org.slf4j:slf4j-api:1.7.36' + runtimeOnly 'org.slf4j:slf4j-android:1.7.36' + compileOnly 'org.jetbrains:annotations:24.0.0' + + testImplementation 'cloud.eppo:sdk-common-jvm:4.0.0-SNAPSHOT' + testImplementation 'junit:junit:4.13.2' + testImplementation 'org.mockito:mockito-core:5.14.2' + testImplementation 'org.robolectric:robolectric:4.12.1' + + androidTestImplementation 'junit:junit:4.13.2' + androidTestImplementation 'org.mockito:mockito-android:5.14.2' + androidTestImplementation 'androidx.test.ext:junit:1.2.1' + androidTestImplementation 'androidx.test:core:1.6.1' + androidTestImplementation 'androidx.test:runner:1.6.2' + androidTestImplementation 'com.fasterxml.jackson.core:jackson-databind:2.19.1' +} + +spotless { + format 'misc', { + target '*.gradle', '.gitattributes', '.gitignore' + + trimTrailingWhitespace() + leadingTabsToSpaces(2) + endWithNewline() + } + java { + target '**/*.java' + + googleJavaFormat() + formatAnnotations() + } +} + +signing { + if (System.getenv("GPG_PRIVATE_KEY") && System.getenv("GPG_PASSPHRASE")) { + useInMemoryPgpKeys(System.env.GPG_PRIVATE_KEY, System.env.GPG_PASSPHRASE) + sign publishing.publications + } else { + required = false + } +} + +tasks.withType(Sign) { + onlyIf { + (System.getenv("GPG_PRIVATE_KEY") && System.getenv("GPG_PASSPHRASE")) || + (project.hasProperty('signing.keyId') && + project.hasProperty('signing.password') && + project.hasProperty('signing.secretKeyRingFile')) + } +} + +mavenPublishing { + publishToMavenCentral(com.vanniktech.maven.publish.SonatypeHost.CENTRAL_PORTAL) + signAllPublications() + coordinates("cloud.eppo", "android-sdk-framework", project.version) + + pom { + name = 'Eppo Android SDK Framework' + description = 'Android SDK Framework for Eppo - Library-independent EppoClient and PrecomputedEppoClient (abstracts JSON, HTTP, storage)' + url = 'https://github.com/Eppo-exp/android-sdk' + licenses { + license { + name = 'MIT License' + url = 'http://www.opensource.org/licenses/mit-license.php' + } + } + developers { + developer { + name = 'Eppo' + email = 'sdk@geteppo.com' + } + } + scm { + connection = 'scm:git:git://github.com/Eppo-exp/android-sdk.git' + developerConnection = 'scm:git:ssh://github.com/Eppo-exp/android-sdk.git' + url = 'https://github.com/Eppo-exp/android-sdk/tree/main' + } + } +} + +task checkNoSnapshotApiDeps { + doLast { + // Only enforce on release builds. Snapshot builds are expected to depend on SNAPSHOT + // upstream artifacts; enforcing here would block legitimate snapshot publish flows. + if (!project.hasProperty('release')) return + def apiConfig = configurations.findByName('api') + if (apiConfig != null) { + apiConfig.dependencies.each { dep -> + if (dep.version?.endsWith('SNAPSHOT')) { + throw new GradleException( + "api dependency '${dep.group}:${dep.name}:${dep.version}' is a SNAPSHOT. " + + "SNAPSHOT api dependencies leak to consumers. Use implementation scope instead." + ) + } + } + } + } +} + +task checkVersion { + dependsOn checkNoSnapshotApiDeps + doLast { + if (!project.hasProperty('release') && !project.hasProperty('snapshot')) { + throw new GradleException("You must specify either -Prelease or -Psnapshot") + } + if (project.hasProperty('release') && project.version.endsWith('SNAPSHOT')) { + throw new GradleException("You cannot specify -Prelease with a SNAPSHOT version") + } + if (project.hasProperty('snapshot') && !project.version.endsWith('SNAPSHOT')) { + throw new GradleException("You cannot specify -Psnapshot with a non-SNAPSHOT version") + } + project.ext.shouldPublish = true + } +} + +tasks.named('publish').configure { + dependsOn checkVersion +} + +tasks.withType(PublishToMavenLocal).configureEach { + dependsOn checkVersion +} + +tasks.withType(PublishToMavenRepository) { + onlyIf { + project.ext.has('shouldPublish') && project.ext.shouldPublish + } +} diff --git a/android-sdk-framework/src/androidTest/java/cloud/eppo/android/framework/EppoClientPollingTest.java b/android-sdk-framework/src/androidTest/java/cloud/eppo/android/framework/EppoClientPollingTest.java new file mode 100644 index 00000000..01920306 --- /dev/null +++ b/android-sdk-framework/src/androidTest/java/cloud/eppo/android/framework/EppoClientPollingTest.java @@ -0,0 +1,243 @@ +package cloud.eppo.android.framework; + +import static org.junit.Assert.assertNotNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.atMost; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import androidx.test.core.app.ApplicationProvider; +import cloud.eppo.api.Configuration; +import cloud.eppo.http.EppoConfigurationClient; +import cloud.eppo.http.EppoConfigurationRequest; +import cloud.eppo.http.EppoConfigurationResponse; +import cloud.eppo.parser.ConfigurationParser; +import com.fasterxml.jackson.databind.JsonNode; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +/** + * Tests for EppoClient polling pause/resume functionality. + * + *

These tests focus on verifying that pausePolling() and resumePolling() can be called safely in + * various sequences, and that polling actually stops and resumes as expected. + */ +public class EppoClientPollingTest { + private static final String DUMMY_API_KEY = "mock-api-key"; + + @Mock private ConfigurationParser mockConfigParser; + @Mock private EppoConfigurationClient mockConfigClient; + + // Tracks the last built client so tearDown can stop its polling timer. + private BaseAndroidEppoClient lastClient; + + @Before + public void setUp() { + MockitoAnnotations.openMocks(this); + } + + @After + public void tearDown() { + if (lastClient != null) { + lastClient.pausePolling(); + lastClient = null; + } + } + + /** + * Builds a client in offline mode with optional polling enabled. + * + * @param pollingEnabled whether to enable polling + * @param pollingIntervalMs polling interval in milliseconds (ignored when pollingEnabled=false) + * @return initialized EppoClient + */ + private BaseAndroidEppoClient buildOfflineClient( + boolean pollingEnabled, long pollingIntervalMs) + throws ExecutionException, InterruptedException { + CompletableFuture initialConfig = + CompletableFuture.completedFuture(Configuration.emptyConfig()); + + BaseAndroidEppoClient.Builder builder = + new BaseAndroidEppoClient.Builder<>( + DUMMY_API_KEY, + ApplicationProvider.getApplicationContext(), + mockConfigParser, + mockConfigClient) + .forceReinitialize(true) + .offlineMode(true) + .initialConfiguration(initialConfig) + .pollingEnabled(pollingEnabled) + .isGracefulMode(true); + + if (pollingEnabled) { + builder.pollingIntervalMs(pollingIntervalMs); + } + + lastClient = builder.buildAndInitAsync().get(); + return lastClient; + } + + @Test + public void testPauseAndResumePolling() throws ExecutionException, InterruptedException { + // Use non-offline mode with a short interval so we can observe actual polling calls. + when(mockConfigClient.execute(any(EppoConfigurationRequest.class))) + .thenReturn(CompletableFuture.completedFuture(EppoConfigurationResponse.error(503, null))); + + CompletableFuture initialConfig = + CompletableFuture.completedFuture(Configuration.emptyConfig()); + + lastClient = + new BaseAndroidEppoClient.Builder<>( + DUMMY_API_KEY, + ApplicationProvider.getApplicationContext(), + mockConfigParser, + mockConfigClient) + .forceReinitialize(true) + .initialConfiguration(initialConfig) + .pollingEnabled(true) + .pollingIntervalMs(50) + .isGracefulMode(true) + .buildAndInitAsync() + .get(); + + assertNotNull("Client should be initialized", lastClient); + + // Wait for at least one polling cycle to fire (50ms interval, wait 150ms). + Thread.sleep(150); + verify(mockConfigClient, atLeastOnce()).execute(any(EppoConfigurationRequest.class)); + + // Pause: stopPolling() calls cancel(false), which does not interrupt a task already running + // on the executor thread. At most one in-flight invocation can complete after pausePolling() + // returns, so we tolerate atMost(1) rather than never(). + lastClient.pausePolling(); + reset(mockConfigClient); + when(mockConfigClient.execute(any(EppoConfigurationRequest.class))) + .thenReturn(CompletableFuture.completedFuture(EppoConfigurationResponse.error(503, null))); + Thread.sleep(200); // wait 4 intervals — polling must be stopped + verify(mockConfigClient, atMost(1)).execute(any(EppoConfigurationRequest.class)); + + // Resume: polling fires again within one interval. + lastClient.resumePolling(); + Thread.sleep(150); + verify(mockConfigClient, atLeastOnce()).execute(any(EppoConfigurationRequest.class)); + + lastClient.pausePolling(); + } + + @Test + public void testResumePollingWithoutStarting() throws ExecutionException, InterruptedException { + BaseAndroidEppoClient androidBaseClient = buildOfflineClient(false, 0); + assertNotNull("Client should be initialized", androidBaseClient); + + // resumePolling() logs a warning when polling interval was not set and does not start polling. + androidBaseClient.resumePolling(); + + Thread.sleep(50); + } + + @Test + public void testMultiplePauseResumeCyclesWithVerification() + throws ExecutionException, InterruptedException { + // Stub the mock so polling calls don't NPE. + when(mockConfigClient.execute(any(EppoConfigurationRequest.class))) + .thenReturn(CompletableFuture.completedFuture(EppoConfigurationResponse.error(503, null))); + + BaseAndroidEppoClient androidBaseClient = buildOfflineClient(true, 50); + assertNotNull("Client should be initialized", androidBaseClient); + + // Let polling fire at least once. + Thread.sleep(150); + verify(mockConfigClient, atLeastOnce()).execute(any(EppoConfigurationRequest.class)); + + // Pause and verify polling stops. + androidBaseClient.pausePolling(); + reset(mockConfigClient); + when(mockConfigClient.execute(any(EppoConfigurationRequest.class))) + .thenReturn(CompletableFuture.completedFuture(EppoConfigurationResponse.error(503, null))); + Thread.sleep(200); + verify(mockConfigClient, atMost(1)).execute(any(EppoConfigurationRequest.class)); + + // Double pause is safe. + androidBaseClient.pausePolling(); + + // Resume and verify polling fires again. + androidBaseClient.resumePolling(); + reset(mockConfigClient); + when(mockConfigClient.execute(any(EppoConfigurationRequest.class))) + .thenReturn(CompletableFuture.completedFuture(EppoConfigurationResponse.error(503, null))); + Thread.sleep(150); + verify(mockConfigClient, atLeastOnce()).execute(any(EppoConfigurationRequest.class)); + + // Double resume is safe. + androidBaseClient.resumePolling(); + Thread.sleep(50); + + // Second pause/resume cycle. + androidBaseClient.pausePolling(); + reset(mockConfigClient); + when(mockConfigClient.execute(any(EppoConfigurationRequest.class))) + .thenReturn(CompletableFuture.completedFuture(EppoConfigurationResponse.error(503, null))); + Thread.sleep(150); + verify(mockConfigClient, atMost(1)).execute(any(EppoConfigurationRequest.class)); + + androidBaseClient.resumePolling(); + reset(mockConfigClient); + when(mockConfigClient.execute(any(EppoConfigurationRequest.class))) + .thenReturn(CompletableFuture.completedFuture(EppoConfigurationResponse.error(503, null))); + Thread.sleep(150); + verify(mockConfigClient, atLeastOnce()).execute(any(EppoConfigurationRequest.class)); + + androidBaseClient.pausePolling(); + } + + @Test + public void testPollingNotEnabledAndResume() throws ExecutionException, InterruptedException { + BaseAndroidEppoClient androidBaseClient = buildOfflineClient(false, 0); + + // Pause should be safe even if not polling + androidBaseClient.pausePolling(); + Thread.sleep(50); + + // resumePolling() logs a warning when polling interval was not set and does not start polling. + androidBaseClient.resumePolling(); + Thread.sleep(50); + + // Multiple calls should all be safe + androidBaseClient.pausePolling(); + androidBaseClient.resumePolling(); + Thread.sleep(50); + } + + @Test + public void testPauseAfterInitStopsPolling() throws ExecutionException, InterruptedException { + when(mockConfigClient.execute(any(EppoConfigurationRequest.class))) + .thenReturn(CompletableFuture.completedFuture(EppoConfigurationResponse.error(503, null))); + + BaseAndroidEppoClient androidBaseClient = buildOfflineClient(true, 50); + + // Immediately pause after initialization — no polls should fire. + androidBaseClient.pausePolling(); + reset(mockConfigClient); + when(mockConfigClient.execute(any(EppoConfigurationRequest.class))) + .thenReturn(CompletableFuture.completedFuture(EppoConfigurationResponse.error(503, null))); + Thread.sleep(200); + verify(mockConfigClient, atMost(1)).execute(any(EppoConfigurationRequest.class)); + + // Resume and verify polling fires. + androidBaseClient.resumePolling(); + reset(mockConfigClient); + when(mockConfigClient.execute(any(EppoConfigurationRequest.class))) + .thenReturn(CompletableFuture.completedFuture(EppoConfigurationResponse.error(503, null))); + Thread.sleep(150); + verify(mockConfigClient, atLeastOnce()).execute(any(EppoConfigurationRequest.class)); + + androidBaseClient.pausePolling(); + } +} diff --git a/android-sdk-framework/src/main/AndroidManifest.xml b/android-sdk-framework/src/main/AndroidManifest.xml new file mode 100644 index 00000000..b2d3ea12 --- /dev/null +++ b/android-sdk-framework/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/android-sdk-framework/src/main/java/cloud/eppo/android/framework/BaseAndroidEppoClient.java b/android-sdk-framework/src/main/java/cloud/eppo/android/framework/BaseAndroidEppoClient.java new file mode 100644 index 00000000..77684606 --- /dev/null +++ b/android-sdk-framework/src/main/java/cloud/eppo/android/framework/BaseAndroidEppoClient.java @@ -0,0 +1,458 @@ +package cloud.eppo.android.framework; + +import static cloud.eppo.android.framework.util.Utils.logTag; +import static cloud.eppo.android.framework.util.Utils.safeCacheKey; + +import android.app.Application; +import android.util.Log; +import cloud.eppo.BaseEppoClient; +import cloud.eppo.android.framework.exceptions.EppoInitializationException; +import cloud.eppo.android.framework.storage.CachingConfigurationStore; +import cloud.eppo.android.framework.storage.ConfigurationCodec; +import cloud.eppo.android.framework.storage.FileBackedConfigStore; +import cloud.eppo.api.Configuration; +import cloud.eppo.api.IAssignmentCache; +import cloud.eppo.http.EppoConfigurationClient; +import cloud.eppo.logging.AssignmentLogger; +import cloud.eppo.parser.ConfigurationParser; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Generic EppoClient that extends BaseEppoClient with JSON type parameter. + * + *

Requires callers to provide implementations of ConfigurationParser and + * EppoConfigurationClient. + * + * @param The JSON type used for JSON flag values (e.g., JsonNode, JsonElement) + */ +public class BaseAndroidEppoClient extends BaseEppoClient { + private static final String TAG = logTag(BaseAndroidEppoClient.class); + private static final boolean DEFAULT_IS_GRACEFUL_MODE = true; + private static final boolean DEFAULT_OBFUSCATE_CONFIG = true; + private static final long DEFAULT_POLLING_INTERVAL_MS = 5 * 60 * 1000; + private static final long DEFAULT_JITTER_INTERVAL_RATIO = 10; + + private volatile long pollingIntervalMs; + private volatile long pollingJitterMs; + + /** + * Protected constructor. Use Builder to construct instances. + * + *

Singleton management is intentionally absent from this class. Concrete subclasses (e.g. + * EppoClient in :eppo) declare their own {@code static volatile} instance field and {@code + * getInstance()} method. Framework-only consumers hold the reference returned by {@link + * Builder#buildAndInitAsync()} directly — typically in the Application class. + * + * @param apiKey API key for Eppo + * @param sdkName SDK name identifier + * @param sdkVersion SDK version string + * @param apiBaseUrl Base URL for API calls + * @param assignmentLogger Logger for assignments + * @param configurationStore Store for configuration persistence + * @param isGracefulMode Whether to operate in graceful mode + * @param expectObfuscatedConfig Whether configuration is obfuscated + * @param initialConfiguration Initial configuration future + * @param assignmentCache Cache for assignments + * @param configurationParser Parser for configuration JSON + * @param configurationClient HTTP client for configuration fetching + */ + protected BaseAndroidEppoClient( + String apiKey, + String sdkName, + String sdkVersion, + @Nullable String apiBaseUrl, + @Nullable AssignmentLogger assignmentLogger, + CachingConfigurationStore configurationStore, + boolean isGracefulMode, + boolean expectObfuscatedConfig, + @Nullable CompletableFuture initialConfiguration, + @Nullable IAssignmentCache assignmentCache, + ConfigurationParser configurationParser, + EppoConfigurationClient configurationClient) { + super( + apiKey, + sdkName, + sdkVersion, + apiBaseUrl, + assignmentLogger, + null, // banditLogger is not supported in Android + configurationStore, + isGracefulMode, + expectObfuscatedConfig, + false, // no bandits. + initialConfiguration, + assignmentCache, + null, + configurationParser, + configurationClient); + } + + /** + * Builder for constructing and initializing EppoClient instances. + * + *

This is the only way to create an EppoClient. The Builder is generic on JsonFlagType and + * builds an EppoClient with the same type parameter. + * + * @param The JSON type used for JSON flag values + */ + public static class Builder { + // Required parameters + private final String apiKey; + private final Application application; + private final ConfigurationParser configurationParser; + private final EppoConfigurationClient configurationClient; + + // Optional parameters with defaults + @Nullable private String apiBaseUrl; + @Nullable private AssignmentLogger assignmentLogger; + @Nullable private CachingConfigurationStore configStore; + private boolean isGracefulMode = DEFAULT_IS_GRACEFUL_MODE; + private boolean obfuscateConfig = DEFAULT_OBFUSCATE_CONFIG; + private boolean forceReinitialize = false; + private boolean offlineMode = false; + @Nullable private CompletableFuture initialConfiguration; + private boolean ignoreCachedConfiguration = false; + private boolean pollingEnabled = false; + private long pollingIntervalMs = DEFAULT_POLLING_INTERVAL_MS; + private long pollingJitterMs = -1; + @Nullable private IAssignmentCache assignmentCache; + @Nullable private Consumer configChangeCallback; + // Tracks the FileBackedConfigStore created by the builder so it can be closed on reinitialize. + // Only set when the builder creates the default store (not when the caller provides one). + @Nullable private FileBackedConfigStore ownedConfigStore; + // Set during buildAndInitAsync() once the instance is constructed, before any async work + // begins. + // Used by buildAndInit() as a last-resort fallback so it never returns null in graceful mode. + // Safety: if the BaseAndroidEppoClient constructor itself throws, buildAndInitAsync() + // propagates + // a RuntimeException synchronously (before returning a Future), so buildAndInit()'s + // ExecutionException catch is never reached and builtInstance being null is not a concern. + @Nullable private BaseAndroidEppoClient builtInstance; + + /** + * Creates a new Builder with required parameters. + * + * @param apiKey API key for Eppo (required) + * @param application Application context (required) + * @param configurationParser Parser for configuration JSON (required) + * @param configurationClient HTTP client for configuration fetching (required) + */ + public Builder( + @NotNull String apiKey, + @NotNull Application application, + @NotNull ConfigurationParser configurationParser, + @NotNull EppoConfigurationClient configurationClient) { + if (apiKey == null || apiKey.isEmpty()) { + throw new IllegalArgumentException("apiKey must not be null or empty"); + } + if (application == null) { + throw new IllegalArgumentException("application must not be null"); + } + if (configurationParser == null) { + throw new IllegalArgumentException("configurationParser must not be null"); + } + if (configurationClient == null) { + throw new IllegalArgumentException("configurationClient must not be null"); + } + this.apiKey = apiKey; + this.application = application; + this.configurationParser = configurationParser; + this.configurationClient = configurationClient; + } + + public Builder apiBaseUrl(@Nullable String apiBaseUrl) { + this.apiBaseUrl = apiBaseUrl; + return this; + } + + public Builder assignmentLogger(@Nullable AssignmentLogger assignmentLogger) { + this.assignmentLogger = assignmentLogger; + return this; + } + + public Builder configStore(@Nullable CachingConfigurationStore configStore) { + this.configStore = configStore; + return this; + } + + public Builder isGracefulMode(boolean isGracefulMode) { + this.isGracefulMode = isGracefulMode; + return this; + } + + public Builder obfuscateConfig(boolean obfuscateConfig) { + this.obfuscateConfig = obfuscateConfig; + return this; + } + + public Builder forceReinitialize(boolean forceReinitialize) { + this.forceReinitialize = forceReinitialize; + return this; + } + + public Builder offlineMode(boolean offlineMode) { + this.offlineMode = offlineMode; + return this; + } + + public Builder initialConfiguration( + @Nullable CompletableFuture initialConfiguration) { + this.initialConfiguration = initialConfiguration; + return this; + } + + public Builder ignoreCachedConfiguration(boolean ignoreCache) { + this.ignoreCachedConfiguration = ignoreCache; + return this; + } + + public Builder pollingEnabled(boolean pollingEnabled) { + this.pollingEnabled = pollingEnabled; + return this; + } + + public Builder pollingIntervalMs(long pollingIntervalMs) { + this.pollingIntervalMs = pollingIntervalMs; + return this; + } + + public Builder pollingJitterMs(long pollingJitterMs) { + this.pollingJitterMs = pollingJitterMs; + return this; + } + + public Builder assignmentCache(@Nullable IAssignmentCache assignmentCache) { + this.assignmentCache = assignmentCache; + return this; + } + + public Builder onConfigurationChange( + @Nullable Consumer configChangeCallback) { + this.configChangeCallback = configChangeCallback; + return this; + } + + /** + * Builds and initializes the EppoClient asynchronously. + * + *

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

    + *
  1. Validates required fields + *
  2. Handles singleton/reinitialize logic + *
  3. Loads initial configuration from cache if needed + *
  4. Constructs the client + *
  5. Fetches configuration if not in offline mode + *
  6. Starts polling if enabled + *
  7. Returns a CompletableFuture that completes when initialization is done + *
+ * + * @return CompletableFuture that completes with the initialized EppoClient + */ + public CompletableFuture> buildAndInitAsync() { + String sdkName = obfuscateConfig ? "android" : "android-debug"; + String sdkVersion = BuildConfig.EPPO_VERSION; + + // Close any previously owned store when force-reinitializing, to release the background + // IO executor. Only the store created by this Builder is closed; caller-provided stores + // are the caller's responsibility. + if (forceReinitialize && ownedConfigStore != null) { + try { + ownedConfigStore.close(); + } catch (java.io.IOException e) { + Log.w(TAG, "Failed to close previous FileBackedConfigStore on reinitialize", e); + } + ownedConfigStore = null; + configStore = null; + } + + if (configStore == null) { + FileBackedConfigStore newStore = + new FileBackedConfigStore( + application, + safeCacheKey(apiKey), + new ConfigurationCodec.Default<>(Configuration.class)); + ownedConfigStore = newStore; + configStore = newStore; + } + + // Use the persisted cache as the initial configuration if none was explicitly provided. + // Also seed the in-memory cache so getConfiguration() returns the cached value immediately + // rather than emptyConfig() while the network fetch is in-flight. + if (initialConfiguration == null && !ignoreCachedConfiguration) { + initialConfiguration = + configStore + .loadAndSeedFromStorage() + .exceptionally( + ex -> { + // Storage failure is non-fatal: proceed without a cached config. + // Losing the cause here would make offline-mode failures opaque, so log. + Log.w(TAG, "Failed to load config from storage; starting without cache", ex); + return null; + }); + } + + // Construct the client + final BaseAndroidEppoClient newInstance = + new BaseAndroidEppoClient<>( + apiKey, + sdkName, + sdkVersion, + apiBaseUrl, + assignmentLogger, + configStore, + isGracefulMode, + obfuscateConfig, + initialConfiguration, + assignmentCache, + configurationParser, + configurationClient); + // Track the instance so buildAndInit()'s defensive catch can return it instead of null. + this.builtInstance = newInstance; + + // Register config change callback if provided + if (configChangeCallback != null) { + newInstance.onConfigurationChange(configChangeCallback); + } + + final CompletableFuture> ret = new CompletableFuture<>(); + AtomicInteger failCount = new AtomicInteger(0); + // Captures the HTTP exception so that when the initial-config future completes the + // combined failure path can include the original network error as the cause. + AtomicReference httpFailure = new AtomicReference<>(); + + if (!offlineMode) { + newInstance + .loadConfigurationAsync() + .handle( + (success, ex) -> { + if (ex == null) { + ret.complete(newInstance); + } else { + httpFailure.set(ex); + if (failCount.incrementAndGet() == 2 + || newInstance.getInitialConfigFuture() == null) { + ret.completeExceptionally( + new EppoInitializationException( + "Unable to initialize client; Configuration could not be loaded", + ex)); + } + } + return null; + }); + } + + // Start polling if configured + if (pollingEnabled && pollingIntervalMs > 0) { + Log.i(TAG, "Starting poller"); + long effectiveJitterMs = pollingJitterMs; + if (effectiveJitterMs < 0) { + effectiveJitterMs = pollingIntervalMs / DEFAULT_JITTER_INTERVAL_RATIO; + } + + // Store interval/jitter on the instance so resumePolling() can restart with the same + // values. + newInstance.pollingIntervalMs = pollingIntervalMs; + newInstance.pollingJitterMs = effectiveJitterMs; + newInstance.startPolling(pollingIntervalMs, effectiveJitterMs); + } + + if (newInstance.getInitialConfigFuture() != null) { + newInstance + .getInitialConfigFuture() + .handle( + (success, ex) -> { + if (ex == null && Boolean.TRUE.equals(success)) { + ret.complete(newInstance); + } else if (offlineMode || failCount.incrementAndGet() == 2) { + // When both the HTTP fetch and initial config load fail, prefer the HTTP + // exception as the cause since it is more actionable than a null or false + // result from the initial config handler. + Throwable cause = httpFailure.get() != null ? httpFailure.get() : ex; + ret.completeExceptionally( + new EppoInitializationException( + "Unable to initialize client; Configuration could not be loaded", + cause)); + } else { + Log.i(TAG, "Initial config was not used."); + } + return null; + }); + } else if (offlineMode) { + ret.complete(newInstance); + } + + return ret.exceptionally( + e -> { + Log.e(TAG, "Exception caught during initialization: " + e.getMessage(), e); + if (!isGracefulMode) { + throw new RuntimeException(e); + } + return newInstance; + }); + } + + /** + * Builds and initializes the EppoClient synchronously (blocking). + * + *

This is a blocking wrapper around buildAndInitAsync(). The underlying future has no + * deadline: if the HTTP fetch stalls permanently (e.g. due to network unavailability with no + * timeout configured on the HTTP client), this call blocks indefinitely. Callers that require a + * bounded wait should use {@link #buildAndInitAsync()} with {@code + * CompletableFuture.orTimeout()} (API 31+) or a timed {@code get(long, TimeUnit)}. + * + * @return The initialized EppoClient + * @throws RuntimeException if initialization fails and {@code isGracefulMode} is false + */ + public BaseAndroidEppoClient buildAndInit() { + try { + return buildAndInitAsync().get(); + } catch (InterruptedException e) { + // Thread interruption is not a graceful-degradation scenario — the caller's thread + // was cancelled while waiting for init. Always re-throw so the caller can handle it. + Thread.currentThread().interrupt(); + throw new RuntimeException("Eppo client initialization interrupted", e); + } catch (ExecutionException e) { + // In graceful mode, buildAndInitAsync()'s .exceptionally() handler converts failures + // into the client instance, so .get() should not throw ExecutionException. This catch + // is a defensive fallback for unexpected .exceptionally() failures. Return the partially- + // constructed instance (which returns defaults for all flag evaluations) rather than null. + Log.e(TAG, "Exception caught during initialization: " + e.getMessage(), e); + if (!isGracefulMode) { + throw new RuntimeException(e); + } + return builtInstance; + } + } + } + + /** + * Pauses polling for configuration updates. + * + *

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

Only works if polling was previously started via Builder. + */ + public void resumePolling() { + if (pollingIntervalMs <= 0) { + Log.w( + TAG, + "resumePolling called, but polling was not started due to invalid polling interval."); + return; + } + super.startPolling(pollingIntervalMs, pollingJitterMs); + } +} diff --git a/android-sdk-framework/src/main/java/cloud/eppo/android/framework/exceptions/EppoInitializationException.java b/android-sdk-framework/src/main/java/cloud/eppo/android/framework/exceptions/EppoInitializationException.java new file mode 100644 index 00000000..8300fa35 --- /dev/null +++ b/android-sdk-framework/src/main/java/cloud/eppo/android/framework/exceptions/EppoInitializationException.java @@ -0,0 +1,7 @@ +package cloud.eppo.android.framework.exceptions; + +public class EppoInitializationException extends Exception { + public EppoInitializationException(String s, Throwable ex) { + super(s, ex); + } +} diff --git a/android-sdk-framework/src/main/java/cloud/eppo/android/framework/exceptions/NotInitializedException.java b/android-sdk-framework/src/main/java/cloud/eppo/android/framework/exceptions/NotInitializedException.java new file mode 100644 index 00000000..cc8566b7 --- /dev/null +++ b/android-sdk-framework/src/main/java/cloud/eppo/android/framework/exceptions/NotInitializedException.java @@ -0,0 +1,7 @@ +package cloud.eppo.android.framework.exceptions; + +public class NotInitializedException extends RuntimeException { + public NotInitializedException() { + super("Eppo client is not initialized"); + } +} diff --git a/android-sdk-framework/src/main/java/cloud/eppo/android/framework/storage/BaseCacheFile.java b/android-sdk-framework/src/main/java/cloud/eppo/android/framework/storage/BaseCacheFile.java new file mode 100644 index 00000000..fe74f84f --- /dev/null +++ b/android-sdk-framework/src/main/java/cloud/eppo/android/framework/storage/BaseCacheFile.java @@ -0,0 +1,71 @@ +package cloud.eppo.android.framework.storage; + +import android.app.Application; +import java.io.BufferedReader; +import java.io.BufferedWriter; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.FileReader; +import java.io.FileWriter; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +/** Base class for disk cache files. */ +public class BaseCacheFile { + private final File cacheFile; + + protected BaseCacheFile(Application application, String fileName) { + if (application == null) { + throw new IllegalArgumentException("application must not be null"); + } + if (fileName == null) { + throw new IllegalArgumentException("fileName must not be null"); + } + File filesDir = application.getFilesDir(); + cacheFile = new File(filesDir, fileName); + } + + public boolean exists() { + return cacheFile.exists(); + } + + /** + * @noinspection ResultOfMethodCallIgnored + */ + public void delete() { + if (cacheFile.exists()) { + cacheFile.delete(); + } + } + + /** Useful for passing in as a writer for JSON serialization. */ + public BufferedWriter getWriter() throws IOException { + return new BufferedWriter(new FileWriter(cacheFile)); + } + + public OutputStream getOutputStream() throws FileNotFoundException { + return new FileOutputStream(cacheFile); + } + + public InputStream getInputStream() throws FileNotFoundException { + return new FileInputStream(cacheFile); + } + + /** Useful for passing in as a reader for JSON deserialization. */ + public BufferedReader getReader() throws IOException { + return new BufferedReader(new FileReader(cacheFile)); + } + + /** Useful for mocking caches in automated tests. */ + public void setContents(String contents) { + delete(); + try (BufferedWriter writer = getWriter()) { + writer.write(contents); + } catch (IOException ex) { + throw new RuntimeException(ex); + } + } +} diff --git a/android-sdk-framework/src/main/java/cloud/eppo/android/framework/storage/ByteStore.java b/android-sdk-framework/src/main/java/cloud/eppo/android/framework/storage/ByteStore.java new file mode 100644 index 00000000..d7195366 --- /dev/null +++ b/android-sdk-framework/src/main/java/cloud/eppo/android/framework/storage/ByteStore.java @@ -0,0 +1,32 @@ +package cloud.eppo.android.framework.storage; + +import java.util.concurrent.CompletableFuture; +import org.jetbrains.annotations.NotNull; + +/** + * Abstraction for asynchronous byte-level I/O operations. + * + *

Implementations handle reading and writing raw bytes to/from persistent storage. This + * interface is agnostic of serialization format and storage medium. + */ +public interface ByteStore { + + /** + * Reads bytes from storage asynchronously. + * + * @return a CompletableFuture that completes with the read bytes, or null if the storage does not + * exist + * @throws RuntimeException (via CompletableFuture) if an I/O error occurs during read + */ + @NotNull CompletableFuture read(); + + /** + * Writes bytes to storage asynchronously. + * + * @param bytes the bytes to write (must not be null) + * @return a CompletableFuture that completes when the write operation finishes + * @throws IllegalArgumentException if bytes is null + * @throws RuntimeException (via CompletableFuture) if an I/O error occurs during write + */ + @NotNull CompletableFuture write(@NotNull byte[] bytes); +} diff --git a/android-sdk-framework/src/main/java/cloud/eppo/android/framework/storage/CachingConfigurationStore.java b/android-sdk-framework/src/main/java/cloud/eppo/android/framework/storage/CachingConfigurationStore.java new file mode 100644 index 00000000..0b504b92 --- /dev/null +++ b/android-sdk-framework/src/main/java/cloud/eppo/android/framework/storage/CachingConfigurationStore.java @@ -0,0 +1,132 @@ +package cloud.eppo.android.framework.storage; + +import cloud.eppo.IConfigurationStore; +import cloud.eppo.api.Configuration; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicReference; +import org.jetbrains.annotations.NotNull; + +/** + * 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 { + + // Sentinel used by seedCache() to detect that no real configuration has been set yet. + // Captured once so that seedCache()'s compareAndSet uses reference equality against the same + // instance stored in the AtomicReference at construction time. + private static final Configuration EMPTY_SENTINEL = Configuration.emptyConfig(); + + private final ConfigurationCodec codec; + private final ByteStore byteStore; + private final AtomicReference configuration = + new AtomicReference<>(EMPTY_SENTINEL); + + protected CachingConfigurationStore( + @NotNull ConfigurationCodec codec, @NotNull ByteStore byteStore) { + if (codec == null) { + throw new IllegalArgumentException("codec must not be null"); + } + if (byteStore == null) { + throw new IllegalArgumentException("byteStore must not be null"); + } + this.codec = codec; + this.byteStore = byteStore; + } + + /** Returns the current in-memory configuration. */ + @Override + @NotNull public Configuration getConfiguration() { + return configuration.get(); + } + + /** + * Saves the configuration to storage and updates the in-memory cache. + * + *

The disk write is performed first. The in-memory cache is updated only after the write + * succeeds, ensuring that the in-memory state never reflects a configuration that failed to + * persist. Concurrent saves are safe: the last write to complete successfully wins in memory. + * + * @param config the configuration to save (must not be null) + * @return a future that completes when the write finishes, or completes exceptionally if the + * underlying {@link ByteStore} write fails (e.g. with an {@link java.io.IOException}) + * @throws IllegalArgumentException if config is null + */ + @Override + @NotNull public CompletableFuture saveConfiguration(@NotNull Configuration config) { + if (config == null) { + throw new IllegalArgumentException("config must not be null"); + } + byte[] bytes = codec.toBytes(config); + return byteStore + .write(bytes) + .whenComplete( + (v, ex) -> { + if (ex == null) { + // Only update in-memory cache after a successful disk write. + // Use set() rather than compareAndSet() so the most-recently-persisted + // configuration always wins, regardless of submission order. + configuration.set(config); + } + }); + } + + /** + * Seeds the in-memory cache with {@code config} without writing to disk. + * + *

Uses {@code compareAndSet} so that a concurrent {@link #saveConfiguration} call always wins. + * If the in-memory value has already been updated by a save, this is a no-op. + * + * @param config the configuration to seed (must not be null) + */ + void seedCache(@NotNull Configuration config) { + if (config == null) { + throw new IllegalArgumentException("config must not be null"); + } + configuration.compareAndSet(EMPTY_SENTINEL, config); + } + + /** + * Loads the configuration from storage without updating the in-memory cache. + * + * @return a future that completes with the loaded configuration, or null if storage is empty or + * missing + */ + @NotNull public CompletableFuture loadFromStorage() { + return byteStore + .read() + .thenApply( + bytes -> { + if (bytes == null || bytes.length == 0) { + return null; + } + return codec.fromBytes(bytes); + }); + } + + /** + * Loads the configuration from storage and seeds the in-memory cache if a non-null configuration + * is found. + * + *

Combines {@link #loadFromStorage()} and {@link #seedCache(Configuration)} so callers do not + * need to reach into the storage package to call the package-private {@code seedCache} method + * directly. + * + * @return a future that completes with the loaded configuration, or null if storage is empty or + * missing + */ + @NotNull public CompletableFuture loadAndSeedFromStorage() { + // thenApplyAsync (common pool) breaks out of the single-thread IO_EXECUTOR so that + // downstream continuations (e.g. ConfigurationRequestor.setInitialConfiguration, which + // calls saveConfiguration → byteStore.write on IO_EXECUTOR) do not deadlock by running + // on the same thread that is blocked waiting for them. + return loadFromStorage() + .thenApplyAsync( + config -> { + if (config != null) { + seedCache(config); + } + return config; + }); + } +} diff --git a/android-sdk-framework/src/main/java/cloud/eppo/android/framework/storage/ConfigCacheFile.java b/android-sdk-framework/src/main/java/cloud/eppo/android/framework/storage/ConfigCacheFile.java new file mode 100644 index 00000000..cb826f41 --- /dev/null +++ b/android-sdk-framework/src/main/java/cloud/eppo/android/framework/storage/ConfigCacheFile.java @@ -0,0 +1,65 @@ +package cloud.eppo.android.framework.storage; + +import android.app.Application; +import java.util.HashMap; +import java.util.Map; +import org.jetbrains.annotations.NotNull; + +/** Disk cache file for flag configuration (used by FileBackedConfigStore). */ +public final class ConfigCacheFile extends BaseCacheFile { + private static final Map CONTENT_TYPE_TO_EXTENSION = new HashMap<>(); + private static final String DEFAULT_EXTENSION = "bin"; + + static { + CONTENT_TYPE_TO_EXTENSION.put("application/json", "json"); + CONTENT_TYPE_TO_EXTENSION.put("application/x-java-serialized-object", "ser"); + CONTENT_TYPE_TO_EXTENSION.put("text/plain", "txt"); + CONTENT_TYPE_TO_EXTENSION.put("text/xml", "xml"); + CONTENT_TYPE_TO_EXTENSION.put("application/xml", "xml"); + CONTENT_TYPE_TO_EXTENSION.put("application/octet-stream", "bin"); + } + + /** + * Creates a cache file with filename "eppo-sdk-flags-{suffix}.{ext}". Extension is derived from + * contentType. + */ + public ConfigCacheFile( + @NotNull Application application, @NotNull String suffix, @NotNull String contentType) { + super( + application, + "eppo-sdk-flags-" + + suffix + + "." + + CONTENT_TYPE_TO_EXTENSION.getOrDefault(contentType, DEFAULT_EXTENSION)); + } + + /** + * Creates a cache file with filename "eppo-sdk-flags-{configType}-{suffix}.{ext}". Used when the + * logical suffix is split into config type and suffix (e.g. for FileBackedConfigStore). + * + * @deprecated Use {@link #ConfigCacheFile(Application, String, String)} instead. These + * package-private constructors exist only to support migration of the eppo module from v3 to + * v4; they will be removed once that migration is complete. + */ + @Deprecated + ConfigCacheFile( + @NotNull Application application, + @NotNull String configType, + @NotNull String suffix, + @NotNull String contentType) { + this(application, configType + "-" + suffix, contentType); + } + + /** + * Creates a cache file with the given full file name (no prefix). Used when the caller supplies + * the complete filename (e.g. baseName + "." + extension). + * + * @deprecated Use {@link #ConfigCacheFile(Application, String, String)} instead. These + * package-private constructors exist only to support migration of the eppo module from v3 to + * v4; they will be removed once that migration is complete. + */ + @Deprecated + ConfigCacheFile(@NotNull Application application, @NotNull String fullFileName) { + super(application, fullFileName); + } +} diff --git a/android-sdk-framework/src/main/java/cloud/eppo/android/framework/storage/ConfigurationCodec.java b/android-sdk-framework/src/main/java/cloud/eppo/android/framework/storage/ConfigurationCodec.java new file mode 100644 index 00000000..e0145b3f --- /dev/null +++ b/android-sdk-framework/src/main/java/cloud/eppo/android/framework/storage/ConfigurationCodec.java @@ -0,0 +1,118 @@ +package cloud.eppo.android.framework.storage; + +import cloud.eppo.api.SerializableEppoConfiguration; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; +import org.jetbrains.annotations.NotNull; + +/** + * Interface for serializing and deserializing configurations to and from bytes. + * + *

Used for persisting configurations to storage. + * + * @param the configuration type, must extend SerializableEppoConfiguration + */ +public interface ConfigurationCodec { + /** + * Serializes a configuration to bytes for storage. + * + * @param configuration the configuration to serialize + * @return serialized bytes (must not be null) + * @throws RuntimeException if the configuration cannot be serialized + */ + byte[] toBytes(@NotNull T configuration); + + /** + * Deserializes a configuration from bytes produced by {@link #toBytes}. + * + * @param bytes serialized configuration (must not be null) + * @return the deserialized configuration + * @throws RuntimeException if the bytes cannot be deserialized to a configuration + */ + @NotNull T fromBytes(byte[] bytes); + + /** + * Returns the MIME content type of the serialized form (e.g. {@code + * application/x-java-serialized-object}). The codec is agnostic of storage; callers that need a + * file extension can map this to one locally. + */ + @NotNull String getContentType(); + + /** + * Default implementation using Java serialization. + * + *

Security Note: Java serialization is used for local storage. Do not use + * this codec to deserialize data from untrusted sources, as Java deserialization has known + * security vulnerabilities. + * + * @param the configuration type, must extend SerializableEppoConfiguration + */ + public static class Default + implements ConfigurationCodec { + private final Class configClass; + + /** + * Creates a default codec for the specified configuration class. + * + * @param configClass the class of the configuration type + */ + public Default(@NotNull Class configClass) { + if (configClass == null) { + throw new IllegalArgumentException("configClass must not be null"); + } + this.configClass = configClass; + } + + @Override + public byte[] toBytes(@NotNull T configuration) { + if (configuration == null) { + throw new IllegalArgumentException("Configuration must not be null"); + } + try (ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ObjectOutputStream oos = new ObjectOutputStream(baos)) { + oos.writeObject(configuration); + return baos.toByteArray(); + } catch (IOException e) { + throw new RuntimeException("Failed to serialize configuration", e); + } + } + + @Override + @SuppressWarnings("unchecked") // Safe cast - verified by configClass.isInstance() check + public @NotNull T fromBytes(byte[] bytes) { + if (bytes == null) { + throw new IllegalArgumentException("Bytes must not be null"); + } + try (ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(bytes))) { + // java.io.ObjectInputFilter (Java 9+) is not part of Android's SDK even at API 26, so + // a pattern-based allowlist cannot be applied at compile time. The deserialization risk + // is low here: the cache file lives in the app's private internal storage (inaccessible + // to other apps on a non-rooted device) and the bytes are produced by the SDK's own + // ObjectOutputStream write path — not transmitted directly from any server. The type + // check below prevents the wrong type from being returned to callers; note that it does + // not prevent gadget-chain execution, which occurs inside readObject() before the check. + Object obj = ois.readObject(); + if (!configClass.isInstance(obj)) { + throw new RuntimeException( + "Deserialized object is not a " + + configClass.getSimpleName() + + ": " + + obj.getClass().getName()); + } + return (T) obj; + } catch (IOException e) { + throw new RuntimeException("Failed to deserialize configuration", e); + } catch (ClassNotFoundException e) { + throw new RuntimeException("Configuration class not found", e); + } + } + + @Override + public @NotNull String getContentType() { + return "application/x-java-serialized-object"; + } + } +} diff --git a/android-sdk-framework/src/main/java/cloud/eppo/android/framework/storage/FileBackedByteStore.java b/android-sdk-framework/src/main/java/cloud/eppo/android/framework/storage/FileBackedByteStore.java new file mode 100644 index 00000000..2b97636f --- /dev/null +++ b/android-sdk-framework/src/main/java/cloud/eppo/android/framework/storage/FileBackedByteStore.java @@ -0,0 +1,88 @@ +package cloud.eppo.android.framework.storage; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import org.jetbrains.annotations.NotNull; + +/** + * {@link ByteStore} implementation that reads and writes a single file via {@link BaseCacheFile}. + * + *

Implements {@link java.io.Closeable}. Call {@link #close()} when the store is no longer needed + * to release the background IO thread. If not closed explicitly, the daemon thread will be + * reclaimed by the JVM/Android runtime on process exit. + */ +public final class FileBackedByteStore implements ByteStore, java.io.Closeable { + + // Dedicated single-thread executor avoids saturating ForkJoinPool.commonPool() with blocking I/O + // on low-core-count Android devices. Instance-level (not static) so it can be shut down via + // close(). The thread is a daemon so it does not block JVM/process exit when close() is omitted + // (e.g. in test environments such as Robolectric). + private final ExecutorService ioExecutor = + Executors.newSingleThreadExecutor( + r -> { + Thread t = new Thread(r, "eppo-io"); + t.setDaemon(true); + return t; + }); + + private final BaseCacheFile cacheFile; + + public FileBackedByteStore(@NotNull BaseCacheFile cacheFile) { + if (cacheFile == null) { + throw new IllegalArgumentException("cacheFile must not be null"); + } + this.cacheFile = cacheFile; + } + + /** + * Shuts down the background IO executor. In-flight operations are allowed to complete; no new + * operations will be accepted after this call. + */ + @Override + public void close() { + ioExecutor.shutdown(); + } + + @Override + @NotNull public CompletableFuture read() { + return CompletableFuture.supplyAsync( + () -> { + if (!cacheFile.exists()) { + return null; + } + try (java.io.InputStream in = cacheFile.getInputStream()) { + return readAllBytes(in); + } catch (Exception e) { + throw new RuntimeException("Failed to read from cache file", e); + } + }, + ioExecutor); + } + + @Override + @NotNull public CompletableFuture write(@NotNull byte[] bytes) { + if (bytes == null) { + throw new IllegalArgumentException("bytes must not be null"); + } + return CompletableFuture.runAsync( + () -> { + try (java.io.OutputStream out = cacheFile.getOutputStream()) { + out.write(bytes); + } catch (Exception e) { + throw new RuntimeException("Failed to write to cache file", e); + } + }, + ioExecutor); + } + + private static byte[] readAllBytes(java.io.InputStream in) throws java.io.IOException { + java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream(); + byte[] buf = new byte[8192]; + int n; + while ((n = in.read(buf)) != -1) { + baos.write(buf, 0, n); + } + return baos.toByteArray(); + } +} diff --git a/android-sdk-framework/src/main/java/cloud/eppo/android/framework/storage/FileBackedConfigStore.java b/android-sdk-framework/src/main/java/cloud/eppo/android/framework/storage/FileBackedConfigStore.java new file mode 100644 index 00000000..fd95fac0 --- /dev/null +++ b/android-sdk-framework/src/main/java/cloud/eppo/android/framework/storage/FileBackedConfigStore.java @@ -0,0 +1,42 @@ +package cloud.eppo.android.framework.storage; + +import android.app.Application; +import cloud.eppo.api.Configuration; +import org.jetbrains.annotations.NotNull; + +public class FileBackedConfigStore extends CachingConfigurationStore implements java.io.Closeable { + + private final FileBackedByteStore byteStore; + + /** + * Creates a FileBackedStore with the specified configuration. + * + * @param application the Android application context + * @param cacheFileSuffix suffix for the cache file name (e.g. "v4-flags-abc123") + * @param codec the codec for serializing/deserializing configurations + */ + public FileBackedConfigStore( + @NotNull Application application, + @NotNull String cacheFileSuffix, + @NotNull ConfigurationCodec codec) { + this( + codec, + new FileBackedByteStore( + new ConfigCacheFile(application, cacheFileSuffix, codec.getContentType()))); + } + + private FileBackedConfigStore( + @NotNull ConfigurationCodec codec, @NotNull FileBackedByteStore byteStore) { + super(codec, byteStore); + this.byteStore = byteStore; + } + + /** + * Releases the background IO executor held by the underlying {@link FileBackedByteStore}. Call + * this when the store is no longer needed (e.g. on reinitialize) to avoid leaking threads. + */ + @Override + public void close() throws java.io.IOException { + byteStore.close(); + } +} diff --git a/android-sdk-framework/src/main/java/cloud/eppo/android/framework/util/Utils.java b/android-sdk-framework/src/main/java/cloud/eppo/android/framework/util/Utils.java new file mode 100644 index 00000000..205b2512 --- /dev/null +++ b/android-sdk-framework/src/main/java/cloud/eppo/android/framework/util/Utils.java @@ -0,0 +1,33 @@ +package cloud.eppo.android.framework.util; + +public class Utils { + public static String logTag(Class loggingClass) { + if (loggingClass == null) { + return "EppoSDK"; + } + // Common prefix can make filtering logs easier + String logTag = ("EppoSDK:" + loggingClass.getSimpleName()); + + // Android prefers keeping log tags 23 characters or less + if (logTag.length() > 23) { + logTag = logTag.substring(0, 23); + } + + return logTag; + } + + public static String safeCacheKey(String key) { + if (key == null || key.isEmpty()) { + return ""; + } + // Take the first eight characters to avoid the key being sensitive information. + // Eppo API keys are formatted as "<8-char-random-prefix>.", so the first 8 characters + // are the unique identifier portion and the period separator is not included. + // \W strips non-word characters (equivalent to [^a-zA-Z0-9_]); underscores are preserved, + // which is fine for filenames. Note: "non-alphanumeric" would imply underscores are removed — + // they are not. If the first 8 characters are all non-word the result is an empty string, + // which produces the filename "eppo-sdk-flags-.bin". Eppo-issued API keys always start + // with alphanumeric characters, so this edge case is not expected in production. + return key.substring(0, Math.min(8, key.length())).replaceAll("\\W", ""); + } +} diff --git a/android-sdk-framework/src/test/java/cloud/eppo/android/framework/storage/CachingConfigurationStoreTest.java b/android-sdk-framework/src/test/java/cloud/eppo/android/framework/storage/CachingConfigurationStoreTest.java new file mode 100644 index 00000000..7d3d5469 --- /dev/null +++ b/android-sdk-framework/src/test/java/cloud/eppo/android/framework/storage/CachingConfigurationStoreTest.java @@ -0,0 +1,359 @@ +package cloud.eppo.android.framework.storage; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import cloud.eppo.JacksonConfigurationParser; +import cloud.eppo.api.Configuration; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +/** Unit tests for {@link CachingConfigurationStore}. */ +@RunWith(RobolectricTestRunner.class) +public class CachingConfigurationStoreTest { + + private ByteStore mockByteStore; + private ConfigurationCodec spyCodec; + private CachingConfigurationStore testedStore; + + /** One shared non-empty configuration used across tests (built once in setUp). */ + private Configuration sampleConfiguration; + + /** Serialized form of sampleConfiguration from the real codec (for verify when needed). */ + private byte[] sampleConfigurationBytes; + + @Before + public void setUp() throws Exception { + mockByteStore = mock(ByteStore.class); + spyCodec = spy(new ConfigurationCodec.Default<>(Configuration.class)); + testedStore = new CachingConfigurationStore(spyCodec, mockByteStore); + // Parse flags-v1.json from test resources using sdk-common-jvm JacksonConfigurationParser. + sampleConfiguration = loadSampleConfigurationFromResource(); + ConfigurationCodec realCodec = + new ConfigurationCodec.Default<>(Configuration.class); + sampleConfigurationBytes = realCodec.toBytes(sampleConfiguration); + } + + private static Configuration loadSampleConfigurationFromResource() throws Exception { + try (InputStream in = + Objects.requireNonNull( + CachingConfigurationStoreTest.class.getResourceAsStream("/flags-v1.json"), + "flags-v1.json not found on test classpath")) { + byte[] jsonBytes = in.readAllBytes(); + JacksonConfigurationParser parser = new JacksonConfigurationParser(); + return new Configuration.Builder(parser.parseFlagConfig(jsonBytes)).build(); + } + } + + @Test + public void testGetConfiguration_returnsEmptyConfigByDefault() { + Configuration config = testedStore.getConfiguration(); + assertNotNull("Configuration should not be null", config); + assertEquals("Should return empty config by default", Configuration.emptyConfig(), config); + } + + @Test(expected = IllegalArgumentException.class) + public void testSaveConfiguration_nullConfiguration_throwsException() { + testedStore.saveConfiguration(null); + } + + @Test + public void testSaveConfiguration_updatesInMemoryCache() throws Exception { + when(mockByteStore.write(any())).thenReturn(CompletableFuture.completedFuture(null)); + + testedStore.saveConfiguration(sampleConfiguration).get(5, TimeUnit.SECONDS); + + assertEquals( + "In-memory config should be updated", sampleConfiguration, testedStore.getConfiguration()); + + verify(spyCodec, times(1)).toBytes(sampleConfiguration); + verify(mockByteStore, times(1)).write(sampleConfigurationBytes); + } + + @Test + public void testLoadFromStorage_whenExists() throws Exception { + + // Mock IO read + when(mockByteStore.read()) + .thenReturn(CompletableFuture.completedFuture(sampleConfigurationBytes)); + + // Load from storage + Configuration loaded = testedStore.loadFromStorage().get(5, TimeUnit.SECONDS); + + // Verify loaded config + assertEquals("Should return stored configuration", sampleConfiguration, loaded); + + // Verify in-memory cache was NOT updated + assertEquals( + "In-memory config should still be empty", + Configuration.emptyConfig(), + testedStore.getConfiguration()); + + // Verify IO and codec were called + verify(mockByteStore, times(1)).read(); + verify(spyCodec, times(1)).fromBytes(sampleConfigurationBytes); + } + + @Test + public void testLoadFromStorage_whenNotExists() throws Exception { + // Mock IO read returning null (file doesn't exist) + when(mockByteStore.read()).thenReturn(CompletableFuture.completedFuture(null)); + + // Load from storage + Configuration loaded = testedStore.loadFromStorage().get(5, TimeUnit.SECONDS); + + // Verify null is returned + assertNull("Should return null when storage doesn't exist", loaded); + + // Verify IO was called but codec was not + verify(mockByteStore, times(1)).read(); + verify(spyCodec, times(0)).fromBytes(any()); + } + + @Test + public void testLoadFromStorage_emptyBytes() throws Exception { + // Mock byte store read returning empty array + when(mockByteStore.read()).thenReturn(CompletableFuture.completedFuture(new byte[0])); + + // Load from storage + Configuration loaded = testedStore.loadFromStorage().get(5, TimeUnit.SECONDS); + + // Verify null is returned for empty bytes + assertNull("Should return null for empty bytes", loaded); + + // Verify IO was called but codec was not + verify(mockByteStore, times(1)).read(); + verify(spyCodec, times(0)).fromBytes(any()); + } + + @Test + public void testSaveConfiguration_codecException() { + Configuration beforeSave = testedStore.getConfiguration(); + + // Mock codec to throw exception (may throw synchronously before returning a future) + when(spyCodec.toBytes(sampleConfiguration)) + .thenThrow(new RuntimeException("Serialization failed")); + + // Save configuration should propagate exception (sync from codec or via ExecutionException) + try { + testedStore.saveConfiguration(sampleConfiguration).get(5, TimeUnit.SECONDS); + fail("Expected exception"); + } catch (ExecutionException e) { + assertTrue("Should contain RuntimeException", e.getCause() instanceof RuntimeException); + assertTrue( + "Should contain error message", + e.getCause().getMessage().contains("Serialization failed")); + } catch (Exception e) { + assertTrue( + "Should be serialization failure: " + e.getMessage(), + e.getMessage() != null && e.getMessage().contains("Serialization failed")); + } + + // Verify in-memory cache was NOT updated + assertSame( + "In-memory config should be unchanged after codec failure", + beforeSave, + testedStore.getConfiguration()); + } + + @Test + public void testSaveConfiguration_ioException() { + Configuration beforeSave = testedStore.getConfiguration(); + byte[] serializedBytes = new byte[] {1, 2, 3, 4}; + + // Mock codec serialization + when(spyCodec.toBytes(sampleConfiguration)).thenReturn(serializedBytes); + + // Mock IO to throw exception + CompletableFuture failedFuture = new CompletableFuture<>(); + failedFuture.completeExceptionally(new RuntimeException("Write failed")); + when(mockByteStore.write(serializedBytes)).thenReturn(failedFuture); + + // Save configuration should propagate exception + try { + testedStore.saveConfiguration(sampleConfiguration).get(5, TimeUnit.SECONDS); + fail("Expected ExecutionException"); + } catch (ExecutionException e) { + assertTrue("Should contain RuntimeException", e.getCause() instanceof RuntimeException); + assertTrue( + "Should contain error message", e.getCause().getMessage().contains("Write failed")); + } catch (Exception e) { + fail("Unexpected exception: " + e.getMessage()); + } + + // Verify in-memory cache was NOT updated + assertSame( + "In-memory config should be unchanged after IO failure", + beforeSave, + testedStore.getConfiguration()); + } + + @Test + public void testLoadFromStorage_codecException() { + byte[] storedBytes = new byte[] {1, 2, 3, 4}; + + // Mock IO read + when(mockByteStore.read()).thenReturn(CompletableFuture.completedFuture(storedBytes)); + + // Load should propagate exception + try { + testedStore.loadFromStorage().get(5, TimeUnit.SECONDS); + fail("Expected ExecutionException"); + } catch (ExecutionException e) { + assertTrue("Should contain RuntimeException", e.getCause() instanceof RuntimeException); + assertEquals("Failed to deserialize configuration", e.getCause().getMessage()); + } catch (Exception e) { + fail("Unexpected exception: " + e.getMessage()); + } + } + + @Test + public void testLoadFromStorage_ioException() { + // Mock IO to throw exception + CompletableFuture failedFuture = new CompletableFuture<>(); + failedFuture.completeExceptionally(new RuntimeException("Read failed")); + when(mockByteStore.read()).thenReturn(failedFuture); + + // Load should propagate exception + try { + testedStore.loadFromStorage().get(5, TimeUnit.SECONDS); + fail("Expected ExecutionException"); + } catch (ExecutionException e) { + assertTrue("Should contain RuntimeException", e.getCause() instanceof RuntimeException); + assertTrue("Should contain error message", e.getCause().getMessage().contains("Read failed")); + } catch (Exception e) { + fail("Unexpected exception: " + e.getMessage()); + } + } + + @Test + public void testThreadSafety_concurrentSaves() throws Exception { + int numThreads = 10; + CountDownLatch startLatch = new CountDownLatch(1); + CountDownLatch doneLatch = new CountDownLatch(numThreads); + List> futures = new ArrayList<>(); + AtomicInteger errorCount = new AtomicInteger(0); + + // Mock codec and IO for successful operations + when(mockByteStore.write(any())).thenReturn(CompletableFuture.completedFuture(null)); + + // Start multiple threads saving configurations concurrently + for (int i = 0; i < numThreads; i++) { + final int threadId = i; + Thread thread = + new Thread( + () -> { + try { + startLatch.await(); // Wait for all threads to be ready + Configuration config = Configuration.emptyConfig(); + CompletableFuture future = testedStore.saveConfiguration(config); + synchronized (futures) { + futures.add(future); + } + } catch (Exception e) { + errorCount.incrementAndGet(); + fail("Unexpected exception in thread " + threadId + ": " + e.getMessage()); + } finally { + doneLatch.countDown(); + } + }); + thread.start(); + } + + // Start all threads at once + startLatch.countDown(); + + // Wait for all threads to complete + doneLatch.await(); + + // Wait for all futures to complete + for (CompletableFuture future : futures) { + future.get(5, TimeUnit.SECONDS); + } + + // Verify all operations completed without errors + assertEquals("Should have no errors", 0, errorCount.get()); + Configuration finalConfig = testedStore.getConfiguration(); + assertNotNull("Final configuration should not be null", finalConfig); + assertEquals("Final config should be empty config", Configuration.emptyConfig(), finalConfig); + } + + @Test + public void testThreadSafety_concurrentLoadAndSave() throws Exception { + // Mock byte store + when(mockByteStore.read()) + .thenReturn(CompletableFuture.completedFuture(sampleConfigurationBytes)); + + when(mockByteStore.write(sampleConfigurationBytes)) + .thenReturn(CompletableFuture.completedFuture(null)); + + // Run concurrent load and save operations (reduced iterations for performance) + CountDownLatch startLatch = new CountDownLatch(1); + CountDownLatch doneLatch = new CountDownLatch(2); + AtomicInteger errorCount = new AtomicInteger(0); + + Thread loadThread = + new Thread( + () -> { + try { + startLatch.await(); + for (int i = 0; i < 20; i++) { + testedStore.loadFromStorage().get(5, TimeUnit.SECONDS); + } + } catch (Exception e) { + errorCount.incrementAndGet(); + fail("Unexpected exception in load thread: " + e.getMessage()); + } finally { + doneLatch.countDown(); + } + }); + + Thread saveThread = + new Thread( + () -> { + try { + startLatch.await(); + for (int i = 0; i < 20; i++) { + testedStore.saveConfiguration(sampleConfiguration).get(5, TimeUnit.SECONDS); + } + } catch (Exception e) { + errorCount.incrementAndGet(); + fail("Unexpected exception in save thread: " + e.getMessage()); + } finally { + doneLatch.countDown(); + } + }); + + loadThread.start(); + saveThread.start(); + startLatch.countDown(); + + // Wait for completion + doneLatch.await(); + + // Verify no exceptions and store is in valid state + assertEquals("Should have no errors", 0, errorCount.get()); + assertNotNull("Store should have valid configuration", testedStore.getConfiguration()); + } +} diff --git a/android-sdk-framework/src/test/java/cloud/eppo/android/framework/storage/ConfigurationCodecTest.java b/android-sdk-framework/src/test/java/cloud/eppo/android/framework/storage/ConfigurationCodecTest.java new file mode 100644 index 00000000..f154cf84 --- /dev/null +++ b/android-sdk-framework/src/test/java/cloud/eppo/android/framework/storage/ConfigurationCodecTest.java @@ -0,0 +1,111 @@ +package cloud.eppo.android.framework.storage; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import cloud.eppo.api.Configuration; +import cloud.eppo.api.SerializableEppoConfiguration; +import java.io.ByteArrayOutputStream; +import java.io.ObjectOutputStream; +import java.nio.charset.StandardCharsets; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +/** Unit tests for {@link ConfigurationCodec} and {@link ConfigurationCodec.Default}. */ +@RunWith(RobolectricTestRunner.class) +public class ConfigurationCodecTest { + + private ConfigurationCodec codec; + + @Before + public void setUp() { + codec = new ConfigurationCodec.Default<>(SerializableEppoConfiguration.class); + } + + @Test + public void getContentType_returnsJavaSerializedObject() { + assertEquals("application/x-java-serialized-object", codec.getContentType()); + } + + @Test + public void toBytes_nullConfiguration_throwsIllegalArgumentException() { + try { + codec.toBytes(null); + fail("Expected IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertTrue( + "Exception should mention null constraint", e.getMessage().contains("must not be null")); + } + } + + @Test + public void fromBytes_nullBytes_throwsIllegalArgumentException() { + try { + codec.fromBytes(null); + fail("Expected IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertTrue( + "Exception should mention null constraint", e.getMessage().contains("must not be null")); + } + } + + @Test + public void fromBytes_invalidData_throwsRuntimeException() { + byte[] invalid = "not java serialized data".getBytes(StandardCharsets.UTF_8); + try { + codec.fromBytes(invalid); + fail("Expected RuntimeException"); + } catch (RuntimeException e) { + assertTrue( + "Exception should mention deserialization failure", + e.getMessage().contains("deserialize")); + } + } + + @Test + public void fromBytes_emptyArray_throwsRuntimeException() { + try { + codec.fromBytes(new byte[0]); + fail("Expected RuntimeException"); + } catch (RuntimeException e) { + assertNotNull("Exception message should not be null", e.getMessage()); + assertTrue( + "Exception should mention deserialization failure", + e.getMessage().contains("deserialize")); + } + } + + @Test + public void fromBytes_javaSerializedWrongType_throwsRuntimeException() throws Exception { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (ObjectOutputStream oos = new ObjectOutputStream(baos)) { + oos.writeObject("not a Configuration"); + } + byte[] bytes = baos.toByteArray(); + try { + codec.fromBytes(bytes); + fail("Expected RuntimeException (deserialized object is not correct type)"); + } catch (RuntimeException e) { + assertTrue( + "Exception should mention type mismatch", + e.getMessage().contains("not a SerializableEppoConfiguration")); + } + } + + @Test + public void roundTrip_serializeAndDeserialize_succeeds() { + SerializableEppoConfiguration original = + (SerializableEppoConfiguration) Configuration.emptyConfig(); + byte[] bytes = codec.toBytes(original); + assertNotNull(bytes); + assertTrue(bytes.length > 0); + + SerializableEppoConfiguration deserialized = codec.fromBytes(bytes); + assertNotNull(deserialized); + assertEquals("Deserialized should equal original", original, deserialized); + } +} diff --git a/android-sdk-framework/src/test/java/cloud/eppo/android/framework/storage/FileBackedByteStoreTest.java b/android-sdk-framework/src/test/java/cloud/eppo/android/framework/storage/FileBackedByteStoreTest.java new file mode 100644 index 00000000..071280f1 --- /dev/null +++ b/android-sdk-framework/src/test/java/cloud/eppo/android/framework/storage/FileBackedByteStoreTest.java @@ -0,0 +1,191 @@ +package cloud.eppo.android.framework.storage; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; + +import android.app.Application; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; + +/** Unit tests for {@link FileBackedByteStore}. */ +@RunWith(RobolectricTestRunner.class) +public class FileBackedByteStoreTest { + + private Application application; + private ConfigCacheFile cacheFile; + private FileBackedByteStore byteStore; + + @Before + public void setUp() { + application = RuntimeEnvironment.getApplication(); + cacheFile = new ConfigCacheFile(application, "test-cache-file", "application/octet-stream"); + byteStore = new FileBackedByteStore(cacheFile); + + cacheFile.delete(); + } + + @After + public void tearDown() { + cacheFile.delete(); + } + + @Test(expected = IllegalArgumentException.class) + public void testConstructorNullCacheFile() { + new FileBackedByteStore(null); + } + + @Test(expected = IllegalArgumentException.class) + public void testWriteNullBytes() { + byteStore.write(null); + } + + @Test + public void testReadNonExistentReturnsNull() throws Exception { + CompletableFuture future = byteStore.read(); + byte[] result = future.get(5, TimeUnit.SECONDS); + assertNull("Expected null for non-existent file", result); + } + + @Test + public void testWriteThenRead() throws Exception { + byte[] testData = "Hello, World!".getBytes(StandardCharsets.UTF_8); + + // Write data + CompletableFuture writeFuture = byteStore.write(testData); + writeFuture.get(5, TimeUnit.SECONDS); // Wait for write to complete + + // Read data back + CompletableFuture readFuture = byteStore.read(); + byte[] result = readFuture.get(5, TimeUnit.SECONDS); + + assertNotNull("Expected non-null result", result); + assertArrayEquals("Data should match", testData, result); + } + + @Test + public void testReadWriteRoundTrip() throws Exception { + byte[] originalData = "Test data for round trip".getBytes(StandardCharsets.UTF_8); + + // Write + byteStore.write(originalData).get(5, TimeUnit.SECONDS); + + // Read + byte[] readData = byteStore.read().get(5, TimeUnit.SECONDS); + + assertNotNull("Read data should not be null", readData); + assertArrayEquals("Round trip should preserve data", originalData, readData); + } + + @Test + public void testAsyncReadWrite() throws Exception { + byte[] data1 = "First write".getBytes(StandardCharsets.UTF_8); + byte[] data2 = "Second write".getBytes(StandardCharsets.UTF_8); + + // First write + byteStore.write(data1).get(5, TimeUnit.SECONDS); + byte[] read1 = byteStore.read().get(5, TimeUnit.SECONDS); + assertArrayEquals("First read should match first write", data1, read1); + + // Second write (overwrite) + byteStore.write(data2).get(5, TimeUnit.SECONDS); + byte[] read2 = byteStore.read().get(5, TimeUnit.SECONDS); + assertArrayEquals("Second read should match second write", data2, read2); + } + + @Test + public void testOverwriteExistingData() throws Exception { + byte[] initialData = "Initial content".getBytes(StandardCharsets.UTF_8); + byte[] newData = "New content".getBytes(StandardCharsets.UTF_8); + + // Write initial data + byteStore.write(initialData).get(5, TimeUnit.SECONDS); + + // Verify initial data + byte[] readInitial = byteStore.read().get(5, TimeUnit.SECONDS); + assertArrayEquals("Initial data should be readable", initialData, readInitial); + + // Overwrite with new data + byteStore.write(newData).get(5, TimeUnit.SECONDS); + + // Verify new data + byte[] readNew = byteStore.read().get(5, TimeUnit.SECONDS); + assertArrayEquals("New data should overwrite old data", newData, readNew); + } + + @Test + public void testWriteEmptyByteArray() throws Exception { + byte[] emptyData = new byte[0]; + + // Write empty array + byteStore.write(emptyData).get(5, TimeUnit.SECONDS); + + // Read back + byte[] result = byteStore.read().get(5, TimeUnit.SECONDS); + assertNotNull("Should be able to read empty array", result); + assertArrayEquals("Empty array should round trip", emptyData, result); + } + + @Test + public void testWriteLargeData() throws Exception { + // Create 1MB of test data + byte[] largeData = new byte[1024 * 1024]; + for (int i = 0; i < largeData.length; i++) { + largeData[i] = (byte) (i % 256); + } + + // Write large data + byteStore.write(largeData).get(10, TimeUnit.SECONDS); + + // Read back + byte[] result = byteStore.read().get(10, TimeUnit.SECONDS); + assertNotNull("Should be able to read large data", result); + assertArrayEquals("Large data should round trip", largeData, result); + } + + @Test + public void testReadAfterDelete() throws Exception { + byte[] testData = "Test data".getBytes(StandardCharsets.UTF_8); + + // Write data + byteStore.write(testData).get(5, TimeUnit.SECONDS); + + // Verify write + byte[] read1 = byteStore.read().get(5, TimeUnit.SECONDS); + assertNotNull("Data should exist", read1); + + // Delete file + cacheFile.delete(); + + // Read after delete should return null + byte[] read2 = byteStore.read().get(5, TimeUnit.SECONDS); + assertNull("Read after delete should return null", read2); + } + + @Test + public void testConcurrentWrites() throws Exception { + byte[] data1 = "Data 1".getBytes(StandardCharsets.UTF_8); + byte[] data2 = "Data 2".getBytes(StandardCharsets.UTF_8); + + // Submit two writes without waiting for the first to finish. IO_EXECUTOR is a single-thread + // executor, so writes are serialized and never interleaved — this tests that queued writes + // complete without error and that the last-submitted write wins (data2 is always the result). + CompletableFuture write1 = byteStore.write(data1); + CompletableFuture write2 = byteStore.write(data2); + + // Wait for both to complete + CompletableFuture.allOf(write1, write2).get(5, TimeUnit.SECONDS); + + // The executor serializes writes in submission order, so data2 always wins. + byte[] result = byteStore.read().get(5, TimeUnit.SECONDS); + assertNotNull("Result should not be null", result); + assertArrayEquals("data2 should always win due to IO_EXECUTOR serialization", data2, result); + } +} diff --git a/android-sdk-framework/src/test/java/cloud/eppo/android/framework/storage/FileBackedConfigStoreTest.java b/android-sdk-framework/src/test/java/cloud/eppo/android/framework/storage/FileBackedConfigStoreTest.java new file mode 100644 index 00000000..3da11c78 --- /dev/null +++ b/android-sdk-framework/src/test/java/cloud/eppo/android/framework/storage/FileBackedConfigStoreTest.java @@ -0,0 +1,91 @@ +package cloud.eppo.android.framework.storage; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; + +import android.app.Application; +import cloud.eppo.api.Configuration; +import java.io.File; +import java.util.concurrent.TimeUnit; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; + +/** Unit tests for {@link FileBackedConfigStore}. */ +@RunWith(RobolectricTestRunner.class) +public class FileBackedConfigStoreTest { + + private Application application; + private ConfigurationCodec codec; + private String cacheFileSuffix; + + @Before + public void setUp() { + application = RuntimeEnvironment.getApplication(); + codec = new ConfigurationCodec.Default<>(Configuration.class); + cacheFileSuffix = "test-" + java.util.UUID.randomUUID().toString().substring(0, 8); + } + + @After + public void tearDown() { + String prefix = "eppo-sdk-flags-" + cacheFileSuffix + "."; + File[] toDelete = application.getFilesDir().listFiles(f -> f.getName().startsWith(prefix)); + if (toDelete != null) { + for (File f : toDelete) { + f.delete(); + } + } + } + + @Test + public void construct_withValidArgs_succeeds() { + FileBackedConfigStore store = new FileBackedConfigStore(application, cacheFileSuffix, codec); + + assertNotNull(store); + } + + @Test + public void getConfiguration_beforeAnySave_returnsEmptyConfig() { + FileBackedConfigStore store = new FileBackedConfigStore(application, cacheFileSuffix, codec); + + Configuration config = store.getConfiguration(); + + assertNotNull(config); + assertEquals(Configuration.emptyConfig(), config); + } + + @Test + public void saveConfiguration_thenGetConfiguration_returnsSavedConfig() throws Exception { + FileBackedConfigStore store = new FileBackedConfigStore(application, cacheFileSuffix, codec); + Configuration toSave = Configuration.emptyConfig(); + + store.saveConfiguration(toSave).get(5, TimeUnit.SECONDS); + + assertEquals(toSave, store.getConfiguration()); + } + + @Test + public void loadFromStorage_whenNothingSaved_returnsNull() throws Exception { + FileBackedConfigStore store = new FileBackedConfigStore(application, cacheFileSuffix, codec); + + Configuration loaded = store.loadFromStorage().get(5, TimeUnit.SECONDS); + + assertNull(loaded); + } + + @Test + public void saveConfiguration_thenLoadFromStorage_returnsSameConfig() throws Exception { + FileBackedConfigStore store = new FileBackedConfigStore(application, cacheFileSuffix, codec); + Configuration toSave = Configuration.emptyConfig(); + + store.saveConfiguration(toSave).get(5, TimeUnit.SECONDS); + Configuration loaded = store.loadFromStorage().get(5, TimeUnit.SECONDS); + + assertNotNull(loaded); + assertEquals(toSave, loaded); + } +} diff --git a/android-sdk-framework/src/test/resources/flags-v1.json b/android-sdk-framework/src/test/resources/flags-v1.json new file mode 100644 index 00000000..5b249284 --- /dev/null +++ b/android-sdk-framework/src/test/resources/flags-v1.json @@ -0,0 +1,3382 @@ +{ + "createdAt": "2024-04-17T19:40:53.716Z", + "format": "SERVER", + "environment": { + "name": "Test" + }, + "flags": { + "empty_flag": { + "key": "empty_flag", + "enabled": true, + "variationType": "STRING", + "variations": {}, + "allocations": [], + "totalShards": 10000 + }, + "disabled_flag": { + "key": "disabled_flag", + "enabled": false, + "variationType": "INTEGER", + "variations": {}, + "allocations": [], + "totalShards": 10000 + }, + "no_allocations_flag": { + "key": "no_allocations_flag", + "enabled": true, + "variationType": "JSON", + "variations": { + "control": { + "key": "control", + "value": "{\"variant\": \"control\"}" + }, + "treatment": { + "key": "treatment", + "value": "{\"variant\": \"treatment\"}" + } + }, + "allocations": [], + "totalShards": 10000 + }, + "numeric_flag": { + "key": "numeric_flag", + "enabled": true, + "variationType": "NUMERIC", + "variations": { + "e": { + "key": "e", + "value": 2.7182818 + }, + "pi": { + "key": "pi", + "value": 3.1415926 + } + }, + "allocations": [ + { + "key": "rollout", + "splits": [ + { + "variationKey": "pi", + "shards": [] + } + ], + "doLog": true + } + ], + "totalShards": 10000 + }, + "invalid-value-flag": { + "key": "invalid-value-flag", + "enabled": true, + "variationType": "INTEGER", + "variations": { + "one": { + "key": "one", + "value": 1 + }, + "pi": { + "key": "pi", + "value": 3.1415926 + } + }, + "allocations": [ + { + "key": "valid", + "rules": [ + { + "conditions": [ + { + "attribute": "country", + "operator": "ONE_OF", + "value": [ + "Canada" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "one", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "invalid", + "rules": [], + "splits": [ + { + "variationKey": "pi", + "shards": [] + } + ], + "doLog": true + } + ], + "totalShards": 10000 + }, + "regex-flag": { + "key": "regex-flag", + "enabled": true, + "variationType": "STRING", + "variations": { + "partial-example": { + "key": "partial-example", + "value": "partial-example" + }, + "test": { + "key": "test", + "value": "test" + } + }, + "allocations": [ + { + "key": "partial-example", + "rules": [ + { + "conditions": [ + { + "attribute": "email", + "operator": "MATCHES", + "value": "@example\\.com" + } + ] + } + ], + "splits": [ + { + "variationKey": "partial-example", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "test", + "rules": [ + { + "conditions": [ + { + "attribute": "email", + "operator": "MATCHES", + "value": ".*@test\\.com" + } + ] + } + ], + "splits": [ + { + "variationKey": "test", + "shards": [] + } + ], + "doLog": true + } + ], + "totalShards": 10000 + }, + "numeric-one-of": { + "key": "numeric-one-of", + "enabled": true, + "variationType": "INTEGER", + "variations": { + "1": { + "key": "1", + "value": 1 + }, + "2": { + "key": "2", + "value": 2 + }, + "3": { + "key": "3", + "value": 3 + } + }, + "allocations": [ + { + "key": "1-for-1", + "rules": [ + { + "conditions": [ + { + "attribute": "number", + "operator": "ONE_OF", + "value": [ + "1" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "1", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "2-for-123456789", + "rules": [ + { + "conditions": [ + { + "attribute": "number", + "operator": "ONE_OF", + "value": [ + "123456789" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "2", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "3-for-not-2", + "rules": [ + { + "conditions": [ + { + "attribute": "number", + "operator": "NOT_ONE_OF", + "value": [ + "2" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "3", + "shards": [] + } + ], + "doLog": true + } + ], + "totalShards": 10000 + }, + "boolean-one-of-matches": { + "key": "boolean-one-of-matches", + "enabled": true, + "variationType": "INTEGER", + "variations": { + "1": { + "key": "1", + "value": 1 + }, + "2": { + "key": "2", + "value": 2 + }, + "3": { + "key": "3", + "value": 3 + }, + "4": { + "key": "4", + "value": 4 + }, + "5": { + "key": "5", + "value": 5 + } + }, + "allocations": [ + { + "key": "1-for-one-of", + "rules": [ + { + "conditions": [ + { + "attribute": "one_of_flag", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "1", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "2-for-matches", + "rules": [ + { + "conditions": [ + { + "attribute": "matches_flag", + "operator": "MATCHES", + "value": "true" + } + ] + } + ], + "splits": [ + { + "variationKey": "2", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "3-for-not-one-of", + "rules": [ + { + "conditions": [ + { + "attribute": "not_one_of_flag", + "operator": "NOT_ONE_OF", + "value": [ + "false" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "3", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "4-for-not-matches", + "rules": [ + { + "conditions": [ + { + "attribute": "not_matches_flag", + "operator": "NOT_MATCHES", + "value": "false" + } + ] + } + ], + "splits": [ + { + "variationKey": "4", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "5-for-matches-null", + "rules": [ + { + "conditions": [ + { + "attribute": "null_flag", + "operator": "ONE_OF", + "value": [ + "null" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "5", + "shards": [] + } + ], + "doLog": true + } + ], + "totalShards": 10000 + }, + "boolean-false-assignment": { + "key": "boolean-false-assignment", + "enabled": true, + "variationType": "BOOLEAN", + "variations": { + "false-variation": { + "key": "false-variation", + "value": false + }, + "true-variation": { + "key": "true-variation", + "value": true + } + }, + "allocations": [ + { + "key": "disable-feature", + "rules": [ + { + "conditions": [ + { + "attribute": "should_disable_feature", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "false-variation", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "enable-feature", + "rules": [ + { + "conditions": [ + { + "attribute": "should_disable_feature", + "operator": "ONE_OF", + "value": [ + "false" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "true-variation", + "shards": [] + } + ], + "doLog": true + } + ], + "totalShards": 10000 + }, + "empty-string-variation": { + "key": "empty-string-variation", + "enabled": true, + "variationType": "STRING", + "variations": { + "empty-content": { + "key": "empty-content", + "value": "" + }, + "detailed-content": { + "key": "detailed-content", + "value": "detailed_content" + } + }, + "allocations": [ + { + "key": "minimal-content", + "rules": [ + { + "conditions": [ + { + "attribute": "content_type", + "operator": "ONE_OF", + "value": [ + "minimal" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "empty-content", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "full-content", + "rules": [ + { + "conditions": [ + { + "attribute": "content_type", + "operator": "ONE_OF", + "value": [ + "full" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "detailed-content", + "shards": [] + } + ], + "doLog": true + } + ], + "totalShards": 10000 + }, + "empty_string_flag": { + "key": "empty_string_flag", + "enabled": true, + "comment": "Testing the empty string as a variation value", + "variationType": "STRING", + "variations": { + "empty_string": { + "key": "empty_string", + "value": "" + }, + "non_empty": { + "key": "non_empty", + "value": "non_empty" + } + }, + "allocations": [ + { + "key": "allocation-empty", + "rules": [ + { + "conditions": [ + { + "attribute": "country", + "operator": "MATCHES", + "value": "US" + } + ] + } + ], + "splits": [ + { + "variationKey": "empty_string", + "shards": [ + { + "salt": "allocation-empty-shards", + "ranges": [ + { + "start": 0, + "end": 10000 + } + ] + } + ] + } + ], + "doLog": true + }, + { + "key": "allocation-test", + "rules": [], + "splits": [ + { + "variationKey": "non_empty", + "shards": [ + { + "salt": "allocation-empty-shards", + "ranges": [ + { + "start": 0, + "end": 10000 + } + ] + } + ] + } + ], + "doLog": true + } + ], + "totalShards": 10000 + }, + "kill-switch": { + "key": "kill-switch", + "enabled": true, + "variationType": "BOOLEAN", + "variations": { + "on": { + "key": "on", + "value": true + }, + "off": { + "key": "off", + "value": false + } + }, + "allocations": [ + { + "key": "on-for-NA", + "rules": [ + { + "conditions": [ + { + "attribute": "country", + "operator": "ONE_OF", + "value": [ + "US", + "Canada", + "Mexico" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "on", + "shards": [ + { + "salt": "some-salt", + "ranges": [ + { + "start": 0, + "end": 10000 + } + ] + } + ] + } + ], + "doLog": true + }, + { + "key": "on-for-age-50+", + "rules": [ + { + "conditions": [ + { + "attribute": "age", + "operator": "GTE", + "value": 50 + } + ] + } + ], + "splits": [ + { + "variationKey": "on", + "shards": [ + { + "salt": "some-salt", + "ranges": [ + { + "start": 0, + "end": 10000 + } + ] + } + ] + } + ], + "doLog": true + }, + { + "key": "off-for-all", + "rules": [], + "splits": [ + { + "variationKey": "off", + "shards": [] + } + ], + "doLog": true + } + ], + "totalShards": 10000 + }, + "semver-test": { + "key": "semver-test", + "enabled": true, + "variationType": "STRING", + "variations": { + "old": { + "key": "old", + "value": "old" + }, + "current": { + "key": "current", + "value": "current" + }, + "new": { + "key": "new", + "value": "new" + } + }, + "allocations": [ + { + "key": "old-versions", + "rules": [ + { + "conditions": [ + { + "attribute": "version", + "operator": "LT", + "value": "1.5.0" + } + ] + } + ], + "splits": [ + { + "variationKey": "old", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "current-versions", + "rules": [ + { + "conditions": [ + { + "attribute": "version", + "operator": "GTE", + "value": "1.5.0" + }, + { + "attribute": "version", + "operator": "LTE", + "value": "2.2.13" + } + ] + } + ], + "splits": [ + { + "variationKey": "current", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "new-versions", + "rules": [ + { + "conditions": [ + { + "attribute": "version", + "operator": "GT", + "value": "3.1.0" + } + ] + } + ], + "splits": [ + { + "variationKey": "new", + "shards": [] + } + ], + "doLog": true + } + ], + "totalShards": 10000 + }, + "comparator-operator-test": { + "key": "comparator-operator-test", + "enabled": true, + "variationType": "STRING", + "variations": { + "small": { + "key": "small", + "value": "small" + }, + "medium": { + "key": "medium", + "value": "medium" + }, + "large": { + "key": "large", + "value": "large" + } + }, + "allocations": [ + { + "key": "small-size", + "rules": [ + { + "conditions": [ + { + "attribute": "size", + "operator": "LT", + "value": 10 + } + ] + } + ], + "splits": [ + { + "variationKey": "small", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "medium-size", + "rules": [ + { + "conditions": [ + { + "attribute": "size", + "operator": "GTE", + "value": 10 + }, + { + "attribute": "size", + "operator": "LTE", + "value": 20 + } + ] + } + ], + "splits": [ + { + "variationKey": "medium", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "large-size", + "rules": [ + { + "conditions": [ + { + "attribute": "size", + "operator": "GT", + "value": 25 + } + ] + } + ], + "splits": [ + { + "variationKey": "large", + "shards": [] + } + ], + "doLog": true + } + ], + "totalShards": 10000 + }, + "start-and-end-date-test": { + "key": "start-and-end-date-test", + "enabled": true, + "variationType": "STRING", + "variations": { + "old": { + "key": "old", + "value": "old" + }, + "current": { + "key": "current", + "value": "current" + }, + "new": { + "key": "new", + "value": "new" + } + }, + "allocations": [ + { + "key": "old-versions", + "splits": [ + { + "variationKey": "old", + "shards": [] + } + ], + "endAt": "2002-10-31T09:00:00.594Z", + "doLog": true + }, + { + "key": "future-versions", + "splits": [ + { + "variationKey": "future", + "shards": [] + } + ], + "startAt": "2052-10-31T09:00:00.594Z", + "doLog": true + }, + { + "key": "current-versions", + "splits": [ + { + "variationKey": "current", + "shards": [] + } + ], + "startAt": "2022-10-31T09:00:00.594Z", + "endAt": "2050-10-31T09:00:00.594Z", + "doLog": true + } + ], + "totalShards": 10000 + }, + "null-operator-test": { + "key": "null-operator-test", + "enabled": true, + "variationType": "STRING", + "variations": { + "old": { + "key": "old", + "value": "old" + }, + "new": { + "key": "new", + "value": "new" + } + }, + "allocations": [ + { + "key": "null-operator", + "rules": [ + { + "conditions": [ + { + "attribute": "size", + "operator": "IS_NULL", + "value": true + } + ] + }, + { + "conditions": [ + { + "attribute": "size", + "operator": "LT", + "value": 10 + } + ] + } + ], + "splits": [ + { + "variationKey": "old", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "not-null-operator", + "rules": [ + { + "conditions": [ + { + "attribute": "size", + "operator": "IS_NULL", + "value": false + } + ] + } + ], + "splits": [ + { + "variationKey": "new", + "shards": [] + } + ], + "doLog": true + } + ], + "totalShards": 10000 + }, + "new-user-onboarding": { + "key": "new-user-onboarding", + "enabled": true, + "variationType": "STRING", + "variations": { + "control": { + "key": "control", + "value": "control" + }, + "red": { + "key": "red", + "value": "red" + }, + "blue": { + "key": "blue", + "value": "blue" + }, + "green": { + "key": "green", + "value": "green" + }, + "yellow": { + "key": "yellow", + "value": "yellow" + }, + "purple": { + "key": "purple", + "value": "purple" + } + }, + "allocations": [ + { + "key": "id rule", + "rules": [ + { + "conditions": [ + { + "attribute": "id", + "operator": "MATCHES", + "value": "zach" + } + ] + } + ], + "splits": [ + { + "variationKey": "purple", + "shards": [] + } + ], + "doLog": false + }, + { + "key": "internal users", + "rules": [ + { + "conditions": [ + { + "attribute": "email", + "operator": "MATCHES", + "value": "@mycompany.com" + } + ] + } + ], + "splits": [ + { + "variationKey": "green", + "shards": [] + } + ], + "doLog": false + }, + { + "key": "experiment", + "rules": [ + { + "conditions": [ + { + "attribute": "country", + "operator": "NOT_ONE_OF", + "value": [ + "US", + "Canada", + "Mexico" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "control", + "shards": [ + { + "salt": "traffic-new-user-onboarding-experiment", + "ranges": [ + { + "start": 0, + "end": 6000 + } + ] + }, + { + "salt": "split-new-user-onboarding-experiment", + "ranges": [ + { + "start": 0, + "end": 5000 + } + ] + } + ] + }, + { + "variationKey": "red", + "shards": [ + { + "salt": "traffic-new-user-onboarding-experiment", + "ranges": [ + { + "start": 0, + "end": 6000 + } + ] + }, + { + "salt": "split-new-user-onboarding-experiment", + "ranges": [ + { + "start": 5000, + "end": 8000 + } + ] + } + ] + }, + { + "variationKey": "yellow", + "shards": [ + { + "salt": "traffic-new-user-onboarding-experiment", + "ranges": [ + { + "start": 0, + "end": 6000 + } + ] + }, + { + "salt": "split-new-user-onboarding-experiment", + "ranges": [ + { + "start": 8000, + "end": 10000 + } + ] + } + ] + } + ], + "doLog": true + }, + { + "key": "rollout", + "rules": [ + { + "conditions": [ + { + "attribute": "country", + "operator": "ONE_OF", + "value": [ + "US", + "Canada", + "Mexico" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "blue", + "shards": [ + { + "salt": "split-new-user-onboarding-rollout", + "ranges": [ + { + "start": 0, + "end": 8000 + } + ] + } + ], + "extraLogging": { + "allocationvalue_type": "rollout", + "owner": "hippo" + } + } + ], + "doLog": true + } + ], + "totalShards": 10000 + }, + "falsy-value-assignments": { + "key": "falsy-value-assignments", + "enabled": true, + "variationType": "INTEGER", + "variations": { + "zero-limit": { + "key": "zero-limit", + "value": 0 + }, + "premium-limit": { + "key": "premium-limit", + "value": 100 + } + }, + "allocations": [ + { + "key": "free-tier-limit", + "rules": [ + { + "conditions": [ + { + "attribute": "plan_tier", + "operator": "ONE_OF", + "value": [ + "free" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "zero-limit", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "premium-tier-limit", + "rules": [ + { + "conditions": [ + { + "attribute": "plan_tier", + "operator": "ONE_OF", + "value": [ + "premium" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "premium-limit", + "shards": [] + } + ], + "doLog": true + } + ], + "totalShards": 10000 + }, + "integer-flag": { + "key": "integer-flag", + "enabled": true, + "variationType": "INTEGER", + "variations": { + "one": { + "key": "one", + "value": 1 + }, + "two": { + "key": "two", + "value": 2 + }, + "three": { + "key": "three", + "value": 3 + } + }, + "allocations": [ + { + "key": "targeted allocation", + "rules": [ + { + "conditions": [ + { + "attribute": "country", + "operator": "ONE_OF", + "value": [ + "US", + "Canada", + "Mexico" + ] + } + ] + }, + { + "conditions": [ + { + "attribute": "email", + "operator": "MATCHES", + "value": ".*@example.com" + } + ] + } + ], + "splits": [ + { + "variationKey": "three", + "shards": [ + { + "salt": "full-range-salt", + "ranges": [ + { + "start": 0, + "end": 10000 + } + ] + } + ] + } + ], + "doLog": true + }, + { + "key": "50/50 split", + "rules": [], + "splits": [ + { + "variationKey": "one", + "shards": [ + { + "salt": "split-numeric-flag-some-allocation", + "ranges": [ + { + "start": 0, + "end": 5000 + } + ] + } + ] + }, + { + "variationKey": "two", + "shards": [ + { + "salt": "split-numeric-flag-some-allocation", + "ranges": [ + { + "start": 5000, + "end": 10000 + } + ] + } + ] + } + ], + "doLog": true + } + ], + "totalShards": 10000 + }, + "json-config-flag": { + "key": "json-config-flag", + "enabled": true, + "variationType": "JSON", + "variations": { + "one": { + "key": "one", + "value": "{ \"integer\": 1, \"string\": \"one\", \"float\": 1.0 }" + }, + "two": { + "key": "two", + "value": "{ \"integer\": 2, \"string\": \"two\", \"float\": 2.0 }" + }, + "empty": { + "key": "empty", + "value": "{}" + } + }, + "allocations": [ + { + "key": "Optionally Force Empty", + "rules": [ + { + "conditions": [ + { + "attribute": "Force Empty", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "empty", + "shards": [ + { + "salt": "full-range-salt", + "ranges": [ + { + "start": 0, + "end": 10000 + } + ] + } + ] + } + ], + "doLog": true + }, + { + "key": "50/50 split", + "rules": [], + "splits": [ + { + "variationKey": "one", + "shards": [ + { + "salt": "traffic-json-flag", + "ranges": [ + { + "start": 0, + "end": 10000 + } + ] + }, + { + "salt": "split-json-flag", + "ranges": [ + { + "start": 0, + "end": 5000 + } + ] + } + ] + }, + { + "variationKey": "two", + "shards": [ + { + "salt": "traffic-json-flag", + "ranges": [ + { + "start": 0, + "end": 10000 + } + ] + }, + { + "salt": "split-json-flag", + "ranges": [ + { + "start": 5000, + "end": 10000 + } + ] + } + ] + } + ], + "doLog": true + } + ], + "totalShards": 10000 + }, + "special-characters": { + "key": "special-characters", + "enabled": true, + "variationType": "JSON", + "variations": { + "de": { + "key": "de", + "value": "{\"a\": \"kümmert\", \"b\": \"schön\"}" + }, + "ua": { + "key": "ua", + "value": "{\"a\": \"піклуватися\", \"b\": \"любов\"}" + }, + "zh": { + "key": "zh", + "value": "{\"a\": \"照顾\", \"b\": \"漂亮\"}" + }, + "emoji": { + "key": "emoji", + "value": "{\"a\": \"🤗\", \"b\": \"🌸\"}" + } + }, + "totalShards": 10000, + "allocations": [ + { + "key": "allocation-test", + "splits": [ + { + "variationKey": "de", + "shards": [ + { + "salt": "split-json-flag", + "ranges": [ + { + "start": 0, + "end": 2500 + } + ] + } + ] + }, + { + "variationKey": "ua", + "shards": [ + { + "salt": "split-json-flag", + "ranges": [ + { + "start": 2500, + "end": 5000 + } + ] + } + ] + }, + { + "variationKey": "zh", + "shards": [ + { + "salt": "split-json-flag", + "ranges": [ + { + "start": 5000, + "end": 7500 + } + ] + } + ] + }, + { + "variationKey": "emoji", + "shards": [ + { + "salt": "split-json-flag", + "ranges": [ + { + "start": 7500, + "end": 10000 + } + ] + } + ] + } + ], + "doLog": true + }, + { + "key": "allocation-default", + "splits": [ + { + "variationKey": "de", + "shards": [] + } + ], + "doLog": false + } + ] + }, + "string_flag_with_special_characters": { + "key": "string_flag_with_special_characters", + "enabled": true, + "comment": "Testing the string with special characters and spaces", + "variationType": "STRING", + "variations": { + "string_with_spaces": { + "key": "string_with_spaces", + "value": " a b c d e f " + }, + "string_with_only_one_space": { + "key": "string_with_only_one_space", + "value": " " + }, + "string_with_only_multiple_spaces": { + "key": "string_with_only_multiple_spaces", + "value": " " + }, + "string_with_dots": { + "key": "string_with_dots", + "value": ".a.b.c.d.e.f." + }, + "string_with_only_one_dot": { + "key": "string_with_only_one_dot", + "value": "." + }, + "string_with_only_multiple_dots": { + "key": "string_with_only_multiple_dots", + "value": "......." + }, + "string_with_comas": { + "key": "string_with_comas", + "value": ",a,b,c,d,e,f," + }, + "string_with_only_one_coma": { + "key": "string_with_only_one_coma", + "value": "," + }, + "string_with_only_multiple_comas": { + "key": "string_with_only_multiple_comas", + "value": ",,,,,,," + }, + "string_with_colons": { + "key": "string_with_colons", + "value": ":a:b:c:d:e:f:" + }, + "string_with_only_one_colon": { + "key": "string_with_only_one_colon", + "value": ":" + }, + "string_with_only_multiple_colons": { + "key": "string_with_only_multiple_colons", + "value": ":::::::" + }, + "string_with_semicolons": { + "key": "string_with_semicolons", + "value": ";a;b;c;d;e;f;" + }, + "string_with_only_one_semicolon": { + "key": "string_with_only_one_semicolon", + "value": ";" + }, + "string_with_only_multiple_semicolons": { + "key": "string_with_only_multiple_semicolons", + "value": ";;;;;;;" + }, + "string_with_slashes": { + "key": "string_with_slashes", + "value": "/a/b/c/d/e/f/" + }, + "string_with_only_one_slash": { + "key": "string_with_only_one_slash", + "value": "/" + }, + "string_with_only_multiple_slashes": { + "key": "string_with_only_multiple_slashes", + "value": "///////" + }, + "string_with_dashes": { + "key": "string_with_dashes", + "value": "-a-b-c-d-e-f-" + }, + "string_with_only_one_dash": { + "key": "string_with_only_one_dash", + "value": "-" + }, + "string_with_only_multiple_dashes": { + "key": "string_with_only_multiple_dashes", + "value": "-------" + }, + "string_with_underscores": { + "key": "string_with_underscores", + "value": "_a_b_c_d_e_f_" + }, + "string_with_only_one_underscore": { + "key": "string_with_only_one_underscore", + "value": "_" + }, + "string_with_only_multiple_underscores": { + "key": "string_with_only_multiple_underscores", + "value": "_______" + }, + "string_with_plus_signs": { + "key": "string_with_plus_signs", + "value": "+a+b+c+d+e+f+" + }, + "string_with_only_one_plus_sign": { + "key": "string_with_only_one_plus_sign", + "value": "+" + }, + "string_with_only_multiple_plus_signs": { + "key": "string_with_only_multiple_plus_signs", + "value": "+++++++" + }, + "string_with_equal_signs": { + "key": "string_with_equal_signs", + "value": "=a=b=c=d=e=f=" + }, + "string_with_only_one_equal_sign": { + "key": "string_with_only_one_equal_sign", + "value": "=" + }, + "string_with_only_multiple_equal_signs": { + "key": "string_with_only_multiple_equal_signs", + "value": "=======" + }, + "string_with_dollar_signs": { + "key": "string_with_dollar_signs", + "value": "$a$b$c$d$e$f$" + }, + "string_with_only_one_dollar_sign": { + "key": "string_with_only_one_dollar_sign", + "value": "$" + }, + "string_with_only_multiple_dollar_signs": { + "key": "string_with_only_multiple_dollar_signs", + "value": "$$$$$$$" + }, + "string_with_at_signs": { + "key": "string_with_at_signs", + "value": "@a@b@c@d@e@f@" + }, + "string_with_only_one_at_sign": { + "key": "string_with_only_one_at_sign", + "value": "@" + }, + "string_with_only_multiple_at_signs": { + "key": "string_with_only_multiple_at_signs", + "value": "@@@@@@@" + }, + "string_with_amp_signs": { + "key": "string_with_amp_signs", + "value": "&a&b&c&d&e&f&" + }, + "string_with_only_one_amp_sign": { + "key": "string_with_only_one_amp_sign", + "value": "&" + }, + "string_with_only_multiple_amp_signs": { + "key": "string_with_only_multiple_amp_signs", + "value": "&&&&&&&" + }, + "string_with_hash_signs": { + "key": "string_with_hash_signs", + "value": "#a#b#c#d#e#f#" + }, + "string_with_only_one_hash_sign": { + "key": "string_with_only_one_hash_sign", + "value": "#" + }, + "string_with_only_multiple_hash_signs": { + "key": "string_with_only_multiple_hash_signs", + "value": "#######" + }, + "string_with_percentage_signs": { + "key": "string_with_percentage_signs", + "value": "%a%b%c%d%e%f%" + }, + "string_with_only_one_percentage_sign": { + "key": "string_with_only_one_percentage_sign", + "value": "%" + }, + "string_with_only_multiple_percentage_signs": { + "key": "string_with_only_multiple_percentage_signs", + "value": "%%%%%%%" + }, + "string_with_tilde_signs": { + "key": "string_with_tilde_signs", + "value": "~a~b~c~d~e~f~" + }, + "string_with_only_one_tilde_sign": { + "key": "string_with_only_one_tilde_sign", + "value": "~" + }, + "string_with_only_multiple_tilde_signs": { + "key": "string_with_only_multiple_tilde_signs", + "value": "~~~~~~~" + }, + "string_with_asterix_signs": { + "key": "string_with_asterix_signs", + "value": "*a*b*c*d*e*f*" + }, + "string_with_only_one_asterix_sign": { + "key": "string_with_only_one_asterix_sign", + "value": "*" + }, + "string_with_only_multiple_asterix_signs": { + "key": "string_with_only_multiple_asterix_signs", + "value": "*******" + }, + "string_with_single_quotes": { + "key": "string_with_single_quotes", + "value": "'a'b'c'd'e'f'" + }, + "string_with_only_one_single_quote": { + "key": "string_with_only_one_single_quote", + "value": "'" + }, + "string_with_only_multiple_single_quotes": { + "key": "string_with_only_multiple_single_quotes", + "value": "'''''''" + }, + "string_with_question_marks": { + "key": "string_with_question_marks", + "value": "?a?b?c?d?e?f?" + }, + "string_with_only_one_question_mark": { + "key": "string_with_only_one_question_mark", + "value": "?" + }, + "string_with_only_multiple_question_marks": { + "key": "string_with_only_multiple_question_marks", + "value": "???????" + }, + "string_with_exclamation_marks": { + "key": "string_with_exclamation_marks", + "value": "!a!b!c!d!e!f!" + }, + "string_with_only_one_exclamation_mark": { + "key": "string_with_only_one_exclamation_mark", + "value": "!" + }, + "string_with_only_multiple_exclamation_marks": { + "key": "string_with_only_multiple_exclamation_marks", + "value": "!!!!!!!" + }, + "string_with_opening_parentheses": { + "key": "string_with_opening_parentheses", + "value": "(a(b(c(d(e(f(" + }, + "string_with_only_one_opening_parenthese": { + "key": "string_with_only_one_opening_parenthese", + "value": "(" + }, + "string_with_only_multiple_opening_parentheses": { + "key": "string_with_only_multiple_opening_parentheses", + "value": "(((((((" + }, + "string_with_closing_parentheses": { + "key": "string_with_closing_parentheses", + "value": ")a)b)c)d)e)f)" + }, + "string_with_only_one_closing_parenthese": { + "key": "string_with_only_one_closing_parenthese", + "value": ")" + }, + "string_with_only_multiple_closing_parentheses": { + "key": "string_with_only_multiple_closing_parentheses", + "value": ")))))))" + } + }, + "totalShards": 10000, + "allocations": [ + { + "key": "allocation-test-string_with_spaces", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_spaces", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_spaces", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_one_space", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_one_space", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_one_space", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_multiple_spaces", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_multiple_spaces", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_multiple_spaces", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_dots", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_dots", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_dots", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_one_dot", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_one_dot", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_one_dot", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_multiple_dots", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_multiple_dots", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_multiple_dots", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_comas", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_comas", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_comas", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_one_coma", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_one_coma", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_one_coma", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_multiple_comas", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_multiple_comas", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_multiple_comas", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_colons", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_colons", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_colons", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_one_colon", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_one_colon", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_one_colon", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_multiple_colons", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_multiple_colons", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_multiple_colons", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_semicolons", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_semicolons", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_semicolons", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_one_semicolon", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_one_semicolon", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_one_semicolon", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_multiple_semicolons", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_multiple_semicolons", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_multiple_semicolons", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_slashes", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_slashes", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_slashes", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_one_slash", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_one_slash", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_one_slash", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_multiple_slashes", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_multiple_slashes", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_multiple_slashes", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_dashes", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_dashes", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_dashes", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_one_dash", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_one_dash", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_one_dash", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_multiple_dashes", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_multiple_dashes", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_multiple_dashes", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_underscores", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_underscores", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_underscores", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_one_underscore", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_one_underscore", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_one_underscore", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_multiple_underscores", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_multiple_underscores", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_multiple_underscores", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_plus_signs", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_plus_signs", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_plus_signs", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_one_plus_sign", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_one_plus_sign", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_one_plus_sign", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_multiple_plus_signs", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_multiple_plus_signs", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_multiple_plus_signs", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_equal_signs", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_equal_signs", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_equal_signs", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_one_equal_sign", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_one_equal_sign", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_one_equal_sign", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_multiple_equal_signs", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_multiple_equal_signs", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_multiple_equal_signs", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_dollar_signs", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_dollar_signs", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_dollar_signs", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_one_dollar_sign", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_one_dollar_sign", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_one_dollar_sign", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_multiple_dollar_signs", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_multiple_dollar_signs", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_multiple_dollar_signs", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_at_signs", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_at_signs", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_at_signs", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_one_at_sign", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_one_at_sign", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_one_at_sign", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_multiple_at_signs", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_multiple_at_signs", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_multiple_at_signs", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_amp_signs", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_amp_signs", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_amp_signs", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_one_amp_sign", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_one_amp_sign", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_one_amp_sign", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_multiple_amp_signs", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_multiple_amp_signs", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_multiple_amp_signs", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_hash_signs", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_hash_signs", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_hash_signs", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_one_hash_sign", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_one_hash_sign", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_one_hash_sign", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_multiple_hash_signs", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_multiple_hash_signs", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_multiple_hash_signs", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_percentage_signs", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_percentage_signs", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_percentage_signs", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_one_percentage_sign", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_one_percentage_sign", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_one_percentage_sign", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_multiple_percentage_signs", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_multiple_percentage_signs", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_multiple_percentage_signs", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_tilde_signs", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_tilde_signs", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_tilde_signs", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_one_tilde_sign", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_one_tilde_sign", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_one_tilde_sign", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_multiple_tilde_signs", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_multiple_tilde_signs", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_multiple_tilde_signs", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_asterix_signs", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_asterix_signs", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_asterix_signs", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_one_asterix_sign", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_one_asterix_sign", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_one_asterix_sign", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_multiple_asterix_signs", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_multiple_asterix_signs", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_multiple_asterix_signs", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_single_quotes", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_single_quotes", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_single_quotes", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_one_single_quote", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_one_single_quote", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_one_single_quote", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_multiple_single_quotes", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_multiple_single_quotes", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_multiple_single_quotes", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_question_marks", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_question_marks", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_question_marks", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_one_question_mark", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_one_question_mark", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_one_question_mark", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_multiple_question_marks", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_multiple_question_marks", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_multiple_question_marks", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_exclamation_marks", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_exclamation_marks", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_exclamation_marks", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_one_exclamation_mark", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_one_exclamation_mark", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_one_exclamation_mark", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_multiple_exclamation_marks", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_multiple_exclamation_marks", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_multiple_exclamation_marks", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_opening_parentheses", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_opening_parentheses", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_opening_parentheses", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_one_opening_parenthese", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_one_opening_parenthese", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_one_opening_parenthese", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_multiple_opening_parentheses", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_multiple_opening_parentheses", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_multiple_opening_parentheses", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_closing_parentheses", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_closing_parentheses", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_closing_parentheses", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_one_closing_parenthese", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_one_closing_parenthese", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_one_closing_parenthese", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_multiple_closing_parentheses", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_multiple_closing_parentheses", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_multiple_closing_parentheses", + "shards": [] + } + ], + "doLog": true + } + ] + } + } +} diff --git a/eppo/src/main/java/cloud/eppo/android/EppoClient.java b/eppo/src/main/java/cloud/eppo/android/EppoClient.java index 2090c2d6..8dd02355 100644 --- a/eppo/src/main/java/cloud/eppo/android/EppoClient.java +++ b/eppo/src/main/java/cloud/eppo/android/EppoClient.java @@ -34,7 +34,7 @@ public class EppoClient extends BaseEppoClient { private long pollingIntervalMs, pollingJitterMs; - @Nullable private static EppoClient instance; + @Nullable private static volatile EppoClient instance; private EppoClient( String apiKey, @@ -352,15 +352,19 @@ public CompletableFuture buildAndInitAsync() { .getInitialConfigFuture() .handle( (success, ex) -> { - if (ex == null && success) { + if (ex == null && Boolean.TRUE.equals(success)) { ret.complete(instance); } else if (offlineMode || failCount.incrementAndGet() == 2) { ret.completeExceptionally( new EppoInitializationException( "Unable to initialize client; Configuration could not be loaded", ex)); } else { + // Initial config was not used (cache miss or parse failure). failCount was + // already incremented to 1 by the else-if condition check above (side + // effect of `failCount.incrementAndGet() == 2` evaluating false). When the + // HTTP fetch also fails its handler will increment failCount to 2 and + // complete ret exceptionally. No additional increment is needed here. Log.d(TAG, "Initial config was not used."); - failCount.incrementAndGet(); } return null; }); diff --git a/settings.gradle b/settings.gradle index fc9b6edb..a31545d6 100644 --- a/settings.gradle +++ b/settings.gradle @@ -19,3 +19,4 @@ dependencyResolutionManagement { rootProject.name = "Eppo SDK" include ':example' include ':eppo' +include ':android-sdk-framework' \ No newline at end of file