Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ see [`GsonBuilder.disableJdkUnsafe()`](https://javadoc.io/doc/com.google.code.gs

#### Minimum Android API level

- Gson 2.14.0 and newer: API level 23
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only added this because the animal-sniffer-maven-plugin config had been changed to use the API Level 23 signatures.

But it seems we don't actually use any Android 23 API yet? So alternatively we could revert this here and used the API Level 21 signatures again?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I think that would make sense. Do you want to add it to this PR?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You mean revert to 21 API Level signatures again, right?

- Gson 2.11.0 and newer: API level 21
- Gson 2.10.1 and older: API level 19

Expand Down
19 changes: 19 additions & 0 deletions gson/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
<project.build.outputTimestamp>2025-09-10T20:39:14Z</project.build.outputTimestamp>

<excludeTestCompilation>**/Java17*</excludeTestCompilation>
<skipJavaTimeTest>false</skipJavaTimeTest>
</properties>

<dependencies>
Expand Down Expand Up @@ -179,6 +180,23 @@
configuration locally). -->
<argLine>--illegal-access=deny</argLine>
</configuration>
<executions>
<!-- Additionally run `JavaTimeTest` with the `java.time` package open for reflection, to ensure backward
compatibility with previous reflection-based serialization -->
<execution>
<id>java-time-test</id>
<goals>
<goal>test</goal>
</goals>
<phase>test</phase>
<configuration>
<skipTests>${skipJavaTimeTest}</skipTests>
<argLine>--add-opens java.base/java.time=ALL-UNNAMED</argLine>
<test>JavaTimeTest</test>
<failIfNoTests>true</failIfNoTests>
</configuration>
</execution>
</executions>
</plugin>
<!-- Failsafe plugin for running integration tests against final JAR, see `*IT.java` test classes -->
<plugin>
Expand Down Expand Up @@ -392,6 +410,7 @@
<profile>
<id>gson-subset</id>
<properties>
<skipJavaTimeTest>true</skipJavaTimeTest>
<gsonSubsetSrcDir>${project.build.directory}/gson-subset-src</gsonSubsetSrcDir>
</properties>
<build>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,10 @@ public <T> TypeAdapter<T> create(Gson gson, TypeToken<T> typeToken) {
};

