chromium/ui/accessibility/android/javatests/src/org/chromium/ui/accessibility/AccessibilityStateTest.java

// Copyright 2023 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

package org.chromium.ui.accessibility;

import static org.chromium.ui.accessibility.AccessibilityState.StateIdentifierForTesting.CAPABILITIES_MASK;
import static org.chromium.ui.accessibility.AccessibilityState.StateIdentifierForTesting.CAPABILITIES_MASK_HEURISTIC;
import static org.chromium.ui.accessibility.AccessibilityState.StateIdentifierForTesting.EVENT_TYPE_MASK;
import static org.chromium.ui.accessibility.AccessibilityState.StateIdentifierForTesting.EVENT_TYPE_MASK_HEURISTIC;
import static org.chromium.ui.accessibility.AccessibilityState.StateIdentifierForTesting.FEEDBACK_TYPE_MASK;
import static org.chromium.ui.accessibility.AccessibilityState.StateIdentifierForTesting.FEEDBACK_TYPE_MASK_HEURISTIC;
import static org.chromium.ui.accessibility.AccessibilityState.StateIdentifierForTesting.FLAGS_MASK;
import static org.chromium.ui.accessibility.AccessibilityState.StateIdentifierForTesting.FLAGS_MASK_HEURISTIC;

import android.accessibilityservice.AccessibilityServiceInfo;
import android.content.Context;
import android.content.pm.ResolveInfo;
import android.content.pm.ServiceInfo;
import android.view.accessibility.AccessibilityEvent;

import androidx.test.filters.SmallTest;

import org.junit.After;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RuntimeEnvironment;

import org.chromium.base.test.BaseRobolectricTestRunner;

import java.lang.reflect.Constructor;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Set;

@RunWith(BaseRobolectricTestRunner.class)
public class AccessibilityStateTest {
    private static final String EVENT_TYPE_MASK_ERROR =
            "Conversion of event masks to event types not correct.";

    private static final int MOCK_EVENT_TYPE_MASK =
            AccessibilityEvent.TYPE_VIEW_CLICKED
                    | AccessibilityEvent.TYPE_VIEW_FOCUSED
                    | AccessibilityEvent.TYPE_VIEW_TEXT_CHANGED
                    | AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED;

    private static final int MOCK_FLAG_TYPE_MASK =
            AccessibilityServiceInfo.DEFAULT
                    | AccessibilityServiceInfo.FLAG_REQUEST_TOUCH_EXPLORATION_MODE
                    | AccessibilityServiceInfo.FLAG_REQUEST_ENHANCED_WEB_ACCESSIBILITY
                    | AccessibilityServiceInfo.FLAG_REPORT_VIEW_IDS
                    | AccessibilityServiceInfo.FLAG_RETRIEVE_INTERACTIVE_WINDOWS;

    private static final int MOCK_CAPABILITY_TYPE_MASK =
            AccessibilityServiceInfo.CAPABILITY_CAN_RETRIEVE_WINDOW_CONTENT;

    private Context mContext;

    @Before
    public void setUp() {
        mContext = RuntimeEnvironment.getApplication();

        // Reset all flags to empty/default state.
        AccessibilityState.setStateMaskForTesting(EVENT_TYPE_MASK, 0);
        AccessibilityState.setStateMaskForTesting(FEEDBACK_TYPE_MASK, 0);
        AccessibilityState.setStateMaskForTesting(FLAGS_MASK, 0);
        AccessibilityState.setStateMaskForTesting(CAPABILITIES_MASK, 0);
        AccessibilityState.setStateMaskForTesting(EVENT_TYPE_MASK_HEURISTIC, 0);
        AccessibilityState.setStateMaskForTesting(FEEDBACK_TYPE_MASK_HEURISTIC, 0);
        AccessibilityState.setStateMaskForTesting(FLAGS_MASK_HEURISTIC, 0);
        AccessibilityState.setStateMaskForTesting(CAPABILITIES_MASK_HEURISTIC, 0);
    }

    @After
    public void tearDown() {
        AccessibilityState.uninitializeForTesting();
    }

    @Test
    @SmallTest
    public void testSimpleString() {
        String inputString = "placeholder";
        List<String> response = AccessibilityState.getCanonicalizedEnabledServiceNames(inputString);

        Assert.assertNotNull(response);
        Assert.assertFalse(response.isEmpty());
        Assert.assertEquals(1, response.size());
        Assert.assertEquals("placeholder", response.get(0));
    }

