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

Update ProGuard default shrinking rules #2448

Merged
merged 11 commits into from
Aug 22, 2023
27 changes: 13 additions & 14 deletions gson/src/main/resources/META-INF/proguard/gson.pro
Original file line number Diff line number Diff line change
Expand Up @@ -15,25 +15,20 @@
# Note: Cannot perform finer selection here to only cover Gson annotations, see also https://stackoverflow.com/q/47515093
-keepattributes RuntimeVisibleAnnotations,AnnotationDefault


### The following rules are needed for R8 in "full mode" which only adheres to `-keepattribtues` if
### the corresponding class or field is matches by a `-keep` rule as well, see
### https://r8.googlesource.com/r8/+/refs/heads/main/compatibility-faq.md#r8-full-mode

# Keep class TypeToken (respectively its generic signature)
-keep class com.google.gson.reflect.TypeToken { *; }
# Keep class TypeToken (respectively its generic signature) if present
-if class com.google.gson.reflect.TypeToken
-keep,allowobfuscation class com.google.gson.reflect.TypeToken

# Keep any (anonymous) classes extending TypeToken
-keep,allowobfuscation class * extends com.google.gson.reflect.TypeToken

# Keep classes with @JsonAdapter annotation
-keep,allowobfuscation,allowoptimization @com.google.gson.annotations.JsonAdapter class *

# Keep fields with @SerializedName annotation, but allow obfuscation of their names
-keepclassmembers,allowobfuscation class * {
@com.google.gson.annotations.SerializedName <fields>;
}
Comment on lines -32 to -35
Copy link
Collaborator

Choose a reason for hiding this comment

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

Out of curiosity, why did you remove this here and add it below guarded by a -if class *? Does that make a difference for the end result?

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, in R8 full mode we have a distinction between referenced and instantiated classes. For classes which are not seen by R8 as instantiated (e.g. if only referenced statically using .class as one will do in fromJson then -keepclassmembers will only keep static members and not instance members. For ProGuard and R8 in compatability mode the two will do the same.

Copy link
Collaborator

@Marcono1234 Marcono1234 Aug 15, 2023

Choose a reason for hiding this comment

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

Hmm thanks, ok that sounds like the previous rules would not have worked properly then because -keepclassmembers would not have kepts the non-static @SerializedName fields. Though I think the shrinker-test showed that it did work as expected?
I am bit confused, or am I misunderstanding your comment or are the shrinker-tests flawed and not close enough to real code?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

One way to see the difference is to have an instance field annotated with @SerializedName which is not used in the code at all, then the field will be removed with the previous rule in R8 full mode (which is default from AGP 8.0.0). Then in a pass through from fromJson to toJson the field value will be lost. Of course it is a matter of taste if unused fields should be kept this way.


# Keep fields with any other Gson annotation
# Also allow obfuscation, assuming that users will additionally use @SerializedName or
# other means to preserve the field names
Expand All @@ -59,12 +54,16 @@
<init>();
}

# If a class is used in some way by the application, and has fields annotated with @SerializedName
# and a no-args constructor, keep those fields and the constructor
# Based on https://issuetracker.google.com/issues/150189783#comment11
# See also https://github.com/google/gson/pull/2420#discussion_r1241813541 for a more detailed explanation
# Keep fields annotated with @SerializedName for classes which are present.
# If classes with fields annotated with @SerializedName have a no-args
# constructor keep that as well.
Copy link
Collaborator

Choose a reason for hiding this comment

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

Why did you remove the link to the pull request discussion?

At least in my opinion this -if class * ... is pretty cryptic and its intended behavior is not directly obvious, so it would be good to have a reference explaining what this actually does.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good point, added those references back.

-if class *
Copy link
Contributor

Choose a reason for hiding this comment

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

I think this if class *, -classeswithmembers... class <1> doesn't do anything relative to -classeswithmembers... class * , after reading some of the follow-up threads on that change.

Copy link
Collaborator

@Marcono1234 Marcono1234 Jul 29, 2023

Choose a reason for hiding this comment

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

At least for R8 it does seem to make a difference: -keepclasseswithmembers ... class * always keeps the class, even if it isn't used. Whereas -if class * ... -keepclasseswithmembers ... class <1> only keeps the class if it is actually used.

Unfortunately this is not covered by the tests yet; have created #2455 to add a test for this.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Thanks also for the other points you raised, but unless sgjesse wants to address them here as well could you please create a separate GitHub issue or pull request? Otherwise the discussion on this pull request here might drift too far away from the original topic.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Changed the rules to be two -if rules

  1. Keep all @SerializedName fields for classes which are present after shrinking
  2. If a class with a @SerializedName field is present keep its no-args constructor

