Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
b82fbbf
feat: add android-sdk-framework module
typotter Mar 12, 2026
bbb0003
fix: update Maven snapshot repository URL to new Maven Central
typotter Mar 12, 2026
45934db
fix: address code review issues in android-sdk-framework
typotter Mar 23, 2026
882786a
fix: address Copilot review feedback on android-sdk-framework
typotter Mar 24, 2026
7944817
style: fix spotless formatting in AndroidBaseClient
typotter Mar 24, 2026
9aa5bd6
fix: address muse review feedback on android-sdk-framework
typotter Apr 16, 2026
264661f
fix: harden android-sdk-framework against deserialization and improve…
typotter Apr 16, 2026
aa31c74
fix: remove ObjectInputFilter — not available in Android SDK
typotter Apr 16, 2026
d4f57b6
docs: add CLAUDE.md, fix .gitignore credential exclusions, update Mak…
typotter Apr 22, 2026
2099f50
fix: address code review feedback on android-sdk-framework
typotter Apr 22, 2026
5bda22e
fix: make CachingConfigurationStore.seedCache() public
typotter Apr 29, 2026
62fc396
refactor: encapsulate seedCache() behind loadAndSeedFromStorage()
typotter Apr 30, 2026
edb6b14
fix: address code review feedback on android-sdk-framework
typotter May 8, 2026
4e501d9
refactor: remove singleton from BaseAndroidClient
typotter May 8, 2026
8d16eeb
fix: deadlock in loadAndSeedFromStorage() with single-thread IO executor
typotter May 8, 2026
99dfdcb
fix: add null checks to BaseCacheFile constructor
typotter May 8, 2026
6bc6813
fix: remove spurious failCount double-increment in buildAndInitAsync
typotter May 29, 2026
639dad6
fix: null-safe Boolean comparison and clarify failCount coordination
typotter May 29, 2026
3ea6600
fix: address threading and resource management feedback on android-sd…
typotter May 29, 2026
05d2ff5
fix: return uninitialized client instead of null in graceful-mode fal…
typotter May 29, 2026
09d03e0
style: fix spotless formatting in BaseAndroidClient
typotter May 29, 2026
bfdc0d9
chore: use 0.1.0-SNAPSHOT version until upstream deps release
typotter May 29, 2026
cbde4f7
refactor: rename BaseAndroidClient to BaseAndroidEppoClient
typotter May 29, 2026
c1e373c
fix: propagate close() through FileBackedConfigStore to release execu…
typotter May 29, 2026
7f26560
chore: address minor review feedback
typotter May 29, 2026
0bf7884
style: apply spotless formatting fixes
typotter May 29, 2026
e151d8d
test: consolidate polling tests and add mock verification
typotter Jun 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
156 changes: 156 additions & 0 deletions android-sdk-framework/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
plugins {
id 'com.android.library'
id 'maven-publish'
id "com.vanniktech.maven.publish" version "0.32.0"
id 'signing'
id "com.diffplug.spotless" version "8.0.0"
}

group = "cloud.eppo"
version = "0.1.0"

