Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit cdceb40d authored by Yara Hassan's avatar Yara Hassan
Browse files

Added AccessibilityCheckerManager which runs a11y checks and caches results

Bug: 326385939
Test: unit tests
Flag: com.android.server.accessibility.enable_a11y_checker_logging
Change-Id: Icfb13d105aaa2644ac6d527706341d0f627832b3
parent 6a3e6b1a
Loading
Loading
Loading
Loading
+33 −0
Original line number Diff line number Diff line
/*
 * 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 java.time.Duration;

/**
 * Constants used by the accessibility checker.
 *
 * @hide
 */
final class AccessibilityCheckerConstants {

    // The min required duration between two consecutive runs of the a11y checker.
    static final Duration MIN_DURATION_BETWEEN_CHECKS = Duration.ofMinutes(1);

    // The max number of cached results at a time.
    static final int MAX_CACHE_CAPACITY = 10000;
}
+169 −0
Original line number Diff line number Diff line
/*
 * 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.AccessibilityCheckerConstants.MAX_CACHE_CAPACITY;
import static com.android.server.accessibility.a11ychecker.AccessibilityCheckerConstants.MIN_DURATION_BETWEEN_CHECKS;

import android.annotation.Nullable;
import android.annotation.RequiresPermission;
import android.annotation.UserIdInt;
import android.content.ComponentName;
import android.content.Context;
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.Flags;
import com.android.server.accessibility.a11ychecker.A11yCheckerProto.AccessibilityCheckResultReported;

import com.google.android.apps.common.testing.accessibility.framework.AccessibilityCheckPreset;
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.uielement.AccessibilityHierarchy;
import com.google.android.apps.common.testing.accessibility.framework.uielement.AccessibilityHierarchyAndroid;

import java.time.Instant;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;


/**
 * The class responsible for running AccessibilityChecks on cached nodes and caching the results for
 * logging. Results are cached and capped to limit the logging frequency and size.
 *
 * @hide
 */
public final class AccessibilityCheckerManager {
    private static final String LOG_TAG = "AccessibilityCheckerManager";

    private final PackageManager mPackageManager;
    private final Set<AccessibilityHierarchyCheck> mHierarchyChecks;
    private final ATFHierarchyBuilder mATFHierarchyBuilder;
    private final Set<AccessibilityCheckResultReported> mCachedResults = new HashSet<>();
    @VisibleForTesting
    final A11yCheckerTimer mTimer = new A11yCheckerTimer();

    public AccessibilityCheckerManager(Context context) {
        this(AccessibilityCheckPreset.getAccessibilityHierarchyChecksForPreset(
                        AccessibilityCheckPreset.LATEST),
                (nodeInfo) -> AccessibilityHierarchyAndroid.newBuilder(nodeInfo, context).build(),
                context.getPackageManager());
    }

    @VisibleForTesting
    AccessibilityCheckerManager(
            Set<AccessibilityHierarchyCheck> hierarchyChecks,
            ATFHierarchyBuilder atfHierarchyBuilder,
            PackageManager packageManager) {
        this.mHierarchyChecks = hierarchyChecks;
        this.mATFHierarchyBuilder = atfHierarchyBuilder;
        this.mPackageManager = packageManager;
    }

    /**
     * If eligible, runs AccessibilityChecks on the given nodes and caches the results for later
     * logging. Returns the check results for the given nodes.
     */
    @RequiresPermission(allOf = {android.Manifest.permission.INTERACT_ACROSS_USERS_FULL})
    public Set<AccessibilityCheckResultReported> maybeRunA11yChecker(
            List<AccessibilityNodeInfo> nodes,
            @Nullable AccessibilityEvent accessibilityEvent,
            ComponentName sourceComponentName,
            @UserIdInt int userId) {
        if (!shouldRunA11yChecker()) {
            return Set.of();
        }

        Set<AccessibilityCheckResultReported> allResults = new HashSet<>();
        String defaultBrowserName = mPackageManager.getDefaultBrowserPackageNameAsUser(userId);

        try {
            for (AccessibilityNodeInfo nodeInfo : nodes) {
                // Skip browser results because they are mostly related to web content and not the
                // browser app itself.
                if (nodeInfo.getPackageName() == null
                        || nodeInfo.getPackageName().toString().equals(defaultBrowserName)) {
                    continue;
                }
                List<AccessibilityHierarchyCheckResult> checkResults = runChecksOnNode(nodeInfo);
                Set<AccessibilityCheckResultReported> filteredResults =
                        AccessibilityCheckerUtils.processResults(nodeInfo, checkResults,
                                accessibilityEvent, mPackageManager, sourceComponentName);
                allResults.addAll(filteredResults);
            }
            mCachedResults.addAll(allResults);
        } catch (RuntimeException e) {
            Slog.e(LOG_TAG, "An unknown error occurred while running a11y checker.", e);
        }

        return allResults;
    }

