From 8a454913d23b679a387901def79222bb3b760f85 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Wed, 28 Jan 2026 10:18:51 -0500 Subject: [PATCH 1/7] feat: add obfuscation utilities and precomputed DTOs Add the foundational data structures and utilities for the precomputed client feature: - ObfuscationUtils: MD5 hashing for flag key obfuscation - PrecomputedFlag: DTO for precomputed flag assignments - PrecomputedBandit: DTO for precomputed bandit assignments - PrecomputedConfigurationResponse: Wire protocol response parsing - BanditResult: Result container for bandit action lookups - MissingSubjectKeyException: Validation exception Includes comprehensive unit tests for serialization round-trips and MD5 hash consistency. --- .../cloud/eppo/android/dto/BanditResult.java | 25 ++ .../eppo/android/dto/PrecomputedBandit.java | 76 ++++++ .../dto/PrecomputedConfigurationResponse.java | 142 +++++++++++ .../eppo/android/dto/PrecomputedFlag.java | 68 +++++ .../MissingSubjectKeyException.java | 8 + .../eppo/android/util/ObfuscationUtils.java | 53 ++++ .../eppo/android/ObfuscationUtilsTest.java | 86 +++++++ .../PrecomputedConfigurationResponseTest.java | 233 ++++++++++++++++++ 8 files changed, 691 insertions(+) create mode 100644 eppo/src/main/java/cloud/eppo/android/dto/BanditResult.java create mode 100644 eppo/src/main/java/cloud/eppo/android/dto/PrecomputedBandit.java create mode 100644 eppo/src/main/java/cloud/eppo/android/dto/PrecomputedConfigurationResponse.java create mode 100644 eppo/src/main/java/cloud/eppo/android/dto/PrecomputedFlag.java create mode 100644 eppo/src/main/java/cloud/eppo/android/exceptions/MissingSubjectKeyException.java create mode 100644 eppo/src/main/java/cloud/eppo/android/util/ObfuscationUtils.java create mode 100644 eppo/src/test/java/cloud/eppo/android/ObfuscationUtilsTest.java create mode 100644 eppo/src/test/java/cloud/eppo/android/PrecomputedConfigurationResponseTest.java diff --git a/eppo/src/main/java/cloud/eppo/android/dto/BanditResult.java b/eppo/src/main/java/cloud/eppo/android/dto/BanditResult.java new file mode 100644 index 00000000..32d36149 --- /dev/null +++ b/eppo/src/main/java/cloud/eppo/android/dto/BanditResult.java @@ -0,0 +1,25 @@ +package cloud.eppo.android.dto; + +import androidx.annotation.Nullable; + +/** Result of a bandit action assignment containing the variation and optional action. */ +public class BanditResult { + + private final String variation; + @Nullable private final String action; + + public BanditResult(String variation, @Nullable String action) { + this.variation = variation; + this.action = action; + } + + /** Returns the assigned variation value. */ + public String getVariation() { + return variation; + } + + /** Returns the action associated with the assignment, or null if not available. */ + @Nullable public String getAction() { + return action; + } +} diff --git a/eppo/src/main/java/cloud/eppo/android/dto/PrecomputedBandit.java b/eppo/src/main/java/cloud/eppo/android/dto/PrecomputedBandit.java new file mode 100644 index 00000000..65312e56 --- /dev/null +++ b/eppo/src/main/java/cloud/eppo/android/dto/PrecomputedBandit.java @@ -0,0 +1,76 @@ +package cloud.eppo.android.dto; + +import androidx.annotation.Nullable; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.Map; + +/** + * Represents a precomputed bandit assignment from the edge endpoint. String fields are Base64 + * encoded. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public class PrecomputedBandit { + + private final String banditKey; + private final String action; + private final String modelVersion; + @Nullable private final Map actionNumericAttributes; + @Nullable private final Map actionCategoricalAttributes; + private final double actionProbability; + private final double optimalityGap; + + @JsonCreator + public PrecomputedBandit( + @JsonProperty("banditKey") String banditKey, + @JsonProperty("action") String action, + @JsonProperty("modelVersion") String modelVersion, + @JsonProperty("actionNumericAttributes") @Nullable Map actionNumericAttributes, + @JsonProperty("actionCategoricalAttributes") @Nullable Map actionCategoricalAttributes, + @JsonProperty("actionProbability") double actionProbability, + @JsonProperty("optimalityGap") double optimalityGap) { + this.banditKey = banditKey; + this.action = action; + this.modelVersion = modelVersion; + this.actionNumericAttributes = actionNumericAttributes; + this.actionCategoricalAttributes = actionCategoricalAttributes; + this.actionProbability = actionProbability; + this.optimalityGap = optimalityGap; + } + + /** Returns the Base64-encoded bandit key. */ + public String getBanditKey() { + return banditKey; + } + + /** Returns the Base64-encoded action. */ + public String getAction() { + return action; + } + + /** Returns the Base64-encoded model version. */ + public String getModelVersion() { + return modelVersion; + } + + /** Returns the Base64-encoded numeric attributes for the action. */ + @Nullable public Map getActionNumericAttributes() { + return actionNumericAttributes; + } + + /** Returns the Base64-encoded categorical attributes for the action. */ + @Nullable public Map getActionCategoricalAttributes() { + return actionCategoricalAttributes; + } + + /** Returns the probability of taking this action. */ + public double getActionProbability() { + return actionProbability; + } + + /** Returns the gap to the optimal action. */ + public double getOptimalityGap() { + return optimalityGap; + } +} diff --git a/eppo/src/main/java/cloud/eppo/android/dto/PrecomputedConfigurationResponse.java b/eppo/src/main/java/cloud/eppo/android/dto/PrecomputedConfigurationResponse.java new file mode 100644 index 00000000..b5302098 --- /dev/null +++ b/eppo/src/main/java/cloud/eppo/android/dto/PrecomputedConfigurationResponse.java @@ -0,0 +1,142 @@ +package cloud.eppo.android.dto; + +import androidx.annotation.Nullable; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonGetter; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.core.JsonProcessingException; +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. */ +@JsonIgnoreProperties(ignoreUnknown = true) +public class PrecomputedConfigurationResponse { + + private static final ObjectMapper objectMapper = new ObjectMapper(); + + private final String format; + private final boolean obfuscated; + private final String createdAt; + @Nullable private final String environmentName; + private final String salt; + private final Map flags; + private final Map bandits; + + @JsonCreator + public PrecomputedConfigurationResponse( + @JsonProperty("format") String format, + @JsonProperty("obfuscated") boolean obfuscated, + @JsonProperty("createdAt") String createdAt, + @JsonProperty("environment") @Nullable JsonNode environment, + @JsonProperty("salt") String salt, + @JsonProperty("flags") @Nullable Map flags, + @JsonProperty("bandits") @Nullable Map bandits) { + this.format = format; + this.obfuscated = obfuscated; + this.createdAt = createdAt; + this.environmentName = extractEnvironmentName(environment); + this.salt = salt; + this.flags = flags != null ? flags : Collections.emptyMap(); + this.bandits = bandits != null ? bandits : Collections.emptyMap(); + } + + @Nullable private static String extractEnvironmentName(@Nullable JsonNode environment) { + if (environment == null) { + return null; + } + if (environment.isTextual()) { + return environment.asText(); + } + if (environment.isObject() && environment.has("name")) { + return environment.get("name").asText(); + } + return null; + } + + /** Returns the format of the configuration (always "PRECOMPUTED"). */ + public String getFormat() { + return format; + } + + /** Returns whether this configuration is obfuscated (always true for precomputed). */ + public boolean isObfuscated() { + return obfuscated; + } + + /** Returns the ISO 8601 timestamp when this configuration was created. */ + public String getCreatedAt() { + return createdAt; + } + + /** Returns the environment name, or null if not present. */ + @JsonIgnore + @Nullable public String getEnvironmentName() { + return environmentName; + } + + /** Returns the environment as a map for JSON serialization. */ + @JsonGetter("environment") + @Nullable public Map getEnvironment() { + if (environmentName == null) { + return null; + } + Map env = new HashMap<>(); + env.put("name", environmentName); + return env; + } + + /** Returns the salt used for MD5 hashing flag keys. */ + public String getSalt() { + return salt; + } + + /** Returns the map of MD5-hashed flag keys to precomputed flags. */ + public Map getFlags() { + return flags; + } + + /** Returns the map of MD5-hashed bandit keys to precomputed bandits. */ + public Map getBandits() { + return bandits; + } + + /** Creates an empty configuration response. */ + public static PrecomputedConfigurationResponse empty() { + return new PrecomputedConfigurationResponse( + "PRECOMPUTED", true, "", null, "", Collections.emptyMap(), Collections.emptyMap()); + } + + /** + * Parses a JSON byte array into a PrecomputedConfigurationResponse. + * + * @param bytes JSON byte array + * @return Parsed response + * @throws RuntimeException if parsing fails + */ + public static PrecomputedConfigurationResponse fromBytes(byte[] bytes) { + try { + return objectMapper.readValue(bytes, PrecomputedConfigurationResponse.class); + } catch (Exception e) { + throw new RuntimeException("Failed to parse precomputed configuration", e); + } + } + + /** + * Serializes this response to a JSON byte array. + * + * @return JSON byte array + * @throws RuntimeException if serialization fails + */ + public byte[] toBytes() { + try { + return objectMapper.writeValueAsBytes(this); + } catch (JsonProcessingException e) { + throw new RuntimeException("Failed to serialize precomputed configuration", e); + } + } +} diff --git a/eppo/src/main/java/cloud/eppo/android/dto/PrecomputedFlag.java b/eppo/src/main/java/cloud/eppo/android/dto/PrecomputedFlag.java new file mode 100644 index 00000000..a87e801f --- /dev/null +++ b/eppo/src/main/java/cloud/eppo/android/dto/PrecomputedFlag.java @@ -0,0 +1,68 @@ +package cloud.eppo.android.dto; + +import androidx.annotation.Nullable; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.Map; + +/** + * Represents a precomputed flag assignment from the edge endpoint. All string fields except + * variationType are Base64 encoded. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public class PrecomputedFlag { + + @Nullable private final String allocationKey; + @Nullable private final String variationKey; + private final String variationType; + private final String variationValue; + @Nullable private final Map extraLogging; + private final boolean doLog; + + @JsonCreator + public PrecomputedFlag( + @JsonProperty("allocationKey") @Nullable String allocationKey, + @JsonProperty("variationKey") @Nullable String variationKey, + @JsonProperty("variationType") String variationType, + @JsonProperty("variationValue") String variationValue, + @JsonProperty("extraLogging") @Nullable Map extraLogging, + @JsonProperty("doLog") boolean doLog) { + this.allocationKey = allocationKey; + this.variationKey = variationKey; + this.variationType = variationType; + this.variationValue = variationValue; + this.extraLogging = extraLogging; + this.doLog = doLog; + } + + /** Returns the Base64-encoded allocation key, or null if not assigned. */ + @Nullable public String getAllocationKey() { + return allocationKey; + } + + /** Returns the Base64-encoded variation key, or null if not assigned. */ + @Nullable public String getVariationKey() { + return variationKey; + } + + /** Returns the variation type (STRING, BOOLEAN, INTEGER, NUMERIC, JSON). */ + public String getVariationType() { + return variationType; + } + + /** Returns the Base64-encoded variation value. */ + public String getVariationValue() { + return variationValue; + } + + /** Returns the Base64-encoded extra logging map, or null if not present. */ + @Nullable public Map getExtraLogging() { + return extraLogging; + } + + /** Returns whether this assignment should be logged. */ + public boolean isDoLog() { + return doLog; + } +} diff --git a/eppo/src/main/java/cloud/eppo/android/exceptions/MissingSubjectKeyException.java b/eppo/src/main/java/cloud/eppo/android/exceptions/MissingSubjectKeyException.java new file mode 100644 index 00000000..3da66e6f --- /dev/null +++ b/eppo/src/main/java/cloud/eppo/android/exceptions/MissingSubjectKeyException.java @@ -0,0 +1,8 @@ +package cloud.eppo.android.exceptions; + +/** Exception thrown when a subject key is required but not provided. */ +public class MissingSubjectKeyException extends RuntimeException { + public MissingSubjectKeyException() { + super("Missing subjectKey"); + } +} diff --git a/eppo/src/main/java/cloud/eppo/android/util/ObfuscationUtils.java b/eppo/src/main/java/cloud/eppo/android/util/ObfuscationUtils.java new file mode 100644 index 00000000..9ad0202e --- /dev/null +++ b/eppo/src/main/java/cloud/eppo/android/util/ObfuscationUtils.java @@ -0,0 +1,53 @@ +package cloud.eppo.android.util; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +/** Utility class for obfuscation operations used in precomputed flag lookups. */ +public final class ObfuscationUtils { + + private ObfuscationUtils() { + // Prevent instantiation + } + + /** + * Generates an MD5 hash of the input string with an optional salt. + * + * @param input The string to hash + * @param salt Optional salt to prepend to the input (can be null) + * @return 32-character lowercase hexadecimal MD5 hash + */ + public static String md5Hex(String input, String salt) { + String saltedInput = salt != null ? salt + input : input; + try { + MessageDigest md = MessageDigest.getInstance("MD5"); + byte[] digest = md.digest(saltedInput.getBytes(StandardCharsets.UTF_8)); + return bytesToHex(digest); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException("MD5 algorithm not available", e); + } + } + + /** + * Generates an MD5 hash of the input string (without salt). + * + * @param input The string to hash + * @return 32-character lowercase hexadecimal MD5 hash + */ + public static String md5Hex(String input) { + return md5Hex(input, null); + } + + private static String bytesToHex(byte[] bytes) { + StringBuilder hexString = new StringBuilder(32); + for (byte b : bytes) { + String hex = Integer.toHexString(0xff & b); + if (hex.length() == 1) { + hexString.append('0'); + } + hexString.append(hex); + } + return hexString.toString(); + } +} diff --git a/eppo/src/test/java/cloud/eppo/android/ObfuscationUtilsTest.java b/eppo/src/test/java/cloud/eppo/android/ObfuscationUtilsTest.java new file mode 100644 index 00000000..d182de3c --- /dev/null +++ b/eppo/src/test/java/cloud/eppo/android/ObfuscationUtilsTest.java @@ -0,0 +1,86 @@ +package cloud.eppo.android; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +import cloud.eppo.android.util.ObfuscationUtils; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +@RunWith(RobolectricTestRunner.class) +public class ObfuscationUtilsTest { + + @Test + public void testMd5HexWithoutSalt() { + // Standard MD5 test vectors + String result = ObfuscationUtils.md5Hex(""); + assertEquals("d41d8cd98f00b204e9800998ecf8427e", result); + + result = ObfuscationUtils.md5Hex("a"); + assertEquals("0cc175b9c0f1b6a831c399e269772661", result); + + result = ObfuscationUtils.md5Hex("abc"); + assertEquals("900150983cd24fb0d6963f7d28e17f72", result); + + result = ObfuscationUtils.md5Hex("message digest"); + assertEquals("f96b697d7cb7938d525a2f31aaf161d0", result); + } + + @Test + public void testMd5HexWithSalt() { + // With salt, the input becomes salt + input + String result = ObfuscationUtils.md5Hex("flag_key", "my-salt"); + assertNotNull(result); + assertEquals(32, result.length()); // MD5 always produces 32 hex chars + + // Verify that salt is prepended + String withoutSalt = ObfuscationUtils.md5Hex("flag_key"); + String saltAndInput = ObfuscationUtils.md5Hex("my-saltflag_key"); + String withSalt = ObfuscationUtils.md5Hex("flag_key", "my-salt"); + + // The result with salt should equal md5(salt + input) + assertEquals(saltAndInput, withSalt); + // And should not equal md5(input) alone + assertNotNull(withoutSalt); + } + + @Test + public void testMd5HexWithNullSalt() { + // Null salt should behave like no salt + String withNullSalt = ObfuscationUtils.md5Hex("test", null); + String withoutSalt = ObfuscationUtils.md5Hex("test"); + assertEquals(withoutSalt, withNullSalt); + } + + @Test + public void testMd5HexConsistency() { + // Same input should always produce same output + String input = "consistent-input"; + String salt = "consistent-salt"; + + String result1 = ObfuscationUtils.md5Hex(input, salt); + String result2 = ObfuscationUtils.md5Hex(input, salt); + assertEquals(result1, result2); + } + + @Test + public void testMd5HexLowercase() { + // Result should always be lowercase + String result = ObfuscationUtils.md5Hex("ABC"); + assertEquals(result.toLowerCase(), result); + } + + @Test + public void testMd5HexLength() { + // MD5 should always produce 32 character hex string + String[] inputs = { + "", "a", "test", "a longer string with spaces", "unicode: \u00e9\u00e8\u00ea" + }; + + for (String input : inputs) { + String result = ObfuscationUtils.md5Hex(input); + assertEquals("MD5 hash should be 32 characters for input: " + input, 32, result.length()); + } + } +} diff --git a/eppo/src/test/java/cloud/eppo/android/PrecomputedConfigurationResponseTest.java b/eppo/src/test/java/cloud/eppo/android/PrecomputedConfigurationResponseTest.java new file mode 100644 index 00000000..875cf9ed --- /dev/null +++ b/eppo/src/test/java/cloud/eppo/android/PrecomputedConfigurationResponseTest.java @@ -0,0 +1,233 @@ +package cloud.eppo.android; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import cloud.eppo.android.dto.PrecomputedBandit; +import cloud.eppo.android.dto.PrecomputedConfigurationResponse; +import cloud.eppo.android.dto.PrecomputedFlag; +import java.nio.charset.StandardCharsets; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +@RunWith(RobolectricTestRunner.class) +public class PrecomputedConfigurationResponseTest { + + @Test + public void testDeserializeBasicResponse() { + String json = + "{\n" + + " \"format\": \"PRECOMPUTED\",\n" + + " \"obfuscated\": true,\n" + + " \"createdAt\": \"2024-01-20T12:00:00.000Z\",\n" + + " \"environment\": { \"name\": \"Production\" },\n" + + " \"salt\": \"random-salt-value\",\n" + + " \"flags\": {},\n" + + " \"bandits\": {}\n" + + "}"; + + PrecomputedConfigurationResponse response = + PrecomputedConfigurationResponse.fromBytes(json.getBytes(StandardCharsets.UTF_8)); + + assertEquals("PRECOMPUTED", response.getFormat()); + assertTrue(response.isObfuscated()); + assertEquals("2024-01-20T12:00:00.000Z", response.getCreatedAt()); + assertEquals("Production", response.getEnvironmentName()); + assertEquals("random-salt-value", response.getSalt()); + assertTrue(response.getFlags().isEmpty()); + assertTrue(response.getBandits().isEmpty()); + } + + @Test + public void testDeserializeWithFlags() { + String json = + "{\n" + + " \"format\": \"PRECOMPUTED\",\n" + + " \"obfuscated\": true,\n" + + " \"createdAt\": \"2024-01-20T12:00:00.000Z\",\n" + + " \"salt\": \"test-salt\",\n" + + " \"flags\": {\n" + + " \"a1b2c3d4\": {\n" + + " \"allocationKey\": \"YWxsb2NhdGlvbi0x\",\n" + + " \"variationKey\": \"dmFyaWFudC1h\",\n" + + " \"variationType\": \"STRING\",\n" + + " \"variationValue\": \"dGVzdC12YWx1ZQ==\",\n" + + " \"doLog\": true,\n" + + " \"extraLogging\": {\"key\": \"dmFsdWU=\"}\n" + + " }\n" + + " },\n" + + " \"bandits\": {}\n" + + "}"; + + PrecomputedConfigurationResponse response = + PrecomputedConfigurationResponse.fromBytes(json.getBytes(StandardCharsets.UTF_8)); + + assertEquals(1, response.getFlags().size()); + PrecomputedFlag flag = response.getFlags().get("a1b2c3d4"); + assertNotNull(flag); + assertEquals("YWxsb2NhdGlvbi0x", flag.getAllocationKey()); + assertEquals("dmFyaWFudC1h", flag.getVariationKey()); + assertEquals("STRING", flag.getVariationType()); + assertEquals("dGVzdC12YWx1ZQ==", flag.getVariationValue()); + assertTrue(flag.isDoLog()); + assertNotNull(flag.getExtraLogging()); + assertEquals("dmFsdWU=", flag.getExtraLogging().get("key")); + } + + @Test + public void testDeserializeWithBandits() { + String json = + "{\n" + + " \"format\": \"PRECOMPUTED\",\n" + + " \"obfuscated\": true,\n" + + " \"createdAt\": \"2024-01-20T12:00:00.000Z\",\n" + + " \"salt\": \"test-salt\",\n" + + " \"flags\": {},\n" + + " \"bandits\": {\n" + + " \"b1c2d3e4\": {\n" + + " \"banditKey\": \"YmFuZGl0LTE=\",\n" + + " \"action\": \"YWN0aW9uLTE=\",\n" + + " \"modelVersion\": \"djEuMA==\",\n" + + " \"actionNumericAttributes\": {\"score\": \"MC41\"},\n" + + " \"actionCategoricalAttributes\": {\"category\": \"Y2F0LWE=\"},\n" + + " \"actionProbability\": 0.75,\n" + + " \"optimalityGap\": 0.05\n" + + " }\n" + + " }\n" + + "}"; + + PrecomputedConfigurationResponse response = + PrecomputedConfigurationResponse.fromBytes(json.getBytes(StandardCharsets.UTF_8)); + + assertEquals(1, response.getBandits().size()); + PrecomputedBandit bandit = response.getBandits().get("b1c2d3e4"); + assertNotNull(bandit); + assertEquals("YmFuZGl0LTE=", bandit.getBanditKey()); + assertEquals("YWN0aW9uLTE=", bandit.getAction()); + assertEquals("djEuMA==", bandit.getModelVersion()); + assertEquals(0.75, bandit.getActionProbability(), 0.001); + assertEquals(0.05, bandit.getOptimalityGap(), 0.001); + assertEquals("MC41", bandit.getActionNumericAttributes().get("score")); + assertEquals("Y2F0LWE=", bandit.getActionCategoricalAttributes().get("category")); + } + + @Test + public void testDeserializeWithNullEnvironment() { + String json = + "{\n" + + " \"format\": \"PRECOMPUTED\",\n" + + " \"obfuscated\": true,\n" + + " \"createdAt\": \"2024-01-20T12:00:00.000Z\",\n" + + " \"salt\": \"test-salt\",\n" + + " \"flags\": {},\n" + + " \"bandits\": {}\n" + + "}"; + + PrecomputedConfigurationResponse response = + PrecomputedConfigurationResponse.fromBytes(json.getBytes(StandardCharsets.UTF_8)); + + assertNull(response.getEnvironmentName()); + } + + @Test + public void testDeserializeWithStringEnvironment() { + String json = + "{\n" + + " \"format\": \"PRECOMPUTED\",\n" + + " \"obfuscated\": true,\n" + + " \"createdAt\": \"2024-01-20T12:00:00.000Z\",\n" + + " \"environment\": \"Production\",\n" + + " \"salt\": \"test-salt\",\n" + + " \"flags\": {},\n" + + " \"bandits\": {}\n" + + "}"; + + PrecomputedConfigurationResponse response = + PrecomputedConfigurationResponse.fromBytes(json.getBytes(StandardCharsets.UTF_8)); + + assertEquals("Production", response.getEnvironmentName()); + } + + @Test + public void testEmptyConfiguration() { + PrecomputedConfigurationResponse empty = PrecomputedConfigurationResponse.empty(); + + assertEquals("PRECOMPUTED", empty.getFormat()); + assertTrue(empty.isObfuscated()); + assertEquals("", empty.getCreatedAt()); + assertEquals("", empty.getSalt()); + assertTrue(empty.getFlags().isEmpty()); + assertTrue(empty.getBandits().isEmpty()); + } + + @Test + public void testRoundTripSerialization() { + 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 original = + PrecomputedConfigurationResponse.fromBytes(json.getBytes(StandardCharsets.UTF_8)); + + // Serialize and deserialize + byte[] serialized = original.toBytes(); + PrecomputedConfigurationResponse deserialized = + PrecomputedConfigurationResponse.fromBytes(serialized); + + assertEquals(original.getFormat(), deserialized.getFormat()); + assertEquals(original.isObfuscated(), deserialized.isObfuscated()); + assertEquals(original.getSalt(), deserialized.getSalt()); + assertEquals(original.getFlags().size(), deserialized.getFlags().size()); + + PrecomputedFlag originalFlag = original.getFlags().get("flag1"); + PrecomputedFlag deserializedFlag = deserialized.getFlags().get("flag1"); + assertEquals(originalFlag.getVariationType(), deserializedFlag.getVariationType()); + assertEquals(originalFlag.getVariationValue(), deserializedFlag.getVariationValue()); + assertEquals(originalFlag.isDoLog(), deserializedFlag.isDoLog()); + } + + @Test + public void testFlagWithNullOptionalFields() { + 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 response = + PrecomputedConfigurationResponse.fromBytes(json.getBytes(StandardCharsets.UTF_8)); + + PrecomputedFlag flag = response.getFlags().get("flag1"); + assertNotNull(flag); + assertNull(flag.getAllocationKey()); + assertNull(flag.getVariationKey()); + assertNull(flag.getExtraLogging()); + assertFalse(flag.isDoLog()); + } +} From a933cd2042b5065cbab33020105a64c10f2ee148 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Mon, 2 Feb 2026 15:10:25 -0500 Subject: [PATCH 2/7] perf: optimize MD5 hex conversion with lookup table Replace Integer.toHexString() with a pre-computed hex character lookup table for byte-to-hex conversion. This avoids creating intermediate String objects for each byte, reducing allocations. Mirrors optimization from iOS SDK PR #91/#93. --- .../eppo/android/util/ObfuscationUtils.java | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/eppo/src/main/java/cloud/eppo/android/util/ObfuscationUtils.java b/eppo/src/main/java/cloud/eppo/android/util/ObfuscationUtils.java index 9ad0202e..44523cdb 100644 --- a/eppo/src/main/java/cloud/eppo/android/util/ObfuscationUtils.java +++ b/eppo/src/main/java/cloud/eppo/android/util/ObfuscationUtils.java @@ -7,6 +7,9 @@ /** Utility class for obfuscation operations used in precomputed flag lookups. */ public final class ObfuscationUtils { + /** Pre-computed hex character lookup table for efficient byte-to-hex conversion. */ + private static final char[] HEX_DIGITS = "0123456789abcdef".toCharArray(); + private ObfuscationUtils() { // Prevent instantiation } @@ -39,15 +42,17 @@ public static String md5Hex(String input) { return md5Hex(input, null); } + /** + * Converts a byte array to a hexadecimal string using a pre-computed lookup table. This avoids + * creating intermediate String objects for each byte (as Integer.toHexString would). + */ private static String bytesToHex(byte[] bytes) { - StringBuilder hexString = new StringBuilder(32); - for (byte b : bytes) { - String hex = Integer.toHexString(0xff & b); - if (hex.length() == 1) { - hexString.append('0'); - } - hexString.append(hex); + char[] hexChars = new char[bytes.length * 2]; + for (int i = 0; i < bytes.length; i++) { + int v = bytes[i] & 0xFF; + hexChars[i * 2] = HEX_DIGITS[v >>> 4]; + hexChars[i * 2 + 1] = HEX_DIGITS[v & 0x0F]; } - return hexString.toString(); + return new String(hexChars); } } From 5970e9fee2330eab3a37b9bc03aaa053b8a3d420 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Mon, 2 Feb 2026 15:13:17 -0500 Subject: [PATCH 3/7] perf: add md5HexPrefix for efficient partial hash Add md5HexPrefix() method that only converts the bytes needed for a given prefix length, avoiding unnecessary work when only a prefix is required (e.g., cache file naming uses first 8 chars). Includes unrolled loop for the common 4-byte (8 hex char) case to help compiler optimization, following iOS SDK PR #93 approach. --- .../eppo/android/util/ObfuscationUtils.java | 53 ++++++++++++++++- .../eppo/android/ObfuscationUtilsTest.java | 58 +++++++++++++++++++ 2 files changed, 109 insertions(+), 2 deletions(-) diff --git a/eppo/src/main/java/cloud/eppo/android/util/ObfuscationUtils.java b/eppo/src/main/java/cloud/eppo/android/util/ObfuscationUtils.java index 44523cdb..4c4fcdd8 100644 --- a/eppo/src/main/java/cloud/eppo/android/util/ObfuscationUtils.java +++ b/eppo/src/main/java/cloud/eppo/android/util/ObfuscationUtils.java @@ -42,13 +42,62 @@ public static String md5Hex(String input) { return md5Hex(input, null); } + /** + * Generates the first N hex characters of an MD5 hash. More efficient than md5Hex().substring() + * when only a prefix is needed, as it avoids converting unused bytes. + * + * @param input The string to hash + * @param salt Optional salt to prepend to the input (can be null) + * @param hexLength Number of hex characters to return (max 32, must be even) + * @return First hexLength characters of the MD5 hex hash + */ + public static String md5HexPrefix(String input, String salt, int hexLength) { + if (hexLength <= 0 || hexLength > 32) { + throw new IllegalArgumentException("hexLength must be between 1 and 32"); + } + String saltedInput = salt != null ? salt + input : input; + try { + MessageDigest md = MessageDigest.getInstance("MD5"); + byte[] digest = md.digest(saltedInput.getBytes(StandardCharsets.UTF_8)); + // Only convert the bytes we need (2 hex chars per byte) + int bytesNeeded = (hexLength + 1) / 2; + return bytesToHex(digest, bytesNeeded).substring(0, hexLength); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException("MD5 algorithm not available", e); + } + } + /** * Converts a byte array to a hexadecimal string using a pre-computed lookup table. This avoids * creating intermediate String objects for each byte (as Integer.toHexString would). */ private static String bytesToHex(byte[] bytes) { - char[] hexChars = new char[bytes.length * 2]; - for (int i = 0; i < bytes.length; i++) { + return bytesToHex(bytes, bytes.length); + } + + /** + * Converts the first N bytes of an array to a hexadecimal string. Unrolled loop for the common + * case of 4 bytes (8 hex chars) to help the compiler optimize. See iOS SDK PR #93. + */ + private static String bytesToHex(byte[] bytes, int byteCount) { + // Fast path: 4 bytes (8 hex chars) - unrolled for compiler optimization + if (byteCount == 4) { + return new String( + new char[] { + HEX_DIGITS[(bytes[0] & 0xFF) >>> 4], + HEX_DIGITS[bytes[0] & 0x0F], + HEX_DIGITS[(bytes[1] & 0xFF) >>> 4], + HEX_DIGITS[bytes[1] & 0x0F], + HEX_DIGITS[(bytes[2] & 0xFF) >>> 4], + HEX_DIGITS[bytes[2] & 0x0F], + HEX_DIGITS[(bytes[3] & 0xFF) >>> 4], + HEX_DIGITS[bytes[3] & 0x0F] + }); + } + + // General case + char[] hexChars = new char[byteCount * 2]; + for (int i = 0; i < byteCount; i++) { int v = bytes[i] & 0xFF; hexChars[i * 2] = HEX_DIGITS[v >>> 4]; hexChars[i * 2 + 1] = HEX_DIGITS[v & 0x0F]; diff --git a/eppo/src/test/java/cloud/eppo/android/ObfuscationUtilsTest.java b/eppo/src/test/java/cloud/eppo/android/ObfuscationUtilsTest.java index d182de3c..f2239c17 100644 --- a/eppo/src/test/java/cloud/eppo/android/ObfuscationUtilsTest.java +++ b/eppo/src/test/java/cloud/eppo/android/ObfuscationUtilsTest.java @@ -2,6 +2,7 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertThrows; import cloud.eppo.android.util.ObfuscationUtils; import org.junit.Test; @@ -83,4 +84,61 @@ public void testMd5HexLength() { assertEquals("MD5 hash should be 32 characters for input: " + input, 32, result.length()); } } + + // md5HexPrefix tests + + @Test + public void testMd5HexPrefixMatchesFullHash() { + // md5HexPrefix should return the same prefix as md5Hex().substring() + String input = "test-input"; + String salt = "test-salt"; + + String fullHash = ObfuscationUtils.md5Hex(input, salt); + + // Test various prefix lengths + for (int len = 1; len <= 32; len++) { + String prefix = ObfuscationUtils.md5HexPrefix(input, salt, len); + assertEquals(len, prefix.length()); + assertEquals(fullHash.substring(0, len), prefix); + } + } + + @Test + public void testMd5HexPrefix8Chars() { + // Common case: 8 character prefix (used for cache file naming) + String prefix = ObfuscationUtils.md5HexPrefix("subject-key", null, 8); + assertEquals(8, prefix.length()); + + // Should match substring of full hash + String fullHash = ObfuscationUtils.md5Hex("subject-key"); + assertEquals(fullHash.substring(0, 8), prefix); + } + + @Test + public void testMd5HexPrefixWithSalt() { + String prefix = ObfuscationUtils.md5HexPrefix("input", "salt", 8); + String fullWithSalt = ObfuscationUtils.md5Hex("input", "salt"); + assertEquals(fullWithSalt.substring(0, 8), prefix); + } + + @Test + public void testMd5HexPrefixInvalidLength() { + assertThrows( + IllegalArgumentException.class, () -> ObfuscationUtils.md5HexPrefix("test", null, 0)); + + assertThrows( + IllegalArgumentException.class, () -> ObfuscationUtils.md5HexPrefix("test", null, -1)); + + assertThrows( + IllegalArgumentException.class, () -> ObfuscationUtils.md5HexPrefix("test", null, 33)); + } + + @Test + public void testMd5HexPrefixConsistency() { + // Same input should always produce same output + String input = "consistent"; + String prefix1 = ObfuscationUtils.md5HexPrefix(input, null, 8); + String prefix2 = ObfuscationUtils.md5HexPrefix(input, null, 8); + assertEquals(prefix1, prefix2); + } } From 5d2f360dd03419d6c7e24ed454e9c6027dd5e10d Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Wed, 28 Jan 2026 10:01:38 -0500 Subject: [PATCH 4/7] refactor: extract BaseCacheFile for reuse Extract common file caching functionality from ConfigCacheFile into a new BaseCacheFile base class. This enables reuse for the upcoming precomputed configuration cache without code duplication. - Add BaseCacheFile with common read/write/delete operations - Refactor ConfigCacheFile to extend BaseCacheFile - No functional changes to existing behavior --- .../cloud/eppo/android/BaseCacheFile.java | 67 +++++++++++++++++++ .../cloud/eppo/android/ConfigCacheFile.java | 61 +---------------- 2 files changed, 70 insertions(+), 58 deletions(-) create mode 100644 eppo/src/main/java/cloud/eppo/android/BaseCacheFile.java diff --git a/eppo/src/main/java/cloud/eppo/android/BaseCacheFile.java b/eppo/src/main/java/cloud/eppo/android/BaseCacheFile.java new file mode 100644 index 00000000..036d2773 --- /dev/null +++ b/eppo/src/main/java/cloud/eppo/android/BaseCacheFile.java @@ -0,0 +1,67 @@ +package cloud.eppo.android; + +import android.app.Application; +import java.io.BufferedReader; +import java.io.BufferedWriter; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.FileReader; +import java.io.FileWriter; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +/** Base class for disk cache files. */ +public class BaseCacheFile { + private final File cacheFile; + + protected BaseCacheFile(Application application, String fileName) { + File filesDir = application.getFilesDir(); + cacheFile = new File(filesDir, fileName); + } + + public boolean exists() { + return cacheFile.exists(); + } + + /** + * @noinspection ResultOfMethodCallIgnored + */ + public void delete() { + if (cacheFile.exists()) { + cacheFile.delete(); + } + } + + /** Useful for passing in as a writer for JSON serialization. */ + public BufferedWriter getWriter() throws IOException { + return new BufferedWriter(new FileWriter(cacheFile)); + } + + public OutputStream getOutputStream() throws FileNotFoundException { + return new FileOutputStream(cacheFile); + } + + public InputStream getInputStream() throws FileNotFoundException { + return new FileInputStream(cacheFile); + } + + /** Useful for passing in as a reader for JSON deserialization. */ + public BufferedReader getReader() throws IOException { + return new BufferedReader(new FileReader(cacheFile)); + } + + /** Useful for mocking caches in automated tests. */ + public void setContents(String contents) { + delete(); + try { + BufferedWriter writer = getWriter(); + writer.write(contents); + writer.close(); + } catch (IOException ex) { + throw new RuntimeException(ex); + } + } +} diff --git a/eppo/src/main/java/cloud/eppo/android/ConfigCacheFile.java b/eppo/src/main/java/cloud/eppo/android/ConfigCacheFile.java index 59625331..1a18b688 100644 --- a/eppo/src/main/java/cloud/eppo/android/ConfigCacheFile.java +++ b/eppo/src/main/java/cloud/eppo/android/ConfigCacheFile.java @@ -1,70 +1,15 @@ package cloud.eppo.android; import android.app.Application; -import java.io.BufferedReader; -import java.io.BufferedWriter; -import java.io.File; -import java.io.FileInputStream; -import java.io.FileNotFoundException; -import java.io.FileOutputStream; -import java.io.FileReader; -import java.io.FileWriter; -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; -public class ConfigCacheFile { - private final File cacheFile; +/** Disk cache file for standard flag configuration. */ +public class ConfigCacheFile extends BaseCacheFile { public ConfigCacheFile(Application application, String fileNameSuffix) { - File filesDir = application.getFilesDir(); - cacheFile = new File(filesDir, cacheFileName(fileNameSuffix)); + super(application, cacheFileName(fileNameSuffix)); } public static String cacheFileName(String suffix) { return "eppo-sdk-config-v4-flags-" + suffix + ".json"; } - - public boolean exists() { - return cacheFile.exists(); - } - - /** - * @noinspection ResultOfMethodCallIgnored - */ - public void delete() { - if (cacheFile.exists()) { - cacheFile.delete(); - } - } - - /** Useful for passing in as a writer for gson serialization */ - public BufferedWriter getWriter() throws IOException { - return new BufferedWriter(new FileWriter(cacheFile)); - } - - public OutputStream getOutputStream() throws FileNotFoundException { - return new FileOutputStream(cacheFile); - } - - public InputStream getInputStream() throws FileNotFoundException { - return new FileInputStream(cacheFile); - } - - /** Useful for passing in as a reader for gson deserialization */ - public BufferedReader getReader() throws IOException { - return new BufferedReader(new FileReader(cacheFile)); - } - - /** Useful for mocking caches in automated tests */ - public void setContents(String contents) { - delete(); - try { - BufferedWriter writer = getWriter(); - writer.write(contents); - writer.close(); - } catch (IOException ex) { - throw new RuntimeException(ex); - } - } } From 5ff6524546e3820cdbc6c5575ae8848420032baf Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Wed, 28 Jan 2026 10:22:25 -0500 Subject: [PATCH 5/7] feat: add precomputed configuration storage Add the storage layer for precomputed flag configurations: - PrecomputedCacheFile: Disk cache file extending BaseCacheFile - PrecomputedConfigurationStore: In-memory + disk storage with async save/load operations and proper thread synchronization - Updates in-memory config even if disk write fails for resilience Also adds test data files to Makefile for integration testing. Includes unit tests for cache operations and failure scenarios. --- Makefile | 4 +- .../eppo/android/PrecomputedCacheFile.java | 15 ++ .../PrecomputedConfigurationStore.java | 138 ++++++++++++ .../PrecomputedConfigurationStoreTest.java | 211 ++++++++++++++++++ 4 files changed, 367 insertions(+), 1 deletion(-) create mode 100644 eppo/src/main/java/cloud/eppo/android/PrecomputedCacheFile.java create mode 100644 eppo/src/main/java/cloud/eppo/android/PrecomputedConfigurationStore.java create mode 100644 eppo/src/test/java/cloud/eppo/android/PrecomputedConfigurationStoreTest.java diff --git a/Makefile b/Makefile index 3af96870..efd7160e 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 00000000..21a5bb2a --- /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 00000000..0758df9b --- /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. */ + @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 PrecomputedConfigurationResponse.empty(); + } + } + } + + /** 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/test/java/cloud/eppo/android/PrecomputedConfigurationStoreTest.java b/eppo/src/test/java/cloud/eppo/android/PrecomputedConfigurationStoreTest.java new file mode 100644 index 00000000..898cae3d --- /dev/null +++ b/eppo/src/test/java/cloud/eppo/android/PrecomputedConfigurationStoreTest.java @@ -0,0 +1,211 @@ +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")); + } +} From dd5309fede3650733d7846243211c79219f78bd3 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Wed, 28 Jan 2026 11:43:56 -0500 Subject: [PATCH 6/7] style: apply spotless formatting --- .../cloud/eppo/android/PrecomputedConfigurationStoreTest.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/eppo/src/test/java/cloud/eppo/android/PrecomputedConfigurationStoreTest.java b/eppo/src/test/java/cloud/eppo/android/PrecomputedConfigurationStoreTest.java index 898cae3d..896744d8 100644 --- a/eppo/src/test/java/cloud/eppo/android/PrecomputedConfigurationStoreTest.java +++ b/eppo/src/test/java/cloud/eppo/android/PrecomputedConfigurationStoreTest.java @@ -70,7 +70,8 @@ public void testSetConfiguration() { } @Test - public void testSaveConfigurationUpdatesInMemory() throws ExecutionException, InterruptedException { + public void testSaveConfigurationUpdatesInMemory() + throws ExecutionException, InterruptedException { String json = "{\n" + " \"format\": \"PRECOMPUTED\",\n" From 4741881bf216dfd782e57aed9eed3e064f51b43b Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Mon, 2 Feb 2026 21:34:28 -0500 Subject: [PATCH 7/7] fix: address PR #239 feedback - Return null consistently in loadConfigFromCache for both file-not-found and read-error cases - Use static EMPTY singleton in PrecomputedConfigurationResponse.empty() - Use Collections.singletonMap() in getEnvironment() for memory efficiency --- .../android/PrecomputedConfigurationStore.java | 4 ++-- .../dto/PrecomputedConfigurationResponse.java | 14 +++++++------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/eppo/src/main/java/cloud/eppo/android/PrecomputedConfigurationStore.java b/eppo/src/main/java/cloud/eppo/android/PrecomputedConfigurationStore.java index 0758df9b..82327937 100644 --- a/eppo/src/main/java/cloud/eppo/android/PrecomputedConfigurationStore.java +++ b/eppo/src/main/java/cloud/eppo/android/PrecomputedConfigurationStore.java @@ -93,7 +93,7 @@ public CompletableFuture loadConfigFromCache() } } - /** Reads the cache file and returns the configuration. */ + /** Reads the cache file and returns the configuration, or null if reading fails. */ @Nullable protected PrecomputedConfigurationResponse readCacheFile() { synchronized (cacheLock) { try (InputStream inputStream = cacheFile.getInputStream()) { @@ -104,7 +104,7 @@ public CompletableFuture loadConfigFromCache() return config; } catch (IOException e) { Log.e(TAG, "Error loading precomputed config from the cache: " + e.getMessage()); - return PrecomputedConfigurationResponse.empty(); + return null; } } } 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 b5302098..991ac4eb 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; } /**