package com.mixpanel.android.viewcrawler;
import android.annotation.TargetApi;
import android.graphics.Bitmap;
import android.graphics.drawable.BitmapDrawable;
import android.text.Editable;
import android.text.TextWatcher;
import android.util.SparseArray;
import android.view.View;
import android.view.ViewGroup;
import android.widget.RelativeLayout;
import android.widget.TextView;
import com.mixpanel.android.mpmetrics.MPConfig;
import com.mixpanel.android.util.MPLog;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import java.util.WeakHashMap;
@TargetApi(MPConfig.UI_FEATURES_MIN_API)
/* package */ abstract class ViewVisitor implements Pathfinder.Accumulator {
/**
* OnEvent will be fired when whatever the ViewVisitor installed fires
* (For example, if the ViewVisitor installs watches for clicks, then OnEvent will be called
* on click)
*/
public interface OnEventListener {
void OnEvent(View host, String eventName, boolean debounce);
}
public interface OnLayoutErrorListener {
void onLayoutError(LayoutErrorMessage e);
}
public static class LayoutErrorMessage {
public LayoutErrorMessage(String errorType, String name) {
mErrorType = errorType;
mName = name;
}
public String getErrorType() {
return mErrorType;
}
public String getName() {
return mName;
}
private final String mErrorType;
private final String mName;
}
/**
* Attempts to apply mutator to every matching view. Use this to update properties
* in the view hierarchy. If accessor is non-null, it will be used to attempt to
* prevent calls to the mutator if the property already has the intended value.
*/
public static class PropertySetVisitor extends ViewVisitor {
public PropertySetVisitor(List<Pathfinder.PathElement> path, Caller mutator, Caller accessor) {
super(path);
mMutator = mutator;
mAccessor = accessor;
mOriginalValueHolder = new Object[1];
mOriginalValues = new WeakHashMap<View, Object>();
}
@Override
public void cleanup() {
for (Map.Entry<View, Object> original:mOriginalValues.entrySet()) {
final View changedView = original.getKey();
final Object originalValue = original.getValue();
if (null != originalValue) {
mOriginalValueHolder[0] = originalValue;
mMutator.applyMethodWithArguments(changedView, mOriginalValueHolder);
}
}
}
@Override
public void accumulate(View found) {
if (null != mAccessor) {
final Object[] setArgs = mMutator.getArgs();
if (1 == setArgs.length) {
final Object desiredValue = setArgs[0];
final Object currentValue = mAccessor.applyMethod(found);
if (desiredValue == currentValue) {
return;
}
if (null != desiredValue) {
if (desiredValue instanceof Bitmap && currentValue instanceof Bitmap) {
final Bitmap desiredBitmap = (Bitmap) desiredValue;
final Bitmap currentBitmap = (Bitmap) currentValue;
if (desiredBitmap.sameAs(currentBitmap)) {
return;
}
} else if (desiredValue instanceof BitmapDrawable && currentValue instanceof BitmapDrawable) {
final Bitmap desiredBitmap = ((BitmapDrawable) desiredValue).getBitmap();
final Bitmap currentBitmap = ((BitmapDrawable) currentValue).getBitmap();
if (desiredBitmap != null && desiredBitmap.sameAs(currentBitmap)) {
return;
}
} else if (desiredValue.equals(currentValue)) {
return;
}
}
if (currentValue instanceof Bitmap ||
currentValue instanceof BitmapDrawable ||
mOriginalValues.containsKey(found)) {
; // Cache exactly one non-image original value
} else {
mOriginalValueHolder[0] = currentValue;
if (mMutator.argsAreApplicable(mOriginalValueHolder)) {
mOriginalValues.put(found, currentValue);
} else {
mOriginalValues.put(found, null);
}
}
}
}
mMutator.applyMethod(found);
}
protected String name() {
return "Property Mutator";
}
private final Caller mMutator;
private final Caller mAccessor;
private final WeakHashMap<View, Object> mOriginalValues;
private final Object[] mOriginalValueHolder;
}
private static class CycleDetector {
/**
* This function detects circular dependencies for all the views under the parent
* of the updated view. The basic idea is to consider the views as a directed
* graph and perform a DFS on all the nodes in the graph. If the current node is
* in the DFS stack already, there must be a circle in the graph. To speed up the
* search, all the parsed nodes will be removed from the graph.
*/
public boolean hasCycle(TreeMap<View, List<View>> dependencyGraph) {
final List<View> dfsStack = new ArrayList<View>();
while (!dependencyGraph.isEmpty()) {
View currentNode = dependencyGraph.firstKey();
if (!detectSubgraphCycle(dependencyGraph, currentNode, dfsStack)) {
return false;
}
}
return true;
}
private boolean detectSubgraphCycle(TreeMap<View, List<View>> dependencyGraph,
View currentNode, List<View> dfsStack) {
if (dfsStack.contains(currentNode)) {
return false;
}
if (dependencyGraph.containsKey(currentNode)) {
final List<View> dependencies = dependencyGraph.remove(currentNode);
dfsStack.add(currentNode);
int size = dependencies.size();
for (int i = 0; i < size; i++) {
if (!detectSubgraphCycle(dependencyGraph, dependencies.get(i), dfsStack)) {
return false;
}
}
dfsStack.remove(currentNode);
}
return true;
}
}
public static class LayoutUpdateVisitor extends ViewVisitor {
public LayoutUpdateVisitor(List<Pathfinder.PathElement> path, List<LayoutRule> args,
String name, OnLayoutErrorListener onLayoutErrorListener) {
super(path);
mOriginalValues = new WeakHashMap<View, int[]>();
mArgs = args;
mName = name;
mAlive = true;
mOnLayoutErrorListener = onLayoutErrorListener;
mCycleDetector = new CycleDetector();
}
@Override
public void cleanup() {
// TODO find a way to optimize this.. remove this visitor and trigger a re-layout??
for (Map.Entry<View, int[]> original:mOriginalValues.entrySet()) {
final View changedView = original.getKey();
final int[] originalValue = original.getValue();
final RelativeLayout.LayoutParams originalParams = (RelativeLayout.LayoutParams) changedView.getLayoutParams();
for (int i = 0; i < originalValue.length; i++) {
originalParams.addRule(i, originalValue[i]);
}
changedView.setLayoutParams(originalParams);
}
mAlive = false;
}
@Override
public void visit(View rootView) {
// this check is necessary - if the layout change is invalid, accumulate will send an error message
// to the Web UI; before Web UI removes such change, this visit may get called by Android again and
// thus send another error message to Web UI which leads to lots of weird problems
if (mAlive) {
getPathfinder().findTargetsInRoot(rootView, getPath(), this);
}
}
// layout changes are performed on the children of found according to the LayoutRule
@Override
public void accumulate(View found) {
ViewGroup parent = (ViewGroup) found;
SparseArray<View> idToChild = new SparseArray<View>();
int count = parent.getChildCount();
for (int i = 0; i < count; i++) {
View child = parent.getChildAt(i);
int childId = child.getId();
if (childId > 0) {
idToChild.put(childId, child);
}
}
int size = mArgs.size();
for (int i = 0; i < size; i++) {
LayoutRule layoutRule = mArgs.get(i);
final View currentChild = idToChild.get(layoutRule.viewId);
if (null == currentChild) {
continue;
}
RelativeLayout.LayoutParams currentParams = (RelativeLayout.LayoutParams) currentChild.getLayoutParams();
final int[] currentRules = currentParams.getRules().clone();
if (currentRules[layoutRule.verb] == layoutRule.anchor) {
continue;
}
if (mOriginalValues.containsKey(currentChild)) {
; // Cache exactly one set of rules per child view
} else {
mOriginalValues.put(currentChild, currentRules);
}
currentParams.addRule(layoutRule.verb, layoutRule.anchor);
final Set<Integer> rules;
if (mHorizontalRules.contains(layoutRule.verb)) {
rules = mHorizontalRules;
} else if (mVerticalRules.contains(layoutRule.verb)) {
rules = mVerticalRules;
} else {
rules = null;
}
if (rules != null && !verifyLayout(rules, idToChild)) {
cleanup();
mOnLayoutErrorListener.onLayoutError(new LayoutErrorMessage("circular_dependency", mName));
return;
}
currentChild.setLayoutParams(currentParams);
}
}
private boolean verifyLayout(Set<Integer> rules, SparseArray<View> idToChild) {
// We don't really care about the order, as long as it's always the same.
final TreeMap<View, List<View>> dependencyGraph = new TreeMap<View, List<View>>(new Comparator<View>() {
@Override
public int compare(final View lhs, final View rhs) {
if (lhs == rhs) {
return 0;
} else if (null == lhs) {
return -1;
} else if (null == rhs){
return 1;
} else {
return rhs.hashCode() - lhs.hashCode();
}
}
});
int size = idToChild.size();
for (int i = 0; i < size; i++) {
final View child = idToChild.valueAt(i);
final RelativeLayout.LayoutParams childLayoutParams = (RelativeLayout.LayoutParams) child.getLayoutParams();
int[] layoutRules = childLayoutParams.getRules();
final List<View> dependencies = new ArrayList<View>();
for (int rule : rules) {
int dependencyId = layoutRules[rule];
if (dependencyId > 0 && dependencyId != child.getId()) {
dependencies.add(idToChild.get(dependencyId));
}
}
dependencyGraph.put(child, dependencies);
}
return mCycleDetector.hasCycle(dependencyGraph);
}
protected String name() { return "Layout Update"; }
private final WeakHashMap<View, int[]> mOriginalValues;
private final List<LayoutRule> mArgs;
private final String mName;
private static final Set<Integer> mHorizontalRules = new HashSet<Integer>(Arrays.asList(
RelativeLayout.LEFT_OF, RelativeLayout.RIGHT_OF,
RelativeLayout.ALIGN_LEFT, RelativeLayout.ALIGN_RIGHT
));
private static final Set<Integer> mVerticalRules = new HashSet<Integer>(Arrays.asList(
RelativeLayout.ABOVE, RelativeLayout.BELOW,
RelativeLayout.ALIGN_BASELINE, RelativeLayout.ALIGN_TOP,
RelativeLayout.ALIGN_BOTTOM
));
private boolean mAlive;
private final OnLayoutErrorListener mOnLayoutErrorListener;
private final CycleDetector mCycleDetector;
}
public static class LayoutRule {
public LayoutRule(int vi, int v, int a) {
viewId = vi;
verb = v;
anchor = a;
}
public final int viewId;
public final int verb;
public final int anchor;
}
/**
* Adds an accessibility event, which will fire OnEvent, to every matching view.
*/
public static class AddAccessibilityEventVisitor extends EventTriggeringVisitor {
public AddAccessibilityEventVisitor(List<Pathfinder.PathElement> path, int accessibilityEventType, String eventName, OnEventListener listener) {
super(path, eventName, listener, false);
mEventType = accessibilityEventType;
mWatching = new WeakHashMap<View, TrackingAccessibilityDelegate>();
}
@Override
public void cleanup() {
for (final Map.Entry<View, TrackingAccessibilityDelegate> entry:mWatching.entrySet()) {
final View v = entry.getKey();
final TrackingAccessibilityDelegate toCleanup = entry.getValue();
final View.AccessibilityDelegate currentViewDelegate = getOldDelegate(v);
if (currentViewDelegate == toCleanup) {
v.setAccessibilityDelegate(toCleanup.getRealDelegate());
} else if (currentViewDelegate instanceof TrackingAccessibilityDelegate) {
final TrackingAccessibilityDelegate newChain = (TrackingAccessibilityDelegate) currentViewDelegate;
newChain.removeFromDelegateChain(toCleanup);
} else {
// Assume we've been replaced, zeroed out, or for some other reason we're already gone.
// (This isn't too weird, for example, it's expected when views get recycled)
}
}
mWatching.clear();
}
@Override
public void accumulate(View found) {
final View.AccessibilityDelegate realDelegate = getOldDelegate(found);
if (realDelegate instanceof TrackingAccessibilityDelegate) {
final TrackingAccessibilityDelegate currentTracker = (TrackingAccessibilityDelegate) realDelegate;
if (currentTracker.willFireEvent(getEventName())) {
return; // Don't double track
}
}
// We aren't already in the tracking call chain of the view
final TrackingAccessibilityDelegate newDelegate = new TrackingAccessibilityDelegate(realDelegate);
found.setAccessibilityDelegate(newDelegate);
mWatching.put(found, newDelegate);
}
@Override
protected String name() {
return getEventName() + " event when (" + mEventType + ")";
}
private View.AccessibilityDelegate getOldDelegate(View v) {
View.AccessibilityDelegate ret = null;
try {
Class<?> klass = v.getClass();
Method m = klass.getMethod("getAccessibilityDelegate");
ret = (View.AccessibilityDelegate) m.invoke(v);
} catch (NoSuchMethodException e) {
// In this case, we just overwrite the original.
} catch (IllegalAccessException e) {
// In this case, we just overwrite the original.
} catch (InvocationTargetException e) {
MPLog.w(LOGTAG, "getAccessibilityDelegate threw an exception when called.", e);
}
return ret;
}
private class TrackingAccessibilityDelegate extends View.AccessibilityDelegate {
public TrackingAccessibilityDelegate(View.AccessibilityDelegate realDelegate) {
mRealDelegate = realDelegate;
}
public View.AccessibilityDelegate getRealDelegate() {
return mRealDelegate;
}
public boolean willFireEvent(final String eventName) {
if (getEventName() == eventName) {
return true;
} else if (mRealDelegate instanceof TrackingAccessibilityDelegate) {
return ((TrackingAccessibilityDelegate) mRealDelegate).willFireEvent(eventName);
} else {
return false;
}
}
public void removeFromDelegateChain(final TrackingAccessibilityDelegate other) {
if (mRealDelegate == other) {
mRealDelegate = other.getRealDelegate();
} else if (mRealDelegate instanceof TrackingAccessibilityDelegate) {
final TrackingAccessibilityDelegate child = (TrackingAccessibilityDelegate) mRealDelegate;
child.removeFromDelegateChain(other);
} else {
// We can't see any further down the chain, just return.
}
}
@Override
public void sendAccessibilityEvent(View host, int eventType) {
if (eventType == mEventType) {
fireEvent(host);
}
if (null != mRealDelegate) {
mRealDelegate.sendAccessibilityEvent(host, eventType);
}
}
private View.AccessibilityDelegate mRealDelegate;
}
private final int mEventType;
private final WeakHashMap<View, TrackingAccessibilityDelegate> mWatching;
}
/**
* Installs a TextWatcher in each matching view. Does nothing if matching views are not TextViews.
*/
public static class AddTextChangeListener extends EventTriggeringVisitor {
public AddTextChangeListener(List<Pathfinder.PathElement> path, String eventName, OnEventListener listener) {
super(path, eventName, listener, true);
mWatching = new HashMap<TextView, TextWatcher>();
}
@Override
public void cleanup() {
for (final Map.Entry<TextView, TextWatcher> entry:mWatching.entrySet()) {
final TextView v = entry.getKey();
final TextWatcher watcher = entry.getValue();
v.removeTextChangedListener(watcher);
}
mWatching.clear();
}
@Override
public void accumulate(View found) {
if (found instanceof TextView) {
final TextView foundTextView = (TextView) found;
final TextWatcher watcher = new TrackingTextWatcher(foundTextView);
final TextWatcher oldWatcher = mWatching.get(foundTextView);
if (null != oldWatcher) {
foundTextView.removeTextChangedListener(oldWatcher);
}
foundTextView.addTextChangedListener(watcher);
mWatching.put(foundTextView, watcher);
}
}
@Override
protected String name() {
return getEventName() + " on Text Change";
}
private class TrackingTextWatcher implements TextWatcher {
public TrackingTextWatcher(View boundTo) {
mBoundTo = boundTo;
}
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after) {
; // Nothing
}
@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {
; // Nothing
}
@Override
public void afterTextChanged(Editable s) {
fireEvent(mBoundTo);
}
private final View mBoundTo;
}
private final Map<TextView, TextWatcher> mWatching;
}
/**
* Monitors the view tree for the appearance of matching views where there were not
* matching views before. Fires only once per traversal.
*/
public static class ViewDetectorVisitor extends EventTriggeringVisitor {
public ViewDetectorVisitor(List<Pathfinder.PathElement> path, String eventName, OnEventListener listener) {
super(path, eventName, listener, false);
mSeen = false;
}
@Override
public void cleanup() {
; // Do nothing, we don't have anything to leak :)
}
@Override
public void accumulate(View found) {
if (found != null && !mSeen) {
fireEvent(found);
}
mSeen = (found != null);
}
@Override
protected String name() {
return getEventName() + " when Detected";
}
private boolean mSeen;
}
private static abstract class EventTriggeringVisitor extends ViewVisitor {
public EventTriggeringVisitor(List<Pathfinder.PathElement> path, String eventName, OnEventListener listener, boolean debounce) {
super(path);
mListener = listener;
mEventName = eventName;
mDebounce = debounce;
}
protected void fireEvent(View found) {
mListener.OnEvent(found, mEventName, mDebounce);
}
protected String getEventName() {
return mEventName;
}
private final OnEventListener mListener;
private final String mEventName;
private final boolean mDebounce;
}
/**
* Scans the View hierarchy below rootView, applying it's operation to each matching child view.
*/
public void visit(View rootView) {
mPathfinder.findTargetsInRoot(rootView, mPath, this);
}
/**
* Removes listeners and frees resources associated with the visitor. Once cleanup is called,
* the ViewVisitor should not be used again.
*/
public abstract void cleanup();
protected ViewVisitor(List<Pathfinder.PathElement> path) {
mPath = path;
mPathfinder = new Pathfinder();
}
protected List<Pathfinder.PathElement> getPath() {
return mPath;
}
protected Pathfinder getPathfinder() {
return mPathfinder;
}
protected abstract String name();
private final List<Pathfinder.PathElement> mPath;
private final Pathfinder mPathfinder;
private static final String LOGTAG = "MixpanelAPI.ViewVisitor";
}