Improve hierarchy viewer dump hierarchy latency

Hierarchy Viewer obtains the properties for each view by using
reflection and looking for fields and methods that have the
@ExportedProperty annotation. Using reflection made it quite slow
for large view hierarchies.

This CL adds a new method (encode) to each class that wishes to
export data to hiererachy viewer. Inside this method, the object
can write a sequence of key, value pairs corresponding to the
values it wants exported.

With this change, the dump hierarchy operation that used to take
more than 10 seconds can be performed in a few hundred milliseconds.

Change-Id: I199ac2e7ca3c59ebcfec7e6bd201e134c41fd583
diff --git a/core/java/android/content/res/Resources.java b/core/java/android/content/res/Resources.java
index 1d108a2..e65b4ca 100644
--- a/core/java/android/content/res/Resources.java
+++ b/core/java/android/content/res/Resources.java
@@ -20,7 +20,6 @@
 import android.annotation.ColorInt;
 import android.annotation.StyleRes;
 import android.annotation.StyleableRes;
-
 import com.android.internal.util.GrowingArrayUtils;
 import com.android.internal.util.XmlUtils;
 
@@ -62,6 +61,7 @@
 import android.util.Slog;
 import android.util.TypedValue;
 import android.view.ViewDebug;
+import android.view.ViewHierarchyEncoder;
 
 import java.io.IOException;
 import java.io.InputStream;
@@ -1806,12 +1806,27 @@
             for (int i = 0, j = N - 1; i < themes.length; i += 2, --j) {
                 final int resId = mKey.mResId[i];
                 final boolean forced = mKey.mForce[i];
-                themes[i] = getResourceName(resId);
+                try {
+                    themes[i] = getResourceName(resId);
+                } catch (NotFoundException e) {
+                    themes[i] = Integer.toHexString(i);
+                }
                 themes[i + 1] = forced ? "forced" : "not forced";
             }
             return themes;
         }
 
