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

Adjust ProGuard default rules and shrinking tests #2420

Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion Troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,8 @@ Note: For newer Gson versions these rules might be applied automatically; make s

**Symptom:** A `JsonIOException` with the message 'Abstract classes can't be instantiated!' is thrown; the class mentioned in the exception message is not actually `abstract` in your source code, and you are using the code shrinking tool R8 (Android app builds normally have this configured by default).

Note: If the class which you are trying to deserialize is actually abstract, then this exception is probably unrelated to R8 and you will have to implement a custom [`InstanceCreator`](https://javadoc.io/doc/com.google.code.gson/gson/latest/com.google.gson/com/google/gson/InstanceCreator.html) or [`TypeAdapter`](https://javadoc.io/doc/com.google.code.gson/gson/latest/com.google.gson/com/google/gson/TypeAdapter.html) which creates an instance of a non-abstract subclass of the class.

**Reason:** The code shrinking tool R8 performs optimizations where it removes the no-args constructor from a class and makes the class `abstract`. Due to this Gson cannot create an instance of the class.

**Solution:** Make sure the class has a no-args constructor, then adjust your R8 configuration file to keep the constructor of the class. For example:
Expand All @@ -324,6 +326,8 @@ Note: For newer Gson versions these rules might be applied automatically; make s
}
```

You can also use `<init>(...);` to keep all constructors of that class, but then you might actually rely on JDK Unsafe to create classes without no-args constructor, see [`GsonBuilder.disableJdkUnsafe()`](https://javadoc.io/doc/com.google.code.gson/gson/latest/com.google.gson/com/google/gson/GsonBuilder.html#disableJdkUnsafe()) for more information.
Copy link
Contributor

Choose a reason for hiding this comment

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

Maybe change "rely on JDK Unsafe" to "rely on sun.misc.Unsafe on both JDK and Android". UnsafeAllocator.create() also try some Android specific magic on older Dalvik VMs, but they are becoming quite rare, so I don't think it is needed to mention that.


For Android you can add this rule to the `proguard-rules.pro` file, see also the [Android documentation](https://developer.android.com/build/shrink-code#keep-code). In case the class name in the exception message is obfuscated, see the Android documentation about [retracing](https://developer.android.com/build/shrink-code#retracing).

Note: If the class which you are trying to deserialize is actually abstract, then this exception is probably unrelated to R8 and you will have to implement a custom [`InstanceCreator`](https://javadoc.io/doc/com.google.code.gson/gson/latest/com.google.gson/com/google/gson/InstanceCreator.html) or [`TypeAdapter`](https://javadoc.io/doc/com.google.code.gson/gson/latest/com.google.gson/com/google/gson/TypeAdapter.html) which creates an instance of a non-abstract subclass of the class.
For Android you can alternatively use the [`@Keep` annotation](https://developer.android.com/studio/write/annotations#keep) on the class or constructor you want to keep. That might be easier than having to maintain a custom R8 configuration.
Copy link
Contributor

Choose a reason for hiding this comment

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

This is not only for Android. You can also use R8 (or ProGuard) to shrink class files.

Also please change @Keep to @androidx.annotation.Keep.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Maybe this paragraph is worded a bit misleading then. What I meant was that Android developers can use Android's @Keep annotation, which has explicit ProGuard / R8 support by the Android build tooling. Or do ProGuard / R8 in general consider any @Keep annotations, regardless of package name and without having to manually configure this first? Do you have any ideas how to better describe this then?

Because this is also written in the context of Android, I am not sure if it is necessary to specify the fully qualified package name. I also preferred linking to the Android Developers page instead of the Javadoc for that @Keep annotation because it has links to more information about shrinking.

Copy link
Contributor

Choose a reason for hiding this comment

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

Thanks, linking to DAC is a good idea, and also keeping Android is actually fine, as the annotation is part of an Android library.

3 changes: 3 additions & 0 deletions examples/android-proguard-example/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,6 @@ tools. Have a look at R8's Compatibility FAQ, and especially at the [Gson sectio
Note that newer Gson versions apply some of the rules shown in `proguard.cfg` automatically by default,
see the file [`gson/META-INF/proguard/gson.pro`](/gson/src/main/resources/META-INF/proguard/gson.pro) for
the Gson version you are using.

An alternative to writing custom keep rules for your classes in the ProGuard configuration can be to use
Android's [`@Keep` annotation](https://developer.android.com/studio/write/annotations#keep).
Copy link
Contributor

Choose a reason for hiding this comment

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

Again, please change change @Keep to [@androidx.annotation.Keep].

Original file line number Diff line number Diff line change
Expand Up @@ -76,14 +76,15 @@ static String checkInstantiable(Class<?> c) {
if (Modifier.isAbstract(modifiers)) {
// R8 performs aggressive optimizations where it removes the default constructor of a class
// and makes the class `abstract`; check for that here explicitly
if (c.getDeclaredConstructors().length == 0) {
return "Abstract classes can't be instantiated! Adjust the R8 configuration or register"
+ " an InstanceCreator or a TypeAdapter for this type. Class name: " + c.getName()
+ "\nSee " + TroubleshootingGuide.createUrl("r8-abstract-class");
}

return "Abstract classes can't be instantiated! Register an InstanceCreator"
+ " or a TypeAdapter for this type. Class name: " + c.getName();
/*
* Note: Ideally should only show this R8-specific message when it is clear that R8 was
* used (e.g. when `c.getDeclaredConstructors().length == 0`), but on Android where this
* issue with R8 occurs most, R8 seems to keep some constructors for some reason while
* still making the class abstract
*/
return "Abstract classes can't be instantiated! Adjust the R8 configuration or register"
+ " an InstanceCreator or a TypeAdapter for this type. Class name: " + c.getName()
+ "\nSee " + TroubleshootingGuide.createUrl("r8-abstract-class");
}
return null;
}
Expand Down
28 changes: 20 additions & 8 deletions gson/src/main/resources/META-INF/proguard/gson.pro
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@

# Keep Gson annotations
# Note: Cannot perform finer selection here to only cover Gson annotations, see also https://stackoverflow.com/q/47515093
-keepattributes *Annotation*
-keepattributes RuntimeVisibleAnnotations,AnnotationDefault


### The following rules are needed for R8 in "full mode" which only adheres to `-keepattribtues` if
Expand All @@ -24,18 +24,20 @@
-keep class com.google.gson.reflect.TypeToken { *; }

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

# Keep classes with @JsonAdapter annotation
-keep @com.google.gson.annotations.JsonAdapter class *
-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>;
}

# Keep fields with any other Gson annotation
-keepclassmembers class * {
# Also allow obfuscation, assuming that users will additionally use @SerializedName or
# other means to preserve the field names
-keepclassmembers,allowobfuscation class * {
@com.google.gson.annotations.Expose <fields>;
@com.google.gson.annotations.JsonAdapter <fields>;
@com.google.gson.annotations.Since <fields>;
Expand All @@ -44,15 +46,25 @@

# Keep no-args constructor of classes which can be used with @JsonAdapter
# By default their no-args constructor is invoked to create an adapter instance
-keep class * extends com.google.gson.TypeAdapter {
-keepclassmembers class * extends com.google.gson.TypeAdapter {
<init>();
}
-keep class * implements com.google.gson.TypeAdapterFactory {
-keepclassmembers class * implements com.google.gson.TypeAdapterFactory {
<init>();
}
-keep class * implements com.google.gson.JsonSerializer {
-keepclassmembers class * implements com.google.gson.JsonSerializer {
<init>();
}
-keep class * implements com.google.gson.JsonDeserializer {
-keepclassmembers class * implements com.google.gson.JsonDeserializer {
<init>();
}

# If a class still exists after shrinking, and has fields annotated with @SerializedName,
# keep the no-args constructor
# Note: This behavior is not officially documented somewhere; based on https://issuetracker.google.com/issues/150189783#comment11
# and discussion in https://github.com/google/gson/pull/2397
-if class *
Copy link
Collaborator Author

@Marcono1234 Marcono1234 Jun 17, 2023

Choose a reason for hiding this comment

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

This seems to work as expected for R8, but it is still to me a bit unclear how -if behaves / is supposed to behave. See open questions in #2397 (comment).

Copy link
Contributor

Choose a reason for hiding this comment

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

-if is documented on https://www.guardsquare.com/manual/configuration/usage. Just having <init>() (without . . .) here is fine. If there is no default constructor, then the unsafe instantiation will kick in. But here is actually a little semantic gotcha, as keeping the default constructor is an indirect way of telling R8 that the class is instantiated.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

-if is documented on https://www.guardsquare.com/manual/configuration/usage

What I tried to point out here is that reading -if class * literally (even when consulting the ProGuard manual) sounds as if that would simply match all classes and have no special effect, but that is not the case (especially for R8?). I have also asked on the ProGuard repository regarding this: Guardsquare/proguard#344

Just having <init>() (without . . .) here is fine. If there is no default constructor, then the unsafe instantiation will kick in. But here is actually a little semantic gotcha, as keeping the default constructor is an indirect way of telling R8 that the class is instantiated.

Right, a class without no-args constructor will with the current Gson ProGuard configuration cause R8 to still make the class abstract, so Unsafe cannot be used. I personally think that this is actually fine to highlight that users should add a no-args constructor (otherwise they would implicitly rely on Unsafe). I have added a test for this now and adjusted the guides slightly.

Copy link
Contributor

Choose a reason for hiding this comment

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

The -if class * will match all classes which are in the residual program, so not all classes in the input program. This means that only if a class is otherwise used by the program and it has <init>() and fields annotated by @com.google.gson.annotations.SerializedName then both the <init>() and the annotated fields will be kept.

Suggesting to always have a default constructor makes sense (I saw the change with the tests and improved error message). The fallback to using Unsafe will then only be needed if using GSON with libraries the developer does not have control over. That could be solved by byte code rewriting injecting the default constructor, but I guess that is out of scope of GSON.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Thanks for the clarification! I have adjusted the comment in gson.pro to link to your explanation now.

-keepclasseswithmembers,allowobfuscation,allowoptimization class <1> {
<init>();
@com.google.gson.annotations.SerializedName <fields>;
}
27 changes: 14 additions & 13 deletions gson/src/test/java/com/google/gson/functional/Java17RecordTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ public int i() {

var exception = assertThrows(JsonIOException.class, () -> gson.getAdapter(LocalRecord.class));
assertThat(exception).hasMessageThat()
.isEqualTo("@SerializedName on method '" + LocalRecord.class.getName() + "#i()' is not supported");
.isEqualTo("@SerializedName on method '" + LocalRecord.class.getName() + "#i()' is not supported");
}

@Test
Expand Down Expand Up @@ -154,7 +154,7 @@ record LocalRecord(String s) {
// TODO: Adjust this once Gson throws more specific exception type
catch (RuntimeException e) {
assertThat(e).hasMessageThat()
.isEqualTo("Failed to invoke constructor '" + LocalRecord.class.getName() + "(String)' with args [value]");
.isEqualTo("Failed to invoke constructor '" + LocalRecord.class.getName() + "(String)' with args [value]");
assertThat(e).hasCauseThat().isSameInstanceAs(LocalRecord.thrownException);
}
}
Expand Down Expand Up @@ -227,7 +227,7 @@ public void testPrimitiveJsonNullValue() {
String s = "{'aString': 's', 'aByte': null, 'aShort': 0}";
var e = assertThrows(JsonParseException.class, () -> gson.fromJson(s, RecordWithPrimitives.class));
assertThat(e).hasMessageThat()
.isEqualTo("null is not allowed as value for record component 'aByte' of primitive type; at path $.aByte");
.isEqualTo("null is not allowed as value for record component 'aByte' of primitive type; at path $.aByte");
}

/**
Expand Down Expand Up @@ -384,8 +384,8 @@ record Blocked(int b) {}

var exception = assertThrows(JsonIOException.class, () -> gson.toJson(new Blocked(1)));
assertThat(exception).hasMessageThat()
.isEqualTo("ReflectionAccessFilter does not permit using reflection for class " + Blocked.class.getName() +
". Register a TypeAdapter for this type or adjust the access filter.");
.isEqualTo("ReflectionAccessFilter does not permit using reflection for class " + Blocked.class.getName() +
". Register a TypeAdapter for this type or adjust the access filter.");
}

@Test
Expand All @@ -396,15 +396,15 @@ public void testReflectionFilterBlockInaccessible() {

var exception = assertThrows(JsonIOException.class, () -> gson.toJson(new PrivateRecord(1)));
assertThat(exception).hasMessageThat()
.isEqualTo("Constructor 'com.google.gson.functional.Java17RecordTest$PrivateRecord(int)' is not accessible and"
+ " ReflectionAccessFilter does not permit making it accessible. Register a TypeAdapter for the declaring"
+ " type, adjust the access filter or increase the visibility of the element and its declaring type.");
.isEqualTo("Constructor 'com.google.gson.functional.Java17RecordTest$PrivateRecord(int)' is not accessible and"
+ " ReflectionAccessFilter does not permit making it accessible. Register a TypeAdapter for the declaring"
+ " type, adjust the access filter or increase the visibility of the element and its declaring type.");

exception = assertThrows(JsonIOException.class, () -> gson.fromJson("{}", PrivateRecord.class));
assertThat(exception).hasMessageThat()
.isEqualTo("Constructor 'com.google.gson.functional.Java17RecordTest$PrivateRecord(int)' is not accessible and"
+ " ReflectionAccessFilter does not permit making it accessible. Register a TypeAdapter for the declaring"
+ " type, adjust the access filter or increase the visibility of the element and its declaring type.");
.isEqualTo("Constructor 'com.google.gson.functional.Java17RecordTest$PrivateRecord(int)' is not accessible and"
+ " ReflectionAccessFilter does not permit making it accessible. Register a TypeAdapter for the declaring"
+ " type, adjust the access filter or increase the visibility of the element and its declaring type.");

assertThat(gson.toJson(new PublicRecord(1))).isEqualTo("{\"i\":1}");
assertThat(gson.fromJson("{\"i\":2}", PublicRecord.class)).isEqualTo(new PublicRecord(2));
Expand All @@ -427,7 +427,8 @@ record LocalRecord(int i) {}

var exception = assertThrows(JsonIOException.class, () -> gson.fromJson("{}", Record.class));
assertThat(exception).hasMessageThat()
.isEqualTo("Abstract classes can't be instantiated! Register an InstanceCreator or a TypeAdapter for"
+ " this type. Class name: java.lang.Record");
.isEqualTo("Abstract classes can't be instantiated! Adjust the R8 configuration or register an InstanceCreator"
+ " or a TypeAdapter for this type. Class name: java.lang.Record"
+ "\nSee https://github.com/google/gson/blob/main/Troubleshooting.md#r8-abstract-class");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,17 +19,14 @@
import static com.google.common.truth.Truth.assertThat;
import static org.junit.Assert.fail;

import com.google.gson.InstanceCreator;
import com.google.gson.ReflectionAccessFilter;
import com.google.gson.reflect.TypeToken;
import java.lang.reflect.Type;
import java.util.Collections;
import org.junit.Test;

public class ConstructorConstructorTest {
private ConstructorConstructor constructorConstructor = new ConstructorConstructor(
Collections.<Type, InstanceCreator<?>>emptyMap(), true,
Collections.<ReflectionAccessFilter>emptyList()
Collections.emptyMap(), true,
Collections.emptyList()
);

private abstract static class AbstractClass {
Expand All @@ -39,7 +36,7 @@ public AbstractClass() { }
private interface Interface { }

/**
* Verify that ConstructorConstructor does not try to invoke no-arg constructor
* Verify that ConstructorConstructor does not try to invoke no-args constructor
* of abstract class.
*/
@Test
Expand All @@ -49,9 +46,10 @@ public void testGet_AbstractClassNoArgConstructor() {
constructor.construct();
fail("Expected exception");
} catch (RuntimeException exception) {
assertThat(exception).hasMessageThat().isEqualTo("Abstract classes can't be instantiated! "
+ "Register an InstanceCreator or a TypeAdapter for this type. "
+ "Class name: com.google.gson.internal.ConstructorConstructorTest$AbstractClass");
assertThat(exception).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.google.gson.internal.ConstructorConstructorTest$AbstractClass"
+ "\nSee https://github.com/google/gson/blob/main/Troubleshooting.md#r8-abstract-class");
}
}

Expand All @@ -62,9 +60,9 @@ public void testGet_Interface() {
constructor.construct();
fail("Expected exception");
} catch (RuntimeException exception) {
assertThat(exception).hasMessageThat().isEqualTo("Interfaces can't be instantiated! "
+ "Register an InstanceCreator or a TypeAdapter for this type. "
+ "Interface name: com.google.gson.internal.ConstructorConstructorTest$Interface");
assertThat(exception).hasMessageThat().isEqualTo("Interfaces can't be instantiated!"
+ " Register an InstanceCreator or a TypeAdapter for this type."
+ " Interface name: com.google.gson.internal.ConstructorConstructorTest$Interface");
}
}
}
2 changes: 2 additions & 0 deletions shrinker-test/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,8 @@
<dependencies>
<dependency>
<!-- R8 dependency used above -->
<!-- Note: For some reason Maven shows the warning "Missing POM for com.android.tools:r8:jar",
but it appears that can be ignored -->
<groupId>com.android.tools</groupId>
<artifactId>r8</artifactId>
<version>8.0.40</version>
Expand Down
18 changes: 18 additions & 0 deletions shrinker-test/proguard.pro
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,21 @@
-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>;
}


-keepclassmembernames class com.example.DefaultConstructorMain$TestClass {
<fields>;
}
-keepclassmembernames class com.example.DefaultConstructorMain$TestClassNotAbstract {
<fields>;
}
16 changes: 1 addition & 15 deletions shrinker-test/r8.pro
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,6 @@
### 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

# Keep the no-args constructor of deserialized classes
-keepclassmembers class com.example.ClassWithDefaultConstructor {
<init>();
}
-keepclassmembers class com.example.GenericClasses$GenericClass {
<init>();
}
-keepclassmembers class com.example.GenericClasses$UsingGenericClass {
<init>();
}
-keepclassmembers class com.example.GenericClasses$GenericUsingGenericClass {
<init>();
}

# For classes with generic type parameter R8 in "full mode" requires to have a keep rule to
# preserve the generic signature
-keep,allowshrinking,allowoptimization,allowobfuscation,allowaccessmodification class com.example.GenericClasses$GenericClass
Expand All @@ -31,6 +17,6 @@
}

# Keep enum constants which are not explicitly used in code
-keep class com.example.EnumClass {
-keepclassmembers class com.example.EnumClass {
** SECOND;
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
*/
public class ClassWithJsonAdapterAnnotation {
// For this field don't use @SerializedName and ignore it for deserialization
// Has custom ProGuard rule to keep the field name
@JsonAdapter(value = Adapter.class, nullSafe = false)
DummyClass f;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,14 @@

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

public class DefaultConstructorMain {
static class TestClass {
@SerializedName("s")
public String s;
}

// R8 rule for this class still removes no-args constructor, but doesn't make class abstract
static class TestClassNotAbstract {
@SerializedName("s")
public String s;
}

Expand Down