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

Commit 22e0d48b authored by Phil Weaver's avatar Phil Weaver
Browse files

Match attributes to actions for pip a11y

When we strip and replace actions for UIs inside a picture-
in-picture window, make sure attributes match. For example,
if an node was clickable and exposes ACTION_CLICK, when
removing actions we must clear both the click action and
the clickable attribute.

Bug: 37923645
Test: Updating unit tests
Change-Id: I84de5cae136bccb20b2234aef59bf5e0a7c15949
parent da3dd7c0
Loading
Loading
Loading
Loading
+20 −5
Original line number Diff line number Diff line
@@ -26,6 +26,7 @@ import android.view.accessibility.IAccessibilityInteractionConnection;
import android.view.accessibility.IAccessibilityInteractionConnectionCallback;
import com.android.internal.annotations.GuardedBy;

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;

@@ -96,7 +97,7 @@ public class ActionReplacingCallback extends IAccessibilityInteractionConnection
                mNodeFromOriginalWindow = info;
            } else {
                Slog.e(LOG_TAG, "Callback with unexpected interactionId");
                throw new RuntimeException("Callback with unexpected interactionId"); // Remove
                return;
            }

            mSingleNodeCallbackHappened = true;
@@ -119,7 +120,7 @@ public class ActionReplacingCallback extends IAccessibilityInteractionConnection
                mNodesWithReplacementActions = infos;
            } else {
                Slog.e(LOG_TAG, "Callback with unexpected interactionId");
                throw new RuntimeException("Callback with unexpected interactionId"); // Remove
                return;
            }
            callbackForSingleNode = mSingleNodeCallbackHappened;
            callbackForMultipleNodes = mMultiNodeCallbackHappened;
@@ -147,7 +148,7 @@ public class ActionReplacingCallback extends IAccessibilityInteractionConnection
                if (DEBUG) {
                    Slog.e(LOG_TAG, "Extra callback");
                }
                throw new RuntimeException("Extra callback"); // Replace with return before submit
                return;
            }
            if (mNodeFromOriginalWindow != null) {
                replaceActionsOnInfoLocked(mNodeFromOriginalWindow);
@@ -172,7 +173,7 @@ public class ActionReplacingCallback extends IAccessibilityInteractionConnection
                if (DEBUG) {
                    Slog.e(LOG_TAG, "Extra callback");
                }
                throw new RuntimeException("Extra callback"); // Replace with return before submit
                return;
            }
            if (mNodesFromOriginalWindow != null) {
                for (int i = 0; i < mNodesFromOriginalWindow.size(); i++) {
@@ -180,7 +181,8 @@ public class ActionReplacingCallback extends IAccessibilityInteractionConnection
                }
            }
            recycleReplaceActionNodesLocked();
            nodesToReturn = mNodesFromOriginalWindow;
            nodesToReturn = (mNodesFromOriginalWindow == null)
                    ? null : new ArrayList<>(mNodesFromOriginalWindow);
            mDone = true;
        }
        try {
@@ -195,6 +197,12 @@ public class ActionReplacingCallback extends IAccessibilityInteractionConnection
    @GuardedBy("mLock")
    private void replaceActionsOnInfoLocked(AccessibilityNodeInfo info) {
        info.removeAllActions();
        info.setClickable(false);
        info.setFocusable(false);
        info.setContextClickable(false);
        info.setScrollable(false);
        info.setLongClickable(false);
        info.setDismissable(false);
        // We currently only replace actions for the root node
        if ((info.getSourceNodeId() == AccessibilityNodeInfo.ROOT_NODE_ID)
                && mNodesWithReplacementActions != null) {
@@ -213,6 +221,12 @@ public class ActionReplacingCallback extends IAccessibilityInteractionConnection
                        info.addAction(AccessibilityAction.ACTION_ACCESSIBILITY_FOCUS);
                        info.addAction(AccessibilityAction.ACTION_CLEAR_ACCESSIBILITY_FOCUS);
                    }
                    info.setClickable(nodeWithReplacementActions.isClickable());
                    info.setFocusable(nodeWithReplacementActions.isFocusable());
                    info.setContextClickable(nodeWithReplacementActions.isContextClickable());
                    info.setScrollable(nodeWithReplacementActions.isScrollable());
                    info.setLongClickable(nodeWithReplacementActions.isLongClickable());
                    info.setDismissable(nodeWithReplacementActions.isDismissable());
                }
            }
        }
