From 9489c93120a9d6cad3e1a08ad101608003547749 Mon Sep 17 00:00:00 2001 From: Marcono1234 Date: Tue, 13 Jan 2026 12:57:31 +0100 Subject: [PATCH 01/15] Follow-up for `java.time` adapter changes --- README.md | 1 + gson/pom.xml | 16 + .../gson/internal/bind/EnumTypeAdapter.java | 5 +- .../internal/bind/IgnoreJRERequirement.java | 19 + .../internal/bind/JavaTimeTypeAdapters.java | 37 +- .../gson/internal/bind/TypeAdapters.java | 23 +- .../functional/DefaultTypeAdaptersTest.java | 254 +------------ .../google/gson/functional/JavaTimeTest.java | 353 ++++++++++++++++++ test-graal-native-image/pom.xml | 1 + .../google/gson/native_test/JavaTimeTest.java | 67 ++++ 10 files changed, 511 insertions(+), 265 deletions(-) create mode 100644 gson/src/test/java/com/google/gson/functional/JavaTimeTest.java create mode 100644 test-graal-native-image/src/test/java/com/google/gson/native_test/JavaTimeTest.java 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..7111c6cd4e 100644 --- a/gson/pom.xml +++ b/gson/pom.xml @@ -179,6 +179,22 @@ configuration locally). --> --illegal-access=deny + + + + java-time-test + + test + + test + + --add-opens java.base/java.time=ALL-UNNAMED + JavaTimeTest + true + + + 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); + } +} From 2ce5629402c9d13705ffdf06cf90e8fb3afb11cc Mon Sep 17 00:00:00 2001 From: Marcono1234 Date: Tue, 13 Jan 2026 13:24:16 +0100 Subject: [PATCH 02/15] Fix Gson subset build --- gson/pom.xml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/gson/pom.xml b/gson/pom.xml index 7111c6cd4e..52ade87bfd 100644 --- a/gson/pom.xml +++ b/gson/pom.xml @@ -39,6 +39,7 @@ 2025-09-10T20:39:14Z **/Java17* + false @@ -189,6 +190,7 @@ test + ${skipJavaTimeTest} --add-opens java.base/java.time=ALL-UNNAMED JavaTimeTest true @@ -408,6 +410,7 @@ gson-subset + true ${project.build.directory}/gson-subset-src From 63dd8444317ceaf2703671455d9cabb1c4b053ef Mon Sep 17 00:00:00 2001 From: Marcono1234 Date: Sun, 18 Jan 2026 01:04:36 +0100 Subject: [PATCH 03/15] Account for desugared `java.time` package name --- .../internal/bind/JavaTimeTypeAdapters.java | 136 +++++++++++------- 1 file changed, 86 insertions(+), 50 deletions(-) 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 5823d30ffd..4b573f2d8e 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 @@ -26,6 +26,7 @@ import com.google.gson.stream.JsonReader; 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; @@ -62,7 +63,11 @@ 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 = @@ -407,55 +412,6 @@ public void write(JsonWriter out, ZonedDateTime value) throws IOException { }.nullSafe(); } - private 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( @@ -463,4 +419,84 @@ 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. + */ + private static String javaTimePackage() { + try { + // Use arbitrary java.time.* class here, one which is quite simple and does not refer to many + // other classes + String c = DateTimeException.class.getName(); + int i = c.lastIndexOf('.'); + // To be safe, account for no '.' in package name (quite unlikely though) in case of Android + // API desugaring + if (i != -1) { + return c.substring(0, i + 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; + } + } } From b0dfcba88a2a40eb0549cd631704e99bd159741c Mon Sep 17 00:00:00 2001 From: Marcono1234 Date: Sun, 18 Jan 2026 01:11:53 +0100 Subject: [PATCH 04/15] Address review feedback --- README.md | 1 - ReleaseProcess.md | 2 +- .../java/com/google/gson/internal/bind/TypeAdapters.java | 2 +- .../test/java/com/google/gson/functional/JavaTimeTest.java | 2 +- pom.xml | 6 +++--- 5 files changed, 6 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 189ce64ba2..f5eab3fe26 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,6 @@ 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/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/src/main/java/com/google/gson/internal/bind/TypeAdapters.java b/gson/src/main/java/com/google/gson/internal/bind/TypeAdapters.java index ee467d0c1e..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 @@ -903,7 +903,7 @@ long[] integerValues(Calendar calendar) { 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; } diff --git a/gson/src/test/java/com/google/gson/functional/JavaTimeTest.java b/gson/src/test/java/com/google/gson/functional/JavaTimeTest.java index 8d58798c25..f3302d8d24 100644 --- a/gson/src/test/java/com/google/gson/functional/JavaTimeTest.java +++ b/gson/src/test/java/com/google/gson/functional/JavaTimeTest.java @@ -49,7 +49,7 @@ * 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), + * adapters behave identically to the reflection-based approach (to ensure backward compatibility), * see {@link #JAVA_TIME_FIELDS_ARE_ACCESSIBLE}. */ public class JavaTimeTest { diff --git a/pom.xml b/pom.xml index 9b28cce683..c29ca39fe0 100644 --- a/pom.xml +++ b/pom.xml @@ -522,10 +522,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 From 738c4b61c86765da79b2f58e65303d17b46fa454 Mon Sep 17 00:00:00 2001 From: Marcono1234 Date: Sun, 18 Jan 2026 01:40:59 +0100 Subject: [PATCH 05/15] Add shrinker test --- .../internal/bind/JavaTimeTypeAdapters.java | 4 +-- .../src/main/java/com/example/Main.java | 27 +++++++++++++++++++ .../java/com/google/gson/it/ShrinkingIT.java | 6 +++++ 3 files changed, 35 insertions(+), 2 deletions(-) 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 4b573f2d8e..c6a4203afc 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 @@ -421,8 +421,8 @@ private static T requireNonNullField(T field, String fieldName, JsonReader r } /** - * Gets the package name (with trailing '.') of the {@code java.time.*} classes, or {@code null} - * if unavailable. + * 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 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", + "===", "")); } From ae58851a2b9e53d833eb95398b5ae6f39c2652ed Mon Sep 17 00:00:00 2001 From: Marcono1234 Date: Sun, 18 Jan 2026 02:05:28 +0100 Subject: [PATCH 06/15] Add tests for `java.time` enums --- .../internal/bind/JavaTimeTypeAdapters.java | 8 ++-- .../google/gson/functional/JavaTimeTest.java | 38 +++++++++++++++++++ 2 files changed, 42 insertions(+), 4 deletions(-) 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 c6a4203afc..b639b84f09 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 @@ -433,12 +433,12 @@ private static String javaTimePackage() { try { // Use arbitrary java.time.* class here, one which is quite simple and does not refer to many // other classes - String c = DateTimeException.class.getName(); - int i = c.lastIndexOf('.'); + String className = DateTimeException.class.getName(); + int packageEnd = className.lastIndexOf('.'); // To be safe, account for no '.' in package name (quite unlikely though) in case of Android // API desugaring - if (i != -1) { - return c.substring(0, i + 1); + if (packageEnd != -1) { + return className.substring(0, packageEnd + 1); } } catch (LinkageError ignored) { // java.time.* classes are probably not available diff --git a/gson/src/test/java/com/google/gson/functional/JavaTimeTest.java b/gson/src/test/java/com/google/gson/functional/JavaTimeTest.java index f3302d8d24..629c3c819d 100644 --- a/gson/src/test/java/com/google/gson/functional/JavaTimeTest.java +++ b/gson/src/test/java/com/google/gson/functional/JavaTimeTest.java @@ -16,9 +16,11 @@ package com.google.gson.functional; import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertThrows; import com.google.gson.Gson; import com.google.gson.GsonBuilder; +import com.google.gson.JsonIOException; import com.google.gson.TypeAdapter; import com.google.gson.internal.bind.ReflectiveTypeAdapterFactory; import com.google.gson.reflect.TypeToken; @@ -27,11 +29,14 @@ 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.InstantSource; 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; @@ -62,11 +67,13 @@ public void setUp() throws Exception { @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); @@ -79,6 +86,15 @@ 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); @@ -123,6 +139,15 @@ public void testLocalDateTime() { 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); @@ -183,6 +208,7 @@ public void testZoneOffset() { 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); } @@ -275,6 +301,18 @@ public Duration read(JsonReader in) throws IOException { assertThat(gson.fromJson("333", Duration.class)).isEqualTo(Duration.ofSeconds(111)); } + /** Tests handling of {@code java.time} classes without a built-in adapter. */ + @Test + public void testUnsupportedClass() { + var e = assertThrows(JsonIOException.class, () -> gson.fromJson("{}", InstantSource.class)); + assertThat(e) + .hasMessageThat() + .isEqualTo( + "Interfaces can't be instantiated! Register an InstanceCreator or a TypeAdapter for" + + " this type. Interface name: " + + InstantSource.class.getName()); + } + /** Whether fields of {@code java.time} classes are accessible through reflection. */ private static final boolean JAVA_TIME_FIELDS_ARE_ACCESSIBLE; From 4fbcdb4cdfc04e6f5c4db982c8f525cf3c709c1c Mon Sep 17 00:00:00 2001 From: Marcono1234 Date: Sun, 18 Jan 2026 02:41:01 +0100 Subject: [PATCH 07/15] Add test for custom ZoneId adapter --- .../google/gson/functional/JavaTimeTest.java | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/gson/src/test/java/com/google/gson/functional/JavaTimeTest.java b/gson/src/test/java/com/google/gson/functional/JavaTimeTest.java index 629c3c819d..4bd48fd745 100644 --- a/gson/src/test/java/com/google/gson/functional/JavaTimeTest.java +++ b/gson/src/test/java/com/google/gson/functional/JavaTimeTest.java @@ -17,6 +17,7 @@ import static com.google.common.truth.Truth.assertThat; import static org.junit.Assert.assertThrows; +import static org.junit.Assume.assumeTrue; import com.google.gson.Gson; import com.google.gson.GsonBuilder; @@ -212,6 +213,54 @@ public void testZoneRegion() { 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 = From fc974735b2c61611466a66dd05ca7e7a140918b6 Mon Sep 17 00:00:00 2001 From: Marcono1234 Date: Sun, 18 Jan 2026 02:59:20 +0100 Subject: [PATCH 08/15] Remove test failing on JDK 11 --- .../com/google/gson/functional/JavaTimeTest.java | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/gson/src/test/java/com/google/gson/functional/JavaTimeTest.java b/gson/src/test/java/com/google/gson/functional/JavaTimeTest.java index 4bd48fd745..d44c8714a1 100644 --- a/gson/src/test/java/com/google/gson/functional/JavaTimeTest.java +++ b/gson/src/test/java/com/google/gson/functional/JavaTimeTest.java @@ -16,12 +16,10 @@ package com.google.gson.functional; import static com.google.common.truth.Truth.assertThat; -import static org.junit.Assert.assertThrows; import static org.junit.Assume.assumeTrue; import com.google.gson.Gson; import com.google.gson.GsonBuilder; -import com.google.gson.JsonIOException; import com.google.gson.TypeAdapter; import com.google.gson.internal.bind.ReflectiveTypeAdapterFactory; import com.google.gson.reflect.TypeToken; @@ -33,7 +31,6 @@ import java.time.DayOfWeek; import java.time.Duration; import java.time.Instant; -import java.time.InstantSource; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.LocalTime; @@ -350,18 +347,6 @@ public Duration read(JsonReader in) throws IOException { assertThat(gson.fromJson("333", Duration.class)).isEqualTo(Duration.ofSeconds(111)); } - /** Tests handling of {@code java.time} classes without a built-in adapter. */ - @Test - public void testUnsupportedClass() { - var e = assertThrows(JsonIOException.class, () -> gson.fromJson("{}", InstantSource.class)); - assertThat(e) - .hasMessageThat() - .isEqualTo( - "Interfaces can't be instantiated! Register an InstanceCreator or a TypeAdapter for" - + " this type. Interface name: " - + InstantSource.class.getName()); - } - /** Whether fields of {@code java.time} classes are accessible through reflection. */ private static final boolean JAVA_TIME_FIELDS_ARE_ACCESSIBLE; From c2f48680fffea235341340e10a3a50eeaa9c1f88 Mon Sep 17 00:00:00 2001 From: Marcono1234 Date: Sun, 18 Jan 2026 03:23:54 +0100 Subject: [PATCH 09/15] Adjust retrieval of java.time package name --- .../internal/bind/JavaTimeTypeAdapters.java | 10 +++------- .../bind/JavaTimeTypeAdaptersTest.java | 18 ++++++++++++++++++ 2 files changed, 21 insertions(+), 7 deletions(-) create mode 100644 gson/src/test/java/com/google/gson/internal/bind/JavaTimeTypeAdaptersTest.java 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 b639b84f09..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 @@ -429,21 +429,17 @@ private static T requireNonNullField(T field, String fieldName, JsonReader r * href="https://developer.android.com/studio/write/java8-support#library-desugaring">API * desugaring. */ - private static String javaTimePackage() { + 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('.'); - // To be safe, account for no '.' in package name (quite unlikely though) in case of Android - // API desugaring - if (packageEnd != -1) { - return className.substring(0, packageEnd + 1); - } + return className.substring(0, packageEnd + 1); } catch (LinkageError ignored) { // java.time.* classes are probably not available + return null; } - return null; } private static class AdapterFactory implements TypeAdapterFactory { 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(); + } +} From 31a1f2eb02656ee34f488250f46cbf191df83e70 Mon Sep 17 00:00:00 2001 From: Marcono1234 Date: Fri, 4 Sep 2026 13:28:16 +0200 Subject: [PATCH 10/15] Tweak Javadoc & tests --- .../gson/internal/bind/EnumTypeAdapter.java | 2 +- .../internal/bind/JavaTimeTypeAdapters.java | 4 ++ .../gson/internal/bind/TypeAdapters.java | 1 + .../google/gson/functional/JavaTimeTest.java | 37 ++++++++++++++++--- 4 files changed, 37 insertions(+), 7 deletions(-) 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 8205f26d97..c0c51e5ae9 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 @@ -54,7 +54,7 @@ public TypeAdapter create(Gson gson, TypeToken typeToken) { * 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}. + *

Taken from Java 19 method {@code 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/JavaTimeTypeAdapters.java b/gson/src/main/java/com/google/gson/internal/bind/JavaTimeTypeAdapters.java index b3968b62df..a46da7aee7 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 @@ -311,6 +311,8 @@ long[] integerValues(YearMonth yearMonth) { // 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 appropriate thing depending // on which of those two fields we see. + // Note: This constant here seems to cause eager class loading, see + // https://github.com/google/gson/pull/2972#discussion_r2702408266 private static final TypeAdapter ZONE_ID = new TypeAdapter() { @Override @@ -486,6 +488,8 @@ public TypeAdapter create(Gson gson, TypeToken typeToken) { // 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.) + // TODO: Maybe this needs an additional `ZoneId.class.isAssignableFrom(rawType)` check + // nonetheless, see https://github.com/google/gson/pull/2972#discussion_r2702021300 adapter = ZONE_ID; } else if (rawType == ZonedDateTime.class) { adapter = zonedDateTime(gson); 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 55567fa46f..e51990ec8e 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 @@ -989,6 +989,7 @@ public void write(JsonWriter out, Locale value) throws IOException { public static final TypeAdapterFactory ENUM_FACTORY = EnumTypeAdapter.FACTORY; interface FactorySupplier { + /** Gets the type adapter factory, or {@code null} if the factory cannot be supplied. */ TypeAdapterFactory get(); } diff --git a/gson/src/test/java/com/google/gson/functional/JavaTimeTest.java b/gson/src/test/java/com/google/gson/functional/JavaTimeTest.java index d44c8714a1..1ce15b19d4 100644 --- a/gson/src/test/java/com/google/gson/functional/JavaTimeTest.java +++ b/gson/src/test/java/com/google/gson/functional/JavaTimeTest.java @@ -202,14 +202,40 @@ public void testZoneOffset() { roundTrip(zoneOffset, json); } + /** + * Asserts that the given {@code zoneId} is an instance of the JDK-internal {@code ZoneRegion} + * class. + */ + private static void assertIsZoneRegion(ZoneId zoneId) { + assertThat(zoneId).isNotNull(); + // To avoid relying too much on JDK internals, only verify that it is neither the base class nor + // the other public subclass + assertThat(zoneId.getClass()).isNotEqualTo(ZoneId.class); + assertThat(zoneId).isNotInstanceOf(ZoneOffset.class); + } + @Test public void testZoneRegion() { ZoneId zoneId = ZoneId.of("Asia/Shanghai"); + assertIsZoneRegion(zoneId); String json = "{\"id\":\"Asia/Shanghai\"}"; // Object class is actually the JDK-internal ZoneRegion, but request the ZoneId adapter here roundTrip(zoneId, ZoneId.class, json); } + @Test + public void testZoneRegionSerialization() { + // TODO: Currently uses the reflective adapter, see + // https://github.com/google/gson/pull/2972#discussion_r2702021300 + assumeTrue(JAVA_TIME_FIELDS_ARE_ACCESSIBLE); + + ZoneId zoneId = ZoneId.of("Asia/Shanghai"); + assertIsZoneRegion(zoneId); + String json = "{\"id\":\"Asia/Shanghai\"}"; + // Uses runtime type of value, that is, the JDK-internal ZoneRegion + assertThat(gson.toJson(zoneId)).isEqualTo(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 @@ -219,9 +245,10 @@ public void testZoneRegion() { */ @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 + // TODO: 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 + // See https://github.com/google/gson/pull/2972#discussion_r2702021300 + // Therefore skip this test if java.time classes are inaccessible to reflection assumeTrue(JAVA_TIME_FIELDS_ARE_ACCESSIBLE); Gson customGson = @@ -242,9 +269,7 @@ public ZoneId read(JsonReader in) { .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); + assertIsZoneRegion(zoneId); ClassWithZoneId object = new ClassWithZoneId(zoneId); assertThat(customGson.toJson(object)).isEqualTo("{\"zoneId\":\"my-zone Asia/Shanghai\"}"); From 9e0059ba9f1be28f3895ac88a51a366e018cdbaa Mon Sep 17 00:00:00 2001 From: Marcono1234 Date: Sat, 19 Sep 2026 16:44:26 +0200 Subject: [PATCH 11/15] Rename class This makes the Git diff for the next commit easier to read --- ...imeTypeAdapters.java => JavaTimeTypeAdapterFactory.java} | 2 +- .../java/com/google/gson/internal/bind/TypeAdapters.java | 2 +- ...daptersTest.java => JavaTimeTypeAdapterFactoryTest.java} | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) rename gson/src/main/java/com/google/gson/internal/bind/{JavaTimeTypeAdapters.java => JavaTimeTypeAdapterFactory.java} (99%) rename gson/src/test/java/com/google/gson/internal/bind/{JavaTimeTypeAdaptersTest.java => JavaTimeTypeAdapterFactoryTest.java} (59%) diff --git a/gson/src/main/java/com/google/gson/internal/bind/JavaTimeTypeAdapters.java b/gson/src/main/java/com/google/gson/internal/bind/JavaTimeTypeAdapterFactory.java similarity index 99% rename from gson/src/main/java/com/google/gson/internal/bind/JavaTimeTypeAdapters.java rename to gson/src/main/java/com/google/gson/internal/bind/JavaTimeTypeAdapterFactory.java index a46da7aee7..afcc24c970 100644 --- a/gson/src/main/java/com/google/gson/internal/bind/JavaTimeTypeAdapters.java +++ b/gson/src/main/java/com/google/gson/internal/bind/JavaTimeTypeAdapterFactory.java @@ -59,7 +59,7 @@ * from {@link TypeAdapters#javaTimeTypeAdapterFactory()}. */ @IgnoreJRERequirement // Protected by a reflective check in `TypeAdapters` -final class JavaTimeTypeAdapters implements TypeAdapters.FactorySupplier { +final class JavaTimeTypeAdapterFactory implements TypeAdapters.FactorySupplier { @Override public TypeAdapterFactory get() { 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 e51990ec8e..a8b43d7a1b 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 @@ -1000,7 +1000,7 @@ interface FactorySupplier { public static TypeAdapterFactory javaTimeTypeAdapterFactory() { try { Class javaTimeTypeAdapterFactoryClass = - Class.forName("com.google.gson.internal.bind.JavaTimeTypeAdapters"); + Class.forName("com.google.gson.internal.bind.JavaTimeTypeAdapterFactory"); FactorySupplier supplier = (FactorySupplier) javaTimeTypeAdapterFactoryClass.getDeclaredConstructor().newInstance(); return supplier.get(); diff --git a/gson/src/test/java/com/google/gson/internal/bind/JavaTimeTypeAdaptersTest.java b/gson/src/test/java/com/google/gson/internal/bind/JavaTimeTypeAdapterFactoryTest.java similarity index 59% rename from gson/src/test/java/com/google/gson/internal/bind/JavaTimeTypeAdaptersTest.java rename to gson/src/test/java/com/google/gson/internal/bind/JavaTimeTypeAdapterFactoryTest.java index 9972d1151b..af7fe111c4 100644 --- a/gson/src/test/java/com/google/gson/internal/bind/JavaTimeTypeAdaptersTest.java +++ b/gson/src/test/java/com/google/gson/internal/bind/JavaTimeTypeAdapterFactoryTest.java @@ -5,14 +5,14 @@ import org.junit.Test; /** Functional tests are in {@link com.google.gson.functional.JavaTimeTest}. */ -public class JavaTimeTypeAdaptersTest { +public class JavaTimeTypeAdapterFactoryTest { @Test public void testJavaTimePackage() { - assertThat(JavaTimeTypeAdapters.javaTimePackage()).isEqualTo("java.time."); + assertThat(JavaTimeTypeAdapterFactory.javaTimePackage()).isEqualTo("java.time."); } @Test public void testGetFactory() { - assertThat(new JavaTimeTypeAdapters().get()).isNotNull(); + assertThat(new JavaTimeTypeAdapterFactory().get()).isNotNull(); } } From b246b10490678c72121be4240da318bb78ae7ce0 Mon Sep 17 00:00:00 2001 From: Marcono1234 Date: Sat, 19 Sep 2026 16:51:22 +0200 Subject: [PATCH 12/15] Refactor java.time adapter factory creation --- .../java/com/google/gson/GsonBuilder.java | 5 +- .../bind/JavaTimeTypeAdapterFactory.java | 693 +++++++++--------- .../gson/internal/bind/TypeAdapters.java | 21 +- .../bind/JavaTimeTypeAdapterFactoryTest.java | 18 - 4 files changed, 343 insertions(+), 394 deletions(-) delete mode 100644 gson/src/test/java/com/google/gson/internal/bind/JavaTimeTypeAdapterFactoryTest.java diff --git a/gson/src/main/java/com/google/gson/GsonBuilder.java b/gson/src/main/java/com/google/gson/GsonBuilder.java index 447ca3aa49..6f5a97207e 100644 --- a/gson/src/main/java/com/google/gson/GsonBuilder.java +++ b/gson/src/main/java/com/google/gson/GsonBuilder.java @@ -977,10 +977,7 @@ List createFactories( factories.add(TypeAdapters.BIT_SET_FACTORY); factories.add(DefaultDateTypeAdapter.DEFAULT_STYLE_FACTORY); factories.add(TypeAdapters.CALENDAR_FACTORY); - TypeAdapterFactory javaTimeFactory = TypeAdapters.javaTimeTypeAdapterFactory(); - if (javaTimeFactory != null) { - factories.add(javaTimeFactory); - } + factories.add(TypeAdapters.JAVA_TIME_FACTORY); factories.addAll(SqlTypesSupport.SQL_TYPE_FACTORIES); factories.add(ArrayTypeAdapter.FACTORY); factories.add(TypeAdapters.CLASS_FACTORY); diff --git a/gson/src/main/java/com/google/gson/internal/bind/JavaTimeTypeAdapterFactory.java b/gson/src/main/java/com/google/gson/internal/bind/JavaTimeTypeAdapterFactory.java index afcc24c970..3e79ab3fa2 100644 --- a/gson/src/main/java/com/google/gson/internal/bind/JavaTimeTypeAdapterFactory.java +++ b/gson/src/main/java/com/google/gson/internal/bind/JavaTimeTypeAdapterFactory.java @@ -26,7 +26,6 @@ import com.google.gson.stream.JsonReader; 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; @@ -43,7 +42,7 @@ import java.time.ZonedDateTime; /** - * Type adapters for {@code java.time} types. + * Type adapter factory for {@code java.time} types. * *

These adapters mimic what {@link ReflectiveTypeAdapterFactory} would produce for the same * types. That is by no means a natural encoding, given that many of the types have standard ISO @@ -54,280 +53,360 @@ * 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 in `TypeAdapters` -final class JavaTimeTypeAdapterFactory implements TypeAdapters.FactorySupplier { +final class JavaTimeTypeAdapterFactory implements TypeAdapterFactory { @Override - public TypeAdapterFactory get() { - String packageName = javaTimePackage(); - if (packageName == null) { - return null; + public TypeAdapter create(Gson gson, TypeToken type) { + Class rawType = type.getRawType(); + String className = rawType.getName(); + // Only try to load `java.time` classes when handling a `java.time` type, otherwise this could + // lead to a LinkageError on Android if `java.time` is unavailable + // Additionally handle "j$.time", the package name used by Android API desugaring, because Gson + // code is rewritten by it as well so all `java.time` references below will then actually refer + // to `j$.time` types + // It seems even with R8 shrinking and obfuscation the `j$.time` name is preserved + if (className.startsWith("java.time.") || className.startsWith("j$.time.")) { + return createImpl(gson, rawType); } - return new AdapterFactory(packageName); + return null; } - private static final TypeAdapter DURATION = - new IntegerFieldsTypeAdapter("seconds", "nanos") { - @Override - Duration create(long[] values) { - return Duration.ofSeconds(values[0], values[1]); - } + // Separate method to really only load the `Impl` class when dealing with `java.time` types, + // avoiding LinkageError when `java.time` is not available + private TypeAdapter createImpl(Gson gson, Class rawType) { + return Impl.createAdapter(gson, rawType); + } - @Override - @SuppressWarnings("JavaDurationGetSecondsGetNano") - long[] integerValues(Duration duration) { - return new long[] {duration.getSeconds(), duration.getNano()}; - } - }; + /** + * Contains the actual implementation, and all access to {@code java.time} types. + * + *

On Android support for {@code java.time} was only added in API Level 26, but Gson currently + * targets a lower API Level as minimum. Therefore this class is only accessed when actually + * handling a {@code java.time} type, meaning the application runs on a JDK, on Android API Level + * >= 26, or it uses Android API + * desugaring. + * + *

In case of Android API desugaring, D8 includes its own implementation of {@code java.time} + * and rewrites all references to it in the app. This also affects the Gson code (included as + * dependency) where all {@code java.time} references are rewritten to {@code j$.time}. + */ + @IgnoreJRERequirement // access to `java.time`; guarded by package name check above + private static class Impl { + private static final TypeAdapter DURATION = + new IntegerFieldsTypeAdapter("seconds", "nanos") { + @Override + Duration create(long[] values) { + return Duration.ofSeconds(values[0], values[1]); + } - private static final TypeAdapter INSTANT = - new IntegerFieldsTypeAdapter("seconds", "nanos") { - @Override - Instant create(long[] values) { - return Instant.ofEpochSecond(values[0], values[1]); - } + @Override + @SuppressWarnings("JavaDurationGetSecondsGetNano") + long[] integerValues(Duration duration) { + return new long[] {duration.getSeconds(), duration.getNano()}; + } + }; - @Override - @SuppressWarnings("JavaInstantGetSecondsGetNano") - long[] integerValues(Instant instant) { - return new long[] {instant.getEpochSecond(), instant.getNano()}; - } - }; + private static final TypeAdapter INSTANT = + new IntegerFieldsTypeAdapter("seconds", "nanos") { + @Override + Instant create(long[] values) { + return Instant.ofEpochSecond(values[0], values[1]); + } - private static final TypeAdapter LOCAL_DATE = - new IntegerFieldsTypeAdapter("year", "month", "day") { - @Override - LocalDate create(long[] values) { - return LocalDate.of(toIntExact(values[0]), toIntExact(values[1]), toIntExact(values[2])); - } + @Override + @SuppressWarnings("JavaInstantGetSecondsGetNano") + long[] integerValues(Instant instant) { + return new long[] {instant.getEpochSecond(), instant.getNano()}; + } + }; + + private static final TypeAdapter LOCAL_DATE = + new IntegerFieldsTypeAdapter("year", "month", "day") { + @Override + LocalDate create(long[] values) { + return LocalDate.of( + toIntExact(values[0]), toIntExact(values[1]), toIntExact(values[2])); + } - @Override - long[] integerValues(LocalDate localDate) { - return new long[] { - localDate.getYear(), localDate.getMonthValue(), localDate.getDayOfMonth() - }; - } - }; + @Override + long[] integerValues(LocalDate localDate) { + return new long[] { + localDate.getYear(), localDate.getMonthValue(), localDate.getDayOfMonth() + }; + } + }; + + private static final TypeAdapter LOCAL_TIME = + new IntegerFieldsTypeAdapter("hour", "minute", "second", "nano") { + @Override + LocalTime create(long[] values) { + return LocalTime.of( + toIntExact(values[0]), + toIntExact(values[1]), + toIntExact(values[2]), + toIntExact(values[3])); + } - private static final TypeAdapter LOCAL_TIME = - new IntegerFieldsTypeAdapter("hour", "minute", "second", "nano") { - @Override - LocalTime create(long[] values) { - return LocalTime.of( - toIntExact(values[0]), - toIntExact(values[1]), - toIntExact(values[2]), - toIntExact(values[3])); - } + @Override + long[] integerValues(LocalTime localTime) { + return new long[] { + localTime.getHour(), localTime.getMinute(), localTime.getSecond(), localTime.getNano() + }; + } + }; + private static TypeAdapter localDateTime(Gson gson) { + TypeAdapter localDateAdapter = gson.getAdapter(LocalDate.class); + TypeAdapter localTimeAdapter = gson.getAdapter(LocalTime.class); + return new TypeAdapter() { @Override - long[] integerValues(LocalTime localTime) { - return new long[] { - localTime.getHour(), localTime.getMinute(), localTime.getSecond(), localTime.getNano() - }; - } - }; - - private static TypeAdapter localDateTime(Gson gson) { - TypeAdapter localDateAdapter = gson.getAdapter(LocalDate.class); - TypeAdapter localTimeAdapter = gson.getAdapter(LocalTime.class); - return new TypeAdapter() { - @Override - public LocalDateTime read(JsonReader in) throws IOException { - LocalDate localDate = null; - LocalTime localTime = null; - in.beginObject(); - while (in.hasNext()) { - String name = in.nextName(); - switch (name) { - case "date": - localDate = localDateAdapter.read(in); - break; - case "time": - localTime = localTimeAdapter.read(in); - break; - default: - // Ignore other fields. - in.skipValue(); + public LocalDateTime read(JsonReader in) throws IOException { + LocalDate localDate = null; + LocalTime localTime = null; + in.beginObject(); + while (in.hasNext()) { + String name = in.nextName(); + switch (name) { + case "date": + localDate = localDateAdapter.read(in); + break; + case "time": + localTime = localTimeAdapter.read(in); + break; + default: + // Ignore other fields. + in.skipValue(); + } } + in.endObject(); + return LocalDateTime.of( + requireNonNullField(localDate, "date", in), + requireNonNullField(localTime, "time", in)); } - in.endObject(); - return LocalDateTime.of( - requireNonNullField(localDate, "date", in), requireNonNullField(localTime, "time", in)); - } - - @Override - public void write(JsonWriter out, LocalDateTime value) throws IOException { - out.beginObject(); - out.name("date"); - localDateAdapter.write(out, value.toLocalDate()); - out.name("time"); - localTimeAdapter.write(out, value.toLocalTime()); - out.endObject(); - } - }.nullSafe(); - } - private static final TypeAdapter MONTH_DAY = - new IntegerFieldsTypeAdapter("month", "day") { @Override - MonthDay create(long[] values) { - return MonthDay.of(toIntExact(values[0]), toIntExact(values[1])); + public void write(JsonWriter out, LocalDateTime value) throws IOException { + out.beginObject(); + out.name("date"); + localDateAdapter.write(out, value.toLocalDate()); + out.name("time"); + localTimeAdapter.write(out, value.toLocalTime()); + out.endObject(); } + }.nullSafe(); + } - @Override - long[] integerValues(MonthDay monthDay) { - return new long[] {monthDay.getMonthValue(), monthDay.getDayOfMonth()}; - } - }; - - private static TypeAdapter offsetDateTime(Gson gson) { - TypeAdapter localDateTimeAdapter = localDateTime(gson); - TypeAdapter zoneOffsetAdapter = gson.getAdapter(ZoneOffset.class); - return new TypeAdapter() { - @Override - public OffsetDateTime read(JsonReader in) throws IOException { - in.beginObject(); - LocalDateTime localDateTime = null; - ZoneOffset zoneOffset = null; - while (in.hasNext()) { - String name = in.nextName(); - switch (name) { - case "dateTime": - localDateTime = localDateTimeAdapter.read(in); - break; - case "offset": - zoneOffset = zoneOffsetAdapter.read(in); - break; - default: - // Ignore other fields. - in.skipValue(); + private static final TypeAdapter MONTH_DAY = + new IntegerFieldsTypeAdapter("month", "day") { + @Override + MonthDay create(long[] values) { + return MonthDay.of(toIntExact(values[0]), toIntExact(values[1])); } - } - in.endObject(); - return OffsetDateTime.of( - requireNonNullField(localDateTime, "dateTime", in), - requireNonNullField(zoneOffset, "offset", in)); - } - - @Override - public void write(JsonWriter out, OffsetDateTime value) throws IOException { - out.beginObject(); - out.name("dateTime"); - localDateTimeAdapter.write(out, value.toLocalDateTime()); - out.name("offset"); - zoneOffsetAdapter.write(out, value.getOffset()); - out.endObject(); - } - }.nullSafe(); - } - private static TypeAdapter offsetTime(Gson gson) { - TypeAdapter localTimeAdapter = gson.getAdapter(LocalTime.class); - TypeAdapter zoneOffsetAdapter = gson.getAdapter(ZoneOffset.class); - return new TypeAdapter() { - @Override - public OffsetTime read(JsonReader in) throws IOException { - in.beginObject(); - LocalTime localTime = null; - ZoneOffset zoneOffset = null; - while (in.hasNext()) { - String name = in.nextName(); - switch (name) { - case "time": - localTime = localTimeAdapter.read(in); - break; - case "offset": - zoneOffset = zoneOffsetAdapter.read(in); - break; - default: - // Ignore other fields. - in.skipValue(); + @Override + long[] integerValues(MonthDay monthDay) { + return new long[] {monthDay.getMonthValue(), monthDay.getDayOfMonth()}; } - } - in.endObject(); - return OffsetTime.of( - requireNonNullField(localTime, "time", in), - requireNonNullField(zoneOffset, "offset", in)); - } - - @Override - public void write(JsonWriter out, OffsetTime value) throws IOException { - out.beginObject(); - out.name("time"); - localTimeAdapter.write(out, value.toLocalTime()); - out.name("offset"); - zoneOffsetAdapter.write(out, value.getOffset()); - out.endObject(); - } - }.nullSafe(); - } + }; - private static final TypeAdapter PERIOD = - new IntegerFieldsTypeAdapter("years", "months", "days") { + private static TypeAdapter offsetDateTime(Gson gson) { + TypeAdapter localDateTimeAdapter = localDateTime(gson); + TypeAdapter zoneOffsetAdapter = gson.getAdapter(ZoneOffset.class); + return new TypeAdapter() { @Override - Period create(long[] values) { - return Period.of(toIntExact(values[0]), toIntExact(values[1]), toIntExact(values[2])); + public OffsetDateTime read(JsonReader in) throws IOException { + in.beginObject(); + LocalDateTime localDateTime = null; + ZoneOffset zoneOffset = null; + while (in.hasNext()) { + String name = in.nextName(); + switch (name) { + case "dateTime": + localDateTime = localDateTimeAdapter.read(in); + break; + case "offset": + zoneOffset = zoneOffsetAdapter.read(in); + break; + default: + // Ignore other fields. + in.skipValue(); + } + } + in.endObject(); + return OffsetDateTime.of( + requireNonNullField(localDateTime, "dateTime", in), + requireNonNullField(zoneOffset, "offset", in)); } @Override - long[] integerValues(Period period) { - return new long[] {period.getYears(), period.getMonths(), period.getDays()}; + public void write(JsonWriter out, OffsetDateTime value) throws IOException { + out.beginObject(); + out.name("dateTime"); + localDateTimeAdapter.write(out, value.toLocalDateTime()); + out.name("offset"); + zoneOffsetAdapter.write(out, value.getOffset()); + out.endObject(); } - }; + }.nullSafe(); + } - private static final TypeAdapter YEAR = - new IntegerFieldsTypeAdapter("year") { + private static TypeAdapter offsetTime(Gson gson) { + TypeAdapter localTimeAdapter = gson.getAdapter(LocalTime.class); + TypeAdapter zoneOffsetAdapter = gson.getAdapter(ZoneOffset.class); + return new TypeAdapter() { @Override - Year create(long[] values) { - return Year.of(toIntExact(values[0])); + public OffsetTime read(JsonReader in) throws IOException { + in.beginObject(); + LocalTime localTime = null; + ZoneOffset zoneOffset = null; + while (in.hasNext()) { + String name = in.nextName(); + switch (name) { + case "time": + localTime = localTimeAdapter.read(in); + break; + case "offset": + zoneOffset = zoneOffsetAdapter.read(in); + break; + default: + // Ignore other fields. + in.skipValue(); + } + } + in.endObject(); + return OffsetTime.of( + requireNonNullField(localTime, "time", in), + requireNonNullField(zoneOffset, "offset", in)); } @Override - long[] integerValues(Year year) { - return new long[] {year.getValue()}; + public void write(JsonWriter out, OffsetTime value) throws IOException { + out.beginObject(); + out.name("time"); + localTimeAdapter.write(out, value.toLocalTime()); + out.name("offset"); + zoneOffsetAdapter.write(out, value.getOffset()); + out.endObject(); } - }; + }.nullSafe(); + } - private static final TypeAdapter YEAR_MONTH = - new IntegerFieldsTypeAdapter("year", "month") { - @Override - YearMonth create(long[] values) { - return YearMonth.of(toIntExact(values[0]), toIntExact(values[1])); - } + private static final TypeAdapter PERIOD = + new IntegerFieldsTypeAdapter("years", "months", "days") { + @Override + Period create(long[] values) { + return Period.of(toIntExact(values[0]), toIntExact(values[1]), toIntExact(values[2])); + } + @Override + long[] integerValues(Period period) { + return new long[] {period.getYears(), period.getMonths(), period.getDays()}; + } + }; + + private static final TypeAdapter YEAR = + new IntegerFieldsTypeAdapter("year") { + @Override + Year create(long[] values) { + return Year.of(toIntExact(values[0])); + } + + @Override + long[] integerValues(Year year) { + return new long[] {year.getValue()}; + } + }; + + private static final TypeAdapter YEAR_MONTH = + new IntegerFieldsTypeAdapter("year", "month") { + @Override + YearMonth create(long[] values) { + return YearMonth.of(toIntExact(values[0]), toIntExact(values[1])); + } + + @Override + long[] integerValues(YearMonth yearMonth) { + return new long[] {yearMonth.getYear(), yearMonth.getMonthValue()}; + } + }; + + // 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 appropriate thing + // depending on which of those two fields we see. + private static final TypeAdapter ZONE_ID = + new TypeAdapter() { + @Override + public ZoneId read(JsonReader in) throws IOException { + in.beginObject(); + String id = null; + Integer totalSeconds = null; + while (in.hasNext()) { + String name = in.nextName(); + switch (name) { + case "id": + id = in.nextString(); + break; + case "totalSeconds": + totalSeconds = in.nextInt(); + break; + default: + // Ignore other fields. + in.skipValue(); + } + } + in.endObject(); + if (id != null) { + return ZoneId.of(id); + } else if (totalSeconds != null) { + return ZoneOffset.ofTotalSeconds(totalSeconds); + } else { + throw new JsonSyntaxException( + "Missing id or totalSeconds field; at path " + in.getPreviousPath()); + } + } + + @Override + public void write(JsonWriter out, ZoneId value) throws IOException { + if (value instanceof ZoneOffset) { + out.beginObject(); + out.name("totalSeconds"); + out.value(((ZoneOffset) value).getTotalSeconds()); + out.endObject(); + } else { + out.beginObject(); + out.name("id"); + out.value(value.getId()); + out.endObject(); + } + } + }.nullSafe(); + + private static TypeAdapter zonedDateTime(Gson gson) { + TypeAdapter localDateTimeAdapter = localDateTime(gson); + TypeAdapter zoneOffsetAdapter = gson.getAdapter(ZoneOffset.class); + TypeAdapter zoneIdAdapter = gson.getAdapter(ZoneId.class); + return new TypeAdapter() { @Override - long[] integerValues(YearMonth yearMonth) { - return new long[] {yearMonth.getYear(), yearMonth.getMonthValue()}; - } - }; - - // 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 appropriate thing depending - // on which of those two fields we see. - // Note: This constant here seems to cause eager class loading, see - // https://github.com/google/gson/pull/2972#discussion_r2702408266 - private static final TypeAdapter ZONE_ID = - new TypeAdapter() { - @Override - public ZoneId read(JsonReader in) throws IOException { + public ZonedDateTime read(JsonReader in) throws IOException { in.beginObject(); - String id = null; - Integer totalSeconds = null; + LocalDateTime localDateTime = null; + ZoneOffset zoneOffset = null; + ZoneId zoneId = null; while (in.hasNext()) { String name = in.nextName(); switch (name) { - case "id": - id = in.nextString(); + case "dateTime": + localDateTime = localDateTimeAdapter.read(in); break; - case "totalSeconds": - totalSeconds = in.nextInt(); + case "offset": + zoneOffset = zoneOffsetAdapter.read(in); + break; + case "zone": + zoneId = zoneIdAdapter.read(in); break; default: // Ignore other fields. @@ -335,130 +414,40 @@ public ZoneId read(JsonReader in) throws IOException { } } in.endObject(); - if (id != null) { - return ZoneId.of(id); - } else if (totalSeconds != null) { - return ZoneOffset.ofTotalSeconds(totalSeconds); - } else { - throw new JsonSyntaxException( - "Missing id or totalSeconds field; at path " + in.getPreviousPath()); - } + return ZonedDateTime.ofInstant( + requireNonNullField(localDateTime, "dateTime", in), + requireNonNullField(zoneOffset, "offset", in), + requireNonNullField(zoneId, "zone", in)); } @Override - public void write(JsonWriter out, ZoneId value) throws IOException { - if (value instanceof ZoneOffset) { - out.beginObject(); - out.name("totalSeconds"); - out.value(((ZoneOffset) value).getTotalSeconds()); - out.endObject(); - } else { - out.beginObject(); - out.name("id"); - out.value(value.getId()); - out.endObject(); + public void write(JsonWriter out, ZonedDateTime value) throws IOException { + if (value == null) { + out.nullValue(); + return; } + out.beginObject(); + out.name("dateTime"); + localDateTimeAdapter.write(out, value.toLocalDateTime()); + out.name("offset"); + zoneOffsetAdapter.write(out, value.getOffset()); + out.name("zone"); + zoneIdAdapter.write(out, value.getZone()); + out.endObject(); } }.nullSafe(); - - private static TypeAdapter zonedDateTime(Gson gson) { - TypeAdapter localDateTimeAdapter = localDateTime(gson); - TypeAdapter zoneOffsetAdapter = gson.getAdapter(ZoneOffset.class); - TypeAdapter zoneIdAdapter = gson.getAdapter(ZoneId.class); - return new TypeAdapter() { - @Override - public ZonedDateTime read(JsonReader in) throws IOException { - in.beginObject(); - LocalDateTime localDateTime = null; - ZoneOffset zoneOffset = null; - ZoneId zoneId = null; - while (in.hasNext()) { - String name = in.nextName(); - switch (name) { - case "dateTime": - localDateTime = localDateTimeAdapter.read(in); - break; - case "offset": - zoneOffset = zoneOffsetAdapter.read(in); - break; - case "zone": - zoneId = zoneIdAdapter.read(in); - break; - default: - // Ignore other fields. - in.skipValue(); - } - } - in.endObject(); - return ZonedDateTime.ofInstant( - requireNonNullField(localDateTime, "dateTime", in), - requireNonNullField(zoneOffset, "offset", in), - requireNonNullField(zoneId, "zone", in)); - } - - @Override - public void write(JsonWriter out, ZonedDateTime value) throws IOException { - if (value == null) { - out.nullValue(); - return; - } - out.beginObject(); - out.name("dateTime"); - localDateTimeAdapter.write(out, value.toLocalDateTime()); - out.name("offset"); - zoneOffsetAdapter.write(out, value.getOffset()); - out.name("zone"); - zoneIdAdapter.write(out, value.getZone()); - out.endObject(); - } - }.nullSafe(); - } - - private static T requireNonNullField(T field, String fieldName, JsonReader reader) { - if (field == null) { - throw new JsonSyntaxException( - "Missing " + fieldName + " field; at path " + reader.getPreviousPath()); - } - 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; + private static T requireNonNullField(T field, String fieldName, JsonReader reader) { + if (field == null) { + throw new JsonSyntaxException( + "Missing " + fieldName + " field; at path " + reader.getPreviousPath()); + } + return field; } - @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; - } + /** Creates the adapter, or {@code null} if no adapter exists for the type. */ + static TypeAdapter createAdapter(Gson gson, Class rawType) { TypeAdapter adapter = null; if (rawType == Duration.class) { adapter = DURATION; @@ -483,11 +472,11 @@ public TypeAdapter create(Gson gson, TypeToken typeToken) { } 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.) + // 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.) // TODO: Maybe this needs an additional `ZoneId.class.isAssignableFrom(rawType)` check // nonetheless, see https://github.com/google/gson/pull/2972#discussion_r2702021300 adapter = ZONE_ID; 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 a8b43d7a1b..8817bb3217 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 @@ -988,26 +988,7 @@ public void write(JsonWriter out, Locale value) throws IOException { public static final TypeAdapterFactory ENUM_FACTORY = EnumTypeAdapter.FACTORY; - interface FactorySupplier { - /** Gets the type adapter factory, or {@code null} if the factory cannot be supplied. */ - 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 = - Class.forName("com.google.gson.internal.bind.JavaTimeTypeAdapterFactory"); - FactorySupplier supplier = - (FactorySupplier) javaTimeTypeAdapterFactoryClass.getDeclaredConstructor().newInstance(); - return supplier.get(); - } catch (ReflectiveOperationException | LinkageError e) { - return null; - } - } + public static final TypeAdapterFactory JAVA_TIME_FACTORY = new JavaTimeTypeAdapterFactory(); @SuppressWarnings("TypeParameterNaming") public static TypeAdapterFactory newFactory( diff --git a/gson/src/test/java/com/google/gson/internal/bind/JavaTimeTypeAdapterFactoryTest.java b/gson/src/test/java/com/google/gson/internal/bind/JavaTimeTypeAdapterFactoryTest.java deleted file mode 100644 index af7fe111c4..0000000000 --- a/gson/src/test/java/com/google/gson/internal/bind/JavaTimeTypeAdapterFactoryTest.java +++ /dev/null @@ -1,18 +0,0 @@ -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 JavaTimeTypeAdapterFactoryTest { - @Test - public void testJavaTimePackage() { - assertThat(JavaTimeTypeAdapterFactory.javaTimePackage()).isEqualTo("java.time."); - } - - @Test - public void testGetFactory() { - assertThat(new JavaTimeTypeAdapterFactory().get()).isNotNull(); - } -} From 1c02db1f5e0e390bd6133dd852847d6c4031a16b Mon Sep 17 00:00:00 2001 From: Marcono1234 Date: Sat, 19 Sep 2026 16:52:41 +0200 Subject: [PATCH 13/15] Rename animal-sniffer ignore annotation --- ...RERequirement.java => IgnoreAndroidApiLevelRequirement.java} | 2 +- .../google/gson/internal/bind/JavaTimeTypeAdapterFactory.java | 2 +- pom.xml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) rename gson/src/main/java/com/google/gson/internal/bind/{IgnoreJRERequirement.java => IgnoreAndroidApiLevelRequirement.java} (95%) diff --git a/gson/src/main/java/com/google/gson/internal/bind/IgnoreJRERequirement.java b/gson/src/main/java/com/google/gson/internal/bind/IgnoreAndroidApiLevelRequirement.java similarity index 95% rename from gson/src/main/java/com/google/gson/internal/bind/IgnoreJRERequirement.java rename to gson/src/main/java/com/google/gson/internal/bind/IgnoreAndroidApiLevelRequirement.java index 5cc216ba9e..55cae77fda 100644 --- a/gson/src/main/java/com/google/gson/internal/bind/IgnoreJRERequirement.java +++ b/gson/src/main/java/com/google/gson/internal/bind/IgnoreAndroidApiLevelRequirement.java @@ -27,4 +27,4 @@ @Retention(RetentionPolicy.CLASS) @Target({ElementType.METHOD, ElementType.CONSTRUCTOR, ElementType.TYPE, ElementType.FIELD}) @SuppressWarnings("IdentifierName") -@interface IgnoreJRERequirement {} +@interface IgnoreAndroidApiLevelRequirement {} diff --git a/gson/src/main/java/com/google/gson/internal/bind/JavaTimeTypeAdapterFactory.java b/gson/src/main/java/com/google/gson/internal/bind/JavaTimeTypeAdapterFactory.java index 3e79ab3fa2..6305760242 100644 --- a/gson/src/main/java/com/google/gson/internal/bind/JavaTimeTypeAdapterFactory.java +++ b/gson/src/main/java/com/google/gson/internal/bind/JavaTimeTypeAdapterFactory.java @@ -92,7 +92,7 @@ private TypeAdapter createImpl(Gson gson, Class rawType) { * and rewrites all references to it in the app. This also affects the Gson code (included as * dependency) where all {@code java.time} references are rewritten to {@code j$.time}. */ - @IgnoreJRERequirement // access to `java.time`; guarded by package name check above + @IgnoreAndroidApiLevelRequirement // access to `java.time`; guarded by package name check above private static class Impl { private static final TypeAdapter DURATION = new IntegerFieldsTypeAdapter("seconds", "nanos") { diff --git a/pom.xml b/pom.xml index cb199e5ad6..93df8f7134 100644 --- a/pom.xml +++ b/pom.xml @@ -529,7 +529,7 @@ 7.0_r2 - com.google.gson.internal.bind.IgnoreJRERequirement + com.google.gson.internal.bind.IgnoreAndroidApiLevelRequirement From 3686a2c91dfd16f903e22a846ec0294a284dfd8e Mon Sep 17 00:00:00 2001 From: Marcono1234 Date: Tue, 3 Nov 2026 21:54:47 +0100 Subject: [PATCH 14/15] Extend assertions verifying non-reflective adapter --- .../google/gson/functional/JavaTimeTest.java | 9 ++++----- .../google/gson/native_test/JavaTimeTest.java | 8 ++++++++ .../src/main/java/com/example/Main.java | 17 +++++++++++++++++ .../java/com/google/gson/it/ShrinkingIT.java | 3 +++ 4 files changed, 32 insertions(+), 5 deletions(-) diff --git a/gson/src/test/java/com/google/gson/functional/JavaTimeTest.java b/gson/src/test/java/com/google/gson/functional/JavaTimeTest.java index 1ce15b19d4..2170013d81 100644 --- a/gson/src/test/java/com/google/gson/functional/JavaTimeTest.java +++ b/gson/src/test/java/com/google/gson/functional/JavaTimeTest.java @@ -416,11 +416,10 @@ private static void roundTrip( } 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"); + TypeAdapter adapter = customGson.getAdapter(valueClass); + // Check `toString()` instead of `getClass().getName()` because adapter is wrapped + // in NullSafeTypeAdapter + assertThat(adapter.toString()).contains("JavaTimeTypeAdapterFactory"); } // Assuming we have reflective access to the fields of java.time classes, check that 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 index 0067da84e2..2beca27c4a 100644 --- 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 @@ -28,6 +28,14 @@ class JavaTimeTest { private final Gson gson = new Gson(); + /** Verify that dedicated adapter is used instead of fallback reflective one */ + @Test + void testAdapter() { + // Check `toString()` instead of `getClass().getName()` because adapter is wrapped + // in NullSafeTypeAdapter + assertThat(gson.getAdapter(Instant.class).toString()).contains("JavaTimeTypeAdapterFactory"); + } + @Test void testDuration() { Duration duration = Duration.ofSeconds(123, 456_789_012); diff --git a/test-shrinker/src/main/java/com/example/Main.java b/test-shrinker/src/main/java/com/example/Main.java index 7d4e1ac21e..cf9048905a 100644 --- a/test-shrinker/src/main/java/com/example/Main.java +++ b/test-shrinker/src/main/java/com/example/Main.java @@ -9,6 +9,7 @@ import com.google.gson.Gson; import com.google.gson.GsonBuilder; import com.google.gson.reflect.TypeToken; +import java.time.Instant; import java.time.LocalTime; import java.time.OffsetTime; import java.time.ZoneOffset; @@ -325,6 +326,16 @@ private static void testDeserializingInterfaceImpl(BiConsumer ou */ private static void testJavaTime(BiConsumer outputConsumer) { Gson gson = new Gson(); + TestExecutor.run( + outputConsumer, + "Adapter: java.time.Instant", + // Because code is obfuscated cannot check adapter class name; instead get adapter for class + // where reflective adapter will be used and then compare against that + () -> + "is reflective: " + + (gson.getAdapter(Instant.class).getClass() + == gson.getAdapter(DummyReflectiveClass.class).getClass())); + TestExecutor.run( outputConsumer, "Write: java.time.OffsetTime", @@ -340,4 +351,10 @@ private static void testJavaTime(BiConsumer outputConsumer) { "Read: java.time.OffsetTime", () -> gson.fromJson(json, OffsetTime.class).toString()); } + + /** Dummy class with no registered adapter, so Gson should use the reflective one. */ + private static class DummyReflectiveClass { + @SuppressWarnings("unused") + int i; + } } 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 00ab54a073..39d17595f0 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,9 @@ public void test() throws Exception { // TODO: Currently only works for ProGuard but not R8 isTestingProGuard() ? "value" : "ClassCastException", "===", + "Adapter: java.time.Instant", + "is reflective: false", + "===", "Write: java.time.OffsetTime", "{\"time\":{\"hour\":12,\"minute\":34,\"second\":56,\"nano\":789012345},\"offset\":{\"totalSeconds\":123}}", "===", From f590c0f637c9f77364197302549c575fae0dbbec Mon Sep 17 00:00:00 2001 From: Marcono1234 Date: Thu, 5 Nov 2026 16:58:21 +0100 Subject: [PATCH 15/15] Remove old code again which was re-introduced by merge --- .../google/gson/internal/bind/TypeAdapters.java | 16 ---------------- 1 file changed, 16 deletions(-) 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 d20232e135..76c414e1ae 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 @@ -992,22 +992,6 @@ public void write(JsonWriter out, Locale value) throws IOException { public static final TypeAdapterFactory JDK_OPTIONAL_FACTORY = JdkOptionalTypeAdapters.FACTORY; - interface FactorySupplier { - TypeAdapterFactory get(); - } - - public static TypeAdapterFactory javaTimeTypeAdapterFactory() { - try { - Class javaTimeTypeAdapterFactoryClass = - Class.forName("com.google.gson.internal.bind.JavaTimeTypeAdapters"); - FactorySupplier supplier = - (FactorySupplier) javaTimeTypeAdapterFactoryClass.getDeclaredConstructor().newInstance(); - return supplier.get(); - } catch (ReflectiveOperationException | LinkageError e) { - return null; - } - } - @SuppressWarnings("TypeParameterNaming") public static TypeAdapterFactory newFactory( TypeToken type, TypeAdapter typeAdapter) {