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