+        /** @hide */
+        public void encode(@NonNull ViewHierarchyEncoder encoder) {
+            encoder.beginObject(this);
+            // TODO: revert after getTheme() is fixed
+            String[] properties = new String[0]; // getTheme();
+            for (int i = 0; i < properties.length; i += 2) {
+                encoder.addProperty(properties[i], properties[i+1]);
+            }
+            encoder.endObject();
+        }
+
         /**
          * Rebases the theme against the parent Resource object's current
          * configuration by re-applying the styles passed to
diff --git a/core/java/android/ddm/DdmHandleViewDebug.java b/core/java/android/ddm/DdmHandleViewDebug.java
index 3a36b0a..be48633 100644
--- a/core/java/android/ddm/DdmHandleViewDebug.java
+++ b/core/java/android/ddm/DdmHandleViewDebug.java
@@ -229,15 +229,25 @@
     private Chunk dumpHierarchy(View rootView, ByteBuffer in) {
         boolean skipChildren = in.getInt() > 0;
         boolean includeProperties = in.getInt() > 0;
+        boolean v2 = in.hasRemaining() && in.getInt() > 0;
 
-        ByteArrayOutputStream b = new ByteArrayOutputStream(1024);
+        long start = System.currentTimeMillis();
+
+        ByteArrayOutputStream b = new ByteArrayOutputStream(2*1024*1024);
         try {
-            ViewDebug.dump(rootView, skipChildren, includeProperties, b);
-        } catch (IOException e) {
+            if (v2) {
+                ViewDebug.dumpv2(rootView, b);
+            } else {
+                ViewDebug.dump(rootView, skipChildren, includeProperties, b);
+            }
+        } catch (IOException | InterruptedException e) {
             return createFailChunk(1, "Unexpected error while obtaining view hierarchy: "
                     + e.getMessage());
         }
 
+        long end = System.currentTimeMillis();
+        Log.d(TAG, "Time to obtain view hierarchy (ms): " + (end - start));
+
         byte[] data = b.toByteArray();
         return new Chunk(CHUNK_VURT, data, 0, data.length);
     }
diff --git a/core/java/android/view/View.java b/core/java/android/view/View.java
index f62e6a2..a26c953 100644
--- a/core/java/android/view/View.java
+++ b/core/java/android/view/View.java
@@ -22353,4 +22353,138 @@
         final String output = bits + " " + name;
         found.put(key, output);
     }
+
+    /** {@hide} */
+    void encode(@NonNull ViewHierarchyEncoder stream) {
+        stream.beginObject(this);
+        encodeProperties(stream);
+        stream.endObject();
+    }
+
+    /** {@hide} */
+    @CallSuper
+    protected void encodeProperties(@NonNull ViewHierarchyEncoder stream) {
+        Object resolveId = ViewDebug.resolveId(getContext(), mID);
+        if (resolveId instanceof String) {
+            stream.addProperty("id", (String) resolveId);
+        } else {
+            stream.addProperty("id", mID);
+        }
+
+        stream.addProperty("misc:transformation.alpha",
+                mTransformationInfo != null ? mTransformationInfo.mAlpha : 0);
+        stream.addProperty("misc:transitionName", getTransitionName());
+
+        // layout
+        stream.addProperty("layout:left", mLeft);
+        stream.addProperty("layout:right", mRight);
+        stream.addProperty("layout:top", mTop);
+        stream.addProperty("layout:bottom", mBottom);
+        stream.addProperty("layout:width", getWidth());
+        stream.addProperty("layout:height", getHeight());
+        stream.addProperty("layout:layoutDirection", getLayoutDirection());
+        stream.addProperty("layout:layoutRtl", isLayoutRtl());
+        stream.addProperty("layout:hasTransientState", hasTransientState());
+        stream.addProperty("layout:baseline", getBaseline());
+
+        // layout params
+        ViewGroup.LayoutParams layoutParams = getLayoutParams();
+        if (layoutParams != null) {
+            stream.addPropertyKey("layoutParams");
+            layoutParams.encode(stream);
+        }
+
+        // scrolling
+        stream.addProperty("scrolling:scrollX", mScrollX);
+        stream.addProperty("scrolling:scrollY", mScrollY);
+
+        // padding
+        stream.addProperty("padding:paddingLeft", mPaddingLeft);
+        stream.addProperty("padding:paddingRight", mPaddingRight);
+        stream.addProperty("padding:paddingTop", mPaddingTop);
+        stream.addProperty("padding:paddingBottom", mPaddingBottom);
+        stream.addProperty("padding:userPaddingRight", mUserPaddingRight);
+        stream.addProperty("padding:userPaddingLeft", mUserPaddingLeft);
+        stream.addProperty("padding:userPaddingBottom", mUserPaddingBottom);
+        stream.addProperty("padding:userPaddingStart", mUserPaddingStart);
+        stream.addProperty("padding:userPaddingEnd", mUserPaddingEnd);
+
+        // measurement
+        stream.addProperty("measurement:minHeight", mMinHeight);
+        stream.addProperty("measurement:minWidth", mMinWidth);
+        stream.addProperty("measurement:measuredWidth", mMeasuredWidth);
+        stream.addProperty("measurement:measuredHeight", mMeasuredHeight);
+
+        // drawing
+        stream.addProperty("drawing:elevation", getElevation());
+        stream.addProperty("drawing:translationX", getTranslationX());
+        stream.addProperty("drawing:translationY", getTranslationY());
+        stream.addProperty("drawing:translationZ", getTranslationZ());
+        stream.addProperty("drawing:rotation", getRotation());
+        stream.addProperty("drawing:rotationX", getRotationX());
+        stream.addProperty("drawing:rotationY", getRotationY());
+        stream.addProperty("drawing:scaleX", getScaleX());
+        stream.addProperty("drawing:scaleY", getScaleY());
+        stream.addProperty("drawing:pivotX", getPivotX());
+        stream.addProperty("drawing:pivotY", getPivotY());
+        stream.addProperty("drawing:opaque", isOpaque());
+        stream.addProperty("drawing:alpha", getAlpha());
+        stream.addProperty("drawing:transitionAlpha", getTransitionAlpha());
+        stream.addProperty("drawing:shadow", hasShadow());
+        stream.addProperty("drawing:solidColor", getSolidColor());
+        stream.addProperty("drawing:layerType", mLayerType);
+        stream.addProperty("drawing:willNotDraw", willNotDraw());
+        stream.addProperty("drawing:hardwareAccelerated", isHardwareAccelerated());
+        stream.addProperty("drawing:willNotCacheDrawing", willNotCacheDrawing());
+        stream.addProperty("drawing:drawingCacheEnabled", isDrawingCacheEnabled());
+        stream.addProperty("drawing:overlappingRendering", hasOverlappingRendering());
+
+        // focus
+        stream.addProperty("focus:hasFocus", hasFocus());
+        stream.addProperty("focus:isFocused", isFocused());
+        stream.addProperty("focus:isFocusable", isFocusable());
+        stream.addProperty("focus:isFocusableInTouchMode", isFocusableInTouchMode());
+
+        stream.addProperty("misc:clickable", isClickable());
+        stream.addProperty("misc:pressed", isPressed());
+        stream.addProperty("misc:selected", isSelected());
+        stream.addProperty("misc:touchMode", isInTouchMode());
+        stream.addProperty("misc:hovered", isHovered());
+        stream.addProperty("misc:activated", isActivated());
+
+        stream.addProperty("misc:visibility", getVisibility());
+        stream.addProperty("misc:fitsSystemWindows", getFitsSystemWindows());
+        stream.addProperty("misc:filterTouchesWhenObscured", getFilterTouchesWhenObscured());
+
+        stream.addProperty("misc:enabled", isEnabled());
+        stream.addProperty("misc:soundEffectsEnabled", isSoundEffectsEnabled());
+        stream.addProperty("misc:hapticFeedbackEnabled", isHapticFeedbackEnabled());
+
+        // theme attributes
+        Resources.Theme theme = getContext().getTheme();
+        if (theme != null) {
+            stream.addPropertyKey("theme");
+            theme.encode(stream);
+        }
+
+        // view attribute information
+        int n = mAttributes != null ? mAttributes.length : 0;
+        stream.addProperty("meta:__attrCount__", n/2);
+        for (int i = 0; i < n; i += 2) {
+            stream.addProperty("meta:__attr__" + mAttributes[i], mAttributes[i+1]);
+        }
+
+        stream.addProperty("misc:scrollBarStyle", getScrollBarStyle());
+
+        // text
+        stream.addProperty("text:textDirection", getTextDirection());
+        stream.addProperty("text:textAlignment", getTextAlignment());
+
+        // accessibility
+        CharSequence contentDescription = getContentDescription();
+        stream.addProperty("accessibility:contentDescription",
+                contentDescription == null ? "" : contentDescription.toString());
+        stream.addProperty("accessibility:labelFor", getLabelFor());
+        stream.addProperty("accessibility:importantForAccessibility", getImportantForAccessibility());
+    }
 }
