• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
2  * Copyright (C) 2020 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 package com.android.car.rotary;
17 
18 import static android.app.ActivityTaskManager.INVALID_TASK_ID;
19 import static android.view.View.FOCUS_DOWN;
20 import static android.view.View.FOCUS_LEFT;
21 import static android.view.View.FOCUS_RIGHT;
22 import static android.view.View.FOCUS_UP;
23 import static android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction.ACTION_SCROLL_BACKWARD;
24 import static android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction.ACTION_SCROLL_FORWARD;
25 import static android.view.accessibility.AccessibilityNodeInfo.FOCUS_INPUT;
26 import static android.view.accessibility.AccessibilityWindowInfo.TYPE_APPLICATION;
27 import static android.view.accessibility.AccessibilityWindowInfo.TYPE_INPUT_METHOD;
28 
29 import android.content.pm.PackageManager;
30 import android.graphics.Rect;
31 import android.view.Display;
32 import android.view.View;
33 import android.view.accessibility.AccessibilityNodeInfo;
34 import android.view.accessibility.AccessibilityWindowInfo;
35 
36 import androidx.annotation.NonNull;
37 import androidx.annotation.Nullable;
38 import androidx.annotation.VisibleForTesting;
39 
40 import com.android.car.ui.FocusArea;
41 import com.android.car.ui.FocusParkingView;
42 import com.android.internal.util.dump.DualDumpOutputStream;
43 
44 import java.util.ArrayList;
45 import java.util.List;
46 import java.util.function.Predicate;
47 
48 /**
49  * A helper class used for finding the next focusable node when the rotary controller is rotated or
50  * nudged.
51  */
52 class Navigator {
53 
54     @NonNull
55     private NodeCopier mNodeCopier = new NodeCopier();
56 
57     @NonNull
58     private final TreeTraverser mTreeTraverser = new TreeTraverser();
59 
60     @NonNull
61     @VisibleForTesting
62     final SurfaceViewHelper mSurfaceViewHelper = new SurfaceViewHelper();
63 
64     private final int mHunLeft;
65     private final int mHunRight;
66 
67     @View.FocusRealDirection
68     private int mHunNudgeDirection;
69 
70     @NonNull
71     private final Rect mAppWindowBounds;
72 
73     private int mAppWindowTaskId = INVALID_TASK_ID;
74 
Navigator(int displayWidth, int displayHeight, int hunLeft, int hunRight, boolean showHunOnBottom)75     Navigator(int displayWidth, int displayHeight, int hunLeft, int hunRight,
76             boolean showHunOnBottom) {
77         mHunLeft = hunLeft;
78         mHunRight = hunRight;
79         mHunNudgeDirection = showHunOnBottom ? FOCUS_DOWN : FOCUS_UP;
80         mAppWindowBounds = new Rect(0, 0, displayWidth, displayHeight);
81     }
82 
83     @VisibleForTesting
Navigator()84     Navigator() {
85         this(0, 0, 0, 0, false);
86     }
87 
88     /**
89      * Updates {@link #mAppWindowTaskId} if {@code window} is a full-screen app window on the
90      * default display.
91      */
updateAppWindowTaskId(@onNull AccessibilityWindowInfo window)92     void updateAppWindowTaskId(@NonNull AccessibilityWindowInfo window) {
93         if (window.getType() == TYPE_APPLICATION
94                 && window.getDisplayId() == Display.DEFAULT_DISPLAY) {
95             Rect windowBounds = new Rect();
96             window.getBoundsInScreen(windowBounds);
97             if (mAppWindowBounds.equals(windowBounds)) {
98                 mAppWindowTaskId = window.getTaskId();
99                 L.d("Task ID of app window: " + mAppWindowTaskId);
100             }
101         }
102     }
103 
104     /** Initializes the package name of the host app. */
initHostApp(@onNull PackageManager packageManager)105     void initHostApp(@NonNull PackageManager packageManager) {
106         mSurfaceViewHelper.initHostApp(packageManager);
107     }
108 
109     /** Clears the package name of the host app if the given {@code packageName} matches. */
clearHostApp(@onNull String packageName)110     void clearHostApp(@NonNull String packageName) {
111         mSurfaceViewHelper.clearHostApp(packageName);
112     }
113 
114     /** Adds the package name of the client app. */
addClientApp(@onNull CharSequence clientAppPackageName)115     void addClientApp(@NonNull CharSequence clientAppPackageName) {
116         mSurfaceViewHelper.addClientApp(clientAppPackageName);
117     }
118 
119     /** Returns whether the given {@code node} represents a view of the host app. */
isHostNode(@onNull AccessibilityNodeInfo node)120     boolean isHostNode(@NonNull AccessibilityNodeInfo node) {
121         return mSurfaceViewHelper.isHostNode(node);
122     }
123 
124     /** Returns whether the given {@code node} represents a view of the client app. */
isClientNode(@onNull AccessibilityNodeInfo node)125     boolean isClientNode(@NonNull AccessibilityNodeInfo node) {
126         return mSurfaceViewHelper.isClientNode(node);
127     }
128 
129     @Nullable
findHunWindow(@onNull List<AccessibilityWindowInfo> windows)130     AccessibilityWindowInfo findHunWindow(@NonNull List<AccessibilityWindowInfo> windows) {
131         for (AccessibilityWindowInfo window : windows) {
132             if (isHunWindow(window)) {
133                 return window;
134             }
135         }
136         return null;
137     }
138 
139     /**
140      * Returns the target focusable for a rotate. The caller is responsible for recycling the node
141      * in the result.
142      *
143      * <p>Limits navigation to focusable views within a scrollable container's viewport, if any.
144      *
145      * @param sourceNode    the current focus
146      * @param direction     rotate direction, must be {@link View#FOCUS_FORWARD} or {@link
147      *                      View#FOCUS_BACKWARD}
148      * @param rotationCount the number of "ticks" to rotate. Only count nodes that can take focus
149      *                      (visible, focusable and enabled). If {@code skipNode} is encountered, it
150      *                      isn't counted.
151      * @return a FindRotateTargetResult containing a node and a count of the number of times the
152      *         search advanced to another node. The node represents a focusable view in the given
153      *         {@code direction} from the current focus within the same {@link FocusArea}. If the
154      *         first or last view is reached before counting up to {@code rotationCount}, the first
155      *         or last view is returned. However, if there are no views that can take focus in the
156      *         given {@code direction}, {@code null} is returned.
157      */
158     @Nullable
findRotateTarget( @onNull AccessibilityNodeInfo sourceNode, int direction, int rotationCount)159     FindRotateTargetResult findRotateTarget(
160             @NonNull AccessibilityNodeInfo sourceNode, int direction, int rotationCount) {
161         int advancedCount = 0;
162         AccessibilityNodeInfo currentFocusArea = getAncestorFocusArea(sourceNode);
163         AccessibilityNodeInfo candidate = copyNode(sourceNode);
164         AccessibilityNodeInfo target = null;
165         while (advancedCount < rotationCount) {
166             AccessibilityNodeInfo nextCandidate = null;
167             // Virtual View hierarchies like WebViews and ComposeViews do not support focusSearch().
168             AccessibilityNodeInfo virtualViewAncestor = findVirtualViewAncestor(candidate);
169             if (virtualViewAncestor != null) {
170                 nextCandidate =
171                     findNextFocusableInVirtualRoot(virtualViewAncestor, candidate, direction);
172             }
173             if (nextCandidate == null) {
174                 // If we aren't in a virtual node hierarchy, or there aren't any more focusable
175                 // nodes within the virtual node hierarchy, use focusSearch().
176                 nextCandidate = candidate.focusSearch(direction);
177             }
178             AccessibilityNodeInfo candidateFocusArea =
179                     nextCandidate == null ? null : getAncestorFocusArea(nextCandidate);
180 
181             // Only advance to nextCandidate if:
182             // 1. it's in the same focus area,
183             // 2. and it isn't a FocusParkingView (this is to prevent wrap-around when there is only
184             //    one focus area in the window, including when the root node is treated as a focus
185             //    area),
186             // 3. and nextCandidate is different from candidate (if sourceNode is the first
187             //    focusable node in the window, searching backward will return sourceNode itself).
188             if (nextCandidate != null && currentFocusArea.equals(candidateFocusArea)
189                     && !Utils.isFocusParkingView(nextCandidate)
190                     && !nextCandidate.equals(candidate)) {
191                 // We need to skip nextTargetNode if:
192                 // 1. it can't perform focus action (focusSearch() may return a node with zero
193                 //    width and height),
194                 // 2. or it is a scrollable container but it shouldn't be scrolled (i.e., it is not
195                 //    scrollable, or its descendants can take focus).
196                 //    When we want to focus on its element directly, we'll skip the container. When
197                 //    we want to focus on container and scroll it, we won't skip the container.
198                 if (!Utils.canPerformFocus(nextCandidate)
199                         || (Utils.isScrollableContainer(nextCandidate)
200                             && !Utils.canScrollableContainerTakeFocus(nextCandidate))) {
201                     Utils.recycleNode(candidate);
202                     Utils.recycleNode(candidateFocusArea);
203                     candidate = nextCandidate;
204                     continue;
205                 }
206 
207                 // If we're navigating in a scrollable container that can scroll in the specified
208                 // direction and the next candidate is off-screen or there are no more focusable
209                 // views within the scrollable container, stop navigating so that any remaining
210                 // detents are used for scrolling.
211                 AccessibilityNodeInfo scrollableContainer = findScrollableContainer(candidate);
212                 AccessibilityNodeInfo.AccessibilityAction scrollAction =
213                         direction == View.FOCUS_FORWARD
214                                 ? ACTION_SCROLL_FORWARD
215                                 : ACTION_SCROLL_BACKWARD;
216                 if (scrollableContainer != null
217                         && scrollableContainer.getActionList().contains(scrollAction)
218                         && (!Utils.isDescendant(scrollableContainer, nextCandidate)
219                                 || Utils.getBoundsInScreen(nextCandidate).isEmpty())) {
220                     Utils.recycleNode(nextCandidate);
221                     Utils.recycleNode(candidateFocusArea);
222                     break;
223                 }
224                 Utils.recycleNode(scrollableContainer);
225 
226                 Utils.recycleNode(candidate);
227                 Utils.recycleNode(candidateFocusArea);
228                 candidate = nextCandidate;
229                 Utils.recycleNode(target);
230                 target = copyNode(candidate);
231                 advancedCount++;
232             } else {
233                 Utils.recycleNode(nextCandidate);
234                 Utils.recycleNode(candidateFocusArea);
235                 break;
236             }
237         }
238         currentFocusArea.recycle();
239         candidate.recycle();
240         if (sourceNode.equals(target)) {
241             L.e("Wrap-around on the same node");
242             target.recycle();
243             return null;
244         }
245         return target == null ? null : new FindRotateTargetResult(target, advancedCount);
246     }
247 
248     /** Sets a NodeCopier instance for testing. */
249     @VisibleForTesting
setNodeCopier(@onNull NodeCopier nodeCopier)250     void setNodeCopier(@NonNull NodeCopier nodeCopier) {
251         mNodeCopier = nodeCopier;
252         mTreeTraverser.setNodeCopier(nodeCopier);
253     }
254 
255     /**
256      * Returns the root node in the tree containing {@code node}. The caller is responsible for
257      * recycling the result.
258      */
259     @NonNull
getRoot(@onNull AccessibilityNodeInfo node)260     AccessibilityNodeInfo getRoot(@NonNull AccessibilityNodeInfo node) {
261         // If the node represents a view in the embedded view hierarchy hosted by a SurfaceView,
262         // return the root node of the hierarchy, which is the only child of the SurfaceView node.
263         if (isHostNode(node)) {
264             AccessibilityNodeInfo child = mNodeCopier.copy(node);
265             AccessibilityNodeInfo parent = node.getParent();
266             while (parent != null && !Utils.isSurfaceView(parent)) {
267                 child.recycle();
268                 child = parent;
269                 parent = child.getParent();
270             }
271             Utils.recycleNode(parent);
272             return child;
273         }
274 
275         // Get the root node directly via the window.
276         AccessibilityWindowInfo window = node.getWindow();
277         if (window != null) {
278             AccessibilityNodeInfo root = window.getRoot();
279             window.recycle();
280             if (root != null) {
281                 return root;
282             }
283         }
284 
285         // If the root node can't be accessed via the window, navigate up the node tree.
286         AccessibilityNodeInfo child = mNodeCopier.copy(node);
287         AccessibilityNodeInfo parent = node.getParent();
288         while (parent != null) {
289             child.recycle();
290             child = parent;
291             parent = child.getParent();
292         }
293         return child;
294     }
295 
296     /**
297      * Searches {@code root} and its descendants, and returns the currently focused node if it's
298      * not a {@link FocusParkingView}, or returns null in other cases. The caller is responsible
299      * for recycling the result.
300      */
301     @Nullable
findFocusedNodeInRoot(@onNull AccessibilityNodeInfo root)302     AccessibilityNodeInfo findFocusedNodeInRoot(@NonNull AccessibilityNodeInfo root) {
303         AccessibilityNodeInfo focusedNode = findFocusedNodeInRootInternal(root);
304         if (focusedNode != null && Utils.isFocusParkingView(focusedNode)) {
305             focusedNode.recycle();
306             return null;
307         }
308         return focusedNode;
309     }
310 
311     /**
312      * Searches {@code root} and its descendants, and returns the currently focused node, if any,
313      * or returns null if not found. The caller is responsible for recycling the result.
314      */
315     @Nullable
findFocusedNodeInRootInternal( @onNull AccessibilityNodeInfo root)316     private AccessibilityNodeInfo findFocusedNodeInRootInternal(
317             @NonNull AccessibilityNodeInfo root) {
318         AccessibilityNodeInfo surfaceView = null;
319         if (!isClientNode(root)) {
320             AccessibilityNodeInfo focusedNode = root.findFocus(FOCUS_INPUT);
321             if (focusedNode != null && Utils.isSurfaceView(focusedNode)) {
322                 // The focused node represents a SurfaceView. In this case the root node is actually
323                 // a client node but Navigator doesn't know that because SurfaceViewHelper doesn't
324                 // know the package name of the client app.
325                 // Although the package name of the client app will be stored in SurfaceViewHelper
326                 // when RotaryService handles TYPE_WINDOW_STATE_CHANGED event, RotaryService may not
327                 // receive the event. For example, RotaryService may have been killed and restarted.
328                 // In this case, Navigator should store the package name.
329                 surfaceView = focusedNode;
330                 addClientApp(surfaceView.getPackageName());
331             } else {
332                 return focusedNode;
333             }
334         }
335 
336         // The root node is in client app, which contains a SurfaceView to display the embedded
337         // view hierarchy. In this case only search inside the embedded view hierarchy.
338         if (surfaceView == null) {
339             surfaceView = findSurfaceViewInRoot(root);
340         }
341         if (surfaceView == null) {
342             L.w("Failed to find SurfaceView in client app " + root);
343             return null;
344         }
345         if (surfaceView.getChildCount() == 0) {
346             L.d("Host app is not loaded yet");
347             surfaceView.recycle();
348             return null;
349         }
350         AccessibilityNodeInfo embeddedRoot = surfaceView.getChild(0);
351         surfaceView.recycle();
352         if (embeddedRoot == null) {
353             L.w("Failed to get the root of host app");
354             return null;
355         }
356         AccessibilityNodeInfo focusedNode = embeddedRoot.findFocus(FOCUS_INPUT);
357         embeddedRoot.recycle();
358         return focusedNode;
359     }
360 
361     /**
362      * Searches the window containing {@code node}, and returns the node representing a {@link
363      * FocusParkingView}, if any, or returns null if not found. The caller is responsible for
364      * recycling the result.
365      */
366     @Nullable
findFocusParkingView(@onNull AccessibilityNodeInfo node)367     AccessibilityNodeInfo findFocusParkingView(@NonNull AccessibilityNodeInfo node) {
368         AccessibilityNodeInfo root = getRoot(node);
369         AccessibilityNodeInfo fpv = findFocusParkingViewInRoot(root);
370         root.recycle();
371         return fpv;
372     }
373 
374     /**
375      * Searches {@code root} and its descendants, and returns the node representing a {@link
376      * FocusParkingView}, if any, or returns null if not found. The caller is responsible for
377      * recycling the result.
378      */
379     @Nullable
findFocusParkingViewInRoot(@onNull AccessibilityNodeInfo root)380     AccessibilityNodeInfo findFocusParkingViewInRoot(@NonNull AccessibilityNodeInfo root) {
381         return mTreeTraverser.depthFirstSearch(
382                 root,
383                 /* skipPredicate= */ Utils::isFocusArea,
384                 /* targetPredicate= */ Utils::isFocusParkingView
385         );
386     }
387 
388     /**
389      * Searches {@code root} and its descendants, and returns the node representing a {@link
390      * android.view.SurfaceView}, if any, or returns null if not found. The caller is responsible
391      * for recycling the result.
392      */
393     @Nullable
findSurfaceViewInRoot(@onNull AccessibilityNodeInfo root)394     AccessibilityNodeInfo findSurfaceViewInRoot(@NonNull AccessibilityNodeInfo root) {
395         return mTreeTraverser.depthFirstSearch(root, /* targetPredicate= */ Utils::isSurfaceView);
396     }
397 
398     /**
399      * Returns the best target focus area for a nudge in the given {@code direction}. The caller is
400      * responsible for recycling the result.
401      *
402      * @param windows          a list of windows to search from
403      * @param sourceNode       the current focus
404      * @param currentFocusArea the current focus area
405      * @param direction        nudge direction, must be {@link View#FOCUS_UP}, {@link
406      *                         View#FOCUS_DOWN}, {@link View#FOCUS_LEFT}, or {@link
407      *                         View#FOCUS_RIGHT}
408      */
findNudgeTargetFocusArea( @onNull List<AccessibilityWindowInfo> windows, @NonNull AccessibilityNodeInfo sourceNode, @NonNull AccessibilityNodeInfo currentFocusArea, int direction)409     AccessibilityNodeInfo findNudgeTargetFocusArea(
410             @NonNull List<AccessibilityWindowInfo> windows,
411             @NonNull AccessibilityNodeInfo sourceNode,
412             @NonNull AccessibilityNodeInfo currentFocusArea,
413             int direction) {
414         AccessibilityWindowInfo currentWindow = sourceNode.getWindow();
415         if (currentWindow == null) {
416             L.e("Currently focused window is null");
417             return null;
418         }
419 
420         // Build a list of candidate focus areas, starting with all the other focus areas in the
421         // same window as the current focus area.
422         List<AccessibilityNodeInfo> candidateFocusAreas = findNonEmptyFocusAreas(currentWindow);
423         for (AccessibilityNodeInfo focusArea : candidateFocusAreas) {
424             if (focusArea.equals(currentFocusArea)) {
425                 candidateFocusAreas.remove(focusArea);
426                 focusArea.recycle();
427                 break;
428             }
429         }
430 
431         List<Rect> candidateFocusAreasBounds = new ArrayList<>();
432         for (AccessibilityNodeInfo focusArea : candidateFocusAreas) {
433             Rect bounds = Utils.getBoundsInScreen(focusArea);
434             candidateFocusAreasBounds.add(bounds);
435         }
436 
437         maybeAddImplicitFocusArea(currentWindow, candidateFocusAreas, candidateFocusAreasBounds);
438 
439         // If the current focus area is an explicit focus area, use its focus area bounds to find
440         // nudge target as usual. Otherwise, use the tailored bounds, which was added as the last
441         // element of the list in maybeAddImplicitFocusArea().
442         Rect currentFocusAreaBounds = Utils.isFocusArea(currentFocusArea)
443                 ? Utils.getBoundsInScreen(currentFocusArea)
444                 : candidateFocusAreasBounds.get(candidateFocusAreasBounds.size() - 1);
445 
446         if (currentWindow.getType() != TYPE_INPUT_METHOD
447                 || shouldNudgeOutOfIme(sourceNode, currentFocusArea, candidateFocusAreas,
448                            direction)) {
449             // Add candidate focus areas in other windows in the given direction.
450             List<AccessibilityWindowInfo> candidateWindows = new ArrayList<>();
451             boolean isSourceNodeEditable = sourceNode.isEditable();
452             addWindowsInDirection(windows, currentWindow, candidateWindows, direction,
453                     isSourceNodeEditable);
454             currentWindow.recycle();
455             for (AccessibilityWindowInfo window : candidateWindows) {
456                 List<AccessibilityNodeInfo> focusAreasInAnotherWindow =
457                         findNonEmptyFocusAreas(window);
458                 candidateFocusAreas.addAll(focusAreasInAnotherWindow);
459 
460                 for (AccessibilityNodeInfo focusArea : focusAreasInAnotherWindow) {
461                     Rect bounds = Utils.getBoundsInScreen(focusArea);
462                     candidateFocusAreasBounds.add(bounds);
463                 }
464 
465                 maybeAddImplicitFocusArea(window, candidateFocusAreas, candidateFocusAreasBounds);
466             }
467         }
468 
469         Rect sourceBounds = Utils.getBoundsInScreen(sourceNode);
470         // Choose the best candidate as our target focus area.
471         AccessibilityNodeInfo targetFocusArea = chooseBestNudgeCandidate(sourceBounds,
472                 currentFocusAreaBounds, candidateFocusAreas, candidateFocusAreasBounds, direction);
473         Utils.recycleNodes(candidateFocusAreas);
474         return targetFocusArea;
475     }
476 
477     /**
478      * If there are orphan nodes in {@code window}, treats the root node of the window as an
479      * implicit focus area, and add it to {@code candidateFocusAreas}. Besides, tailors its bounds
480      * so that it just wraps its orphan descendants, and adds the tailored bounds to
481      * {@code candidateFocusAreasBounds}.
482      * Orphan nodes are focusable nodes not wrapped inside any explicitly declared focus areas.
483      * It happens in two scenarios:
484      * <ul>
485      *     <li>The app developer wants to treat the entire window as a focus area but doesn't bother
486      *         declaring a focus area to wrap around them. This is allowed.
487      *     <li>The app developer intends to declare focus areas to wrap around focusable views, but
488      *         misses some focusable views, causing them to be unreachable via rotary controller.
489      *         This is not allowed, but RotaryService will try its best to make them reachable.
490      * </ul>
491      */
492     @VisibleForTesting
maybeAddImplicitFocusArea(@onNull AccessibilityWindowInfo window, @NonNull List<AccessibilityNodeInfo> candidateFocusAreas, @NonNull List<Rect> candidateFocusAreasBounds)493     void maybeAddImplicitFocusArea(@NonNull AccessibilityWindowInfo window,
494             @NonNull List<AccessibilityNodeInfo> candidateFocusAreas,
495             @NonNull List<Rect> candidateFocusAreasBounds) {
496         AccessibilityNodeInfo root = window.getRoot();
497         if (root == null) {
498             L.e("No root node for " + window);
499             return;
500         }
501         // If the root node is in the client app and therefore contains a SurfaceView, skip the view
502         // hierarchy of the client app, and scan the view hierarchy of the host app, which is
503         // embedded in the SurfaceView.
504         if (isClientNode(root)) {
505             L.v("Root node is client node " + root);
506             AccessibilityNodeInfo hostRoot = getDescendantHostRoot(root);
507             root.recycle();
508             if (hostRoot == null || !hasFocusableDescendants(hostRoot)) {
509                 L.w("No host node or host node has no focusable descendants " + hostRoot);
510                 Utils.recycleNode(hostRoot);
511                 return;
512             }
513             candidateFocusAreas.add(hostRoot);
514             Rect bounds = new Rect();
515             // To make things simple, just use the node's bounds. Don't tailor the bounds.
516             hostRoot.getBoundsInScreen(bounds);
517             candidateFocusAreasBounds.add(bounds);
518             return;
519         }
520 
521         Rect bounds = computeMinimumBoundsForOrphanDescendants(root);
522         if (bounds.isEmpty()) {
523             return;
524         }
525         L.w("The root node contains focusable nodes that are not inside any focus "
526                 + "areas: " + root);
527         candidateFocusAreas.add(root);
528         candidateFocusAreasBounds.add(bounds);
529     }
530 
531     /**
532      * Returns whether it should nudge out the IME window. If the current window is IME window and
533      * there are candidate FocusAreas in it for the given direction, it shouldn't nudge out of the
534      * IME window.
535      */
shouldNudgeOutOfIme(@onNull AccessibilityNodeInfo sourceNode, @NonNull AccessibilityNodeInfo currentFocusArea, @NonNull List<AccessibilityNodeInfo> focusAreasInCurrentWindow, int direction)536     private boolean shouldNudgeOutOfIme(@NonNull AccessibilityNodeInfo sourceNode,
537             @NonNull AccessibilityNodeInfo currentFocusArea,
538             @NonNull List<AccessibilityNodeInfo> focusAreasInCurrentWindow,
539             int direction) {
540         if (!focusAreasInCurrentWindow.isEmpty()) {
541             Rect sourceBounds = Utils.getBoundsInScreen(sourceNode);
542             Rect sourceFocusAreaBounds = Utils.getBoundsInScreen(currentFocusArea);
543             Rect candidateBounds = Utils.getBoundsInScreen(currentFocusArea);
544             for (AccessibilityNodeInfo candidate : focusAreasInCurrentWindow) {
545                 if (isCandidate(sourceBounds, sourceFocusAreaBounds, candidate, candidateBounds,
546                         direction)) {
547                     return false;
548                 }
549             }
550         }
551         return true;
552     }
553 
containsWebViewWithFocusableDescendants(@onNull AccessibilityNodeInfo node)554     private boolean containsWebViewWithFocusableDescendants(@NonNull AccessibilityNodeInfo node) {
555         List<AccessibilityNodeInfo> webViews = new ArrayList<>();
556         mTreeTraverser.depthFirstSelect(node, Utils::isWebView, webViews);
557         if (webViews.isEmpty()) {
558             return false;
559         }
560         boolean hasFocusableDescendant = false;
561         for (AccessibilityNodeInfo webView : webViews) {
562             AccessibilityNodeInfo focusableDescendant = mTreeTraverser.depthFirstSearch(webView,
563                     Utils::canPerformFocus);
564             if (focusableDescendant != null) {
565                 hasFocusableDescendant = true;
566                 focusableDescendant.recycle();
567                 break;
568             }
569         }
570         Utils.recycleNodes(webViews);
571         return hasFocusableDescendant;
572     }
573 
574     /**
575      * Adds all the {@code windows} in the given {@code direction} of the given {@code source}
576      * window to the given list if the {@code source} window is not an overlay. If it's an overlay
577      * and the source node is editable, adds the IME window only. Otherwise does nothing.
578      */
addWindowsInDirection(@onNull List<AccessibilityWindowInfo> windows, @NonNull AccessibilityWindowInfo source, @NonNull List<AccessibilityWindowInfo> results, int direction, boolean isSourceNodeEditable)579     private void addWindowsInDirection(@NonNull List<AccessibilityWindowInfo> windows,
580             @NonNull AccessibilityWindowInfo source,
581             @NonNull List<AccessibilityWindowInfo> results,
582             int direction,
583             boolean isSourceNodeEditable) {
584         Rect sourceBounds = new Rect();
585         source.getBoundsInScreen(sourceBounds);
586         boolean isSourceWindowOverlayWindow = isOverlayWindow(source, sourceBounds);
587         Rect destBounds = new Rect();
588         for (AccessibilityWindowInfo window : windows) {
589             if (window.equals(source)) {
590                continue;
591             }
592             // Nudging out of the overlay window is not allowed unless the source node is editable
593             // and the target window is an IME window. E.g., nudging from the EditText in the Dialog
594             // to the IME is allowed, while nudging from the Button in the Dialog to the IME is not
595             // allowed.
596             if (isSourceWindowOverlayWindow
597                     && (!isSourceNodeEditable || window.getType() != TYPE_INPUT_METHOD)) {
598                 continue;
599             }
600 
601             window.getBoundsInScreen(destBounds);
602             // Even if only part of destBounds is in the given direction of sourceBounds, we
603             // still include it because that part may contain the target focus area.
604             if (FocusFinder.isPartiallyInDirection(sourceBounds, destBounds, direction)) {
605                 results.add(window);
606             }
607         }
608     }
609 
610     /**
611      * Returns whether the given {@code window} with the given {@code bounds} is an overlay window.
612      * <p>
613      * If the source window is an application window on the default display and it's smaller than
614        the display, then it's either a TaskView window or an overlay window (such as a Dialog
615        window). The ID of a TaskView task is different from the full screen application, while
616        the ID of an overlay task is the same with the full screen application, so task ID is used
617        to decide whether it's an overlay window.
618      */
isOverlayWindow(@onNull AccessibilityWindowInfo window, @NonNull Rect bounds)619     private boolean isOverlayWindow(@NonNull AccessibilityWindowInfo window, @NonNull Rect bounds) {
620         return window.getType() == TYPE_APPLICATION
621                 && window.getDisplayId() == Display.DEFAULT_DISPLAY
622                 && !mAppWindowBounds.equals(bounds)
623                 && window.getTaskId() == mAppWindowTaskId;
624     }
625 
626     /**
627      * Returns whether nudging to the given {@code direction} can dismiss the given {@code window}
628      * with the given {@code bounds}.
629      */
isDismissible(@onNull AccessibilityWindowInfo window, @NonNull Rect bounds, @View.FocusRealDirection int direction)630     boolean isDismissible(@NonNull AccessibilityWindowInfo window,
631             @NonNull Rect bounds,
632             @View.FocusRealDirection int direction) {
633         // Only overlay windows can be dismissed.
634         if (!isOverlayWindow(window, bounds)) {
635             return false;
636         }
637         // The window can be dismissed when part of the underlying window is not covered by it in
638         // the given direction.
639         switch (direction) {
640             case FOCUS_UP:
641                 return mAppWindowBounds.top < bounds.top;
642             case FOCUS_DOWN:
643                 return mAppWindowBounds.bottom > bounds.bottom;
644             case FOCUS_LEFT:
645                 return mAppWindowBounds.left < bounds.left;
646             case FOCUS_RIGHT:
647                 return mAppWindowBounds.right > bounds.right;
648         }
649         return false;
650     }
651 
652     /**
653      * Scans the view hierarchy of the given {@code window} looking for explicit focus areas with
654      * focusable descendants and returns the focus areas. The caller is responsible for recycling
655      * the result.
656      */
657     @NonNull
658     @VisibleForTesting
findNonEmptyFocusAreas(@onNull AccessibilityWindowInfo window)659     List<AccessibilityNodeInfo> findNonEmptyFocusAreas(@NonNull AccessibilityWindowInfo window) {
660         List<AccessibilityNodeInfo> results = new ArrayList<>();
661         AccessibilityNodeInfo rootNode = window.getRoot();
662         if (rootNode == null) {
663             L.e("No root node for " + window);
664         } else if (!isClientNode(rootNode)) {
665             addNonEmptyFocusAreas(rootNode, results);
666         }
667         // If the root node is in the client app, it won't contain any explicit focus areas, so
668         // skip it.
669 
670         Utils.recycleNode(rootNode);
671         return results;
672     }
673 
674     /**
675      * Searches from {@code clientNode}, and returns the root of the embedded view hierarchy if any,
676      * or returns null if not found. The caller is responsible for recycling the result.
677      */
678     @Nullable
getDescendantHostRoot(@onNull AccessibilityNodeInfo clientNode)679     AccessibilityNodeInfo getDescendantHostRoot(@NonNull AccessibilityNodeInfo clientNode) {
680         return mTreeTraverser.depthFirstSearch(clientNode, this::isHostNode);
681     }
682 
683     /**
684      * Returns whether the given window is the Heads-up Notification (HUN) window. The HUN window
685      * is identified by the left and right edges. The top and bottom vary depending on whether the
686      * HUN appears at the top or bottom of the screen and on the height of the notification being
687      * displayed so they aren't used.
688      */
isHunWindow(@ullable AccessibilityWindowInfo window)689     boolean isHunWindow(@Nullable AccessibilityWindowInfo window) {
690         if (window == null || window.getType() != AccessibilityWindowInfo.TYPE_SYSTEM) {
691             return false;
692         }
693         Rect bounds = new Rect();
694         window.getBoundsInScreen(bounds);
695         return bounds.left == mHunLeft && bounds.right == mHunRight;
696     }
697 
698     /**
699      * Searches from the given node up through its ancestors to the containing focus area, looking
700      * for a node that's marked as horizontally or vertically scrollable. Returns a copy of the
701      * first such node or null if none is found. The caller is responsible for recycling the result.
702      */
703     @Nullable
findScrollableContainer(@onNull AccessibilityNodeInfo node)704     AccessibilityNodeInfo findScrollableContainer(@NonNull AccessibilityNodeInfo node) {
705         return mTreeTraverser.findNodeOrAncestor(node, /* stopPredicate= */ Utils::isFocusArea,
706                 /* targetPredicate= */ Utils::isScrollableContainer);
707     }
708 
709     /**
710      * Returns the previous node  before {@code referenceNode} in Tab order that can take focus or
711      * the next node after {@code referenceNode} in Tab order that can take focus, depending on
712      * {@code direction}. The search is limited to descendants of {@code containerNode}. Returns
713      * null if there are no descendants that can take focus in the given direction. The caller is
714      * responsible for recycling the result.
715      *
716      * @param containerNode the node with descendants
717      * @param referenceNode a descendant of {@code containerNode} to start from
718      * @param direction     {@link View#FOCUS_FORWARD} or {@link View#FOCUS_BACKWARD}
719      * @return the node before or after {@code referenceNode} or null if none
720      */
721     @Nullable
findFocusableDescendantInDirection( @onNull AccessibilityNodeInfo containerNode, @NonNull AccessibilityNodeInfo referenceNode, int direction)722     AccessibilityNodeInfo findFocusableDescendantInDirection(
723             @NonNull AccessibilityNodeInfo containerNode,
724             @NonNull AccessibilityNodeInfo referenceNode,
725             int direction) {
726         AccessibilityNodeInfo targetNode = copyNode(referenceNode);
727         do {
728             AccessibilityNodeInfo nextTargetNode = targetNode.focusSearch(direction);
729             if (nextTargetNode == null
730                     || nextTargetNode.equals(containerNode)
731                     || !Utils.isDescendant(containerNode, nextTargetNode)) {
732                 Utils.recycleNode(nextTargetNode);
733                 Utils.recycleNode(targetNode);
734                 return null;
735             }
736             if (nextTargetNode.equals(referenceNode) || nextTargetNode.equals(targetNode)) {
737                 L.w((direction == View.FOCUS_FORWARD ? "Next" : "Previous")
738                         + " node is the same node: " + referenceNode);
739                 Utils.recycleNode(nextTargetNode);
740                 Utils.recycleNode(targetNode);
741                 return null;
742             }
743             targetNode.recycle();
744             targetNode = nextTargetNode;
745         } while (!Utils.canTakeFocus(targetNode));
746         return targetNode;
747     }
748 
749     /**
750      * Returns the first descendant of {@code node} which can take focus. The nodes are searched in
751      * in depth-first order, not including {@code node} itself. If no descendant can take focus,
752      * null is returned. The caller is responsible for recycling the result.
753      */
754     @Nullable
findFirstFocusableDescendant(@onNull AccessibilityNodeInfo node)755     AccessibilityNodeInfo findFirstFocusableDescendant(@NonNull AccessibilityNodeInfo node) {
756         return mTreeTraverser.depthFirstSearch(node,
757                 candidateNode -> candidateNode != node && Utils.canTakeFocus(candidateNode));
758     }
759 
760     /**
761      * Returns the first orphan descendant (focusable descendant not inside any focus areas) of
762      * {@code node}. The nodes are searched in depth-first order, not including {@code node} itself.
763      * If not found, null is returned. The caller is responsible for recycling the result.
764      */
765     @Nullable
findFirstOrphan(@onNull AccessibilityNodeInfo node)766     AccessibilityNodeInfo findFirstOrphan(@NonNull AccessibilityNodeInfo node) {
767         return mTreeTraverser.depthFirstSearch(node,
768                 /* skipPredicate= */ Utils::isFocusArea,
769                 /* targetPredicate= */ candidateNode -> candidateNode != node
770                         && Utils.canTakeFocus(candidateNode));
771     }
772 
773     /**
774      * Returns the last descendant of {@code node} which can take focus. The nodes are searched in
775      * reverse depth-first order, not including {@code node} itself. If no descendant can take
776      * focus, null is returned. The caller is responsible for recycling the result.
777      */
778     @Nullable
findLastFocusableDescendant(@onNull AccessibilityNodeInfo node)779     AccessibilityNodeInfo findLastFocusableDescendant(@NonNull AccessibilityNodeInfo node) {
780         return mTreeTraverser.reverseDepthFirstSearch(node,
781                 candidateNode -> candidateNode != node && Utils.canTakeFocus(candidateNode));
782     }
783 
784     /**
785      * Scans descendants of the given {@code rootNode} looking for explicit focus areas with
786      * focusable descendants and adds the focus areas to the given list. It doesn't scan inside
787      * focus areas since nested focus areas aren't allowed. It ignores focus areas without
788      * focusable descendants, because once we found the best candidate focus area, we don't dig
789      * into other ones. If it has no descendants to take focus, the nudge will fail. The caller is
790      * responsible for recycling added nodes.
791      *
792      * @param rootNode the root to start scanning from
793      * @param results  a list of focus areas to add to
794      */
addNonEmptyFocusAreas(@onNull AccessibilityNodeInfo rootNode, @NonNull List<AccessibilityNodeInfo> results)795     private void addNonEmptyFocusAreas(@NonNull AccessibilityNodeInfo rootNode,
796             @NonNull List<AccessibilityNodeInfo> results) {
797         mTreeTraverser.depthFirstSelect(rootNode,
798                 (focusArea) -> Utils.isFocusArea(focusArea) && hasFocusableDescendants(focusArea),
799                 results);
800     }
801 
hasFocusableDescendants(@onNull AccessibilityNodeInfo focusArea)802     private boolean hasFocusableDescendants(@NonNull AccessibilityNodeInfo focusArea) {
803         return Utils.canHaveFocus(focusArea) || containsWebViewWithFocusableDescendants(focusArea);
804     }
805 
806     /**
807      * Returns the minimum rectangle wrapping the given {@code node}'s orphan descendants. If
808      * {@code node} has no orphan descendants, returns an empty {@link Rect}.
809      */
810     @NonNull
811     @VisibleForTesting
computeMinimumBoundsForOrphanDescendants( @onNull AccessibilityNodeInfo node)812     Rect computeMinimumBoundsForOrphanDescendants(
813             @NonNull AccessibilityNodeInfo node) {
814         Rect bounds = new Rect();
815         if (Utils.isFocusArea(node) || Utils.isFocusParkingView(node)) {
816             return bounds;
817         }
818         if (Utils.canTakeFocus(node) || containsWebViewWithFocusableDescendants(node)) {
819             return Utils.getBoundsInScreen(node);
820         }
821         for (int i = 0; i < node.getChildCount(); i++) {
822             AccessibilityNodeInfo child = node.getChild(i);
823             if (child == null) {
824                 continue;
825             }
826             Rect childBounds = computeMinimumBoundsForOrphanDescendants(child);
827             child.recycle();
828             if (childBounds != null) {
829                 bounds.union(childBounds);
830             }
831         }
832         return bounds;
833     }
834 
835     /**
836      * Returns a copy of the best candidate from among the given {@code candidates} for a nudge
837      * from {@code sourceNode} in the given {@code direction}. Returns null if none of the {@code
838      * candidates} are in the given {@code direction}. The caller is responsible for recycling the
839      * result.
840      *
841      * @param candidates could be a list of {@link FocusArea}s, or a list of focusable views
842      */
843     @Nullable
chooseBestNudgeCandidate(@onNull Rect sourceBounds, @NonNull Rect sourceFocusAreaBounds, @NonNull List<AccessibilityNodeInfo> candidates, @NonNull List<Rect> candidatesBounds, int direction)844     private AccessibilityNodeInfo chooseBestNudgeCandidate(@NonNull Rect sourceBounds,
845             @NonNull Rect sourceFocusAreaBounds,
846             @NonNull List<AccessibilityNodeInfo> candidates,
847             @NonNull List<Rect> candidatesBounds,
848             int direction) {
849         if (candidates.isEmpty()) {
850             return null;
851         }
852         AccessibilityNodeInfo bestNode = null;
853         Rect bestBounds = new Rect();
854         for (int i = 0; i < candidates.size(); i++) {
855             AccessibilityNodeInfo candidate = candidates.get(i);
856             Rect candidateBounds = candidatesBounds.get(i);
857             if (isCandidate(sourceBounds, sourceFocusAreaBounds, candidate, candidateBounds,
858                     direction)) {
859                 if (bestNode == null || FocusFinder.isBetterCandidate(
860                         direction, sourceBounds, candidateBounds, bestBounds)) {
861                     bestNode = candidate;
862                     bestBounds.set(candidateBounds);
863                 }
864             }
865         }
866         return copyNode(bestNode);
867     }
868 
869     /**
870      * Returns whether the given {@code node} is a candidate from {@code sourceBounds} to the given
871      * {@code direction}.
872      * <p>
873      * To be a candidate, the node
874      * <ul>
875      *     <li>must be considered a candidate by {@link FocusFinder#isCandidate} if it represents a
876      *         focusable view within a focus area
877      *     <li>must be in the {@code direction} of the {@code sourceFocusAreaBounds} and one of its
878      *         focusable descendants must be a candidate if it represents a focus area
879      * </ul>
880      */
isCandidate(@onNull Rect sourceBounds, @NonNull Rect sourceFocusAreaBounds, @NonNull AccessibilityNodeInfo node, @NonNull Rect nodeBounds, int direction)881     private boolean isCandidate(@NonNull Rect sourceBounds,
882             @NonNull Rect sourceFocusAreaBounds,
883             @NonNull AccessibilityNodeInfo node,
884             @NonNull Rect nodeBounds,
885             int direction) {
886         AccessibilityNodeInfo candidate = mTreeTraverser.depthFirstSearch(node,
887                 /* skipPredicate= */ candidateNode -> {
888                     if (Utils.canTakeFocus(candidateNode)) {
889                         return false;
890                     }
891                     // If a node can't take focus, it represents a focus area. If the focus area
892                     // doesn't intersect with sourceFocusAreaBounds, and it's not in the given
893                     // direction of sourceFocusAreaBounds, it's not a candidate, so we should return
894                     // true to stop searching.
895                     return !Rect.intersects(nodeBounds, sourceFocusAreaBounds)
896                             && !FocusFinder.isInDirection(
897                                 sourceFocusAreaBounds, nodeBounds, direction);
898                 },
899                 /* targetPredicate= */ candidateNode -> {
900                     // RotaryService can navigate to nodes in a WebView or a ComposeView even when
901                     // off-screen, so we use canPerformFocus() to skip the bounds check.
902                     if (isInVirtualNodeHierarchy(candidateNode)) {
903                         return Utils.canPerformFocus(candidateNode);
904                     }
905                     // If a node isn't visible to the user, e.g. another window is obscuring it,
906                     // skip it.
907                     if (!candidateNode.isVisibleToUser()) {
908                         return false;
909                     }
910                     // If a node can't take focus, it represents a focus area, so we return false to
911                     // skip the node and let it search its descendants.
912                     if (!Utils.canTakeFocus(candidateNode)) {
913                         return false;
914                     }
915                     // The node represents a focusable view in a focus area, so check the geometry.
916                     return FocusFinder.isCandidate(sourceBounds, nodeBounds, direction);
917                 });
918         if (candidate == null) {
919             return false;
920         }
921         candidate.recycle();
922         return true;
923     }
924 
copyNode(@ullable AccessibilityNodeInfo node)925     private AccessibilityNodeInfo copyNode(@Nullable AccessibilityNodeInfo node) {
926         return mNodeCopier.copy(node);
927     }
928 
929     /**
930      * Returns the closest ancestor focus area of the given {@code node}.
931      * <ul>
932      *     <li> If the given {@code node} is a {@link FocusArea} node or a descendant of a {@link
933      *          FocusArea} node, returns the {@link FocusArea} node.
934      *     <li> If there are no explicitly declared {@link FocusArea}s among the ancestors of this
935      *          view, and this view is not in an embedded view hierarchy, returns the root node.
936      *     <li> If there are no explicitly declared {@link FocusArea}s among the ancestors of this
937      *          view, and this view is in an embedded view hierarchy, returns the root node of
938      *          embedded view hierarchy.
939      * </ul>
940      * The caller is responsible for recycling the result.
941      */
942     @NonNull
getAncestorFocusArea(@onNull AccessibilityNodeInfo node)943     AccessibilityNodeInfo getAncestorFocusArea(@NonNull AccessibilityNodeInfo node) {
944         Predicate<AccessibilityNodeInfo> isFocusAreaOrRoot = candidateNode -> {
945             if (Utils.isFocusArea(candidateNode)) {
946                 // The candidateNode is a focus area.
947                 return true;
948             }
949             AccessibilityNodeInfo parent = candidateNode.getParent();
950             if (parent == null) {
951                 // The candidateNode is the root node.
952                 return true;
953             }
954             if (Utils.isSurfaceView(parent)) {
955                 // Treat the root of embedded view hierarchy (i.e., the only child of the
956                 // SurfaceView) as an implicit focus area.
957                 return true;
958             }
959             parent.recycle();
960             return false;
961         };
962         AccessibilityNodeInfo result = mTreeTraverser.findNodeOrAncestor(node, isFocusAreaOrRoot);
963         if (result == null || !Utils.isFocusArea(result)) {
964             L.w("Couldn't find ancestor focus area for given node: " + node);
965         }
966         return result;
967     }
968 
969     /**
970      * Returns a copy of {@code node} or the nearest ancestor that represents a {@code WebView}.
971      * Returns null if {@code node} isn't a {@code WebView} and isn't a descendant of a {@code
972      * WebView}.
973      */
974     @Nullable
findWebViewAncestor(@onNull AccessibilityNodeInfo node)975     private AccessibilityNodeInfo findWebViewAncestor(@NonNull AccessibilityNodeInfo node) {
976         return mTreeTraverser.findNodeOrAncestor(node, Utils::isWebView);
977     }
978 
979     /**
980      * Returns a copy of {@code node} or the nearest ancestor that represents a {@code ComposeView}
981      * or a {@code WebView}. Returns null if {@code node} isn't a {@code ComposeView} or a
982      * {@code WebView} and is not a descendant of a {@code ComposeView} or a {@code WebView}.
983      *
984      * TODO(b/192274274): This method may not be necessary anymore if Compose supports focusSearch.
985      */
986     @Nullable
findVirtualViewAncestor(@onNull AccessibilityNodeInfo node)987     private AccessibilityNodeInfo findVirtualViewAncestor(@NonNull AccessibilityNodeInfo node) {
988         return mTreeTraverser.findNodeOrAncestor(node, /* targetPredicate= */ (nodeInfo) ->
989             Utils.isComposeView(nodeInfo) || Utils.isWebView(nodeInfo));
990     }
991 
992     /** Returns whether {@code node} is a {@code WebView} or is a descendant of one. */
isInWebView(@onNull AccessibilityNodeInfo node)993     boolean isInWebView(@NonNull AccessibilityNodeInfo node) {
994         AccessibilityNodeInfo webView = findWebViewAncestor(node);
995         if (webView == null) {
996             return false;
997         }
998         webView.recycle();
999         return true;
1000     }
1001 
1002     /**
1003      * Returns whether {@code node} is a {@code ComposeView}, is a {@code WebView}, or is a
1004      * descendant of either.
1005      */
isInVirtualNodeHierarchy(@onNull AccessibilityNodeInfo node)1006     boolean isInVirtualNodeHierarchy(@NonNull AccessibilityNodeInfo node) {
1007         AccessibilityNodeInfo virtualViewAncestor = findVirtualViewAncestor(node);
1008         if (virtualViewAncestor == null) {
1009             return false;
1010         }
1011         virtualViewAncestor.recycle();
1012         return true;
1013     }
1014 
1015     /**
1016      * Returns the next focusable node after {@code candidate} in {@code direction} in {@code
1017      * root} or null if none. This handles navigating into a WebView as well as within a WebView.
1018      * This also handles navigating into a ComposeView, as well as within a ComposeView.
1019      */
1020     @Nullable
findNextFocusableInVirtualRoot( @onNull AccessibilityNodeInfo root, @NonNull AccessibilityNodeInfo candidate, int direction)1021     private AccessibilityNodeInfo findNextFocusableInVirtualRoot(
1022             @NonNull AccessibilityNodeInfo root,
1023             @NonNull AccessibilityNodeInfo candidate, int direction) {
1024         // focusSearch() doesn't work in WebViews or ComposeViews so use tree traversal instead.
1025         if (Utils.isWebView(candidate) || Utils.isComposeView(candidate)) {
1026             if (direction == View.FOCUS_FORWARD) {
1027                 // When entering into the root of a virtual node hierarchy, find the first focusable
1028                 // child node of the root if any.
1029                 return findFirstFocusableDescendantInVirtualRoot(candidate);
1030             } else {
1031                 // When backing into the root of a virtual node hierarchy, find the last focusable
1032                 // child node of the root if any.
1033                 return findLastFocusableDescendantInVirtualRoot(candidate);
1034             }
1035         } else {
1036             // When navigating within a virtual view hierarchy, find the next or previous focusable
1037             // node in depth-first order.
1038             if (direction == View.FOCUS_FORWARD) {
1039                 return findFirstFocusDescendantInVirtualRootAfter(root, candidate);
1040             } else {
1041                 return findFirstFocusDescendantInVirtualRootBefore(root, candidate);
1042             }
1043         }
1044     }
1045 
1046     /**
1047      * Returns the first descendant of {@code webView} which can perform focus. This includes off-
1048      * screen descendants. The nodes are searched in in depth-first order, not including
1049      * {@code root} itself. If no descendant can perform focus, null is returned. The caller is
1050      * responsible for recycling the result.
1051      */
1052     @Nullable
findFirstFocusableDescendantInVirtualRoot( @onNull AccessibilityNodeInfo root)1053     private AccessibilityNodeInfo findFirstFocusableDescendantInVirtualRoot(
1054             @NonNull AccessibilityNodeInfo root) {
1055         return mTreeTraverser.depthFirstSearch(root,
1056                 candidateNode -> candidateNode != root && Utils.canPerformFocus(candidateNode));
1057     }
1058 
1059     /**
1060      * Returns the last descendant of {@code root} which can perform focus. This includes off-
1061      * screen descendants. The nodes are searched in reverse depth-first order, not including
1062      * {@code root} itself. If no descendant can perform focus, null is returned. The caller is
1063      * responsible for recycling the result.
1064      */
1065     @Nullable
findLastFocusableDescendantInVirtualRoot( @onNull AccessibilityNodeInfo root)1066     private AccessibilityNodeInfo findLastFocusableDescendantInVirtualRoot(
1067             @NonNull AccessibilityNodeInfo root) {
1068         return mTreeTraverser.reverseDepthFirstSearch(root,
1069                 candidateNode -> candidateNode != root && Utils.canPerformFocus(candidateNode));
1070     }
1071 
1072     @Nullable
findFirstFocusDescendantInVirtualRootBefore( @onNull AccessibilityNodeInfo root, @NonNull AccessibilityNodeInfo beforeNode)1073     private AccessibilityNodeInfo findFirstFocusDescendantInVirtualRootBefore(
1074             @NonNull AccessibilityNodeInfo root, @NonNull AccessibilityNodeInfo beforeNode) {
1075         boolean[] foundBeforeNode = new boolean[1];
1076         return mTreeTraverser.reverseDepthFirstSearch(root,
1077                 node -> {
1078                     if (foundBeforeNode[0] && Utils.canPerformFocus(node)) {
1079                         return true;
1080                     }
1081                     if (node.equals(beforeNode)) {
1082                         foundBeforeNode[0] = true;
1083                     }
1084                     return false;
1085                 });
1086     }
1087 
1088     @Nullable
1089     private AccessibilityNodeInfo findFirstFocusDescendantInVirtualRootAfter(
1090             @NonNull AccessibilityNodeInfo root, @NonNull AccessibilityNodeInfo afterNode) {
1091         boolean[] foundAfterNode = new boolean[1];
1092         return mTreeTraverser.depthFirstSearch(root,
1093                 node -> {
1094                     if (foundAfterNode[0] && Utils.canPerformFocus(node)) {
1095                         return true;
1096                     }
1097                     if (node.equals(afterNode)) {
1098                         foundAfterNode[0] = true;
1099                     }
1100                     return false;
1101                 });
1102     }
1103 
1104     void dump(@NonNull DualDumpOutputStream dumpOutputStream, boolean dumpAsProto,
1105             @NonNull String fieldName, long fieldId) {
1106         long fieldToken = dumpOutputStream.start(fieldName, fieldId);
1107         dumpOutputStream.write("hunLeft", RotaryProtos.Navigator.HUN_LEFT, mHunLeft);
1108         dumpOutputStream.write("hunRight", RotaryProtos.Navigator.HUN_RIGHT, mHunRight);
1109         DumpUtils.writeFocusDirection(dumpOutputStream, dumpAsProto, "hunNudgeDirection",
1110                 RotaryProtos.Navigator.HUN_NUDGE_DIRECTION, mHunNudgeDirection);
1111         DumpUtils.writeRect(dumpOutputStream, mAppWindowBounds, "appWindowBounds",
1112                 RotaryProtos.Navigator.APP_WINDOW_BOUNDS);
1113         mSurfaceViewHelper.dump(dumpOutputStream, dumpAsProto, "surfaceViewHelper",
1114                 RotaryProtos.Navigator.SURFACE_VIEW_HELPER);
1115         dumpOutputStream.end(fieldToken);
1116     }
1117 
1118     static String directionToString(@View.FocusRealDirection int direction) {
1119         switch (direction) {
1120             case FOCUS_UP:
1121                 return "FOCUS_UP";
1122             case FOCUS_DOWN:
1123                 return "FOCUS_DOWN";
1124             case FOCUS_LEFT:
1125                 return "FOCUS_LEFT";
1126             case FOCUS_RIGHT:
1127                 return "FOCUS_RIGHT";
1128             default:
1129                 return "<unknown direction " + direction + ">";
1130         }
1131     }
1132 
1133     /** Result from {@link #findRotateTarget}. */
1134     static class FindRotateTargetResult {
1135         @NonNull final AccessibilityNodeInfo node;
1136         final int advancedCount;
1137 
1138         FindRotateTargetResult(@NonNull AccessibilityNodeInfo node, int advancedCount) {
1139             this.node = node;
1140             this.advancedCount = advancedCount;
1141         }
1142     }
1143 }
1144