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 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 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