    private List<AccessibilityHierarchyCheckResult> runChecksOnNode(
            AccessibilityNodeInfo nodeInfo) {
        AccessibilityHierarchy checkableHierarchy = mATFHierarchyBuilder.getATFCheckableHierarchy(
                nodeInfo);
        List<AccessibilityHierarchyCheckResult> checkResults = new ArrayList<>();
        for (AccessibilityHierarchyCheck check : mHierarchyChecks) {
            checkResults.addAll(check.runCheckOnHierarchy(checkableHierarchy));
        }
        return checkResults;
    }

    public Set<AccessibilityCheckResultReported> getCachedResults() {
        return Collections.unmodifiableSet(mCachedResults);
    }

    @VisibleForTesting
    boolean shouldRunA11yChecker() {
        if (!Flags.enableA11yCheckerLogging() || mCachedResults.size() == MAX_CACHE_CAPACITY) {
            return false;
        }
        if (mTimer.getLastCheckTime() == null || mTimer.getLastCheckTime().plus(
                MIN_DURATION_BETWEEN_CHECKS).isBefore(Instant.now())) {
            mTimer.setLastCheckTime(Instant.now());
            return true;
        }
        return false;
    }

    /** Timer class to facilitate testing with fake times. */
    @VisibleForTesting
    static class A11yCheckerTimer {
        private Instant mLastCheckTime = null;

        Instant getLastCheckTime() {
            return mLastCheckTime;
        }

        void setLastCheckTime(Instant newTime) {
            mLastCheckTime = newTime;
        }
    }

    /** AccessibilityHierarchy wrapper to facilitate testing with fake hierarchies. */
    @VisibleForTesting
    interface ATFHierarchyBuilder {
        AccessibilityHierarchy getATFCheckableHierarchy(AccessibilityNodeInfo nodeInfo);
    }
}
+6 −13
Original line number Diff line number Diff line
@@ -19,7 +19,6 @@ 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;
@@ -62,6 +61,7 @@ import java.util.stream.Collectors;
public class AccessibilityCheckerUtils {

    private static final String LOG_TAG = "AccessibilityCheckerUtils";

