com.google.android.marvin.mytalkback.CursorController.java Source code

Java tutorial

Introduction

Here is the source code for com.google.android.marvin.mytalkback.CursorController.java

Source

/*
 * Copyright (C) 2011 Google Inc.
 *
 * Licensed under the Apache License, Version 2.0 (the "License"); you may not
 * use this file except in compliance with the License. You may obtain a copy of
 * the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
 * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
 * License for the specific language governing permissions and limitations under
 * the License.
 */

package com.google.android.marvin.mytalkback;

import android.accessibilityservice.AccessibilityService;
import android.annotation.TargetApi;
import android.content.Context;
import android.content.SharedPreferences;
import android.os.Build;
import android.preference.PreferenceManager;
import android.support.v4.view.accessibility.AccessibilityEventCompat;
import android.support.v4.view.accessibility.AccessibilityNodeInfoCompat;
import android.util.Log;
import android.view.accessibility.AccessibilityEvent;
import android.view.accessibility.AccessibilityNodeInfo;

import com.google.android.marvin.mytalkback.TalkBackService.AccessibilityEventListener;
import com.googlecode.eyesfree.compat.CompatUtils;
import com.googlecode.eyesfree.utils.AccessibilityNodeInfoRef;
import com.googlecode.eyesfree.utils.AccessibilityNodeInfoUtils;
import com.googlecode.eyesfree.utils.LogUtils;
import com.googlecode.eyesfree.utils.NodeFilter;
import com.googlecode.eyesfree.utils.NodeFocusFinder;
import com.googlecode.eyesfree.utils.SharedPreferencesUtils;
import com.googlecode.eyesfree.utils.WebInterfaceUtils;

import java.util.HashSet;

/**
 * Handles screen reader cursor management.
 *
 * @author alanv@google.com (Alan Viverette)
 */
@TargetApi(Build.VERSION_CODES.JELLY_BEAN)
public class CursorController implements AccessibilityEventListener {
    /** The minimum API level supported by the cursor controller. */
    public static final int MIN_API_LEVEL = Build.VERSION_CODES.JELLY_BEAN;

    /** Represents navigation to next element. */
    private static final int NAVIGATION_DIRECTION_NEXT = 1;

    /** Represents navigation to previous element. */
    private static final int NAVIGATION_DIRECTION_PREVIOUS = -1;

    /**
     * Class for Samsung's TouchWiz implementation of AbsListView. May be
     * {@code null} on non-Samsung devices.
     */
    private static final Class<?> CLASS_TOUCHWIZ_TWABSLISTVIEW = CompatUtils
            .getClass("com.sec.android.touchwiz.widget.TwAbsListView");

    /** Set of "seen" nodes used for eliminating duplicates during navigation. */
    private final HashSet<AccessibilityNodeInfoCompat> mNavigateSeenNodes = new HashSet<AccessibilityNodeInfoCompat>();

    /** The host service. Used to access the root node. */
    private final AccessibilityService mService;

    /** Callback for responding to granularity change events. */
    private CursorControllerListener mListener;

    /** Handles traversal using granularity. */
    private CursorGranularityManager mGranularityManager;

    /** Whether the user hit an edge with the last swipe. */
    private boolean mReachedEdge;

    /**
     * Creates a new cursor controller using the specified input controller.
     *
     * @param service The accessibility service. Used to obtain the current root
     *            node.
     */
    public CursorController(AccessibilityService service) {
        mService = service;
        mGranularityManager = new CursorGranularityManager(service);
    }

    public void setListener(CursorControllerListener listener) {
        mListener = listener;
    }

    /**
     * Releases all resources held by this controller and save any persistent
     * preferences.
     */
    public void shutdown() {
        mGranularityManager.shutdown();
    }

    /**
     * Clears and replaces focus for the currently focused node. If there is no
     * currently focused node, this method is a no-op.
     *
     * @return Whether the current node was refocused.
     */
    public boolean refocus() {
        final AccessibilityNodeInfoCompat node = getCursor();
        if (node == null) {
            return false;
        }

        clearCursor();
        final boolean result = setCursor(node);
        node.recycle();
        return result;
    }

    /**
     * Attempts to move to the next item using the current navigation mode.
     *
     * @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.
     * @return {@code true} if successful.
     */
    public boolean next(boolean shouldWrap, boolean shouldScroll) {
        return navigateWithGranularity(NAVIGATION_DIRECTION_NEXT, shouldWrap, shouldScroll);
    }

