com.android.talkback.eventprocessor.ProcessorFocusAndSingleTap.java Source code

Java tutorial

Introduction

Here is the source code for com.android.talkback.eventprocessor.ProcessorFocusAndSingleTap.java

Source

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

package com.android.talkback.eventprocessor;

import android.os.SystemClock;
import android.util.Pair;

import android.annotation.TargetApi;
import android.content.Context;
import android.os.Build;
import android.os.Message;
import android.support.v4.view.accessibility.AccessibilityEventCompat;
import android.support.v4.view.accessibility.AccessibilityNodeInfoCompat;
import android.support.v4.view.accessibility.AccessibilityRecordCompat;
import android.util.Log;
import android.view.ViewConfiguration;
import android.view.accessibility.AccessibilityEvent;
import android.view.accessibility.AccessibilityManager;
import android.view.accessibility.AccessibilityNodeInfo;
import com.android.talkback.InputModeManager;
import com.android.talkback.R;
import com.android.talkback.SpeechController;
import com.android.utils.Role;
import com.google.android.marvin.talkback.TalkBackService;
import com.android.talkback.controller.CursorController;
import com.android.talkback.controller.FeedbackController;
import com.android.talkback.tutorial.AccessibilityTutorialActivity;
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.WeakReferenceHandler;
import com.android.utils.WebInterfaceUtils;
import com.android.utils.compat.accessibilityservice.AccessibilityServiceCompatUtils;
import com.android.utils.traversal.TraversalStrategy;
import com.android.utils.traversal.TraversalStrategyUtils;

import java.util.ArrayDeque;
import java.util.Iterator;

/**
 * Places focus in response to various {@link AccessibilityEvent} types,
 * including hover events, list scrolling, and placing input focus. Also handles
 * single-tap activation in response to touch interaction events.
 */
public class ProcessorFocusAndSingleTap implements AccessibilityEventListener, CursorController.ScrollListener {
    /** Single-tap requires JellyBean (API 17). */
    public static final int MIN_API_LEVEL_SINGLE_TAP = Build.VERSION_CODES.JELLY_BEAN_MR1;

    /** Whether refocusing is enabled. Requires API 17. */
    private static final boolean SUPPORTS_INTERACTION_EVENTS = (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1);

    /** The timeout after which an event is no longer considered a tap. */
    private static final long TAP_TIMEOUT = ViewConfiguration.getJumpTapTimeout();

    private static final int MAX_CACHED_FOCUSED_RECORD_QUEUE = 10;

    private final TalkBackService mService;
    private final SpeechController mSpeechController;
    private final CursorController mCursorController;
    private final AccessibilityManager mAccessibilityManager;

    // The previous AccessibilityRecordCompat that failed to focus, but it is potentially
    // focusable when view scrolls, or window state changes.
    private final ArrayDeque<Pair<AccessibilityRecordCompat, Integer>> mCachedPotentiallyFocusableRecordQueue = new ArrayDeque<>(
            MAX_CACHED_FOCUSED_RECORD_QUEUE);

    private @TraversalStrategy.SearchDirectionOrUnknown int mLastScrollDirection;
    private int mLastScrollFromIndex = -1;
    private int mLastScrollToIndex = -1;
    private int mLastScrollX = -1;
    private int mLastScrollY = -1;

    /**
     * Whether single-tap activation is enabled, always {@code false} on
     * versions prior to Jelly Bean MR1.
     */
    private boolean mSingleTapEnabled;

    /** The first focused item touched during the current touch interaction. */
    private AccessibilityNodeInfoCompat mFirstFocusedItem;

    private AccessibilityNodeInfoCompat mActionScrolledNode;
    private AccessibilityNodeInfoCompat mLastFocusedItem;

    /** The number of items focused during the current touch interaction. */
    private int mFocusedItems;

    /** Whether the current interaction may result in refocusing. */
    private boolean mMaybeRefocus;

    /** Whether the current interaction may result in a single tap. */
    private boolean mMaybeSingleTap;

    private long mLastRefocusStartTime = 0;
    private long mLastRefocusEndTime = 0;
    private AccessibilityNodeInfoCompat mLastRefocusedNode = null;

    private FirstWindowFocusManager mFirstWindowFocusManager;

