Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
161 changes: 161 additions & 0 deletions android-sdk-framework/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
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'

implementation 'org.slf4j:slf4j-android:1.7.36'
testImplementation 'com.google.code.gson:gson:2.10.1'
compileOnly 'org.jetbrains:annotations:24.0.0'

testImplementation 'cloud.eppo:sdk-common-jvm:4.0.0-SNAPSHOT'
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 checkVersion {
doLast {
if (!project.hasProperty('release') && !project.hasProperty('snapshot')) {
throw new GradleException("You must specify either -Prelease or -Psnapshot")
}
if (project.hasProperty('release') && project.version.endsWith('SNAPSHOT')) {
throw new GradleException("You cannot specify -Prelease with a SNAPSHOT version")
}
if (project.hasProperty('snapshot') && !project.version.endsWith('SNAPSHOT')) {
throw new GradleException("You cannot specify -Psnapshot with a non-SNAPSHOT version")
}
project.ext.shouldPublish = true
}
}

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

tasks.withType(PublishToMavenLocal).configureEach {
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,231 @@
package cloud.eppo.android.framework;

import static cloud.eppo.android.framework.util.Utils.logTag;
import static org.junit.Assert.assertNotNull;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.atMost;
import static org.mockito.Mockito.reset;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

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

/**
* Tests for EppoClient polling pause/resume functionality.
*
* <p>These tests focus on verifying that pausePolling() and resumePolling() can be called safely in
* various sequences, and that polling actually stops and resumes as expected.
*/
public class EppoClientPollingTest {
private static final String TAG = logTag(EppoClientPollingTest.class);
private static final String DUMMY_API_KEY = "mock-api-key";

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

// Tracks the last built client so tearDown can stop its polling timer.
private AndroidBaseClient<JsonNode> 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 AndroidBaseClient<JsonNode> buildOfflineClient(
boolean pollingEnabled, long pollingIntervalMs)
throws ExecutionException, InterruptedException {
CompletableFuture<Configuration> initialConfig =
CompletableFuture.completedFuture(Configuration.emptyConfig());

AndroidBaseClient.Builder<JsonNode> builder =
new AndroidBaseClient.Builder<>(
DUMMY_API_KEY,
ApplicationProvider.getApplicationContext(),
mockConfigParser,
mockConfigClient)
.forceReinitialize(true)
.offlineMode(true)
.initialConfiguration(initialConfig)
.pollingEnabled(pollingEnabled)
.isGracefulMode(true);

if (pollingEnabled) {
builder.pollingIntervalMs(pollingIntervalMs);
}

lastClient = builder.buildAndInitAsync().get();
return lastClient;
}

@Test
public void testPauseAndResumePolling() throws ExecutionException, InterruptedException {
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.

// 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<Configuration> initialConfig =
CompletableFuture.completedFuture(Configuration.emptyConfig());

lastClient =
new AndroidBaseClient.Builder<>(
DUMMY_API_KEY,
ApplicationProvider.getApplicationContext(),
mockConfigParser,
mockConfigClient)
.forceReinitialize(true)
.initialConfiguration(initialConfig)
.pollingEnabled(true)
.pollingIntervalMs(50)
.isGracefulMode(true)
.buildAndInitAsync()
.get();

assertNotNull("Client should be initialized", lastClient);

// Wait for at least one polling cycle to fire (50ms interval, wait 150ms).
Thread.sleep(150);
verify(mockConfigClient, atLeastOnce()).execute(any(EppoConfigurationRequest.class));

// Pause: stopPolling() calls cancel(false), which does not interrupt a task already running
// on the executor thread. At most one in-flight invocation can complete after pausePolling()
// returns, so we tolerate atMost(1) rather than never().
lastClient.pausePolling();
reset(mockConfigClient);
when(mockConfigClient.execute(any(EppoConfigurationRequest.class)))
.thenReturn(CompletableFuture.completedFuture(EppoConfigurationResponse.error(503, null)));
Thread.sleep(200); // wait 4 intervals — polling must be stopped
verify(mockConfigClient, atMost(1)).execute(any(EppoConfigurationRequest.class));

// Resume: polling fires again within one interval.
lastClient.resumePolling();
Thread.sleep(150);
verify(mockConfigClient, atLeastOnce()).execute(any(EppoConfigurationRequest.class));

lastClient.pausePolling();
}

@Test
public void testResumePollingWithoutStarting() throws ExecutionException, InterruptedException {
AndroidBaseClient<JsonNode> 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();
Log.d(TAG, "Resume called without starting - should log warning");

Thread.sleep(50);
}

@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 = buildOfflineClient(true, 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);

// 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 = buildOfflineClient(true, 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 = 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 testPauseAfterInitDoesNotCrash() throws ExecutionException, InterruptedException {
AndroidBaseClient<JsonNode> androidBaseClient = buildOfflineClient(true, 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