blob: a493a2f1123625e3789a6891df2e11e40a4671fa [file] [log] [blame]
/*
* Copyright (C) 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.car.rotary;
import static android.app.ActivityTaskManager.INVALID_TASK_ID;
import static android.view.View.FOCUS_DOWN;
import static android.view.View.FOCUS_LEFT;
import static android.view.View.FOCUS_RIGHT;
import static android.view.View.FOCUS_UP;
import static android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction.ACTION_SCROLL_BACKWARD;
import static android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction.ACTION_SCROLL_FORWARD;
import static android.view.accessibility.AccessibilityNodeInfo.FOCUS_INPUT;
import static android.view.accessibility.AccessibilityWindowInfo.TYPE_APPLICATION;
import static android.view.accessibility.AccessibilityWindowInfo.TYPE_INPUT_METHOD;
import android.content.pm.PackageManager;
import android.graphics.Rect;
import android.view.Display;
import android.view.View;
import android.view.accessibility.AccessibilityNodeInfo;
import android.view.accessibility.AccessibilityWindowInfo;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import com.android.car.ui.FocusArea;
import com.android.car.ui.FocusParkingView;
import com.android.internal.util.dump.DualDumpOutputStream;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Predicate;
/**
* A helper class used for finding the next focusable node when the rotary controller is rotated or
* nudged.
*/
class Navigator {
@NonNull
private NodeCopier mNodeCopier = new NodeCopier();
@NonNull
private final TreeTraverser mTreeTraverser = new TreeTraverser();
@NonNull
@VisibleForTesting
final SurfaceViewHelper mSurfaceViewHelper = new SurfaceViewHelper();
private final int mHunLeft;
private final int mHunRight;
@View.FocusRealDirection
private int mHunNudgeDirection;
@NonNull
private final Rect mAppWindowBounds;
private int mAppWindowTaskId = INVALID_TASK_ID;
Navigator(int displayWidth, int displayHeight, int hunLeft, int hunRight,
boolean showHunOnBottom) {
mHunLeft = hunLeft;
mHunRight = hunRight;
mHunNudgeDirection = showHunOnBottom ? FOCUS_DOWN : FOCUS_UP;
mAppWindowBounds = new Rect(0, 0, displayWidth, displayHeight);
}
@VisibleForTesting
Navigator() {
this(0, 0, 0, 0, false);
}
/**
* Updates {@link #mAppWindowTaskId} if {@code window} is a full-screen app window on the
* default display.
*/
void updateAppWindowTaskId(@NonNull AccessibilityWindowInfo window) {
if (window.getType() == TYPE_APPLICATION
&& window.getDisplayId() == Display.DEFAULT_DISPLAY) {
Rect windowBounds = new Rect();
window.getBoundsInScreen(windowBounds);
if (mAppWindowBounds.equals(windowBounds)) {
mAppWindowTaskId = window.getTaskId();
L.d("Task ID of app window: " + mAppWindowTaskId);
}
}
}
/** Initializes the package name of the host app. */
void initHostApp(@NonNull PackageManager packageManager) {
mSurfaceViewHelper.initHostApp(packageManager);
}
/** Clears the package name of the host app if the given {@code packageName} matches. */
void clearHostApp(@NonNull String packageName) {
mSurfaceViewHelper.clearHostApp(packageName);
}
/** Adds the package name of the client app. */
void addClientApp(@NonNull CharSequence clientAppPackageName) {
mSurfaceViewHelper.addClientApp(clientAppPackageName);
}
/** Returns whether the given {@code node} represents a view of the host app. */
boolean isHostNode(@NonNull AccessibilityNodeInfo node) {
return mSurfaceViewHelper.isHostNode(node);
}
/** Returns whether the given {@code node} represents a view of the client app. */
boolean isClientNode(@NonNull AccessibilityNodeInfo node) {
return mSurfaceViewHelper.isClientNode(node);
}
@Nullable
AccessibilityWindowInfo findHunWindow(@NonNull List<AccessibilityWindowInfo> windows) {
for (AccessibilityWindowInfo window : windows) {
if (isHunWindow(window)) {
return window;
}
}
return null;
}
/**
* Returns the target focusable for a rotate. The caller is responsible for recycling the node
* in the result.
*
* <p>Limits navigation to focusable views within a scrollable container's viewport, if any.
*
* @param sourceNode the current focus
* @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
* (visible, focusable and enabled). If {@code skipNode} is encountered, it
* isn't counted.
* @return a FindRotateTargetResult containing a node and a count of the number of times the
* search advanced to another node. The node represents a focusable view in the given
* {@code direction} from the current focus within the same {@link FocusArea}. If the
* first or last view is reached before counting up to {@code rotationCount}, the first
* or last view is returned. However, if there are no views that can take focus in the
* given {@code direction}, {@code null} is returned.
*/
@Nullable
FindRotateTargetResult findRotateTarget(
@NonNull AccessibilityNodeInfo sourceNode, int direction, int rotationCount) {
int advancedCount = 0;
AccessibilityNodeInfo currentFocusArea = getAncestorFocusArea(sourceNode);
AccessibilityNodeInfo candidate = copyNode(sourceNode);
AccessibilityNodeInfo target = null;
while (advancedCount < rotationCount) {
AccessibilityNodeInfo nextCandidate = null;
// Virtual View hierarchies like WebViews and ComposeViews do not support focusSearch().
AccessibilityNodeInfo virtualViewAncestor = findVirtualViewAncestor(candidate);
if (virtualViewAncestor != null) {
nextCandidate =
findNextFocusableInVirtualRoot(virtualViewAncestor, candidate, direction);
}
if (nextCandidate == null) {
// If we aren't in a virtual node hierarchy, or there aren't any more focusable
// nodes within the virtual node hierarchy, use focusSearch().
nextCandidate = candidate.focusSearch(direction);
}
AccessibilityNodeInfo candidateFocusArea =
nextCandidate == null ? null : getAncestorFocusArea(nextCandidate);
// Only advance to nextCandidate if:
// 1. it's in the same focus area,
// 2. and it isn't a FocusParkingView (this is to prevent wrap-around when there is only
// one focus area in the window, including when the root node is treated as a focus
// area),
// 3. and nextCandidate is different from candidate (if sourceNode is the first
// focusable node in the window, searching backward will return sourceNode itself).
if (nextCandidate != null && currentFocusArea.equals(candidateFocusArea)
&& !Utils.isFocusParkingView(nextCandidate)
&& !nextCandidate.equals(candidate)) {
// We need to skip nextTargetNode if:
// 1. it can't perform focus action (focusSearch() may return a node with zero
// width and height),
// 2. or it is a scrollable container but it shouldn't be scrolled (i.e., it is not
// scrollable, or its descendants can take focus).
// When we want to focus on its element directly, we'll skip the container. When
// we want to focus on container and scroll it, we won't skip the container.
if (!Utils.canPerformFocus(nextCandidate)
|| (Utils.isScrollableContainer(nextCandidate)
&& !Utils.canScrollableContainerTakeFocus(nextCandidate))) {
Utils.recycleNode(candidate);
Utils.recycleNode(candidateFocusArea);
candidate = nextCandidate;
continue;
}
// 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)
&& (!Utils.isDescendant(scrollableContainer, nextCandidate)
|| Utils.getBoundsInScreen(nextCandidate).isEmpty())) {
Utils.recycleNode(nextCandidate);
Utils.recycleNode(candidateFocusArea);
break;
}
Utils.recycleNode(scrollableContainer);
Utils.recycleNode(candidate);
Utils.recycleNode(candidateFocusArea);
candidate = nextCandidate;
Utils.recycleNode(target);
target = copyNode(candidate);
advancedCount++;
} else {
Utils.recycleNode(nextCandidate);
Utils.recycleNode(candidateFocusArea);
break;
}
}
currentFocusArea.recycle();
candidate.recycle();
if (sourceNode.equals(target)) {
L.e("Wrap-around on the same node");
target.recycle();
return null;
}
return target == null ? null : new FindRotateTargetResult(target, advancedCount);
}
/** Sets a NodeCopier instance for testing. */
@VisibleForTesting
void setNodeCopier(@NonNull NodeCopier nodeCopier) {
mNodeCopier = nodeCopier;
mTreeTraverser.setNodeCopier(nodeCopier);
}
/**
* Returns the root node in the tree containing {@code node}. The caller is responsible for
* recycling the result.
*/
@NonNull
AccessibilityNodeInfo getRoot(@NonNull AccessibilityNodeInfo node) {
// If the node represents a view in the embedded view hierarchy hosted by a SurfaceView,
// return the root node of the hierarchy, which is the only child of the SurfaceView node.
if (isHostNode(node)) {
AccessibilityNodeInfo child = mNodeCopier.copy(node);
AccessibilityNodeInfo parent = node.getParent();
while (parent != null && !Utils.isSurfaceView(parent)) {
child.recycle();
child = parent;
parent = child.getParent();
}
Utils.recycleNode(parent);
return child;
}
// Get the root node directly via the window.
AccessibilityWindowInfo window = node.getWindow();
if (window != null) {
AccessibilityNodeInfo root = window.getRoot();
window.recycle();
if (root != null) {
return root;
}
}
// If the root node can't be accessed via the window, navigate up the node tree.
AccessibilityNodeInfo child = mNodeCopier.copy(node);
AccessibilityNodeInfo parent = node.getParent();
while (parent != null) {
child.recycle();
child = parent;
parent = child.getParent();
}
return child;
}
/**
* Searches {@code root} and its descendants, and returns the currently focused node if it's
* not a {@link FocusParkingView}, or returns null in other cases. The caller is responsible
* for recycling the result.
*/
@Nullable
AccessibilityNodeInfo findFocusedNodeInRoot(@NonNull AccessibilityNodeInfo root) {
AccessibilityNodeInfo focusedNode = findFocusedNodeInRootInternal(root);
if (focusedNode != null && Utils.isFocusParkingView(focusedNode)) {
focusedNode.recycle();
return null;
}
return focusedNode;
}
/**
* Searches {@code root} and its descendants, and returns the currently focused node, if any,
* or returns null if not found. The caller is responsible for recycling the result.
*/
@Nullable
private AccessibilityNodeInfo findFocusedNodeInRootInternal(
@NonNull AccessibilityNodeInfo root) {
AccessibilityNodeInfo surfaceView = null;
if (!isClientNode(root)) {
AccessibilityNodeInfo focusedNode = root.findFocus(FOCUS_INPUT);
if (focusedNode != null && Utils.isSurfaceView(focusedNode)) {
// The focused node represents a SurfaceView. In this case the root node is actually
// a client node but Navigator doesn't know that because SurfaceViewHelper doesn't
// know the package name of the client app.
// Although the package name of the client app will be stored in SurfaceViewHelper
// when RotaryService handles TYPE_WINDOW_STATE_CHANGED event, RotaryService may not
// receive the event. For example, RotaryService may have been killed and restarted.
// In this case, Navigator should store the package name.
surfaceView = focusedNode;
addClientApp(surfaceView.getPackageName());
} else {
return focusedNode;
}
}
// The root node is in client app, which contains a SurfaceView to display the embedded
// view hierarchy. In this case only search inside the embedded view hierarchy.
if (surfaceView == null) {
surfaceView = findSurfaceViewInRoot(root);
}
if (surfaceView == null) {
L.w("Failed to find SurfaceView in client app " + root);
return null;
}
if (surfaceView.getChildCount() == 0) {
L.d("Host app is not loaded yet");
surfaceView.recycle();
return null;
}
AccessibilityNodeInfo embeddedRoot = surfaceView.getChild(0);
surfaceView.recycle();
if (embeddedRoot == null) {
L.w("Failed to get the root of host app");
return null;
}
AccessibilityNodeInfo focusedNode = embeddedRoot.findFocus(FOCUS_INPUT);
embeddedRoot.recycle();
return focusedNode;
}
/**
* Searches the window containing {@code node}, and returns the node representing a {@link
* FocusParkingView}, if any, or returns null if not found. The caller is responsible for
* recycling the result.
*/
@Nullable
AccessibilityNodeInfo findFocusParkingView(@NonNull AccessibilityNodeInfo node) {
AccessibilityNodeInfo root = getRoot(node);
AccessibilityNodeInfo fpv = findFocusParkingViewInRoot(root);
root.recycle();
return fpv;
}
/**
* Searches {@code root} and its descendants, and returns the node representing a {@link
* FocusParkingView}, if any, or returns null if not found. The caller is responsible for
* recycling the result.
*/
@Nullable
AccessibilityNodeInfo findFocusParkingViewInRoot(@NonNull AccessibilityNodeInfo root) {
return mTreeTraverser.depthFirstSearch(
root,
/* skipPredicate= */ Utils::isFocusArea,
/* targetPredicate= */ Utils::isFocusParkingView
);
}
/**
* Searches {@code root} and its descendants, and returns the node representing a {@link
* android.view.SurfaceView}, if any, or returns null if not found. The caller is responsible
* for recycling the result.
*/
@Nullable
AccessibilityNodeInfo findSurfaceViewInRoot(@NonNull AccessibilityNodeInfo root) {
return mTreeTraverser.depthFirstSearch(root, /* targetPredicate= */ Utils::isSurfaceView);
}
/**
* Returns the best target focus area for a nudge in the given {@code direction}. The caller is
* responsible for recycling the result.
*
* @param windows a list of windows to search from
* @param sourceNode the current focus
* @param currentFocusArea the current focus area
* @param direction nudge direction, must be {@link View#FOCUS_UP}, {@link
* View#FOCUS_DOWN}, {@link View#FOCUS_LEFT}, or {@link
* View#FOCUS_RIGHT}
*/
AccessibilityNodeInfo findNudgeTargetFocusArea(
@NonNull List<AccessibilityWindowInfo> windows,
@NonNull AccessibilityNodeInfo sourceNode,
@NonNull AccessibilityNodeInfo currentFocusArea,
int direction) {
AccessibilityWindowInfo currentWindow = sourceNode.getWindow();
if (currentWindow == null) {
L.e("Currently focused window is null");
return null;
}
// Build a list of candidate focus areas, starting with all the other focus areas in the
// same window as the current focus area.
List<AccessibilityNodeInfo> candidateFocusAreas = findNonEmptyFocusAreas(currentWindow);
for (AccessibilityNodeInfo focusArea : candidateFocusAreas) {
if (focusArea.equals(currentFocusArea)) {
candidateFocusAreas.remove(focusArea);
focusArea.recycle();
break;
}
}
List<Rect> candidateFocusAreasBounds = new ArrayList<>();
for (AccessibilityNodeInfo focusArea : candidateFocusAreas) {
Rect bounds = Utils.getBoundsInScreen(focusArea);
candidateFocusAreasBounds.add(bounds);
}
maybeAddImplicitFocusArea(currentWindow, candidateFocusAreas, candidateFocusAreasBounds);
// If the current focus area is an explicit focus area, use its focus area bounds to find
// nudge target as usual. Otherwise, use the tailored bounds, which was added as the last
// element of the list in maybeAddImplicitFocusArea().
Rect currentFocusAreaBounds = Utils.isFocusArea(currentFocusArea)
? Utils.getBoundsInScreen(currentFocusArea)
: candidateFocusAreasBounds.get(candidateFocusAreasBounds.size() - 1);
if (currentWindow.getType() != TYPE_INPUT_METHOD
|| shouldNudgeOutOfIme(sourceNode, currentFocusArea, candidateFocusAreas,
direction)) {
// Add candidate focus areas in other windows in the given direction.
List<AccessibilityWindowInfo> candidateWindows = new ArrayList<>();
boolean isSourceNodeEditable = sourceNode.isEditable();
addWindowsInDirection(windows, currentWindow, candidateWindows, direction,
isSourceNodeEditable);
currentWindow.recycle();
for (AccessibilityWindowInfo window : candidateWindows) {
List<AccessibilityNodeInfo> focusAreasInAnotherWindow =
findNonEmptyFocusAreas(window);
candidateFocusAreas.addAll(focusAreasInAnotherWindow);
for (AccessibilityNodeInfo focusArea : focusAreasInAnotherWindow) {
Rect bounds = Utils.getBoundsInScreen(focusArea);
candidateFocusAreasBounds.add(bounds);
}
maybeAddImplicitFocusArea(window, candidateFocusAreas, candidateFocusAreasBounds);
}
}
Rect sourceBounds = Utils.getBoundsInScreen(sourceNode);
// Choose the best candidate as our target focus area.
AccessibilityNodeInfo targetFocusArea = chooseBestNudgeCandidate(sourceBounds,
currentFocusAreaBounds, candidateFocusAreas, candidateFocusAreasBounds, direction);
Utils.recycleNodes(candidateFocusAreas);
return targetFocusArea;
}
/**
* If there are orphan nodes in {@code window}, treats the root node of the window as an
* implicit focus area, and add it to {@code candidateFocusAreas}. Besides, tailors its bounds
* so that it just wraps its orphan descendants, and adds the tailored bounds to
* {@code candidateFocusAreasBounds}.
* Orphan nodes are focusable nodes not wrapped inside any explicitly declared focus areas.
* It happens in two scenarios:
* <ul>
* <li>The app developer wants to treat the entire window as a focus area but doesn't bother
* declaring a focus area to wrap around them. This is allowed.
* <li>The app developer intends to declare focus areas to wrap around focusable views, but
* misses some focusable views, causing them to be unreachable via rotary controller.
* This is not allowed, but RotaryService will try its best to make them reachable.
* </ul>
*/
@VisibleForTesting
void maybeAddImplicitFocusArea(@NonNull AccessibilityWindowInfo window,
@NonNull List<AccessibilityNodeInfo> candidateFocusAreas,
@NonNull List<Rect> candidateFocusAreasBounds) {
AccessibilityNodeInfo root = window.getRoot();
if (root == null) {
L.e("No root node for " + window);
return;
}
// If the root node is in the client app and therefore contains a SurfaceView, skip the view
// hierarchy of the client app, and scan the view hierarchy of the host app, which is
// embedded in the SurfaceView.
if (isClientNode(root)) {
L.v("Root node is client node " + root);
AccessibilityNodeInfo hostRoot = getDescendantHostRoot(root);
root.recycle();
if (hostRoot == null || !hasFocusableDescendants(hostRoot)) {
L.w("No host node or host node has no focusable descendants " + hostRoot);
Utils.recycleNode(hostRoot);
return;
}
candidateFocusAreas.add(hostRoot);
Rect bounds = new Rect();
// To make things simple, just use the node's bounds. Don't tailor the bounds.
hostRoot.getBoundsInScreen(bounds);
candidateFocusAreasBounds.add(bounds);
return;
}
Rect bounds = computeMinimumBoundsForOrphanDescendants(root);
if (bounds.isEmpty()) {
return;
}
L.w("The root node contains focusable nodes that are not inside any focus "
+ "areas: " + root);
candidateFocusAreas.add(root);
candidateFocusAreasBounds.add(bounds);
}
/**
* Returns whether it should nudge out the IME window. If the current window is IME window and
* there are candidate FocusAreas in it for the given direction, it shouldn't nudge out of the
* IME window.
*/
private boolean shouldNudgeOutOfIme(@NonNull AccessibilityNodeInfo sourceNode,
@NonNull AccessibilityNodeInfo currentFocusArea,
@NonNull List<AccessibilityNodeInfo> focusAreasInCurrentWindow,
int direction) {
if (!focusAreasInCurrentWindow.isEmpty()) {
Rect sourceBounds = Utils.getBoundsInScreen(sourceNode);
Rect sourceFocusAreaBounds = Utils.getBoundsInScreen(currentFocusArea);
Rect candidateBounds = Utils.getBoundsInScreen(currentFocusArea);
for (AccessibilityNodeInfo candidate : focusAreasInCurrentWindow) {
if (isCandidate(sourceBounds, sourceFocusAreaBounds, candidate, candidateBounds,
direction)) {
return false;
}
}
}
return true;
}
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 if the {@code source} window is not an overlay. If it's an overlay
* and the source node is editable, adds the IME window only. Otherwise does nothing.
*/
private void addWindowsInDirection(@NonNull List<AccessibilityWindowInfo> windows,
@NonNull AccessibilityWindowInfo source,
@NonNull List<AccessibilityWindowInfo> results,
int direction,
boolean isSourceNodeEditable) {
Rect sourceBounds = new Rect();
source.getBoundsInScreen(sourceBounds);
boolean isSourceWindowOverlayWindow = isOverlayWindow(source, sourceBounds);
Rect destBounds = new Rect();
for (AccessibilityWindowInfo window : windows) {
if (window.equals(source)) {
continue;
}
// Nudging out of the overlay window is not allowed unless the source node is editable
// and the target window is an IME window. E.g., nudging from the EditText in the Dialog
// to the IME is allowed, while nudging from the Button in the Dialog to the IME is not
// allowed.
if (isSourceWindowOverlayWindow
&& (!isSourceNodeEditable || window.getType() != TYPE_INPUT_METHOD)) {
continue;
}
window.getBoundsInScreen(destBounds);
// Even if only part of destBounds is in the given direction of sourceBounds, we
// still include it because that part may contain the target focus area.
if (FocusFinder.isPartiallyInDirection(sourceBounds, destBounds, direction)) {
results.add(window);
}
}
}
/**
* Returns whether the given {@code window} with the given {@code bounds} is an overlay window.
* <p>
* If the source window is an application window on the default display and it's smaller than
the display, then it's either a TaskView window or an overlay window (such as a Dialog
window). The ID of a TaskView task is different from the full screen application, while
the ID of an overlay task is the same with the full screen application, so task ID is used
to decide whether it's an overlay window.
*/
private boolean isOverlayWindow(@NonNull AccessibilityWindowInfo window, @NonNull Rect bounds) {
return window.getType() == TYPE_APPLICATION
&& window.getDisplayId() == Display.DEFAULT_DISPLAY
&& !mAppWindowBounds.equals(bounds)
&& window.getTaskId() == mAppWindowTaskId;
}
/**
* Returns whether nudging to the given {@code direction} can dismiss the given {@code window}
* with the given {@code bounds}.
*/
boolean isDismissible(@NonNull AccessibilityWindowInfo window,
@NonNull Rect bounds,
@View.FocusRealDirection int direction) {
// Only overlay windows can be dismissed.
if (!isOverlayWindow(window, bounds)) {
return false;
}
// The window can be dismissed when part of the underlying window is not covered by it in
// the given direction.
switch (direction) {
case FOCUS_UP:
return mAppWindowBounds.top < bounds.top;
case FOCUS_DOWN:
return mAppWindowBounds.bottom > bounds.bottom;
case FOCUS_LEFT:
return mAppWindowBounds.left < bounds.left;
case FOCUS_RIGHT:
return mAppWindowBounds.right > bounds.right;
}
return false;
}
/**
* Scans the view hierarchy of the given {@code window} looking for explicit focus areas with
* focusable descendants and returns the focus areas. The caller is responsible for recycling
* the result.
*/
@NonNull
@VisibleForTesting
List<AccessibilityNodeInfo> findNonEmptyFocusAreas(@NonNull AccessibilityWindowInfo window) {
List<AccessibilityNodeInfo> results = new ArrayList<>();
AccessibilityNodeInfo rootNode = window.getRoot();
if (rootNode == null) {
L.e("No root node for " + window);
} else if (!isClientNode(rootNode)) {
addNonEmptyFocusAreas(rootNode, results);
}
// If the root node is in the client app, it won't contain any explicit focus areas, so
// skip it.
Utils.recycleNode(rootNode);
return results;
}
/**
* Searches from {@code clientNode}, and returns the root of the embedded view hierarchy if any,
* or returns null if not found. The caller is responsible for recycling the result.
*/
@Nullable
AccessibilityNodeInfo getDescendantHostRoot(@NonNull AccessibilityNodeInfo clientNode) {
return mTreeTraverser.depthFirstSearch(clientNode, this::isHostNode);
}
/**
* Returns whether the given window is the Heads-up Notification (HUN) window. The HUN window
* is identified by the left and right edges. The top and bottom vary depending on whether the
* HUN appears at the top or bottom of the screen and on the height of the notification being
* displayed so they aren't used.
*/
boolean isHunWindow(@Nullable AccessibilityWindowInfo window) {
if (window == null || window.getType() != AccessibilityWindowInfo.TYPE_SYSTEM) {
return false;
}
Rect bounds = new Rect();
window.getBoundsInScreen(bounds);
return bounds.left == mHunLeft && bounds.right == mHunRight;
}
/**
* Searches from the given node up through its ancestors to the containing focus area, looking
* for a node that's marked as horizontally or vertically scrollable. Returns a copy of the
* first such node or null if none is found. The caller is responsible for recycling the result.
*/
@Nullable
AccessibilityNodeInfo findScrollableContainer(@NonNull AccessibilityNodeInfo node) {
return mTreeTraverser.findNodeOrAncestor(node, /* stopPredicate= */ Utils::isFocusArea,
/* targetPredicate= */ Utils::isScrollableContainer);
}
/**
* Returns the previous node before {@code referenceNode} in Tab order that can take focus or
* the next node after {@code referenceNode} in Tab order that can take focus, depending on
* {@code direction}. The search is limited to descendants of {@code containerNode}. Returns
* null if there are no descendants that can take focus in the given direction. The caller is
* responsible for recycling the result.
*
* @param containerNode the node with descendants
* @param referenceNode a descendant of {@code containerNode} to start from
* @param direction {@link View#FOCUS_FORWARD} or {@link View#FOCUS_BACKWARD}
* @return the node before or after {@code referenceNode} or null if none
*/
@Nullable
AccessibilityNodeInfo findFocusableDescendantInDirection(
@NonNull AccessibilityNodeInfo containerNode,
@NonNull AccessibilityNodeInfo referenceNode,
int direction) {
AccessibilityNodeInfo targetNode = copyNode(referenceNode);
do {
AccessibilityNodeInfo nextTargetNode = targetNode.focusSearch(direction);
if (nextTargetNode == null
|| nextTargetNode.equals(containerNode)
|| !Utils.isDescendant(containerNode, nextTargetNode)) {
Utils.recycleNode(nextTargetNode);
Utils.recycleNode(targetNode);
return null;
}
if (nextTargetNode.equals(referenceNode) || nextTargetNode.equals(targetNode)) {
L.w((direction == View.FOCUS_FORWARD ? "Next" : "Previous")
+ " node is the same node: " + referenceNode);
Utils.recycleNode(nextTargetNode);
Utils.recycleNode(targetNode);
return null;
}
targetNode.recycle();
targetNode = nextTargetNode;
} while (!Utils.canTakeFocus(targetNode));
return targetNode;
}
/**
* Returns the first descendant of {@code node} which can take focus. The nodes are searched in
* in depth-first order, not including {@code node} itself. If no descendant can take focus,
* null is returned. The caller is responsible for recycling the result.
*/
@Nullable
AccessibilityNodeInfo findFirstFocusableDescendant(@NonNull AccessibilityNodeInfo node) {
return mTreeTraverser.depthFirstSearch(node,
candidateNode -> candidateNode != node && Utils.canTakeFocus(candidateNode));
}
/**
* Returns the first orphan descendant (focusable descendant not inside any focus areas) of
* {@code node}. The nodes are searched in depth-first order, not including {@code node} itself.
* If not found, null is returned. The caller is responsible for recycling the result.
*/
@Nullable
AccessibilityNodeInfo findFirstOrphan(@NonNull AccessibilityNodeInfo node) {
return mTreeTraverser.depthFirstSearch(node,
/* skipPredicate= */ Utils::isFocusArea,
/* targetPredicate= */ candidateNode -> candidateNode != node
&& Utils.canTakeFocus(candidateNode));
}
/**
* Returns the last descendant of {@code node} which can take focus. The nodes are searched in
* reverse depth-first order, not including {@code node} itself. If no descendant can take
* focus, null is returned. The caller is responsible for recycling the result.
*/
@Nullable
AccessibilityNodeInfo findLastFocusableDescendant(@NonNull AccessibilityNodeInfo node) {
return mTreeTraverser.reverseDepthFirstSearch(node,
candidateNode -> candidateNode != node && Utils.canTakeFocus(candidateNode));
}
/**
* Scans descendants of the given {@code rootNode} looking for explicit focus areas with
* focusable descendants and adds the focus areas to the given list. It doesn't scan inside
* focus areas since nested focus areas aren't allowed. It ignores focus areas without
* focusable descendants, because once we found the best candidate focus area, we don't dig
* into other ones. If it has no descendants to take focus, the nudge will fail. The caller is
* responsible for recycling added nodes.
*
* @param rootNode the root to start scanning from
* @param results a list of focus areas to add to
*/
private void addNonEmptyFocusAreas(@NonNull AccessibilityNodeInfo rootNode,
@NonNull List<AccessibilityNodeInfo> results) {
mTreeTraverser.depthFirstSelect(rootNode,
(focusArea) -> Utils.isFocusArea(focusArea) && hasFocusableDescendants(focusArea),
results);
}
private boolean hasFocusableDescendants(@NonNull AccessibilityNodeInfo focusArea) {
return Utils.canHaveFocus(focusArea) || containsWebViewWithFocusableDescendants(focusArea);
}
/**
* Returns the minimum rectangle wrapping the given {@code node}'s orphan descendants. If
* {@code node} has no orphan descendants, returns an empty {@link Rect}.
*/
@NonNull
@VisibleForTesting
Rect computeMinimumBoundsForOrphanDescendants(
@NonNull AccessibilityNodeInfo node) {
Rect bounds = new Rect();
if (Utils.isFocusArea(node) || Utils.isFocusParkingView(node)) {
return bounds;
}
if (Utils.canTakeFocus(node) || containsWebViewWithFocusableDescendants(node)) {
return Utils.getBoundsInScreen(node);
}
for (int i = 0; i < node.getChildCount(); i++) {
AccessibilityNodeInfo child = node.getChild(i);
if (child == null) {
continue;
}
Rect childBounds = computeMinimumBoundsForOrphanDescendants(child);
child.recycle();
if (childBounds != null) {
bounds.union(childBounds);
}
}
return bounds;
}
/**
* Returns a copy of the best candidate from among the given {@code candidates} for a nudge
* from {@code sourceNode} in the given {@code direction}. Returns null if none of the {@code
* candidates} are in the given {@code direction}. The caller is responsible for recycling the
* result.
*
* @param candidates could be a list of {@link FocusArea}s, or a list of focusable views
*/
@Nullable
private AccessibilityNodeInfo chooseBestNudgeCandidate(@NonNull Rect sourceBounds,
@NonNull Rect sourceFocusAreaBounds,
@NonNull List<AccessibilityNodeInfo> candidates,
@NonNull List<Rect> candidatesBounds,
int direction) {
if (candidates.isEmpty()) {
return null;
}
AccessibilityNodeInfo bestNode = null;
Rect bestBounds = new Rect();
for (int i = 0; i < candidates.size(); i++) {
AccessibilityNodeInfo candidate = candidates.get(i);
Rect candidateBounds = candidatesBounds.get(i);
if (isCandidate(sourceBounds, sourceFocusAreaBounds, candidate, candidateBounds,
direction)) {
if (bestNode == null || FocusFinder.isBetterCandidate(
direction, sourceBounds, candidateBounds, bestBounds)) {
bestNode = candidate;
bestBounds.set(candidateBounds);
}
}
}
return copyNode(bestNode);
}
/**
* Returns whether the given {@code node} is a candidate from {@code sourceBounds} to the given
* {@code direction}.
* <p>
* To be a candidate, the node
* <ul>
* <li>must be considered a candidate by {@link FocusFinder#isCandidate} if it represents a
* focusable view within a focus area
* <li>must be in the {@code direction} of the {@code sourceFocusAreaBounds} and one of its
* focusable descendants must be a candidate if it represents a focus area
* </ul>
*/
private boolean isCandidate(@NonNull Rect sourceBounds,
@NonNull Rect sourceFocusAreaBounds,
@NonNull AccessibilityNodeInfo node,
@NonNull Rect nodeBounds,
int direction) {
AccessibilityNodeInfo candidate = mTreeTraverser.depthFirstSearch(node,
/* skipPredicate= */ candidateNode -> {
if (Utils.canTakeFocus(candidateNode)) {
return false;
}
// If a node can't take focus, it represents a focus area. If the focus area
// doesn't intersect with sourceFocusAreaBounds, and it's not in the given
// direction of sourceFocusAreaBounds, it's not a candidate, so we should return
// true to stop searching.
return !Rect.intersects(nodeBounds, sourceFocusAreaBounds)
&& !FocusFinder.isInDirection(
sourceFocusAreaBounds, nodeBounds, direction);
},
/* targetPredicate= */ candidateNode -> {
// RotaryService can navigate to nodes in a WebView or a ComposeView even when
// off-screen, so we use canPerformFocus() to skip the bounds check.
if (isInVirtualNodeHierarchy(candidateNode)) {
return Utils.canPerformFocus(candidateNode);
}
// If a node isn't visible to the user, e.g. another window is obscuring it,
// skip it.
if (!candidateNode.isVisibleToUser()) {
return false;
}
// 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)) {
return false;
}
// The node represents a focusable view in a focus area, so check the geometry.
return FocusFinder.isCandidate(sourceBounds, nodeBounds, direction);
});
if (candidate == null) {
return false;
}
candidate.recycle();
return true;
}
private AccessibilityNodeInfo copyNode(@Nullable AccessibilityNodeInfo node) {
return mNodeCopier.copy(node);
}
/**
* Returns the closest ancestor focus area of the given {@code node}.
* <ul>
* <li> If the given {@code node} is a {@link FocusArea} node or a descendant of a {@link
* FocusArea} node, returns the {@link FocusArea} node.
* <li> If there are no explicitly declared {@link FocusArea}s among the ancestors of this
* view, and this view is not in an embedded view hierarchy, returns the root node.
* <li> If there are no explicitly declared {@link FocusArea}s among the ancestors of this
* view, and this view is in an embedded view hierarchy, returns the root node of
* embedded view hierarchy.
* </ul>
* The caller is responsible for recycling the result.
*/
@NonNull
AccessibilityNodeInfo getAncestorFocusArea(@NonNull AccessibilityNodeInfo node) {
Predicate<AccessibilityNodeInfo> isFocusAreaOrRoot = candidateNode -> {
if (Utils.isFocusArea(candidateNode)) {
// The candidateNode is a focus area.
return true;
}
AccessibilityNodeInfo parent = candidateNode.getParent();
if (parent == null) {
// The candidateNode is the root node.
return true;
}
if (Utils.isSurfaceView(parent)) {
// Treat the root of embedded view hierarchy (i.e., the only child of the
// SurfaceView) as an implicit focus area.
return true;
}
parent.recycle();
return false;
};
AccessibilityNodeInfo result = mTreeTraverser.findNodeOrAncestor(node, isFocusAreaOrRoot);
if (result == null || !Utils.isFocusArea(result)) {
L.w("Couldn't find ancestor focus area for given node: " + node);
}
return result;
}
/**
* 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 a copy of {@code node} or the nearest ancestor that represents a {@code ComposeView}
* or a {@code WebView}. Returns null if {@code node} isn't a {@code ComposeView} or a
* {@code WebView} and is not a descendant of a {@code ComposeView} or a {@code WebView}.
*
* TODO(b/192274274): This method may not be necessary anymore if Compose supports focusSearch.
*/
@Nullable
private AccessibilityNodeInfo findVirtualViewAncestor(@NonNull AccessibilityNodeInfo node) {
return mTreeTraverser.findNodeOrAncestor(node, /* targetPredicate= */ (nodeInfo) ->
Utils.isComposeView(nodeInfo) || Utils.isWebView(nodeInfo));
}
/** 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 whether {@code node} is a {@code ComposeView}, is a {@code WebView}, or is a
* descendant of either.
*/
boolean isInVirtualNodeHierarchy(@NonNull AccessibilityNodeInfo node) {
AccessibilityNodeInfo virtualViewAncestor = findVirtualViewAncestor(node);
if (virtualViewAncestor == null) {
return false;
}
virtualViewAncestor.recycle();
return true;
}
/**
* Returns the next focusable node after {@code candidate} in {@code direction} in {@code
* root} or null if none. This handles navigating into a WebView as well as within a WebView.
* This also handles navigating into a ComposeView, as well as within a ComposeView.
*/
@Nullable
private AccessibilityNodeInfo findNextFocusableInVirtualRoot(
@NonNull AccessibilityNodeInfo root,
@NonNull AccessibilityNodeInfo candidate, int direction) {
// focusSearch() doesn't work in WebViews or ComposeViews so use tree traversal instead.
if (Utils.isWebView(candidate) || Utils.isComposeView(candidate)) {
if (direction == View.FOCUS_FORWARD) {
// When entering into the root of a virtual node hierarchy, find the first focusable
// child node of the root if any.
return findFirstFocusableDescendantInVirtualRoot(candidate);
} else {
// When backing into the root of a virtual node hierarchy, find the last focusable
// child node of the root if any.
return findLastFocusableDescendantInVirtualRoot(candidate);
}
} else {
// When navigating within a virtual view hierarchy, find the next or previous focusable
// node in depth-first order.
if (direction == View.FOCUS_FORWARD) {
return findFirstFocusDescendantInVirtualRootAfter(root, candidate);
} else {
return findFirstFocusDescendantInVirtualRootBefore(root, 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 root} itself. If no descendant can perform focus, null is returned. The caller is
* responsible for recycling the result.
*/
@Nullable
private AccessibilityNodeInfo findFirstFocusableDescendantInVirtualRoot(
@NonNull AccessibilityNodeInfo root) {
return mTreeTraverser.depthFirstSearch(root,
candidateNode -> candidateNode != root && Utils.canPerformFocus(candidateNode));
}
/**
* Returns the last descendant of {@code root} which can perform focus. This includes off-
* screen descendants. The nodes are searched in reverse depth-first order, not including
* {@code root} itself. If no descendant can perform focus, null is returned. The caller is
* responsible for recycling the result.
*/
@Nullable
private AccessibilityNodeInfo findLastFocusableDescendantInVirtualRoot(
@NonNull AccessibilityNodeInfo root) {
return mTreeTraverser.reverseDepthFirstSearch(root,
candidateNode -> candidateNode != root && Utils.canPerformFocus(candidateNode));
}
@Nullable
private AccessibilityNodeInfo findFirstFocusDescendantInVirtualRootBefore(
@NonNull AccessibilityNodeInfo root, @NonNull AccessibilityNodeInfo beforeNode) {
boolean[] foundBeforeNode = new boolean[1];
return mTreeTraverser.reverseDepthFirstSearch(root,
node -> {
if (foundBeforeNode[0] && Utils.canPerformFocus(node)) {
return true;
}
if (node.equals(beforeNode)) {
foundBeforeNode[0] = true;
}
return false;
});
}
@Nullable
private AccessibilityNodeInfo findFirstFocusDescendantInVirtualRootAfter(
@NonNull AccessibilityNodeInfo root, @NonNull AccessibilityNodeInfo afterNode) {
boolean[] foundAfterNode = new boolean[1];
return mTreeTraverser.depthFirstSearch(root,
node -> {
if (foundAfterNode[0] && Utils.canPerformFocus(node)) {
return true;
}
if (node.equals(afterNode)) {
foundAfterNode[0] = true;
}
return false;
});
}
void dump(@NonNull DualDumpOutputStream dumpOutputStream, boolean dumpAsProto,
@NonNull String fieldName, long fieldId) {
long fieldToken = dumpOutputStream.start(fieldName, fieldId);
dumpOutputStream.write("hunLeft", RotaryProtos.Navigator.HUN_LEFT, mHunLeft);
dumpOutputStream.write("hunRight", RotaryProtos.Navigator.HUN_RIGHT, mHunRight);
DumpUtils.writeFocusDirection(dumpOutputStream, dumpAsProto, "hunNudgeDirection",
RotaryProtos.Navigator.HUN_NUDGE_DIRECTION, mHunNudgeDirection);
DumpUtils.writeRect(dumpOutputStream, mAppWindowBounds, "appWindowBounds",
RotaryProtos.Navigator.APP_WINDOW_BOUNDS);
mSurfaceViewHelper.dump(dumpOutputStream, dumpAsProto, "surfaceViewHelper",
RotaryProtos.Navigator.SURFACE_VIEW_HELPER);
dumpOutputStream.end(fieldToken);
}
static String directionToString(@View.FocusRealDirection int direction) {
switch (direction) {
case FOCUS_UP:
return "FOCUS_UP";
case FOCUS_DOWN:
return "FOCUS_DOWN";
case FOCUS_LEFT:
return "FOCUS_LEFT";
case FOCUS_RIGHT:
return "FOCUS_RIGHT";
default:
return "<unknown direction " + direction + ">";
}
}
/** Result from {@link #findRotateTarget}. */
static class FindRotateTargetResult {
@NonNull final AccessibilityNodeInfo node;
final int advancedCount;
FindRotateTargetResult(@NonNull AccessibilityNodeInfo node, int advancedCount) {
this.node = node;
this.advancedCount = advancedCount;
}
}
}