    public ProcessorFocusAndSingleTap(CursorController cursorController, FeedbackController feedbackController,
            SpeechController speechController, TalkBackService service) {
        if (cursorController == null)
            throw new IllegalStateException();
        if (feedbackController == null)
            throw new IllegalStateException();
        if (speechController == null)
            throw new IllegalStateException();

        mService = service;
        mSpeechController = speechController;
        mCursorController = cursorController;
        mCursorController.addScrollListener(this);
        mHandler = new FollowFocusHandler(this, feedbackController);
        mAccessibilityManager = (AccessibilityManager) service.getSystemService(Context.ACCESSIBILITY_SERVICE);
        mFirstWindowFocusManager = new FirstWindowFocusManager(service);
    }

    @Override
    public void onAccessibilityEvent(AccessibilityEvent event) {
        if (!mAccessibilityManager.isTouchExplorationEnabled()) {
            // Don't manage focus when touch exploration is disabled.
            return;
        }

        final AccessibilityRecordCompat record = AccessibilityEventCompat.asRecord(event);

        switch (event.getEventType()) {
        case AccessibilityEvent.TYPE_VIEW_CLICKED:
            // Prevent conflicts between lift-to-type and single tap. This
            // is only necessary when a CLICKED event occurs during a touch
            // interaction sequence (e.g. before an INTERACTION_END event),
            // but it isn't harmful to call more often.
            cancelSingleTap();
            break;
        case AccessibilityEvent.TYPE_VIEW_FOCUSED:
        case AccessibilityEvent.TYPE_VIEW_SELECTED:
            if (!mFirstWindowFocusManager.shouldProcessFocusEvent(event)) {
                return;
            }
            boolean isViewFocusedEvent = (AccessibilityEvent.TYPE_VIEW_FOCUSED == event.getEventType());
            if (!setFocusOnView(record, isViewFocusedEvent)) {
                // It is possible that the only speakable child of source node is invisible
                // at the moment, but could be made visible when view scrolls, or window state
                // changes. Cache it now. And try to focus on the cached record on:
                // VIEW_SCROLLED, WINDOW_CONTENT_CHANGED, WINDOW_STATE_CHANGED.
                // The above 3 are the events that could affect view visibility.
                if (mCachedPotentiallyFocusableRecordQueue.size() == MAX_CACHED_FOCUSED_RECORD_QUEUE) {
                    mCachedPotentiallyFocusableRecordQueue.remove().first.recycle();
                }

                mCachedPotentiallyFocusableRecordQueue
                        .add(new Pair<>(AccessibilityRecordCompat.obtain(record), event.getEventType()));
            } else {
                emptyCachedPotentialFocusQueue();
            }
            break;
        case AccessibilityEvent.TYPE_VIEW_HOVER_ENTER:
            final AccessibilityNodeInfoCompat touchedNode = record.getSource();
            try {
                if ((touchedNode != null) && !setFocusFromViewHoverEnter(touchedNode)) {
                    mHandler.sendEmptyTouchAreaFeedbackDelayed(touchedNode);
                }
            } finally {
                AccessibilityNodeInfoUtils.recycleNodes(touchedNode);
            }

            break;
        case AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED:
            mHandler.cancelEmptyTouchAreaFeedback();
            AccessibilityNodeInfo source = event.getSource();
            if (source != null) {
                AccessibilityNodeInfoCompat compatSource = new AccessibilityNodeInfoCompat(source);
                mLastFocusedItem = AccessibilityNodeInfoCompat.obtain(compatSource);
            }
            break;
        case AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED:
            mFirstWindowFocusManager.registerWindowChange(event);
            handleWindowStateChange(event);
            break;
        case AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED:
            handleWindowContentChanged();
            break;
        case AccessibilityEvent.TYPE_VIEW_SCROLLED:
            handleViewScrolled(event, record);
            break;
        case AccessibilityEventCompat.TYPE_TOUCH_INTERACTION_START:
            // This event type only exists on API 17+ (JB MR1).
            handleTouchInteractionStart();
            break;
        case AccessibilityEventCompat.TYPE_TOUCH_INTERACTION_END:
            // This event type only exists on API 17+ (JB MR1).
            handleTouchInteractionEnd();
            break;
        }
    }

    private void emptyCachedPotentialFocusQueue() {
        if (mCachedPotentiallyFocusableRecordQueue.isEmpty()) {
            return;
        }

        for (Pair<AccessibilityRecordCompat, Integer> focusableRecord : mCachedPotentiallyFocusableRecordQueue) {
            focusableRecord.first.recycle();
        }
        mCachedPotentiallyFocusableRecordQueue.clear();
    }

