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();
}