/**
* Taken from Java 19 method {@link HashMap.newHashMap}, using default load factor {@code 0.75F}.
* Calculates the 'capacity' needed to hold {@code numMappings} entries without resizing, using
* default load factor {@code 0.75F}.
*
* <p>Taken from Java 19 method {@link HashMap#newHashMap}.
*/
private static int calculateHashMapCapacity(int numMappings) {
return (int) Math.ceil(numMappings / 0.75F);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,29 @@
/*
* Copyright (C) 2026 Google Inc.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, I should have caught that. :-)

*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.gson.internal.bind;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
* Used for animal-sniffer-maven-plugin to suppress warnings about API being unavailable for the
* target Android API Level.
*/
@Retention(RetentionPolicy.CLASS)
@Target({ElementType.METHOD, ElementType.CONSTRUCTOR, ElementType.TYPE, ElementType.FIELD})
@SuppressWarnings("IdentifierName")
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,18 @@
/*
* Copyright (C) 2026 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.gson.internal.bind;

import static java.lang.Math.toIntExact;
Expand All @@ -9,7 +24,6 @@
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;
Expand Down Expand Up @@ -39,8 +53,11 @@
* 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.
*
* <p>This class should not directly be used, instead the type adapter factory should be obtained
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is fair, since apparently the presence of .internal. in the package name is not enough to discourage people from reaching in and grabbing things.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My main concern was actually usage from within Gson (since TypeAdapters is internal as well), to avoid having other places directly access this class and forgetting to account for the potential reflection exceptions.

* from {@link TypeAdapters#javaTimeTypeAdapterFactory()}.
*/
@IgnoreJRERequirement // Protected by a reflective check
@IgnoreJRERequirement // Protected by a reflective check in `TypeAdapters`
final class JavaTimeTypeAdapters implements TypeAdapters.FactorySupplier {

@Override
Expand Down Expand Up @@ -91,7 +108,7 @@ long[] integerValues(LocalDate localDate) {
}
};

public static final TypeAdapter<LocalTime> LOCAL_TIME =
private static final TypeAdapter<LocalTime> LOCAL_TIME =
new IntegerFieldsTypeAdapter<LocalTime>("hour", "minute", "second", "nano") {
@Override
LocalTime create(long[] values) {
Expand Down Expand Up @@ -119,7 +136,7 @@ public LocalDateTime read(JsonReader in) throws IOException {
LocalDate localDate = null;
LocalTime localTime = null;
in.beginObject();
while (in.peek() != JsonToken.END_OBJECT) {
while (in.hasNext()) {
String name = in.nextName();
switch (name) {
case "date":
Expand Down Expand Up @@ -172,7 +189,7 @@ public OffsetDateTime read(JsonReader in) throws IOException {
in.beginObject();
LocalDateTime localDateTime = null;
ZoneOffset zoneOffset = null;
while (in.peek() != JsonToken.END_OBJECT) {
while (in.hasNext()) {
String name = in.nextName();
switch (name) {
case "dateTime":
Expand Down Expand Up @@ -213,7 +230,7 @@ public OffsetTime read(JsonReader in) throws IOException {
in.beginObject();
LocalTime localTime = null;
ZoneOffset zoneOffset = null;
while (in.peek() != JsonToken.END_OBJECT) {
while (in.hasNext()) {
String name = in.nextName();
switch (name) {
case "time":
Expand Down Expand Up @@ -287,7 +304,7 @@ long[] integerValues(YearMonth yearMonth) {
// 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
// if we have a ZoneOffset. When reading, we need to construct the appropriate thing depending
// on which of those two fields we see.
private static final TypeAdapter<ZoneId> ZONE_ID =
new TypeAdapter<ZoneId>() {
Expand All @@ -296,7 +313,7 @@ public ZoneId read(JsonReader in) throws IOException {
in.beginObject();
String id = null;
Integer totalSeconds = null;
while (in.peek() != JsonToken.END_OBJECT) {
while (in.hasNext()) {
String name = in.nextName();
switch (name) {
case "id":
Expand Down Expand Up @@ -348,7 +365,7 @@ public ZonedDateTime read(JsonReader in) throws IOException {
LocalDateTime localDateTime = null;
ZoneOffset zoneOffset = null;
ZoneId zoneId = null;
while (in.peek() != JsonToken.END_OBJECT) {
while (in.hasNext()) {
String name = in.nextName();
switch (name) {
case "dateTime":
Expand Down Expand Up @@ -390,7 +407,7 @@ public void write(JsonWriter out, ZonedDateTime value) throws IOException {
}.nullSafe();
}

static final TypeAdapterFactory JAVA_TIME_FACTORY =
private static final TypeAdapterFactory JAVA_TIME_FACTORY =
new TypeAdapterFactory() {
@Override
public <T> TypeAdapter<T> create(Gson gson, TypeToken<T> typeToken) {
Expand Down
23 changes: 21 additions & 2 deletions gson/src/main/java/com/google/gson/internal/bind/TypeAdapters.java
Original file line number Diff line number Diff line change
Expand Up @@ -807,6 +807,8 @@ public void write(JsonWriter out, Currency value) throws IOException {
* An abstract {@link TypeAdapter} for classes whose JSON serialization consists of a fixed set of
* integer fields. That is the case for {@link Calendar} and the legacy serialization of various
* {@code java.time} types.
*
* <p>This class handles {@code null}; subclasses don't have to use {@link #nullSafe()}.
*/
abstract static class IntegerFieldsTypeAdapter<T> extends TypeAdapter<T> {
private final List<String> fields;
Expand All @@ -815,8 +817,21 @@ abstract static class IntegerFieldsTypeAdapter<T> extends TypeAdapter<T> {
this.fields = Arrays.asList(fields);
}

/**
* On deserialization: Creates an object from the integer values. Subclasses should use {@link
* Math#toIntExact(long)} and similar if necessary to prevent silent truncation.
*
* <p>Values have the same order as the field names provided to the {@linkplain
* #IntegerFieldsTypeAdapter(String[]) constructor}.
*/
abstract T create(long[] values);

/**
* On serialization: Extracts the integer values from the object.
*
* <p>Values must have the same order as the field names provided to the {@linkplain
* #IntegerFieldsTypeAdapter(String[]) constructor}.
*/
abstract long[] integerValues(T t);

@Override
Expand All @@ -827,7 +842,7 @@ public T read(JsonReader in) throws IOException {
}
in.beginObject();
long[] values = new long[fields.size()];
while (in.peek() != JsonToken.END_OBJECT) {
while (in.hasNext()) {
String name = in.nextName();
int index = fields.indexOf(name);
if (index >= 0) {
Expand Down Expand Up @@ -884,7 +899,7 @@ long[] integerValues(Calendar calendar) {
}
};

// TODO: update this when we are on at least Android API Level 24.
// TODO: switch to `Math#toIntExact` when we are on at least Android API Level 24.
Comment on lines -887 to +902
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Side note: Math#toIntExact throws ArithmeticException (a direct subclass of RuntimeException) instead of IllegalArgumentException which is currently used here.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, maybe we should throw ArithmeticException here too then.

private static int toIntExact(long x) {
int i = (int) x;
if (i != x) {
Expand Down Expand Up @@ -946,6 +961,10 @@ interface FactorySupplier {
TypeAdapterFactory get();
}

/**
* Adapter factory for {@code java.time} classes. Returns {@code null} if not supported by the
* current environment (e.g. too old Android version, without desugaring).
*/
Comment on lines +964 to +967
Copy link
Contributor Author

@Marcono1234 Marcono1234 Jan 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, maybe this comment I added is incorrect? When java.time is unavailable, I am not sure if trying to load JavaTimeTypeAdapters and accessing the factory would really trigger reflection exceptions. Maybe those would only occur on usage of the factory (which would be bad) or for all non-java.time classes the getName().startsWith("java.time.") would not succeed and the factory returns null (which would be fine).

Do you have an internal test which covers this, and know how this behaves?
(I will see if I can also test this with a dummy Android project)

public static TypeAdapterFactory javaTimeTypeAdapterFactory() {
try {
Class<?> javaTimeTypeAdapterFactoryClass =
Expand Down
Loading