From 1c9f3099e37297e733789a9b0f726b6a95a5a093 Mon Sep 17 00:00:00 2001 From: Marcono1234 Date: Fri, 26 May 2023 21:34:41 +0200 Subject: [PATCH 1/7] Add code shrinking tools integration test --- Troubleshooting.md | 19 ++ examples/android-proguard-example/README.md | 3 +- extras/pom.xml | 1 + gson/pom.xml | 1 - .../gson/internal/ConstructorConstructor.java | 16 +- ...onAdapterAnnotationTypeAdapterFactory.java | 2 + pom.xml | 8 +- proto/pom.xml | 1 - shrinker-test/README.md | 7 + shrinker-test/pom.xml | 204 ++++++++++++++++++ shrinker-test/proguard.pro | 64 ++++++ shrinker-test/r8.pro | 26 +++ .../java/com/example/ClassWithAdapter.java | 44 ++++ .../com/example/ClassWithAnnotations.java | 44 ++++ .../example/ClassWithDefaultConstructor.java | 12 ++ .../example/ClassWithExposeAnnotation.java | 10 + .../com/example/ClassWithNamedFields.java | 10 + .../com/example/ClassWithSerializedName.java | 15 ++ .../com/example/DefaultConstructorMain.java | 21 ++ .../src/main/java/com/example/EnumClass.java | 6 + .../example/EnumClassWithSerializedName.java | 10 + .../src/main/java/com/example/Main.java | 120 +++++++++++ .../main/java/com/example/TestExecutor.java | 34 +++ .../java/com/google/gson/it/ShrinkingIT.java | 177 +++++++++++++++ 24 files changed, 847 insertions(+), 8 deletions(-) create mode 100644 shrinker-test/README.md create mode 100644 shrinker-test/pom.xml create mode 100644 shrinker-test/proguard.pro create mode 100644 shrinker-test/r8.pro create mode 100644 shrinker-test/src/main/java/com/example/ClassWithAdapter.java create mode 100644 shrinker-test/src/main/java/com/example/ClassWithAnnotations.java create mode 100644 shrinker-test/src/main/java/com/example/ClassWithDefaultConstructor.java create mode 100644 shrinker-test/src/main/java/com/example/ClassWithExposeAnnotation.java create mode 100644 shrinker-test/src/main/java/com/example/ClassWithNamedFields.java create mode 100644 shrinker-test/src/main/java/com/example/ClassWithSerializedName.java create mode 100644 shrinker-test/src/main/java/com/example/DefaultConstructorMain.java create mode 100644 shrinker-test/src/main/java/com/example/EnumClass.java create mode 100644 shrinker-test/src/main/java/com/example/EnumClassWithSerializedName.java create mode 100644 shrinker-test/src/main/java/com/example/Main.java create mode 100644 shrinker-test/src/main/java/com/example/TestExecutor.java create mode 100644 shrinker-test/src/test/java/com/google/gson/it/ShrinkingIT.java diff --git a/Troubleshooting.md b/Troubleshooting.md index 17e9f14004..eae7ae675b 100644 --- a/Troubleshooting.md +++ b/Troubleshooting.md @@ -281,3 +281,22 @@ Class.forName(jsonString, false, getClass().getClassLoader()).asSubclass(MyBaseC ``` This will not initialize arbitrary classes, and it will throw a `ClassCastException` if the loaded class is not the same as or a subclass of `MyBaseClass`. + +## `JsonIOException`: 'Abstract classes can't be instantiated!' (R8) + +**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). + +**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: + +``` +# Keep the no-args constructor of the deserialized class +-keep class com.example.MyClass { + (); +} +``` + +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. diff --git a/examples/android-proguard-example/README.md b/examples/android-proguard-example/README.md index 093c8eebca..153e6e48b0 100644 --- a/examples/android-proguard-example/README.md +++ b/examples/android-proguard-example/README.md @@ -6,7 +6,8 @@ or remove them if they appear to be unused. This can cause issues for Gson which access the fields of a class. It is necessary to configure ProGuard to make sure that Gson works correctly. Also have a look at the [ProGuard manual](https://www.guardsquare.com/manual/configuration/usage#keepoverview) -for more details on how ProGuard can be configured. +and the [ProGuard Gson examples](https://www.guardsquare.com/manual/configuration/examples#gson) for more +details on how ProGuard can be configured. The R8 code shrinker uses the same rule format as ProGuard, but there are differences between these two tools. Have a look at R8's Compatibility FAQ, and especially at the [Gson section](https://r8.googlesource.com/r8/+/refs/heads/main/compatibility-faq.md#gson). diff --git a/extras/pom.xml b/extras/pom.xml index 8600547700..d958bea74b 100644 --- a/extras/pom.xml +++ b/extras/pom.xml @@ -50,6 +50,7 @@ jsr250-api 1.0 + junit junit diff --git a/gson/pom.xml b/gson/pom.xml index abbe573dfd..21fc3e8ffe 100644 --- a/gson/pom.xml +++ b/gson/pom.xml @@ -57,7 +57,6 @@ com.google.truth truth - 1.1.3 test diff --git a/gson/src/main/java/com/google/gson/internal/ConstructorConstructor.java b/gson/src/main/java/com/google/gson/internal/ConstructorConstructor.java index a08d9df494..6b897d3c5b 100644 --- a/gson/src/main/java/com/google/gson/internal/ConstructorConstructor.java +++ b/gson/src/main/java/com/google/gson/internal/ConstructorConstructor.java @@ -70,12 +70,20 @@ public ConstructorConstructor(Map> instanceCreators, bo static String checkInstantiable(Class c) { int modifiers = c.getModifiers(); if (Modifier.isInterface(modifiers)) { - return "Interfaces can't be instantiated! Register an InstanceCreator " - + "or a TypeAdapter for this type. Interface name: " + c.getName(); + return "Interfaces can't be instantiated! Register an InstanceCreator" + + " or a TypeAdapter for this type. Interface name: " + c.getName(); } if (Modifier.isAbstract(modifiers)) { - return "Abstract classes can't be instantiated! Register an InstanceCreator " - + "or a TypeAdapter for this type. Class name: " + c.getName(); + // 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(); } return null; } diff --git a/gson/src/main/java/com/google/gson/internal/bind/JsonAdapterAnnotationTypeAdapterFactory.java b/gson/src/main/java/com/google/gson/internal/bind/JsonAdapterAnnotationTypeAdapterFactory.java index 643c51909d..9cd5649e9f 100644 --- a/gson/src/main/java/com/google/gson/internal/bind/JsonAdapterAnnotationTypeAdapterFactory.java +++ b/gson/src/main/java/com/google/gson/internal/bind/JsonAdapterAnnotationTypeAdapterFactory.java @@ -51,6 +51,8 @@ public TypeAdapter create(Gson gson, TypeToken targetType) { TypeAdapter getTypeAdapter(ConstructorConstructor constructorConstructor, Gson gson, TypeToken type, JsonAdapter annotation) { + // TODO: The exception messages created by ConstructorConstructor are currently written in the context of + // deserialization and for example suggest usage of TypeAdapter, which would not work for @JsonAdapter usage Object instance = constructorConstructor.get(TypeToken.get(annotation.value())).construct(); TypeAdapter typeAdapter; diff --git a/pom.xml b/pom.xml index 26e8022abe..55b17e8068 100644 --- a/pom.xml +++ b/pom.xml @@ -29,6 +29,7 @@ gson + shrinker-test extras metrics proto @@ -83,7 +84,12 @@ junit junit 4.13.2 - test + + + + com.google.truth + truth + 1.1.3 diff --git a/proto/pom.xml b/proto/pom.xml index 9225ff2239..313bb10613 100644 --- a/proto/pom.xml +++ b/proto/pom.xml @@ -62,7 +62,6 @@ com.google.truth truth - 1.1.3 test diff --git a/shrinker-test/README.md b/shrinker-test/README.md new file mode 100644 index 0000000000..563cd93b24 --- /dev/null +++ b/shrinker-test/README.md @@ -0,0 +1,7 @@ +# shrinker-test + +This Maven module contains integration tests which check the behavior of Gson when used in combination with code shrinking and obfuscation tools, such as ProGuard or R8. + +The code which is shrunken is under `src/main/java`; it should not contain any important assertions in case the code shrinking tools affect these assertions in any way. The test code under `src/test/java` executes the shrunken and obfuscated JAR and verifies that it behaves as expected. + +**Important:** Because execution of the code shrinking tools is performed during the Maven build, trying to directly run the integration tests from the IDE might not work, or might use stale results if you changed the configuration in between. Run `mvn clean verify` before trying to run the integration tests from the IDE. diff --git a/shrinker-test/pom.xml b/shrinker-test/pom.xml new file mode 100644 index 0000000000..0beed617a8 --- /dev/null +++ b/shrinker-test/pom.xml @@ -0,0 +1,204 @@ + + + + 4.0.0 + + + com.google.code.gson + gson-parent + 2.10.2-SNAPSHOT + + + shrinker-test + + + 8 + + + + + + google + https://maven.google.com + + + + + + com.google.code.gson + gson + ${project.parent.version} + + + + junit + junit + test + + + com.google.truth + truth + test + + + + + + + + org.apache.maven.plugins + maven-deploy-plugin + + + true + + + + + + + + + com.github.wvengen + proguard-maven-plugin + 2.6.0 + + + package + + proguard + + + + + true + ${basedir}/proguard.pro + + ${java.home}/jmods/java.base.jmod + + ${java.home}/jmods/java.sql.jmod + + ${java.home}/jmods/java.compiler.jmod + + + true + proguard-output.jar + + + + + + + org.apache.maven.plugins + maven-shade-plugin + 3.4.1 + + + package + + shade + + + false + + false + + + *:* + + + META-INF/MANIFEST.MF + + + + + + + + + + + org.codehaus.mojo + exec-maven-plugin + 3.1.0 + + + r8 + package + + java + + + + false + false + + true + + + com.android.tools + r8 + + + + com.android.tools.r8.R8 + + --release + + --classfile + --lib${java.home} + --pg-conf${basedir}/r8.pro + + --pg-map-output${project.build.directory}/r8_map.txt + --output${project.build.directory}/r8-output.jar + ${project.build.directory}/${project.build.finalName}.jar + + + + + + + + com.android.tools + r8 + 8.0.40 + + + + + + + + org.apache.maven.plugins + maven-failsafe-plugin + 3.1.0 + + + + integration-test + verify + + + + + + + diff --git a/shrinker-test/proguard.pro b/shrinker-test/proguard.pro new file mode 100644 index 0000000000..d815d39ae4 --- /dev/null +++ b/shrinker-test/proguard.pro @@ -0,0 +1,64 @@ +### 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.DefaultConstructorMain { + public static java.lang.String runTest(); +} + + +### Test data setup + +# Keep fields without annotations which should be preserved +-keepclassmembers class com.example.ClassWithNamedFields { + !transient ; +} + + +### TODO: Move these to `META-INF/proguard` +# Keep generic signatures; needed for correct type resolution +-keepattributes Signature + +# Keep Gson annotations +# Note: Cannot perform finer selection here to only cover Gson annotations, see also https://stackoverflow.com/q/47515093 +-keepattributes *Annotation* + + +### 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/master/compatibility-faq.md#r8-full-mode + +# Keep class TypeToken (respectively its generic signature) +-keep class com.google.gson.reflect.TypeToken { *; } + +# Keep any (anonymous) classes extending TypeToken +-keep class * extends com.google.gson.reflect.TypeToken + +# Keep classes with @JsonAdapter annotation +-keep @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 ; +} + +# Keep fields with any other Gson annotation +-keepclassmembers class * { + @com.google.gson.annotations.Expose ; + @com.google.gson.annotations.JsonAdapter ; + @com.google.gson.annotations.Since ; + @com.google.gson.annotations.Until ; +} diff --git a/shrinker-test/r8.pro b/shrinker-test/r8.pro new file mode 100644 index 0000000000..7813edab24 --- /dev/null +++ b/shrinker-test/r8.pro @@ -0,0 +1,26 @@ +# Extend the ProGuard rules +-include proguard.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/master/compatibility-faq.md#r8-full-mode + +# Keep the no-args constructor of classes referenced by @JsonAdapter +-keep class com.example.ClassWithAdapter$Adapter { + (); +} +-keep class com.example.ClassWithAnnotations$DummyAdapter { + (); +} + +# Keep the no-args constructor of deserialized class +-keep class com.example.ClassWithDefaultConstructor { + (); +} + +# Don't obfuscate class name, to check it in exception message +-keep,allowshrinking,allowoptimization class com.example.DefaultConstructorMain$TestClass + +# Keep enum constants which are not explicitly used in code +-keep class com.example.EnumClass { + ** SECOND; +} diff --git a/shrinker-test/src/main/java/com/example/ClassWithAdapter.java b/shrinker-test/src/main/java/com/example/ClassWithAdapter.java new file mode 100644 index 0000000000..aa7f08da6b --- /dev/null +++ b/shrinker-test/src/main/java/com/example/ClassWithAdapter.java @@ -0,0 +1,44 @@ +package com.example; + +import com.google.gson.TypeAdapter; +import com.google.gson.annotations.JsonAdapter; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonWriter; +import java.io.IOException; + +@JsonAdapter(ClassWithAdapter.Adapter.class) +public class ClassWithAdapter { + static class Adapter extends TypeAdapter { + @Override + public ClassWithAdapter read(JsonReader in) throws IOException { + in.beginObject(); + String name = in.nextName(); + if (!name.equals("custom")) { + throw new IllegalArgumentException("Unexpected name: " + name); + } + int i = in.nextInt(); + in.endObject(); + + return new ClassWithAdapter(i); + } + + @Override + public void write(JsonWriter out, ClassWithAdapter value) throws IOException { + out.beginObject(); + out.name("custom"); + out.value(value.i); + out.endObject(); + } + } + + public Integer i; + + public ClassWithAdapter(int i) { + this.i = i; + } + + @Override + public String toString() { + return "ClassWithAdapter[" + i + "]"; + } +} diff --git a/shrinker-test/src/main/java/com/example/ClassWithAnnotations.java b/shrinker-test/src/main/java/com/example/ClassWithAnnotations.java new file mode 100644 index 0000000000..14e8bef6c8 --- /dev/null +++ b/shrinker-test/src/main/java/com/example/ClassWithAnnotations.java @@ -0,0 +1,44 @@ +package com.example; + +import com.google.gson.TypeAdapter; +import com.google.gson.annotations.JsonAdapter; +import com.google.gson.annotations.Since; +import com.google.gson.annotations.Until; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonWriter; +import java.io.IOException; + +/** + * Uses various Gson annotations. + * + *

{@code @Expose} is covered by {@link ClassWithExposeAnnotation}, + * {@code @SerializedName} is covered by {@link ClassWithSerializedName}. + */ +public class ClassWithAnnotations { + @JsonAdapter(ClassWithAnnotations.DummyAdapter.class) + int i1; + + @Since(1) + int i2; + + @Until(1) // will be ignored with GsonBuilder.setVersion(1) + int i3; + + @Since(2) // will be ignored with GsonBuilder.setVersion(1) + int i4; + + @Until(2) + int i5; + + static class DummyAdapter extends TypeAdapter { + @Override + public Object read(JsonReader in) throws IOException { + throw new UnsupportedOperationException(); + } + + @Override + public void write(JsonWriter out, Object value) throws IOException { + out.value("custom"); + } + } +} diff --git a/shrinker-test/src/main/java/com/example/ClassWithDefaultConstructor.java b/shrinker-test/src/main/java/com/example/ClassWithDefaultConstructor.java new file mode 100644 index 0000000000..6296237f66 --- /dev/null +++ b/shrinker-test/src/main/java/com/example/ClassWithDefaultConstructor.java @@ -0,0 +1,12 @@ +package com.example; + +import com.google.gson.annotations.SerializedName; + +public class ClassWithDefaultConstructor { + @SerializedName("myField") + public int i; + + public ClassWithDefaultConstructor() { + i = -3; + } +} diff --git a/shrinker-test/src/main/java/com/example/ClassWithExposeAnnotation.java b/shrinker-test/src/main/java/com/example/ClassWithExposeAnnotation.java new file mode 100644 index 0000000000..00b2934da0 --- /dev/null +++ b/shrinker-test/src/main/java/com/example/ClassWithExposeAnnotation.java @@ -0,0 +1,10 @@ +package com.example; + +import com.google.gson.annotations.Expose; + +public class ClassWithExposeAnnotation { + @Expose + int i; + + int i2; +} diff --git a/shrinker-test/src/main/java/com/example/ClassWithNamedFields.java b/shrinker-test/src/main/java/com/example/ClassWithNamedFields.java new file mode 100644 index 0000000000..0a68da9c25 --- /dev/null +++ b/shrinker-test/src/main/java/com/example/ClassWithNamedFields.java @@ -0,0 +1,10 @@ +package com.example; + +public class ClassWithNamedFields { + public int myField; + public short notAccessedField = -1; + + public ClassWithNamedFields(int i) { + myField = i; + } +} diff --git a/shrinker-test/src/main/java/com/example/ClassWithSerializedName.java b/shrinker-test/src/main/java/com/example/ClassWithSerializedName.java new file mode 100644 index 0000000000..ce982215ca --- /dev/null +++ b/shrinker-test/src/main/java/com/example/ClassWithSerializedName.java @@ -0,0 +1,15 @@ +package com.example; + +import com.google.gson.annotations.SerializedName; + +public class ClassWithSerializedName { + @SerializedName("myField") + public int i; + + @SerializedName("notAccessed") + public short notAccessedField = -1; + + public ClassWithSerializedName(int i) { + this.i = i; + } +} diff --git a/shrinker-test/src/main/java/com/example/DefaultConstructorMain.java b/shrinker-test/src/main/java/com/example/DefaultConstructorMain.java new file mode 100644 index 0000000000..73dc608ac6 --- /dev/null +++ b/shrinker-test/src/main/java/com/example/DefaultConstructorMain.java @@ -0,0 +1,21 @@ +package com.example; + +import static com.example.TestExecutor.same; + +import com.google.gson.Gson; +import com.google.gson.annotations.SerializedName; + +public class DefaultConstructorMain { + static class TestClass { + @SerializedName("s") + public String s; + } + + /** + * Main entrypoint, called by {@code ShrinkingIT.testDefaultConstructor()}. + */ + public static String runTest() { + TestClass deserialized = new Gson().fromJson("{\"s\":\"value\"}", same(TestClass.class)); + return deserialized.s; + } +} diff --git a/shrinker-test/src/main/java/com/example/EnumClass.java b/shrinker-test/src/main/java/com/example/EnumClass.java new file mode 100644 index 0000000000..36688887bb --- /dev/null +++ b/shrinker-test/src/main/java/com/example/EnumClass.java @@ -0,0 +1,6 @@ +package com.example; + +public enum EnumClass { + FIRST, + SECOND +} diff --git a/shrinker-test/src/main/java/com/example/EnumClassWithSerializedName.java b/shrinker-test/src/main/java/com/example/EnumClassWithSerializedName.java new file mode 100644 index 0000000000..a127a8be13 --- /dev/null +++ b/shrinker-test/src/main/java/com/example/EnumClassWithSerializedName.java @@ -0,0 +1,10 @@ +package com.example; + +import com.google.gson.annotations.SerializedName; + +public enum EnumClassWithSerializedName { + @SerializedName("one") + FIRST, + @SerializedName("two") + SECOND +} diff --git a/shrinker-test/src/main/java/com/example/Main.java b/shrinker-test/src/main/java/com/example/Main.java new file mode 100644 index 0000000000..f580029904 --- /dev/null +++ b/shrinker-test/src/main/java/com/example/Main.java @@ -0,0 +1,120 @@ +package com.example; + +import static com.example.TestExecutor.same; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.reflect.TypeToken; +import java.util.Arrays; +import java.util.List; +import java.util.function.BiConsumer; +import java.util.function.Supplier; + +public class Main { + /** + * Main entrypoint, called by {@code ShrinkingIT.test()}. + * + *

To be safe let all tests put their output to the consumer and let integration test verify it; + * don't perform any relevant assertions in this code because code shrinkers could affect it. + * + * @param outputConsumer consumes the test output: {@code name, content} pairs + */ + public static void runTests(BiConsumer outputConsumer) { + // Create the TypeToken instances on demand because creation of them can fail when + // generic signatures were erased + testTypeTokenWriteRead(outputConsumer, "anonymous", () -> new TypeToken>() {}); + testTypeTokenWriteRead(outputConsumer, "manual", () -> TypeToken.getParameterized(List.class, ClassWithAdapter.class)); + + testNamedFields(outputConsumer); + testSerializedName(outputConsumer); + + testNoJdkUnsafe(outputConsumer); + + testEnum(outputConsumer); + testEnumSerializedName(outputConsumer); + + testExposeAnnotation(outputConsumer); + testAnnotations(outputConsumer); + } + + private static void testTypeTokenWriteRead(BiConsumer outputConsumer, String description, Supplier> typeTokenSupplier) { + Gson gson = new GsonBuilder().setPrettyPrinting().create(); + + TestExecutor.run(outputConsumer, "Write: TypeToken " + description, + () -> gson.toJson(Arrays.asList(new ClassWithAdapter(1)), typeTokenSupplier.get().getType())); + TestExecutor.run(outputConsumer, "Read: TypeToken " + description, () -> { + Object deserialized = gson.fromJson("[{\"custom\": 3}]", typeTokenSupplier.get()); + return deserialized.toString(); + }); + } + + /** + * Calls {@link Gson#toJson}, but (hopefully) in a way which prevents code shrinkers + * from understanding that reflection is used for {@code obj}. + */ + private static String toJson(Gson gson, Object obj) { + return gson.toJson(same(obj)); + } + + /** + * Calls {@link Gson#fromJson}, but (hopefully) in a way which prevents code shrinkers + * from understanding that reflection is used for {@code c}. + */ + private static T fromJson(Gson gson, String json, Class c) { + return gson.fromJson(json, same(c)); + } + + private static void testNamedFields(BiConsumer outputConsumer) { + Gson gson = new GsonBuilder().setPrettyPrinting().create(); + TestExecutor.run(outputConsumer, "Write: Named fields", () -> toJson(gson, new ClassWithNamedFields(2))); + TestExecutor.run(outputConsumer, "Read: Named fields", () -> { + ClassWithNamedFields deserialized = fromJson(gson, "{\"myField\": 3}", ClassWithNamedFields.class); + return Integer.toString(deserialized.myField); + }); + } + + private static void testSerializedName(BiConsumer outputConsumer) { + Gson gson = new GsonBuilder().setPrettyPrinting().create(); + TestExecutor.run(outputConsumer, "Write: SerializedName", () -> toJson(gson, new ClassWithSerializedName(2))); + TestExecutor.run(outputConsumer, "Read: SerializedName", () -> { + ClassWithSerializedName deserialized = fromJson(gson, "{\"myField\": 3}", ClassWithSerializedName.class); + return Integer.toString(deserialized.i); + }); + } + + private static void testNoJdkUnsafe(BiConsumer outputConsumer) { + Gson gson = new GsonBuilder().disableJdkUnsafe().create(); + TestExecutor.run(outputConsumer, "Read: No JDK Unsafe; initial constructor value", () -> { + ClassWithDefaultConstructor deserialized = fromJson(gson, "{}", ClassWithDefaultConstructor.class); + return Integer.toString(deserialized.i); + }); + TestExecutor.run(outputConsumer, "Read: No JDK Unsafe; custom value", () -> { + ClassWithDefaultConstructor deserialized = fromJson(gson, "{\"myField\": 3}", ClassWithDefaultConstructor.class); + return Integer.toString(deserialized.i); + }); + } + + private static void testEnum(BiConsumer outputConsumer) { + Gson gson = new GsonBuilder().setPrettyPrinting().create(); + TestExecutor.run(outputConsumer, "Write: Enum", () -> toJson(gson, EnumClass.FIRST)); + TestExecutor.run(outputConsumer, "Read: Enum", () -> fromJson(gson, "\"SECOND\"", EnumClass.class).toString()); + } + + private static void testEnumSerializedName(BiConsumer outputConsumer) { + Gson gson = new GsonBuilder().setPrettyPrinting().create(); + TestExecutor.run(outputConsumer, "Write: Enum SerializedName", + () -> toJson(gson, EnumClassWithSerializedName.FIRST)); + TestExecutor.run(outputConsumer, "Read: Enum SerializedName", + () -> fromJson(gson, "\"two\"", EnumClassWithSerializedName.class).toString()); + } + + private static void testExposeAnnotation(BiConsumer outputConsumer) { + Gson gson = new GsonBuilder().excludeFieldsWithoutExposeAnnotation().create(); + TestExecutor.run(outputConsumer, "Write: @Expose", () -> toJson(gson, new ClassWithExposeAnnotation())); + } + + private static void testAnnotations(BiConsumer outputConsumer) { + Gson gson = new GsonBuilder().setVersion(1).create(); + TestExecutor.run(outputConsumer, "Write: Annotations", () -> toJson(gson, new ClassWithAnnotations())); + } +} diff --git a/shrinker-test/src/main/java/com/example/TestExecutor.java b/shrinker-test/src/main/java/com/example/TestExecutor.java new file mode 100644 index 0000000000..6ea9b9b927 --- /dev/null +++ b/shrinker-test/src/main/java/com/example/TestExecutor.java @@ -0,0 +1,34 @@ +package com.example; + +import java.util.Optional; +import java.util.function.BiConsumer; +import java.util.function.Supplier; + +public class TestExecutor { + /** + * Helper method for running individual tests. In case of an exception wraps it and + * includes the {@code name} of the test to make debugging issues with the obfuscated + * JARs a bit easier. + */ + public static void run(BiConsumer outputConsumer, String name, Supplier resultSupplier) { + String result; + try { + result = resultSupplier.get(); + } catch (Throwable t) { + throw new RuntimeException("Test failed: " + name, t); + } + outputConsumer.accept(name, result); + } + + /** + * Returns {@code t}, but in a way which (hopefully) prevents code shrinkers from + * simplifying this. + */ + public static T same(T t) { + // This is essentially `return t`, but contains some redundant code to try + // prevent the code shrinkers from simplifying this + return Optional.of(t) + .map(v -> Optional.of(v).get()) + .orElseThrow(() -> new AssertionError("unreachable")); + } +} diff --git a/shrinker-test/src/test/java/com/google/gson/it/ShrinkingIT.java b/shrinker-test/src/test/java/com/google/gson/it/ShrinkingIT.java new file mode 100644 index 0000000000..814882fef6 --- /dev/null +++ b/shrinker-test/src/test/java/com/google/gson/it/ShrinkingIT.java @@ -0,0 +1,177 @@ +/* + * Copyright (C) 2023 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.gson.it; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.fail; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Arrays; +import java.util.List; +import java.util.function.BiConsumer; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameter; +import org.junit.runners.Parameterized.Parameters; + +/** + * Integration test verifying behavior of shrunken and obfuscated JARs. + */ +@RunWith(Parameterized.class) +public class ShrinkingIT { + // These JAR files are prepared by the Maven build + public static final Path PROGUARD_RESULT_PATH = Paths.get("target/proguard-output.jar"); + public static final Path R8_RESULT_PATH = Paths.get("target/r8-output.jar"); + + @Parameters(name = "{index}: {0}") + public static List jarsToTest() { + return Arrays.asList(PROGUARD_RESULT_PATH, R8_RESULT_PATH); + } + + @Parameter + public Path jarToTest; + + @Before + public void verifyJarExists() { + if (!Files.isRegularFile(jarToTest)) { + fail("JAR file " + jarToTest + " does not exist; run this test with `mvn clean verify`"); + } + } + + @FunctionalInterface + interface TestAction { + void run(Class c) throws Exception; + } + + private void runTest(String className, TestAction testAction) throws Exception { + // Use bootstrap class loader; load all custom classes from JAR and not + // from dependencies of this test + ClassLoader classLoader = null; + + // Load the shrunken and obfuscated JARs with a separate class loader, then load + // the main test class from it and let the test action invoke its test methods + try (URLClassLoader loader = new URLClassLoader(new URL[] {jarToTest.toUri().toURL()}, classLoader)) { + Class c = loader.loadClass(className); + testAction.run(c); + } + } + + @Test + public void test() throws Exception { + StringBuilder output = new StringBuilder(); + + runTest("com.example.Main", c -> { + Method m = c.getMethod("runTests", BiConsumer.class); + m.invoke(null, (BiConsumer) (name, content) -> output.append(name + "\n" + content + "\n===\n")); + }); + + assertThat(output.toString()).isEqualTo(String.join("\n", + "Write: TypeToken anonymous", + "[", + " {", + " \"custom\": 1", + " }", + "]", + "===", + "Read: TypeToken anonymous", + "[ClassWithAdapter[3]]", + "===", + "Write: TypeToken manual", + "[", + " {", + " \"custom\": 1", + " }", + "]", + "===", + "Read: TypeToken manual", + "[ClassWithAdapter[3]]", + "===", + "Write: Named fields", + "{", + " \"myField\": 2,", + " \"notAccessedField\": -1", + "}", + "===", + "Read: Named fields", + "3", + "===", + "Write: SerializedName", + "{", + " \"myField\": 2,", + " \"notAccessed\": -1", + "}", + "===", + "Read: SerializedName", + "3", + "===", + "Read: No JDK Unsafe; initial constructor value", + "-3", + "===", + "Read: No JDK Unsafe; custom value", + "3", + "===", + "Write: Enum", + "\"FIRST\"", + "===", + "Read: Enum", + "SECOND", + "===", + "Write: Enum SerializedName", + "\"one\"", + "===", + "Read: Enum SerializedName", + "SECOND", + "===", + "Write: @Expose", + "{\"i\":0}", + "===", + "Write: Annotations", + "{\"i1\":\"custom\",\"i2\":0,\"i5\":0}", + "===", + "" + )); + } + + @Test + public void testDefaultConstructor() throws Exception { + runTest("com.example.DefaultConstructorMain", c -> { + Method m = c.getMethod("runTest"); + + if (jarToTest.equals(PROGUARD_RESULT_PATH)) { + Object result = m.invoke(null); + assertThat(result).isEqualTo("value"); + } else { + // R8 performs more aggressive optimizations + 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" + + "\nSee https://github.com/google/gson/blob/master/Troubleshooting.md#r8-abstract-class" + ); + } + }); + } +} From 2ff2a66a04ec98137607735e7253fd3f8410da78 Mon Sep 17 00:00:00 2001 From: Marcono1234 Date: Sat, 27 May 2023 01:15:47 +0200 Subject: [PATCH 2/7] Keep no-args constructor of classes usable with JsonAdapter --- shrinker-test/proguard.pro | 15 +++++++++++++++ shrinker-test/r8.pro | 8 -------- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/shrinker-test/proguard.pro b/shrinker-test/proguard.pro index d815d39ae4..0821680923 100644 --- a/shrinker-test/proguard.pro +++ b/shrinker-test/proguard.pro @@ -62,3 +62,18 @@ @com.google.gson.annotations.Since ; @com.google.gson.annotations.Until ; } + +# 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 { + (); +} +-keep class * implements com.google.gson.TypeAdapterFactory { + (); +} +-keep class * implements com.google.gson.JsonSerializer { + (); +} +-keep class * implements com.google.gson.JsonDeserializer { + (); +} diff --git a/shrinker-test/r8.pro b/shrinker-test/r8.pro index 7813edab24..3ea8f972f9 100644 --- a/shrinker-test/r8.pro +++ b/shrinker-test/r8.pro @@ -4,14 +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/master/compatibility-faq.md#r8-full-mode -# Keep the no-args constructor of classes referenced by @JsonAdapter --keep class com.example.ClassWithAdapter$Adapter { - (); -} --keep class com.example.ClassWithAnnotations$DummyAdapter { - (); -} - # Keep the no-args constructor of deserialized class -keep class com.example.ClassWithDefaultConstructor { (); From 1fc9a84293cb634a732952166ad30f1d8fb55c47 Mon Sep 17 00:00:00 2001 From: Marcono1234 Date: Sat, 27 May 2023 02:20:04 +0200 Subject: [PATCH 3/7] Add library ProGuard rules for Gson They are automatically applied for all users of Gson, see https://developer.android.com/build/shrink-code#configuration-files --- examples/android-proguard-example/README.md | 4 ++ gson/pom.xml | 4 +- .../main/resources/META-INF/proguard/gson.pro | 58 +++++++++++++++++++ shrinker-test/pom.xml | 10 +++- shrinker-test/proguard.pro | 51 ---------------- 5 files changed, 72 insertions(+), 55 deletions(-) create mode 100644 gson/src/main/resources/META-INF/proguard/gson.pro diff --git a/examples/android-proguard-example/README.md b/examples/android-proguard-example/README.md index 153e6e48b0..942477d71a 100644 --- a/examples/android-proguard-example/README.md +++ b/examples/android-proguard-example/README.md @@ -11,3 +11,7 @@ details on how ProGuard can be configured. The R8 code shrinker uses the same rule format as ProGuard, but there are differences between these two tools. Have a look at R8's Compatibility FAQ, and especially at the [Gson section](https://r8.googlesource.com/r8/+/refs/heads/main/compatibility-faq.md#gson). + +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. diff --git a/gson/pom.xml b/gson/pom.xml index 21fc3e8ffe..6e911c74de 100644 --- a/gson/pom.xml +++ b/gson/pom.xml @@ -83,7 +83,7 @@ filter-sources - ${basedir}/src/main/java-templates + ${project.basedir}/src/main/java-templates ${project.build.directory}/generated-sources/java-templates @@ -191,7 +191,7 @@ test-classes-obfuscated-injar test-classes-obfuscated-outjar **/*.class - ${basedir}/src/test/resources/testcases-proguard.conf + ${project.basedir}/src/test/resources/testcases-proguard.conf ${project.build.directory}/classes ${java.home}/jmods/java.base.jmod diff --git a/gson/src/main/resources/META-INF/proguard/gson.pro b/gson/src/main/resources/META-INF/proguard/gson.pro new file mode 100644 index 0000000000..31609337f2 --- /dev/null +++ b/gson/src/main/resources/META-INF/proguard/gson.pro @@ -0,0 +1,58 @@ +### Gson ProGuard and R8 rules which are relevant for all users +### This file is automatically recognized by ProGuard and R8, see https://developer.android.com/build/shrink-code#configuration-files +### +### IMPORTANT: +### - These rules are additive; don't include anything here which is not specific to Gson (such as completely +### disabling obfuscation for all classes); the user would be unable to disable that then +### - These rules are not complete; users will most likely have to add additional rules for their specific +### classes, for example to disable obfuscation for certain fields or to keep no-args constructors +### + +# Keep generic signatures; needed for correct type resolution +-keepattributes Signature + +# Keep Gson annotations +# Note: Cannot perform finer selection here to only cover Gson annotations, see also https://stackoverflow.com/q/47515093 +-keepattributes *Annotation* + + +### 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/master/compatibility-faq.md#r8-full-mode + +# Keep class TypeToken (respectively its generic signature) +-keep class com.google.gson.reflect.TypeToken { *; } + +# Keep any (anonymous) classes extending TypeToken +-keep class * extends com.google.gson.reflect.TypeToken + +# Keep classes with @JsonAdapter annotation +-keep @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 ; +} + +# Keep fields with any other Gson annotation +-keepclassmembers class * { + @com.google.gson.annotations.Expose ; + @com.google.gson.annotations.JsonAdapter ; + @com.google.gson.annotations.Since ; + @com.google.gson.annotations.Until ; +} + +# 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 { + (); +} +-keep class * implements com.google.gson.TypeAdapterFactory { + (); +} +-keep class * implements com.google.gson.JsonSerializer { + (); +} +-keep class * implements com.google.gson.JsonDeserializer { + (); +} diff --git a/shrinker-test/pom.xml b/shrinker-test/pom.xml index 0beed617a8..bbd7fe11aa 100644 --- a/shrinker-test/pom.xml +++ b/shrinker-test/pom.xml @@ -88,7 +88,13 @@ true - ${basedir}/proguard.pro + ${project.basedir}/proguard.pro + + + + ${java.home}/jmods/java.base.jmod @@ -165,7 +171,7 @@ --classfile --lib${java.home} - --pg-conf${basedir}/r8.pro + --pg-conf${project.basedir}/r8.pro --pg-map-output${project.build.directory}/r8_map.txt --output${project.build.directory}/r8-output.jar diff --git a/shrinker-test/proguard.pro b/shrinker-test/proguard.pro index 0821680923..02a6ef5efb 100644 --- a/shrinker-test/proguard.pro +++ b/shrinker-test/proguard.pro @@ -26,54 +26,3 @@ -keepclassmembers class com.example.ClassWithNamedFields { !transient ; } - - -### TODO: Move these to `META-INF/proguard` -# Keep generic signatures; needed for correct type resolution --keepattributes Signature - -# Keep Gson annotations -# Note: Cannot perform finer selection here to only cover Gson annotations, see also https://stackoverflow.com/q/47515093 --keepattributes *Annotation* - - -### 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/master/compatibility-faq.md#r8-full-mode - -# Keep class TypeToken (respectively its generic signature) --keep class com.google.gson.reflect.TypeToken { *; } - -# Keep any (anonymous) classes extending TypeToken --keep class * extends com.google.gson.reflect.TypeToken - -# Keep classes with @JsonAdapter annotation --keep @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 ; -} - -# Keep fields with any other Gson annotation --keepclassmembers class * { - @com.google.gson.annotations.Expose ; - @com.google.gson.annotations.JsonAdapter ; - @com.google.gson.annotations.Since ; - @com.google.gson.annotations.Until ; -} - -# 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 { - (); -} --keep class * implements com.google.gson.TypeAdapterFactory { - (); -} --keep class * implements com.google.gson.JsonSerializer { - (); -} --keep class * implements com.google.gson.JsonDeserializer { - (); -} From 5c72714d5655bbe42b768f670447268bb8320a95 Mon Sep 17 00:00:00 2001 From: Marcono1234 Date: Sat, 27 May 2023 02:59:50 +0200 Subject: [PATCH 4/7] Skip japicmp-maven-plugin for shrinker-test --- metrics/pom.xml | 1 - shrinker-test/pom.xml | 8 ++++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/metrics/pom.xml b/metrics/pom.xml index baf6a2f879..71dbccc375 100644 --- a/metrics/pom.xml +++ b/metrics/pom.xml @@ -63,7 +63,6 @@ com.github.siom79.japicmp japicmp-maven-plugin - 0.17.2 true diff --git a/shrinker-test/pom.xml b/shrinker-test/pom.xml index bbd7fe11aa..bd6f789ac6 100644 --- a/shrinker-test/pom.xml +++ b/shrinker-test/pom.xml @@ -61,6 +61,14 @@ + + com.github.siom79.japicmp + japicmp-maven-plugin + + + true + + org.apache.maven.plugins maven-deploy-plugin From 99ee4ec9f03b16fef60396e10ee4a566c23da58e Mon Sep 17 00:00:00 2001 From: Marcono1234 Date: Sun, 28 May 2023 11:46:39 +0200 Subject: [PATCH 5/7] Add more tests for JsonAdapter, add tests for generic classes --- Troubleshooting.md | 2 +- shrinker-test/r8.pro | 18 ++- .../com/example/ClassWithAnnotations.java | 44 ------ .../example/ClassWithExposeAnnotation.java | 3 + .../ClassWithJsonAdapterAnnotation.java | 126 ++++++++++++++++++ .../example/ClassWithVersionAnnotations.java | 21 +++ .../main/java/com/example/GenericClasses.java | 66 +++++++++ .../src/main/java/com/example/Main.java | 28 +++- .../java/com/google/gson/it/ShrinkingIT.java | 29 +++- 9 files changed, 285 insertions(+), 52 deletions(-) delete mode 100644 shrinker-test/src/main/java/com/example/ClassWithAnnotations.java create mode 100644 shrinker-test/src/main/java/com/example/ClassWithJsonAdapterAnnotation.java create mode 100644 shrinker-test/src/main/java/com/example/ClassWithVersionAnnotations.java create mode 100644 shrinker-test/src/main/java/com/example/GenericClasses.java diff --git a/Troubleshooting.md b/Troubleshooting.md index eae7ae675b..67ad8036c2 100644 --- a/Troubleshooting.md +++ b/Troubleshooting.md @@ -292,7 +292,7 @@ This will not initialize arbitrary classes, and it will throw a `ClassCastExcept ``` # Keep the no-args constructor of the deserialized class --keep class com.example.MyClass { +-keepclassmembers class com.example.MyClass { (); } ``` diff --git a/shrinker-test/r8.pro b/shrinker-test/r8.pro index 3ea8f972f9..29868aa3bf 100644 --- a/shrinker-test/r8.pro +++ b/shrinker-test/r8.pro @@ -4,10 +4,24 @@ ### 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/master/compatibility-faq.md#r8-full-mode -# Keep the no-args constructor of deserialized class --keep class com.example.ClassWithDefaultConstructor { +# Keep the no-args constructor of deserialized classes +-keepclassmembers class com.example.ClassWithDefaultConstructor { (); } +-keepclassmembers class com.example.GenericClasses$GenericClass { + (); +} +-keepclassmembers class com.example.GenericClasses$UsingGenericClass { + (); +} +-keepclassmembers class com.example.GenericClasses$GenericUsingGenericClass { + (); +} + +# 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 +-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 diff --git a/shrinker-test/src/main/java/com/example/ClassWithAnnotations.java b/shrinker-test/src/main/java/com/example/ClassWithAnnotations.java deleted file mode 100644 index 14e8bef6c8..0000000000 --- a/shrinker-test/src/main/java/com/example/ClassWithAnnotations.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.example; - -import com.google.gson.TypeAdapter; -import com.google.gson.annotations.JsonAdapter; -import com.google.gson.annotations.Since; -import com.google.gson.annotations.Until; -import com.google.gson.stream.JsonReader; -import com.google.gson.stream.JsonWriter; -import java.io.IOException; - -/** - * Uses various Gson annotations. - * - *

{@code @Expose} is covered by {@link ClassWithExposeAnnotation}, - * {@code @SerializedName} is covered by {@link ClassWithSerializedName}. - */ -public class ClassWithAnnotations { - @JsonAdapter(ClassWithAnnotations.DummyAdapter.class) - int i1; - - @Since(1) - int i2; - - @Until(1) // will be ignored with GsonBuilder.setVersion(1) - int i3; - - @Since(2) // will be ignored with GsonBuilder.setVersion(1) - int i4; - - @Until(2) - int i5; - - static class DummyAdapter extends TypeAdapter { - @Override - public Object read(JsonReader in) throws IOException { - throw new UnsupportedOperationException(); - } - - @Override - public void write(JsonWriter out, Object value) throws IOException { - out.value("custom"); - } - } -} diff --git a/shrinker-test/src/main/java/com/example/ClassWithExposeAnnotation.java b/shrinker-test/src/main/java/com/example/ClassWithExposeAnnotation.java index 00b2934da0..30a61fa921 100644 --- a/shrinker-test/src/main/java/com/example/ClassWithExposeAnnotation.java +++ b/shrinker-test/src/main/java/com/example/ClassWithExposeAnnotation.java @@ -2,6 +2,9 @@ import com.google.gson.annotations.Expose; +/** + * Uses {@link Expose} annotation. + */ public class ClassWithExposeAnnotation { @Expose int i; diff --git a/shrinker-test/src/main/java/com/example/ClassWithJsonAdapterAnnotation.java b/shrinker-test/src/main/java/com/example/ClassWithJsonAdapterAnnotation.java new file mode 100644 index 0000000000..238ee1818e --- /dev/null +++ b/shrinker-test/src/main/java/com/example/ClassWithJsonAdapterAnnotation.java @@ -0,0 +1,126 @@ +package com.example; + +import com.google.gson.Gson; +import com.google.gson.JsonDeserializationContext; +import com.google.gson.JsonDeserializer; +import com.google.gson.JsonElement; +import com.google.gson.JsonParseException; +import com.google.gson.JsonPrimitive; +import com.google.gson.JsonSerializationContext; +import com.google.gson.JsonSerializer; +import com.google.gson.TypeAdapter; +import com.google.gson.TypeAdapterFactory; +import com.google.gson.annotations.JsonAdapter; +import com.google.gson.annotations.SerializedName; +import com.google.gson.reflect.TypeToken; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonWriter; +import java.io.IOException; +import java.lang.reflect.Type; + +/** + * Uses {@link JsonAdapter} annotation on fields. + */ +public class ClassWithJsonAdapterAnnotation { + // For this field don't use @SerializedName and ignore it for deserialization + @JsonAdapter(value = Adapter.class, nullSafe = false) + DummyClass f; + + @SerializedName("f1") + @JsonAdapter(Adapter.class) + DummyClass f1; + + @SerializedName("f2") + @JsonAdapter(Factory.class) + DummyClass f2; + + @SerializedName("f3") + @JsonAdapter(Serializer.class) + DummyClass f3; + + @SerializedName("f4") + @JsonAdapter(Deserializer.class) + DummyClass f4; + + public ClassWithJsonAdapterAnnotation() { + } + + // Note: R8 seems to make this constructor the no-args constructor and initialize fields + // by default; currently this is not visible in the deserialization test because the JSON data + // contains values for all fields; but it is noticeable once the JSON data is missing fields + public ClassWithJsonAdapterAnnotation(int i1, int i2, int i3, int i4) { + f1 = new DummyClass(Integer.toString(i1)); + f2 = new DummyClass(Integer.toString(i2)); + f3 = new DummyClass(Integer.toString(i3)); + f4 = new DummyClass(Integer.toString(i4)); + + // Note: Deliberately don't initialize field `f` here to not refer to it anywhere in code + } + + @Override + public String toString() { + return "ClassWithJsonAdapterAnnotation[f1=" + f1 + ", f2=" + f2 + ", f3=" + f3 + ", f4=" + f4 + "]"; + } + + static class Adapter extends TypeAdapter { + @Override + public DummyClass read(JsonReader in) throws IOException { + return new DummyClass("adapter-" + in.nextInt()); + } + + @Override + public void write(JsonWriter out, DummyClass value) throws IOException { + out.value("adapter-" + value); + } + } + + static class Factory implements TypeAdapterFactory { + @Override + public TypeAdapter create(Gson gson, TypeToken type) { + @SuppressWarnings("unchecked") // the code below is not type safe, but does not matter for this test + TypeAdapter r = (TypeAdapter) new TypeAdapter() { + @Override + public DummyClass read(JsonReader in) throws IOException { + return new DummyClass("factory-" + in.nextInt()); + } + + @Override + public void write(JsonWriter out, DummyClass value) throws IOException { + out.value("factory-" + value.s); + } + }; + + return r; + } + } + + static class Serializer implements JsonSerializer { + @Override + public JsonElement serialize(DummyClass src, Type typeOfSrc, JsonSerializationContext context) { + return new JsonPrimitive("serializer-" + src.s); + } + } + + static class Deserializer implements JsonDeserializer { + @Override + public DummyClass deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException { + return new DummyClass("deserializer-" + json.getAsInt()); + } + } + + // Use this separate class mainly to work around incorrect delegation behavior for JsonSerializer + // and JsonDeserializer used with @JsonAdapter, see https://github.com/google/gson/issues/1783 + static class DummyClass { + @SerializedName("s") + String s; + + DummyClass(String s) { + this.s = s; + } + + @Override + public String toString() { + return s; + } + } +} diff --git a/shrinker-test/src/main/java/com/example/ClassWithVersionAnnotations.java b/shrinker-test/src/main/java/com/example/ClassWithVersionAnnotations.java new file mode 100644 index 0000000000..9c554f7e9b --- /dev/null +++ b/shrinker-test/src/main/java/com/example/ClassWithVersionAnnotations.java @@ -0,0 +1,21 @@ +package com.example; + +import com.google.gson.annotations.Since; +import com.google.gson.annotations.Until; + +/** + * Uses {@link Since} and {@link Until} annotations. + */ +public class ClassWithVersionAnnotations { + @Since(1) + int i1; + + @Until(1) // will be ignored with GsonBuilder.setVersion(1) + int i2; + + @Since(2) // will be ignored with GsonBuilder.setVersion(1) + int i3; + + @Until(2) + int i4; +} diff --git a/shrinker-test/src/main/java/com/example/GenericClasses.java b/shrinker-test/src/main/java/com/example/GenericClasses.java new file mode 100644 index 0000000000..cd91149be4 --- /dev/null +++ b/shrinker-test/src/main/java/com/example/GenericClasses.java @@ -0,0 +1,66 @@ +package com.example; + +import com.google.gson.TypeAdapter; +import com.google.gson.annotations.JsonAdapter; +import com.google.gson.annotations.SerializedName; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonWriter; +import java.io.IOException; + +public class GenericClasses { + static class GenericClass { + @SerializedName("t") + T t; + + @Override + public String toString() { + return "{t=" + t + "}"; + } + } + + static class UsingGenericClass { + @SerializedName("g") + GenericClass g; + + @Override + public String toString() { + return "{g=" + g + "}"; + } + } + + static class GenericUsingGenericClass { + @SerializedName("g") + GenericClass g; + + @Override + public String toString() { + return "{g=" + g + "}"; + } + } + + @JsonAdapter(DummyClass.Adapter.class) + static class DummyClass { + String s; + + DummyClass(String s) { + this.s = s; + } + + @Override + public String toString() { + return s; + } + + static class Adapter extends TypeAdapter { + @Override + public DummyClass read(JsonReader in) throws IOException { + return new DummyClass("read-" + in.nextInt()); + } + + @Override + public void write(JsonWriter out, DummyClass value) throws IOException { + throw new UnsupportedOperationException(); + } + } + } +} diff --git a/shrinker-test/src/main/java/com/example/Main.java b/shrinker-test/src/main/java/com/example/Main.java index f580029904..55bbb6377d 100644 --- a/shrinker-test/src/main/java/com/example/Main.java +++ b/shrinker-test/src/main/java/com/example/Main.java @@ -2,6 +2,10 @@ import static com.example.TestExecutor.same; +import com.example.GenericClasses.DummyClass; +import com.example.GenericClasses.GenericClass; +import com.example.GenericClasses.GenericUsingGenericClass; +import com.example.GenericClasses.UsingGenericClass; import com.google.gson.Gson; import com.google.gson.GsonBuilder; import com.google.gson.reflect.TypeToken; @@ -34,7 +38,10 @@ public static void runTests(BiConsumer outputConsumer) { testEnumSerializedName(outputConsumer); testExposeAnnotation(outputConsumer); - testAnnotations(outputConsumer); + testVersionAnnotations(outputConsumer); + testJsonAdapterAnnotation(outputConsumer); + + testGenericClasses(outputConsumer); } private static void testTypeTokenWriteRead(BiConsumer outputConsumer, String description, Supplier> typeTokenSupplier) { @@ -113,8 +120,23 @@ private static void testExposeAnnotation(BiConsumer outputConsum TestExecutor.run(outputConsumer, "Write: @Expose", () -> toJson(gson, new ClassWithExposeAnnotation())); } - private static void testAnnotations(BiConsumer outputConsumer) { + private static void testVersionAnnotations(BiConsumer outputConsumer) { Gson gson = new GsonBuilder().setVersion(1).create(); - TestExecutor.run(outputConsumer, "Write: Annotations", () -> toJson(gson, new ClassWithAnnotations())); + TestExecutor.run(outputConsumer, "Write: Version annotations", () -> toJson(gson, new ClassWithVersionAnnotations())); + } + + private static void testJsonAdapterAnnotation(BiConsumer outputConsumer) { + Gson gson = new GsonBuilder().setPrettyPrinting().create(); + TestExecutor.run(outputConsumer, "Write: JsonAdapter on fields", () -> toJson(gson, new ClassWithJsonAdapterAnnotation(1, 2, 3, 4))); + + String json = "{\"f1\": 1, \"f2\": 2, \"f3\": {\"s\": \"3\"}, \"f4\": 4}"; + TestExecutor.run(outputConsumer, "Read: JsonAdapter on fields", () -> fromJson(gson, json, ClassWithJsonAdapterAnnotation.class).toString()); + } + + private static void testGenericClasses(BiConsumer outputConsumer) { + Gson gson = new Gson(); + TestExecutor.run(outputConsumer, "Read: Generic TypeToken", () -> gson.fromJson("{\"t\": 1}", new TypeToken>() {}).toString()); + TestExecutor.run(outputConsumer, "Read: Using Generic", () -> fromJson(gson, "{\"g\": {\"t\": 1}}", UsingGenericClass.class).toString()); + TestExecutor.run(outputConsumer, "Read: Using Generic TypeToken", () -> gson.fromJson("{\"g\": {\"t\": 1}}", new TypeToken>() {}).toString()); } } diff --git a/shrinker-test/src/test/java/com/google/gson/it/ShrinkingIT.java b/shrinker-test/src/test/java/com/google/gson/it/ShrinkingIT.java index 814882fef6..21d729b53d 100644 --- a/shrinker-test/src/test/java/com/google/gson/it/ShrinkingIT.java +++ b/shrinker-test/src/test/java/com/google/gson/it/ShrinkingIT.java @@ -148,8 +148,33 @@ public void test() throws Exception { "Write: @Expose", "{\"i\":0}", "===", - "Write: Annotations", - "{\"i1\":\"custom\",\"i2\":0,\"i5\":0}", + "Write: Version annotations", + "{\"i1\":0,\"i4\":0}", + "===", + "Write: JsonAdapter on fields", + "{", + " \"f\": \"adapter-null\",", + " \"f1\": \"adapter-1\",", + " \"f2\": \"factory-2\",", + " \"f3\": \"serializer-3\",", + // For f4 only a JsonDeserializer is registered, so serialization falls back to reflection + " \"f4\": {", + " \"s\": \"4\"", + " }", + "}", + "===", + "Read: JsonAdapter on fields", + // For f3 only a JsonSerializer is registered, so for deserialization value is read as is using reflection + "ClassWithJsonAdapterAnnotation[f1=adapter-1, f2=factory-2, f3=3, f4=deserializer-4]", + "===", + "Read: Generic TypeToken", + "{t=read-1}", + "===", + "Read: Using Generic", + "{g={t=read-1}}", + "===", + "Read: Using Generic TypeToken", + "{g={t=read-1}}", "===", "" )); From 745cf1faf33482cf805e62d71d6b52a109d07d57 Mon Sep 17 00:00:00 2001 From: Marcono1234 Date: Sun, 28 May 2023 12:04:41 +0200 Subject: [PATCH 6/7] Extend default constructor test --- .../gson/internal/ConstructorConstructor.java | 38 ++++++++++++------- shrinker-test/README.md | 2 + shrinker-test/proguard.pro | 1 + shrinker-test/r8.pro | 4 ++ .../com/example/DefaultConstructorMain.java | 16 ++++++++ .../java/com/google/gson/it/ShrinkingIT.java | 21 ++++++++++ 6 files changed, 68 insertions(+), 14 deletions(-) diff --git a/gson/src/main/java/com/google/gson/internal/ConstructorConstructor.java b/gson/src/main/java/com/google/gson/internal/ConstructorConstructor.java index 6b897d3c5b..7d2dc9b622 100644 --- a/gson/src/main/java/com/google/gson/internal/ConstructorConstructor.java +++ b/gson/src/main/java/com/google/gson/internal/ConstructorConstructor.java @@ -152,9 +152,9 @@ public ObjectConstructor get(TypeToken typeToken) { // finally try unsafe return newUnsafeAllocator(rawType); } else { - final String message = "Unable to create instance of " + rawType + "; ReflectionAccessFilter " - + "does not permit using reflection or Unsafe. Register an InstanceCreator or a TypeAdapter " - + "for this type or adjust the access filter to allow using reflection."; + final String message = "Unable to create instance of " + rawType + "; ReflectionAccessFilter" + + " does not permit using reflection or Unsafe. Register an InstanceCreator or a TypeAdapter" + + " for this type or adjust the access filter to allow using reflection."; return new ObjectConstructor() { @Override public T construct() { throw new JsonIOException(message); @@ -227,10 +227,10 @@ private static ObjectConstructor newDefaultConstructor(Class r && (filterResult != FilterResult.BLOCK_ALL || Modifier.isPublic(constructor.getModifiers()))); if (!canAccess) { - final String message = "Unable to invoke no-args constructor of " + rawType + "; " - + "constructor is not accessible and ReflectionAccessFilter does not permit making " - + "it accessible. Register an InstanceCreator or a TypeAdapter for this type, change " - + "the visibility of the constructor or adjust the access filter."; + final String message = "Unable to invoke no-args constructor of " + rawType + ";" + + " constructor is not accessible and ReflectionAccessFilter does not permit making" + + " it accessible. Register an InstanceCreator or a TypeAdapter for this type, change" + + " the visibility of the constructor or adjust the access filter."; return new ObjectConstructor() { @Override public T construct() { throw new JsonIOException(message); @@ -378,19 +378,29 @@ private ObjectConstructor newUnsafeAllocator(final Class rawTy T newInstance = (T) UnsafeAllocator.INSTANCE.newInstance(rawType); return newInstance; } catch (Exception e) { - throw new RuntimeException(("Unable to create instance of " + rawType + ". " - + "Registering an InstanceCreator or a TypeAdapter for this type, or adding a no-args " - + "constructor may fix this problem."), e); + throw new RuntimeException(("Unable to create instance of " + rawType + "." + + " Registering an InstanceCreator or a TypeAdapter for this type, or adding a no-args" + + " constructor may fix this problem."), e); } } }; } else { - final String exceptionMessage = "Unable to create instance of " + rawType + "; 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."; + String exceptionMessage = "Unable to create instance of " + rawType + "; 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."; + + // Check if R8 removed all constructors + if (rawType.getDeclaredConstructors().length == 0) { + // R8 with Unsafe disabled might not be common enough to warrant a separate Troubleshooting Guide entry + exceptionMessage += " Or adjust your R8 configuration to keep the no-args constructor of the class."; + } + + // Explicit final variable to allow usage in the anonymous class below + final String exceptionMessageF = exceptionMessage; + return new ObjectConstructor() { @Override public T construct() { - throw new JsonIOException(exceptionMessage); + throw new JsonIOException(exceptionMessageF); } }; } diff --git a/shrinker-test/README.md b/shrinker-test/README.md index 563cd93b24..f9b674d143 100644 --- a/shrinker-test/README.md +++ b/shrinker-test/README.md @@ -4,4 +4,6 @@ This Maven module contains integration tests which check the behavior of Gson wh The code which is shrunken is under `src/main/java`; it should not contain any important assertions in case the code shrinking tools affect these assertions in any way. The test code under `src/test/java` executes the shrunken and obfuscated JAR and verifies that it behaves as expected. +The tests might be a bit brittle, especially the R8 test setup. Future ProGuard and R8 versions might cause the tests to behave differently. In case tests fail the ProGuard and R8 mapping files created in the `target` directory can help with debugging. If necessary rewrite tests or even remove them if they cannot be implemented anymore for newer ProGuard or R8 versions. + **Important:** Because execution of the code shrinking tools is performed during the Maven build, trying to directly run the integration tests from the IDE might not work, or might use stale results if you changed the configuration in between. Run `mvn clean verify` before trying to run the integration tests from the IDE. diff --git a/shrinker-test/proguard.pro b/shrinker-test/proguard.pro index 02a6ef5efb..0cb35048c9 100644 --- a/shrinker-test/proguard.pro +++ b/shrinker-test/proguard.pro @@ -17,6 +17,7 @@ } -keep class com.example.DefaultConstructorMain { public static java.lang.String runTest(); + public static java.lang.String runTestNoJdkUnsafe(); } diff --git a/shrinker-test/r8.pro b/shrinker-test/r8.pro index 29868aa3bf..a415aa1614 100644 --- a/shrinker-test/r8.pro +++ b/shrinker-test/r8.pro @@ -25,6 +25,10 @@ # Don't obfuscate class name, to check it in exception message -keep,allowshrinking,allowoptimization class com.example.DefaultConstructorMain$TestClass +# 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 { + @com.google.gson.annotations.SerializedName ; +} # Keep enum constants which are not explicitly used in code -keep class com.example.EnumClass { diff --git a/shrinker-test/src/main/java/com/example/DefaultConstructorMain.java b/shrinker-test/src/main/java/com/example/DefaultConstructorMain.java index 73dc608ac6..e570866bec 100644 --- a/shrinker-test/src/main/java/com/example/DefaultConstructorMain.java +++ b/shrinker-test/src/main/java/com/example/DefaultConstructorMain.java @@ -3,6 +3,7 @@ import static com.example.TestExecutor.same; import com.google.gson.Gson; +import com.google.gson.GsonBuilder; import com.google.gson.annotations.SerializedName; public class DefaultConstructorMain { @@ -11,6 +12,12 @@ static class TestClass { 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; + } + /** * Main entrypoint, called by {@code ShrinkingIT.testDefaultConstructor()}. */ @@ -18,4 +25,13 @@ public static String runTest() { TestClass deserialized = new Gson().fromJson("{\"s\":\"value\"}", same(TestClass.class)); return deserialized.s; } + + /** + * Main entrypoint, called by {@code ShrinkingIT.testDefaultConstructorNoJdkUnsafe()}. + */ + public static String runTestNoJdkUnsafe() { + Gson gson = new GsonBuilder().disableJdkUnsafe().create(); + TestClassNotAbstract deserialized = gson.fromJson("{\"s\": \"value\"}", same(TestClassNotAbstract.class)); + return deserialized.s; + } } diff --git a/shrinker-test/src/test/java/com/google/gson/it/ShrinkingIT.java b/shrinker-test/src/test/java/com/google/gson/it/ShrinkingIT.java index 21d729b53d..ddf6f34f19 100644 --- a/shrinker-test/src/test/java/com/google/gson/it/ShrinkingIT.java +++ b/shrinker-test/src/test/java/com/google/gson/it/ShrinkingIT.java @@ -199,4 +199,25 @@ public void testDefaultConstructor() throws Exception { } }); } + + @Test + public void testDefaultConstructorNoJdkUnsafe() throws Exception { + runTest("com.example.DefaultConstructorMain", c -> { + Method m = c.getMethod("runTestNoJdkUnsafe"); + + if (jarToTest.equals(PROGUARD_RESULT_PATH)) { + Object result = m.invoke(null); + assertThat(result).isEqualTo("value"); + } else { + // 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;" + + " 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." + ); + } + }); + } } From 21eda758db2acf10af26f2c75c29f89ec9c9354c Mon Sep 17 00:00:00 2001 From: Marcono1234 Date: Sun, 28 May 2023 15:00:39 +0200 Subject: [PATCH 7/7] Add Troubleshooting Guide entry for TypeToken --- Troubleshooting.md | 27 +++++++++++++++++++ .../com/google/gson/reflect/TypeToken.java | 7 +++-- .../google/gson/reflect/TypeTokenTest.java | 6 +++-- 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/Troubleshooting.md b/Troubleshooting.md index 67ad8036c2..57e781cbb9 100644 --- a/Troubleshooting.md +++ b/Troubleshooting.md @@ -282,6 +282,33 @@ Class.forName(jsonString, false, getClass().getClassLoader()).asSubclass(MyBaseC This will not initialize arbitrary classes, and it will throw a `ClassCastException` if the loaded class is not the same as or a subclass of `MyBaseClass`. +## `IllegalStateException`: 'TypeToken must be created with a type argument'
`RuntimeException`: 'Missing type parameter' + +**Symptom:** An `IllegalStateException` with the message 'TypeToken must be created with a type argument' is thrown. +For older Gson versions a `RuntimeException` with message 'Missing type parameter' is thrown. + +**Reason:** + +- You created a `TypeToken` without type argument, for example `new TypeToken() {}` (note the missing `<...>`). You always have to provide the type argument, for example like this: `new TypeToken>() {}`. Normally the compiler will also emit a 'raw types' warning when you forget the `<...>`. +- You are using a code shrinking tool such as ProGuard or R8 (Android app builds normally have this enabled by default) but have not configured it correctly for usage with Gson. + +**Solution:** When you are using a code shrinking tool such as ProGuard or R8 you have to adjust your configuration to include the following rules: + +``` +# Keep generic signatures; needed for correct type resolution +-keepattributes Signature + +# Keep class TypeToken (respectively its generic signature) +-keep class com.google.gson.reflect.TypeToken { *; } + +# Keep any (anonymous) classes extending TypeToken +-keep class * extends com.google.gson.reflect.TypeToken +``` + +See also the [Android example](examples/android-proguard-example/README.md) for more information. + +Note: For newer Gson versions these rules might be applied automatically; make sure you are using the latest Gson version and the latest version of the code shrinking tool. + ## `JsonIOException`: 'Abstract classes can't be instantiated!' (R8) **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). diff --git a/gson/src/main/java/com/google/gson/reflect/TypeToken.java b/gson/src/main/java/com/google/gson/reflect/TypeToken.java index 39e81f33f9..f4a9c0d943 100644 --- a/gson/src/main/java/com/google/gson/reflect/TypeToken.java +++ b/gson/src/main/java/com/google/gson/reflect/TypeToken.java @@ -17,6 +17,7 @@ package com.google.gson.reflect; import com.google.gson.internal.$Gson$Types; +import com.google.gson.internal.TroubleshootingGuide; import java.lang.reflect.GenericArrayType; import java.lang.reflect.ParameterizedType; import java.lang.reflect.Type; @@ -97,8 +98,10 @@ private Type getTypeTokenTypeArgument() { } // Check for raw TypeToken as superclass else if (superclass == TypeToken.class) { - throw new IllegalStateException("TypeToken must be created with a type argument: new TypeToken<...>() {}; " - + "When using code shrinkers (ProGuard, R8, ...) make sure that generic signatures are preserved."); + throw new IllegalStateException("TypeToken must be created with a type argument: new TypeToken<...>() {};" + + " When using code shrinkers (ProGuard, R8, ...) make sure that generic signatures are preserved." + + "\nSee " + TroubleshootingGuide.createUrl("type-token-raw") + ); } // User created subclass of subclass of TypeToken diff --git a/gson/src/test/java/com/google/gson/reflect/TypeTokenTest.java b/gson/src/test/java/com/google/gson/reflect/TypeTokenTest.java index 61c5dfc2b9..1617c408a9 100644 --- a/gson/src/test/java/com/google/gson/reflect/TypeTokenTest.java +++ b/gson/src/test/java/com/google/gson/reflect/TypeTokenTest.java @@ -260,8 +260,10 @@ public void testTypeTokenRaw() { new TypeToken() {}; fail(); } catch (IllegalStateException expected) { - assertThat(expected).hasMessageThat().isEqualTo("TypeToken must be created with a type argument: new TypeToken<...>() {}; " - + "When using code shrinkers (ProGuard, R8, ...) make sure that generic signatures are preserved."); + assertThat(expected).hasMessageThat().isEqualTo("TypeToken must be created with a type argument: new TypeToken<...>() {};" + + " When using code shrinkers (ProGuard, R8, ...) make sure that generic signatures are preserved." + + "\nSee https://github.com/google/gson/blob/master/Troubleshooting.md#type-token-raw" + ); } } }