From f94c85b13021c83d50109d0feed25cf498f1cfbd Mon Sep 17 00:00:00 2001 From: Sally Date: Thu, 18 Feb 2021 20:58:38 +0000 Subject: [PATCH] Prefetching can be interrupted by other service requests. This is the re-merging of ag/12923546 (where most of that original message is posted below), which includes various bug fixes. Slow prefetch requests would block user interactive requests, creating noticeable sluggishness and unresponsiveness in accessibility services, especially on the web. Let's make it so a user interactive requests stops prefetching. We can't interrupt an API call, but we can stop in between API calls. On the service side, we have to separate the prefetch callbacks from the find callback. And we have to make it asynchronous. It does dispatch intothe main thread, so the AccessibilityCache can remain single threaded. When the calls are interrupted on the application side, returnPendingFindAccessibilityNodeInfosInPrefetch checks the find requests that are waiting in the queue, to see if they can be addressed by the prefetch results. If they can be, we don't have to call into potentially non-performant application code. We don't check requests that have differing prefetch flags (FLAG_INCLUDE_NOT_IMPORTANT_VIEWS, FLAG_REPORT_VIEW_IDS) that would result in different caches. We also make mPendingFindNodeByIdMessages thread-safe and ensure in ActionReplacingCallback we don't return null results. Merged ag/13246536, ag/13256330 UiAutomation does't require a main thread, so getMainLooper may return null. In this case, instead of posting to the main looper, we cache nodes on the binder thread (which is our ultimate goal once b/180957109 is fixed). Added tests to verify AccessibilityInteractionController interactions Test: atest AccessibilityInteractionControllerNodeRequestsTest, FrameworksServicesTests FrameworksCoreTests, CtsAccessibility, Manual testing Bug: b/176195360, b/175877007, b/175884343, b/178726546, b/175832139, b/176195505 Change-Id: I346a3f40c84c6697b8a1e1d84a636eada655b984 --- .../AccessibilityInteractionController.java | 351 +++++++---- .../AccessibilityInteractionClient.java | 93 ++- ...sibilityInteractionConnectionCallback.aidl | 9 + .../AccessibilityInteractionClientTest.java | 9 +- .../ActionReplacingCallback.java | 227 ++++--- ...InteractionControllerNodeRequestsTest.java | 581 ++++++++++++++++++ 6 files changed, 1029 insertions(+), 241 deletions(-) create mode 100644 services/tests/servicestests/src/com/android/server/accessibility/AccessibilityInteractionControllerNodeRequestsTest.java diff --git a/core/java/android/view/AccessibilityInteractionController.java b/core/java/android/view/AccessibilityInteractionController.java index 9473845b15e6b..0499f39f2fe4e 100644 --- a/core/java/android/view/AccessibilityInteractionController.java +++ b/core/java/android/view/AccessibilityInteractionController.java @@ -86,12 +86,19 @@ public final class AccessibilityInteractionController { // accessibility from hanging private static final long REQUEST_PREPARER_TIMEOUT_MS = 500; + // Callbacks should have the same configuration of the flags below to allow satisfying a pending + // node request on prefetch + private static final int FLAGS_AFFECTING_REPORTED_DATA = + AccessibilityNodeInfo.FLAG_INCLUDE_NOT_IMPORTANT_VIEWS + | AccessibilityNodeInfo.FLAG_REPORT_VIEW_IDS; + private final ArrayList mTempAccessibilityNodeInfoList = new ArrayList(); private final Object mLock = new Object(); - private final PrivateHandler mHandler; + @VisibleForTesting + public final PrivateHandler mHandler; private final ViewRootImpl mViewRootImpl; @@ -113,6 +120,9 @@ public final class AccessibilityInteractionController { private AddNodeInfosForViewId mAddNodeInfosForViewId; + @GuardedBy("mLock") + private ArrayList mPendingFindNodeByIdMessages; + @GuardedBy("mLock") private int mNumActiveRequestPreparers; @GuardedBy("mLock") @@ -128,6 +138,7 @@ public final class AccessibilityInteractionController { mViewRootImpl = viewRootImpl; mPrefetcher = new AccessibilityNodePrefetcher(); mA11yManager = mViewRootImpl.mContext.getSystemService(AccessibilityManager.class); + mPendingFindNodeByIdMessages = new ArrayList<>(); } private void scheduleMessage(Message message, int interrogatingPid, long interrogatingTid, @@ -177,6 +188,9 @@ public final class AccessibilityInteractionController { args.arg4 = arguments; message.obj = args; + synchronized (mLock) { + mPendingFindNodeByIdMessages.add(message); + } scheduleMessage(message, interrogatingPid, interrogatingTid, CONSIDER_REQUEST_PREPARERS); } @@ -315,6 +329,9 @@ public final class AccessibilityInteractionController { } private void findAccessibilityNodeInfoByAccessibilityIdUiThread(Message message) { + synchronized (mLock) { + mPendingFindNodeByIdMessages.remove(message); + } final int flags = message.arg1; SomeArgs args = (SomeArgs) message.obj; @@ -329,22 +346,58 @@ public final class AccessibilityInteractionController { args.recycle(); - List infos = mTempAccessibilityNodeInfoList; - infos.clear(); + View rootView = null; + AccessibilityNodeInfo rootNode = null; try { if (mViewRootImpl.mView == null || mViewRootImpl.mAttachInfo == null) { return; } mViewRootImpl.mAttachInfo.mAccessibilityFetchFlags = flags; - final View root = findViewByAccessibilityId(accessibilityViewId); - if (root != null && isShown(root)) { - mPrefetcher.prefetchAccessibilityNodeInfos( - root, virtualDescendantId, flags, infos, arguments); + rootView = findViewByAccessibilityId(accessibilityViewId); + if (rootView != null && isShown(rootView)) { + rootNode = populateAccessibilityNodeInfoForView( + rootView, arguments, virtualDescendantId); } } finally { - updateInfosForViewportAndReturnFindNodeResult( - infos, callback, interactionId, spec, interactiveRegion); + updateInfoForViewportAndReturnFindNodeResult( + rootNode == null ? null : AccessibilityNodeInfo.obtain(rootNode), + callback, interactionId, spec, interactiveRegion); } + ArrayList infos = mTempAccessibilityNodeInfoList; + infos.clear(); + mPrefetcher.prefetchAccessibilityNodeInfos( + rootView, rootNode == null ? null : AccessibilityNodeInfo.obtain(rootNode), + virtualDescendantId, flags, infos); + mViewRootImpl.mAttachInfo.mAccessibilityFetchFlags = 0; + updateInfosForViewPort(infos, spec, interactiveRegion); + returnPrefetchResult(interactionId, infos, callback); + returnPendingFindAccessibilityNodeInfosInPrefetch(rootNode, infos, flags); + } + + private AccessibilityNodeInfo populateAccessibilityNodeInfoForView( + View view, Bundle arguments, int virtualViewId) { + AccessibilityNodeProvider provider = view.getAccessibilityNodeProvider(); + // Determine if we'll be populating extra data + final String extraDataRequested = (arguments == null) ? null + : arguments.getString(EXTRA_DATA_REQUESTED_KEY); + AccessibilityNodeInfo root = null; + if (provider == null) { + root = view.createAccessibilityNodeInfo(); + if (root != null) { + if (extraDataRequested != null) { + view.addExtraDataToAccessibilityNodeInfo(root, extraDataRequested, arguments); + } + } + } else { + root = provider.createAccessibilityNodeInfo(virtualViewId); + if (root != null) { + if (extraDataRequested != null) { + provider.addExtraDataToAccessibilityNodeInfo( + virtualViewId, root, extraDataRequested, arguments); + } + } + } + return root; } public void findAccessibilityNodeInfosByViewIdClientThread(long accessibilityNodeId, @@ -402,6 +455,7 @@ public final class AccessibilityInteractionController { mAddNodeInfosForViewId.reset(); } } finally { + mViewRootImpl.mAttachInfo.mAccessibilityFetchFlags = 0; updateInfosForViewportAndReturnFindNodeResult( infos, callback, interactionId, spec, interactiveRegion); } @@ -484,6 +538,7 @@ public final class AccessibilityInteractionController { } } } finally { + mViewRootImpl.mAttachInfo.mAccessibilityFetchFlags = 0; updateInfosForViewportAndReturnFindNodeResult( infos, callback, interactionId, spec, interactiveRegion); } @@ -575,6 +630,7 @@ public final class AccessibilityInteractionController { } } } finally { + mViewRootImpl.mAttachInfo.mAccessibilityFetchFlags = 0; updateInfoForViewportAndReturnFindNodeResult( focused, callback, interactionId, spec, interactiveRegion); } @@ -629,6 +685,7 @@ public final class AccessibilityInteractionController { } } } finally { + mViewRootImpl.mAttachInfo.mAccessibilityFetchFlags = 0; updateInfoForViewportAndReturnFindNodeResult( next, callback, interactionId, spec, interactiveRegion); } @@ -785,33 +842,6 @@ public final class AccessibilityInteractionController { } } - private void applyAppScaleAndMagnificationSpecIfNeeded(List infos, - MagnificationSpec spec) { - if (infos == null) { - return; - } - final float applicationScale = mViewRootImpl.mAttachInfo.mApplicationScale; - if (shouldApplyAppScaleAndMagnificationSpec(applicationScale, spec)) { - final int infoCount = infos.size(); - for (int i = 0; i < infoCount; i++) { - AccessibilityNodeInfo info = infos.get(i); - applyAppScaleAndMagnificationSpecIfNeeded(info, spec); - } - } - } - - private void adjustIsVisibleToUserIfNeeded(List infos, - Region interactiveRegion) { - if (interactiveRegion == null || infos == null) { - return; - } - final int infoCount = infos.size(); - for (int i = 0; i < infoCount; i++) { - AccessibilityNodeInfo info = infos.get(i); - adjustIsVisibleToUserIfNeeded(info, interactiveRegion); - } - } - private void adjustIsVisibleToUserIfNeeded(AccessibilityNodeInfo info, Region interactiveRegion) { if (interactiveRegion == null || info == null) { @@ -832,17 +862,6 @@ public final class AccessibilityInteractionController { return false; } - private void adjustBoundsInScreenIfNeeded(List infos) { - if (infos == null || shouldBypassAdjustBoundsInScreen()) { - return; - } - final int infoCount = infos.size(); - for (int i = 0; i < infoCount; i++) { - final AccessibilityNodeInfo info = infos.get(i); - adjustBoundsInScreenIfNeeded(info); - } - } - private void adjustBoundsInScreenIfNeeded(AccessibilityNodeInfo info) { if (info == null || shouldBypassAdjustBoundsInScreen()) { return; @@ -890,17 +909,6 @@ public final class AccessibilityInteractionController { return screenMatrix == null || screenMatrix.isIdentity(); } - private void associateLeashedParentIfNeeded(List infos) { - if (infos == null || shouldBypassAssociateLeashedParent()) { - return; - } - final int infoCount = infos.size(); - for (int i = 0; i < infoCount; i++) { - final AccessibilityNodeInfo info = infos.get(i); - associateLeashedParentIfNeeded(info); - } - } - private void associateLeashedParentIfNeeded(AccessibilityNodeInfo info) { if (info == null || shouldBypassAssociateLeashedParent()) { return; @@ -974,18 +982,46 @@ public final class AccessibilityInteractionController { return (appScale != 1.0f || (spec != null && !spec.isNop())); } + private void updateInfosForViewPort(List infos, MagnificationSpec spec, + Region interactiveRegion) { + for (int i = 0; i < infos.size(); i++) { + updateInfoForViewPort(infos.get(i), spec, interactiveRegion); + } + } + + private void updateInfoForViewPort(AccessibilityNodeInfo info, MagnificationSpec spec, + Region interactiveRegion) { + associateLeashedParentIfNeeded(info); + applyScreenMatrixIfNeeded(info); + adjustBoundsInScreenIfNeeded(info); + // To avoid applyAppScaleAndMagnificationSpecIfNeeded changing the bounds of node, + // then impact the visibility result, we need to adjust visibility before apply scale. + adjustIsVisibleToUserIfNeeded(info, interactiveRegion); + applyAppScaleAndMagnificationSpecIfNeeded(info, spec); + } + private void updateInfosForViewportAndReturnFindNodeResult(List infos, IAccessibilityInteractionConnectionCallback callback, int interactionId, MagnificationSpec spec, Region interactiveRegion) { + if (infos != null) { + updateInfosForViewPort(infos, spec, interactiveRegion); + } + returnFindNodesResult(infos, callback, interactionId); + } + + private void returnFindNodeResult(AccessibilityNodeInfo info, + IAccessibilityInteractionConnectionCallback callback, + int interactionId) { + try { + callback.setFindAccessibilityNodeInfoResult(info, interactionId); + } catch (RemoteException re) { + /* ignore - the other side will time out */ + } + } + + private void returnFindNodesResult(List infos, + IAccessibilityInteractionConnectionCallback callback, int interactionId) { try { - mViewRootImpl.mAttachInfo.mAccessibilityFetchFlags = 0; - associateLeashedParentIfNeeded(infos); - applyScreenMatrixIfNeeded(infos); - adjustBoundsInScreenIfNeeded(infos); - // To avoid applyAppScaleAndMagnificationSpecIfNeeded changing the bounds of node, - // then impact the visibility result, we need to adjust visibility before apply scale. - adjustIsVisibleToUserIfNeeded(infos, interactiveRegion); - applyAppScaleAndMagnificationSpecIfNeeded(infos, spec); callback.setFindAccessibilityNodeInfosResult(infos, interactionId); if (infos != null) { infos.clear(); @@ -995,22 +1031,80 @@ public final class AccessibilityInteractionController { } } + private void returnPendingFindAccessibilityNodeInfosInPrefetch(AccessibilityNodeInfo rootNode, + List infos, int flags) { + + AccessibilityNodeInfo satisfiedPendingRequestPrefetchedNode = null; + IAccessibilityInteractionConnectionCallback satisfiedPendingRequestCallback = null; + int satisfiedPendingRequestInteractionId = AccessibilityInteractionClient.NO_ID; + + synchronized (mLock) { + for (int i = 0; i < mPendingFindNodeByIdMessages.size(); i++) { + final Message pendingMessage = mPendingFindNodeByIdMessages.get(i); + final int pendingFlags = pendingMessage.arg1; + if ((pendingFlags & FLAGS_AFFECTING_REPORTED_DATA) + != (flags & FLAGS_AFFECTING_REPORTED_DATA)) { + continue; + } + SomeArgs args = (SomeArgs) pendingMessage.obj; + final int accessibilityViewId = args.argi1; + final int virtualDescendantId = args.argi2; + + satisfiedPendingRequestPrefetchedNode = nodeWithIdFromList(rootNode, + infos, AccessibilityNodeInfo.makeNodeId( + accessibilityViewId, virtualDescendantId)); + + if (satisfiedPendingRequestPrefetchedNode != null) { + satisfiedPendingRequestCallback = + (IAccessibilityInteractionConnectionCallback) args.arg1; + satisfiedPendingRequestInteractionId = args.argi3; + mHandler.removeMessages( + PrivateHandler.MSG_FIND_ACCESSIBILITY_NODE_INFO_BY_ACCESSIBILITY_ID, + pendingMessage.obj); + args.recycle(); + break; + } + } + mPendingFindNodeByIdMessages.clear(); + } + + if (satisfiedPendingRequestPrefetchedNode != null) { + returnFindNodeResult( + AccessibilityNodeInfo.obtain(satisfiedPendingRequestPrefetchedNode), + satisfiedPendingRequestCallback, satisfiedPendingRequestInteractionId); + } + } + + private AccessibilityNodeInfo nodeWithIdFromList(AccessibilityNodeInfo rootNode, + List infos, long nodeId) { + if (rootNode != null && rootNode.getSourceNodeId() == nodeId) { + return rootNode; + } + for (int j = 0; j < infos.size(); j++) { + AccessibilityNodeInfo info = infos.get(j); + if (info.getSourceNodeId() == nodeId) { + return info; + } + } + return null; + } + + private void returnPrefetchResult(int interactionId, List infos, + IAccessibilityInteractionConnectionCallback callback) { + if (infos.size() > 0) { + try { + callback.setPrefetchAccessibilityNodeInfoResult(infos, interactionId); + } catch (RemoteException re) { + /* ignore - other side isn't too bothered if this doesn't arrive */ + } + } + } + private void updateInfoForViewportAndReturnFindNodeResult(AccessibilityNodeInfo info, IAccessibilityInteractionConnectionCallback callback, int interactionId, MagnificationSpec spec, Region interactiveRegion) { - try { - mViewRootImpl.mAttachInfo.mAccessibilityFetchFlags = 0; - associateLeashedParentIfNeeded(info); - applyScreenMatrixIfNeeded(info); - adjustBoundsInScreenIfNeeded(info); - // To avoid applyAppScaleAndMagnificationSpecIfNeeded changing the bounds of node, - // then impact the visibility result, we need to adjust visibility before apply scale. - adjustIsVisibleToUserIfNeeded(info, interactiveRegion); - applyAppScaleAndMagnificationSpecIfNeeded(info, spec); - callback.setFindAccessibilityNodeInfoResult(info, interactionId); - } catch (RemoteException re) { - /* ignore - the other side will time out */ - } + updateInfoForViewPort(info, spec, interactiveRegion); + returnFindNodeResult(info, callback, interactionId); } private boolean handleClickableSpanActionUiThread( @@ -1053,56 +1147,45 @@ public final class AccessibilityInteractionController { private final ArrayList mTempViewList = new ArrayList(); - public void prefetchAccessibilityNodeInfos(View view, int virtualViewId, int fetchFlags, - List outInfos, Bundle arguments) { + public void prefetchAccessibilityNodeInfos(View view, AccessibilityNodeInfo root, + int virtualViewId, int fetchFlags, List outInfos) { + if (root == null) { + return; + } AccessibilityNodeProvider provider = view.getAccessibilityNodeProvider(); - // Determine if we'll be populating extra data - final String extraDataRequested = (arguments == null) ? null - : arguments.getString(EXTRA_DATA_REQUESTED_KEY); if (provider == null) { - AccessibilityNodeInfo root = view.createAccessibilityNodeInfo(); - if (root != null) { - if (extraDataRequested != null) { - view.addExtraDataToAccessibilityNodeInfo( - root, extraDataRequested, arguments); - } - outInfos.add(root); - if ((fetchFlags & AccessibilityNodeInfo.FLAG_PREFETCH_PREDECESSORS) != 0) { - prefetchPredecessorsOfRealNode(view, outInfos); - } - if ((fetchFlags & AccessibilityNodeInfo.FLAG_PREFETCH_SIBLINGS) != 0) { - prefetchSiblingsOfRealNode(view, outInfos); - } - if ((fetchFlags & AccessibilityNodeInfo.FLAG_PREFETCH_DESCENDANTS) != 0) { - prefetchDescendantsOfRealNode(view, outInfos); - } + if ((fetchFlags & AccessibilityNodeInfo.FLAG_PREFETCH_PREDECESSORS) != 0) { + prefetchPredecessorsOfRealNode(view, outInfos); + } + if ((fetchFlags & AccessibilityNodeInfo.FLAG_PREFETCH_SIBLINGS) != 0) { + prefetchSiblingsOfRealNode(view, outInfos); + } + if ((fetchFlags & AccessibilityNodeInfo.FLAG_PREFETCH_DESCENDANTS) != 0) { + prefetchDescendantsOfRealNode(view, outInfos); } } else { - final AccessibilityNodeInfo root = - provider.createAccessibilityNodeInfo(virtualViewId); - if (root != null) { - if (extraDataRequested != null) { - provider.addExtraDataToAccessibilityNodeInfo( - virtualViewId, root, extraDataRequested, arguments); - } - outInfos.add(root); - if ((fetchFlags & AccessibilityNodeInfo.FLAG_PREFETCH_PREDECESSORS) != 0) { - prefetchPredecessorsOfVirtualNode(root, view, provider, outInfos); - } - if ((fetchFlags & AccessibilityNodeInfo.FLAG_PREFETCH_SIBLINGS) != 0) { - prefetchSiblingsOfVirtualNode(root, view, provider, outInfos); - } - if ((fetchFlags & AccessibilityNodeInfo.FLAG_PREFETCH_DESCENDANTS) != 0) { - prefetchDescendantsOfVirtualNode(root, provider, outInfos); - } + if ((fetchFlags & AccessibilityNodeInfo.FLAG_PREFETCH_PREDECESSORS) != 0) { + prefetchPredecessorsOfVirtualNode(root, view, provider, outInfos); + } + if ((fetchFlags & AccessibilityNodeInfo.FLAG_PREFETCH_SIBLINGS) != 0) { + prefetchSiblingsOfVirtualNode(root, view, provider, outInfos); + } + if ((fetchFlags & AccessibilityNodeInfo.FLAG_PREFETCH_DESCENDANTS) != 0) { + prefetchDescendantsOfVirtualNode(root, provider, outInfos); } } if (ENFORCE_NODE_TREE_CONSISTENT) { - enforceNodeTreeConsistent(outInfos); + enforceNodeTreeConsistent(root, outInfos); } } - private void enforceNodeTreeConsistent(List nodes) { + private boolean shouldStopPrefetching(List prefetchededInfos) { + return mHandler.hasUserInteractiveMessagesWaiting() + || prefetchededInfos.size() >= MAX_ACCESSIBILITY_NODE_INFO_BATCH_SIZE; + } + + private void enforceNodeTreeConsistent( + AccessibilityNodeInfo root, List nodes) { LongSparseArray nodeMap = new LongSparseArray(); final int nodeCount = nodes.size(); @@ -1113,7 +1196,6 @@ public final class AccessibilityInteractionController { // If the nodes are a tree it does not matter from // which node we start to search for the root. - AccessibilityNodeInfo root = nodeMap.valueAt(0); AccessibilityNodeInfo parent = root; while (parent != null) { root = parent; @@ -1180,9 +1262,11 @@ public final class AccessibilityInteractionController { private void prefetchPredecessorsOfRealNode(View view, List outInfos) { + if (shouldStopPrefetching(outInfos)) { + return; + } ViewParent parent = view.getParentForAccessibility(); - while (parent instanceof View - && outInfos.size() < MAX_ACCESSIBILITY_NODE_INFO_BATCH_SIZE) { + while (parent instanceof View && !shouldStopPrefetching(outInfos)) { View parentView = (View) parent; AccessibilityNodeInfo info = parentView.createAccessibilityNodeInfo(); if (info != null) { @@ -1194,6 +1278,9 @@ public final class AccessibilityInteractionController { private void prefetchSiblingsOfRealNode(View current, List outInfos) { + if (shouldStopPrefetching(outInfos)) { + return; + } ViewParent parent = current.getParentForAccessibility(); if (parent instanceof ViewGroup) { ViewGroup parentGroup = (ViewGroup) parent; @@ -1203,7 +1290,7 @@ public final class AccessibilityInteractionController { parentGroup.addChildrenForAccessibility(children); final int childCount = children.size(); for (int i = 0; i < childCount; i++) { - if (outInfos.size() >= MAX_ACCESSIBILITY_NODE_INFO_BATCH_SIZE) { + if (shouldStopPrefetching(outInfos)) { return; } View child = children.get(i); @@ -1231,7 +1318,7 @@ public final class AccessibilityInteractionController { private void prefetchDescendantsOfRealNode(View root, List outInfos) { - if (!(root instanceof ViewGroup)) { + if (shouldStopPrefetching(outInfos) || !(root instanceof ViewGroup)) { return; } HashMap addedChildren = @@ -1242,7 +1329,7 @@ public final class AccessibilityInteractionController { root.addChildrenForAccessibility(children); final int childCount = children.size(); for (int i = 0; i < childCount; i++) { - if (outInfos.size() >= MAX_ACCESSIBILITY_NODE_INFO_BATCH_SIZE) { + if (shouldStopPrefetching(outInfos)) { return; } View child = children.get(i); @@ -1267,7 +1354,7 @@ public final class AccessibilityInteractionController { } finally { children.clear(); } - if (outInfos.size() < MAX_ACCESSIBILITY_NODE_INFO_BATCH_SIZE) { + if (!shouldStopPrefetching(outInfos)) { for (Map.Entry entry : addedChildren.entrySet()) { View addedChild = entry.getKey(); AccessibilityNodeInfo virtualRoot = entry.getValue(); @@ -1289,7 +1376,7 @@ public final class AccessibilityInteractionController { long parentNodeId = root.getParentNodeId(); int accessibilityViewId = AccessibilityNodeInfo.getAccessibilityViewId(parentNodeId); while (accessibilityViewId != AccessibilityNodeInfo.UNDEFINED_ITEM_ID) { - if (outInfos.size() >= MAX_ACCESSIBILITY_NODE_INFO_BATCH_SIZE) { + if (shouldStopPrefetching(outInfos)) { return; } final int virtualDescendantId = @@ -1334,7 +1421,7 @@ public final class AccessibilityInteractionController { if (parent != null) { final int childCount = parent.getChildCount(); for (int i = 0; i < childCount; i++) { - if (outInfos.size() >= MAX_ACCESSIBILITY_NODE_INFO_BATCH_SIZE) { + if (shouldStopPrefetching(outInfos)) { return; } final long childNodeId = parent.getChildId(i); @@ -1359,7 +1446,7 @@ public final class AccessibilityInteractionController { final int initialOutInfosSize = outInfos.size(); final int childCount = root.getChildCount(); for (int i = 0; i < childCount; i++) { - if (outInfos.size() >= MAX_ACCESSIBILITY_NODE_INFO_BATCH_SIZE) { + if (shouldStopPrefetching(outInfos)) { return; } final long childNodeId = root.getChildId(i); @@ -1369,7 +1456,7 @@ public final class AccessibilityInteractionController { outInfos.add(child); } } - if (outInfos.size() < MAX_ACCESSIBILITY_NODE_INFO_BATCH_SIZE) { + if (!shouldStopPrefetching(outInfos)) { final int addedChildCount = outInfos.size() - initialOutInfosSize; for (int i = 0; i < addedChildCount; i++) { AccessibilityNodeInfo child = outInfos.get(initialOutInfosSize + i); @@ -1478,6 +1565,10 @@ public final class AccessibilityInteractionController { boolean hasAccessibilityCallback(Message message) { return message.what < FIRST_NO_ACCESSIBILITY_CALLBACK_MSG ? true : false; } + + boolean hasUserInteractiveMessagesWaiting() { + return hasMessagesOrCallbacks(); + } } private final class AddNodeInfosForViewId implements Predicate { diff --git a/core/java/android/view/accessibility/AccessibilityInteractionClient.java b/core/java/android/view/accessibility/AccessibilityInteractionClient.java index f63749be6df26..9556c25575cd9 100644 --- a/core/java/android/view/accessibility/AccessibilityInteractionClient.java +++ b/core/java/android/view/accessibility/AccessibilityInteractionClient.java @@ -23,7 +23,9 @@ import android.compat.annotation.UnsupportedAppUsage; import android.os.Binder; import android.os.Build; import android.os.Bundle; +import android.os.Handler; import android.os.IBinder; +import android.os.Looper; import android.os.Message; import android.os.Process; import android.os.RemoteException; @@ -113,6 +115,8 @@ public final class AccessibilityInteractionClient private final Object mInstanceLock = new Object(); + private Handler mMainHandler; + private volatile int mInteractionId = -1; private AccessibilityNodeInfo mFindAccessibilityNodeInfoResult; @@ -123,6 +127,11 @@ public final class AccessibilityInteractionClient private Message mSameThreadMessage; + private int mInteractionIdWaitingForPrefetchResult; + private int mConnectionIdWaitingForPrefetchResult; + private String[] mPackageNamesForNextPrefetchResult; + private Runnable mPrefetchResultRunnable; + /** * @return The client for the current thread. */ @@ -197,6 +206,9 @@ public final class AccessibilityInteractionClient private AccessibilityInteractionClient() { /* reducing constructor visibility */ + if (Looper.getMainLooper() != null) { + mMainHandler = new Handler(Looper.getMainLooper()); + } } /** @@ -451,16 +463,16 @@ public final class AccessibilityInteractionClient Binder.restoreCallingIdentity(identityToken); } if (packageNames != null) { - List infos = getFindAccessibilityNodeInfosResultAndClear( - interactionId); - finalizeAndCacheAccessibilityNodeInfos(infos, connectionId, - bypassCache, packageNames); - if (infos != null && !infos.isEmpty()) { - for (int i = 1; i < infos.size(); i++) { - infos.get(i).recycle(); - } - return infos.get(0); + AccessibilityNodeInfo info = + getFindAccessibilityNodeInfoResultAndClear(interactionId); + if ((prefetchFlags & AccessibilityNodeInfo.FLAG_PREFETCH_MASK) != 0 + && info != null) { + setInteractionWaitingForPrefetchResult(interactionId, connectionId, + packageNames); } + finalizeAndCacheAccessibilityNodeInfo(info, connectionId, + bypassCache, packageNames); + return info; } } else { if (DEBUG) { @@ -474,6 +486,15 @@ public final class AccessibilityInteractionClient return null; } + private void setInteractionWaitingForPrefetchResult(int interactionId, int connectionId, + String[] packageNames) { + synchronized (mInstanceLock) { + mInteractionIdWaitingForPrefetchResult = interactionId; + mConnectionIdWaitingForPrefetchResult = connectionId; + mPackageNamesForNextPrefetchResult = packageNames; + } + } + private static String idToString(int accessibilityWindowId, long accessibilityNodeId) { return accessibilityWindowId + "/" + AccessibilityNodeInfo.idToString(accessibilityNodeId); @@ -828,6 +849,60 @@ public final class AccessibilityInteractionClient } } + /** + * {@inheritDoc} + */ + @Override + public void setPrefetchAccessibilityNodeInfoResult(@NonNull List infos, + int interactionId) { + List infosCopy = null; + int mConnectionIdWaitingForPrefetchResultCopy = -1; + String[] mPackageNamesForNextPrefetchResultCopy = null; + + synchronized (mInstanceLock) { + if (!infos.isEmpty() && mInteractionIdWaitingForPrefetchResult == interactionId) { + if (mMainHandler != null) { + if (mPrefetchResultRunnable != null) { + mMainHandler.removeCallbacks(mPrefetchResultRunnable); + mPrefetchResultRunnable = null; + } + /** + * TODO(b/180957109): AccessibilityCache is prone to deadlocks + * We post caching the prefetched nodes in the main thread. Using the binder + * thread results in "Long monitor contention with owner main" logs where + * service response times may exceed 5 seconds. This is due to the cache calling + * out to the system when refreshing nodes with the lock held. + */ + mPrefetchResultRunnable = () -> finalizeAndCacheAccessibilityNodeInfos( + infos, mConnectionIdWaitingForPrefetchResult, false, + mPackageNamesForNextPrefetchResult); + mMainHandler.post(mPrefetchResultRunnable); + + } else { + for (AccessibilityNodeInfo info : infos) { + infosCopy.add(new AccessibilityNodeInfo(info)); + } + mConnectionIdWaitingForPrefetchResultCopy = + mConnectionIdWaitingForPrefetchResult; + mPackageNamesForNextPrefetchResultCopy = + new String[mPackageNamesForNextPrefetchResult.length]; + for (int i = 0; i < mPackageNamesForNextPrefetchResult.length; i++) { + mPackageNamesForNextPrefetchResultCopy[i] = + mPackageNamesForNextPrefetchResult[i]; + + } + } + } + + } + + if (infosCopy != null) { + finalizeAndCacheAccessibilityNodeInfos( + infosCopy, mConnectionIdWaitingForPrefetchResultCopy, false, + mPackageNamesForNextPrefetchResultCopy); + } + } + /** * Gets the result of a request to perform an accessibility action. * diff --git a/core/java/android/view/accessibility/IAccessibilityInteractionConnectionCallback.aidl b/core/java/android/view/accessibility/IAccessibilityInteractionConnectionCallback.aidl index 049bb31adbb11..231e75a19a068 100644 --- a/core/java/android/view/accessibility/IAccessibilityInteractionConnectionCallback.aidl +++ b/core/java/android/view/accessibility/IAccessibilityInteractionConnectionCallback.aidl @@ -46,6 +46,15 @@ oneway interface IAccessibilityInteractionConnectionCallback { void setFindAccessibilityNodeInfosResult(in List infos, int interactionId); + /** + * Sets the result of a prefetch request that returns {@link AccessibilityNodeInfo}s. + * + * @param root The {@link AccessibilityNodeInfo} for which the prefetching is based off of. + * @param infos The result {@link AccessibilityNodeInfo}s. + */ + void setPrefetchAccessibilityNodeInfoResult( + in List infos, int interactionId); + /** * Sets the result of a request to perform an accessibility action. * diff --git a/core/tests/coretests/src/android/view/accessibility/AccessibilityInteractionClientTest.java b/core/tests/coretests/src/android/view/accessibility/AccessibilityInteractionClientTest.java index ab24f89015c79..7e1e7f4bdd7fb 100644 --- a/core/tests/coretests/src/android/view/accessibility/AccessibilityInteractionClientTest.java +++ b/core/tests/coretests/src/android/view/accessibility/AccessibilityInteractionClientTest.java @@ -33,9 +33,6 @@ import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.Mock; -import java.util.Arrays; -import java.util.List; - /** * Tests for AccessibilityInteractionClient */ @@ -65,7 +62,7 @@ public class AccessibilityInteractionClientTest { final long accessibilityNodeId = 0x4321L; AccessibilityNodeInfo nodeFromConnection = AccessibilityNodeInfo.obtain(); nodeFromConnection.setSourceNodeId(accessibilityNodeId, windowId); - mMockConnection.mInfosToReturn = Arrays.asList(nodeFromConnection); + mMockConnection.mInfoToReturn = nodeFromConnection; AccessibilityInteractionClient client = AccessibilityInteractionClient.getInstance(); AccessibilityNodeInfo node = client.findAccessibilityNodeInfoByAccessibilityId( MOCK_CONNECTION_ID, windowId, accessibilityNodeId, true, 0, null); @@ -75,7 +72,7 @@ public class AccessibilityInteractionClientTest { } private static class MockConnection extends AccessibilityServiceConnectionImpl { - List mInfosToReturn; + AccessibilityNodeInfo mInfoToReturn; @Override public String[] findAccessibilityNodeInfoByAccessibilityId(int accessibilityWindowId, @@ -83,7 +80,7 @@ public class AccessibilityInteractionClientTest { IAccessibilityInteractionConnectionCallback callback, int flags, long threadId, Bundle arguments) { try { - callback.setFindAccessibilityNodeInfosResult(mInfosToReturn, interactionId); + callback.setFindAccessibilityNodeInfoResult(mInfoToReturn, interactionId); } catch (RemoteException e) { throw new RuntimeException(e); } diff --git a/services/accessibility/java/com/android/server/accessibility/ActionReplacingCallback.java b/services/accessibility/java/com/android/server/accessibility/ActionReplacingCallback.java index bafb641dcc9ed..6828dd916701f 100644 --- a/services/accessibility/java/com/android/server/accessibility/ActionReplacingCallback.java +++ b/services/accessibility/java/com/android/server/accessibility/ActionReplacingCallback.java @@ -40,29 +40,34 @@ public class ActionReplacingCallback extends IAccessibilityInteractionConnection private final IAccessibilityInteractionConnectionCallback mServiceCallback; private final IAccessibilityInteractionConnection mConnectionWithReplacementActions; private final int mInteractionId; + private final int mNodeWithReplacementActionsInteractionId; private final Object mLock = new Object(); @GuardedBy("mLock") - List mNodesWithReplacementActions; + private boolean mReplacementNodeIsReadyOrFailed; + + @GuardedBy("mLock") + AccessibilityNodeInfo mNodeWithReplacementActions; @GuardedBy("mLock") List mNodesFromOriginalWindow; + @GuardedBy("mLock") + boolean mSetFindNodeFromOriginalWindowCalled = false; + @GuardedBy("mLock") AccessibilityNodeInfo mNodeFromOriginalWindow; - // Keep track of whether or not we've been called back for a single node @GuardedBy("mLock") - boolean mSingleNodeCallbackHappened; + boolean mSetFindNodesFromOriginalWindowCalled = false; - // Keep track of whether or not we've been called back for multiple node - @GuardedBy("mLock") - boolean mMultiNodeCallbackHappened; - // We shouldn't get any more callbacks after we've called back the original service, but - // keep track to make sure we catch such strange things @GuardedBy("mLock") - boolean mDone; + List mPrefetchedNodesFromOriginalWindow; + + @GuardedBy("mLock") + boolean mSetPrefetchFromOriginalWindowCalled = false; + public ActionReplacingCallback(IAccessibilityInteractionConnectionCallback serviceCallback, IAccessibilityInteractionConnection connectionWithReplacementActions, @@ -70,19 +75,20 @@ public class ActionReplacingCallback extends IAccessibilityInteractionConnection mServiceCallback = serviceCallback; mConnectionWithReplacementActions = connectionWithReplacementActions; mInteractionId = interactionId; + mNodeWithReplacementActionsInteractionId = interactionId + 1; // Request the root node of the replacing window final long identityToken = Binder.clearCallingIdentity(); try { mConnectionWithReplacementActions.findAccessibilityNodeInfoByAccessibilityId( - AccessibilityNodeInfo.ROOT_NODE_ID, null, interactionId + 1, this, 0, + AccessibilityNodeInfo.ROOT_NODE_ID, null, + mNodeWithReplacementActionsInteractionId, this, 0, interrogatingPid, interrogatingTid, null, null); } catch (RemoteException re) { if (DEBUG) { Slog.e(LOG_TAG, "Error calling findAccessibilityNodeInfoByAccessibilityId()"); } - // Pretend we already got a (null) list of replacement nodes - mMultiNodeCallbackHappened = true; + mReplacementNodeIsReadyOrFailed = true; } finally { Binder.restoreCallingIdentity(identityToken); } @@ -90,46 +96,67 @@ public class ActionReplacingCallback extends IAccessibilityInteractionConnection @Override public void setFindAccessibilityNodeInfoResult(AccessibilityNodeInfo info, int interactionId) { - boolean readyForCallback; - synchronized(mLock) { + synchronized (mLock) { if (interactionId == mInteractionId) { mNodeFromOriginalWindow = info; + mSetFindNodeFromOriginalWindowCalled = true; + } else if (interactionId == mNodeWithReplacementActionsInteractionId) { + mNodeWithReplacementActions = info; + mReplacementNodeIsReadyOrFailed = true; } else { Slog.e(LOG_TAG, "Callback with unexpected interactionId"); return; } - - mSingleNodeCallbackHappened = true; - readyForCallback = mMultiNodeCallbackHappened; - } - if (readyForCallback) { - replaceInfoActionsAndCallService(); } + replaceInfoActionsAndCallServiceIfReady(); } @Override public void setFindAccessibilityNodeInfosResult(List infos, int interactionId) { - boolean callbackForSingleNode; - boolean callbackForMultipleNodes; - synchronized(mLock) { + synchronized (mLock) { if (interactionId == mInteractionId) { mNodesFromOriginalWindow = infos; - } else if (interactionId == mInteractionId + 1) { - mNodesWithReplacementActions = infos; + mSetFindNodesFromOriginalWindowCalled = true; + } else if (interactionId == mNodeWithReplacementActionsInteractionId) { + setNodeWithReplacementActionsFromList(infos); + mReplacementNodeIsReadyOrFailed = true; } else { Slog.e(LOG_TAG, "Callback with unexpected interactionId"); return; } - callbackForSingleNode = mSingleNodeCallbackHappened; - callbackForMultipleNodes = mMultiNodeCallbackHappened; - mMultiNodeCallbackHappened = true; } - if (callbackForSingleNode) { - replaceInfoActionsAndCallService(); + replaceInfoActionsAndCallServiceIfReady(); + } + + @Override + public void setPrefetchAccessibilityNodeInfoResult(List infos, + int interactionId) + throws RemoteException { + synchronized (mLock) { + if (interactionId == mInteractionId) { + mPrefetchedNodesFromOriginalWindow = infos; + mSetPrefetchFromOriginalWindowCalled = true; + } else { + Slog.e(LOG_TAG, "Callback with unexpected interactionId"); + return; + } } - if (callbackForMultipleNodes) { - replaceInfosActionsAndCallService(); + replaceInfoActionsAndCallServiceIfReady(); + } + + private void replaceInfoActionsAndCallServiceIfReady() { + replaceInfoActionsAndCallService(); + replaceInfosActionsAndCallService(); + replacePrefetchInfosActionsAndCallService(); + } + + private void setNodeWithReplacementActionsFromList(List infos) { + for (int i = 0; i < infos.size(); i++) { + AccessibilityNodeInfo info = infos.get(i); + if (info.getSourceNodeId() == AccessibilityNodeInfo.ROOT_NODE_ID) { + mNodeWithReplacementActions = info; + } } } @@ -142,55 +169,81 @@ public class ActionReplacingCallback extends IAccessibilityInteractionConnection private void replaceInfoActionsAndCallService() { final AccessibilityNodeInfo nodeToReturn; + boolean doCallback = false; synchronized (mLock) { - if (mDone) { - if (DEBUG) { - Slog.e(LOG_TAG, "Extra callback"); - } - return; - } - if (mNodeFromOriginalWindow != null) { + doCallback = mReplacementNodeIsReadyOrFailed + && mSetFindNodeFromOriginalWindowCalled; + if (doCallback && mNodeFromOriginalWindow != null) { replaceActionsOnInfoLocked(mNodeFromOriginalWindow); + mSetFindNodeFromOriginalWindowCalled = false; } - recycleReplaceActionNodesLocked(); nodeToReturn = mNodeFromOriginalWindow; - mDone = true; } - try { - mServiceCallback.setFindAccessibilityNodeInfoResult(nodeToReturn, mInteractionId); - } catch (RemoteException re) { - if (DEBUG) { - Slog.e(LOG_TAG, "Failed to setFindAccessibilityNodeInfoResult"); + if (doCallback) { + try { + mServiceCallback.setFindAccessibilityNodeInfoResult(nodeToReturn, mInteractionId); + } catch (RemoteException re) { + if (DEBUG) { + Slog.e(LOG_TAG, "Failed to setFindAccessibilityNodeInfoResult"); + } } } } private void replaceInfosActionsAndCallService() { - final List nodesToReturn; + List nodesToReturn = null; + boolean doCallback = false; synchronized (mLock) { - if (mDone) { + doCallback = mReplacementNodeIsReadyOrFailed + && mSetFindNodesFromOriginalWindowCalled; + if (doCallback) { + nodesToReturn = replaceActionsLocked(mNodesFromOriginalWindow); + mSetFindNodesFromOriginalWindowCalled = false; + } + } + if (doCallback) { + try { + mServiceCallback.setFindAccessibilityNodeInfosResult(nodesToReturn, mInteractionId); + } catch (RemoteException re) { if (DEBUG) { - Slog.e(LOG_TAG, "Extra callback"); - } - return; - } - if (mNodesFromOriginalWindow != null) { - for (int i = 0; i < mNodesFromOriginalWindow.size(); i++) { - replaceActionsOnInfoLocked(mNodesFromOriginalWindow.get(i)); + Slog.e(LOG_TAG, "Failed to setFindAccessibilityNodeInfosResult"); } } - recycleReplaceActionNodesLocked(); - nodesToReturn = (mNodesFromOriginalWindow == null) - ? null : new ArrayList<>(mNodesFromOriginalWindow); - mDone = true; } - try { - mServiceCallback.setFindAccessibilityNodeInfosResult(nodesToReturn, mInteractionId); - } catch (RemoteException re) { - if (DEBUG) { - Slog.e(LOG_TAG, "Failed to setFindAccessibilityNodeInfosResult"); + } + + private void replacePrefetchInfosActionsAndCallService() { + List nodesToReturn = null; + boolean doCallback = false; + synchronized (mLock) { + doCallback = mReplacementNodeIsReadyOrFailed + && mSetPrefetchFromOriginalWindowCalled; + if (doCallback) { + nodesToReturn = replaceActionsLocked(mPrefetchedNodesFromOriginalWindow); + mSetPrefetchFromOriginalWindowCalled = false; } } + if (doCallback) { + try { + mServiceCallback.setPrefetchAccessibilityNodeInfoResult( + nodesToReturn, mInteractionId); + } catch (RemoteException re) { + if (DEBUG) { + Slog.e(LOG_TAG, "Failed to setFindAccessibilityNodeInfosResult"); + } + } + } + } + + @GuardedBy("mLock") + private List replaceActionsLocked(List infos) { + if (infos != null) { + for (int i = 0; i < infos.size(); i++) { + replaceActionsOnInfoLocked(infos.get(i)); + } + } + return (infos == null) + ? null : new ArrayList<>(infos); } @GuardedBy("mLock") @@ -204,40 +257,22 @@ public class ActionReplacingCallback extends IAccessibilityInteractionConnection info.setDismissable(false); // We currently only replace actions for the root node if ((info.getSourceNodeId() == AccessibilityNodeInfo.ROOT_NODE_ID) - && mNodesWithReplacementActions != null) { - // This list should always contain a single node with the root ID - for (int i = 0; i < mNodesWithReplacementActions.size(); i++) { - AccessibilityNodeInfo nodeWithReplacementActions = - mNodesWithReplacementActions.get(i); - if (nodeWithReplacementActions.getSourceNodeId() - == AccessibilityNodeInfo.ROOT_NODE_ID) { - List actions = nodeWithReplacementActions.getActionList(); - if (actions != null) { - for (int j = 0; j < actions.size(); j++) { - info.addAction(actions.get(j)); - } - // The PIP needs to be able to take accessibility focus - 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()); + && mNodeWithReplacementActions != null) { + List actions = mNodeWithReplacementActions.getActionList(); + if (actions != null) { + for (int j = 0; j < actions.size(); j++) { + info.addAction(actions.get(j)); } + // The PIP needs to be able to take accessibility focus + info.addAction(AccessibilityAction.ACTION_ACCESSIBILITY_FOCUS); + info.addAction(AccessibilityAction.ACTION_CLEAR_ACCESSIBILITY_FOCUS); } + info.setClickable(mNodeWithReplacementActions.isClickable()); + info.setFocusable(mNodeWithReplacementActions.isFocusable()); + info.setContextClickable(mNodeWithReplacementActions.isContextClickable()); + info.setScrollable(mNodeWithReplacementActions.isScrollable()); + info.setLongClickable(mNodeWithReplacementActions.isLongClickable()); + info.setDismissable(mNodeWithReplacementActions.isDismissable()); } } - - @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(); - } - mNodesWithReplacementActions = null; - } } diff --git a/services/tests/servicestests/src/com/android/server/accessibility/AccessibilityInteractionControllerNodeRequestsTest.java b/services/tests/servicestests/src/com/android/server/accessibility/AccessibilityInteractionControllerNodeRequestsTest.java new file mode 100644 index 0000000000000..170f561aa2dab --- /dev/null +++ b/services/tests/servicestests/src/com/android/server/accessibility/AccessibilityInteractionControllerNodeRequestsTest.java @@ -0,0 +1,581 @@ +/* + * Copyright (C) 2021 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; + + +import static android.view.accessibility.AccessibilityNodeInfo.FLAG_INCLUDE_NOT_IMPORTANT_VIEWS; +import static android.view.accessibility.AccessibilityNodeInfo.FLAG_PREFETCH_DESCENDANTS; +import static android.view.accessibility.AccessibilityNodeInfo.FLAG_PREFETCH_SIBLINGS; +import static android.view.accessibility.AccessibilityNodeInfo.ROOT_NODE_ID; + +import static org.junit.Assert.assertEquals; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import android.app.Instrumentation; +import android.content.Context; +import android.os.RemoteException; +import android.view.AccessibilityInteractionController; +import android.view.View; +import android.view.ViewRootImpl; +import android.view.WindowManager; +import android.view.accessibility.AccessibilityNodeIdManager; +import android.view.accessibility.AccessibilityNodeInfo; +import android.view.accessibility.AccessibilityNodeProvider; +import android.view.accessibility.IAccessibilityInteractionConnectionCallback; +import android.widget.FrameLayout; +import android.widget.TextView; + +import androidx.test.platform.app.InstrumentationRegistry; +import androidx.test.runner.AndroidJUnit4; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.util.ArrayList; +import java.util.List; + +/** + * Tests that verify expected node and prefetched node results when finding a view by node id. We + * send some requests to the controller via View methods to control message timing. + */ +@RunWith(AndroidJUnit4.class) +public class AccessibilityInteractionControllerNodeRequestsTest { + private AccessibilityInteractionController mAccessibilityInteractionController; + @Mock + private IAccessibilityInteractionConnectionCallback mMockClientCallback1; + @Mock + private IAccessibilityInteractionConnectionCallback mMockClientCallback2; + + @Captor + private ArgumentCaptor mFindInfoCaptor; + @Captor private ArgumentCaptor> mPrefetchInfoListCaptor; + + private final Instrumentation mInstrumentation = InstrumentationRegistry.getInstrumentation(); + private static final int MOCK_CLIENT_1_THREAD_AND_PROCESS_ID = 1; + private static final int MOCK_CLIENT_2_THREAD_AND_PROCESS_ID = 2; + + private static final String FRAME_LAYOUT_DESCRIPTION = "frameLayout"; + private static final String TEXT_VIEW_1_DESCRIPTION = "textView1"; + private static final String TEXT_VIEW_2_DESCRIPTION = "textView2"; + + private TestFrameLayout mFrameLayout; + private TestTextView mTextView1; + private TestTextView2 mTextView2; + + private boolean mSendClient1RequestForTextAfterTextPrefetched; + private boolean mSendClient2RequestForTextAfterTextPrefetched; + private boolean mSendRequestForTextAndIncludeUnImportantViews; + private int mMockClient1InteractionId; + private int mMockClient2InteractionId; + + @Before + public void setUp() throws Throwable { + MockitoAnnotations.initMocks(this); + + mInstrumentation.runOnMainSync(() -> { + final Context context = mInstrumentation.getTargetContext(); + final ViewRootImpl viewRootImpl = new ViewRootImpl(context, context.getDisplay()); + + mFrameLayout = new TestFrameLayout(context); + mTextView1 = new TestTextView(context); + mTextView2 = new TestTextView2(context); + + mFrameLayout.addView(mTextView1); + mFrameLayout.addView(mTextView2); + + // The controller retrieves views through this manager, and registration happens on + // when attached to a window, which we don't have. We can simply reference FrameLayout + // with ROOT_NODE_ID + AccessibilityNodeIdManager.getInstance().registerViewWithId( + mTextView1, mTextView1.getAccessibilityViewId()); + AccessibilityNodeIdManager.getInstance().registerViewWithId( + mTextView2, mTextView2.getAccessibilityViewId()); + + try { + viewRootImpl.setView(mFrameLayout, new WindowManager.LayoutParams(), null); + + } catch (WindowManager.BadTokenException e) { + // activity isn't running, we will ignore BadTokenException. + } + + mAccessibilityInteractionController = + new AccessibilityInteractionController(viewRootImpl); + }); + + } + + @After + public void tearDown() throws Throwable { + AccessibilityNodeIdManager.getInstance().unregisterViewWithId( + mTextView1.getAccessibilityViewId()); + AccessibilityNodeIdManager.getInstance().unregisterViewWithId( + mTextView2.getAccessibilityViewId()); + } + + /** + * Tests a basic request for the root node with prefetch flag + * {@link AccessibilityNodeInfo#FLAG_PREFETCH_DESCENDANTS} + * + * @throws RemoteException + */ + @Test + public void testFindRootView_withOneClient_shouldReturnRootNodeAndPrefetchDescendants() + throws RemoteException { + // Request for our FrameLayout + sendNodeRequestToController(ROOT_NODE_ID, mMockClientCallback1, + mMockClient1InteractionId, FLAG_PREFETCH_DESCENDANTS); + mInstrumentation.waitForIdleSync(); + + // Verify we get FrameLayout + verify(mMockClientCallback1).setFindAccessibilityNodeInfoResult( + mFindInfoCaptor.capture(), eq(mMockClient1InteractionId)); + AccessibilityNodeInfo infoSentToService = mFindInfoCaptor.getValue(); + assertEquals(FRAME_LAYOUT_DESCRIPTION, infoSentToService.getContentDescription()); + + verify(mMockClientCallback1).setPrefetchAccessibilityNodeInfoResult( + mPrefetchInfoListCaptor.capture(), eq(mMockClient1InteractionId)); + // The descendants are our two TextViews + List prefetchedNodes = mPrefetchInfoListCaptor.getValue(); + assertEquals(2, prefetchedNodes.size()); + assertEquals(TEXT_VIEW_1_DESCRIPTION, prefetchedNodes.get(0).getContentDescription()); + assertEquals(TEXT_VIEW_2_DESCRIPTION, prefetchedNodes.get(1).getContentDescription()); + + } + + /** + * Tests a basic request for TestTextView1's node with prefetch flag + * {@link AccessibilityNodeInfo#FLAG_PREFETCH_SIBLINGS} + * + * @throws RemoteException + */ + @Test + public void testFindTextView_withOneClient_shouldReturnNodeAndPrefetchedSiblings() + throws RemoteException { + // Request for TextView1 + sendNodeRequestToController(AccessibilityNodeInfo.makeNodeId( + mTextView1.getAccessibilityViewId(), AccessibilityNodeProvider.HOST_VIEW_ID), + mMockClientCallback1, mMockClient1InteractionId, FLAG_PREFETCH_SIBLINGS); + mInstrumentation.waitForIdleSync(); + + // Verify we get TextView1 + verify(mMockClientCallback1).setFindAccessibilityNodeInfoResult( + mFindInfoCaptor.capture(), eq(mMockClient1InteractionId)); + AccessibilityNodeInfo infoSentToService = mFindInfoCaptor.getValue(); + assertEquals(TEXT_VIEW_1_DESCRIPTION, infoSentToService.getContentDescription()); + + // Verify the prefetched sibling of TextView1 is TextView2 + verify(mMockClientCallback1).setPrefetchAccessibilityNodeInfoResult( + mPrefetchInfoListCaptor.capture(), eq(mMockClient1InteractionId)); + // TextView2 is the prefetched sibling + List prefetchedNodes = mPrefetchInfoListCaptor.getValue(); + assertEquals(1, prefetchedNodes.size()); + assertEquals(TEXT_VIEW_2_DESCRIPTION, prefetchedNodes.get(0).getContentDescription()); + } + + /** + * Tests a series of controller requests to prevent prefetching. + * Request 1: Client 1 requests the root node + * Request 2: When the root node is initialized in + * {@link TestFrameLayout#onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo)}, + * Client 2 requests TestTextView1's node + * + * Request 2 on the queue prevents prefetching for Request 1. + * + * @throws RemoteException + */ + @Test + public void testFindRootAndTextNodes_withTwoClients_shouldPreventClient1Prefetch() + throws RemoteException { + mFrameLayout.setAccessibilityDelegate(new View.AccessibilityDelegate() { + @Override + public void onInitializeAccessibilityNodeInfo(View host, AccessibilityNodeInfo info) { + super.onInitializeAccessibilityNodeInfo(host, info); + final long nodeId = AccessibilityNodeInfo.makeNodeId( + mTextView1.getAccessibilityViewId(), + AccessibilityNodeProvider.HOST_VIEW_ID); + + // Enqueue a request when this node is found from a different service for + // TextView1 + sendNodeRequestToController(nodeId, mMockClientCallback2, + mMockClient2InteractionId, FLAG_PREFETCH_SIBLINGS); + } + }); + // Client 1 request for FrameLayout + sendNodeRequestToController(ROOT_NODE_ID, mMockClientCallback1, + mMockClient1InteractionId, FLAG_PREFETCH_DESCENDANTS); + + mInstrumentation.waitForIdleSync(); + + // Verify client 1 gets FrameLayout + verify(mMockClientCallback1).setFindAccessibilityNodeInfoResult( + mFindInfoCaptor.capture(), eq(mMockClient1InteractionId)); + AccessibilityNodeInfo infoSentToService = mFindInfoCaptor.getValue(); + assertEquals(FRAME_LAYOUT_DESCRIPTION, infoSentToService.getContentDescription()); + + // The second request is put in the queue in the FrameLayout's onInitializeA11yNodeInfo, + // meaning prefetching is interrupted and does not even begin for the first request + verify(mMockClientCallback1, never()) + .setPrefetchAccessibilityNodeInfoResult(anyList(), anyInt()); + + // Verify client 2 gets TextView1 + verify(mMockClientCallback2).setFindAccessibilityNodeInfoResult( + mFindInfoCaptor.capture(), eq(mMockClient2InteractionId)); + infoSentToService = mFindInfoCaptor.getValue(); + assertEquals(TEXT_VIEW_1_DESCRIPTION, infoSentToService.getContentDescription()); + + // Verify the prefetched sibling of TextView1 is TextView2 (FLAG_PREFETCH_SIBLINGS) + verify(mMockClientCallback2).setPrefetchAccessibilityNodeInfoResult( + mPrefetchInfoListCaptor.capture(), eq(mMockClient2InteractionId)); + List prefetchedNodes = mPrefetchInfoListCaptor.getValue(); + assertEquals(1, prefetchedNodes.size()); + assertEquals(TEXT_VIEW_2_DESCRIPTION, prefetchedNodes.get(0).getContentDescription()); + } + + /** + * Tests a series of controller same-service requests to interrupt prefetching and satisfy a + * pending node request. + * Request 1: Request the root node + * Request 2: When TextTextView1's node is initialized as part of Request 1's prefetching, + * request TestTextView1's node + * + * Request 1 prefetches TestTextView1's node, is interrupted by a pending request, and checks + * if its prefetched nodes satisfy any pending requests. It satisfies Request 2's request for + * TestTextView1's node. Request 2 is fulfilled, so it is removed from queue and does not + * prefetch. + * + * @throws RemoteException + */ + @Test + public void testFindRootAndTextNode_withOneClient_shouldInterruptPrefetchAndSatisfyPendingMsg() + throws RemoteException { + mSendClient1RequestForTextAfterTextPrefetched = true; + + mTextView1.setAccessibilityDelegate(new View.AccessibilityDelegate(){ + @Override + public void onInitializeAccessibilityNodeInfo(View host, AccessibilityNodeInfo info) { + super.onInitializeAccessibilityNodeInfo(host, info); + info.setContentDescription(TEXT_VIEW_1_DESCRIPTION); + final long nodeId = AccessibilityNodeInfo.makeNodeId( + mTextView1.getAccessibilityViewId(), + AccessibilityNodeProvider.HOST_VIEW_ID); + + if (mSendClient1RequestForTextAfterTextPrefetched) { + // Prevent a loop when processing second request + mSendClient1RequestForTextAfterTextPrefetched = false; + // TextView1 is prefetched here after the FrameLayout is found. Now enqueue a + // same-client request for TextView1 + sendNodeRequestToController(nodeId, mMockClientCallback1, + ++mMockClient1InteractionId, FLAG_PREFETCH_SIBLINGS); + + } + } + }); + // Client 1 requests FrameLayout + sendNodeRequestToController(ROOT_NODE_ID, mMockClientCallback1, + mMockClient1InteractionId, FLAG_PREFETCH_DESCENDANTS); + + // Flush out all messages + mInstrumentation.waitForIdleSync(); + + // When TextView1 is prefetched for FrameLayout, we put a message on the queue in + // TextView1's onInitializeA11yNodeInfo that requests for TextView1. The service thus get + // two node results for FrameLayout and TextView1. + verify(mMockClientCallback1, times(2)) + .setFindAccessibilityNodeInfoResult(mFindInfoCaptor.capture(), anyInt()); + + List foundNodes = mFindInfoCaptor.getAllValues(); + assertEquals(FRAME_LAYOUT_DESCRIPTION, foundNodes.get(0).getContentDescription()); + assertEquals(TEXT_VIEW_1_DESCRIPTION, foundNodes.get(1).getContentDescription()); + + // The controller will look at FrameLayout's prefetched nodes and find matching nodes in + // pending requests. The prefetched TextView1 matches the second request. The second + // request was removed from queue and prefetching for this request never occurred. + verify(mMockClientCallback1, times(1)) + .setPrefetchAccessibilityNodeInfoResult(mPrefetchInfoListCaptor.capture(), + eq(mMockClient1InteractionId - 1)); + List prefetchedNodes = mPrefetchInfoListCaptor.getValue(); + assertEquals(1, prefetchedNodes.size()); + assertEquals(TEXT_VIEW_1_DESCRIPTION, prefetchedNodes.get(0).getContentDescription()); + } + + /** + * Like above, but tests a series of controller requests from different services to interrupt + * prefetching and satisfy a pending node request. + * + * @throws RemoteException + */ + @Test + public void testFindRootAndTextNode_withTwoClients_shouldInterruptPrefetchAndSatisfyPendingMsg() + throws RemoteException { + mSendClient2RequestForTextAfterTextPrefetched = true; + mTextView1.setAccessibilityDelegate(new View.AccessibilityDelegate(){ + @Override + public void onInitializeAccessibilityNodeInfo(View host, AccessibilityNodeInfo info) { + super.onInitializeAccessibilityNodeInfo(host, info); + info.setContentDescription(TEXT_VIEW_1_DESCRIPTION); + final long nodeId = AccessibilityNodeInfo.makeNodeId( + mTextView1.getAccessibilityViewId(), + AccessibilityNodeProvider.HOST_VIEW_ID); + + if (mSendClient2RequestForTextAfterTextPrefetched) { + mSendClient2RequestForTextAfterTextPrefetched = false; + // TextView1 is prefetched here. Now enqueue client 2's request for + // TextView1 + sendNodeRequestToController(nodeId, mMockClientCallback2, + mMockClient2InteractionId, FLAG_PREFETCH_SIBLINGS); + } + } + }); + // Client 1 requests FrameLayout + sendNodeRequestToController(ROOT_NODE_ID, mMockClientCallback1, + mMockClient1InteractionId, FLAG_PREFETCH_DESCENDANTS); + + mInstrumentation.waitForIdleSync(); + + // Verify client 1 gets FrameLayout + verify(mMockClientCallback1, times(1)) + .setFindAccessibilityNodeInfoResult(mFindInfoCaptor.capture(), anyInt()); + assertEquals(FRAME_LAYOUT_DESCRIPTION, + mFindInfoCaptor.getValue().getContentDescription()); + + // Verify client 1 has prefetched nodes + verify(mMockClientCallback1, times(1)) + .setPrefetchAccessibilityNodeInfoResult(mPrefetchInfoListCaptor.capture(), + eq(mMockClient1InteractionId)); + + // Verify client 1's only prefetched node is TextView1 + List prefetchedNodes = mPrefetchInfoListCaptor.getValue(); + assertEquals(1, prefetchedNodes.size()); + assertEquals(TEXT_VIEW_1_DESCRIPTION, prefetchedNodes.get(0).getContentDescription()); + + // Verify client 2 gets TextView1 + verify(mMockClientCallback2, times(1)) + .setFindAccessibilityNodeInfoResult(mFindInfoCaptor.capture(), anyInt()); + + assertEquals(TEXT_VIEW_1_DESCRIPTION, mFindInfoCaptor.getValue().getContentDescription()); + + // The second request was removed from queue and prefetching for this client request never + // occurred as it was satisfied. + verify(mMockClientCallback2, never()) + .setPrefetchAccessibilityNodeInfoResult(anyList(), anyInt()); + + } + + @Test + public void testFindNodeById_withTwoDifferentPrefetchFlags_shouldNotSatisfyPendingRequest() + throws RemoteException { + mSendRequestForTextAndIncludeUnImportantViews = true; + mTextView1.setAccessibilityDelegate(new View.AccessibilityDelegate(){ + @Override + public void onInitializeAccessibilityNodeInfo(View host, AccessibilityNodeInfo info) { + super.onInitializeAccessibilityNodeInfo(host, info); + info.setContentDescription(TEXT_VIEW_1_DESCRIPTION); + final long nodeId = AccessibilityNodeInfo.makeNodeId( + mTextView1.getAccessibilityViewId(), + AccessibilityNodeProvider.HOST_VIEW_ID); + + if (mSendRequestForTextAndIncludeUnImportantViews) { + mSendRequestForTextAndIncludeUnImportantViews = false; + // TextView1 is prefetched here for client 1. Now enqueue a request from a + // different client that holds different fetch flags for TextView1 + sendNodeRequestToController(nodeId, mMockClientCallback2, + mMockClient2InteractionId, + FLAG_PREFETCH_SIBLINGS | FLAG_INCLUDE_NOT_IMPORTANT_VIEWS); + } + } + }); + + // Mockito does not make copies of objects when called. It holds references, so + // the captor would point to client 2's results after all requests are processed. Verify + // prefetched node immediately + doAnswer(invocation -> { + List prefetched = invocation.getArgument(0); + assertEquals(TEXT_VIEW_1_DESCRIPTION, prefetched.get(0).getContentDescription()); + return null; + }).when(mMockClientCallback1).setPrefetchAccessibilityNodeInfoResult(anyList(), + eq(mMockClient1InteractionId)); + + // Client 1 requests FrameLayout + sendNodeRequestToController(ROOT_NODE_ID, mMockClientCallback1, + mMockClient1InteractionId, FLAG_PREFETCH_DESCENDANTS); + + mInstrumentation.waitForIdleSync(); + + // Verify client 1 gets FrameLayout + verify(mMockClientCallback1, times(1)) + .setFindAccessibilityNodeInfoResult(mFindInfoCaptor.capture(), + eq(mMockClient1InteractionId)); + + assertEquals(FRAME_LAYOUT_DESCRIPTION, + mFindInfoCaptor.getValue().getContentDescription()); + + // Verify client 1 has prefetched results. The only prefetched node is TextView1 + // (from above doAnswer) + verify(mMockClientCallback1, times(1)) + .setPrefetchAccessibilityNodeInfoResult(mPrefetchInfoListCaptor.capture(), + eq(mMockClient1InteractionId)); + + // Verify client 2 gets TextView1 + verify(mMockClientCallback2, times(1)) + .setFindAccessibilityNodeInfoResult(mFindInfoCaptor.capture(), + eq(mMockClient2InteractionId)); + assertEquals(TEXT_VIEW_1_DESCRIPTION, + mFindInfoCaptor.getValue().getContentDescription()); + // Verify client 2 has TextView2 as a prefetched node + verify(mMockClientCallback2, times(1)) + .setPrefetchAccessibilityNodeInfoResult(mPrefetchInfoListCaptor.capture(), + eq(mMockClient2InteractionId)); + List prefetchedNode = mPrefetchInfoListCaptor.getValue(); + assertEquals(1, prefetchedNode.size()); + assertEquals(TEXT_VIEW_2_DESCRIPTION, prefetchedNode.get(0).getContentDescription()); + } + + private void sendNodeRequestToController(long requestedNodeId, + IAccessibilityInteractionConnectionCallback callback, int interactionId, + int prefetchFlags) { + final int processAndThreadId = callback == mMockClientCallback1 + ? MOCK_CLIENT_1_THREAD_AND_PROCESS_ID + : MOCK_CLIENT_2_THREAD_AND_PROCESS_ID; + + mAccessibilityInteractionController.findAccessibilityNodeInfoByAccessibilityIdClientThread( + requestedNodeId, + null, interactionId, + callback, prefetchFlags, + processAndThreadId, + processAndThreadId, null, null); + + } + + private class TestFrameLayout extends FrameLayout { + + TestFrameLayout(Context context) { + super(context); + } + + @Override + public int getWindowVisibility() { + // We aren't attached to a window so let's pretend + return VISIBLE; + } + + @Override + public boolean isShown() { + // Controller check + return true; + } + + @Override + public int getAccessibilityViewId() { + // static id doesn't reset after tests so return the same one + return 0; + } + + @Override + public void addChildrenForAccessibility(ArrayList outChildren) { + // ViewGroup#addChildrenForAccessbility sorting logic will switch these two + outChildren.add(mTextView1); + outChildren.add(mTextView2); + } + + @Override + public boolean includeForAccessibility() { + return true; + } + + @Override + public void onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo info) { + super.onInitializeAccessibilityNodeInfo(info); + info.setContentDescription(FRAME_LAYOUT_DESCRIPTION); + } + } + + private class TestTextView extends TextView { + TestTextView(Context context) { + super(context); + } + + @Override + public int getWindowVisibility() { + return VISIBLE; + } + + @Override + public boolean isShown() { + return true; + } + + @Override + public int getAccessibilityViewId() { + return 1; + } + + @Override + public boolean includeForAccessibility() { + return true; + } + + @Override + public void onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo info) { + super.onInitializeAccessibilityNodeInfo(info); + info.setContentDescription(TEXT_VIEW_1_DESCRIPTION); + } + } + + private class TestTextView2 extends TextView { + TestTextView2(Context context) { + super(context); + } + + @Override + public int getWindowVisibility() { + return VISIBLE; + } + + @Override + public boolean isShown() { + return true; + } + + @Override + public int getAccessibilityViewId() { + return 2; + } + + @Override + public boolean includeForAccessibility() { + return true; + } + + @Override + public void onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo info) { + super.onInitializeAccessibilityNodeInfo(info); + info.setContentDescription(TEXT_VIEW_2_DESCRIPTION); + } + } +}