Allow user to rotate to view scrolled off screen

When searching for a target view to rotate to, don't skip over views
that aren't visible. They may be scrolled off the screen, in which
case we should allow the user to navigate to them. Instead, skip over
views with zero width or height.

Bug: 157937087
Test: atest CarRotaryControllerRoboTests
Change-Id: Iaa58073f3aa95aea56e527f939375c4c7657543d
diff --git a/src/com/android/car/rotary/Utils.java b/src/com/android/car/rotary/Utils.java
index 67a5dee..07bf30a 100644
--- a/src/com/android/car/rotary/Utils.java
+++ b/src/com/android/car/rotary/Utils.java
@@ -19,6 +19,7 @@
 import static com.android.car.ui.utils.RotaryConstants.ROTARY_HORIZONTALLY_SCROLLABLE;
 import static com.android.car.ui.utils.RotaryConstants.ROTARY_VERTICALLY_SCROLLABLE;
 
+import android.graphics.Rect;
 import android.view.accessibility.AccessibilityNodeInfo;
 import android.view.accessibility.AccessibilityWindowInfo;
 
@@ -83,13 +84,19 @@
     }
 
     /**
-     * Returns whether the given {@code node} can perform focus action.
+     * Returns whether RotaryService can call {@code performFocusAction()} with the given
+     * {@code node}.
      * <p>
-     * A node can perform focus action means RotaryService can call performFocusAction() with the
-     * node.
+     * We don't check if the node is visible because we want to allow nodes scrolled off the screen
+     * to be focused.
      */
     static boolean canPerformFocus(@NonNull AccessibilityNodeInfo node) {
-        return node.isVisibleToUser() && node.isFocusable() && node.isEnabled();
+        if (!node.isFocusable() || !node.isEnabled()) {
+            return false;
+        }
+        Rect bounds = new Rect();
+        node.getBoundsInScreen(bounds);
+        return !bounds.isEmpty();
     }
 
     /**
diff --git a/tests/robotests/src/com/android/car/rotary/NavigatorTest.java b/tests/robotests/src/com/android/car/rotary/NavigatorTest.java
index d6c0df9..421058f 100644
--- a/tests/robotests/src/com/android/car/rotary/NavigatorTest.java
+++ b/tests/robotests/src/com/android/car/rotary/NavigatorTest.java
@@ -180,11 +180,11 @@
      *                         /  |  \
      *                       /    |    \
      *                     /      |      \
-     *               button1   invisible  button2
+     *               button1   invisible   button2
      * </pre>
      */
     @Test
