com.android.screenspeak.KeyboardSearchManager.java Source code

Java tutorial

Introduction

Here is the source code for com.android.screenspeak.KeyboardSearchManager.java

Source

/*
 * 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.screenspeak;

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.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.screenspeak.ScreenSpeakService;

/**
 * Handles keyboard search of the nodes on the screen.
 */
@TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR2)
public class KeyboardSearchManager implements ScreenSpeakService.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 ScreenSpeakService 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(ScreenSpeakService 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 ScreenSpeak 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*/);
        } else {
            result = mContext.getCursorController().next(false /* shouldWrap */, true /* shouldScroll */,
                    false /*useInputFocusAsPivotIfEmpty*/);
        }

        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();
        } else {
            result = mContext.getCursorController().jumpToBottom();
        }

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