Repository: google/gson (Java)

Issue:
Declare proguard rules automatically
Could gson have basic proguard rules automatically declared as retrofit does ?
https://github.com/square/retrofit/blob/master/retrofit/src/main/resources/META-INF/proguard/retrofit2.pro


Hints:
I think that's exactly what #1929 proposes, but for that PR to proceed someone should write a test for it.
Any update to this one

Patch:
diff --git a/gson/src/main/java/com/google/gson/internal/ConstructorConstructor.java b/gson/src/main/java/com/google/gson/internal/ConstructorConstructor.java
--- 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<Type, InstanceCreator<?>> 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;
   }
@@ -144,9 +152,9 @@ public <T> ObjectConstructor<T> get(TypeToken<T> 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<T>() {
         @Override public T construct() {
           throw new JsonIOException(message);
@@ -219,10 +227,10 @@ private static <T> ObjectConstructor<T> newDefaultConstructor(Class<? super T> 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<T>() {
         @Override public T construct() {
           throw new JsonIOException(message);
@@ -370,19 +378,29 @@ private <T> ObjectConstructor<T> newUnsafeAllocator(final Class<? super T> 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<T>() {
         @Override public T construct() {
-          throw new JsonIOException(exceptionMessage);
+          throw new JsonIOException(exceptionMessageF);
         }
       };
     }
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
--- 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 <T> TypeAdapter<T> create(Gson gson, TypeToken<T> 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/gson/src/main/java/com/google/gson/reflect/TypeToken.java b/gson/src/main/java/com/google/gson/reflect/TypeToken.java
--- 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/shrinker-test/src/main/java/com/example/ClassWithAdapter.java b/shrinker-test/src/main/java/com/example/ClassWithAdapter.java
new file mode 100644
--- /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<ClassWithAdapter> {
+    @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/ClassWithDefaultConstructor.java b/shrinker-test/src/main/java/com/example/ClassWithDefaultConstructor.java
new file mode 100644
--- /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
--- /dev/null
+++ b/shrinker-test/src/main/java/com/example/ClassWithExposeAnnotation.java
@@ -0,0 +1,13 @@
+package com.example;
+
+import com.google.gson.annotations.Expose;
+
+/**
+ * Uses {@link Expose} annotation.
+ */
+public class ClassWithExposeAnnotation {
+  @Expose
+  int i;
+
+  int i2;
+}
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
--- /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<DummyClass> {
+    @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 <T> TypeAdapter<T> create(Gson gson, TypeToken<T> type) {
+      @SuppressWarnings("unchecked") // the code below is not type safe, but does not matter for this test
+      TypeAdapter<T> r = (TypeAdapter<T>) new TypeAdapter<DummyClass>() {
+        @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<DummyClass> {
+    @Override
+    public JsonElement serialize(DummyClass src, Type typeOfSrc, JsonSerializationContext context) {
+      return new JsonPrimitive("serializer-" + src.s);
+    }
+  }
+
+  static class Deserializer implements JsonDeserializer<DummyClass> {
+    @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/ClassWithNamedFields.java b/shrinker-test/src/main/java/com/example/ClassWithNamedFields.java
new file mode 100644
--- /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
--- /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/ClassWithVersionAnnotations.java b/shrinker-test/src/main/java/com/example/ClassWithVersionAnnotations.java
new file mode 100644
--- /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/DefaultConstructorMain.java b/shrinker-test/src/main/java/com/example/DefaultConstructorMain.java
new file mode 100644
--- /dev/null
+++ b/shrinker-test/src/main/java/com/example/DefaultConstructorMain.java
@@ -0,0 +1,37 @@
+package com.example;
+
+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 {
+  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;
+  }
+
+  /**
+   * Main entrypoint, called by {@code ShrinkingIT.testDefaultConstructor()}.
+   */
+  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/main/java/com/example/EnumClass.java b/shrinker-test/src/main/java/com/example/EnumClass.java
new file mode 100644
--- /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
--- /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/GenericClasses.java b/shrinker-test/src/main/java/com/example/GenericClasses.java
new file mode 100644
--- /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<T> {
+    @SerializedName("t")
+    T t;
+
+    @Override
+    public String toString() {
+      return "{t=" + t + "}";
+    }
+  }
+
+  static class UsingGenericClass {
+    @SerializedName("g")
+    GenericClass<DummyClass> g;
+
+    @Override
+    public String toString() {
+      return "{g=" + g + "}";
+    }
+  }
+
+  static class GenericUsingGenericClass<T> {
+    @SerializedName("g")
+    GenericClass<T> 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<DummyClass> {
+      @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
new file mode 100644
--- /dev/null
+++ b/shrinker-test/src/main/java/com/example/Main.java
@@ -0,0 +1,142 @@
+package com.example;
+
+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;
+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()}.
+   *
+   * <p>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<String, String> outputConsumer) {
+    // Create the TypeToken instances on demand because creation of them can fail when
+    // generic signatures were erased
+    testTypeTokenWriteRead(outputConsumer, "anonymous", () -> new TypeToken<List<ClassWithAdapter>>() {});
+    testTypeTokenWriteRead(outputConsumer, "manual", () -> TypeToken.getParameterized(List.class, ClassWithAdapter.class));
+
+    testNamedFields(outputConsumer);
+    testSerializedName(outputConsumer);
+
+    testNoJdkUnsafe(outputConsumer);
+
+    testEnum(outputConsumer);
+    testEnumSerializedName(outputConsumer);
+
+    testExposeAnnotation(outputConsumer);
+    testVersionAnnotations(outputConsumer);
+    testJsonAdapterAnnotation(outputConsumer);
+
+    testGenericClasses(outputConsumer);
+  }
+
+  private static void testTypeTokenWriteRead(BiConsumer<String, String> outputConsumer, String description, Supplier<TypeToken<?>> 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> T fromJson(Gson gson, String json, Class<T> c) {
+    return gson.fromJson(json, same(c));
+  }
+
+  private static void testNamedFields(BiConsumer<String, String> 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<String, String> 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<String, String> 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<String, String> 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<String, String> 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<String, String> outputConsumer) {
+    Gson gson = new GsonBuilder().excludeFieldsWithoutExposeAnnotation().create();
+    TestExecutor.run(outputConsumer, "Write: @Expose", () -> toJson(gson, new ClassWithExposeAnnotation()));
+  }
+
+  private static void testVersionAnnotations(BiConsumer<String, String> outputConsumer) {
+    Gson gson = new GsonBuilder().setVersion(1).create();
+    TestExecutor.run(outputConsumer, "Write: Version annotations", () -> toJson(gson, new ClassWithVersionAnnotations()));
+  }
+
+  private static void testJsonAdapterAnnotation(BiConsumer<String, String> 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<String, String> outputConsumer) {
+    Gson gson = new Gson();
+    TestExecutor.run(outputConsumer, "Read: Generic TypeToken", () -> gson.fromJson("{\"t\": 1}", new TypeToken<GenericClass<DummyClass>>() {}).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<GenericUsingGenericClass<DummyClass>>() {}).toString());
+  }
+}
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
--- /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<String, String> outputConsumer, String name, Supplier<String> 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> 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"));
+  }
+}


Test patch:
diff --git a/gson/src/test/java/com/google/gson/reflect/TypeTokenTest.java b/gson/src/test/java/com/google/gson/reflect/TypeTokenTest.java
--- 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"
+      );
     }
   }
 }
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
--- /dev/null
+++ b/shrinker-test/src/test/java/com/google/gson/it/ShrinkingIT.java
@@ -0,0 +1,223 @@
+/*
+ * 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<Path> 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<String, String>) (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: 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}}",
+        "===",
+        ""
+      ));
+  }
+
+  @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"
+        );
+      }
+    });
+  }
+
+  @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."
+        );
+      }
+    });
+  }
+}