/*
* Copyright (C) 2011 Google Inc.
*
* 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.talkback;
import android.annotation.TargetApi;
import android.content.Context;
import android.os.Build;
import android.os.Bundle;
import android.support.v4.view.accessibility.AccessibilityNodeInfoCompat;
import android.text.TextUtils;
import android.util.Log;
import android.widget.EditText;
import com.android.utils.AccessibilityNodeInfoUtils;
import com.android.utils.LogUtils;
import com.android.utils.PerformActionUtils;
import com.android.utils.Role;
import com.android.utils.WebInterfaceUtils;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Set;
/**
* Class to manage the navigation granularity for a given {@link AccessibilityNodeInfoCompat}.
*/
public class CursorGranularityManager {
/** Unsupported movement within a granularity */
private static final int NOT_SUPPORTED = -1;
/** Movement within a granularity reached the edge of possible movement */
public static final int HIT_EDGE = 0;
/** Movement within a granularity was successful */
public static final int SUCCESS = 1;
/** Represents an increase in granularity */
public static final int CHANGE_GRANULARITY_HIGHER = 1;
/** Represents a decrease in granularity */
public static final int CHANGE_GRANULARITY_LOWER = -1;
/**
* The list of navigable nodes. Computed by {@link #extractNavigableNodes}.
*/
private final List<AccessibilityNodeInfoCompat> mNavigableNodes = new ArrayList<>();
/**
* The list of granularities supported by the navigable nodes. Computed
* by {@link #extractNavigableNodes}.
*/
private final ArrayList<CursorGranularity> mSupportedGranularities = new ArrayList<>();
/** The parent context. */
private final Context mContext;
/**
* The top-level node within which the user is navigating. This node's
* navigable children are represented in {@link #mNavigableNodes}.
*/
private AccessibilityNodeInfoCompat mLockedNode;
/** The index of the current node within {@link #mNavigableNodes}. */
private int mCurrentNodeIndex;
// granularity that was chosen by user
private CursorGranularity mSavedGranularity = CursorGranularity.DEFAULT;
// it usually equals mSavedGranularity. But sometimes when we move focus between nodes some
// nodes does not contain previously chosen granularity. In that case default granularity
// is used for that node. but when we move to next node that support previously chosen
// granularity (stored in mSavedGranularity) it switch back to that.
private CursorGranularity mCurrentGranularity = CursorGranularity.DEFAULT;
/** Used on API 18+ to track when text selection mode is active. */
private boolean mSelectionModeActive;
public CursorGranularityManager(Context context) {
mContext = context;
}
/**
* Releases resources associated with this object.
*/
public void shutdown() {
clear();
}
/**
* Whether granular navigation is locked to {@code node}. If the currently
* requested granularity is {@link CursorGranularity#DEFAULT} this will
* always return {@code false}.
*
* @param node The node to check.
* @return Whether navigation is locked to {@code node}.
*/
public boolean isLockedTo(AccessibilityNodeInfoCompat node) {
// If the requested granularity is default, don't report as locked.
return mCurrentGranularity != CursorGranularity.DEFAULT
&& ((mLockedNode != null) && mLockedNode.equals(node));
}
/**
* @return The current navigation granularity, or
* {@link CursorGranularity#DEFAULT} if the currently requested
* granularity is invalid.
*/
public CursorGranularity getCurrentGranularity() {
return mCurrentGranularity;
}
/**
* Locks navigation within the specified node, if not already locked, and
* sets the current granularity.
*
* @param granularity The requested granularity.
* @return {@code true} if successful, {@code false} otherwise.
*/
public boolean setGranularityAt(
AccessibilityNodeInfoCompat node, CursorGranularity granularity) {
setLockedNode(node);
if (!mSupportedGranularities.contains(granularity)) {
mCurrentGranularity = CursorGranularity.DEFAULT;
return false;
}
mSavedGranularity = granularity;
mCurrentGranularity = granularity;
return true;
}
/**
* Sets the current state of selection mode for navigation within text
* content. When enabled, the manager will attempt to extend selection
* during navigation within a locked node.
*
* @param active {@code true} to activate selection mode, {@code false} to
* deactivate.
*/
public void setSelectionModeActive(boolean active) {
mSelectionModeActive = active;
}
/**
* @return {@code true} if selection mode is active, {@code false}
* otherwise.
*/
public boolean isSelectionModeActive() {
return mSelectionModeActive;
}
/**
* Locks navigation within the specified node, if not already locked, and
* adjusts the current granularity in the specified direction.
*
* @param direction The direction to adjust granularity. One of
* {@link CursorGranularityManager#CHANGE_GRANULARITY_HIGHER} or
* {@link CursorGranularityManager#CHANGE_GRANULARITY_LOWER}
* @return {@code true} if the granularity changed.
*/
public boolean adjustGranularityAt(AccessibilityNodeInfoCompat node, int direction) {
setLockedNode(node);
final int count = mSupportedGranularities.size();
int currentIndex = mSupportedGranularities.indexOf(mCurrentGranularity);
int nextIndex;
// Granularity adjustments always wrap around.
nextIndex = (currentIndex + direction) % count;
if (nextIndex < 0) {
nextIndex = count - 1;
}
mCurrentGranularity = mSupportedGranularities.get(nextIndex);
mSavedGranularity = mCurrentGranularity;
return nextIndex != currentIndex;
}
/**
* Clears the currently locked node and associated state variables. Recycles all currently held
* nodes. Resets the requested granularity.
*/
private void clear() {
mCurrentNodeIndex = 0;
mSupportedGranularities.clear();
AccessibilityNodeInfoUtils.recycleNodes(mNavigableNodes);
mNavigableNodes.clear();
AccessibilityNodeInfoUtils.recycleNodes(mLockedNode);
mLockedNode = null;
setSelectionModeActive(false);
}
/**
* As {@link #clear), but in addition keeps the granularity
*
* @param focusedNode The new node to be focused, {@code null} if there is no new node.
*/
private void clearAndRetainGranularity(AccessibilityNodeInfoCompat focusedNode) {
CursorGranularity currentGranularity = mSavedGranularity;
clear();
setGranularityAt(focusedNode, currentGranularity);
}
/**
* Processes TYPE_VIEW_ACCESSIBILITY_FOCUSED events by clearing the
* currently locked node and associated state variables if the provided node
* is different from the locked node and from the same window.
*
* @param node The node to compare against the locked node.
*/
public void onNodeFocused(AccessibilityNodeInfoCompat node) {
if ((mLockedNode == null) || (node == null)) {
return;
}
if (!mLockedNode.equals(node) && (mLockedNode.getWindowId() == node.getWindowId())) {
clearAndRetainGranularity(node);
}
}
public void startFromLastNode() {
mCurrentNodeIndex = mNavigableNodes.size() - 1;
}
/**
* Attempt to navigate within the currently locked node at the current
* granularity. You should call either {@link #setGranularityAt} or
* {@link #adjustGranularityAt} before calling this method.
*
* @return The result of navigation, which is always {@link #NOT_SUPPORTED}
* if there is no locked node or if the requested granularity is
* {@link CursorGranularity#DEFAULT}.
*/
public int navigate(int action) {
if (mLockedNode == null) {
return NOT_SUPPORTED;
}
final CursorGranularity requestedGranularity = mCurrentGranularity;
if ((requestedGranularity == null) || (requestedGranularity == CursorGranularity.DEFAULT)) {
return NOT_SUPPORTED;
}
// Handle web granularity separately.
if (requestedGranularity.isWebGranularity()) {
return navigateWeb(action, requestedGranularity);
}
final Bundle arguments = new Bundle();
final int count = mNavigableNodes.size();
final int increment;
switch (action) {
case AccessibilityNodeInfoCompat.ACTION_NEXT_AT_MOVEMENT_GRANULARITY:
increment = 1;
if (mCurrentNodeIndex < 0) {
mCurrentNodeIndex++;
}
break;
case AccessibilityNodeInfoCompat.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY:
increment = -1;
if (mCurrentNodeIndex >= count) {
mCurrentNodeIndex--;
}
break;
default:
return NOT_SUPPORTED;
}
while ((mCurrentNodeIndex >= 0) && (mCurrentNodeIndex < count)) {
if (isSelectionModeActive()) {
arguments.putBoolean(
AccessibilityNodeInfoCompat.ACTION_ARGUMENT_EXTEND_SELECTION_BOOLEAN, true);
}
arguments.putInt(AccessibilityNodeInfoCompat.ACTION_ARGUMENT_MOVEMENT_GRANULARITY_INT,
requestedGranularity.value);
final AccessibilityNodeInfoCompat currentNode = mNavigableNodes.get(mCurrentNodeIndex);
if (PerformActionUtils.performAction(currentNode, action, arguments)) {
return SUCCESS;
}
LogUtils.log(this, Log.VERBOSE, "Failed to move with granularity %s, trying next node",
requestedGranularity.name());
// If movement failed, advance to the next node and try again.
mCurrentNodeIndex += increment;
}
return HIT_EDGE;
}
/**
* Attempts to navigate web content at the specified granularity.
*
* @param action The accessibility action to perform, one of:
* <ul>
* <li>{@link AccessibilityNodeInfoCompat#ACTION_NEXT_AT_MOVEMENT_GRANULARITY}
* <li>{@link AccessibilityNodeInfoCompat#ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY}
* </ul>
* @param granularity The granularity at which to navigate.
* @return The result of navigation, which is always {@link #NOT_SUPPORTED}
* if there is no locked node or if the requested granularity is
* {@link CursorGranularity#DEFAULT}.
*/
private int navigateWeb(int action, CursorGranularity granularity) {
final int movementType;
final String htmlElementType;
switch (action) {
case AccessibilityNodeInfoCompat.ACTION_NEXT_AT_MOVEMENT_GRANULARITY:
movementType = WebInterfaceUtils.DIRECTION_FORWARD;
break;
case AccessibilityNodeInfoCompat.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY:
movementType = WebInterfaceUtils.DIRECTION_BACKWARD;
break;
default:
return NOT_SUPPORTED;
}
switch (granularity) {
case WEB_SECTION:
htmlElementType = WebInterfaceUtils.HTML_ELEMENT_MOVE_BY_SECTION;
break;
case WEB_LINK:
htmlElementType = WebInterfaceUtils.HTML_ELEMENT_MOVE_BY_LINK;
break;
case WEB_LIST:
htmlElementType = WebInterfaceUtils.HTML_ELEMENT_MOVE_BY_LIST;
break;
case WEB_CONTROL:
htmlElementType = WebInterfaceUtils.HTML_ELEMENT_MOVE_BY_CONTROL;
break;
default:
return NOT_SUPPORTED;
}
if (!WebInterfaceUtils.performNavigationToHtmlElementAction(
mLockedNode, movementType, htmlElementType)) {
return HIT_EDGE;
}
return SUCCESS;
}
/**
* Manages the currently locked node, clearing properties and loading
* navigable children if necessary.
*
* @param node The node the user wishes to navigate within.
*/
private void setLockedNode(AccessibilityNodeInfoCompat node) {
if ((mLockedNode != null) && !mLockedNode.equals(node)) {
clear();
}
if (mLockedNode == null) {
mLockedNode = AccessibilityNodeInfoCompat.obtain(node);
if (shouldClearSelection(mLockedNode)) {
PerformActionUtils.performAction(mLockedNode,
AccessibilityNodeInfoCompat.ACTION_SET_SELECTION);
}
// Extract the navigable nodes and supported granularities.
final List<CursorGranularity> supported = mSupportedGranularities;
Set<AccessibilityNodeInfoCompat> visitedNodes = new HashSet<>();
final int supportedMask = extractNavigableNodes(mLockedNode, mNavigableNodes,
visitedNodes);
AccessibilityNodeInfoUtils.recycleNodes(visitedNodes);
final boolean hasWebContent = WebInterfaceUtils.hasNavigableWebContent(
mContext, mLockedNode);
String[] supportedHtmlElements =
WebInterfaceUtils.getSupportedHtmlElements(mLockedNode);
CursorGranularity.extractFromMask(supportedMask, hasWebContent, supportedHtmlElements,
supported);
}
}
/**
* Return whether selection should be cleared from the specified node when
* locking navigation to it.
*
* @param node The node to check.
* @return {@code true} if selection should be cleared.
*/
private boolean shouldClearSelection(AccessibilityNodeInfoCompat node) {
// EditText has has a stable cursor position, so don't clear selection.
return Role.getRole(node) != Role.ROLE_EDIT_TEXT;
}
/**
* Populates a list with the set of {@link CursorGranularity}s supported by
* the specified root node and its navigable children.
*
* @param context The parent context.
* @param root The root node from which to extract granularities.
* @return A list of supported granularities.
*/
public static List<CursorGranularity> getSupportedGranularities(
Context context, AccessibilityNodeInfoCompat root) {
final LinkedList<CursorGranularity> supported = new LinkedList<>();
Set<AccessibilityNodeInfoCompat> visitedNodes = new HashSet<>();
final int supportedMask = extractNavigableNodes(root, null, visitedNodes);
AccessibilityNodeInfoUtils.recycleNodes(visitedNodes);
final boolean hasWebContent = WebInterfaceUtils.hasNavigableWebContent(context, root);
String[] supportedHtmlElements = WebInterfaceUtils.getSupportedHtmlElements(root);
CursorGranularity.extractFromMask(supportedMask, hasWebContent, supportedHtmlElements,
supported);
return supported;
}
/**
* Extract the child nodes from the given root and adds them to the supplied
* list of nodes.
*
* @param root The root node.
* @param nodes The list of child nodes.
* @return The mask of supported all granularities supported by the root and
* child nodes.
*/
private static int extractNavigableNodes(AccessibilityNodeInfoCompat root,
List<AccessibilityNodeInfoCompat> nodes,
Set<AccessibilityNodeInfoCompat> visitedNodes) {
if (root == null) {
return 0;
}
AccessibilityNodeInfoCompat visitedNode = AccessibilityNodeInfoCompat.obtain(root);
if (!visitedNodes.add(visitedNode)) {
visitedNode.recycle();
return 0;
}
if (nodes != null) {
nodes.add(AccessibilityNodeInfoCompat.obtain(root));
}
int supportedGranularities = root.getMovementGranularities();
// Don't pull children from nodes with content descriptions.
if (!TextUtils.isEmpty(root.getContentDescription())) {
return supportedGranularities;
}
// Don't pull children from nodes with web navigation actions.
if (WebInterfaceUtils.supportsWebActions(root)) {
return supportedGranularities;
}
final int childCount = root.getChildCount();
for (int i = 0; i < childCount; i++) {
final AccessibilityNodeInfoCompat child = root.getChild(i);
if (child == null) {
continue;
}
PerformActionUtils.performAction(child,
AccessibilityNodeInfoCompat.ACTION_SET_SELECTION, null);
// Only extract nodes that aren't reachable by traversal.
if (!AccessibilityNodeInfoUtils.shouldFocusNode(child)) {
supportedGranularities |= extractNavigableNodes(child, nodes, visitedNodes);
}
child.recycle();
}
return supportedGranularities;
}
}