diff --git a/core/java/android/view/ViewDebug.java b/core/java/android/view/ViewDebug.java
index 27304f5..8bf53a8 100644
--- a/core/java/android/view/ViewDebug.java
+++ b/core/java/android/view/ViewDebug.java
@@ -16,6 +16,7 @@
 
 package android.view;
 
+import android.annotation.NonNull;
 import android.content.Context;
 import android.content.res.Resources;
 import android.graphics.Bitmap;
@@ -800,6 +801,7 @@
 
     /**
      * Dumps the view hierarchy starting from the given view.
+     * @deprecated See {@link #dumpv2(View, ByteArrayOutputStream)} below.
      * @hide
      */
     public static void dump(View root, boolean skipChildren, boolean includeProperties,
@@ -825,6 +827,28 @@
     }
 
     /**
+     * Dumps the view hierarchy starting from the given view.
+     * Rather than using reflection, it uses View's encode method to obtain all the properties.
+     * @hide
+     */
+    public static void dumpv2(@NonNull final View view, @NonNull ByteArrayOutputStream out)
+            throws InterruptedException {
+        final ViewHierarchyEncoder encoder = new ViewHierarchyEncoder(out);
+        final CountDownLatch latch = new CountDownLatch(1);
+
+        view.post(new Runnable() {
+            @Override
+            public void run() {
+                view.encode(encoder);
+                latch.countDown();
+            }
+        });
+
+        latch.await(2, TimeUnit.SECONDS);
+        encoder.endStream();
+    }
+
+    /**
      * Dumps the theme attributes from the given View.
      * @hide
      */
diff --git a/core/java/android/view/ViewGroup.java b/core/java/android/view/ViewGroup.java
index babb4e9..d0738b0 100644
--- a/core/java/android/view/ViewGroup.java
+++ b/core/java/android/view/ViewGroup.java
@@ -6861,6 +6861,19 @@
             }
             return String.valueOf(size);
         }
+
+        /** @hide */
+        void encode(@NonNull ViewHierarchyEncoder encoder) {
+            encoder.beginObject(this);
+            encodeProperties(encoder);
+            encoder.endObject();
+        }
+
+        /** @hide */
+        protected void encodeProperties(@NonNull ViewHierarchyEncoder encoder) {
+            encoder.addProperty("width", width);
+            encoder.addProperty("height", height);
+        }
     }
 
     /**
@@ -7329,6 +7342,18 @@
                     bottomMargin,
                     paint);
         }
+
+        /** @hide */
+        @Override
+        protected void encodeProperties(@NonNull ViewHierarchyEncoder encoder) {
+            super.encodeProperties(encoder);
+            encoder.addProperty("leftMargin", leftMargin);
+            encoder.addProperty("topMargin", topMargin);
+            encoder.addProperty("rightMargin", rightMargin);
+            encoder.addProperty("bottomMargin", bottomMargin);
+            encoder.addProperty("startMargin", startMargin);
+            encoder.addProperty("endMargin", endMargin);
+        }
     }
 
     /* Describes a touched view and the ids of the pointers that it has captured.
@@ -7665,4 +7690,23 @@
 
         canvas.drawLines(sDebugLines, paint);
     }
+
+    /** @hide */
+    @Override
+    protected void encodeProperties(@NonNull ViewHierarchyEncoder encoder) {
+        super.encodeProperties(encoder);
+
+        encoder.addProperty("focus:descendantFocusability", getDescendantFocusability());
+        encoder.addProperty("drawing:clipChildren", getClipChildren());
+        encoder.addProperty("drawing:clipToPadding", getClipToPadding());
+        encoder.addProperty("drawing:childrenDrawingOrderEnabled", isChildrenDrawingOrderEnabled());
+        encoder.addProperty("drawing:persistentDrawingCache", getPersistentDrawingCache());
+
+        int n = getChildCount();
+        encoder.addProperty("meta:__childCount__", (short)n);
+        for (int i = 0; i < n; i++) {
+            encoder.addPropertyKey("meta:__child__" + i);
+            getChildAt(i).encode(encoder);
+        }
+    }
 }
