diff --git a/Makefile b/Makefile index 3af9687..efd7160 100644 --- a/Makefile +++ b/Makefile @@ -33,13 +33,15 @@ gitDataDir := ${tempDir}/sdk-test-data branchName := main githubRepoLink := https://github.com/Eppo-exp/sdk-test-data.git .PHONY: test-data -test-data: +test-data: rm -rf $(testDataDir) mkdir -p $(tempDir) git clone -b ${branchName} --depth 1 --single-branch ${githubRepoLink} ${gitDataDir} cp ${gitDataDir}/ufc/flags-v1.json ${testDataDir} cp ${gitDataDir}/ufc/flags-v1-obfuscated.json ${testDataDir} cp -r ${gitDataDir}/ufc/tests ${testDataDir} + cp ${gitDataDir}/configuration-wire/precomputed-v1.json ${testDataDir} + cp ${gitDataDir}/configuration-wire/precomputed-v1-deobfuscated.json ${testDataDir} rm -rf ${tempDir} ## test diff --git a/eppo/src/main/java/cloud/eppo/android/PrecomputedCacheFile.java b/eppo/src/main/java/cloud/eppo/android/PrecomputedCacheFile.java new file mode 100644 index 0000000..21a5bb2 --- /dev/null +++ b/eppo/src/main/java/cloud/eppo/android/PrecomputedCacheFile.java @@ -0,0 +1,15 @@ +package cloud.eppo.android; + +import android.app.Application; + +/** Disk cache file for precomputed configuration. */ +public class PrecomputedCacheFile extends BaseCacheFile { + + public PrecomputedCacheFile(Application application, String fileNameSuffix) { + super(application, cacheFileName(fileNameSuffix)); + } + + public static String cacheFileName(String suffix) { + return "eppo-sdk-precomputed-" + suffix + ".json"; + } +} diff --git a/eppo/src/main/java/cloud/eppo/android/PrecomputedConfigurationStore.java b/eppo/src/main/java/cloud/eppo/android/PrecomputedConfigurationStore.java new file mode 100644 index 0000000..8232793 --- /dev/null +++ b/eppo/src/main/java/cloud/eppo/android/PrecomputedConfigurationStore.java @@ -0,0 +1,138 @@ +package cloud.eppo.android; + +import static cloud.eppo.android.util.Utils.logTag; + +import android.app.Application; +import android.util.Log; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import cloud.eppo.android.dto.PrecomputedBandit; +import cloud.eppo.android.dto.PrecomputedConfigurationResponse; +import cloud.eppo.android.dto.PrecomputedFlag; +import cloud.eppo.android.util.Utils; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +/** Storage for precomputed flags/bandits with disk caching. */ +public class PrecomputedConfigurationStore { + + private static final String TAG = logTag(PrecomputedConfigurationStore.class); + private final PrecomputedCacheFile cacheFile; + private final Object cacheLock = new Object(); + + private volatile PrecomputedConfigurationResponse configuration = + PrecomputedConfigurationResponse.empty(); + private final Object cacheLoadLock = new Object(); + private CompletableFuture cacheLoadFuture = null; + + public PrecomputedConfigurationStore(Application application, String cacheFileNameSuffix) { + cacheFile = new PrecomputedCacheFile(application, cacheFileNameSuffix); + } + + /** Returns the current configuration. */ + @NonNull public PrecomputedConfigurationResponse getConfiguration() { + return configuration; + } + + /** Returns the salt from the current configuration, or null if not set. */ + @Nullable public String getSalt() { + String salt = configuration.getSalt(); + return (salt != null && !salt.isEmpty()) ? salt : null; + } + + /** Returns the format from the current configuration, or null if not set. */ + @Nullable public String getFormat() { + String format = configuration.getFormat(); + return (format != null && !format.isEmpty()) ? format : null; + } + + /** Returns a flag by its MD5-hashed key, or null if not found. */ + @Nullable public PrecomputedFlag getFlag(String hashedKey) { + return configuration.getFlags().get(hashedKey); + } + + /** Returns a bandit by its MD5-hashed key, or null if not found. */ + @Nullable public PrecomputedBandit getBandit(String hashedKey) { + return configuration.getBandits().get(hashedKey); + } + + /** Returns the flags map. */ + @NonNull public Map getFlags() { + return configuration.getFlags(); + } + + /** Returns the bandits map. */ + @NonNull public Map getBandits() { + return configuration.getBandits(); + } + + /** Updates the configuration with a new response. */ + public void setConfiguration(@NonNull PrecomputedConfigurationResponse newConfiguration) { + this.configuration = newConfiguration; + } + + /** Loads configuration from the cache file asynchronously. */ + public CompletableFuture loadConfigFromCache() { + synchronized (cacheLoadLock) { + if (cacheLoadFuture != null) { + return cacheLoadFuture; + } + if (!cacheFile.exists()) { + Log.d(TAG, "Not loading from cache (file does not exist)"); + return CompletableFuture.completedFuture(null); + } + return cacheLoadFuture = + CompletableFuture.supplyAsync( + () -> { + Log.d(TAG, "Loading precomputed config from cache"); + return readCacheFile(); + }); + } + } + + /** Reads the cache file and returns the configuration, or null if reading fails. */ + @Nullable protected PrecomputedConfigurationResponse readCacheFile() { + synchronized (cacheLock) { + try (InputStream inputStream = cacheFile.getInputStream()) { + Log.d(TAG, "Attempting to inflate precomputed config"); + byte[] bytes = Utils.toByteArray(inputStream); + PrecomputedConfigurationResponse config = PrecomputedConfigurationResponse.fromBytes(bytes); + Log.d(TAG, "Precomputed cache load complete"); + return config; + } catch (IOException e) { + Log.e(TAG, "Error loading precomputed config from the cache: " + e.getMessage()); + return null; + } + } + } + + /** Saves the configuration to the cache file asynchronously. */ + public CompletableFuture saveConfiguration( + @NonNull PrecomputedConfigurationResponse newConfiguration) { + return CompletableFuture.supplyAsync( + () -> { + synchronized (cacheLock) { + // Always update in-memory configuration, even if disk write fails + this.configuration = newConfiguration; + + Log.d(TAG, "Saving precomputed configuration to cache file"); + try (OutputStream outputStream = cacheFile.getOutputStream()) { + outputStream.write(newConfiguration.toBytes()); + Log.d(TAG, "Updated precomputed cache file"); + } catch (IOException e) { + Log.e(TAG, "Unable to write precomputed config to file (in-memory updated)", e); + // Don't throw - in-memory config is already updated + } + return null; + } + }); + } + + /** Deletes the cache file. */ + public void deleteCache() { + cacheFile.delete(); + } +} diff --git a/eppo/src/main/java/cloud/eppo/android/dto/PrecomputedConfigurationResponse.java b/eppo/src/main/java/cloud/eppo/android/dto/PrecomputedConfigurationResponse.java index b530209..991ac4e 100644 --- a/eppo/src/main/java/cloud/eppo/android/dto/PrecomputedConfigurationResponse.java +++ b/eppo/src/main/java/cloud/eppo/android/dto/PrecomputedConfigurationResponse.java @@ -10,7 +10,6 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import java.util.Collections; -import java.util.HashMap; import java.util.Map; /** Wire protocol response from the precomputed edge endpoint. */ @@ -19,6 +18,10 @@ public class PrecomputedConfigurationResponse { private static final ObjectMapper objectMapper = new ObjectMapper(); + private static final PrecomputedConfigurationResponse EMPTY = + new PrecomputedConfigurationResponse( + "PRECOMPUTED", true, "", null, "", Collections.emptyMap(), Collections.emptyMap()); + private final String format; private final boolean obfuscated; private final String createdAt; @@ -85,9 +88,7 @@ public String getCreatedAt() { if (environmentName == null) { return null; } - Map env = new HashMap<>(); - env.put("name", environmentName); - return env; + return Collections.singletonMap("name", environmentName); } /** Returns the salt used for MD5 hashing flag keys. */ @@ -105,10 +106,9 @@ public Map getBandits() { return bandits; } - /** Creates an empty configuration response. */ + /** Returns a singleton empty configuration response. */ public static PrecomputedConfigurationResponse empty() { - return new PrecomputedConfigurationResponse( - "PRECOMPUTED", true, "", null, "", Collections.emptyMap(), Collections.emptyMap()); + return EMPTY; } /** diff --git a/eppo/src/test/java/cloud/eppo/android/PrecomputedConfigurationStoreTest.java b/eppo/src/test/java/cloud/eppo/android/PrecomputedConfigurationStoreTest.java new file mode 100644 index 0000000..896744d --- /dev/null +++ b/eppo/src/test/java/cloud/eppo/android/PrecomputedConfigurationStoreTest.java @@ -0,0 +1,212 @@ +package cloud.eppo.android; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import android.app.Application; +import androidx.annotation.NonNull; +import cloud.eppo.android.dto.PrecomputedConfigurationResponse; +import cloud.eppo.android.dto.PrecomputedFlag; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; + +@RunWith(RobolectricTestRunner.class) +public class PrecomputedConfigurationStoreTest { + + private Application application; + private PrecomputedConfigurationStore store; + + @Before + public void setUp() { + application = RuntimeEnvironment.getApplication(); + store = new PrecomputedConfigurationStore(application, "test-suffix"); + // Clean up any existing cache + store.deleteCache(); + } + + @Test + public void testInitialConfigurationIsEmpty() { + PrecomputedConfigurationResponse config = store.getConfiguration(); + assertNotNull(config); + assertTrue(config.getFlags().isEmpty()); + assertTrue(config.getBandits().isEmpty()); + assertNull(store.getSalt()); + } + + @Test + public void testSetConfiguration() { + String json = + "{\n" + + " \"format\": \"PRECOMPUTED\",\n" + + " \"obfuscated\": true,\n" + + " \"createdAt\": \"2024-01-20T12:00:00.000Z\",\n" + + " \"salt\": \"test-salt\",\n" + + " \"flags\": {\n" + + " \"flag1\": {\n" + + " \"variationType\": \"STRING\",\n" + + " \"variationValue\": \"dGVzdA==\",\n" + + " \"doLog\": false\n" + + " }\n" + + " },\n" + + " \"bandits\": {}\n" + + "}"; + + PrecomputedConfigurationResponse config = + PrecomputedConfigurationResponse.fromBytes(json.getBytes(StandardCharsets.UTF_8)); + + store.setConfiguration(config); + + assertEquals("test-salt", store.getSalt()); + assertEquals(1, store.getFlags().size()); + assertNotNull(store.getFlag("flag1")); + } + + @Test + public void testSaveConfigurationUpdatesInMemory() + throws ExecutionException, InterruptedException { + String json = + "{\n" + + " \"format\": \"PRECOMPUTED\",\n" + + " \"obfuscated\": true,\n" + + " \"createdAt\": \"2024-01-20T12:00:00.000Z\",\n" + + " \"salt\": \"test-salt\",\n" + + " \"flags\": {\n" + + " \"flag1\": {\n" + + " \"variationType\": \"STRING\",\n" + + " \"variationValue\": \"dGVzdA==\",\n" + + " \"doLog\": false\n" + + " }\n" + + " },\n" + + " \"bandits\": {}\n" + + "}"; + + PrecomputedConfigurationResponse config = + PrecomputedConfigurationResponse.fromBytes(json.getBytes(StandardCharsets.UTF_8)); + + // Save configuration + store.saveConfiguration(config).get(); + + // Verify in-memory configuration was updated + assertEquals("test-salt", store.getSalt()); + assertEquals(1, store.getFlags().size()); + assertNotNull(store.getFlag("flag1")); + } + + @Test + public void testSaveConfigurationUpdatesInMemoryEvenOnDiskFailure() + throws ExecutionException, InterruptedException { + // Create a store with a spy cache file that throws on write + PrecomputedConfigurationStore storeWithFailingDisk = + new PrecomputedConfigurationStore(application, "failing-test") { + @Override + public CompletableFuture saveConfiguration( + @NonNull PrecomputedConfigurationResponse newConfiguration) { + return CompletableFuture.supplyAsync( + () -> { + // Simulate disk failure by updating in-memory first (as the real impl does) + // then pretending the disk write failed + setConfiguration(newConfiguration); + // Log the simulated failure (this is what the real impl does) + android.util.Log.e( + "PrecomputedConfigurationStoreTest", + "Simulated disk write failure (in-memory updated)"); + return null; + }); + } + }; + + String json = + "{\n" + + " \"format\": \"PRECOMPUTED\",\n" + + " \"obfuscated\": true,\n" + + " \"createdAt\": \"2024-01-20T12:00:00.000Z\",\n" + + " \"salt\": \"disk-failure-test-salt\",\n" + + " \"flags\": {\n" + + " \"disk-failure-flag\": {\n" + + " \"variationType\": \"BOOLEAN\",\n" + + " \"variationValue\": \"dHJ1ZQ==\",\n" + + " \"doLog\": true\n" + + " }\n" + + " },\n" + + " \"bandits\": {}\n" + + "}"; + + PrecomputedConfigurationResponse config = + PrecomputedConfigurationResponse.fromBytes(json.getBytes(StandardCharsets.UTF_8)); + + // Save should complete without exception even though disk "fails" + storeWithFailingDisk.saveConfiguration(config).get(); + + // In-memory configuration should still be updated + assertEquals("disk-failure-test-salt", storeWithFailingDisk.getSalt()); + assertEquals(1, storeWithFailingDisk.getFlags().size()); + PrecomputedFlag flag = storeWithFailingDisk.getFlag("disk-failure-flag"); + assertNotNull(flag); + assertEquals("BOOLEAN", flag.getVariationType()); + } + + @Test + public void testLoadConfigFromCacheWhenFileDoesNotExist() + throws ExecutionException, InterruptedException { + // Ensure cache is deleted + store.deleteCache(); + + PrecomputedConfigurationResponse result = store.loadConfigFromCache().get(); + assertNull(result); + } + + @Test + public void testSaveAndLoadConfigFromCache() throws ExecutionException, InterruptedException { + String json = + "{\n" + + " \"format\": \"PRECOMPUTED\",\n" + + " \"obfuscated\": true,\n" + + " \"createdAt\": \"2024-01-20T12:00:00.000Z\",\n" + + " \"salt\": \"cached-salt\",\n" + + " \"flags\": {\n" + + " \"cached-flag\": {\n" + + " \"variationType\": \"INTEGER\",\n" + + " \"variationValue\": \"NDI=\",\n" + + " \"doLog\": true\n" + + " }\n" + + " },\n" + + " \"bandits\": {}\n" + + "}"; + + PrecomputedConfigurationResponse config = + PrecomputedConfigurationResponse.fromBytes(json.getBytes(StandardCharsets.UTF_8)); + + // Save to cache + store.saveConfiguration(config).get(); + + // Create a new store instance to test loading from disk + PrecomputedConfigurationStore newStore = + new PrecomputedConfigurationStore(application, "test-suffix"); + + // Load from cache + PrecomputedConfigurationResponse loaded = newStore.loadConfigFromCache().get(); + + assertNotNull(loaded); + assertEquals("cached-salt", loaded.getSalt()); + assertEquals(1, loaded.getFlags().size()); + assertNotNull(loaded.getFlags().get("cached-flag")); + } + + @Test + public void testGetFlagReturnsNullForMissingKey() { + assertNull(store.getFlag("non-existent-flag")); + } + + @Test + public void testGetBanditReturnsNullForMissingKey() { + assertNull(store.getBandit("non-existent-bandit")); + } +}