    /**
     * Attempts to move to the previous item using the current navigation mode.
     *
     * @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.
     * @return {@code true} if successful.
     */
    public boolean previous(boolean shouldWrap, boolean shouldScroll) {
        return navigateWithGranularity(NAVIGATION_DIRECTION_PREVIOUS, shouldWrap, shouldScroll);
    }

    /**
     * Attempts to jump to the first item that appears on the screen.
     *
     * @return {@code true} if successful.
     */
    public boolean jumpToTop() {
        clearCursor();
        mReachedEdge = true;
        return next(true /*shouldWrap*/, false /*shouldScroll*/);
    }

    /**
     * Attempts to jump to the last item that appears on the screen.
     *
     * @return {@code true} if successful.
     */
    public boolean jumpToBottom() {
        clearCursor();
        mReachedEdge = true;
        return previous(true /*shouldWrap*/, false /*shouldScroll*/);
    }

    /**
     * Attempts to scroll forward within the current cursor.
     *
     * @return {@code true} if successful.
     */
    public boolean more() {
        return attemptScrollAction(AccessibilityNodeInfoCompat.ACTION_SCROLL_FORWARD);
    }

    /**
     * Attempts to scroll backward within the current cursor.
     *
     * @return {@code true} if successful.
     */
    public boolean less() {
        return attemptScrollAction(AccessibilityNodeInfoCompat.ACTION_SCROLL_BACKWARD);
    }

    /**
     * Attempts to click on the center of the current cursor.
     *
     * @return {@code true} if successful.
     */
    public boolean clickCurrent() {
        return performAction(AccessibilityNodeInfoCompat.ACTION_CLICK);
    }

    /**
     * Sends a long press event to the center of the current cursor.
     *
     * @return {@code true} if successful.
     */
    public boolean longPressCurrent() {
        return performAction(AccessibilityNodeInfoCompat.ACTION_LONG_CLICK);
    }

    /**
     * Attempts to move to the next reading level.
     *
     * @return {@code true} if successful.
     */
    public boolean nextGranularity() {
        return adjustGranularity(1);
    }

    /**
     * Attempts to move to the previous reading level.
     *
     * @return {@code true} if successful.
     */
    public boolean previousGranularity() {
        return adjustGranularity(-1);
    }

    /**
     * Adjust the cursor's granularity by moving it directly to the specified
     * granularity. If the granularity is {@link CursorGranularity#DEFAULT},
     * unlocks navigation; otherwise, locks navigation to the current cursor.
     *
     * @param granularity The {@link CursorGranularity} to request.
     * @return {@code true} if the granularity change was successful,
     *         {@code false} otherwise.
     */
    public boolean setGranularity(CursorGranularity granularity, boolean fromUser) {
        AccessibilityNodeInfoCompat current = null;

        try {
            current = getCursor();
            if (current == null) {
                return false;
            }

            if (!mGranularityManager.setGranularityAt(current, granularity)) {
                return false;
            }

            if (mListener != null) {
                mListener.onGranularityChanged(granularity, fromUser);
            }

            // If navigating at cursor granularity, speak the current character
            // by quickly navigating next and previous.
            if (granularity == CursorGranularity.CHARACTER) {
                mGranularityManager.navigate(NAVIGATION_DIRECTION_NEXT);
                mGranularityManager.navigate(NAVIGATION_DIRECTION_PREVIOUS);
            }
        } finally {
            AccessibilityNodeInfoUtils.recycleNodes(current);
        }

        return true;
    }

    /**
     * Sets the current cursor position.
     *
     * @param node The node to set as the cursor.
     * @return {@code true} if successful.
     */
    public boolean setCursor(AccessibilityNodeInfoCompat node) {
        return node.performAction(AccessibilityNodeInfoCompat.ACTION_ACCESSIBILITY_FOCUS);
    }

    /**
     * Sets the current state of selection mode for navigation within text
     * content. When enabled, the manager will attempt to extend selection
     * during navigation. If the target node of selection mode is not locked to
     * a granularity, calling this method will switch to
     * {@link CursorGranularity#CHARACTER}
     *
     * @param node The node on which selection mode should be enabled.
     * @param active {@code true} to activate selection mode, {@code false} to
     *            deactivate.
     */
    public void setSelectionModeActive(AccessibilityNodeInfoCompat node, boolean active) {
        if (active && !mGranularityManager.isLockedTo(node)) {
            setGranularity(CursorGranularity.CHARACTER, false /* fromUser */);
        }

        mGranularityManager.setSelectionModeActive(active);
    }