diff --git a/core/java/android/view/ViewHierarchyEncoder.java b/core/java/android/view/ViewHierarchyEncoder.java
new file mode 100644
index 0000000..8770216
--- /dev/null
+++ b/core/java/android/view/ViewHierarchyEncoder.java
@@ -0,0 +1,201 @@
+package android.view;
+
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+
+import java.io.ByteArrayOutputStream;
+import java.io.DataOutputStream;
+import java.io.IOException;
+import java.nio.charset.Charset;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * {@link ViewHierarchyEncoder} is a serializer that is tailored towards writing out
+ * view hierarchies (the view tree, along with the properties for each view) to a stream.
+ *
+ * It is typically used as follows:
+ * <pre>
+ *   ViewHierarchyEncoder e = new ViewHierarchyEncoder();
+ *
+ *   for (View view : views) {
+ *      e.beginObject(view);
+ *      e.addProperty("prop1", value);
+ *      ...
+ *      e.endObject();
+ *   }
+ *
+ *   // repeat above snippet for each view, finally end with:
+ *   e.endStream();
+ * </pre>
+ *
+ * <p>On the stream, a snippet such as the above gets encoded as a series of Map's (one
+ * corresponding to each view) with the property name as the key and the property value
+ * as the value.
+ *
+ * <p>Since the property names are practically the same across all views, rather than using
+ * the property name directly as the key, we use a short integer id corresponding to each
+ * property name as the key. A final map is added at the end which contains the mapping
+ * from the integer to its property name.
+ *
+ * <p>A value is encoded as a single byte type identifier followed by the encoding of the
+ * value. Only primitive types are supported as values, in addition to the Map type.
+ *
+ * @hide
+ */
+public class ViewHierarchyEncoder {
+    // Prefixes for simple primitives. These match the JNI definitions.
+    private static final byte SIG_BOOLEAN = 'Z';
+    private static final byte SIG_BYTE = 'B';
+    private static final byte SIG_SHORT = 'S';
+    private static final byte SIG_INT = 'I';
+    private static final byte SIG_LONG = 'J';
+    private static final byte SIG_FLOAT = 'F';
+    private static final byte SIG_DOUBLE = 'D';
+
+    // Prefixes for some commonly used objects
+    private static final byte SIG_STRING = 'R';
+
+    private static final byte SIG_MAP = 'M'; // a map with an short key
+    private static final short SIG_END_MAP = 0;
+
+    private final DataOutputStream mStream;
+
+    private final Map<String,Short> mPropertyNames = new HashMap<String, Short>(200);
+    private short mPropertyId = 1;
+    private Charset mCharset = Charset.forName("utf-8");
+
+    public ViewHierarchyEncoder(@NonNull ByteArrayOutputStream stream) {
+        mStream = new DataOutputStream(stream);
+    }
+
+    public void beginObject(@NonNull Object o) {
+        startPropertyMap();
+        addProperty("meta:__name__", o.getClass().getName());
+        addProperty("meta:__hash__", o.hashCode());
+    }
+
+    public void endObject() {
+        endPropertyMap();
+    }
+
+    public void endStream() {
+        // write out the string table
+        startPropertyMap();
+        addProperty("__name__", "propertyIndex");
+        for (Map.Entry<String,Short> entry : mPropertyNames.entrySet()) {
+            writeShort(entry.getValue());
+            writeString(entry.getKey());
+        }
+        endPropertyMap();
+    }
+
+    public void addProperty(@NonNull String name, boolean v) {
+        writeShort(createPropertyIndex(name));
+        writeBoolean(v);
+    }
+
+    public void addProperty(@NonNull String name, short s) {
+        writeShort(createPropertyIndex(name));
+        writeShort(s);
+    }
+
+    public void addProperty(@NonNull String name, int v) {
+        writeShort(createPropertyIndex(name));
+        writeInt(v);
+    }
+
+    public void addProperty(@NonNull String name, float v) {
+        writeShort(createPropertyIndex(name));
+        writeFloat(v);
+    }
+
+    public void addProperty(@NonNull String name, @Nullable String s) {
+        writeShort(createPropertyIndex(name));
+        writeString(s);
+    }
+
+    /**
+     * Writes the given name as the property name, and leaves it to the callee
+     * to fill in value for this property.
+     */
+    public void addPropertyKey(@NonNull String name) {
+        writeShort(createPropertyIndex(name));
+    }
+
+    private short createPropertyIndex(@NonNull String name) {
+        Short index = mPropertyNames.get(name);
+        if (index == null) {
+            index = mPropertyId++;
+            mPropertyNames.put(name, index);
+        }
+
+        return index;
+    }
+
+    private void startPropertyMap() {
+        try {
+            mStream.write(SIG_MAP);
+        } catch (IOException e) {
+            // does not happen since the stream simply wraps a ByteArrayOutputStream
+        }
+    }
+
+    private void endPropertyMap() {
+        writeShort(SIG_END_MAP);
+    }
+
+    private void writeBoolean(boolean v) {
+        try {
+            mStream.write(SIG_BOOLEAN);
+            mStream.write(v ? 1 : 0);
+        } catch (IOException e) {
+            // does not happen since the stream simply wraps a ByteArrayOutputStream
+        }
+    }
+
+    private void writeShort(short s) {
+        try {
+            mStream.write(SIG_SHORT);
+            mStream.writeShort(s);
+        } catch (IOException e) {
+            // does not happen since the stream simply wraps a ByteArrayOutputStream
+        }
+    }
+
+    private void writeInt(int i) {
+        try {
+            mStream.write(SIG_INT);
+            mStream.writeInt(i);
+        } catch (IOException e) {
+            // does not happen since the stream simply wraps a ByteArrayOutputStream
+        }
+    }
+
+    private void writeFloat(float v) {
+        try {
+            mStream.write(SIG_FLOAT);
+            mStream.writeFloat(v);
+        } catch (IOException e) {
+            // does not happen since the stream simply wraps a ByteArrayOutputStream
+        }
+    }
+
+    private void writeString(@Nullable String s) {
+        if (s == null) {
+            s = "";
+        }
+
+        try {
+            mStream.write(SIG_STRING);
+            byte[] bytes = s.getBytes(mCharset);
+
+            short len = (short)Math.min(bytes.length, Short.MAX_VALUE);
+            mStream.writeShort(len);
+
+            mStream.write(bytes, 0, len);
+        } catch (IOException e) {
+            // does not happen since the stream simply wraps a ByteArrayOutputStream
+        }
+    }
+}
diff --git a/core/java/android/view/WindowManager.java b/core/java/android/view/WindowManager.java
index e983910..e6c6b120 100644
--- a/core/java/android/view/WindowManager.java
+++ b/core/java/android/view/WindowManager.java
@@ -16,6 +16,7 @@
 
 package android.view;
 