    @Test
    @SmallTest
    public void testBadInput() {
        String inputString = "placeholder:::";
        List<String> response = AccessibilityState.getCanonicalizedEnabledServiceNames(inputString);

        Assert.assertNotNull(response);
        Assert.assertFalse(response.isEmpty());
        Assert.assertEquals(1, response.size());
        Assert.assertEquals("placeholder", response.get(0));
    }

    @Test
    @SmallTest
    public void testComplexString() {
        String inputString = "com.google.placeholder.test/com.test.google";
        List<String> response = AccessibilityState.getCanonicalizedEnabledServiceNames(inputString);

        Assert.assertNotNull(response);
        Assert.assertFalse(response.isEmpty());
        Assert.assertEquals(1, response.size());
        Assert.assertEquals("com.google.placeholder.test/com.test.google", response.get(0));
    }

    @Test
    @SmallTest
    public void testMultipleSimpleStrings() {
        String inputString = "placeholder:foo:bar";
        List<String> response = AccessibilityState.getCanonicalizedEnabledServiceNames(inputString);

        Assert.assertNotNull(response);
        Assert.assertFalse(response.isEmpty());
        Assert.assertEquals(3, response.size());
        Assert.assertEquals("placeholder", response.get(0));
        Assert.assertEquals("foo", response.get(1));
        Assert.assertEquals("bar", response.get(2));
    }

    @Test
    @SmallTest
    public void testMulitpleComplexStrings() {
        String inputString =
                "com.google.placeholder.test/com.test.google:"
                        + "placeholder:com.google.test/.classname:com.google.test/test.google";
        List<String> response = AccessibilityState.getCanonicalizedEnabledServiceNames(inputString);

        Assert.assertNotNull(response);
        Assert.assertFalse(response.isEmpty());
        Assert.assertEquals(4, response.size());
        Assert.assertEquals("com.google.placeholder.test/com.test.google", response.get(0));
        Assert.assertEquals("placeholder", response.get(1));
        Assert.assertEquals("com.google.test/.classname", response.get(2));
        Assert.assertEquals("com.google.test/test.google", response.get(3));
    }

    @Test
    @SmallTest
    public void testMulitpleComplexStringsIncludingBadInput() {
        String inputString =
                "com.google.placeholder.test/com.test.google:"
                        + "placeholder::::com.google.test/.classname:::com.google.test/test.google";
        List<String> response = AccessibilityState.getCanonicalizedEnabledServiceNames(inputString);

        Assert.assertNotNull(response);
        Assert.assertFalse(response.isEmpty());
        Assert.assertEquals(4, response.size());
        Assert.assertEquals("com.google.placeholder.test/com.test.google", response.get(0));
        Assert.assertEquals("placeholder", response.get(1));
        Assert.assertEquals("com.google.test/.classname", response.get(2));
        Assert.assertEquals("com.google.test/test.google", response.get(3));
    }

    @Test
    @SmallTest
    public void testEnabledServicesForTesting() {
        String enabledServicesForTesting = "placeholder:services";
        AccessibilityState.setEnabledServiceStringForTesting(enabledServicesForTesting);

        Assert.assertEquals(
                enabledServicesForTesting, AccessibilityState.getEnabledServiceString(null));
    }

    @Test
    @SmallTest
    public void testRunningServicesForTesting() {
        AccessibilityServiceInfo service1 = new AccessibilityServiceInfo();
        AccessibilityServiceInfo service2 = new AccessibilityServiceInfo();
        List<AccessibilityServiceInfo> serviceInfoList = new ArrayList<AccessibilityServiceInfo>();
        serviceInfoList.add(service1);
        serviceInfoList.add(service2);
        AccessibilityState.setEnabledServiceInfoListForTesting(serviceInfoList);

        List<AccessibilityServiceInfo> runningServices =
                AccessibilityState.getRunningServiceInfoList();
        Assert.assertNotNull(runningServices);
        Assert.assertFalse(runningServices.isEmpty());
        Assert.assertEquals(2, runningServices.size());
        Assert.assertEquals(service1, runningServices.get(0));
        Assert.assertEquals(service2, runningServices.get(1));
    }

