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;
+}
diff --git a/services/tests/servicestests/Android.bp b/services/tests/servicestests/Android.bp
index 753db12..b9e99dd 100644
--- a/services/tests/servicestests/Android.bp
+++ b/services/tests/servicestests/Android.bp
@@ -36,6 +36,8 @@
"-Werror",
],
static_libs: [
+ "a11ychecker-protos-java-proto-lite",
+ "aatf",
"cts-input-lib",
"frameworks-base-testutils",
"services.accessibility",
diff --git a/services/tests/servicestests/src/com/android/server/accessibility/a11ychecker/AccessibilityCheckerUtilsTest.java b/services/tests/servicestests/src/com/android/server/accessibility/a11ychecker/AccessibilityCheckerUtilsTest.java
new file mode 100644
index 0000000..90d4275
--- /dev/null
+++ b/services/tests/servicestests/src/com/android/server/accessibility/a11ychecker/AccessibilityCheckerUtilsTest.java
@@ -0,0 +1,187 @@
+/*
+ * 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 static com.android.server.accessibility.a11ychecker.TestUtils.TEST_A11Y_SERVICE_CLASS_NAME;
+import static com.android.server.accessibility.a11ychecker.TestUtils.TEST_A11Y_SERVICE_SOURCE_PACKAGE_NAME;
+import static com.android.server.accessibility.a11ychecker.TestUtils.TEST_A11Y_SERVICE_SOURCE_VERSION_CODE;
+import static com.android.server.accessibility.a11ychecker.TestUtils.TEST_ACTIVITY_NAME;
+import static com.android.server.accessibility.a11ychecker.TestUtils.TEST_APP_PACKAGE_NAME;
+import static com.android.server.accessibility.a11ychecker.TestUtils.TEST_APP_VERSION_CODE;
+import static com.android.server.accessibility.a11ychecker.TestUtils.TEST_WINDOW_TITLE;
+import static com.android.server.accessibility.a11ychecker.TestUtils.getMockPackageManagerWithInstalledApps;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.Mockito.when;
+
+import android.content.ComponentName;
+import android.content.pm.PackageManager;
+import android.view.accessibility.AccessibilityEvent;
+import android.view.accessibility.AccessibilityNodeInfo;
+
+import androidx.test.runner.AndroidJUnit4;
+
+import com.google.android.apps.common.testing.accessibility.framework.AccessibilityCheckPreset;
+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.SpeakableTextPresentCheck;
+import com.google.android.apps.common.testing.accessibility.framework.checks.TouchTargetSizeCheck;
+import com.google.common.collect.ImmutableSet;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+@RunWith(AndroidJUnit4.class)
+public class AccessibilityCheckerUtilsTest {
+
+ PackageManager mMockPackageManager;
+
+ @Before
+ public void setUp() throws PackageManager.NameNotFoundException {
+ mMockPackageManager = getMockPackageManagerWithInstalledApps();
+ }
+
+ @Test
+ public void processResults_happyPath_setsAllFields() {
+ AccessibilityNodeInfo mockNodeInfo =
+ new MockAccessibilityNodeInfoBuilder()
+ .setViewIdResourceName("TargetNode")
+ .build();
+ AccessibilityHierarchyCheckResult result1 =
+ new AccessibilityHierarchyCheckResult(
+ SpeakableTextPresentCheck.class,
+ AccessibilityCheckResult.AccessibilityCheckResultType.WARNING, null, 1,
+ null);
+ AccessibilityHierarchyCheckResult result2 =
+ new AccessibilityHierarchyCheckResult(
+ TouchTargetSizeCheck.class,
+ AccessibilityCheckResult.AccessibilityCheckResultType.ERROR, null, 2, null);
+ AccessibilityHierarchyCheckResult result3 =
+ new AccessibilityHierarchyCheckResult(
+ ClassNameCheck.class,
+ AccessibilityCheckResult.AccessibilityCheckResultType.INFO, null, 5, null);
+ AccessibilityHierarchyCheckResult result4 =
+ new AccessibilityHierarchyCheckResult(
+ ClickableSpanCheck.class,
+ AccessibilityCheckResult.AccessibilityCheckResultType.NOT_RUN, null, 5,
+ null);
+
+ Set<A11yCheckerProto.AccessibilityCheckResultReported> atoms =
+ AccessibilityCheckerUtils.processResults(
+ mockNodeInfo,
+ List.of(result1, result2, result3, result4),
+ null,
+ mMockPackageManager,
+ new ComponentName(TEST_A11Y_SERVICE_SOURCE_PACKAGE_NAME,
+ TEST_A11Y_SERVICE_CLASS_NAME));
+
+ assertThat(atoms).containsExactly(
+ createAtom(A11yCheckerProto.AccessibilityCheckClass.SPEAKABLE_TEXT_PRESENT_CHECK,
+ A11yCheckerProto.AccessibilityCheckResultType.WARNING, 1),
+ createAtom(A11yCheckerProto.AccessibilityCheckClass.TOUCH_TARGET_SIZE_CHECK,
+ A11yCheckerProto.AccessibilityCheckResultType.ERROR, 2)
+ );
+ }
+
+ @Test
+ public void processResults_packageNameNotFound_returnsEmptySet()
+ throws PackageManager.NameNotFoundException {
+ when(mMockPackageManager.getPackageInfo("com.uninstalled.app", 0))
+ .thenThrow(PackageManager.NameNotFoundException.class);
+ AccessibilityNodeInfo mockNodeInfo =
+ new MockAccessibilityNodeInfoBuilder()
+ .setPackageName("com.uninstalled.app")
+ .setViewIdResourceName("TargetNode")
+ .build();
+ AccessibilityHierarchyCheckResult result1 =
+ new AccessibilityHierarchyCheckResult(
+ TouchTargetSizeCheck.class,
+ AccessibilityCheckResult.AccessibilityCheckResultType.WARNING, null, 1,
+ null);
+ AccessibilityHierarchyCheckResult result2 =
+ new AccessibilityHierarchyCheckResult(
+ TouchTargetSizeCheck.class,
+ AccessibilityCheckResult.AccessibilityCheckResultType.ERROR, null, 2, null);
+
+ Set<A11yCheckerProto.AccessibilityCheckResultReported> atoms =
+ AccessibilityCheckerUtils.processResults(
+ mockNodeInfo,
+ List.of(result1, result2),
+ null,
+ mMockPackageManager,
+ new ComponentName(TEST_A11Y_SERVICE_SOURCE_PACKAGE_NAME,
+ TEST_A11Y_SERVICE_CLASS_NAME));
+
+ assertThat(atoms).isEmpty();
+ }
+
+ @Test
+ public void getActivityName_hasWindowStateChangedEvent_returnsActivityName() {
+ AccessibilityEvent accessibilityEvent =
+ AccessibilityEvent.obtain(AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED);
+ accessibilityEvent.setPackageName(TEST_APP_PACKAGE_NAME);
+ accessibilityEvent.setClassName(TEST_ACTIVITY_NAME);
+
+ assertThat(AccessibilityCheckerUtils.getActivityName(mMockPackageManager,
+ accessibilityEvent)).isEqualTo("MainActivity");
+ }
+
+ // Makes sure the AccessibilityHierarchyCheck class to enum mapping is up to date with the
+ // latest prod preset.
+ @Test
+ public void checkClassToEnumMap_hasAllLatestPreset() {
+ ImmutableSet<AccessibilityHierarchyCheck> checkPreset =
+ AccessibilityCheckPreset.getAccessibilityHierarchyChecksForPreset(
+ AccessibilityCheckPreset.LATEST);
+ Set<Class<? extends AccessibilityHierarchyCheck>> latestCheckClasses =
+ checkPreset.stream().map(AccessibilityHierarchyCheck::getClass).collect(
+ Collectors.toUnmodifiableSet());
+
+ assertThat(AccessibilityCheckerUtils.CHECK_CLASS_TO_ENUM_MAP.keySet())
+ .containsExactlyElementsIn(latestCheckClasses);
+ }
+
+
+ private static A11yCheckerProto.AccessibilityCheckResultReported createAtom(
+ A11yCheckerProto.AccessibilityCheckClass checkClass,
+ A11yCheckerProto.AccessibilityCheckResultType resultType,
+ int resultId) {
+ return A11yCheckerProto.AccessibilityCheckResultReported.newBuilder()
+ .setPackageName(TEST_APP_PACKAGE_NAME)
+ .setAppVersionCode(TEST_APP_VERSION_CODE)
+ .setUiElementPath(TEST_APP_PACKAGE_NAME + ":TargetNode")
+ .setWindowTitle(TEST_WINDOW_TITLE)
+ .setActivityName("")
+ .setSourceComponentName(new ComponentName(TEST_A11Y_SERVICE_SOURCE_PACKAGE_NAME,
+ TEST_A11Y_SERVICE_CLASS_NAME).flattenToString())
+ .setSourceVersionCode(TEST_A11Y_SERVICE_SOURCE_VERSION_CODE)
+ .setResultCheckClass(checkClass)
+ .setResultType(resultType)
+ .setResultId(resultId)
+ .build();
+ }
+
+}
diff --git a/services/tests/servicestests/src/com/android/server/accessibility/a11ychecker/AccessibilityNodePathBuilderTest.java b/services/tests/servicestests/src/com/android/server/accessibility/a11ychecker/AccessibilityNodePathBuilderTest.java
new file mode 100644
index 0000000..a53f42e
--- /dev/null
+++ b/services/tests/servicestests/src/com/android/server/accessibility/a11ychecker/AccessibilityNodePathBuilderTest.java
@@ -0,0 +1,145 @@
+/*
+ * 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 static com.android.server.accessibility.a11ychecker.TestUtils.TEST_APP_PACKAGE_NAME;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.view.accessibility.AccessibilityNodeInfo;
+import android.widget.FrameLayout;
+import android.widget.TextView;
+
+import androidx.test.runner.AndroidJUnit4;
+
+import com.android.internal.widget.RecyclerView;
+
+import com.google.common.collect.ImmutableList;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class AccessibilityNodePathBuilderTest {
+
+ public static final String RESOURCE_ID_PREFIX = TEST_APP_PACKAGE_NAME + ":id/";
+
+ @Test
+ public void createNodePath_pathWithResourceNames() {
+ AccessibilityNodeInfo child = new MockAccessibilityNodeInfoBuilder()
+ .setViewIdResourceName(RESOURCE_ID_PREFIX + "child_node")
+ .build();
+ AccessibilityNodeInfo parent =
+ new MockAccessibilityNodeInfoBuilder()
+ .setViewIdResourceName(RESOURCE_ID_PREFIX + "parent_node")
+ .addChildren(ImmutableList.of(child))
+ .build();
+ AccessibilityNodeInfo root =
+ new MockAccessibilityNodeInfoBuilder()
+ .setViewIdResourceName(RESOURCE_ID_PREFIX + "root_node")
+ .addChildren(ImmutableList.of(parent))
+ .build();
+
+ assertThat(AccessibilityNodePathBuilder.createNodePath(child))
+ .isEqualTo(TEST_APP_PACKAGE_NAME + ":root_node/parent_node[1]/child_node[1]");
+ assertThat(AccessibilityNodePathBuilder.createNodePath(parent))
+ .isEqualTo(TEST_APP_PACKAGE_NAME + ":root_node/parent_node[1]");
+ assertThat(AccessibilityNodePathBuilder.createNodePath(root))
+ .isEqualTo(TEST_APP_PACKAGE_NAME + ":root_node");
+ }
+
+ @Test
+ public void createNodePath_pathWithoutResourceNames() {
+ AccessibilityNodeInfo child =
+ new MockAccessibilityNodeInfoBuilder()
+ .setClassName(TextView.class.getName())
+ .build();
+ AccessibilityNodeInfo parent =
+
+ new MockAccessibilityNodeInfoBuilder()
+ .setClassName(RecyclerView.class.getName())
+ .addChildren(ImmutableList.of(child))
+ .build();
+ AccessibilityNodeInfo root =
+ new MockAccessibilityNodeInfoBuilder()
+ .setClassName(FrameLayout.class.getName())
+ .addChildren(ImmutableList.of(parent))
+ .build();
+
+ assertThat(AccessibilityNodePathBuilder.createNodePath(child))
+ .isEqualTo(TEST_APP_PACKAGE_NAME + ":FrameLayout/RecyclerView[1]/TextView[1]");
+ assertThat(AccessibilityNodePathBuilder.createNodePath(parent))
+ .isEqualTo(TEST_APP_PACKAGE_NAME + ":FrameLayout/RecyclerView[1]");
+ assertThat(AccessibilityNodePathBuilder.createNodePath(root))
+ .isEqualTo(TEST_APP_PACKAGE_NAME + ":FrameLayout");
+ }
+
+ @Test
+ public void createNodePath_parentWithMultipleChildren() {
+ AccessibilityNodeInfo child1 =
+ new MockAccessibilityNodeInfoBuilder()
+ .setViewIdResourceName(RESOURCE_ID_PREFIX + "child1")
+ .build();
+ AccessibilityNodeInfo child2 =
+ new MockAccessibilityNodeInfoBuilder()
+ .setClassName(TextView.class.getName())
+ .build();
+ AccessibilityNodeInfo parent =
+ new MockAccessibilityNodeInfoBuilder()
+ .setClassName(FrameLayout.class.getName())
+ .addChildren(ImmutableList.of(child1, child2))
+ .build();
+
+ assertThat(AccessibilityNodePathBuilder.createNodePath(child1))
+ .isEqualTo(TEST_APP_PACKAGE_NAME + ":FrameLayout/child1[1]");
+ assertThat(AccessibilityNodePathBuilder.createNodePath(child2))
+ .isEqualTo(TEST_APP_PACKAGE_NAME + ":FrameLayout/TextView[2]");
+ assertThat(AccessibilityNodePathBuilder.createNodePath(parent))
+ .isEqualTo(TEST_APP_PACKAGE_NAME + ":FrameLayout");
+ }
+
+ @Test
+ public void createNodePath_handlesDifferentIdFormats() {
+ AccessibilityNodeInfo child1 =
+ new MockAccessibilityNodeInfoBuilder()
+ .setViewIdResourceName(RESOURCE_ID_PREFIX + "childId")
+ .build();
+ AccessibilityNodeInfo child2 =
+ new MockAccessibilityNodeInfoBuilder()
+ .setViewIdResourceName(RESOURCE_ID_PREFIX + "child/Id/With/Slash")
+ .build();
+ AccessibilityNodeInfo child3 =
+ new MockAccessibilityNodeInfoBuilder()
+ .setViewIdResourceName("childIdWithoutPrefix")
+ .build();
+ AccessibilityNodeInfo parent =
+ new MockAccessibilityNodeInfoBuilder()
+ .addChildren(ImmutableList.of(child1, child2, child3))
+ .setViewIdResourceName(RESOURCE_ID_PREFIX + "parentId")
+ .build();
+
+ assertThat(AccessibilityNodePathBuilder.createNodePath(child1))
+ .isEqualTo(TEST_APP_PACKAGE_NAME + ":parentId/childId[1]");
+ assertThat(AccessibilityNodePathBuilder.createNodePath(child2))
+ .isEqualTo(TEST_APP_PACKAGE_NAME + ":parentId/child/Id/With/Slash[2]");
+ assertThat(AccessibilityNodePathBuilder.createNodePath(child3))
+ .isEqualTo(TEST_APP_PACKAGE_NAME + ":parentId/childIdWithoutPrefix[3]");
+ assertThat(AccessibilityNodePathBuilder.createNodePath(parent))
+ .isEqualTo(TEST_APP_PACKAGE_NAME + ":parentId");
+ }
+
+}
diff --git a/services/tests/servicestests/src/com/android/server/accessibility/a11ychecker/MockAccessibilityNodeInfoBuilder.java b/services/tests/servicestests/src/com/android/server/accessibility/a11ychecker/MockAccessibilityNodeInfoBuilder.java
new file mode 100644
index 0000000..7cd3535
--- /dev/null
+++ b/services/tests/servicestests/src/com/android/server/accessibility/a11ychecker/MockAccessibilityNodeInfoBuilder.java
@@ -0,0 +1,70 @@
+/*
+ * 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 static com.android.server.accessibility.a11ychecker.TestUtils.TEST_APP_PACKAGE_NAME;
+import static com.android.server.accessibility.a11ychecker.TestUtils.TEST_WINDOW_TITLE;
+
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import android.view.accessibility.AccessibilityNodeInfo;
+import android.view.accessibility.AccessibilityWindowInfo;
+
+import java.util.List;
+
+final class MockAccessibilityNodeInfoBuilder {
+ private final AccessibilityNodeInfo mMockNodeInfo = mock(AccessibilityNodeInfo.class);
+
+ MockAccessibilityNodeInfoBuilder() {
+ setPackageName(TEST_APP_PACKAGE_NAME);
+
+ AccessibilityWindowInfo windowInfo = new AccessibilityWindowInfo();
+ windowInfo.setTitle(TEST_WINDOW_TITLE);
+ when(mMockNodeInfo.getWindow()).thenReturn(windowInfo);
+ }
+
+ MockAccessibilityNodeInfoBuilder setPackageName(String packageName) {
+ when(mMockNodeInfo.getPackageName()).thenReturn(packageName);
+ return this;
+ }
+
+ MockAccessibilityNodeInfoBuilder setClassName(String className) {
+ when(mMockNodeInfo.getClassName()).thenReturn(className);
+ return this;
+ }
+
+ MockAccessibilityNodeInfoBuilder setViewIdResourceName(String
+ viewIdResourceName) {
+ when(mMockNodeInfo.getViewIdResourceName()).thenReturn(viewIdResourceName);
+ return this;
+ }
+
+ MockAccessibilityNodeInfoBuilder addChildren(List<AccessibilityNodeInfo>
+ children) {
+ when(mMockNodeInfo.getChildCount()).thenReturn(children.size());
+ for (int i = 0; i < children.size(); i++) {
+ when(mMockNodeInfo.getChild(i)).thenReturn(children.get(i));
+ when(children.get(i).getParent()).thenReturn(mMockNodeInfo);
+ }
+ return this;
+ }
+
+ AccessibilityNodeInfo build() {
+ return mMockNodeInfo;
+ }
+}
diff --git a/services/tests/servicestests/src/com/android/server/accessibility/a11ychecker/OWNERS b/services/tests/servicestests/src/com/android/server/accessibility/a11ychecker/OWNERS
new file mode 100644
index 0000000..7bdc029
--- /dev/null
+++ b/services/tests/servicestests/src/com/android/server/accessibility/a11ychecker/OWNERS
@@ -0,0 +1 @@
+include /services/accessibility/java/com/android/server/accessibility/a11ychecker/OWNERS
diff --git a/services/tests/servicestests/src/com/android/server/accessibility/a11ychecker/TestUtils.java b/services/tests/servicestests/src/com/android/server/accessibility/a11ychecker/TestUtils.java
new file mode 100644
index 0000000..a04bbee
--- /dev/null
+++ b/services/tests/servicestests/src/com/android/server/accessibility/a11ychecker/TestUtils.java
@@ -0,0 +1,74 @@
+/*
+ * 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 static org.mockito.Mockito.when;
+
+import android.annotation.Nullable;
+import android.content.ComponentName;
+import android.content.pm.ActivityInfo;
+import android.content.pm.PackageInfo;
+import android.content.pm.PackageManager;
+
+import org.mockito.Mockito;
+
+public class TestUtils {
+ static final String TEST_APP_PACKAGE_NAME = "com.example.app";
+ static final int TEST_APP_VERSION_CODE = 12321;
+ static final String TEST_ACTIVITY_NAME = "com.example.app.MainActivity";
+ static final String TEST_A11Y_SERVICE_SOURCE_PACKAGE_NAME = "com.assistive.app";
+ static final String TEST_A11Y_SERVICE_CLASS_NAME = "MyA11yService";
+ static final int TEST_A11Y_SERVICE_SOURCE_VERSION_CODE = 333555;
+ static final String TEST_WINDOW_TITLE = "Example window";
+
+ static PackageManager getMockPackageManagerWithInstalledApps()
+ throws PackageManager.NameNotFoundException {
+ PackageManager mockPackageManager = Mockito.mock(PackageManager.class);
+ ActivityInfo testActivityInfo = getTestActivityInfo();
+ ComponentName testActivityComponentName = new ComponentName(TEST_APP_PACKAGE_NAME,
+ TEST_ACTIVITY_NAME);
+
+ when(mockPackageManager.getActivityInfo(testActivityComponentName, 0))
+ .thenReturn(testActivityInfo);
+ when(mockPackageManager.getPackageInfo(TEST_APP_PACKAGE_NAME, 0))
+ .thenReturn(createPackageInfo(TEST_APP_PACKAGE_NAME, TEST_APP_VERSION_CODE,
+ testActivityInfo));
+ when(mockPackageManager.getPackageInfo(TEST_A11Y_SERVICE_SOURCE_PACKAGE_NAME, 0))
+ .thenReturn(createPackageInfo(TEST_A11Y_SERVICE_SOURCE_PACKAGE_NAME,
+ TEST_A11Y_SERVICE_SOURCE_VERSION_CODE, null));
+ return mockPackageManager;
+ }
+
+ static ActivityInfo getTestActivityInfo() {
+ ActivityInfo activityInfo = new ActivityInfo();
+ activityInfo.packageName = TEST_APP_PACKAGE_NAME;
+ activityInfo.name = TEST_ACTIVITY_NAME;
+ return activityInfo;
+ }
+
+ static PackageInfo createPackageInfo(String packageName, int versionCode,
+ @Nullable ActivityInfo activityInfo) {
+ PackageInfo packageInfo = new PackageInfo();
+ packageInfo.packageName = packageName;
+ packageInfo.setLongVersionCode(versionCode);
+ if (activityInfo != null) {
+ packageInfo.activities = new ActivityInfo[]{activityInfo};
+ }
+ return packageInfo;
+
+ }
+}