-keepclasseswithmembers,allowobfuscation,allowoptimization class <1> {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Why did you remove allowoptimization?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Having allowoptimization can allow R8 to propagate field values, so if you have an explicit construction of a serialized class always setting a field to a specific value, then that single value can become the default value for the created instances instead of 0/null.

<init>();
-keepclasseswithmembers,allowobfuscation class <1> {
@com.google.gson.annotations.SerializedName <fields>;
}
-if class * {
@com.google.gson.annotations.SerializedName <fields>;
}
-keepclassmembers,allowobfuscation class <1> {
<init>();
}
40 changes: 40 additions & 0 deletions shrinker-test/common.pro
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
### Common rules for ProGuard and R8
### Should only contains rules needed specifically for the integration test;
### any general rules which are relevant for all users should not be here but in `META-INF/proguard` of Gson

-allowaccessmodification

# On Windows mixed case class names might cause problems
-dontusemixedcaseclassnames

# Ignore notes about duplicate JDK classes
-dontnote module-info,jdk.internal.**


# Keep test entrypoints
-keep class com.example.Main {
public static void runTests(...);
}
-keep class com.example.NoSerializedNameMain {
public static java.lang.String runTest();
public static java.lang.String runTestNoJdkUnsafe();
public static java.lang.String runTestNoDefaultConstructor();
}


### Test data setup

# Keep fields without annotations which should be preserved
-keepclassmembers class com.example.ClassWithNamedFields {
!transient <fields>;
}

-keepclassmembernames class com.example.ClassWithExposeAnnotation {
<fields>;
}
-keepclassmembernames class com.example.ClassWithJsonAdapterAnnotation {
** f;
}
-keepclassmembernames class com.example.ClassWithVersionAnnotations {
<fields>;
}
52 changes: 12 additions & 40 deletions shrinker-test/proguard.pro
Original file line number Diff line number Diff line change
@@ -1,48 +1,20 @@
### Common rules for ProGuard and R8
### Should only contains rules needed specifically for the integration test;
### any general rules which are relevant for all users should not be here but in `META-INF/proguard` of Gson
# Include common rules
-include common.pro

-allowaccessmodification
### ProGuard specific rules

# On Windows mixed case class names might cause problems
-dontusemixedcaseclassnames

# Ignore notes about duplicate JDK classes
-dontnote module-info,jdk.internal.**


# Keep test entrypoints
-keep class com.example.Main {
public static void runTests(...);
}
-keep class com.example.DefaultConstructorMain {
public static java.lang.String runTest();
public static java.lang.String runTestNoJdkUnsafe();
public static java.lang.String runTestNoDefaultConstructor();
}


### Test data setup

# Keep fields without annotations which should be preserved
-keepclassmembers class com.example.ClassWithNamedFields {
!transient <fields>;
}

-keepclassmembernames class com.example.ClassWithExposeAnnotation {
# Unlike R8, ProGuard does not perform aggressive optimization which makes classes abstract,
# therefore for ProGuard can successfully perform deserialization, and for that need to
# preserve the field names
-keepclassmembernames class com.example.NoSerializedNameMain$TestClass {
<fields>;
}
-keepclassmembernames class com.example.ClassWithJsonAdapterAnnotation {
** f;
}
-keepclassmembernames class com.example.ClassWithVersionAnnotations {
<fields>;
}


-keepclassmembernames class com.example.DefaultConstructorMain$TestClass {
-keepclassmembernames class com.example.NoSerializedNameMain$TestClassNotAbstract {
<fields>;
}
-keepclassmembernames class com.example.DefaultConstructorMain$TestClassNotAbstract {
-keepclassmembernames class com.example.NoSerializedNameMain$TestClassWithoutDefaultConstructor {
<fields>;
}
#-keep class com.example.ClassWithSerializedName {
# <init>(...);
#}
Copy link
Collaborator

Choose a reason for hiding this comment

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

Is this needed?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good catch, no removed the rules in comments. Also removed the rule above, as that was also not needed. I probably added it while testing different rules.

10 changes: 5 additions & 5 deletions shrinker-test/r8.pro
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# Extend the ProGuard rules
-include proguard.pro
# Include common rules
-include common.pro

### The following rules are needed for R8 in "full mode", which performs more aggressive optimizations than ProGuard
### See https://r8.googlesource.com/r8/+/refs/heads/main/compatibility-faq.md#r8-full-mode
Expand All @@ -10,11 +10,11 @@
-keep,allowshrinking,allowoptimization,allowobfuscation,allowaccessmodification class com.example.GenericClasses$GenericUsingGenericClass

# Don't obfuscate class name, to check it in exception message
-keep,allowshrinking,allowoptimization class com.example.DefaultConstructorMain$TestClass
-keep,allowshrinking,allowoptimization class com.example.DefaultConstructorMain$TestClassWithoutDefaultConstructor
-keep,allowshrinking,allowoptimization class com.example.NoSerializedNameMain$TestClass
-keep,allowshrinking,allowoptimization class com.example.NoSerializedNameMain$TestClassWithoutDefaultConstructor

# This rule has the side-effect that R8 still removes the no-args constructor, but does not make the class abstract
-keep class com.example.DefaultConstructorMain$TestClassNotAbstract {
-keep class com.example.NoSerializedNameMain$TestClassNotAbstract {
@com.google.gson.annotations.SerializedName <fields>;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

import com.google.gson.annotations.SerializedName;

/**
* Class with no-args default constructor and with field annotated with
* {@link SerializedName}.
*/
public class ClassWithDefaultConstructor {
@SerializedName("myField")
public int i;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.example;

import com.google.gson.annotations.SerializedName;

/**
* Class without no-args default constructor, but with field annotated with
* {@link SerializedName}.
*/
public class ClassWithoutDefaultConstructor {
@SerializedName("myField")
public int i;

// Specify explicit constructor with args to remove implicit no-args default constructor
public ClassWithoutDefaultConstructor(int i) {
this.i = i;
}
}
11 changes: 11 additions & 0 deletions shrinker-test/src/main/java/com/example/Main.java
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ public static void runTests(BiConsumer<String, String> outputConsumer) {
testNamedFields(outputConsumer);
testSerializedName(outputConsumer);

testNoDefaultConstructor(outputConsumer);
testNoJdkUnsafe(outputConsumer);

testEnum(outputConsumer);
Expand Down Expand Up @@ -89,6 +90,16 @@ private static void testSerializedName(BiConsumer<String, String> outputConsumer
});
}

private static void testNoDefaultConstructor(BiConsumer<String, String> outputConsumer) {
Gson gson = new GsonBuilder().setPrettyPrinting().create();
TestExecutor.run(outputConsumer, "Write: No default constructor", () -> toJson(gson, new ClassWithoutDefaultConstructor(2)));
// This most likely relies on JDK Unsafe (unless the shrinker rewrites the constructor in some way)
TestExecutor.run(outputConsumer, "Read: No default constructor", () -> {
ClassWithoutDefaultConstructor deserialized = fromJson(gson, "{\"myField\": 3}", ClassWithoutDefaultConstructor.class);
return Integer.toString(deserialized.i);
});
}

private static void testNoJdkUnsafe(BiConsumer<String, String> outputConsumer) {
Gson gson = new GsonBuilder().disableJdkUnsafe().create();
TestExecutor.run(outputConsumer, "Read: No JDK Unsafe; initial constructor value", () -> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,40 +4,40 @@

import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.annotations.SerializedName;

public class DefaultConstructorMain {
/**
* Covers cases of classes which don't use {@code @SerializedName} on their fields, and are
* therefore not matched by the default {@code gson.pro} rules.
*/
public class NoSerializedNameMain {
static class TestClass {
public String s;
}

// R8 rule for this class still removes no-args constructor, but doesn't make class abstract
// R8 test rule in r8.pro for this class still removes no-args constructor, but doesn't make class abstract
static class TestClassNotAbstract {
public String s;
}

// Current Gson ProGuard rules only keep default constructor (and only then prevent R8 from
// making class abstract); other constructors are ignored to suggest to user adding default
// constructor instead of implicitly relying on JDK Unsafe
static class TestClassWithoutDefaultConstructor {
@SerializedName("s")
public String s;

// Specify explicit constructor with args to remove implicit no-args default constructor
public TestClassWithoutDefaultConstructor(String s) {
this.s = s;
}
}

/**
* Main entrypoint, called by {@code ShrinkingIT.testDefaultConstructor()}.
* Main entrypoint, called by {@code ShrinkingIT.testNoSerializedName_DefaultConstructor()}.
*/
public static String runTest() {
TestClass deserialized = new Gson().fromJson("{\"s\":\"value\"}", same(TestClass.class));
return deserialized.s;
}

/**
* Main entrypoint, called by {@code ShrinkingIT.testDefaultConstructorNoJdkUnsafe()}.
* Main entrypoint, called by {@code ShrinkingIT.testNoSerializedName_DefaultConstructorNoJdkUnsafe()}.
*/
public static String runTestNoJdkUnsafe() {
Gson gson = new GsonBuilder().disableJdkUnsafe().create();
Expand All @@ -46,7 +46,7 @@ public static String runTestNoJdkUnsafe() {
}

/**
* Main entrypoint, called by {@code ShrinkingIT.testNoDefaultConstructor()}.
* Main entrypoint, called by {@code ShrinkingIT.testNoSerializedName_NoDefaultConstructor()}.
*/
public static String runTestNoDefaultConstructor() {
TestClassWithoutDefaultConstructor deserialized = new Gson().fromJson("{\"s\":\"value\"}", same(TestClassWithoutDefaultConstructor.class));
Expand Down
26 changes: 17 additions & 9 deletions shrinker-test/src/test/java/com/google/gson/it/ShrinkingIT.java
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,14 @@ public void test() throws Exception {
"Read: SerializedName",
"3",
"===",
"Write: No default constructor",
"{",
" \"myField\": 2",
"}",
"===",
"Read: No default constructor",
"3",
"===",
"Read: No JDK Unsafe; initial constructor value",
"-3",
"===",
Expand Down Expand Up @@ -181,8 +189,8 @@ public void test() throws Exception {
}

@Test
public void testDefaultConstructor() throws Exception {
runTest("com.example.DefaultConstructorMain", c -> {
public void testNoSerializedName_DefaultConstructor() throws Exception {
runTest("com.example.NoSerializedNameMain", c -> {
Method m = c.getMethod("runTest");

if (jarToTest.equals(PROGUARD_RESULT_PATH)) {
Expand All @@ -193,16 +201,16 @@ public void testDefaultConstructor() throws Exception {
Exception e = assertThrows(InvocationTargetException.class, () -> m.invoke(null));
assertThat(e).hasCauseThat().hasMessageThat().isEqualTo(
"Abstract classes can't be instantiated! Adjust the R8 configuration or register an InstanceCreator"
+ " or a TypeAdapter for this type. Class name: com.example.DefaultConstructorMain$TestClass"
+ " or a TypeAdapter for this type. Class name: com.example.NoSerializedNameMain$TestClass"
+ "\nSee https://github.com/google/gson/blob/main/Troubleshooting.md#r8-abstract-class"
);
}
});
}

@Test
public void testDefaultConstructorNoJdkUnsafe() throws Exception {
runTest("com.example.DefaultConstructorMain", c -> {
public void testNoSerializedName_DefaultConstructorNoJdkUnsafe() throws Exception {
runTest("com.example.NoSerializedNameMain", c -> {
Method m = c.getMethod("runTestNoJdkUnsafe");

if (jarToTest.equals(PROGUARD_RESULT_PATH)) {
Expand All @@ -212,7 +220,7 @@ public void testDefaultConstructorNoJdkUnsafe() throws Exception {
// R8 performs more aggressive optimizations
Exception e = assertThrows(InvocationTargetException.class, () -> m.invoke(null));
assertThat(e).hasCauseThat().hasMessageThat().isEqualTo(
"Unable to create instance of class com.example.DefaultConstructorMain$TestClassNotAbstract;"
"Unable to create instance of class com.example.NoSerializedNameMain$TestClassNotAbstract;"
+ " usage of JDK Unsafe is disabled. Registering an InstanceCreator or a TypeAdapter for this type,"
+ " adding a no-args constructor, or enabling usage of JDK Unsafe may fix this problem. Or adjust"
+ " your R8 configuration to keep the no-args constructor of the class."
Expand All @@ -222,8 +230,8 @@ public void testDefaultConstructorNoJdkUnsafe() throws Exception {
}

@Test
public void testNoDefaultConstructor() throws Exception {
runTest("com.example.DefaultConstructorMain", c -> {
public void testNoSerializedName_NoDefaultConstructor() throws Exception {
runTest("com.example.NoSerializedNameMain", c -> {
Method m = c.getMethod("runTestNoDefaultConstructor");

if (jarToTest.equals(PROGUARD_RESULT_PATH)) {
Expand All @@ -234,7 +242,7 @@ public void testNoDefaultConstructor() throws Exception {
Exception e = assertThrows(InvocationTargetException.class, () -> m.invoke(null));
assertThat(e).hasCauseThat().hasMessageThat().isEqualTo(
"Abstract classes can't be instantiated! Adjust the R8 configuration or register an InstanceCreator"
+ " or a TypeAdapter for this type. Class name: com.example.DefaultConstructorMain$TestClassWithoutDefaultConstructor"
+ " or a TypeAdapter for this type. Class name: com.example.NoSerializedNameMain$TestClassWithoutDefaultConstructor"
+ "\nSee https://github.com/google/gson/blob/main/Troubleshooting.md#r8-abstract-class"
);
}
Expand Down