/* * 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.view.accessibility.AccessibilityNodeInfo.AccessibilityAction.ACTION_SCROLL_BACKWARD; import static android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction.ACTION_SCROLL_FORWARD; import android.graphics.Rect; import android.os.SystemClock; 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 java.util.ArrayList; import java.util.Iterator; import java.util.List; /** * 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(); private final RotaryCache mRotaryCache; private final int mHunLeft; private final int mHunRight; @View.FocusRealDirection private int mHunNudgeDirection; Navigator(@RotaryCache.CacheType int focusHistoryCacheType, int focusHistoryCacheSize, int focusHistoryExpirationTimeMs, @RotaryCache.CacheType int focusAreaHistoryCacheType, int focusAreaHistoryCacheSize, int focusAreaHistoryExpirationTimeMs, @RotaryCache.CacheType int focusWindowCacheType, int focusWindowCacheSize, int focusWindowExpirationTimeMs, int hunLeft, int hunRight, boolean showHunOnBottom) { mRotaryCache = new RotaryCache(focusHistoryCacheType, focusHistoryCacheSize, focusHistoryExpirationTimeMs, focusAreaHistoryCacheType, focusAreaHistoryCacheSize, focusAreaHistoryExpirationTimeMs, focusWindowCacheType, focusWindowCacheSize, focusWindowExpirationTimeMs); mHunLeft = hunLeft; mHunRight = hunRight; mHunNudgeDirection = showHunOnBottom ? View.FOCUS_DOWN : View.FOCUS_UP; } /** Clears focus area history cache. */ void clearFocusAreaHistory() { mRotaryCache.clearFocusAreaHistory(); } /** Caches the focused node by focus area and by window. */ void saveFocusedNode(@NonNull AccessibilityNodeInfo focusedNode) { long elapsedRealtime = SystemClock.elapsedRealtime(); AccessibilityNodeInfo focusArea = getAncestorFocusArea(focusedNode); mRotaryCache.saveFocusedNode(focusArea, focusedNode, elapsedRealtime); mRotaryCache.saveWindowFocus(focusedNode, elapsedRealtime); Utils.recycleNode(focusArea); } /** * Returns the most recently focused valid node or {@code null} if there are no valid nodes * saved by {@link #saveFocusedNode}. The caller is responsible for recycling the result. */ AccessibilityNodeInfo getMostRecentFocus() { return mRotaryCache.getMostRecentFocus(SystemClock.elapsedRealtime()); } /** * Returns the target focusable for a nudge: *
If {@code skipNode} isn't null, this node will be skipped. This is used when the focus is * inside a scrollable container to avoid moving the focus to the scrollable container itself. * *
Limits navigation to focusable views within a scrollable container's viewport, if any.
*
* @param sourceNode the current focus
* @param skipNode a node to skip - optional
* @param direction rotate direction, must be {@link View#FOCUS_FORWARD} or {@link
* View#FOCUS_BACKWARD}
* @param rotationCount the number of "ticks" to rotate. Only count nodes that can take focus
* (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,
@Nullable AccessibilityNodeInfo skipNode, int direction, int rotationCount) {
int advancedCount = 0;
AccessibilityNodeInfo currentFocusArea = getAncestorFocusArea(sourceNode);
AccessibilityNodeInfo targetNode = copyNode(sourceNode);
for (int i = 0; i < rotationCount; i++) {
AccessibilityNodeInfo nextTargetNode = targetNode.focusSearch(direction);
if (skipNode != null && skipNode.equals(nextTargetNode)) {
Utils.recycleNode(nextTargetNode);
nextTargetNode = skipNode.focusSearch(direction);
}
AccessibilityNodeInfo targetFocusArea =
nextTargetNode == null ? null : getAncestorFocusArea(nextTargetNode);
// Only advance to nextTargetNode if it's in the same focus area and it isn't a
// FocusParkingView. The second condition prevents wrap-around when there is only one
// focus area in the window, including when the root node is treated as a focus area.
if (nextTargetNode != null && currentFocusArea.equals(targetFocusArea)
&& !Utils.isFocusParkingView(nextTargetNode)) {
// If we're navigating through a scrolling view that can scroll in the specified
// direction and the next view is off-screen, don't advance to it. (We'll scroll
// instead.)
Rect nextTargetBounds = new Rect();
nextTargetNode.getBoundsInScreen(nextTargetBounds);
AccessibilityNodeInfo scrollableContainer = findScrollableContainer(targetNode);
AccessibilityNodeInfo.AccessibilityAction scrollAction =
direction == View.FOCUS_FORWARD
? ACTION_SCROLL_FORWARD
: ACTION_SCROLL_BACKWARD;
if (scrollableContainer != null
&& scrollableContainer.getActionList().contains(scrollAction)) {
Rect scrollBounds = new Rect();
scrollableContainer.getBoundsInScreen(scrollBounds);
boolean intersects = nextTargetBounds.intersect(scrollBounds);
if (!intersects) {
Utils.recycleNode(nextTargetNode);
Utils.recycleNode(targetFocusArea);
break;
}
}
Utils.recycleNode(scrollableContainer);
Utils.recycleNode(targetNode);
Utils.recycleNode(targetFocusArea);
targetNode = nextTargetNode;
advancedCount++;
} else {
Utils.recycleNode(nextTargetNode);
Utils.recycleNode(targetFocusArea);
break;
}
}
Utils.recycleNode(currentFocusArea);
if (sourceNode.equals(targetNode)) {
targetNode.recycle();
return null;
}
return new FindRotateTargetResult(targetNode, advancedCount);
}
/**
* Searches the {@code rootNode} and its descendants in depth-first order, and returns the first
* focus descendant (a node inside a focus area that can take focus) if any, or returns null if
* not found. The caller is responsible for recycling the result.
*/
AccessibilityNodeInfo findFirstFocusDescendant(@NonNull AccessibilityNodeInfo rootNode) {
// First try finding the first focus area and searching forward from the focus area. This
// is a quick way to find the first node but it doesn't always work.
AccessibilityNodeInfo focusDescendant = findFirstFocus(rootNode);
if (focusDescendant != null) {
return focusDescendant;
}
// Fall back to tree traversal.
L.w("Falling back to tree traversal");
focusDescendant = findDepthFirstFocus(rootNode);
if (focusDescendant == null) {
L.w("No node can take focus in the current window");
}
return focusDescendant;
}
/** Sets a mock Utils instance for testing. */
@VisibleForTesting
void setNodeCopier(@NonNull NodeCopier nodeCopier) {
mNodeCopier = nodeCopier;
mTreeTraverser.setNodeCopier(nodeCopier);
mRotaryCache.setNodeCopier(nodeCopier);
}
/**
* Searches all the nodes in the {@code window}, and returns the node representing a {@link
* FocusParkingView}, if any, or returns null if not found. The caller is responsible for
* recycling the result.
*/
AccessibilityNodeInfo findFocusParkingView(@NonNull AccessibilityWindowInfo window) {
AccessibilityNodeInfo root = window.getRoot();
if (root == null) {
L.e("No root node in " + window);
return null;
}
AccessibilityNodeInfo focusParkingView = findFocusParkingView(root);
root.recycle();
return focusParkingView;
}
/**
* Searches the {@code node} and its descendants in depth-first order, and returns the node
* representing a {@link FocusParkingView}, if any, or returns null if not found. The caller is
* responsible for recycling the result.
*/
private AccessibilityNodeInfo findFocusParkingView(@NonNull AccessibilityNodeInfo node) {
return mTreeTraverser.depthFirstSearch(node, /* skipPredicate= */ Utils::isFocusArea,
/* targetPredicate= */ Utils::isFocusParkingView);
}
/**
* Searches the {@code rootNode} and its descendants in depth-first order for the first focus
* area, and returns the first node that can take focus in tab order from the focus area.
* The return value could be a node inside or outside the first focus area, or null if not
* found. The caller is responsible for recycling result.
*/
private AccessibilityNodeInfo findFirstFocus(@NonNull AccessibilityNodeInfo rootNode) {
AccessibilityNodeInfo focusArea = findFirstFocusArea(rootNode);
if (focusArea == null) {
L.w("No FocusArea in the tree");
// rootNode is an implicit focus area if no explicit FocusAreas are specified.
focusArea = copyNode(rootNode);
}
AccessibilityNodeInfo targetNode = focusArea.focusSearch(View.FOCUS_FORWARD);
AccessibilityNodeInfo firstTarget = copyNode(targetNode);
// focusSearch() searches in the active window, which has at least one FocusParkingView. We
// need to skip it.
while (targetNode != null && Utils.isFocusParkingView(targetNode)) {
L.d("Found FocusParkingView, continue focusSearch() ...");
AccessibilityNodeInfo nextTargetNode = targetNode.focusSearch(View.FOCUS_FORWARD);
targetNode.recycle();
targetNode = nextTargetNode;
// If we found the same FocusParkingView again, it means all the focusable views in
// current window are FocusParkingViews, so we should just return null.
if (firstTarget.equals(targetNode)) {
L.w("Stop focusSearch() because there is no view to take focus except "
+ "FocusParkingViews");
Utils.recycleNode(targetNode);
targetNode = null;
break;
}
}
Utils.recycleNode(firstTarget);
focusArea.recycle();
return targetNode;
}
/**
* Searches the given {@code node} and its descendants in depth-first order, and returns the
* first {@link FocusArea}, or returns null if not found. The caller is responsible for
* recycling the result.
*/
private AccessibilityNodeInfo findFirstFocusArea(@NonNull AccessibilityNodeInfo node) {
return mTreeTraverser.depthFirstSearch(node, Utils::isFocusArea);
}
/**
* Searches the given {@code node} and its descendants in depth-first order, and returns the
* first node that can take focus, or returns null if not found. The caller is responsible for
* recycling result.
*/
private AccessibilityNodeInfo findDepthFirstFocus(@NonNull AccessibilityNodeInfo node) {
return mTreeTraverser.depthFirstSearch(node, Utils::canTakeFocus);
}
/**
* Returns the target focus area for a nudge in the given {@code direction} from the current
* focus, or null if not found. Checks the cache first. If nothing is found in the cache,
* returns the best nudge target from among all the candidate focus areas. In all cases, the
* nudge back is saved in the cache. The caller is responsible for recycling the result.
*/
private AccessibilityNodeInfo findNudgeTargetFocusArea(
@NonNull List