diff --git a/gson/src/main/java/com/google/gson/Gson.java b/gson/src/main/java/com/google/gson/Gson.java
index ef8c81e378..2e904131d7 100644
--- a/gson/src/main/java/com/google/gson/Gson.java
+++ b/gson/src/main/java/com/google/gson/Gson.java
@@ -370,6 +370,10 @@ public Gson() {
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);
+ }
if (SqlTypesSupport.SUPPORTS_SQL_TYPES) {
factories.add(SqlTypesSupport.TIME_FACTORY);
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
new file mode 100644
index 0000000000..b48c49b61e
--- /dev/null
+++ b/gson/src/main/java/com/google/gson/internal/bind/JavaTimeTypeAdapters.java
@@ -0,0 +1,448 @@
+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.
+ */
+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 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 super T> 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 71f98c0034..091fc9b394 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
@@ -37,6 +37,7 @@
import java.net.URISyntaxException;
import java.net.URL;
import java.util.ArrayList;
+import java.util.Arrays;
import java.util.BitSet;
import java.util.Calendar;
import java.util.Currency;
@@ -689,81 +690,96 @@ public void write(JsonWriter out, Currency value) throws IOException {
}.nullSafe();
public static final TypeAdapterFactory CURRENCY_FACTORY = newFactory(Currency.class, CURRENCY);
+ /**
+ * 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.
+ */
+ abstract static class IntegerFieldsTypeAdapter extends TypeAdapter {
+ private final List fields;
+
+ IntegerFieldsTypeAdapter(String... fields) {
+ this.fields = Arrays.asList(fields);
+ }
+
+ abstract T create(long[] values);
+
+ abstract long[] integerValues(T t);
+
+ @Override
+ public T read(JsonReader in) throws IOException {
+ if (in.peek() == JsonToken.NULL) {
+ in.nextNull();
+ return null;
+ }
+ in.beginObject();
+ long[] values = new long[fields.size()];
+ while (in.peek() != JsonToken.END_OBJECT) {
+ String name = in.nextName();
+ int index = fields.indexOf(name);
+ if (index >= 0) {
+ values[index] = in.nextLong();
+ } else {
+ in.skipValue();
+ }
+ }
+ in.endObject();
+ return create(values);
+ }
+
+ @Override
+ public void write(JsonWriter out, T value) throws IOException {
+ if (value == null) {
+ out.nullValue();
+ return;
+ }
+ out.beginObject();
+ long[] values = integerValues(value);
+ for (int i = 0; i < fields.size(); i++) {
+ out.name(fields.get(i));
+ out.value(values[i]);
+ }
+ out.endObject();
+ }
+ }
+
public static final TypeAdapter CALENDAR =
- new TypeAdapter() {
- private static final String YEAR = "year";
- private static final String MONTH = "month";
- private static final String DAY_OF_MONTH = "dayOfMonth";
- private static final String HOUR_OF_DAY = "hourOfDay";
- private static final String MINUTE = "minute";
- private static final String SECOND = "second";
+ new IntegerFieldsTypeAdapter(
+ "year", "month", "dayOfMonth", "hourOfDay", "minute", "second") {
@Override
- public Calendar read(JsonReader in) throws IOException {
- if (in.peek() == JsonToken.NULL) {
- in.nextNull();
- return null;
- }
- in.beginObject();
- int year = 0;
- int month = 0;
- int dayOfMonth = 0;
- int hourOfDay = 0;
- int minute = 0;
- int second = 0;
- while (in.peek() != JsonToken.END_OBJECT) {
- String name = in.nextName();
- int value = in.nextInt();
- switch (name) {
- case YEAR:
- year = value;
- break;
- case MONTH:
- month = value;
- break;
- case DAY_OF_MONTH:
- dayOfMonth = value;
- break;
- case HOUR_OF_DAY:
- hourOfDay = value;
- break;
- case MINUTE:
- minute = value;
- break;
- case SECOND:
- second = value;
- break;
- default:
- // Ignore unknown JSON property
- }
- }
- in.endObject();
- return new GregorianCalendar(year, month, dayOfMonth, hourOfDay, minute, second);
+ Calendar create(long[] values) {
+ return new GregorianCalendar(
+ toIntExact(values[0]),
+ toIntExact(values[1]),
+ toIntExact(values[2]),
+ toIntExact(values[3]),
+ toIntExact(values[4]),
+ toIntExact(values[5]));
}
@Override
- public void write(JsonWriter out, Calendar value) throws IOException {
- if (value == null) {
- out.nullValue();
- return;
- }
- out.beginObject();
- out.name(YEAR);
- out.value(value.get(Calendar.YEAR));
- out.name(MONTH);
- out.value(value.get(Calendar.MONTH));
- out.name(DAY_OF_MONTH);
- out.value(value.get(Calendar.DAY_OF_MONTH));
- out.name(HOUR_OF_DAY);
- out.value(value.get(Calendar.HOUR_OF_DAY));
- out.name(MINUTE);
- out.value(value.get(Calendar.MINUTE));
- out.name(SECOND);
- out.value(value.get(Calendar.SECOND));
- out.endObject();
+ long[] integerValues(Calendar calendar) {
+ return new long[] {
+ calendar.get(Calendar.YEAR),
+ calendar.get(Calendar.MONTH),
+ calendar.get(Calendar.DAY_OF_MONTH),
+ calendar.get(Calendar.HOUR_OF_DAY),
+ calendar.get(Calendar.MINUTE),
+ calendar.get(Calendar.SECOND)
+ };
}
};
+ // This is Math.toIntExact, but works on Android level 23.
+ private static int toIntExact(long value) {
+ int castValue = (int) value;
+ if (castValue != value) {
+ throw new ArithmeticException("Too big for an int: " + value);
+ }
+ return castValue;
+ }
+
public static final TypeAdapterFactory CALENDAR_FACTORY =
newFactoryForMultipleTypes(Calendar.class, GregorianCalendar.class, CALENDAR);
@@ -813,6 +829,22 @@ public void write(JsonWriter out, Locale value) throws IOException {
public static final TypeAdapterFactory ENUM_FACTORY = EnumTypeAdapter.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 e) {
+ return null;
+ }
+ }
+
@SuppressWarnings("TypeParameterNaming")
public static TypeAdapterFactory newFactory(
TypeToken type, TypeAdapter typeAdapter) {
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 c3892188be..79eeadb8b9 100644
--- a/gson/src/test/java/com/google/gson/functional/DefaultTypeAdaptersTest.java
+++ b/gson/src/test/java/com/google/gson/functional/DefaultTypeAdaptersTest.java
@@ -30,11 +30,14 @@
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;
@@ -42,6 +45,20 @@
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;
@@ -200,6 +217,13 @@ 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) {
@@ -812,6 +836,232 @@ 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