    /** Test logic for converting event type masks to a list of relevant event types. */
    @Test
    @SmallTest
    public void testMaskToEventTypeConversion() {
        // Create some event masks with known outcomes.
        int serviceEventMask_empty = 0;
        int serviceEventMask_full = Integer.MAX_VALUE;
        int serviceEventMask_test =
                AccessibilityEvent.TYPE_VIEW_CLICKED
                        | AccessibilityEvent.TYPE_VIEW_LONG_CLICKED
                        | AccessibilityEvent.TYPE_VIEW_FOCUSED
                        | AccessibilityEvent.TYPE_VIEW_SCROLLED
                        | AccessibilityEvent.TYPE_VIEW_SELECTED
                        | AccessibilityEvent.TYPE_TOUCH_EXPLORATION_GESTURE_END;

        // Convert each mask to a set of eventTypes.
        AccessibilityState.setStateMaskForTesting(EVENT_TYPE_MASK, serviceEventMask_empty);
        Set<Integer> outcome_empty = AccessibilityState.relevantEventTypesForCurrentServices();

        AccessibilityState.setStateMaskForTesting(EVENT_TYPE_MASK, serviceEventMask_full);
        Set<Integer> outcome_full = AccessibilityState.relevantEventTypesForCurrentServices();

        AccessibilityState.setStateMaskForTesting(EVENT_TYPE_MASK, serviceEventMask_test);
        Set<Integer> outcome_test = AccessibilityState.relevantEventTypesForCurrentServices();

        // Verify results.
        Assert.assertNotNull(EVENT_TYPE_MASK_ERROR, outcome_empty);
        Assert.assertTrue(EVENT_TYPE_MASK_ERROR, outcome_empty.isEmpty());

        Assert.assertNotNull(EVENT_TYPE_MASK_ERROR, outcome_full);
        Assert.assertEquals(EVENT_TYPE_MASK_ERROR, 31, outcome_full.size());

        Set<Integer> expected_test =
                new HashSet<Integer>(
                        Arrays.asList(
                                AccessibilityEvent.TYPE_VIEW_CLICKED,
                                AccessibilityEvent.TYPE_VIEW_LONG_CLICKED,
                                AccessibilityEvent.TYPE_VIEW_FOCUSED,
                                AccessibilityEvent.TYPE_VIEW_SCROLLED,
                                AccessibilityEvent.TYPE_VIEW_SELECTED,
                                AccessibilityEvent.TYPE_TOUCH_EXPLORATION_GESTURE_END));

        Assert.assertNotNull(EVENT_TYPE_MASK_ERROR, outcome_test);
        Assert.assertEquals(EVENT_TYPE_MASK_ERROR, expected_test, outcome_test);
    }

    @Test
    @SmallTest
    public void testAreOnlyPasswordManagerFlagsRequested_empty() {
        Assert.assertFalse(AccessibilityState.areOnlyPasswordManagerMasksRequested());
    }

    @Test
    @SmallTest
    public void testAreOnlyPasswordManagerFlagsRequested_true() {
        AccessibilityState.setStateMaskForTesting(
                EVENT_TYPE_MASK_HEURISTIC, AccessibilityState.PASSWORD_MANAGER_EVENT_TYPE_MASK);
        AccessibilityState.setStateMaskForTesting(
                FLAGS_MASK_HEURISTIC, AccessibilityState.PASSWORD_MANAGER_FLAG_TYPE_MASK);
        AccessibilityState.setStateMaskForTesting(
                CAPABILITIES_MASK_HEURISTIC,
                AccessibilityState.PASSWORD_MANAGER_CAPABILITY_TYPE_MASK);

        Assert.assertTrue(AccessibilityState.areOnlyPasswordManagerMasksRequested());
    }

    @Test
    @SmallTest
    public void testAreOnlyPasswordManagerFlagsRequested_missingFlags() {
        AccessibilityState.setStateMaskForTesting(
                EVENT_TYPE_MASK_HEURISTIC, AccessibilityState.PASSWORD_MANAGER_EVENT_TYPE_MASK);

        int flags_mask =
                AccessibilityServiceInfo.DEFAULT
                        | AccessibilityServiceInfo.FLAG_INCLUDE_NOT_IMPORTANT_VIEWS
                        | AccessibilityServiceInfo.FLAG_REQUEST_TOUCH_EXPLORATION_MODE;
        // Do not add the following to make sure we don't get false negatives:
        // | AccessibilityServiceInfo.FLAG_REQUEST_ENHANCED_WEB_ACCESSIBILITY
        // | AccessibilityServiceInfo.FLAG_REPORT_VIEW_IDS
        // | AccessibilityServiceInfo.FLAG_RETRIEVE_INTERACTIVE_WINDOWS;

        AccessibilityState.setStateMaskForTesting(FLAGS_MASK_HEURISTIC, flags_mask);
        AccessibilityState.setStateMaskForTesting(
                CAPABILITIES_MASK_HEURISTIC,
                AccessibilityState.PASSWORD_MANAGER_CAPABILITY_TYPE_MASK);

        Assert.assertTrue(AccessibilityState.areOnlyPasswordManagerMasksRequested());
    }