    /**
     * Sets whether single-tap activation is enabled. If it is, the follow focus
     * processor needs to avoid re-focusing items that are already focused.
     *
     * @param enabled Whether single-tap activation is enabled.
     */
    @TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR1)
    public void setSingleTapEnabled(boolean enabled) {
        mSingleTapEnabled = enabled;
    }

    private void handleWindowStateChange(AccessibilityEvent event) {
        if (mLastFocusedItem != null) {
            mLastFocusedItem.recycle();
            mLastFocusedItem = null;
        }

        clearScrollAction();
        mLastScrollFromIndex = -1;
        mLastScrollToIndex = -1;

        // Since we may get WINDOW_STATE_CHANGE events from the keyboard even
        // though the active window is still another app, only clear focus if
        // the event's window ID matches the cursor's window ID.
        final AccessibilityNodeInfoCompat cursor = mCursorController.getCursor();
        if ((cursor != null) && (cursor.getWindowId() == event.getWindowId())) {
            ensureFocusConsistency();
        }
        if (cursor != null) {
            cursor.recycle();
        }
        tryFocusCachedRecord();
    }

    private void handleWindowContentChanged() {
        mHandler.followContentChangedDelayed();

        tryFocusCachedRecord();
    }

    private void handleViewScrolled(AccessibilityEvent event, AccessibilityRecordCompat record) {
        AccessibilityNodeInfoCompat source = null;

        @TraversalStrategy.SearchDirectionOrUnknown
        int direction;
        boolean wasScrollAction;
        if (mActionScrolledNode != null) {
            source = record.getSource();
            if (source == null)
                return;
            if (source.equals(mActionScrolledNode)) {
                direction = mLastScrollDirection;
                wasScrollAction = true;
                clearScrollAction();
            } else {
                direction = getScrollDirection(event);
                wasScrollAction = false;
            }
        } else {
            direction = getScrollDirection(event);
            wasScrollAction = false;
        }

        followScrollEvent(source, record, direction, wasScrollAction);

        mLastScrollFromIndex = record.getFromIndex();
        mLastScrollToIndex = record.getToIndex();
        mLastScrollX = record.getScrollX();
        mLastScrollY = record.getScrollY();

        tryFocusCachedRecord();
    }

    private @TraversalStrategy.SearchDirectionOrUnknown int getScrollDirection(AccessibilityEvent event) {
        //check scroll of AdapterViews
        if (event.getFromIndex() > mLastScrollFromIndex || event.getToIndex() > mLastScrollToIndex) {
            return TraversalStrategy.SEARCH_FOCUS_FORWARD;
        } else if (event.getFromIndex() < mLastScrollFromIndex || event.getToIndex() < mLastScrollToIndex) {
            return TraversalStrategy.SEARCH_FOCUS_BACKWARD;
        }

        //check scroll of ScrollViews
        if (event.getScrollX() > mLastScrollX || event.getScrollY() > mLastScrollY) {
            return TraversalStrategy.SEARCH_FOCUS_FORWARD;
        } else if (event.getScrollX() < mLastScrollX || event.getScrollY() < mLastScrollY) {
            return TraversalStrategy.SEARCH_FOCUS_BACKWARD;
        }

        return TraversalStrategy.SEARCH_FOCUS_UNKNOWN;
    }

    private void clearScrollAction() {
        mLastScrollDirection = TraversalStrategy.SEARCH_FOCUS_UNKNOWN;
        if (mActionScrolledNode != null) {
            mActionScrolledNode.recycle();
        }

        mActionScrolledNode = null;
    }

    private void tryFocusCachedRecord() {
        if (mCachedPotentiallyFocusableRecordQueue.isEmpty()) {
            return;
        }

        Iterator<Pair<AccessibilityRecordCompat, Integer>> iterator = mCachedPotentiallyFocusableRecordQueue
                .descendingIterator();

        while (iterator.hasNext()) {
            Pair<AccessibilityRecordCompat, Integer> focusableRecord = iterator.next();
            AccessibilityRecordCompat record = focusableRecord.first;
            int eventType = focusableRecord.second;
            if (setFocusOnView(record, eventType == AccessibilityEvent.TYPE_VIEW_FOCUSED)) {
                emptyCachedPotentialFocusQueue();
                return;
            }
        }
    }

    private void followScrollEvent(AccessibilityNodeInfoCompat source, AccessibilityRecordCompat record,
            @TraversalStrategy.SearchDirectionOrUnknown int direction, boolean wasScrollAction) {
        // SEARCH_FOCUS_UNKNOWN can be passed, so need to guarantee that direction is a
        // @TraversalStrategy.SearchDirection before continuing.
        if (direction == TraversalStrategy.SEARCH_FOCUS_UNKNOWN) {
            return;
        }

        AccessibilityNodeInfoCompat root = null;
        AccessibilityNodeInfoCompat accessibilityFocused = null;

        try {
            // First, see if we've already placed accessibility focus.
            root = AccessibilityServiceCompatUtils.getRootInAccessibilityFocusedWindow(mService);
            if (root == null) {
                return;
            }

            accessibilityFocused = root.findFocus(AccessibilityNodeInfo.FOCUS_ACCESSIBILITY);
            boolean validAccessibilityFocus = AccessibilityNodeInfoUtils.shouldFocusNode(accessibilityFocused);
            // there are cases when scrollable container was scrolled and application set
            // focus on node that is on new container page. We should keep this focus
            boolean hasInputFocus = accessibilityFocused != null && accessibilityFocused.isFocused();

            if (validAccessibilityFocus && (hasInputFocus || !wasScrollAction)) {
                // focused on valid node and scrolled not by scroll action
                // keep focus
                return;
            }

            if (validAccessibilityFocus) {
                // focused on valid node and scrolled by scroll action
                // focus on next focusable node
                if (source == null) {
                    source = record.getSource();
                    if (source == null)
                        return;
                }
                if (!AccessibilityNodeInfoUtils.hasAncestor(accessibilityFocused, source)) {
                    return;
                }
                TraversalStrategy traversal = TraversalStrategyUtils.getTraversalStrategy(root, direction);
                try {
                    focusNextFocusedNode(traversal, accessibilityFocused, direction);
                } finally {
                    traversal.recycle();
                }
            } else {
                if (mLastFocusedItem == null) {
                    // there was no focus - don't set focus
                    return;
                }

                if (source == null) {
                    source = record.getSource();
                    if (source == null)
                        return;
                }
                if (mLastFocusedItem.equals(source)
                        || AccessibilityNodeInfoUtils.hasAncestor(mLastFocusedItem, source)) {

                    // There is no focus now, but it was on source node's child before
                    // Try focusing the appropriate child node.
                    if (tryFocusingChild(source, direction)) {
                        return;
                    }

                    // Finally, try focusing the scrollable node itself.
                    tryFocusing(source);
                }
            }
        } finally {
            AccessibilityNodeInfoUtils.recycleNodes(root, accessibilityFocused);
        }
    }

    private boolean focusNextFocusedNode(TraversalStrategy traversal, AccessibilityNodeInfoCompat node,
            @TraversalStrategy.SearchDirection int direction) {
        if (node == null) {
            return false;
        }

        NodeFilter filter = new NodeFilter() {
            @Override
            public boolean accept(AccessibilityNodeInfoCompat node) {
                return node != null && AccessibilityNodeInfoUtils.shouldFocusNode(node) && PerformActionUtils
                        .performAction(node, AccessibilityNodeInfoCompat.ACTION_ACCESSIBILITY_FOCUS);
            }
        };

        AccessibilityNodeInfoCompat candidateFocus = AccessibilityNodeInfoUtils.searchFocus(traversal, node,
                direction, filter);

        return candidateFocus != null;
    }

    /**
     * @param record the AccessbilityRecord for the event
     * @param isViewFocusedEvent true if the event is TYPE_VIEW_FOCUSED, otherwise it is
     * TYPE_VIEW_SELECTED.
     */
    private boolean setFocusOnView(AccessibilityRecordCompat record, boolean isViewFocusedEvent) {
        AccessibilityNodeInfoCompat source = null;
        AccessibilityNodeInfoCompat existing = null;
        AccessibilityNodeInfoCompat child = null;

        try {
            source = record.getSource();
            if (source == null) {
                return false;
            }

            if (record.getItemCount() > 0) {
                final int index = (record.getCurrentItemIndex() - record.getFromIndex());
                if (index >= 0 && index < source.getChildCount()) {
                    child = source.getChild(index);
                    if (child != null) {
                        if (AccessibilityNodeInfoUtils.isTopLevelScrollItem(child) && tryFocusing(child)) {
                            return true;
                        }
                    }
                }
            }

            if (!isViewFocusedEvent) {
                return false;
            }

            // Logic below is only specific to TYPE_VIEW_FOCUSED event
            // Try focusing the source node.
            if (tryFocusing(source)) {
                return true;
            }

            // If we fail and the source node already contains focus, abort.
            existing = source.findFocus(AccessibilityNodeInfo.FOCUS_ACCESSIBILITY);
            if (existing != null) {
                return false;
            }

            // If we fail to focus a node, perhaps because it is a focusable
            // but non-speaking container, we should still attempt to place
            // focus on a speaking child within the container.
            child = AccessibilityNodeInfoUtils.searchFromBfs(source,
                    AccessibilityNodeInfoUtils.FILTER_SHOULD_FOCUS);
            return child != null && tryFocusing(child);

        } finally {
            AccessibilityNodeInfoUtils.recycleNodes(source, existing, child);
        }
    }

    /**
     * Attempts to place focus within a new window.
     */
    private boolean ensureFocusConsistency() {
        AccessibilityNodeInfoCompat root = null;
        AccessibilityNodeInfoCompat focused = null;

        try {
            root = AccessibilityServiceCompatUtils.getRootInAccessibilityFocusedWindow(mService);
            if (root == null) {
                return false;
            }

            // First, see if we've already placed accessibility focus.
            focused = root.findFocus(AccessibilityNodeInfo.FOCUS_ACCESSIBILITY);
            if (focused != null) {
                if (AccessibilityNodeInfoUtils.shouldFocusNode(focused)) {
                    return true;
                }

                LogUtils.log(Log.VERBOSE, "Clearing focus from invalid node");
                PerformActionUtils.performAction(focused, AccessibilityNodeInfo.ACTION_CLEAR_ACCESSIBILITY_FOCUS);
            }

            return false;
        } finally {
            AccessibilityNodeInfoUtils.recycleNodes(root, focused);
        }
    }

    /**
     * Handles the beginning of a new touch interaction event.
     */
    @TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR1)
    private void handleTouchInteractionStart() {
        if (mFirstFocusedItem != null) {
            mFirstFocusedItem.recycle();
            mFirstFocusedItem = null;
        }

        if (mSpeechController.isSpeaking()) {
            mMaybeRefocus = false;

            final AccessibilityNodeInfoCompat currentNode = mCursorController.getCursor();
            // Don't silence speech on first touch if the tutorial is active
            // or if a WebView is active. This works around an issue where
            // the IME is unintentionally dismissed by WebView's
            // performAction implementation.
            if (!AccessibilityTutorialActivity.isTutorialActive()
                    && Role.getRole(currentNode) != Role.ROLE_WEB_VIEW) {
                mService.interruptAllFeedback();
            }
            AccessibilityNodeInfoUtils.recycleNodes(currentNode);
        } else {
            mMaybeRefocus = true;
        }

        mMaybeSingleTap = true;
        mFocusedItems = 0;
    }

    /**
     * Handles the end of an ongoing touch interaction event.
     */
    @TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR1)
    private void handleTouchInteractionEnd() {
        if (mFirstFocusedItem == null) {
            return;
        }

        if (mSingleTapEnabled && mMaybeSingleTap) {
            mHandler.cancelRefocusTimeout(false);
            performClick(mFirstFocusedItem);
        }

        mFirstFocusedItem.recycle();
        mFirstFocusedItem = null;
    }

    /**
     * Attempts to place focus on an accessibility-focusable node, starting from
     * the {@code touchedNode}.
     */
    private boolean setFocusFromViewHoverEnter(AccessibilityNodeInfoCompat touchedNode) {
        AccessibilityNodeInfoCompat focusable = null;

        try {
            focusable = AccessibilityNodeInfoUtils.findFocusFromHover(touchedNode);
            if (focusable == null) {
                return false;
            }

            if (SUPPORTS_INTERACTION_EVENTS && (mFirstFocusedItem == null) && (mFocusedItems == 0)
                    && focusable.isAccessibilityFocused()) {
                mFirstFocusedItem = AccessibilityNodeInfoCompat.obtain(focusable);

                if (mSingleTapEnabled) {
                    mHandler.refocusAfterTimeout(focusable);
                    return false;
                }

                return attemptRefocusNode(focusable);
            }

            if (!tryFocusing(focusable)) {
                return false;
            }

            mService.getInputModeManager().setInputMode(InputModeManager.INPUT_MODE_TOUCH);

            // If something received focus, single tap cannot occur.
            if (mSingleTapEnabled) {
                cancelSingleTap();
            }

            mFocusedItems++;

            return true;
        } finally {
            AccessibilityNodeInfoUtils.recycleNodes(focusable);
        }
    }

    /**
     * Ensures that a single-tap will not occur when the current touch
     * interaction ends.
     */
    @TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR1)
    private void cancelSingleTap() {
        mMaybeSingleTap = false;
    }

    private boolean attemptRefocusNode(AccessibilityNodeInfoCompat node) {
        if (!mMaybeRefocus || mSpeechController.isSpeaking()) {
            return false;
        }

        // Never refocus legacy web content, it will just read the title again.
        if (WebInterfaceUtils.hasLegacyWebContent(node)) {
            return false;
        }

        mLastRefocusStartTime = SystemClock.uptimeMillis();
        if (mLastRefocusedNode != null) {
            mLastRefocusedNode.recycle();
        }
        mLastRefocusedNode = AccessibilityNodeInfoCompat.obtain(node);
        boolean result = PerformActionUtils.performAction(node,
                AccessibilityNodeInfo.ACTION_CLEAR_ACCESSIBILITY_FOCUS) && tryFocusing(node, true /* force */);
        mLastRefocusEndTime = SystemClock.uptimeMillis();
        return result;
    }

    public boolean isFromRefocusAction(AccessibilityEvent event) {
        long eventTime = event.getEventTime();
        int eventType = event.getEventType();
        if (eventType != AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED
                && eventType != AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUS_CLEARED) {
            return false;
        }
        AccessibilityNodeInfo source = event.getSource();
        try {
            return mLastRefocusStartTime < eventTime
                    && (mLastRefocusEndTime > eventTime || mLastRefocusEndTime < mLastRefocusStartTime)
                    && mLastRefocusedNode != null && mLastRefocusedNode.getInfo().equals(source);
        } finally {
            if (source != null) {
                source.recycle();
            }
        }
    }

    private void followContentChangedEvent() {
        ensureFocusConsistency();
    }

    /**
     * If {@code wasMovingForward} is true, moves to the first focusable child.
     * Otherwise, moves to the last focusable child.
     */
    private boolean tryFocusingChild(AccessibilityNodeInfoCompat parent,
            @TraversalStrategy.SearchDirection int direction) {
        AccessibilityNodeInfoCompat child = null;

        try {
            child = findChildFromNode(parent, direction);
            return child != null && tryFocusing(child);

        } finally {
            AccessibilityNodeInfoUtils.recycleNodes(child);
        }
    }

    /**
     * Returns the first focusable child found while traversing the child of the
     * specified node in a specific direction. Only traverses direct children.
     *
     * @param root The node to search within.
     * @param direction The direction to search, one of the
     *            {@link TraversalStrategy.SearchDirection} constants.
     * @return The first focusable child encountered in the specified direction.
     */
    private AccessibilityNodeInfoCompat findChildFromNode(AccessibilityNodeInfoCompat root,
            @TraversalStrategy.SearchDirection int direction) {
        if (root == null || root.getChildCount() == 0) {
            return null;
        }

        final TraversalStrategy traversalStrategy = TraversalStrategyUtils.getTraversalStrategy(root, direction);

        AccessibilityNodeInfoCompat pivotNode = traversalStrategy.focusInitial(root, direction);

        NodeFilter filter = new NodeFilter() {
            @Override
            public boolean accept(AccessibilityNodeInfoCompat node) {
                return node != null && AccessibilityNodeInfoUtils.shouldFocusNode(node,
                        traversalStrategy.getSpeakingNodesCache());
            }
        };

        try {
            if (filter.accept(pivotNode)) {
                return AccessibilityNodeInfoCompat.obtain(pivotNode);
            }

            return AccessibilityNodeInfoUtils.searchFocus(traversalStrategy, pivotNode, direction, filter);
        } finally {
            if (pivotNode != null) {
                pivotNode.recycle();
            }
        }
    }

    /**
     * If the source node does not have accessibility focus, attempts to focus the source node.
     * Returns {@code true} if the node was successfully focused or already had accessibility focus.
     * Note that nothing is done for source nodes that already have accessibility focus, but
     * {@code true} is returned anyways.
     */
    private boolean tryFocusing(AccessibilityNodeInfoCompat source) {
        return tryFocusing(source, false);
    }

    /**
     * If the source node does not have accessibility focus or {@code force} is {@code true},
     * attempts to focus the source node. Returns {@code true} if the node was successfully focused
     * or already had accessibility focus.
     */
    private boolean tryFocusing(AccessibilityNodeInfoCompat source, boolean force) {
        if (source == null) {
            return false;
        }

        if (!AccessibilityNodeInfoUtils.shouldFocusNode(source)) {
            return false;
        }

        boolean shouldPerformAction = force || !source.isAccessibilityFocused();
        if (shouldPerformAction && !PerformActionUtils.performAction(source,
                AccessibilityNodeInfoCompat.ACTION_ACCESSIBILITY_FOCUS)) {
            return false;
        }

        mHandler.interruptFollowDelayed();
        return true;
    }

    private void performClick(AccessibilityNodeInfoCompat node) {
        // Performing a click on an EditText does not show the IME, so we need
        // to place input focus on it. If the IME was already connected and is
        // hidden, there is nothing we can do.
        if (Role.getRole(node) == Role.ROLE_EDIT_TEXT) {
            PerformActionUtils.performAction(node, AccessibilityNodeInfoCompat.ACTION_FOCUS);
            return;
        }

        // If a user quickly touch explores in web content (event stream <
        // TAP_TIMEOUT), we'll send an unintentional ACTION_CLICK. Switch
        // off clicking on web content for now.
        if (WebInterfaceUtils.supportsWebActions(node)) {
            return;
        }

        PerformActionUtils.performAction(node, AccessibilityNodeInfoCompat.ACTION_CLICK);
    }

    /**
     * Listens for scroll events.
     *
     * @param action The type of scroll event received.
     * @param auto If {@code true}, then the scroll was initiated automatically. If
     *     {@code false}, then the user initiated the scroll action.
     */
    @Override
    public void onScroll(AccessibilityNodeInfoCompat scrolledNode, int action, boolean auto) {
        if (scrolledNode == null) {
            clearScrollAction();
        }

        @TraversalStrategy.SearchDirectionOrUnknown
        int direction = TraversalStrategyUtils.convertScrollActionToSearchDirection(action);
        if (direction != TraversalStrategy.SEARCH_FOCUS_UNKNOWN) {
            mLastScrollDirection = direction;
            if (mActionScrolledNode != null) {
                mActionScrolledNode.recycle();
            }

            if (scrolledNode != null) {
                mActionScrolledNode = AccessibilityNodeInfoCompat.obtain(scrolledNode);
            }
        }
    }

    private final FollowFocusHandler mHandler;

    private static class FollowFocusHandler extends WeakReferenceHandler<ProcessorFocusAndSingleTap> {
        private static final int FOCUS_AFTER_CONTENT_CHANGED = 2;
        private static final int REFOCUS_AFTER_TIMEOUT = 3;
        private static final int EMPTY_TOUCH_AREA = 5;

        /** Delay after a scroll event before checking focus. */
        private static final long FOCUS_AFTER_CONTENT_CHANGED_DELAY = 500;

        /** Delay for indicating the user has explored into an unfocusable area. */
        private static final long EMPTY_TOUCH_AREA_DELAY = 100;

        private AccessibilityNodeInfoCompat mCachedFocusedNode;
        private AccessibilityNodeInfoCompat mCachedTouchedNode;
        private final FeedbackController mFeedbackController;
        boolean mHasContentChangeMessage = false;

        public FollowFocusHandler(ProcessorFocusAndSingleTap parent, FeedbackController feedbackController) {
            super(parent);
            mFeedbackController = feedbackController;
        }

        @Override
        public void handleMessage(Message msg, ProcessorFocusAndSingleTap parent) {
            switch (msg.what) {
            case FOCUS_AFTER_CONTENT_CHANGED:
                mHasContentChangeMessage = false;
                parent.followContentChangedEvent();
                break;
            case REFOCUS_AFTER_TIMEOUT:
                parent.cancelSingleTap();
                cancelRefocusTimeout(true);
                break;
            case EMPTY_TOUCH_AREA:
                if (!AccessibilityNodeInfoUtils.isSelfOrAncestorFocused(mCachedTouchedNode)) {
                    mFeedbackController.playHaptic(R.array.view_hovered_pattern);
                    mFeedbackController.playAuditory(R.raw.view_entered, 1.3f, 1);
                }

                break;
            }
        }

        /**
         * Ensure that focus is placed after content change actions, but use a delay to
         * avoid consuming too many resources.
         */
        public void followContentChangedDelayed() {
            if (!mHasContentChangeMessage) {
                mHasContentChangeMessage = true;
                sendMessageDelayed(obtainMessage(FOCUS_AFTER_CONTENT_CHANGED), FOCUS_AFTER_CONTENT_CHANGED_DELAY);
            }
        }

        /**
         * Attempts to refocus the specified node after a timeout period, unless
         * {@link #cancelRefocusTimeout} is called first.
         *
         * @param source The node to refocus after a timeout.
         */
        @TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR1)
        public void refocusAfterTimeout(AccessibilityNodeInfoCompat source) {
            removeMessages(REFOCUS_AFTER_TIMEOUT);

            if (mCachedFocusedNode != null) {
                mCachedFocusedNode.recycle();
                mCachedFocusedNode = null;
            }

            mCachedFocusedNode = AccessibilityNodeInfoCompat.obtain(source);

            final Message msg = obtainMessage(REFOCUS_AFTER_TIMEOUT);
            sendMessageDelayed(msg, TAP_TIMEOUT);
        }

        /**
         * Provides feedback indicating an empty or unfocusable area after a
         * delay.
         */
        public void sendEmptyTouchAreaFeedbackDelayed(AccessibilityNodeInfoCompat touchedNode) {
            cancelEmptyTouchAreaFeedback();
            mCachedTouchedNode = AccessibilityNodeInfoCompat.obtain(touchedNode);

            final Message msg = obtainMessage(EMPTY_TOUCH_AREA);
            sendMessageDelayed(msg, EMPTY_TOUCH_AREA_DELAY);
        }

        /**
         * Cancels a refocus timeout initiated by {@link #refocusAfterTimeout}
         * and optionally refocuses the target node immediately.
         *
         * @param shouldRefocus Whether to refocus the target node immediately.
         */
        @TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR1)
        public void cancelRefocusTimeout(boolean shouldRefocus) {
            removeMessages(REFOCUS_AFTER_TIMEOUT);

            final ProcessorFocusAndSingleTap parent = getParent();
            if (parent == null) {
                return;
            }

            if (shouldRefocus && (mCachedFocusedNode != null)) {
                parent.attemptRefocusNode(mCachedFocusedNode);
            }

            if (mCachedFocusedNode != null) {
                mCachedFocusedNode.recycle();
                mCachedFocusedNode = null;
            }
        }

        /**
         * Interrupt any pending follow-focus messages.
         */
        public void interruptFollowDelayed() {
            mHasContentChangeMessage = false;
            removeMessages(FOCUS_AFTER_CONTENT_CHANGED);
        }

        /**
         * Cancel any pending messages for delivering feedback indicating an
         * empty or unfocusable area.
         */
        public void cancelEmptyTouchAreaFeedback() {
            removeMessages(EMPTY_TOUCH_AREA);

            if (mCachedTouchedNode != null) {
                mCachedTouchedNode.recycle();
                mCachedTouchedNode = null;
            }
        }
    }

    private static class FirstWindowFocusManager implements CursorController.CursorListener {
        private static final int MISS_FOCUS_DELAY_NORMAL = 300;
        // TODO: Revisit the delay due to TV transitions if BUG changes.
        private static final int MISS_FOCUS_DELAY_TV = 1200; // Longer transitions on TV.

        private long mLastWindowStateChangeEventTime;
        private long mLastWindowId;
        private boolean mIsFirstFocusInWindow;
        private final TalkBackService mService;

        public FirstWindowFocusManager(TalkBackService service) {
            mService = service;
            mService.getCursorController().addCursorListener(this);
        }

        public void registerWindowChange(AccessibilityEvent event) {
            mLastWindowStateChangeEventTime = event.getEventTime();
            if (mLastWindowId != event.getWindowId()) {
                mLastWindowId = event.getWindowId();
                mIsFirstFocusInWindow = true;
            }
        }

        @Override
        public void beforeSetCursor(AccessibilityNodeInfoCompat newCursor, int action) {
            // Manual focus actions should go through, even if mLastWindowId doesn't match.
            if (action == AccessibilityNodeInfoCompat.ACTION_FOCUS) {
                mLastWindowId = newCursor.getWindowId();
            }
        }

        @Override
        public void onSetCursor(AccessibilityNodeInfoCompat newCursor, int action) {
        }

        public boolean shouldProcessFocusEvent(AccessibilityEvent event) {
            boolean isFirstFocus = mIsFirstFocusInWindow;
            mIsFirstFocusInWindow = false;

            if (mLastWindowId != event.getWindowId()) {
                mLastWindowId = event.getWindowId();
                return false;
            }

            int focusDelay = mService.isDeviceTelevision() ? MISS_FOCUS_DELAY_TV : MISS_FOCUS_DELAY_NORMAL;

            return !isFirstFocus || event.getEventTime() - mLastWindowStateChangeEventTime > focusDelay;
        }
    }
}