diff --git a/README.md b/README.md index f5eab3fe26..189ce64ba2 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,7 @@ see [`GsonBuilder.disableJdkUnsafe()`](https://javadoc.io/doc/com.google.code.gs #### Minimum Android API level +- Gson 2.14.0 and newer: API level 23 - Gson 2.11.0 and newer: API level 21 - Gson 2.10.1 and older: API level 19 diff --git a/gson/pom.xml b/gson/pom.xml index 0d03552ce0..52ade87bfd 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..5823d30ffd 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,7 +24,6 @@ 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.Duration; @@ -39,8 +53,11 @@ * 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 @@ -91,7 +108,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 +136,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 +189,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 +230,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 +304,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 +313,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 +365,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,7 +407,7 @@ public void write(JsonWriter out, ZonedDateTime value) throws IOException { }.nullSafe(); } - static final TypeAdapterFactory JAVA_TIME_FACTORY = + private static final TypeAdapterFactory JAVA_TIME_FACTORY = new TypeAdapterFactory() { @Override public TypeAdapter create(Gson gson, TypeToken typeToken) { 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..ee467d0c1e 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,7 +899,7 @@ 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) { @@ -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 6dac057a8e..caa2d61774 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..8d58798c25 --- /dev/null +++ b/gson/src/test/java/com/google/gson/functional/JavaTimeTest.java @@ -0,0 +1,353 @@ +/* + * 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 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.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.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 identical 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(Duration.class); + assertNullSafe(Instant.class); + assertNullSafe(LocalDate.class); + assertNullSafe(LocalTime.class); + assertNullSafe(LocalDateTime.class); + 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); + } + + @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); + } + + @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\"}"; + roundTrip(zoneId, ZoneId.class, json); + } + + @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/test-graal-native-image/pom.xml b/test-graal-native-image/pom.xml index 42693d5485..e9f77a6be7 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); + } +}