/* * Copyright (c) 2014-present, Facebook, Inc. * All rights reserved. * * This source code is licensed under the BSD-style license found in the * LICENSE file in the root directory of this source tree. An additional grant * of patent rights can be found in the PATENTS file in the same directory. */ package com.facebook.stetho.json; import javax.annotation.Nullable; import javax.annotation.concurrent.GuardedBy; import java.lang.annotation.Annotation; import java.lang.reflect.Constructor; import java.lang.reflect.Field; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.lang.reflect.Modifier; import java.lang.reflect.ParameterizedType; import java.lang.reflect.Type; import java.util.ArrayList; import java.util.IdentityHashMap; import java.util.List; import java.util.Map; import com.facebook.stetho.common.ExceptionUtil; import com.facebook.stetho.json.annotation.JsonProperty; import com.facebook.stetho.json.annotation.JsonValue; import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; /** * This class is a lightweight version of Jackson's ObjectMapper. It is designed to have a minimal * subset of the functionality required for stetho. * <p> * It would be awesome if there were a lightweight library that supported converting between * arbitrary {@link Object} and {@link JSONObject} representations. * <p> * Admittedly the other approach would be to use an Annotation Processor to create static conversion * functions that discover something like a {@link JsonProperty} and create a function at compile * time however since this is just being used for a simple debug utility and Kit-Kat caches the * results of reflection this class is sufficient for stethos needs. */ public class ObjectMapper { @GuardedBy("mJsonValueMethodCache") private final Map<Class<?>, Method> mJsonValueMethodCache = new IdentityHashMap<>(); /** * Support mapping between arbitrary classes and {@link JSONObject}. * <note> * It is possible for a {@link Throwable} to be propagated out of this class if there is an * {@link InvocationTargetException}. * </note> * @param fromValue * @param toValueType * @param <T> * @return * @throws IllegalArgumentException when there is an error converting. One of either * {@code fromValue.getClass()} or {@code toValueType} must be {@link JSONObject}. */ public <T> T convertValue(Object fromValue, Class<T> toValueType) throws IllegalArgumentException { if (fromValue == null) { return null; } if (toValueType != Object.class && toValueType.isAssignableFrom(fromValue.getClass())) { return (T) fromValue; } try { if (fromValue instanceof JSONObject) { return _convertFromJSONObject((JSONObject) fromValue, toValueType); } else if (toValueType == JSONObject.class) { return (T) _convertToJSONObject(fromValue); } else { throw new IllegalArgumentException( "Expecting either fromValue or toValueType to be a JSONObject"); } } catch (NoSuchMethodException e) { throw new IllegalArgumentException(e); } catch (IllegalAccessException e) { throw new IllegalArgumentException(e); } catch (InstantiationException e) { throw new IllegalArgumentException(e); } catch (JSONException e) { throw new IllegalArgumentException(e); } catch (InvocationTargetException e) { throw ExceptionUtil.propagate(e.getCause()); } } private <T> T _convertFromJSONObject(JSONObject jsonObject, Class<T> type) throws NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException, JSONException { Constructor<T> constructor = type.getDeclaredConstructor((Class[]) null); constructor.setAccessible(true); T instance = constructor.newInstance(); Field[] fields = type.getFields(); for (int i = 0; i < fields.length; ++i) { Field field = fields[i]; if (Modifier.isStatic(field.getModifiers())) { continue; } Object value = jsonObject.opt(field.getName()); Object setValue = getValueForField(field, value); try { field.set(instance, setValue); } catch (IllegalArgumentException e) { throw new IllegalArgumentException( "Class: " + type.getSimpleName() + " " + "Field: " + field.getName() + " type " + (setValue != null ? setValue.getClass().getName() : "null"), e); } } return instance; } private Object getValueForField(Field field, Object value) throws JSONException { try { if (value != null) { if (value == JSONObject.NULL) { return null; } if (value.getClass() == field.getType()) { return value; } if (value instanceof JSONObject) { return convertValue(value, field.getType()); } else { if (field.getType().isEnum()) { return getEnumValue((String) value, field.getType().asSubclass(Enum.class)); } else if (value instanceof JSONArray) { return convertArrayToList(field, (JSONArray) value); } else if (value instanceof Number) { // Need to convert value to Number This happens because json treats 1 as an Integer even // if the field is supposed to be a Long Number numberValue = (Number) value; Class<?> clazz = field.getType(); if (clazz == Integer.class || clazz == int.class) { return numberValue.intValue(); } else if (clazz == Long.class || clazz == long.class) { return numberValue.longValue(); } else if (clazz == Double.class || clazz == double.class) { return numberValue.doubleValue(); } else if (clazz == Float.class || clazz == float.class) { return numberValue.floatValue(); } else if (clazz == Byte.class || clazz == byte.class) { return numberValue.byteValue(); } else if (clazz == Short.class || clazz == short.class) { return numberValue.shortValue(); } else { throw new IllegalArgumentException("Not setup to handle class " + clazz.getName()); } } } } } catch (IllegalAccessException e) { throw new IllegalArgumentException("Unable to set value for field " + field.getName(), e); } return value; } private Enum getEnumValue(String value, Class<? extends Enum> clazz) { Method method = getJsonValueMethod(clazz); if (method != null) { return getEnumByMethod(value, clazz, method); } else { return Enum.valueOf(clazz, value); } } /** * In this case we know that there is an {@link Enum} decorated with {@link JsonValue}. This means * that we need to iterate through all of the values of the {@link Enum} returned by the given * {@link Method} to check the given value. * @param value * @param clazz * @param method * @return */ private Enum getEnumByMethod(String value, Class<? extends Enum> clazz, Method method) { Enum[] enumValues = clazz.getEnumConstants(); // Start at the front to ensure first always wins for (int i = 0; i < enumValues.length; ++i) { Enum enumValue = enumValues[i]; try { Object o = method.invoke(enumValue); if (o != null) { if (o.toString().equals(value)) { return enumValue; } } } catch (Exception ex) { throw new IllegalArgumentException(ex); } } throw new IllegalArgumentException("No enum constant " + clazz.getName() + "." + value); } private List<Object> convertArrayToList(Field field, JSONArray array) throws IllegalAccessException, JSONException { if (List.class.isAssignableFrom(field.getType())) { ParameterizedType parameterizedType = (ParameterizedType) field.getGenericType(); Type[] types = parameterizedType.getActualTypeArguments(); if (types.length != 1) { throw new IllegalArgumentException("Only able to handle a single type in a list " + field.getName()); } Class arrayClass = (Class)types[0]; List<Object> objectList = new ArrayList<Object>(); for (int i = 0; i < array.length(); ++i) { if (arrayClass.isEnum()) { objectList.add(getEnumValue(array.getString(i), arrayClass)); } else if (canDirectlySerializeClass(arrayClass)) { objectList.add(array.get(i)); } else { JSONObject jsonObject = array.getJSONObject(i); if (jsonObject == null) { objectList.add(null); } else { objectList.add(convertValue(jsonObject, arrayClass)); } } } return objectList; } else { throw new IllegalArgumentException("only know how to deserialize List<?> on field " + field.getName()); } } private JSONObject _convertToJSONObject(Object fromValue) throws JSONException, InvocationTargetException, IllegalAccessException { JSONObject jsonObject = new JSONObject(); Field[] fields = fromValue.getClass().getFields(); for (int i = 0; i < fields.length; ++i) { Field field = fields[i]; if (Modifier.isStatic(field.getModifiers())) { continue; } JsonProperty property = field.getAnnotation(JsonProperty.class); if (property != null) { // AutoBox here ... Object value = field.get(fromValue); Class clazz = field.getType(); if (value != null) { clazz = value.getClass(); } String name = field.getName(); if (property.required() && value == null) { value = JSONObject.NULL; } else if (value == JSONObject.NULL) { // Leave it as null in this case. } else { value = getJsonValue(value, clazz, field); } jsonObject.put(name, value); } } return jsonObject; } private Object getJsonValue(Object value, Class<?> clazz, Field field) throws InvocationTargetException, IllegalAccessException { if (value == null) { // Now technically we /could/ return JsonNode.NULL here but Chrome's webkit inspector croaks // if you pass a null "id" return null; } if (List.class.isAssignableFrom(clazz)) { return convertListToJsonArray(value); } // Finally check to see if there is a JsonValue present Method m = getJsonValueMethod(clazz); if (m != null) { return m.invoke(value); } if (!canDirectlySerializeClass(clazz)) { return convertValue(value, JSONObject.class); } // JSON has no support for NaN, Infinity or -Infinity, so we serialize // then as strings. Google Chrome's inspector will accept them just fine. if (clazz.equals(Double.class) || clazz.equals(Float.class)) { double doubleValue = ((Number) value).doubleValue(); if (Double.isNaN(doubleValue)) { return "NaN"; } else if (doubleValue == Double.POSITIVE_INFINITY) { return "Infinity"; } else if (doubleValue == Double.NEGATIVE_INFINITY) { return "-Infinity"; } } // hmm we should be able to directly serialize here... return value; } private JSONArray convertListToJsonArray(Object value) throws InvocationTargetException, IllegalAccessException { JSONArray array = new JSONArray(); List<Object> list = (List<Object>) value; for(Object obj : list) { // Send null, if this is an array of arrays we are screwed array.put(obj != null ? getJsonValue(obj, obj.getClass(), null /* field */) : null); } return array; } /** * * @param clazz * @return the first method annotated with {@link JsonValue} or null if one does not exist. */ @Nullable private Method getJsonValueMethod(Class<?> clazz) { synchronized (mJsonValueMethodCache) { Method method = mJsonValueMethodCache.get(clazz); if (method == null && !mJsonValueMethodCache.containsKey(clazz)) { method = getJsonValueMethodImpl(clazz); mJsonValueMethodCache.put(clazz, method); } return method; } } @Nullable private static Method getJsonValueMethodImpl(Class<?> clazz) { Method[] methods = clazz.getMethods(); for(int i = 0; i < methods.length; ++i) { Annotation jsonValue = methods[i].getAnnotation(JsonValue.class); if (jsonValue != null) { return methods[i]; } } return null; } private static boolean canDirectlySerializeClass(Class clazz) { return isWrapperOrPrimitiveType(clazz) || clazz.equals(String.class); } private static boolean isWrapperOrPrimitiveType(Class<?> clazz) { return clazz.isPrimitive() || clazz.equals(Boolean.class) || clazz.equals(Integer.class) || clazz.equals(Character.class) || clazz.equals(Byte.class) || clazz.equals(Short.class) || clazz.equals(Double.class) || clazz.equals(Long.class) || clazz.equals(Float.class); } }