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