diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 4112a34d..593c2f80 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -37,7 +37,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - api-level: [26, 31, 34] + api-level: [21, 26, 31, 34] # Allow other tests to continue if one fails fail-fast: false @@ -51,9 +51,17 @@ jobs: echo "SDK Branch: android-sdk@${SDK_BRANCH}" echo "API Level: ${{ matrix.api-level }}" + - name: Check out Common Java SDK + uses: actions/checkout@v4 + with: + repository: Eppo-exp/sdk-common-jdk + ref: typo/pull-json + path: sdk-common-jdk + - name: Check out Java SDK uses: actions/checkout@v4 with: + path: android-sdk repository: ${{ inputs.base_repo || github.repository }} ref: ${{ env.SDK_BRANCH }} @@ -69,7 +77,15 @@ jobs: mkdir -p ~/.gradle/ echo "GRADLE_USER_HOME=${HOME}/.gradle" >> $GITHUB_ENV + - name: Gradle cache + uses: gradle/actions/setup-gradle@v3 + + - name: Publish Common SDK to Maven Local + working-directory: sdk-common-jdk + run: ./gradlew publishToMavenLocal --no-daemon + - name: Set up test data + working-directory: android-sdk run: make test-data branchName=${{env.TEST_DATA_BRANCH}} - name: Wait for mock UFC DNS to resolve @@ -92,10 +108,8 @@ jobs: sudo udevadm control --reload-rules sudo udevadm trigger --name-match=kvm - - name: Gradle cache - uses: gradle/actions/setup-gradle@v3 - - name: Run tests + working-directory: android-sdk run: ./gradlew check --no-daemon - name: AVD cache @@ -105,7 +119,7 @@ jobs: path: | ~/.android/avd/* ~/.android/adb* - key: avd-api-${{ matrix.api-level }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + key: avd-api-${{ matrix.api-level }}-${{ hashFiles('android-sdk/**/*.gradle*', 'android-sdk/**/gradle-wrapper.properties') }} - name: create AVD and generate snapshot for caching if: steps.avd-cache.outputs.cache-hit != 'true' @@ -135,7 +149,7 @@ jobs: touch app/emulator.log # create log file chmod 777 app/emulator.log # allow writing to log file adb logcat | grep EppoSDK >> app/emulator.log & # pipe all logcat messages into log file as a background process - ./gradlew connectedCheck --no-daemon -Pandroid.testInstrumentationRunnerArguments.TEST_DATA_BRANCH=${{ env.TEST_DATA_BRANCH }} # run tests + cd android-sdk && ./gradlew connectedCheck --no-daemon -Pandroid.testInstrumentationRunnerArguments.TEST_DATA_BRANCH=${{ env.TEST_DATA_BRANCH }} # run tests - name: Store reports on failure if: failure() @@ -143,8 +157,8 @@ jobs: with: name: reports path: | - **/build/reports/ - **/build/test-results/ + android-sdk/**/build/reports/ + android-sdk/**/build/test-results/ - name: Upload Emulator Logs if: always() @@ -158,4 +172,4 @@ jobs: uses: actions/upload-artifact@v4 with: name: report-api-level-${{ matrix.api-level }} - path: /Users/runner/work/android-sdk/android-sdk/eppo/build/reports/androidTests/connected/index.html + path: /Users/runner/work/android-sdk/android-sdk/android-sdk/eppo/build/reports/androidTests/connected/index.html diff --git a/eppo/build.gradle b/eppo/build.gradle index e9096b6e..c2069c29 100644 --- a/eppo/build.gradle +++ b/eppo/build.gradle @@ -15,7 +15,7 @@ android { defaultConfig { namespace "cloud.eppo.android" - minSdk 26 + minSdk 21 targetSdk 34 testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" @@ -38,7 +38,8 @@ android { matchingFallbacks = ['debug'] } } - compileOptions { + compileOptions { // Flag to enable support for the new language APIs + coreLibraryDesugaringEnabled true sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } @@ -52,6 +53,7 @@ android { } } + ext {} ext.versions = [ "junit": "4.13.2", @@ -68,14 +70,15 @@ ext.versions = [ ] dependencies { - api 'cloud.eppo:sdk-common-jvm:3.10.0' + api 'cloud.eppo:sdk-common-jvm:4.0.0' + + coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.5' implementation 'org.slf4j:slf4j-api:2.0.17' implementation "androidx.core:core:${versions.androidx_core}" implementation "com.squareup.okhttp3:okhttp:${versions.okhttp}" implementation "com.github.zafarkhaja:java-semver:${versions.semver}" - implementation "com.fasterxml.jackson.core:jackson-databind:2.19.0" testImplementation "junit:junit:${versions.junit}" testImplementation "commons-io:commons-io:${versions.commonsio}" diff --git a/eppo/src/androidTest/java/cloud/eppo/android/EppoClientTest.java b/eppo/src/androidTest/java/cloud/eppo/android/EppoClientTest.java index 6aa0fbbb..a522c7a1 100644 --- a/eppo/src/androidTest/java/cloud/eppo/android/EppoClientTest.java +++ b/eppo/src/androidTest/java/cloud/eppo/android/EppoClientTest.java @@ -1,8 +1,8 @@ package cloud.eppo.android; -import static cloud.eppo.android.ConfigCacheFile.cacheFileName; -import static cloud.eppo.android.util.Utils.logTag; -import static cloud.eppo.android.util.Utils.safeCacheKey; +import static cloud.eppo.android.util.AndroidUtils.logTag; +import static cloud.eppo.android.util.AndroidUtils.safeCacheKey; +import static java.lang.Thread.sleep; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; @@ -12,11 +12,9 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.doThrow; -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 android.content.res.AssetManager; import android.util.Log; @@ -25,46 +23,37 @@ import androidx.test.platform.app.InstrumentationRegistry; import cloud.eppo.BaseEppoClient; import cloud.eppo.EppoHttpClient; -import cloud.eppo.android.cache.LRUAssignmentCache; +import cloud.eppo.IEppoHttpClient; +import cloud.eppo.Utils; import cloud.eppo.android.helpers.AssignmentTestCase; import cloud.eppo.android.helpers.AssignmentTestCaseDeserializer; import cloud.eppo.android.helpers.SubjectAssignment; import cloud.eppo.android.helpers.TestCaseValue; +import cloud.eppo.android.helpers.TestUtils; import cloud.eppo.api.Attributes; import cloud.eppo.api.Configuration; +import cloud.eppo.api.EppoActionCallback; import cloud.eppo.api.EppoValue; import cloud.eppo.api.IAssignmentCache; -import cloud.eppo.logging.Assignment; import cloud.eppo.logging.AssignmentLogger; -import cloud.eppo.ufc.dto.FlagConfig; -import cloud.eppo.ufc.dto.FlagConfigResponse; import cloud.eppo.ufc.dto.VariationType; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.module.SimpleModule; -import java.io.File; import java.io.IOException; import java.io.InputStream; import java.lang.reflect.Field; -import java.text.SimpleDateFormat; import java.util.ArrayList; -import java.util.Date; -import java.util.HashMap; +import java.util.HashSet; import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.concurrent.CompletableFuture; +import java.util.Set; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; -import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicReference; import org.apache.commons.io.Charsets; import org.apache.commons.io.IOUtils; import org.json.JSONException; +import org.json.JSONObject; import org.junit.Before; import org.junit.Test; -import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.MockitoAnnotations; @@ -76,23 +65,25 @@ public class EppoClientTest { // Use branch if specified by env variable `TEST_DATA_BRANCH`. private static final String TEST_BRANCH = InstrumentationRegistry.getArguments().getString("TEST_DATA_BRANCH"); - private static final String TEST_HOST_BASE = + private static final String TEST_URL_BASE = "https://us-central1-eppo-qa.cloudfunctions.net/serveGitHubRacTestFile"; - private static final String TEST_HOST = - TEST_HOST_BASE + (TEST_BRANCH != null ? "/b/" + TEST_BRANCH : ""); + private static final String TEST_API_BASE_URL = + TEST_URL_BASE + (TEST_BRANCH != null ? "/b/" + TEST_BRANCH : "") + "/api"; private static final String INVALID_HOST = "https://thisisabaddomainforthistest.com"; private static final byte[] EMPTY_CONFIG = "{\"flags\":{}}".getBytes(); - private final ObjectMapper mapper = new ObjectMapper().registerModule(module()); + + AssignmentTestCaseDeserializer assignmentTestCaseDeserializer = + new AssignmentTestCaseDeserializer(); + @Mock AssignmentLogger mockAssignmentLogger; @Mock EppoHttpClient mockHttpClient; private void initClient( - String host, + String baseUrl, boolean throwOnCallbackError, boolean shouldDeleteCacheFiles, boolean isGracefulMode, - boolean obfuscateConfig, @Nullable EppoHttpClient httpClientOverride, @Nullable ConfigurationStore configurationStoreOverride, String apiKey, @@ -105,35 +96,51 @@ private void initClient( setBaseClientHttpClientOverrideField(httpClientOverride); - CompletableFuture futureClient = + // Replace CompletableFuture with CountDownLatch + final java.util.concurrent.CountDownLatch latch = new java.util.concurrent.CountDownLatch(1); + final Throwable[] initError = new Throwable[1]; + + EppoClient.Builder builder = new EppoClient.Builder(apiKey, ApplicationProvider.getApplicationContext()) .isGracefulMode(isGracefulMode) - .host(host) + .apiBaseUrl(baseUrl) .assignmentLogger(mockAssignmentLogger) - .obfuscateConfig(obfuscateConfig) .forceReinitialize(true) .offlineMode(offlineMode) .configStore(configurationStoreOverride) - .assignmentCache(assignmentCache) - .buildAndInitAsync() - .thenAccept(client -> Log.i(TAG, "Test client async buildAndInit completed.")) - .exceptionally( - error -> { - Log.e(TAG, "Test client async buildAndInit error" + error.getMessage(), error); - if (throwOnCallbackError) { - throw new RuntimeException( - "Unable to initialize: " + error.getMessage(), error); - } - return null; - }); + .assignmentCache(assignmentCache); + + builder.buildAndInitAsync( + new EppoActionCallback() { + @Override + public void onSuccess(EppoClient data) { + Log.i(TAG, "Test client async buildAndInit completed."); + latch.countDown(); + } + + @Override + public void onFailure(Throwable error) { + Log.e(TAG, "Test client async buildAndInit error" + error.getMessage(), error); + initError[0] = error; + if (throwOnCallbackError) { + throw new RuntimeException("Unable to initialize: " + error.getMessage(), error); + } + latch.countDown(); + } + }); // Wait for initialization to succeed or fail, up to 10 seconds, before continuing try { - futureClient.get(10, TimeUnit.SECONDS); + if (!latch.await(10, TimeUnit.SECONDS)) { + throw new RuntimeException("Client initialization timed out"); + } + if (initError[0] != null && throwOnCallbackError) { + throw new RuntimeException( + "Unable to initialize: " + initError[0].getMessage(), initError[0]); + } Log.d(TAG, "Test client initialized"); - } catch (ExecutionException | TimeoutException | InterruptedException e) { - - throw new RuntimeException("Client initialization not complete within timeout", e); + } catch (InterruptedException e) { + throw new RuntimeException("Client initialization interrupted", e); } } @@ -157,19 +164,19 @@ private void clearCacheFile(String apiKey) { @Test public void testUnobfuscatedAssignments() { - initClient(TEST_HOST, true, true, false, false, null, null, DUMMY_API_KEY, false, null, false); + initClient(TEST_API_BASE_URL, true, true, false, null, null, DUMMY_API_KEY, false, null, false); runTestCases(); } @Test public void testAssignments() { - initClient(TEST_HOST, true, true, false, true, null, null, DUMMY_API_KEY, false, null, false); + initClient(TEST_API_BASE_URL, true, true, false, null, null, DUMMY_API_KEY, false, null, false); runTestCases(); } @Test - public void testErrorGracefulModeOn() throws JSONException, JsonProcessingException { - initClient(TEST_HOST, false, true, true, true, null, null, DUMMY_API_KEY, false, null, false); + public void testErrorGracefulModeOn() throws JSONException { + initClient(TEST_API_BASE_URL, false, true, true, null, null, DUMMY_API_KEY, false, null, false); EppoClient realClient = EppoClient.getInstance(); EppoClient spyClient = spy(realClient); @@ -199,10 +206,10 @@ public void testErrorGracefulModeOn() throws JSONException, JsonProcessingExcept "", spyClient.getStringAssignment("experiment1", "subject1", new Attributes(), "")); assertEquals( - mapper.readTree("{\"a\": 1, \"b\": false}").toString(), + new JSONObject("{\"a\": 1, \"b\": false}").toString(), spyClient .getJSONAssignment( - "subject1", "experiment1", mapper.readTree("{\"a\": 1, \"b\": false}")) + "subject1", "experiment1", new JSONObject("{\"a\": 1, \"b\": false}")) .toString()); assertEquals( @@ -210,15 +217,16 @@ public void testErrorGracefulModeOn() throws JSONException, JsonProcessingExcept spyClient.getJSONStringAssignment("subject1", "experiment1", "{\"a\": 1, \"b\": false}")); assertEquals( - mapper.readTree("{}").toString(), + new JSONObject("{}").toString(), spyClient - .getJSONAssignment("subject1", "experiment1", new Attributes(), mapper.readTree("{}")) + .getJSONAssignment("subject1", "experiment1", new Attributes(), new JSONObject("{}")) .toString()); } @Test public void testErrorGracefulModeOff() { - initClient(TEST_HOST, false, true, false, true, null, null, DUMMY_API_KEY, false, null, false); + initClient( + TEST_API_BASE_URL, false, true, false, null, null, DUMMY_API_KEY, false, null, false); EppoClient realClient = EppoClient.getInstance(); EppoClient spyClient = spy(realClient); @@ -263,67 +271,97 @@ public void testErrorGracefulModeOff() { RuntimeException.class, () -> spyClient.getJSONAssignment( - "subject1", "experiment1", mapper.readTree("{\"a\": 1, \"b\": false}"))); + "subject1", "experiment1", new JSONObject("{\"a\": 1, \"b\": false}"))); assertThrows( RuntimeException.class, () -> spyClient.getJSONAssignment( - "subject1", "experiment1", new Attributes(), mapper.readTree("{}"))); + "subject1", "experiment1", new Attributes(), new JSONObject("{}"))); } - private static EppoHttpClient mockHttpError() { - // Create a mock instance of EppoHttpClient - EppoHttpClient mockHttpClient = mock(EppoHttpClient.class); - - // Mock sync get - when(mockHttpClient.get(anyString())).thenThrow(new RuntimeException("Intentional Error")); - - // Mock async get - CompletableFuture mockAsyncResponse = new CompletableFuture<>(); - when(mockHttpClient.getAsync(anyString())).thenReturn(mockAsyncResponse); - mockAsyncResponse.completeExceptionally(new RuntimeException("Intentional Error")); + private static IEppoHttpClient mockHttpError() { + // Create a mock instance of EppoHttpClient tha throws + IEppoHttpClient mockHttpClient = new TestUtils.ThrowingHttpClient(); return mockHttpClient; } @Test - public void testGracefulInitializationFailure() throws ExecutionException, InterruptedException { + public void testGracefulInitializationFailure() { // Set up bad HTTP response - EppoHttpClient http = mockHttpError(); + IEppoHttpClient http = mockHttpError(); setBaseClientHttpClientOverrideField(http); + // Use CountDownLatch instead of CompletableFuture + final java.util.concurrent.CountDownLatch latch = new java.util.concurrent.CountDownLatch(1); + final boolean[] success = new boolean[1]; + EppoClient.Builder clientBuilder = new EppoClient.Builder(DUMMY_API_KEY, ApplicationProvider.getApplicationContext()) .forceReinitialize(true) .isGracefulMode(true); // Initialize and no exception should be thrown. - clientBuilder.buildAndInitAsync().get(); + clientBuilder.buildAndInitAsync( + new EppoActionCallback() { + @Override + public void onSuccess(EppoClient data) { + success[0] = true; + latch.countDown(); + } + + @Override + public void onFailure(Throwable error) { + success[0] = false; + latch.countDown(); + } + }); + + try { + latch.await(5, TimeUnit.SECONDS); + assertTrue("Client should have initialized successfully in graceful mode", success[0]); + } catch (InterruptedException e) { + fail("Test was interrupted"); + } } @Test - public void testLoadConfiguration() throws ExecutionException, InterruptedException { - testLoadConfigurationHelper(false); + public void testFetchAndActivateConfiguration() throws ExecutionException, InterruptedException { + testFetchAndActivateConfigurationHelper(false); } @Test - public void testLoadConfigurationAsync() throws ExecutionException, InterruptedException { - testLoadConfigurationHelper(true); + public void testFetchAndActivateConfigurationAsync() + throws ExecutionException, InterruptedException { + testFetchAndActivateConfigurationHelper(true); } - private void testLoadConfigurationHelper(boolean loadAsync) - throws ExecutionException, InterruptedException { - // Set up a changing response from the "server" - EppoHttpClient mockHttpClient = mock(EppoHttpClient.class); + private static class LatchedCallback implements EppoActionCallback { + public final AtomicReference result = new AtomicReference<>(); + public final AtomicReference failure = new AtomicReference<>(); + private final CountDownLatch latch = new CountDownLatch(1); + + public boolean await(long duration, TimeUnit timeUnit) throws InterruptedException { + return latch.await(duration, timeUnit); + } - // Mock sync get to return empty - when(mockHttpClient.get(anyString())).thenReturn(EMPTY_CONFIG); + @Override + public void onSuccess(T data) { + result.set(data); + latch.countDown(); + } - // Mock async get to return empty - CompletableFuture emptyResponse = CompletableFuture.completedFuture(EMPTY_CONFIG); - when(mockHttpClient.getAsync(anyString())).thenReturn(emptyResponse); + @Override + public void onFailure(Throwable error) { + failure.set(error); + latch.countDown(); + } + } - setBaseClientHttpClientOverrideField(mockHttpClient); + private void testFetchAndActivateConfigurationHelper(boolean loadAsync) + throws ExecutionException, InterruptedException { + // Set up a changing response from the "server" + TestUtils.MockHttpClient mockHttpClient = getMockHttpClient(); EppoClient.Builder clientBuilder = new EppoClient.Builder(DUMMY_API_KEY, ApplicationProvider.getApplicationContext()) @@ -331,44 +369,37 @@ private void testLoadConfigurationHelper(boolean loadAsync) .isGracefulMode(false); // Initialize and no exception should be thrown. - EppoClient eppoClient = clientBuilder.buildAndInitAsync().get(); + EppoClient eppoClient = clientBuilder.buildAndInit(); - verify(mockHttpClient, times(1)).getAsync(anyString()); + verify(mockHttpClient, times(1)).getAsync(anyString(), any(IEppoHttpClient.Callback.class)); assertFalse(eppoClient.getBooleanAssignment("bool_flag", "subject1", false)); // Now, return the boolean flag config (bool_flag = true) - when(mockHttpClient.get(anyString())).thenReturn(BOOL_FLAG_CONFIG); - - // Mock async get to return the boolean flag config (bool_flag = true) - CompletableFuture boolFlagResponse = - CompletableFuture.completedFuture(BOOL_FLAG_CONFIG); - when(mockHttpClient.getAsync(anyString())).thenReturn(boolFlagResponse); + mockHttpClient.changeResponse(BOOL_FLAG_CONFIG); // Trigger a reload of the client if (loadAsync) { - eppoClient.loadConfigurationAsync().get(); + LatchedCallback latch = new LatchedCallback<>(); + eppoClient.fetchAndActivateConfigurationAsync(latch); + assertTrue( + "Client did not initialize asynchronously within 5 seconds", + latch.await(5, TimeUnit.SECONDS)); } else { - eppoClient.loadConfiguration(); + eppoClient.fetchAndActivateConfiguration(); } + Set keySet = new HashSet<>(); + keySet.add(Utils.getMD5Hex("bool_flag")); + assertEquals(keySet, eppoClient.getConfiguration().getFlagKeys()); assertTrue(eppoClient.getBooleanAssignment("bool_flag", "subject1", false)); } @Test - public void testConfigurationChangeListener() throws ExecutionException, InterruptedException { + public void testConfigurationChangeListener() { List received = new ArrayList<>(); // Set up a changing response from the "server" - EppoHttpClient mockHttpClient = mock(EppoHttpClient.class); - - // Mock sync get to return empty - when(mockHttpClient.get(anyString())).thenReturn(EMPTY_CONFIG); - - // Mock async get to return empty - CompletableFuture emptyResponse = CompletableFuture.completedFuture(EMPTY_CONFIG); - when(mockHttpClient.getAsync(anyString())).thenReturn(emptyResponse); - - setBaseClientHttpClientOverrideField(mockHttpClient); + TestUtils.MockHttpClient mockHttpClient = getMockHttpClient(); EppoClient.Builder clientBuilder = new EppoClient.Builder(DUMMY_API_KEY, ApplicationProvider.getApplicationContext()) @@ -377,51 +408,38 @@ public void testConfigurationChangeListener() throws ExecutionException, Interru .isGracefulMode(false); // Initialize and no exception should be thrown. - EppoClient eppoClient = clientBuilder.buildAndInitAsync().get(); + EppoClient eppoClient = clientBuilder.buildAndInit(); - verify(mockHttpClient, times(1)).getAsync(anyString()); + verify(mockHttpClient, times(1)).getAsync(anyString(), any(IEppoHttpClient.Callback.class)); assertEquals(1, received.size()); // Now, return the boolean flag config so that the config has changed. - when(mockHttpClient.get(anyString())).thenReturn(BOOL_FLAG_CONFIG); + mockHttpClient.changeResponse(BOOL_FLAG_CONFIG); // Trigger a reload of the client - eppoClient.loadConfiguration(); + eppoClient.fetchAndActivateConfiguration(); assertEquals(2, received.size()); // Reload the client again; the config hasn't changed, but Java doesn't check eTag (yet) - eppoClient.loadConfiguration(); + eppoClient.fetchAndActivateConfiguration(); assertEquals(3, received.size()); } + private static TestUtils.MockHttpClient getMockHttpClient() { + TestUtils.MockHttpClient mockHttpClient = spy(new TestUtils.MockHttpClient(EMPTY_CONFIG)); + setBaseClientHttpClientOverrideField(mockHttpClient); + return mockHttpClient; + } + @Test - public void testPollingClient() throws ExecutionException, InterruptedException { - EppoHttpClient mockHttpClient = mock(EppoHttpClient.class); + public void testPollingClient() throws InterruptedException { + TestUtils.MockHttpClient mockHttpClient = spy(new TestUtils.MockHttpClient(EMPTY_CONFIG)); CountDownLatch pollLatch = new CountDownLatch(1); CountDownLatch configActivatedLatch = new CountDownLatch(1); - // The poller fetches synchronously so let's return the boolean flag config - when(mockHttpClient.get(anyString())) - .thenAnswer( - invocation -> { - pollLatch.countDown(); // Signal that polling occurred - Log.d("TEST", "Polling has occurred"); - return BOOL_FLAG_CONFIG; - }); - - // Async get is used for initialization, so we'll return an empty response. - CompletableFuture emptyResponse = - CompletableFuture.supplyAsync( - () -> { - Log.d("TEST", "empty config supplied"); - return EMPTY_CONFIG; - }); - - when(mockHttpClient.getAsync(anyString())).thenReturn(emptyResponse); - setBaseClientHttpClientOverrideField(mockHttpClient); long pollingIntervalMs = 50; @@ -431,22 +449,29 @@ public void testPollingClient() throws ExecutionException, InterruptedException .forceReinitialize(true) .pollingEnabled(true) .pollingIntervalMs(pollingIntervalMs) + .onConfigurationChange( + (config) -> { + configActivatedLatch.countDown(); + }) .isGracefulMode(false); - EppoClient eppoClient = clientBuilder.buildAndInitAsync().get(); - eppoClient.onConfigurationChange( - (config) -> { - configActivatedLatch.countDown(); - }); + EppoClient eppoClient = clientBuilder.buildAndInit(); // Empty config on initialization - verify(mockHttpClient, times(1)).getAsync(anyString()); + verify(mockHttpClient, times(1)).getAsync(anyString(), any(IEppoHttpClient.Callback.class)); assertFalse(eppoClient.getBooleanAssignment("bool_flag", "subject1", false)); + // Change the served config to the "boolean flag config" + mockHttpClient.changeResponse(BOOL_FLAG_CONFIG); + // Wait for the client to send the "fetch" - assertTrue("Polling did not occur within timeout", pollLatch.await(5, TimeUnit.SECONDS)); + // assertTrue("Polling did not occur within timeout", pollLatch.await(5, + // TimeUnit.SECONDS)); + sleep(pollingIntervalMs * 12 / 10); // Wait for the client to apply the fetch and notify of config change. + + verify(mockHttpClient, times(1)).get(anyString()); assertTrue( "Configuration not activated within timeout", configActivatedLatch.await(250, TimeUnit.MILLISECONDS)); @@ -469,7 +494,7 @@ public void testClientMakesDefaultAssignmentsAfterFailingToInitialize() .isGracefulMode(true); // Initialize and no exception should be thrown. - EppoClient eppoClient = clientBuilder.buildAndInitAsync().get(); + EppoClient eppoClient = clientBuilder.buildAndInit(); assertEquals("default", eppoClient.getStringAssignment("experiment1", "subject1", "default")); } @@ -487,9 +512,9 @@ public void testClientMakesDefaultAssignmentsAfterFailingToInitializeNonGraceful // Initialize, expect the exception and then verify that the client can still complete an // assignment. try { - clientBuilder.buildAndInitAsync().get(); + clientBuilder.buildAndInit(); fail("Expected exception"); - } catch (RuntimeException | ExecutionException | InterruptedException e) { + } catch (RuntimeException e) { // Expected assertNotNull(e.getCause()); @@ -504,13 +529,35 @@ public void testNonGracefulInitializationFailure() { // Set up bad HTTP response setBaseClientHttpClientOverrideField(mockHttpError()); + final java.util.concurrent.CountDownLatch latch = new java.util.concurrent.CountDownLatch(1); + final Throwable[] error = new Throwable[1]; + EppoClient.Builder clientBuilder = new EppoClient.Builder(DUMMY_API_KEY, ApplicationProvider.getApplicationContext()) .forceReinitialize(true) .isGracefulMode(false); // Initialize and expect an exception. - assertThrows(Exception.class, () -> clientBuilder.buildAndInitAsync().get()); + clientBuilder.buildAndInitAsync( + new EppoActionCallback() { + @Override + public void onSuccess(EppoClient data) { + latch.countDown(); + } + + @Override + public void onFailure(Throwable e) { + error[0] = e; + latch.countDown(); + } + }); + + try { + latch.await(5, TimeUnit.SECONDS); + assertNotNull("Expected an error", error[0]); + } catch (InterruptedException e) { + fail("Test was interrupted"); + } } private void runTestCases() { @@ -529,144 +576,75 @@ private void runTestCases() { } } - @Test - public void testOfflineInit() throws IOException { - testOfflineInitFromFile("flags-v1.json"); - } - - @Test - public void testObfuscatedOfflineInit() throws IOException { - testOfflineInitFromFile("flags-v1-obfuscated.json"); - } - - public void testOfflineInitFromFile(String filepath) throws IOException { - AssetManager assets = ApplicationProvider.getApplicationContext().getAssets(); - - InputStream stream = assets.open(filepath); - int size = stream.available(); - byte[] buffer = new byte[size]; - int numBytes = stream.read(buffer); - stream.close(); - - CompletableFuture futureClient = - new EppoClient.Builder("DUMMYKEY", ApplicationProvider.getApplicationContext()) - .isGracefulMode(false) - .offlineMode(true) - .assignmentLogger(mockAssignmentLogger) - .forceReinitialize(true) - .initialConfiguration(buffer) - .buildAndInitAsync() - .thenAccept(client -> Log.i(TAG, "Test client async buildAndInit completed.")); - - Double result = - futureClient - .thenApply( - clVoid -> { - return EppoClient.getInstance().getDoubleAssignment("numeric_flag", "bob", 99.0); - }) - .join(); - - assertEquals(3.14, result, 0.1); - } + private int runTestCaseFileStream(InputStream testCaseStream) throws IOException, JSONException { - @Test - public void testCachedConfigurations() { - // First initialize successfully - initClient( - TEST_HOST, - true, - true, - false, - false, - null, - null, - DUMMY_API_KEY, - false, - null, - false); // ensure cache is populated - - // wait for a bit since cache file is written asynchronously - waitForPopulatedCache(); - - // Then reinitialize with a bad host so we know it's using the cached UFC built from the first - // initialization - initClient( - INVALID_HOST, - false, - false, - false, - false, - null, - null, - DUMMY_API_KEY, - false, - null, - false); // invalid host to force to use cache + try { + String json = IOUtils.toString(testCaseStream, Charsets.toCharset("UTF8")); + AssignmentTestCase testCase = assignmentTestCaseDeserializer.deserialize(json); + String flagKey = testCase.getFlag(); + TestCaseValue defaultValue = testCase.getDefaultValue(); + EppoClient eppoClient = EppoClient.getInstance(); + + System.out.println("Running for flag: " + testCase.getFlag()); + for (SubjectAssignment subjectAssignment : testCase.getSubjects()) { + String subjectKey = subjectAssignment.getSubjectKey(); + Attributes subjectAttributes = subjectAssignment.getAttributes(); + + // Depending on the variation type, we will need to change which assignment method we call + // and + // how we get the default value + switch (testCase.getVariationType()) { + case BOOLEAN: + boolean boolAssignment = + eppoClient.getBooleanAssignment( + flagKey, subjectKey, subjectAttributes, defaultValue.booleanValue()); + assertAssignment(flagKey, subjectAssignment, boolAssignment); + break; + case INTEGER: + int intAssignment = + eppoClient.getIntegerAssignment( + flagKey, + subjectKey, + subjectAttributes, + Double.valueOf(defaultValue.doubleValue()).intValue()); + assertAssignment(flagKey, subjectAssignment, intAssignment); + break; + case NUMERIC: + double doubleAssignment = + eppoClient.getDoubleAssignment( + flagKey, subjectKey, subjectAttributes, defaultValue.doubleValue()); + assertAssignment(flagKey, subjectAssignment, doubleAssignment); + break; + case STRING: + String stringAssignment = + eppoClient.getStringAssignment( + flagKey, subjectKey, subjectAttributes, defaultValue.stringValue()); + assertAssignment(flagKey, subjectAssignment, stringAssignment); + break; + case JSON: + JSONObject jsonAssignment = + eppoClient.getJSONAssignment( + flagKey, + subjectKey, + subjectAttributes, + new JSONObject(defaultValue.jsonValue().toString())); + assertAssignment(flagKey, subjectAssignment, jsonAssignment); + break; + default: + throw new UnsupportedOperationException( + "Unexpected variation type " + + testCase.getVariationType() + + " for " + + flagKey + + " test case"); + } + } - runTestCases(); - } + return testCase.getSubjects().size(); + } catch (Exception e) { - private int runTestCaseFileStream(InputStream testCaseStream) throws IOException, JSONException { - String json = IOUtils.toString(testCaseStream, Charsets.toCharset("UTF8")); - AssignmentTestCase testCase = mapper.readValue(json, AssignmentTestCase.class); - String flagKey = testCase.getFlag(); - TestCaseValue defaultValue = testCase.getDefaultValue(); - EppoClient eppoClient = EppoClient.getInstance(); - - for (SubjectAssignment subjectAssignment : testCase.getSubjects()) { - String subjectKey = subjectAssignment.getSubjectKey(); - Attributes subjectAttributes = subjectAssignment.getAttributes(); - - // Depending on the variation type, we will need to change which assignment method we call and - // how we get the default value - switch (testCase.getVariationType()) { - case BOOLEAN: - boolean boolAssignment = - eppoClient.getBooleanAssignment( - flagKey, subjectKey, subjectAttributes, defaultValue.booleanValue()); - assertAssignment(flagKey, subjectAssignment, boolAssignment); - break; - case INTEGER: - int intAssignment = - eppoClient.getIntegerAssignment( - flagKey, - subjectKey, - subjectAttributes, - Double.valueOf(defaultValue.doubleValue()).intValue()); - assertAssignment(flagKey, subjectAssignment, intAssignment); - break; - case NUMERIC: - double doubleAssignment = - eppoClient.getDoubleAssignment( - flagKey, subjectKey, subjectAttributes, defaultValue.doubleValue()); - assertAssignment(flagKey, subjectAssignment, doubleAssignment); - break; - case STRING: - String stringAssignment = - eppoClient.getStringAssignment( - flagKey, subjectKey, subjectAttributes, defaultValue.stringValue()); - assertAssignment(flagKey, subjectAssignment, stringAssignment); - break; - case JSON: - JsonNode jsonAssignment = - eppoClient.getJSONAssignment( - flagKey, - subjectKey, - subjectAttributes, - mapper.readTree(defaultValue.jsonValue().toString())); - assertAssignment(flagKey, subjectAssignment, jsonAssignment); - break; - default: - throw new UnsupportedOperationException( - "Unexpected variation type " - + testCase.getVariationType() - + " for " - + flagKey - + " test case"); - } + throw new RuntimeException(e); } - - return testCase.getSubjects().size(); } /** Helper method for asserting a subject assignment with a useful failure message. */ @@ -704,7 +682,7 @@ private void assertAssignment( } else if (assignment instanceof String) { assertEquals( failureMessage, expectedSubjectAssignment.getAssignment().stringValue(), assignment); - } else if (assignment instanceof JsonNode) { + } else if (assignment instanceof JSONObject) { assertEquals( failureMessage, expectedSubjectAssignment.getAssignment().jsonValue().toString(), @@ -715,385 +693,7 @@ private void assertAssignment( } } - @Test - public void testInvalidConfigJSON() { - when(mockHttpClient.getAsync(anyString())) - .thenReturn(CompletableFuture.completedFuture("{}".getBytes())); - - initClient( - TEST_HOST, - true, - true, - false, - false, - mockHttpClient, - null, - DUMMY_API_KEY, - false, - null, - false); - - String result = - EppoClient.getInstance() - .getStringAssignment("dummy subject", "dummy flag", "not-populated"); - assertEquals("not-populated", result); - } - - @Test - public void testInvalidConfigJSONAsync() { - - // Create a mock instance of EppoHttpClient - CompletableFuture httpResponse = CompletableFuture.completedFuture("{}".getBytes()); - - when(mockHttpClient.getAsync(anyString())).thenReturn(httpResponse); - - initClient( - TEST_HOST, - true, - true, - false, - false, - mockHttpClient, - null, - DUMMY_API_KEY, - false, - null, - false); - - String result = - EppoClient.getInstance() - .getStringAssignment("dummy subject", "dummy flag", "not-populated"); - assertEquals("not-populated", result); - } - - @Test - public void testCachedBadResponseRequiresFetch() { - // Populate the cache with a bad response - ConfigCacheFile cacheFile = - new ConfigCacheFile( - ApplicationProvider.getApplicationContext(), safeCacheKey(DUMMY_API_KEY)); - cacheFile.setContents("NEEDS TO BE A VALID JSON TREE"); - - initClient(TEST_HOST, true, false, false, true, null, null, DUMMY_API_KEY, false, null, false); - - double assignment = EppoClient.getInstance().getDoubleAssignment("numeric_flag", "alice", 0.0); - assertEquals(3.1415926, assignment, 0.0000001); - } - - @Test - public void testEmptyFlagsResponseRequiresFetch() throws IOException { - // Populate the cache with a bad response - ConfigCacheFile cacheFile = - new ConfigCacheFile( - ApplicationProvider.getApplicationContext(), safeCacheKey(DUMMY_API_KEY)); - Configuration config = Configuration.emptyConfig(); - cacheFile.getOutputStream().write(config.serializeFlagConfigToBytes()); - - initClient(TEST_HOST, true, false, false, true, null, null, DUMMY_API_KEY, false, null, false); - double assignment = EppoClient.getInstance().getDoubleAssignment("numeric_flag", "alice", 0.0); - assertEquals(3.1415926, assignment, 0.0000001); - } - - @Test - public void testDifferentCacheFilesPerKey() throws IOException { - initClient(TEST_HOST, true, true, false, true, null, null, DUMMY_API_KEY, false, null, false); - // API Key 1 will fetch and then populate its cache with the usual test data - double apiKey1Assignment = - EppoClient.getInstance().getDoubleAssignment("numeric_flag", "alice", 0.0); - assertEquals(3.1415926, apiKey1Assignment, 0.0000001); - - // Pre-seed a different flag configuration for the other API Key - ConfigCacheFile cacheFile2 = - new ConfigCacheFile( - ApplicationProvider.getApplicationContext(), safeCacheKey(DUMMY_OTHER_API_KEY)); - // Set the experiment_with_boolean_variations flag to always return true - byte[] jsonBytes = - ("{\n" - + " \"createdAt\": \"2024-04-17T19:40:53.716Z\",\n" - + " \"flags\": {\n" - + " \"2c27190d8645fe3bc3c1d63b31f0e4ee\": {\n" - + " \"key\": \"2c27190d8645fe3bc3c1d63b31f0e4ee\",\n" - + " \"enabled\": true,\n" - + " \"variationType\": \"NUMERIC\",\n" - + " \"totalShards\": 10000,\n" - + " \"variations\": {\n" - + " \"cGk=\": {\n" - + " \"key\": \"cGk=\",\n" - + " \"value\": \"MS4yMzQ1\"\n" - + // Changed to be 1.2345 encoded - " }\n" - + " },\n" - + " \"allocations\": [\n" - + " {\n" - + " \"key\": \"cm9sbG91dA==\",\n" - + " \"doLog\": true,\n" - + " \"splits\": [\n" - + " {\n" - + " \"variationKey\": \"cGk=\",\n" - + " \"shards\": []\n" - + " }\n" - + " ]\n" - + " }\n" - + " ]\n" - + " }\n" - + " }\n" - + "}") - .getBytes(); - cacheFile2 - .getOutputStream() - .write(Configuration.builder(jsonBytes, true).build().serializeFlagConfigToBytes()); - - // Initialize with offline mode to prevent instance2 from pulling config via fetch. - initClient( - TEST_HOST, true, false, false, true, null, null, DUMMY_OTHER_API_KEY, true, null, false); - - // Ensure API key 2 uses its cache - double apiKey2Assignment = - EppoClient.getInstance().getDoubleAssignment("numeric_flag", "alice", 0.0); - assertEquals(1.2345, apiKey2Assignment, 0.0000001); - - // Reinitialize API key 1 to be sure it used its cache - initClient(TEST_HOST, true, false, false, true, null, null, DUMMY_API_KEY, false, null, false); - // API Key 1 will fetch and then populate its cache with the usual test data - apiKey1Assignment = EppoClient.getInstance().getDoubleAssignment("numeric_flag", "alice", 0.0); - assertEquals(3.1415926, apiKey1Assignment, 0.0000001); - } - - @Test - public void testForceIgnoreCache() throws ExecutionException, InterruptedException { - cacheUselessConfig(); - // Initialize with "useless" cache available. - new EppoClient.Builder(DUMMY_API_KEY, ApplicationProvider.getApplicationContext()) - .host(TEST_HOST) - .assignmentLogger(mockAssignmentLogger) - .obfuscateConfig(true) - .forceReinitialize(true) - .offlineMode(false) - .buildAndInitAsync() - .get(); - double assignment = EppoClient.getInstance().getDoubleAssignment("numeric_flag", "alice", 0.0); - assertEquals(0.0, assignment, 0.0000001); - - // Initialize again with "useless" cache available but ignoreCache = true - cacheUselessConfig(); - new EppoClient.Builder(DUMMY_API_KEY, ApplicationProvider.getApplicationContext()) - .host(TEST_HOST) - .assignmentLogger(mockAssignmentLogger) - .obfuscateConfig(true) - .forceReinitialize(true) - .offlineMode(false) - .ignoreCachedConfiguration(true) - .buildAndInitAsync() - .get(); - - double properAssignment = - EppoClient.getInstance().getDoubleAssignment("numeric_flag", "alice", 0.0); - assertEquals(3.1415926, properAssignment, 0.0000001); - } - - private void cacheUselessConfig() { - ConfigCacheFile cacheFile = - new ConfigCacheFile( - ApplicationProvider.getApplicationContext(), safeCacheKey(DUMMY_API_KEY)); - - Configuration config = new Configuration.Builder(uselessFlagConfigBytes).build(); - - try { - cacheFile.getOutputStream().write(config.serializeFlagConfigToBytes()); - } catch (IOException e) { - throw new RuntimeException(e); - } - } - - private static final byte[] uselessFlagConfigBytes = - ("{\n" - + " \"createdAt\": \"2024-04-17T19:40:53.716Z\",\n" - + " \"format\": \"SERVER\",\n" - + " \"environment\": {\n" - + " \"name\": \"Test\"\n" - + " },\n" - + " \"flags\": {\n" - + " \"empty_flag\": {\n" - + " \"key\": \"empty_flag\",\n" - + " \"enabled\": true,\n" - + " \"variationType\": \"STRING\",\n" - + " \"variations\": {},\n" - + " \"allocations\": [],\n" - + " \"totalShards\": 10000\n" - + " },\n" - + " \"disabled_flag\": {\n" - + " \"key\": \"disabled_flag\",\n" - + " \"enabled\": false,\n" - + " \"variationType\": \"INTEGER\",\n" - + " \"variations\": {},\n" - + " \"allocations\": [],\n" - + " \"totalShards\": 10000\n" - + " }\n" - + " }\n" - + "}") - .getBytes(); - - @Test - public void testFetchCompletesBeforeCacheLoad() { - ConfigurationStore slowStore = - new ConfigurationStore( - ApplicationProvider.getApplicationContext(), safeCacheKey(DUMMY_API_KEY)) { - @Override - protected Configuration readCacheFile() { - Log.d(TAG, "Simulating slow cache read start"); - try { - Thread.sleep(2000); - } catch (InterruptedException ex) { - throw new RuntimeException(ex); - } - Map mockFlags = new HashMap<>(); - // make the map non-empty so it's not ignored - mockFlags.put("dummy", new FlagConfig(null, false, 0, null, null, null)); - - Log.d(TAG, "Simulating slow cache read end"); - byte[] flagConfig = null; - try { - flagConfig = - mapper.writeValueAsBytes(new FlagConfigResponse(mockFlags, new HashMap<>())); - } catch (JsonProcessingException e) { - throw new RuntimeException(e); - } - return Configuration.builder(flagConfig, false).build(); - } - }; - - initClient( - TEST_HOST, true, false, false, true, null, slowStore, DUMMY_API_KEY, false, null, false); - - EppoClient client = EppoClient.getInstance(); - // Give time for async slow cache read to finish - try { - Thread.sleep(2500); - } catch (InterruptedException ex) { - throw new RuntimeException(ex); - } - - double assignment = EppoClient.getInstance().getDoubleAssignment("numeric_flag", "alice", 0.0); - assertEquals(3.1415926, assignment, 0.0000001); - } - - private void waitForPopulatedCache() { - long waitStart = System.currentTimeMillis(); - long waitEnd = waitStart + 10 * 1000; // allow up to 10 seconds - boolean cachePopulated = false; - try { - File file = - new File( - ApplicationProvider.getApplicationContext().getFilesDir(), - cacheFileName(safeCacheKey(DUMMY_API_KEY))); - while (!cachePopulated) { - if (System.currentTimeMillis() > waitEnd) { - throw new InterruptedException( - "Cache file never populated or smaller than expected 8000 bytes; assuming configuration error"); - } - long expectedMinimumSizeInBytes = - 8000; // Last time this test was updated, cache size was 11,506 bytes - cachePopulated = file.exists() && file.length() > expectedMinimumSizeInBytes; - if (!cachePopulated) { - Thread.sleep(8000); - } - } - } catch (InterruptedException e) { - throw new RuntimeException(e); - } - } - - @Test - public void testAssignmentEventCorrectlyCreated() { - Date testStart = new Date(); - initClient(TEST_HOST, true, true, false, true, null, null, DUMMY_API_KEY, false, null, false); - Attributes subjectAttributes = new Attributes(); - subjectAttributes.put("age", EppoValue.valueOf(30)); - subjectAttributes.put("employer", EppoValue.valueOf("Eppo")); - double assignment = - EppoClient.getInstance() - .getDoubleAssignment("numeric_flag", "alice", subjectAttributes, 0.0); - - assertEquals(3.1415926, assignment, 0.0000001); - - ArgumentCaptor assignmentLogCaptor = ArgumentCaptor.forClass(Assignment.class); - verify(mockAssignmentLogger, times(1)).logAssignment(assignmentLogCaptor.capture()); - Assignment capturedAssignment = assignmentLogCaptor.getValue(); - assertEquals("numeric_flag-rollout", capturedAssignment.getExperiment()); - assertEquals("numeric_flag", capturedAssignment.getFeatureFlag()); - assertEquals("rollout", capturedAssignment.getAllocation()); - assertEquals( - "pi", - capturedAssignment - .getVariation()); // Note: unlike this test, typically variation keys will just be the - // value for everything not JSON - assertEquals("alice", capturedAssignment.getSubject()); - assertEquals(subjectAttributes, capturedAssignment.getSubjectAttributes()); - assertEquals(new HashMap<>(), capturedAssignment.getExtraLogging()); - - Date assertionDate = new Date(); - SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US); - dateFormat.setTimeZone(java.util.TimeZone.getTimeZone("UTC")); - Date parsedTimestamp = capturedAssignment.getTimestamp(); - assertNotNull(parsedTimestamp); - assertTrue(parsedTimestamp.after(testStart)); - assertTrue(parsedTimestamp.before(assertionDate)); - - Map expectedMeta = new HashMap<>(); - expectedMeta.put("obfuscated", "true"); - expectedMeta.put("sdkLanguage", "android"); - expectedMeta.put("sdkLibVersion", BuildConfig.EPPO_VERSION); - - assertEquals(expectedMeta, capturedAssignment.getMetaData()); - } - - @Test - public void testAssignmentEventDuplicatedWithoutCache() { - initClient(TEST_HOST, true, true, false, true, null, null, DUMMY_API_KEY, false, null, false); - Attributes subjectAttributes = new Attributes(); - subjectAttributes.put("age", EppoValue.valueOf(30)); - subjectAttributes.put("employer", EppoValue.valueOf("Eppo")); - - EppoClient.getInstance().getDoubleAssignment("numeric_flag", "alice", subjectAttributes, 0.0); - EppoClient.getInstance().getDoubleAssignment("numeric_flag", "alice", subjectAttributes, 0.0); - - ArgumentCaptor assignmentLogCaptor = ArgumentCaptor.forClass(Assignment.class); - verify(mockAssignmentLogger, times(2)).logAssignment(assignmentLogCaptor.capture()); - } - - @Test - public void testAssignmentEventDeDupedWithCache() { - initClient( - TEST_HOST, - true, - true, - false, - true, - null, - null, - DUMMY_API_KEY, - false, - new LRUAssignmentCache(1024), - false); - - Attributes subjectAttributes = new Attributes(); - subjectAttributes.put("age", EppoValue.valueOf(30)); - subjectAttributes.put("employer", EppoValue.valueOf("Eppo")); - - EppoClient.getInstance().getDoubleAssignment("numeric_flag", "alice", subjectAttributes, 0.0); - EppoClient.getInstance().getDoubleAssignment("numeric_flag", "alice", subjectAttributes, 0.0); - - ArgumentCaptor assignmentLogCaptor = ArgumentCaptor.forClass(Assignment.class); - verify(mockAssignmentLogger, times(1)).logAssignment(assignmentLogCaptor.capture()); - } - - private static SimpleModule module() { - SimpleModule module = new SimpleModule(); - module.addDeserializer(AssignmentTestCase.class, new AssignmentTestCaseDeserializer()); - return module; - } - - private static void setBaseClientHttpClientOverrideField(EppoHttpClient httpClient) { + private static void setBaseClientHttpClientOverrideField(IEppoHttpClient httpClient) { setBaseClientOverrideField("httpClientOverride", httpClient); } diff --git a/eppo/src/androidTest/java/cloud/eppo/android/helpers/AssignmentTestCaseDeserializer.java b/eppo/src/androidTest/java/cloud/eppo/android/helpers/AssignmentTestCaseDeserializer.java index 9977af64..df0d06fb 100644 --- a/eppo/src/androidTest/java/cloud/eppo/android/helpers/AssignmentTestCaseDeserializer.java +++ b/eppo/src/androidTest/java/cloud/eppo/android/helpers/AssignmentTestCaseDeserializer.java @@ -1,76 +1,74 @@ package cloud.eppo.android.helpers; +import android.util.Log; +import cloud.eppo.android.adapters.EppoValueDeserializer; +import cloud.eppo.android.util.AndroidUtils; import cloud.eppo.api.Attributes; import cloud.eppo.api.EppoValue; import cloud.eppo.ufc.dto.VariationType; -import cloud.eppo.ufc.dto.adapters.EppoValueDeserializer; -import com.fasterxml.jackson.core.JsonParser; -import com.fasterxml.jackson.databind.DeserializationContext; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.deser.std.StdDeserializer; -import java.io.IOException; import java.util.ArrayList; import java.util.Iterator; import java.util.List; -import java.util.Map; +import org.json.JSONArray; import org.json.JSONException; +import org.json.JSONObject; -public class AssignmentTestCaseDeserializer extends StdDeserializer { +public class AssignmentTestCaseDeserializer { + private static final String TAG = AndroidUtils.logTag(AssignmentTestCaseDeserializer.class); private final EppoValueDeserializer eppoValueDeserializer = new EppoValueDeserializer(); - public AssignmentTestCaseDeserializer() { - super(AssignmentTestCase.class); - } + public AssignmentTestCase deserialize(String jsonString) throws JSONException { + JSONObject rootNode = new JSONObject(jsonString); - @Override - public AssignmentTestCase deserialize(JsonParser parser, DeserializationContext ctxt) - throws IOException { - JsonNode rootNode = parser.getCodec().readTree(parser); - String flag = rootNode.get("flag").asText(); - try { - VariationType variationType = - VariationType.fromString(rootNode.get("variationType").asText()); - TestCaseValue defaultValue = deserializeTestCaseValue(rootNode.get("defaultValue")); - List subjects = deserializeSubjectAssignments(rootNode.get("subjects")); - return new AssignmentTestCase(flag, variationType, defaultValue, subjects); - } catch (JSONException e) { - throw new IOException(e); - } + String flag = rootNode.getString("flag"); + VariationType variationType = VariationType.fromString(rootNode.getString("variationType")); + TestCaseValue defaultValue = deserializeTestCaseValue(rootNode.get("defaultValue")); + List subjects = + deserializeSubjectAssignments(rootNode.getJSONArray("subjects")); + + return new AssignmentTestCase(flag, variationType, defaultValue, subjects); } - private List deserializeSubjectAssignments(JsonNode jsonNode) - throws JSONException { + private List deserializeSubjectAssignments(JSONArray jsonArray) { List subjectAssignments = new ArrayList<>(); - if (jsonNode != null && jsonNode.isArray()) { - for (JsonNode subjectAssignmentNode : jsonNode) { - String subjectKey = subjectAssignmentNode.get("subjectKey").asText(); + if (jsonArray == null) { + return subjectAssignments; + } + + for (int i = 0; i < jsonArray.length(); i++) { + try { + JSONObject subjectAssignmentNode = jsonArray.getJSONObject(i); + String subjectKey = subjectAssignmentNode.getString("subjectKey"); Attributes attributes = new Attributes(); - JsonNode attributesNode = subjectAssignmentNode.get("subjectAttributes"); - if (attributesNode != null && attributesNode.isObject()) { - for (Iterator> it = attributesNode.fields(); it.hasNext(); ) { - Map.Entry entry = it.next(); - String attributeName = entry.getKey(); - EppoValue attributeValue = eppoValueDeserializer.deserializeNode(entry.getValue()); + if (subjectAssignmentNode.has("subjectAttributes")) { + JSONObject attributesNode = subjectAssignmentNode.getJSONObject("subjectAttributes"); + Iterator keys = attributesNode.keys(); + + while (keys.hasNext()) { + String attributeName = keys.next(); + EppoValue attributeValue = + eppoValueDeserializer.deserialize(attributesNode.opt(attributeName)); attributes.put(attributeName, attributeValue); } } TestCaseValue assignment = deserializeTestCaseValue(subjectAssignmentNode.get("assignment")); - subjectAssignments.add(new SubjectAssignment(subjectKey, attributes, assignment)); + } catch (JSONException e) { + Log.w(TAG, "Error deserializing subject assignment at index " + i, e); } } return subjectAssignments; } - private TestCaseValue deserializeTestCaseValue(JsonNode jsonNode) throws JSONException { - if (jsonNode != null && jsonNode.isObject()) { - return TestCaseValue.valueOf(jsonNode); + private TestCaseValue deserializeTestCaseValue(Object jsonValue) throws JSONException { + if (jsonValue instanceof JSONObject) { + return TestCaseValue.valueOf((JSONObject) jsonValue); } else { - return TestCaseValue.copyOf(eppoValueDeserializer.deserializeNode(jsonNode)); + return TestCaseValue.copyOf(eppoValueDeserializer.deserialize(jsonValue)); } } } diff --git a/eppo/src/androidTest/java/cloud/eppo/android/helpers/TestCaseValue.java b/eppo/src/androidTest/java/cloud/eppo/android/helpers/TestCaseValue.java index 63cc4ba5..0f72c513 100644 --- a/eppo/src/androidTest/java/cloud/eppo/android/helpers/TestCaseValue.java +++ b/eppo/src/androidTest/java/cloud/eppo/android/helpers/TestCaseValue.java @@ -1,11 +1,11 @@ package cloud.eppo.android.helpers; import cloud.eppo.api.EppoValue; -import com.fasterxml.jackson.databind.JsonNode; import java.util.List; +import org.json.JSONObject; public class TestCaseValue extends EppoValue { - private JsonNode jsonValue; + private JSONObject jsonValue; private TestCaseValue() { super(); @@ -27,7 +27,7 @@ private TestCaseValue(List stringArrayValue) { super(stringArrayValue); } - private TestCaseValue(JsonNode jsonValue) { + private TestCaseValue(JSONObject jsonValue) { super(jsonValue.toString()); this.jsonValue = jsonValue; } @@ -48,7 +48,7 @@ public static TestCaseValue copyOf(EppoValue eppoValue) { } } - public static TestCaseValue valueOf(JsonNode jsonValue) { + public static TestCaseValue valueOf(JSONObject jsonValue) { return new TestCaseValue(jsonValue); } @@ -56,7 +56,7 @@ public boolean isJson() { return this.jsonValue != null; } - public JsonNode jsonValue() { + public JSONObject jsonValue() { return this.jsonValue; } } diff --git a/eppo/src/androidTest/java/cloud/eppo/android/helpers/TestUtils.java b/eppo/src/androidTest/java/cloud/eppo/android/helpers/TestUtils.java new file mode 100644 index 00000000..5a58ca02 --- /dev/null +++ b/eppo/src/androidTest/java/cloud/eppo/android/helpers/TestUtils.java @@ -0,0 +1,71 @@ +package cloud.eppo.android.helpers; + +import cloud.eppo.IEppoHttpClient; + +public class TestUtils { + + public static class MockHttpClient extends DelayedHttpClient { + public MockHttpClient(byte[] responseBody) { + super(responseBody); + flush(); + } + + public void changeResponse(byte[] responseBody) { + this.responseBody = responseBody; + } + } + + public static class ThrowingHttpClient implements IEppoHttpClient { + + @Override + public byte[] get(String path) { + throw new RuntimeException("Intentional Error"); + } + + @Override + public void getAsync(String path, Callback callback) { + callback.onFailure(new RuntimeException("Intentional Error")); + } + } + + public static class DelayedHttpClient implements IEppoHttpClient { + protected byte[] responseBody; + private Callback callback; + private boolean flushed = false; + private Throwable error = null; + + public DelayedHttpClient(byte[] responseBody) { + this.responseBody = responseBody; + } + + @Override + public byte[] get(String path) { + return responseBody; + } + + @Override + public void getAsync(String path, Callback callback) { + if (flushed) { + callback.onSuccess(responseBody); + } else if (error != null) { + callback.onFailure(error); + } else { + this.callback = callback; + } + } + + public void fail(Throwable error) { + this.error = error; + if (this.callback != null) { + this.callback.onFailure(error); + } + } + + public void flush() { + flushed = true; + if (callback != null) { + callback.onSuccess(responseBody); + } + } + } +} diff --git a/eppo/src/main/java/cloud/eppo/android/ConfigurationStore.java b/eppo/src/main/java/cloud/eppo/android/ConfigurationStore.java index 57708be9..859b2c77 100644 --- a/eppo/src/main/java/cloud/eppo/android/ConfigurationStore.java +++ b/eppo/src/main/java/cloud/eppo/android/ConfigurationStore.java @@ -1,28 +1,32 @@ package cloud.eppo.android; -import static cloud.eppo.android.util.Utils.logTag; +import static cloud.eppo.android.util.AndroidUtils.logTag; import android.app.Application; +import android.os.Handler; +import android.os.Looper; import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import cloud.eppo.IConfigurationStore; -import cloud.eppo.android.util.Utils; +import cloud.eppo.android.util.AndroidUtils; import cloud.eppo.api.Configuration; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; -import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; public class ConfigurationStore implements IConfigurationStore { private static final String TAG = logTag(ConfigurationStore.class); private final ConfigCacheFile cacheFile; private final Object cacheLock = new Object(); + private final Executor backgroundExecutor = Executors.newSingleThreadExecutor(); + private final Handler mainHandler = new Handler(Looper.getMainLooper()); // default to an empty config private volatile Configuration configuration = Configuration.emptyConfig(); - private CompletableFuture cacheLoadFuture = null; public ConfigurationStore(Application application, String cacheFileNameSuffix) { cacheFile = new ConfigCacheFile(application, cacheFileNameSuffix); @@ -33,54 +37,71 @@ public Configuration getConfiguration() { return configuration; } - public CompletableFuture loadConfigFromCache() { - if (cacheLoadFuture != null) { - return cacheLoadFuture; - } + @Nullable public Configuration loadConfigFromCache() { if (!cacheFile.exists()) { Log.d(TAG, "Not loading from cache (file does not exist)"); + return null; + } + Log.d(TAG, "Loading from cache"); + + return readCacheFile(); + } - return CompletableFuture.completedFuture(null); + public void loadConfigFromCacheAsync(Configuration.Callback callback) { + if (!cacheFile.exists()) { + Log.d(TAG, "Not loading from cache (file does not exist)"); + callback.accept(null); + return; } - return cacheLoadFuture = - CompletableFuture.supplyAsync( - () -> { - Log.d(TAG, "Loading from cache"); - return readCacheFile(); - }); + Log.d(TAG, "Loading from cache"); + + // Use anonymous inner class instead of lambda for Android API 21 compatibility + backgroundExecutor.execute( + new Runnable() { + @Override + public void run() { + Configuration config = readCacheFile(); + + // Use anonymous inner class instead of lambda for Android API 21 compatibility + mainHandler.post( + new Runnable() { + @Override + public void run() { + callback.accept(config); + } + }); + } + }); } @Nullable protected Configuration readCacheFile() { synchronized (cacheLock) { try (InputStream inputStream = cacheFile.getInputStream()) { Log.d(TAG, "Attempting to inflate config"); - Configuration config = new Configuration.Builder(Utils.toByteArray(inputStream)).build(); + Configuration config = + new Configuration.Builder(AndroidUtils.toByteArray(inputStream)).build(); Log.d(TAG, "Cache load complete"); return config; } catch (IOException e) { - Log.e("Error loading from the cache: {}", e.getMessage()); - return Configuration.emptyConfig(); + Log.e(TAG, "Error loading from the cache", e); + return null; } } } @Override - public CompletableFuture saveConfiguration(@NonNull Configuration configuration) { - return CompletableFuture.supplyAsync( - () -> { - synchronized (cacheLock) { - Log.d(TAG, "Saving configuration to cache file"); - // We do not save bandits yet as they are not supported on mobile. - try (OutputStream outputStream = cacheFile.getOutputStream()) { - outputStream.write(configuration.serializeFlagConfigToBytes()); - Log.d(TAG, "Updated cache file"); - this.configuration = configuration; - } catch (IOException e) { - Log.e(TAG, "Unable write to cache config to file", e); - throw new RuntimeException(e); - } - return null; - } - }); + public void saveConfiguration(@NonNull Configuration configuration) { + synchronized (cacheLock) { + Log.d(TAG, "Saving configuration to cache file"); + // We do not save bandits yet as they are not supported on mobile. + try (OutputStream outputStream = cacheFile.getOutputStream()) { + outputStream.write(configuration.serializeFlagConfigToBytes()); + Log.d(TAG, "Updated cache file"); + this.configuration = configuration; + } catch (IOException e) { + Log.e(TAG, "Unable write to cache config to file", e); + throw new RuntimeException(e); + } + } } } diff --git a/eppo/src/main/java/cloud/eppo/android/EppoClient.java b/eppo/src/main/java/cloud/eppo/android/EppoClient.java index 2090c2d6..df80aeab 100644 --- a/eppo/src/main/java/cloud/eppo/android/EppoClient.java +++ b/eppo/src/main/java/cloud/eppo/android/EppoClient.java @@ -1,7 +1,7 @@ package cloud.eppo.android; -import static cloud.eppo.android.util.Utils.logTag; -import static cloud.eppo.android.util.Utils.safeCacheKey; +import static cloud.eppo.android.util.AndroidUtils.logTag; +import static cloud.eppo.android.util.AndroidUtils.safeCacheKey; import android.app.Application; import android.util.Log; @@ -9,26 +9,26 @@ import androidx.annotation.Nullable; import cloud.eppo.BaseEppoClient; import cloud.eppo.IConfigurationStore; +import cloud.eppo.Utils; import cloud.eppo.android.cache.LRUAssignmentCache; -import cloud.eppo.android.exceptions.MissingApiKeyException; -import cloud.eppo.android.exceptions.MissingApplicationException; import cloud.eppo.android.exceptions.NotInitializedException; +import cloud.eppo.android.adapters.AndroidJsonDeserializer; +import cloud.eppo.android.util.AndroidUtils; import cloud.eppo.api.Attributes; import cloud.eppo.api.Configuration; +import cloud.eppo.api.EppoActionCallback; import cloud.eppo.api.EppoValue; import cloud.eppo.api.IAssignmentCache; import cloud.eppo.logging.AssignmentLogger; import cloud.eppo.ufc.dto.VariationType; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CompletionException; -import java.util.concurrent.ExecutionException; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; -import java.util.function.Consumer; +import org.json.JSONException; +import org.json.JSONObject; public class EppoClient extends BaseEppoClient { private static final String TAG = logTag(EppoClient.class); private static final boolean DEFAULT_IS_GRACEFUL_MODE = true; - private static final boolean DEFAULT_OBFUSCATE_CONFIG = true; private static final long DEFAULT_POLLING_INTERVAL_MS = 5 * 60 * 1000; private static final long DEFAULT_JITTER_INTERVAL_RATIO = 10; @@ -36,29 +36,32 @@ public class EppoClient extends BaseEppoClient { @Nullable private static EppoClient instance; + // Provide a base64 codec based on Androids base64 util. + static { + Utils.setBase64Codec(new AndroidUtils()); + Utils.setJsonDeserializer(new AndroidJsonDeserializer()); + } + private EppoClient( - String apiKey, + String sdkKey, String sdkName, String sdkVersion, - @Deprecated @Nullable String host, @Nullable String apiBaseUrl, @Nullable AssignmentLogger assignmentLogger, IConfigurationStore configurationStore, boolean isGracefulMode, - boolean obfuscateConfig, - @Nullable CompletableFuture initialConfiguration, + @Nullable Configuration initialConfiguration, @Nullable IAssignmentCache assignmentCache) { + super( - apiKey, + sdkKey, sdkName, sdkVersion, - host, apiBaseUrl, assignmentLogger, null, configurationStore, isGracefulMode, - obfuscateConfig, false, initialConfiguration, assignmentCache, @@ -70,35 +73,33 @@ private EppoClient( */ public static EppoClient init( @NonNull Application application, - @NonNull String apiKey, - @Nullable String host, + @NonNull String sdkKey, @Nullable String apiBaseUrl, @Nullable AssignmentLogger assignmentLogger, - boolean isGracefulMode) { - return new Builder(apiKey, application) - .host(host) + boolean isGracefulMode, + long timeoutMs) { + return new Builder(sdkKey, application) .apiBaseUrl(apiBaseUrl) .assignmentLogger(assignmentLogger) .isGracefulMode(isGracefulMode) - .obfuscateConfig(DEFAULT_OBFUSCATE_CONFIG) - .buildAndInit(); + .buildAndInit(timeoutMs <= 0 ? 5000 : timeoutMs); } /** * @noinspection unused */ - public static CompletableFuture initAsync( + public static EppoClient initAsync( @NonNull Application application, - @NonNull String apiKey, - @NonNull String host, + @NonNull String sdkKey, + @NonNull String apiBaseUrl, @Nullable AssignmentLogger assignmentLogger, - boolean isGracefulMode) { - return new Builder(apiKey, application) - .host(host) + boolean isGracefulMode, + @NonNull EppoActionCallback onInitializedCallback) { + return new Builder(sdkKey, application) + .apiBaseUrl(apiBaseUrl) .assignmentLogger(assignmentLogger) .isGracefulMode(isGracefulMode) - .obfuscateConfig(DEFAULT_OBFUSCATE_CONFIG) - .buildAndInitAsync(); + .buildAndInitAsync(onInitializedCallback); } public static EppoClient getInstance() throws NotInitializedException { @@ -119,30 +120,50 @@ protected EppoValue getTypedAssignment( flagKey, subjectKey, subjectAttributes, defaultValue, expectedType); } + public JSONObject getJSONAssignment(String flagKey, String subjectKey, JSONObject defaultValue) { + String result = super.getJSONStringAssignment(flagKey, subjectKey, defaultValue.toString()); + return getJsonObject(defaultValue, result); + } + + public JSONObject getJSONAssignment( + String flagKey, String subjectKey, Attributes subjectAttributes, JSONObject defaultValue) { + String result = + super.getJSONStringAssignment( + flagKey, subjectKey, subjectAttributes, defaultValue.toString()); + return getJsonObject(defaultValue, result); + } + + private JSONObject getJsonObject(JSONObject defaultValue, String result) { + try { + return new JSONObject(result); + } catch (JSONException e) { + + return throwIfNotGraceful(e, defaultValue); + } + } + /** (Re)loads flag and experiment configuration from the API server. */ @Override - public void loadConfiguration() { - super.loadConfiguration(); + public void fetchAndActivateConfiguration() { + super.fetchAndActivateConfiguration(); } - /** Asynchronously (re)loads flag and experiment configuration from the API server. */ + /** (Re)loads flag and experiment configuration from the API server. */ @Override - public CompletableFuture loadConfigurationAsync() { - return super.loadConfigurationAsync(); + public void fetchAndActivateConfigurationAsync(EppoActionCallback callback) { + super.fetchAndActivateConfigurationAsync(callback); } public static class Builder { - private String host; private String apiBaseUrl; private final Application application; - private final String apiKey; + private final String sdkKey; @Nullable private AssignmentLogger assignmentLogger; @Nullable private ConfigurationStore configStore; private boolean isGracefulMode = DEFAULT_IS_GRACEFUL_MODE; - private boolean obfuscateConfig = DEFAULT_OBFUSCATE_CONFIG; private boolean forceReinitialize = false; private boolean offlineMode = false; - private CompletableFuture initialConfiguration; + private Configuration initialConfiguration; private boolean ignoreCachedConfiguration = false; private boolean pollingEnabled = false; private long pollingIntervalMs = DEFAULT_POLLING_INTERVAL_MS; @@ -155,16 +176,11 @@ public static class Builder { // Assignment caching on by default. To disable, call `builder.assignmentCache(null);` private IAssignmentCache assignmentCache = new LRUAssignmentCache(100); - @Nullable private Consumer configChangeCallback; + @Nullable private Configuration.Callback configChangeCallback; - public Builder(@NonNull String apiKey, @NonNull Application application) { + public Builder(@NonNull String sdkKey, @NonNull Application application) { this.application = application; - this.apiKey = apiKey; - } - - public Builder host(@Nullable String host) { - this.host = host; - return this; + this.sdkKey = sdkKey; } public Builder apiBaseUrl(@Nullable String apiBaseUrl) { @@ -187,11 +203,6 @@ public Builder isGracefulMode(boolean isGracefulMode) { return this; } - public Builder obfuscateConfig(boolean obfuscateConfig) { - this.obfuscateConfig = obfuscateConfig; - return this; - } - public Builder forceReinitialize(boolean forceReinitialize) { this.forceReinitialize = forceReinitialize; return this; @@ -208,15 +219,7 @@ public Builder assignmentCache(IAssignmentCache assignmentCache) { } public Builder initialConfiguration(byte[] initialFlagConfigResponse) { - this.initialConfiguration = - CompletableFuture.completedFuture( - new Configuration.Builder(initialFlagConfigResponse).build()); - return this; - } - - public Builder initialConfiguration(CompletableFuture initialFlagConfigResponse) { - this.initialConfiguration = - initialFlagConfigResponse.thenApply(ic -> new Configuration.Builder(ic).build()); + this.initialConfiguration = Configuration.builder(initialFlagConfigResponse).build(); return this; } @@ -256,22 +259,50 @@ public Builder pollingJitterMs(long pollingJitterMs) { /** * Registers a callback for when a new configuration is applied to the `EppoClient` instance. */ - public Builder onConfigurationChange(Consumer configChangeCallback) { + public Builder onConfigurationChange(Configuration.Callback configChangeCallback) { this.configChangeCallback = configChangeCallback; return this; } - public CompletableFuture buildAndInitAsync() { - if (application == null) { - throw new MissingApplicationException(); + private static class GracefulInitCallback implements EppoActionCallback { + private final EppoActionCallback wrappedCallback; + private final boolean isGracefulMode; + private final EppoClient fallback; + + public GracefulInitCallback( + EppoActionCallback wrappedCallback, + EppoClient fallbackInstance, + boolean isGracefulMode) { + this.isGracefulMode = isGracefulMode; + this.wrappedCallback = wrappedCallback; + this.fallback = fallbackInstance; } - if (apiKey == null) { - throw new MissingApiKeyException(); + + @Override + public void onSuccess(EppoClient data) { + wrappedCallback.onSuccess(data); } + @Override + public void onFailure(Throwable error) { + Log.e(TAG, "Exception caught during initialization: " + error.getMessage(), error); + if (!isGracefulMode) { + + wrappedCallback.onFailure(error); + } else { + + fallback.activateConfiguration(Configuration.emptyConfig()); + wrappedCallback.onSuccess(fallback); + } + } + } + + public EppoClient buildAndInitAsync(EppoActionCallback onInitializedCallback) { + if (instance != null && !forceReinitialize) { Log.w(TAG, "Eppo Client instance already initialized"); - return CompletableFuture.completedFuture(instance); + onInitializedCallback.onSuccess(instance); + return instance; } else if (instance != null) { // Stop polling (if the client is polling for configuration) instance.stopPolling(); @@ -280,122 +311,199 @@ public CompletableFuture buildAndInitAsync() { Log.d(TAG, "`forceReinitialize` triggered reinitializing Eppo Client"); } - String sdkName = obfuscateConfig ? "android" : "android-debug"; + // String sdkName = obfuscateConfig ? "android" : "android-debug"; + String sdkName = "android"; String sdkVersion = BuildConfig.EPPO_VERSION; // Get caching from config store if (configStore == null) { // Cache at a per-API key level (useful for development) - String cacheFileNameSuffix = safeCacheKey(apiKey); + String cacheFileNameSuffix = safeCacheKey(sdkKey); configStore = new ConfigurationStore(application, cacheFileNameSuffix); } - // If the initial config was not set, use the ConfigurationStore's cache as the initial - // config. - if (initialConfiguration == null && !ignoreCachedConfiguration) { - initialConfiguration = configStore.loadConfigFromCache(); - } - instance = new EppoClient( - apiKey, + sdkKey, sdkName, sdkVersion, - host, apiBaseUrl, assignmentLogger, configStore, isGracefulMode, - obfuscateConfig, initialConfiguration, assignmentCache); + GracefulInitCallback initCallback = + new GracefulInitCallback(onInitializedCallback, instance, isGracefulMode); + if (configChangeCallback != null) { instance.onConfigurationChange(configChangeCallback); } - final CompletableFuture ret = new CompletableFuture<>(); - - AtomicInteger failCount = new AtomicInteger(0); - - if (!offlineMode) { - - // Not offline mode. Kick off a fetch. - instance - .loadConfigurationAsync() - .handle( - (success, ex) -> { - if (ex == null) { - ret.complete(instance); - } else if (failCount.incrementAndGet() == 2 - || instance.getInitialConfigFuture() == null) { - ret.completeExceptionally( - new EppoInitializationException( - "Unable to initialize client; Configuration could not be loaded", ex)); + // Early return for offline mode. + if (offlineMode) { + // Offline mode means initializing without making/waiting on any fetches or polling. + // Note: breaking change + if (pollingEnabled) { + Log.w(TAG, "Ignoring pollingEnabled parameter as offlineMode is set to true"); + } + // If there is not an `initialConfiguration`, attempt to load from the cache, or use an + // empty config. + if (initialConfiguration == null) { + if (!ignoreCachedConfiguration) { + configStore.loadConfigFromCacheAsync( + new Configuration.Callback() { + @Override + public void accept(Configuration config) { + instance.activateConfiguration( + config != null ? config : Configuration.emptyConfig()); + initCallback.onSuccess(instance); } - return null; }); + + } else { + // No initial config, offline mode, and ignore cache means bootstrap with an empty + // config + instance.activateConfiguration(Configuration.emptyConfig()); + initCallback.onSuccess(instance); + } + } else { + initCallback.onSuccess(instance); + } + return instance; } + AtomicInteger failCount = new AtomicInteger(0); + + // Not in offline mode. We'll kick off a fetch and attempt to load from the cache async. + AtomicBoolean configLoaded = new AtomicBoolean(false); + + configStore.loadConfigFromCacheAsync( + new Configuration.Callback() { + @Override + public void accept(Configuration configuration) { + if (configuration != null && !configuration.isEmpty()) { + if (!configLoaded.getAndSet(true)) { + // Config is not null, not empty and has not yet been set so set this one. + instance.activateConfiguration(configuration); + initCallback.onSuccess(instance); + } // else config has already been set + } else { + if (failCount.incrementAndGet() == 2) { + initCallback.onFailure( + new EppoInitializationException( + "Unable to initialize client; Configuration could not be loaded", null)); + } + } + } + }); + + instance.fetchAndActivateConfigurationAsync( + new EppoActionCallback() { + @Override + public void onSuccess(Configuration data) { + if (!configLoaded.getAndSet(true)) { + // Cache has not yet set the config + initCallback.onSuccess(instance); + } + } + + @Override + public void onFailure(Throwable error) { + // If the local load already failed, throw an error + if (failCount.incrementAndGet() == 2) { + initCallback.onFailure( + new EppoInitializationException( + "Unable to initialize client; Configuration could not be loaded", null)); + } + } + }); + // Start polling, if configured. if (pollingEnabled && pollingIntervalMs > 0) { Log.d(TAG, "Starting poller"); if (pollingJitterMs < 0) { pollingJitterMs = pollingIntervalMs / DEFAULT_JITTER_INTERVAL_RATIO; } - instance.startPolling(pollingIntervalMs, pollingJitterMs); } - - if (instance.getInitialConfigFuture() != null) { - instance - .getInitialConfigFuture() - .handle( - (success, ex) -> { - if (ex == null && 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 { - Log.d(TAG, "Initial config was not used."); - failCount.incrementAndGet(); - } - return null; - }); - } - return ret.exceptionally( - e -> { - Log.e(TAG, "Exception caught during initialization: " + e.getMessage(), e); - if (!isGracefulMode) { - throw new RuntimeException(e); - } - return instance; - }); + return instance; } /** Builds and initializes an `EppoClient`, immediately available to compute assignments. */ public EppoClient buildAndInit() { + return buildAndInit(5000); + } + + public EppoClient buildAndInit(long timeoutMs) { + final java.util.concurrent.CountDownLatch latch = new java.util.concurrent.CountDownLatch(1); + + // Using 1 element arrays as a shortcut for passing results back to the main thread + // (AtomicReference would also work) + final EppoClient[] resultClient = new EppoClient[1]; + final Throwable[] resultError = new Throwable[1]; + + buildAndInitAsync( + new EppoActionCallback() { + @Override + public void onSuccess(EppoClient data) { + resultClient[0] = data; + latch.countDown(); + } + + @Override + public void onFailure(Throwable error) { + resultError[0] = error; + latch.countDown(); + } + }); + try { - return buildAndInitAsync().get(); - } catch (ExecutionException | InterruptedException | CompletionException e) { - // If the exception was an `EppoInitializationException`, we know for sure that - // `buildAndInitAsync` logged it (and wrapped it with a RuntimeException) which was then - // wrapped by `CompletableFuture` with a `CompletionException`. - if (e instanceof CompletionException) { - Throwable cause = e.getCause(); - if (cause instanceof RuntimeException - && cause.getCause() instanceof EppoInitializationException) { - return instance; + // Wait for initialization to complete with timeout + if (!latch.await(timeoutMs, java.util.concurrent.TimeUnit.MILLISECONDS)) { + // Check for graceful mode here. + if (isGracefulMode) { + Log.e( + TAG, + "Timed out waiting for Eppo initialization, using empty config", + new RuntimeException("Initialization timeout")); + if (instance != null) { + instance.activateConfiguration(Configuration.emptyConfig()); + return instance; + } } + throw new RuntimeException("Timed out waiting for Eppo initialization"); } - Log.e(TAG, "Exception caught during initialization: " + e.getMessage(), e); - if (!isGracefulMode) { - throw new RuntimeException(e); + + if (resultError[0] != null) { + if (isGracefulMode) { + Log.e(TAG, "Failed to initialize Eppo, using empty config", resultError[0]); + if (instance != null) { + instance.activateConfiguration(Configuration.emptyConfig()); + return instance; + } + } + + if (resultError[0] instanceof RuntimeException) { + throw (RuntimeException) resultError[0]; + } + throw new RuntimeException("Failed to initialize Eppo", resultError[0]); } + + return resultClient[0]; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + // Check graceful mode here. + if (isGracefulMode) { + Log.e(TAG, "Interrupted while waiting for Eppo initialization, using empty config", e); + if (instance != null) { + instance.activateConfiguration(Configuration.emptyConfig()); + return instance; + } + } + throw new RuntimeException("Interrupted while waiting for Eppo initialization", e); } - return instance; } } diff --git a/eppo/src/main/java/cloud/eppo/android/adapters/AndroidJsonDeserializer.java b/eppo/src/main/java/cloud/eppo/android/adapters/AndroidJsonDeserializer.java new file mode 100644 index 00000000..ed0158f1 --- /dev/null +++ b/eppo/src/main/java/cloud/eppo/android/adapters/AndroidJsonDeserializer.java @@ -0,0 +1,359 @@ +package cloud.eppo.android.adapters; + +import static cloud.eppo.Utils.parseUtcISODateString; + +import android.util.Log; +import androidx.annotation.Nullable; +import cloud.eppo.Utils; +import cloud.eppo.android.util.AndroidUtils; +import cloud.eppo.api.EppoValue; +import cloud.eppo.exception.JsonParsingException; +import cloud.eppo.model.ShardRange; +import cloud.eppo.ufc.dto.Allocation; +import cloud.eppo.ufc.dto.BanditFlagVariation; +import cloud.eppo.ufc.dto.BanditParametersResponse; +import cloud.eppo.ufc.dto.BanditReference; +import cloud.eppo.ufc.dto.FlagConfig; +import cloud.eppo.ufc.dto.FlagConfigResponse; +import cloud.eppo.ufc.dto.OperatorType; +import cloud.eppo.ufc.dto.Shard; +import cloud.eppo.ufc.dto.Split; +import cloud.eppo.ufc.dto.TargetingCondition; +import cloud.eppo.ufc.dto.TargetingRule; +import cloud.eppo.ufc.dto.Variation; +import cloud.eppo.ufc.dto.VariationType; +import java.util.ArrayList; +import java.util.Date; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +public class AndroidJsonDeserializer implements Utils.JsonDeserializer { + private static final String TAG = AndroidUtils.logTag(AndroidJsonDeserializer.class); + private final cloud.eppo.android.adapters.EppoValueDeserializer eppoValueDeserializer = + new cloud.eppo.android.adapters.EppoValueDeserializer(); + + @Override + public FlagConfigResponse parseFlagConfigResponse(byte[] jsonBytes) throws JsonParsingException { + try { + String jsonString = new String(jsonBytes); + JSONObject root = new JSONObject(jsonString); + + if (!root.has("flags")) { + Log.w(TAG, "No root-level flags object"); + return new FlagConfigResponse(); + } + + // Default is to assume that the config is not obfuscated. + FlagConfigResponse.Format format = + root.has("format") + ? FlagConfigResponse.Format.valueOf(root.getString("format")) + : FlagConfigResponse.Format.SERVER; + + // Parse flags + Map flags = new HashMap<>(); + JSONObject flagsNode = root.getJSONObject("flags"); + Iterator flagKeys = flagsNode.keys(); + + while (flagKeys.hasNext()) { + String flagKey = flagKeys.next(); + flags.put(flagKey, deserializeFlag(flagsNode.getJSONObject(flagKey))); + } + + // Parse bandit references if they exist + Map banditRefs = new HashMap<>(); + if (root.has("banditReferences")) { + JSONObject banditRefsNode = root.getJSONObject("banditReferences"); + Iterator banditRefKeys = banditRefsNode.keys(); + + while (banditRefKeys.hasNext()) { + String banditKey = banditRefKeys.next(); + banditRefs.put( + banditKey, deserializeBanditReference(banditRefsNode.getJSONObject(banditKey))); + } + } + + return new FlagConfigResponse(flags, banditRefs, format); + } catch (JSONException e) { + // throw new JsonParsingException("Error parsing flag config response", e); + return new FlagConfigResponse(); + } + } + + @Override + public BanditParametersResponse parseBanditParametersResponse(byte[] jsonBytes) + throws JsonParsingException { + try { + String jsonString = new String(jsonBytes); + JSONObject root = new JSONObject(jsonString); + + // Implementation for BanditParametersResponse parsing + // This would need to be completed based on the BanditParametersResponse class structure + + return new BanditParametersResponse(); + } catch (JSONException e) { + // throw new JsonParsingException("Error parsing bandit parameters response", e); + return new BanditParametersResponse(); + } + } + + @Override + public boolean isValidJson(String json) { + try { + new JSONObject(json); + return true; + } catch (JSONException ex) { + try { + new JSONArray(json); + return true; + } catch (JSONException ex1) { + return false; + } + } + } + + @Override + public String serializeAttributesToJSONString(Map map, boolean omitNulls) { + try { + JSONObject jsonObject = new JSONObject(); + + for (Map.Entry entry : map.entrySet()) { + EppoValue value = entry.getValue(); + String key = entry.getKey(); + + // Skip null values if omitNulls is true + if (omitNulls && (value == null || value.isNull())) { + continue; + } + + if (value == null || value.isNull()) { + jsonObject.put(key, JSONObject.NULL); + } else if (value.isBoolean()) { + jsonObject.put(key, value.booleanValue()); + } else if (value.isNumeric()) { + jsonObject.put(key, value.doubleValue()); + } else if (value.isString()) { + jsonObject.put(key, value.stringValue()); + } else if (value.isStringArray()) { + JSONArray jsonArray = new JSONArray(); + for (String str : value.stringArrayValue()) { + jsonArray.put(str); + } + jsonObject.put(key, jsonArray); + } + } + + return jsonObject.toString(); + } catch (JSONException e) { + // In case of serialization error, return empty JSON object + return "{}"; + } + } + + private FlagConfig deserializeFlag(JSONObject jsonNode) throws JSONException { + String key = jsonNode.getString("key"); + boolean enabled = jsonNode.getBoolean("enabled"); + int totalShards = jsonNode.getInt("totalShards"); + VariationType variationType = VariationType.fromString(jsonNode.getString("variationType")); + Map variations = deserializeVariations(jsonNode.getJSONObject("variations")); + List allocations = deserializeAllocations(jsonNode.getJSONArray("allocations")); + + return new FlagConfig(key, enabled, totalShards, variationType, variations, allocations); + } + + private Map deserializeVariations(JSONObject jsonNode) throws JSONException { + Map variations = new HashMap<>(); + if (jsonNode == null) { + return variations; + } + + Iterator keys = jsonNode.keys(); + while (keys.hasNext()) { + String variationKey = keys.next(); + JSONObject variationNode = jsonNode.getJSONObject(variationKey); + String key = variationNode.getString("key"); + EppoValue value = eppoValueDeserializer.deserialize(variationNode.opt("value")); + variations.put(key, new Variation(key, value)); + } + + return variations; + } + + private List deserializeAllocations(JSONArray jsonArray) throws JSONException { + List allocations = new ArrayList<>(); + if (jsonArray == null) { + return allocations; + } + + for (int i = 0; i < jsonArray.length(); i++) { + JSONObject allocationNode = jsonArray.getJSONObject(i); + + try { + String key = allocationNode.getString("key"); + Set rules = deserializeTargetingRules(allocationNode.optJSONArray("rules")); + Date startAt = parseUtcISODateString(allocationNode.optString("startAt")); + Date endAt = parseUtcISODateString(allocationNode.optString("endAt")); + List splits = deserializeSplits(allocationNode.optJSONArray("splits")); + boolean doLog = allocationNode.getBoolean("doLog"); + + allocations.add(new Allocation(key, rules, startAt, endAt, splits, doLog)); + } catch (JSONException e) { + Log.w(TAG, "Error deserializing allocation at index " + i, e); + } + } + + return allocations; + } + + private Set deserializeTargetingRules(@Nullable JSONArray jsonArray) { + Set targetingRules = new HashSet<>(); + if (jsonArray == null || jsonArray.length() == 0) { + return targetingRules; + } + + // Better approach to the nested loops - process each rule separately + for (int i = 0; i < jsonArray.length(); i++) { + try { + JSONObject ruleNode = jsonArray.getJSONObject(i); + Set conditions = + deserializeConditions(ruleNode.optJSONArray("conditions")); + targetingRules.add(new TargetingRule(conditions)); + } catch (JSONException e) { + Log.w(TAG, "Error deserializing targeting rule at index " + i, e); + } + } + + return targetingRules; + } + + private Set deserializeConditions(JSONArray conditionArray) { + Set conditions = new HashSet<>(); + if (conditionArray == null || conditionArray.length() == 0) { + return conditions; + } + + // Process each condition in a separate method for better readability + for (int i = 0; i < conditionArray.length(); i++) { + try { + JSONObject conditionNode = conditionArray.getJSONObject(i); + String attribute = conditionNode.getString("attribute"); + String operatorKey = conditionNode.getString("operator"); + OperatorType operator = OperatorType.fromString(operatorKey); + + if (operator == null) { + Log.w(TAG, "Unknown operator: " + operatorKey); + continue; + } + + EppoValue value = eppoValueDeserializer.deserialize(conditionNode.get("value")); + conditions.add(new TargetingCondition(operator, attribute, value)); + } catch (JSONException e) { + Log.w(TAG, "Error deserializing condition at index " + i, e); + } + } + + return conditions; + } + + private List deserializeSplits(@Nullable JSONArray jsonArray) throws JSONException { + List splits = new ArrayList<>(); + if (jsonArray == null) { + return splits; + } + + for (int i = 0; i < jsonArray.length(); i++) { + try { + JSONObject splitNode = jsonArray.getJSONObject(i); + Set shards = deserializeShards(splitNode.getJSONArray("shards")); + + Map extraLogging = new HashMap<>(); + if (splitNode.has("extraLogging")) { + JSONObject extraLoggingNode = splitNode.getJSONObject("extraLogging"); + + Iterator logKeys = extraLoggingNode.keys(); + while (logKeys.hasNext()) { + String logKey = logKeys.next(); + extraLogging.put(logKey, extraLoggingNode.getString(logKey)); + } + } + + String key = splitNode.getString("variationKey"); + splits.add(new Split(key, shards, extraLogging)); + } catch (JSONException e) { + Log.w(TAG, "Error deserializing split at index " + i, e); + } + } + + return splits; + } + + private Set deserializeShards(JSONArray jsonArray) throws JSONException { + Set shards = new HashSet<>(); + if (jsonArray == null) { + return shards; + } + + for (int i = 0; i < jsonArray.length(); i++) { + try { + JSONObject shardNode = jsonArray.getJSONObject(i); + Set ranges = + (shardNode.has("ranges")) + ? deserializeShardRanges(shardNode.getJSONArray("ranges")) + : new HashSet<>(); + + String salt = shardNode.getString("salt"); + shards.add(new Shard(salt, ranges)); + } catch (JSONException e) { + Log.w(TAG, "Error deserializing shard at index " + i, e); + } + } + + return shards; + } + + private Set deserializeShardRanges(JSONArray rangeArray) throws JSONException { + Set ranges = new HashSet<>(); + for (int j = 0; j < rangeArray.length(); j++) { + JSONObject rangeNode = rangeArray.getJSONObject(j); + int startShardInclusive = rangeNode.getInt("start"); + int endShardExclusive = rangeNode.getInt("end"); + ranges.add(new ShardRange(startShardInclusive, endShardExclusive)); + } + return ranges; + } + + private BanditReference deserializeBanditReference(JSONObject jsonNode) throws JSONException { + String modelVersion = jsonNode.getString("modelVersion"); + List flagVariations = new ArrayList<>(); + + if (jsonNode.has("flagVariations")) { + JSONArray flagVariationsArray = jsonNode.getJSONArray("flagVariations"); + + for (int i = 0; i < flagVariationsArray.length(); i++) { + try { + JSONObject flagVariationNode = flagVariationsArray.getJSONObject(i); + String banditKey = flagVariationNode.getString("key"); + String flagKey = flagVariationNode.getString("flagKey"); + String allocationKey = flagVariationNode.getString("allocationKey"); + String variationKey = flagVariationNode.getString("variationKey"); + String variationValue = flagVariationNode.getString("variationValue"); + + BanditFlagVariation flagVariation = + new BanditFlagVariation( + banditKey, flagKey, allocationKey, variationKey, variationValue); + flagVariations.add(flagVariation); + } catch (JSONException e) { + Log.w(TAG, "Error deserializing bandit flag variation at index " + i, e); + } + } + } + + return new BanditReference(modelVersion, flagVariations); + } +} diff --git a/eppo/src/main/java/cloud/eppo/android/adapters/EppoValueDeserializer.java b/eppo/src/main/java/cloud/eppo/android/adapters/EppoValueDeserializer.java new file mode 100644 index 00000000..dd5489ac --- /dev/null +++ b/eppo/src/main/java/cloud/eppo/android/adapters/EppoValueDeserializer.java @@ -0,0 +1,44 @@ +package cloud.eppo.android.adapters; + +import androidx.annotation.Nullable; +import cloud.eppo.api.EppoValue; +import java.util.ArrayList; +import java.util.List; +import org.json.JSONArray; +import org.json.JSONObject; + +public class EppoValueDeserializer { + private final boolean isObfuscated; + + public EppoValueDeserializer(boolean isObfuscated) { + this.isObfuscated = isObfuscated; + } + + public EppoValueDeserializer() { + this(false); + } + + public EppoValue deserialize(@Nullable Object valueNode) { + if (valueNode == null || JSONObject.NULL.equals(valueNode)) { + return EppoValue.nullValue(); + } + if (valueNode instanceof String) { + return EppoValue.valueOf((String) valueNode); + } + if (valueNode instanceof Integer || valueNode instanceof Double) { + return EppoValue.valueOf(Double.parseDouble(valueNode.toString())); + } + if (valueNode instanceof Boolean) { + return EppoValue.valueOf((boolean) valueNode); + } + if (valueNode instanceof JSONArray) { + List strings = new ArrayList<>(); + JSONArray jArray = (JSONArray) valueNode; + for (int i = 0; i < jArray.length(); i++) { + strings.add(jArray.optString(i)); + } + return EppoValue.valueOf(strings); + } + throw new RuntimeException("unknown value type"); + } +} diff --git a/eppo/src/main/java/cloud/eppo/android/util/Utils.java b/eppo/src/main/java/cloud/eppo/android/util/AndroidUtils.java similarity index 92% rename from eppo/src/main/java/cloud/eppo/android/util/Utils.java rename to eppo/src/main/java/cloud/eppo/android/util/AndroidUtils.java index 8ba6a528..a549ec0a 100644 --- a/eppo/src/main/java/cloud/eppo/android/util/Utils.java +++ b/eppo/src/main/java/cloud/eppo/android/util/AndroidUtils.java @@ -1,5 +1,7 @@ package cloud.eppo.android.util; +import static cloud.eppo.Utils.Base64Codec; + import android.util.Base64; import java.io.ByteArrayOutputStream; import java.io.IOException; @@ -9,11 +11,12 @@ import java.util.Date; import java.util.Locale; -public final class Utils { +public final class AndroidUtils implements Base64Codec { private static final int BUFFER_SIZE = 8192; private static final SimpleDateFormat isoUtcDateFormat = buildUtcIsoDateFormat(); - public static String base64Encode(String input) { + @Override + public String base64Encode(String input) { if (input == null) { return null; } @@ -25,7 +28,8 @@ public static String base64Encode(String input) { return result; } - public static String base64Decode(String input) { + @Override + public String base64Decode(String input) { if (input == null) { return null; } diff --git a/eppo/src/test/java/cloud/eppo/android/UtilsTest.java b/eppo/src/test/java/cloud/eppo/android/UtilsTest.java index 413afe23..4081300c 100644 --- a/eppo/src/test/java/cloud/eppo/android/UtilsTest.java +++ b/eppo/src/test/java/cloud/eppo/android/UtilsTest.java @@ -1,13 +1,14 @@ package cloud.eppo.android; -import static cloud.eppo.android.util.Utils.base64Decode; -import static cloud.eppo.android.util.Utils.base64Encode; +import static cloud.eppo.Utils.base64Decode; +import static cloud.eppo.Utils.base64Encode; 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.android.util.Utils; +import cloud.eppo.Utils; +import cloud.eppo.android.util.AndroidUtils; import java.text.ParseException; import java.text.SimpleDateFormat; import java.util.Date; @@ -19,6 +20,9 @@ @RunWith(RobolectricTestRunner.class) // Needed for anything that relies on Base64 public class UtilsTest { + static { + Utils.setBase64Codec(new AndroidUtils()); + } @Test public void testGetISODate() { diff --git a/example/build.gradle b/example/build.gradle index 89b73fd1..cbf7f19c 100644 --- a/example/build.gradle +++ b/example/build.gradle @@ -34,6 +34,7 @@ android { } } compileOptions { + coreLibraryDesugaringEnabled true sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } @@ -57,6 +58,8 @@ spotless { dependencies { implementation 'androidx.appcompat:appcompat:1.7.0' implementation 'com.google.android.material:material:1.12.0' + coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.5' + implementation 'androidx.constraintlayout:constraintlayout:2.2.1' implementation project(path: ':eppo') testImplementation 'junit:junit:4.13.2' diff --git a/example/src/main/java/cloud/eppo/androidexample/MainActivity.java b/example/src/main/java/cloud/eppo/androidexample/MainActivity.java index c64f62d4..60fc7390 100644 --- a/example/src/main/java/cloud/eppo/androidexample/MainActivity.java +++ b/example/src/main/java/cloud/eppo/androidexample/MainActivity.java @@ -1,6 +1,6 @@ package cloud.eppo.androidexample; -import static cloud.eppo.android.util.Utils.safeCacheKey; +import static cloud.eppo.android.util.AndroidUtils.safeCacheKey; import static cloud.eppo.androidexample.Constants.INITIAL_FLAG_KEY; import static cloud.eppo.androidexample.Constants.INITIAL_SUBJECT_ID; diff --git a/example/src/main/java/cloud/eppo/androidexample/SecondActivity.java b/example/src/main/java/cloud/eppo/androidexample/SecondActivity.java index 6c64d21b..35efe11f 100644 --- a/example/src/main/java/cloud/eppo/androidexample/SecondActivity.java +++ b/example/src/main/java/cloud/eppo/androidexample/SecondActivity.java @@ -13,8 +13,10 @@ import androidx.appcompat.app.AppCompatActivity; import cloud.eppo.android.EppoClient; import cloud.eppo.api.Attributes; +import cloud.eppo.api.EppoActionCallback; import com.geteppo.androidexample.BuildConfig; import com.geteppo.androidexample.R; +import java.util.concurrent.CompletableFuture; public class SecondActivity extends AppCompatActivity { private static final String TAG = SecondActivity.class.getSimpleName(); @@ -24,6 +26,24 @@ public class SecondActivity extends AppCompatActivity { private TextView assignmentLog; private ScrollView assignmentLogScrollView; + public static class CompletableFutureCallback implements EppoActionCallback { + public final CompletableFuture future; + + public CompletableFutureCallback() { + future = new CompletableFuture<>(); + } + + @Override + public void onSuccess(T data) { + future.complete(data); + } + + @Override + public void onFailure(Throwable error) { + future.completeExceptionally(error); + } + } + @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); @@ -31,11 +51,12 @@ protected void onCreate(Bundle savedInstanceState) { Bundle extras = getIntent().getExtras(); boolean offlineMode = extras != null && extras.getBoolean(this.getPackageName() + ".offlineMode", false); - + CompletableFutureCallback buffer = new CompletableFutureCallback<>(); new EppoClient.Builder(API_KEY, getApplication()) .isGracefulMode( false) // Note: This is for debugging--stick to default of "true" in production .offlineMode(offlineMode) + .pollingIntervalMs(30000) .assignmentLogger( assignment -> { Log.d( @@ -46,7 +67,9 @@ protected void onCreate(Bundle savedInstanceState) { + " assigned to " + assignment.getExperiment()); }) - .buildAndInitAsync() + .buildAndInitAsync(buffer); + buffer + .future .thenAccept( client -> { Log.d(TAG, "Eppo SDK initialized");