Skip to content
Draft
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
154 changes: 154 additions & 0 deletions android-sdk-framework/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
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"
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'
api 'org.slf4j:slf4j-android:1.7.36'
compileOnly 'org.jetbrains:annotations:24.0.0'

testImplementation 'cloud.eppo:sdk-common-jvm:4.0.0-SNAPSHOT'
testImplementation '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')) {
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()
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 {
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)
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 {
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()
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