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

Commit 1054f4ec authored by Treehugger Robot's avatar Treehugger Robot Committed by Android (Google) Code Review
Browse files

Merge "Added AccessibilityCheckerManager which runs a11y checks and caches results" into main

parents e044a2a4 cdceb40d
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