Updates for version 2.1.
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..eb5a316
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+target
diff --git a/pom.xml b/pom.xml
index 9a9bb4b..43e7fdb 100644
--- a/pom.xml
+++ b/pom.xml
@@ -4,12 +4,12 @@
 
   <groupId>com.google.android.apps.common.testing.accessibility.framework</groupId>
   <artifactId>accessibility-test-framework</artifactId>
-  <version>2.0</version>
+  <version>2.1</version>
   <packaging>jar</packaging>
 
   <name>Accessibility Test Framework</name>
   <description>Library used to test for common accessibility issues.</description>
-  <url>https://code.google.com/p/eyes-free/</url>
+  <url>https://github.com/google/Accessibility-Test-Framework-for-Android</url>
 
   <licenses>
     <license>
@@ -41,16 +41,16 @@
   </developers>
 
   <scm>
-    <connection>scm:svn:http://eyes-free.googlecode.com/svn/trunk/</connection>
-    <developerConnection>scm:svn:https://eyes-free.googlecode.com/svn/trunk/</developerConnection>
-    <url>https://code.google.com/p/eyes-free/source/browse</url>
+    <connection>scm:git:[email protected]:google/Accessibility-Test-Framework-for-Android.git</connection>
+    <developerConnection>scm:git:[email protected]:google/Accessibility-Test-Framework-for-Android.git</developerConnection>
+    <url>https://github.com/google/Accessibility-Test-Framework-for-Android</url>
   </scm>
 
   <dependencies>
     <dependency>
       <groupId>android.support</groupId>
       <artifactId>compatibility-v4</artifactId>
-      <version>22.0.0</version>
+      <version>23.0.1</version>
       <scope>provided</scope>
     </dependency>
     <dependency>
@@ -64,6 +64,16 @@
       <artifactId>hamcrest-core</artifactId>
       <version>1.3</version>
     </dependency>
+    <dependency>
+      <groupId>org.hamcrest</groupId>
+      <artifactId>hamcrest-library</artifactId>
+      <version>1.3</version>
+    </dependency>
+    <dependency>
+      <groupId>com.google.protobuf</groupId>
+      <artifactId>protobuf-java</artifactId>
+      <version>2.6.1</version>
+    </dependency>
   </dependencies>
 
   <build>
@@ -117,6 +127,7 @@
       <version>2.10.3</version>
       <configuration>
        <sourcepath>src</sourcepath>
+       <additionalparam>-Xdoclint:none</additionalparam>
       </configuration>
       <executions>
        <execution>
@@ -127,6 +138,28 @@
        </execution>
       </executions>
      </plugin>
+     <plugin>
+      <groupId>com.github.os72</groupId>
+      <artifactId>protoc-jar-maven-plugin</artifactId>
+      <version>3.0.0-a3</version>
+      <executions>
+       <execution>
+        <phase>generate-sources</phase>
+        <goals>
+         <goal>run</goal>
+        </goals>
+        <configuration>
+         <protocVersion>2.4.1</protocVersion>
+         <includeDirectories>
+          <include>src/main</include>
+         </includeDirectories>
+         <inputDirectories>
+          <include>src/main/java/com/google/android/apps/common/testing/accessibility/framework/proto</include>
+         </inputDirectories>
+        </configuration>
+       </execution>
+      </executions>
+     </plugin>
     </plugins>
    </build>
 </project>
diff --git a/src/main/java/com/google/android/apps/common/testing/accessibility/framework/AccessibilityCheckPreset.java b/src/main/java/com/google/android/apps/common/testing/accessibility/framework/AccessibilityCheckPreset.java
index c521498..86f0033 100644
--- a/src/main/java/com/google/android/apps/common/testing/accessibility/framework/AccessibilityCheckPreset.java
+++ b/src/main/java/com/google/android/apps/common/testing/accessibility/framework/AccessibilityCheckPreset.java
@@ -16,6 +16,8 @@
 
 package com.google.android.apps.common.testing.accessibility.framework;
 
+import android.view.View;
+
 import java.util.HashSet;
 import java.util.Set;
 
@@ -39,7 +41,15 @@
    * Preset used occasionally to hold checks that are about to be part of {@code LATEST}.
    * Includes all checks in {@code LATEST}.
    */
-  PRERELEASE;
+  PRERELEASE,
+
+  /**
+   * Included for compatibility with Robolectric. Do not use.
+   */
+  @Deprecated
+  VIEW_CHECKS,
+  @Deprecated
+  VIEW_HIERARCHY_CHECKS;
 
   /**
    * @param preset The preset of interest
@@ -110,10 +120,12 @@
       return checks;
     }
 
+    checks.add(new ContrastInfoCheck());
     /* Checks added since last release */
     if (preset == LATEST) {
       return checks;
     }
+
     if (preset == PRERELEASE) {
       return checks;
     }
@@ -155,4 +167,28 @@
      */
     throw new IllegalArgumentException();
   }
+
+  /**
+   * Included for compatibility with older Robolectric version. Do not use.
+   * Remove for public release.
+   * TODO(pweaver) This is a workaround to make Robolectric built against 1.0 of the framework
+   * continue to function with later versions of the framework. Fix Robolectric properly.
+   */
+  @Deprecated
+  public static Set<? extends AccessibilityCheck> getAllChecksForPreset(
+      @SuppressWarnings("unused") AccessibilityCheckPreset preset) {
+    Set<AccessibilityViewHierarchyCheck> checks = new HashSet<>();
+
+    if (preset == VIEW_HIERARCHY_CHECKS) {
+      checks.add(new SpeakableTextPresentViewCheck() {
+        @Override
+        protected boolean shouldFocusView(View view) {
+          return true;
+        }
+      });
+      checks.add(new ClickableSpanViewCheck());
+      checks.add(new EditableContentDescViewCheck());
+    }
+    return checks;
+  }
 }
diff --git a/src/main/java/com/google/android/apps/common/testing/accessibility/framework/AccessibilityCheckResult.java b/src/main/java/com/google/android/apps/common/testing/accessibility/framework/AccessibilityCheckResult.java
index 6ec33d3..c14f774 100644
--- a/src/main/java/com/google/android/apps/common/testing/accessibility/framework/AccessibilityCheckResult.java
+++ b/src/main/java/com/google/android/apps/common/testing/accessibility/framework/AccessibilityCheckResult.java
@@ -14,6 +14,8 @@
 
 package com.google.android.apps.common.testing.accessibility.framework;
 
+import com.google.android.apps.common.testing.accessibility.framework.proto.FrameworkProtos.AccessibilityCheckResultProto;
+
 import android.graphics.Rect;
 import android.os.Build;
 import android.view.View;
