Adds HeapReports library into perflib.

See go/heapreports for more detail on HeapReports.

Change-Id: I499acf9e00f8d7b103911573a6ca9e4fbe505567
diff --git a/perflib/build.gradle b/perflib/build.gradle
index d88f50f..d59b582 100644
--- a/perflib/build.gradle
+++ b/perflib/build.gradle
@@ -13,6 +13,7 @@
     testCompile 'org.easymock:easymock:3.1'
     testCompile 'junit:junit:4.12'
     testCompile project(':base:testutils')
+    testCompile 'org.mockito:mockito-all:1.9.5'
 }
 
 project.ext.pomName = 'Android Tools perflib'
diff --git a/perflib/perflib.iml b/perflib/perflib.iml
index 5dc20f5..79ab1df 100644
--- a/perflib/perflib.iml
+++ b/perflib/perflib.iml
@@ -13,5 +13,6 @@
     <orderEntry type="library" scope="TEST" name="JUnit4" level="project" />
     <orderEntry type="library" name="Trove4j" level="project" />
     <orderEntry type="module" module-name="testutils" scope="TEST" />
+    <orderEntry type="library" scope="TEST" name="mockito" level="project" />
   </component>
-</module>
\ No newline at end of file
+</module>
diff --git a/perflib/src/main/java/com/android/tools/perflib/heap/memoryanalyzer/DefaultReport.java b/perflib/src/main/java/com/android/tools/perflib/heap/memoryanalyzer/DefaultReport.java
new file mode 100644
index 0000000..b4a701a
--- /dev/null
+++ b/perflib/src/main/java/com/android/tools/perflib/heap/memoryanalyzer/DefaultReport.java
@@ -0,0 +1,58 @@
+package com.android.tools.perflib.heap.memoryanalyzer;
+
+import com.android.annotations.NonNull;
+import com.android.tools.perflib.analyzer.AnalysisResultEntry;
+import com.google.common.annotations.VisibleForTesting;
+
+import java.util.List;
+
+/**
+ * The default Report implementation, which prints the Report Name in the format "MyTaskName
+ * Report", the description (from {@link MemoryAnalyzerTask#getTaskDescription()}), and the table of
+ * results.
+ */
+public final class DefaultReport implements Report {
+
+    private final MemoryAnalyzerTask mTask;
+    protected List<AnalysisResultEntry<?>> mResults;
+
+    @VisibleForTesting
+    protected static final String NO_ISSUES_FOUND_STRING = "No issues found.";
+
+    public DefaultReport(@NonNull MemoryAnalyzerTask task) {
+        mTask = task;
+    }
+
+    @Override
+    public void generate(@NonNull List<AnalysisResultEntry<?>> data) {
+        mResults = data;
+    }
+
+    /**
+     * Prints the title of the report, the description, and the warning messages of all of the
+     * entries.
+     *
+     * <p>This simple output is sufficient for many tasks, assuming their titles and warning
+     * messages contain useful data.
+     *
+     * @param printer the {@link Printer} to print out to.
+     */
+    @Override
+    public void print(@NonNull Printer printer) {
+        printer.addHeading(
+                2, (new StringBuilder()).append(mTask.getTaskName()).append(" Report").toString());
+
+        printer.addParagraph(mTask.getTaskDescription());
+
+        if (mResults != null && mResults.isEmpty()) {
+            printer.addParagraph(NO_ISSUES_FOUND_STRING);
+            return;
+        }
+        printer.startTable();
+        for (AnalysisResultEntry<?> result : mResults) {
+            printer.addRow(result.getWarningMessage());
+        }
+        printer.endTable();
+    }
+
+}
diff --git a/perflib/src/main/java/com/android/tools/perflib/heap/memoryanalyzer/DuplicatedStringsReport.java b/perflib/src/main/java/com/android/tools/perflib/heap/memoryanalyzer/DuplicatedStringsReport.java
new file mode 100644
index 0000000..fd2fe48
--- /dev/null
+++ b/perflib/src/main/java/com/android/tools/perflib/heap/memoryanalyzer/DuplicatedStringsReport.java
@@ -0,0 +1,73 @@
+package com.android.tools.perflib.heap.memoryanalyzer;
+
+import com.android.annotations.NonNull;
+import com.android.tools.perflib.analyzer.AnalysisResultEntry;
+import com.android.tools.perflib.heap.Instance;
+
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.List;
+
+/**
+ * A report to organize the results of perflib's {@link DuplicatedStringsAnalyzerTask}.
+ *
+ * <p>Specifically, this report sorts the results by the effective size of the duplicated string:
+ * the length of the string times the number of occurrences. Additionally, when printed, it creates
+ * a detailed table including the string value, number of occurrences, total consumed size, and an
+ * example duplicate instance.
+ */
+public final class DuplicatedStringsReport implements Report {
+
+    private List<AnalysisResultEntry<?>> mResults;
+
+    // Limits the number of characters of the duplicated string is shown.
+    private static final int MAX_VALUE_STRING_LENGTH = 100;
+
+    @Override
+    public void generate(@NonNull List<AnalysisResultEntry<?>> results) {
+        // Sort by how many bytes each set of duplicates is actually occupying.
+        Collections.sort(
+                results,
+                Collections.reverseOrder(
+                        new Comparator<AnalysisResultEntry<?>>() {
+                            @Override
+                            public int compare(AnalysisResultEntry<?> o1,
+                                    AnalysisResultEntry<?> o2) {
+                                return getConsumedBytes(o1) - getConsumedBytes(o2);
+                            }
+                        }));
+
+        mResults = results;
+    }
+
+    @Override
+    public void print(@NonNull Printer printer) {
+        DuplicatedStringsAnalyzerTask task = new DuplicatedStringsAnalyzerTask();
+        printer.addHeading(2, task.getTaskName() + " Report");
+        printer.addParagraph(task.getTaskDescription());
+
+        if (mResults == null || mResults.isEmpty()) {
+            printer.addParagraph("No issues found.");
+            return;
+        }
+
+        printer.startTable("Value", "Bytes", "Duplicates", "First Duplicate");
+        for (AnalysisResultEntry<?> entry : mResults) {
+            String value = entry.getOffender().getOffendingDescription();
+            if (value.length() > MAX_VALUE_STRING_LENGTH) {
+                value = value.substring(0, MAX_VALUE_STRING_LENGTH) + "...";
+            }
+            String consumedBytes = Integer.toString(getConsumedBytes(entry));
+            String duplicates = Integer.toString(entry.getOffender().getOffenders().size());
+            String instance =
+                    printer.formatInstance((Instance) entry.getOffender().getOffenders().get(0));
+            printer.addRow(value, consumedBytes, duplicates, instance);
+        }
+        printer.endTable();
+    }
+
+    private int getConsumedBytes(@NonNull AnalysisResultEntry<?> entry) {
+        return entry.getOffender().getOffendingDescription().length()
+                * entry.getOffender().getOffenders().size();
+    }
+}
diff --git a/perflib/src/main/java/com/android/tools/perflib/heap/memoryanalyzer/HeapReports.java b/perflib/src/main/java/com/android/tools/perflib/heap/memoryanalyzer/HeapReports.java
new file mode 100644
index 0000000..8b5198f
--- /dev/null
+++ b/perflib/src/main/java/com/android/tools/perflib/heap/memoryanalyzer/HeapReports.java
@@ -0,0 +1,69 @@
+package com.android.tools.perflib.heap.memoryanalyzer;
+
+import com.android.annotations.NonNull;
+import com.android.tools.perflib.analyzer.AnalysisResultEntry;
+import com.android.tools.perflib.captures.MemoryMappedFileBuffer;
+import com.android.tools.perflib.heap.Snapshot;
+
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.PrintStream;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * Utility class which uses perflib to analyze Android heap dumps and creates {@link Report}s with
+ * the results.
+ */
+public final class HeapReports {
+
+    private HeapReports() {
+    }
+
+    /**
+     * Runs a {@link MemoryAnalyzerTask} on a {@link Snapshot} and returns a {@link DefaultReport}
+     * with the results.
+     *
+     * <p>The user is responsible for setting up the Snapshot as they need, for example by calling
+     * computeDominators() or resolveClasses() on the Snapshot. It is recommended to at least call
+     * computeDominators(), as most tasks will depend on the data generated in this method.
+     *
+     * @param task     the task which should be run and whose results should be put in the report
+     * @param snapshot the heap dump to run on.
+     * @return a {@link DefaultReport} with the results of the task.
+     */
+    public static DefaultReport generateReport(@NonNull MemoryAnalyzerTask task,
+            @NonNull Snapshot snapshot) {
+        DefaultReport report = new DefaultReport(task);
+        generateReport(report, task, snapshot);
+        return report;
+    }
+
+    /**
+     * Runs a {@link MemoryAnalyzerTask} on a {@link Snapshot} and pass data to a custom {@link
+     * Report}.
+     *
+     * <p>The user is responsible for setting up the Snapshot as they need, for example by calling
+     * computeDominators() or resolveClasses() on the Snapshot. It is recommended to at least call
+     * computeDominators(), as most tasks will depend on the data generated in this method.
+     *
+     * <p>It is the user's responsibility to ensure that the report matches the task; otherwise,
+     * this method's behavior is undefined. For example, if you pass a DuplicatedStringsReport, you
+     * need to pass a DuplicatedStringsAnalyzerTask.
+     *
+     * @param report   an instance of the custom Report, which will be generated and ready to print
+     *                 when the method returns.
+     * @param task     the task which should be run and whose results should be put in the report.
+     * @param snapshot the heap dump to run on.
+     */
+    public static void generateReport(@NonNull Report report, @NonNull MemoryAnalyzerTask task,
+            @NonNull Snapshot snapshot) {
+        Set<MemoryAnalyzerTask> tasks = new HashSet<>();
+        tasks.add(task);
+        List<AnalysisResultEntry<?>> results = TaskRunner.runTasks(tasks, snapshot);
+        report.generate(results);
+    }
+}
diff --git a/perflib/src/main/java/com/android/tools/perflib/heap/memoryanalyzer/HtmlPrinter.java b/perflib/src/main/java/com/android/tools/perflib/heap/memoryanalyzer/HtmlPrinter.java
new file mode 100644
index 0000000..ad4e0bb
--- /dev/null
+++ b/perflib/src/main/java/com/android/tools/perflib/heap/memoryanalyzer/HtmlPrinter.java
@@ -0,0 +1,161 @@
+package com.android.tools.perflib.heap.memoryanalyzer;
+
+import com.android.annotations.NonNull;
+import com.android.tools.perflib.heap.Instance;
+import com.google.common.escape.Escaper;
+import com.google.common.html.HtmlEscapers;
+
+import java.awt.Dimension;
+import java.awt.image.BufferedImage;
+import java.io.ByteArrayOutputStream;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.PrintStream;
+import java.nio.file.Path;
+import java.util.Base64;
+
+import javax.imageio.ImageIO;
+
+/**
+ * Prints summary documents as HTML.
+ *
+ * <p>This printer is designed to match the output of ahat's AhatPrinter as closely as possible.
+ * Specifically, see formatInstance.
+ */
+public final class HtmlPrinter implements Printer {
+
+    private final PrintStream mOutStream;
+    private final Escaper mEscaper;
+
+    // Number of characters of a String instance that will be shown when calling formatInstance.
+    // Picked 100 characters to give context to the string, but to prevent long strings from taking
+    // too much space. Feel free to change this in the future.
+    private static final int MAX_PREVIEW_STRING_LENGTH = 100;
+
+    public HtmlPrinter(@NonNull Path path) throws FileNotFoundException {
+        this(new PrintStream(path.toFile()));
+    }
+
+    public HtmlPrinter(@NonNull PrintStream outStream) {
+        mOutStream = outStream;
+        mEscaper = HtmlEscapers.htmlEscaper();
+    }
+
+    @Override
+    public void addHeading(int level, @NonNull String content) {
+        mOutStream.printf("<h%d>%s</h%1$d>\n", level, mEscaper.escape(content));
+    }
+
+    @Override
+    public void addParagraph(@NonNull String content) {
+        mOutStream.printf("<p>%s</p>\n", mEscaper.escape(content));
+    }
+
+    @Override
+    public void startTable(@NonNull String... columnHeadings) {
+        mOutStream.printf("<table>\n");
+
+        if (columnHeadings.length > 0) {
+            mOutStream.printf("<tr style='border: 1px solid black;'>\n");
+            for (String column : columnHeadings) {
+                mOutStream.printf("<th style='border: 1px solid black;'>%s</th>\n",
+                        mEscaper.escape(column));
+            }
+            mOutStream.printf("</tr>\n");
+        }
+    }
+
+    @Override
+    public void addRow(@NonNull String... values) {
+        mOutStream.printf("<tr>\n");
+        for (String value : values) {
+            mOutStream.printf("<td>%s</td>\n", mEscaper.escape(value));
+        }
+        mOutStream.printf("</tr>\n");
+    }
+
+    @Override
+    public void endTable() {
+        mOutStream.printf("</table>\n");
+    }
+
+    @Override
+    public void addImage(@NonNull Instance instance) {
+        if (!HprofBitmapProvider.canGetBitmapFromInstance(instance)) {
+            return;
+        }
+        try {
+            HprofBitmapProvider bitmapProvider = new HprofBitmapProvider(instance);
+            String configName = bitmapProvider.getBitmapConfigName();
+            int width = bitmapProvider.getDimension().width;
+            int height = bitmapProvider.getDimension().height;
+
+            byte[] raw = bitmapProvider.getPixelBytes(new Dimension());
+            int[] converted;
+            if ("\"ARGB_8888\"".equals(configName)) {
+                converted = new int[width * height];
+                for (int i = 0; i < converted.length; i++) {
+                    converted[i] = (
+                            (((int) raw[i * 4 + 3] & 0xFF) << 24)
+                                    + (((int) raw[i * 4 + 0] & 0xFF) << 16)
+                                    + (((int) raw[i * 4 + 1] & 0xFF) << 8)
+                                    + ((int) raw[i * 4 + 2] & 0xFF));
+                }
+            } else {
+                throw new Exception("RGB_565/ALPHA_8 conversion not implemented");
+            }
+
+            int imageType = -1;
+            switch (configName) {
+                case "\"ARGB_8888\"":
+                    imageType = BufferedImage.TYPE_4BYTE_ABGR;
+                    break;
+                case "\"RGB_565\"":
+                    imageType = BufferedImage.TYPE_USHORT_565_RGB;
+                    break;
+                case "\"ALPHA_8\"":
+                    imageType = BufferedImage.TYPE_BYTE_GRAY;
+                    break;
+            }
+
+            BufferedImage image = new BufferedImage(width, height, imageType);
+            image.setRGB(0, 0, width, height, converted, 0, width);
+
+            ByteArrayOutputStream byteOutputStream = new ByteArrayOutputStream();
+            ImageIO.write(image, "png", byteOutputStream);
+            byteOutputStream.flush();
+            String imageDataString =
+                    Base64.getEncoder().encodeToString(byteOutputStream.toByteArray());
+            byteOutputStream.close();
+
+            mOutStream.printf("<img src='data:image/png;base64,%s' \\>\n", imageDataString);
+        } catch (Exception e) {
+            e.printStackTrace();
+            return;
+        }
+    }
+
+    @Override
+    public String formatInstance(@NonNull Instance instance) {
+        return instance.toString();
+    }
+
+    /**
+     * Convert a {@link BufferedImage} into a Base64 string.
+     *
+     * @return the string, or null if an exception occurred.
+     */
+    private String bitmapAsBase64String(@NonNull BufferedImage image) {
+        try {
+            ByteArrayOutputStream byteOutputStream = new ByteArrayOutputStream();
+            ImageIO.write(image, "png", byteOutputStream);
+            byteOutputStream.flush();
+            String imageDataString =
+                    Base64.getEncoder().encodeToString(byteOutputStream.toByteArray());
+            byteOutputStream.close();
+            return imageDataString;
+        } catch (IOException e) {
+            return null;
+        }
+    }
+}
diff --git a/perflib/src/main/java/com/android/tools/perflib/heap/memoryanalyzer/Printer.java b/perflib/src/main/java/com/android/tools/perflib/heap/memoryanalyzer/Printer.java
new file mode 100644
index 0000000..f63353d
--- /dev/null
+++ b/perflib/src/main/java/com/android/tools/perflib/heap/memoryanalyzer/Printer.java
@@ -0,0 +1,36 @@
+package com.android.tools.perflib.heap.memoryanalyzer;
+
+import com.android.annotations.NonNull;
+import com.android.tools.perflib.heap.Instance;
+
+/**
+ * Represents an object capable of printing documents.
+ */
+public interface Printer {
+
+    void addHeading(int level, @NonNull String content);
+
+    void addParagraph(@NonNull String content);
+
+    /**
+     * Start a table which will have the given column headings. If no column headings are supplied,
+     * the table will simply start with the first row of data. This should be followed by calls to
+     * addRow. Be sure to end the table with endTable().
+     */
+    void startTable(@NonNull String... columnHeadings);
+
+    void addRow(@NonNull String... values);
+
+    void endTable();
+
+    void addImage(@NonNull Instance instance);
+
+    /**
+     * Turn an {@link Instance} into a string and return it.
+     *
+     * <p>Some printers may apply special formatting when printing an Instance object; for example,
+     * the {@link HtmlPrinter} puts {@code instance.toString()} in bold. If you do not want your
+     * printer to apply any special format, simply return {@code instance.toString()}.
+     */
+    String formatInstance(@NonNull Instance instance);
+}
diff --git a/perflib/src/main/java/com/android/tools/perflib/heap/memoryanalyzer/Report.java b/perflib/src/main/java/com/android/tools/perflib/heap/memoryanalyzer/Report.java
new file mode 100644
index 0000000..715c47a
--- /dev/null
+++ b/perflib/src/main/java/com/android/tools/perflib/heap/memoryanalyzer/Report.java
@@ -0,0 +1,30 @@
+package com.android.tools.perflib.heap.memoryanalyzer;
+
+import com.android.annotations.NonNull;
+import com.android.tools.perflib.analyzer.AnalysisResultEntry;
+
+import java.util.List;
+
+/**
+ * Formats a set of {@link AnalysisResultEntry}s in a human-readable way.
+ *
+ * <p>In their {@link #generate} method, Reports process a list of results of a perflib {@link
+ * MemoryAnalyzerTask}. In their {@link #print} method, Reports visualize the results through a
+ * printer interface. <p>{@link DefaultReport} is provided as a basic Report implementation.
+ */
+public interface Report {
+
+    /**
+     * Take {@link MemoryAnalyzerTask} results and process them - e.g. sort the results.
+     */
+    void generate(@NonNull List<AnalysisResultEntry<?>> data);
+
+    /**
+     * Print report to a {@link Printer}.
+     *
+     * <p>Please read the Printer interface documentation to see what methods are available when
+     * overriding print().
+     */
+    void print(@NonNull Printer printer);
+
+}
diff --git a/perflib/src/main/java/com/android/tools/perflib/heap/memoryanalyzer/TaskRunner.java b/perflib/src/main/java/com/android/tools/perflib/heap/memoryanalyzer/TaskRunner.java
new file mode 100644
index 0000000..02f19cb
--- /dev/null
+++ b/perflib/src/main/java/com/android/tools/perflib/heap/memoryanalyzer/TaskRunner.java
@@ -0,0 +1,96 @@
+package com.android.tools.perflib.heap.memoryanalyzer;
+
+import com.android.annotations.NonNull;
+import com.android.tools.perflib.analyzer.AnalysisReport.Listener;
+import com.android.tools.perflib.analyzer.AnalysisResultEntry;
+import com.android.tools.perflib.analyzer.Capture;
+import com.android.tools.perflib.analyzer.CaptureGroup;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+/**
+ * Runs a set of {@link MemoryAnalyzerTask}s and returns the results.
+ */
+final class TaskRunner {
+
+    /**
+     * Blocks until the given tasks are run through a {@link MemoryAnalyzer}.
+     *
+     * <p>All result entries will be aggregated into one list and returned.
+     *
+     * @param captureGroup the perflib {@link CaptureGroup} to run the tasks on.
+     * @return list of results, or null if the report was cancelled or the tasks interrupted.
+     */
+    static List<AnalysisResultEntry<?>> runTasks(
+            @NonNull Set<MemoryAnalyzerTask> tasks, @NonNull Set<Listener> listeners,
+            @NonNull CaptureGroup captureGroup) {
+
+        final List<AnalysisResultEntry<?>> generatedEntries = new ArrayList<>();
+
+        final ExecutorService executorService = Executors.newSingleThreadExecutor();
+
+        // Setup listeners - user supplied listeners from this.listeners, plus our own custom listener.
+        final Set<Listener> listenerSet = new HashSet<>();
+        listenerSet.addAll(listeners);
+        final CountDownLatch latch = new CountDownLatch(1);
+        final AtomicBoolean cancelledOrInterrupted = new AtomicBoolean(false);
+        listenerSet.add(
+                new Listener() {
+                    @Override
+                    public void onResultsAdded(List<AnalysisResultEntry<?>> entries) {
+                        generatedEntries.addAll(entries);
+                    }
+
+                    @Override
+                    public void onAnalysisComplete() {
+                        latch.countDown();
+                    }
+
+                    @Override
+                    public void onAnalysisCancelled() {
+                        cancelledOrInterrupted.set(true);
+                        latch.countDown();
+                    }
+                });
+
+        MemoryAnalyzer memoryAnalyzer = new MemoryAnalyzer();
+        memoryAnalyzer.analyze(captureGroup, listenerSet, tasks, executorService, executorService);
+
+        // Block until complete.
+        try {
+            latch.await();
+        } catch (InterruptedException e) {
+            cancelledOrInterrupted.set(true);
+        }
+
+        executorService.shutdownNow();
+
+        if (!cancelledOrInterrupted.get()) {
+            return generatedEntries;
+        } else {
+            return null;
+        }
+    }
+
+    static List<AnalysisResultEntry<?>> runTasks(
+            @NonNull Set<MemoryAnalyzerTask> tasks, @NonNull CaptureGroup captureGroup) {
+        return runTasks(tasks, Collections.emptySet(), captureGroup);
+    }
+
+    static List<AnalysisResultEntry<?>> runTasks(@NonNull Set<MemoryAnalyzerTask> tasks,
+            @NonNull Capture... captures) {
+        CaptureGroup captureGroup = new CaptureGroup();
+        for (Capture capture : captures) {
+            captureGroup.addCapture(capture);
+        }
+        return runTasks(tasks, Collections.emptySet(), captureGroup);
+    }
+}
diff --git a/perflib/src/test/java/com/android/tools/perflib/heap/memoryanalyzer/BasicAnalyzerTask.java b/perflib/src/test/java/com/android/tools/perflib/heap/memoryanalyzer/BasicAnalyzerTask.java
new file mode 100644
index 0000000..94a832c
--- /dev/null
+++ b/perflib/src/test/java/com/android/tools/perflib/heap/memoryanalyzer/BasicAnalyzerTask.java
@@ -0,0 +1,52 @@
+package com.android.tools.perflib.heap.memoryanalyzer;
+
+import com.android.tools.perflib.analyzer.AnalysisResultEntry;
+import com.android.tools.perflib.heap.Snapshot;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Dummy task for testing.
+ */
+class BasicAnalyzerTask extends MemoryAnalyzerTask {
+
+    protected static final String TASK_WARNING = "test warning";
+    protected static final String TEST_CATEGORY = "test category";
+    protected static final String TASK_NAME = "Basic Analyzer Task";
+    protected static final String TASK_DESCRIPTION = "Basic Analyzer Task";
+
+    @Override
+    protected List<AnalysisResultEntry<?>> analyze(Configuration configuration, Snapshot snapshot) {
+        List<AnalysisResultEntry<?>> list = new ArrayList<>();
+        list.add(new BasicResultEntry());
+        return list;
+    }
+
+    @Override
+    public String getTaskName() {
+        return TASK_NAME;
+    }
+
+    @Override
+    public String getTaskDescription() {
+        return TASK_DESCRIPTION;
+    }
+
+    static class BasicResultEntry extends MemoryAnalysisResultEntry {
+
+        protected BasicResultEntry() {
+            super(null, null);
+        }
+
+        @Override
+        public String getWarningMessage() {
+            return TASK_WARNING;
+        }
+
+        @Override
+        public String getCategory() {
+            return TEST_CATEGORY;
+        }
+    }
+}
diff --git a/perflib/src/test/java/com/android/tools/perflib/heap/memoryanalyzer/DefaultReportTest.java b/perflib/src/test/java/com/android/tools/perflib/heap/memoryanalyzer/DefaultReportTest.java
new file mode 100644
index 0000000..1edce8a
--- /dev/null
+++ b/perflib/src/test/java/com/android/tools/perflib/heap/memoryanalyzer/DefaultReportTest.java
@@ -0,0 +1,66 @@
+package com.android.tools.perflib.heap.memoryanalyzer;
+
+import com.android.tools.perflib.analyzer.AnalysisResultEntry;
+import com.android.tools.perflib.heap.memoryanalyzer.BasicAnalyzerTask.BasicResultEntry;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+import org.mockito.Mock;
+import org.mockito.Mockito;
+import org.mockito.MockitoAnnotations;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Tests for {@link Report}.
+ */
+@RunWith(JUnit4.class)
+public class DefaultReportTest {
+
+    @Mock
+    private Printer mPrinterMock;
+
+    @Before
+    public void setUpMocks() {
+        MockitoAnnotations.initMocks(this);
+    }
+
+    @Test
+    public void testPrintFormatting_dataEmpty() {
+        // arrange
+        List<AnalysisResultEntry<?>> data = Collections.emptyList();
+        Report report = new DefaultReport(new BasicAnalyzerTask());
+        report.generate(data);
+
+        // act
+        report.print(mPrinterMock);
+
+        // verify
+        Mockito.verify(mPrinterMock)
+                .addParagraph(DefaultReport.NO_ISSUES_FOUND_STRING);
+    }
+
+    @Test
+    public void testPrintFormatting_dataGenerated() {
+        // arrange
+        List<AnalysisResultEntry<?>> data = new ArrayList<>();
+        data.add(new BasicResultEntry());
+        Report report = new DefaultReport(new BasicAnalyzerTask());
+        report.generate(data);
+
+        // act
+        report.print(mPrinterMock);
+
+        // verify
+        Mockito.verify(mPrinterMock)
+                .addHeading(2, BasicAnalyzerTask.TASK_NAME + " Report");
+        Mockito.verify(mPrinterMock)
+                .addParagraph(BasicAnalyzerTask.TASK_DESCRIPTION);
+        Mockito.verify(mPrinterMock)
+                .addRow(BasicAnalyzerTask.TASK_WARNING);
+    }
+}
diff --git a/perflib/src/test/java/com/android/tools/perflib/heap/memoryanalyzer/DuplicatedStringsReportTest.java b/perflib/src/test/java/com/android/tools/perflib/heap/memoryanalyzer/DuplicatedStringsReportTest.java
new file mode 100644
index 0000000..3a7580a
--- /dev/null
+++ b/perflib/src/test/java/com/android/tools/perflib/heap/memoryanalyzer/DuplicatedStringsReportTest.java
@@ -0,0 +1,98 @@
+package com.android.tools.perflib.heap.memoryanalyzer;
+
+import com.android.tools.perflib.analyzer.AnalysisResultEntry;
+import com.android.tools.perflib.heap.Instance;
+import com.android.tools.perflib.heap.memoryanalyzer.DuplicatedStringsAnalyzerTask.DuplicatedStringsEntry;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+import org.mockito.InOrder;
+import org.mockito.Mock;
+import org.mockito.Mockito;
+import org.mockito.MockitoAnnotations;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Tests for {@link DuplicatedStringsReport}.
+ */
+@RunWith(JUnit4.class)
+public final class DuplicatedStringsReportTest {
+
+    private DuplicatedStringsEntry mEntry1;
+    private DuplicatedStringsEntry mEntry2;
+    private Instance mMockInstance;
+
+    @Mock
+    private Printer mPrinterMock;
+
+    @Before
+    public void setupMocks() {
+        MockitoAnnotations.initMocks(this);
+
+        mMockInstance = Mockito.mock(Instance.class, Mockito.RETURNS_DEEP_STUBS);
+        Mockito.when(mMockInstance.toString()).thenReturn("mockInstance");
+
+        mEntry1 = Mockito.mock(DuplicatedStringsEntry.class, Mockito.RETURNS_DEEP_STUBS);
+        mEntry2 = Mockito.mock(DuplicatedStringsEntry.class, Mockito.RETURNS_DEEP_STUBS);
+        Mockito.when(mEntry1.getOffender().getOffendingDescription())
+                .thenReturn("offending string 1");
+        Mockito.when(mEntry2.getOffender().getOffendingDescription())
+                .thenReturn("offending string 2");
+        Mockito.when(mEntry1.getOffender().getOffenders().size()).thenReturn(1);
+        Mockito.when(mEntry2.getOffender().getOffenders().size()).thenReturn(2);
+        Mockito.when((Object) mEntry1.getOffender().getOffenders().get(0))
+                .thenReturn(mMockInstance);
+        Mockito.when((Object) mEntry2.getOffender().getOffenders().get(0))
+                .thenReturn(mMockInstance);
+    }
+
+    @Test
+    public void testDuplicatedStringsReport() throws Exception {
+        // arrange
+        List<AnalysisResultEntry<?>> entries = new ArrayList<>();
+        entries.add(mEntry1);
+        entries.add(mEntry2);
+
+        // act
+        DuplicatedStringsReport report = new DuplicatedStringsReport();
+        report.generate(entries);
+        report.print(mPrinterMock);
+
+        // verify
+        DuplicatedStringsAnalyzerTask task = new DuplicatedStringsAnalyzerTask();
+        InOrder inOrder = Mockito.inOrder(mPrinterMock);
+        inOrder.verify(mPrinterMock).addHeading(2, task.getTaskName() + " Report");
+        inOrder.verify(mPrinterMock).addParagraph(task.getTaskDescription());
+        // verify that the entries were sorted correctly
+        inOrder
+                .verify(mPrinterMock)
+                .addRow(Mockito.anyString(), Mockito.anyString(), Mockito.eq("2"),
+                        Mockito.anyString());
+        inOrder
+                .verify(mPrinterMock)
+                .addRow(Mockito.anyString(), Mockito.anyString(), Mockito.eq("1"),
+                        Mockito.anyString());
+    }
+
+    @Test
+    public void testPrintFormatting_dataEmpty() {
+        // arrange
+        DuplicatedStringsAnalyzerTask task = new DuplicatedStringsAnalyzerTask();
+        List<AnalysisResultEntry<?>> data = Collections.emptyList();
+        Report report = new DuplicatedStringsReport();
+        report.generate(data);
+
+        // act
+        report.print(mPrinterMock);
+
+        // verify
+        Mockito.verify(mPrinterMock).addHeading(2, task.getTaskName() + " Report");
+        Mockito.verify(mPrinterMock).addParagraph(task.getTaskDescription());
+        Mockito.verify(mPrinterMock).addParagraph(Mockito.contains("No issues found."));
+    }
+}
diff --git a/perflib/src/test/java/com/android/tools/perflib/heap/memoryanalyzer/HeapReportsTest.java b/perflib/src/test/java/com/android/tools/perflib/heap/memoryanalyzer/HeapReportsTest.java
new file mode 100644
index 0000000..4e712ad
--- /dev/null
+++ b/perflib/src/test/java/com/android/tools/perflib/heap/memoryanalyzer/HeapReportsTest.java
@@ -0,0 +1,46 @@
+package com.android.tools.perflib.heap.memoryanalyzer;
+
+import com.android.tools.perflib.analyzer.AnalysisResultEntry;
+import com.android.tools.perflib.heap.Heap;
+import com.android.tools.perflib.heap.Snapshot;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+import org.mockito.Matchers;
+import org.mockito.Mock;
+import org.mockito.Mockito;
+import org.mockito.MockitoAnnotations;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Tests for {@link HeapReports}.
+ */
+@RunWith(JUnit4.class)
+public class HeapReportsTest {
+
+    @Mock
+    private Snapshot mSnapshotMock;
+    @Mock
+    private Report mReportMock;
+
+    @Before
+    public void setUpMocks() {
+        MockitoAnnotations.initMocks(this);
+        Mockito.when(mSnapshotMock.getTypeName()).thenReturn(Snapshot.TYPE_NAME);
+        Mockito.when(mSnapshotMock.getRepresentation(Snapshot.class)).thenReturn(mSnapshotMock);
+        Mockito.when(mSnapshotMock.getHeaps()).thenReturn(new ArrayList<Heap>());
+    }
+
+    @Test
+    public void analyzeGeneratesData() {
+        // act
+        HeapReports.generateReport(mReportMock, new BasicAnalyzerTask(), mSnapshotMock);
+
+        // assert
+        Mockito.verify(mReportMock).generate(Matchers.<List<AnalysisResultEntry<?>>>any());
+    }
+}
diff --git a/perflib/src/test/java/com/android/tools/perflib/heap/memoryanalyzer/HtmlPrinterTest.java b/perflib/src/test/java/com/android/tools/perflib/heap/memoryanalyzer/HtmlPrinterTest.java
new file mode 100644
index 0000000..1e3f19c
--- /dev/null
+++ b/perflib/src/test/java/com/android/tools/perflib/heap/memoryanalyzer/HtmlPrinterTest.java
@@ -0,0 +1,153 @@
+package com.android.tools.perflib.heap.memoryanalyzer;
+
+import static org.junit.Assert.assertEquals;
+
+import com.android.ddmlib.BitmapDecoder;
+import com.android.tools.perflib.heap.ArrayInstance;
+import com.android.tools.perflib.heap.ClassInstance;
+import com.android.tools.perflib.heap.ClassObj;
+import com.android.tools.perflib.heap.Field;
+import com.android.tools.perflib.heap.Instance;
+import com.android.tools.perflib.heap.StackFrame;
+import com.android.tools.perflib.heap.StackTrace;
+import com.android.tools.perflib.heap.Type;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+import org.mockito.Answers;
+import org.mockito.Mock;
+import org.mockito.Mockito;
+import org.mockito.MockitoAnnotations;
+
+import java.io.ByteArrayOutputStream;
+import java.io.PrintStream;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Tests for {@link HtmlPrinter}.
+ */
+@RunWith(JUnit4.class)
+public final class HtmlPrinterTest {
+
+    @Mock
+    private Instance mInstanceMock;
+    @Mock
+    private ClassObj mClassObjMock;
+    @Mock(answer = Answers.RETURNS_DEEP_STUBS)
+    private ClassInstance mBitmapClassInstanceMock;
+    @Mock
+    private ArrayInstance mBufferMock;
+
+    private ByteArrayOutputStream mByteStream;
+    private HtmlPrinter mPrinter;
+
+    @Before
+    public void setUp() {
+        mByteStream = new ByteArrayOutputStream();
+        mPrinter = new HtmlPrinter(new PrintStream((mByteStream)));
+
+        MockitoAnnotations.initMocks(this);
+    }
+
+    @Test
+    public void testAddHeading() throws Exception {
+        // act
+        mPrinter.addHeading(1, "test heading");
+
+        // assert
+        String out = new String(mByteStream.toByteArray(), StandardCharsets.UTF_8);
+        assertEquals(out, "<h1>test heading</h1>\n");
+    }
+
+    @Test
+    public void testAddParagraph() throws Exception {
+        // act
+        mPrinter.addParagraph("test paragraph");
+
+        // assert
+        String out = new String(mByteStream.toByteArray(), StandardCharsets.UTF_8);
+        assertEquals(out, "<p>test paragraph</p>\n");
+    }
+
+    @Test
+    public void testTable() throws Exception {
+        // act
+        mPrinter.startTable("test row heading");
+        mPrinter.addRow("test data");
+        mPrinter.endTable();
+
+        // assert
+        String out = new String(mByteStream.toByteArray(), StandardCharsets.UTF_8);
+        assertEquals(out,
+                "<table>\n"
+                        + "<tr style='border: 1px solid black;'>\n"
+                        + "<th style='border: 1px solid black;'>test row heading</th>\n"
+                        + "</tr>\n"
+                        + "<tr>\n"
+                        + "<td>test data</td>\n"
+                        + "</tr>\n"
+                        + "</table>\n");
+    }
+
+    @Test
+    public void testTable_noHeadings() throws Exception {
+        // act
+        mPrinter.startTable();
+        mPrinter.addRow("test data");
+        mPrinter.endTable();
+
+        // assert
+        String out = new String(mByteStream.toByteArray(), StandardCharsets.UTF_8);
+        assertEquals(out,
+                "<table>\n"
+                        + "<tr>\n"
+                        + "<td>test data</td>\n"
+                        + "</tr>\n"
+                        + "</table>\n");
+    }
+
+    @Test
+    public void testAddImage() throws Exception {
+        // arrange
+        Mockito.when(mBufferMock.getArrayType()).thenReturn(Type.BYTE);
+        Mockito.when(mBufferMock.asRawByteArray(Mockito.anyInt(), Mockito.anyInt()))
+                .thenReturn(new byte[]{0, 0, 0, 0});
+        Mockito.when(mBufferMock.getLength()).thenReturn(4);
+        List<ClassInstance.FieldValue> fields = new ArrayList<>();
+        fields.add(new ClassInstance.FieldValue(new Field(Type.OBJECT, "mBuffer"), mBufferMock));
+        fields.add(new ClassInstance.FieldValue(new Field(Type.BOOLEAN, "mIsMutable"),
+                new Boolean(true)));
+        fields.add(new ClassInstance.FieldValue(new Field(Type.INT, "mWidth"), new Integer(1)));
+        fields.add(new ClassInstance.FieldValue(new Field(Type.INT, "mHeight"), new Integer(1)));
+        ClassObj bitmapClassObj = new ClassObj(0L, new StackTrace(0, 0, new StackFrame[0]),
+                BitmapDecoder.BITMAP_FQCN, 0L);
+        Mockito.when(mBitmapClassInstanceMock.getClassObj()).thenReturn(bitmapClassObj);
+        Mockito.when(mBitmapClassInstanceMock.getValues()).thenReturn(fields);
+
+        // act
+        mPrinter.addImage(mBitmapClassInstanceMock);
+
+        // assert
+        String out = new String(mByteStream.toByteArray(), StandardCharsets.UTF_8);
+        System.err.println(out);
+        assertEquals(out,
+                "<img src='data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAA"
+                        + "C0lEQVR42mNgAAIAAAUAAen63NgAAAAASUVORK5CYII=' \\>\n");
+    }
+
+    @Test
+    public void testFormatInstance() throws Exception {
+        // arrange
+        Mockito.when(mInstanceMock.toString()).thenReturn("mock instance");
+
+        // act
+        String out = mPrinter.formatInstance(mInstanceMock);
+
+        // assert
+        assertEquals(out, "mock instance");
+    }
+}
diff --git a/perflib/src/test/java/com/android/tools/perflib/heap/memoryanalyzer/TaskRunnerTest.java b/perflib/src/test/java/com/android/tools/perflib/heap/memoryanalyzer/TaskRunnerTest.java
new file mode 100644
index 0000000..744979c
--- /dev/null
+++ b/perflib/src/test/java/com/android/tools/perflib/heap/memoryanalyzer/TaskRunnerTest.java
@@ -0,0 +1,66 @@
+package com.android.tools.perflib.heap.memoryanalyzer;
+
+import static org.junit.Assert.assertEquals;
+
+import com.android.tools.perflib.analyzer.AnalysisResultEntry;
+import com.android.tools.perflib.heap.Heap;
+import com.android.tools.perflib.heap.Snapshot;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+import org.mockito.Mock;
+import org.mockito.Mockito;
+import org.mockito.MockitoAnnotations;
+
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * Tests for {@link TaskRunner}.
+ */
+@RunWith(JUnit4.class)
+public class TaskRunnerTest {
+
+    @Mock
+    private Snapshot mSnapshotMock;
+
+    @Before
+    public void setUpMocks() {
+        MockitoAnnotations.initMocks(this);
+        Mockito.when(mSnapshotMock.getTypeName()).thenReturn(Snapshot.TYPE_NAME);
+        Mockito.when(mSnapshotMock.getRepresentation(Snapshot.class)).thenReturn(mSnapshotMock);
+        Mockito.when(mSnapshotMock.getHeaps()).thenReturn(Collections.<Heap>emptyList());
+    }
+
+    @Test
+    public void runTasksShouldGenerateEntry() throws InterruptedException {
+        // arrange
+        Set<MemoryAnalyzerTask> tasks = new HashSet<>();
+        tasks.add(new BasicAnalyzerTask());
+
+        // act
+        List<AnalysisResultEntry<?>> content = TaskRunner.runTasks(tasks, mSnapshotMock);
+
+        // assert
+        assertEquals(1, content.size());
+        assertEquals(BasicAnalyzerTask.TASK_WARNING, content.get(0).getWarningMessage());
+    }
+
+    @Test
+    public void runTasksShouldGenerateEntries_multipleCapture() throws InterruptedException {
+        // arrange
+        Set<MemoryAnalyzerTask> tasks = new HashSet<>();
+        tasks.add(new BasicAnalyzerTask());
+        // act
+        List<AnalysisResultEntry<?>> content = TaskRunner
+                .runTasks(tasks, mSnapshotMock, mSnapshotMock);
+
+        // assert
+        assertEquals(2, content.size());
+        assertEquals(BasicAnalyzerTask.TASK_WARNING, content.get(0).getWarningMessage());
+    }
+}