com.android.talkback.controller.CursorControllerApp.java Source code

Java tutorial

Introduction

Here is the source code for com.android.talkback.controller.CursorControllerApp.java

Source

/*
 * 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;
        }
    }
}