From 7853e418e79393f95de1b231faa3a12837047f60 Mon Sep 17 00:00:00 2001 From: Sally Date: Tue, 30 Mar 2021 20:47:49 +0000 Subject: [PATCH] Service requests can interrupt node prefetching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This is the re re..re-merging of ag/12923546 (where most of that original message is posted below). This 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 satisfy at most one pending request. We also make mPendingFindNodeByIdMessages thread-safe and ensure in ActionReplacingCallback we don't return null results. Merged ag/13246536, ag/13256330 Messages should be added to PrivateHandler and mPendingFindNodeIdMessages at the same time to avoid a race condition where we try removing a message from the handler before it's actually enqueued. This was causing double recycling We call into AccessibilityCache from the Binder thread, now that the cache doesn’t call out to the app main thread with a lock(ag/14020225) Added tests to verify AccessibilityInteractionController interactions Test: atest AccessibilityInteractionControllerNodeRequestsTest atest CtsAccessibilityServiceTestCases CtsAccessibilityTestCases CtsUiAutomationTestCases FrameworksServicesTests:com.android.server.accessibility FrameworksCoreTests:com.android.internal.accessibility FrameworksCoreTests:android.view.accessibility Bug: b/184076735 Change-Id: Iaad1b9100655ec86a788ba1c89edd2dd8a7df1f6 --- .../AccessibilityInteractionController.java | 384 +++++++---- .../AccessibilityInteractionClient.java | 83 ++- ...sibilityInteractionConnectionCallback.aidl | 9 + .../AccessibilityInteractionClientTest.java | 9 +- .../ActionReplacingCallback.java | 227 +++--- ...InteractionControllerNodeRequestsTest.java | 644 ++++++++++++++++++ 6 files changed, 1110 insertions(+), 246 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 09452828057e8..199ecb3e5df28 100644 --- a/core/java/android/view/AccessibilityInteractionController.java +++ b/core/java/android/view/AccessibilityInteractionController.java @@ -86,6 +86,12 @@ 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(); @@ -113,6 +119,9 @@ public final class AccessibilityInteractionController { private AddNodeInfosForViewId mAddNodeInfosForViewId; + @GuardedBy("mLock") + private ArrayList mPendingFindNodeByIdMessages; + @GuardedBy("mLock") private int mNumActiveRequestPreparers; @GuardedBy("mLock") @@ -128,6 +137,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,7 +187,11 @@ public final class AccessibilityInteractionController { args.arg4 = arguments; message.obj = args; - scheduleMessage(message, interrogatingPid, interrogatingTid, CONSIDER_REQUEST_PREPARERS); + 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,69 @@ public final class AccessibilityInteractionController { args.recycle(); - List infos = mTempAccessibilityNodeInfoList; - infos.clear(); + View requestedView = null; + AccessibilityNodeInfo requestedNode = 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); + requestedView = findViewByAccessibilityId(accessibilityViewId); + if (requestedView != null && isShown(requestedView)) { + requestedNode = populateAccessibilityNodeInfoForView( + requestedView, arguments, virtualDescendantId); } } finally { - updateInfosForViewportAndReturnFindNodeResult( - infos, callback, interactionId, spec, interactiveRegion); + updateInfoForViewportAndReturnFindNodeResult( + requestedNode == null ? null : AccessibilityNodeInfo.obtain(requestedNode), + callback, interactionId, spec, interactiveRegion); } + ArrayList infos = mTempAccessibilityNodeInfoList; + infos.clear(); + mPrefetcher.prefetchAccessibilityNodeInfos(requestedView, + requestedNode == null ? null : AccessibilityNodeInfo.obtain(requestedNode), + virtualDescendantId, flags, infos); + mViewRootImpl.mAttachInfo.mAccessibilityFetchFlags = 0; + updateInfosForViewPort(infos, spec, interactiveRegion); + final SatisfiedFindAccessibilityNodeByAccessibilityIdRequest satisfiedRequest = + getSatisfiedRequestInPrefetch( + requestedNode == null ? null : requestedNode, infos, flags); + + if (satisfiedRequest != null && satisfiedRequest.mSatisfiedRequestNode != requestedNode) { + infos.remove(satisfiedRequest.mSatisfiedRequestNode); + } + + returnPrefetchResult(interactionId, infos, callback); + + if (satisfiedRequest != null) { + returnFindNodeResult(satisfiedRequest); + } + } + + 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, @@ -403,6 +467,7 @@ public final class AccessibilityInteractionController { mAddNodeInfosForViewId.reset(); } } finally { + mViewRootImpl.mAttachInfo.mAccessibilityFetchFlags = 0; updateInfosForViewportAndReturnFindNodeResult( infos, callback, interactionId, spec, interactiveRegion); } @@ -485,6 +550,7 @@ public final class AccessibilityInteractionController { } } } finally { + mViewRootImpl.mAttachInfo.mAccessibilityFetchFlags = 0; updateInfosForViewportAndReturnFindNodeResult( infos, callback, interactionId, spec, interactiveRegion); } @@ -576,6 +642,7 @@ public final class AccessibilityInteractionController { } } } finally { + mViewRootImpl.mAttachInfo.mAccessibilityFetchFlags = 0; updateInfoForViewportAndReturnFindNodeResult( focused, callback, interactionId, spec, interactiveRegion); } @@ -630,6 +697,7 @@ public final class AccessibilityInteractionController { } } } finally { + mViewRootImpl.mAttachInfo.mAccessibilityFetchFlags = 0; updateInfoForViewportAndReturnFindNodeResult( next, callback, interactionId, spec, interactiveRegion); } @@ -786,33 +854,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) { @@ -833,17 +874,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; @@ -891,17 +921,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; @@ -975,18 +994,59 @@ 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 returnFindNodeResult(SatisfiedFindAccessibilityNodeByAccessibilityIdRequest + satisfiedRequest) { + try { + final AccessibilityNodeInfo info = satisfiedRequest.mSatisfiedRequestNode; + final IAccessibilityInteractionConnectionCallback callback = + satisfiedRequest.mSatisfiedRequestCallback; + final int interactionId = satisfiedRequest.mSatisfiedRequestInteractionId; + 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(); @@ -996,22 +1056,74 @@ public final class AccessibilityInteractionController { } } + private SatisfiedFindAccessibilityNodeByAccessibilityIdRequest getSatisfiedRequestInPrefetch( + AccessibilityNodeInfo requestedNode, List infos, int flags) { + SatisfiedFindAccessibilityNodeByAccessibilityIdRequest satisfiedRequest = null; + 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; + + final AccessibilityNodeInfo satisfiedRequestNode = nodeWithIdFromList(requestedNode, + infos, AccessibilityNodeInfo.makeNodeId( + accessibilityViewId, virtualDescendantId)); + + if (satisfiedRequestNode != null) { + mHandler.removeMessages( + PrivateHandler.MSG_FIND_ACCESSIBILITY_NODE_INFO_BY_ACCESSIBILITY_ID, + pendingMessage.obj); + final IAccessibilityInteractionConnectionCallback satisfiedRequestCallback = + (IAccessibilityInteractionConnectionCallback) args.arg1; + final int satisfiedRequestInteractionId = args.argi3; + satisfiedRequest = new SatisfiedFindAccessibilityNodeByAccessibilityIdRequest( + satisfiedRequestNode, satisfiedRequestCallback, + satisfiedRequestInteractionId); + args.recycle(); + break; + } + } + mPendingFindNodeByIdMessages.clear(); + return satisfiedRequest; + } + } + + private AccessibilityNodeInfo nodeWithIdFromList(AccessibilityNodeInfo requestedNode, + List infos, long nodeId) { + if (requestedNode != null && requestedNode.getSourceNodeId() == nodeId) { + return requestedNode; + } + 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( @@ -1054,56 +1166,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(); @@ -1114,7 +1215,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; @@ -1181,9 +1281,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) { @@ -1195,6 +1297,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; @@ -1204,7 +1309,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); @@ -1232,7 +1337,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 = @@ -1243,7 +1348,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); @@ -1268,7 +1373,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(); @@ -1290,7 +1395,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 = @@ -1335,7 +1440,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); @@ -1360,7 +1465,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); @@ -1370,7 +1475,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); @@ -1479,6 +1584,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 { @@ -1515,4 +1624,19 @@ public final class AccessibilityInteractionController { mInterrogatingTid = interrogatingTid; } } + + private static class SatisfiedFindAccessibilityNodeByAccessibilityIdRequest { + final AccessibilityNodeInfo mSatisfiedRequestNode; + final IAccessibilityInteractionConnectionCallback mSatisfiedRequestCallback; + final int mSatisfiedRequestInteractionId; + + SatisfiedFindAccessibilityNodeByAccessibilityIdRequest( + AccessibilityNodeInfo satisfiedRequestNode, + IAccessibilityInteractionConnectionCallback satisfiedRequestCallback, + int satisfiedRequestInteractionId) { + mSatisfiedRequestNode = satisfiedRequestNode; + mSatisfiedRequestCallback = satisfiedRequestCallback; + mSatisfiedRequestInteractionId = satisfiedRequestInteractionId; + } + } } diff --git a/core/java/android/view/accessibility/AccessibilityInteractionClient.java b/core/java/android/view/accessibility/AccessibilityInteractionClient.java index e64157734fdb3..df67740565ea9 100644 --- a/core/java/android/view/accessibility/AccessibilityInteractionClient.java +++ b/core/java/android/view/accessibility/AccessibilityInteractionClient.java @@ -130,6 +130,10 @@ public final class AccessibilityInteractionClient private Message mSameThreadMessage; + private int mInteractionIdWaitingForPrefetchResult = -1; + private int mConnectionIdWaitingForPrefetchResult; + private String[] mPackageNamesForNextPrefetchResult; + /** * @return The client for the current thread. */ @@ -494,25 +498,27 @@ public final class AccessibilityInteractionClient Binder.restoreCallingIdentity(identityToken); } if (packageNames != null) { - List infos = getFindAccessibilityNodeInfosResultAndClear( - interactionId); + AccessibilityNodeInfo info = + getFindAccessibilityNodeInfoResultAndClear(interactionId); if (mAccessibilityManager != null && mAccessibilityManager.isAccessibilityTracingEnabled()) { logTrace(connection, "findAccessibilityNodeInfoByAccessibilityId", - "InteractionId:" + interactionId + ";Result: " + infos - + ";connectionId=" + connectionId + ";accessibilityWindowId=" - + accessibilityWindowId + ";accessibilityNodeId=" - + accessibilityNodeId + ";bypassCache=" + bypassCache - + ";prefetchFlags=" + prefetchFlags + ";arguments=" + arguments); + "InteractionId:" + interactionId + ";Result: " + info + + ";connectionId=" + connectionId + + ";accessibilityWindowId=" + + accessibilityWindowId + ";accessibilityNodeId=" + + accessibilityNodeId + ";bypassCache=" + bypassCache + + ";prefetchFlags=" + prefetchFlags + + ";arguments=" + arguments); } - finalizeAndCacheAccessibilityNodeInfos(infos, connectionId, + if ((prefetchFlags & AccessibilityNodeInfo.FLAG_PREFETCH_MASK) != 0 + && info != null) { + setInteractionWaitingForPrefetchResult(interactionId, connectionId, + packageNames); + } + finalizeAndCacheAccessibilityNodeInfo(info, connectionId, bypassCache, packageNames); - if (infos != null && !infos.isEmpty()) { - for (int i = 1; i < infos.size(); i++) { - infos.get(i).recycle(); - } - return infos.get(0); - } + return info; } } else { if (DEBUG) { @@ -526,6 +532,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); @@ -923,6 +938,46 @@ public final class AccessibilityInteractionClient } } + /** + * {@inheritDoc} + */ + @Override + public void setPrefetchAccessibilityNodeInfoResult(@NonNull List infos, + int interactionId) { + int interactionIdWaitingForPrefetchResultCopy = -1; + int connectionIdWaitingForPrefetchResultCopy = -1; + String[] packageNamesForNextPrefetchResultCopy = null; + + if (infos.isEmpty()) { + return; + } + + synchronized (mInstanceLock) { + if (mInteractionIdWaitingForPrefetchResult == interactionId) { + interactionIdWaitingForPrefetchResultCopy = mInteractionIdWaitingForPrefetchResult; + connectionIdWaitingForPrefetchResultCopy = + mConnectionIdWaitingForPrefetchResult; + if (mPackageNamesForNextPrefetchResult != null) { + packageNamesForNextPrefetchResultCopy = + new String[mPackageNamesForNextPrefetchResult.length]; + for (int i = 0; i < mPackageNamesForNextPrefetchResult.length; i++) { + packageNamesForNextPrefetchResultCopy[i] = + mPackageNamesForNextPrefetchResult[i]; + } + } + } + } + + if (interactionIdWaitingForPrefetchResultCopy == interactionId) { + finalizeAndCacheAccessibilityNodeInfos( + infos, connectionIdWaitingForPrefetchResultCopy, false, + packageNamesForNextPrefetchResultCopy); + } else if (DEBUG) { + Log.w(LOG_TAG, "Prefetching for interaction with id " + interactionId + " dropped " + + infos.size() + " nodes"); + } + } + /** * 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..7179c60f7d8db --- /dev/null +++ b/services/tests/servicestests/src/com/android/server/accessibility/AccessibilityInteractionControllerNodeRequestsTest.java @@ -0,0 +1,644 @@ +/* + * 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 boolean mSendClient1RequestForRootAfterTextPrefetched; + 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. TestTextView1 is removed from Request's 1's prefetched results, meaning the list + * is empty. + * + * @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. This is removed + // from the first request's prefetch list, which is now empty. The second + // request was removed from queue and prefetching for this request never occurred. + verify(mMockClientCallback1, never()) + .setPrefetchAccessibilityNodeInfoResult(mPrefetchInfoListCaptor.capture(), + eq(mMockClient1InteractionId - 1)); + } + + /** + * 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 the root 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 + * the root node. Request 2 is fulfilled, so it is removed from queue and does not + * prefetch. + * + * @throws RemoteException + */ + @Test + public void testFindRoot_withOneClient_shouldInterruptPrefetchAndSatisfyPendingMsg() + throws RemoteException { + mSendClient1RequestForRootAfterTextPrefetched = 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( + mFrameLayout.getAccessibilityViewId(), + AccessibilityNodeProvider.HOST_VIEW_ID); + + if (mSendClient1RequestForRootAfterTextPrefetched) { + // Prevent a loop when processing second request + mSendClient1RequestForRootAfterTextPrefetched = false; + // TextView1 is prefetched here after the FrameLayout is found. Now enqueue a + // same-client request for FrameLayout + sendNodeRequestToController(nodeId, mMockClientCallback1, + ++mMockClient1InteractionId, FLAG_PREFETCH_DESCENDANTS); + + } + } + }); + // 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 gets + // 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(FRAME_LAYOUT_DESCRIPTION, foundNodes.get(1).getContentDescription()); + + // The controller will look at FrameLayout's prefetched nodes and find matching nodes in + // pending requests. The first requested node (FrameLayout) is also checked, and this + // satifies the second request. The second request is removed from queue and prefetching + // for this request never occurs. + 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 doesn't have prefetched nodes + verify(mMockClientCallback1, never()) + .setPrefetchAccessibilityNodeInfoResult(mPrefetchInfoListCaptor.capture(), + eq(mMockClient1InteractionId)); + + // 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); + } + } +}