    @VisibleForTesting
    // LINT.IfChange
    static final Map<Class<? extends AccessibilityHierarchyCheck>, AccessibilityCheckClass>
@@ -93,17 +93,6 @@ public class AccessibilityCheckerUtils {
                            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,
@@ -111,12 +100,16 @@ public class AccessibilityCheckerUtils {
            PackageManager packageManager,
            ComponentName a11yServiceComponentName) {
        String appPackageName = nodeInfo.getPackageName().toString();
        String nodePath = AccessibilityNodePathBuilder.createNodePath(nodeInfo);
        if (nodePath == null) {
            return Set.of();
        }
        AccessibilityCheckResultReported.Builder builder;
        try {
            builder = AccessibilityCheckResultReported.newBuilder()
                    .setPackageName(appPackageName)
                    .setAppVersionCode(getAppVersionCode(packageManager, appPackageName))
                    .setUiElementPath(AccessibilityNodePathBuilder.createNodePath(nodeInfo))
                    .setUiElementPath(nodePath)
                    .setActivityName(getActivityName(packageManager, accessibilityEvent))
                    .setWindowTitle(getWindowTitle(nodeInfo))
                    .setSourceComponentName(a11yServiceComponentName.flattenToString())
+18 −12
Original line number Diff line number Diff line
@@ -47,12 +47,15 @@ public final class AccessibilityNodePathBuilder {
     *
     * <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.
     * See {@link com.google.android.apps.common.testing.accessibility.framework.ClusteringUtils}.
     */
    public static @Nullable String createNodePath(@NonNull AccessibilityNodeInfo nodeInfo) {
        String packageName = nodeInfo.getPackageName().toString();
        if (packageName == null) {
            return null;
        }
        StringBuilder resourceIdBuilder = getNodePathBuilder(nodeInfo);
        return resourceIdBuilder == null ? null : String.valueOf(nodeInfo.getPackageName()) + ':'
                + resourceIdBuilder;
        return resourceIdBuilder == null ? null : packageName + ':' + resourceIdBuilder;
    }

    private static @Nullable StringBuilder getNodePathBuilder(AccessibilityNodeInfo nodeInfo) {
@@ -84,20 +87,23 @@ public final class AccessibilityNodePathBuilder {
    //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) {
    private static @Nullable CharSequence getShortUiElementName(AccessibilityNodeInfo nodeInfo) {
        String viewIdResourceName = nodeInfo.getViewIdResourceName();
        if (viewIdResourceName != null) {
        if (viewIdResourceName == null) {
            return getSimpleClassName(nodeInfo);
        }
        String idQualifier = ":id/";
        int idQualifierStartIndex = viewIdResourceName.indexOf(idQualifier);
            int unqualifiedNameStartIndex = idQualifierStartIndex == -1 ? 0
                    : (idQualifierStartIndex + idQualifier.length());
        int unqualifiedNameStartIndex =
                idQualifierStartIndex == -1 ? 0 : (idQualifierStartIndex + idQualifier.length());
        return viewIdResourceName.substring(unqualifiedNameStartIndex);
    }
        return getSimpleClassName(nodeInfo);
    }

    private static CharSequence getSimpleClassName(AccessibilityNodeInfo nodeInfo) {
    private static @Nullable CharSequence getSimpleClassName(AccessibilityNodeInfo nodeInfo) {
        CharSequence name = nodeInfo.getClassName();
        if (name == null) {
            return null;
        }
        for (int i = name.length() - 1; i > 0; i--) {
            char ithChar = name.charAt(i);
            if (ithChar == '.' || ithChar == '$') {
+192 −0
Original line number Diff line number Diff line
/*
 * 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.Flags.FLAG_ENABLE_A11Y_CHECKER_LOGGING;
import static com.android.server.accessibility.a11ychecker.AccessibilityCheckerConstants.MIN_DURATION_BETWEEN_CHECKS;
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_ACTIVITY_NAME;
import static com.android.server.accessibility.a11ychecker.TestUtils.TEST_DEFAULT_BROWSER;
import static com.android.server.accessibility.a11ychecker.TestUtils.createAtom;
import static com.android.server.accessibility.a11ychecker.TestUtils.getMockPackageManagerWithInstalledApps;
import static com.android.server.accessibility.a11ychecker.TestUtils.getTestAccessibilityEvent;

import static com.google.common.truth.Truth.assertThat;

import static org.mockito.Mockito.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

import android.content.ComponentName;
import android.content.pm.PackageManager;
import android.platform.test.annotations.DisableFlags;
import android.platform.test.annotations.EnableFlags;
import android.platform.test.flag.junit.SetFlagsRule;
import android.view.accessibility.AccessibilityNodeInfo;

import androidx.test.runner.AndroidJUnit4;

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.TouchTargetSizeCheck;
import com.google.android.apps.common.testing.accessibility.framework.uielement.AccessibilityHierarchy;

import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;

import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Set;

@RunWith(AndroidJUnit4.class)
public class AccessibilityCheckerManagerTest {
    @Rule
    public final SetFlagsRule mSetFlagsRule = new SetFlagsRule();

    private AccessibilityCheckerManager mAccessibilityCheckerManager;

    @Before
    public void setup() throws PackageManager.NameNotFoundException {
        PackageManager mockPackageManager = getMockPackageManagerWithInstalledApps();
        mAccessibilityCheckerManager = new AccessibilityCheckerManager(setupMockChecks(),
                nodeInfo -> mock(AccessibilityHierarchy.class), mockPackageManager);
    }


    @Test
    @EnableFlags(FLAG_ENABLE_A11Y_CHECKER_LOGGING)
    public void shouldRunA11yChecker_firstUpdate() {
        assertThat(mAccessibilityCheckerManager.shouldRunA11yChecker()).isTrue();
    }

    @Test
    @EnableFlags(FLAG_ENABLE_A11Y_CHECKER_LOGGING)
    public void shouldRunA11yChecker_minDurationPassed() {
        mAccessibilityCheckerManager.mTimer.setLastCheckTime(
                Instant.now().minus(MIN_DURATION_BETWEEN_CHECKS.plus(Duration.ofSeconds(2))));
        assertThat(mAccessibilityCheckerManager.shouldRunA11yChecker()).isTrue();
    }

    @Test
    @EnableFlags(FLAG_ENABLE_A11Y_CHECKER_LOGGING)
    public void shouldRunA11yChecker_tooEarly() {
        mAccessibilityCheckerManager.mTimer.setLastCheckTime(
                Instant.now().minus(MIN_DURATION_BETWEEN_CHECKS.minus(Duration.ofSeconds(2))));
        assertThat(mAccessibilityCheckerManager.shouldRunA11yChecker()).isFalse();
    }

    @Test
    @DisableFlags(FLAG_ENABLE_A11Y_CHECKER_LOGGING)
    public void shouldRunA11yChecker_featureDisabled() {
        assertThat(mAccessibilityCheckerManager.shouldRunA11yChecker()).isFalse();
    }

    @Test
    @EnableFlags(FLAG_ENABLE_A11Y_CHECKER_LOGGING)
    public void maybeRunA11yChecker_happyPath() {
        AccessibilityNodeInfo mockNodeInfo1 =
                new MockAccessibilityNodeInfoBuilder()
                        .setViewIdResourceName("node1")
                        .build();
        AccessibilityNodeInfo mockNodeInfo2 =
                new MockAccessibilityNodeInfoBuilder()
                        .setViewIdResourceName("node2")
                        .build();

        Set<A11yCheckerProto.AccessibilityCheckResultReported> results =
                mAccessibilityCheckerManager.maybeRunA11yChecker(
                        List.of(mockNodeInfo1, mockNodeInfo2), getTestAccessibilityEvent(),
                        new ComponentName(TEST_A11Y_SERVICE_SOURCE_PACKAGE_NAME,
                                TEST_A11Y_SERVICE_CLASS_NAME), /*userId=*/ 0);

        assertThat(results).containsExactly(
                createAtom(/*viewIdResourceName=*/ "node1", TEST_ACTIVITY_NAME,
                        A11yCheckerProto.AccessibilityCheckClass.TOUCH_TARGET_SIZE_CHECK,
                        A11yCheckerProto.AccessibilityCheckResultType.ERROR, /*resultId=*/ 2),
                createAtom(/*viewIdResourceName=*/ "node2", TEST_ACTIVITY_NAME,
                        A11yCheckerProto.AccessibilityCheckClass.TOUCH_TARGET_SIZE_CHECK,
                        A11yCheckerProto.AccessibilityCheckResultType.ERROR, /*resultId=*/ 2)
        );
    }

    @Test
    @EnableFlags(FLAG_ENABLE_A11Y_CHECKER_LOGGING)
    public void maybeRunA11yChecker_skipsNodesFromDefaultBrowser() {
        AccessibilityNodeInfo mockNodeInfo =
                new MockAccessibilityNodeInfoBuilder()
                        .setPackageName(TEST_DEFAULT_BROWSER)
                        .setViewIdResourceName("node1")
                        .build();

        Set<A11yCheckerProto.AccessibilityCheckResultReported> results =
                mAccessibilityCheckerManager.maybeRunA11yChecker(
                        List.of(mockNodeInfo), getTestAccessibilityEvent(),
                        new ComponentName(TEST_A11Y_SERVICE_SOURCE_PACKAGE_NAME,
                                TEST_A11Y_SERVICE_CLASS_NAME), /*userId=*/ 0);

        assertThat(results).isEmpty();
    }

    @Test
    @EnableFlags(FLAG_ENABLE_A11Y_CHECKER_LOGGING)
    public void maybeRunA11yChecker_doesNotStoreDuplicates() {
        AccessibilityNodeInfo mockNodeInfo =
                new MockAccessibilityNodeInfoBuilder()
                        .setViewIdResourceName("node1")
                        .build();
        AccessibilityNodeInfo mockNodeInfoDuplicate =
                new MockAccessibilityNodeInfoBuilder()
                        .setViewIdResourceName("node1")
                        .build();

        Set<A11yCheckerProto.AccessibilityCheckResultReported> results =
                mAccessibilityCheckerManager.maybeRunA11yChecker(
                        List.of(mockNodeInfo, mockNodeInfoDuplicate), getTestAccessibilityEvent(),
                        new ComponentName(TEST_A11Y_SERVICE_SOURCE_PACKAGE_NAME,
                                TEST_A11Y_SERVICE_CLASS_NAME), /*userId=*/ 0);

        assertThat(results).containsExactly(
                createAtom(/*viewIdResourceName=*/ "node1", TEST_ACTIVITY_NAME,
                        A11yCheckerProto.AccessibilityCheckClass.TOUCH_TARGET_SIZE_CHECK,
                        A11yCheckerProto.AccessibilityCheckResultType.ERROR, /*resultId=*/ 2)
        );
    }

    private Set<AccessibilityHierarchyCheck> setupMockChecks() {
        AccessibilityHierarchyCheck mockCheck1 = mock(AccessibilityHierarchyCheck.class);
        AccessibilityHierarchyCheckResult infoTypeResult =
                new AccessibilityHierarchyCheckResult(
                        TouchTargetSizeCheck.class,
                        AccessibilityCheckResult.AccessibilityCheckResultType.INFO, null, 1, null);
        when(mockCheck1.runCheckOnHierarchy(any())).thenReturn(List.of(infoTypeResult));

        AccessibilityHierarchyCheck mockCheck2 = mock(AccessibilityHierarchyCheck.class);
        AccessibilityHierarchyCheckResult errorTypeResult =
                new AccessibilityHierarchyCheckResult(
                        TouchTargetSizeCheck.class,
                        AccessibilityCheckResult.AccessibilityCheckResultType.ERROR, null, 2,
                        null);
        when(mockCheck2.runCheckOnHierarchy(any())).thenReturn(List.of(errorTypeResult));

        return Set.of(mockCheck1, mockCheck2);
    }
}
Loading