From 8a454913d23b679a387901def79222bb3b760f85 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Wed, 28 Jan 2026 10:18:51 -0500 Subject: [PATCH 01/13] 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 02/13] 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 03/13] 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 04/13] 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 05/13] 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 06/13] 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 07/13] 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; } /** From c8c0fe8bd816657966bb59eabb06face7de7bb92 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Wed, 28 Jan 2026 10:52:32 -0500 Subject: [PATCH 08/13] feat: add EppoPrecomputedClient Add the main precomputed client implementation with: - Server-side precomputed flag assignments with instant lookups - Support for all flag types: string, boolean, integer, numeric, JSON - Bandit action support with attribute decoding - Builder pattern with extensive configuration options - Offline mode with initial configuration support - Background polling with configurable interval and jitter - Assignment and bandit logging with deduplication caches - Graceful error handling mode The client fetches precomputed assignments from the edge endpoint, eliminating client-side flag evaluation overhead. Includes comprehensive instrumented tests covering: - All flag type assignments - Assignment logging and deduplication - Bandit actions - Offline mode - SDK test data integration --- .../android/EppoPrecomputedClientTest.java | 465 ++++++++ .../eppo/android/EppoPrecomputedClient.java | 1034 +++++++++++++++++ 2 files changed, 1499 insertions(+) create mode 100644 eppo/src/androidTest/java/cloud/eppo/android/EppoPrecomputedClientTest.java create mode 100644 eppo/src/main/java/cloud/eppo/android/EppoPrecomputedClient.java diff --git a/eppo/src/androidTest/java/cloud/eppo/android/EppoPrecomputedClientTest.java b/eppo/src/androidTest/java/cloud/eppo/android/EppoPrecomputedClientTest.java new file mode 100644 index 00000000..4fa3f9a4 --- /dev/null +++ b/eppo/src/androidTest/java/cloud/eppo/android/EppoPrecomputedClientTest.java @@ -0,0 +1,465 @@ +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.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import android.app.Application; +import android.content.Context; +import androidx.test.core.app.ApplicationProvider; +import cloud.eppo.android.cache.LRUAssignmentCache; +import cloud.eppo.android.dto.BanditResult; +import cloud.eppo.android.exceptions.MissingApiKeyException; +import cloud.eppo.android.exceptions.MissingSubjectKeyException; +import cloud.eppo.android.util.ObfuscationUtils; +import cloud.eppo.api.Attributes; +import cloud.eppo.api.EppoValue; +import cloud.eppo.api.IAssignmentCache; +import cloud.eppo.logging.Assignment; +import cloud.eppo.logging.AssignmentLogger; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import org.junit.Before; +import org.junit.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +public class EppoPrecomputedClientTest { + + private static final String TEST_API_KEY = "test-api-key"; + private static final String TEST_SUBJECT_KEY = "test-subject-123"; + private static final String TEST_SALT = "test-salt"; + private static final ObjectMapper objectMapper = new ObjectMapper(); + + private Application application; + + @Mock AssignmentLogger mockAssignmentLogger; + + @Before + public void setUp() { + MockitoAnnotations.openMocks(this); + application = ApplicationProvider.getApplicationContext(); + } + + /** + * Creates a mock precomputed configuration response. The MD5 hashes are computed as: MD5(salt + + * flagKey) + */ + private String getMockPrecomputedResponse() { + // Compute MD5 hashes for the flag keys with the salt + String stringFlagHash = ObfuscationUtils.md5Hex("string_flag", TEST_SALT); + String boolFlagHash = ObfuscationUtils.md5Hex("bool_flag", TEST_SALT); + String intFlagHash = ObfuscationUtils.md5Hex("int_flag", TEST_SALT); + String numericFlagHash = ObfuscationUtils.md5Hex("numeric_flag", TEST_SALT); + String jsonFlagHash = ObfuscationUtils.md5Hex("json_flag", TEST_SALT); + + // Base64 encoded values: + // "test-string" = dGVzdC1zdHJpbmc= + // "true" = dHJ1ZQ== + // "42" = NDI= + // "3.14159" = My4xNDE1OQ== + // {"key":"value"} = eyJrZXkiOiJ2YWx1ZSJ9 + // "allocation-1" = YWxsb2NhdGlvbi0x + // "variant-a" = dmFyaWFudC1h + + return "{\n" + + " \"format\": \"PRECOMPUTED\",\n" + + " \"obfuscated\": true,\n" + + " \"createdAt\": \"2024-01-20T12:00:00.000Z\",\n" + + " \"environment\": { \"name\": \"Test\" },\n" + + " \"salt\": \"" + + TEST_SALT + + "\",\n" + + " \"flags\": {\n" + + " \"" + + stringFlagHash + + "\": {\n" + + " \"allocationKey\": \"YWxsb2NhdGlvbi0x\",\n" + + " \"variationKey\": \"dmFyaWFudC1h\",\n" + + " \"variationType\": \"STRING\",\n" + + " \"variationValue\": \"dGVzdC1zdHJpbmc=\",\n" + + " \"doLog\": true,\n" + + " \"extraLogging\": {}\n" + + " },\n" + + " \"" + + boolFlagHash + + "\": {\n" + + " \"allocationKey\": \"YWxsb2NhdGlvbi0x\",\n" + + " \"variationKey\": \"dmFyaWFudC1h\",\n" + + " \"variationType\": \"BOOLEAN\",\n" + + " \"variationValue\": \"dHJ1ZQ==\",\n" + + " \"doLog\": true,\n" + + " \"extraLogging\": {}\n" + + " },\n" + + " \"" + + intFlagHash + + "\": {\n" + + " \"allocationKey\": \"YWxsb2NhdGlvbi0x\",\n" + + " \"variationKey\": \"dmFyaWFudC1h\",\n" + + " \"variationType\": \"INTEGER\",\n" + + " \"variationValue\": \"NDI=\",\n" + + " \"doLog\": true,\n" + + " \"extraLogging\": {}\n" + + " },\n" + + " \"" + + numericFlagHash + + "\": {\n" + + " \"allocationKey\": \"YWxsb2NhdGlvbi0x\",\n" + + " \"variationKey\": \"dmFyaWFudC1h\",\n" + + " \"variationType\": \"NUMERIC\",\n" + + " \"variationValue\": \"My4xNDE1OQ==\",\n" + + " \"doLog\": true,\n" + + " \"extraLogging\": {}\n" + + " },\n" + + " \"" + + jsonFlagHash + + "\": {\n" + + " \"allocationKey\": \"YWxsb2NhdGlvbi0x\",\n" + + " \"variationKey\": \"dmFyaWFudC1h\",\n" + + " \"variationType\": \"JSON\",\n" + + " \"variationValue\": \"eyJrZXkiOiJ2YWx1ZSJ9\",\n" + + " \"doLog\": true,\n" + + " \"extraLogging\": {}\n" + + " }\n" + + " },\n" + + " \"bandits\": {}\n" + + "}"; + } + + private EppoPrecomputedClient initializeClientOffline( + AssignmentLogger assignmentLogger, IAssignmentCache cache) { + byte[] configBytes = getMockPrecomputedResponse().getBytes(StandardCharsets.UTF_8); + + return new EppoPrecomputedClient.Builder(TEST_API_KEY, application) + .subjectKey(TEST_SUBJECT_KEY) + .offlineMode(true) + .initialConfiguration(configBytes) + .assignmentLogger(assignmentLogger) + .assignmentCache(cache) + .forceReinitialize(true) + .buildAndInit(); + } + + @Test + public void testBuilderRequiresApiKey() { + assertThrows( + MissingApiKeyException.class, + () -> + new EppoPrecomputedClient.Builder("", application) + .subjectKey(TEST_SUBJECT_KEY) + .buildAndInit()); + } + + @Test + public void testBuilderRequiresSubjectKey() { + assertThrows( + MissingSubjectKeyException.class, + () -> + new EppoPrecomputedClient.Builder(TEST_API_KEY, application) + .offlineMode(true) + .buildAndInit()); + } + + @Test + public void testOfflineModeWithInitialConfiguration() { + EppoPrecomputedClient client = initializeClientOffline(null, null); + assertNotNull(client); + } + + @Test + public void testEmptyFlagKeyReturnsDefault() { + EppoPrecomputedClient client = initializeClientOffline(null, null); + + assertEquals("default", client.getStringAssignment("", "default")); + assertEquals("default", client.getStringAssignment(null, "default")); + } + + @Test + public void testTypeMismatchReturnsDefault() { + EppoPrecomputedClient client = initializeClientOffline(null, null); + + // string_flag is STRING type, requesting boolean should return default + assertFalse(client.getBooleanAssignment("string_flag", false)); + + // bool_flag is BOOLEAN type, requesting string should return default + assertEquals("default", client.getStringAssignment("bool_flag", "default")); + } + + @Test + public void testAssignmentLogging() { + AssignmentLogger mockLogger = mock(AssignmentLogger.class); + EppoPrecomputedClient client = initializeClientOffline(mockLogger, null); + + // Make an assignment request + client.getStringAssignment("string_flag", "default"); + + // Verify logger was called + ArgumentCaptor captor = ArgumentCaptor.forClass(Assignment.class); + verify(mockLogger, times(1)).logAssignment(captor.capture()); + + Assignment logged = captor.getValue(); + assertEquals(TEST_SUBJECT_KEY, logged.getSubject()); + assertEquals("string_flag", logged.getFeatureFlag()); + assertEquals("allocation-1", logged.getAllocation()); + assertEquals("variant-a", logged.getVariation()); + assertNotNull(logged.getMetaData()); + assertEquals("true", logged.getMetaData().get("obfuscated")); + assertEquals("android", logged.getMetaData().get("sdkLanguage")); + } + + @Test + public void testAssignmentDeduplicationWithCache() { + AssignmentLogger mockLogger = mock(AssignmentLogger.class); + IAssignmentCache cache = new LRUAssignmentCache(100); + + EppoPrecomputedClient client = initializeClientOffline(mockLogger, cache); + + // Make the same assignment request twice + client.getStringAssignment("string_flag", "default"); + client.getStringAssignment("string_flag", "default"); + + // Logger should only be called once due to cache deduplication + verify(mockLogger, times(1)).logAssignment(org.mockito.ArgumentMatchers.any()); + } + + @Test + public void testBanditResultDefaultValue() { + EppoPrecomputedClient client = initializeClientOffline(null, null); + + BanditResult result = client.getBanditAction("unknown_bandit", "default_variation"); + assertEquals("default_variation", result.getVariation()); + assertNull(result.getAction()); + } + + @Test + public void testSubjectAttributes() { + Attributes attributes = new Attributes(); + attributes.put("age", EppoValue.valueOf(25)); + attributes.put("country", EppoValue.valueOf("US")); + + byte[] configBytes = getMockPrecomputedResponse().getBytes(StandardCharsets.UTF_8); + + EppoPrecomputedClient client = + new EppoPrecomputedClient.Builder(TEST_API_KEY, application) + .subjectKey(TEST_SUBJECT_KEY) + .subjectAttributes(attributes) + .offlineMode(true) + .initialConfiguration(configBytes) + .forceReinitialize(true) + .buildAndInit(); + + assertNotNull(client); + // Assignments should still work + assertEquals("test-string", client.getStringAssignment("string_flag", "default")); + } + + @Test + public void testPollingCanBePausedAndResumed() { + EppoPrecomputedClient client = initializeClientOffline(null, null); + + // Start polling manually + client.startPolling(60000, 6000); + + // Pause, resume, and stop should not throw + client.pausePolling(); + client.resumePolling(); + client.stopPolling(); + } + + @Test + public void testGracefulModeReturnsDefaultsWithNoConfig() { + // Initialize without any configuration + EppoPrecomputedClient client = + new EppoPrecomputedClient.Builder(TEST_API_KEY, application) + .subjectKey(TEST_SUBJECT_KEY) + .offlineMode(true) + .isGracefulMode(true) + .forceReinitialize(true) + .buildAndInit(); + + // Should return defaults when no configuration is available + assertEquals("default", client.getStringAssignment("any_flag", "default")); + assertFalse(client.getBooleanAssignment("any_flag", false)); + assertEquals(0, client.getIntegerAssignment("any_flag", 0)); + assertEquals(0.0, client.getNumericAssignment("any_flag", 0.0), 0.001); + } + + // ============================================= + // Tests using sdk-test-data precomputed files + // ============================================= + + /** + * Loads the precomputed test data from sdk-test-data and extracts the response JSON. The file + * format is: { "version": 1, "precomputed": { "response": "escaped-json-string", ... } } + */ + private byte[] loadPrecomputedTestData() throws Exception { + Context context = ApplicationProvider.getApplicationContext(); + InputStream inputStream = context.getAssets().open("precomputed-v1.json"); + byte[] bytes = new byte[inputStream.available()]; + inputStream.read(bytes); + inputStream.close(); + + // Parse the wrapper and extract the response string + JsonNode wrapper = objectMapper.readTree(bytes); + String responseJson = wrapper.get("precomputed").get("response").asText(); + return responseJson.getBytes(StandardCharsets.UTF_8); + } + + private EppoPrecomputedClient initializeClientWithTestData(AssignmentLogger logger) + throws Exception { + byte[] configBytes = loadPrecomputedTestData(); + + return new EppoPrecomputedClient.Builder(TEST_API_KEY, application) + .subjectKey("test-subject-key") + .offlineMode(true) + .initialConfiguration(configBytes) + .assignmentLogger(logger) + .forceReinitialize(true) + .buildAndInit(); + } + + @Test + public void testSdkTestData_StringFlag() throws Exception { + EppoPrecomputedClient client = initializeClientWithTestData(null); + + String result = client.getStringAssignment("string-flag", "default"); + assertEquals("red", result); + } + + @Test + public void testSdkTestData_BooleanFlag() throws Exception { + EppoPrecomputedClient client = initializeClientWithTestData(null); + + boolean result = client.getBooleanAssignment("boolean-flag", false); + assertTrue(result); + } + + @Test + public void testSdkTestData_IntegerFlag() throws Exception { + EppoPrecomputedClient client = initializeClientWithTestData(null); + + int result = client.getIntegerAssignment("integer-flag", 0); + assertEquals(42, result); + } + + @Test + public void testSdkTestData_NumericFlag() throws Exception { + EppoPrecomputedClient client = initializeClientWithTestData(null); + + double result = client.getNumericAssignment("numeric-flag", 0.0); + assertEquals(3.14, result, 0.001); + } + + @Test + public void testSdkTestData_JsonFlag() throws Exception { + EppoPrecomputedClient client = initializeClientWithTestData(null); + + JsonNode defaultValue = objectMapper.readTree("{}"); + JsonNode result = client.getJSONAssignment("json-flag", defaultValue); + + assertNotNull(result); + assertTrue(result.has("key")); + assertEquals("value", result.get("key").asText()); + assertTrue(result.has("number")); + assertEquals(123, result.get("number").asInt()); + } + + @Test + public void testSdkTestData_StringFlagWithExtraLogging() throws Exception { + AssignmentLogger mockLogger = mock(AssignmentLogger.class); + EppoPrecomputedClient client = initializeClientWithTestData(mockLogger); + + String result = client.getStringAssignment("string-flag-with-extra-logging", "default"); + assertEquals("red", result); + + // Verify the assignment was logged with extra logging info + ArgumentCaptor captor = ArgumentCaptor.forClass(Assignment.class); + verify(mockLogger, times(1)).logAssignment(captor.capture()); + + Assignment logged = captor.getValue(); + assertEquals("string-flag-with-extra-logging", logged.getFeatureFlag()); + // Extra logging should include holdout information + assertNotNull(logged.getMetaData()); + } + + @Test + public void testSdkTestData_NotABanditFlag() throws Exception { + EppoPrecomputedClient client = initializeClientWithTestData(null); + + String result = client.getStringAssignment("not-a-bandit-flag", "default"); + assertEquals("control", result); + } + + @Test + public void testSdkTestData_BanditAction() throws Exception { + EppoPrecomputedClient client = initializeClientWithTestData(null); + + // string-flag has a bandit associated with it + BanditResult result = client.getBanditAction("string-flag", "default"); + + assertNotNull(result); + assertEquals("red", result.getVariation()); + assertEquals("show_red_button", result.getAction()); + } + + @Test + public void testSdkTestData_BanditActionWithExtraLogging() throws Exception { + EppoPrecomputedClient client = initializeClientWithTestData(null); + + // string-flag-with-extra-logging has a bandit + BanditResult result = client.getBanditAction("string-flag-with-extra-logging", "default"); + + assertNotNull(result); + assertEquals("red", result.getVariation()); + assertEquals("featured_content", result.getAction()); + } + + @Test + public void testSdkTestData_UnknownFlagReturnsDefault() throws Exception { + EppoPrecomputedClient client = initializeClientWithTestData(null); + + assertEquals("default", client.getStringAssignment("non-existent-flag", "default")); + assertFalse(client.getBooleanAssignment("non-existent-flag", false)); + assertEquals(99, client.getIntegerAssignment("non-existent-flag", 99)); + assertEquals(1.5, client.getNumericAssignment("non-existent-flag", 1.5), 0.001); + } + + @Test + public void testResumePollingAfterStop() { + EppoPrecomputedClient client = initializeClientOffline(null, null); + + // Start polling, then stop (which shuts down the executor) + client.startPolling(60000, 6000); + client.stopPolling(); + + // Resume should recreate the executor and not throw + client.resumePolling(); + client.stopPolling(); + } + + @Test + public void testNonGracefulModeThrowsOnMissingConfig() { + // Initialize without configuration and with graceful mode disabled + EppoPrecomputedClient client = + new EppoPrecomputedClient.Builder(TEST_API_KEY, application) + .subjectKey(TEST_SUBJECT_KEY) + .offlineMode(true) + .isGracefulMode(false) + .forceReinitialize(true) + .buildAndInit(); + + // In non-graceful mode, accessing a flag with no config should throw + // Note: Currently returns default because salt is null, which is handled gracefully + // This test verifies the client was created with non-graceful mode + assertNotNull(client); + } +} diff --git a/eppo/src/main/java/cloud/eppo/android/EppoPrecomputedClient.java b/eppo/src/main/java/cloud/eppo/android/EppoPrecomputedClient.java new file mode 100644 index 00000000..5d270f8c --- /dev/null +++ b/eppo/src/main/java/cloud/eppo/android/EppoPrecomputedClient.java @@ -0,0 +1,1034 @@ +package cloud.eppo.android; + +import static cloud.eppo.android.util.Utils.logTag; +import static cloud.eppo.android.util.Utils.safeCacheKey; + +import android.app.Application; +import android.util.Log; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import cloud.eppo.android.cache.LRUAssignmentCache; +import cloud.eppo.android.dto.BanditResult; +import cloud.eppo.android.dto.PrecomputedBandit; +import cloud.eppo.android.dto.PrecomputedConfigurationResponse; +import cloud.eppo.android.dto.PrecomputedFlag; +import cloud.eppo.android.exceptions.MissingApiKeyException; +import cloud.eppo.android.exceptions.MissingApplicationException; +import cloud.eppo.android.exceptions.MissingSubjectKeyException; +import cloud.eppo.android.exceptions.NotInitializedException; +import cloud.eppo.android.util.ObfuscationUtils; +import cloud.eppo.android.util.Utils; +import cloud.eppo.api.Attributes; +import cloud.eppo.api.EppoValue; +import cloud.eppo.api.IAssignmentCache; +import cloud.eppo.cache.AssignmentCacheEntry; +import cloud.eppo.cache.AssignmentCacheKey; +import cloud.eppo.cache.BanditCacheValue; +import cloud.eppo.cache.VariationCacheValue; +import cloud.eppo.logging.Assignment; +import cloud.eppo.logging.AssignmentLogger; +import cloud.eppo.logging.BanditAssignment; +import cloud.eppo.logging.BanditLogger; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import okhttp3.Call; +import okhttp3.Callback; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; +import okhttp3.ResponseBody; + +/** + * Precomputed client for Eppo feature flags. All flag assignments are computed server-side and + * delivered as a batch, providing instant lookups with zero client-side evaluation time. + */ +public class EppoPrecomputedClient { + private static final String TAG = logTag(EppoPrecomputedClient.class); + private static final boolean DEFAULT_IS_GRACEFUL_MODE = true; + private static final long DEFAULT_POLLING_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes + private static final long DEFAULT_JITTER_INTERVAL_RATIO = 10; + private static final String DEFAULT_BASE_URL = "https://fs-edge-assignment.eppo.cloud"; + private static final String ASSIGNMENTS_ENDPOINT = "/assignments"; + private static final int SUBJECT_KEY_HASH_LENGTH = 8; + private static final MediaType JSON_MEDIA_TYPE = MediaType.get("application/json; charset=utf-8"); + private static final ObjectMapper objectMapper = new ObjectMapper(); + + @Nullable private static EppoPrecomputedClient instance; + + private final String apiKey; + private final String subjectKey; + @Nullable private final Attributes subjectAttributes; + @Nullable private final Map> banditActions; + @Nullable private final AssignmentLogger assignmentLogger; + @Nullable private final BanditLogger banditLogger; + @Nullable private final IAssignmentCache assignmentCache; + @Nullable private final IAssignmentCache banditCache; + private final PrecomputedConfigurationStore configurationStore; + private final boolean isGracefulMode; + private final String baseUrl; + private final OkHttpClient httpClient; + + private long pollingIntervalMs; + private long pollingJitterMs; + @Nullable private ScheduledExecutorService poller; + @Nullable private ScheduledFuture pollFuture; + private final AtomicBoolean isPolling = new AtomicBoolean(false); + + private EppoPrecomputedClient( + String apiKey, + String subjectKey, + @Nullable Attributes subjectAttributes, + @Nullable Map> banditActions, + @Nullable AssignmentLogger assignmentLogger, + @Nullable BanditLogger banditLogger, + @Nullable IAssignmentCache assignmentCache, + @Nullable IAssignmentCache banditCache, + PrecomputedConfigurationStore configurationStore, + boolean isGracefulMode, + String baseUrl, + OkHttpClient httpClient) { + this.apiKey = apiKey; + this.subjectKey = subjectKey; + this.subjectAttributes = subjectAttributes; + this.banditActions = banditActions; + this.assignmentLogger = assignmentLogger; + this.banditLogger = banditLogger; + this.assignmentCache = assignmentCache; + this.banditCache = banditCache; + this.configurationStore = configurationStore; + this.isGracefulMode = isGracefulMode; + this.baseUrl = baseUrl; + this.httpClient = httpClient; + } + + /** + * Returns the singleton instance of the client. + * + * @throws NotInitializedException if the client has not been initialized + */ + public static EppoPrecomputedClient getInstance() throws NotInitializedException { + if (EppoPrecomputedClient.instance == null) { + throw new NotInitializedException(); + } + return EppoPrecomputedClient.instance; + } + + // Assignment methods + + /** + * Gets a string assignment for a flag. + * + * @param flagKey The flag key + * @param defaultValue The default value if not found + * @return The assigned string value or default + */ + public String getStringAssignment(String flagKey, String defaultValue) { + try { + Object result = getPrecomputedAssignment(flagKey, defaultValue, "STRING"); + return result != null ? result.toString() : defaultValue; + } catch (Exception e) { + return handleException(e, defaultValue); + } + } + + /** + * Gets a boolean assignment for a flag. + * + * @param flagKey The flag key + * @param defaultValue The default value if not found + * @return The assigned boolean value or default + */ + public boolean getBooleanAssignment(String flagKey, boolean defaultValue) { + try { + Object result = getPrecomputedAssignment(flagKey, defaultValue, "BOOLEAN"); + if (result instanceof Boolean) { + return (Boolean) result; + } + return defaultValue; + } catch (Exception e) { + return handleException(e, defaultValue); + } + } + + /** + * Gets an integer assignment for a flag. + * + * @param flagKey The flag key + * @param defaultValue The default value if not found + * @return The assigned integer value or default + */ + public int getIntegerAssignment(String flagKey, int defaultValue) { + try { + Object result = getPrecomputedAssignment(flagKey, defaultValue, "INTEGER"); + if (result instanceof Number) { + return ((Number) result).intValue(); + } + return defaultValue; + } catch (Exception e) { + return handleException(e, defaultValue); + } + } + + /** + * Gets a numeric (double) assignment for a flag. + * + * @param flagKey The flag key + * @param defaultValue The default value if not found + * @return The assigned numeric value or default + */ + public double getNumericAssignment(String flagKey, double defaultValue) { + try { + Object result = getPrecomputedAssignment(flagKey, defaultValue, "NUMERIC"); + if (result instanceof Number) { + return ((Number) result).doubleValue(); + } + return defaultValue; + } catch (Exception e) { + return handleException(e, defaultValue); + } + } + + /** + * Gets a JSON assignment for a flag. + * + * @param flagKey The flag key + * @param defaultValue The default value if not found + * @return The assigned JSON value or default + */ + public JsonNode getJSONAssignment(String flagKey, JsonNode defaultValue) { + try { + Object result = getPrecomputedAssignment(flagKey, defaultValue, "JSON"); + if (result instanceof JsonNode) { + return (JsonNode) result; + } + return defaultValue; + } catch (Exception e) { + return handleException(e, defaultValue); + } + } + + /** + * Gets a bandit action for a flag. + * + * @param flagKey The flag key + * @param defaultValue The default variation value if not found + * @return The bandit result containing variation and action + */ + public BanditResult getBanditAction(String flagKey, String defaultValue) { + try { + return getPrecomputedBanditAction(flagKey, defaultValue); + } catch (Exception e) { + return handleException(e, new BanditResult(defaultValue, null)); + } + } + + // Internal assignment logic + + private Object getPrecomputedAssignment( + String flagKey, Object defaultValue, String expectedType) { + if (flagKey == null || flagKey.isEmpty()) { + Log.w(TAG, "Invalid argument: flagKey cannot be blank"); + return defaultValue; + } + + String salt = configurationStore.getSalt(); + if (salt == null) { + Log.w(TAG, "Missing salt for flag store"); + return defaultValue; + } + + String hashedKey = ObfuscationUtils.md5Hex(flagKey, salt); + PrecomputedFlag flag = configurationStore.getFlag(hashedKey); + + if (flag == null) { + Log.d(TAG, "No assigned variation because flag not found: " + flagKey); + return defaultValue; + } + + // Check type match + if (!checkTypeMatch(expectedType, flag.getVariationType())) { + Log.w( + TAG, + "Type mismatch for flag " + + flagKey + + ": expected " + + expectedType + + ", got " + + flag.getVariationType()); + return defaultValue; + } + + // Decode the value + Object decodedValue = decodeValue(flag.getVariationValue(), flag.getVariationType()); + + // Log assignment if needed + if (flag.isDoLog() && assignmentLogger != null) { + String decodedAllocationKey = + flag.getAllocationKey() != null ? Utils.base64Decode(flag.getAllocationKey()) : null; + String decodedVariationKey = + flag.getVariationKey() != null ? Utils.base64Decode(flag.getVariationKey()) : null; + + // Check assignment cache for deduplication + boolean shouldLog = true; + if (assignmentCache != null && decodedAllocationKey != null && decodedVariationKey != null) { + AssignmentCacheEntry cacheEntry = + new AssignmentCacheEntry( + new AssignmentCacheKey(subjectKey, flagKey), + new VariationCacheValue(decodedAllocationKey, decodedVariationKey)); + shouldLog = assignmentCache.putIfAbsent(cacheEntry); + } + + if (shouldLog) { + logAssignment(flagKey, decodedAllocationKey, decodedVariationKey, flag.getExtraLogging()); + } + } + + return decodedValue; + } + + private BanditResult getPrecomputedBanditAction(String flagKey, String defaultValue) { + if (flagKey == null || flagKey.isEmpty()) { + Log.w(TAG, "Invalid argument: flagKey cannot be blank"); + return new BanditResult(defaultValue, null); + } + + String salt = configurationStore.getSalt(); + if (salt == null) { + Log.w(TAG, "Missing salt for bandit store"); + return new BanditResult(defaultValue, null); + } + + String hashedKey = ObfuscationUtils.md5Hex(flagKey, salt); + PrecomputedBandit bandit = configurationStore.getBandit(hashedKey); + + if (bandit == null) { + Log.d(TAG, "No assigned bandit action because bandit not found: " + flagKey); + return new BanditResult(defaultValue, null); + } + + // Decode bandit values (with null safety) + String decodedBanditKey = + bandit.getBanditKey() != null ? Utils.base64Decode(bandit.getBanditKey()) : null; + String decodedAction = + bandit.getAction() != null ? Utils.base64Decode(bandit.getAction()) : null; + String decodedModelVersion = + bandit.getModelVersion() != null ? Utils.base64Decode(bandit.getModelVersion()) : null; + + // Get the variation from the flag assignment + String assignedVariation = getStringAssignment(flagKey, defaultValue); + + // Decode action attributes (both keys and values are Base64 encoded) + Attributes decodedNumericAttrs = new Attributes(); + if (bandit.getActionNumericAttributes() != null) { + for (Map.Entry entry : bandit.getActionNumericAttributes().entrySet()) { + try { + String decodedKey = Utils.base64Decode(entry.getKey()); + String decodedValue = Utils.base64Decode(entry.getValue()); + decodedNumericAttrs.put(decodedKey, EppoValue.valueOf(Double.parseDouble(decodedValue))); + } catch (NumberFormatException e) { + Log.w(TAG, "Failed to parse numeric attribute: " + entry.getKey()); + } + } + } + + Attributes decodedCategoricalAttrs = new Attributes(); + if (bandit.getActionCategoricalAttributes() != null) { + for (Map.Entry entry : bandit.getActionCategoricalAttributes().entrySet()) { + String decodedKey = Utils.base64Decode(entry.getKey()); + String decodedValue = Utils.base64Decode(entry.getValue()); + decodedCategoricalAttrs.put(decodedKey, EppoValue.valueOf(decodedValue)); + } + } + + // Log bandit event if needed + if (banditLogger != null) { + // Check bandit cache for deduplication + boolean shouldLog = true; + if (banditCache != null) { + String actionKey = decodedAction != null ? decodedAction : "__eppo_no_action"; + AssignmentCacheEntry cacheEntry = + new AssignmentCacheEntry( + new AssignmentCacheKey(subjectKey, flagKey), + new BanditCacheValue(decodedBanditKey, actionKey)); + shouldLog = banditCache.putIfAbsent(cacheEntry); + } + + if (shouldLog) { + logBanditAction( + flagKey, + decodedBanditKey, + decodedAction, + bandit.getActionProbability(), + bandit.getOptimalityGap(), + decodedModelVersion, + decodedNumericAttrs, + decodedCategoricalAttrs); + } + } + + return new BanditResult(assignedVariation, decodedAction); + } + + private boolean checkTypeMatch(String expected, String actual) { + if (expected.equalsIgnoreCase(actual)) { + return true; + } + // Integer is compatible with numeric + if ("NUMERIC".equalsIgnoreCase(expected) && "INTEGER".equalsIgnoreCase(actual)) { + return true; + } + return false; + } + + private Object decodeValue(String encodedValue, String variationType) { + String decoded = Utils.base64Decode(encodedValue); + + switch (variationType.toUpperCase()) { + case "STRING": + return decoded; + case "BOOLEAN": + return "true".equalsIgnoreCase(decoded); + case "INTEGER": + try { + return Integer.parseInt(decoded); + } catch (NumberFormatException e) { + Log.w(TAG, "Failed to parse integer value: " + decoded); + return 0; + } + case "NUMERIC": + try { + return Double.parseDouble(decoded); + } catch (NumberFormatException e) { + Log.w(TAG, "Failed to parse numeric value: " + decoded); + return 0.0; + } + case "JSON": + try { + return objectMapper.readTree(decoded); + } catch (Exception e) { + Log.w(TAG, "Failed to parse JSON value: " + decoded); + return objectMapper.createObjectNode(); + } + default: + return decoded; + } + } + + // Logging methods + + private void logAssignment( + String flagKey, + @Nullable String allocationKey, + @Nullable String variationKey, + @Nullable Map extraLogging) { + if (assignmentLogger == null) { + return; + } + + String experiment = allocationKey != null ? flagKey + "-" + allocationKey : null; + + Map decodedExtraLogging = new HashMap<>(); + if (extraLogging != null) { + for (Map.Entry entry : extraLogging.entrySet()) { + decodedExtraLogging.put( + Utils.base64Decode(entry.getKey()), Utils.base64Decode(entry.getValue())); + } + } + + Map metaData = buildMetaData(); + + Assignment assignment = + new Assignment( + experiment, + flagKey, + allocationKey, + variationKey, + subjectKey, + subjectAttributes != null ? subjectAttributes : new Attributes(), + decodedExtraLogging, + metaData); + + try { + assignmentLogger.logAssignment(assignment); + } catch (Exception e) { + Log.e(TAG, "Failed to log assignment", e); + } + } + + private void logBanditAction( + String flagKey, + String banditKey, + String action, + double actionProbability, + double optimalityGap, + String modelVersion, + Attributes actionNumericAttrs, + Attributes actionCategoricalAttrs) { + if (banditLogger == null) { + return; + } + + Attributes subjectNumericAttrs = new Attributes(); + Attributes subjectCategoricalAttrs = new Attributes(); + + if (subjectAttributes != null) { + for (String key : subjectAttributes.keySet()) { + EppoValue value = subjectAttributes.get(key); + if (value != null) { + if (value.isNumeric()) { + subjectNumericAttrs.put(key, value); + } else { + subjectCategoricalAttrs.put(key, value); + } + } + } + } + + Map metaData = buildMetaData(); + + BanditAssignment banditAssignment = + new BanditAssignment( + flagKey, + banditKey, + subjectKey, + action, + actionProbability, + optimalityGap, + modelVersion, + subjectNumericAttrs, + subjectCategoricalAttrs, + actionNumericAttrs, + actionCategoricalAttrs, + metaData); + + try { + banditLogger.logBanditAssignment(banditAssignment); + } catch (Exception e) { + Log.e(TAG, "Failed to log bandit assignment", e); + } + } + + private Map buildMetaData() { + Map metaData = new HashMap<>(); + metaData.put("obfuscated", "true"); + metaData.put("sdkLanguage", "android"); + metaData.put("sdkLibVersion", BuildConfig.EPPO_VERSION); + return metaData; + } + + // Error handling + + private T handleException(Exception e, T defaultValue) { + Log.e(TAG, "Error getting assignment: " + e.getMessage(), e); + if (!isGracefulMode) { + throw new RuntimeException(e); + } + return defaultValue; + } + + // HTTP methods + + /** Fetches precomputed flags from the server. */ + public void fetchPrecomputedFlags() { + try { + fetchPrecomputedFlagsAsync().get(); + } catch (InterruptedException | ExecutionException e) { + Log.e(TAG, "Error fetching precomputed flags", e); + if (!isGracefulMode) { + throw new RuntimeException(e); + } + } + } + + /** Fetches precomputed flags from the server asynchronously. */ + public CompletableFuture fetchPrecomputedFlagsAsync() { + CompletableFuture future = new CompletableFuture<>(); + + try { + String url = buildRequestUrl(); + String requestBody = buildRequestBody(); + + Log.d(TAG, "Fetching precomputed flags from: " + baseUrl + ASSIGNMENTS_ENDPOINT); + + Request request = + new Request.Builder() + .url(url) + .post(RequestBody.create(requestBody, JSON_MEDIA_TYPE)) + .build(); + + httpClient + .newCall(request) + .enqueue( + new Callback() { + @Override + public void onFailure(@NonNull Call call, @NonNull IOException e) { + Log.e(TAG, "Failed to fetch precomputed flags", e); + future.completeExceptionally(e); + } + + @Override + public void onResponse(@NonNull Call call, @NonNull Response response) { + try (ResponseBody body = response.body()) { + if (!response.isSuccessful()) { + String responseText = body != null ? body.string() : "(no body)"; + String errorMsg = "HTTP error: " + response.code() + " - " + responseText; + Log.e(TAG, errorMsg); + future.completeExceptionally(new IOException(errorMsg)); + return; + } + + if (body == null) { + future.completeExceptionally(new IOException("Empty response body")); + return; + } + + byte[] bytes = body.bytes(); + PrecomputedConfigurationResponse config = + PrecomputedConfigurationResponse.fromBytes(bytes); + + configurationStore + .saveConfiguration(config) + .thenRun( + () -> { + Log.d( + TAG, + "Successfully fetched precomputed flags: " + + config.getFlags().size() + + " flags, " + + config.getBandits().size() + + " bandits"); + future.complete(null); + }) + .exceptionally( + ex -> { + future.completeExceptionally(ex); + return null; + }); + } catch (Exception e) { + Log.e(TAG, "Error processing response", e); + future.completeExceptionally(e); + } + } + }); + } catch (Exception e) { + future.completeExceptionally(e); + } + + return future; + } + + private String buildRequestUrl() { + return baseUrl + + ASSIGNMENTS_ENDPOINT + + "?apiKey=" + + apiKey + + "&sdkVersion=" + + BuildConfig.EPPO_VERSION + + "&sdkName=android"; + } + + private String buildRequestBody() throws Exception { + Map body = new HashMap<>(); + body.put("subject_key", subjectKey); + + Map subjectAttrsMap = new HashMap<>(); + Map numericAttrs = new HashMap<>(); + Map categoricalAttrs = new HashMap<>(); + + if (subjectAttributes != null) { + for (String key : subjectAttributes.keySet()) { + EppoValue value = subjectAttributes.get(key); + if (value != null) { + if (value.isNumeric()) { + numericAttrs.put(key, value.doubleValue()); + } else { + categoricalAttrs.put(key, value.stringValue()); + } + } + } + } + + subjectAttrsMap.put("numericAttributes", numericAttrs); + subjectAttrsMap.put("categoricalAttributes", categoricalAttrs); + body.put("subject_attributes", subjectAttrsMap); + + if (banditActions != null && !banditActions.isEmpty()) { + body.put("bandit_actions", banditActions); + } + + return objectMapper.writeValueAsString(body); + } + + // Polling methods + + /** Starts polling for configuration updates. */ + public void startPolling(long intervalMs, long jitterMs) { + if (isPolling.getAndSet(true)) { + Log.w(TAG, "Polling is already running"); + return; + } + + this.pollingIntervalMs = intervalMs; + this.pollingJitterMs = jitterMs; + + poller = Executors.newSingleThreadScheduledExecutor(); + scheduleNextPoll(); + Log.d(TAG, "Started polling with interval: " + intervalMs + "ms, jitter: " + jitterMs + "ms"); + } + + private void scheduleNextPoll() { + if (poller == null || poller.isShutdown()) { + return; + } + + long jitter = (long) (Math.random() * pollingJitterMs); + long delay = pollingIntervalMs + jitter; + + pollFuture = + poller.schedule( + () -> { + try { + fetchPrecomputedFlags(); + } catch (Exception e) { + Log.e(TAG, "Error during polling fetch", e); + } + if (isPolling.get()) { + scheduleNextPoll(); + } + }, + delay, + TimeUnit.MILLISECONDS); + } + + /** Pauses polling for configuration updates. */ + public void pausePolling() { + isPolling.set(false); + if (pollFuture != null) { + pollFuture.cancel(false); + pollFuture = null; + } + Log.d(TAG, "Paused polling"); + } + + /** Resumes polling for configuration updates. */ + public void resumePolling() { + if (pollingIntervalMs <= 0) { + Log.w(TAG, "Cannot resume polling - no polling interval configured"); + return; + } + + if (isPolling.getAndSet(true)) { + Log.w(TAG, "Polling is already running"); + return; + } + + if (poller == null || poller.isShutdown()) { + poller = Executors.newSingleThreadScheduledExecutor(); + } + + scheduleNextPoll(); + Log.d(TAG, "Resumed polling"); + } + + /** Stops polling for configuration updates and releases resources. */ + public void stopPolling() { + isPolling.set(false); + if (pollFuture != null) { + pollFuture.cancel(false); + pollFuture = null; + } + if (poller != null) { + poller.shutdown(); + poller = null; + } + Log.d(TAG, "Stopped polling"); + } + + // Builder class + + public static class Builder { + private final String apiKey; + private final Application application; + @Nullable private String subjectKey; + @Nullable private Attributes subjectAttributes; + @Nullable private Map> banditActions; + @Nullable private AssignmentLogger assignmentLogger; + @Nullable private BanditLogger banditLogger; + private IAssignmentCache assignmentCache = new LRUAssignmentCache(100); + @Nullable private IAssignmentCache banditCache; + @Nullable private PrecomputedConfigurationStore configStore; + private boolean isGracefulMode = DEFAULT_IS_GRACEFUL_MODE; + private boolean forceReinitialize = false; + private boolean offlineMode = false; + private boolean pollingEnabled = false; + private long pollingIntervalMs = DEFAULT_POLLING_INTERVAL_MS; + private long pollingJitterMs = -1; + private String baseUrl = DEFAULT_BASE_URL; + @Nullable private byte[] initialConfiguration; + private boolean ignoreCachedConfiguration = false; + @Nullable private OkHttpClient httpClient; + + public Builder(@NonNull String apiKey, @NonNull Application application) { + this.apiKey = apiKey; + this.application = application; + } + + /** Sets the subject key (required). */ + public Builder subjectKey(@NonNull String subjectKey) { + this.subjectKey = subjectKey; + return this; + } + + /** Sets the subject attributes (optional). */ + public Builder subjectAttributes(@Nullable Attributes subjectAttributes) { + this.subjectAttributes = subjectAttributes; + return this; + } + + /** Sets the bandit actions (optional). */ + public Builder banditActions(@Nullable Map> banditActions) { + this.banditActions = banditActions; + return this; + } + + /** Sets the assignment logger (optional). */ + public Builder assignmentLogger(@Nullable AssignmentLogger assignmentLogger) { + this.assignmentLogger = assignmentLogger; + return this; + } + + /** Sets the bandit logger (optional). */ + public Builder banditLogger(@Nullable BanditLogger banditLogger) { + this.banditLogger = banditLogger; + return this; + } + + /** Sets the assignment cache (optional). Default is LRUAssignmentCache(100). */ + public Builder assignmentCache(@Nullable IAssignmentCache assignmentCache) { + this.assignmentCache = assignmentCache; + return this; + } + + /** Sets the bandit cache (optional). */ + public Builder banditCache(@Nullable IAssignmentCache banditCache) { + this.banditCache = banditCache; + return this; + } + + /** Sets the configuration store (optional). */ + public Builder configStore(@Nullable PrecomputedConfigurationStore configStore) { + this.configStore = configStore; + return this; + } + + /** Sets graceful mode (optional). Default is true. */ + public Builder isGracefulMode(boolean isGracefulMode) { + this.isGracefulMode = isGracefulMode; + return this; + } + + /** Forces reinitialization even if an instance already exists. */ + public Builder forceReinitialize(boolean forceReinitialize) { + this.forceReinitialize = forceReinitialize; + return this; + } + + /** Sets offline mode (optional). Default is false. */ + public Builder offlineMode(boolean offlineMode) { + this.offlineMode = offlineMode; + return this; + } + + /** Enables polling for configuration updates. */ + public Builder pollingEnabled(boolean pollingEnabled) { + this.pollingEnabled = pollingEnabled; + return this; + } + + /** Sets the polling interval in milliseconds. Default is 5 minutes. */ + public Builder pollingIntervalMs(long pollingIntervalMs) { + this.pollingIntervalMs = pollingIntervalMs; + return this; + } + + /** Sets the polling jitter in milliseconds. Default is 10% of polling interval. */ + public Builder pollingJitterMs(long pollingJitterMs) { + this.pollingJitterMs = pollingJitterMs; + return this; + } + + /** Sets the base URL for the API. Default is the edge endpoint. */ + public Builder baseUrl(@NonNull String baseUrl) { + this.baseUrl = baseUrl; + return this; + } + + /** Sets the initial configuration for offline mode. */ + public Builder initialConfiguration(@Nullable byte[] initialConfiguration) { + this.initialConfiguration = initialConfiguration; + return this; + } + + /** Ignores cached configuration and always fetches fresh. */ + public Builder ignoreCachedConfiguration(boolean ignoreCachedConfiguration) { + this.ignoreCachedConfiguration = ignoreCachedConfiguration; + return this; + } + + /** Sets a custom HTTP client (optional, for testing). */ + public Builder httpClient(@Nullable OkHttpClient httpClient) { + this.httpClient = httpClient; + return this; + } + + /** Builds and initializes the client asynchronously. */ + public CompletableFuture buildAndInitAsync() { + if (application == null) { + throw new MissingApplicationException(); + } + if (apiKey == null || apiKey.isEmpty()) { + throw new MissingApiKeyException(); + } + if (subjectKey == null || subjectKey.isEmpty()) { + throw new MissingSubjectKeyException(); + } + + if (instance != null && !forceReinitialize) { + Log.w(TAG, "EppoPrecomputedClient instance already initialized"); + return CompletableFuture.completedFuture(instance); + } else if (instance != null) { + instance.stopPolling(); + Log.d(TAG, "`forceReinitialize` triggered reinitializing EppoPrecomputedClient"); + } + + // Create configuration store + if (configStore == null) { + // Use MD5 hash of subject key to ensure consistent length and privacy + String subjectKeyHash = + ObfuscationUtils.md5Hex(subjectKey, null).substring(0, SUBJECT_KEY_HASH_LENGTH); + String cacheFileNameSuffix = safeCacheKey(apiKey) + "-" + subjectKeyHash; + configStore = new PrecomputedConfigurationStore(application, cacheFileNameSuffix); + } + + // Create HTTP client + OkHttpClient client = httpClient != null ? httpClient : new OkHttpClient(); + + instance = + new EppoPrecomputedClient( + apiKey, + subjectKey, + subjectAttributes, + banditActions, + assignmentLogger, + banditLogger, + assignmentCache, + banditCache, + configStore, + isGracefulMode, + baseUrl, + client); + + CompletableFuture result = new CompletableFuture<>(); + + // Load initial configuration + if (initialConfiguration != null) { + // Use provided initial configuration + try { + PrecomputedConfigurationResponse config = + PrecomputedConfigurationResponse.fromBytes(initialConfiguration); + configStore.setConfiguration(config); + Log.d(TAG, "Loaded initial configuration with " + config.getFlags().size() + " flags"); + } catch (Exception e) { + Log.e(TAG, "Failed to parse initial configuration", e); + } + } else if (!ignoreCachedConfiguration) { + // Try to load from cache (runs concurrently with network fetch) + configStore + .loadConfigFromCache() + .thenAccept( + config -> { + if (config != null && !config.getFlags().isEmpty()) { + configStore.setConfiguration(config); + Log.d( + TAG, + "Loaded cached configuration with " + config.getFlags().size() + " flags"); + } + }); + } + + // Capture final values for lambda + final long finalPollingIntervalMs = pollingIntervalMs; + final long finalPollingJitterMs = + pollingJitterMs < 0 ? pollingIntervalMs / DEFAULT_JITTER_INTERVAL_RATIO : pollingJitterMs; + final boolean shouldStartPolling = pollingEnabled && pollingIntervalMs > 0; + + if (!offlineMode) { + // Fetch configuration from server + instance + .fetchPrecomputedFlagsAsync() + .thenRun( + () -> { + // Start polling after initial fetch completes + if (shouldStartPolling) { + instance.startPolling(finalPollingIntervalMs, finalPollingJitterMs); + } + result.complete(instance); + }) + .exceptionally( + ex -> { + Log.e(TAG, "Failed to fetch precomputed flags", ex); + if (isGracefulMode) { + // Still complete successfully in graceful mode + // Start polling even on failure so we can retry + if (shouldStartPolling) { + instance.startPolling(finalPollingIntervalMs, finalPollingJitterMs); + } + result.complete(instance); + } else { + result.completeExceptionally( + new EppoInitializationException( + "Unable to initialize client; Configuration could not be loaded", ex)); + } + return null; + }); + } else { + // In offline mode, complete immediately (no polling in offline mode) + result.complete(instance); + } + + return result.exceptionally( + e -> { + Log.e(TAG, "Exception caught during initialization: " + e.getMessage(), e); + if (!isGracefulMode) { + throw new RuntimeException(e); + } + return instance; + }); + } + + /** Builds and initializes the client synchronously. */ + public EppoPrecomputedClient buildAndInit() { + try { + return buildAndInitAsync().get(); + } catch (ExecutionException | InterruptedException e) { + Log.e(TAG, "Exception caught during initialization: " + e.getMessage(), e); + if (!isGracefulMode) { + throw new RuntimeException(e); + } + return instance; + } + } + } +} From 590804ea95798757edf166ada298f599f93826aa Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Mon, 2 Feb 2026 15:14:42 -0500 Subject: [PATCH 09/13] perf: use md5HexPrefix for cache file naming --- .../main/java/cloud/eppo/android/EppoPrecomputedClient.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/eppo/src/main/java/cloud/eppo/android/EppoPrecomputedClient.java b/eppo/src/main/java/cloud/eppo/android/EppoPrecomputedClient.java index 5d270f8c..63e3a393 100644 --- a/eppo/src/main/java/cloud/eppo/android/EppoPrecomputedClient.java +++ b/eppo/src/main/java/cloud/eppo/android/EppoPrecomputedClient.java @@ -915,9 +915,9 @@ public CompletableFuture buildAndInitAsync() { // Create configuration store if (configStore == null) { - // Use MD5 hash of subject key to ensure consistent length and privacy + // Use MD5 hash prefix of subject key to ensure consistent length and privacy String subjectKeyHash = - ObfuscationUtils.md5Hex(subjectKey, null).substring(0, SUBJECT_KEY_HASH_LENGTH); + ObfuscationUtils.md5HexPrefix(subjectKey, null, SUBJECT_KEY_HASH_LENGTH); String cacheFileNameSuffix = safeCacheKey(apiKey) + "-" + subjectKeyHash; configStore = new PrecomputedConfigurationStore(application, cacheFileNameSuffix); } From 78c90acd068b9d63d55b8cad30323c0445635ae2 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Mon, 2 Feb 2026 21:58:10 -0500 Subject: [PATCH 10/13] fix: address PR review feedback for EppoPrecomputedClient - Make polling fields volatile for thread safety - Return user's default value on parse failure instead of hardcoded 0 - Extract magic string to NO_ACTION_CACHE_KEY constant - Fix banditActions serialization to match JS SDK wire format - Add comments for hash length and jitter calculation - Rename misleading test to testNonGracefulModeCanBeConfigured --- .../android/EppoPrecomputedClientTest.java | 8 +-- .../eppo/android/EppoPrecomputedClient.java | 52 +++++++++++++++---- 2 files changed, 47 insertions(+), 13 deletions(-) diff --git a/eppo/src/androidTest/java/cloud/eppo/android/EppoPrecomputedClientTest.java b/eppo/src/androidTest/java/cloud/eppo/android/EppoPrecomputedClientTest.java index 4fa3f9a4..ff63efe8 100644 --- a/eppo/src/androidTest/java/cloud/eppo/android/EppoPrecomputedClientTest.java +++ b/eppo/src/androidTest/java/cloud/eppo/android/EppoPrecomputedClientTest.java @@ -447,7 +447,7 @@ public void testResumePollingAfterStop() { } @Test - public void testNonGracefulModeThrowsOnMissingConfig() { + public void testNonGracefulModeCanBeConfigured() { // Initialize without configuration and with graceful mode disabled EppoPrecomputedClient client = new EppoPrecomputedClient.Builder(TEST_API_KEY, application) @@ -457,9 +457,9 @@ public void testNonGracefulModeThrowsOnMissingConfig() { .forceReinitialize(true) .buildAndInit(); - // In non-graceful mode, accessing a flag with no config should throw - // Note: Currently returns default because salt is null, which is handled gracefully - // This test verifies the client was created with non-graceful mode + // Client should be created successfully even in non-graceful mode + // When no config is loaded, assignments return defaults (salt is null) assertNotNull(client); + assertEquals("default", client.getStringAssignment("any_flag", "default")); } } diff --git a/eppo/src/main/java/cloud/eppo/android/EppoPrecomputedClient.java b/eppo/src/main/java/cloud/eppo/android/EppoPrecomputedClient.java index 63e3a393..4e4ab9bf 100644 --- a/eppo/src/main/java/cloud/eppo/android/EppoPrecomputedClient.java +++ b/eppo/src/main/java/cloud/eppo/android/EppoPrecomputedClient.java @@ -61,9 +61,11 @@ public class EppoPrecomputedClient { private static final long DEFAULT_JITTER_INTERVAL_RATIO = 10; private static final String DEFAULT_BASE_URL = "https://fs-edge-assignment.eppo.cloud"; private static final String ASSIGNMENTS_ENDPOINT = "/assignments"; + // Hash prefix length for cache file naming; 8 hex chars = 32 bits of entropy private static final int SUBJECT_KEY_HASH_LENGTH = 8; private static final MediaType JSON_MEDIA_TYPE = MediaType.get("application/json; charset=utf-8"); private static final ObjectMapper objectMapper = new ObjectMapper(); + private static final String NO_ACTION_CACHE_KEY = "__eppo_no_action"; @Nullable private static EppoPrecomputedClient instance; @@ -80,8 +82,8 @@ public class EppoPrecomputedClient { private final String baseUrl; private final OkHttpClient httpClient; - private long pollingIntervalMs; - private long pollingJitterMs; + private volatile long pollingIntervalMs; + private volatile long pollingJitterMs; @Nullable private ScheduledExecutorService poller; @Nullable private ScheduledFuture pollFuture; private final AtomicBoolean isPolling = new AtomicBoolean(false); @@ -271,7 +273,8 @@ private Object getPrecomputedAssignment( } // Decode the value - Object decodedValue = decodeValue(flag.getVariationValue(), flag.getVariationType()); + Object decodedValue = + decodeValue(flag.getVariationValue(), flag.getVariationType(), defaultValue); // Log assignment if needed if (flag.isDoLog() && assignmentLogger != null) { @@ -357,7 +360,7 @@ private BanditResult getPrecomputedBanditAction(String flagKey, String defaultVa // Check bandit cache for deduplication boolean shouldLog = true; if (banditCache != null) { - String actionKey = decodedAction != null ? decodedAction : "__eppo_no_action"; + String actionKey = decodedAction != null ? decodedAction : NO_ACTION_CACHE_KEY; AssignmentCacheEntry cacheEntry = new AssignmentCacheEntry( new AssignmentCacheKey(subjectKey, flagKey), @@ -392,7 +395,7 @@ private boolean checkTypeMatch(String expected, String actual) { return false; } - private Object decodeValue(String encodedValue, String variationType) { + private Object decodeValue(String encodedValue, String variationType, Object defaultValue) { String decoded = Utils.base64Decode(encodedValue); switch (variationType.toUpperCase()) { @@ -405,21 +408,21 @@ private Object decodeValue(String encodedValue, String variationType) { return Integer.parseInt(decoded); } catch (NumberFormatException e) { Log.w(TAG, "Failed to parse integer value: " + decoded); - return 0; + return defaultValue; } case "NUMERIC": try { return Double.parseDouble(decoded); } catch (NumberFormatException e) { Log.w(TAG, "Failed to parse numeric value: " + decoded); - return 0.0; + return defaultValue; } case "JSON": try { return objectMapper.readTree(decoded); } catch (Exception e) { Log.w(TAG, "Failed to parse JSON value: " + decoded); - return objectMapper.createObjectNode(); + return defaultValue; } default: return decoded; @@ -665,7 +668,37 @@ private String buildRequestBody() throws Exception { body.put("subject_attributes", subjectAttrsMap); if (banditActions != null && !banditActions.isEmpty()) { - body.put("bandit_actions", banditActions); + // Transform banditActions to match the expected wire format with numericAttributes and + // categoricalAttributes (same structure as subject_attributes) + Map>> serializedBanditActions = new HashMap<>(); + for (Map.Entry> flagEntry : banditActions.entrySet()) { + Map> actionsForFlag = new HashMap<>(); + for (Map.Entry actionEntry : flagEntry.getValue().entrySet()) { + Map actionAttrsMap = new HashMap<>(); + Map actionNumericAttrs = new HashMap<>(); + Map actionCategoricalAttrs = new HashMap<>(); + + Attributes attrs = actionEntry.getValue(); + if (attrs != null) { + for (String key : attrs.keySet()) { + EppoValue value = attrs.get(key); + if (value != null) { + if (value.isNumeric()) { + actionNumericAttrs.put(key, value.doubleValue()); + } else { + actionCategoricalAttrs.put(key, value.stringValue()); + } + } + } + } + + actionAttrsMap.put("numericAttributes", actionNumericAttrs); + actionAttrsMap.put("categoricalAttributes", actionCategoricalAttrs); + actionsForFlag.put(actionEntry.getKey(), actionAttrsMap); + } + serializedBanditActions.put(flagEntry.getKey(), actionsForFlag); + } + body.put("bandit_actions", serializedBanditActions); } return objectMapper.writeValueAsString(body); @@ -970,6 +1003,7 @@ public CompletableFuture buildAndInitAsync() { // Capture final values for lambda final long finalPollingIntervalMs = pollingIntervalMs; + // Default jitter to 10% of polling interval if not explicitly set final long finalPollingJitterMs = pollingJitterMs < 0 ? pollingIntervalMs / DEFAULT_JITTER_INTERVAL_RATIO : pollingJitterMs; final boolean shouldStartPolling = pollingEnabled && pollingIntervalMs > 0; From 4239fa046c134d1114a71b76a2207a46ab8480f4 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Tue, 3 Feb 2026 17:05:45 -0500 Subject: [PATCH 11/13] feat: derive precomputed client base URL from SDK key Extract environment prefix from SDK key to automatically construct the correct edge endpoint URL (e.g., https://5qhpgd.fs-edge-assignment.eppo.cloud). This removes the need for users to manually configure the base URL. --- .../eppo/android/EppoPrecomputedClient.java | 17 ++++++-- .../java/cloud/eppo/android/util/Utils.java | 41 +++++++++++++++++++ .../java/cloud/eppo/android/UtilsTest.java | 39 ++++++++++++++++++ 3 files changed, 94 insertions(+), 3 deletions(-) diff --git a/eppo/src/main/java/cloud/eppo/android/EppoPrecomputedClient.java b/eppo/src/main/java/cloud/eppo/android/EppoPrecomputedClient.java index 4e4ab9bf..83ea0b49 100644 --- a/eppo/src/main/java/cloud/eppo/android/EppoPrecomputedClient.java +++ b/eppo/src/main/java/cloud/eppo/android/EppoPrecomputedClient.java @@ -59,7 +59,7 @@ public class EppoPrecomputedClient { private static final boolean DEFAULT_IS_GRACEFUL_MODE = true; private static final long DEFAULT_POLLING_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes private static final long DEFAULT_JITTER_INTERVAL_RATIO = 10; - private static final String DEFAULT_BASE_URL = "https://fs-edge-assignment.eppo.cloud"; + private static final String DEFAULT_EDGE_HOST = "fs-edge-assignment.eppo.cloud"; private static final String ASSIGNMENTS_ENDPOINT = "/assignments"; // Hash prefix length for cache file naming; 8 hex chars = 32 bits of entropy private static final int SUBJECT_KEY_HASH_LENGTH = 8; @@ -808,7 +808,7 @@ public static class Builder { private boolean pollingEnabled = false; private long pollingIntervalMs = DEFAULT_POLLING_INTERVAL_MS; private long pollingJitterMs = -1; - private String baseUrl = DEFAULT_BASE_URL; + @Nullable private String baseUrl; @Nullable private byte[] initialConfiguration; private boolean ignoreCachedConfiguration = false; @Nullable private OkHttpClient httpClient; @@ -958,6 +958,17 @@ public CompletableFuture buildAndInitAsync() { // Create HTTP client OkHttpClient client = httpClient != null ? httpClient : new OkHttpClient(); + // Derive base URL from API key if not explicitly set + String effectiveBaseUrl = baseUrl; + if (effectiveBaseUrl == null) { + String envPrefix = Utils.getEnvironmentFromSdkKey(apiKey); + if (envPrefix != null) { + effectiveBaseUrl = "https://" + envPrefix + "." + DEFAULT_EDGE_HOST; + } else { + effectiveBaseUrl = "https://" + DEFAULT_EDGE_HOST; + } + } + instance = new EppoPrecomputedClient( apiKey, @@ -970,7 +981,7 @@ public CompletableFuture buildAndInitAsync() { banditCache, configStore, isGracefulMode, - baseUrl, + effectiveBaseUrl, client); CompletableFuture result = new CompletableFuture<>(); diff --git a/eppo/src/main/java/cloud/eppo/android/util/Utils.java b/eppo/src/main/java/cloud/eppo/android/util/Utils.java index 8ba6a528..7fe8207f 100644 --- a/eppo/src/main/java/cloud/eppo/android/util/Utils.java +++ b/eppo/src/main/java/cloud/eppo/android/util/Utils.java @@ -53,6 +53,47 @@ public static String safeCacheKey(String key) { return key.substring(0, 8).replaceAll("\\W", ""); } + /** + * Extracts the environment prefix from an SDK key. The SDK key format is: + * {random_key}.{base64_encoded_params} where params decode to "eh={prefix}.e.eppo.cloud&cs=..." + * + * @param sdkKey The SDK key + * @return The environment prefix (e.g., "5qhpgd"), or null if not found + */ + public static String getEnvironmentFromSdkKey(String sdkKey) { + if (sdkKey == null || !sdkKey.contains(".")) { + return null; + } + + try { + String[] parts = sdkKey.split("\\.", 2); + if (parts.length < 2) { + return null; + } + + String decoded = base64Decode(parts[1]); + if (decoded == null) { + return null; + } + + // Parse query string format: "eh=5qhpgd.e.eppo.cloud&cs=5qhpgd" + for (String param : decoded.split("&")) { + if (param.startsWith("eh=")) { + String envHost = param.substring(3); // Remove "eh=" + // Extract subdomain (everything before first ".") + int dotIndex = envHost.indexOf('.'); + if (dotIndex > 0) { + return envHost.substring(0, dotIndex); + } + } + } + } catch (Exception e) { + // Return null if parsing fails + } + + return null; + } + public static String logTag(Class loggingClass) { // Common prefix can make filtering logs easier String logTag = ("EppoSDK:" + loggingClass.getSimpleName()); diff --git a/eppo/src/test/java/cloud/eppo/android/UtilsTest.java b/eppo/src/test/java/cloud/eppo/android/UtilsTest.java index 413afe23..e909a9d1 100644 --- a/eppo/src/test/java/cloud/eppo/android/UtilsTest.java +++ b/eppo/src/test/java/cloud/eppo/android/UtilsTest.java @@ -4,6 +4,7 @@ import static cloud.eppo.android.util.Utils.base64Encode; 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 static org.junit.Assert.fail; @@ -77,4 +78,42 @@ public void testBase64EncodeAndDecode() { String decodedOutput = base64Decode(encodedInput); assertEquals("a", decodedOutput); } + + @Test + public void testGetEnvironmentFromSdkKey() { + // SDK key with encoded params: eh=5qhpgd.e.eppo.cloud&cs=5qhpgd + String sdkKey = "nx3VNR-S6H2RQexXkQffFKaxwd9SAr4u.ZWg9NXFocGdkLmUuZXBwby5jbG91ZCZjcz01cWhwZ2Q"; + String env = Utils.getEnvironmentFromSdkKey(sdkKey); + assertEquals("5qhpgd", env); + } + + @Test + public void testGetEnvironmentFromSdkKeyWithDifferentFormat() { + // Test another valid key format + String sdkKey = "someRandomKey.ZWg9YWJjMTIzLmUuZXBwby5jbG91ZCZjcz1hYmMxMjM="; + String env = Utils.getEnvironmentFromSdkKey(sdkKey); + assertEquals("abc123", env); + } + + @Test + public void testGetEnvironmentFromSdkKeyReturnsNullForInvalidKey() { + // No dot separator + assertNull(Utils.getEnvironmentFromSdkKey("invalidKeyWithoutDot")); + + // Null key + assertNull(Utils.getEnvironmentFromSdkKey(null)); + + // Empty string + assertNull(Utils.getEnvironmentFromSdkKey("")); + + // Invalid base64 + assertNull(Utils.getEnvironmentFromSdkKey("key.!!!invalidbase64!!!")); + } + + @Test + public void testGetEnvironmentFromSdkKeyWithMissingEhParam() { + // Encoded params without "eh" parameter: cs=something + String sdkKey = "key.Y3M9c29tZXRoaW5n"; + assertNull(Utils.getEnvironmentFromSdkKey(sdkKey)); + } } From dd5c86d8afead1d50d5146cc9dc2274b2ddbade2 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Wed, 28 Jan 2026 11:29:36 -0500 Subject: [PATCH 12/13] feat(example): add precomputed client demo Add demonstration of the precomputed client in the example app: - Rename MainActivity -> HomeActivity, SecondActivity -> StandardClientActivity - Add PrecomputedActivity with: - Subject ID input with server/disk initialization options - Dynamic subject attributes table (add/remove key-value pairs) - Flag key input with type selection (string, bool, int, numeric, JSON) - Assignment log display - Proper lifecycle handling for polling (pause/resume/stop) - Add back navigation to all activities - Update layouts and strings This provides a complete interactive demo of the precomputed client for testing and development purposes. --- example/src/main/AndroidManifest.xml | 9 +- .../{MainActivity.java => HomeActivity.java} | 33 ++- .../androidexample/PrecomputedActivity.java | 277 ++++++++++++++++++ ...ivity.java => StandardClientActivity.java} | 20 +- .../{activity_main.xml => activity_home.xml} | 9 +- .../main/res/layout/activity_precomputed.xml | 186 ++++++++++++ example/src/main/res/values/strings.xml | 1 + 7 files changed, 525 insertions(+), 10 deletions(-) rename example/src/main/java/cloud/eppo/androidexample/{MainActivity.java => HomeActivity.java} (60%) create mode 100644 example/src/main/java/cloud/eppo/androidexample/PrecomputedActivity.java rename example/src/main/java/cloud/eppo/androidexample/{SecondActivity.java => StandardClientActivity.java} (85%) rename example/src/main/res/layout/{activity_main.xml => activity_home.xml} (82%) create mode 100644 example/src/main/res/layout/activity_precomputed.xml diff --git a/example/src/main/AndroidManifest.xml b/example/src/main/AndroidManifest.xml index 290d5b93..c22fafcb 100644 --- a/example/src/main/AndroidManifest.xml +++ b/example/src/main/AndroidManifest.xml @@ -14,7 +14,7 @@ android:theme="@style/Theme.EppoExample" tools:targetApi="30"> @@ -24,7 +24,12 @@ + android:name="cloud.eppo.androidexample.StandardClientActivity" + android:parentActivityName="cloud.eppo.androidexample.HomeActivity" /> + + \ No newline at end of file diff --git a/example/src/main/java/cloud/eppo/androidexample/MainActivity.java b/example/src/main/java/cloud/eppo/androidexample/HomeActivity.java similarity index 60% rename from example/src/main/java/cloud/eppo/androidexample/MainActivity.java rename to example/src/main/java/cloud/eppo/androidexample/HomeActivity.java index c64f62d4..92c50bc1 100644 --- a/example/src/main/java/cloud/eppo/androidexample/MainActivity.java +++ b/example/src/main/java/cloud/eppo/androidexample/HomeActivity.java @@ -14,16 +14,17 @@ import cloud.eppo.android.EppoClient; import com.geteppo.androidexample.BuildConfig; import com.geteppo.androidexample.R; +import java.io.File; -public class MainActivity extends AppCompatActivity { +public class HomeActivity extends AppCompatActivity { private static final String API_KEY = BuildConfig.API_KEY; // Set in root-level local.properties @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); - setContentView(R.layout.activity_main); + setContentView(R.layout.activity_home); Button button = findViewById(R.id.button_start_assigner); - Intent launchAssigner = new Intent(MainActivity.this, SecondActivity.class); + Intent launchAssigner = new Intent(HomeActivity.this, StandardClientActivity.class); button.setOnClickListener(view -> startActivity(launchAssigner)); @@ -32,15 +33,39 @@ protected void onCreate(Bundle savedInstanceState) { view -> startActivity(launchAssigner.putExtra(this.getPackageName() + ".offlineMode", false))); + Button precomputedButton = findViewById(R.id.button_start_precomputed); + Intent launchPrecomputed = new Intent(HomeActivity.this, PrecomputedActivity.class); + precomputedButton.setOnClickListener(view -> startActivity(launchPrecomputed)); + Button clearCacheButton = findViewById(R.id.button_clear_cache); clearCacheButton.setOnClickListener(view -> clearCacheFile()); } private void clearCacheFile() { + // Clear standard client cache String cacheFileNameSuffix = safeCacheKey(API_KEY); ConfigCacheFile cacheFile = new ConfigCacheFile(getApplication(), cacheFileNameSuffix); cacheFile.delete(); - Toast.makeText(this, "Cache Cleared", Toast.LENGTH_SHORT).show(); + + // Clear all precomputed client caches (they include subject key hash in filename) + File filesDir = getApplication().getFilesDir(); + File[] precomputedCaches = + filesDir.listFiles( + (dir, name) -> name.startsWith("eppo-sdk-precomputed-") && name.endsWith(".json")); + int precomputedCount = 0; + if (precomputedCaches != null) { + for (File file : precomputedCaches) { + if (file.delete()) { + precomputedCount++; + } + } + } + + String message = + precomputedCount > 0 + ? "Cache Cleared (" + precomputedCount + " precomputed)" + : "Cache Cleared"; + Toast.makeText(this, message, Toast.LENGTH_SHORT).show(); } @Override diff --git a/example/src/main/java/cloud/eppo/androidexample/PrecomputedActivity.java b/example/src/main/java/cloud/eppo/androidexample/PrecomputedActivity.java new file mode 100644 index 00000000..14089de5 --- /dev/null +++ b/example/src/main/java/cloud/eppo/androidexample/PrecomputedActivity.java @@ -0,0 +1,277 @@ +package cloud.eppo.androidexample; + +import android.os.Bundle; +import android.text.TextUtils; +import android.util.Log; +import android.view.MenuItem; +import android.view.View; +import android.widget.Button; +import android.widget.EditText; +import android.widget.LinearLayout; +import android.widget.RadioGroup; +import android.widget.ScrollView; +import android.widget.TextView; +import androidx.appcompat.app.AppCompatActivity; +import cloud.eppo.android.EppoPrecomputedClient; +import cloud.eppo.api.Attributes; +import cloud.eppo.api.EppoValue; +import com.geteppo.androidexample.BuildConfig; +import com.geteppo.androidexample.R; +import java.util.ArrayList; +import java.util.List; + +/** + * Example activity demonstrating the EppoPrecomputedClient. The precomputed client computes all + * flag assignments server-side for a specific subject, providing instant lookups. + */ +public class PrecomputedActivity extends AppCompatActivity { + private static final String TAG = PrecomputedActivity.class.getSimpleName(); + private static final String API_KEY = BuildConfig.API_KEY; + + private EditText subjectInput; + private EditText flagKeyInput; + private RadioGroup flagTypeGroup; + private TextView assignmentLog; + private ScrollView assignmentLogScrollView; + private TextView statusText; + private LinearLayout attributesContainer; + private Button getAssignmentButton; + private List attributeRows = new ArrayList<>(); + + private EppoPrecomputedClient precomputedClient; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_precomputed); + + // Enable the action bar back button + if (getSupportActionBar() != null) { + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + getSupportActionBar().setTitle("Precomputed Client"); + } + + subjectInput = findViewById(R.id.precomputed_subject); + flagKeyInput = findViewById(R.id.precomputed_flag_key); + flagTypeGroup = findViewById(R.id.flag_type_group); + assignmentLog = findViewById(R.id.precomputed_assignment_log); + assignmentLogScrollView = findViewById(R.id.precomputed_assignment_log_scrollview); + statusText = findViewById(R.id.precomputed_status); + attributesContainer = findViewById(R.id.attributes_container); + + findViewById(R.id.btn_init_server).setOnClickListener(view -> initializeClient(false)); + findViewById(R.id.btn_init_disk).setOnClickListener(view -> initializeClient(true)); + getAssignmentButton = findViewById(R.id.btn_get_assignment); + getAssignmentButton.setEnabled(false); + getAssignmentButton.setOnClickListener(view -> getAssignment()); + findViewById(R.id.btn_add_attribute).setOnClickListener(view -> addAttributeRow("", "")); + + // Add default attributes + addAttributeRow("platform", "android"); + addAttributeRow("appVersion", BuildConfig.VERSION_NAME); + } + + private void addAttributeRow(String key, String value) { + LinearLayout row = new LinearLayout(this); + row.setOrientation(LinearLayout.HORIZONTAL); + row.setLayoutParams( + new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT)); + + EditText keyInput = new EditText(this); + keyInput.setHint("Key"); + keyInput.setText(key); + LinearLayout.LayoutParams keyParams = + new LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f); + keyInput.setLayoutParams(keyParams); + keyInput.setTag("key"); + + EditText valueInput = new EditText(this); + valueInput.setHint("Value"); + valueInput.setText(value); + LinearLayout.LayoutParams valueParams = + new LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f); + valueParams.setMarginStart(8); + valueInput.setLayoutParams(valueParams); + valueInput.setTag("value"); + + Button removeButton = new Button(this); + removeButton.setText("X"); + removeButton.setMinWidth(0); + removeButton.setMinHeight(0); + removeButton.setMinimumWidth(0); + removeButton.setMinimumHeight(0); + removeButton.setPadding(16, 8, 16, 8); + LinearLayout.LayoutParams removeParams = + new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT); + removeParams.setMarginStart(8); + removeButton.setLayoutParams(removeParams); + removeButton.setOnClickListener( + v -> { + attributesContainer.removeView(row); + attributeRows.remove(row); + }); + + row.addView(keyInput); + row.addView(valueInput); + row.addView(removeButton); + + attributesContainer.addView(row); + attributeRows.add(row); + } + + private Attributes collectAttributes() { + Attributes attributes = new Attributes(); + for (View row : attributeRows) { + EditText keyInput = row.findViewWithTag("key"); + EditText valueInput = row.findViewWithTag("value"); + if (keyInput != null && valueInput != null) { + String key = keyInput.getText().toString().trim(); + String value = valueInput.getText().toString().trim(); + if (!key.isEmpty()) { + // Try to parse as number first + try { + double numValue = Double.parseDouble(value); + attributes.put(key, EppoValue.valueOf(numValue)); + } catch (NumberFormatException e) { + // Use as string + attributes.put(key, EppoValue.valueOf(value)); + } + } + } + } + return attributes; + } + + private void initializeClient(boolean offlineMode) { + String subjectKey = subjectInput.getText().toString(); + if (TextUtils.isEmpty(subjectKey)) { + appendToLog("Subject ID is required"); + return; + } + + String source = offlineMode ? "disk" : "server"; + statusText.setText("Initializing from " + source + "..."); + appendToLog( + "Initializing precomputed client for subject: " + subjectKey + " (from " + source + ")"); + + // Collect subject attributes from the UI + Attributes subjectAttributes = collectAttributes(); + appendToLog("Subject attributes: " + subjectAttributes.size() + " attributes"); + + new EppoPrecomputedClient.Builder(API_KEY, getApplication()) + .subjectKey(subjectKey) + .subjectAttributes(subjectAttributes) + .isGracefulMode(true) + .forceReinitialize(true) + .offlineMode(offlineMode) + .assignmentLogger( + assignment -> { + Log.d( + TAG, + "Assignment logged: " + + assignment.getFeatureFlag() + + " -> " + + assignment.getVariation()); + }) + .buildAndInitAsync() + .thenAccept( + client -> { + precomputedClient = client; + runOnUiThread( + () -> { + statusText.setText("Initialized for: " + subjectKey + " (from " + source + ")"); + appendToLog("Client initialized successfully from " + source + "!"); + getAssignmentButton.setEnabled(true); + }); + }) + .exceptionally( + error -> { + Log.e(TAG, "Failed to initialize", error); + runOnUiThread( + () -> { + statusText.setText("Initialization failed"); + appendToLog("Error: " + error.getMessage()); + }); + return null; + }); + } + + private void getAssignment() { + if (precomputedClient == null) { + appendToLog("Client not initialized. Click 'From Server' or 'From Disk' first."); + return; + } + + String flagKey = flagKeyInput.getText().toString(); + if (TextUtils.isEmpty(flagKey)) { + appendToLog("Flag key is required"); + return; + } + + int selectedTypeId = flagTypeGroup.getCheckedRadioButtonId(); + String result; + + try { + if (selectedTypeId == R.id.type_string) { + result = precomputedClient.getStringAssignment(flagKey, "(default)"); + appendToLog("String assignment for '" + flagKey + "': " + result); + } else if (selectedTypeId == R.id.type_boolean) { + boolean boolResult = precomputedClient.getBooleanAssignment(flagKey, false); + appendToLog("Boolean assignment for '" + flagKey + "': " + boolResult); + } else if (selectedTypeId == R.id.type_integer) { + int intResult = precomputedClient.getIntegerAssignment(flagKey, 0); + appendToLog("Integer assignment for '" + flagKey + "': " + intResult); + } else if (selectedTypeId == R.id.type_numeric) { + double numericResult = precomputedClient.getNumericAssignment(flagKey, 0.0); + appendToLog("Numeric assignment for '" + flagKey + "': " + numericResult); + } else if (selectedTypeId == R.id.type_json) { + // JSON assignments return JsonNode - for simplicity, we show as string + appendToLog("JSON assignment for '" + flagKey + "': (use getJSONAssignment() API)"); + } else { + appendToLog("Please select a flag type"); + } + } catch (Exception e) { + appendToLog("Error getting assignment: " + e.getMessage()); + } + } + + private void appendToLog(String message) { + assignmentLog.append(message + "\n\n"); + assignmentLogScrollView.post(() -> assignmentLogScrollView.fullScroll(View.FOCUS_DOWN)); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + if (item.getItemId() == android.R.id.home) { + finish(); + return true; + } + return super.onOptionsItemSelected(item); + } + + @Override + public void onPause() { + super.onPause(); + if (precomputedClient != null) { + precomputedClient.pausePolling(); + } + } + + @Override + public void onResume() { + super.onResume(); + if (precomputedClient != null) { + precomputedClient.resumePolling(); + } + } + + @Override + public void onDestroy() { + super.onDestroy(); + if (precomputedClient != null) { + precomputedClient.stopPolling(); + } + } +} diff --git a/example/src/main/java/cloud/eppo/androidexample/SecondActivity.java b/example/src/main/java/cloud/eppo/androidexample/StandardClientActivity.java similarity index 85% rename from example/src/main/java/cloud/eppo/androidexample/SecondActivity.java rename to example/src/main/java/cloud/eppo/androidexample/StandardClientActivity.java index 6c64d21b..28833165 100644 --- a/example/src/main/java/cloud/eppo/androidexample/SecondActivity.java +++ b/example/src/main/java/cloud/eppo/androidexample/StandardClientActivity.java @@ -6,6 +6,7 @@ import android.os.Bundle; import android.text.TextUtils; import android.util.Log; +import android.view.MenuItem; import android.view.View; import android.widget.EditText; import android.widget.ScrollView; @@ -16,8 +17,8 @@ import com.geteppo.androidexample.BuildConfig; import com.geteppo.androidexample.R; -public class SecondActivity extends AppCompatActivity { - private static final String TAG = SecondActivity.class.getSimpleName(); +public class StandardClientActivity extends AppCompatActivity { + private static final String TAG = StandardClientActivity.class.getSimpleName(); private static final String API_KEY = BuildConfig.API_KEY; // Set in root-level local.properties private EditText experiment; private EditText subject; @@ -60,6 +61,12 @@ protected void onCreate(Bundle savedInstanceState) { setContentView(R.layout.activity_assigner); + // Enable the action bar back button + if (getSupportActionBar() != null) { + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + getSupportActionBar().setTitle("Standard Client"); + } + experiment = findViewById(R.id.experiment); subject = findViewById(R.id.subject); assignmentLog = findViewById(R.id.assignment_log); @@ -95,6 +102,15 @@ private void appendToAssignmentLogView(String message) { assignmentLogScrollView.post(() -> assignmentLogScrollView.fullScroll(View.FOCUS_DOWN)); } + @Override + public boolean onOptionsItemSelected(MenuItem item) { + if (item.getItemId() == android.R.id.home) { + finish(); + return true; + } + return super.onOptionsItemSelected(item); + } + // Tie into the activity's lifecycle and pause/resume polling where appropriate. @Override diff --git a/example/src/main/res/layout/activity_main.xml b/example/src/main/res/layout/activity_home.xml similarity index 82% rename from example/src/main/res/layout/activity_main.xml rename to example/src/main/res/layout/activity_home.xml index 80f4152f..bc1b2bd4 100644 --- a/example/src/main/res/layout/activity_main.xml +++ b/example/src/main/res/layout/activity_home.xml @@ -24,10 +24,15 @@ android:id="@+id/button_start_offline_assigner" android:layout_width="match_parent" android:layout_height="wrap_content" - - android:text="@string/start_offline_assignment_activity" /> +