-
Notifications
You must be signed in to change notification settings - Fork 1
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Handle being able to be minified by ProGuard #43
Conversation
@@ -0,0 +1,7 @@ | |||
package cloud.eppo.android; | |||
|
|||
public interface CacheLoadCallback { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Instead of having the Cache operation call the passed-through InitializationCallback,
it now has its own callback. This way, if loading the configuration from the cache fails but getting the configuration from the RAC succeeds, the success callback will called (instead of the failure callback).
import cloud.eppo.android.util.Utils; | ||
|
||
public class ConfigurationStore { | ||
|
||
private static final String TAG = logTag(ConfigurationStore.class); | ||
|
||
private final Gson gson = new GsonBuilder() | ||
.registerTypeAdapter(RandomizationConfigResponse.class, new RandomizationConfigResponseDeserializer()) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We now use a custom (vs. automagic) deserializer.
This way, upstream users of our SDK who also use ProGuard modification don't need to modify their proguard-rules.pro
file. (Note: This is what LaunchDarkly requires)
It will also protect against accidentally breaking things if we add new classes that for whatever reason are not covered by the rules, gson is updated, etc.
} | ||
|
||
public void setFlags(Reader response) { | ||
RandomizationConfigResponse config = gson.fromJson(response, RandomizationConfigResponse.class); | ||
flags = config.getFlags(); | ||
if (flags == null) { | ||
if (config == null || config.getFlags() == null) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Re-arrange to handle the null config edge case (shouldn't happen, but being defensive)
private List<Variation> variations; | ||
|
||
public float getPercentExposure() { | ||
return percentExposure; | ||
} | ||
|
||
public void setPercentExposure(float percentExposure) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Now that we are not using automagic, we need explicitly-defined setters for our DTOs
public void setStart(int start) { | ||
this.start = start; | ||
} | ||
|
||
public int getEnd() { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Aiming for consistent get
/set
ordering in all our DTOs
* unreliable when ProGuard minification is in-use and not configured to protect | ||
* JSON-deserialization-related classes and annotations. | ||
*/ | ||
public class RandomizationConfigResponseDeserializer implements JsonDeserializer<RandomizationConfigResponse> { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I went with RandomizationConfigResponseDeserializer
instead of RandomizationConfigResponseAdapter
(the pattern EppoValueAdapater
followed) as it's implementing the JsonDeserializer
interface so I felt it was more descriptive, even though its main use is in gson
's .registerTypeAdapter()
method.
@@ -1,4 +1,4 @@ | |||
package cloud.eppo.android.dto.adapters; | |||
package cloud.eppo.android.dto.deserializers; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Figured EppoValueAdapater
, which is a Deserializer
, should be in the same folder/package as RandomizationConfigResponseDeserializer
if (rootElement == null || !rootElement.isJsonObject()) { | ||
Log.w(TAG, "no top-level JSON object"); | ||
return configResponse; | ||
} | ||
|
||
JsonObject rootObject = rootElement.getAsJsonObject(); | ||
JsonElement flagsElement = rootObject.get("flags"); | ||
if (flagsElement == null) { | ||
Log.w(TAG, "no root-level flags property"); | ||
return configResponse; | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Defensiveness against getting a response that is valid JSON, but doesn't have any flags (error/edge case)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Presumably the deserialization process will log appropriate errors for invalid JSON, like the Log.w(TAG, "Unknown operator \""+operatorKey+"\"");
line that comes later
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, these we want to handle especially, as it means something is up with the JSON being returned. However if we get a config but it's malformed, a JsonParseException
will be thrown and caught upstream.
@@ -18,8 +18,9 @@ android { | |||
|
|||
buildTypes { | |||
release { | |||
minifyEnabled false | |||
minifyEnabled true |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
To mirror what some customers have going on, we minify for our release build. (This let me reproduce the issue 🎉)
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' | ||
signingConfig signingConfigs.debug |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Added by Android Studio to allow the app to run in "release mode" on the emulator
|
||
public class EppoApplication extends Application { | ||
private static final String TAG = EppoApplication.class.getSimpleName(); | ||
private static final String API_KEY = "REPLACE WITH YOUR API KEY"; | ||
private static final String API_KEY = BuildConfig.API_KEY; // Set in root-level local.properties |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull the API key from .gitignore
ed local.properties
rather than having it here, where somebody could accidentally check one in.
def localProperties = new Properties() | ||
def localPropertiesFile = rootProject.file('local.properties') | ||
if (localPropertiesFile.exists()) { | ||
localProperties.load(new FileInputStream(localPropertiesFile)) | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Leverage .gitignore
ed local.properties
for secure placing of secrets during local development and testing
@@ -14,12 +20,15 @@ android { | |||
versionName "1.0" | |||
|
|||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" | |||
|
|||
buildConfigField "String", "API_KEY", "\"" + (localProperties['cloud.eppo.apiKey'] ?: "need to set cloud.eppo.apiKey in local.properties") + "\"" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
make cloud.eppo.apiKey
in local.properties
available as API_KEY
to the example application
private final EppoValueAdapter eppoValueAdapter = new EppoValueAdapter(); | ||
|
||
@Override | ||
public RandomizationConfigResponse deserialize(JsonElement rootElement, Type type, JsonDeserializationContext context) throws JsonParseException { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Example file to deserialize: https://us-central1-eppo-qa.cloudfunctions.net/serveGitHubRacTestFile/api/v3/randomized_assignment?sdkName=android
Beautified version:
test_rac.json
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🙏
@@ -13,6 +13,10 @@ public String getAllocationKey() { | |||
return allocationKey; | |||
} | |||
|
|||
public void setAllocationKey(String allocationKey) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why not cleanup SerializedName
here too?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🤦 Yes, we absolutely should! Good catch!
* JSON-deserialization-related classes and annotations. | ||
*/ | ||
public class RandomizationConfigResponseDeserializer implements JsonDeserializer<RandomizationConfigResponse> { | ||
private static final String TAG = logTag(EppoClient.class); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
copy pasta?
private static final String TAG = logTag(EppoClient.class); | |
private static final String TAG = logTag(RandomizationConfigResponseDeserializer.class); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
😱 Another good catch! THIS IS WHY WE DO PULL REQUESTS! 🙌
} | ||
|
||
for (Map.Entry<String, JsonElement> typedOverridesEntry : jsonElement.getAsJsonObject().entrySet()) { | ||
typedOverrides.put(typedOverridesEntry.getKey(), typedOverridesEntry.getValue().getAsString()); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
getValue()
shouldn't ever be null here right?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It could be, as this is JSON, so it's certainly possible. However, if we have a typedOverrides,
we're going to count on it being properly formatted. If not, this will throw a JsonParseException,
which is what we want in this situation. Both places we parse are surrounded by a higher-level try
-catch
to handle that exception appropriately.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice work!
public void setAllocationKey(String allocationKey) { | ||
this.allocationKey = allocationKey; | ||
} | ||
|
||
public List<TargetingCondition> getConditions() { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Related to Lisa's comment -- get and set conditions methods were already in place here, but also with the @SerializedName("conditions")
decorator. Was there a reason we needed both?
if (rootElement == null || !rootElement.isJsonObject()) { | ||
Log.w(TAG, "no top-level JSON object"); | ||
return configResponse; | ||
} | ||
|
||
JsonObject rootObject = rootElement.getAsJsonObject(); | ||
JsonElement flagsElement = rootObject.get("flags"); | ||
if (flagsElement == null) { | ||
Log.w(TAG, "no root-level flags property"); | ||
return configResponse; | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Presumably the deserialization process will log appropriate errors for invalid JSON, like the Log.w(TAG, "Unknown operator \""+operatorKey+"\"");
line that comes later
|
||
@Test | ||
public void testDeserialize() throws IOException { | ||
File testRac = new File("src/androidTest/assets/rac-experiments-v3.json"); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice!
private ConcurrentHashMap<String, FlagConfig> flags; | ||
|
||
public ConcurrentHashMap<String, FlagConfig> getFlags() { | ||
return flags; | ||
} | ||
|
||
public void setFlags(ConcurrentHashMap<String, FlagConfig> flags) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
And this time, we don't need a getFlags
method?
Eppo Internal: 🎟️ Ticket: FF-1892 - Android SDK Handle loading flags when using ProGuard minification
Our existing JSON deserialization leveraged
gson
's automatic mapping of JSON fields to Java Object properties based on their names. However, this would fail when those property names changed due to minification, such as by ProGuard.To get around this without the need to modify
proguard-rules.pro
(which could be at best burdonsome and at worst fragile), we explicitly define a deserializer and use that to parse the configuration response.This was tested by building the example application with minification enabled. Before the changes, it didn't get assignments, but now it does! 🎉