@@ -114,6 +116,24 @@
   }
 
   /**
+   * Returns a populated {@link AccessibilityCheckResultProto}
+   */
+  public AccessibilityCheckResultProto toProto() {
+    AccessibilityCheckResultProto.Builder builder = AccessibilityCheckResultProto.newBuilder();
+    if (type != null) {
+      // enum in this class and proto are consistent, one can resolve the string name in the other
+      builder.setResultType(AccessibilityCheckResultProto.ResultType.valueOf(type.name()));
+    }
+    if (message != null) {
+      builder.setMsg(message.toString());
+    }
+    if (checkClass != null) {
+      builder.setSourceCheckClass(checkClass.getName());
+    }
+    return builder.build();
+  }
+
+  /**
    * An object that describes an {@link AccessibilityCheckResult}. This can be extended to provide
    * descriptions of the result and their contents in a form that is localized to the environment in
    * which checks are being run.
diff --git a/src/main/java/com/google/android/apps/common/testing/accessibility/framework/AccessibilityCheckResultUtils.java b/src/main/java/com/google/android/apps/common/testing/accessibility/framework/AccessibilityCheckResultUtils.java
index 24a10f2..308746b 100644
--- a/src/main/java/com/google/android/apps/common/testing/accessibility/framework/AccessibilityCheckResultUtils.java
+++ b/src/main/java/com/google/android/apps/common/testing/accessibility/framework/AccessibilityCheckResultUtils.java
@@ -23,6 +23,7 @@
 
 import org.hamcrest.Description;
 import org.hamcrest.Matcher;
+import org.hamcrest.Matchers;
 import org.hamcrest.TypeSafeMatcher;
 
 import java.util.ArrayList;
@@ -141,6 +142,30 @@
   }
 
   /**
+   * Returns a {@link Matcher} for an {@link AccessibilityCheckResult} whose source check class
+   * matches the given matcher.
+   * <p>
+   * Note: Do not use {@link Matchers#is} for a {@link Class}, as the deprecated form will match
+   * only objects of that class instead of the class object itself. Use {@link Matchers#equalTo}
+   * instead.
+   *
+   * @param classMatcher a {@code Matcher} for a {@code Class<? extends AccessibilityCheck>}.
+   *                     Note: strict typing not enforced for Java 7 compatibility
+   * @return a {@code Matcher} for a {@code AccessibilityCheckResult}
+   */
+  public static Matcher<AccessibilityCheckResult> matchesChecks(final Matcher<?> classMatcher) {
+    if (classMatcher == null) {
+      return null;
+    }
+    return new TypeSafeMemberMatcher<AccessibilityCheckResult>("source check", classMatcher) {
+      @Override
+      public boolean matchesSafely(AccessibilityCheckResult result) {
+        return classMatcher.matches(result.getSourceCheckClass());
+      }
+    };
+  }
+
+  /**
    * Returns a {@link Matcher} for an {@link AccessibilityCheckResult} whose source check class has
    * a simple name that matches the given matcher for a {@code String}.
    *
diff --git a/src/main/java/com/google/android/apps/common/testing/accessibility/framework/AccessibilityInfoCheckResult.java b/src/main/java/com/google/android/apps/common/testing/accessibility/framework/AccessibilityInfoCheckResult.java
index 382573a..9009958 100644
--- a/src/main/java/com/google/android/apps/common/testing/accessibility/framework/AccessibilityInfoCheckResult.java
+++ b/src/main/java/com/google/android/apps/common/testing/accessibility/framework/AccessibilityInfoCheckResult.java
@@ -22,8 +22,11 @@
 import android.util.Log;
 import android.view.accessibility.AccessibilityNodeInfo;
 
+import com.googlecode.eyesfree.compat.CompatUtils;
 import com.googlecode.eyesfree.utils.LogUtils;
 
+import java.lang.reflect.Method;
+
 /**
  * Result generated when an accessibility check runs on a {@code AccessibilityNodeInfo}.
  */