+import android.annotation.NonNull;
 import android.annotation.SystemApi;
 import android.app.Presentation;
 import android.content.Context;
@@ -2066,5 +2067,18 @@
         }
 
         private CharSequence mTitle = "";
+
+        /** @hide */
+        @Override
+        protected void encodeProperties(@NonNull ViewHierarchyEncoder encoder) {
+            super.encodeProperties(encoder);
+
+            encoder.addProperty("x", x);
+            encoder.addProperty("y", y);
+            encoder.addProperty("horizontalWeight", horizontalWeight);
+            encoder.addProperty("verticalWeight", verticalWeight);
+            encoder.addProperty("type", type);
+            encoder.addProperty("flags", flags);
+        }
     }
 }
diff --git a/tests/HierarchyViewerTest/.gitignore b/tests/HierarchyViewerTest/.gitignore
new file mode 100644
index 0000000..75eec98
--- /dev/null
+++ b/tests/HierarchyViewerTest/.gitignore
@@ -0,0 +1,6 @@
+.gradle
+.idea
+*.iml
+gradle*
+build
+local.properties
diff --git a/tests/HierarchyViewerTest/Android.mk b/tests/HierarchyViewerTest/Android.mk
new file mode 100644
index 0000000..07b90f0
--- /dev/null
+++ b/tests/HierarchyViewerTest/Android.mk
@@ -0,0 +1,12 @@
+LOCAL_PATH:= $(call my-dir)
+include $(CLEAR_VARS)
+
+LOCAL_MODULE_TAGS := tests
+
+LOCAL_SRC_FILES := $(call all-subdir-java-files)
+
+LOCAL_PACKAGE_NAME := HierarchyViewerTest
+
+LOCAL_JAVA_LIBRARIES := android.test.runner
+
+include $(BUILD_PACKAGE)
diff --git a/tests/HierarchyViewerTest/AndroidManifest.xml b/tests/HierarchyViewerTest/AndroidManifest.xml
new file mode 100644
index 0000000..65f2fd3
--- /dev/null
+++ b/tests/HierarchyViewerTest/AndroidManifest.xml
@@ -0,0 +1,36 @@
+<!--
+  ~ 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
+  -->
+
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+        package="com.android.test.hierarchyviewer">
+
+    <application>
+        <uses-library android:name="android.test.runner" />
+
+        <activity
+            android:name=".MainActivity"
+            android:label="HvTest" >
+            <intent-filter>
+                <action android:name="android.intent.action.MAIN" />
+                <category android:name="android.intent.category.LAUNCHER" />
+            </intent-filter>
+        </activity>
+    </application>
+
+    <instrumentation
+        android:name="android.test.InstrumentationTestRunner"
+        android:targetPackage="com.android.test.hierarchyviewer" />
+</manifest>
diff --git a/tests/HierarchyViewerTest/build.gradle b/tests/HierarchyViewerTest/build.gradle
new file mode 100644
index 0000000..e8cdfa2
--- /dev/null
+++ b/tests/HierarchyViewerTest/build.gradle
@@ -0,0 +1,31 @@
+buildscript {
+    repositories {
+        jcenter()
+    }
+    dependencies {
+        classpath 'com.android.tools.build:gradle:1.1.0+'
+
+    }
+}
+
+apply plugin: 'com.android.application'
+
+android {
+    compileSdkVersion 21
+    buildToolsVersion "22.0.0"
+
+    defaultConfig {
+        minSdkVersion 21
+        targetSdkVersion 21
+        versionCode 1
+        versionName "1.0"
+    }
+
+    sourceSets {
+	main {
+	    manifest.srcFile 'AndroidManifest.xml'
+	    java.srcDirs = ['src']
+	    res.srcDirs = ['res']
+	}
+    }
+}
diff --git a/tests/HierarchyViewerTest/res/layout/activity_main.xml b/tests/HierarchyViewerTest/res/layout/activity_main.xml
new file mode 100644
index 0000000..410a776
--- /dev/null
+++ b/tests/HierarchyViewerTest/res/layout/activity_main.xml
@@ -0,0 +1,12 @@
+<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent"
+    android:layout_height="match_parent">
+
+    <TextView
+        android:id="@+id/textView"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:scaleX="10"
+        android:text="@string/test" />
+
+</RelativeLayout>
diff --git a/tests/HierarchyViewerTest/res/menu/menu_main.xml b/tests/HierarchyViewerTest/res/menu/menu_main.xml
new file mode 100644
index 0000000..9b78a1e
--- /dev/null
+++ b/tests/HierarchyViewerTest/res/menu/menu_main.xml
@@ -0,0 +1,5 @@
+<menu xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:tools="http://schemas.android.com/tools" tools:context=".MainActivity">
+    <item android:id="@+id/action_settings" android:title="Settings"
+        android:orderInCategory="100" android:showAsAction="never" />
+</menu>
diff --git a/tests/HierarchyViewerTest/res/values/strings.xml b/tests/HierarchyViewerTest/res/values/strings.xml
new file mode 100644
index 0000000..800ee1c
--- /dev/null
+++ b/tests/HierarchyViewerTest/res/values/strings.xml
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+    <string name="test">Hello World</string>
+</resources>
\ No newline at end of file
diff --git a/tests/HierarchyViewerTest/run_tests.sh b/tests/HierarchyViewerTest/run_tests.sh
new file mode 100644
index 0000000..094bb4c
--- /dev/null
+++ b/tests/HierarchyViewerTest/run_tests.sh
@@ -0,0 +1,4 @@
+#!/usr/bin/env bash
+# Runs the tests in this apk
+adb install $OUT/data/app/HierarchyViewerTest/HierarchyViewerTest.apk
+adb shell am instrument -w com.android.test.hierarchyviewer/android.test.InstrumentationTestRunner
diff --git a/tests/HierarchyViewerTest/src/com/android/test/hierarchyviewer/Decoder.java b/tests/HierarchyViewerTest/src/com/android/test/hierarchyviewer/Decoder.java
new file mode 100644
index 0000000..c6f1470
--- /dev/null
+++ b/tests/HierarchyViewerTest/src/com/android/test/hierarchyviewer/Decoder.java
@@ -0,0 +1,101 @@
+package com.android.test.hierarchyviewer;
+
+import java.nio.ByteBuffer;
+import java.nio.charset.Charset;
+import java.util.HashMap;
+import java.util.Map;
+
+public class Decoder {
+    // Prefixes for simple primitives. These match the JNI definitions.
+    public static final byte SIG_BOOLEAN = 'Z';
+    public static final byte SIG_BYTE = 'B';
+    public static final byte SIG_SHORT = 'S';
+    public static final byte SIG_INT = 'I';
+    public static final byte SIG_LONG = 'J';
+    public static final byte SIG_FLOAT = 'F';
+    public static final byte SIG_DOUBLE = 'D';
+
+    // Prefixes for some commonly used objects
+    public static final byte SIG_STRING = 'R';
+
+    public static final byte SIG_MAP = 'M'; // a map with an short key
+    public static final short SIG_END_MAP = 0;
+
+    private final ByteBuffer mBuf;
+
+    public Decoder(byte[] buf) {
+        this(ByteBuffer.wrap(buf));
+    }
+
+    public Decoder(ByteBuffer buf) {
+        mBuf = buf;
+    }
+
+    public boolean hasRemaining() {
+        return mBuf.hasRemaining();
+    }
+
+    public Object readObject() {
+        byte sig = mBuf.get();
+
+        switch (sig) {
+            case SIG_BOOLEAN:
+                return mBuf.get() == 0 ? Boolean.FALSE : Boolean.TRUE;
+            case SIG_BYTE:
+                return mBuf.get();
+            case SIG_SHORT:
+                return mBuf.getShort();
+            case SIG_INT:
+                return mBuf.getInt();
+            case SIG_LONG:
+                return mBuf.getLong();
+            case SIG_FLOAT:
+                return mBuf.getFloat();
+            case SIG_DOUBLE:
+                return mBuf.getDouble();
+            case SIG_STRING:
+                return readString();
+            case SIG_MAP:
+                return readMap();
+            default:
+                throw new DecoderException(sig, mBuf.position() - 1);
+        }
+    }
+
+    private String readString() {
+        short len = mBuf.getShort();
+        byte[] b = new byte[len];
+        mBuf.get(b, 0, len);
+        return new String(b, Charset.forName("utf-8"));
+    }
+
+    private Map<Short, Object> readMap() {
+        Map<Short, Object> m = new HashMap<Short, Object>();
+
+        while (true) {
+            Object o = readObject();
+            if (!(o instanceof Short)) {
+                throw new DecoderException("Expected short key, got " + o.getClass());
+            }
+
+            Short key = (Short)o;
+            if (key == SIG_END_MAP) {
+                break;
+            }
+
+            m.put(key, readObject());
+        }
+
+        return m;
+    }
+
+    public static class DecoderException extends RuntimeException {
+        public DecoderException(byte seen, int pos) {
+            super(String.format("Unexpected byte %c seen at position %d", (char)seen, pos));
+        }
+
+        public DecoderException(String msg) {
+            super(msg);
+        }
+    }
+}
diff --git a/tests/HierarchyViewerTest/src/com/android/test/hierarchyviewer/MainActivity.java b/tests/HierarchyViewerTest/src/com/android/test/hierarchyviewer/MainActivity.java
new file mode 100644
index 0000000..3a67273
--- /dev/null
+++ b/tests/HierarchyViewerTest/src/com/android/test/hierarchyviewer/MainActivity.java
@@ -0,0 +1,44 @@
+package com.android.test.hierarchyviewer;
+
+import android.app.Activity;
+import android.os.Bundle;
+import android.util.Log;
+import android.view.Menu;
+import android.view.MenuItem;
+import android.view.View;
+
+
+public class MainActivity extends Activity {
+    private static final String TAG = "Main";
+
+    @Override
+    protected void onCreate(Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
+        setContentView(R.layout.activity_main);
+
+        View textView = findViewById(R.id.textView);
+        Log.d(TAG, "x, y = " + textView.getX() + ", " + textView.getY());
+    }
+
+    @Override
+    public boolean onCreateOptionsMenu(Menu menu) {
+        // Inflate the menu; this adds items to the action bar if it is present.
+        getMenuInflater().inflate(R.menu.menu_main, menu);
+        return true;
+    }
+
+    @Override
+    public boolean onOptionsItemSelected(MenuItem item) {
+        // Handle action bar item clicks here. The action bar will
+        // automatically handle clicks on the Home/Up button, so long
+        // as you specify a parent activity in AndroidManifest.xml.
+        int id = item.getItemId();
+
+        //noinspection SimplifiableIfStatement
+        if (id == R.id.action_settings) {
+            return true;
+        }
+
+        return super.onOptionsItemSelected(item);
+    }
+}
diff --git a/tests/HierarchyViewerTest/src/com/android/test/hierarchyviewer/MainActivityTest.java b/tests/HierarchyViewerTest/src/com/android/test/hierarchyviewer/MainActivityTest.java
new file mode 100644
index 0000000..aae0ff5
--- /dev/null
+++ b/tests/HierarchyViewerTest/src/com/android/test/hierarchyviewer/MainActivityTest.java
@@ -0,0 +1,81 @@
+package com.android.test.hierarchyviewer;
+
+import android.test.ActivityInstrumentationTestCase2;
+import android.view.View;
+
+import java.io.ByteArrayOutputStream;
+import java.lang.reflect.Constructor;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.util.List;
+import java.util.Map;
+
+public class MainActivityTest extends ActivityInstrumentationTestCase2<MainActivity> {
+    private MainActivity mActivity;
+    private View mTextView;
+
+
+    public MainActivityTest() {
+        super(MainActivity.class);
+    }
+
+    @Override
+    protected void setUp() throws Exception {
+        super.setUp();
+
+        mActivity = getActivity();
+        mTextView = mActivity.findViewById(R.id.textView);
+    }
+
+    private byte[] encode(View view) throws ClassNotFoundException, NoSuchMethodException,
+            IllegalAccessException, InstantiationException, InvocationTargetException {
+        ByteArrayOutputStream baos = new ByteArrayOutputStream(1024 * 1024);
+
+        Object encoder = createEncoder(baos);
+        invokeMethod(View.class, view, "encode", encoder);
+        invokeMethod(encoder.getClass(), encoder, "endStream");
+
+        return baos.toByteArray();
+    }
+
+    private Object invokeMethod(Class targetClass, Object target, String methodName, Object... params)
+            throws NoSuchMethodException, InvocationTargetException, IllegalAccessException {
+        Class[] paramClasses = new Class[params.length];
+        for (int i = 0; i < params.length; i++) {
+            paramClasses[i] = params[i].getClass();
+        }
+        Method method = targetClass.getDeclaredMethod(methodName, paramClasses);
+        method.setAccessible(true);
+        return method.invoke(target, params);
+    }
+
+    private Object createEncoder(ByteArrayOutputStream baos) throws ClassNotFoundException,
+            NoSuchMethodException, IllegalAccessException, InvocationTargetException,
+            InstantiationException {
+        Class clazz = Class.forName("android.view.ViewHierarchyEncoder");
+        Constructor constructor = clazz.getConstructor(ByteArrayOutputStream.class);
+        return constructor.newInstance(baos);
+    }
+
+    public void testTextView() throws Exception {
+        byte[] data = encode(mTextView);
+        assertNotNull(data);
+        assertTrue(data.length > 0);
+
+        ViewDumpParser parser = new ViewDumpParser();
+        parser.parse(data);
+
+        List<Map<Short, Object>> views = parser.getViews();
+        Map<String, Short> propertyNameTable = parser.getIds();
+
+        assertEquals(1, views.size());
+        assertNotNull(propertyNameTable);
+
+        Map<Short, Object> textViewProperties = views.get(0);
+        assertEquals("android.widget.TextView",
+                textViewProperties.get(propertyNameTable.get("meta:__name__")));
+
+//        assertEquals(mActivity.getString(R.string.test),
+//                textViewProperties.get(propertyNameTable.get("text")));
+    }
+}
diff --git a/tests/HierarchyViewerTest/src/com/android/test/hierarchyviewer/ViewDumpParser.java b/tests/HierarchyViewerTest/src/com/android/test/hierarchyviewer/ViewDumpParser.java
new file mode 100644
index 0000000..0111bc6
--- /dev/null
+++ b/tests/HierarchyViewerTest/src/com/android/test/hierarchyviewer/ViewDumpParser.java
@@ -0,0 +1,73 @@
+package com.android.test.hierarchyviewer;
+
+import java.nio.ByteBuffer;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+public class ViewDumpParser {
+    private Map<String, Short> mIds;
+    private List<Map<Short,Object>> mViews;
+
+    public void parse(byte[] data) {
+        Decoder d = new Decoder(ByteBuffer.wrap(data));
+
+        mViews = new ArrayList<>(100);
+        while (d.hasRemaining()) {
+            Object o = d.readObject();
+            if (o instanceof Map) {
+                //noinspection unchecked
+                mViews.add((Map<Short, Object>) o);
+            }
+        }
+
+        if (mViews.isEmpty()) {
+            return;
+        }
+
+        // the last one is the property map
+        Map<Short,Object> idMap = mViews.remove(mViews.size() - 1);
+        mIds = reverse(idMap);
+    }
+
+    public String getFirstView() {
+        if (mViews.isEmpty()) {
+            return null;
+        }
+
+        Map<Short, Object> props = mViews.get(0);
+        Object name = getProperty(props, "__name__");
+        Object hash = getProperty(props, "__hash__");
+
+        if (name instanceof String && hash instanceof Integer) {
+            return String.format(Locale.US, "%s@%x", name, hash);
+        } else {
+            return null;
+        }
+    }
+
+    private Object getProperty(Map<Short, Object> props, String key) {
+        return props.get(mIds.get(key));
+    }
+
+    private static Map<String, Short> reverse(Map<Short, Object> m) {
+        Map<String, Short> r = new HashMap<String, Short>(m.size());
+
+        for (Map.Entry<Short, Object> e : m.entrySet()) {
+            r.put((String)e.getValue(), e.getKey());
+        }
+
+        return r;
+    }
+
+    public List<Map<Short, Object>> getViews() {
+        return mViews;
+    }
+
+    public Map<String, Short> getIds() {
+        return mIds;
+    }
+
+}