ModelCodec.java

/*
 * Copyright (C) 2015 The Android Open Source Project
 *
 * 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 androidx.test.espresso.web.model;

import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.base.Preconditions.checkNotNull;
import static com.google.common.base.Preconditions.checkState;

import android.os.Build;
import android.util.JsonReader;
import android.util.Log;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
import java.io.IOException;
import java.io.StringReader;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.CopyOnWriteArrayList;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import org.json.JSONStringer;
import org.json.JSONTokener;

/** Encodes/Decodes JSON. */
public final class ModelCodec {
  private static final String TAG = "JS_CODEC";

  private static final ImmutableSet<Class<?>> VALUEABLE_CLASSES =
      ImmutableSet.of(Boolean.class, Number.class, String.class, JSONObject.class, JSONArray.class);

  private static final ImmutableSet<Class<?>> TOP_LEVEL_CLASSES =
      ImmutableSet.of(
          JSONObject.class,
          JSONArray.class,
          Iterable.class,
          Object[].class,
          Map.class,
          JSONAble.class);

  private static final CopyOnWriteArrayList<JSONAble.DeJSONFactory> DEJSONIZERS =
      new CopyOnWriteArrayList<JSONAble.DeJSONFactory>(
          Lists.newArrayList(
              Evaluation.DEJSONIZER, WindowReference.DEJSONIZER, ElementReference.DEJSONIZER));

  private ModelCodec() {}

  /** Transforms a JSON string to an evaluation. */
  public static Evaluation decodeEvaluation(String json) {
    Object obj = decode(json);
    if (obj instanceof Evaluation) {
      return (Evaluation) obj;
    } else {
      throw new IllegalArgumentException(
          String.format(
              "Document: \"%s\" did not decode to an evaluation. Instead: \"%s\"", json, obj));
    }
  }

  /** Encodes a Java Object into a JSON string. */
  public static String encode(Object javaObject) {
    checkNotNull(javaObject);
    try {
      if (javaObject instanceof JSONObject) {
        return javaObject.toString();
      } else if (javaObject instanceof JSONArray) {
        return javaObject.toString();
      } else if (javaObject instanceof JSONAble) {
        return new JSONObject(((JSONAble) javaObject).toJSONString()).toString();
      } else if ((javaObject instanceof Iterable)
          || (javaObject instanceof Map)
          || (javaObject instanceof Object[])) {
        JSONStringer stringer = new JSONStringer();
        return encodeHelper(javaObject, stringer).toString();
      }
      throw new IllegalArgumentException(
          String.format(
              "%s: not a valid top level class. Want one of: %s",
              javaObject.getClass(), TOP_LEVEL_CLASSES));
    } catch (JSONException je) {
      throw new RuntimeException("Encode failed: " + javaObject, je);
    }
  }

  /**
   * Removes a DeJSONFactory from the list of factories that transform JSONObjects to java objects.
   */
  public static void removeDeJSONFactory(JSONAble.DeJSONFactory dejson) {
    DEJSONIZERS.remove(dejson);
  }

  /** Adds a DeJSONFactory to intercept JSONObjects and replace them with more suitable types. */
  public static void addDeJSONFactory(JSONAble.DeJSONFactory dejson) {
    DEJSONIZERS.add(checkNotNull(dejson));
  }

  static Object decode(String json) {
    checkNotNull(json);
    checkArgument(!"".equals(json), "Empty docs not supported.");

    try {
      if (Build.VERSION.SDK_INT < 13) {
        // After API 13, there is the JSONReader API - which is nicer to work with.
        return decodeViaJSONObject(json);
      } else {
        return decodeViaJSONReader(json);
      }
    } catch (JSONException je) {
      throw new RuntimeException(String.format("Could not parse: %s", json), je);
    } catch (IOException ioe) {
      throw new RuntimeException(String.format("Could not parse: %s", json), ioe);
    }
  }

  private static Object decodeViaJSONObject(String json) throws JSONException {
    JSONTokener tokener = new JSONTokener(json);
    Object value = tokener.nextValue();
    if (value instanceof JSONArray) {
      return decodeArray((JSONArray) value);
    } else if (value instanceof JSONObject) {
      return decodeObject((JSONObject) value);
    } else {
      throw new IllegalArgumentException("No top level object or array: " + json);
    }
  }

  private static List<Object> decodeArray(JSONArray array) throws JSONException {
    List<Object> data = Lists.newArrayList();
    for (int i = 0; i < array.length(); i++) {
      if (array.isNull(i)) {
        data.add(null);
      } else {
        Object value = array.get(i);
        if (value instanceof JSONObject) {
          data.add(decodeObject((JSONObject) value));
        } else if (value instanceof JSONArray) {
          data.add(decodeArray((JSONArray) value));
        } else {
          // boolean / string / or number.
          data.add(value);
        }
      }
    }
    return data;
  }

  private static Object decodeObject(JSONObject jsonObject) throws JSONException {
    List<String> nullKeys = Lists.newArrayList();
    Map<String, Object> obj = Maps.newHashMap();
    Iterator<String> keys = jsonObject.keys();
    while (keys.hasNext()) {
      String key = keys.next();
      if (jsonObject.isNull(key)) {
        nullKeys.add(key);
        obj.put(key, JSONObject.NULL);
      } else {
        Object value = jsonObject.get(key);
        if (value instanceof JSONObject) {
          obj.put(key, decodeObject((JSONObject) value));
        } else if (value instanceof JSONArray) {
          obj.put(key, decodeArray((JSONArray) value));
        } else {
          // boolean / string / or number.
          obj.put(key, value);
        }
      }
    }
    Object replacement = maybeReplaceMap(obj);
    if (replacement != null) {
      return replacement;
    } else {
      for (String key : nullKeys) {
        obj.remove(key);
      }

      return obj;
    }
  }

