Skip to content
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

Merged
merged 7 commits into from Apr 9, 2024

Conversation

aarsilv
Copy link
Contributor

@aarsilv aarsilv commented Apr 9, 2024

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! 🎉

image

@@ -0,0 +1,7 @@
package cloud.eppo.android;

public interface CacheLoadCallback {
Copy link
Contributor Author

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())
Copy link
Contributor Author

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) {
Copy link
Contributor Author

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) {
Copy link
Contributor Author

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() {
Copy link
Contributor Author

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> {
Copy link
Contributor Author

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;
Copy link
Contributor Author

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

Comment on lines +47 to +57
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;
}
Copy link
Contributor Author

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)

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

Copy link
Contributor Author

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
Copy link
Contributor Author

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
Copy link
Contributor Author

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
Copy link
Contributor Author

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 .gitignoreed local.properties rather than having it here, where somebody could accidentally check one in.

Comment on lines +5 to +9
def localProperties = new Properties()
def localPropertiesFile = rootProject.file('local.properties')
if (localPropertiesFile.exists()) {
localProperties.load(new FileInputStream(localPropertiesFile))
}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leverage .gitignoreed 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") + "\""
Copy link
Contributor Author

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

@aarsilv aarsilv marked this pull request as ready for review April 9, 2024 17:40
@aarsilv aarsilv requested review from petzel and sameerank April 9, 2024 17:40
private final EppoValueAdapter eppoValueAdapter = new EppoValueAdapter();

@Override
public RandomizationConfigResponse deserialize(JsonElement rootElement, Type type, JsonDeserializationContext context) throws JsonParseException {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link

@lisaah lisaah left a 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) {
Copy link

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?

Copy link
Contributor Author

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);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

copy pasta?

Suggested change
private static final String TAG = logTag(EppoClient.class);
private static final String TAG = logTag(RandomizationConfigResponseDeserializer.class);

Copy link
Contributor Author

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());
Copy link

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?

Copy link
Contributor Author

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.

Copy link

@sameerank sameerank left a 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() {

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?

Comment on lines +47 to +57
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;
}

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");

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) {

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?

@aarsilv aarsilv assigned aarsilv and unassigned sameerank Apr 9, 2024
@aarsilv aarsilv merged commit 34c5c51 into main Apr 9, 2024
1 check passed
@aarsilv aarsilv deleted the aaron/ff-1892/handle-minification branch April 9, 2024 19:16
@aarsilv aarsilv mentioned this pull request Apr 9, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

3 participants