diff --git a/ReleaseProcess.md b/ReleaseProcess.md index f431ea621a..cae620ffeb 100644 --- a/ReleaseProcess.md +++ b/ReleaseProcess.md @@ -34,7 +34,7 @@ The following describes how to perform the steps of the release locally to verif > [!CAUTION]\ > Be careful with this, these steps might be outdated or incomplete. Double-check that you are working on a copy of your local Gson Git repository and make sure you have followed all steps. To be safe you can also temporarily turn off your internet connection to avoid accidentally pushing changes to the real remote Git or Maven repository.\ -> As an alternative to the steps described below you can instead [perform a dry run](https://maven.apache.org/maven-release/maven-release-plugin/usage.html#do-a-dry-run), though this might not behave identical to a real release. +> As an alternative to the steps described below you can instead [perform a dry run](https://maven.apache.org/maven-release/maven-release-plugin/usage.html#do-a-dry-run), though this might not behave identically to a real release. 1. Make a copy of your local Gson Git repository and only work with that copy 1. Make sure you are on the `main` branch diff --git a/gson/pom.xml b/gson/pom.xml index dbb294d00b..58a076e74e 100644 --- a/gson/pom.xml +++ b/gson/pom.xml @@ -39,6 +39,7 @@ 2025-09-10T20:39:14Z **/Java17* + false @@ -179,6 +180,23 @@ configuration locally). --> --illegal-access=deny + + + + java-time-test + + test + + test + + ${skipJavaTimeTest} + --add-opens java.base/java.time=ALL-UNNAMED + JavaTimeTest + true + + + @@ -392,6 +410,7 @@ gson-subset + true ${project.build.directory}/gson-subset-src diff --git a/gson/src/main/java/com/google/gson/internal/bind/EnumTypeAdapter.java b/gson/src/main/java/com/google/gson/internal/bind/EnumTypeAdapter.java index b3dbb680c1..8205f26d97 100644 --- a/gson/src/main/java/com/google/gson/internal/bind/EnumTypeAdapter.java +++ b/gson/src/main/java/com/google/gson/internal/bind/EnumTypeAdapter.java @@ -51,7 +51,10 @@ public TypeAdapter create(Gson gson, TypeToken typeToken) { }; /** - * Taken from Java 19 method {@link HashMap.newHashMap}, using default load factor {@code 0.75F}. + * Calculates the 'capacity' needed to hold {@code numMappings} entries without resizing, using + * default load factor {@code 0.75F}. + * + *

Taken from Java 19 method {@link HashMap#newHashMap}. */ private static int calculateHashMapCapacity(int numMappings) { return (int) Math.ceil(numMappings / 0.75F); diff --git a/gson/src/main/java/com/google/gson/internal/bind/IgnoreJRERequirement.java b/gson/src/main/java/com/google/gson/internal/bind/IgnoreJRERequirement.java index c51a034867..5cc216ba9e 100644 --- a/gson/src/main/java/com/google/gson/internal/bind/IgnoreJRERequirement.java +++ b/gson/src/main/java/com/google/gson/internal/bind/IgnoreJRERequirement.java @@ -1,3 +1,18 @@ +/* + * Copyright (C) 2026 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.internal.bind; import java.lang.annotation.ElementType; @@ -5,6 +20,10 @@ import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; +/** + * Used for animal-sniffer-maven-plugin to suppress warnings about API being unavailable for the + * target Android API Level. + */ @Retention(RetentionPolicy.CLASS) @Target({ElementType.METHOD, ElementType.CONSTRUCTOR, ElementType.TYPE, ElementType.FIELD}) @SuppressWarnings("IdentifierName") diff --git a/gson/src/main/java/com/google/gson/internal/bind/JavaTimeTypeAdapters.java b/gson/src/main/java/com/google/gson/internal/bind/JavaTimeTypeAdapters.java index 791f15b44a..b3968b62df 100644 --- a/gson/src/main/java/com/google/gson/internal/bind/JavaTimeTypeAdapters.java +++ b/gson/src/main/java/com/google/gson/internal/bind/JavaTimeTypeAdapters.java @@ -1,3 +1,18 @@ +/* + * Copyright (C) 2026 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.internal.bind; import static java.lang.Math.toIntExact; @@ -9,9 +24,9 @@ import com.google.gson.internal.bind.TypeAdapters.IntegerFieldsTypeAdapter; import com.google.gson.reflect.TypeToken; import com.google.gson.stream.JsonReader; -import com.google.gson.stream.JsonToken; import com.google.gson.stream.JsonWriter; import java.io.IOException; +import java.time.DateTimeException; import java.time.Duration; import java.time.Instant; import java.time.LocalDate; @@ -39,13 +54,20 @@ * is obviously fragile, and it also needs special {@code --add-opens} configuration with more * recent JDK versions. So here we freeze the representation that was current with JDK 21, in a way * that does not use reflection. + * + *

This class should not directly be used, instead the type adapter factory should be obtained + * from {@link TypeAdapters#javaTimeTypeAdapterFactory()}. */ -@IgnoreJRERequirement // Protected by a reflective check +@IgnoreJRERequirement // Protected by a reflective check in `TypeAdapters` final class JavaTimeTypeAdapters implements TypeAdapters.FactorySupplier { @Override public TypeAdapterFactory get() { - return JAVA_TIME_FACTORY; + String packageName = javaTimePackage(); + if (packageName == null) { + return null; + } + return new AdapterFactory(packageName); } private static final TypeAdapter DURATION = @@ -91,7 +113,7 @@ long[] integerValues(LocalDate localDate) { } }; - public static final TypeAdapter LOCAL_TIME = + private static final TypeAdapter LOCAL_TIME = new IntegerFieldsTypeAdapter("hour", "minute", "second", "nano") { @Override LocalTime create(long[] values) { @@ -119,7 +141,7 @@ public LocalDateTime read(JsonReader in) throws IOException { LocalDate localDate = null; LocalTime localTime = null; in.beginObject(); - while (in.peek() != JsonToken.END_OBJECT) { + while (in.hasNext()) { String name = in.nextName(); switch (name) { case "date": @@ -172,7 +194,7 @@ public OffsetDateTime read(JsonReader in) throws IOException { in.beginObject(); LocalDateTime localDateTime = null; ZoneOffset zoneOffset = null; - while (in.peek() != JsonToken.END_OBJECT) { + while (in.hasNext()) { String name = in.nextName(); switch (name) { case "dateTime": @@ -213,7 +235,7 @@ public OffsetTime read(JsonReader in) throws IOException { in.beginObject(); LocalTime localTime = null; ZoneOffset zoneOffset = null; - while (in.peek() != JsonToken.END_OBJECT) { + while (in.hasNext()) { String name = in.nextName(); switch (name) { case "time": @@ -287,7 +309,7 @@ long[] integerValues(YearMonth yearMonth) { // A ZoneId is either a ZoneOffset or a ZoneRegion, where ZoneOffset is public and ZoneRegion is // not. For compatibility with reflection-based serialization, we need to write the "id" field of // ZoneRegion if we have a ZoneRegion, and we need to write the "totalSeconds" field of ZoneOffset - // if we have a ZoneOffset. When reading, we need to construct the the appropriate thing depending + // if we have a ZoneOffset. When reading, we need to construct the appropriate thing depending // on which of those two fields we see. private static final TypeAdapter ZONE_ID = new TypeAdapter() { @@ -296,7 +318,7 @@ public ZoneId read(JsonReader in) throws IOException { in.beginObject(); String id = null; Integer totalSeconds = null; - while (in.peek() != JsonToken.END_OBJECT) { + while (in.hasNext()) { String name = in.nextName(); switch (name) { case "id": @@ -348,7 +370,7 @@ public ZonedDateTime read(JsonReader in) throws IOException { LocalDateTime localDateTime = null; ZoneOffset zoneOffset = null; ZoneId zoneId = null; - while (in.peek() != JsonToken.END_OBJECT) { + while (in.hasNext()) { String name = in.nextName(); switch (name) { case "dateTime": @@ -390,55 +412,6 @@ public void write(JsonWriter out, ZonedDateTime value) throws IOException { }.nullSafe(); } - static final TypeAdapterFactory JAVA_TIME_FACTORY = - new TypeAdapterFactory() { - @Override - public TypeAdapter create(Gson gson, TypeToken typeToken) { - Class rawType = typeToken.getRawType(); - if (!rawType.getName().startsWith("java.time.")) { - // Immediately return null so we don't load all these classes when nobody's doing - // anything with java.time. - return null; - } - TypeAdapter adapter = null; - if (rawType == Duration.class) { - adapter = DURATION; - } else if (rawType == Instant.class) { - adapter = INSTANT; - } else if (rawType == LocalDate.class) { - adapter = LOCAL_DATE; - } else if (rawType == LocalTime.class) { - adapter = LOCAL_TIME; - } else if (rawType == LocalDateTime.class) { - adapter = localDateTime(gson); - } else if (rawType == MonthDay.class) { - adapter = MONTH_DAY; - } else if (rawType == OffsetDateTime.class) { - adapter = offsetDateTime(gson); - } else if (rawType == OffsetTime.class) { - adapter = offsetTime(gson); - } else if (rawType == Period.class) { - adapter = PERIOD; - } else if (rawType == Year.class) { - adapter = YEAR; - } else if (rawType == YearMonth.class) { - adapter = YEAR_MONTH; - } else if (rawType == ZoneId.class || rawType == ZoneOffset.class) { - // We don't check ZoneId.class.isAssignableFrom(rawType) because we don't want to match - // the non-public class ZoneRegion in the runtime type check in - // TypeAdapterRuntimeTypeWrapper.write. If we did, then our ZONE_ID would take - // precedence over a ZoneId adapter that the user might have registered. (This exact - // situation showed up in a Google-internal test.) - adapter = ZONE_ID; - } else if (rawType == ZonedDateTime.class) { - adapter = zonedDateTime(gson); - } - @SuppressWarnings("unchecked") - TypeAdapter result = (TypeAdapter) adapter; - return result; - } - }; - private static T requireNonNullField(T field, String fieldName, JsonReader reader) { if (field == null) { throw new JsonSyntaxException( @@ -446,4 +419,80 @@ private static T requireNonNullField(T field, String fieldName, JsonReader r } return field; } + + /** + * Gets the package name (with trailing '.') of the {@code java.time} classes, or {@code null} if + * unavailable. + * + *

On Android this might actually be something other than {@code "java.time."}, for example + * {@code "j$.time."}, due to API + * desugaring. + */ + static String javaTimePackage() { + try { + // Use arbitrary java.time.* class here, one which is quite simple and does not refer to many + // other classes + String className = DateTimeException.class.getName(); + int packageEnd = className.lastIndexOf('.'); + return className.substring(0, packageEnd + 1); + } catch (LinkageError ignored) { + // java.time.* classes are probably not available + return null; + } + } + + private static class AdapterFactory implements TypeAdapterFactory { + private final String javaTimePackage; + + AdapterFactory(String javaTimePackage) { + this.javaTimePackage = javaTimePackage; + } + + @Override + public TypeAdapter create(Gson gson, TypeToken typeToken) { + Class rawType = typeToken.getRawType(); + if (!rawType.getName().startsWith(javaTimePackage)) { + // Immediately return null so we don't load all these classes when nobody's doing + // anything with java.time. + return null; + } + TypeAdapter adapter = null; + if (rawType == Duration.class) { + adapter = DURATION; + } else if (rawType == Instant.class) { + adapter = INSTANT; + } else if (rawType == LocalDate.class) { + adapter = LOCAL_DATE; + } else if (rawType == LocalTime.class) { + adapter = LOCAL_TIME; + } else if (rawType == LocalDateTime.class) { + adapter = localDateTime(gson); + } else if (rawType == MonthDay.class) { + adapter = MONTH_DAY; + } else if (rawType == OffsetDateTime.class) { + adapter = offsetDateTime(gson); + } else if (rawType == OffsetTime.class) { + adapter = offsetTime(gson); + } else if (rawType == Period.class) { + adapter = PERIOD; + } else if (rawType == Year.class) { + adapter = YEAR; + } else if (rawType == YearMonth.class) { + adapter = YEAR_MONTH; + } else if (rawType == ZoneId.class || rawType == ZoneOffset.class) { + // We don't check ZoneId.class.isAssignableFrom(rawType) because we don't want to match + // the non-public class ZoneRegion in the runtime type check in + // TypeAdapterRuntimeTypeWrapper.write. If we did, then our ZONE_ID would take + // precedence over a ZoneId adapter that the user might have registered. (This exact + // situation showed up in a Google-internal test.) + adapter = ZONE_ID; + } else if (rawType == ZonedDateTime.class) { + adapter = zonedDateTime(gson); + } + @SuppressWarnings("unchecked") + TypeAdapter result = (TypeAdapter) adapter; + return result; + } + } } diff --git a/gson/src/main/java/com/google/gson/internal/bind/TypeAdapters.java b/gson/src/main/java/com/google/gson/internal/bind/TypeAdapters.java index 852ae56c5b..cb4737363e 100644 --- a/gson/src/main/java/com/google/gson/internal/bind/TypeAdapters.java +++ b/gson/src/main/java/com/google/gson/internal/bind/TypeAdapters.java @@ -807,6 +807,8 @@ public void write(JsonWriter out, Currency value) throws IOException { * An abstract {@link TypeAdapter} for classes whose JSON serialization consists of a fixed set of * integer fields. That is the case for {@link Calendar} and the legacy serialization of various * {@code java.time} types. + * + *

This class handles {@code null}; subclasses don't have to use {@link #nullSafe()}. */ abstract static class IntegerFieldsTypeAdapter extends TypeAdapter { private final List fields; @@ -815,8 +817,21 @@ abstract static class IntegerFieldsTypeAdapter extends TypeAdapter { this.fields = Arrays.asList(fields); } + /** + * On deserialization: Creates an object from the integer values. Subclasses should use {@link + * Math#toIntExact(long)} and similar if necessary to prevent silent truncation. + * + *

Values have the same order as the field names provided to the {@linkplain + * #IntegerFieldsTypeAdapter(String[]) constructor}. + */ abstract T create(long[] values); + /** + * On serialization: Extracts the integer values from the object. + * + *

Values must have the same order as the field names provided to the {@linkplain + * #IntegerFieldsTypeAdapter(String[]) constructor}. + */ abstract long[] integerValues(T t); @Override @@ -827,7 +842,7 @@ public T read(JsonReader in) throws IOException { } in.beginObject(); long[] values = new long[fields.size()]; - while (in.peek() != JsonToken.END_OBJECT) { + while (in.hasNext()) { String name = in.nextName(); int index = fields.indexOf(name); if (index >= 0) { @@ -884,11 +899,11 @@ long[] integerValues(Calendar calendar) { } }; - // TODO: update this when we are on at least Android API Level 24. + // TODO: switch to `Math#toIntExact` when we are on at least Android API Level 24. private static int toIntExact(long x) { int i = (int) x; if (i != x) { - throw new IllegalArgumentException("Too big for an int: " + x); + throw new ArithmeticException("Too big for an int: " + x); } return i; } @@ -946,6 +961,10 @@ interface FactorySupplier { TypeAdapterFactory get(); } + /** + * Adapter factory for {@code java.time} classes. Returns {@code null} if not supported by the + * current environment (e.g. too old Android version, without desugaring). + */ public static TypeAdapterFactory javaTimeTypeAdapterFactory() { try { Class javaTimeTypeAdapterFactoryClass = diff --git a/gson/src/test/java/com/google/gson/functional/DefaultTypeAdaptersTest.java b/gson/src/test/java/com/google/gson/functional/DefaultTypeAdaptersTest.java index 187c3f2808..0425084c86 100644 --- a/gson/src/test/java/com/google/gson/functional/DefaultTypeAdaptersTest.java +++ b/gson/src/test/java/com/google/gson/functional/DefaultTypeAdaptersTest.java @@ -30,14 +30,11 @@ import com.google.gson.JsonPrimitive; import com.google.gson.JsonSyntaxException; import com.google.gson.TypeAdapter; -import com.google.gson.internal.bind.ReflectiveTypeAdapterFactory; 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.Constructor; -import java.lang.reflect.Field; -import java.lang.reflect.InaccessibleObjectException; import java.lang.reflect.Type; import java.math.BigDecimal; import java.math.BigInteger; @@ -45,20 +42,6 @@ import java.net.URI; import java.net.URL; import java.text.DateFormat; -import java.time.Duration; -import java.time.Instant; -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.time.LocalTime; -import java.time.MonthDay; -import java.time.OffsetDateTime; -import java.time.OffsetTime; -import java.time.Period; -import java.time.Year; -import java.time.YearMonth; -import java.time.ZoneId; -import java.time.ZoneOffset; -import java.time.ZonedDateTime; import java.util.ArrayList; import java.util.Arrays; import java.util.BitSet; @@ -78,7 +61,7 @@ import org.junit.Test; /** - * Functional test for Json serialization and deserialization for common classes for which default + * Functional test for JSON serialization and deserialization for common classes for which default * support is provided in Gson. The tests for Map types are available in {@link MapTest}. * * @author Inderjeet Singh @@ -217,13 +200,6 @@ public void testNullSerialization() { testNullSerializationAndDeserialization(GregorianCalendar.class); testNullSerializationAndDeserialization(Calendar.class); testNullSerializationAndDeserialization(Class.class); - testNullSerializationAndDeserialization(Duration.class); - testNullSerializationAndDeserialization(Instant.class); - testNullSerializationAndDeserialization(LocalDate.class); - testNullSerializationAndDeserialization(LocalTime.class); - testNullSerializationAndDeserialization(LocalDateTime.class); - testNullSerializationAndDeserialization(ZoneId.class); - testNullSerializationAndDeserialization(ZonedDateTime.class); } private void testNullSerializationAndDeserialization(Class c) { @@ -624,7 +600,7 @@ public void testDateDeserializationWithPattern() { } @Test - public void testDateSerializationWithPatternNotOverridenByTypeAdapter() { + public void testDateSerializationWithPatternNotOverriddenByTypeAdapter() { String pattern = "yyyy-MM-dd"; Gson gson = new GsonBuilder() @@ -836,232 +812,6 @@ public void testStringBufferDeserialization() { assertThat(sb.toString()).isEqualTo("abc"); } - @Test - public void testJavaTimeDuration() { - Duration duration = Duration.ofSeconds(123, 456_789_012); - String json = "{\"seconds\":123,\"nanos\":456789012}"; - roundTrip(duration, json); - } - - @Test - public void testJavaTimeDurationWithUnknownFields() { - Duration duration = Duration.ofSeconds(123, 456_789_012); - String json = "{\"seconds\":123,\"nanos\":456789012,\"tiddly\":\"pom\",\"wibble\":\"wobble\"}"; - assertThat(gson.fromJson(json, Duration.class)).isEqualTo(duration); - } - - @Test - public void testJavaTimeInstant() { - Instant instant = Instant.ofEpochSecond(123, 456_789_012); - String json = "{\"seconds\":123,\"nanos\":456789012}"; - roundTrip(instant, json); - } - - @Test - public void testJavaTimeLocalDate() { - LocalDate localDate = LocalDate.of(2021, 12, 2); - String json = "{\"year\":2021,\"month\":12,\"day\":2}"; - roundTrip(localDate, json); - } - - @Test - public void testJavaTimeLocalTime() { - LocalTime localTime = LocalTime.of(12, 34, 56, 789_012_345); - String json = "{\"hour\":12,\"minute\":34,\"second\":56,\"nano\":789012345}"; - roundTrip(localTime, json); - } - - @Test - public void testJavaTimeLocalDateTime() { - LocalDateTime localDateTime = LocalDateTime.of(2021, 12, 2, 12, 34, 56, 789_012_345); - String json = - "{\"date\":{\"year\":2021,\"month\":12,\"day\":2}," - + "\"time\":{\"hour\":12,\"minute\":34,\"second\":56,\"nano\":789012345}}"; - roundTrip(localDateTime, json); - } - - @Test - public void testJavaTimeMonthDay() { - MonthDay monthDay = MonthDay.of(2, 17); - String json = "{\"month\":2,\"day\":17}"; - roundTrip(monthDay, json); - } - - @Test - public void testJavaTimeOffsetDateTime() { - OffsetDateTime offsetDateTime = - OffsetDateTime.of( - LocalDate.of(2021, 12, 2), LocalTime.of(12, 34, 56, 789_012_345), ZoneOffset.UTC); - String json = - "{\"dateTime\":{\"date\":{\"year\":2021,\"month\":12,\"day\":2}," - + "\"time\":{\"hour\":12,\"minute\":34,\"second\":56,\"nano\":789012345}}," - + "\"offset\":{\"totalSeconds\":0}}"; - roundTrip(offsetDateTime, json); - } - - @Test - public void testJavaTimeOffsetTime() { - OffsetTime offsetTime = OffsetTime.of(LocalTime.of(12, 34, 56, 789_012_345), ZoneOffset.UTC); - String json = - "{\"time\":{\"hour\":12,\"minute\":34,\"second\":56,\"nano\":789012345}," - + "\"offset\":{\"totalSeconds\":0}}"; - roundTrip(offsetTime, json); - } - - @Test - public void testJavaTimePeriod() { - Period period = Period.of(2025, 2, 3); - String json = "{\"years\":2025,\"months\":2,\"days\":3}"; - roundTrip(period, json); - } - - @Test - public void testJavaTimeYear() { - Year year = Year.of(2025); - String json = "{\"year\":2025}"; - roundTrip(year, json); - } - - @Test - public void testJavaTimeYearMonth() { - YearMonth yearMonth = YearMonth.of(2025, 2); - String json = "{\"year\":2025,\"month\":2}"; - roundTrip(yearMonth, json); - } - - @Test - public void testJavaTimeZoneOffset() { - ZoneOffset zoneOffset = ZoneOffset.ofTotalSeconds(-8 * 60 * 60); - String json = "{\"totalSeconds\":-28800}"; - roundTrip(zoneOffset, json); - } - - @Test - public void testJavaTimeZoneRegion() { - ZoneId zoneId = ZoneId.of("Asia/Shanghai"); - String json = "{\"id\":\"Asia/Shanghai\"}"; - roundTrip(zoneId, ZoneId.class, json); - } - - @Test - public void testJavaTimeZonedDateTimeWithZoneOffset() { - ZonedDateTime zonedDateTime = - ZonedDateTime.of( - LocalDate.of(2021, 12, 2), LocalTime.of(12, 34, 56, 789_012_345), ZoneOffset.UTC); - String json = - "{\"dateTime\":{\"date\":{\"year\":2021,\"month\":12,\"day\":2}," - + "\"time\":{\"hour\":12,\"minute\":34,\"second\":56,\"nano\":789012345}}," - + "\"offset\":{\"totalSeconds\":0}," - + "\"zone\":{\"totalSeconds\":0}}"; - roundTrip(zonedDateTime, json); - } - - @Test - public void testJavaTimeZonedDateTimeWithZoneId() { - ZoneId zoneId = ZoneId.of("UTC+01:00"); - int totalSeconds = ((ZoneOffset) zoneId.normalized()).getTotalSeconds(); - ZonedDateTime zonedDateTime = - ZonedDateTime.of(LocalDate.of(2021, 12, 2), LocalTime.of(12, 34, 56, 789_012_345), zoneId); - String json = - "{\"dateTime\":{\"date\":{\"year\":2021,\"month\":12,\"day\":2}," - + "\"time\":{\"hour\":12,\"minute\":34,\"second\":56,\"nano\":789012345}}," - + "\"offset\":{\"totalSeconds\":" - + totalSeconds - + "}," - + "\"zone\":{\"id\":\"" - + zoneId.getId() - + "\"}}"; - roundTrip(zonedDateTime, json); - } - - @Test - public void testJavaTimeZonedDateTimeWithZoneIdThatHasAdapter() { - TypeAdapter zoneIdAdapter = - new TypeAdapter() { - @Override - public void write(JsonWriter out, ZoneId value) throws IOException { - out.value(value.getId()); - } - - @Override - public ZoneId read(JsonReader in) throws IOException { - return ZoneId.of(in.nextString()); - } - }; - Gson customGson = new GsonBuilder().registerTypeAdapter(ZoneId.class, zoneIdAdapter).create(); - ZoneId zoneId = ZoneId.of("UTC+01:00"); - int totalSeconds = ((ZoneOffset) zoneId.normalized()).getTotalSeconds(); - ZonedDateTime zonedDateTime = - ZonedDateTime.of(LocalDate.of(2021, 12, 2), LocalTime.of(12, 34, 56, 789_012_345), zoneId); - String json = - "{\"dateTime\":{\"date\":{\"year\":2021,\"month\":12,\"day\":2}," - + "\"time\":{\"hour\":12,\"minute\":34,\"second\":56,\"nano\":789012345}}," - + "\"offset\":{\"totalSeconds\":" - + totalSeconds - + "}," - + "\"zone\":\"" - + zoneId.getId() - + "\"}"; - roundTrip(customGson, zonedDateTime, ZonedDateTime.class, json); - } - - private static final boolean JAVA_TIME_FIELDS_ARE_ACCESSIBLE; - - static { - boolean accessible = false; - try { - Instant.class.getDeclaredField("seconds").setAccessible(true); - accessible = true; - } catch (InaccessibleObjectException e) { - // OK: we can't reflect on java.time fields - } catch (NoSuchFieldException e) { - // JDK implementation has changed and we no longer have an Instant.seconds field. - throw new AssertionError(e); - } - JAVA_TIME_FIELDS_ARE_ACCESSIBLE = accessible; - } - - private void roundTrip(Object value, String expectedJson) { - roundTrip(value, value.getClass(), expectedJson); - } - - private void roundTrip(Object value, Class valueClass, String expectedJson) { - roundTrip(gson, value, valueClass, expectedJson); - if (JAVA_TIME_FIELDS_ARE_ACCESSIBLE) { - checkReflectiveTypeAdapterFactory(value, expectedJson); - } - } - - private void roundTrip(Gson customGson, Object value, Class valueClass, String expectedJson) { - assertThat(customGson.getAdapter(valueClass).getClass().getName()).doesNotContain("Reflective"); - assertThat(customGson.toJson(value, valueClass)).isEqualTo(expectedJson); - assertThat(customGson.fromJson(expectedJson, valueClass)).isEqualTo(value); - } - - // Assuming we have reflective access to the fields of java.time classes, check that - // ReflectiveTypeAdapterFactory would produce the same JSON. This ensures that we are preserving - // a compatible JSON format for those classes even though we no longer use reflection. - private void checkReflectiveTypeAdapterFactory(Object value, String expectedJson) { - List factories; - try { - Field factoriesField = gson.getClass().getDeclaredField("factories"); - factoriesField.setAccessible(true); - factories = (List) factoriesField.get(gson); - } catch (ReflectiveOperationException e) { - throw new LinkageError(e.getMessage(), e); - } - ReflectiveTypeAdapterFactory adapterFactory = - factories.stream() - .filter(f -> f instanceof ReflectiveTypeAdapterFactory) - .map(f -> (ReflectiveTypeAdapterFactory) f) - .findFirst() - .get(); - TypeToken typeToken = TypeToken.get(value.getClass()); - @SuppressWarnings("unchecked") - TypeAdapter adapter = (TypeAdapter) adapterFactory.create(gson, typeToken); - assertThat(adapter.toJson(value)).isEqualTo(expectedJson); - } - private static class MyClassTypeAdapter extends TypeAdapter> { @Override public void write(JsonWriter out, Class value) throws IOException { diff --git a/gson/src/test/java/com/google/gson/functional/JavaTimeTest.java b/gson/src/test/java/com/google/gson/functional/JavaTimeTest.java new file mode 100644 index 0000000000..d44c8714a1 --- /dev/null +++ b/gson/src/test/java/com/google/gson/functional/JavaTimeTest.java @@ -0,0 +1,425 @@ +/* + * Copyright (C) 2026 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.functional; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assume.assumeTrue; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.TypeAdapter; +import com.google.gson.internal.bind.ReflectiveTypeAdapterFactory; +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.Field; +import java.lang.reflect.InaccessibleObjectException; +import java.time.DayOfWeek; +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.Month; +import java.time.MonthDay; +import java.time.OffsetDateTime; +import java.time.OffsetTime; +import java.time.Period; +import java.time.Year; +import java.time.YearMonth; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.util.List; +import org.junit.Before; +import org.junit.Test; + +/** + * Test for {@code java.time} classes. + * + *

If reflective access to JDK classes is possible, this test also verifies that the custom + * adapters behave identically to the reflection-based approach (to ensure backward compatibility), + * see {@link #JAVA_TIME_FIELDS_ARE_ACCESSIBLE}. + */ +public class JavaTimeTest { + private Gson gson; + + @Before + public void setUp() throws Exception { + gson = new Gson(); + } + + @Test + public void testNullSafe() { + assertNullSafe(DayOfWeek.class); // uses standard enum adapter + assertNullSafe(Duration.class); + assertNullSafe(Instant.class); + assertNullSafe(LocalDate.class); + assertNullSafe(LocalTime.class); + assertNullSafe(LocalDateTime.class); + assertNullSafe(Month.class); // uses standard enum adapter + assertNullSafe(MonthDay.class); + assertNullSafe(Period.class); + assertNullSafe(Year.class); + assertNullSafe(YearMonth.class); + assertNullSafe(ZoneId.class); + assertNullSafe(ZonedDateTime.class); + } + + private void assertNullSafe(Class c) { + DefaultTypeAdaptersTest.testNullSerializationAndDeserialization(gson, c); + } + + // uses standard enum adapter + @Test + public void testDayOfWeek() { + DayOfWeek day = DayOfWeek.TUESDAY; + String json = "\"TUESDAY\""; + assertThat(gson.toJson(day)).isEqualTo(json); + assertThat(gson.fromJson(json, DayOfWeek.class)).isEqualTo(day); + } + + @Test + public void testDuration() { + Duration duration = Duration.ofSeconds(123, 456_789_012); + String json = "{\"seconds\":123,\"nanos\":456789012}"; + roundTrip(duration, json); + } + + @Test + public void testDurationWithUnknownFields() { + Duration duration = Duration.ofSeconds(123, 456_789_012); + String json = "{\"seconds\":123,\"nanos\":456789012,\"tiddly\":\"pom\",\"wibble\":\"wobble\"}"; + assertThat(gson.fromJson(json, Duration.class)).isEqualTo(duration); + } + + @Test + public void testInstant() { + Instant instant = Instant.ofEpochSecond(123, 456_789_012); + String json = "{\"seconds\":123,\"nanos\":456789012}"; + roundTrip(instant, json); + } + + @Test + public void testLocalDate() { + LocalDate localDate = LocalDate.of(2021, 12, 2); + String json = "{\"year\":2021,\"month\":12,\"day\":2}"; + roundTrip(localDate, json); + } + + @Test + public void testLocalTime() { + LocalTime localTime = LocalTime.of(12, 34, 56, 789_012_345); + String json = "{\"hour\":12,\"minute\":34,\"second\":56,\"nano\":789012345}"; + roundTrip(localTime, json); + } + + @Test + public void testLocalDateTime() { + LocalDateTime localDateTime = LocalDateTime.of(2021, 12, 2, 12, 34, 56, 789_012_345); + String json = + "{\"date\":{\"year\":2021,\"month\":12,\"day\":2}," + + "\"time\":{\"hour\":12,\"minute\":34,\"second\":56,\"nano\":789012345}}"; + roundTrip(localDateTime, json); + } + + // uses standard enum adapter + @Test + public void testMonth() { + Month month = Month.FEBRUARY; + String json = "\"FEBRUARY\""; + assertThat(gson.toJson(month)).isEqualTo(json); + assertThat(gson.fromJson(json, Month.class)).isEqualTo(month); + } + + @Test + public void testMonthDay() { + MonthDay monthDay = MonthDay.of(2, 17); + String json = "{\"month\":2,\"day\":17}"; + roundTrip(monthDay, json); + } + + @Test + public void testOffsetDateTime() { + OffsetDateTime offsetDateTime = + OffsetDateTime.of( + LocalDate.of(2021, 12, 2), LocalTime.of(12, 34, 56, 789_012_345), ZoneOffset.UTC); + String json = + "{\"dateTime\":{\"date\":{\"year\":2021,\"month\":12,\"day\":2}," + + "\"time\":{\"hour\":12,\"minute\":34,\"second\":56,\"nano\":789012345}}," + + "\"offset\":{\"totalSeconds\":0}}"; + roundTrip(offsetDateTime, json); + } + + @Test + public void testOffsetTime() { + OffsetTime offsetTime = OffsetTime.of(LocalTime.of(12, 34, 56, 789_012_345), ZoneOffset.UTC); + String json = + "{\"time\":{\"hour\":12,\"minute\":34,\"second\":56,\"nano\":789012345}," + + "\"offset\":{\"totalSeconds\":0}}"; + roundTrip(offsetTime, json); + } + + @Test + public void testPeriod() { + Period period = Period.of(2025, 2, 3); + String json = "{\"years\":2025,\"months\":2,\"days\":3}"; + roundTrip(period, json); + } + + @Test + public void testYear() { + Year year = Year.of(2025); + String json = "{\"year\":2025}"; + roundTrip(year, json); + } + + @Test + public void testYearMonth() { + YearMonth yearMonth = YearMonth.of(2025, 2); + String json = "{\"year\":2025,\"month\":2}"; + roundTrip(yearMonth, json); + } + + @Test + public void testZoneOffset() { + ZoneOffset zoneOffset = ZoneOffset.ofTotalSeconds(-8 * 60 * 60); + String json = "{\"totalSeconds\":-28800}"; + roundTrip(zoneOffset, json); + } + + @Test + public void testZoneRegion() { + ZoneId zoneId = ZoneId.of("Asia/Shanghai"); + String json = "{\"id\":\"Asia/Shanghai\"}"; + // Object class is actually the JDK-internal ZoneRegion, but request the ZoneId adapter here + roundTrip(zoneId, ZoneId.class, json); + } + + /** + * Tests behavior when a custom adapter has been registered for the {@link ZoneId} class, and an + * instance of the JDK-internal subclass {@code ZoneRegion} is serialized as value of a field of + * type {@code ZoneId}. To preserve backward compatibility the custom adapter should be used in + * that case, even though the built-in adapter which handles {@code ZoneRegion} would be more + * specific and would normally be preferred by {@code TypeAdapterRuntimeTypeWrapper}. + */ + @Test + public void testZoneRegionCustomAdapter() { + // Gson tries to obtain adapter for runtime type ZoneRegion (which fails if class is + // inaccessible) and compile-time type, but prefers custom adapter for compile-time type + // Therefore skip this test if java.time classes are inaccessible to reflection + assumeTrue(JAVA_TIME_FIELDS_ARE_ACCESSIBLE); + + Gson customGson = + new GsonBuilder() + .registerTypeAdapter( + ZoneId.class, + new TypeAdapter() { + @Override + public void write(JsonWriter out, ZoneId value) throws IOException { + out.value("my-zone " + value.getId()); + } + + @Override + public ZoneId read(JsonReader in) { + throw new AssertionError("not needed for test"); + } + }) + .create(); + + ZoneId zoneId = ZoneId.of("Asia/Shanghai"); + // Verify that value is of JDK-internal subclass of ZoneId + assertThat(zoneId.getClass()).isNotEqualTo(ZoneId.class); + assertThat(zoneId).isNotInstanceOf(ZoneOffset.class); + ClassWithZoneId object = new ClassWithZoneId(zoneId); + + assertThat(customGson.toJson(object)).isEqualTo("{\"zoneId\":\"my-zone Asia/Shanghai\"}"); + } + + static class ClassWithZoneId { + ZoneId zoneId; + + ClassWithZoneId(ZoneId zoneId) { + this.zoneId = zoneId; + } + } + + @Test + public void testZonedDateTimeWithZoneOffset() { + ZonedDateTime zonedDateTime = + ZonedDateTime.of( + LocalDate.of(2021, 12, 2), LocalTime.of(12, 34, 56, 789_012_345), ZoneOffset.UTC); + String json = + "{\"dateTime\":{\"date\":{\"year\":2021,\"month\":12,\"day\":2}," + + "\"time\":{\"hour\":12,\"minute\":34,\"second\":56,\"nano\":789012345}}," + + "\"offset\":{\"totalSeconds\":0}," + + "\"zone\":{\"totalSeconds\":0}}"; + roundTrip(zonedDateTime, json); + } + + @Test + public void testZonedDateTimeWithZoneId() { + ZoneId zoneId = ZoneId.of("UTC+01:00"); + int totalSeconds = ((ZoneOffset) zoneId.normalized()).getTotalSeconds(); + ZonedDateTime zonedDateTime = + ZonedDateTime.of(LocalDate.of(2021, 12, 2), LocalTime.of(12, 34, 56, 789_012_345), zoneId); + String json = + "{\"dateTime\":{\"date\":{\"year\":2021,\"month\":12,\"day\":2}," + + "\"time\":{\"hour\":12,\"minute\":34,\"second\":56,\"nano\":789012345}}," + + "\"offset\":{\"totalSeconds\":" + + totalSeconds + + "}," + + "\"zone\":{\"id\":\"" + + zoneId.getId() + + "\"}}"; + roundTrip(zonedDateTime, json); + } + + @Test + public void testZonedDateTimeWithZoneIdThatHasAdapter() { + TypeAdapter zoneIdAdapter = + new TypeAdapter<>() { + @Override + public void write(JsonWriter out, ZoneId value) throws IOException { + out.value(value.getId()); + } + + @Override + public ZoneId read(JsonReader in) throws IOException { + return ZoneId.of(in.nextString()); + } + }; + Gson customGson = new GsonBuilder().registerTypeAdapter(ZoneId.class, zoneIdAdapter).create(); + ZoneId zoneId = ZoneId.of("UTC+01:00"); + int totalSeconds = ((ZoneOffset) zoneId.normalized()).getTotalSeconds(); + ZonedDateTime zonedDateTime = + ZonedDateTime.of(LocalDate.of(2021, 12, 2), LocalTime.of(12, 34, 56, 789_012_345), zoneId); + String json = + "{\"dateTime\":{\"date\":{\"year\":2021,\"month\":12,\"day\":2}," + + "\"time\":{\"hour\":12,\"minute\":34,\"second\":56,\"nano\":789012345}}," + + "\"offset\":{\"totalSeconds\":" + + totalSeconds + + "}," + + "\"zone\":\"" + + zoneId.getId() + + "\"}"; + roundTrip(customGson, zonedDateTime, ZonedDateTime.class, json); + } + + /** + * Verifies that custom adapters for {@code java.time} classes have higher precedence than + * built-in ones. + */ + @Test + public void testCustomAdapter() { + Gson gson = + new GsonBuilder() + .registerTypeAdapter( + Duration.class, + new TypeAdapter() { + @Override + public void write(JsonWriter out, Duration value) throws IOException { + out.value(value.toSeconds() * 3); + } + + @Override + public Duration read(JsonReader in) throws IOException { + return Duration.ofSeconds(in.nextLong() / 3); + } + }) + .create(); + + assertThat(gson.toJson(Duration.ofSeconds(111))).isEqualTo("333"); + assertThat(gson.fromJson("333", Duration.class)).isEqualTo(Duration.ofSeconds(111)); + } + + /** Whether fields of {@code java.time} classes are accessible through reflection. */ + private static final boolean JAVA_TIME_FIELDS_ARE_ACCESSIBLE; + + static { + boolean accessible = false; + try { + Instant.class.getDeclaredField("seconds").setAccessible(true); + accessible = true; + } catch (InaccessibleObjectException e) { + // OK: we can't reflect on java.time fields + } catch (NoSuchFieldException e) { + // JDK implementation has changed and we no longer have an Instant.seconds field. + throw new AssertionError(e); + } + JAVA_TIME_FIELDS_ARE_ACCESSIBLE = accessible; + + // Print this to console to make troubleshooting Maven test execution easier + debugPrint("java.time fields are accessible: " + JAVA_TIME_FIELDS_ARE_ACCESSIBLE); + } + + @SuppressWarnings("SystemOut") + private static void debugPrint(String s) { + System.out.println(s); + } + + private void roundTrip(Object value, String expectedJson) { + roundTrip(value, value.getClass(), expectedJson); + } + + private void roundTrip(Object value, Class valueClass, String expectedJson) { + roundTrip(gson, value, valueClass, expectedJson); + if (JAVA_TIME_FIELDS_ARE_ACCESSIBLE) { + checkReflectiveTypeAdapterFactory(value, expectedJson); + } + } + + private static void roundTrip( + Gson customGson, Object value, Class valueClass, String expectedJson) { + assertUsesCustomAdapter(customGson, valueClass); + assertThat(customGson.toJson(value, valueClass)).isEqualTo(expectedJson); + assertThat(customGson.fromJson(expectedJson, valueClass)).isEqualTo(value); + } + + private static void assertUsesCustomAdapter(Gson customGson, Class valueClass) { + Class adapterClass = customGson.getAdapter(valueClass).getClass(); + assertThat(adapterClass).isNotInstanceOf(ReflectiveTypeAdapterFactory.Adapter.class); + // To be safe also check the class name (in case the adapter factory has other nested adapter + // classes as well) + assertThat(adapterClass.getName()).doesNotContain("Reflective"); + } + + // Assuming we have reflective access to the fields of java.time classes, check that + // ReflectiveTypeAdapterFactory would produce the same JSON. This ensures that we are preserving + // a compatible JSON format for those classes even though we no longer use reflection. + private void checkReflectiveTypeAdapterFactory(Object value, String expectedJson) { + List factories; + try { + Field factoriesField = gson.getClass().getDeclaredField("factories"); + factoriesField.setAccessible(true); + factories = (List) factoriesField.get(gson); + } catch (ReflectiveOperationException e) { + throw new LinkageError(e.getMessage(), e); + } + ReflectiveTypeAdapterFactory adapterFactory = + factories.stream() + .filter(f -> f instanceof ReflectiveTypeAdapterFactory) + .map(f -> (ReflectiveTypeAdapterFactory) f) + .findFirst() + .orElseThrow(); + TypeToken typeToken = TypeToken.get(value.getClass()); + @SuppressWarnings("unchecked") + TypeAdapter adapter = (TypeAdapter) adapterFactory.create(gson, typeToken); + assertThat(adapter).isNotNull(); + assertThat(adapter.toJson(value)).isEqualTo(expectedJson); + } +} diff --git a/gson/src/test/java/com/google/gson/internal/bind/JavaTimeTypeAdaptersTest.java b/gson/src/test/java/com/google/gson/internal/bind/JavaTimeTypeAdaptersTest.java new file mode 100644 index 0000000000..9972d1151b --- /dev/null +++ b/gson/src/test/java/com/google/gson/internal/bind/JavaTimeTypeAdaptersTest.java @@ -0,0 +1,18 @@ +package com.google.gson.internal.bind; + +import static com.google.common.truth.Truth.assertThat; + +import org.junit.Test; + +/** Functional tests are in {@link com.google.gson.functional.JavaTimeTest}. */ +public class JavaTimeTypeAdaptersTest { + @Test + public void testJavaTimePackage() { + assertThat(JavaTimeTypeAdapters.javaTimePackage()).isEqualTo("java.time."); + } + + @Test + public void testGetFactory() { + assertThat(new JavaTimeTypeAdapters().get()).isNotNull(); + } +} diff --git a/pom.xml b/pom.xml index 6a583fa58c..dd3d1b39f1 100644 --- a/pom.xml +++ b/pom.xml @@ -523,10 +523,10 @@ ${gson.isTestModule} - + net.sf.androidscents.signature - android-api-level-23 - 6.0_r3 + android-api-level-21 + 5.0.1_r2 com.google.gson.internal.bind.IgnoreJRERequirement diff --git a/test-graal-native-image/pom.xml b/test-graal-native-image/pom.xml index c3a61163a4..dd28098469 100644 --- a/test-graal-native-image/pom.xml +++ b/test-graal-native-image/pom.xml @@ -150,6 +150,7 @@ -H:+ReportExceptionStackTraces + --initialize-at-build-time=org.junit.jupiter.engine.discovery.MethodSegmentResolver diff --git a/test-graal-native-image/src/test/java/com/google/gson/native_test/JavaTimeTest.java b/test-graal-native-image/src/test/java/com/google/gson/native_test/JavaTimeTest.java new file mode 100644 index 0000000000..0067da84e2 --- /dev/null +++ b/test-graal-native-image/src/test/java/com/google/gson/native_test/JavaTimeTest.java @@ -0,0 +1,67 @@ +/* + * Copyright (C) 2026 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.native_test; + +import static com.google.common.truth.Truth.assertThat; + +import com.google.gson.Gson; +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; +import org.junit.jupiter.api.Test; + +/** Test for (some) {@code java.time} classes. */ +class JavaTimeTest { + private final Gson gson = new Gson(); + + @Test + void testDuration() { + Duration duration = Duration.ofSeconds(123, 456_789_012); + String json = "{\"seconds\":123,\"nanos\":456789012}"; + roundTrip(duration, json); + } + + @Test + void testInstant() { + Instant instant = Instant.ofEpochSecond(123, 456_789_012); + String json = "{\"seconds\":123,\"nanos\":456789012}"; + roundTrip(instant, json); + } + + @Test + void testLocalDate() { + LocalDate localDate = LocalDate.of(2021, 12, 2); + String json = "{\"year\":2021,\"month\":12,\"day\":2}"; + roundTrip(localDate, json); + } + + /** + * Verifies that deserialization of {@code ZoneRegion} (JDK internal subclass of {@link ZoneId}) + * is possible. + */ + @Test + void testZoneRegion() { + String json = "{\"id\":\"Asia/Shanghai\"}"; + assertThat(gson.fromJson(json, ZoneId.class).getId()).isEqualTo("Asia/Shanghai"); + } + + private void roundTrip(Object value, String json) { + Class valueClass = value.getClass(); + assertThat(gson.toJson(value, valueClass)).isEqualTo(json); + assertThat(gson.fromJson(json, valueClass)).isEqualTo(value); + } +} diff --git a/test-shrinker/src/main/java/com/example/Main.java b/test-shrinker/src/main/java/com/example/Main.java index ef6f0e646f..7d4e1ac21e 100644 --- a/test-shrinker/src/main/java/com/example/Main.java +++ b/test-shrinker/src/main/java/com/example/Main.java @@ -9,6 +9,9 @@ import com.google.gson.Gson; import com.google.gson.GsonBuilder; import com.google.gson.reflect.TypeToken; +import java.time.LocalTime; +import java.time.OffsetTime; +import java.time.ZoneOffset; import java.util.Arrays; import java.util.List; import java.util.function.BiConsumer; @@ -55,6 +58,8 @@ public static void runTests(BiConsumer outputConsumer) { testGenericClasses(outputConsumer); testDeserializingInterfaceImpl(outputConsumer); + + testJavaTime(outputConsumer); } private static void testTypeTokenWriteRead( @@ -313,4 +318,26 @@ private static void testDeserializingInterfaceImpl(BiConsumer ou } }); } + + /** + * Test for {@code java.time} classes, to make sure that the reflection usage in their current + * adapter implementations does not interfere with code shrinking tools. + */ + private static void testJavaTime(BiConsumer outputConsumer) { + Gson gson = new Gson(); + TestExecutor.run( + outputConsumer, + "Write: java.time.OffsetTime", + () -> + gson.toJson( + OffsetTime.of( + LocalTime.of(12, 34, 56, 789_012_345), ZoneOffset.ofTotalSeconds(123)))); + + String json = + "{\"time\":{\"hour\":12,\"minute\":34,\"second\":56,\"nano\":789012345},\"offset\":{\"totalSeconds\":123}}"; + TestExecutor.run( + outputConsumer, + "Read: java.time.OffsetTime", + () -> gson.fromJson(json, OffsetTime.class).toString()); + } } diff --git a/test-shrinker/src/test/java/com/google/gson/it/ShrinkingIT.java b/test-shrinker/src/test/java/com/google/gson/it/ShrinkingIT.java index 69fe9f7da6..3a3fb0be9b 100644 --- a/test-shrinker/src/test/java/com/google/gson/it/ShrinkingIT.java +++ b/test-shrinker/src/test/java/com/google/gson/it/ShrinkingIT.java @@ -225,6 +225,12 @@ public void test() throws Exception { // TODO: Currently only works for ProGuard but not R8 isTestingProGuard() ? "value" : "ClassCastException", "===", + "Write: java.time.OffsetTime", + "{\"time\":{\"hour\":12,\"minute\":34,\"second\":56,\"nano\":789012345},\"offset\":{\"totalSeconds\":123}}", + "===", + "Read: java.time.OffsetTime", + "12:34:56.789012345+00:02:03", + "===", "")); }