  private static Object decodeViaJSONReader(String json) throws IOException {
    JsonReader reader = null;
    try {
      reader = new JsonReader(new StringReader(json));
      while (true) {
        switch (reader.peek()) {
          case BEGIN_OBJECT:
            return decodeObject(reader);
          case BEGIN_ARRAY:
            return decodeArray(reader);
          default:
            throw new IllegalStateException("Bogus document: " + json);
        }
      }
    } finally {
      if (null != reader) {
        try {
          reader.close();
        } catch (IOException ioe) {
          Log.i(TAG, "json reader - close exception", ioe);
        }
      }
    }
  }

  private static List<Object> decodeArray(JsonReader reader) throws IOException {
    List<Object> array = Lists.newArrayList();
    reader.beginArray();
    while (reader.hasNext()) {
      switch (reader.peek()) {
        case BEGIN_OBJECT:
          array.add(decodeObject(reader));
          break;
        case NULL:
          reader.nextNull();
          array.add(null);
          break;
        case STRING:
          array.add(reader.nextString());
          break;
        case BOOLEAN:
          array.add(reader.nextBoolean());
          break;
        case BEGIN_ARRAY:
          array.add(decodeArray(reader));
          break;
        case NUMBER:
          array.add(decodeNumber(reader.nextString()));
          break;
        default:
          throw new IllegalStateException(String.format("%s: bogus token", reader.peek()));
      }
    }

    reader.endArray();
    return array;
  }

  private static Number decodeNumber(String value) {
    try {
      return Integer.valueOf(value);
    } catch (NumberFormatException i) {
      try {
        return Long.valueOf(value);
      } catch (NumberFormatException i2) {
        try {
          return Double.valueOf(value);
        } catch (NumberFormatException i3) {
          try {
            return new BigInteger(value);
          } catch (NumberFormatException i4) {
            return new BigDecimal(value);
          }
        }
      }
    }
  }

  private static Object decodeObject(JsonReader reader) throws IOException {
    Map<String, Object> obj = Maps.newHashMap();
    List<String> nullKeys = Lists.newArrayList();
    reader.beginObject();
    while (reader.hasNext()) {
      String key = reader.nextName();
      Object value = null;
      switch (reader.peek()) {
        case BEGIN_OBJECT:
          obj.put(key, decodeObject(reader));
          break;
        case NULL:
          reader.nextNull();
          nullKeys.add(key);
          obj.put(key, JSONObject.NULL);
          break;
        case STRING:
          obj.put(key, reader.nextString());
          break;
        case BOOLEAN:
          obj.put(key, reader.nextBoolean());
          break;
        case NUMBER:
          obj.put(key, decodeNumber(reader.nextString()));
          break;
        case BEGIN_ARRAY:
          obj.put(key, decodeArray(reader));
          break;
        default:
          throw new IllegalStateException(String.format("%s: bogus token.", reader.peek()));
      }
    }
    reader.endObject();
    Object replacement = maybeReplaceMap(obj);
    if (null != replacement) {
      return replacement;
    } else {
      for (String key : nullKeys) {
        obj.remove(key);
      }
    }
    return obj;
  }

  private static Object maybeReplaceMap(Map<String, Object> obj) {
    for (JSONAble.DeJSONFactory dejsonizer : DEJSONIZERS) {
      Object maybe = dejsonizer.attemptDeJSONize(obj);
      if (null != maybe) {
        return maybe;
      }
    }
    return null;
  }

  private static JSONStringer encodeHelper(Object javaObject, JSONStringer stringer)
      throws JSONException {
    if (null == javaObject) {
      stringer.value(javaObject);
    } else if (javaObject instanceof Map) {
      stringer.object();
      Set<Map.Entry> entries = ((Map) javaObject).entrySet();
      for (Map.Entry entry : entries) {
        stringer.key(entry.getKey().toString());
        encodeHelper(entry.getValue(), stringer);
      }
      stringer.endObject();
    } else if (javaObject instanceof Iterable) {
      stringer.array();
      for (Object obj : ((Iterable) javaObject)) {
        encodeHelper(obj, stringer);
      }
      stringer.endArray();
    } else if (javaObject instanceof Object[]) {
      stringer.array();
      for (Object obj : ((Object[]) javaObject)) {
        encodeHelper(obj, stringer);
      }
      stringer.endArray();
    } else if (javaObject instanceof JSONAble) {
      JSONObject jsonObj = new JSONObject(((JSONAble) javaObject).toJSONString());
      stringer.value(jsonObj);
    } else {
      boolean converted = false;
      for (Class valuableClazz : VALUEABLE_CLASSES) {
        if (valuableClazz.isAssignableFrom(javaObject.getClass())) {
          converted = true;
          stringer.value(javaObject);
        }
      }
      checkState(
          converted,
          "%s: not encodable. Want one of: %s",
          javaObject.getClass(),
          VALUEABLE_CLASSES);
    }
    return stringer;
  }
}