Don't move focus to RecyclerView

To scroll a RecyclerView, we can focus on it directly, but we
shouldn't move focus to it via rotating or nudging the rotary
controller if it has a descendant to take focus.

Fixes: 160924453
Test: manual test and atest CarRotaryControllerRoboTests
Change-Id: Id52d6c4276447e3203a3ec19f7865550032b4b6d
diff --git a/src/com/android/car/rotary/Navigator.java b/src/com/android/car/rotary/Navigator.java
index a2410f1..1cc951f 100644
--- a/src/com/android/car/rotary/Navigator.java
+++ b/src/com/android/car/rotary/Navigator.java
@@ -298,13 +298,9 @@
      * Returns the target focusable for a rotate. The caller is responsible for recycling the node
      * in the result.
      *
-     * <p>If {@code skipNode} isn't null, this node will be skipped. This is used when the focus is
-     * inside a scrollable container to avoid moving the focus to the scrollable container itself.
-     *
      * <p>Limits navigation to focusable views within a scrollable container's viewport, if any.
      *
      * @param sourceNode    the current focus
-     * @param skipNode      a node to skip - optional
      * @param direction     rotate direction, must be {@link View#FOCUS_FORWARD} or {@link
      *                      View#FOCUS_BACKWARD}
      * @param rotationCount the number of "ticks" to rotate. Only count nodes that can take focus
@@ -318,17 +314,13 @@
      *         given {@code direction}, {@code null} is returned.
      */
     @Nullable
-    FindRotateTargetResult findRotateTarget(@NonNull AccessibilityNodeInfo sourceNode,
-            @Nullable AccessibilityNodeInfo skipNode, int direction, int rotationCount) {
+    FindRotateTargetResult findRotateTarget(
+            @NonNull AccessibilityNodeInfo sourceNode, int direction, int rotationCount) {
         int advancedCount = 0;
         AccessibilityNodeInfo currentFocusArea = getAncestorFocusArea(sourceNode);
         AccessibilityNodeInfo targetNode = copyNode(sourceNode);
         for (int i = 0; i < rotationCount; i++) {
             AccessibilityNodeInfo nextTargetNode = targetNode.focusSearch(direction);
-            if (skipNode != null && skipNode.equals(nextTargetNode)) {
-                Utils.recycleNode(nextTargetNode);
-                nextTargetNode = skipNode.focusSearch(direction);
-            }
             AccessibilityNodeInfo targetFocusArea =
                     nextTargetNode == null ? null : getAncestorFocusArea(nextTargetNode);
 
@@ -337,6 +329,16 @@
             // focus area in the window, including when the root node is treated as a focus area.
             if (nextTargetNode != null && currentFocusArea.equals(targetFocusArea)
                     && !Utils.isFocusParkingView(nextTargetNode)) {
+                // If nextTargetNode is a scrollable container with descendants that can take focus,
+                // skip it, and search for the next target.
+                if (Utils.isScrollableContainer(nextTargetNode)
+                        && Utils.descendantCanTakeFocus(nextTargetNode)) {
+                    Utils.recycleNode(targetNode);
+                    Utils.recycleNode(targetFocusArea);
+                    targetNode = nextTargetNode;
+                    --i;
+                    continue;
+                }
                 // If we're navigating through a scrolling view that can scroll in the specified
                 // direction and the next view is off-screen, don't advance to it. (We'll scroll
                 // instead.)
diff --git a/src/com/android/car/rotary/RotaryService.java b/src/com/android/car/rotary/RotaryService.java
index 8820e5b..8a737dd 100644
--- a/src/com/android/car/rotary/RotaryService.java
+++ b/src/com/android/car/rotary/RotaryService.java
@@ -460,13 +460,20 @@
 
     @Override
     public void onAccessibilityEvent(AccessibilityEvent event) {
+        L.v("onAccessibilityEvent: " + event);
+        AccessibilityNodeInfo source = event.getSource();
+        if (source != null) {
+            L.v("event source: " + source);
+        }
+        L.v("event window ID: " + Integer.toHexString(event.getWindowId()));
+
         switch (event.getEventType()) {
             case TYPE_VIEW_FOCUSED: {
-                handleViewFocusedEvent(event);
+                handleViewFocusedEvent(event, source);
                 break;
             }
             case TYPE_VIEW_CLICKED: {
-                handleViewClickedEvent(event);
+                handleViewClickedEvent(event, source);
                 break;
             }
             case TYPE_VIEW_ACCESSIBILITY_FOCUSED: {
@@ -478,7 +485,7 @@
                 break;
             }
             case TYPE_VIEW_SCROLLED: {
-                handleViewScrolledEvent(event);
+                handleViewScrolledEvent(event, source);
                 break;
             }
             case TYPE_WINDOW_STATE_CHANGED: {
@@ -498,6 +505,7 @@
             default:
                 // Do nothing.
         }
+        Utils.recycleNode(source);
     }
 
     /**
@@ -702,7 +710,8 @@
     }
 
     /** Handles {@link AccessibilityEvent#TYPE_VIEW_FOCUSED} event. */
-    private void handleViewFocusedEvent(@NonNull AccessibilityEvent event) {
+    private void handleViewFocusedEvent(@NonNull AccessibilityEvent event,
+            @Nullable AccessibilityNodeInfo sourceNode) {
         // A view was focused. We ignore focus changes in touch mode. We don't use
         // TYPE_VIEW_FOCUSED to keep mLastTouchedNode up to date because most views can't be
         // focused in touch mode. In rotary mode, we use TYPE_VIEW_FOCUSED events to detect whether
@@ -711,7 +720,6 @@
         if (!mInRotaryMode) {
             return;
         }
-        AccessibilityNodeInfo sourceNode = event.getSource();
 
         // No need to handle TYPE_VIEW_FOCUSED event if sourceNode is null.
         if (sourceNode == null) {
@@ -727,7 +735,6 @@
             L.d("A FocusParkingView was focused because we cleared the focus in another window");
             Utils.recycleNode(mFocusParkingView);
             mFocusParkingView = null;
-            sourceNode.recycle();
             return;
         }
 
@@ -744,7 +751,6 @@
             } else {
                 L.d("mScrollableContainer is not in the view tree");
             }
-            sourceNode.recycle();
             return;
         }
 
@@ -760,7 +766,6 @@
             if (match && !sourceNode.equals(mFocusedNode)) {
                 setFocusedNode(sourceNode);
             }
-            sourceNode.recycle();
             return;
         }
 
@@ -780,18 +785,17 @@
         if (isFpv) {
             L.d("Move focus to a nearby view because Android focused a FocusParkingView");
             onFocusParkingViewFocusedAutomatically(sourceNode);
-            Utils.recycleNode(sourceNode);
             return;
         }
 
         // Case 5: Android focused a non-FocusParkingView. We should update mFocusedNode.
         L.d("Android focused a non-FocusParkingView automatically " + sourceNode);
         onNodeFocusedAutomatically(sourceNode);
-        Utils.recycleNode(sourceNode);
     }
 
     /** Handles {@link AccessibilityEvent#TYPE_VIEW_CLICKED} event. */
-    private void handleViewClickedEvent(@NonNull AccessibilityEvent event) {
+    private void handleViewClickedEvent(@NonNull AccessibilityEvent event,
+            @Nullable AccessibilityNodeInfo sourceNode) {
         // A view was clicked. If we triggered the click via performAction(ACTION_CLICK) or
         // by injecting KEYCODE_DPAD_CENTER, we ignore it. Otherwise, we assume the user
         // touched the screen. In this case, we update mLastTouchedNode, and clear the focus
@@ -805,12 +809,10 @@
         // Note: there is no way to tell whether the window is removed in click event
         // because window remove event (TYPE_WINDOWS_CHANGED with type
         // WINDOWS_CHANGE_REMOVED) comes AFTER click event.
-        AccessibilityNodeInfo sourceNode = event.getSource();
         if (mIgnoreViewClickedNode != null
                 && event.getEventTime() < mIgnoreViewClickedUntil
                 && ((sourceNode == null) || mIgnoreViewClickedNode.equals(sourceNode))) {
             setIgnoreViewClickedNode(null);
-            Utils.recycleNode(sourceNode);
             return;
         }
 
@@ -829,18 +831,16 @@
         if (!sourceNode.equals(mLastTouchedNode) && Utils.canTakeFocus(sourceNode)) {
             setLastTouchedNode(sourceNode);
         }
-        sourceNode.recycle();
     }
 
     /** Handles {@link AccessibilityEvent#TYPE_VIEW_SCROLLED} event. */
-    private void handleViewScrolledEvent(@NonNull AccessibilityEvent event) {
+    private void handleViewScrolledEvent(@NonNull AccessibilityEvent event,
+            @Nullable AccessibilityNodeInfo sourceNode) {
         if (mAfterScrollAction == AfterScrollAction.NONE
                 || SystemClock.uptimeMillis() >= mAfterScrollActionUntil) {
             return;
         }
-        AccessibilityNodeInfo sourceNode = event.getSource();
         if (sourceNode == null || !Utils.isScrollableContainer(sourceNode)) {
-            Utils.recycleNode(sourceNode);
             return;
         }
         switch (mAfterScrollAction) {
@@ -888,7 +888,6 @@
                 throw new IllegalStateException(
                         "Unknown after scroll action: " + mAfterScrollAction);
         }
-        Utils.recycleNode(sourceNode);
     }
 
     /**
@@ -1144,13 +1143,11 @@
             return;
         }
 
-        // If the focused node is not in direct manipulation mode, move the focus. Skip over
-        // mScrollableContainer; we don't want to navigate from a focusable descendant to the
-        // scrollable container except as a side-effect of scrolling.
+        // If the focused node is not in direct manipulation mode, move the focus.
         int remainingRotationCount = rotationCount;
         int direction = clockwise ? View.FOCUS_FORWARD : View.FOCUS_BACKWARD;
-        Navigator.FindRotateTargetResult result = mNavigator.findRotateTarget(mFocusedNode,
-                /* skipNode= */ mScrollableContainer, direction, rotationCount);
+        Navigator.FindRotateTargetResult result =
+                mNavigator.findRotateTarget(mFocusedNode, direction, rotationCount);
         if (result != null) {
             if (performFocusAction(result.node)) {
                 remainingRotationCount -= result.advancedCount;
@@ -1256,7 +1253,7 @@
         return result;
     }
 
-    private void updateDirectManipulationMode(AccessibilityEvent event, boolean enable) {
+    private void updateDirectManipulationMode(@NonNull AccessibilityEvent event, boolean enable) {
         if (!mInRotaryMode || !DirectManipulationHelper.isDirectManipulation(event)) {
             return;
         }
diff --git a/src/com/android/car/rotary/Utils.java b/src/com/android/car/rotary/Utils.java
index 9d4d5fd..683bcec 100644
--- a/src/com/android/car/rotary/Utils.java
+++ b/src/com/android/car/rotary/Utils.java
@@ -82,17 +82,34 @@
         return null;
     }
 
-    /** Returns whether the given {@code node} can be focused by a rotary controller. */
+    /**
+     * Returns whether the given {@code node} can be focused by rotating or nudging the rotary
+     * controller.
+     * <ul>
+     *     <li>To be reachable via the rotary controller, a node must be able to perform {@link
+     *         AccessibilityNodeInfo#ACTION_FOCUS}, which requires the node to be visible to the
+     *         user, focusable, and enabled.
+     *     <li>In addition, though a {@link FocusParkingView} can perform {@link
+     *         AccessibilityNodeInfo#ACTION_FOCUS}, it can't be reached directly via the rotary
+     *         controller.
+     *     <li>If a node is a focusable container, it can be reached via the rotary controller only
+     *         when it has no descendants to take focus.
+     * </ul>
+     *
+     */
     static boolean canTakeFocus(@NonNull AccessibilityNodeInfo node) {
         return node.isVisibleToUser() && node.isFocusable() && node.isEnabled()
-                && !isFocusParkingView(node);
+                && !isFocusParkingView(node)
+                && (!isScrollableContainer(node) || !descendantCanTakeFocus(node));
     }
 
     /** Returns whether the given {@code node} or its descendants can take focus. */
     static boolean canHaveFocus(@NonNull AccessibilityNodeInfo node) {
-        if (canTakeFocus(node)) {
-            return true;
-        }
+        return canTakeFocus(node) || descendantCanTakeFocus(node);
+    }
+
+    /** Returns whether the given {@code node}'s descendants can take focus. */
+    static boolean descendantCanTakeFocus(@NonNull AccessibilityNodeInfo node) {
         for (int i = 0; i < node.getChildCount(); i++) {
             AccessibilityNodeInfo childNode = node.getChild(i);
             if (childNode != null) {
diff --git a/tests/robotests/src/com/android/car/rotary/NavigatorTest.java b/tests/robotests/src/com/android/car/rotary/NavigatorTest.java
index fcef14c..96058b8 100644
--- a/tests/robotests/src/com/android/car/rotary/NavigatorTest.java
+++ b/tests/robotests/src/com/android/car/rotary/NavigatorTest.java
@@ -15,6 +15,8 @@
  */
 package com.android.car.rotary;
 
+import static com.android.car.ui.utils.RotaryConstants.ROTARY_VERTICALLY_SCROLLABLE;
+
 import static com.google.common.truth.Truth.assertThat;
 
 import static org.mockito.AdditionalAnswers.returnsFirstArg;
@@ -32,7 +34,6 @@
 import com.android.car.rotary.Navigator.FindRotateTargetResult;
 import com.android.car.ui.FocusArea;
 import com.android.car.ui.FocusParkingView;
-import com.android.car.ui.utils.RotaryConstants;
 
 import org.junit.Before;
 import org.junit.Test;
@@ -136,24 +137,19 @@
         when(button3.focusSearch(direction)).thenReturn(null);
 
         // Rotate once, the focus should move from button1 to button2.
-        FindRotateTargetResult target = mNavigator.findRotateTarget(button1, null, direction, 1);
+        FindRotateTargetResult target = mNavigator.findRotateTarget(button1, direction, 1);
         assertThat(target.node).isSameAs(button2);
         assertThat(target.advancedCount).isEqualTo(1);
 
         // Rotate twice, the focus should move from button1 to button3.
-        target = mNavigator.findRotateTarget(button1, null, direction, 2);
+        target = mNavigator.findRotateTarget(button1, direction, 2);
         assertThat(target.node).isSameAs(button3);
         assertThat(target.advancedCount).isEqualTo(2);
 
         // Rotate 3 times and exceed the boundary, the focus should stay at the boundary.
-        target = mNavigator.findRotateTarget(button1, null, direction, 3);
+        target = mNavigator.findRotateTarget(button1, direction, 3);
         assertThat(target.node).isSameAs(button3);
         assertThat(target.advancedCount).isEqualTo(2);
-
-        // Rotate once, skipping button2; the focus should move to button3.
-        target = mNavigator.findRotateTarget(button1, button2, direction, 1);
-        assertThat(target.node).isSameAs(button3);
-        assertThat(target.advancedCount).isEqualTo(1);
     }
 
     /**
@@ -206,7 +202,7 @@
         when(focusParkingView.focusSearch(direction)).thenReturn(button1);
 
         // Rotate at the end of focus area, no wrap-around should happen.
-        FindRotateTargetResult target = mNavigator.findRotateTarget(button2, null, direction, 1);
+        FindRotateTargetResult target = mNavigator.findRotateTarget(button2, direction, 1);
         assertThat(target).isNull();
     }
 
@@ -252,11 +248,100 @@
         when(focusParkingView.focusSearch(direction)).thenReturn(button1);
 
         // Rotate at the end of focus area, no wrap-around should happen.
-        FindRotateTargetResult target = mNavigator.findRotateTarget(button2, null, direction, 1);
+        FindRotateTargetResult target = mNavigator.findRotateTarget(button2, direction, 1);
         assertThat(target).isNull();
     }
 
     /**
+     * Tests {@link Navigator#findRotateTarget} in the following node tree:
+     * <pre>
+     *                          root
+     *                         /  |  \
+     *                       /    |    \
+     *                     /      |      \
+     *              button1  scrollable   button2
+     *                        container
+     *                            |
+     *                      non-focusable
+     * </pre>
+     */
+    @Test
+    public void testFindRotateTargetReturnScrollableContainer() {
+        AccessibilityNodeInfo root = new NodeBuilder().setNodeList(mNodeList).build();
+        AccessibilityNodeInfo button1 = new NodeBuilder()
+                .setNodeList(mNodeList)
+                .setParent(root)
+                .build();
+        AccessibilityNodeInfo button2 = new NodeBuilder()
+                .setNodeList(mNodeList)
+                .setParent(root)
+                .build();
+        AccessibilityNodeInfo scrollableContainer = new NodeBuilder()
+                .setNodeList(mNodeList)
+                .setParent(root)
+                .setContentDescription(ROTARY_VERTICALLY_SCROLLABLE)
+                .build();
+        AccessibilityNodeInfo nonFocusable = new NodeBuilder()
+                .setNodeList(mNodeList)
+                .setParent(scrollableContainer)
+                .setVisibleToUser(true)
+                .setEnabled(true)
+                .build();
+
+        int direction = View.FOCUS_FORWARD;
+        when(button1.focusSearch(direction)).thenReturn(scrollableContainer);
+        when(scrollableContainer.focusSearch(direction)).thenReturn(button2);
+
+        FindRotateTargetResult target = mNavigator.findRotateTarget(button1, direction, 1);
+        assertThat(target.node).isSameAs(scrollableContainer);
+    }
+
+    /**
+     * Tests {@link Navigator#findRotateTarget} in the following node tree:
+     * <pre>
+     *                          root
+     *                         /  |  \
+     *                       /    |    \
+     *                     /      |      \
+     *              button1  scrollable   button2
+     *                        container
+     *                            |
+     *                        focusable
+     * </pre>
+     */
+    @Test
+    public void testFindRotateTargetSkipScrollableContainer() {
+        AccessibilityNodeInfo root = new NodeBuilder().setNodeList(mNodeList).build();
+        AccessibilityNodeInfo button1 = new NodeBuilder()
+                .setNodeList(mNodeList)
+                .setParent(root)
+                .build();
+        AccessibilityNodeInfo button2 = new NodeBuilder()
+                .setNodeList(mNodeList)
+                .setParent(root)
+                .build();
+        AccessibilityNodeInfo scrollableContainer = new NodeBuilder()
+                .setNodeList(mNodeList)
+                .setParent(root)
+                .setContentDescription(ROTARY_VERTICALLY_SCROLLABLE)
+                .build();
+        AccessibilityNodeInfo focusable = new NodeBuilder()
+                .setNodeList(mNodeList)
+                .setParent(scrollableContainer)
+                .setFocusable(true)
+                .setVisibleToUser(true)
+                .setEnabled(true)
+                .build();
+
+        int direction = View.FOCUS_FORWARD;
+        when(button1.focusSearch(direction)).thenReturn(scrollableContainer);
+        when(scrollableContainer.focusSearch(direction)).thenReturn(button2);
+
+        FindRotateTargetResult target = mNavigator.findRotateTarget(button1, direction, 1);
+        assertThat(target.node).isSameAs(button2);
+    }
+
+    /**
      * Tests {@link Navigator#findRotateTarget} in the following layout:
      * <pre>
      *     ============ focus area ============
@@ -294,7 +379,7 @@
                 .setNodeList(mNodeList)
                 .setParent(focusArea)
                 .setFocusable(true)
-                .setContentDescription(RotaryConstants.ROTARY_VERTICALLY_SCROLLABLE)
+                .setContentDescription(ROTARY_VERTICALLY_SCROLLABLE)
                 .setActionList(new ArrayList<>(Collections.singletonList(
                         AccessibilityNodeInfo.AccessibilityAction.ACTION_SCROLL_FORWARD)))
                 .setBoundsInScreen(new Rect(0, 0, 100, 100))
@@ -322,18 +407,18 @@
         when(button3.focusSearch(direction)).thenReturn(null);
 
         // Rotate once, the focus should move from button1 to button2.
-        FindRotateTargetResult target = mNavigator.findRotateTarget(button1, null, direction, 1);
+        FindRotateTargetResult target = mNavigator.findRotateTarget(button1, direction, 1);
         assertThat(target.node).isSameAs(button2);
         assertThat(target.advancedCount).isEqualTo(1);
 
         // Rotate twice, the focus should move from button1 to button2 since button3 is out of
         // bounds.
-        target = mNavigator.findRotateTarget(button1, null, direction, 2);
+        target = mNavigator.findRotateTarget(button1, direction, 2);
         assertThat(target.node).isSameAs(button2);
         assertThat(target.advancedCount).isEqualTo(1);
 
         // Rotate three times should do the same.
-        target = mNavigator.findRotateTarget(button1, null, direction, 3);
+        target = mNavigator.findRotateTarget(button1, direction, 3);
         assertThat(target.node).isSameAs(button2);
         assertThat(target.advancedCount).isEqualTo(1);
     }
@@ -1201,6 +1286,204 @@
     }
 
     /**
+     * Tests {@link Navigator#findNudgeTarget} in the following layout:
+     * <pre>
+     *    **********leftWindow*********    ****************rightWindow*****************
+     *    *                           *    *                                          *
+     *    *  ===left focus area===    *    *    ==========right focus area========    *
+     *    *  =                   =    *    *    =                                =    *
+     *    *  =                   =    *    *    =  ....scrollableContainer.....  =    *
+     *    *  =                   =    *    *    =  .                          .  =    *
+     *    *  =      left         =    *    *    =  .    non-focusable         .  =    *
+     *    *  =                   =    *    *    =  .                          .  =    *
+     *    *  =                   =    *    *    =  ............................  =    *
+     *    *  =                   =    *    *    =                                =    *
+     *    *  =====================    *    *    ==================================    *
+     *    *                           *    *                                          *
+     *    *****************************    ********************************************
+     * </pre>
+     */
+    @Test
+    public void testFindNudgeTargetReturnScrollableContainer() {
+        // There are 2 windows. This is the left window.
+        Rect leftWindowBounds = new Rect(0, 0, 400, 400);
+        AccessibilityWindowInfo leftWindow = new WindowBuilder()
+                .setBoundsInScreen(leftWindowBounds)
+                .build();
+        AccessibilityNodeInfo leftRoot = new NodeBuilder()
+                .setNodeList(mNodeList)
+                .setWindow(leftWindow)
+                .setBoundsInScreen(leftWindowBounds)
+                .build();
+        setRootNodeForWindow(leftRoot, leftWindow);
+
+        // Left focus area and its view inside.
+        AccessibilityNodeInfo leftFocusArea = new NodeBuilder()
+                .setNodeList(mNodeList)
+                .setWindow(leftWindow)
+                .setParent(leftRoot)
+                .setClassName(FOCUS_AREA_CLASS_NAME)
+                .setBoundsInScreen(new Rect(0, 0, 400, 400))
+                .build();
+        AccessibilityNodeInfo left = new NodeBuilder()
+                .setNodeList(mNodeList)
+                .setWindow(leftWindow)
+                .setParent(leftFocusArea)
+                .setFocusable(true)
+                .setVisibleToUser(true)
+                .setEnabled(true)
+                .setBoundsInScreen(new Rect(0, 0, 400, 400))
+                .build();
+
+        // Right window.
+        Rect rightWindowBounds = new Rect(400, 0, 800, 400);
+        AccessibilityWindowInfo rightWindow = new WindowBuilder()
+                .setBoundsInScreen(rightWindowBounds)
+                .build();
+        AccessibilityNodeInfo rightRoot = new NodeBuilder()
+                .setNodeList(mNodeList)
+                .setWindow(rightWindow)
+                .setBoundsInScreen(rightWindowBounds)
+                .build();
+        setRootNodeForWindow(rightRoot, rightWindow);
+
+        // Right focus area and its view inside.
+        AccessibilityNodeInfo rightFocusArea = new NodeBuilder()
+                .setNodeList(mNodeList)
+                .setWindow(rightWindow)
+                .setParent(rightRoot)
+                .setClassName(FOCUS_AREA_CLASS_NAME)
+                .setBoundsInScreen(new Rect(400, 0, 800, 400))
+                .build();
+        AccessibilityNodeInfo scrollableContainer = new NodeBuilder()
+                .setNodeList(mNodeList)
+                .setWindow(rightWindow)
+                .setParent(rightFocusArea)
+                .setFocusable(true)
+                .setVisibleToUser(true)
+                .setEnabled(true)
+                .setBoundsInScreen(new Rect(400, 0, 800, 400))
+                .setContentDescription(ROTARY_VERTICALLY_SCROLLABLE)
+                .build();
+        AccessibilityNodeInfo nonFocusable = new NodeBuilder()
+                .setNodeList(mNodeList)
+                .setWindow(rightWindow)
+                .setParent(scrollableContainer)
+                .setVisibleToUser(true)
+                .setEnabled(true)
+                .setBoundsInScreen(new Rect(400, 0, 800, 400))
+                .build();
+
+        List<AccessibilityWindowInfo> windows = new ArrayList<>();
+        windows.add(leftWindow);
+        windows.add(rightWindow);
+
+        // Nudge from left window to right window.
+        AccessibilityNodeInfo target = mNavigator.findNudgeTarget(windows, left, View.FOCUS_RIGHT);
+        assertThat(target).isSameAs(scrollableContainer);
+    }
+
+
+    /**
+     * Tests {@link Navigator#findNudgeTarget} in the following layout:
+     * <pre>
+     *    **********leftWindow*********    ****************rightWindow*****************
+     *    *                           *    *                                          *
+     *    *  ===left focus area===    *    *    ==========right focus area========    *
+     *    *  =                   =    *    *    =                                =    *
+     *    *  =                   =    *    *    =  ....scrollableContainer.....  =    *
+     *    *  =                   =    *    *    =  .                          .  =    *
+     *    *  =      left         =    *    *    =  .       focusable          .  =    *
+     *    *  =                   =    *    *    =  .                          .  =    *
+     *    *  =                   =    *    *    =  ............................  =    *
+     *    *  =                   =    *    *    =                                =    *
+     *    *  =====================    *    *    ==================================    *
+     *    *                           *    *                                          *
+     *    *****************************    ********************************************
+     * </pre>
+     */
+    @Test
+    public void testFindNudgeTargetSkipScrollableContainer() {
+        // There are 2 windows. This is the left window.
+        Rect leftWindowBounds = new Rect(0, 0, 400, 400);
+        AccessibilityWindowInfo leftWindow = new WindowBuilder()
+                .setBoundsInScreen(leftWindowBounds)
+                .build();
+        AccessibilityNodeInfo leftRoot = new NodeBuilder()
+                .setNodeList(mNodeList)
+                .setWindow(leftWindow)
+                .setBoundsInScreen(leftWindowBounds)
+                .build();
+        setRootNodeForWindow(leftRoot, leftWindow);
+
+        // Left focus area and its view inside.
+        AccessibilityNodeInfo leftFocusArea = new NodeBuilder()
+                .setNodeList(mNodeList)
+                .setWindow(leftWindow)
+                .setParent(leftRoot)
+                .setClassName(FOCUS_AREA_CLASS_NAME)
+                .setBoundsInScreen(new Rect(0, 0, 400, 400))
+                .build();
+        AccessibilityNodeInfo left = new NodeBuilder()
+                .setNodeList(mNodeList)
+                .setWindow(leftWindow)
+                .setParent(leftFocusArea)
+                .setFocusable(true)
+                .setVisibleToUser(true)
+                .setEnabled(true)
+                .setBoundsInScreen(new Rect(0, 0, 400, 400))
+                .build();
+
+        // Right window.
+        Rect rightWindowBounds = new Rect(400, 0, 800, 400);
+        AccessibilityWindowInfo rightWindow = new WindowBuilder()
+                .setBoundsInScreen(rightWindowBounds)
+                .build();
+        AccessibilityNodeInfo rightRoot = new NodeBuilder()
+                .setNodeList(mNodeList)
+                .setWindow(rightWindow)
+                .setBoundsInScreen(rightWindowBounds)
+                .build();
+        setRootNodeForWindow(rightRoot, rightWindow);
+
+        // Right focus area and its view inside.
+        AccessibilityNodeInfo rightFocusArea = new NodeBuilder()
+                .setNodeList(mNodeList)
+                .setWindow(rightWindow)
+                .setParent(rightRoot)
+                .setClassName(FOCUS_AREA_CLASS_NAME)
+                .setBoundsInScreen(new Rect(400, 0, 800, 400))
+                .build();
+        AccessibilityNodeInfo scrollableContainer = new NodeBuilder()
+                .setNodeList(mNodeList)
+                .setWindow(rightWindow)
+                .setParent(rightFocusArea)
+                .setFocusable(true)
+                .setVisibleToUser(true)
+                .setEnabled(true)
+                .setBoundsInScreen(new Rect(400, 0, 800, 400))
+                .setContentDescription(ROTARY_VERTICALLY_SCROLLABLE)
+                .build();
+        AccessibilityNodeInfo focusable = new NodeBuilder()
+                .setNodeList(mNodeList)
+                .setWindow(rightWindow)
+                .setParent(scrollableContainer)
+                .setFocusable(true)
+                .setVisibleToUser(true)
+                .setEnabled(true)
+                .setBoundsInScreen(new Rect(400, 0, 800, 400))
+                .build();
+
+        List<AccessibilityWindowInfo> windows = new ArrayList<>();
+        windows.add(leftWindow);
+        windows.add(rightWindow);
+
+        // Nudge from left window to right window.
+        AccessibilityNodeInfo target = mNavigator.findNudgeTarget(windows, left, View.FOCUS_RIGHT);
+        assertThat(target).isSameAs(focusable);
+    }
+
+    /**
      * Tests {@link Navigator#findFirstFocusDescendant} in the following node tree:
      * <pre>
      *                   root
@@ -1357,7 +1640,7 @@
                 .setNodeList(mNodeList)
                 .setParent(focusArea)
                 .setFocusable(true)
-                .setContentDescription(RotaryConstants.ROTARY_VERTICALLY_SCROLLABLE)
+                .setContentDescription(ROTARY_VERTICALLY_SCROLLABLE)
                 .build();
         AccessibilityNodeInfo container = new NodeBuilder()
                 .setNodeList(mNodeList)