diff --git a/Troubleshooting.md b/Troubleshooting.md index 184f19166e..b8dafd218b 100644 --- a/Troubleshooting.md +++ b/Troubleshooting.md @@ -54,6 +54,318 @@ module mymodule { Or in case this occurs for a field in one of your classes which you did not actually want to serialize or deserialize in the first place, you can exclude that field, see the [user guide](UserGuide.md#excluding-fields-from-serialization-and-deserialization). +## `RuntimeException`: 'Unsupported class from other JSON library: ...' + +**Symptom:** An exception with a message in the form 'Unsupported class from other JSON library: ...' is thrown + +**Reason:** You are using classes from a different JSON library with Gson, and because Gson does not support those classes it throws an exception to avoid unexpected serialization or deserialization results + +**Solution:** The easiest solution is to avoid mixing multiple JSON libraries; Gson provides the classes [`com.google.gson.JsonArray`](https://javadoc.io/doc/com.google.code.gson/gson/latest/com.google.gson/com/google/gson/JsonArray.html) and [`com.google.gson.JsonObject`](https://javadoc.io/doc/com.google.code.gson/gson/latest/com.google.gson/com/google/gson/JsonObject.html) which you can use instead of the classes from the other JSON library. + +If you cannot switch the classes you are using, see the library-specific solutions below: + +- `org.json.JSONArray`, `org.json.JSONObject` ([JSON-java](https://github.com/stleary/JSON-java), Android) +
+ + (Click to show) + + If you cannot switch to the Gson classes, but the structure of the JSON data does not have to remain the same, you can use the following Gson `TypeAdapterFactory` which you have to [register on a `GsonBuilder`](https://javadoc.io/doc/com.google.code.gson/gson/latest/com.google.gson/com/google/gson/GsonBuilder.html#registerTypeAdapterFactory(com.google.gson.TypeAdapterFactory)): + + + ```java + /** + * {@code TypeAdapterFactory} for {@link JSONArray} and {@link JSONObject}. + * + *

This factory is mainly intended for applications which cannot switch to + * Gson's own {@link JsonArray} and {@link JsonObject} classes. + */ + public class JsonOrgAdapterFactory implements TypeAdapterFactory { + private abstract static class JsonOrgAdapter extends TypeAdapter { + private final TypeAdapter jsonElementAdapter; + + public JsonOrgAdapter(TypeAdapter jsonElementAdapter) { + this.jsonElementAdapter = jsonElementAdapter; + } + + protected abstract T readJsonOrgValue(String json) throws JSONException; + + @Override + public T read(JsonReader in) throws IOException { + if (in.peek() == JsonToken.NULL) { + in.nextNull(); + return null; + } + + // For correctness convert JSON data to string, then let JSON-java parse it; + // this is pretty inefficient, but makes sure it gets all the corner cases + // of JSON-java correct + // However, unlike JSONObject this will not prevent duplicate member names + JsonElement jsonElement = jsonElementAdapter.read(in); + String json = jsonElementAdapter.toJson(jsonElement); + try { + return readJsonOrgValue(json); + } + // For Android this is a checked exception; for the latest JSON-java artifacts it isn't anymore + catch (JSONException e) { + throw new RuntimeException(e); + } + } + + @Override + public void write(JsonWriter out, T value) throws IOException { + if (value == null) { + out.nullValue(); + return; + } + + // For correctness let JSON-java perform JSON conversion, then parse again and write + // with Gson; this is pretty inefficient, but makes sure it gets all the corner cases + // of JSON-java correct + String json = value.toString(); + JsonElement jsonElement = jsonElementAdapter.fromJson(json); + jsonElementAdapter.write(out, jsonElement); + } + } + + @Override + public TypeAdapter create(Gson gson, TypeToken type) { + Class rawType = type.getRawType(); + if (rawType != JSONArray.class && rawType != JSONObject.class) { + return null; + } + + TypeAdapter jsonElementAdapter = gson.getAdapter(JsonElement.class); + + TypeAdapter adapter; + if (rawType == JSONArray.class) { + adapter = new JsonOrgAdapter(jsonElementAdapter) { + @Override + protected JSONArray readJsonOrgValue(String json) throws JSONException { + return new JSONArray(json); + } + }; + } else { + adapter = new JsonOrgAdapter(jsonElementAdapter) { + @Override + protected JSONObject readJsonOrgValue(String json) throws JSONException { + return new JSONObject(json); + } + }; + } + + // Safe due to type check at beginning of method + @SuppressWarnings("unchecked") + TypeAdapter t = (TypeAdapter) adapter; + return t; + } + } + ``` + + Otherwise, if for backward compatibility you also have to preserve the existing JSON structure which was previously produced by Gson's reflection-based adapter, you can use the following factory: + + + ```java + /** + * Custom {@code TypeAdapterFactory} for {@link JSONArray} and {@link JSONObject}, + * which uses a format similar to what Gson's reflection-based adapter would have + * used. + * + *

This factory is mainly intended for applications which in the past by accident + * relied on Gson's reflection-based adapter for {@code JSONArray} and {@code JSONObject} + * and now have to keep this format for backward compatibility. + */ + public class JsonOrgBackwardCompatibleAdapterFactory implements TypeAdapterFactory { + private abstract static class JsonOrgBackwardCompatibleAdapter extends TypeAdapter { + /** Internal field name used by JSON-java / Android for the respective JSON value class */ + private final String fieldName; + private final TypeAdapter wrappedTypeAdapter; + + public JsonOrgBackwardCompatibleAdapter(String fieldName, TypeAdapter wrappedTypeAdapter) { + this.fieldName = fieldName; + this.wrappedTypeAdapter = wrappedTypeAdapter; + } + + protected abstract T createJsonOrgValue(W wrapped) throws JSONException; + + @Override + public T read(JsonReader in) throws IOException { + if (in.peek() == JsonToken.NULL) { + in.nextNull(); + return null; + } + + in.beginObject(); + String name = in.nextName(); + if (!name.equals(fieldName)) { + throw new IllegalArgumentException("Unexpected name '" + name + "', expected '" + fieldName + "' at " + in.getPath()); + } + T value; + try { + value = createJsonOrgValue(wrappedTypeAdapter.read(in)); + } + // For Android this is a checked exception; for the latest JSON-java artifacts it isn't anymore + catch (JSONException e) { + throw new RuntimeException(e); + } + in.endObject(); + + return value; + } + + protected abstract W getWrapped(T value); + + @Override + public void write(JsonWriter out, T value) throws IOException { + if (value == null) { + out.nullValue(); + return; + } + + out.beginObject(); + out.name(fieldName); + wrappedTypeAdapter.write(out, getWrapped(value)); + out.endObject(); + } + } + + /** + * For multiple alternative field names, tries to find the first which exists on the class. + */ + private static String getFieldName(Class c, String... names) throws NoSuchFieldException { + assert(names.length > 0); + NoSuchFieldException exception = null; + + for (String name : names) { + try { + Field unused = c.getDeclaredField(name); + return name; + } catch (NoSuchFieldException e) { + exception = e; + } + } + + throw exception; + } + + @Override + public TypeAdapter create(Gson gson, TypeToken type) { + Class rawType = type.getRawType(); + + // Note: This handling for JSONObject.NULL is not the same as the previous Gson reflection-based + // behavior which would have written `{}`, but this implementation here probably makes more sense + if (rawType == JSONObject.NULL.getClass()) { + return new TypeAdapter() { + @Override + public T read(JsonReader in) throws IOException { + in.nextNull(); + return null; + } + + @Override + public void write(JsonWriter out, T value) throws IOException { + out.nullValue(); + } + }; + } + + if (rawType != JSONArray.class && rawType != JSONObject.class) { + return null; + } + + TypeAdapter adapter; + if (rawType == JSONArray.class) { + // Choose correct field name depending on whether JSON-java or Android is used + String fieldName; + try { + String jsonJavaName = "myArrayList"; + String androidName = "values"; + fieldName = getFieldName(JSONArray.class, jsonJavaName, androidName); + } catch (NoSuchFieldException e) { + throw new RuntimeException("Unable to get internal field name for JSONArray", e); + } + + TypeAdapter> wrappedAdapter = gson.getAdapter(new TypeToken> () {}); + adapter = new JsonOrgBackwardCompatibleAdapter, JSONArray>(fieldName, wrappedAdapter) { + @Override + protected JSONArray createJsonOrgValue(List wrapped) throws JSONException { + JSONArray jsonArray = new JSONArray(); + // Unlike JSONArray(Collection) constructor, `put` does not wrap elements and is therefore closer + // to original Gson reflection-based behavior + for (Object element : wrapped) { + jsonArray.put(element); + } + + return jsonArray; + } + + @Override + protected List getWrapped(JSONArray jsonArray) { + // Cannot use JSONArray.toList() because that converts elements + List list = new ArrayList<>(jsonArray.length()); + for (int i = 0; i < jsonArray.length(); i++) { + // Use opt(int) because get(int) cannot handle null values + Object element = jsonArray.opt(i); + list.add(element); + } + + return list; + } + }; + } else { + // Choose correct field name depending on whether JSON-java or Android is used + String fieldName; + try { + String jsonJavaName = "map"; + String androidName = "nameValuePairs"; + fieldName = getFieldName(JSONObject.class, jsonJavaName, androidName); + } catch (NoSuchFieldException e) { + throw new RuntimeException("Unable to get internal field name for JSONObject", e); + } + + TypeAdapter> wrappedAdapter = gson.getAdapter(new TypeToken> () {}); + adapter = new JsonOrgBackwardCompatibleAdapter, JSONObject>(fieldName, wrappedAdapter) { + @Override + protected JSONObject createJsonOrgValue(Map map) throws JSONException { + // JSONObject(Map) constructor wraps elements, so instead put elements separately to be closer + // to original Gson reflection-based behavior + JSONObject jsonObject = new JSONObject(); + for (Entry entry : map.entrySet()) { + jsonObject.put(entry.getKey(), entry.getValue()); + } + + return jsonObject; + } + + @Override + protected Map getWrapped(JSONObject jsonObject) { + // Cannot use JSONObject.toMap() because that converts elements + Map map = new LinkedHashMap<>(jsonObject.length()); + @SuppressWarnings("unchecked") // Old JSON-java versions return just `Iterator` instead of `Iterator` + Iterator names = jsonObject.keys(); + while (names.hasNext()) { + String name = names.next(); + // Use opt(String) because get(String) cannot handle null values + // Most likely null values cannot occur normally though; they would be JSONObject.NULL + map.put(name, jsonObject.opt(name)); + } + + return map; + } + }; + } + + // Safe due to type check at beginning of method + @SuppressWarnings("unchecked") + TypeAdapter t = (TypeAdapter) adapter; + return t; + } + } + ``` + + + +**Important:** Verify carefully that these solutions work as expected for your use case and produce the desired JSON data or parse the JSON data without issues. There might be corner cases where they behave slightly differently than Gson's reflection-based adapter, respectively behave differently than the other JSON library would behave. + ## Android app not working in Release mode; random property names **Symptom:** Your Android app is working fine in Debug mode but fails in Release mode and the JSON properties have seemingly random names such as `a`, `b`, ... diff --git a/gson/pom.xml b/gson/pom.xml index 9a48ef4ded..cd21541c19 100644 --- a/gson/pom.xml +++ b/gson/pom.xml @@ -49,6 +49,16 @@ 2.20.0 + + + org.json + json + + 20090211 + test + + junit junit diff --git a/gson/src/main/java/com/google/gson/Gson.java b/gson/src/main/java/com/google/gson/Gson.java index c6f8508ef1..2d6daa3fa5 100644 --- a/gson/src/main/java/com/google/gson/Gson.java +++ b/gson/src/main/java/com/google/gson/Gson.java @@ -25,6 +25,7 @@ import com.google.gson.internal.bind.ArrayTypeAdapter; import com.google.gson.internal.bind.CollectionTypeAdapterFactory; import com.google.gson.internal.bind.DateTypeAdapter; +import com.google.gson.internal.bind.UnsupportedJsonLibraryTypeAdapterFactory; import com.google.gson.internal.bind.JsonAdapterAnnotationTypeAdapterFactory; import com.google.gson.internal.bind.JsonTreeReader; import com.google.gson.internal.bind.JsonTreeWriter; @@ -340,6 +341,9 @@ public Gson() { this.jsonAdapterFactory = new JsonAdapterAnnotationTypeAdapterFactory(constructorConstructor); factories.add(jsonAdapterFactory); factories.add(TypeAdapters.ENUM_FACTORY); + // Register this right before reflection-based adapter to allow other adapters to handle these + // types (if possible) and to let users specify their own custom adapters + factories.add(UnsupportedJsonLibraryTypeAdapterFactory.INSTANCE); factories.add(new ReflectiveTypeAdapterFactory( constructorConstructor, fieldNamingStrategy, excluder, jsonAdapterFactory, reflectionFilters)); diff --git a/gson/src/main/java/com/google/gson/internal/bind/UnsupportedJsonLibraryTypeAdapterFactory.java b/gson/src/main/java/com/google/gson/internal/bind/UnsupportedJsonLibraryTypeAdapterFactory.java new file mode 100644 index 0000000000..0a08cb79e4 --- /dev/null +++ b/gson/src/main/java/com/google/gson/internal/bind/UnsupportedJsonLibraryTypeAdapterFactory.java @@ -0,0 +1,85 @@ +/* + * Copyright (C) 2023 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 com.google.gson.Gson; +import com.google.gson.TypeAdapter; +import com.google.gson.TypeAdapterFactory; +import com.google.gson.internal.TroubleshootingGuide; +import com.google.gson.reflect.TypeToken; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonWriter; +import java.io.IOException; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +/** + * {@code TypeAdapterFactory} which throws an exception when trying to serialize or + * deserialize unsupported classes from third-party JSON libraries. + * + *

This is mainly intended as help for users who accidentally mix Gson and other + * JSON libraries and are then surprised by unexpected JSON data or issues when trying + * to deserialize the JSON data. + */ +public class UnsupportedJsonLibraryTypeAdapterFactory implements TypeAdapterFactory { + public static final UnsupportedJsonLibraryTypeAdapterFactory INSTANCE = new UnsupportedJsonLibraryTypeAdapterFactory(); + + private UnsupportedJsonLibraryTypeAdapterFactory() { + } + + // Cover JSON classes from popular libraries which might be used by accident with Gson + // Don't have to cover classes which implement `Collection` / `List` or `Map` because + // Gson's built-in adapters for these types should be able to handle them just fine + private static final Set UNSUPPORTED_CLASS_NAMES = new HashSet<>(Arrays.asList( + // https://github.com/stleary/JSON-java and Android + "org.json.JSONArray", + "org.json.JSONObject", + // https://github.com/eclipse-vertx/vert.x + "io.vertx.core.json.JsonArray", + "io.vertx.core.json.JsonObject" + )); + + @Override + public TypeAdapter create(Gson gson, TypeToken type) { + final String className = type.getRawType().getName(); + if (!UNSUPPORTED_CLASS_NAMES.contains(className)) { + return null; + } + + // Don't directly throw exception here in case no instance of the class is every serialized + // or deserialized, instead only thrown when actual serialization or deserialization attempt + // occurs + return new TypeAdapter() { + private RuntimeException createException() { + // TODO: Use more specific exception type; also adjust Troubleshooting.md entry then + return new RuntimeException("Unsupported class from other JSON library: " + className + + "\nSee " + TroubleshootingGuide.createUrl("unsupported-json-library-class")); + } + + @Override + public T read(JsonReader in) throws IOException { + throw createException(); + } + + @Override + public void write(JsonWriter out, T value) throws IOException { + throw createException(); + } + }; + } +} diff --git a/gson/src/test/java/com/google/gson/functional/JsonOrgInteropTest.java b/gson/src/test/java/com/google/gson/functional/JsonOrgInteropTest.java new file mode 100644 index 0000000000..1fc6acbe98 --- /dev/null +++ b/gson/src/test/java/com/google/gson/functional/JsonOrgInteropTest.java @@ -0,0 +1,649 @@ +/* + * Copyright (C) 2023 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.functional; + +import static com.google.common.truth.Truth.assertAbout; +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertThrows; + +import com.google.common.truth.FailureMetadata; +import com.google.common.truth.StandardSubjectBuilder; +import com.google.common.truth.Subject; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.TypeAdapter; +import com.google.gson.TypeAdapterFactory; +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.lang.reflect.Field; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import org.checkerframework.checker.nullness.qual.Nullable; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import org.json.JSONString; +import org.junit.Test; + +/** + * Tests interoperability with https://github.com/stleary/JSON-java ({@code org.json} package). + */ +public class JsonOrgInteropTest { + @Test + public void testNoCustomAdapter() { + Gson gson = new Gson(); + + // Merely requesting the adapter should not throw an exception + assertThat(gson.getAdapter(JSONArray.class)).isNotNull(); + assertThat(gson.getAdapter(JSONObject.class)).isNotNull(); + + String expectedMessageArray = "Unsupported class from other JSON library: org.json.JSONArray" + + "\nSee https://github.com/google/gson/blob/main/Troubleshooting.md#unsupported-json-library-class"; + String expectedMessageObject = "Unsupported class from other JSON library: org.json.JSONObject" + + "\nSee https://github.com/google/gson/blob/main/Troubleshooting.md#unsupported-json-library-class"; + + // TODO: Adjust these once more specific exception type than RuntimeException is thrown + Exception e = assertThrows(RuntimeException.class, () -> gson.toJson(new JSONArray())); + assertThat(e).hasMessageThat().isEqualTo(expectedMessageArray); + + e = assertThrows(RuntimeException.class, () -> gson.toJson(new JSONObject())); + assertThat(e).hasMessageThat().isEqualTo(expectedMessageObject); + + e = assertThrows(RuntimeException.class, () -> gson.fromJson("[]", JSONArray.class)); + assertThat(e).hasMessageThat().isEqualTo(expectedMessageArray); + + e = assertThrows(RuntimeException.class, () -> gson.fromJson("{}", JSONObject.class)); + assertThat(e).hasMessageThat().isEqualTo(expectedMessageObject); + } + + // Custom classes for equality assertions to avoid using directly JSONArray and JSONObject + // which perform element wrapping and conversion, and because their `toList()` and `toMap()` + // methods also recursively convert values + private static class ExpectedJSONArray { + public final List elements; + + public ExpectedJSONArray(List elements) { + this.elements = elements; + } + + @Override + public String toString() { + return "JSONArray" + elements; + } + } + + private static class ExpectedJSONObject { + public final Map entries; + + public ExpectedJSONObject(Map entries) { + this.entries = entries; + } + + @Override + public String toString() { + return "JSONObject" + entries; + } + } + + private abstract static class JsonOrgBaseSubject extends Subject { + protected JsonOrgBaseSubject(FailureMetadata metadata, @Nullable Object actual) { + super(metadata, actual); + } + + protected void checkElementValues(String message, Object expected, Object actual) { + StandardSubjectBuilder builder = check(message); + + if (actual instanceof JSONArray) { + builder.about(JSONArraySubject.jsonArrays()).that((JSONArray) actual).isEqualTo(expected); + } else if (actual instanceof JSONObject) { + builder.about(JSONObjectSubject.jsonObjects()).that((JSONObject) actual).isEqualTo(expected); + } else { + builder.that(actual).isEqualTo(expected); + } + } + } + + private static class JSONArraySubject extends JsonOrgBaseSubject { + private final @Nullable JSONArray actual; + + private JSONArraySubject(FailureMetadata failureMetadata, @Nullable JSONArray subject) { + super(failureMetadata, subject); + this.actual = subject; + } + + public static Factory jsonArrays() { + return JSONArraySubject::new; + } + + public static JSONArraySubject assertThat(@Nullable JSONArray actual) { + return assertAbout(JSONArraySubject.jsonArrays()).that(actual); + } + + @Override + public void isEqualTo(Object expected) { + if (!(expected instanceof ExpectedJSONArray)) { + failWithActual("did not expect to be", "a JSONArray"); + } + isNotNull(); + + List expectedElements = ((ExpectedJSONArray) expected).elements; + check("length()").that(actual.length()).isEqualTo(expectedElements.size()); + + for (int i = 0; i < expectedElements.size(); i++) { + Object actualElement = actual.opt(i); + Object expectedElement = expectedElements.get(i); + + checkElementValues("elements[" + i + "]", expectedElement, actualElement); + } + } + } + + private static class JSONObjectSubject extends JsonOrgBaseSubject { + private final @Nullable JSONObject actual; + + private JSONObjectSubject(FailureMetadata failureMetadata, @Nullable JSONObject subject) { + super(failureMetadata, subject); + this.actual = subject; + } + + public static Factory jsonObjects() { + return JSONObjectSubject::new; + } + + public static JSONObjectSubject assertThat(@Nullable JSONObject actual) { + return assertAbout(JSONObjectSubject.jsonObjects()).that(actual); + } + + @Override + public void isEqualTo(Object expected) { + if (!(expected instanceof ExpectedJSONObject)) { + failWithActual("did not expect to be", "a JSONObject"); + } + isNotNull(); + + Map expectedEntries = ((ExpectedJSONObject) expected).entries; + check("length()").that(actual.length()).isEqualTo(expectedEntries.size()); + + for (Entry expectedEntry : expectedEntries.entrySet()) { + String expectedKey = expectedEntry.getKey(); + Object actualValue = actual.opt(expectedKey); + + checkElementValues("entries[" + expectedKey + "]", expectedEntry.getValue(), actualValue); + } + } + } + + + + private static class CustomClass { + @SuppressWarnings("unused") + int i = 1; + + @Override + public String toString() { + return "custom-toString"; + } + } + + private static class CustomJsonStringClass implements JSONString { + @Override + public String toJSONString() { + return "\"custom\""; + } + } + + // Important: Make sure this class is in-sync with the code in Troubleshooting.md + /** + * {@code TypeAdapterFactory} for {@link JSONArray} and {@link JSONObject}. + * + *

This factory is mainly intended for applications which cannot switch to + * Gson's own {@link JsonArray} and {@link JsonObject} classes. + */ + private static class JsonOrgAdapterFactory implements TypeAdapterFactory { + private abstract static class JsonOrgAdapter extends TypeAdapter { + private final TypeAdapter jsonElementAdapter; + + public JsonOrgAdapter(TypeAdapter jsonElementAdapter) { + this.jsonElementAdapter = jsonElementAdapter; + } + + protected abstract T readJsonOrgValue(String json) throws JSONException; + + @Override + public T read(JsonReader in) throws IOException { + if (in.peek() == JsonToken.NULL) { + in.nextNull(); + return null; + } + + // For correctness convert JSON data to string, then let JSON-java parse it; + // this is pretty inefficient, but makes sure it gets all the corner cases + // of JSON-java correct + // However, unlike JSONObject this will not prevent duplicate member names + JsonElement jsonElement = jsonElementAdapter.read(in); + String json = jsonElementAdapter.toJson(jsonElement); + try { + return readJsonOrgValue(json); + } + // For Android this is a checked exception; for the latest JSON-java artifacts it isn't anymore + catch (JSONException e) { + throw new RuntimeException(e); + } + } + + @Override + public void write(JsonWriter out, T value) throws IOException { + if (value == null) { + out.nullValue(); + return; + } + + // For correctness let JSON-java perform JSON conversion, then parse again and write + // with Gson; this is pretty inefficient, but makes sure it gets all the corner cases + // of JSON-java correct + String json = value.toString(); + JsonElement jsonElement = jsonElementAdapter.fromJson(json); + jsonElementAdapter.write(out, jsonElement); + } + } + + @Override + public TypeAdapter create(Gson gson, TypeToken type) { + Class rawType = type.getRawType(); + if (rawType != JSONArray.class && rawType != JSONObject.class) { + return null; + } + + TypeAdapter jsonElementAdapter = gson.getAdapter(JsonElement.class); + + TypeAdapter adapter; + if (rawType == JSONArray.class) { + adapter = new JsonOrgAdapter(jsonElementAdapter) { + @Override + protected JSONArray readJsonOrgValue(String json) throws JSONException { + return new JSONArray(json); + } + }; + } else { + adapter = new JsonOrgAdapter(jsonElementAdapter) { + @Override + protected JSONObject readJsonOrgValue(String json) throws JSONException { + return new JSONObject(json); + } + }; + } + + // Safe due to type check at beginning of method + @SuppressWarnings("unchecked") + TypeAdapter t = (TypeAdapter) adapter; + return t; + } + } + + /** + * Tests usage of custom adapters for {@link JSONArray} and {@link JSONObject}. + * + *

This test also verifies that the code shown in {@code Troubleshooting.md} works + * as expected. + */ + @Test + public void testCustomAdapters() throws JSONException { + Gson gson = new GsonBuilder() + .serializeNulls() + .registerTypeAdapterFactory(new JsonOrgAdapterFactory()) + .create(); + + JSONArray array = new JSONArray(Arrays.asList( + null, + JSONObject.NULL, + new CustomClass(), + new CustomJsonStringClass(), + 123.4, + true, + new JSONObject(Collections.singletonMap("key", 1)), + new JSONArray(Arrays.asList(2)), + Collections.singletonMap("key", 3), + Arrays.asList(4), + new boolean[] {false} + )); + assertThat(gson.toJson(array)).isEqualTo( + "[null,null,\"custom-toString\",\"custom\",123.4,true,{\"key\":1},[2],{\"key\":3},[4],[false]]"); + assertThat(gson.toJson(null, JSONArray.class)).isEqualTo("null"); + + JSONObject object = new JSONObject(); + object.put("1", JSONObject.NULL); + object.put("2", new CustomClass()); + object.put("3", new CustomJsonStringClass()); + object.put("4", 123.4); + object.put("5", true); + object.put("6", new JSONObject(Collections.singletonMap("key", 1))); + object.put("7", new JSONArray(Arrays.asList(2))); + object.put("8", Collections.singletonMap("key", 3)); + object.put("9", Arrays.asList(4)); + object.put("10", new boolean[] {false}); + assertThat(gson.toJson(object)).isEqualTo( + "{\"1\":null,\"2\":\"custom-toString\",\"3\":\"custom\",\"4\":123.4,\"5\":true,\"6\":{\"key\":1},\"7\":[2],\"8\":{\"key\":3},\"9\":[4],\"10\":[false]}"); + assertThat(gson.toJson(null, JSONObject.class)).isEqualTo("null"); + + ExpectedJSONArray expectedArray = new ExpectedJSONArray(Arrays.asList( + JSONObject.NULL, + true, + 12, + "string", + new ExpectedJSONObject(Collections.singletonMap("key", 1)), + new ExpectedJSONArray(Arrays.asList(2)) + )); + String json = "[null, true, 12, \"string\", {\"key\": 1}, [2]]"; + JSONArraySubject.assertThat(gson.fromJson(json, JSONArray.class)).isEqualTo(expectedArray); + assertThat(gson.fromJson("null", JSONArray.class)).isNull(); + + Map expectedObject = new HashMap<>(); + expectedObject.put("1", JSONObject.NULL); + expectedObject.put("2", true); + expectedObject.put("3", 12); + expectedObject.put("4", "string"); + expectedObject.put("5", new ExpectedJSONObject(Collections.singletonMap("key", 1))); + expectedObject.put("6", new ExpectedJSONArray(Arrays.asList(2))); + json = "{\"1\": null, \"2\": true, \"3\": 12, \"4\": \"string\", \"5\": {\"key\": 1}, \"6\": [2]}"; + JSONObjectSubject.assertThat(gson.fromJson(json, JSONObject.class)).isEqualTo(new ExpectedJSONObject(expectedObject)); + assertThat(gson.fromJson("null", JSONObject.class)).isNull(); + } + + // Important: Make sure this class is in-sync with the code in Troubleshooting.md + /** + * Custom {@code TypeAdapterFactory} for {@link JSONArray} and {@link JSONObject}, + * which uses a format similar to what Gson's reflection-based adapter would have + * used. + * + *

This factory is mainly intended for applications which in the past by accident + * relied on Gson's reflection-based adapter for {@code JSONArray} and {@code JSONObject} + * and now have to keep this format for backward compatibility. + */ + private static class JsonOrgBackwardCompatibleAdapterFactory implements TypeAdapterFactory { + private abstract static class JsonOrgBackwardCompatibleAdapter extends TypeAdapter { + /** Internal field name used by JSON-java / Android for the respective JSON value class */ + private final String fieldName; + private final TypeAdapter wrappedTypeAdapter; + + public JsonOrgBackwardCompatibleAdapter(String fieldName, TypeAdapter wrappedTypeAdapter) { + this.fieldName = fieldName; + this.wrappedTypeAdapter = wrappedTypeAdapter; + } + + protected abstract T createJsonOrgValue(W wrapped) throws JSONException; + + @Override + public T read(JsonReader in) throws IOException { + if (in.peek() == JsonToken.NULL) { + in.nextNull(); + return null; + } + + in.beginObject(); + String name = in.nextName(); + if (!name.equals(fieldName)) { + throw new IllegalArgumentException("Unexpected name '" + name + "', expected '" + fieldName + "' at " + in.getPath()); + } + T value; + try { + value = createJsonOrgValue(wrappedTypeAdapter.read(in)); + } + // For Android this is a checked exception; for the latest JSON-java artifacts it isn't anymore + catch (JSONException e) { + throw new RuntimeException(e); + } + in.endObject(); + + return value; + } + + protected abstract W getWrapped(T value); + + @Override + public void write(JsonWriter out, T value) throws IOException { + if (value == null) { + out.nullValue(); + return; + } + + out.beginObject(); + out.name(fieldName); + wrappedTypeAdapter.write(out, getWrapped(value)); + out.endObject(); + } + } + + /** + * For multiple alternative field names, tries to find the first which exists on the class. + */ + private static String getFieldName(Class c, String... names) throws NoSuchFieldException { + assert(names.length > 0); + NoSuchFieldException exception = null; + + for (String name : names) { + try { + Field unused = c.getDeclaredField(name); + return name; + } catch (NoSuchFieldException e) { + exception = e; + } + } + + throw exception; + } + + @Override + public TypeAdapter create(Gson gson, TypeToken type) { + Class rawType = type.getRawType(); + + // Note: This handling for JSONObject.NULL is not the same as the previous Gson reflection-based + // behavior which would have written `{}`, but this implementation here probably makes more sense + if (rawType == JSONObject.NULL.getClass()) { + return new TypeAdapter() { + @Override + public T read(JsonReader in) throws IOException { + in.nextNull(); + return null; + } + + @Override + public void write(JsonWriter out, T value) throws IOException { + out.nullValue(); + } + }; + } + + if (rawType != JSONArray.class && rawType != JSONObject.class) { + return null; + } + + TypeAdapter adapter; + if (rawType == JSONArray.class) { + // Choose correct field name depending on whether JSON-java or Android is used + String fieldName; + try { + String jsonJavaName = "myArrayList"; + String androidName = "values"; + fieldName = getFieldName(JSONArray.class, jsonJavaName, androidName); + } catch (NoSuchFieldException e) { + throw new RuntimeException("Unable to get internal field name for JSONArray", e); + } + + TypeAdapter> wrappedAdapter = gson.getAdapter(new TypeToken> () {}); + adapter = new JsonOrgBackwardCompatibleAdapter, JSONArray>(fieldName, wrappedAdapter) { + @Override + protected JSONArray createJsonOrgValue(List wrapped) throws JSONException { + JSONArray jsonArray = new JSONArray(); + // Unlike JSONArray(Collection) constructor, `put` does not wrap elements and is therefore closer + // to original Gson reflection-based behavior + for (Object element : wrapped) { + jsonArray.put(element); + } + + return jsonArray; + } + + @Override + protected List getWrapped(JSONArray jsonArray) { + // Cannot use JSONArray.toList() because that converts elements + List list = new ArrayList<>(jsonArray.length()); + for (int i = 0; i < jsonArray.length(); i++) { + // Use opt(int) because get(int) cannot handle null values + Object element = jsonArray.opt(i); + list.add(element); + } + + return list; + } + }; + } else { + // Choose correct field name depending on whether JSON-java or Android is used + String fieldName; + try { + String jsonJavaName = "map"; + String androidName = "nameValuePairs"; + fieldName = getFieldName(JSONObject.class, jsonJavaName, androidName); + } catch (NoSuchFieldException e) { + throw new RuntimeException("Unable to get internal field name for JSONObject", e); + } + + TypeAdapter> wrappedAdapter = gson.getAdapter(new TypeToken> () {}); + adapter = new JsonOrgBackwardCompatibleAdapter, JSONObject>(fieldName, wrappedAdapter) { + @Override + protected JSONObject createJsonOrgValue(Map map) throws JSONException { + // JSONObject(Map) constructor wraps elements, so instead put elements separately to be closer + // to original Gson reflection-based behavior + JSONObject jsonObject = new JSONObject(); + for (Entry entry : map.entrySet()) { + jsonObject.put(entry.getKey(), entry.getValue()); + } + + return jsonObject; + } + + @Override + protected Map getWrapped(JSONObject jsonObject) { + // Cannot use JSONObject.toMap() because that converts elements + Map map = new LinkedHashMap<>(jsonObject.length()); + @SuppressWarnings("unchecked") // Old JSON-java versions return just `Iterator` instead of `Iterator` + Iterator names = jsonObject.keys(); + while (names.hasNext()) { + String name = names.next(); + // Use opt(String) because get(String) cannot handle null values + // Most likely null values cannot occur normally though; they would be JSONObject.NULL + map.put(name, jsonObject.opt(name)); + } + + return map; + } + }; + } + + // Safe due to type check at beginning of method + @SuppressWarnings("unchecked") + TypeAdapter t = (TypeAdapter) adapter; + return t; + } + } + + /** + * Tests usage of custom adapters for {@link JSONArray} and {@link JSONObject}, + * which serialize and deserialize these classes in (nearly) the same format which the + * reflection-based adapter would use for them. + * + *

This test also verifies that the code shown in {@code Troubleshooting.md} works + * as expected. + */ + @Test + public void testCustomBackwardCompatibleAdapters() throws JSONException { + Gson gson = new GsonBuilder() + .serializeNulls() + .registerTypeAdapterFactory(new JsonOrgBackwardCompatibleAdapterFactory()) + .create(); + + JSONArray array = new JSONArray(Arrays.asList( + null, + JSONObject.NULL, + 123.4, + true, + new JSONObject(Collections.singletonMap("key", 1)), + new JSONArray(Arrays.asList(2)), + Collections.singletonMap("key", 3), + Arrays.asList(4), + new boolean[] {false} + )); + assertThat(gson.toJson(array)).isEqualTo( + "{\"myArrayList\":[null,null,123.4,true,{\"map\":{\"key\":1}},{\"myArrayList\":[2]},{\"key\":3},[4],[false]]}"); + assertThat(gson.toJson(null, JSONArray.class)).isEqualTo("null"); + + JSONObject object = new JSONObject(); + object.put("1", JSONObject.NULL); + object.put("2", 123.4); + object.put("3", true); + object.put("4", new JSONObject(Collections.singletonMap("key", 1))); + object.put("5", new JSONArray(Arrays.asList(2))); + object.put("6", Collections.singletonMap("key", 3)); + object.put("7", Arrays.asList(4)); + object.put("8", new boolean[] {false}); + assertThat(gson.toJson(object)).isEqualTo( + "{\"map\":{\"1\":null,\"2\":123.4,\"3\":true,\"4\":{\"map\":{\"key\":1}},\"5\":{\"myArrayList\":[2]},\"6\":{\"map\":{\"key\":3}},\"7\":{\"myArrayList\":[4]},\"8\":[false]}}"); + assertThat(gson.toJson(null, JSONObject.class)).isEqualTo("null"); + + ExpectedJSONArray expectedArray = new ExpectedJSONArray(Arrays.asList( + null, + true, + 12.0, + "string", + Collections.singletonMap("key", 1.0), + // Nested JSONObject cannot be restored properly + Collections.singletonMap("map", Collections.singletonMap("key", 2.0)), + Arrays.asList(3.0), + // Nested JSONArray cannot be restored properly + Collections.singletonMap("myArrayList", Arrays.asList(4.0)) + )); + String json = "{\"myArrayList\": [null, true, 12, \"string\", {\"key\": 1}, {\"map\": {\"key\": 2}}, [3], {\"myArrayList\": [4]}]}"; + JSONArraySubject.assertThat(gson.fromJson(json, JSONArray.class)).isEqualTo(expectedArray); + assertThat(gson.fromJson("null", JSONArray.class)).isNull(); + + Map expectedObject = new HashMap<>(); + expectedObject.put("1", true); + expectedObject.put("2", 12.0); + expectedObject.put("3", "string"); + expectedObject.put("4", Collections.singletonMap("key", 1.0)); + // Nested JSONObject cannot be restored properly + expectedObject.put("5", Collections.singletonMap("map", Collections.singletonMap("key", 2.0))); + expectedObject.put("6", Arrays.asList(3.0)); + // Nested JSONArray cannot be restored properly + expectedObject.put("7", Collections.singletonMap("myArrayList", Arrays.asList(4.0))); + json = "{\"map\": {\"1\": true, \"2\": 12, \"3\": \"string\", \"4\": {\"key\": 1}, \"5\": {\"map\": {\"key\": 2}}, \"6\": [3], \"7\": {\"myArrayList\": [4]}}}"; + JSONObjectSubject.assertThat(gson.fromJson(json, JSONObject.class)).isEqualTo(new ExpectedJSONObject(expectedObject)); + assertThat(gson.fromJson("null", JSONObject.class)).isNull(); + } +}