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 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 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/pom.xml b/pom.xml index f25a4110ce..60a9f461da 100644 --- a/pom.xml +++ b/pom.xml @@ -527,8 +527,8 @@ future, could consider switching to https://github.com/open-toast/gummy-bears which accounts for Android desugaring and might allow usage of more Java classes --> net.sf.androidscents.signature - android-api-level-21 - 5.0.1_r2 + android-api-level-26 + 8.0.0_r2