diff --git a/gson/src/main/java/com/google/gson/Gson.java b/gson/src/main/java/com/google/gson/Gson.java
index ef8c81e378..f46402d350 100644
--- a/gson/src/main/java/com/google/gson/Gson.java
+++ b/gson/src/main/java/com/google/gson/Gson.java
@@ -370,6 +370,7 @@ public Gson() {
factories.add(TypeAdapters.BIT_SET_FACTORY);
factories.add(DefaultDateTypeAdapter.DEFAULT_STYLE_FACTORY);
factories.add(TypeAdapters.CALENDAR_FACTORY);
+ factories.add(TypeAdapters.JAVA_TIME_FACTORY);
if (SqlTypesSupport.SUPPORTS_SQL_TYPES) {
factories.add(SqlTypesSupport.TIME_FACTORY);
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..3ccba49080 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
@@ -16,6 +16,9 @@
package com.google.gson.internal.bind;
+import static java.lang.Math.toIntExact;
+import static java.util.Objects.requireNonNull;
+
import com.google.gson.Gson;
import com.google.gson.JsonElement;
import com.google.gson.JsonIOException;
@@ -36,7 +39,17 @@
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
+import java.time.DateTimeException;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.LocalTime;
+import java.time.ZoneId;
+import java.time.ZoneOffset;
+import java.time.ZonedDateTime;
import java.util.ArrayList;
+import java.util.Arrays;
import java.util.BitSet;
import java.util.Calendar;
import java.util.Currency;
@@ -689,83 +702,339 @@ 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 the legacy serialization of various {@code java.time}
+ * types.
+ *
+ *
This is a base class for {@link Calendar}, {@link Duration}, {@link Instant}, {@link
+ * LocalDate}, {@link LocalTime}, {@link LocalDateTime}, and {@link ZoneOffset}.
+ */
+ private 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();
+ }
+ }
+ 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
+ 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 Calendar read(JsonReader in) throws IOException {
+ 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)
+ };
+ }
+ };
+
+ public static final TypeAdapterFactory CALENDAR_FACTORY =
+ newFactoryForMultipleTypes(Calendar.class, GregorianCalendar.class, CALENDAR);
+
+ public 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()};
+ }
+ };
+
+ public 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()};
+ }
+ };
+
+ public 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()
+ };
+ }
+ };
+
+ public static final TypeAdapter LOCAL_DATE_TIME =
+ new TypeAdapter() {
+ @Override
+ public LocalDateTime read(JsonReader in) throws IOException {
if (in.peek() == JsonToken.NULL) {
in.nextNull();
return null;
}
+ LocalDate localDate = null;
+ LocalTime localTime = 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;
+ case "date":
+ localDate = LOCAL_DATE.read(in);
break;
- case MONTH:
- month = value;
+ case "time":
+ localTime = LOCAL_TIME.read(in);
break;
- case DAY_OF_MONTH:
- dayOfMonth = value;
- break;
- case HOUR_OF_DAY:
- hourOfDay = value;
+ default:
+ // Ignore other fields.
+ }
+ }
+ in.endObject();
+ return requireNonNull(localTime, "Missing time field")
+ .atDate(requireNonNull(localDate, "Missing date field"));
+ }
+
+ @Override
+ public void write(JsonWriter out, LocalDateTime value) throws IOException {
+ if (value == null) {
+ out.nullValue();
+ return;
+ }
+ out.beginObject();
+ out.name("date");
+ LOCAL_DATE.write(out, value.toLocalDate());
+ out.name("time");
+ LOCAL_TIME.write(out, value.toLocalTime());
+ out.endObject();
+ }
+ };
+
+ public static final TypeAdapter ZONE_OFFSET =
+ new IntegerFieldsTypeAdapter("totalSeconds") {
+ @Override
+ ZoneOffset create(long[] values) {
+ return ZoneOffset.ofTotalSeconds(toIntExact(values[0]));
+ }
+
+ @Override
+ long[] integerValues(ZoneOffset zoneOffset) {
+ return new long[] {zoneOffset.getTotalSeconds()};
+ }
+ };
+
+ // The ZoneRegion class is not public and is the other possible implementation of ZoneId alongside
+ // ZoneOffset. If we have a ZoneId that is not a ZoneOffset, we assume it is a ZoneRegion.
+ private static final TypeAdapter ZONE_REGION =
+ new TypeAdapter() {
+ @Override
+ public ZoneId read(JsonReader in) throws IOException {
+ if (in.peek() == JsonToken.NULL) {
+ in.nextNull();
+ return null;
+ }
+ in.beginObject();
+ ZoneId zoneId = null;
+ while (in.peek() != JsonToken.END_OBJECT) {
+ if (in.nextName().equals("id")) {
+ String id = in.nextString();
+ try {
+ zoneId = ZoneId.of(id);
+ } catch (DateTimeException e) {
+ throw new JsonSyntaxException(
+ "Failed parsing '" + id + "' as ZoneId; at path " + in.getPreviousPath(), e);
+ }
+ }
+ }
+ in.endObject();
+ if (zoneId == null) {
+ throw new JsonSyntaxException("Missing id field; at path " + in.getPreviousPath());
+ }
+ return zoneId;
+ }
+
+ @Override
+ public void write(JsonWriter out, ZoneId value) throws IOException {
+ if (value == null) {
+ out.nullValue();
+ return;
+ }
+ out.beginObject();
+ out.name("id");
+ out.value(value.getId());
+ out.endObject();
+ }
+ };
+
+ // TODO: this does not currently work correctly.
+ public static final TypeAdapter ZONED_DATE_TIME =
+ new TypeAdapter() {
+ @Override
+ public ZonedDateTime read(JsonReader in) throws IOException {
+ if (in.peek() == JsonToken.NULL) {
+ in.nextNull();
+ return null;
+ }
+ 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 = LOCAL_DATE_TIME.read(in);
break;
- case MINUTE:
- minute = value;
+ case "offset":
+ zoneOffset = ZONE_OFFSET.read(in);
break;
- case SECOND:
- second = value;
+ case "zone":
+ zoneId = ZONE_REGION.read(in);
break;
default:
- // Ignore unknown JSON property
+ // Ignore other fields.
}
}
in.endObject();
- return new GregorianCalendar(year, month, dayOfMonth, hourOfDay, minute, second);
+ return ZonedDateTime.ofInstant(
+ requireNonNull(localDateTime, "Missing dateTime field"),
+ requireNonNull(zoneOffset, "Missing offset field"),
+ requireNonNull(zoneId, "Missing zone field"));
}
@Override
- public void write(JsonWriter out, Calendar value) throws IOException {
+ public void write(JsonWriter out, ZonedDateTime 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.name("dateTime");
+ LOCAL_DATE_TIME.write(out, value.toLocalDateTime());
+ out.name("offset");
+ ZONE_OFFSET.write(out, value.getOffset());
+ out.name("zone");
+ ZONE_REGION.write(out, value.getZone());
out.endObject();
}
};
- public static final TypeAdapterFactory CALENDAR_FACTORY =
- newFactoryForMultipleTypes(Calendar.class, GregorianCalendar.class, CALENDAR);
+ public 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 = LOCAL_DATE_TIME;
+ } else if (rawType == ZoneOffset.class) {
+ adapter = ZONE_OFFSET;
+ } else if (ZoneId.class.isAssignableFrom(rawType)) {
+ adapter = ZONE_REGION;
+ // } else if (rawType == ZonedDateTime.class) {
+ // adapter = ZONED_DATE_TIME;
+ }
+ @SuppressWarnings("unchecked")
+ TypeAdapter result = (TypeAdapter) adapter;
+ return result;
+ }
+ };
public static final TypeAdapter LOCALE =
new 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..8e4346f3d5 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,14 @@
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.ZoneId;
+import java.time.ZoneOffset;
+import java.time.ZonedDateTime;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.BitSet;
@@ -58,6 +69,7 @@
import java.util.UUID;
import org.junit.After;
import org.junit.Before;
+import org.junit.Ignore;
import org.junit.Test;
/**
@@ -812,6 +824,119 @@ 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 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 testJavaTimeZoneOffset() {
+ ZoneOffset zoneOffset = ZoneOffset.ofTotalSeconds(-8 * 60 * 60);
+ String json = "{\"totalSeconds\":-28800}";
+ roundTrip(zoneOffset, json);
+ }
+
+ @Test
+ public void testJavaTimeZoneRegion() {
+ ZoneId zoneId = ZoneId.of("America/Los_Angeles");
+ String json = "{\"id\":\"America/Los_Angeles\"}";
+ roundTrip(zoneId, json);
+ }
+
+ @Ignore // this adapter is not currently correct
+ @Test
+ public void testJavaZonedDateTime() {
+ 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\":{\"id\":\"Z\"}}";
+ roundTrip(zonedDateTime, 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) {
+ assertThat(gson.toJson(value)).isEqualTo(expectedJson);
+ assertThat(gson.fromJson(expectedJson, value.getClass())).isEqualTo(value);
+ if (JAVA_TIME_FIELDS_ARE_ACCESSIBLE) {
+ checkReflectiveTypeAdapterFactory(value, expectedJson);
+ }
+ }
+
+ // 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