@@ -31,7 +34,7 @@
 public final class AccessibilityInfoCheckResult extends AccessibilityCheckResult implements
     Parcelable {
 
-  private AccessibilityNodeInfo info;
+  private AccessibilityNodeInfoWrapper mInfoWrapper;
 
   /**
    * @param checkClass The check that generated the error
@@ -43,7 +46,7 @@
       AccessibilityCheckResultType type, CharSequence message, AccessibilityNodeInfo info) {
     super(checkClass, type, message);
     if (info != null) {
-      this.info = AccessibilityNodeInfo.obtain(info);
+      this.mInfoWrapper = new AccessibilityNodeInfoWrapper(AccessibilityNodeInfo.obtain(info));
     }
   }
 
@@ -56,16 +59,16 @@
    * @return The info to which the result applies.
    */
   public AccessibilityNodeInfo getInfo() {
-    return info;
+    return (mInfoWrapper != null) ? mInfoWrapper.getWrappedInfo() : null;
   }
 
   @Override
   public void recycle() {
     super.recycle();
 
-    if (info != null) {
-      info.recycle();
-      info = null;
+    if (mInfoWrapper != null) {
+      mInfoWrapper.getWrappedInfo().recycle();
+      mInfoWrapper = null;
     }
   }
 
@@ -79,10 +82,9 @@
     dest.writeString((checkClass != null) ? checkClass.getName() : "");
     dest.writeInt((type != null) ? type.ordinal() : -1);
     TextUtils.writeToParcel(message, dest, flags);
-    // Info requires a presence flag
-    if (info != null) {
+    if (mInfoWrapper != null) {
       dest.writeInt(1);
-      info.writeToParcel(dest, flags);
+      mInfoWrapper.writeToParcel(dest, flags);
     } else {
       dest.writeInt(0);
     }
@@ -112,13 +114,15 @@
     // Message
     this.message = TextUtils.CHAR_SEQUENCE_CREATOR.createFromParcel(in);
 
-    // Info
-    this.info = (in.readInt() == 1) ? AccessibilityNodeInfo.CREATOR.createFromParcel(in) : null;
-
+    // Info wrapper
+    this.mInfoWrapper =
+        (in.readInt() == 1) ? AccessibilityNodeInfoWrapper.WRAPPER_CREATOR.createFromParcel(in)
+            : null;
   }
 
   public static final Parcelable.Creator<AccessibilityInfoCheckResult> CREATOR =
       new Parcelable.Creator<AccessibilityInfoCheckResult>() {
+
         @Override
         public AccessibilityInfoCheckResult createFromParcel(Parcel in) {
           return new AccessibilityInfoCheckResult(in);
@@ -129,4 +133,82 @@
           return new AccessibilityInfoCheckResult[size];
         }
       };
+
+  /**
+   * We use a Parcelable wrapper for {@link AccessibilityNodeInfo} to work around a bug within the
+   * Android framework, which improperly unparcels instances which are sealed.
+   */
+  private static class AccessibilityNodeInfoWrapper implements Parcelable {
+    private static final Method METHOD_isSealed = CompatUtils.getMethod(
+        AccessibilityNodeInfo.class, "isSealed");
+
+    private static final Method METHOD_setSealed = CompatUtils.getMethod(
+        AccessibilityNodeInfo.class, "setSealed", boolean.class);
+
+    AccessibilityNodeInfo mWrappedInfo;
+
+    public AccessibilityNodeInfoWrapper(AccessibilityNodeInfo wrappedNode) {
+      mWrappedInfo = wrappedNode;
+    }
+
+    private AccessibilityNodeInfoWrapper(Parcel in) {
+      readFromParcel(in);
+    }
+
+    @Override
+    public int describeContents() {
+      return 0;
+    }
+
+    public AccessibilityNodeInfo getWrappedInfo() {
+      return mWrappedInfo;
+    }
+
+    @Override
+    public void writeToParcel(Parcel dest, int flags) {
+      if (mWrappedInfo != null) {
+        dest.writeInt(1);
+        if ((Boolean) CompatUtils.invoke(mWrappedInfo, false, METHOD_isSealed, (Object[]) null)) {
+          // In the case we've encountered a sealed info, we need to unseal it before parceling.
+          // Otherwise, the Android framework won't allow it to be recreated from a parcel due to a
+          // bug which improperly checks sealed state when adding parceled actions to an instance.
+          // We write our own int to indicate that the node must be re-sealed.
+          dest.writeInt(1);
+          CompatUtils.invoke(mWrappedInfo, null, METHOD_setSealed, false);
+        } else {
+          dest.writeInt(0);
+        }
+        mWrappedInfo.writeToParcel(dest, flags);
+      } else {
+        dest.writeInt(0);
+      }
+    }
+
+    private void readFromParcel(Parcel in) {
+      if (in.readInt() == 1) {
+        boolean shouldSeal = (in.readInt() == 1);
+        mWrappedInfo = AccessibilityNodeInfo.CREATOR.createFromParcel(in);
+        if (shouldSeal) {
+          CompatUtils.invoke(mWrappedInfo, null, METHOD_setSealed, true);
+        }
+      } else {
+        mWrappedInfo = null;
+      }
+    }
+
+
+    public static final Parcelable.Creator<AccessibilityNodeInfoWrapper> WRAPPER_CREATOR =
+        new Parcelable.Creator<AccessibilityNodeInfoWrapper>() {
+
+          @Override
+          public AccessibilityNodeInfoWrapper createFromParcel(Parcel in) {
+            return new AccessibilityNodeInfoWrapper(in);
+          }
+
+          @Override
+          public AccessibilityNodeInfoWrapper[] newArray(int size) {
+            return new AccessibilityNodeInfoWrapper[size];
+          }
+        };
+  }
 }
diff --git a/src/main/java/com/google/android/apps/common/testing/accessibility/framework/ClickableSpanViewCheck.java b/src/main/java/com/google/android/apps/common/testing/accessibility/framework/ClickableSpanViewCheck.java
index 047c8c5..a3a314e 100644
--- a/src/main/java/com/google/android/apps/common/testing/accessibility/framework/ClickableSpanViewCheck.java
+++ b/src/main/java/com/google/android/apps/common/testing/accessibility/framework/ClickableSpanViewCheck.java
@@ -32,8 +32,8 @@
  * Check to ensure that {@code ClickableSpan} is not being used in a TextView.
  *
  * <p>{@code ClickableSpan} is inaccessible because individual spans cannot be selected
- * independently in a single TextView and because accessibility services are unable to call
- * the OnClick method of a {@code ClickableSpan}.
+ * independently in a single {@code TextView} and because accessibility services are unable to call
+ * {@link ClickableSpan#onClick}.
  *
  * <p>The exception to this rule is that {@code URLSpan}s are accessible if they do not contain a
  * relative URI.
@@ -42,7 +42,7 @@
 
   @Override
   public List<AccessibilityViewCheckResult> runCheckOnView(View view) {
-    List<AccessibilityViewCheckResult> results = new ArrayList<AccessibilityViewCheckResult>(1);
+    List<AccessibilityViewCheckResult> results = new ArrayList<>(1);
     if (view instanceof TextView) {
       TextView textView = (TextView) view;
       if (textView.getText() instanceof Spanned) {
@@ -52,28 +52,41 @@
           if (clickableSpan instanceof URLSpan) {
             String url = ((URLSpan) clickableSpan).getURL();
             if (url == null) {
-              results.add(new AccessibilityViewCheckResult(this.getClass(),
-                  AccessibilityCheckResultType.ERROR, "URLSpan has null URL", view));
+              results.add(
+                  new AccessibilityViewCheckResult(
+                      this.getClass(),
+                      AccessibilityCheckResultType.ERROR,
+                      "URLSpan has null URL",
+                      view));
             } else {
               Uri uri = Uri.parse(url);
               if (uri.isRelative()) {
                 // Relative URIs cannot be resolved.
-                results.add(new AccessibilityViewCheckResult(this.getClass(),
-                    AccessibilityCheckResultType.ERROR, "URLSpan should not contain relative links",
-                    view));
+                results.add(
+                    new AccessibilityViewCheckResult(
+                        this.getClass(),
+                        AccessibilityCheckResultType.ERROR,
+                        "URLSpan should not contain relative links",
+                        view));
               }
             }
           } else { // Non-URLSpan ClickableSpan
-            results.add(new AccessibilityViewCheckResult(this.getClass(),
-                AccessibilityCheckResultType.ERROR,
-                "URLSpan should be used in place of ClickableSpan for improved accessibility",
-                view));
+            results.add(
+                new AccessibilityViewCheckResult(
+                    this.getClass(),
+                    AccessibilityCheckResultType.ERROR,
+                    "URLSpan should be used in place of ClickableSpan for improved accessibility",
+                    view));
           }
         }
       }
     } else {
-      results.add(new AccessibilityViewCheckResult(this.getClass(),
-          AccessibilityCheckResultType.NOT_RUN, "View must be a TextView", view));
+      results.add(
+          new AccessibilityViewCheckResult(
+              this.getClass(),
+              AccessibilityCheckResultType.NOT_RUN,
+              "View must be a TextView",
+              view));
     }
     return results;
   }
diff --git a/src/main/java/com/google/android/apps/common/testing/accessibility/framework/ContrastInfoCheck.java b/src/main/java/com/google/android/apps/common/testing/accessibility/framework/ContrastInfoCheck.java
new file mode 100644
index 0000000..38f0720
--- /dev/null
+++ b/src/main/java/com/google/android/apps/common/testing/accessibility/framework/ContrastInfoCheck.java
@@ -0,0 +1,148 @@
+/*
+ * Copyright (C) 2015 Google Inc.
+ *
+ * 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.
+ */
+
+package com.google.android.apps.common.testing.accessibility.framework;
+
+import com.google.android.apps.common.testing.accessibility.framework.AccessibilityCheckResult.AccessibilityCheckResultType;
+
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.graphics.Rect;
+import android.os.Bundle;
+import android.support.v4.view.accessibility.AccessibilityNodeInfoCompat;
+import android.text.TextUtils;
+import android.view.accessibility.AccessibilityNodeInfo;
+import android.widget.ImageView;
+import android.widget.TextView;
+
+import com.googlecode.eyesfree.utils.AccessibilityNodeInfoUtils;
+import com.googlecode.eyesfree.utils.ContrastSwatch;
+import com.googlecode.eyesfree.utils.ContrastUtils;
+import com.googlecode.eyesfree.utils.NodeFilter;
+import com.googlecode.eyesfree.utils.ScreenshotUtils;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Checks to ensure that certain eligible items on-screen items have sufficient contrast. This check
+ * uses screen capture data to heuristically evaluate foreground/background color contrast ratios.
+ */
+public class ContrastInfoCheck extends AccessibilityInfoHierarchyCheck {
+
+  private static final NodeFilter FILTER_CONTRAST_EVAL_ELIGIBLE = new NodeFilter() {
+
+    @Override
+    public boolean accept(Context context, AccessibilityNodeInfoCompat node) {
+      boolean isText =
+          AccessibilityNodeInfoUtils.nodeMatchesAnyClassByType(context, node, TextView.class);
+      boolean isImage =
+          AccessibilityNodeInfoUtils.nodeMatchesAnyClassByType(context, node, ImageView.class);
+      boolean hasText = !TextUtils.isEmpty(node.getText());
+      boolean isVisible = AccessibilityNodeInfoUtils.isVisibleOrLegacy(node);
+
+      return isVisible && ((isText && hasText) || isImage);
+    }
+  };
+
+  private static final NodeFilter FILTER_CONTRAST_EVAL_INELIGIBLE = new NodeFilter() {
+
+    @Override
+    public boolean accept(Context context, AccessibilityNodeInfoCompat node) {
+      return !FILTER_CONTRAST_EVAL_ELIGIBLE.accept(context, node);
+    }
+  };
+
+  @Override
+  public List<AccessibilityInfoCheckResult> runCheckOnInfoHierarchy(AccessibilityNodeInfo root,
+      Context context, Bundle metadata) {
+    List<AccessibilityInfoCheckResult> results = new ArrayList<AccessibilityInfoCheckResult>();
+    Bitmap screenCapture = null;
+    if (metadata != null) {
+      screenCapture =
+          metadata.getParcelable(AccessibilityCheckMetadata.METADATA_KEY_SCREEN_CAPTURE_BITMAP);
+    }
+
+    if (screenCapture == null) {
+      results.add(new AccessibilityInfoCheckResult(getClass(), AccessibilityCheckResultType.NOT_RUN,
+          "This check did not execute because it was unable to obtain screen capture data.", null));
+      return results;
+    }
+
+    AccessibilityNodeInfoCompat rootCompat = new AccessibilityNodeInfoCompat(root);
+    List<AccessibilityNodeInfoCompat> candidates = AccessibilityNodeInfoUtils.searchAllFromBfs(
+        context, rootCompat, FILTER_CONTRAST_EVAL_ELIGIBLE);
+    List<AccessibilityNodeInfoCompat> nonCandidates = AccessibilityNodeInfoUtils.searchAllFromBfs(
+        context, rootCompat, FILTER_CONTRAST_EVAL_INELIGIBLE);
+
+    // Ineligible nodes all receive NOT_RUN results
+    for (AccessibilityNodeInfoCompat nonCandidate : nonCandidates) {
+      AccessibilityNodeInfo unwrappedNonCandidate = (AccessibilityNodeInfo) nonCandidate.getInfo();
+      results.add(new AccessibilityInfoCheckResult(getClass(), AccessibilityCheckResultType.NOT_RUN,
+          "This view's contrast was not evaluated because it contains neither text nor an image.",
+          unwrappedNonCandidate));
+    }
+
+    Rect screenCaptureBounds =
+        new Rect(0, 0, screenCapture.getWidth() - 1, screenCapture.getHeight() - 1);
+    for (AccessibilityNodeInfoCompat candidate : candidates) {
+      AccessibilityNodeInfo unwrappedCandidate = (AccessibilityNodeInfo) candidate.getInfo();
+      Rect viewBounds = new Rect();
+      unwrappedCandidate.getBoundsInScreen(viewBounds);
+      if (!screenCaptureBounds.contains(viewBounds)) {
+        // If an off-screen view reports itself as visible, we shouldn't evaluate it.
+        String message = String.format(
+            "View bounds %1$s were not within the screen capture bounds %2$s.", viewBounds,
+            screenCaptureBounds);
+        results.add(new AccessibilityInfoCheckResult(getClass(),
+            AccessibilityCheckResultType.NOT_RUN, message, unwrappedCandidate));
+        continue;
+      }
+      ContrastSwatch candidateSwatch = new ContrastSwatch(
+          ScreenshotUtils.cropBitmap(screenCapture, viewBounds), viewBounds,
+          unwrappedCandidate.getViewIdResourceName());
+      double contrastRatio = candidateSwatch.getContrastRatio();
+      if (AccessibilityNodeInfoUtils.nodeMatchesAnyClassByType(context, candidate,
+          TextView.class)) {
+        if (contrastRatio < ContrastUtils.CONTRAST_RATIO_WCAG_LARGE_TEXT) {
+          String message = String.format("This view's foreground to background contrast ratio "
+              + "(%1$.2f) is not sufficient.", contrastRatio);
+          results.add(new AccessibilityInfoCheckResult(getClass(),
+              AccessibilityCheckResultType.ERROR, message, unwrappedCandidate));
+        } else if (contrastRatio < ContrastUtils.CONTRAST_RATIO_WCAG_NORMAL_TEXT) {
+          String message = String.format("This view's foreground to background contrast ratio "
+              + "(%1$.2f) may not be sufficient unless it contains large text.", contrastRatio);
+          results.add(new AccessibilityInfoCheckResult(getClass(),
+              AccessibilityCheckResultType.WARNING, message, unwrappedCandidate));
+        }
+      } else if (AccessibilityNodeInfoUtils.nodeMatchesAnyClassByType(context, candidate,
+          ImageView.class)) {
+        // Lower confidence in heuristics for ImageViews, so we'll report only warnings and use
+        // the more permissive threshold ratio since images are generally large.
+        if (contrastRatio < ContrastUtils.CONTRAST_RATIO_WCAG_LARGE_TEXT) {
+          String message = String.format("This image's foreground to background contrast ratio "
+              + "(%1$.2f) is not sufficient.  NOTE: This test is experimental and may be less "
+              + "accurate for some images.", contrastRatio);
+          results.add(new AccessibilityInfoCheckResult(getClass(),
+              AccessibilityCheckResultType.WARNING, message, unwrappedCandidate));
+        }
+      }
+      candidateSwatch.recycle();
+    }
+
+    AccessibilityNodeInfoUtils.recycleNodes(candidates);
+    AccessibilityNodeInfoUtils.recycleNodes(nonCandidates);
+    return results;
+  }
+}
diff --git a/src/main/java/com/google/android/apps/common/testing/accessibility/framework/DuplicateSpeakableTextViewHierarchyCheck.java b/src/main/java/com/google/android/apps/common/testing/accessibility/framework/DuplicateSpeakableTextViewHierarchyCheck.java
index b934a2e..3210b3c 100644
--- a/src/main/java/com/google/android/apps/common/testing/accessibility/framework/DuplicateSpeakableTextViewHierarchyCheck.java
+++ b/src/main/java/com/google/android/apps/common/testing/accessibility/framework/DuplicateSpeakableTextViewHierarchyCheck.java
@@ -72,7 +72,7 @@
         results.add(new AccessibilityViewCheckResult(this.getClass(),
             AccessibilityCheckResultType.WARNING, String.format(Locale.US,
                 "Clickable view's speakable text: \"%s\" is identical to that of %d "
-                + "other view(s)", speakableText, nonClickableViews.size()),
+                + "other clickable view(s)", speakableText, clickableViews.size()),
             clickableViews.get(0)));
         clickableViews.remove(0);
       } else {
diff --git a/src/main/java/com/google/android/apps/common/testing/accessibility/framework/SpeakableTextPresentViewCheck.java b/src/main/java/com/google/android/apps/common/testing/accessibility/framework/SpeakableTextPresentViewCheck.java
index 5c99eee..80d23a5 100644
--- a/src/main/java/com/google/android/apps/common/testing/accessibility/framework/SpeakableTextPresentViewCheck.java
+++ b/src/main/java/com/google/android/apps/common/testing/accessibility/framework/SpeakableTextPresentViewCheck.java
@@ -66,7 +66,7 @@
         return results;
       }
     }
