Merge "Basic rotary support for WebViews" into rvc-qpr-dev am: 5b14d5c49c

Original change: https://googleplex-android-review.googlesource.com/c/platform/packages/apps/Car/RotaryController/+/12515636

Change-Id: I0a3fcce4b6a2dd0f1612225c8406b8212f3d880d
diff --git a/src/com/android/car/rotary/Navigator.java b/src/com/android/car/rotary/Navigator.java
index ac4684b..3219ddc 100644
--- a/src/com/android/car/rotary/Navigator.java
+++ b/src/com/android/car/rotary/Navigator.java
@@ -321,7 +321,16 @@
         AccessibilityNodeInfo candidate = copyNode(sourceNode);
         AccessibilityNodeInfo target = null;
         while (advancedCount < rotationCount) {
-            AccessibilityNodeInfo nextCandidate = candidate.focusSearch(direction);
+            AccessibilityNodeInfo nextCandidate = null;
+            AccessibilityNodeInfo webView = findWebViewAncestor(candidate);
+            if (webView != null) {
+                nextCandidate = findNextFocusableInWebView(webView, candidate, direction);
+            }
+            if (nextCandidate == null) {
+                // If we aren't in a WebView or there aren't any more focusable nodes within the
+                // WebView, use focusSearch().
+                nextCandidate = candidate.focusSearch(direction);
+            }
             AccessibilityNodeInfo candidateFocusArea =
                     nextCandidate == null ? null : getAncestorFocusArea(nextCandidate);
 
@@ -351,27 +360,23 @@
                     candidate = nextCandidate;
                     continue;
                 }
-                // If we're navigating through a scrolling view that can scroll in the specified
-                // direction, and the next view's bounds don't intersect the scrolling view's
-                // bounds, don't advance to it. We'll scroll the remaining count instead.
-                // There are two cases where the bounds don't intersect:
-                // 1. the next view is not a descendant of the scrolling view
-                // 2. the next view is a descendant, but it's off the screen, so its bounds in
-                //    screen is empty, thus don't intersect the scrolling view's bounds
+
+                // If we're navigating in a scrollable container that can scroll in the specified
+                // direction and the next candidate is off-screen or there are no more focusable
+                // views within the scrollable container, stop navigating so that any remaining
+                // detents are used for scrolling.
                 AccessibilityNodeInfo scrollableContainer = findScrollableContainer(candidate);
                 AccessibilityNodeInfo.AccessibilityAction scrollAction =
                         direction == View.FOCUS_FORWARD
                                 ? ACTION_SCROLL_FORWARD
                                 : ACTION_SCROLL_BACKWARD;
                 if (scrollableContainer != null
-                        && scrollableContainer.getActionList().contains(scrollAction)) {
-                    Rect nextTargetBounds = Utils.getBoundsInScreen(nextCandidate);
-                    Rect scrollBounds = Utils.getBoundsInScreen(scrollableContainer);
-                    if (!Rect.intersects(nextTargetBounds, scrollBounds)) {
-                        Utils.recycleNode(nextCandidate);
-                        Utils.recycleNode(candidateFocusArea);
-                        break;
-                    }
+                        && scrollableContainer.getActionList().contains(scrollAction)
+                        && (!Utils.isDescendant(scrollableContainer, nextCandidate)
+                                || Utils.getBoundsInScreen(nextCandidate).isEmpty())) {
+                    Utils.recycleNode(nextCandidate);
+                    Utils.recycleNode(candidateFocusArea);
+                    break;
                 }
                 Utils.recycleNode(scrollableContainer);
 
@@ -569,17 +574,38 @@
         return targetFocusArea;
     }
 
