blob: f348f9941bfd65e2141989c05feafc5fb0eeff4a [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 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 java.util.ArrayList;
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 static Utils sUtils = Utils.getInstance();
private final RotaryCache mRotaryCache;
Navigator(@RotaryCache.CacheType int focusHistoryCacheType,
int focusHistoryCacheSize,
int focusHistoryExpirationTimeMs,
@RotaryCache.CacheType int focusAreaHistoryCacheType,
int focusAreaHistoryCacheSize,
int focusAreaHistoryExpirationTimeMs) {
mRotaryCache = new RotaryCache(focusHistoryCacheType,
focusHistoryCacheSize,
focusHistoryExpirationTimeMs,
focusAreaHistoryCacheType,
focusAreaHistoryCacheSize,
focusAreaHistoryExpirationTimeMs);
}
/** Clears focus area history cache. */
void clearFocusAreaHistory() {
mRotaryCache.clearFocusAreaHistory();
}
/** Caches the focused node by focus area. */
void saveFocusedNode(@NonNull AccessibilityNodeInfo focusedNode) {
AccessibilityNodeInfo focusArea = getAncestorFocusArea(focusedNode);
mRotaryCache.saveFocusedNode(focusArea, focusedNode, SystemClock.elapsedRealtime());
Utils.recycleNode(focusArea);
}
/**
* Returns the target focusable for a nudge. The caller is responsible for recycling the result.
*
* @param windows a list of windows to search from
* @param sourceNode the current focus
* @param direction nudge direction, must be {@link View#FOCUS_UP}, {@link View#FOCUS_DOWN},
* {@link View#FOCUS_LEFT}, or {@link View#FOCUS_RIGHT}
* @return a view that can take focus (visible, focusable and enabled) within another {@link
* FocusArea}, which is in the given {@code direction} from the current {@link
* FocusArea}, or null if not found
*/
AccessibilityNodeInfo findNudgeTarget(@NonNull List<AccessibilityWindowInfo> windows,
@NonNull AccessibilityNodeInfo sourceNode, int direction) {
AccessibilityNodeInfo currentFocusArea = getAncestorFocusArea(sourceNode);
AccessibilityNodeInfo targetFocusArea =
findNudgeTargetFocusArea(windows, sourceNode, currentFocusArea, direction);
Utils.recycleNode(currentFocusArea);
if (targetFocusArea == null) {
return null;
}
// Return the recently focused node within the target focus area, if any.
AccessibilityNodeInfo cachedFocusedNode =
mRotaryCache.getFocusedNode(targetFocusArea, SystemClock.elapsedRealtime());
if (cachedFocusedNode != null) {
Utils.recycleNode(targetFocusArea);
return cachedFocusedNode;
}
// Make a list of candidate nodes in the target FocusArea.
List<AccessibilityNodeInfo> candidateNodes = new ArrayList<>();
addFocusDescendants(targetFocusArea, candidateNodes);
// Choose the best candidate as the target node.
AccessibilityNodeInfo bestCandidate =
chooseBestNudgeCandidate(sourceNode, candidateNodes, direction);
Utils.recycleNodes(candidateNodes);
Utils.recycleNode(targetFocusArea);
return bestCandidate;
}
/**
* Returns the target focusable for a rotate. The caller is responsible for recycling the
* result.
*
* @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).
* @return 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.
*/
static AccessibilityNodeInfo findRotateTarget(@NonNull AccessibilityNodeInfo sourceNode,
int direction, int rotationCount) {
AccessibilityNodeInfo currentFocusArea = getAncestorFocusArea(sourceNode);
AccessibilityNodeInfo targetNode = copyNode(sourceNode);
for (int i = 0; i < rotationCount; i++) {
AccessibilityNodeInfo nextTargetNode = targetNode.focusSearch(direction);
AccessibilityNodeInfo targetFocusArea =
nextTargetNode == null ? null : getAncestorFocusArea(nextTargetNode);
if (nextTargetNode != null && !isFocusArea(nextTargetNode)
// TODO(b/151458195): remove !isFocusArea(nextTargetNode) once FocusArea is
// not focusable.
&& currentFocusArea.equals(targetFocusArea)) {
Utils.recycleNode(targetNode);
Utils.recycleNode(targetFocusArea);
targetNode = nextTargetNode;
} else {
Utils.recycleNode(nextTargetNode);
Utils.recycleNode(targetFocusArea);
break;
}
}
Utils.recycleNode(currentFocusArea);
if (sourceNode.equals(targetNode)) {
targetNode.recycle();
return null;
}
return targetNode;
}
/**
* 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.
*/
static 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
static void setUtils(@NonNull Utils utils) {
sUtils = utils;
RotaryCache.setUtils(utils);
}
/**
* 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 static AccessibilityNodeInfo findFirstFocus(@NonNull AccessibilityNodeInfo rootNode) {
AccessibilityNodeInfo focusArea = findFirstFocusArea(rootNode);
if (focusArea == null) {
L.e("No FocusArea in the tree");
return null;
}
AccessibilityNodeInfo targetNode = focusArea.focusSearch(View.FOCUS_FORWARD);
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 static AccessibilityNodeInfo findFirstFocusArea(@NonNull AccessibilityNodeInfo node) {
if (isFocusArea(node)) {
return copyNode(node);
}
for (int i = 0; i < node.getChildCount(); i++) {
AccessibilityNodeInfo childNode = node.getChild(i);
if (childNode != null) {
AccessibilityNodeInfo focusArea = findFirstFocusArea(childNode);
childNode.recycle();
if (focusArea != null) {
return focusArea;
}
}
}
return null;
}
/**
* 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 static AccessibilityNodeInfo findDepthFirstFocus(@NonNull AccessibilityNodeInfo node) {
if (canTakeFocus(node)) {
return copyNode(node);
}
for (int i = 0; i < node.getChildCount(); i++) {
AccessibilityNodeInfo child = node.getChild(i);
AccessibilityNodeInfo result = findDepthFirstFocus(child);
child.recycle();
if (result != null) {
return result;
}
}
return null;
}
/**
* Returns the target focus area for a nudge in the given {@code direction} from the current
* focus, or null if not found. The caller is responsible for recycling the result.
*/
private AccessibilityNodeInfo findNudgeTargetFocusArea(
@NonNull List<AccessibilityWindowInfo> windows,
@NonNull AccessibilityNodeInfo focusedNode,
@NonNull AccessibilityNodeInfo currentFocusArea,
int direction) {
long elapsedRealtime = SystemClock.elapsedRealtime();
// If there is a target focus area in the cache, returns it.
AccessibilityNodeInfo cachedTargetFocusArea =
mRotaryCache.getTargetFocusArea(currentFocusArea, direction, elapsedRealtime);
if (cachedTargetFocusArea != null) {
// We already got nudge history in the cache. Before nudging back, let's save "nudge
// back" history.
mRotaryCache.saveTargetFocusArea(
currentFocusArea, cachedTargetFocusArea, direction, elapsedRealtime);
return cachedTargetFocusArea;
}
// No target focus area in the cache; we need to search the node tree to find it.
AccessibilityWindowInfo currentWindow = focusedNode.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 = findFocusAreas(currentWindow);
for (AccessibilityNodeInfo focusArea : candidateFocusAreas) {
if (focusArea.equals(currentFocusArea)) {
candidateFocusAreas.remove(focusArea);
focusArea.recycle();
break;
}
}
// Add candidate focus areas in other windows in the given direction.
List<AccessibilityWindowInfo> candidateWindows = new ArrayList<>();
addWindowsInDirection(windows, currentWindow, candidateWindows, direction);
currentWindow.recycle();
for (AccessibilityWindowInfo window : candidateWindows) {
List<AccessibilityNodeInfo> focusAreasInAnotherWindow = findFocusAreas(window);
candidateFocusAreas.addAll(focusAreasInAnotherWindow);
}
// Choose the best candidate as our target focus area.
AccessibilityNodeInfo targetFocusArea =
chooseBestNudgeCandidate(focusedNode, candidateFocusAreas, direction);
Utils.recycleNodes(candidateFocusAreas);
if (targetFocusArea != null) {
// Save nudge history.
mRotaryCache.saveTargetFocusArea(
currentFocusArea, targetFocusArea, direction, elapsedRealtime);
}
return targetFocusArea;
}
/**
* Adds all the {@code windows} in the given {@code direction} of the given {@code source}
* window to the given list.
*/
private void addWindowsInDirection(@NonNull List<AccessibilityWindowInfo> windows,
@NonNull AccessibilityWindowInfo source,
@NonNull List<AccessibilityWindowInfo> results,
int direction) {
Rect sourceBounds = new Rect();
source.getBoundsInScreen(sourceBounds);
Rect destBounds = new Rect();
for (AccessibilityWindowInfo window : windows) {
if (!window.equals(source)) {
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);
}
}
}
}
/**
* Scans the view hierarchy of the given {@code window} looking for focus areas and returns
* them. If there are no explicitly declared {@link FocusArea}s, returns the root view. The
* caller is responsible for recycling the
* result.
*/
private @NonNull
List<AccessibilityNodeInfo> findFocusAreas(@NonNull AccessibilityWindowInfo window) {
List<AccessibilityNodeInfo> results = new ArrayList<>();
AccessibilityNodeInfo rootNode = window.getRoot();
if (rootNode != null) {
addFocusAreas(rootNode, results);
if (results.isEmpty()) {
results.add(copyNode(rootNode));
}
rootNode.recycle();
}
return results;
}
/**
* Scans descendants of the given {@code rootNode} looking for focus areas and adds them to the
* given list. It doesn't scan inside focus areas since nested focus areas aren't allowed. 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 static void addFocusAreas(@NonNull AccessibilityNodeInfo rootNode,
@NonNull List<AccessibilityNodeInfo> results) {
if (isFocusArea(rootNode)) {
results.add(copyNode(rootNode));
} else {
for (int i = 0; i < rootNode.getChildCount(); i++) {
AccessibilityNodeInfo childNode = rootNode.getChild(i);
if (childNode != null) {
addFocusAreas(childNode, results);
childNode.recycle();
}
}
}
}
/**
* Adds the given {@code node} and all its focus descendants (nodes that can take focus) to the
* given list. The caller is responsible for recycling added nodes.
*/
private static void addFocusDescendants(@NonNull AccessibilityNodeInfo node,
@NonNull List<AccessibilityNodeInfo> results) {
if (canTakeFocus(node)) {
results.add(copyNode(node));
}
for (int i = 0; i < node.getChildCount(); i++) {
AccessibilityNodeInfo child = node.getChild(i);
addFocusDescendants(child, results);
child.recycle();
}
}
/** Returns whether the given {@code node} can be focused by a rotary controller. */
private static boolean canTakeFocus(@NonNull AccessibilityNodeInfo node) {
// TODO(b/151458195): remove "!isFocusArea(node)" once FocusArea is not focusable.
return node.isVisibleToUser() && node.isFocusable() && node.isEnabled()
&& !isFocusArea(node);
}
/**
* 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.
*/
private static AccessibilityNodeInfo chooseBestNudgeCandidate(
@NonNull AccessibilityNodeInfo sourceNode,
@NonNull List<AccessibilityNodeInfo> candidates,
int direction) {
if (candidates.isEmpty()) {
return null;
}
Rect sourceBounds = new Rect();
sourceNode.getBoundsInScreen(sourceBounds);
AccessibilityNodeInfo bestNode = null;
Rect bestBounds = new Rect();
Rect candidateBounds = new Rect();
for (AccessibilityNodeInfo candidate : candidates) {
candidate.getBoundsInScreen(candidateBounds);
if (FocusFinder.isCandidate(sourceBounds, candidateBounds, direction)) {
if (bestNode == null || FocusFinder.isBetterCandidate(
direction, sourceBounds, candidateBounds, bestBounds)) {
bestNode = candidate;
bestBounds.set(candidateBounds);
}
}
}
return copyNode(bestNode);
}
private static AccessibilityNodeInfo copyNode(@Nullable AccessibilityNodeInfo node) {
return sUtils.copyNode(node);
}
/**
* Finds the closest ancestor focus area of the given {@code node}. If the given {@code node}
* is a focus area, returns it; if there are no explicitly declared {@link FocusArea}s among the
* ancestors of this view, returns the root view. The caller is responsible for recycling the
* result.
*/
private @NonNull
static AccessibilityNodeInfo getAncestorFocusArea(@NonNull AccessibilityNodeInfo node) {
if (isFocusArea(node)) {
return copyNode(node);
}
AccessibilityNodeInfo parentNode = node.getParent();
if (parentNode == null) {
L.w("Couldn't find ancestor focus area for given node: " + node);
return copyNode(node);
}
AccessibilityNodeInfo result = getAncestorFocusArea(parentNode);
parentNode.recycle();
return result;
}
/** Returns whether the given {@code node} represents a {@link FocusArea}. */
private static boolean isFocusArea(@NonNull AccessibilityNodeInfo node) {
CharSequence className = node.getClassName() == null ? "" : node.getClassName();
// TODO(b/151458195): return FocusArea.class.getName().contentEquals(className);
String focusArea = "com.android.car.ui.FocusArea";
return focusArea.contentEquals(className);
}
}