-    if (ViewAccessibilityUtils.shouldFocusView(view)) {
+    if (shouldFocusView(view)) {
       // We must evaluate this view for speakable text
       if (TextUtils.isEmpty(AccessibilityCheckUtils.getSpeakableTextForView(view))) {
         results.add(new AccessibilityViewCheckResult(this.getClass(),
@@ -79,4 +79,9 @@
     }
     return results;
   }
+
+  /* TODO(pweaver) Remove this awkward way of allowing Robolectric to use this */
+  protected boolean shouldFocusView(View view) {
+    return ViewAccessibilityUtils.shouldFocusView(view);
+  }
 }
diff --git a/src/main/java/com/google/android/apps/common/testing/accessibility/framework/TouchTargetSizeInfoCheck.java b/src/main/java/com/google/android/apps/common/testing/accessibility/framework/TouchTargetSizeInfoCheck.java
index ab4c742..907f350 100644
--- a/src/main/java/com/google/android/apps/common/testing/accessibility/framework/TouchTargetSizeInfoCheck.java
+++ b/src/main/java/com/google/android/apps/common/testing/accessibility/framework/TouchTargetSizeInfoCheck.java
@@ -48,7 +48,7 @@
     // TODO(sjrush): Have all info checks use AccessibilityNodeInfoCompat
     AccessibilityNodeInfoCompat infoCompat = new AccessibilityNodeInfoCompat(info);
     if (!(AccessibilityNodeInfoUtils.isClickable(infoCompat)
-          || AccessibilityNodeInfoUtils.isLongClickable(infoCompat))) {
+        || AccessibilityNodeInfoUtils.isLongClickable(infoCompat))) {
       results.add(new AccessibilityInfoCheckResult(this.getClass(),
           AccessibilityCheckResultType.NOT_RUN, "View is not clickable", info));
       return results;
@@ -64,19 +64,34 @@
     float density = context.getResources().getDisplayMetrics().density;
     Rect bounds = new Rect();
     info.getBoundsInScreen(bounds);
-    float targetHeight = bounds.height() / density;
-    float targetWidth = bounds.width() / density;
+    int targetHeight = (int) (Math.abs(bounds.height()) / density);
+    int targetWidth = (int) (Math.abs(bounds.width()) / density);
     if (targetHeight < TOUCH_TARGET_MIN_HEIGHT || targetWidth < TOUCH_TARGET_MIN_WIDTH) {
-      String message = String.format(Locale.US,
-          "View is too small of a touch target. Minimum touch target size is %dx%ddp. "
-          + "Actual size is %.1fx%.1fdp (screen density is %.1f).",
-          TOUCH_TARGET_MIN_WIDTH,
-          TOUCH_TARGET_MIN_HEIGHT,
-          targetWidth,
-          targetHeight,
-          density);
+      StringBuilder messageBuilder = new StringBuilder(String.format(Locale.US,
+          "View falls below the minimum recommended size for touch targets."));
+      if (targetHeight < TOUCH_TARGET_MIN_HEIGHT && targetWidth < TOUCH_TARGET_MIN_WIDTH) {
+        // Not tall or wide enough
+        messageBuilder.append(String.format(" Minimum touch target size is %dx%ddp. "
+            + "Actual size is %dx%ddp.",
+            TOUCH_TARGET_MIN_WIDTH,
+            TOUCH_TARGET_MIN_HEIGHT,
+            targetWidth,
+            targetHeight));
+      } else if (targetHeight < TOUCH_TARGET_MIN_HEIGHT) {
+        // Not tall enough
+        messageBuilder.append(String.format(" Minimum touch target height is %ddp. "
+            + "Actual height is %ddp.",
+            TOUCH_TARGET_MIN_HEIGHT,
+            targetHeight));
+      } else if (targetWidth < TOUCH_TARGET_MIN_WIDTH) {
+        // Not wide enough
+        messageBuilder.append(String.format(" Minimum touch target width is %ddp. "
+            + "Actual width is %ddp.",
+            TOUCH_TARGET_MIN_WIDTH,
+            targetWidth));
+      }
       results.add(new AccessibilityInfoCheckResult(this.getClass(),
-          AccessibilityCheckResultType.ERROR, message, info));
+          AccessibilityCheckResultType.ERROR, messageBuilder, info));
     }
     return results;
   }
diff --git a/src/main/java/com/google/android/apps/common/testing/accessibility/framework/TouchTargetSizeViewCheck.java b/src/main/java/com/google/android/apps/common/testing/accessibility/framework/TouchTargetSizeViewCheck.java
index 2504d8f..73bf735 100644
--- a/src/main/java/com/google/android/apps/common/testing/accessibility/framework/TouchTargetSizeViewCheck.java
+++ b/src/main/java/com/google/android/apps/common/testing/accessibility/framework/TouchTargetSizeViewCheck.java
@@ -52,8 +52,8 @@
 
     // dp calculation is pixels/density
     float density = view.getContext().getResources().getDisplayMetrics().density;
