package com.mixpanel.android.viewcrawler; import android.os.Handler; import android.view.View; import android.view.ViewGroup; import android.widget.TextView; import com.mixpanel.android.mpmetrics.MixpanelAPI; import com.mixpanel.android.util.MPLog; import org.json.JSONException; import org.json.JSONObject; import java.util.HashMap; import java.util.Iterator; import java.util.Map; /** * Handles translating events detected by ViewVisitors into events sent to Mixpanel * * - Builds properties by interrogating view subtrees * * - Possibly debounces events using the Handler given at construction * * - Calls MixpanelAPI.track */ /* package */ class DynamicEventTracker implements ViewVisitor.OnEventListener { public DynamicEventTracker(MixpanelAPI mixpanel, Handler homeHandler) { mMixpanel = mixpanel; mDebouncedEvents = new HashMap<Signature, UnsentEvent>(); mTask = new SendDebouncedTask(); mHandler = homeHandler; } @Override public void OnEvent(View v, String eventName, boolean debounce) { // Will be called on the UI thread final long moment = System.currentTimeMillis(); final JSONObject properties = new JSONObject(); try { final String text = textPropertyFromView(v); properties.put("$text", text); properties.put("$from_binding", true); // We may call track much later, but we'll be tracking something // that happened right at moment. properties.put("time", moment / 1000); } catch (JSONException e) { MPLog.e(LOGTAG, "Can't format properties from view due to JSON issue", e); } if (debounce) { final Signature eventSignature = new Signature(v, eventName); final UnsentEvent event = new UnsentEvent(eventName, properties, moment); // No scheduling mTask without holding a lock on mDebouncedEvents, // so that we don't have a rogue thread spinning away when no events // are coming in. synchronized (mDebouncedEvents) { final boolean needsRestart = mDebouncedEvents.isEmpty(); mDebouncedEvents.put(eventSignature, event); if (needsRestart) { mHandler.postDelayed(mTask, DEBOUNCE_TIME_MILLIS); } } } else { mMixpanel.track(eventName, properties); } } // Attempts to send all tasks in mDebouncedEvents that have been waiting for // more than DEBOUNCE_TIME_MILLIS. Will reschedule itself as long as there // are more events waiting (but will *not* wait on an empty set) private final class SendDebouncedTask implements Runnable { @Override public void run() { final long now = System.currentTimeMillis(); synchronized (mDebouncedEvents) { final Iterator<Map.Entry<Signature, UnsentEvent>> iter = mDebouncedEvents.entrySet().iterator(); while (iter.hasNext()) { final Map.Entry<Signature, UnsentEvent> entry = iter.next(); final UnsentEvent val = entry.getValue(); if (now - val.timeSentMillis > DEBOUNCE_TIME_MILLIS) { mMixpanel.track(val.eventName, val.properties); iter.remove(); } } if (! mDebouncedEvents.isEmpty()) { // In the average case, this is enough time to catch the next signal mHandler.postDelayed(this, DEBOUNCE_TIME_MILLIS / 2); } } // synchronized } } /** * Recursively scans a view and it's children, looking for user-visible text to * provide as an event property. */ private static String textPropertyFromView(View v) { String ret = null; if (v instanceof TextView) { final TextView textV = (TextView) v; final CharSequence retSequence = textV.getText(); if (null != retSequence) { ret = retSequence.toString(); } } else if (v instanceof ViewGroup) { final StringBuilder builder = new StringBuilder(); final ViewGroup vGroup = (ViewGroup) v; final int childCount = vGroup.getChildCount(); boolean textSeen = false; for (int i = 0; i < childCount && builder.length() < MAX_PROPERTY_LENGTH; i++) { final View child = vGroup.getChildAt(i); final String childText = textPropertyFromView(child); if (null != childText && childText.length() > 0) { if (textSeen) { builder.append(", "); } builder.append(childText); textSeen = true; } } if (builder.length() > MAX_PROPERTY_LENGTH) { ret = builder.substring(0, MAX_PROPERTY_LENGTH); } else if (textSeen) { ret = builder.toString(); } } return ret; } // An event is the same from a debouncing perspective if it comes from the same view, // and has the same event name. private static class Signature { public Signature(final View view, final String eventName) { mHashCode = view.hashCode() ^ eventName.hashCode(); } @Override public boolean equals(Object o) { if (o instanceof Signature) { return mHashCode == o.hashCode(); } return false; } @Override public int hashCode() { return mHashCode; } private final int mHashCode; } private static class UnsentEvent { public UnsentEvent(final String name, final JSONObject props, final long timeSent) { eventName = name; properties = props; timeSentMillis = timeSent; } public final long timeSentMillis; public final String eventName; public final JSONObject properties; } private final MixpanelAPI mMixpanel; private final Handler mHandler; private final Runnable mTask; // List of debounced events, All accesses must be synchronized private final Map<Signature, UnsentEvent> mDebouncedEvents; private static final int MAX_PROPERTY_LENGTH = 128; private static final int DEBOUNCE_TIME_MILLIS = 1000; // 1 second delay before sending @SuppressWarnings("Unused") private static String LOGTAG = "MixpanelAPI.DynamicEventTracker"; }