Adding files omitted from previous commit and needed for 2.0.
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 c21ad2d..8b1dcd3 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;
+
/**
* The result of an accessibility check. The results are "interesting" in the sense that they
* indicate some sort of accessibility issue. {@code AccessibilityCheck}s return lists of classes
@@ -107,4 +109,22 @@
type = null;
message = null;
}
+
+ /**
+ * 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();
+ }
}
diff --git a/src/main/java/com/google/android/apps/common/testing/accessibility/framework/AccessibilityEventCheck.java b/src/main/java/com/google/android/apps/common/testing/accessibility/framework/AccessibilityEventCheck.java
new file mode 100644
index 0000000..0ca8bbe
--- /dev/null
+++ b/src/main/java/com/google/android/apps/common/testing/accessibility/framework/AccessibilityEventCheck.java
@@ -0,0 +1,119 @@
+/*
+ * 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 android.view.accessibility.AccessibilityEvent;
+
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * An {@code AccessibilityEventCheck} is used as a mechanism for detecting accessibility issues
+ * based on the {@link AccessibilityEvent}s dispatched by an application or UI component.
+ * <p>
+ * Extending classes should use {@link #shouldHandleEvent(AccessibilityEvent)} as a convenience
+ * mechanism for filtering events which they are interested in evaluating. If {@code true} is
+ * returned, the event will be passed to {@link #runCheckOnEvent(AccessibilityEvent)} for
+ * evaluation. Much like other {@link AccessibilityCheck}s, results are returned through a single
+ * object, in this case {@link AccessibilityEventCheckResult}, which may include the culprit
+ * {@link AccessibilityEvent}.
+ * <p>
+ * {@code AccessibilityEventCheck}s are different from other {@link AccessibilityCheck}s in that
+ * they may maintain state internal to the class. This is because {@code AccessibilityEventCheck}s
+ * operate on a stream of {@link AccessibilityEvent}s, and ordering, timing, or comparison of
+ * multiple events may be required to properly evaluate an accessibility issue. The class defines
+ * several callback methods to simplify managing this state. The component responsible for executing
+ * this check must invoke {@link #onExecutionStarted()} when a new logical "test run" begins. It
+ * must also invoke {@link #onExecutionEnded()} as it ends. Extending classes may wish to clean up
+ * any state and return any final results from this method.
+ * <p>
+ * NOTE: Although extending classes can access all AccessibilityEvents fired by the system during
+ * the test run interval, no guarantees about stability of an underlying UI are made. Information
+ * about the current view hierarchy state may be accessed via
+ * {@link AccessibilityEvent#getSource()}, but implementations must take care when determining when
+ * and how to maintain state related to this information across invocations of
+ * {@link #runCheckOnEvent(AccessibilityEvent)}. A general recommendation is to only store
+ * information related to the {@link AccessibilityEvent} stream as part of the state maintained by
+ * an extension of this class. To write an {@link AccessibilityCheck} that verifies some properties
+ * of a view hierarchy, use an {@link AccessibilityViewCheck} or {@link AccessibilityInfoCheck}.
+ */
+public abstract class AccessibilityEventCheck extends AccessibilityCheck {
+
+ /**
+ * Convenience method for easily filtering the {@link AccessibilityEvent}s to be dispatched to
+ * {@link #runCheckOnEvent(AccessibilityEvent)} for evaluation.
+ * <p>
+ * NOTE: The default implementation accepts all incoming events.
+ *
+ * @param event The event to filter
+ * @return {@code true} if this {@code AccessibilityEventCheck} should handle this event,
+ * {@code false} otherwise.
+ */
+ protected boolean shouldHandleEvent(AccessibilityEvent event) {
+ return true;
+ }
+
+ /**
+ * Invoked when a new logical test run is beginning. Implementing checks should use this method to
+ * initialize resources or state needed for evaluation, if needed.
+ * {@link #shouldHandleEvent(AccessibilityEvent)} and {@link #runCheckOnEvent(AccessibilityEvent)}
+ * are guaranteed to not be invoked until execution of this method terminates.
+ */
+ public void onExecutionStarted() {}
+
+ /**
+ * Invoked by the component responsible for executing this {@code AccessibilityCheck} to dispatch
+ * an {@link AccessibilityEvent} to this check's logic.
+ *
+ * @param event The event to dispatch
+ * @return A {@link List} of {@link AccessibilityEventCheckResult}s generated by the check. If no
+ * such results are generated, {@code null} or an empty collection may be returned.
+ */
+ public final List<AccessibilityEventCheckResult> dispatchEvent(AccessibilityEvent event) {
+ if (shouldHandleEvent(event)) {
+ return runCheckOnEvent(event);
+ }
+ return null;
+ }
+
+ /**
+ * Mechanism by which {@link AccessibilityEvent}s are delivered for evaluation. Extending classes
+ * should override this method and return {@link AccessibilityEventCheckResult}s to indicate
+ * results, if appropriate.
+ *
+ * @param event The event to evaluate.
+ * @return A List of {@link AccessibilityEventCheckResult}s indicating an accessibility issue, if
+ * any. If no issues are found, or if an issue cannot be identified from the stream of
+ * {@link AccessibilityEvent}s observed, {@code null} or an empty collection may be
+ * returned.
+ */
+ protected abstract List<AccessibilityEventCheckResult> runCheckOnEvent(AccessibilityEvent event);
+
+ /**
+ * Invoked when a logical test run has concluded. Implementing checks should use this to clear any
+ * state relevant to the previous evaluation, if needed. It is guaranteed that
+ * {@link #shouldHandleEvent(AccessibilityEvent)} or {@link #runCheckOnEvent(AccessibilityEvent)}
+ * will not be invoked after execution of this method has begun until a new logical test run is
+ * signaled by {@link #onExecutionStarted()};
+ *
+ * @return A List of {@link AccessibilityEventCheckResult}s indicating accessibility issues, if
+ * any. If no issues are found, or if an issue cannot be identified from the stream of
+ * {@link AccessibilityEvent}s observed, {@code null} or an empty collection may be
+ * returned.
+ */
+ public List<AccessibilityEventCheckResult> onExecutionEnded() {
+ return Collections.emptyList();
+ }
+}
diff --git a/src/main/java/com/google/android/apps/common/testing/accessibility/framework/AccessibilityEventCheckResult.java b/src/main/java/com/google/android/apps/common/testing/accessibility/framework/AccessibilityEventCheckResult.java
new file mode 100644
index 0000000..6ba00bd
--- /dev/null
+++ b/src/main/java/com/google/android/apps/common/testing/accessibility/framework/AccessibilityEventCheckResult.java
@@ -0,0 +1,131 @@
+/*
+ * 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 android.annotation.TargetApi;
+import android.os.Build;
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.text.TextUtils;
+import android.util.Log;
+import android.view.accessibility.AccessibilityEvent;
+
+import com.googlecode.eyesfree.utils.LogUtils;
+
+/**
+ * Result generated when an accessibility check runs on a {@link AccessibilityEvent}.
+ */
+@TargetApi(Build.VERSION_CODES.DONUT)
+public final class AccessibilityEventCheckResult extends AccessibilityCheckResult implements
+ Parcelable {
+
+ private AccessibilityEvent event;
+
+ /**
+ * @param checkClass The check that generated the error
+ * @param type The type of the result
+ * @param message A human-readable message explaining the error
+ * @param event The {@link AccessibilityEvent} reported as the cause of the result
+ */
+ public AccessibilityEventCheckResult(Class<? extends AccessibilityCheck> checkClass,
+ AccessibilityCheckResultType type, CharSequence message, AccessibilityEvent event) {
+ super(checkClass, type, message);
+ if (event != null) {
+ this.event = AccessibilityEvent.obtain(event);
+ }
+ }
+
+ private AccessibilityEventCheckResult(Parcel in) {
+ super(null, null, null);
+ readFromParcel(in);
+ }
+
+ /**
+ * @return The {@link AccessibilityEvent} to which the result applies
+ */
+ public AccessibilityEvent getEvent() {
+ return event;
+ }
+
+ @Override
+ public void recycle() {
+ super.recycle();
+ if (event != null) {
+ event.recycle();
+ event = null;
+ }
+ }
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(Parcel dest, int flags) {
+ dest.writeString((checkClass != null) ? checkClass.getName() : "");
+ dest.writeInt((type != null) ? type.ordinal() : -1);
+ TextUtils.writeToParcel(message, dest, flags);
+
+ // Event requires a presence flag
+ if (event != null) {
+ dest.writeInt(1);
+ event.writeToParcel(dest, flags);
+ } else {
+ dest.writeInt(0);
+ }
+ }
+
+ @SuppressWarnings("unchecked")
+ private void readFromParcel(Parcel in) {
+ // Check class (unchecked cast checked by isAssignableFrom)
+ checkClass = null;
+ String checkClassName = in.readString();
+ if (!("".equals(checkClassName))) {
+ try {
+ Class<?> uncheckedClass = Class.forName(checkClassName);
+ if (AccessibilityCheck.class.isAssignableFrom(uncheckedClass)) {
+ checkClass = (Class<? extends AccessibilityCheck>) uncheckedClass;
+ }
+ } catch (ClassNotFoundException e) {
+ // If the reference can't be resolved by our class loader, remain null.
+ LogUtils.log(this, Log.WARN, "Attempt to obtain unknown class %1$s", checkClassName);
+ }
+ }
+
+ // Type
+ final int type = in.readInt();
+ this.type = (type != -1) ? AccessibilityCheckResultType.values()[type] : null;
+
+ // Message
+ this.message = TextUtils.CHAR_SEQUENCE_CREATOR.createFromParcel(in);
+
+ // Event
+ this.event = (in.readInt() == 1) ? AccessibilityEvent.CREATOR.createFromParcel(in) : null;
+ }
+
+ public static final Parcelable.Creator<AccessibilityEventCheckResult> CREATOR =
+ new Parcelable.Creator<AccessibilityEventCheckResult>() {
+ @Override
+ public AccessibilityEventCheckResult createFromParcel(Parcel in) {
+ return new AccessibilityEventCheckResult(in);
+ }
+
+ @Override
+ public AccessibilityEventCheckResult[] newArray(int size) {
+ return new AccessibilityEventCheckResult[size];
+ }
+ };
+}
diff --git a/src/main/java/com/google/android/apps/common/testing/accessibility/framework/AccessibilityViewCheckException.java b/src/main/java/com/google/android/apps/common/testing/accessibility/framework/AccessibilityViewCheckException.java
new file mode 100644
index 0000000..86b6be8
--- /dev/null
+++ b/src/main/java/com/google/android/apps/common/testing/accessibility/framework/AccessibilityViewCheckException.java
@@ -0,0 +1,91 @@
+/*
+ * 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 android.view.View;
+
+import java.util.List;
+import java.util.Locale;
+
+/**
+ * An exception class to be used for throwing exceptions with accessibility results. This class can
+ * be extended to provide descriptions of {@link AccessibilityViewCheckResult}s that the developer
+ * considers readable. To extend this class, override the constructor and call super, and override
+ * {@link #getResultMessage} to provide a readable String description of a result.
+ */
+public class AccessibilityViewCheckException extends RuntimeException {
+ private List<AccessibilityViewCheckResult> results;
+
+ /**
+ * Any extension of this class must call this constructor.
+ *
+ * @param results a list of {@link AccessibilityViewCheckResult}s that are associated with the
+ * failure(s) that cause this to be thrown.
+ */
+ protected AccessibilityViewCheckException(List<AccessibilityViewCheckResult> results) {
+ super();
+ if ((results == null) || (results.size() == 0)) {
+ throw new RuntimeException(
+ "AccessibilityViewCheckException requires at least 1 AccessibilityViewCheckResult");
+ }
+ this.results = results;
+ }
+
+ @Override
+ public String getMessage() {
+ // Lump all error result messages into one string to be the exception message
+ StringBuilder exceptionMessage = new StringBuilder();
+ String errorCountMessage = (results.size() == 1)
+ ? "There was 1 accessibility error:\n"
+ : String.format(Locale.US, "There were %d accessibility errors:\n", results.size());
+ exceptionMessage.append(errorCountMessage);
+ for (int i = 0; i < results.size(); i++) {
+ if (i > 0) {
+ exceptionMessage.append(",\n");
+ }
+ AccessibilityViewCheckResult result = results.get(i);
+ exceptionMessage.append(getResultMessage(result));
+ }
+ return exceptionMessage.toString();
+ }
+
+ /**
+ * @return the list of results associated with this instance
+ */
+ public List<AccessibilityViewCheckResult> getResults() {
+ return results;
+ }
+
+ /**
+ * Returns a String description of the given {@link AccessibilityViewCheckResult}. The default
+ * is to return the view's resource entry name followed by the result's message.
+ *
+ * @param result the {@link AccessibilityViewCheckResult} to describe
+ * @return a String description of the result
+ */
+ protected String getResultMessage(AccessibilityViewCheckResult result) {
+ StringBuilder msg = new StringBuilder();
+ View view = result.getView();
+ if ((view != null) && (view.getId() != View.NO_ID) && (view.getResources() != null)) {
+ msg.append("View ");
+ msg.append(view.getResources().getResourceEntryName(view.getId()));
+ msg.append(": ");
+ } else {
+ msg.append("View with no valid resource name: ");
+ }
+ msg.append(result.getMessage());
+ return msg.toString();
+ }
+}
diff --git a/src/main/java/com/google/android/apps/common/testing/accessibility/framework/AnnouncementEventCheck.java b/src/main/java/com/google/android/apps/common/testing/accessibility/framework/AnnouncementEventCheck.java
new file mode 100644
index 0000000..102c892
--- /dev/null
+++ b/src/main/java/com/google/android/apps/common/testing/accessibility/framework/AnnouncementEventCheck.java
@@ -0,0 +1,51 @@
+/*
+ * 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.annotation.TargetApi;
+import android.os.Build;
+import android.view.View;
+import android.view.accessibility.AccessibilityEvent;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Check which may be used to flag use of {@link View#announceForAccessibility(CharSequence)} or
+ * dispatch of {@link AccessibilityEvent}s of type {@link AccessibilityEvent#TYPE_ANNOUNCEMENT}. The
+ * use of these events, expect in specific situations, can be disruptive to the user.
+ */
+@TargetApi(Build.VERSION_CODES.JELLY_BEAN)
+public class AnnouncementEventCheck extends AccessibilityEventCheck {
+
+ @Override
+ public boolean shouldHandleEvent(AccessibilityEvent event) {
+ return (event != null) && (event.getEventType() == AccessibilityEvent.TYPE_ANNOUNCEMENT);
+ }
+
+ @Override
+ public List<AccessibilityEventCheckResult> runCheckOnEvent(AccessibilityEvent event) {
+ // TODO(caseyburkhardt): Promote multiple qualifying events within a time threshold to
+ // AccessibilityCheckResultType.ERROR
+ // TODO(caseyburkhardt): Develop some heuristic for identifying approved use cases
+ List<AccessibilityEventCheckResult> results = new ArrayList<AccessibilityEventCheckResult>(1);
+ results.add(new AccessibilityEventCheckResult(this.getClass(),
+ AccessibilityCheckResultType.WARNING,
+ "A disruptive accessibility announcement has been used,", event));
+ return results;
+ }
+}
diff --git a/src/main/java/com/google/android/apps/common/testing/accessibility/framework/ClickableSpanInfoCheck.java b/src/main/java/com/google/android/apps/common/testing/accessibility/framework/ClickableSpanInfoCheck.java
new file mode 100644
index 0000000..cc488b3
--- /dev/null
+++ b/src/main/java/com/google/android/apps/common/testing/accessibility/framework/ClickableSpanInfoCheck.java
@@ -0,0 +1,85 @@
+/*
+ * 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.google.android.apps.common.testing.accessibility.framework;
+
+import com.google.android.apps.common.testing.accessibility.framework.AccessibilityCheckResult.AccessibilityCheckResultType;
+
+import android.content.Context;
+import android.net.Uri;
+import android.support.v4.view.accessibility.AccessibilityNodeInfoCompat;
+import android.text.Spanned;
+import android.text.style.ClickableSpan;
+import android.text.style.URLSpan;
+import android.view.accessibility.AccessibilityNodeInfo;
+import android.widget.TextView;
+
+import com.googlecode.eyesfree.utils.AccessibilityNodeInfoUtils;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 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
+ * {@link ClickableSpan#onClick}.
+ *
+ * <p>The exception to this rule is that {@code URLSpan}s are accessible if they do not contain a
+ * relative URI.
+ */
+public class ClickableSpanInfoCheck extends AccessibilityInfoCheck {
+
+ @Override
+ public List<AccessibilityInfoCheckResult> runCheckOnInfo(AccessibilityNodeInfo info,
+ Context context) {
+ List<AccessibilityInfoCheckResult> results = new ArrayList<AccessibilityInfoCheckResult>(1);
+ AccessibilityNodeInfoCompat compatInfo = new AccessibilityNodeInfoCompat(info);
+ if (AccessibilityNodeInfoUtils.nodeMatchesAnyClassByType(context, compatInfo, TextView.class)) {
+ if (info.getText() instanceof Spanned) {
+ Spanned text = (Spanned) info.getText();
+ ClickableSpan[] clickableSpans = text.getSpans(0, text.length(), ClickableSpan.class);
+ for (ClickableSpan clickableSpan : clickableSpans) {
+ if (clickableSpan instanceof URLSpan) {
+ String url = ((URLSpan) clickableSpan).getURL();
+ if (url == null) {
+ results.add(new AccessibilityInfoCheckResult(this.getClass(),
+ AccessibilityCheckResultType.ERROR, "URLSpan has null URL", info));
+ } else {
+ Uri uri = Uri.parse(url);
+ if (uri.isRelative()) {
+ // Relative URIs cannot be resolved.
+ results.add(new AccessibilityInfoCheckResult(this.getClass(),
+ AccessibilityCheckResultType.ERROR, "URLSpan should not contain relative links",
+ info));
+ }
+ }
+ } else { // Non-URLSpan ClickableSpan
+ results.add(new AccessibilityInfoCheckResult(this.getClass(),
+ AccessibilityCheckResultType.ERROR,
+ "URLSpan should be used in place of ClickableSpan for improved accessibility",
+ info));
+ }
+ }
+ }
+ } else {
+ results.add(new AccessibilityInfoCheckResult(this.getClass(),
+ AccessibilityCheckResultType.NOT_RUN, "View must be a TextView", info));
+ }
+ return results;
+ }
+}
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
new file mode 100644
index 0000000..047c8c5
--- /dev/null
+++ b/src/main/java/com/google/android/apps/common/testing/accessibility/framework/ClickableSpanViewCheck.java
@@ -0,0 +1,80 @@
+/*
+ * 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.google.android.apps.common.testing.accessibility.framework;
+
+import com.google.android.apps.common.testing.accessibility.framework.AccessibilityCheckResult.AccessibilityCheckResultType;
+
+import android.net.Uri;
+import android.text.Spanned;
+import android.text.style.ClickableSpan;
+import android.text.style.URLSpan;
+import android.view.View;
+import android.widget.TextView;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 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}.
+ *
+ * <p>The exception to this rule is that {@code URLSpan}s are accessible if they do not contain a
+ * relative URI.
+ */
+public class ClickableSpanViewCheck extends AccessibilityViewCheck {
+
+ @Override
+ public List<AccessibilityViewCheckResult> runCheckOnView(View view) {
+ List<AccessibilityViewCheckResult> results = new ArrayList<AccessibilityViewCheckResult>(1);
+ if (view instanceof TextView) {
+ TextView textView = (TextView) view;
+ if (textView.getText() instanceof Spanned) {
+ Spanned text = (Spanned) textView.getText();
+ ClickableSpan[] clickableSpans = text.getSpans(0, text.length(), ClickableSpan.class);
+ for (ClickableSpan clickableSpan : clickableSpans) {
+ 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));
+ } 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));
+ }
+ }
+ } else { // Non-URLSpan ClickableSpan
+ 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));
+ }
+ return results;
+ }
+}
diff --git a/src/main/java/com/google/android/apps/common/testing/accessibility/framework/DuplicateClickableBoundsInfoCheck.java b/src/main/java/com/google/android/apps/common/testing/accessibility/framework/DuplicateClickableBoundsInfoCheck.java
new file mode 100644
index 0000000..e83f06d
--- /dev/null
+++ b/src/main/java/com/google/android/apps/common/testing/accessibility/framework/DuplicateClickableBoundsInfoCheck.java
@@ -0,0 +1,75 @@
+/*
+ * 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.Rect;
+import android.view.accessibility.AccessibilityNodeInfo;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Developers sometimes have containers marked clickable when they don't process click events.
+ * This error is difficult to detect, but when a container shares its bounds with a child view,
+ * that is a clear error. This class catches that case.
+ */
+public class DuplicateClickableBoundsInfoCheck extends AccessibilityInfoHierarchyCheck {
+
+ @Override
+ public List<AccessibilityInfoCheckResult> runCheckOnInfoHierarchy(AccessibilityNodeInfo root,
+ Context context) {
+ List<AccessibilityInfoCheckResult> results = new ArrayList<>(1);
+ Map<Rect, AccessibilityNodeInfo> clickableRectToInfoMap = new HashMap<>();
+
+ checkForDuplicateClickableViews(root, clickableRectToInfoMap, results);
+ for (AccessibilityNodeInfo info : clickableRectToInfoMap.values()) {
+ info.recycle();
+ }
+ return results;
+ }
+
+ private void checkForDuplicateClickableViews(AccessibilityNodeInfo root,
+ Map<Rect, AccessibilityNodeInfo> clickableRectToInfoMap,
+ List<AccessibilityInfoCheckResult> results) {
+ /*
+ * TODO(pweaver) It may be possible for this check to false-negative if one view is marked
+ * clickable and the other is only long clickable and/or has custom actions. Determine if this
+ * limitation applies to real UIs.
+ */
+ if (root.isClickable() && root.isVisibleToUser()) {
+ Rect bounds = new Rect();
+ root.getBoundsInScreen(bounds);
+ if (clickableRectToInfoMap.containsKey(bounds)) {
+ results.add(new AccessibilityInfoCheckResult(this.getClass(),
+ AccessibilityCheckResultType.ERROR,
+ "Clickable view has same bounds as another clickable view (likely a descendent)",
+ clickableRectToInfoMap.get(bounds)));
+ } else {
+ clickableRectToInfoMap.put(bounds, AccessibilityNodeInfo.obtain(root));
+ }
+ }
+
+ for (int i = 0; i < root.getChildCount(); ++i) {
+ AccessibilityNodeInfo child = root.getChild(i);
+ checkForDuplicateClickableViews(child, clickableRectToInfoMap, results);
+ child.recycle();
+ }
+ }
+}
diff --git a/src/main/java/com/google/android/apps/common/testing/accessibility/framework/DuplicateClickableBoundsViewCheck.java b/src/main/java/com/google/android/apps/common/testing/accessibility/framework/DuplicateClickableBoundsViewCheck.java
new file mode 100644
index 0000000..97bcd17
--- /dev/null
+++ b/src/main/java/com/google/android/apps/common/testing/accessibility/framework/DuplicateClickableBoundsViewCheck.java
@@ -0,0 +1,79 @@
+/*
+ * 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.graphics.Rect;
+import android.os.Build;
+import android.view.View;
+import android.view.ViewGroup;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Developers sometimes have containers marked clickable when they don't process click events.
+ * This error is difficult to detect, but when a container shares its bounds with a child view,
+ * that is a clear error. This class catches that case.
+ */
+public class DuplicateClickableBoundsViewCheck extends AccessibilityViewHierarchyCheck {
+
+ @Override
+ public List<AccessibilityViewCheckResult> runCheckOnViewHierarchy(View root) {
+ List<AccessibilityViewCheckResult> results = new ArrayList<>(1);
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN) {
+ results.add(new AccessibilityViewCheckResult(this.getClass(),
+ AccessibilityCheckResultType.NOT_RUN,
+ "This check only runs on Android 2.3.3 and above.",
+ root));
+ return results;
+ }
+ Map<Rect, View> clickableRectToViewMap = new HashMap<>();
+
+ checkForDuplicateClickableViews(root, clickableRectToViewMap, results);
+ return results;
+ }
+
+ private void checkForDuplicateClickableViews(View root, Map<Rect, View> clickableRectToViewMap,
+ List<AccessibilityViewCheckResult> results) {
+ if (!ViewAccessibilityUtils.isVisibleToUser(root)) {
+ return;
+ }
+ if (root.isClickable() && ViewAccessibilityUtils.isImportantForAccessibility(root)) {
+ Rect bounds = new Rect();
+ if (root.getGlobalVisibleRect(bounds)) {
+ if (clickableRectToViewMap.containsKey(bounds)) {
+ results.add(new AccessibilityViewCheckResult(this.getClass(),
+ AccessibilityCheckResultType.ERROR,
+ "Clickable view has same bounds as another clickable view (likely a descendent)",
+ clickableRectToViewMap.get(bounds)));
+ } else {
+ clickableRectToViewMap.put(bounds, root);
+ }
+ }
+ }
+ if (!(root instanceof ViewGroup)) {
+ return;
+ }
+ ViewGroup viewGroup = (ViewGroup) root;
+ for (int i = 0; i < viewGroup.getChildCount(); ++i) {
+ View child = viewGroup.getChildAt(i);
+ checkForDuplicateClickableViews(child, clickableRectToViewMap, results);
+ }
+ }
+}
diff --git a/src/main/java/com/google/android/apps/common/testing/accessibility/framework/RedundantContentDescInfoCheck.java b/src/main/java/com/google/android/apps/common/testing/accessibility/framework/RedundantContentDescInfoCheck.java
new file mode 100644
index 0000000..eaec370
--- /dev/null
+++ b/src/main/java/com/google/android/apps/common/testing/accessibility/framework/RedundantContentDescInfoCheck.java
@@ -0,0 +1,72 @@
+/*
+ * 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.support.v4.view.accessibility.AccessibilityNodeInfoCompat;
+import android.text.TextUtils;
+import android.view.accessibility.AccessibilityNodeInfo;
+import android.widget.Button;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+
+/**
+ * Checks to ensure that speakable text does not contain redundant information about the view's
+ * type. Accessibility services are aware of the view's type and can use that information as needed
+ * (ex: Screen readers may append "button" to the speakable text of a {@link Button}).
+ */
+public class RedundantContentDescInfoCheck extends AccessibilityInfoHierarchyCheck {
+ private static List<CharSequence> redundantWords = new ArrayList<>();
+ static {
+ redundantWords.add("button");
+ }
+
+ @Override
+ public List<AccessibilityInfoCheckResult> runCheckOnInfoHierarchy(AccessibilityNodeInfo root,
+ Context context) {
+ List<AccessibilityInfoCheckResult> results = new ArrayList<AccessibilityInfoCheckResult>();
+ // TODO(sjrush): This check needs internationalization support
+ if (!Locale.getDefault().getLanguage().equals(Locale.ENGLISH.getLanguage())) {
+ results.add(new AccessibilityInfoCheckResult(this.getClass(),
+ AccessibilityCheckResultType.NOT_RUN, "This check only runs in English locales", root));
+ return results;
+ }
+
+ List<AccessibilityNodeInfoCompat> compatInfos = getAllInfoCompatsInHierarchy(context, root);
+ for (AccessibilityNodeInfoCompat compatInfo : compatInfos) {
+ AccessibilityNodeInfo info = (AccessibilityNodeInfo) compatInfo.getInfo();
+ CharSequence contentDescription = info.getContentDescription();
+ if (TextUtils.isEmpty(contentDescription)) {
+ results.add(new AccessibilityInfoCheckResult(this.getClass(),
+ AccessibilityCheckResultType.NOT_RUN, "View has no content description", info));
+ continue;
+ }
+ for (CharSequence redundantWord : redundantWords) {
+ if (contentDescription.toString().toLowerCase().contains(redundantWord)) {
+ results.add(new AccessibilityInfoCheckResult(this.getClass(),
+ AccessibilityCheckResultType.WARNING,
+ "View's speakable text ends with view type",
+ info));
+ break;
+ }
+ }
+ }
+ return results;
+ }
+}
diff --git a/src/main/java/com/google/android/apps/common/testing/accessibility/framework/RedundantContentDescViewCheck.java b/src/main/java/com/google/android/apps/common/testing/accessibility/framework/RedundantContentDescViewCheck.java
new file mode 100644
index 0000000..2852457
--- /dev/null
+++ b/src/main/java/com/google/android/apps/common/testing/accessibility/framework/RedundantContentDescViewCheck.java
@@ -0,0 +1,78 @@
+/*
+ * 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.os.Build;
+import android.text.TextUtils;
+import android.view.View;
+import android.widget.Button;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+
+/**
+ * Checks to ensure that speakable text does not contain redundant information about the view's
+ * type. Accessibility services are aware of the view's type and can use that information as needed
+ * (ex: Screen readers may append "button" to the speakable text of a {@link Button}).
+ */
+public class RedundantContentDescViewCheck extends AccessibilityViewHierarchyCheck {
+ private static List<CharSequence> redundantWords = new ArrayList<>();
+ static {
+ redundantWords.add("button");
+ }
+
+ @Override
+ public List<AccessibilityViewCheckResult> runCheckOnViewHierarchy(View root) {
+ List<AccessibilityViewCheckResult> results = new ArrayList<AccessibilityViewCheckResult>();
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN) {
+ results.add(new AccessibilityViewCheckResult(this.getClass(),
+ AccessibilityCheckResultType.NOT_RUN, "This check only runs on Android 4.1 and above.",
+ root));
+ return results;
+ }
+ // TODO(sjrush): This check needs internationalization support
+ if (!Locale.getDefault().getLanguage().equals(Locale.ENGLISH.getLanguage())) {
+ results.add(new AccessibilityViewCheckResult(this.getClass(),
+ AccessibilityCheckResultType.NOT_RUN, "This check only runs in English locales", root));
+ return results;
+ }
+
+ for (View view : ViewAccessibilityUtils.getAllViewsInHierarchy(root)) {
+ if (!ViewAccessibilityUtils.isImportantForAccessibility(view)) {
+ results.add(new AccessibilityViewCheckResult(this.getClass(),
+ AccessibilityCheckResultType.NOT_RUN, "View is not important for accessibility", view));
+ continue;
+ }
+ CharSequence contentDescription = view.getContentDescription();
+ if (TextUtils.isEmpty(contentDescription)) {
+ results.add(new AccessibilityViewCheckResult(this.getClass(),
+ AccessibilityCheckResultType.NOT_RUN, "View has no content description", view));
+ continue;
+ }
+ for (CharSequence redundantWord : redundantWords) {
+ if (contentDescription.toString().toLowerCase().contains(redundantWord)) {
+ results.add(new AccessibilityViewCheckResult(this.getClass(),
+ AccessibilityCheckResultType.WARNING,
+ "View's speakable text ends with view type",
+ view));
+ }
+ }
+ }
+ return results;
+ }
+}
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
new file mode 100644
index 0000000..32a7a5d
--- /dev/null
+++ b/src/main/java/com/google/android/apps/common/testing/accessibility/framework/TouchTargetSizeInfoCheck.java
@@ -0,0 +1,82 @@
+/*
+ * 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.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.Rect;
+import android.support.v4.view.accessibility.AccessibilityNodeInfoCompat;
+import android.view.accessibility.AccessibilityNodeInfo;
+
+import com.googlecode.eyesfree.utils.AccessibilityNodeInfoUtils;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+
+/**
+ * Check to ensure that a view has a touch target that is at least 48x48dp.
+ */
+public class TouchTargetSizeInfoCheck extends AccessibilityInfoCheck {
+
+ /**
+ * Minimum height and width are set according to <a
+ * href="http://developer.android.com/design/patterns/accessibility.html"></a>
+ */
+ private static final int TOUCH_TARGET_MIN_HEIGHT = 48;
+ private static final int TOUCH_TARGET_MIN_WIDTH = 48;
+
+ @Override
+ public List<AccessibilityInfoCheckResult> runCheckOnInfo(AccessibilityNodeInfo info,
+ Context context) {
+ ArrayList<AccessibilityInfoCheckResult> results = new ArrayList<AccessibilityInfoCheckResult>();
+
+ // TODO(sjrush): Have all info checks use AccessibilityNodeInfoCompat
+ AccessibilityNodeInfoCompat infoCompat = new AccessibilityNodeInfoCompat(info);
+ if (!(AccessibilityNodeInfoUtils.isClickable(infoCompat)
+ || AccessibilityNodeInfoUtils.isLongClickable(infoCompat))) {
+ results.add(new AccessibilityInfoCheckResult(this.getClass(),
+ AccessibilityCheckResultType.NOT_RUN, "View is not clickable", info));
+ return results;
+ }
+ if (context == null) {
+ results.add(new AccessibilityInfoCheckResult(this.getClass(),
+ AccessibilityCheckResultType.NOT_RUN, "This check needs a context", info));
+ return results;
+ }
+
+ // TODO(sjrush): Find a way to make this check work without a context
+ // dp calculation is pixels/density
+ float density = context.getResources().getDisplayMetrics().density;
+ Rect bounds = new Rect();
+ info.getBoundsInScreen(bounds);
+ float targetHeight = bounds.height() / density;
+ float targetWidth = 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);
+ results.add(new AccessibilityInfoCheckResult(this.getClass(),
+ AccessibilityCheckResultType.ERROR, message, info));
+ }
+ return results;
+ }
+}
diff --git a/src/main/java/com/google/android/apps/common/testing/accessibility/framework/ViewAccessibilityUtils.java b/src/main/java/com/google/android/apps/common/testing/accessibility/framework/ViewAccessibilityUtils.java
new file mode 100644
index 0000000..d5b7069
--- /dev/null
+++ b/src/main/java/com/google/android/apps/common/testing/accessibility/framework/ViewAccessibilityUtils.java
@@ -0,0 +1,433 @@
+/*
+ * 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 android.annotation.TargetApi;
+import android.graphics.Rect;
+import android.os.Build;
+import android.support.v4.view.ViewCompat;
+import android.text.TextUtils;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.ViewParent;
+import android.widget.AdapterView;
+import android.widget.CompoundButton;
+import android.widget.HorizontalScrollView;
+import android.widget.ScrollView;
+import android.widget.Spinner;
+import android.widget.TextView;
+
+import java.util.HashSet;
+import java.util.Set;
+
+/**
+ * This class provides a set of utilities used to evaluate accessibility properties and behaviors of
+ * hierarchies of {@link View}s.
+ */
+@TargetApi(Build.VERSION_CODES.JELLY_BEAN)
+final class ViewAccessibilityUtils {
+
+ private ViewAccessibilityUtils() {}
+
+ /**
+ * @param rootView The root of a View hierarchy
+ * @return A Set containing the root view and all views below it in the hierarchy
+ */
+ public static Set<View> getAllViewsInHierarchy(View rootView) {
+ Set<View> allViews = new HashSet<View>();
+ allViews.add(rootView);
+ addAllChildrenToSet(rootView, allViews);
+ return allViews;
+ }
+
+ /**
+ * @see View#isImportantForAccessibility()
+ */
+ public static boolean isImportantForAccessibility(View view) {
+ if (view == null) {
+ return false;
+ }
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
+ return view.isImportantForAccessibility();
+ } else {
+ // On earlier APIs, we must piece together accessibility importance from the available
+ // properties. We return false incorrectly for some cases where unretrievable listeners
+ // prevent us from determining importance.
+
+ // If the developer marked the view as explicitly not important, it isn't.
+ int mode = view.getImportantForAccessibility();
+ if ((mode == View.IMPORTANT_FOR_ACCESSIBILITY_NO)
+ || (mode == View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS)) {
+ return false;
+ }
+
+ // No parent view can be hiding us. (APIs 19 to 21)
+ ViewParent parent = view.getParent();
+ while (parent instanceof View) {
+ if (((View) parent).getImportantForAccessibility()
+ == View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS) {
+ return false;
+ }
+ parent = parent.getParent();
+ }
+
+ // Interrogate the view's other properties to determine importance.
+ return (mode == View.IMPORTANT_FOR_ACCESSIBILITY_YES)
+ || isActionableForAccessibility(view)
+ || hasListenersForAccessibility(view)
+ || (view.getAccessibilityNodeProvider() != null)
+ || (ViewCompat.getAccessibilityLiveRegion(view)
+ != ViewCompat.ACCESSIBILITY_LIVE_REGION_NONE);
+ }
+ }
+
+ /**
+ * Determines if the supplied {@link View} is actionable for accessibility purposes.
+ *
+ * @param view The {@link View} to evaluate
+ * @return {@code true} if {@code view} is considered actionable for accessibility
+ */
+ public static boolean isActionableForAccessibility(View view) {
+ if (view == null) {
+ return false;
+ }
+
+ return (view.isClickable() || view.isLongClickable() || view.isFocusable());
+ }
+
+ /**
+ * Determines if the supplied {@link View} is visible to the user, which requires that it be
+ * marked visible, that all its parents are visible, that it and all parents have alpha
+ * greater than 0, and that it has non-zero size. This code attempts to replicate the protected
+ * method {@code View.isVisibleToUser}.
+ *
+ * @param view The {@link View} to evaluate
+ * @return {@code true} if {@code view} is visible to the user
+ */
+ public static boolean isVisibleToUser(View view) {
+ if (view == null) {
+ return false;
+ }
+
+ Object current = view;
+ while (current instanceof View) {
+ View currentView = (View) current;
+ if ((ViewCompat.getAlpha(currentView) <= 0)
+ || (currentView.getVisibility() != View.VISIBLE)) {
+ return false;
+ }
+ current = currentView.getParent();
+ }
+ return view.getGlobalVisibleRect(new Rect());
+ }
+
+ /**
+ * Determines if the supplied {@link View} would be focused during navigation operations with a
+ * screen reader.
+ *
+ * @param view The {@link View} to evaluate
+ * @return {@code true} if a screen reader would choose to place accessibility focus on
+ * {@code view}, {@code false} otherwise.
+ */
+ public static boolean shouldFocusView(View view) {
+ if (view == null) {
+ return false;
+ }
+
+ if (!isVisibleToUser(view)) {
+ // We don't focus views that are not visible
+ return false;
+ }
+
+ if (isAccessibilityFocusable(view)) {
+ if ((!(view instanceof ViewGroup))
+ || ((view instanceof ViewGroup) && (((ViewGroup) view).getChildCount() == 0))) {
+ // TODO(caseyburkhardt): Do we need to evaluate all ViewGroups and filter non-important
+ // Views to determine leaves? If so, this seems like a rare corner case.
+
+ // Leaves that are accessibility focusable always gain focus regardless of presence of a
+ // spoken description. This allows unlabeled, but still actionable, widgets to be activated
+ // by the user.
+ return true;
+ } else if (isSpeakingView(view)) {
+ // The view (or its non-actionable children)
+ return true;
+ }
+
+ return false;
+ }
+
+ if (hasText(view) && !hasFocusableAncestor(view)) {
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Find a {@code View}, if one exists, that labels a given {@code View}.
+ *
+ * @param view The target of the labelFor.
+ * @return The {@code View} that is the labelFor the specified view. {@code null}
+ * if nothing labels it.
+ */
+ public static View getLabelForView(View view) {
+ if ((Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR1)) {
+ /* Earlier versions don't support labelFor */
+ return null;
+ }
+ int idToFind = view.getId();
+ if (idToFind == View.NO_ID) {
+ /* Views lacking IDs can't be labeled by others */
+ return null;
+ }
+
+ /*
+ * Search for the "nearest" View that labels this one, since IDs aren't unique. This code
+ * follows the framework code by DFSing first children, then siblings, then parent and its
+ * siblings, etc. childToSkip is passed in to the helper method to avoid repeating consideration
+ * of a View when examining its parent.
+ */
+ View childToSkip = null;
+ while (true) {
+ View labelingView = lookForLabelForViewInViewAndChildren(view, childToSkip, idToFind);
+ if (labelingView != null) {
+ return labelingView;
+ }
+ ViewParent parent = view.getParent();
+ childToSkip = view;
+ if (!(parent instanceof View)) {
+ return null;
+ }
+ view = (View) parent;
+ }
+ }
+
+ private static View lookForLabelForViewInViewAndChildren(View view, View childToSkip,
+ int idToFind) {
+ if (view.getLabelFor() == idToFind) {
+ return view;
+ }
+ if (!(view instanceof ViewGroup)) {
+ return null;
+ }
+ ViewGroup viewGroup = (ViewGroup) view;
+ for (int i = 0; i < viewGroup.getChildCount(); ++i) {
+ View child = viewGroup.getChildAt(i);
+ if (!child.equals(childToSkip)) {
+ View labelingView = lookForLabelForViewInViewAndChildren(child, null, idToFind);
+ if (labelingView != null) {
+ return labelingView;
+ }
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Add all children in the view tree rooted at rootView to a set
+ *
+ * @param rootView The root of the view tree desired
+ * @param theSet The set to add views to
+ */
+ private static void addAllChildrenToSet(View rootView, Set<View> theSet) {
+ if (!(rootView instanceof ViewGroup)) {
+ return;
+ }
+
+ ViewGroup rootViewGroup = (ViewGroup) rootView;
+ for (int i = 0; i < rootViewGroup.getChildCount(); ++i) {
+ View nextView = rootViewGroup.getChildAt(i);
+ theSet.add(nextView);
+ addAllChildrenToSet(nextView, theSet);
+ }
+ }
+
+ /**
+ * Determines if the supplied {@link View} has any retrievable listeners that might qualify the
+ * view to be important for accessibility purposes.
+ * <p>
+ * NOTE: This method tries to behave like the hidden {@code View#hasListenersForAccessibility()}
+ * method, but cannot retrieve several of the listeners.
+ *
+ * @param view The {@link View} to evaluate
+ * @return {@code true} if any of the retrievable listeners on {@code view} might qualify it to be
+ * important for accessibility purposes.
+ */
+ private static boolean hasListenersForAccessibility(View view) {
+ if (view == null) {
+ return false;
+ }
+
+ boolean result = false;
+ // Ideally, here we check for...
+ // TouchDelegate
+ result |= view.getTouchDelegate() != null;
+
+ // OnKeyListener, OnTouchListener, OnGenericMotionListener, OnHoverListener, OnDragListener
+ // aren't accessible to us.
+ return result;
+ }
+
+ /**
+ * Determines if the supplied {@link View} has an ancestor which meets the criteria for gaining
+ * accessibility focus.
+ * <p>
+ * NOTE: This method only evaluates ancestors which may be considered important for accessibility
+ * and explicitly does not evaluate the supplied {@code view}.
+ *
+ * @param view The {@link View} to evaluate
+ * @return {@code true} if an ancestor of {@code view} may gain accessibility focus, {@code false}
+ * otherwise
+ */
+ private static boolean hasFocusableAncestor(View view) {
+ if (view == null) {
+ return false;
+ }
+
+ ViewParent parent = view.getParentForAccessibility();
+ if (!(parent instanceof View)) {
+ return false;
+ }
+
+ if (isAccessibilityFocusable((View) parent)) {
+ return true;
+ }
+
+ return hasFocusableAncestor((View) parent);
+ }
+
+ /**
+ * Determines if the supplied {@link View} meets the criteria for gaining accessibility focus.
+ *
+ * @param view The {@link View} to evaluate
+ * @return {@code true} if it is possible for {@code view} to gain accessibility focus,
+ * {@code false} otherwise.
+ */
+ private static boolean isAccessibilityFocusable(View view) {
+ if (view == null) {
+ return false;
+ }
+
+ if (view.getVisibility() != View.VISIBLE) {
+ return false;
+ }
+
+ if (isActionableForAccessibility(view)) {
+ return true;
+ }
+
+ return isChildOfScrollableContainer(view) && isSpeakingView(view);
+ }
+
+ /**
+ * Determines if the supplied {@link View} is a top-level item within a scrollable container.
+ *
+ * @param view The {@link View} to evaluate
+ * @return {@code true} if {@code view} is a top-level view within a scrollable container,
+ * {@code false} otherwise
+ */
+ private static boolean isChildOfScrollableContainer(View view) {
+ if (view == null) {
+ return false;
+ }
+
+ ViewParent viewParent = view.getParentForAccessibility();
+ if ((viewParent == null) || !(viewParent instanceof View)) {
+ return false;
+ }
+
+ View parent = (View) viewParent;
+ if (parent.isScrollContainer()) {
+ return true;
+ }
+
+ // Specifically check for parents that are AdapterView, ScrollView, or HorizontalScrollView, but
+ // exclude Spinners, which are a special case of AdapterView.
+ return (((parent instanceof AdapterView) || (parent instanceof ScrollView)
+ || (parent instanceof HorizontalScrollView)) && !(parent instanceof Spinner));
+ }
+
+ /**
+ * Determines if the supplied {@link View} is one which would produce speech if it were to gain
+ * accessibility focus.
+ * <p>
+ * NOTE: This method also evaluates the subtree of the {@code view} for children that should be
+ * included in {@code view}'s spoken description.
+ *
+ * @param view The {@link View} to evaluate
+ * @return {@code true} if a spoken description for {@code view} was determined, {@code false}
+ * otherwise.
+ */
+ private static boolean isSpeakingView(View view) {
+ if (hasText(view)) {
+ return true;
+ } else if (view instanceof CompoundButton) {
+ // Special case for CompoundButton / CheckBox / Switch.
+ return true;
+ } else if (hasNonActionableSpeakingChildren(view)) {
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Determines if the supplied {@link View} has child view(s) which are not independently
+ * accessibility focusable and also have a spoken description. Put another way, this method
+ * determines if {@code view} has at least one child which should be included in {@code view}'s
+ * spoken description if {@code view} were to be accessibility focused.
+ *
+ * @param view The {@link View} to evaluate
+ * @return {@code true} if {@code view} has non-actionable speaking children within its subtree
+ */
+ private static boolean hasNonActionableSpeakingChildren(View view) {
+ if ((view == null) || !(view instanceof ViewGroup)) {
+ return false;
+ }
+
+ ViewGroup group = (ViewGroup) view;
+ for (int i = 0; i < group.getChildCount(); ++i) {
+ View child = group.getChildAt(i);
+ if ((child == null) || (child.getVisibility() != View.VISIBLE)
+ || isAccessibilityFocusable(child)) {
+ continue;
+ }
+
+ if (isImportantForAccessibility(child) && isSpeakingView(child)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Determines if the supplied {@link View} has a contentDescription or text.
+ *
+ * @param view The {@link View} to evaluate
+ * @return {@code true} if {@code view} has a contentDescription or text.
+ */
+ private static boolean hasText(View view) {
+ if (!TextUtils.isEmpty(view.getContentDescription())) {
+ return true;
+ } else if (view instanceof TextView) {
+ return !TextUtils.isEmpty(((TextView) view).getText());
+ }
+
+ return false;
+ }
+}
diff --git a/src/main/java/com/google/android/apps/common/testing/accessibility/framework/integrations/AccessibilityCheckAssertion.java b/src/main/java/com/google/android/apps/common/testing/accessibility/framework/integrations/AccessibilityCheckAssertion.java
new file mode 100644
index 0000000..82444fb
--- /dev/null
+++ b/src/main/java/com/google/android/apps/common/testing/accessibility/framework/integrations/AccessibilityCheckAssertion.java
@@ -0,0 +1,238 @@
+package com.google.android.apps.common.testing.accessibility.framework.integrations;
+
+import com.google.android.apps.common.testing.accessibility.framework.AccessibilityCheckPreset;
+import com.google.android.apps.common.testing.accessibility.framework.AccessibilityCheckResult.AccessibilityCheckResultType;
+import com.google.android.apps.common.testing.accessibility.framework.AccessibilityCheckResultUtils;
+import com.google.android.apps.common.testing.accessibility.framework.AccessibilityViewCheckException;
+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.testrunner.InstrumentationArgumentsRegistry;
+import com.google.android.apps.common.testing.ui.espresso.EspressoException;
+import com.google.android.apps.common.testing.ui.espresso.NoMatchingViewException;
+import com.google.android.apps.common.testing.ui.espresso.ViewAssertion;
+import com.google.android.apps.common.testing.ui.espresso.action.ViewActions;
+import com.google.android.apps.common.testing.ui.espresso.util.HumanReadables;
+
+import android.os.Bundle;
+import android.util.Log;
+import android.view.View;
+
+import org.hamcrest.Matcher;
+
+import java.util.Collections;
+import java.util.LinkedList;
+import java.util.List;
+
+/**
+ * A class to enable automated accessibility checks in Espresso tests. These checks will run
+ * as a global {@code ViewAssertion} ({@see ViewActions#addGlobalAssertion(ViewAssertion)}), and
+ * cover a variety of accessibility issues (see {@link AccessibilityCheckPreset#VIEW_CHECKS} and
+ * {@link AccessibilityCheckPreset#VIEW_HIERARCHY_CHECKS} to see which checks are run).
+ */
+public class AccessibilityCheckAssertion implements ViewAssertion {
+
+ private static final String TAG = "AccessibilityCheckAssertion";
+ private static final String SUPPRESS_ACCESSIBILITY_CHECKS_FLAG = "suppress_a11y_checks";
+ private boolean checksEnabled = false;
+ private boolean runChecksFromRootView = false;
+ private boolean throwExceptionForErrors = true;
+ private List<AccessibilityViewHierarchyCheck> viewHierarchyChecks =
+ new LinkedList<AccessibilityViewHierarchyCheck>();
+ private Matcher<? super AccessibilityViewCheckResult> suppressingMatcher = null;
+
+ public AccessibilityCheckAssertion() {
+ viewHierarchyChecks.addAll(AccessibilityCheckPreset.getViewChecksForPreset(
+ AccessibilityCheckPreset.LATEST));
+ }
+
+ @Override
+ public void check(View view, NoMatchingViewException noViewFoundException) {
+ if (noViewFoundException != null) {
+ Log.e(TAG,
+ String.format("'accessibility checks could not be performed because view '%s' was not"
+ + "found.\n", noViewFoundException.getViewMatcherDescription()));
+ throw noViewFoundException;
+ }
+ if (view == null) {
+ throw new NullPointerException();
+ }
+ checkAndReturnResults(view);
+ }
+
+ /**
+ * Runs accessibility checks and returns the list of results.
+ *
+ * @param view the {@link View} to check
+ * @return the resulting list of {@link AccessibilityViewCheckResult}
+ */
+ protected final List<AccessibilityViewCheckResult> checkAndReturnResults(View view) {
+ if (view != null) {
+ View viewToCheck = runChecksFromRootView ? view.getRootView() : view;
+ return runAccessibilityChecks(viewToCheck);
+ }
+ return Collections.<AccessibilityViewCheckResult>emptyList();
+ }
+
+ /**
+ * Enables accessibility checking as a global ViewAssertion in {@link ViewActions}.
+ * Check {@link #isEnabled()} before calling to avoid an {code IllegalStateException".}
+ *
+ * @throws {@code IllegalStateException} if accessibilty checks were already enabled
+ */
+ public void enable() {
+ if (checksEnabled) {
+ throw new IllegalStateException("Accessibility checks already enabled!");
+ }
+ checksEnabled = true;
+ ViewActions.addGlobalAssertion("Accessibility Checks", this);
+ }
+
+ /**
+ * Calls {@link #enable()} if a flag with the given key is present in the instrumentation
+ * arguments. These flags can be passed to {@code adb instrument} with the -e flag.
+ */
+ public void enableIfFlagPresent(String flag) {
+ Bundle args = InstrumentationArgumentsRegistry.getInstance();
+ String flagValue = args.getString(flag);
+ if (flagValue != null) {
+ enable();
+ }
+ }
+
+ /**
+ * Disables accessibility checking.
+ * Check {@link #isEnabled()} before calling to avoid an {code IllegalStateException".}
+ *
+ * @throws {@code IllegalStateException} if accessibilty checks were already disabled
+ */
+ public void disable() {
+ if (!checksEnabled) {
+ throw new IllegalStateException("Accessibility checks already disabled!");
+ }
+ checksEnabled = false;
+ ViewActions.removeGlobalAssertion(this);
+ }
+
+ /**
+ * @return true if accessibility checking is enabled
+ */
+ public boolean isEnabled() {
+ return checksEnabled;
+ }
+
+ /**
+ * @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
+ */
+ public AccessibilityCheckAssertion setRunChecksFromRootView(boolean runChecksFromRootView) {
+ this.runChecksFromRootView = runChecksFromRootView;
+ return this;
+ }
+
+ /**
+ * Suppresses all results that match the given matcher. Suppressed results will not be included
+ * in any logs or cause any {@code Exception} to be thrown
+ *
+ * @param resultMatcher a matcher to match a {@link AccessibilityViewCheckResult}
+ * @return this
+ */
+ public AccessibilityCheckAssertion setSuppressingResultMatcher(
+ Matcher<? super AccessibilityViewCheckResult> resultMatcher) {
+ suppressingMatcher = resultMatcher;
+ return this;
+ }
+
+ /**
+ * @param throwExceptionForErrors {@code true} to throw an {@code Exception} when there is at
+ * least one error result, {@code false} to just log the error results to logcat.
+ * Default: {@code true}
+ * @return this
+ */
+ public AccessibilityCheckAssertion setThrowExceptionForErrors(boolean throwExceptionForErrors) {
+ this.throwExceptionForErrors = throwExceptionForErrors;
+ return this;
+ }
+
+ private static boolean shouldCheckAccessibility() {
+ Bundle args = InstrumentationArgumentsRegistry.getInstance();
+ if (args != null && Boolean.valueOf(args.getString(SUPPRESS_ACCESSIBILITY_CHECKS_FLAG))) {
+ return false;
+ }
+ return true;
+ }
+
+ /**
+ * Runs accessibility checks on a {@code View} with the arguments passed through
+ * 'adb am shell instrument -e' (see {@link shouldCheckAccessibility()} to determine which
+ * arguments will allow these checks to run)
+ *
+ * @param view the {@code View} to run accessibility checks on
+ * @return a list of the results of the checks
+ */
+ private List<AccessibilityViewCheckResult> runAccessibilityChecks(
+ View view) {
+ if (!shouldCheckAccessibility()) {
+ return null;
+ }
+ List<AccessibilityViewCheckResult> results = new LinkedList<AccessibilityViewCheckResult>();
+ results.addAll(getViewHierarchyCheckResults(view, viewHierarchyChecks));
+ AccessibilityCheckResultUtils.suppressMatchingResults(results, suppressingMatcher);
+ processResults(results);
+ return results;
+ }
+
+ private static List<AccessibilityViewCheckResult> getViewHierarchyCheckResults(View root,
+ Iterable<AccessibilityViewHierarchyCheck> checks) {
+ List<AccessibilityViewCheckResult> results = new LinkedList<AccessibilityViewCheckResult>();
+ for (AccessibilityViewHierarchyCheck check : checks) {
+ results.addAll(check.runCheckOnViewHierarchy(root));
+ }
+ return results;
+ }
+
+ private void processResults(Iterable<AccessibilityViewCheckResult> results) {
+ if (results == null) {
+ return;
+ }
+ List<AccessibilityViewCheckResult> infos = AccessibilityCheckResultUtils.getResultsForType(
+ results, AccessibilityCheckResultType.INFO);
+ List<AccessibilityViewCheckResult> warnings = AccessibilityCheckResultUtils.getResultsForType(
+ results, AccessibilityCheckResultType.WARNING);
+ List<AccessibilityViewCheckResult> errors = AccessibilityCheckResultUtils.getResultsForType(
+ results, AccessibilityCheckResultType.ERROR);
+ for (AccessibilityViewCheckResult result : infos) {
+ Log.i(TAG, getResultMessage(result));
+ }
+ for (AccessibilityViewCheckResult result : warnings) {
+ Log.w(TAG, getResultMessage(result));
+ }
+ if (!errors.isEmpty() && throwExceptionForErrors) {
+ throw new EspressoAccessibilityException(errors);
+ } else {
+ for (AccessibilityViewCheckResult result : errors) {
+ Log.e(TAG, getResultMessage(result));
+ }
+ }
+ }
+
+ private static String getResultMessage(AccessibilityViewCheckResult result) {
+ StringBuilder message = new StringBuilder();
+ message.append(HumanReadables.describe(result.getView()));
+ message.append(": ");
+ message.append(result.getMessage());
+ return message.toString();
+ }
+
+ private static class EspressoAccessibilityException extends AccessibilityViewCheckException
+ implements EspressoException {
+ protected EspressoAccessibilityException(List<AccessibilityViewCheckResult> results) {
+ super(results);
+ }
+
+ @Override
+ protected String getResultMessage(AccessibilityViewCheckResult result) {
+ return AccessibilityCheckAssertion.getResultMessage(result);
+ }
+ }
+}