android {
namespace "cloud.eppo.android.framework"
compileSdk 34

buildFeatures.buildConfig true

defaultConfig {
minSdk 26
targetSdk 34
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}

buildTypes {
def FRAMEWORK_VERSION = "FRAMEWORK_VERSION"
// 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'

api 'com.google.code.gson:gson:2.10.1'

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is now a test dependency only (isn't that the whole point of all of this 😅 )

api 'org.slf4j:slf4j-android:1.7.36'

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't see this in the code, should this be runtimeOnly?

compileOnly 'org.jetbrains:annotations:24.0.0'

testImplementation 'cloud.eppo:sdk-common-jvm:4.0.0-SNAPSHOT'
Comment on lines +51 to +58
testImplementation 'junit:junit:4.13.2'
testImplementation 'org.mockito:mockito-core:5.14.2'
testImplementation 'org.robolectric:robolectric:4.12.1'

androidTestImplementation 'junit:junit:4.13.2'
androidTestImplementation 'org.mockito:mockito-android:5.14.2'
androidTestImplementation 'androidx.test.ext:junit:1.2.1'
androidTestImplementation 'androidx.test:core:1.6.1'
androidTestImplementation 'androidx.test:runner:1.6.2'
androidTestImplementation 'com.fasterxml.jackson.core:jackson-databind:2.19.1'
}

spotless {
format 'misc', {
target '*.gradle', '.gitattributes', '.gitignore'

trimTrailingWhitespace()
leadingTabsToSpaces(2)
endWithNewline()
}
java {
target '**/*.java'

googleJavaFormat()
formatAnnotations()
}
}

signing {
if (System.getenv("GPG_PRIVATE_KEY") && System.getenv("GPG_PASSPHRASE")) {
useInMemoryPgpKeys(System.env.GPG_PRIVATE_KEY, System.env.GPG_PASSPHRASE)
}

sign publishing.publications
}

tasks.withType(Sign) {
onlyIf {
(System.getenv("GPG_PRIVATE_KEY") && System.getenv("GPG_PASSPHRASE")) ||
(project.hasProperty('signing.keyId') &&
project.hasProperty('signing.password') &&
project.hasProperty('signing.secretKeyRingFile'))
}
}

mavenPublishing {
publishToMavenCentral(com.vanniktech.maven.publish.SonatypeHost.CENTRAL_PORTAL)
signAllPublications()
coordinates("cloud.eppo", "android-sdk-framework", project.version)

pom {
name = 'Eppo Android SDK Framework'
description = 'Android SDK Framework for Eppo - Library-independent EppoClient and PrecomputedEppoClient (abstracts JSON, HTTP, storage)'
url = 'https://github.com/Eppo-exp/android-sdk'
licenses {
license {
name = 'MIT License'
url = 'http://www.opensource.org/licenses/mit-license.php'
}
}
developers {
developer {
name = 'Eppo'
email = 'https://www.geteppo.com'
}
}
scm {
connection = 'scm:git:git://github.com/Eppo-exp/android-sdk.git'
developerConnection = 'scm:git:ssh://github.com/Eppo-exp/android-sdk.git'
url = 'https://github.com/Eppo-exp/android-sdk/tree/main'
}
}
}

task checkVersion {
doLast {
if (!project.hasProperty('release') && !project.hasProperty('snapshot')) {
throw new GradleException("You must specify either -Prelease or -Psnapshot")
}
if (project.hasProperty('release') && project.version.endsWith('SNAPSHOT')) {
throw new GradleException("You cannot specify -Prelease with a SNAPSHOT version")
}
if (project.hasProperty('snapshot') && !project.version.endsWith('SNAPSHOT')) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Comment from AI: This is the line that makes the snapshot publish path impossible for the new module. Since version is fixed at 0.1.0, ./gradlew :android-sdk-framework:publish -Psnapshot always throws here (You cannot specify -Psnapshot with a non-SNAPSHOT version). Release publish is blocked too while the api dependency is still 0.1.0-SNAPSHOT. Could we either make the module version 0.1.0-SNAPSHOT until upstream is released, or derive the version from the snapshot/release flag so the snapshot flow can actually publish?

throw new GradleException("You cannot specify -Psnapshot with a non-SNAPSHOT version")
}
project.ext.shouldPublish = true
}
}

tasks.named('publish').configure {
dependsOn checkVersion
}

tasks.withType(PublishToMavenRepository) {
onlyIf {
project.ext.has('shouldPublish') && project.ext.shouldPublish
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
package cloud.eppo.android.framework;

import static cloud.eppo.android.framework.util.Utils.logTag;
import static org.junit.Assert.assertNotNull;

import android.util.Log;
import androidx.test.core.app.ApplicationProvider;
import cloud.eppo.api.Configuration;
import cloud.eppo.http.EppoConfigurationClient;
import cloud.eppo.parser.ConfigurationParser;
import com.fasterxml.jackson.databind.JsonNode;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import org.junit.Before;
import org.junit.Test;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;

/**
* Tests for EppoClient polling pause/resume functionality.
*
* <p>These tests use offline mode to avoid needing to mock complex configuration loading behavior.
* They focus on verifying that pausePolling() and resumePolling() can be called safely in various
* sequences.
*/
public class EppoClientPollingTest {
private static final String TAG = logTag(EppoClientPollingTest.class);
private static final String DUMMY_API_KEY = "mock-api-key";

@Mock private ConfigurationParser<JsonNode> mockConfigParser;
@Mock private EppoConfigurationClient mockConfigClient;

@Before
public void setUp() {
MockitoAnnotations.openMocks(this);
}

/**
* Builds a client in offline mode with polling enabled.
*
* @param pollingIntervalMs Polling interval in milliseconds
* @return Initialized EppoClient
*/
private AndroidBaseClient<JsonNode> buildOfflineClientWithPolling(long pollingIntervalMs)
throws ExecutionException, InterruptedException {
// Use an empty configuration for offline mode
CompletableFuture<Configuration> initialConfig =
CompletableFuture.completedFuture(Configuration.emptyConfig());

return new AndroidBaseClient.Builder<>(
DUMMY_API_KEY,
ApplicationProvider.getApplicationContext(),
mockConfigParser,
mockConfigClient)
.forceReinitialize(true)
.offlineMode(true)
.initialConfiguration(initialConfig)
.pollingEnabled(true)
.pollingIntervalMs(pollingIntervalMs)
.isGracefulMode(true) // Enable graceful mode to handle initialization issues
.buildAndInitAsync()
.get();
}

/**
* Builds a client in offline mode without polling enabled.
*
* @return Initialized EppoClient
*/
private AndroidBaseClient<JsonNode> buildOfflineClientWithoutPolling()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pretty much same as previous method but with a different boolean passed for pollingEnabled() and the call to pollingIntervalMs() Could be consolidated to a single method (with 0 pollingMs meaning no polling) or the explicitly named methods with a shared base method.

throws ExecutionException, InterruptedException {
CompletableFuture<Configuration> initialConfig =
CompletableFuture.completedFuture(Configuration.emptyConfig());

return new AndroidBaseClient.Builder<>(
DUMMY_API_KEY,
ApplicationProvider.getApplicationContext(),
mockConfigParser,
mockConfigClient)
.forceReinitialize(true)
.offlineMode(true)
.initialConfiguration(initialConfig)
.pollingEnabled(false)
.isGracefulMode(true) // Enable graceful mode to handle initialization issues
.buildAndInitAsync()
.get();
}

@Test
public void testPauseAndResumePolling() throws ExecutionException, InterruptedException {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test doesn't seem to be doing much. Consider a shorter polling interval and then pausing longer than the interval to make sure no polls happens and then check polling happened again after unpausing.

AndroidBaseClient<JsonNode> androidBaseClient = buildOfflineClientWithPolling(100);
assertNotNull("Client should be initialized", androidBaseClient);

// Test pause
androidBaseClient.pausePolling();
Log.d(TAG, "Polling paused");

// Wait a bit to ensure no crashes
Thread.sleep(50);

// Test resume
androidBaseClient.resumePolling();
Log.d(TAG, "Polling resumed");

// Wait a bit to ensure no crashes
Thread.sleep(50);

// Final pause for cleanup
androidBaseClient.pausePolling();
}

@Test
public void testResumePollingWithoutStarting() throws ExecutionException, InterruptedException {
AndroidBaseClient<JsonNode> androidBaseClient = buildOfflineClientWithoutPolling();
assertNotNull("Client should be initialized", androidBaseClient);

// Try to resume polling (should log warning and not crash per EppoClient.java:436-441)

Copilot AI Mar 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These comments reference EppoClient.java:436-441, but this module uses AndroidBaseClient and line numbers will drift over time. Consider referencing the specific method (AndroidBaseClient.resumePolling) or the expected behavior instead of a hard-coded file/line reference.

Suggested change
// Try to resume polling (should log warning and not crash per EppoClient.java:436-441)
// Try to resume polling; AndroidBaseClient.resumePolling should log a warning and not crash if polling was never started

Copilot uses AI. Check for mistakes.
androidBaseClient.resumePolling();
Log.d(TAG, "Resume called without starting - should log warning");

// Wait a bit to ensure no crashes
Thread.sleep(50);

// Should not crash or throw exception
}

@Test
public void testMultiplePauseResumeCycles() throws ExecutionException, InterruptedException {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like overkill

AndroidBaseClient<JsonNode> androidBaseClient = buildOfflineClientWithPolling(100);
assertNotNull("Client should be initialized", androidBaseClient);

// First cycle
androidBaseClient.pausePolling();
Log.d(TAG, "First pause");
Thread.sleep(50);
androidBaseClient.resumePolling();
Log.d(TAG, "First resume");
Thread.sleep(50);

// Second cycle
androidBaseClient.pausePolling();
Log.d(TAG, "Second pause");
Thread.sleep(50);
androidBaseClient.resumePolling();
Log.d(TAG, "Second resume");
Thread.sleep(50);

// Third cycle
androidBaseClient.pausePolling();
Log.d(TAG, "Third pause");
Thread.sleep(50);
androidBaseClient.resumePolling();
Log.d(TAG, "Third resume");
Thread.sleep(50);

// Final cleanup
androidBaseClient.pausePolling();
}

@Test
public void testPauseResumeSequenceDoesNotCrash()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

throws ExecutionException, InterruptedException {
AndroidBaseClient<JsonNode> androidBaseClient = buildOfflineClientWithPolling(50);

// Various sequences that should all work without crashing
androidBaseClient.pausePolling();
androidBaseClient.pausePolling(); // Double pause
Thread.sleep(50);

androidBaseClient.resumePolling();
Thread.sleep(50);

androidBaseClient.resumePolling(); // Double resume
Thread.sleep(50);

androidBaseClient.pausePolling();
androidBaseClient.resumePolling();
Thread.sleep(50);

androidBaseClient.pausePolling(); // Final pause for cleanup
}

@Test
public void testPollingNotEnabledAndResume() throws ExecutionException, InterruptedException {
AndroidBaseClient<JsonNode> androidBaseClient = buildOfflineClientWithoutPolling();

// Pause should be safe even if not polling
androidBaseClient.pausePolling();
Thread.sleep(50);

// Resume should log warning per EppoClient.java:436-441
androidBaseClient.resumePolling();
Thread.sleep(50);

// Multiple calls should all be safe
androidBaseClient.pausePolling();
androidBaseClient.resumePolling();
Thread.sleep(50);
}

@Test
public void testPauseAfterInitDoesNotCrash() throws ExecutionException, InterruptedException {
AndroidBaseClient<JsonNode> androidBaseClient = buildOfflineClientWithPolling(100);

// Immediately pause after initialization
androidBaseClient.pausePolling();
Log.d(TAG, "Paused immediately after init");
Thread.sleep(200);

// Resume
androidBaseClient.resumePolling();
Thread.sleep(200);

// Final pause
androidBaseClient.pausePolling();
}
}
2 changes: 2 additions & 0 deletions android-sdk-framework/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" />
Loading
Loading