@@ -220,6 +234,7 @@ public class ActionReplacingCallback extends IAccessibilityInteractionConnection

    @GuardedBy("mLock")
    private void recycleReplaceActionNodesLocked() {
        if (mNodesWithReplacementActions == null) return;
        for (int i = mNodesWithReplacementActions.size() - 1; i >= 0; i--) {
            AccessibilityNodeInfo nodeWithReplacementAction = mNodesWithReplacementActions.get(i);
            nodeWithReplacementAction.recycle();
+65 −29
Original line number Diff line number Diff line
@@ -25,6 +25,9 @@ import android.view.accessibility.AccessibilityWindowInfo;
import android.view.accessibility.IAccessibilityInteractionConnection;
import android.view.accessibility.IAccessibilityInteractionConnectionCallback;

import org.hamcrest.BaseMatcher;
import org.hamcrest.Description;
import org.hamcrest.Matcher;
import org.junit.Before;
import org.junit.Test;
import org.mockito.ArgumentCaptor;
@@ -45,12 +48,13 @@ import static junit.framework.TestCase.assertTrue;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotEquals;
import static org.junit.Assert.assertThat;
import static org.mockito.Matchers.anyInt;
import static org.mockito.Matchers.anyObject;
import static org.mockito.Matchers.eq;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoMoreInteractions;
import static org.mockito.Mockito.when;
import static org.mockito.MockitoAnnotations.initMocks;

/**
@@ -63,15 +67,50 @@ public class ActionReplacingCallbackTest {
    private static final int NON_ROOT_NODE_ID = 0xAAAA5555;
    private static final long INTERROGATING_TID = 0x1234FACE;

    private static final AccessibilityAction[] ACTIONS_FROM_REPLACER =
            {ACTION_CLICK, ACTION_EXPAND};
    private static final AccessibilityAction[] A11Y_FOCUS_ACTIONS =
            {ACTION_ACCESSIBILITY_FOCUS, ACTION_CLEAR_ACCESSIBILITY_FOCUS};
    // We expect both the replacer actions and a11y focus actions to appear
    private static final AccessibilityAction[] REQUIRED_ACTIONS_ON_ROOT_TO_SERVICE =
            {ACTION_CLICK, ACTION_EXPAND, ACTION_ACCESSIBILITY_FOCUS,
                    ACTION_CLEAR_ACCESSIBILITY_FOCUS};

    private static final Matcher<AccessibilityNodeInfo> HAS_NO_ACTIONS =
            new BaseMatcher<AccessibilityNodeInfo>() {
        @Override
        public boolean matches(Object o) {
            AccessibilityNodeInfo node = (AccessibilityNodeInfo) o;
            if (!node.getActionList().isEmpty()) return false;
            return (!node.isScrollable() && !node.isLongClickable() && !node.isClickable()
                    && !node.isContextClickable() && !node.isDismissable() && !node.isFocusable());
        }

        @Override
        public void describeTo(Description description) {
            description.appendText("Has no actions");
        }
    };

    private static final Matcher<AccessibilityNodeInfo> HAS_EXPECTED_ACTIONS_ON_ROOT =
            new BaseMatcher<AccessibilityNodeInfo>() {
                @Override
                public boolean matches(Object o) {
                    AccessibilityNodeInfo node = (AccessibilityNodeInfo) o;
                    List<AccessibilityAction> actions = node.getActionList();
                    if ((actions.size() != 4) || !actions.contains(ACTION_CLICK)
                            || !actions.contains(ACTION_EXPAND)
                            || !actions.contains(ACTION_ACCESSIBILITY_FOCUS)) {
                        return false;
                    }
                    return (!node.isScrollable() && !node.isLongClickable()
                            && !node.isLongClickable() && node.isClickable()
                            && !node.isContextClickable() && !node.isDismissable()
                            && !node.isFocusable());
                }

                @Override
                public void describeTo(Description description) {
                    description.appendText("Has only 4 actions expected on root");
                }
            };

    @Mock IAccessibilityInteractionConnectionCallback mMockServiceCallback;
    @Mock IAccessibilityInteractionConnection mMockReplacerConnection;

@@ -118,9 +157,10 @@ public class ActionReplacingCallbackTest {
                eq(INTERACTION_ID));
        AccessibilityNodeInfo infoSentToService = mInfoCaptor.getValue();
        assertEquals(AccessibilityNodeInfo.ROOT_NODE_ID, infoSentToService.getSourceNodeId());
        assertInfoHasExactlyTheseActions(infoSentToService, REQUIRED_ACTIONS_ON_ROOT_TO_SERVICE);
        assertThat(infoSentToService, HAS_EXPECTED_ACTIONS_ON_ROOT);
    }

    @Test
    public void testCallbacks_singleNonrootNodeThenReplacer_returnsNodeWithNoActions()
            throws RemoteException {
        AccessibilityNodeInfo infoFromApp = AccessibilityNodeInfo.obtain();
@@ -136,9 +176,10 @@ public class ActionReplacingCallbackTest {
                eq(INTERACTION_ID));
        AccessibilityNodeInfo infoSentToService = mInfoCaptor.getValue();
        assertEquals(NON_ROOT_NODE_ID, infoSentToService.getSourceNodeId());
        assertTrue(infoSentToService.getActionList().isEmpty());
        assertThat(infoSentToService, HAS_NO_ACTIONS);
    }

    @Test
    public void testCallbacks_replacerThenSingleRootNode_returnsNodeWithReplacedActions()
            throws RemoteException {
        mActionReplacingCallback.setFindAccessibilityNodeInfosResult(getReplacerNodes(),
@@ -154,9 +195,10 @@ public class ActionReplacingCallbackTest {
                eq(INTERACTION_ID));
        AccessibilityNodeInfo infoSentToService = mInfoCaptor.getValue();
        assertEquals(AccessibilityNodeInfo.ROOT_NODE_ID, infoSentToService.getSourceNodeId());
        assertInfoHasExactlyTheseActions(infoSentToService, REQUIRED_ACTIONS_ON_ROOT_TO_SERVICE);
        assertThat(infoSentToService, HAS_EXPECTED_ACTIONS_ON_ROOT);
    }

    @Test
    public void testCallbacks_multipleNodesThenReplacer_clearsActionsAndAddsSomeToRoot()
            throws RemoteException {
        mActionReplacingCallback
@@ -173,11 +215,11 @@ public class ActionReplacingCallbackTest {
                mInfoListCaptor.getValue(), AccessibilityNodeInfo.ROOT_NODE_ID);
        AccessibilityNodeInfo otherInfoSentToService = getNodeWithIdFromList(
                mInfoListCaptor.getValue(), NON_ROOT_NODE_ID);
        assertInfoHasExactlyTheseActions(
                rootInfoSentToService, REQUIRED_ACTIONS_ON_ROOT_TO_SERVICE);
        assertTrue(otherInfoSentToService.getActionList().isEmpty());
        assertThat(rootInfoSentToService, HAS_EXPECTED_ACTIONS_ON_ROOT);
        assertThat(otherInfoSentToService, HAS_NO_ACTIONS);
    }

    @Test
    public void testCallbacks_replacerThenMultipleNodes_clearsActionsAndAddsSomeToRoot()
            throws RemoteException {
        mActionReplacingCallback.setFindAccessibilityNodeInfosResult(getReplacerNodes(),
@@ -194,18 +236,18 @@ public class ActionReplacingCallbackTest {
                mInfoListCaptor.getValue(), AccessibilityNodeInfo.ROOT_NODE_ID);
        AccessibilityNodeInfo otherInfoSentToService = getNodeWithIdFromList(
                mInfoListCaptor.getValue(), NON_ROOT_NODE_ID);
        assertInfoHasExactlyTheseActions(
                rootInfoSentToService, REQUIRED_ACTIONS_ON_ROOT_TO_SERVICE);
        assertTrue(otherInfoSentToService.getActionList().isEmpty());
        assertThat(rootInfoSentToService, HAS_EXPECTED_ACTIONS_ON_ROOT);
        assertThat(otherInfoSentToService, HAS_NO_ACTIONS);
    }

    @Test
    public void testConstructor_actionReplacerThrowsException_passesDataToService()
            throws RemoteException {
        doThrow(RemoteException.class).when(mMockReplacerConnection)
                .findAccessibilityNodeInfoByAccessibilityId(eq(AccessibilityNodeInfo.ROOT_NODE_ID),
                        (Region) anyObject(), mInteractionIdCaptor.capture(),
                        eq(mActionReplacingCallback), eq(0), eq(INTERROGATING_PID),
                        eq(INTERROGATING_TID), (MagnificationSpec) anyObject(), eq(null));
                        (Region) anyObject(), anyInt(), (ActionReplacingCallback) anyObject(),
                        eq(0),  eq(INTERROGATING_PID), eq(INTERROGATING_TID),
                        (MagnificationSpec) anyObject(), eq(null));
        ActionReplacingCallback actionReplacingCallback = new ActionReplacingCallback(
                mMockServiceCallback, mMockReplacerConnection, INTERACTION_ID, INTERROGATING_PID,
                INTERROGATING_TID);
@@ -214,16 +256,17 @@ public class ActionReplacingCallbackTest {
        AccessibilityNodeInfo infoFromApp = AccessibilityNodeInfo.obtain();
        infoFromApp.setSourceNodeId(AccessibilityNodeInfo.ROOT_NODE_ID, APP_WINDOW_ID);
        infoFromApp.addAction(ACTION_CONTEXT_CLICK);
        infoFromApp.setContextClickable(true);
        actionReplacingCallback.setFindAccessibilityNodeInfoResult(infoFromApp, INTERACTION_ID);

        verify(mMockServiceCallback).setFindAccessibilityNodeInfoResult(mInfoCaptor.capture(),
                eq(INTERACTION_ID));
        AccessibilityNodeInfo infoSentToService = mInfoCaptor.getValue();
        assertEquals(AccessibilityNodeInfo.ROOT_NODE_ID, infoSentToService.getSourceNodeId());
        assertEquals(1, infoSentToService.getActionList().size());
        assertEquals(ACTION_CONTEXT_CLICK, infoSentToService.getActionList().get(0));
        assertThat(infoSentToService, HAS_NO_ACTIONS);
    }

    @Test
    public void testSetPerformAccessibilityActionResult_actsAsPassThrough() throws RemoteException {
        mActionReplacingCallback.setPerformAccessibilityActionResult(true, INTERACTION_ID);
        verify(mMockServiceCallback).setPerformAccessibilityActionResult(true, INTERACTION_ID);
@@ -236,9 +279,9 @@ public class ActionReplacingCallbackTest {
        AccessibilityNodeInfo root = AccessibilityNodeInfo.obtain();
        root.setSourceNodeId(AccessibilityNodeInfo.ROOT_NODE_ID,
                AccessibilityWindowInfo.PICTURE_IN_PICTURE_ACTION_REPLACER_WINDOW_ID);
        for (AccessibilityAction action : ACTIONS_FROM_REPLACER) {
            root.addAction(action);
        }
        root.addAction(ACTION_CLICK);
        root.addAction(ACTION_EXPAND);
        root.setClickable(true);

        // Second node should have no effect
        AccessibilityNodeInfo other = AccessibilityNodeInfo.obtain();
@@ -249,13 +292,6 @@ public class ActionReplacingCallbackTest {
        return Arrays.asList(root, other);
    }

    private void assertInfoHasExactlyTheseActions(
            AccessibilityNodeInfo info, AccessibilityAction[] actions) {
        List<AccessibilityAction> nodeActions = info.getActionList();
        assertEquals(new HashSet<AccessibilityAction>(nodeActions),
                new HashSet<AccessibilityAction>(Arrays.asList(actions)));
    }

    private AccessibilityNodeInfo getNodeWithIdFromList(
            List<AccessibilityNodeInfo> infos, long id) {
        for (AccessibilityNodeInfo info : infos) {