    /**
     * @return {@code true} if selection mode is active, {@code false}
     *         otherwise.
     */
    public boolean isSelectionModeActive() {
        return mGranularityManager.isSelectionModeActive();
    }

    /**
     * Clears the current cursor position.
     */
    public void clearCursor() {
        final AccessibilityNodeInfo root = mService.getRootInActiveWindow();
        if (root == null) {
            return;
        }

        final AccessibilityNodeInfo focused = root.findFocus(AccessibilityNodeInfo.FOCUS_ACCESSIBILITY);
        if (focused == null) {
            return;
        }

        focused.performAction(AccessibilityNodeInfo.ACTION_CLEAR_ACCESSIBILITY_FOCUS);
    }

    /**
     * Returns the node in the active window that has accessibility focus. If no
     * node has focus, or if the focused node is invisible, returns the root
     * node.
     * <p>
     * The client is responsible for recycling the resulting node.
     *
     * @return The node in the active window that has accessibility focus.
     */
    public AccessibilityNodeInfoCompat getCursor() {
        final AccessibilityNodeInfo activeRoot = mService.getRootInActiveWindow();
        if (activeRoot == null) {
            return null;
        }

        final AccessibilityNodeInfoCompat compatRoot = new AccessibilityNodeInfoCompat(activeRoot);
        final AccessibilityNodeInfoCompat focusedNode = compatRoot
                .findFocus(AccessibilityNodeInfoCompat.FOCUS_ACCESSIBILITY);

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

        // TODO: We should treat non-focusable nodes (e.g. invisible or fail the
        // heuristic we're using elsewhere) then we should try to find a
        // focusable node.
        if (!AccessibilityNodeInfoUtils.isVisibleOrLegacy(focusedNode)) {
            focusedNode.recycle();
            return compatRoot;
        }

        return focusedNode;
    }

    /**
     * Return the current granularity at the specified node, or
     * {@link CursorGranularity#DEFAULT} if none is set. Always returns
     * {@link CursorGranularity#DEFAULT} if granular navigation is not locked to
     * the specified node.
     *
     * @param node The node to check.
     * @return A cursor granularity.
     */
    public CursorGranularity getGranularityAt(AccessibilityNodeInfoCompat node) {
        if (mGranularityManager.isLockedTo(node)) {
            return mGranularityManager.getRequestedGranularity();
        }

        return CursorGranularity.DEFAULT;
    }

    /**
     * Attempts to scroll using the specified action.
     *
     * @param action The scroll action to perform.
     * @return Whether the action was performed.
     */
    private boolean attemptScrollAction(int action) {
        AccessibilityNodeInfoCompat cursor = null;
        AccessibilityNodeInfoCompat scrollableNode = null;

        try {
            cursor = getCursor();
            if (cursor == null) {
                return false;
            }

            scrollableNode = getBestScrollableNode(cursor);
            if (scrollableNode == null) {
                return false;
            }

            final boolean performedAction = scrollableNode.performAction(action);
            if (performedAction && (mListener != null)) {
                mListener.onActionPerformed(action);
            }

            return performedAction;
        } finally {
            AccessibilityNodeInfoUtils.recycleNodes(cursor, scrollableNode);
        }
    }

    private AccessibilityNodeInfoCompat getBestScrollableNode(AccessibilityNodeInfoCompat cursor) {
        final AccessibilityNodeInfoCompat predecessor = AccessibilityNodeInfoUtils
                .getSelfOrMatchingAncestor(mService, cursor, AccessibilityNodeInfoUtils.FILTER_SCROLLABLE);

        if (predecessor != null) {
            return predecessor;
        }

        // If there isn't a scrollable predecessor, we'll have to search the
        // screen for the "best" scrollable node. Currently, "best" means:
        //   1. Matches FILTER_SCROLLABLE
        //   2. Appears first during BFS from the root node

        final AccessibilityNodeInfoCompat root = AccessibilityNodeInfoUtils.getRoot(cursor);
        final AccessibilityNodeInfoCompat searched = AccessibilityNodeInfoUtils.searchFromBfs(mService, root,
                AccessibilityNodeInfoUtils.FILTER_SCROLLABLE);

        if (searched != null) {
            return searched;
        }

        return null;
    }