-    public void testFindRotateTargetSkipNodeThatCannotPerformFocus() {
+    public void testFindRotateTargetDoesNotSkipInvisibleNode() {
         AccessibilityNodeInfo root = mNodeBuilder.build();
         AccessibilityNodeInfo button1 = mNodeBuilder.setParent(root).build();
         AccessibilityNodeInfo invisible = mNodeBuilder
@@ -197,7 +197,36 @@
         when(button1.focusSearch(direction)).thenReturn(invisible);
         when(invisible.focusSearch(direction)).thenReturn(button2);
 
-        // Rotate from button1, it should skip the invisible view.
+        // Rotate from button1, it shouldn't skip the invisible view.
+        FindRotateTargetResult target = mNavigator.findRotateTarget(button1, direction, 1);
+        assertThat(target.node).isSameAs(invisible);
+    }
+
+    /**
+     * Tests {@link Navigator#findRotateTarget} in the following node tree:
+     * <pre>
+     *                          root
+     *                         /  |  \
+     *                       /    |    \
+     *                     /      |      \
+     *               button1   empty   button2
+     * </pre>
+     */
+    @Test
+    public void testFindRotateTargetSkipNodeThatCannotPerformFocus() {
+        AccessibilityNodeInfo root = mNodeBuilder.build();
+        AccessibilityNodeInfo button1 = mNodeBuilder.setParent(root).build();
+        AccessibilityNodeInfo empty = mNodeBuilder
+                .setParent(root)
+                .setBoundsInScreen(new Rect(0, 0, 0, 10))
+                .build();
+        AccessibilityNodeInfo button2 = mNodeBuilder.setParent(root).build();
+
+        int direction = View.FOCUS_FORWARD;
+        when(button1.focusSearch(direction)).thenReturn(empty);
+        when(empty.focusSearch(direction)).thenReturn(button2);
+
+        // Rotate from button1, it should skip the empty view.
         FindRotateTargetResult target = mNavigator.findRotateTarget(button1, direction, 1);
         assertThat(target.node).isSameAs(button2);
     }
diff --git a/tests/robotests/src/com/android/car/rotary/NodeBuilder.java b/tests/robotests/src/com/android/car/rotary/NodeBuilder.java
index 72a7549..69e6a89 100644
--- a/tests/robotests/src/com/android/car/rotary/NodeBuilder.java
+++ b/tests/robotests/src/com/android/car/rotary/NodeBuilder.java
@@ -48,6 +48,8 @@
  */
 class NodeBuilder {
 
+    private static final Rect DEFAULT_BOUNDS = new Rect(0, 0, 100, 100);
+
     /**
      * A list of mock nodes created via NodeBuilder. This list is used for searching for a
      * node's child nodes.
@@ -66,9 +68,9 @@
     /** The class this node comes from. */
     @Nullable
     private String mClassName;
+    @NonNull
     /** The node bounds in screen coordinates. */
-    @Nullable
-    private Rect mBoundsInScreen;
+    private Rect mBoundsInScreen = new Rect(DEFAULT_BOUNDS);
     /** Whether this node is focusable. */
     private boolean mFocusable = true;
     /** Whether this node is visible to the user. */
@@ -132,14 +134,11 @@
 
         when(node.getClassName()).thenReturn(builder.mClassName);
 
-        if (builder.mBoundsInScreen != null) {
-            // Mock AccessibilityNodeInfo#getBoundsInScreen(Rect).
-            doAnswer(invocation -> {
-                Object[] args = invocation.getArguments();
-                ((Rect) args[0]).set(builder.mBoundsInScreen);
-                return null;
-            }).when(node).getBoundsInScreen(any(Rect.class));
-        }
+        doAnswer(invocation -> {
+            Object[] args = invocation.getArguments();
+            ((Rect) args[0]).set(builder.mBoundsInScreen);
+            return null;
+        }).when(node).getBoundsInScreen(any(Rect.class));
 
         when(node.isFocusable()).thenReturn(builder.mFocusable);
         when(node.isVisibleToUser()).thenReturn(builder.mVisibleToUser);
@@ -173,7 +172,7 @@
         return this;
     }
 
-    NodeBuilder setBoundsInScreen(@Nullable Rect boundsInScreen) {
+    NodeBuilder setBoundsInScreen(@NonNull Rect boundsInScreen) {
         mBoundsInScreen = boundsInScreen;
         return this;
     }
@@ -253,7 +252,7 @@
         mWindowId = UNDEFINED_WINDOW_ID;
         mParent = null;
         mClassName = null;
-        mBoundsInScreen = null;
+        mBoundsInScreen = new Rect(DEFAULT_BOUNDS);
         mFocusable = true;
         mVisibleToUser = true;
         mEnabled = true;
diff --git a/tests/robotests/src/com/android/car/rotary/NodeBuilderTest.java b/tests/robotests/src/com/android/car/rotary/NodeBuilderTest.java
index 7f5669f..3c291bb 100644
--- a/tests/robotests/src/com/android/car/rotary/NodeBuilderTest.java
+++ b/tests/robotests/src/com/android/car/rotary/NodeBuilderTest.java
@@ -59,6 +59,9 @@
         assertThat(node.isVisibleToUser()).isTrue();
         assertThat(node.refresh()).isTrue();
         assertThat(node.isEnabled()).isTrue();
+        Rect bounds = new Rect();
+        node.getBoundsInScreen(bounds);
+        assertThat(bounds.isEmpty()).isFalse();
     }
 
     @Test