-    float targetHeight = view.getHeight() / density;
-    float targetWidth = view.getWidth() / density;
+    int targetHeight = (int) (view.getHeight() / density);
+    int targetWidth = (int) (view.getWidth() / density);
 
     if (targetHeight < TOUCH_TARGET_MIN_HEIGHT || targetWidth < TOUCH_TARGET_MIN_WIDTH) {
       // Before we know a view fails this check, we must check if one of the view's ancestors may be
@@ -66,18 +66,34 @@
           hasDelegate ? AccessibilityCheckResultType.WARNING : AccessibilityCheckResultType.ERROR;
 
       StringBuilder messageBuilder = new StringBuilder(String.format(
-          "View falls below the minimum recommended size for touch targets. Minimum touch target "
-          + "size is %dx%ddp. Actual size is %.1fx%.1fdp (screen density is %.1f).",
-          TOUCH_TARGET_MIN_WIDTH,
-          TOUCH_TARGET_MIN_HEIGHT,
-          targetWidth,
-          targetHeight,
-          density));
+          "View falls below the minimum recommended size for touch targets."));
+
+      if (targetHeight < TOUCH_TARGET_MIN_HEIGHT && targetWidth < TOUCH_TARGET_MIN_WIDTH) {
+        // Not wide or tall enough
+        messageBuilder.append(String.format(" Minimum touch target "
+            + "size is %dx%ddp. Actual size is %dx%ddp.",
+            TOUCH_TARGET_MIN_WIDTH,
+            TOUCH_TARGET_MIN_HEIGHT,
+            targetWidth,
+            targetHeight));
+      } else if (targetHeight < TOUCH_TARGET_MIN_HEIGHT) {
+        // Not tall enough
+        messageBuilder.append(String.format(" Minimum touch target "
+            + "height is %ddp. Actual height is %ddp.",
+            TOUCH_TARGET_MIN_HEIGHT,
+            targetHeight));
+      } else if (targetWidth < TOUCH_TARGET_MIN_WIDTH) {
+        // Not wide enough
+        messageBuilder.append(String.format(" Minimum touch target "
+            + "width is %ddp. Actual width is %ddp.",
+            TOUCH_TARGET_MIN_WIDTH,
+            targetWidth));
+      }
       if (hasDelegate) {
         messageBuilder.append(
             " A TouchDelegate has been detected on one of this view's ancestors. If the delegate "
-            + "is of sufficient size and handles touches for this view, this warning may be "
-            + "ignored.");
+                + "is of sufficient size and handles touches for this view, this warning may be "
+                + "ignored.");
       }
 
       results.add(
diff --git a/src/main/java/com/google/android/apps/common/testing/accessibility/framework/integrations/AccessibilityViewCheckException.java b/src/main/java/com/google/android/apps/common/testing/accessibility/framework/integrations/AccessibilityViewCheckException.java
index b1747f8..844552a 100644
--- a/src/main/java/com/google/android/apps/common/testing/accessibility/framework/integrations/AccessibilityViewCheckException.java
+++ b/src/main/java/com/google/android/apps/common/testing/accessibility/framework/integrations/AccessibilityViewCheckException.java
@@ -23,30 +23,45 @@
 /**
  * An exception class to be used for throwing exceptions with accessibility results.
  */
-public class AccessibilityViewCheckException extends RuntimeException {
+public final class AccessibilityViewCheckException extends RuntimeException {
   private List<AccessibilityViewCheckResult> results;
-  private AccessibilityCheckResultDescriptor resultDescriptor =
-      new AccessibilityCheckResultDescriptor();
+  private AccessibilityCheckResultDescriptor resultDescriptor;
 
   /**
-   * Any extension of this class must call this constructor.
+   * Create an instance with the default {@link AccessibilityCheckResultDescriptor}
+   */
+  public AccessibilityViewCheckException(List<AccessibilityViewCheckResult> results) {
+    this(results, new AccessibilityCheckResultDescriptor());
+  }
+
+  /**
+   * Create an exception with results and a result descriptor to generate the message.
    *
    * @param results a list of {@link AccessibilityViewCheckResult}s that are associated with the
    *        failure(s) that cause this to be thrown.
+   * @param resultDescriptor the {@link AccessibilityCheckResultDescriptor} used to generate the
+   *        exception message.
    */
-  public AccessibilityViewCheckException(List<AccessibilityViewCheckResult> results) {
+  public AccessibilityViewCheckException(List<AccessibilityViewCheckResult> results,
+      AccessibilityCheckResultDescriptor resultDescriptor) {
     super();
-    if ((results == null) || (results.size() == 0)) {
-      throw new RuntimeException(
+    if (results == null || results.isEmpty()) {
+      throw new IllegalArgumentException(
           "AccessibilityViewCheckException requires at least 1 AccessibilityViewCheckResult");
     }
+    if (resultDescriptor == null) {
+      throw new IllegalArgumentException("Result descriptor cannot be null");
+    }
     this.results = results;
+    this.resultDescriptor = resultDescriptor;
   }
 
   @Override
   public String getMessage() {
     // Lump all error result messages into one string to be the exception message
     StringBuilder exceptionMessage = new StringBuilder();
+    // TODO(sjrush): allow for developers to set their own Locale, and use that instead of
+    // Locale.US below, regardless of what Locale they're testing on.
     String errorCountMessage = (results.size() == 1)
         ? "There was 1 accessibility error:\n"
         : String.format(Locale.US, "There were %d accessibility errors:\n", results.size());
@@ -62,17 +77,6 @@
   }
 
   /**
-   * Sets the {@link AccessibilityCheckResultDescriptor} used to generate the exception message.
-   *
-   * @return this
-   */
-  public AccessibilityViewCheckException setResultDescriptor(
-      AccessibilityCheckResultDescriptor resultDescriptor) {
-    this.resultDescriptor = resultDescriptor;
-    return this;
-  }
-
-  /**
    * @return the list of results associated with this instance
    */
   public List<AccessibilityViewCheckResult> getResults() {
diff --git a/src/main/java/com/google/android/apps/common/testing/accessibility/framework/integrations/espresso/AccessibilityValidator.java b/src/main/java/com/google/android/apps/common/testing/accessibility/framework/integrations/espresso/AccessibilityValidator.java
index 69b7036..e4d8c4c 100644
--- a/src/main/java/com/google/android/apps/common/testing/accessibility/framework/integrations/espresso/AccessibilityValidator.java
+++ b/src/main/java/com/google/android/apps/common/testing/accessibility/framework/integrations/espresso/AccessibilityValidator.java
@@ -20,12 +20,13 @@
 import com.google.android.apps.common.testing.accessibility.framework.AccessibilityViewCheckResult;
 import com.google.android.apps.common.testing.accessibility.framework.AccessibilityViewHierarchyCheck;
 import com.google.android.apps.common.testing.accessibility.framework.integrations.AccessibilityViewCheckException;
-
+import android.content.Context;
 import android.util.Log;
 import android.view.View;
 
 import org.hamcrest.Matcher;
 
+import java.util.ArrayList;
 import java.util.Collections;
 import java.util.LinkedList;
 import java.util.List;
@@ -35,20 +36,18 @@
  * Espresso. Clients can call {@link #checkAndReturnResults} on a {@link View}
  * to run all of the checks with the options specified in this object.
  */
-public class AccessibilityValidator {
+public final class AccessibilityValidator {
 
   private static final String TAG = "AccessibilityValidator";
+  private AccessibilityCheckPreset preset = AccessibilityCheckPreset.LATEST;
   private boolean runChecksFromRootView = false;
   private boolean throwExceptionForErrors = true;
   private AccessibilityCheckResultDescriptor resultDescriptor =
       new AccessibilityCheckResultDescriptor();
-  private List<AccessibilityViewHierarchyCheck> viewHierarchyChecks =
-      new LinkedList<AccessibilityViewHierarchyCheck>();
   private Matcher<? super AccessibilityViewCheckResult> suppressingMatcher = null;
+  private List<AccessibilityCheckListener> checkListeners = new LinkedList<>();
 
   public AccessibilityValidator() {
-    viewHierarchyChecks.addAll(AccessibilityCheckPreset.getViewChecksForPreset(
-        AccessibilityCheckPreset.LATEST));
   }
 
   /**
@@ -66,6 +65,17 @@
   }
 
   /**
+   * Specify the set of checks to be run. The default is {link AccessibilityCheckPreset.LATEST}.
+   *
+   * @param preset The preset specifying the group of checks to run.
+   * @return this
+   */
+  public AccessibilityValidator setCheckPreset(AccessibilityCheckPreset preset) {
+    this.preset = preset;
+    return this;
+  }
+
+  /**
    * @param runChecksFromRootView {@code true} to check all views in the hierarchy, {@code false} to
    *        check only views in the hierarchy rooted at the passed in view. Default: {@code false}
    * @return this
@@ -114,6 +124,21 @@
   }
 
   /**
+   * Adds a listener to receive all {@link AccessibilityCheckResult}s after suppression. Listeners
+   * will be called in the order they are added and before any
+   * {@link AccessibilityViewCheckException} would be thrown.
+   *
+   * @return this
+   */
+  public AccessibilityValidator addCheckListener(AccessibilityCheckListener listener) {
+    if (listener == null) {
+      throw new IllegalArgumentException("Check listener cannot be null");
+    }
+    checkListeners.add(listener);
+    return this;
+  }
+
+  /**
    * Runs accessibility checks on a {@code View} hierarchy
    *
    * @param root the root {@code View} of the hierarchy
@@ -121,20 +146,31 @@
    */
   private List<AccessibilityViewCheckResult> runAccessibilityChecks(
       View root) {
-    List<AccessibilityViewCheckResult> results = new LinkedList<>();
+    List<AccessibilityViewHierarchyCheck> viewHierarchyChecks = new ArrayList<>(
+        AccessibilityCheckPreset.getViewChecksForPreset(preset));
+    List<AccessibilityViewCheckResult> results = new ArrayList<>();
     for (AccessibilityViewHierarchyCheck check : viewHierarchyChecks) {
       results.addAll(check.runCheckOnViewHierarchy(root));
     }
     AccessibilityCheckResultUtils.suppressMatchingResults(results, suppressingMatcher);
+
+    for (AccessibilityCheckListener checkListener : checkListeners) {
+      checkListener.onResults(root.getContext(), results);
+    }
+
     processResults(results);
     return results;
   }
 
+  /**
+   * Reports the given results to the user using logcat and/or exceptions depending on the options
+   * set for this {@code AccessibilityValidator}. Results of type {@code INFO} and {@code WARNING}
+   * will be logged to logcat, and results of type {@code ERROR} will be logged to logcat or
+   * a single {@link AccessibilityViewCheckException} will be thrown containing all {@code ERROR}
+   * results, depending on the value of {@link #throwExceptionForErrors}.
+   */
   // TODO(sjrush): Determine a more robust reporting mechanism instead of using logcat.
   private void processResults(Iterable<AccessibilityViewCheckResult> results) {
-    if (results == null) {
-      return;
-    }
     List<AccessibilityViewCheckResult> infos = AccessibilityCheckResultUtils.getResultsForType(
         results, AccessibilityCheckResultType.INFO);
     List<AccessibilityViewCheckResult> warnings = AccessibilityCheckResultUtils.getResultsForType(
@@ -148,12 +184,18 @@
       Log.w(TAG, resultDescriptor.describeResult(result));
     }
     if (!errors.isEmpty() && throwExceptionForErrors) {
-      throw new AccessibilityViewCheckException(errors)
-          .setResultDescriptor(resultDescriptor);
+      throw new AccessibilityViewCheckException(errors, resultDescriptor);
     } else {
       for (AccessibilityViewCheckResult result : errors) {
         Log.e(TAG, resultDescriptor.describeResult(result));
       }
     }
   }
+
+  /**
+   * Interface for receiving callbacks when results have been obtained.
+   */
+  public static interface AccessibilityCheckListener {
+    void onResults(Context context, List<? extends AccessibilityViewCheckResult> results);
+  }
 }
diff --git a/src/main/java/com/google/android/apps/common/testing/accessibility/framework/proto/framework.proto b/src/main/java/com/google/android/apps/common/testing/accessibility/framework/proto/framework.proto
new file mode 100644
index 0000000..73e427d
--- /dev/null
+++ b/src/main/java/com/google/android/apps/common/testing/accessibility/framework/proto/framework.proto
@@ -0,0 +1,53 @@
+/*
+ * Copyright (C) 2015 Google Inc.
+ *
+ * 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.
+ */
+
+syntax = "proto2";
+
+package android.apps.common.testing.accessibility.framework.proto;
+
+option java_package = "com.google.android.apps.common.testing.accessibility.framework.proto";
+option java_outer_classname  = "FrameworkProtos";
+
+// Proto describing a run of the ATF
+message Run {
+  repeated AccessibilityCheckResultProto result = 1;
+  // TODO(sjrush): include more fields, like screenshot and activity information
+}
+
+// Proto describing an AccessibilityCheckResult
+// Next index: 5
+message AccessibilityCheckResultProto {
+  // Other than UNKNOWN, this should mirror
+  // AccessibilityCheckResult.AccessibilityCheckResultType
+  // Next index: 6
+  enum ResultType {
+    UNKNOWN = 0;
+    ERROR = 1;
+    WARNING = 2;
+    INFO = 3;
+    NOT_RUN = 4;
+    SUPPRESSED = 5;
+  }
+
+  optional ResultType result_type = 1;
+  optional string source_check_class = 2;
+  optional string msg = 3;
+  optional ViewDescriptionProto view_description = 4;
+}
+
+// A description of a UI element (a View, an AccessibilityNodeInfo, or other)
+// Next index: 1
+message ViewDescriptionProto {
+  extensions 10000 to max;
+}
diff --git a/src/main/java/com/googlecode/eyesfree/compat/SurfaceControlCompatUtils.java b/src/main/java/com/googlecode/eyesfree/compat/SurfaceControlCompatUtils.java
new file mode 100644
index 0000000..8dc46e0
--- /dev/null
+++ b/src/main/java/com/googlecode/eyesfree/compat/SurfaceControlCompatUtils.java
@@ -0,0 +1,60 @@
+/*
+ * Copyright (C) 2015 Google Inc.
+ *
+ * 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.
+ */
+
+package com.googlecode.eyesfree.compat.view;
+
+import android.graphics.Bitmap;
+import android.util.Log;
+
+import com.googlecode.eyesfree.compat.CompatUtils;
+
+import java.lang.reflect.Method;
+
+/**
+ * Provides access to {@code android.view.SurfaceControl} methods.
+ *
+ * @author [email protected] (Alan Viverette)
+ */
+public class SurfaceControlCompatUtils {
+    private static final String TAG = SurfaceControlCompatUtils.class.getSimpleName();
+
+    private static final Class<?> CLASS_Surface_Control = CompatUtils.getClass(
+            "android.view.SurfaceControl");
+    private static final Method METHOD_screenshot_II = CompatUtils.getMethod(CLASS_Surface_Control,
+            "screenshot", int.class, int.class);
+
+    private SurfaceControlCompatUtils() {
+        // This class is non-instantiable.
+    }
+
+    /**
+     * Copy the current screen contents into a bitmap and return it. Use width =
+     * 0 and height = 0 to obtain an unscaled screenshot.
+     *
+     * @param width The desired width of the returned bitmap; the raw screen
+     *            will be scaled down to this size.
+     * @param height The desired height of the returned bitmap; the raw screen
+     *            will be scaled down to this size.
+     */
+    public static Bitmap screenshot(int width, int height) {
+        if (METHOD_screenshot_II == null) {
+            Log.e(TAG, "screenshot method was not found.");
+            return null;
+        }
+
+        return (Bitmap) CompatUtils.invoke(null, null, METHOD_screenshot_II, width, height);
+    }
+}
diff --git a/src/main/java/com/googlecode/eyesfree/utils/ContrastSwatch.java b/src/main/java/com/googlecode/eyesfree/utils/ContrastSwatch.java
new file mode 100644
index 0000000..1512ef0
--- /dev/null
+++ b/src/main/java/com/googlecode/eyesfree/utils/ContrastSwatch.java
@@ -0,0 +1,304 @@
+/*
+ * Copyright (C) 2015 Google Inc.
+ *
+ * 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.
+ */
+
+package com.googlecode.eyesfree.utils;
+
+import android.graphics.Bitmap;
+import android.graphics.Color;
+import android.graphics.Rect;
+import android.os.Parcel;
+import android.os.Parcelable;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Map.Entry;
+
+/**
+ * Represents a section of a screenshot bitmap and its associated color and
+ * contrast data.
+ *
+ * @author [email protected] (Casey Burkhardt)
+ */
+public class ContrastSwatch implements Parcelable {
+
+    private Bitmap mImage;
+
+    private final String mName;
+
+    private final HashMap<Integer, Double> mLuminanceMap;
+
+    private final HashMap<Double, Integer> mLuminanceHistogram;
+
+    private final List<Integer> mBackgroundColors;
+
+    private final List<Integer> mForegroundColors;
+
+    private double mBackgroundLuminance;
+
+    private double mForegroundLuminance;
+
+    private final Rect mScreenBounds;
+
+    private double mContrastRatio;
+
+    /**
+     * Constructs a ContrastSwatch, also extracting certain properties from the
+     * bitmap related to contrast and luminance.
+     * <p>
+     * NOTE: Invoking this constructor performs image processing tasks, which
+     * are relatively heavyweight. Callers should create these objects off the
+     * UI thread.
+     *
+     * @param image {@link Bitmap} of the area of an image to be evaluated
+     * @param screenBounds The bounds in screen coordinates of the image being
+     *            evaluated
+     * @param name Optional name identifying the image being evaluated
+     */
+    public ContrastSwatch(Bitmap image, Rect screenBounds, String name) {
+        mImage = image;
+        mScreenBounds = screenBounds;
+        mName = name;
+        mBackgroundColors = new LinkedList<Integer>();
+        mForegroundColors = new LinkedList<Integer>();
+        mLuminanceMap = new HashMap<Integer, Double>();
+        mLuminanceHistogram = new HashMap<Double, Integer>();
+
+        processSwatch();
+    }
+
+    private ContrastSwatch(Parcel source) {
+        mBackgroundColors = new LinkedList<Integer>();
+        mForegroundColors = new LinkedList<Integer>();
+        mLuminanceMap = new HashMap<Integer, Double>();
+        mLuminanceHistogram = new HashMap<Double, Integer>();
+
+        mName = source.readString();
+        source.readMap(mLuminanceMap, null);
+        source.readMap(mLuminanceHistogram, null);
+        source.readList(mBackgroundColors, null);
+        source.readList(mForegroundColors, null);
+        mBackgroundLuminance = source.readDouble();
+        mForegroundLuminance = source.readDouble();
+        mScreenBounds = (Rect) source.readValue(Rect.class.getClassLoader());
+        mContrastRatio = source.readDouble();
+    }
+
+    public void recycle() {
+        if (mImage != null) {
+            mImage.recycle();
+        }
+    }
+
+    private void processSwatch() {
+        processLuminanceData();
+        extractFgBgData();
+
+        // Two-decimal digits of precision for the contrast ratio
+        mContrastRatio = Math.round(
+                ContrastUtils.calculateContrastRatio(mBackgroundLuminance, mForegroundLuminance)
+                * 100.0d) / 100.0d;
+    }
+
+    private void processLuminanceData() {
+        for (int x = 0; x < mImage.getWidth(); ++x) {
+            for (int y = 0; y < mImage.getHeight(); ++y) {
+                final int color = mImage.getPixel(x, y);
+                final double luminance = ContrastUtils.calculateLuminance(color);
+                if (!mLuminanceMap.containsKey(color)) {
+                    mLuminanceMap.put(color, luminance);
+                }
+
+                if (!mLuminanceHistogram.containsKey(luminance)) {
+                    mLuminanceHistogram.put(luminance, 0);
+                }
+
+                mLuminanceHistogram.put(luminance, mLuminanceHistogram.get(luminance) + 1);
+            }
+        }
+    }
+
+    private void extractFgBgData() {
+        if (mLuminanceMap.isEmpty()) {
+            // An empty luminance map indicates we've encountered a 0px area
+            // image. It has no luminance.
+            mBackgroundLuminance = mForegroundLuminance = 0;
+            mBackgroundColors.add(Color.BLACK);
+            mForegroundColors.add(Color.BLACK);
+        } else if (mLuminanceMap.size() == 1) {
+            // Deal with views that only contain a single color
+            mBackgroundLuminance = mForegroundLuminance = mLuminanceHistogram.keySet().iterator()
+                    .next();
+            final int singleColor = mLuminanceMap.keySet().iterator().next();
+            mForegroundColors.add(singleColor);
+            mBackgroundColors.add(singleColor);
+        } else {
+            // Sort all luminance values seen from low to high
+            final ArrayList<Entry<Integer, Double>> colorsByLuminance = new ArrayList<
+                    Entry<Integer, Double>>(mLuminanceMap.size());
+            colorsByLuminance.addAll(mLuminanceMap.entrySet());
+            Collections.sort(colorsByLuminance, new Comparator<Entry<Integer, Double>>() {
+                @Override
+                public int compare(Entry<Integer, Double> lhs, Entry<Integer, Double> rhs) {
+                    return Double.compare(lhs.getValue(), rhs.getValue());
+                }
+            });
+
+            // Sort luminance values seen by frequency in the image
+            final ArrayList<Entry<Double, Integer>> luminanceByFrequency = new ArrayList<
+                    Entry<Double, Integer>>(mLuminanceHistogram.size());
+            luminanceByFrequency.addAll(mLuminanceHistogram.entrySet());
+            Collections.sort(luminanceByFrequency, new Comparator<Entry<Double, Integer>>() {
+                @Override
+                public int compare(Entry<Double, Integer> lhs, Entry<Double, Integer> rhs) {
+                    return Integer.compare(lhs.getValue(), rhs.getValue());
+                }
+            });
+
+            // Find the average luminance value within the set of luminances for
+            // purposes of splitting luminance values into high-luminance and
+            // low-luminance buckets. This is explicitly not a weighted average.
+            double luminanceSum = 0;
+            for (Entry<Double, Integer> luminanceCount : luminanceByFrequency) {
+                luminanceSum += luminanceCount.getKey();
+            }
+
+            final double averageLuminance = luminanceSum / luminanceByFrequency.size();
+
+            // Select the highest and lowest luminance values that contribute to
+            // most number of pixels in the image -- our background and
+            // foreground colors.
+            double lowLuminanceContributor = 0.0d;
+            for (int i = luminanceByFrequency.size() - 1; i >= 0; --i) {
+                final double luminanceValue = luminanceByFrequency.get(i).getKey();
+                if (luminanceValue < averageLuminance) {
+                    lowLuminanceContributor = luminanceValue;
+                    break;
+                }
+            }
+
+            double highLuminanceContributor = 1.0d;
+            for (int i = luminanceByFrequency.size() - 1; i >= 0; --i) {
+                final double luminanceValue = luminanceByFrequency.get(i).getKey();
+                if (luminanceValue >= averageLuminance) {
+                    highLuminanceContributor = luminanceValue;
+                    break;
+                }
+            }
+
+            // Background luminance is that which occurs more frequently
+            if (mLuminanceHistogram.get(highLuminanceContributor)
+                    > mLuminanceHistogram.get(lowLuminanceContributor)) {
+                mBackgroundLuminance = highLuminanceContributor;
+                mForegroundLuminance = lowLuminanceContributor;
+            } else {
+                mBackgroundLuminance = lowLuminanceContributor;
+                mForegroundLuminance = highLuminanceContributor;
+            }
+
+            // Determine the contributing colors for those luminance values
+            // TODO(caseyburkhardt): I know, this is gross to iterate through
+            // the whole image again...
+            for (Entry<Integer, Double> colorLuminance : mLuminanceMap.entrySet()) {
+                if (colorLuminance.getValue() == mBackgroundLuminance) {
+                    mBackgroundColors.add(colorLuminance.getKey());
+                }
+
+                if (colorLuminance.getValue() == mForegroundLuminance) {
+                    mForegroundColors.add(colorLuminance.getKey());
+                }
+            }
+        }
+    }
+
+    public Bitmap getImage() {
+        return Bitmap.createBitmap(mImage);
+    }
+
+    public void setImage(Bitmap image) {
+        mImage = image;
+    }
+
+    public CharSequence getName() {
+        return mName;
+    }
+
+    public List<Integer> getBackgroundColors() {
+        return Collections.unmodifiableList(mBackgroundColors);
+    }
+
+    public List<Integer> getForegroundColors() {
+        return Collections.unmodifiableList(mForegroundColors);
+    }
+
+    public double getBackgroundLuminance() {
+        return mBackgroundLuminance;
+    }
+
+    public double getForegroundLuminance() {
+        return mForegroundLuminance;
+    }
+
+    public Rect getBounds() {
+        return new Rect(mScreenBounds);
+    }
+
+    public double getContrastRatio() {
+        return mContrastRatio;
+    }
+
+    @Override
+    public int describeContents() {
+        return 0;
+    }
+
+    @Override
+    public void writeToParcel(Parcel dest, int flags) {
+        dest.writeString(mName);
+        dest.writeMap(mLuminanceMap);
+        dest.writeMap(mLuminanceHistogram);
+        dest.writeList(mBackgroundColors);
+        dest.writeList(mForegroundColors);
+        dest.writeDouble(mBackgroundLuminance);
+        dest.writeDouble(mForegroundLuminance);
+        dest.writeValue(mScreenBounds);
+        dest.writeDouble(mContrastRatio);
+    }
+
+    @Override
+    public String toString() {
+        return "{name:" + mName + ", contrast:1:" + mContrastRatio + ", background:"
+                + ContrastUtils.colorsToHexString(mBackgroundColors) + ", foreground:"
+                + ContrastUtils.colorsToHexString(mForegroundColors) + "}";
+    }
+
+    public static final Parcelable.Creator<ContrastSwatch>
+            CREATOR = new Parcelable.Creator<ContrastSwatch>() {
+
+        @Override
+        public ContrastSwatch createFromParcel(Parcel source) {
+            return new ContrastSwatch(source);
+        }
+
+        @Override
+        public ContrastSwatch[] newArray(int size) {
+            return new ContrastSwatch[size];
+        }
+    };
+}
diff --git a/src/main/java/com/googlecode/eyesfree/utils/ScreenshotUtils.java b/src/main/java/com/googlecode/eyesfree/utils/ScreenshotUtils.java
new file mode 100644
index 0000000..eee9387
--- /dev/null
+++ b/src/main/java/com/googlecode/eyesfree/utils/ScreenshotUtils.java
@@ -0,0 +1,205 @@
+/*
+ * Copyright (C) 2014 Google Inc.
+ *
+ * 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.
+ */
+
+package com.googlecode.eyesfree.utils;
+
+import android.Manifest;
+import android.content.Context;
+import android.content.pm.PackageManager;
+import android.graphics.Bitmap;
+import android.graphics.Bitmap.CompressFormat;
+import android.graphics.Canvas;
+import android.graphics.Rect;
+import android.util.Log;
+import android.view.Surface;
+import android.view.WindowManager;
+
+import com.googlecode.eyesfree.compat.view.SurfaceControlCompatUtils;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+
+/**
+ * A utility class for taking screenshots.
+ *
+ * @author [email protected] (Casey Burkhardt)
+ */
+public class ScreenshotUtils {
+
+    private ScreenshotUtils() {
+        // This class is non-instantiable.
+    }
+
+    public static boolean hasScreenshotPermission(Context context) {
+        return (context.checkCallingOrSelfPermission(Manifest.permission.READ_FRAME_BUFFER) ==
+                PackageManager.PERMISSION_GRANTED);
+    }
+
+    /**
+     * Returns a screenshot with the contents of the current display that
+     * matches the current display rotation.
+     *
+     * @param context The current context.
+     * @return A bitmap of the screenshot.
+     */
+    public static Bitmap createScreenshot(Context context) {
+        if (!hasScreenshotPermission(context)) {
+            LogUtils.log(ScreenshotUtils.class, Log.ERROR, "Screenshot permission denied.");
+            return null;
+        }
+
+        final WindowManager windowManager =
+                (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);
+
+        final Bitmap bitmap = SurfaceControlCompatUtils.screenshot(0, 0);
+
+        // Bail if we couldn't take the screenshot.
+        if (bitmap == null) {
+            LogUtils.log(ScreenshotUtils.class, Log.ERROR, "Failed to take screenshot.");
+            return null;
+        }
+
+        final int width = bitmap.getWidth();
+        final int height = bitmap.getHeight();
+        final int rotation = windowManager.getDefaultDisplay().getRotation();
+
+        final int outWidth;
+        final int outHeight;
+        final float rotationDegrees;
+
+        switch (rotation) {
+            case Surface.ROTATION_90:
+                outWidth = height;
+                outHeight = width;
+                rotationDegrees = 90;
+                break;
+            case Surface.ROTATION_180:
+                outWidth = width;
+                outHeight = height;
+                rotationDegrees = 180;
+                break;
+            case Surface.ROTATION_270:
+                outWidth = height;
+                outHeight = width;
+                rotationDegrees = 270;
+                break;
+            default:
+                return bitmap;
+        }
+
+        // Rotate the screenshot to match the screen orientation.
+        final Bitmap rotatedBitmap =
+                Bitmap.createBitmap(outWidth, outHeight, Bitmap.Config.RGB_565);
+        final Canvas c = new Canvas(rotatedBitmap);
+
+        c.translate(outWidth / 2.0f, outHeight / 2.0f);
+        c.rotate(-rotationDegrees);
+        c.translate(-width / 2.0f, -height / 2.0f);
+        c.drawBitmap(bitmap, 0, 0, null);
+
+        bitmap.recycle();
+
+        return rotatedBitmap;
+    }
+
+    /**
+     * Creates a new {@link Bitmap} object with a rectangular region of pixels
+     * from the source bitmap.
+     * <p>
+     * The source bitmap is unaffected by this operation.
+     *
+     * @param sourceBitmap The source bitmap to crop.
+     * @param bounds The rectangular bounds to keep when cropping.
+     * @return A new bitmap of the cropped area, or {@code null} if the source
+     *         was {@code null} or the crop parameters were out of bounds.
+     */
+    public static Bitmap cropBitmap(Bitmap sourceBitmap, Rect bounds) {
+        if (sourceBitmap == null) {
+            return null;
+        }
+
+        try {
+            return Bitmap.createBitmap(
+                    sourceBitmap, bounds.left, bounds.top, bounds.width(), bounds.height());
+        } catch (IllegalArgumentException ex) {
+            // Can throw exception if cropping arguments are out of bounds.
+            LogUtils.log(ScreenshotUtils.class, Log.ERROR, Log.getStackTraceString(ex));
+            return null;
+        }
+    }
+
+    /**
+     * Writes a {@link Bitmap} object to a file in the current context's files
+     * directory.
+     *
+     * @param context The current context.
+     * @param bitmap The bitmap object to output.
+     * @param dir The output directory name within the files directory.
+     * @param filename The name of the file to output.
+     * @return A file where the Bitmap was stored, or {@code null} if the write
+     *         operation failed.
+     */
+    public static File writeBitmap(Context context, Bitmap bitmap, String dir, String filename) {
+        if (bitmap == null) {
+            return null;
+        }
+
+        final File dirFile = new File(context.getFilesDir(), dir);
+        if (!dirFile.exists() && !dirFile.mkdirs()) {
+            LogUtils.log(ScreenshotUtils.class, Log.WARN,
+                    "Directory %s does not exist and could not be created.",
+                    dirFile.getAbsolutePath());
+            return null;
+        }
+
+        final File outFile = new File(dirFile, filename);
+        if (outFile.exists()) {
+            LogUtils.log(ScreenshotUtils.class, Log.WARN,
+                    "Tried to write a bitmap to a file that already exists.");
+            return null;
+        }
+
+        FileOutputStream outStream = null;
+        try {
+            outStream = new FileOutputStream(outFile);
+            final boolean compressSuccess = bitmap.compress(
+                    CompressFormat.PNG, 0 /* quality, ignored for PNG */, outStream);
+
+            if (compressSuccess) {
+                LogUtils.log(ScreenshotUtils.class, Log.VERBOSE, "Wrote bitmap to %s.",
+                        outFile.getAbsolutePath());
+                return outFile;
+            } else {
+                LogUtils.log(ScreenshotUtils.class, Log.WARN,
+                        "Bitmap failed to compress to file %s.", outFile.getAbsolutePath());
+                return null;
+            }
+        } catch (IOException e) {
+            LogUtils.log(ScreenshotUtils.class, Log.WARN,
+                    "Could not output bitmap file to %s.", outFile.getAbsolutePath());
+            return null;
+        } finally {
+            if (outStream != null) {
+                try {
+                    outStream.close();
+                } catch (IOException e) {
+                    LogUtils.log(ScreenshotUtils.class, Log.WARN, "Unable to close resource.");
+                }
+            }
+        }
+    }
+}
\ No newline at end of file