diff --git a/ReleaseProcess.md b/ReleaseProcess.md index f431ea621a..cae620ffeb 100644 --- a/ReleaseProcess.md +++ b/ReleaseProcess.md @@ -34,7 +34,7 @@ The following describes how to perform the steps of the release locally to verif > [!CAUTION]\ > Be careful with this, these steps might be outdated or incomplete. Double-check that you are working on a copy of your local Gson Git repository and make sure you have followed all steps. To be safe you can also temporarily turn off your internet connection to avoid accidentally pushing changes to the real remote Git or Maven repository.\ -> As an alternative to the steps described below you can instead [perform a dry run](https://maven.apache.org/maven-release/maven-release-plugin/usage.html#do-a-dry-run), though this might not behave identical to a real release. +> As an alternative to the steps described below you can instead [perform a dry run](https://maven.apache.org/maven-release/maven-release-plugin/usage.html#do-a-dry-run), though this might not behave identically to a real release. 1. Make a copy of your local Gson Git repository and only work with that copy 1. Make sure you are on the `main` branch diff --git a/gson/pom.xml b/gson/pom.xml index c133ee45bf..19baf84984 100644 --- a/gson/pom.xml +++ b/gson/pom.xml @@ -39,6 +39,7 @@ 2026-10-24T01:07:07Z **/Java17* + false @@ -185,6 +186,23 @@ configuration locally). --> --illegal-access=deny + + + + java-time-test + + test + + test + + ${skipJavaTimeTest} + --add-opens java.base/java.time=ALL-UNNAMED + JavaTimeTest + true + + + @@ -402,6 +420,7 @@ gson-subset + true ${project.build.directory}/gson-subset-src diff --git a/gson/src/main/java/com/google/gson/GsonBuilder.java b/gson/src/main/java/com/google/gson/GsonBuilder.java index 64b076ede4..d9b1f13654 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.add(TypeAdapters.JDK_OPTIONAL_FACTORY); factories.addAll(SqlTypesSupport.SQL_TYPE_FACTORIES); factories.add(ArrayTypeAdapter.FACTORY); 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..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 @@ -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 {@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/IgnoreAndroidApiLevelRequirement.java b/gson/src/main/java/com/google/gson/internal/bind/IgnoreAndroidApiLevelRequirement.java new file mode 100644 index 0000000000..55cae77fda --- /dev/null +++ b/gson/src/main/java/com/google/gson/internal/bind/IgnoreAndroidApiLevelRequirement.java @@ -0,0 +1,30 @@ +/* + * 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; +import java.lang.annotation.Retention; +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") +@interface IgnoreAndroidApiLevelRequirement {} 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 deleted file mode 100644 index c51a034867..0000000000 --- a/gson/src/main/java/com/google/gson/internal/bind/IgnoreJRERequirement.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.google.gson.internal.bind; - -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -@Retention(RetentionPolicy.CLASS) -@Target({ElementType.METHOD, ElementType.CONSTRUCTOR, ElementType.TYPE, ElementType.FIELD}) -@SuppressWarnings("IdentifierName") -@interface IgnoreJRERequirement {} 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 new file mode 100644 index 0000000000..6305760242 --- /dev/null +++ b/gson/src/main/java/com/google/gson/internal/bind/JavaTimeTypeAdapterFactory.java @@ -0,0 +1,491 @@ +/* + * 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; + +import com.google.gson.Gson; +import com.google.gson.JsonSyntaxException; +import com.google.gson.TypeAdapter; +import com.google.gson.TypeAdapterFactory; +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.JsonWriter; +import java.io.IOException; +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; + +/** + * 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 + * representations. If Gson had added support for the types at the same time they appeared (in Java + * 8, released in 2014), it would surely have used those representations. Unfortunately, in the + * intervening time, people have been using the reflective representations, and changing that would + * potentially be incompatible. Meanwhile, depending on the details of private fields in JDK classes + * 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. + */ +final class JavaTimeTypeAdapterFactory implements TypeAdapterFactory { + + @Override + 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 null; + } + + // 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); + } + + /** + * 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}. + */ + @IgnoreAndroidApiLevelRequirement // 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]); + } + + @Override + @SuppressWarnings("JavaDurationGetSecondsGetNano") + long[] integerValues(Duration duration) { + return new long[] {duration.getSeconds(), duration.getNano()}; + } + }; + + private static final TypeAdapter INSTANT = + new IntegerFieldsTypeAdapter("seconds", "nanos") { + @Override + Instant create(long[] values) { + return Instant.ofEpochSecond(values[0], values[1]); + } + + @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() + }; + } + }; + + 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 + 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)); + } + + @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])); + } + + @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(); + } + } + 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(); + } + } + 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") { + @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 + 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; + } + + /** 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; + } 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.) + // 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); + } + @SuppressWarnings("unchecked") + TypeAdapter result = (TypeAdapter) adapter; + return result; + } + } +} 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 deleted file mode 100644 index 92fc764765..0000000000 --- a/gson/src/main/java/com/google/gson/internal/bind/JavaTimeTypeAdapters.java +++ /dev/null @@ -1,449 +0,0 @@ -package com.google.gson.internal.bind; - -import static java.lang.Math.toIntExact; - -import com.google.gson.Gson; -import com.google.gson.JsonSyntaxException; -import com.google.gson.TypeAdapter; -import com.google.gson.TypeAdapterFactory; -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; -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; - -/** - * Type adapters 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 - * representations. If Gson had added support for the types at the same time they appeared (in Java - * 8, released in 2014), it would surely have used those representations. Unfortunately, in the - * intervening time, people have been using the reflective representations, and changing that would - * potentially be incompatible. Meanwhile, depending on the details of private fields in JDK classes - * 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. - */ -@IgnoreJRERequirement // Protected by a reflective check -final class JavaTimeTypeAdapters implements TypeAdapters.FactorySupplier { - - @Override - public TypeAdapterFactory get() { - return JAVA_TIME_FACTORY; - } - - private static final TypeAdapter DURATION = - new IntegerFieldsTypeAdapter("seconds", "nanos") { - @Override - Duration create(long[] values) { - return Duration.ofSeconds(values[0], values[1]); - } - - @Override - @SuppressWarnings("JavaDurationGetSecondsGetNano") - long[] integerValues(Duration duration) { - return new long[] {duration.getSeconds(), duration.getNano()}; - } - }; - - private static final TypeAdapter INSTANT = - new IntegerFieldsTypeAdapter("seconds", "nanos") { - @Override - Instant create(long[] values) { - return Instant.ofEpochSecond(values[0], values[1]); - } - - @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() - }; - } - }; - - public 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 - public LocalDateTime read(JsonReader in) throws IOException { - LocalDate localDate = null; - LocalTime localTime = null; - in.beginObject(); - while (in.peek() != JsonToken.END_OBJECT) { - 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)); - } - - @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])); - } - - @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.peek() != JsonToken.END_OBJECT) { - 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 - 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.peek() != JsonToken.END_OBJECT) { - 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 - 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") { - @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.peek() != JsonToken.END_OBJECT) { - 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 - public ZonedDateTime read(JsonReader in) throws IOException { - in.beginObject(); - LocalDateTime localDateTime = null; - ZoneOffset zoneOffset = null; - ZoneId zoneId = null; - while (in.peek() != JsonToken.END_OBJECT) { - 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(); - } - - 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( - "Missing " + fieldName + " field; at path " + reader.getPreviousPath()); - } - return field; - } -} 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 5ca6875b7b..d20232e135 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 @@ -847,6 +847,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; @@ -855,8 +857,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 @@ -867,7 +882,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) { @@ -973,6 +988,8 @@ public void write(JsonWriter out, Locale value) throws IOException { public static final TypeAdapterFactory ENUM_FACTORY = EnumTypeAdapter.FACTORY; + public static final TypeAdapterFactory JAVA_TIME_FACTORY = new JavaTimeTypeAdapterFactory(); + public static final TypeAdapterFactory JDK_OPTIONAL_FACTORY = JdkOptionalTypeAdapters.FACTORY; interface FactorySupplier { 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 ca8444f3af..137a982e17 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; @@ -47,20 +44,6 @@ import java.net.URISyntaxException; 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; @@ -80,7 +63,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 @@ -244,13 +227,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) { @@ -863,232 +839,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..2170013d81 --- /dev/null +++ b/gson/src/test/java/com/google/gson/functional/JavaTimeTest.java @@ -0,0 +1,449 @@ +/* + * Copyright (C) 2026 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.gson.functional; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assume.assumeTrue; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.TypeAdapter; +import com.google.gson.internal.bind.ReflectiveTypeAdapterFactory; +import com.google.gson.reflect.TypeToken; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonWriter; +import java.io.IOException; +import java.lang.reflect.Field; +import java.lang.reflect.InaccessibleObjectException; +import java.time.DayOfWeek; +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.Month; +import java.time.MonthDay; +import java.time.OffsetDateTime; +import java.time.OffsetTime; +import java.time.Period; +import java.time.Year; +import java.time.YearMonth; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.util.List; +import org.junit.Before; +import org.junit.Test; + +/** + * Test for {@code java.time} classes. + * + *

If reflective access to JDK classes is possible, this test also verifies that the custom + * adapters behave identically to the reflection-based approach (to ensure backward compatibility), + * see {@link #JAVA_TIME_FIELDS_ARE_ACCESSIBLE}. + */ +public class JavaTimeTest { + private Gson gson; + + @Before + public void setUp() throws Exception { + gson = new Gson(); + } + + @Test + public void testNullSafe() { + assertNullSafe(DayOfWeek.class); // uses standard enum adapter + assertNullSafe(Duration.class); + assertNullSafe(Instant.class); + assertNullSafe(LocalDate.class); + assertNullSafe(LocalTime.class); + assertNullSafe(LocalDateTime.class); + assertNullSafe(Month.class); // uses standard enum adapter + assertNullSafe(MonthDay.class); + assertNullSafe(Period.class); + assertNullSafe(Year.class); + assertNullSafe(YearMonth.class); + assertNullSafe(ZoneId.class); + assertNullSafe(ZonedDateTime.class); + } + + private void assertNullSafe(Class c) { + DefaultTypeAdaptersTest.testNullSerializationAndDeserialization(gson, c); + } + + // uses standard enum adapter + @Test + public void testDayOfWeek() { + DayOfWeek day = DayOfWeek.TUESDAY; + String json = "\"TUESDAY\""; + assertThat(gson.toJson(day)).isEqualTo(json); + assertThat(gson.fromJson(json, DayOfWeek.class)).isEqualTo(day); + } + + @Test + public void testDuration() { + Duration duration = Duration.ofSeconds(123, 456_789_012); + String json = "{\"seconds\":123,\"nanos\":456789012}"; + roundTrip(duration, json); + } + + @Test + public void testDurationWithUnknownFields() { + Duration duration = Duration.ofSeconds(123, 456_789_012); + String json = "{\"seconds\":123,\"nanos\":456789012,\"tiddly\":\"pom\",\"wibble\":\"wobble\"}"; + assertThat(gson.fromJson(json, Duration.class)).isEqualTo(duration); + } + + @Test + public void testInstant() { + Instant instant = Instant.ofEpochSecond(123, 456_789_012); + String json = "{\"seconds\":123,\"nanos\":456789012}"; + roundTrip(instant, json); + } + + @Test + public void testLocalDate() { + LocalDate localDate = LocalDate.of(2021, 12, 2); + String json = "{\"year\":2021,\"month\":12,\"day\":2}"; + roundTrip(localDate, json); + } + + @Test + public void testLocalTime() { + LocalTime localTime = LocalTime.of(12, 34, 56, 789_012_345); + String json = "{\"hour\":12,\"minute\":34,\"second\":56,\"nano\":789012345}"; + roundTrip(localTime, json); + } + + @Test + public void testLocalDateTime() { + LocalDateTime localDateTime = LocalDateTime.of(2021, 12, 2, 12, 34, 56, 789_012_345); + String json = + "{\"date\":{\"year\":2021,\"month\":12,\"day\":2}," + + "\"time\":{\"hour\":12,\"minute\":34,\"second\":56,\"nano\":789012345}}"; + roundTrip(localDateTime, json); + } + + // uses standard enum adapter + @Test + public void testMonth() { + Month month = Month.FEBRUARY; + String json = "\"FEBRUARY\""; + assertThat(gson.toJson(month)).isEqualTo(json); + assertThat(gson.fromJson(json, Month.class)).isEqualTo(month); + } + + @Test + public void testMonthDay() { + MonthDay monthDay = MonthDay.of(2, 17); + String json = "{\"month\":2,\"day\":17}"; + roundTrip(monthDay, json); + } + + @Test + public void testOffsetDateTime() { + OffsetDateTime offsetDateTime = + OffsetDateTime.of( + LocalDate.of(2021, 12, 2), LocalTime.of(12, 34, 56, 789_012_345), ZoneOffset.UTC); + String json = + "{\"dateTime\":{\"date\":{\"year\":2021,\"month\":12,\"day\":2}," + + "\"time\":{\"hour\":12,\"minute\":34,\"second\":56,\"nano\":789012345}}," + + "\"offset\":{\"totalSeconds\":0}}"; + roundTrip(offsetDateTime, json); + } + + @Test + public void testOffsetTime() { + OffsetTime offsetTime = OffsetTime.of(LocalTime.of(12, 34, 56, 789_012_345), ZoneOffset.UTC); + String json = + "{\"time\":{\"hour\":12,\"minute\":34,\"second\":56,\"nano\":789012345}," + + "\"offset\":{\"totalSeconds\":0}}"; + roundTrip(offsetTime, json); + } + + @Test + public void testPeriod() { + Period period = Period.of(2025, 2, 3); + String json = "{\"years\":2025,\"months\":2,\"days\":3}"; + roundTrip(period, json); + } + + @Test + public void testYear() { + Year year = Year.of(2025); + String json = "{\"year\":2025}"; + roundTrip(year, json); + } + + @Test + public void testYearMonth() { + YearMonth yearMonth = YearMonth.of(2025, 2); + String json = "{\"year\":2025,\"month\":2}"; + roundTrip(yearMonth, json); + } + + @Test + public void testZoneOffset() { + ZoneOffset zoneOffset = ZoneOffset.ofTotalSeconds(-8 * 60 * 60); + String json = "{\"totalSeconds\":-28800}"; + roundTrip(zoneOffset, json); + } + + /** + * 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 + * 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() { + // 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 = + 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"); + assertIsZoneRegion(zoneId); + ClassWithZoneId object = new ClassWithZoneId(zoneId); + + assertThat(customGson.toJson(object)).isEqualTo("{\"zoneId\":\"my-zone Asia/Shanghai\"}"); + } + + static class ClassWithZoneId { + ZoneId zoneId; + + ClassWithZoneId(ZoneId zoneId) { + this.zoneId = zoneId; + } + } + + @Test + public void testZonedDateTimeWithZoneOffset() { + ZonedDateTime zonedDateTime = + ZonedDateTime.of( + LocalDate.of(2021, 12, 2), LocalTime.of(12, 34, 56, 789_012_345), ZoneOffset.UTC); + String json = + "{\"dateTime\":{\"date\":{\"year\":2021,\"month\":12,\"day\":2}," + + "\"time\":{\"hour\":12,\"minute\":34,\"second\":56,\"nano\":789012345}}," + + "\"offset\":{\"totalSeconds\":0}," + + "\"zone\":{\"totalSeconds\":0}}"; + roundTrip(zonedDateTime, json); + } + + @Test + public void testZonedDateTimeWithZoneId() { + ZoneId zoneId = ZoneId.of("UTC+01:00"); + int totalSeconds = ((ZoneOffset) zoneId.normalized()).getTotalSeconds(); + ZonedDateTime zonedDateTime = + ZonedDateTime.of(LocalDate.of(2021, 12, 2), LocalTime.of(12, 34, 56, 789_012_345), zoneId); + String json = + "{\"dateTime\":{\"date\":{\"year\":2021,\"month\":12,\"day\":2}," + + "\"time\":{\"hour\":12,\"minute\":34,\"second\":56,\"nano\":789012345}}," + + "\"offset\":{\"totalSeconds\":" + + totalSeconds + + "}," + + "\"zone\":{\"id\":\"" + + zoneId.getId() + + "\"}}"; + roundTrip(zonedDateTime, json); + } + + @Test + public void testZonedDateTimeWithZoneIdThatHasAdapter() { + TypeAdapter zoneIdAdapter = + new TypeAdapter<>() { + @Override + public void write(JsonWriter out, ZoneId value) throws IOException { + out.value(value.getId()); + } + + @Override + public ZoneId read(JsonReader in) throws IOException { + return ZoneId.of(in.nextString()); + } + }; + Gson customGson = new GsonBuilder().registerTypeAdapter(ZoneId.class, zoneIdAdapter).create(); + ZoneId zoneId = ZoneId.of("UTC+01:00"); + int totalSeconds = ((ZoneOffset) zoneId.normalized()).getTotalSeconds(); + ZonedDateTime zonedDateTime = + ZonedDateTime.of(LocalDate.of(2021, 12, 2), LocalTime.of(12, 34, 56, 789_012_345), zoneId); + String json = + "{\"dateTime\":{\"date\":{\"year\":2021,\"month\":12,\"day\":2}," + + "\"time\":{\"hour\":12,\"minute\":34,\"second\":56,\"nano\":789012345}}," + + "\"offset\":{\"totalSeconds\":" + + totalSeconds + + "}," + + "\"zone\":\"" + + zoneId.getId() + + "\"}"; + roundTrip(customGson, zonedDateTime, ZonedDateTime.class, json); + } + + /** + * Verifies that custom adapters for {@code java.time} classes have higher precedence than + * built-in ones. + */ + @Test + public void testCustomAdapter() { + Gson gson = + new GsonBuilder() + .registerTypeAdapter( + Duration.class, + new TypeAdapter() { + @Override + public void write(JsonWriter out, Duration value) throws IOException { + out.value(value.toSeconds() * 3); + } + + @Override + public Duration read(JsonReader in) throws IOException { + return Duration.ofSeconds(in.nextLong() / 3); + } + }) + .create(); + + assertThat(gson.toJson(Duration.ofSeconds(111))).isEqualTo("333"); + assertThat(gson.fromJson("333", Duration.class)).isEqualTo(Duration.ofSeconds(111)); + } + + /** Whether fields of {@code java.time} classes are accessible through reflection. */ + private static final boolean JAVA_TIME_FIELDS_ARE_ACCESSIBLE; + + static { + boolean accessible = false; + try { + Instant.class.getDeclaredField("seconds").setAccessible(true); + accessible = true; + } catch (InaccessibleObjectException e) { + // OK: we can't reflect on java.time fields + } catch (NoSuchFieldException e) { + // JDK implementation has changed and we no longer have an Instant.seconds field. + throw new AssertionError(e); + } + JAVA_TIME_FIELDS_ARE_ACCESSIBLE = accessible; + + // Print this to console to make troubleshooting Maven test execution easier + debugPrint("java.time fields are accessible: " + JAVA_TIME_FIELDS_ARE_ACCESSIBLE); + } + + @SuppressWarnings("SystemOut") + private static void debugPrint(String s) { + System.out.println(s); + } + + private void roundTrip(Object value, String expectedJson) { + roundTrip(value, value.getClass(), expectedJson); + } + + private void roundTrip(Object value, Class valueClass, String expectedJson) { + roundTrip(gson, value, valueClass, expectedJson); + if (JAVA_TIME_FIELDS_ARE_ACCESSIBLE) { + checkReflectiveTypeAdapterFactory(value, expectedJson); + } + } + + private static void roundTrip( + Gson customGson, Object value, Class valueClass, String expectedJson) { + assertUsesCustomAdapter(customGson, valueClass); + assertThat(customGson.toJson(value, valueClass)).isEqualTo(expectedJson); + assertThat(customGson.fromJson(expectedJson, valueClass)).isEqualTo(value); + } + + private static void assertUsesCustomAdapter(Gson customGson, Class valueClass) { + 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 + // 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/pom.xml b/pom.xml index 9509ecc98a..e6a3522072 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 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..2beca27c4a --- /dev/null +++ b/test-graal-native-image/src/test/java/com/google/gson/native_test/JavaTimeTest.java @@ -0,0 +1,75 @@ +/* + * 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(); + + /** 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); + String json = "{\"seconds\":123,\"nanos\":456789012}"; + roundTrip(duration, json); + } + + @Test + void testInstant() { + Instant instant = Instant.ofEpochSecond(123, 456_789_012); + String json = "{\"seconds\":123,\"nanos\":456789012}"; + roundTrip(instant, json); + } + + @Test + void testLocalDate() { + LocalDate localDate = LocalDate.of(2021, 12, 2); + String json = "{\"year\":2021,\"month\":12,\"day\":2}"; + roundTrip(localDate, json); + } + + /** + * Verifies that deserialization of {@code ZoneRegion} (JDK internal subclass of {@link ZoneId}) + * is possible. + */ + @Test + void testZoneRegion() { + String json = "{\"id\":\"Asia/Shanghai\"}"; + assertThat(gson.fromJson(json, ZoneId.class).getId()).isEqualTo("Asia/Shanghai"); + } + + private void roundTrip(Object value, String json) { + Class valueClass = value.getClass(); + assertThat(gson.toJson(value, valueClass)).isEqualTo(json); + assertThat(gson.fromJson(json, valueClass)).isEqualTo(value); + } +} diff --git a/test-shrinker/src/main/java/com/example/Main.java b/test-shrinker/src/main/java/com/example/Main.java index ef6f0e646f..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,10 @@ 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; import java.util.Arrays; import java.util.List; import java.util.function.BiConsumer; @@ -55,6 +59,8 @@ public static void runTests(BiConsumer outputConsumer) { testGenericClasses(outputConsumer); testDeserializingInterfaceImpl(outputConsumer); + + testJavaTime(outputConsumer); } private static void testTypeTokenWriteRead( @@ -313,4 +319,42 @@ 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, + "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", + () -> + 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()); + } + + /** 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 c087d7a085..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,15 @@ 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}}", + "===", + "Read: java.time.OffsetTime", + "12:34:56.789012345+00:02:03", + "===", "")); }