package com.mixpanel.android.viewcrawler;
import android.annotation.TargetApi;
import android.app.Activity;
import android.content.Context;
import android.content.res.ColorStateList;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.Rect;
import android.graphics.drawable.ColorDrawable;
import android.graphics.drawable.Drawable;
import android.os.Build;
import android.os.Handler;
import android.os.Looper;
import android.util.Base64;
import android.util.Base64OutputStream;
import android.util.DisplayMetrics;
import android.util.JsonWriter;
import android.util.LruCache;
import android.view.View;
import android.view.ViewGroup;
import android.widget.RelativeLayout;
import com.mixpanel.android.mpmetrics.MPConfig;
import com.mixpanel.android.mpmetrics.ResourceIds;
import com.mixpanel.android.util.MPLog;
import org.json.JSONObject;
import java.io.IOException;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Set;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
@TargetApi(MPConfig.UI_FEATURES_MIN_API)
/* package */ class ViewSnapshot {
public ViewSnapshot(Context context, List<PropertyDescription> properties, ResourceIds resourceIds) {
mConfig = MPConfig.getInstance(context);
mProperties = properties;
mResourceIds = resourceIds;
mMainThreadHandler = new Handler(Looper.getMainLooper());
mRootViewFinder = new RootViewFinder();
mClassnameCache = new ClassNameCache(MAX_CLASS_NAME_CACHE_SIZE);
}
/**
* Take a snapshot of each activity in liveActivities. The given UIThreadSet will be accessed
* on the main UI thread, and should contain a set with elements for every activity to be
* snapshotted. Given stream out will be written on the calling thread.
*/
public void snapshots(UIThreadSet<Activity> liveActivities, OutputStream out) throws IOException {
mRootViewFinder.findInActivities(liveActivities);
final FutureTask<List<RootViewInfo>> infoFuture = new FutureTask<List<RootViewInfo>>(mRootViewFinder);
mMainThreadHandler.post(infoFuture);
final OutputStreamWriter writer = new OutputStreamWriter(out);
List<RootViewInfo> infoList = Collections.<RootViewInfo>emptyList();
writer.write("[");
try {
infoList = infoFuture.get(1, TimeUnit.SECONDS);
} catch (final InterruptedException e) {
MPLog.d(LOGTAG, "Screenshot interrupted, no screenshot will be sent.", e);
} catch (final TimeoutException e) {
MPLog.i(LOGTAG, "Screenshot took more than 1 second to be scheduled and executed. No screenshot will be sent.", e);
} catch (final ExecutionException e) {
MPLog.e(LOGTAG, "Exception thrown during screenshot attempt", e);
}
final int infoCount = infoList.size();
for (int i = 0; i < infoCount; i++) {
if (i > 0) {
writer.write(",");
}
final RootViewInfo info = infoList.get(i);
writer.write("{");
writer.write("\"activity\":");
writer.write(JSONObject.quote(info.activityName));
writer.write(",");
writer.write("\"scale\":");
writer.write(String.format("%s", info.scale));
writer.write(",");
writer.write("\"serialized_objects\":");
{
final JsonWriter j = new JsonWriter(writer);
j.beginObject();
j.name("rootObject").value(info.rootView.hashCode());
j.name("objects");
snapshotViewHierarchy(j, info.rootView);
j.endObject();
j.flush();
}
writer.write(",");
writer.write("\"screenshot\":");
writer.flush();
info.screenshot.writeBitmapJSON(Bitmap.CompressFormat.PNG, 100, out);
writer.write("}");
}
writer.write("]");
writer.flush();
}
// For testing only
/* package */ List<PropertyDescription> getProperties() {
return mProperties;
}
/* package */ void snapshotViewHierarchy(JsonWriter j, View rootView)
throws IOException {
j.beginArray();
snapshotView(j, rootView);
j.endArray();
}
private void snapshotView(JsonWriter j, View view)
throws IOException {
if (view.getVisibility() == View.INVISIBLE && mConfig.getIgnoreInvisibleViewsEditor()) {
return;
}
final int viewId = view.getId();
final String viewIdName;
if (-1 == viewId) {
viewIdName = null;
} else {
viewIdName = mResourceIds.nameForId(viewId);
}
j.beginObject();
j.name("hashCode").value(view.hashCode());
j.name("id").value(viewId);
j.name("mp_id_name").value(viewIdName);
final CharSequence description = view.getContentDescription();
if (null == description) {
j.name("contentDescription").nullValue();
} else {
j.name("contentDescription").value(description.toString());
}
final Object tag = view.getTag();
if (null == tag) {
j.name("tag").nullValue();
} else if (tag instanceof CharSequence) {
j.name("tag").value(tag.toString());
}
j.name("top").value(view.getTop());
j.name("left").value(view.getLeft());
j.name("width").value(view.getWidth());
j.name("height").value(view.getHeight());
j.name("scrollX").value(view.getScrollX());
j.name("scrollY").value(view.getScrollY());
j.name("visibility").value(view.getVisibility());
float translationX = 0;
float translationY = 0;
if (Build.VERSION.SDK_INT >= 11) {
translationX = view.getTranslationX();
translationY = view.getTranslationY();
}
j.name("translationX").value(translationX);
j.name("translationY").value(translationY);
j.name("classes");
j.beginArray();
Class<?> klass = view.getClass();
do {
j.value(mClassnameCache.get(klass));
klass = klass.getSuperclass();
} while (klass != Object.class && klass != null);
j.endArray();
addProperties(j, view);
ViewGroup.LayoutParams layoutParams = view.getLayoutParams();
if (layoutParams instanceof RelativeLayout.LayoutParams) {
RelativeLayout.LayoutParams relativeLayoutParams = (RelativeLayout.LayoutParams) layoutParams;
int[] rules = relativeLayoutParams.getRules();
j.name("layoutRules");
j.beginArray();
for (int rule : rules) {
j.value(rule);
}
j.endArray();
}
j.name("subviews");
j.beginArray();
if (view instanceof ViewGroup) {
final ViewGroup group = (ViewGroup) view;
final int childCount = group.getChildCount();
for (int i = 0; i < childCount; i++) {
final View child = group.getChildAt(i);
// child can be null when views are getting disposed.
if (null != child) {
j.value(child.hashCode());
}
}
}
j.endArray();
j.endObject();
if (view instanceof ViewGroup) {
final ViewGroup group = (ViewGroup) view;
final int childCount = group.getChildCount();
for (int i = 0; i < childCount; i++) {
final View child = group.getChildAt(i);
// child can be null when views are getting disposed.
if (null != child) {
snapshotView(j, child);
}
}
}
}
private void addProperties(JsonWriter j, View v)
throws IOException {
final Class<?> viewClass = v.getClass();
for (final PropertyDescription desc : mProperties) {
if (desc.targetClass.isAssignableFrom(viewClass) && null != desc.accessor) {
final Object value = desc.accessor.applyMethod(v);
if (null == value) {
// Don't produce anything in this case
} else if (value instanceof Number) {
j.name(desc.name).value((Number) value);
} else if (value instanceof Boolean) {
j.name(desc.name).value((Boolean) value);
} else if (value instanceof ColorStateList) {
j.name(desc.name).value((Integer) ((ColorStateList) value).getDefaultColor());
} else if (value instanceof Drawable) {
final Drawable drawable = (Drawable) value;
final Rect bounds = drawable.getBounds();
j.name(desc.name);
j.beginObject();
j.name("classes");
j.beginArray();
Class klass = drawable.getClass();
while (klass != Object.class) {
j.value(klass.getCanonicalName());
klass = klass.getSuperclass();
}
j.endArray();
j.name("dimensions");
j.beginObject();
j.name("left").value(bounds.left);
j.name("right").value(bounds.right);
j.name("top").value(bounds.top);
j.name("bottom").value(bounds.bottom);
j.endObject();
if (drawable instanceof ColorDrawable) {
final ColorDrawable colorDrawable = (ColorDrawable) drawable;
j.name("color").value(colorDrawable.getColor());
}
j.endObject();
} else {
j.name(desc.name).value(value.toString());
}
}
}
}
private static class ClassNameCache extends LruCache<Class<?>, String> {
public ClassNameCache(int maxSize) {
super(maxSize);
}
@Override
protected String create(Class<?> klass) {
return klass.getCanonicalName();
}
}
private static class RootViewFinder implements Callable<List<RootViewInfo>> {
public RootViewFinder() {
mDisplayMetrics = new DisplayMetrics();
mRootViews = new ArrayList<RootViewInfo>();
mCachedBitmap = new CachedBitmap();
}
public void findInActivities(UIThreadSet<Activity> liveActivities) {
mLiveActivities = liveActivities;
}
@Override
public List<RootViewInfo> call() throws Exception {
mRootViews.clear();
final Set<Activity> liveActivities = mLiveActivities.getAll();
for (final Activity a : liveActivities) {
final String activityName = a.getClass().getCanonicalName();
final View rootView = a.getWindow().getDecorView().getRootView();
a.getWindowManager().getDefaultDisplay().getMetrics(mDisplayMetrics);
final RootViewInfo info = new RootViewInfo(activityName, rootView);
mRootViews.add(info);
}
final int viewCount = mRootViews.size();
for (int i = 0; i < viewCount; i++) {
final RootViewInfo info = mRootViews.get(i);
takeScreenshot(info);
}
return mRootViews;
}
private void takeScreenshot(final RootViewInfo info) {
final View rootView = info.rootView;
Bitmap rawBitmap = null;
try {
final Method createSnapshot = View.class.getDeclaredMethod("createSnapshot", Bitmap.Config.class, Integer.TYPE, Boolean.TYPE);
createSnapshot.setAccessible(true);
rawBitmap = (Bitmap) createSnapshot.invoke(rootView, Bitmap.Config.RGB_565, Color.WHITE, false);
} catch (final NoSuchMethodException e) {
MPLog.v(LOGTAG, "Can't call createSnapshot, will use drawCache", e);
} catch (final IllegalArgumentException e) {
MPLog.d(LOGTAG, "Can't call createSnapshot with arguments", e);
} catch (final InvocationTargetException e) {
MPLog.e(LOGTAG, "Exception when calling createSnapshot", e);
} catch (final IllegalAccessException e) {
MPLog.e(LOGTAG, "Can't access createSnapshot, using drawCache", e);
} catch (final ClassCastException e) {
MPLog.e(LOGTAG, "createSnapshot didn't return a bitmap?", e);
}
Boolean originalCacheState = null;
try {
if (null == rawBitmap) {
originalCacheState = rootView.isDrawingCacheEnabled();
rootView.setDrawingCacheEnabled(true);
rootView.buildDrawingCache(true);
rawBitmap = rootView.getDrawingCache();
}
} catch (final RuntimeException e) {
MPLog.v(LOGTAG, "Can't take a bitmap snapshot of view " + rootView + ", skipping for now.", e);
}
float scale = 1.0f;
if (null != rawBitmap) {
final int rawDensity = rawBitmap.getDensity();
if (rawDensity != Bitmap.DENSITY_NONE) {
scale = ((float) mClientDensity) / rawDensity;
}
final int rawWidth = rawBitmap.getWidth();
final int rawHeight = rawBitmap.getHeight();
final int destWidth = (int) ((rawBitmap.getWidth() * scale) + 0.5);
final int destHeight = (int) ((rawBitmap.getHeight() * scale) + 0.5);
if (rawWidth > 0 && rawHeight > 0 && destWidth > 0 && destHeight > 0) {
mCachedBitmap.recreate(destWidth, destHeight, mClientDensity, rawBitmap);
}
}
if (null != originalCacheState && !originalCacheState) {
rootView.setDrawingCacheEnabled(false);
}
info.scale = scale;
info.screenshot = mCachedBitmap;
}
private UIThreadSet<Activity> mLiveActivities;
private final List<RootViewInfo> mRootViews;
private final DisplayMetrics mDisplayMetrics;
private final CachedBitmap mCachedBitmap;
private final int mClientDensity = DisplayMetrics.DENSITY_DEFAULT;
}
private static class CachedBitmap {
public CachedBitmap() {
mPaint = new Paint(Paint.FILTER_BITMAP_FLAG);
mCached = null;
}
public synchronized void recreate(int width, int height, int destDensity, Bitmap source) {
if (null == mCached || mCached.getWidth() != width || mCached.getHeight() != height) {
try {
mCached = Bitmap.createBitmap(width, height, Bitmap.Config.RGB_565);
} catch (final OutOfMemoryError e) {
mCached = null;
}
if (null != mCached) {
mCached.setDensity(destDensity);
}
}
if (null != mCached) {
final Canvas scaledCanvas = new Canvas(mCached);
scaledCanvas.drawBitmap(source, 0, 0, mPaint);
}
}
// Writes a QUOTED base64 string (or the string null) to the output stream
public synchronized void writeBitmapJSON(Bitmap.CompressFormat format, int quality, OutputStream out)
throws IOException {
if (null == mCached || mCached.getWidth() == 0 || mCached.getHeight() == 0) {
out.write("null".getBytes());
} else {
out.write('"');
final Base64OutputStream imageOut = new Base64OutputStream(out, Base64.NO_WRAP);
mCached.compress(Bitmap.CompressFormat.PNG, 100, imageOut);
imageOut.flush();
out.write('"');
}
}
private Bitmap mCached;
private final Paint mPaint;
}
private static class RootViewInfo {
public RootViewInfo(String activityName, View rootView) {
this.activityName = activityName;
this.rootView = rootView;
this.screenshot = null;
this.scale = 1.0f;
}
public final String activityName;
public final View rootView;
public CachedBitmap screenshot;
public float scale;
}
private final MPConfig mConfig;
private final RootViewFinder mRootViewFinder;
private final List<PropertyDescription> mProperties;
private final ClassNameCache mClassnameCache;
private final Handler mMainThreadHandler;
private final ResourceIds mResourceIds;
private static final int MAX_CLASS_NAME_CACHE_SIZE = 255;
@SuppressWarnings("unused")
private static final String LOGTAG = "MixpanelAPI.Snapshot";
}