    /**
     * 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 = getCursor();
            if (currentNode == null) {
                return false;
            }

            final boolean wasAdjusted = mGranularityManager.adjustGranularityAt(currentNode, direction);
            if (wasAdjusted && (mListener != null)) {
                mListener.onGranularityChanged(mGranularityManager.getRequestedGranularity(), true);
            }

            return wasAdjusted;
        } finally {
            AccessibilityNodeInfoUtils.recycleNodes(currentNode);
        }
    }

    /**
     * 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.
     * @return true on success, false on failure.
     */
    private boolean navigateWithGranularity(int direction, boolean shouldWrap, boolean shouldScroll) {
        final int navigationAction;
        final int scrollDirection;
        final int focusSearchDirection;
        final int edgeDirection;

        // Map the navigation action to various directions.
        if (direction == NAVIGATION_DIRECTION_NEXT) {
            navigationAction = AccessibilityNodeInfoCompat.ACTION_NEXT_AT_MOVEMENT_GRANULARITY;
            scrollDirection = AccessibilityNodeInfoCompat.ACTION_SCROLL_FORWARD;
            focusSearchDirection = NodeFocusFinder.SEARCH_FORWARD;
            edgeDirection = 1;
        } else {
            navigationAction = AccessibilityNodeInfoCompat.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY;
            scrollDirection = AccessibilityNodeInfoCompat.ACTION_SCROLL_BACKWARD;
            focusSearchDirection = NodeFocusFinder.SEARCH_BACKWARD;
            edgeDirection = -1;
        }

        AccessibilityNodeInfoCompat current = null;
        AccessibilityNodeInfoCompat target = null;

        try {
            current = getCursor();
            if (current == null) {
                return false;
            }

            // 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);
                return (result == CursorGranularityManager.SUCCESS);
            }

            // If the current node has web content, attempt HTML navigation.
            if (WebInterfaceUtils.hasWebContent(current) && attemptHtmlNavigation(current, direction)) {
                return true;
            }

            // If the user has disabled automatic scrolling, don't attempt to scroll.
            // TODO(caseyburkhardt): Remove once auto-scroll is settled.
            if (shouldScroll) {
                final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(mService);
                shouldScroll = SharedPreferencesUtils.getBooleanPref(prefs, mService.getResources(),
                        R.string.pref_auto_scroll_key, R.bool.pref_auto_scroll_default);
            }

            // 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.isEdgeListItem(mService, current, edgeDirection,
                    FILTER_AUTO_SCROLL) && attemptScrollAction(scrollDirection)) {
                return true;
            }

            // Otherwise, move focus to next or previous focusable node.
            target = navigateFrom(current, focusSearchDirection);
            if ((target != null) && setCursor(target)) {
                mReachedEdge = false;
                return true;
            }

            if (mReachedEdge && shouldWrap) {
                mReachedEdge = false;
                return navigateWrapAround(focusSearchDirection);
            }

            // TODO(alanv): We need to reset the "reached edge" flag after the user
            // touch explores to a new node.
            mReachedEdge = true;
            return false;
        } finally {
            AccessibilityNodeInfoUtils.recycleNodes(current, target);
        }
    }

    private boolean navigateWrapAround(int direction) {
        final AccessibilityNodeInfo root = mService.getRootInActiveWindow();
        if (root == null) {
            return false;
        }

        AccessibilityNodeInfoCompat compatRoot = null;
        AccessibilityNodeInfoCompat tempNode = null;
        AccessibilityNodeInfoCompat wrapNode = null;

        try {
            compatRoot = new AccessibilityNodeInfoCompat(root);

            switch (direction) {
            case NodeFocusFinder.SEARCH_FORWARD:
                wrapNode = navigateSelfOrFrom(compatRoot, direction);
                break;
            case NodeFocusFinder.SEARCH_BACKWARD:
                tempNode = getLastNodeFrom(compatRoot);
                wrapNode = navigateSelfOrFrom(tempNode, direction);
                break;
            }

            if (wrapNode == null) {
                LogUtils.log(this, Log.ERROR, "Failed to wrap navigation");
                return false;
            }

            return setCursor(wrapNode);
        } finally {
            AccessibilityNodeInfoUtils.recycleNodes(compatRoot, tempNode, wrapNode);
        }
    }

    private static AccessibilityNodeInfoCompat getLastNodeFrom(AccessibilityNodeInfoCompat root) {
        final AccessibilityNodeInfoRef ref = AccessibilityNodeInfoRef.obtain(root);
        ref.lastDescendant();
        return ref.release();
    }

    private AccessibilityNodeInfoCompat navigateSelfOrFrom(AccessibilityNodeInfoCompat node, int direction) {
        if (node == null) {
            return null;
        }

        if (AccessibilityNodeInfoUtils.shouldFocusNode(mService, node)) {
            return AccessibilityNodeInfoCompat.obtain(node);
        }

        return navigateFrom(node, direction);
    }

    private AccessibilityNodeInfoCompat navigateFrom(AccessibilityNodeInfoCompat node, int direction) {
        if (node == null) {
            return null;
        }

        AccessibilityNodeInfoCompat next = null;

        try {
            // Be cautious and always clear the list of seen nodes.
            AccessibilityNodeInfoUtils.recycleNodes(mNavigateSeenNodes);

            next = NodeFocusFinder.focusSearch(node, direction);

            while ((next != null) && !AccessibilityNodeInfoUtils.shouldFocusNode(mService, next)) {
                if (mNavigateSeenNodes.contains(next)) {
                    LogUtils.log(this, Log.ERROR, "Found duplicate during traversal: %s", next.getInfo());
                    // TODO(alanv): Should we return null here or just stop traversing?
                    break;
                }

                LogUtils.log(this, Log.VERBOSE, "Search strategy rejected node: %s", next.getInfo());

                mNavigateSeenNodes.add(next);

                next = NodeFocusFinder.focusSearch(next, direction);
            }

            return next;
        } finally {
            AccessibilityNodeInfoUtils.recycleNodes(mNavigateSeenNodes);
        }
    }

    /**
     * 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();
            if (current == null) {
                return false;
            }

            return current.performAction(action);
        } finally {
            AccessibilityNodeInfoUtils.recycleNodes(current);
        }
    }

    @Override
    public void onAccessibilityEvent(AccessibilityEvent event) {
        if (event.getEventType() == AccessibilityEventCompat.TYPE_VIEW_ACCESSIBILITY_FOCUSED) {
            final AccessibilityNodeInfo node = event.getSource();
            if (node == null) {
                LogUtils.log(this, Log.WARN, "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);
            nodeCompat.recycle();
        }
    }

    /**
     * Attempts to navigate the node using HTML navigation.
     *
     * @param node
     * @param direction The direction to navigate, one of:
     *            <ul>
     *            <li>{@link #NAVIGATION_DIRECTION_NEXT}</li>
     *            <li>{@link #NAVIGATION_DIRECTION_PREVIOUS}</li>
     *            </ul>
     * @return {@code true} if navigation succeeded.
     */
    private static boolean attemptHtmlNavigation(AccessibilityNodeInfoCompat node, int direction) {
        final int action = (direction == NAVIGATION_DIRECTION_NEXT)
                ? AccessibilityNodeInfoCompat.ACTION_NEXT_HTML_ELEMENT
                : AccessibilityNodeInfoCompat.ACTION_PREVIOUS_HTML_ELEMENT;
        return node.performAction(action);
    }

    /**
     * Filter that defines which types of views should be auto-scrolled.
     * Generally speaking, only accepts views that are capable of showing
     * partially-visible data.
     * <p>
     * Accepts the following classes (and sub-classes thereof):
     * <ul>
     * <li>{@link android.widget.AbsListView} (and Samsung's TwAbsListView)
     * <li>{@link android.widget.AbsSpinner}
     * <li>{@link android.widget.ScrollView}
     * <li>{@link android.widget.HorizontalScrollView}
     * </ul>
     * <p>
     * Specifically excludes {@link android.widget.AdapterViewAnimator} and
     * sub-classes, since they represent overlapping views. Also excludes
     * {@link android.support.v4.view.ViewPager} since it exclusively represents
     * off-screen views.
     */
    private static final NodeFilter FILTER_AUTO_SCROLL = new NodeFilter() {
        @Override
        public boolean accept(Context context, AccessibilityNodeInfoCompat node) {
            return AccessibilityNodeInfoUtils.nodeMatchesAnyClassByType(context, node,
                    android.widget.AbsListView.class, android.widget.AbsSpinner.class,
                    android.widget.ScrollView.class, android.widget.HorizontalScrollView.class,
                    CLASS_TOUCHWIZ_TWABSLISTVIEW);
        }
    };

    interface CursorControllerListener {
        public void onGranularityChanged(CursorGranularity granularity, boolean fromUser);

        public void onActionPerformed(int action);
    }
}