package com.mixpanel.android.viewcrawler;
import android.content.Context;
import android.content.res.Resources;
import android.graphics.Bitmap;
import android.graphics.drawable.BitmapDrawable;
import android.graphics.drawable.ColorDrawable;
import android.graphics.drawable.Drawable;
import android.util.Pair;
import android.view.accessibility.AccessibilityEvent;
import android.widget.RelativeLayout;
import com.mixpanel.android.mpmetrics.ResourceIds;
import com.mixpanel.android.util.ImageStore;
import com.mixpanel.android.util.JSONUtils;
import com.mixpanel.android.util.MPLog;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
/* package */ class EditProtocol {
public static class BadInstructionsException extends Exception {
private static final long serialVersionUID = -4062004792184145311L;
public BadInstructionsException(String message) {
super(message);
}
public BadInstructionsException(String message, Throwable e) {
super(message, e);
}
}
public static class InapplicableInstructionsException extends BadInstructionsException {
private static final long serialVersionUID = 3977056710817909104L;
public InapplicableInstructionsException(String message) {
super(message);
}
}
public static class CantGetEditAssetsException extends Exception {
public CantGetEditAssetsException(String message) {
super(message);
}
public CantGetEditAssetsException(String message, Throwable cause) {
super(message, cause);
}
}
public static class Edit {
private Edit(ViewVisitor aVisitor, List<String> someUrls) {
visitor = aVisitor;
imageUrls = someUrls;
}
public final ViewVisitor visitor;
public final List<String> imageUrls;
}
public EditProtocol(Context context, ResourceIds resourceIds, ImageStore imageStore, ViewVisitor.OnLayoutErrorListener layoutErrorListener) {
mContext = context;
mResourceIds = resourceIds;
mImageStore = imageStore;
mLayoutErrorListener = layoutErrorListener;
}
public ViewVisitor readEventBinding(JSONObject source, ViewVisitor.OnEventListener listener) throws BadInstructionsException {
try {
final String eventName = source.getString("event_name");
final String eventType = source.getString("event_type");
final JSONArray pathDesc = source.getJSONArray("path");
final List<Pathfinder.PathElement> path = readPath(pathDesc, mResourceIds);
if (path.size() == 0) {
throw new InapplicableInstructionsException("event '" + eventName + "' will not be bound to any element in the UI.");
}
if ("click".equals(eventType)) {
return new ViewVisitor.AddAccessibilityEventVisitor(
path,
AccessibilityEvent.TYPE_VIEW_CLICKED,
eventName,
listener
);
} else if ("selected".equals(eventType)) {
return new ViewVisitor.AddAccessibilityEventVisitor(
path,
AccessibilityEvent.TYPE_VIEW_SELECTED,
eventName,
listener
);
} else if ("text_changed".equals(eventType)) {
return new ViewVisitor.AddTextChangeListener(path, eventName, listener);
} else if ("detected".equals(eventType)) {
return new ViewVisitor.ViewDetectorVisitor(path, eventName, listener);
} else {
throw new BadInstructionsException("Mixpanel can't track event type \"" + eventType + "\"");
}
} catch (final JSONException e) {
throw new BadInstructionsException("Can't interpret instructions due to JSONException", e);
}
}
public Edit readEdit(JSONObject source) throws BadInstructionsException, CantGetEditAssetsException {
final ViewVisitor visitor;
final List<String> assetsLoaded = new ArrayList<String>();
try {
final JSONArray pathDesc = source.getJSONArray("path");
final List<Pathfinder.PathElement> path = readPath(pathDesc, mResourceIds);
if (path.size() == 0) {
throw new InapplicableInstructionsException("Edit will not be bound to any element in the UI.");
}
if (source.getString("change_type").equals("property")) {
final JSONObject propertyDesc = source.getJSONObject("property");
final String targetClassName = propertyDesc.getString("classname");
if (null == targetClassName) {
throw new BadInstructionsException("Can't bind an edit property without a target class");
}
final Class<?> targetClass;
try {
targetClass = Class.forName(targetClassName);
} catch (final ClassNotFoundException e) {
throw new BadInstructionsException("Can't find class for visit path: " + targetClassName, e);
}
final PropertyDescription prop = readPropertyDescription(targetClass, source.getJSONObject("property"));
final JSONArray argsAndTypes = source.getJSONArray("args");
final Object[] methodArgs = new Object[argsAndTypes.length()];
for (int i = 0; i < argsAndTypes.length(); i++) {
final JSONArray argPlusType = argsAndTypes.getJSONArray(i);
final Object jsonArg = argPlusType.get(0);
final String argType = argPlusType.getString(1);
methodArgs[i] = convertArgument(jsonArg, argType, assetsLoaded);
}
final Caller mutator = prop.makeMutator(methodArgs);
if (null == mutator) {
throw new BadInstructionsException("Can't update a read-only property " + prop.name + " (add a mutator to make this work)");
}
visitor = new ViewVisitor.PropertySetVisitor(path, mutator, prop.accessor);
} else if (source.getString("change_type").equals("layout")) {
final JSONArray args = source.getJSONArray("args");
ArrayList<ViewVisitor.LayoutRule> newParams = new ArrayList<ViewVisitor.LayoutRule>();
int length = args.length();
for (int i = 0; i < length; i++) {
JSONObject layout_info = args.optJSONObject(i);
ViewVisitor.LayoutRule params;
final String view_id_name = layout_info.getString("view_id_name");
final String anchor_id_name = layout_info.getString("anchor_id_name");
final Integer view_id = reconcileIds(-1, view_id_name, mResourceIds);
final Integer anchor_id;
if (anchor_id_name.equals("0")) {
anchor_id = 0;
} else if (anchor_id_name.equals("-1")) {
anchor_id = RelativeLayout.TRUE;
} else {
anchor_id = reconcileIds(-1, anchor_id_name, mResourceIds);
}
if (view_id == null || anchor_id == null) {
MPLog.w(LOGTAG, "View (" + view_id_name + ") or anchor (" + anchor_id_name + ") not found.");
continue;
}
params = new ViewVisitor.LayoutRule(view_id, layout_info.getInt("verb"), anchor_id);
newParams.add(params);
}
visitor = new ViewVisitor.LayoutUpdateVisitor(path, newParams, source.getString("name"), mLayoutErrorListener);
} else {
throw new BadInstructionsException("Can't figure out the edit type");
}
} catch (final NoSuchMethodException e) {
throw new BadInstructionsException("Can't create property mutator", e);
} catch (final JSONException e) {
throw new BadInstructionsException("Can't interpret instructions due to JSONException", e);
}
return new Edit(visitor, assetsLoaded);
}
public ViewSnapshot readSnapshotConfig(JSONObject source) throws BadInstructionsException {
final List<PropertyDescription> properties = new ArrayList<PropertyDescription>();
try {
final JSONObject config = source.getJSONObject("config");
final JSONArray classes = config.getJSONArray("classes");
for (int classIx = 0; classIx < classes.length(); classIx++) {
final JSONObject classDesc = classes.getJSONObject(classIx);
final String targetClassName = classDesc.getString("name");
final Class<?> targetClass = Class.forName(targetClassName);
final JSONArray propertyDescs = classDesc.getJSONArray("properties");
for (int i = 0; i < propertyDescs.length(); i++) {
final JSONObject propertyDesc = propertyDescs.getJSONObject(i);
final PropertyDescription desc = readPropertyDescription(targetClass, propertyDesc);
properties.add(desc);
}
}
return new ViewSnapshot(mContext, properties, mResourceIds);
} catch (JSONException e) {
throw new BadInstructionsException("Can't read snapshot configuration", e);
} catch (final ClassNotFoundException e) {
throw new BadInstructionsException("Can't resolve types for snapshot configuration", e);
}
}
public Pair<String, Object> readTweak(JSONObject tweakDesc) throws BadInstructionsException {
try {
final String tweakName = tweakDesc.getString("name");
final String type = tweakDesc.getString("type");
Object value;
if ("number".equals(type)) {
final String encoding = tweakDesc.getString("encoding");
if ("d".equals(encoding)) {
value = tweakDesc.getDouble("value");
} else if ("l".equals(encoding)) {
value = tweakDesc.getLong("value");
} else {
throw new BadInstructionsException("number must have encoding of type \"l\" for long or \"d\" for double in: " + tweakDesc);
}
} else if ("boolean".equals(type)) {
value = tweakDesc.getBoolean("value");
} else if ("string".equals(type)) {
value = tweakDesc.getString("value");
} else {
throw new BadInstructionsException("Unrecognized tweak type " + type + " in: " + tweakDesc);
}
return new Pair<String, Object>(tweakName, value);
} catch (JSONException e) {
throw new BadInstructionsException("Can't read tweak update", e);
}
}
// Package access FOR TESTING ONLY
/* package */ List<Pathfinder.PathElement> readPath(JSONArray pathDesc, ResourceIds idNameToId) throws JSONException {
final List<Pathfinder.PathElement> path = new ArrayList<Pathfinder.PathElement>();
for (int i = 0; i < pathDesc.length(); i++) {
final JSONObject targetView = pathDesc.getJSONObject(i);
final String prefixCode = JSONUtils.optionalStringKey(targetView, "prefix");
final String targetViewClass = JSONUtils.optionalStringKey(targetView, "view_class");
final int targetIndex = targetView.optInt("index", -1);
final String targetDescription = JSONUtils.optionalStringKey(targetView, "contentDescription");
final int targetExplicitId = targetView.optInt("id", -1);
final String targetIdName = JSONUtils.optionalStringKey(targetView, "mp_id_name");
final String targetTag = JSONUtils.optionalStringKey(targetView, "tag");
final int prefix;
if ("shortest".equals(prefixCode)) {
prefix = Pathfinder.PathElement.SHORTEST_PREFIX;
} else if (null == prefixCode) {
prefix = Pathfinder.PathElement.ZERO_LENGTH_PREFIX;
} else {
MPLog.w(LOGTAG, "Unrecognized prefix type \"" + prefixCode + "\". No views will be matched");
return NEVER_MATCH_PATH;
}
final int targetId;
final Integer targetIdOrNull = reconcileIds(targetExplicitId, targetIdName, idNameToId);
if (null == targetIdOrNull) {
return NEVER_MATCH_PATH;
} else {
targetId = targetIdOrNull.intValue();
}
path.add(new Pathfinder.PathElement(prefix, targetViewClass, targetIndex, targetId, targetDescription, targetTag));
}
return path;
}
// May return null (and log a warning) if arguments cannot be reconciled
private Integer reconcileIds(int explicitId, String idName, ResourceIds idNameToId) {
final int idFromName;
if (null != idName) {
if (idNameToId.knownIdName(idName)) {
idFromName = idNameToId.idFromName(idName);
} else {
MPLog.w(LOGTAG,
"Path element contains an id name not known to the system. No views will be matched.\n" +
"Make sure that you're not stripping your packages R class out with proguard.\n" +
"id name was \"" + idName + "\""
);
return null;
}
} else {
idFromName = -1;
}
if (-1 != idFromName && -1 != explicitId && idFromName != explicitId) {
MPLog.e(LOGTAG, "Path contains both a named and an explicit id, and they don't match. No views will be matched.");
return null;
}
if (-1 != idFromName) {
return idFromName;
}
return explicitId;
}
private PropertyDescription readPropertyDescription(Class<?> targetClass, JSONObject propertyDesc)
throws BadInstructionsException {
try {
final String propName = propertyDesc.getString("name");
Caller accessor = null;
if (propertyDesc.has("get")) {
final JSONObject accessorConfig = propertyDesc.getJSONObject("get");
final String accessorName = accessorConfig.getString("selector");
final String accessorResultTypeName = accessorConfig.getJSONObject("result").getString("type");
final Class<?> accessorResultType = Class.forName(accessorResultTypeName);
accessor = new Caller(targetClass, accessorName, NO_PARAMS, accessorResultType);
}
final String mutatorName;
if (propertyDesc.has("set")) {
final JSONObject mutatorConfig = propertyDesc.getJSONObject("set");
mutatorName = mutatorConfig.getString("selector");
} else {
mutatorName = null;
}
return new PropertyDescription(propName, targetClass, accessor, mutatorName);
} catch (final NoSuchMethodException e) {
throw new BadInstructionsException("Can't create property reader", e);
} catch (final JSONException e) {
throw new BadInstructionsException("Can't read property JSON", e);
} catch (final ClassNotFoundException e) {
throw new BadInstructionsException("Can't read property JSON, relevant arg/return class not found", e);
}
}
private Object convertArgument(Object jsonArgument, String type, List<String> assetsLoaded)
throws BadInstructionsException, CantGetEditAssetsException {
// Object is a Boolean, JSONArray, JSONObject, Number, String, or JSONObject.NULL
try {
if ("java.lang.CharSequence".equals(type)) { // Because we're assignable
return jsonArgument;
} else if ("boolean".equals(type) || "java.lang.Boolean".equals(type)) {
return jsonArgument;
} else if ("int".equals(type) || "java.lang.Integer".equals(type)) {
return ((Number) jsonArgument).intValue();
} else if ("float".equals(type) || "java.lang.Float".equals(type)) {
return ((Number) jsonArgument).floatValue();
} else if ("android.graphics.drawable.Drawable".equals(type)) {
// For historical reasons, we attempt to interpret generic Drawables as BitmapDrawables
return readBitmapDrawable((JSONObject) jsonArgument, assetsLoaded);
} else if ("android.graphics.drawable.BitmapDrawable".equals(type)) {
return readBitmapDrawable((JSONObject) jsonArgument, assetsLoaded);
} else if ("android.graphics.drawable.ColorDrawable".equals(type)) {
int colorValue = ((Number) jsonArgument).intValue();
return new ColorDrawable(colorValue);
} else {
throw new BadInstructionsException("Don't know how to interpret type " + type + " (arg was " + jsonArgument + ")");
}
} catch (final ClassCastException e) {
throw new BadInstructionsException("Couldn't interpret <" + jsonArgument + "> as " + type);
}
}
private Drawable readBitmapDrawable(JSONObject description, List<String> assetsLoaded)
throws BadInstructionsException, CantGetEditAssetsException {
try {
if (description.isNull("url")) {
throw new BadInstructionsException("Can't construct a BitmapDrawable with a null url");
}
final String url = description.getString("url");
final boolean useBounds;
final int left;
final int right;
final int top;
final int bottom;
if (description.isNull("dimensions")) {
left = right = top = bottom = 0;
useBounds = false;
} else {
final JSONObject dimensions = description.getJSONObject("dimensions");
left = dimensions.getInt("left");
right = dimensions.getInt("right");
top = dimensions.getInt("top");
bottom = dimensions.getInt("bottom");
useBounds = true;
}
final Bitmap image;
try {
image = mImageStore.getImage(url);
assetsLoaded.add(url);
} catch (ImageStore.CantGetImageException e) {
throw new CantGetEditAssetsException(e.getMessage(), e.getCause());
}
final Drawable ret = new BitmapDrawable(Resources.getSystem(), image);
if (useBounds) {
ret.setBounds(left, top, right, bottom);
}
return ret;
} catch (JSONException e) {
throw new BadInstructionsException("Couldn't read drawable description", e);
}
}
private final Context mContext;
private final ResourceIds mResourceIds;
private final ImageStore mImageStore;
private final ViewVisitor.OnLayoutErrorListener mLayoutErrorListener;
private static final Class<?>[] NO_PARAMS = new Class[0];
private static final List<Pathfinder.PathElement> NEVER_MATCH_PATH = Collections.<Pathfinder.PathElement>emptyList();
@SuppressWarnings("unused")
private static final String LOGTAG = "MixpanelAPI.EProtocol";
} // EditProtocol