    @Test
    @SmallTest
    public void testAreOnlyPasswordManagerFlagsRequested_extraFlags() {
        AccessibilityState.setStateMaskForTesting(
                EVENT_TYPE_MASK_HEURISTIC, AccessibilityState.PASSWORD_MANAGER_EVENT_TYPE_MASK);

        int flags_mask =
                AccessibilityServiceInfo.DEFAULT
                        | AccessibilityServiceInfo.FLAG_INCLUDE_NOT_IMPORTANT_VIEWS
                        | AccessibilityServiceInfo.FLAG_REQUEST_TOUCH_EXPLORATION_MODE
                        | AccessibilityServiceInfo.FLAG_REQUEST_ENHANCED_WEB_ACCESSIBILITY
                        | AccessibilityServiceInfo.FLAG_REPORT_VIEW_IDS
                        | AccessibilityServiceInfo.FLAG_RETRIEVE_INTERACTIVE_WINDOWS
                        // Add extra flag to make sure we don't get false positives:
                        | AccessibilityServiceInfo.FLAG_ENABLE_ACCESSIBILITY_VOLUME;

        AccessibilityState.setStateMaskForTesting(FLAGS_MASK_HEURISTIC, flags_mask);
        AccessibilityState.setStateMaskForTesting(
                CAPABILITIES_MASK_HEURISTIC,
                AccessibilityState.PASSWORD_MANAGER_CAPABILITY_TYPE_MASK);

        Assert.assertFalse(AccessibilityState.areOnlyPasswordManagerMasksRequested());
    }

    @Test
    @SmallTest
    public void testCalculateHeuristicState_Autofill_passwordManager() {
        AccessibilityServiceInfo myService =
                new BuilderForTests(mContext)
                        .setPackageName("android")
                        .setClassName(
                                "com.android.server.autofill.AutofillCompatAccessibilityService")
                        .setEventTypes(AccessibilityState.PASSWORD_MANAGER_EVENT_TYPE_MASK)
                        .setFlags(AccessibilityState.PASSWORD_MANAGER_FLAG_TYPE_MASK)
                        .setCapabilities(AccessibilityState.PASSWORD_MANAGER_CAPABILITY_TYPE_MASK)
                        .build();
        startTestWithService(
                myService,
                "android/com.android.server.autofill.AutofillCompatAccessibilityService");

        AccessibilityState.updateAccessibilityServices();

        Assert.assertTrue(AccessibilityState.isAnyAccessibilityServiceEnabled());
        Assert.assertFalse(AccessibilityState.areOnlyPasswordManagerMasksRequested());
    }

    @Test
    @SmallTest
    public void testCalculateHeuristicState_notAutofill_notPasswordManager() {
        AccessibilityServiceInfo myService =
                new BuilderForTests(mContext)
                        .setEventTypes(~0)
                        .setFlags(~0)
                        .setCapabilities(~0)
                        .build();
        startTestWithService(myService);

        AccessibilityState.updateAccessibilityServices();

        Assert.assertTrue(AccessibilityState.isAnyAccessibilityServiceEnabled());
        Assert.assertFalse(AccessibilityState.areOnlyPasswordManagerMasksRequested());
    }

    @Test
    @SmallTest
    public void testCalculateHeuristicState_notAutofill_passwordManager() {
        AccessibilityServiceInfo myService =
                new BuilderForTests(mContext)
                        .setEventTypes(AccessibilityState.PASSWORD_MANAGER_EVENT_TYPE_MASK)
                        .setFlags(AccessibilityState.PASSWORD_MANAGER_FLAG_TYPE_MASK)
                        .setCapabilities(AccessibilityState.PASSWORD_MANAGER_CAPABILITY_TYPE_MASK)
                        .build();
        startTestWithService(myService);

        AccessibilityState.updateAccessibilityServices();

        Assert.assertTrue(AccessibilityState.isAnyAccessibilityServiceEnabled());
        Assert.assertTrue(AccessibilityState.areOnlyPasswordManagerMasksRequested());
    }