-    private static void removeEmptyFocusAreas(@NonNull List<AccessibilityNodeInfo> focusAreas) {
+    private void removeEmptyFocusAreas(@NonNull List<AccessibilityNodeInfo> focusAreas) {
         for (Iterator<AccessibilityNodeInfo> iterator = focusAreas.iterator();
                 iterator.hasNext(); ) {
             AccessibilityNodeInfo focusArea = iterator.next();
-            if (!Utils.canHaveFocus(focusArea)) {
+            if (!Utils.canHaveFocus(focusArea)
+                    && !containsWebViewWithFocusableDescendants(focusArea)) {
                 iterator.remove();
                 focusArea.recycle();
             }
         }
     }
 
+    private boolean containsWebViewWithFocusableDescendants(@NonNull AccessibilityNodeInfo node) {
+        List<AccessibilityNodeInfo> webViews = new ArrayList<>();
+        mTreeTraverser.depthFirstSelect(node, Utils::isWebView, webViews);
+        if (webViews.isEmpty()) {
+            return false;
+        }
+        boolean hasFocusableDescendant = false;
+        for (AccessibilityNodeInfo webView : webViews) {
+            AccessibilityNodeInfo focusableDescendant = mTreeTraverser.depthFirstSearch(webView,
+                    Utils::canPerformFocus);
+            if (focusableDescendant != null) {
+                hasFocusableDescendant = true;
+                focusableDescendant.recycle();
+                break;
+            }
+        }
+        Utils.recycleNodes(webViews);
+        return hasFocusableDescendant;
+    }
+
     /**
      * Adds all the {@code windows} in the given {@code direction} of the given {@code source}
      * window to the given list.
@@ -650,29 +676,6 @@
     }
 
     /**
-     * Returns the previous node in Tab order before {@code referenceNode} within
-     * {@code containerNode} or null if none. The caller is responsible for recycling the result.
-     */
-    @Nullable
-    static AccessibilityNodeInfo findPreviousFocusableDescendant(
-            @NonNull AccessibilityNodeInfo containerNode,
-            @NonNull AccessibilityNodeInfo referenceNode) {
-        return findFocusableDescendantInDirection(containerNode, referenceNode,
-                View.FOCUS_BACKWARD);
-    }
-
-    /**
-     * Returns the next node after {@code referenceNode} in Tab order within {@code containerNode}
-     * or null if none. The caller is responsible for recycling the result.
-     */
-    @Nullable
-    static AccessibilityNodeInfo findNextFocusableDescendant(
-            @NonNull AccessibilityNodeInfo containerNode,
-            @NonNull AccessibilityNodeInfo referenceNode) {
-        return findFocusableDescendantInDirection(containerNode, referenceNode, View.FOCUS_FORWARD);
-    }
-
-    /**
      * Returns the previous node before {@code referenceNode} in Tab order or the next node after
      * {@code referenceNode} in Tab order, depending on {@code direction}. The search is limited to
      * descendants of {@code containerNode}. Returns null if there are no focusable descendants in
@@ -746,7 +749,21 @@
      */
     private void addFocusDescendants(@NonNull AccessibilityNodeInfo node,
             @NonNull List<AccessibilityNodeInfo> results) {
-        mTreeTraverser.depthFirstSelect(node, Utils::canTakeFocus, results);
+        // Include off-screen nodes within a WebView.
+        if (Utils.isWebView(node)) {
+            mTreeTraverser.depthFirstSelect(node, Utils::canPerformFocus, results);
+            return;
+        }
+
+        if (Utils.canTakeFocus(node)) {
+            results.add(node);
+        }
+        for (int i = 0; i < node.getChildCount(); i++) {
+            AccessibilityNodeInfo child = node.getChild(i);
+            if (child != null) {
+                addFocusDescendants(child, results);
+            }
+        }
     }
 
     /**
@@ -816,6 +833,11 @@
                                 sourceFocusAreaBounds, candidateBounds, direction);
                 },
                 /* targetPredicate= */ candidateNode -> {
+                    // RotaryService can navigate to nodes in a WebView even when off-screen so we
+                    // use canPerformFocus() to skip the bounds check.
+                    if (isInWebView(candidateNode)) {
+                        return Utils.canPerformFocus(candidateNode);
+                    }
                     // If a node can't take focus, it represents a focus area, so we return false to
                     // skip the node and let it search its descendants.
                     if (!Utils.canTakeFocus(candidateNode)) {
@@ -864,9 +886,111 @@
         return result;
     }
 
-    /** An interface for a lambda that returns an {@link AccessibilityNodeInfo}. */
-    interface NodeProvider {
-        AccessibilityNodeInfo provideNode();
+    /**
+     * Returns a copy of {@code node} or the nearest ancestor that represents a {@code WebView}.
+     * Returns null if {@code node} isn't a {@code WebView} and isn't a descendant of a {@code
+     * WebView}.
+     */
+    @Nullable
+    private AccessibilityNodeInfo findWebViewAncestor(@NonNull AccessibilityNodeInfo node) {
+        return mTreeTraverser.findNodeOrAncestor(node, Utils::isWebView);
+    }
+
+    /** Returns whether {@code node} is a {@code WebView} or is a descendant of one. */
+    boolean isInWebView(@NonNull AccessibilityNodeInfo node) {
+        AccessibilityNodeInfo webView = findWebViewAncestor(node);
+        if (webView == null) {
+            return false;
+        }
+        webView.recycle();
+        return true;
+    }
+
+    /**
+     * Returns the next focusable node after {@code candidate} in {@code direction} in {@code
+     * webView} or null if none. This handles navigating into a WebView as well as within a WebView.
+     */
+    @Nullable
+    private AccessibilityNodeInfo findNextFocusableInWebView(@NonNull AccessibilityNodeInfo webView,
+            @NonNull AccessibilityNodeInfo candidate, int direction) {
+        // focusSearch() doesn't work in WebViews so use tree traversal instead.
+        if (Utils.isWebView(candidate)) {
+            if (direction == View.FOCUS_FORWARD) {
+                // When entering into a WebView, find the first focusable node within the
+                // WebView if any.
+                return findFirstFocusableDescendantInWebView(candidate);
+            } else {
+                // When backing into a WebView, find the last focusable node within the
+                // WebView if any.
+                return findLastFocusableDescendantInWebView(candidate);
+            }
+        } else {
+            // When navigating within a WebView, find the next or previous focusable node in
+            // depth-first order.
+            if (direction == View.FOCUS_FORWARD) {
+                return findFirstFocusDescendantInWebViewAfter(webView, candidate);
+            } else {
+                return findFirstFocusDescendantInWebViewBefore(webView, candidate);
+            }
+        }
+    }
+
+    /**
+     * Returns the first descendant of {@code webView} which can perform focus. This includes off-
+     * screen descendants. The nodes are searched in in depth-first order, not including
+     * {@code webView} itself. If no descendant can perform focus, null is returned. The caller is
+     * responsible for recycling the result.
+     */
+    @Nullable
+    private AccessibilityNodeInfo findFirstFocusableDescendantInWebView(
+            @NonNull AccessibilityNodeInfo webView) {
+        return mTreeTraverser.depthFirstSearch(webView,
+                candidateNode -> candidateNode != webView && Utils.canPerformFocus(candidateNode));
+    }
+
+    /**
+     * Returns the last descendant of {@code webView} which can perform focus. This includes off-
+     * screen descendants. The nodes are searched in reverse depth-first order, not including
+     * {@code webView} itself. If no descendant can perform focus, null is returned. The caller is
+     * responsible for recycling the result.
+     */
+    @Nullable
+    private AccessibilityNodeInfo findLastFocusableDescendantInWebView(
+            @NonNull AccessibilityNodeInfo webView) {
+        return mTreeTraverser.reverseDepthFirstSearch(webView,
+                candidateNode -> candidateNode != webView && Utils.canPerformFocus(candidateNode));
+    }
+
+    @Nullable
+    private AccessibilityNodeInfo findFirstFocusDescendantInWebViewBefore(
+            @NonNull AccessibilityNodeInfo webView, @NonNull AccessibilityNodeInfo beforeNode) {
+        boolean[] foundBeforeNode = new boolean[1];
+        return mTreeTraverser.reverseDepthFirstSearch(webView,
+                node -> {
+                    if (foundBeforeNode[0] && Utils.canPerformFocus(node)) {
+                        return true;
+                    }
+                    if (node.equals(beforeNode)) {
+                        foundBeforeNode[0] = true;
+                    }
+                    return false;
+                });
+    }
+
+    @Nullable
+    private AccessibilityNodeInfo findFirstFocusDescendantInWebViewAfter(
+            @NonNull AccessibilityNodeInfo webView, @NonNull AccessibilityNodeInfo afterNode) {
+        boolean[] foundAfterNode = new boolean[1];
+        return mTreeTraverser.depthFirstSearch(webView,
+                node -> {
+                    if (foundAfterNode[0] && Utils.canPerformFocus(node)) {
+                        return true;
+                    }
+                    if (node.equals(afterNode)) {
+                        foundAfterNode[0] = true;
+                    }
+                    return false;
+                });
     }
 
     /** Result from {@link #findRotateTarget}. */
diff --git a/src/com/android/car/rotary/RotaryService.java b/src/com/android/car/rotary/RotaryService.java
index 6a999d4..82bdbc8 100644
--- a/src/com/android/car/rotary/RotaryService.java
+++ b/src/com/android/car/rotary/RotaryService.java
@@ -1047,10 +1047,13 @@
         }
 
         // Case 2: the focused node doesn't support rotate directly and it's in application window.
-        // We should inject KEYCODE_DPAD_CENTER event, then the application will handle the injected
-        // event.
+        // We should inject KEYCODE_DPAD_CENTER event (or KEYCODE_ENTER in a WebView), then the
+        // application will handle the injected event.
         if (isInApplicationWindow(mFocusedNode)) {
-            injectKeyEvent(KeyEvent.KEYCODE_DPAD_CENTER, action);
+            int keyCode = mNavigator.isInWebView(mFocusedNode)
+                    ? KeyEvent.KEYCODE_ENTER
+                    : KeyEvent.KEYCODE_DPAD_CENTER;
+            injectKeyEvent(keyCode, action);
             setIgnoreViewClickedNode(mFocusedNode);
             return;
         }
@@ -1458,9 +1461,17 @@
         refreshSavedNodes();
         setInRotaryMode(true);
         if (mFocusedNode != null) {
+            // If mFocusedNode is focused, we're in a good state and can proceed with whatever
+            // action the user requested.
             if (mFocusedNode.isFocused()) {
                 return false;
             }
+            // If the focused node represents an HTML element in a WebView, we just assume the focus
+            // is already initialized here, and we'll handle it properly when the user uses the
+            // controller next time.
+            if (mNavigator.isInWebView(mFocusedNode)) {
+                return false;
+            }
             // mFocusedNode is still in the view tree, but its state has changed and it's not
             // focused any more. In this case we should set mFocusedNode to null.
             setFocusedNode(null);
@@ -1836,7 +1847,8 @@
             L.d("No need to focus on targetNode because it's already focused: " + targetNode);
             return true;
         }
-        if (targetNode.isFocused()) {
+        boolean isInWebView = mNavigator.isInWebView(targetNode);
+        if (targetNode.isFocused() && !isInWebView) {
             // This happens when:
             // 1. A window has no FocusParkingView, thus leaving the window won't clear the view
             //    focus in it. When going back to the window, we may find that the targetNode is
@@ -1851,6 +1863,8 @@
             //    nearby and try to focus it. The view we found might be the focusedByDefault view,
             //    which was already focused by Android. This is fine, and we just need to update
             //    mFocusedNode.
+            // If the target node is in a WebView, it may not actually be focused. In this case, we
+            // go ahead and perform ACTION_FOCUS to focus it.
             L.w("The focus on targetNode might not be cleared: " + targetNode);
             setFocusedNode(targetNode);
             return true;
@@ -1860,15 +1874,17 @@
                     + "waiting for the focus event: " + targetNode);
             return false;
         }
-        if (!Utils.isFocusArea(targetNode) && Utils.hasFocus(targetNode)) {
+        if (!Utils.isFocusArea(targetNode) && Utils.hasFocus(targetNode) && !isInWebView) {
             // One of targetNode's descendants is already focused, so we can't perform ACTION_FOCUS
             // on targetNode directly unless it's a FocusArea. The workaround is to clear the focus
-            // first (by focusing on the FocusParkingView), then focus on targetNode.
+            // first (by focusing on the FocusParkingView), then focus on targetNode. The
+            // prohibition on focusing a node that has focus doesn't apply in WebViews.
             L.d("One of targetNode's descendants is already focused: " + targetNode);
             if (!clearFocusInCurrentWindow()) {
                 return false;
             }
         }
+
         // Now we can perform ACTION_FOCUS on targetNode since it doesn't have focus, its
         // descendant's focus has been cleared, or it's a FocusArea.
         boolean result = targetNode.performAction(ACTION_FOCUS, arguments);
diff --git a/src/com/android/car/rotary/Utils.java b/src/com/android/car/rotary/Utils.java
index f2a3ce8..293937d 100644
--- a/src/com/android/car/rotary/Utils.java
+++ b/src/com/android/car/rotary/Utils.java
@@ -27,9 +27,11 @@
 import android.os.Bundle;
 import android.view.accessibility.AccessibilityNodeInfo;
 import android.view.accessibility.AccessibilityWindowInfo;
+import android.webkit.WebView;
 
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
+import androidx.annotation.VisibleForTesting;
 
 import com.android.car.ui.FocusArea;
 import com.android.car.ui.FocusParkingView;
@@ -48,8 +50,11 @@
  */
 final class Utils {
 
+    @VisibleForTesting
     static final String FOCUS_AREA_CLASS_NAME = FocusArea.class.getName();
+    @VisibleForTesting
     static final String FOCUS_PARKING_VIEW_CLASS_NAME = FocusParkingView.class.getName();
+    private static final String WEB_VIEW_CLASS_NAME = WebView.class.getName();
 
     private Utils() {
     }
@@ -100,6 +105,11 @@
             return false;
         }
 
+        // ACTION_FOCUS doesn't work on WebViews.
+        if (isWebView(node)) {
+            return false;
+        }
+
         // Check the bounds in the parent rather than the bounds in the screen because the latter
         // are always empty for views that are off screen.
         Rect bounds = new Rect();
@@ -118,14 +128,14 @@
      *         we want to focus on it, thus we can scroll it when the rotary controller is rotated.
      *     <li>To be a focus candidate, a node must be on the screen. Usually the node off the
      *         screen (its bounds in screen is empty) is ignored by RotaryService, but there are
-     *         exceptions.
+     *         exceptions, e.g. nodes in a WebView.
      * </ul>
      */
     static boolean canTakeFocus(@NonNull AccessibilityNodeInfo node) {
-        boolean result =  canPerformFocus(node)
+        boolean result = canPerformFocus(node)
                 && !isFocusParkingView(node)
                 && (!isScrollableContainer(node)
-                    || (node.isScrollable() && !descendantCanTakeFocus(node)));
+                        || (node.isScrollable() && !descendantCanTakeFocus(node)));
         if (result) {
             Rect bounds = getBoundsInScreen(node);
             if (!bounds.isEmpty()) {
@@ -190,6 +200,21 @@
     }
 
     /**
+     * Returns whether {@code node} represents a {@code WebView} or the root of the document within
+     * one.
+     * <p>
+     * The descendants of a node representing a {@code WebView} represent HTML elements rather
+     * than {@code View}s so {@link AccessibilityNodeInfo#focusSearch} doesn't work for these nodes.
+     * The focused state of these nodes isn't reliable. The node representing a {@code WebView} has
+     * a single child node representing the HTML document. This node also claims to be a {@code
+     * WebView}. Unlike its parent, it is scrollable and focusable.
+     */
+    static boolean isWebView(@NonNull AccessibilityNodeInfo node) {
+        CharSequence className = node.getClassName();
+        return className != null && WEB_VIEW_CLASS_NAME.contentEquals(className);
+    }
+
+    /**
      * Returns whether the given node represents a view which can be scrolled using the rotary
      * controller, as indicated by its content description.
      */
diff --git a/tests/robotests/src/com/android/car/rotary/NavigatorTest.java b/tests/robotests/src/com/android/car/rotary/NavigatorTest.java
index 501ccd8..4d5d76b 100644
--- a/tests/robotests/src/com/android/car/rotary/NavigatorTest.java
+++ b/tests/robotests/src/com/android/car/rotary/NavigatorTest.java
@@ -1961,7 +1961,8 @@
     }
 
     /**
-     * Tests {@link Navigator#findPreviousFocusableDescendant} in the following node tree:
+     * Tests {@link Navigator#findFocusableDescendantInDirection} going
+     *      * {@link View#FOCUS_BACKWARD} in the following node tree:
      * <pre>
      *                     root
      *                   /      \
@@ -1973,7 +1974,7 @@
      * </pre>
      */
     @Test
-    public void testFindPreviousFocusableDescendant() {
+    public void testFindFocusableVisibleDescendantInDirectionBackward() {
         AccessibilityNodeInfo root = mNodeBuilder.build();
         AccessibilityNodeInfo container1 = mNodeBuilder.setParent(root).build();
         AccessibilityNodeInfo button1 = mNodeBuilder.setParent(container1).build();
@@ -1988,19 +1989,23 @@
         when(button2.focusSearch(direction)).thenReturn(button1);
         when(button1.focusSearch(direction)).thenReturn(null);
 
-        AccessibilityNodeInfo target =
-                Navigator.findPreviousFocusableDescendant(container2, button4);
+        AccessibilityNodeInfo target = mNavigator.findFocusableDescendantInDirection(
+                container2, button4, View.FOCUS_BACKWARD);
         assertThat(target).isSameAs(button3);
-        target = Navigator.findPreviousFocusableDescendant(container2, button3);
+        target = mNavigator.findFocusableDescendantInDirection(container2, button3,
+                View.FOCUS_BACKWARD);
         assertThat(target).isNull();
-        target = Navigator.findPreviousFocusableDescendant(container1, button2);
+        target = mNavigator.findFocusableDescendantInDirection(container1, button2,
+                View.FOCUS_BACKWARD);
         assertThat(target).isSameAs(button1);
-        target = Navigator.findPreviousFocusableDescendant(container1, button1);
+        target = mNavigator.findFocusableDescendantInDirection(container1, button1,
+                View.FOCUS_BACKWARD);
         assertThat(target).isNull();
     }
 
     /**
-     * Tests {@link Navigator#findNextFocusableDescendant} in the following node tree:
+     * Tests {@link Navigator#findFocusableDescendantInDirection} going
+     * {@link View#FOCUS_FORWARD} in the following node tree:
      * <pre>
      *                     root
      *                   /      \
@@ -2012,7 +2017,7 @@
      * </pre>
      */
     @Test
-    public void testFindNextFocusableDescendant() {
+    public void testFindFocusableVisibleDescendantInDirectionForward() {
         AccessibilityNodeInfo root = mNodeBuilder.build();
         AccessibilityNodeInfo container1 = mNodeBuilder.setParent(root).build();
         AccessibilityNodeInfo button1 = mNodeBuilder.setParent(container1).build();
@@ -2027,13 +2032,17 @@
         when(button3.focusSearch(direction)).thenReturn(button4);
         when(button4.focusSearch(direction)).thenReturn(null);
 
-        AccessibilityNodeInfo target = mNavigator.findNextFocusableDescendant(container1, button1);
+        AccessibilityNodeInfo target = mNavigator.findFocusableDescendantInDirection(
+                container1, button1, View.FOCUS_FORWARD);
         assertThat(target).isSameAs(button2);
-        target = mNavigator.findNextFocusableDescendant(container1, button2);
+        target = mNavigator.findFocusableDescendantInDirection(container1, button2,
+                View.FOCUS_FORWARD);
         assertThat(target).isNull();
-        target = mNavigator.findNextFocusableDescendant(container2, button3);
+        target = mNavigator.findFocusableDescendantInDirection(container2, button3,
+                View.FOCUS_FORWARD);
         assertThat(target).isSameAs(button4);
-        target = mNavigator.findNextFocusableDescendant(container2, button4);
+        target = mNavigator.findFocusableDescendantInDirection(container2, button4,
+                View.FOCUS_FORWARD);
         assertThat(target).isNull();
     }