Add utils to process a11y check results
Also moved a11ychecker from core/... to services/accessibility
NO_IFTTT=New IFTTT
Bug: 350530488
Bug: 341926585
Test: unit tests
Flag: com.android.server.accessibility.enable_a11y_checker_logging
Change-Id: Ibc0cb4eb2d207032ef21a46196d4b2cc04cf7e95
diff --git a/services/accessibility/Android.bp b/services/accessibility/Android.bp
index 7a99b60..311addb 100644
--- a/services/accessibility/Android.bp
+++ b/services/accessibility/Android.bp
@@ -29,10 +29,12 @@
"//frameworks/base/packages/SettingsLib/RestrictedLockUtils:SettingsLibRestrictedLockUtilsSrc",
],
libs: [
+ "aatf",
"services.core",
"androidx.annotation_annotation",
],
static_libs: [
+ "a11ychecker-protos-java-proto-lite",
"com_android_server_accessibility_flags_lib",
"//frameworks/base/packages/SystemUI/aconfig:com_android_systemui_flags_lib",
@@ -68,3 +70,14 @@
name: "com_android_server_accessibility_flags_lib",
aconfig_declarations: "com_android_server_accessibility_flags",
}
+
+java_library_static {
+ name: "a11ychecker-protos-java-proto-lite",
+ proto: {
+ type: "lite",
+ canonical_path_from_root: false,
+ },
+ srcs: [
+ "java/**/a11ychecker/proto/*.proto",
+ ],
+}
diff --git a/services/accessibility/java/com/android/server/accessibility/a11ychecker/AccessibilityCheckerUtils.java b/services/accessibility/java/com/android/server/accessibility/a11ychecker/AccessibilityCheckerUtils.java
new file mode 100644
index 0000000..55af9a0
--- /dev/null
+++ b/services/accessibility/java/com/android/server/accessibility/a11ychecker/AccessibilityCheckerUtils.java
@@ -0,0 +1,218 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.server.accessibility.a11ychecker;
+
+
+import android.annotation.Nullable;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.pm.PackageInfo;
+import android.content.pm.PackageManager;
+import android.util.Slog;
+import android.view.accessibility.AccessibilityEvent;
+import android.view.accessibility.AccessibilityNodeInfo;
+
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.server.accessibility.a11ychecker.A11yCheckerProto.AccessibilityCheckClass;
+import com.android.server.accessibility.a11ychecker.A11yCheckerProto.AccessibilityCheckResultReported;
+import com.android.server.accessibility.a11ychecker.A11yCheckerProto.AccessibilityCheckResultType;
+
+import com.google.android.apps.common.testing.accessibility.framework.AccessibilityCheckResult;
+import com.google.android.apps.common.testing.accessibility.framework.AccessibilityHierarchyCheck;
+import com.google.android.apps.common.testing.accessibility.framework.AccessibilityHierarchyCheckResult;
+import com.google.android.apps.common.testing.accessibility.framework.checks.ClassNameCheck;
+import com.google.android.apps.common.testing.accessibility.framework.checks.ClickableSpanCheck;
+import com.google.android.apps.common.testing.accessibility.framework.checks.DuplicateClickableBoundsCheck;
+import com.google.android.apps.common.testing.accessibility.framework.checks.DuplicateSpeakableTextCheck;
+import com.google.android.apps.common.testing.accessibility.framework.checks.EditableContentDescCheck;
+import com.google.android.apps.common.testing.accessibility.framework.checks.ImageContrastCheck;
+import com.google.android.apps.common.testing.accessibility.framework.checks.LinkPurposeUnclearCheck;
+import com.google.android.apps.common.testing.accessibility.framework.checks.RedundantDescriptionCheck;
+import com.google.android.apps.common.testing.accessibility.framework.checks.SpeakableTextPresentCheck;
+import com.google.android.apps.common.testing.accessibility.framework.checks.TextContrastCheck;
+import com.google.android.apps.common.testing.accessibility.framework.checks.TextSizeCheck;
+import com.google.android.apps.common.testing.accessibility.framework.checks.TouchTargetSizeCheck;
+import com.google.android.apps.common.testing.accessibility.framework.checks.TraversalOrderCheck;
+
+import java.util.AbstractMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+/**
+ * Util class to process a11y checker results for logging.
+ *
+ * @hide
+ */
+public class AccessibilityCheckerUtils {
+
+ private static final String LOG_TAG = "AccessibilityCheckerUtils";
+ @VisibleForTesting
+ // LINT.IfChange
+ static final Map<Class<? extends AccessibilityHierarchyCheck>, AccessibilityCheckClass>
+ CHECK_CLASS_TO_ENUM_MAP =
+ Map.ofEntries(
+ classMapEntry(ClassNameCheck.class, AccessibilityCheckClass.CLASS_NAME_CHECK),
+ classMapEntry(ClickableSpanCheck.class,
+ AccessibilityCheckClass.CLICKABLE_SPAN_CHECK),
+ classMapEntry(DuplicateClickableBoundsCheck.class,
+ AccessibilityCheckClass.DUPLICATE_CLICKABLE_BOUNDS_CHECK),
+ classMapEntry(DuplicateSpeakableTextCheck.class,
+ AccessibilityCheckClass.DUPLICATE_SPEAKABLE_TEXT_CHECK),
+ classMapEntry(EditableContentDescCheck.class,
+ AccessibilityCheckClass.EDITABLE_CONTENT_DESC_CHECK),
+ classMapEntry(ImageContrastCheck.class,
+ AccessibilityCheckClass.IMAGE_CONTRAST_CHECK),
+ classMapEntry(LinkPurposeUnclearCheck.class,
+ AccessibilityCheckClass.LINK_PURPOSE_UNCLEAR_CHECK),
+ classMapEntry(RedundantDescriptionCheck.class,
+ AccessibilityCheckClass.REDUNDANT_DESCRIPTION_CHECK),
+ classMapEntry(SpeakableTextPresentCheck.class,
+ AccessibilityCheckClass.SPEAKABLE_TEXT_PRESENT_CHECK),
+ classMapEntry(TextContrastCheck.class,
+ AccessibilityCheckClass.TEXT_CONTRAST_CHECK),
+ classMapEntry(TextSizeCheck.class, AccessibilityCheckClass.TEXT_SIZE_CHECK),
+ classMapEntry(TouchTargetSizeCheck.class,
+ AccessibilityCheckClass.TOUCH_TARGET_SIZE_CHECK),
+ classMapEntry(TraversalOrderCheck.class,
+ AccessibilityCheckClass.TRAVERSAL_ORDER_CHECK));
+ // LINT.ThenChange(/services/accessibility/java/com/android/server/accessibility/a11ychecker/proto/a11ychecker.proto)
+
+ static Set<AccessibilityCheckResultReported> processResults(
+ Context context,
+ AccessibilityNodeInfo nodeInfo,
+ List<AccessibilityHierarchyCheckResult> checkResults,
+ @Nullable AccessibilityEvent accessibilityEvent,
+ ComponentName a11yServiceComponentName) {
+ return processResults(nodeInfo, checkResults, accessibilityEvent,
+ context.getPackageManager(), a11yServiceComponentName);
+ }
+
+ @VisibleForTesting
+ static Set<AccessibilityCheckResultReported> processResults(
+ AccessibilityNodeInfo nodeInfo,
+ List<AccessibilityHierarchyCheckResult> checkResults,
+ @Nullable AccessibilityEvent accessibilityEvent,
+ PackageManager packageManager,
+ ComponentName a11yServiceComponentName) {
+ String appPackageName = nodeInfo.getPackageName().toString();
+ AccessibilityCheckResultReported.Builder builder;
+ try {
+ builder = AccessibilityCheckResultReported.newBuilder()
+ .setPackageName(appPackageName)
+ .setAppVersionCode(getAppVersionCode(packageManager, appPackageName))
+ .setUiElementPath(AccessibilityNodePathBuilder.createNodePath(nodeInfo))
+ .setActivityName(getActivityName(packageManager, accessibilityEvent))
+ .setWindowTitle(getWindowTitle(nodeInfo))
+ .setSourceComponentName(a11yServiceComponentName.flattenToString())
+ .setSourceVersionCode(
+ getAppVersionCode(packageManager,
+ a11yServiceComponentName.getPackageName()));
+ } catch (PackageManager.NameNotFoundException e) {
+ Slog.e(LOG_TAG, "Unknown package name", e);
+ return Set.of();
+ }
+
+ return checkResults.stream()
+ .filter(checkResult -> checkResult.getType()
+ == AccessibilityCheckResult.AccessibilityCheckResultType.ERROR
+ || checkResult.getType()
+ == AccessibilityCheckResult.AccessibilityCheckResultType.WARNING)
+ .map(checkResult -> builder.setResultCheckClass(
+ getCheckClass(checkResult)).setResultType(
+ getCheckResultType(checkResult)).setResultId(
+ checkResult.getResultId()).build())
+ .collect(Collectors.toUnmodifiableSet());
+ }
+
+ private static long getAppVersionCode(PackageManager packageManager, String packageName) throws
+ PackageManager.NameNotFoundException {
+ PackageInfo packageInfo = packageManager.getPackageInfo(packageName, 0);
+ return packageInfo.getLongVersionCode();
+ }
+
+ /**
+ * Returns the simple class name of the Activity providing the cache update, if available,
+ * or an empty String if not.
+ */
+ @VisibleForTesting
+ static String getActivityName(
+ PackageManager packageManager, @Nullable AccessibilityEvent accessibilityEvent) {
+ if (accessibilityEvent == null) {
+ return "";
+ }
+ CharSequence activityName = accessibilityEvent.getClassName();
+ if (accessibilityEvent.getEventType() == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED
+ && accessibilityEvent.getPackageName() != null
+ && activityName != null) {
+ try {
+ // Check class is for a valid Activity.
+ packageManager
+ .getActivityInfo(
+ new ComponentName(accessibilityEvent.getPackageName().toString(),
+ activityName.toString()), 0);
+ int qualifierEnd = activityName.toString().lastIndexOf('.');
+ return activityName.toString().substring(qualifierEnd + 1);
+ } catch (PackageManager.NameNotFoundException e) {
+ // No need to spam the logs. This is very frequent when the class doesn't match
+ // an activity.
+ }
+ }
+ return "";
+ }
+
+ /**
+ * Returns the title of the window containing the a11y node.
+ */
+ private static String getWindowTitle(AccessibilityNodeInfo nodeInfo) {
+ if (nodeInfo.getWindow() == null) {
+ return "";
+ }
+ CharSequence windowTitle = nodeInfo.getWindow().getTitle();
+ return windowTitle == null ? "" : windowTitle.toString();
+ }
+
+ /**
+ * Maps the {@link AccessibilityHierarchyCheck} class that produced the given result, with the
+ * corresponding {@link AccessibilityCheckClass} enum. This enumeration is to avoid relying on
+ * String class names in the logging, which can be proguarded. It also reduces the logging size.
+ */
+ private static AccessibilityCheckClass getCheckClass(
+ AccessibilityHierarchyCheckResult checkResult) {
+ if (CHECK_CLASS_TO_ENUM_MAP.containsKey(checkResult.getSourceCheckClass())) {
+ return CHECK_CLASS_TO_ENUM_MAP.get(checkResult.getSourceCheckClass());
+ }
+ return AccessibilityCheckClass.UNKNOWN_CHECK;
+ }
+
+ private static AccessibilityCheckResultType getCheckResultType(
+ AccessibilityHierarchyCheckResult checkResult) {
+ return switch (checkResult.getType()) {
+ case ERROR -> AccessibilityCheckResultType.ERROR;
+ case WARNING -> AccessibilityCheckResultType.WARNING;
+ default -> AccessibilityCheckResultType.UNKNOWN_RESULT_TYPE;
+ };
+ }
+
+ private static Map.Entry<Class<? extends AccessibilityHierarchyCheck>,
+ AccessibilityCheckClass> classMapEntry(
+ Class<? extends AccessibilityHierarchyCheck> checkClass,
+ AccessibilityCheckClass checkClassEnum) {
+ return new AbstractMap.SimpleImmutableEntry<>(checkClass, checkClassEnum);
+ }
+}
diff --git a/services/accessibility/java/com/android/server/accessibility/a11ychecker/AccessibilityNodePathBuilder.java b/services/accessibility/java/com/android/server/accessibility/a11ychecker/AccessibilityNodePathBuilder.java
new file mode 100644
index 0000000..bbfb217
--- /dev/null
+++ b/services/accessibility/java/com/android/server/accessibility/a11ychecker/AccessibilityNodePathBuilder.java
@@ -0,0 +1,112 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.server.accessibility.a11ychecker;
+
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.view.accessibility.AccessibilityNodeInfo;
+
+/**
+ * Utility class to create developer-friendly {@link AccessibilityNodeInfo} path Strings for use
+ * in reporting AccessibilityCheck results.
+ *
+ * @hide
+ */
+public final class AccessibilityNodePathBuilder {
+
+ /**
+ * Returns the path of the node within its accessibility hierarchy starting from the root node
+ * down to the given node itself, and prefixed by the package name. This path is not guaranteed
+ * to be unique. This can return null in case the node's hierarchy changes while scanning.
+ *
+ * <p>Each element in the path is represented by its View ID resource name, when available, or
+ * the
+ * simple class name if not. The path also includes the index of each child node relative to
+ * its
+ * parent. See {@link AccessibilityNodeInfo#getViewIdResourceName()}.
+ *
+ * <p>For example,
+ * "com.example.app:RootElementClassName/parent_resource_name[1]/TargetElementClassName[3]"
+ * indicates the element has type {@code TargetElementClassName}, and is the third child of an
+ * element with the resource name {@code parent_resource_name}, which is the first child of an
+ * element of type {@code RootElementClassName}.
+ *
+ * <p>This format is consistent with elements paths in Pre-Launch Reports and the Accessibility
+ * Scanner, starting from the window's root node instead of the first resource name.
+ * TODO (b/344607035): link to ClusteringUtils when AATF is merged in main.
+ */
+ public static @Nullable String createNodePath(@NonNull AccessibilityNodeInfo nodeInfo) {
+ StringBuilder resourceIdBuilder = getNodePathBuilder(nodeInfo);
+ return resourceIdBuilder == null ? null : String.valueOf(nodeInfo.getPackageName()) + ':'
+ + resourceIdBuilder;
+ }
+
+ private static @Nullable StringBuilder getNodePathBuilder(AccessibilityNodeInfo nodeInfo) {
+ AccessibilityNodeInfo parent = nodeInfo.getParent();
+ if (parent == null) {
+ return new StringBuilder(getShortUiElementName(nodeInfo));
+ }
+ StringBuilder parentNodePath = getNodePathBuilder(parent);
+ if (parentNodePath == null) {
+ return null;
+ }
+ int childCount = parent.getChildCount();
+ for (int i = 0; i < childCount; i++) {
+ if (!nodeInfo.equals(parent.getChild(i))) {
+ continue;
+ }
+ CharSequence uiElementName = getShortUiElementName(nodeInfo);
+ if (uiElementName != null) {
+ parentNodePath.append('/').append(uiElementName).append('[').append(i + 1).append(
+ ']');
+ } else {
+ parentNodePath.append(":nth-child(").append(i + 1).append(')');
+ }
+ return parentNodePath;
+ }
+ return null;
+ }
+
+ //Returns the part of the element's View ID resource name after the qualifier
+ // "package_name:id/" or the last '/', when available. Otherwise, returns the element's
+ // simple class name.
+ private static CharSequence getShortUiElementName(AccessibilityNodeInfo nodeInfo) {
+ String viewIdResourceName = nodeInfo.getViewIdResourceName();
+ if (viewIdResourceName != null) {
+ String idQualifier = ":id/";
+ int idQualifierStartIndex = viewIdResourceName.indexOf(idQualifier);
+ int unqualifiedNameStartIndex = idQualifierStartIndex == -1 ? 0
+ : (idQualifierStartIndex + idQualifier.length());
+ return viewIdResourceName.substring(unqualifiedNameStartIndex);
+ }
+ return getSimpleClassName(nodeInfo);
+ }
+
+ private static CharSequence getSimpleClassName(AccessibilityNodeInfo nodeInfo) {
+ CharSequence name = nodeInfo.getClassName();
+ for (int i = name.length() - 1; i > 0; i--) {
+ char ithChar = name.charAt(i);
+ if (ithChar == '.' || ithChar == '$') {
+ return name.subSequence(i + 1, name.length());
+ }
+ }
+ return name;
+ }
+
+ private AccessibilityNodePathBuilder() {
+ }
+}
diff --git a/services/accessibility/java/com/android/server/accessibility/a11ychecker/OWNERS b/services/accessibility/java/com/android/server/accessibility/a11ychecker/OWNERS
new file mode 100644
index 0000000..d1e7986
--- /dev/null
+++ b/services/accessibility/java/com/android/server/accessibility/a11ychecker/OWNERS
@@ -0,0 +1,4 @@
+# Android Accessibility Framework owners
+include /services/accessibility/OWNERS
+
+yaraabdullatif@google.com
diff --git a/services/accessibility/java/com/android/server/accessibility/a11ychecker/proto/a11ychecker.proto b/services/accessibility/java/com/android/server/accessibility/a11ychecker/proto/a11ychecker.proto
new file mode 100644
index 0000000..8beed4a
--- /dev/null
+++ b/services/accessibility/java/com/android/server/accessibility/a11ychecker/proto/a11ychecker.proto
@@ -0,0 +1,73 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+syntax = "proto2";
+package android.accessibility;
+
+option java_package = "com.android.server.accessibility.a11ychecker";
+option java_outer_classname = "A11yCheckerProto";
+
+// TODO(b/326385939): remove and replace usage with the atom extension proto, when submitted.
+/** Logs the result of an AccessibilityCheck. */
+message AccessibilityCheckResultReported {
+ // Package name of the app containing the checked View.
+ optional string package_name = 1;
+ // Version code of the app containing the checked View.
+ optional int64 app_version_code = 2;
+ // The path of the View starting from the root element in the window. Each element is
+ // represented by the View's resource id, when available, or the View's class name.
+ optional string ui_element_path = 3;
+ // Class name of the activity containing the checked View.
+ optional string activity_name = 4;
+ // Title of the window containing the checked View.
+ optional string window_title = 5;
+ // The flattened component name of the app running the AccessibilityService which provided the a11y node.
+ optional string source_component_name = 6;
+ // Version code of the app running the AccessibilityService that provided the a11y node.
+ optional int64 source_version_code = 7;
+ // Class Name of the AccessibilityCheck that produced the result.
+ optional AccessibilityCheckClass result_check_class = 8;
+ // Result type of the AccessibilityCheckResult.
+ optional AccessibilityCheckResultType result_type = 9;
+ // Result ID of the AccessibilityCheckResult.
+ optional int32 result_id = 10;
+}
+
+/** The AccessibilityCheck class. */
+// LINT.IfChange
+enum AccessibilityCheckClass {
+ UNKNOWN_CHECK = 0;
+ CLASS_NAME_CHECK = 1;
+ CLICKABLE_SPAN_CHECK = 2;
+ DUPLICATE_CLICKABLE_BOUNDS_CHECK = 3;
+ DUPLICATE_SPEAKABLE_TEXT_CHECK = 4;
+ EDITABLE_CONTENT_DESC_CHECK = 5;
+ IMAGE_CONTRAST_CHECK = 6;
+ LINK_PURPOSE_UNCLEAR_CHECK = 7;
+ REDUNDANT_DESCRIPTION_CHECK = 8;
+ SPEAKABLE_TEXT_PRESENT_CHECK = 9;
+ TEXT_CONTRAST_CHECK = 10;
+ TEXT_SIZE_CHECK = 11;
+ TOUCH_TARGET_SIZE_CHECK = 12;
+ TRAVERSAL_ORDER_CHECK = 13;
+}
+// LINT.ThenChange(/services/accessibility/java/com/android/server/accessibility/a11ychecker/AccessibilityCheckerUtils.java)
+
+/** The type of AccessibilityCheckResult */
+enum AccessibilityCheckResultType {
+ UNKNOWN_RESULT_TYPE = 0;
+ ERROR = 1;
+ WARNING = 2;
+}