/* * Copyright (C) 2014 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; import android.annotation.TargetApi; import android.os.Build; import android.os.Handler; import android.support.v4.view.accessibility.AccessibilityNodeInfoCompat; import android.view.KeyEvent; import android.view.accessibility.AccessibilityEvent; import android.view.accessibility.AccessibilityNodeInfo; import com.android.talkback.InputModeManager; import com.android.utils.AccessibilityEventListener; import com.android.utils.AccessibilityNodeInfoRef; import com.android.utils.AccessibilityNodeInfoUtils; import com.android.utils.FocusFinder; import com.android.utils.NodeSearch; import com.android.utils.PerformActionUtils; import com.android.utils.labeling.CustomLabelManager; import com.android.utils.traversal.NodeFocusFinder; import com.google.android.marvin.talkback.TalkBackService; /** * Handles keyboard search of the nodes on the screen. */ @TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR2) public class KeyboardSearchManager implements TalkBackService.KeyEventListener, KeyComboManager.KeyComboListener, AccessibilityEventListener { public static final int MIN_API_LEVEL = Build.VERSION_CODES.JELLY_BEAN_MR2; /** The delay, in milliseconds, between the user's last action and the hint speech. */ private static final int HINT_DELAY = 5000; /** The parent context. */ private final TalkBackService mContext; /** The custom label manager that may be used for hint speech. */ private final CustomLabelManager mLabelManager; /** The NodeSearch instance used to execute searches. */ private final NodeSearch mNodeSearch; /** The SpeechController used to speak hints and announce actions. */ private final SpeechController mSpeechController; /** The handler used to speak hints when the user takes no action for the hint delay time. */ private Handler mHandler = new Handler(); /** * The node that was focused before entering search mode. The focus is moved back to this node * if the search is canceled. */ private final AccessibilityNodeInfoRef mInitialNode = new AccessibilityNodeInfoRef(); /** Whether the user has navigated within search mode using the arrow keys. */ private boolean mHasNavigated; public KeyboardSearchManager(TalkBackService context, CustomLabelManager labelManager) { mContext = context; mLabelManager = labelManager; NodeSearch.SearchTextFormatter formatter = new NodeSearch.SearchTextFormatter() { @Override public float getTextSize() { return mContext.getResources() .getDimensionPixelSize(R.dimen.search_text_font_size); } @Override public String getDisplayText(String queryText) { return mContext.getString(R.string.search_dialog_label, queryText); } }; mNodeSearch = new NodeSearch(context, labelManager, formatter); mSpeechController = context.getSpeechController(); } /** * Toggle search mode. */ void toggleSearch() { if (mNodeSearch.isActive()) { cancelSearch(); } else { startSearch(); } } /** * To be called when TalkBack receives a gesture. * * @return {@code true} if search mode consumed the gesture, or {@code false} otherwise. */ public boolean onGesture() { // All gestures cancel the search. if (mNodeSearch.isActive()) { cancelSearch(); return true; } return false; } @Override public boolean onKeyEvent(KeyEvent event) { // Only handle single-key events here. The KeyComboManager will pass us combos. if (event.getModifiers() != 0 || !mNodeSearch.isActive()) { return false; } if (event.getAction() == KeyEvent.ACTION_DOWN) { switch (event.getKeyCode()) { case KeyEvent.KEYCODE_ENTER: if (mHasNavigated || mNodeSearch.hasMatch()) { finishSearch(); mContext.getCursorController().clickCurrent(); } else { cancelSearch(); } return true; case KeyEvent.KEYCODE_DEL: resetHintTime(); final String queryText = mNodeSearch.getCurrentQuery(); if (queryText.isEmpty()) { cancelSearch(); } else { final String lastChar = queryText.substring(queryText.length() - 1); mNodeSearch.backspaceQueryText(); mSpeechController.speak( mContext.getString(R.string.template_text_removed, lastChar), SpeechController.QUEUE_MODE_FLUSH_ALL, FeedbackItem.FLAG_NO_HISTORY, null); } return true; case KeyEvent.KEYCODE_DPAD_UP: moveToEnd(NodeFocusFinder.SEARCH_BACKWARD); return true; case KeyEvent.KEYCODE_DPAD_LEFT: moveToNext(NodeFocusFinder.SEARCH_BACKWARD); return true; case KeyEvent.KEYCODE_DPAD_DOWN: moveToEnd(NodeFocusFinder.SEARCH_FORWARD); return true; case KeyEvent.KEYCODE_DPAD_RIGHT: moveToNext(NodeFocusFinder.SEARCH_FORWARD); return true; case KeyEvent.KEYCODE_SPACE: resetHintTime(); if (mNodeSearch.tryAddQueryText(" ")) { mSpeechController.speak(mContext.getString(R.string.symbol_space), SpeechController.QUEUE_MODE_FLUSH_ALL, FeedbackItem.FLAG_NO_HISTORY, null); } else { mContext.getFeedbackController().playAuditory(R.raw.complete); } return true; default: if (event.isPrintingKey()) { resetHintTime(); final String key = String.valueOf(event.getDisplayLabel()); if (mNodeSearch.tryAddQueryText(key)) { mSpeechController.speak(key.toLowerCase(), SpeechController.QUEUE_MODE_FLUSH_ALL, FeedbackItem.FLAG_NO_HISTORY, null); } else { mContext.getFeedbackController().playAuditory(R.raw.complete); } return true; } break; } } return false; } @Override public boolean processWhenServiceSuspended() { return false; } @Override public boolean onComboPerformed(int id) { if (id == KeyComboManager.ACTION_TOGGLE_SEARCH) { toggleSearch(); return true; } // No other combos should be consumed if search mode is not active. if (!mNodeSearch.isActive()) { return false; } switch (id) { case KeyComboManager.ACTION_NAVIGATE_PREVIOUS: moveToNext(NodeFocusFinder.SEARCH_BACKWARD); return true; case KeyComboManager.ACTION_NAVIGATE_NEXT: moveToNext(NodeFocusFinder.SEARCH_FORWARD); return true; case KeyComboManager.ACTION_NAVIGATE_FIRST: moveToEnd(NodeFocusFinder.SEARCH_BACKWARD); return true; case KeyComboManager.ACTION_NAVIGATE_LAST: moveToEnd(NodeFocusFinder.SEARCH_FORWARD); return true; case KeyComboManager.ACTION_PERFORM_CLICK: if (mHasNavigated || mNodeSearch.hasMatch()) { finishSearch(); mContext.getCursorController().clickCurrent(); } else { cancelSearch(); } return true; } return false; } @Override public void onAccessibilityEvent(AccessibilityEvent event) { if (!mNodeSearch.isActive()) { return; } switch (event.getEventType()) { case AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED: cancelSearch(); break; case AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED: if (mNodeSearch.hasMatch()) { mNodeSearch.reEvaluateSearch(); } break; default: break; } } /** * Move accessibility focus to the next matching node in the specified direction. If no match * has been found yet, simply focus the next node in that direction on the screen. * * @param direction The direction in which to move, either * {@link NodeFocusFinder#SEARCH_BACKWARD} or {@link NodeFocusFinder#SEARCH_FORWARD}. * @return {@code true} if the accessibility focus was moved, or {@code false} otherwise. */ private boolean moveToNext(int direction) { resetHintTime(); final boolean result; if (mNodeSearch.hasMatch()) { result = mNodeSearch.nextResult(direction); } else if (direction == NodeFocusFinder.SEARCH_BACKWARD) { result = mContext.getCursorController().previous( false /* shouldWrap */, true /* shouldScroll */, false /*useInputFocusAsPivotIfEmpty*/, InputModeManager.INPUT_MODE_KEYBOARD); } else { result = mContext.getCursorController().next( false /* shouldWrap */, true /* shouldScroll */, false /*useInputFocusAsPivotIfEmpty*/, InputModeManager.INPUT_MODE_KEYBOARD); } mHasNavigated = true; return result; } /** * Move accessibility focus to the last matching node in the specified direction. If no match * has been found yet, simply focus the last node in that direction on the screen. * * @param direction The direction in which to move, either * {@link NodeFocusFinder#SEARCH_BACKWARD} or {@link NodeFocusFinder#SEARCH_FORWARD}. * @return {@code true} if the accessibility focus was moved, or {@code false} otherwise. */ private boolean moveToEnd(int direction) { resetHintTime(); final boolean result; if (mNodeSearch.hasMatch()) { result = mNodeSearch.nextResult(direction); while (mNodeSearch.nextResult(direction)) {} } else if (direction == NodeFocusFinder.SEARCH_BACKWARD) { result = mContext.getCursorController().jumpToTop(InputModeManager.INPUT_MODE_KEYBOARD); } else { result = mContext.getCursorController().jumpToBottom( InputModeManager.INPUT_MODE_KEYBOARD); } mHasNavigated = true; return result; } /** * Reset the hint's delay time so that the delay is counted from the time this method is called. */ private void resetHintTime() { mHandler.removeCallbacks(mHint); mHandler.postDelayed(mHint, HINT_DELAY); } /** * Start search mode. */ private void startSearch() { AccessibilityNodeInfoCompat focused = FocusFinder.getFocusedNode(mContext, true); mInitialNode.reset(focused); mHasNavigated = false; mNodeSearch.startSearch(); mSpeechController.speak(mContext.getString(R.string.search_mode_open), SpeechController.QUEUE_MODE_FLUSH_ALL, FeedbackItem.FLAG_NO_HISTORY, null); mHandler.postDelayed(mHint, HINT_DELAY); } /** * Finish the current search. Exit search mode and leave the accessibility focus on the result. */ private void finishSearch() { mHandler.removeCallbacks(mHint); mNodeSearch.stopSearch(); mSpeechController.speak(mContext.getString(R.string.search_mode_finish), SpeechController.QUEUE_MODE_UNINTERRUPTIBLE, FeedbackItem.FLAG_NO_HISTORY, null); } /** * Cancel the current search. Return accessibility focus to the initial node. */ private void cancelSearch() { mHandler.removeCallbacks(mHint); mNodeSearch.stopSearch(); mSpeechController.speak(mContext.getString(R.string.search_mode_cancel), SpeechController.QUEUE_MODE_UNINTERRUPTIBLE, FeedbackItem.FLAG_NO_HISTORY, null); AccessibilityNodeInfoCompat focused = FocusFinder.getFocusedNode(mContext, false); if (focused == null) { return; } try { mInitialNode.reset(AccessibilityNodeInfoUtils.refreshNode(mInitialNode.get())); if (!AccessibilityNodeInfoRef.isNull(mInitialNode)) { if (mInitialNode.get().isAccessibilityFocused()) { return; } PerformActionUtils.performAction(mInitialNode.get(), AccessibilityNodeInfo.ACTION_ACCESSIBILITY_FOCUS); } else { PerformActionUtils.performAction(focused, AccessibilityNodeInfoCompat.ACTION_CLEAR_ACCESSIBILITY_FOCUS); } } finally { focused.recycle(); } } /** The runnable that speaks the hint. */ private final Runnable mHint = new Runnable() { @Override public void run() { String hint = mContext.getString(R.string.search_mode_hint_start); hint += " "; final String queryText = mNodeSearch.getCurrentQuery(); if (queryText.isEmpty()) { hint += mContext.getString(R.string.search_mode_hint_no_query); } else { final int length = queryText.length(); String separatedQuery = ""; for (int i = 0; i < length; i++) { final Character currentChar = queryText.charAt(i); if (Character.isWhitespace(currentChar)) { separatedQuery += mContext.getString(R.string.symbol_space); } else { separatedQuery += currentChar; } separatedQuery += ", "; } // Remove the extra comma and space. separatedQuery = separatedQuery.substring(0, separatedQuery.length() - 2); hint += mContext.getString(R.string.search_mode_hint_query, separatedQuery); } hint += " "; if (mHasNavigated || mNodeSearch.hasMatch()) { AccessibilityNodeInfoCompat selected = FocusFinder.getFocusedNode(mContext, false); if (selected != null) { final CharSequence matchText = AccessibilityNodeInfoUtils.getNodeText(selected, mLabelManager); if (matchText != null && matchText.length() > 0) { hint += mContext.getString(R.string.search_mode_hint_selection, matchText); mSpeechController.speak(hint, SpeechController.QUEUE_MODE_FLUSH_ALL, FeedbackItem.FLAG_NO_HISTORY, null); return; } } } hint += mContext.getString(R.string.search_mode_hint_no_selection); mSpeechController.speak(hint, SpeechController.QUEUE_MODE_FLUSH_ALL, FeedbackItem.FLAG_NO_HISTORY, null); } }; }