    @Test
    @SmallTest
    public void testTogglingMisconfiguredAccessibilityServices() {
        // This service has the same config as Microsoft Authenticator during recent P0.
        AccessibilityServiceInfo errorProneService =
                new BuilderForTests(mContext)
                        .setEventTypes(MOCK_EVENT_TYPE_MASK)
                        .setFlags(MOCK_FLAG_TYPE_MASK)
                        .setCapabilities(MOCK_CAPABILITY_TYPE_MASK)
                        .build();

        // This service has the correct config for a password manager.
        AccessibilityServiceInfo properConfigService =
                new BuilderForTests(mContext)
                        .setEventTypes(MOCK_EVENT_TYPE_MASK)
                        .setFlags(MOCK_FLAG_TYPE_MASK)
                        .setCapabilities(
                                MOCK_CAPABILITY_TYPE_MASK
                                        | AccessibilityServiceInfo
                                                .CAPABILITY_CAN_REQUEST_TOUCH_EXPLORATION)
                        .build();

        startTestWithService(errorProneService);

        AccessibilityState.updateAccessibilityServices();

        Assert.assertTrue(AccessibilityState.isAnyAccessibilityServiceEnabled());
        // Before P0 fix, this call would have (incorrectly) returned true.
        Assert.assertFalse(AccessibilityState.isTouchExplorationEnabled());

        // Now enable the proper config, and ensure we do not enter an infinite loop and that
        // we now show touch exploration as being enabled.
        AccessibilityState.setEnabledServiceInfoListForTesting(List.of(properConfigService));

        AccessibilityState.updateAccessibilityServices();

        Assert.assertTrue(AccessibilityState.isAnyAccessibilityServiceEnabled());
        Assert.assertTrue(AccessibilityState.isTouchExplorationEnabled());
    }

    private void startTestWithService(AccessibilityServiceInfo newService) {
        startTestWithService(
                newService, "com.example.google/app.accessibility.AccessibilityService");
    }

    private void startTestWithService(AccessibilityServiceInfo newService, String serviceName) {
        Assert.assertNotNull(newService);
        Assert.assertFalse(AccessibilityState.isAnyAccessibilityServiceEnabled());
        AccessibilityState.setEnabledServiceInfoListForTesting(List.of(newService));
        AccessibilityState.setEnabledServiceStringForTesting(serviceName);
    }

    public static class BuilderForTests {

        private Context mContext;
        private String mPackageName = "com.example.google";
        private String mClassName = "app.accessibility.AccessibilityService";
        private int mEventTypes;
        private int mFeedbackType = AccessibilityServiceInfo.FEEDBACK_GENERIC;
        private int mFlags;
        private int mCapabilities;

        public BuilderForTests(Context context) {
            this.mContext = context;
        }

        public BuilderForTests setPackageName(String packageName) {
            this.mPackageName = packageName;
            return this;
        }

        public BuilderForTests setClassName(String className) {
            this.mClassName = className;
            return this;
        }

        public BuilderForTests setEventTypes(int eventTypes) {
            this.mEventTypes = eventTypes;
            return this;
        }

        public BuilderForTests setFeedbackType(int feedbackType) {
            this.mFeedbackType = feedbackType;
            return this;
        }

        public BuilderForTests setFlags(int flags) {
            this.mFlags = flags;
            return this;
        }

        public BuilderForTests setCapabilities(int capabilities) {
            this.mCapabilities = capabilities;
            return this;
        }

        public AccessibilityServiceInfo build() {
            ServiceInfo serviceInfo = new ServiceInfo();
            serviceInfo.packageName = mPackageName;
            serviceInfo.name = mClassName;
            serviceInfo.flags = ServiceInfo.FLAG_SINGLE_USER;

            ResolveInfo resolveInfo = new ResolveInfo();
            resolveInfo.serviceInfo = serviceInfo;

            AccessibilityServiceInfo service =
                    constructAccessibilityServiceInfo(resolveInfo, mContext);
            setCapabilities(service, mCapabilities);
            assert service != null;
            service.eventTypes = mEventTypes;
            service.feedbackType = mFeedbackType;
            service.flags = mFlags;

            return service;
        }

        private void setCapabilities(AccessibilityServiceInfo info, int capabilities) {
            try {
                Method setResolveInfoMethod =
                        AccessibilityServiceInfo.class.getMethod("setCapabilities", int.class);
                setResolveInfoMethod.invoke(info, capabilities);
            } catch (Exception ex) {
                Assert.fail("Unable to call AccessibilityServiceInfo hidden method.");
            }
        }

        private AccessibilityServiceInfo constructAccessibilityServiceInfo(
                ResolveInfo resolveInfo, Context context) {
            try {
                Constructor<AccessibilityServiceInfo> ctr =
                        AccessibilityServiceInfo.class.getConstructor(
                                ResolveInfo.class, Context.class);
                return ctr.newInstance(resolveInfo, context);
            } catch (Exception ex) {
                Assert.fail("Unable to call AccessibilityServiceInfo hidden method.");
            }
            return null;
        }
    }
}