/*
* Copyright (C) 2015 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.controller;
import android.annotation.SuppressLint;
import android.content.SharedPreferences;
import android.os.Build;
import android.support.v4.os.BuildCompat;
import android.support.v4.view.accessibility.AccessibilityEventCompat;
import android.support.v4.view.accessibility.AccessibilityNodeInfoCompat;
import android.support.v4.view.accessibility.AccessibilityWindowInfoCompat;
import android.text.TextUtils;
import android.util.Log;
import android.view.accessibility.AccessibilityEvent;
import android.view.accessibility.AccessibilityNodeInfo;
import android.view.accessibility.AccessibilityWindowInfo;
import android.widget.DatePicker;
import android.widget.NumberPicker;
import com.android.talkback.CursorGranularity;
import com.android.talkback.CursorGranularityManager;
import com.android.talkback.FeedbackItem;
import com.android.talkback.InputModeManager;
import com.android.talkback.KeyComboManager;
import com.android.talkback.R;
import com.android.talkback.SpeechController;
import com.android.talkback.eventprocessor.EventState;
import com.android.utils.Role;
import com.google.android.marvin.talkback.TalkBackService;
import com.android.utils.AccessibilityEventListener;
import com.android.utils.AccessibilityNodeInfoUtils;
import com.android.utils.LogUtils;
import com.android.utils.NodeFilter;
import com.android.utils.PerformActionUtils;
import com.android.utils.SharedPreferencesUtils;
import com.android.utils.WebInterfaceUtils;
import com.android.utils.WindowManager;
import com.android.utils.compat.accessibilityservice.AccessibilityServiceCompatUtils;
import com.android.utils.traversal.TraversalStrategy;
import com.android.utils.traversal.TraversalStrategyUtils;
import java.util.Arrays;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
/**
* Handles screen reader cursor management.
*/
public class CursorControllerApp implements
CursorController, AccessibilityEventListener, KeyComboManager.KeyComboListener {
private static final String LOGTAG = "CursorControllerApp";
private static final String HTML_ELEMENT_HEADING = "HEADING";
private static final String HTML_ELEMENT_BUTTON = "BUTTON";
private static final String HTML_ELEMENT_CHECKBOX = "CHECKBOX";
private static final String HTML_ELEMENT_ARIA_LANDMARK = "LANDMARK";
private static final String HTML_ELEMENT_EDIT_FIELD = "TEXT_FIELD";
private static final String HTML_ELEMENT_FOCUSABLE_ITEM = "FOCUSABLE";
private static final String HTML_ELEMENT_HEADING_1 = "H1";
private static final String HTML_ELEMENT_HEADING_2 = "H2";
private static final String HTML_ELEMENT_HEADING_3 = "H3";
private static final String HTML_ELEMENT_HEADING_4 = "H4";
private static final String HTML_ELEMENT_HEADING_5 = "H5";
private static final String HTML_ELEMENT_HEADING_6 = "H6";
private static final String HTML_ELEMENT_LINK = "LINK";
private static final String HTML_ELEMENT_CONTROL = "CONTROL";
private static final String HTML_ELEMENT_GRAPHIC = "GRAPHIC";
private static final String HTML_ELEMENT_LIST_ITEM = "LIST_ITEM";
private static final String HTML_ELEMENT_LIST = "LIST";
private static final String HTML_ELEMENT_TABLE = "TABLE";
private static final String HTML_ELEMENT_COMBOBOX = "COMBOBOX";
private static final String HTML_ELEMENT_SECTION = "SECTION";
private static final int WINDOW_TYPE_SYSTEM = 1;
private static final int WINDOW_TYPE_APPLICATION = 1 << 1;
private static final int WINDOW_TYPE_SPLIT_SCREEN_DIVIDER = 1 << 2;
private static final int FOCUS_STRATEGY_WRAP_AROUND = 0;
private static final int FOCUS_STRATEGY_RESUME_FOCUS = 1;
/** The host service. Used to access the root node. */
private final TalkBackService mService;
/** Handles traversal using granularity. */
private final CursorGranularityManager mGranularityManager;
/** Whether we should drive input focus instead of accessibility focus where possible. */
private final boolean mControlInputFocus;
/** Whether the current device supports navigating between multiple windows. */
private final boolean mIsWindowNavigationAvailable;
/** Whether the user hit an edge with the last swipe. */
private boolean mReachedEdge;
private boolean mGranularityNavigationReachedEdge;
private final Map<Integer, AccessibilityNodeInfoCompat> mLastFocusedNodeMap = new HashMap<>();
private final Set<GranularityChangeListener> mGranularityListeners = new HashSet<>();
private final Set<ScrollListener> mScrollListeners = new HashSet<>();
private final Set<CursorListener> mCursorListeners = new HashSet<>();
/** The last input-focused editable node. */
private AccessibilityNodeInfoCompat mLastEditable;
private int mSwitchNodeWithGranularityDirection = 0;
/**
* Creates a new cursor controller using the specified input controller.
*
* @param service The accessibility service. Used to obtain the current root
* node.
*/
public CursorControllerApp(TalkBackService service) {
mService = service;
mGranularityManager = new CursorGranularityManager(service);
mControlInputFocus = service.isDeviceTelevision();
mIsWindowNavigationAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1
&& !service.isDeviceTelevision();
}
@Override
public void addGranularityListener(GranularityChangeListener listener) {
if (listener == null) {
throw new IllegalArgumentException();
}
mGranularityListeners.add(listener);
}
@Override
public void removeGranularityListener(GranularityChangeListener listener) {
if (listener == null) {
throw new IllegalArgumentException();
}
mGranularityListeners.remove(listener);
}
@Override
public void addScrollListener(ScrollListener listener) {
if (listener == null) {
throw new IllegalArgumentException();
}
mScrollListeners.add(listener);
}
@Override
public void addCursorListener(CursorListener listener) {
if (listener == null) {
throw new IllegalArgumentException();
}
mCursorListeners.add(listener);
}
@Override
public void shutdown() {
mGranularityManager.shutdown();
}
@Override
public boolean refocus() {
final AccessibilityNodeInfoCompat node = getCursor();
if (node == null) {
return false;
}
EventState.getInstance().addEvent(EventState.EVENT_NODE_REFOCUSED);
clearCursor(node);
final boolean result = setCursor(node);
node.recycle();
return result;
}
@Override
public boolean next(boolean shouldWrap, boolean shouldScroll,
boolean useInputFocusAsPivotIfEmpty, int inputMode) {
return navigateWithGranularity(TraversalStrategy.SEARCH_FOCUS_FORWARD, shouldWrap,
shouldScroll, useInputFocusAsPivotIfEmpty, inputMode);
}
@Override
public boolean previous(boolean shouldWrap, boolean shouldScroll,
boolean useInputFocusAsPivotIfEmpty, int inputMode) {
return navigateWithGranularity(TraversalStrategy.SEARCH_FOCUS_BACKWARD, shouldWrap,
shouldScroll, useInputFocusAsPivotIfEmpty, inputMode);
}
@Override
public boolean left(boolean shouldWrap, boolean shouldScroll,
boolean useInputFocusAsPivotIfEmpty, int inputMode) {
return navigateWithGranularity(TraversalStrategy.SEARCH_FOCUS_LEFT, shouldWrap,
shouldScroll, useInputFocusAsPivotIfEmpty, inputMode);
}
@Override
public boolean right(boolean shouldWrap, boolean shouldScroll,
boolean useInputFocusAsPivotIfEmpty, int inputMode) {
return navigateWithGranularity(TraversalStrategy.SEARCH_FOCUS_RIGHT, shouldWrap,
shouldScroll, useInputFocusAsPivotIfEmpty, inputMode);
}
@Override
public boolean up(boolean shouldWrap, boolean shouldScroll,
boolean useInputFocusAsPivotIfEmpty, int inputMode) {
return navigateWithGranularity(TraversalStrategy.SEARCH_FOCUS_UP, shouldWrap,
shouldScroll, useInputFocusAsPivotIfEmpty, inputMode);
}
@Override
public boolean down(boolean shouldWrap, boolean shouldScroll,
boolean useInputFocusAsPivotIfEmpty, int inputMode) {
return navigateWithGranularity(TraversalStrategy.SEARCH_FOCUS_DOWN, shouldWrap,
shouldScroll, useInputFocusAsPivotIfEmpty, inputMode);
}
@Override
public boolean jumpToTop(int inputMode) {
clearCursor();
mReachedEdge = true;
return next(true /*shouldWrap*/, false /*shouldScroll*/,
false /*useInputFocusAsPivotIfEmpty*/, inputMode);
}
@Override
public boolean jumpToBottom(int inputMode) {
clearCursor();
mReachedEdge = true;
return previous(true /*shouldWrap*/, false /*shouldScroll*/,
false /*useInputFocusAsPivotIfEmpty*/, inputMode);
}
@Override
public boolean more() {
return attemptScrollToDirection(AccessibilityNodeInfoCompat.ACTION_SCROLL_FORWARD);
}
@Override
public boolean less() {
return attemptScrollToDirection(AccessibilityNodeInfoCompat.ACTION_SCROLL_BACKWARD);
}
@Override
public boolean nextWithSpecifiedGranularity(CursorGranularity granularity, boolean shouldWrap,
boolean shouldScroll, boolean useInputFocusAsPivotIfEmpty, int inputMode) {
return navigateWithSpecifiedGranularity(TraversalStrategy.SEARCH_FOCUS_FORWARD,
granularity, shouldWrap, shouldScroll, useInputFocusAsPivotIfEmpty, inputMode);
}
@Override
public boolean previousWithSpecifiedGranularity(CursorGranularity granularity,
boolean shouldWrap, boolean shouldScroll, boolean useInputFocusAsPivotIfEmpty,
int inputMode) {
return navigateWithSpecifiedGranularity(TraversalStrategy.SEARCH_FOCUS_BACKWARD,
granularity, shouldWrap, shouldScroll, useInputFocusAsPivotIfEmpty, inputMode);
}
@Override
public boolean nextHtmlElement(String htmlElement, int inputMode) {
return navigateToHTMLElement(htmlElement, true /* forward */, inputMode);
}
@Override
public boolean previousHtmlElement(String htmlElement, int inputMode) {
return navigateToHTMLElement(htmlElement, false /* backward */, inputMode);
}
private boolean isSupportedHtmlElement(String htmlElement) {
AccessibilityNodeInfoCompat node = getCursor();
if (node == null) {
return false;
}
String[] supportedHtmlElements = WebInterfaceUtils.getSupportedHtmlElements(node);
AccessibilityNodeInfoUtils.recycleNodes(node);
return supportedHtmlElements != null &&
Arrays.asList(supportedHtmlElements).contains(htmlElement);
}
private boolean navigateToHTMLElement(String htmlElement, boolean forward, int inputMode) {
AccessibilityNodeInfoCompat node = getCursor();
if (node == null) {
return false;
}
try {
int direction = forward ? WebInterfaceUtils.DIRECTION_FORWARD :
WebInterfaceUtils.DIRECTION_BACKWARD;
if (WebInterfaceUtils.performNavigationToHtmlElementAction(
node, direction, htmlElement)) {
mService.getInputModeManager().setInputMode(inputMode);
return true;
} else {
return false;
}
} finally {
AccessibilityNodeInfoUtils.recycleNodes(node);
}
}
private boolean attemptScrollToDirection(int direction) {
AccessibilityNodeInfoCompat cursor = null;
AccessibilityNodeInfoCompat rootNode = null;
AccessibilityNodeInfoCompat bfsScrollableNode = null;
boolean result = false;
try {
cursor = getCursor();
if (cursor != null) {
result = attemptScrollAction(cursor, direction, false);
}
if (!result) {
rootNode = AccessibilityServiceCompatUtils
.getRootInAccessibilityFocusedWindow(mService);
bfsScrollableNode = AccessibilityNodeInfoUtils.searchFromBfs(
rootNode, AccessibilityNodeInfoUtils.FILTER_SCROLLABLE);
if (bfsScrollableNode != null && isLogicalScrollableWidget(bfsScrollableNode)) {
result = attemptScrollAction(bfsScrollableNode, direction, false);
}
}
} finally {
AccessibilityNodeInfoUtils.recycleNodes(cursor, rootNode, bfsScrollableNode);
}
return result;
}
@Override
public boolean clickCurrent() {
return performAction(AccessibilityNodeInfoCompat.ACTION_CLICK);
}
@Override
public boolean clickCurrentHierarchical() {
NodeFilter clickFilter = new NodeFilter() {
@Override
public boolean accept(AccessibilityNodeInfoCompat node) {
return AccessibilityNodeInfoUtils.isClickable(node);
}
};
AccessibilityNodeInfoCompat cursor = null;
AccessibilityNodeInfoCompat match = null;
try {
cursor = getCursor();
match = AccessibilityNodeInfoUtils.getSelfOrMatchingAncestor(cursor, clickFilter);
return PerformActionUtils.performAction(match,
AccessibilityNodeInfoCompat.ACTION_CLICK);
} finally {
AccessibilityNodeInfoUtils.recycleNodes(cursor, match);
}
}
@Override
public boolean longClickCurrent() {
return performAction(AccessibilityNodeInfoCompat.ACTION_LONG_CLICK);
}
@Override
public boolean nextGranularity() {
return adjustGranularity(1);
}
@Override
public boolean previousGranularity() {
return adjustGranularity(-1);
}
@Override
public boolean setGranularity(CursorGranularity granularity, boolean fromUser) {
AccessibilityNodeInfoCompat current = null;
try {
current = getCursorOrInputCursor();
return setGranularity(granularity, current, fromUser);
} finally {
AccessibilityNodeInfoUtils.recycleNodes(current);
}
}
@Override
public boolean setGranularity(CursorGranularity granularity, AccessibilityNodeInfoCompat node,
boolean fromUser) {
if (node == null) {
return false;
}
if (!mGranularityManager.setGranularityAt(node, granularity)) {
return false;
}
granularityUpdated(granularity, fromUser);
return true;
}
@Override
public boolean setCursor(AccessibilityNodeInfoCompat node) {
// Accessibility focus follows input focus; on TVs we want to set both simultaneously,
// so we change the input focus if possible and let the ProcessorFocusAndSingleTap
// handle changing the accessibility focus.
if (mControlInputFocus && node.isFocusable() && !node.isFocused()) {
if (setCursor(node, AccessibilityNodeInfoCompat.ACTION_FOCUS)) {
return true;
}
}
// Set accessibility focus otherwise (or as a fallback if setting input focus failed).
return setCursor(node, AccessibilityNodeInfoCompat.ACTION_ACCESSIBILITY_FOCUS);
}
private boolean setCursor(AccessibilityNodeInfoCompat node, int action) {
final Set<CursorListener> listeners = new HashSet<>(mCursorListeners);
for (CursorListener listener : listeners) {
listener.beforeSetCursor(node, action);
}
boolean performedAction = PerformActionUtils.performAction(node, action);
if (performedAction) {
rememberLastFocusedNode(node);
for (CursorListener listener : listeners) {
listener.onSetCursor(node, action);
}
}
return performedAction;
}
@Override
public void setSelectionModeActive(AccessibilityNodeInfoCompat node, boolean active) {
if (active && !mGranularityManager.isLockedTo(node)) {
setGranularity(CursorGranularity.CHARACTER, false /* fromUser */);
}
mGranularityManager.setSelectionModeActive(active);
}
@Override
public boolean isSelectionModeActive() {
return mGranularityManager.isSelectionModeActive();
}
@Override
public void clearCursor() {
AccessibilityNodeInfoCompat currentNode = getCursor();
if (currentNode == null) {
return;
}
clearCursor(currentNode);
currentNode.recycle();
}
@Override
public void clearCursor(AccessibilityNodeInfoCompat currentNode) {
if (currentNode == null) {
return;
}
PerformActionUtils.performAction(currentNode,
AccessibilityNodeInfo.ACTION_CLEAR_ACCESSIBILITY_FOCUS);
}
@Override
public AccessibilityNodeInfoCompat getCursor() {
return getAccessibilityFocusedOrRootNode();
}
@Override
public AccessibilityNodeInfoCompat getCursorOrInputCursor() {
return getAccessibilityFocusedOrInputFocusedEditableNode();
}
private AccessibilityNodeInfoCompat getAccessibilityFocusedOrRootNode() {
final AccessibilityNodeInfoCompat compatRoot = AccessibilityServiceCompatUtils
.getRootInAccessibilityFocusedWindow(mService);
if (compatRoot == null) {
return null;
}
AccessibilityNodeInfoCompat focusedNode = getAccessibilityFocusedNode(compatRoot);
// TODO: If there's no focused node, we should either mimic following
// focus from new window or try to be smart for things like list views.
if (focusedNode == null) {
return compatRoot;
}
return focusedNode;
}
public AccessibilityNodeInfoCompat getAccessibilityFocusedOrInputFocusedEditableNode() {
final AccessibilityNodeInfoCompat compatRoot = AccessibilityServiceCompatUtils
.getRootInAccessibilityFocusedWindow(mService);
if (compatRoot == null) {
return null;
}
AccessibilityNodeInfoCompat focusedNode = getAccessibilityFocusedNode(compatRoot);
// TODO: If there's no focused node, we should either mimic following
// focus from new window or try to be smart for things like list views.
if (focusedNode == null) {
AccessibilityNodeInfoCompat inputFocusedNode = getInputFocusedNode();
if (inputFocusedNode != null && inputFocusedNode.isFocused()
&& inputFocusedNode.isEditable() ) {
focusedNode = inputFocusedNode;
}
}
// If we can't find the focused node but the keyboard is showing, return the last editable.
// This will occur if the input-focused view is actually a virtual view (e.g. in WebViews).
// Note: need to refresh() in order to verify that the node is still available on-screen.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP && focusedNode == null &&
mLastEditable != null && mLastEditable.refresh()) {
WindowManager windowManager = new WindowManager(false); // RTL state doesn't matter.
windowManager.setWindows(mService.getWindows());
if (windowManager.isInputWindowOnScreen()) {
focusedNode = AccessibilityNodeInfoCompat.obtain(mLastEditable);
}
}
return focusedNode;
}
public AccessibilityNodeInfoCompat getAccessibilityFocusedNode(
AccessibilityNodeInfoCompat compatRoot) {
if (compatRoot == null) {
return null;
}
AccessibilityNodeInfoCompat focusedNode = compatRoot.findFocus(
AccessibilityNodeInfoCompat.FOCUS_ACCESSIBILITY);
if (focusedNode == null) {
return null;
}
if (!AccessibilityNodeInfoUtils.isVisible(focusedNode)) {
focusedNode.recycle();
return null;
}
return focusedNode;
}
private AccessibilityNodeInfoCompat getInputFocusedNode() {
AccessibilityNodeInfoCompat activeRoot =
AccessibilityServiceCompatUtils.getRootInActiveWindow(mService);
if (activeRoot != null) {
try {
return activeRoot.findFocus(AccessibilityNodeInfoCompat.FOCUS_INPUT);
} finally {
activeRoot.recycle();
}
}
return null;
}
public boolean isLinearNavigationLocked(AccessibilityNodeInfoCompat node) {
return mGranularityManager.isLockedTo(node);
}
@Override
public CursorGranularity getGranularityAt(AccessibilityNodeInfoCompat node) {
if (mGranularityManager.isLockedTo(node)) {
return mGranularityManager.getCurrentGranularity();
}
return CursorGranularity.DEFAULT;
}
/**
* Attempts to scroll using the specified action.
*
* @param action The scroll action to perform.
* @param auto If {@code true}, then the scroll was initiated automatically. If
* {@code false}, then the user initiated the scroll action.
* @return Whether the action was performed.
*/
private boolean attemptScrollAction(AccessibilityNodeInfoCompat cursor, int action,
boolean auto) {
if (cursor == null) {
return false;
}
AccessibilityNodeInfoCompat scrollableNode = null;
try {
scrollableNode = getBestScrollableNode(cursor, action);
if (scrollableNode == null) {
return false;
}
final boolean performedAction = PerformActionUtils.performAction(
scrollableNode, action);
if (performedAction) {
final Set<ScrollListener> listeners = new HashSet<>(mScrollListeners);
for (ScrollListener listener : listeners) {
listener.onScroll(scrollableNode, action, auto);
}
}
return performedAction;
} finally {
AccessibilityNodeInfoUtils.recycleNodes(scrollableNode);
}
}
private AccessibilityNodeInfoCompat getBestScrollableNode(
AccessibilityNodeInfoCompat cursor, final int action) {
final AccessibilityNodeInfoCompat predecessor =
AccessibilityNodeInfoUtils.getSelfOrMatchingAncestor(cursor,
AccessibilityNodeInfoUtils.FILTER_SCROLLABLE.and(new NodeFilter() {
@Override
public boolean accept(AccessibilityNodeInfoCompat node) {
return node != null &&
AccessibilityNodeInfoUtils.supportsAction(node, action);
}
}));
if (predecessor != null && isLogicalScrollableWidget(predecessor)) {
return predecessor;
}
return null;
}
// TODO that is hack to temporary not to scroll DatePicker and Number picker while they are
// unusable on that case
private boolean isLogicalScrollableWidget(AccessibilityNodeInfoCompat node) {
if (node == null) {
return false;
}
CharSequence className = node.getClassName();
return !(TextUtils.equals(className, DatePicker.class.getName()) ||
TextUtils.equals(className, NumberPicker.class.getName()));
}
/**
* Attempts to adjust granularity in the direction indicated.
*
* @param direction The direction to adjust granularity. One of
* {@link CursorGranularityManager#CHANGE_GRANULARITY_HIGHER} or
* {@link CursorGranularityManager#CHANGE_GRANULARITY_LOWER}
* @return true on success, false if no nodes in the current hierarchy
* support a granularity other than the default.
*/
private boolean adjustGranularity(int direction) {
AccessibilityNodeInfoCompat currentNode = null;
try {
currentNode = getCursorOrInputCursor();
if (currentNode == null) {
return false;
}
final boolean wasAdjusted = mGranularityManager.adjustGranularityAt(
currentNode, direction);
if (wasAdjusted) {
granularityUpdated(mGranularityManager.getCurrentGranularity(), true);
}
return wasAdjusted;
} finally {
AccessibilityNodeInfoUtils.recycleNodes(currentNode);
}
}
/**
* Try to navigate with specified granularity.
*/
private boolean navigateWithSpecifiedGranularity(int direction, CursorGranularity granularity,
boolean shouldWrap, boolean shouldScroll, boolean useInputFocusAsPivotIfEmpty,
int inputMode) {
// Keep current granularity to set it back after this operation.
CursorGranularity currentGranularity = mGranularityManager.getCurrentGranularity();
boolean sameGranularity = currentGranularity == granularity;
// Navigate with specified granularity.
if (!sameGranularity) {
setGranularity(granularity, false /* not from user */);
}
boolean result = navigateWithGranularity(direction, false, true, true, inputMode);
// Set back to the granularity which is used before this operation.
if (!sameGranularity) {
setGranularity(currentGranularity, false /* not from user */);
}
return result;
}
/**
* Attempts to move in the direction indicated.
* <p>
* If a navigation granularity other than DEFAULT has been applied, attempts
* to move within the current object at the specified granularity.
* </p>
* <p>
* If no granularity has been applied, or if the DEFAULT granularity has
* been applied, attempts to move in the specified direction using
* {@link android.view.View#focusSearch(int)}.
* </p>
*
* @param direction The direction to move.
* @param shouldWrap Whether navigating past the last item on the screen
* should wrap around to the first item on the screen.
* @param shouldScroll Whether navigating past the last visible item in a
* scrollable container should automatically scroll to the next
* visible item.
* @param useInputFocusAsPivotIfEmpty Whether navigation should start from node that has input
* focused editable node if there is no node with
* accessibility focus
* @return true on success, false on failure.
*/
private boolean navigateWithGranularity(@TraversalStrategy.SearchDirection int direction,
boolean shouldWrap,
boolean shouldScroll,
boolean useInputFocusAsPivotIfEmpty,
int inputMode) {
@TraversalStrategy.SearchDirection int logicalDirection =
TraversalStrategyUtils.getLogicalDirection(direction, mService.isScreenLayoutRTL());
final int navigationAction;
if (logicalDirection == TraversalStrategy.SEARCH_FOCUS_FORWARD) {
navigationAction = AccessibilityNodeInfoCompat.ACTION_NEXT_AT_MOVEMENT_GRANULARITY;
} else if (logicalDirection == TraversalStrategy.SEARCH_FOCUS_BACKWARD) {
navigationAction = AccessibilityNodeInfoCompat.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY;
} else {
throw new IllegalStateException("Unknown logical direction");
}
mService.getInputModeManager().setInputMode(inputMode);
final int scrollDirection =
TraversalStrategyUtils.convertSearchDirectionToScrollAction(direction);
if (scrollDirection == 0) {
// We won't be able to handle scrollable views very well on older SDK versions,
// so don't allow d-pad navigation,
return false;
}
AccessibilityNodeInfoCompat current = null;
AccessibilityNodeInfoCompat target = null;
TraversalStrategy traversalStrategy = null;
AccessibilityNodeInfoCompat rootNode = null;
boolean processResult = false;
try {
current = getCurrentCursor(useInputFocusAsPivotIfEmpty);
if (current == null) {
processResult = false;
return processResult;
}
if (!mIsWindowNavigationAvailable) {
// If we're in a background window, we need to return the cursor to the current
// window and prevent navigation within the background window.
AccessibilityWindowInfoCompat currentWindow = current.getWindow();
if (currentWindow != null) {
if (!currentWindow.isActive()) {
AccessibilityNodeInfoCompat activeRoot =
AccessibilityServiceCompatUtils.getRootInActiveWindow(mService);
if (activeRoot != null) {
current.recycle();
current = activeRoot;
}
}
currentWindow.recycle();
}
}
// If granularity is set to anything other than default, restrict
// navigation to the current node.
if (mGranularityManager.isLockedTo(current)) {
final int result = mGranularityManager.navigate(navigationAction);
if (result == CursorGranularityManager.SUCCESS) {
mGranularityNavigationReachedEdge = false;
processResult = true;
return processResult;
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1 &&
result == CursorGranularityManager.HIT_EDGE && !current.isEditable()) {
if (!mGranularityNavigationReachedEdge) {
// alert when we navigate to the edge with a web granularity.
if (mGranularityManager.getCurrentGranularity().isWebGranularity()) {
int resId = mGranularityManager.getCurrentGranularity().resourceId;
String htmlElement = null;
if (resId == CursorGranularity.WEB_CONTROL.resourceId) {
htmlElement = HTML_ELEMENT_CONTROL;
} else if (resId == CursorGranularity.WEB_LINK.resourceId) {
htmlElement = HTML_ELEMENT_LINK;
} else if (resId == CursorGranularity.WEB_LIST.resourceId) {
htmlElement = HTML_ELEMENT_LIST;
} else if (resId == CursorGranularity.WEB_SECTION.resourceId) {
htmlElement = HTML_ELEMENT_SECTION;
}
alertWebNavigationHitEdge(htmlElement,
logicalDirection == TraversalStrategy.SEARCH_FOCUS_FORWARD);
}
// skip one swipe when hit edge during granularity navigation
mGranularityNavigationReachedEdge = true;
processResult = false;
return processResult;
} else {
// We shouldn't navigate past the last "link", "heading", etc. when
// navigating with a web granularity.
// It makes sense to navigate to the next node with other kinds of
// granularities(characters, words, etc.).
if (mGranularityManager.getCurrentGranularity().isWebGranularity()) {
processResult = false;
return processResult;
}
mSwitchNodeWithGranularityDirection = navigationAction;
EventState.getInstance().addEvent(
EventState.EVENT_SKIP_FOCUS_PROCESSING_AFTER_GRANULARITY_MOVE);
EventState.getInstance().addEvent(
EventState.EVENT_SKIP_HINT_AFTER_GRANULARITY_MOVE);
}
} else {
processResult = false;
return processResult;
}
}
// If the current node has web content, attempt HTML navigation.
if (shouldAttemptHtmlNavigation(current, direction)) {
if (attemptHtmlNavigation(current, direction)) {
// Succeeded finding destination inside WebView
processResult = true;
return true;
} else {
// Ascend to WebView, preparing to navigate past WebView with normal navigation
AccessibilityNodeInfoCompat webView = ascendToWebView(current);
if (webView != null) {
current.recycle();
current = webView;
}
}
}
// If the user has disabled automatic scrolling, don't attempt to scroll.
// TODO: Remove once auto-scroll is settled.
if (shouldScroll) {
final SharedPreferences prefs = SharedPreferencesUtils.getSharedPreferences(
mService);
shouldScroll = SharedPreferencesUtils.getBooleanPref(prefs,
mService.getResources(), R.string.pref_auto_scroll_key,
R.bool.pref_auto_scroll_default);
}
rootNode = AccessibilityNodeInfoUtils.getRoot(current);
traversalStrategy = TraversalStrategyUtils.getTraversalStrategy(rootNode, direction);
// If the current item is at the edge of a scrollable view, try to
// automatically scroll the view in the direction of navigation.
if (shouldScroll && AccessibilityNodeInfoUtils.isAutoScrollEdgeListItem(
current, direction, traversalStrategy) &&
attemptScrollAction(current, scrollDirection, true)) {
processResult = true;
return processResult;
}
// Otherwise, move focus to next or previous focusable node.
target = navigateFrom(current, direction, traversalStrategy);
if ((target != null)) {
// The `spatial` condition provides a work-around for RecyclerViews.
// Currently RecyclerViews do not support ACTION_SCROLL_LEFT, UP, etc.
// TODO: Remove `spatial` check when RecyclerViews support new scroll actions.
final boolean spatial = TraversalStrategyUtils.isSpatialDirection(direction);
boolean autoScroll = AccessibilityNodeInfoUtils.isAutoScrollEdgeListItem(target,
direction, traversalStrategy) || spatial;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && shouldScroll && autoScroll) {
PerformActionUtils.performAction(target,
AccessibilityNodeInfo.AccessibilityAction.ACTION_SHOW_ON_SCREEN.getId());
}
if (setCursor(target)) {
mReachedEdge = false;
processResult = true;
return processResult;
}
}
// skip one swipe if in the border of window and no other application window to
// move focus to
if (!mReachedEdge && needPauseInTraversalAfterCurrentWindow(direction)) {
mReachedEdge = true;
processResult = false;
return processResult;
}
// move focus from application to next application window
if (navigateToNextOrPreviousWindow(direction,
WINDOW_TYPE_APPLICATION | WINDOW_TYPE_SPLIT_SCREEN_DIVIDER,
FOCUS_STRATEGY_WRAP_AROUND, false /* useInputFocusAsPivot */, inputMode)) {
mReachedEdge = false;
processResult = true;
return processResult;
}
if (mReachedEdge && shouldWrap) {
mReachedEdge = false;
processResult = navigateWrapAround(rootNode, direction, traversalStrategy,
inputMode);
return processResult;
}
processResult = false;
return processResult;
} finally {
AccessibilityNodeInfoUtils.recycleNodes(current, target, rootNode);
if (traversalStrategy != null) {
traversalStrategy.recycle();
}
if (!processResult) {
mSwitchNodeWithGranularityDirection = 0;
EventState.getInstance().clearEvent(
EventState.EVENT_SKIP_FOCUS_PROCESSING_AFTER_GRANULARITY_MOVE);
EventState.getInstance().clearEvent(
EventState.EVENT_SKIP_HINT_AFTER_GRANULARITY_MOVE);
}
}
}
private AccessibilityNodeInfoCompat ascendToWebView(AccessibilityNodeInfoCompat current) {
return AccessibilityNodeInfoUtils.getSelfOrMatchingAncestor(current, null,
new NodeFilter() {
@Override
public boolean accept(AccessibilityNodeInfoCompat node) {
return (node != null && Role.getRole(node) == Role.ROLE_WEB_VIEW);
}
});
}
private AccessibilityNodeInfoCompat getCurrentCursor(boolean useInputFocusAsPivotIfEmpty) {
AccessibilityNodeInfoCompat cursor = null;
if (useInputFocusAsPivotIfEmpty) {
cursor = getAccessibilityFocusedOrInputFocusedEditableNode();
}
if (cursor == null) {
cursor = getAccessibilityFocusedOrRootNode();
}
return cursor;
}
@SuppressLint("InlinedApi")
private boolean needPauseInTraversalAfterCurrentWindow(
@TraversalStrategy.SearchDirection int direction) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP_MR1) {
// always pause before loop in one-window conditions
return true;
}
WindowManager windowManager = new WindowManager(mService.isScreenLayoutRTL());
windowManager.setWindows(mService.getWindows());
if (!windowManager.isApplicationWindowFocused() &&
!windowManager.isSplitScreenDividerFocused()) {
// need pause before looping traversal in non-application window
return true;
}
@TraversalStrategy.SearchDirection int logicalDirection =
TraversalStrategyUtils.getLogicalDirection(direction, mService.isScreenLayoutRTL());
if (logicalDirection == TraversalStrategy.SEARCH_FOCUS_FORWARD) {
return windowManager.isLastWindow(
windowManager.getCurrentWindow(false /* useInputFocus */),
AccessibilityWindowInfo.TYPE_APPLICATION);
} else if (logicalDirection == TraversalStrategy.SEARCH_FOCUS_BACKWARD) {
return windowManager.isFirstWindow(
windowManager.getCurrentWindow(false /* useInputFocus */),
AccessibilityWindowInfo.TYPE_APPLICATION);
} else {
throw new IllegalStateException("Unknown logical direction");
}
}
private boolean navigateToNextOrPreviousWindow(
@TraversalStrategy.SearchDirection int direction,
int windowTypeFilter, int focusStrategy, boolean useInputFocusAsPivot,
int inputMode) {
if (!mIsWindowNavigationAvailable) {
return false;
}
WindowManager windowManager = new WindowManager(mService.isScreenLayoutRTL());
windowManager.setWindows(mService.getWindows());
AccessibilityWindowInfo pivotWindow = windowManager.getCurrentWindow(useInputFocusAsPivot);
if (pivotWindow == null || !matchWindowType(pivotWindow, windowTypeFilter)) {
return false;
}
AccessibilityWindowInfo targetWindow = pivotWindow;
while (true) {
@TraversalStrategy.SearchDirection int logicalDirection = TraversalStrategyUtils
.getLogicalDirection(direction, mService.isScreenLayoutRTL());
if (logicalDirection == TraversalStrategy.SEARCH_FOCUS_FORWARD) {
targetWindow = windowManager.getNextWindow(targetWindow);
} else if (logicalDirection == TraversalStrategy.SEARCH_FOCUS_BACKWARD) {
targetWindow = windowManager.getPreviousWindow(targetWindow);
} else {
throw new IllegalStateException("Unknown logical direction");
}
if (targetWindow == null || pivotWindow.equals(targetWindow)) {
return false;
}
if (!matchWindowType(targetWindow, windowTypeFilter)) {
continue;
}
AccessibilityNodeInfo windowRoot = targetWindow.getRoot();
if (windowRoot == null) {
continue;
}
AccessibilityNodeInfoCompat compatRoot = new AccessibilityNodeInfoCompat(windowRoot);
if (focusStrategy == FOCUS_STRATEGY_RESUME_FOCUS) {
if (resumeLastFocus(targetWindow.getId(), inputMode)) {
return true;
}
// If it cannot resume last focus, try to focus the first focusable element.
TraversalStrategy traversalStrategy =
TraversalStrategyUtils.getTraversalStrategy(compatRoot,
TraversalStrategy.SEARCH_FOCUS_FORWARD);
if (navigateWrapAround(compatRoot, TraversalStrategy.SEARCH_FOCUS_FORWARD,
traversalStrategy, inputMode)) {
return true;
}
} else {
TraversalStrategy traversalStrategy =
TraversalStrategyUtils.getTraversalStrategy(compatRoot, direction);
if (navigateWrapAround(compatRoot, direction, traversalStrategy, inputMode)) {
return true;
}
}
}
}
private boolean matchWindowType(AccessibilityWindowInfo window, int windowTypeFilter) {
int windowType = window.getType();
if ((windowTypeFilter & WINDOW_TYPE_SYSTEM) != 0 &&
windowType == AccessibilityWindowInfo.TYPE_SYSTEM) {
return true;
} else if ((windowTypeFilter & WINDOW_TYPE_APPLICATION) != 0 &&
windowType == AccessibilityWindowInfo.TYPE_APPLICATION) {
return true;
} else if ((windowTypeFilter & WINDOW_TYPE_SPLIT_SCREEN_DIVIDER) != 0 &&
windowType == AccessibilityWindowInfo.TYPE_SPLIT_SCREEN_DIVIDER) {
return true;
} else {
return false;
}
}
private boolean navigateWrapAround(AccessibilityNodeInfoCompat root,
@TraversalStrategy.SearchDirection int direction,
TraversalStrategy traversalStrategy, int inputMode) {
if (root == null) {
return false;
}
AccessibilityNodeInfoCompat tempNode = null;
AccessibilityNodeInfoCompat wrapNode = null;
try {
tempNode = traversalStrategy.focusInitial(root, direction);
wrapNode = navigateSelfOrFrom(tempNode, direction, traversalStrategy);
if (wrapNode == null) {
if (LogUtils.LOG_LEVEL <= Log.ERROR) {
Log.e(LOGTAG, "Failed to wrap navigation");
}
return false;
}
if (setCursor(wrapNode)) {
mService.getInputModeManager().setInputMode(inputMode);
return true;
} else {
return false;
}
} finally {
AccessibilityNodeInfoUtils.recycleNodes(tempNode, wrapNode);
}
}
private boolean resumeLastFocus(int windowId, int inputMode) {
AccessibilityNodeInfoCompat lastFocusedNode = mLastFocusedNodeMap.get(windowId);
if (lastFocusedNode == null) {
return false;
}
if (setCursor(lastFocusedNode)) {
mService.getInputModeManager().setInputMode(inputMode);
return true;
} else {
return false;
}
}
private AccessibilityNodeInfoCompat navigateSelfOrFrom(AccessibilityNodeInfoCompat node,
@TraversalStrategy.SearchDirection int direction,
TraversalStrategy traversalStrategy) {
if (node == null) {
return null;
}
if (AccessibilityNodeInfoUtils.shouldFocusNode(node,
traversalStrategy.getSpeakingNodesCache())) {
return AccessibilityNodeInfoCompat.obtain(node);
}
return navigateFrom(node, direction, traversalStrategy);
}
private AccessibilityNodeInfoCompat navigateFrom(
AccessibilityNodeInfoCompat node,
@TraversalStrategy.SearchDirection int direction,
final TraversalStrategy traversalStrategy) {
if (node == null) {
return null;
}
NodeFilter filter = new NodeFilter() {
@Override
public boolean accept(AccessibilityNodeInfoCompat node) {
return node != null && AccessibilityNodeInfoUtils.shouldFocusNode(node,
traversalStrategy.getSpeakingNodesCache());
}
};
return AccessibilityNodeInfoUtils.searchFocus(traversalStrategy, node, direction, filter);
}
private void granularityUpdated(CursorGranularity granularity, boolean fromUser) {
final Set<GranularityChangeListener> localListeners = new HashSet<>(mGranularityListeners);
for (GranularityChangeListener listener : localListeners) {
listener.onGranularityChanged(granularity);
}
if (fromUser) {
mService.getSpeechController().speak(mService.getString(granularity.resourceId),
SpeechController.QUEUE_MODE_INTERRUPT, 0, null);
}
}
/**
* Performs the specified action on the current cursor.
*
* @param action The action to perform on the current cursor.
* @return {@code true} if successful.
*/
private boolean performAction(int action) {
AccessibilityNodeInfoCompat current = null;
try {
current = getCursor();
return current != null &&
PerformActionUtils.performAction(current, action);
} finally {
AccessibilityNodeInfoUtils.recycleNodes(current);
}
}
@Override
public boolean onComboPerformed(int id) {
switch (id) {
case KeyComboManager.ACTION_NAVIGATE_NEXT:
nextWithSpecifiedGranularity(CursorGranularity.DEFAULT, true /* shouldWrap */,
true /* shouldScroll */, true /* useInputFocusAsPivotIfEmpty */,
InputModeManager.INPUT_MODE_KEYBOARD);
return true;
case KeyComboManager.ACTION_NAVIGATE_PREVIOUS:
previousWithSpecifiedGranularity(CursorGranularity.DEFAULT, true /* shouldWrap */,
true /* shouldScroll */, true /* useInputFocusAsPivotIfEmpty */,
InputModeManager.INPUT_MODE_KEYBOARD);
return true;
case KeyComboManager.ACTION_NAVIGATE_UP:
up(true, true, true, InputModeManager.INPUT_MODE_KEYBOARD);
return true;
case KeyComboManager.ACTION_NAVIGATE_DOWN:
down(true, true, true, InputModeManager.INPUT_MODE_KEYBOARD);
return true;
case KeyComboManager.ACTION_NAVIGATE_FIRST:
jumpToTop(InputModeManager.INPUT_MODE_KEYBOARD);
return true;
case KeyComboManager.ACTION_NAVIGATE_LAST:
jumpToBottom(InputModeManager.INPUT_MODE_KEYBOARD);
return true;
case KeyComboManager.ACTION_PERFORM_CLICK:
clickCurrent();
return true;
case KeyComboManager.ACTION_NAVIGATE_NEXT_WORD:
nextWithSpecifiedGranularity(CursorGranularity.WORD, false /* shouldWrap */,
true /* shouldScroll */, true /* useInputFocusAsPivotIfEmpty */,
InputModeManager.INPUT_MODE_KEYBOARD);
return true;
case KeyComboManager.ACTION_NAVIGATE_PREVIOUS_WORD:
previousWithSpecifiedGranularity(CursorGranularity.WORD, false /* shouldWrap */,
true /* shouldScroll */, true /* useInputFocusAsPivotIfEmpty */,
InputModeManager.INPUT_MODE_KEYBOARD);
return true;
case KeyComboManager.ACTION_NAVIGATE_NEXT_CHARACTER:
nextWithSpecifiedGranularity(CursorGranularity.CHARACTER, false /* shouldWrap */,
true /* shouldScroll */, true /* useInputFocusAsPivotIfEmpty */,
InputModeManager.INPUT_MODE_KEYBOARD);
return true;
case KeyComboManager.ACTION_NAVIGATE_PREVIOUS_CHARACTER:
previousWithSpecifiedGranularity(CursorGranularity.CHARACTER,
false /* shouldWrap */, true /* shouldScroll */,
true /* useInputFocusAsPivotIfEmpty */,
InputModeManager.INPUT_MODE_KEYBOARD);
return true;
case KeyComboManager.ACTION_PERFORM_LONG_CLICK:
longClickCurrent();
return true;
case KeyComboManager.ACTION_NAVIGATE_NEXT_HEADING:
performWebNavigationKeyCombo(HTML_ELEMENT_HEADING, true /* forward */);
return true;
case KeyComboManager.ACTION_NAVIGATE_PREVIOUS_HEADING:
performWebNavigationKeyCombo(HTML_ELEMENT_HEADING, false /* backward */);
return true;
case KeyComboManager.ACTION_NAVIGATE_NEXT_BUTTON:
performWebNavigationKeyCombo(HTML_ELEMENT_BUTTON, true /* forward */);
return true;
case KeyComboManager.ACTION_NAVIGATE_PREVIOUS_BUTTON:
performWebNavigationKeyCombo(HTML_ELEMENT_BUTTON, false /* backward */);
return true;
case KeyComboManager.ACTION_NAVIGATE_NEXT_CHECKBOX:
performWebNavigationKeyCombo(HTML_ELEMENT_CHECKBOX, true /* forward */);
return true;
case KeyComboManager.ACTION_NAVIGATE_PREVIOUS_CHECKBOX:
performWebNavigationKeyCombo(HTML_ELEMENT_CHECKBOX, false /* backward */);
return true;
case KeyComboManager.ACTION_NAVIGATE_NEXT_ARIA_LANDMARK:
performWebNavigationKeyCombo(HTML_ELEMENT_ARIA_LANDMARK, true /* forward */);
return true;
case KeyComboManager.ACTION_NAVIGATE_PREVIOUS_ARIA_LANDMARK:
performWebNavigationKeyCombo(HTML_ELEMENT_ARIA_LANDMARK, false /* backward */);
return true;
case KeyComboManager.ACTION_NAVIGATE_NEXT_EDIT_FIELD:
performWebNavigationKeyCombo(HTML_ELEMENT_EDIT_FIELD, true /* forward */);
return true;
case KeyComboManager.ACTION_NAVIGATE_PREVIOUS_EDIT_FIELD:
performWebNavigationKeyCombo(HTML_ELEMENT_EDIT_FIELD, false /* backward */);
return true;
case KeyComboManager.ACTION_NAVIGATE_NEXT_FOCUSABLE_ITEM:
performWebNavigationKeyCombo(HTML_ELEMENT_FOCUSABLE_ITEM, true /* forward */);
return true;
case KeyComboManager.ACTION_NAVIGATE_PREVIOUS_FOCUSABLE_ITEM:
performWebNavigationKeyCombo(HTML_ELEMENT_FOCUSABLE_ITEM, false /* backward */);
return true;
case KeyComboManager.ACTION_NAVIGATE_NEXT_HEADING_1:
performWebNavigationKeyCombo(HTML_ELEMENT_HEADING_1, true /* forward */);
return true;
case KeyComboManager.ACTION_NAVIGATE_PREVIOUS_HEADING_1:
performWebNavigationKeyCombo(HTML_ELEMENT_HEADING_1, false /* backward */);
return true;
case KeyComboManager.ACTION_NAVIGATE_NEXT_HEADING_2:
performWebNavigationKeyCombo(HTML_ELEMENT_HEADING_2, true /* forward */);
return true;
case KeyComboManager.ACTION_NAVIGATE_PREVIOUS_HEADING_2:
performWebNavigationKeyCombo(HTML_ELEMENT_HEADING_2, false /* backward */);
return true;
case KeyComboManager.ACTION_NAVIGATE_NEXT_HEADING_3:
performWebNavigationKeyCombo(HTML_ELEMENT_HEADING_3, true /* forward */);
return true;
case KeyComboManager.ACTION_NAVIGATE_PREVIOUS_HEADING_3:
performWebNavigationKeyCombo(HTML_ELEMENT_HEADING_3, false /* backward */);
return true;
case KeyComboManager.ACTION_NAVIGATE_NEXT_HEADING_4:
performWebNavigationKeyCombo(HTML_ELEMENT_HEADING_4, true /* forward */);
return true;
case KeyComboManager.ACTION_NAVIGATE_PREVIOUS_HEADING_4:
performWebNavigationKeyCombo(HTML_ELEMENT_HEADING_4, false /* backward */);
return true;
case KeyComboManager.ACTION_NAVIGATE_NEXT_HEADING_5:
performWebNavigationKeyCombo(HTML_ELEMENT_HEADING_5, true /* forward */);
return true;
case KeyComboManager.ACTION_NAVIGATE_PREVIOUS_HEADING_5:
performWebNavigationKeyCombo(HTML_ELEMENT_HEADING_5, false /* backward */);
return true;
case KeyComboManager.ACTION_NAVIGATE_NEXT_HEADING_6:
performWebNavigationKeyCombo(HTML_ELEMENT_HEADING_6, true /* forward */);
return true;
case KeyComboManager.ACTION_NAVIGATE_PREVIOUS_HEADING_6:
performWebNavigationKeyCombo(HTML_ELEMENT_HEADING_6, false /* backward */);
return true;
case KeyComboManager.ACTION_NAVIGATE_NEXT_LINK:
performWebNavigationKeyCombo(HTML_ELEMENT_LINK, true /* forward */);
return true;
case KeyComboManager.ACTION_NAVIGATE_PREVIOUS_LINK:
performWebNavigationKeyCombo(HTML_ELEMENT_LINK, false /* backward */);
return true;
case KeyComboManager.ACTION_NAVIGATE_NEXT_CONTROL:
performWebNavigationKeyCombo(HTML_ELEMENT_CONTROL, true /* forward */);
return true;
case KeyComboManager.ACTION_NAVIGATE_PREVIOUS_CONTROL:
performWebNavigationKeyCombo(HTML_ELEMENT_CONTROL, false /* backward */);
return true;
case KeyComboManager.ACTION_NAVIGATE_NEXT_GRAPHIC:
performWebNavigationKeyCombo(HTML_ELEMENT_GRAPHIC, true /* forward */);
return true;
case KeyComboManager.ACTION_NAVIGATE_PREVIOUS_GRAPHIC:
performWebNavigationKeyCombo(HTML_ELEMENT_GRAPHIC, false /* backward */);
return true;
case KeyComboManager.ACTION_NAVIGATE_NEXT_LIST_ITEM:
performWebNavigationKeyCombo(HTML_ELEMENT_LIST_ITEM, true /* forward */);
return true;
case KeyComboManager.ACTION_NAVIGATE_PREVIOUS_LIST_ITEM:
performWebNavigationKeyCombo(HTML_ELEMENT_LIST_ITEM, false /* backward */);
return true;
case KeyComboManager.ACTION_NAVIGATE_NEXT_LIST:
performWebNavigationKeyCombo(HTML_ELEMENT_LIST, true /* forward */);
return true;
case KeyComboManager.ACTION_NAVIGATE_PREVIOUS_LIST:
performWebNavigationKeyCombo(HTML_ELEMENT_LIST, false /* backward */);
return true;
case KeyComboManager.ACTION_NAVIGATE_NEXT_TABLE:
performWebNavigationKeyCombo(HTML_ELEMENT_TABLE, true /* forward */);
return true;
case KeyComboManager.ACTION_NAVIGATE_PREVIOUS_TABLE:
performWebNavigationKeyCombo(HTML_ELEMENT_TABLE, false /* backward */);
return true;
case KeyComboManager.ACTION_NAVIGATE_NEXT_COMBOBOX:
performWebNavigationKeyCombo(HTML_ELEMENT_COMBOBOX, true /* forward */);
return true;
case KeyComboManager.ACTION_NAVIGATE_PREVIOUS_COMBOBOX:
performWebNavigationKeyCombo(HTML_ELEMENT_COMBOBOX, false /* backward */);
return true;
case KeyComboManager.ACTION_NAVIGATE_NEXT_WINDOW:
navigateToNextOrPreviousWindow(TraversalStrategy.SEARCH_FOCUS_FORWARD,
WINDOW_TYPE_SYSTEM | WINDOW_TYPE_APPLICATION, FOCUS_STRATEGY_RESUME_FOCUS,
true /* useInputFocusAsPivot */, InputModeManager.INPUT_MODE_KEYBOARD);
return true;
case KeyComboManager.ACTION_NAVIGATE_PREVIOUS_WINDOW:
navigateToNextOrPreviousWindow(TraversalStrategy.SEARCH_FOCUS_BACKWARD,
WINDOW_TYPE_SYSTEM | WINDOW_TYPE_APPLICATION, FOCUS_STRATEGY_RESUME_FOCUS,
true /* useInputFocusAsPivot */, InputModeManager.INPUT_MODE_KEYBOARD);
return true;
}
return false;
}
private void alertWebNavigationHitEdge(String htmlElement, boolean forward) {
int resId = forward ? R.string.end_of_web : R.string.start_of_web;
String displayName = null;
switch (htmlElement) {
case HTML_ELEMENT_HEADING:
displayName = mService.getString(R.string.display_name_heading);
break;
case HTML_ELEMENT_BUTTON:
displayName = mService.getString(R.string.display_name_button);
break;
case HTML_ELEMENT_CHECKBOX:
displayName = mService.getString(R.string.display_name_checkbox);
break;
case HTML_ELEMENT_ARIA_LANDMARK:
displayName = mService.getString(R.string.display_name_aria_landmark);
break;
case HTML_ELEMENT_EDIT_FIELD:
displayName = mService.getString(R.string.display_name_edit_field);
break;
case HTML_ELEMENT_FOCUSABLE_ITEM:
displayName = mService.getString(R.string.display_name_focusable_item);
break;
case HTML_ELEMENT_HEADING_1:
displayName = mService.getString(R.string.display_name_heading_1);
break;
case HTML_ELEMENT_HEADING_2:
displayName = mService.getString(R.string.display_name_heading_2);
break;
case HTML_ELEMENT_HEADING_3:
displayName = mService.getString(R.string.display_name_heading_3);
break;
case HTML_ELEMENT_HEADING_4:
displayName = mService.getString(R.string.display_name_heading_4);
break;
case HTML_ELEMENT_HEADING_5:
displayName = mService.getString(R.string.display_name_heading_5);
break;
case HTML_ELEMENT_HEADING_6:
displayName = mService.getString(R.string.display_name_heading_6);
break;
case HTML_ELEMENT_LINK:
displayName = mService.getString(R.string.display_name_link);
break;
case HTML_ELEMENT_CONTROL:
displayName = mService.getString(R.string.display_name_control);
break;
case HTML_ELEMENT_GRAPHIC:
displayName = mService.getString(R.string.display_name_graphic);
break;
case HTML_ELEMENT_LIST_ITEM:
displayName = mService.getString(R.string.display_name_list_item);
break;
case HTML_ELEMENT_LIST:
displayName = mService.getString(R.string.display_name_list);
break;
case HTML_ELEMENT_TABLE:
displayName = mService.getString(R.string.display_name_table);
break;
case HTML_ELEMENT_COMBOBOX:
displayName = mService.getString(R.string.display_name_combobox);
break;
case HTML_ELEMENT_SECTION:
displayName = mService.getString(R.string.display_name_section);
break;
}
mService.getSpeechController().speak(
mService.getString(resId, displayName),
SpeechController.QUEUE_MODE_INTERRUPT,
0,
null);
}
private boolean performWebNavigationKeyCombo(String htmlElement, boolean forward) {
if (isSupportedHtmlElement(htmlElement)) {
boolean navigationSucceeded = forward ?
nextHtmlElement(htmlElement, InputModeManager.INPUT_MODE_KEYBOARD) :
previousHtmlElement(htmlElement, InputModeManager.INPUT_MODE_KEYBOARD);
if (!navigationSucceeded) {
alertWebNavigationHitEdge(htmlElement, forward);
}
return navigationSucceeded;
}
mService.getSpeechController().speak(
mService.getString(R.string.keycombo_announce_shortcut_not_supported),
SpeechController.QUEUE_MODE_INTERRUPT,
FeedbackItem.FLAG_NO_HISTORY,
null);
return false;
}
@Override
public void onAccessibilityEvent(AccessibilityEvent event) {
int eventType = event.getEventType();
if (eventType == AccessibilityEventCompat.TYPE_VIEW_ACCESSIBILITY_FOCUSED) {
final AccessibilityNodeInfo node = event.getSource();
if (node == null) {
if (LogUtils.LOG_LEVEL <= Log.WARN) {
Log.w(LOGTAG, "TYPE_VIEW_ACCESSIBILITY_FOCUSED event without a source.");
}
return;
}
// When a new view gets focus, clear the state of the granularity
// manager if this event came from a different node than the locked
// node but from the same window.
final AccessibilityNodeInfoCompat nodeCompat = new AccessibilityNodeInfoCompat(node);
mGranularityManager.onNodeFocused(nodeCompat);
if (mSwitchNodeWithGranularityDirection ==
AccessibilityNodeInfoCompat.ACTION_NEXT_AT_MOVEMENT_GRANULARITY) {
mGranularityManager.navigate(
AccessibilityNodeInfoCompat.ACTION_NEXT_AT_MOVEMENT_GRANULARITY);
} else if (mSwitchNodeWithGranularityDirection ==
AccessibilityNodeInfoCompat.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY) {
mGranularityManager.startFromLastNode();
mGranularityManager.navigate(
AccessibilityNodeInfoCompat.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY);
}
mSwitchNodeWithGranularityDirection = 0;
nodeCompat.recycle();
mReachedEdge = false;
mGranularityNavigationReachedEdge = false;
} else if (eventType == AccessibilityEvent.TYPE_VIEW_FOCUSED) {
final AccessibilityNodeInfo node = event.getSource();
if (node != null) {
final AccessibilityNodeInfoCompat nodeCompat =
new AccessibilityNodeInfoCompat(node);
// Note: we also need to check ROLE_EDIT_TEXT for JB MR1 and lower and for
// Chrome/WebView 51 and lower. We should check isEditable() first because it's
// more semantically appropriate for what we want.
if (nodeCompat.isEditable() || Role.getRole(nodeCompat) == Role.ROLE_EDIT_TEXT) {
AccessibilityNodeInfoUtils.recycleNodes(mLastEditable);
mLastEditable = nodeCompat;
} else {
nodeCompat.recycle();
}
}
} else if (mIsWindowNavigationAvailable &&
eventType == AccessibilityEvent.TYPE_WINDOWS_CHANGED) {
// Remove last focused nodes of non-existing windows.
Set<Integer> windowIdsToBeRemoved = new HashSet(mLastFocusedNodeMap.keySet());
for (AccessibilityWindowInfo window : mService.getWindows()) {
windowIdsToBeRemoved.remove(window.getId());
}
for (Integer windowIdToBeRemoved : windowIdsToBeRemoved) {
AccessibilityNodeInfoCompat removedNode =
mLastFocusedNodeMap.remove(windowIdToBeRemoved);
if (removedNode != null) {
removedNode.recycle();
}
}
}
}
private void rememberLastFocusedNode(AccessibilityNodeInfoCompat lastFocusedNode) {
if (!mIsWindowNavigationAvailable) {
return;
}
AccessibilityNodeInfoCompat oldNode = mLastFocusedNodeMap.put(lastFocusedNode.getWindowId(),
AccessibilityNodeInfoCompat.obtain(lastFocusedNode));
if (oldNode != null) {
oldNode.recycle();
}
}
/**
* Determines if we should try web navigation on a node. Returns false if we should just do
* normal navigation instead.
*
* @param node to navigate on
* @param direction The direction to navigate, one of {@link TraversalStrategy.SearchDirection}.
* @return {@code true} to attempt web navigation.
*/
private boolean shouldAttemptHtmlNavigation(AccessibilityNodeInfoCompat node,
@TraversalStrategy.SearchDirection int direction) {
if (direction == TraversalStrategy.SEARCH_FOCUS_FORWARD ||
direction == TraversalStrategy.SEARCH_FOCUS_BACKWARD) {
return WebInterfaceUtils.supportsWebActions(node);
} else {
return false;
}
}
/**
* Attempts to navigate the node using HTML navigation.
*
* @param node to navigate on
* @param direction The direction to navigate, one of {@link TraversalStrategy.SearchDirection}.
* @return {@code true} if navigation succeeded.
*/
private boolean attemptHtmlNavigation(AccessibilityNodeInfoCompat node,
@TraversalStrategy.SearchDirection int direction) {
if (direction == TraversalStrategy.SEARCH_FOCUS_FORWARD) {
return WebInterfaceUtils.performNavigationToHtmlElementAction(node,
WebInterfaceUtils.DIRECTION_FORWARD, "");
} else if (direction == TraversalStrategy.SEARCH_FOCUS_BACKWARD) {
return WebInterfaceUtils.performNavigationToHtmlElementAction(node,
WebInterfaceUtils.DIRECTION_BACKWARD, "");
} else {
return false;
}
}
}