SearchBar.java revision edb67b0b59ee6657b2d1adb6b2822858db34e8c8
1/*
2 * Copyright (C) 2014 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
5 * in compliance with the License. You may obtain a copy of the License at
6 *
7 * http://www.apache.org/licenses/LICENSE-2.0
8 *
9 * Unless required by applicable law or agreed to in writing, software distributed under the License
10 * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
11 * or implied. See the License for the specific language governing permissions and limitations under
12 * the License.
13 */
14package android.support.v17.leanback.widget;
15
16import android.content.Context;
17import android.content.Intent;
18import android.content.pm.PackageManager;
19import android.content.res.Resources;
20import android.graphics.Color;
21import android.graphics.drawable.Drawable;
22import android.media.AudioManager;
23import android.media.SoundPool;
24import android.os.Bundle;
25import android.os.Handler;
26import android.os.SystemClock;
27import android.speech.RecognitionListener;
28import android.speech.RecognizerIntent;
29import android.speech.SpeechRecognizer;
30import android.text.Editable;
31import android.text.TextUtils;
32import android.text.TextWatcher;
33import android.util.AttributeSet;
34import android.util.Log;
35import android.util.SparseIntArray;
36import android.view.LayoutInflater;
37import android.view.ViewGroup;
38import android.view.inputmethod.CompletionInfo;
39import android.view.inputmethod.EditorInfo;
40import android.view.KeyEvent;
41import android.view.MotionEvent;
42import android.view.View;
43import android.widget.ImageView;
44import android.view.inputmethod.InputMethodManager;
45import android.widget.RelativeLayout;
46import android.support.v17.leanback.R;
47import android.widget.TextView;
48
49import java.util.ArrayList;
50import java.util.List;
51
52/**
53 * <p>SearchBar is a search widget.</p>
54 *
55 * <p>Note: Your application will need to request android.permission.RECORD_AUDIO</p>
56 */
57public class SearchBar extends RelativeLayout {
58    private static final String TAG = SearchBar.class.getSimpleName();
59    private static final boolean DEBUG = false;
60
61    private static final float FULL_LEFT_VOLUME = 1.0f;
62    private static final float FULL_RIGHT_VOLUME = 1.0f;
63    private static final int DEFAULT_PRIORITY = 1;
64    private static final int DO_NOT_LOOP = 0;
65    private static final float DEFAULT_RATE = 1.0f;
66
67    /**
68     * Listener for search query changes
69     */
70    public interface SearchBarListener {
71
72        /**
73         * Method invoked when the search bar detects a change in the query.
74         *
75         * @param query The current full query.
76         */
77        public void onSearchQueryChange(String query);
78
79        /**
80         * <p>Method invoked when the search query is submitted.</p>
81         *
82         * <p>This method can be called without a preceeding onSearchQueryChange,
83         * in particular in the case of a voice input.</p>
84         *
85         * @param query The query being submitted.
86         */
87        public void onSearchQuerySubmit(String query);
88
89        /**
90         * Method invoked when the IME is being dismissed.
91         *
92         * @param query The query set in the search bar at the time the IME is being dismissed.
93         */
94        public void onKeyboardDismiss(String query);
95    }
96
97    private SearchBarListener mSearchBarListener;
98    private SearchEditText mSearchTextEditor;
99    private SpeechOrbView mSpeechOrbView;
100    private ImageView mBadgeView;
101    private String mSearchQuery;
102    private String mTitle;
103    private Drawable mBadgeDrawable;
104    private final Handler mHandler = new Handler();
105    private final InputMethodManager mInputMethodManager;
106    private boolean mAutoStartRecognition = false;
107    private Drawable mBarBackground;
108
109    private final int mTextColor;
110    private final int mTextColorSpeechMode;
111    private final int mTextHintColor;
112    private final int mTextHintColorSpeechMode;
113    private int mBackgroundAlpha;
114    private int mBackgroundSpeechAlpha;
115    private int mBarHeight;
116    private SpeechRecognizer mSpeechRecognizer;
117    private SpeechRecognitionCallback mSpeechRecognitionCallback;
118    private boolean mListening;
119    private SoundPool mSoundPool;
120    private SparseIntArray mSoundMap = new SparseIntArray();
121    private boolean mRecognizing = false;
122    private final Context mContext;
123
124    public SearchBar(Context context) {
125        this(context, null);
126    }
127
128    public SearchBar(Context context, AttributeSet attrs) {
129        this(context, attrs, 0);
130    }
131
132    public SearchBar(Context context, AttributeSet attrs, int defStyle) {
133        super(context, attrs, defStyle);
134        mContext = context;
135
136        Resources r = getResources();
137
138        LayoutInflater inflater = LayoutInflater.from(getContext());
139        inflater.inflate(R.layout.lb_search_bar, this, true);
140
141        mBarHeight = getResources().getDimensionPixelSize(R.dimen.lb_search_bar_height);
142        RelativeLayout.LayoutParams params = new LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,
143                mBarHeight);
144        params.addRule(ALIGN_PARENT_TOP, RelativeLayout.TRUE);
145        setLayoutParams(params);
146        setBackgroundColor(Color.TRANSPARENT);
147        setClipChildren(false);
148
149        mSearchQuery = "";
150        mInputMethodManager =
151                (InputMethodManager)context.getSystemService(Context.INPUT_METHOD_SERVICE);
152
153        mTextColorSpeechMode = r.getColor(R.color.lb_search_bar_text_speech_mode);
154        mTextColor = r.getColor(R.color.lb_search_bar_text);
155
156        mBackgroundSpeechAlpha = r.getInteger(R.integer.lb_search_bar_speech_mode_background_alpha);
157        mBackgroundAlpha = r.getInteger(R.integer.lb_search_bar_text_mode_background_alpha);
158
159        mTextHintColorSpeechMode = r.getColor(R.color.lb_search_bar_hint_speech_mode);
160        mTextHintColor = r.getColor(R.color.lb_search_bar_hint);
161    }
162
163    @Override
164    protected void onFinishInflate() {
165        super.onFinishInflate();
166
167        RelativeLayout items = (RelativeLayout)findViewById(R.id.lb_search_bar_items);
168        mBarBackground = items.getBackground();
169
170        mSearchTextEditor = (SearchEditText)findViewById(R.id.lb_search_text_editor);
171        mBadgeView = (ImageView)findViewById(R.id.lb_search_bar_badge);
172        if (null != mBadgeDrawable) {
173            mBadgeView.setImageDrawable(mBadgeDrawable);
174        }
175
176        mSearchTextEditor.setOnFocusChangeListener(new OnFocusChangeListener() {
177            @Override
178            public void onFocusChange(View view, boolean hasFocus) {
179                if (DEBUG) Log.v(TAG, "EditText.onFocusChange " + hasFocus);
180                if (hasFocus) {
181                    showNativeKeyboard();
182                }
183                updateUi();
184            }
185        });
186        final Runnable mOnTextChangedRunnable = new Runnable() {
187            @Override
188            public void run() {
189                setSearchQueryInternal(mSearchTextEditor.getText().toString());
190            }
191        };
192        mSearchTextEditor.addTextChangedListener(new TextWatcher() {
193            @Override
194            public void beforeTextChanged(CharSequence charSequence, int i, int i2, int i3) {
195            }
196
197            @Override
198            public void onTextChanged(CharSequence charSequence, int i, int i2, int i3) {
199                // don't propagate event during speech recognition.
200                if (mRecognizing) {
201                    return;
202                }
203                // while IME opens,  text editor becomes "" then restores to current value
204                mHandler.removeCallbacks(mOnTextChangedRunnable);
205                mHandler.post(mOnTextChangedRunnable);
206            }
207
208            @Override
209            public void afterTextChanged(Editable editable) {
210
211            }
212        });
213        mSearchTextEditor.setOnKeyboardDismissListener(
214                new SearchEditText.OnKeyboardDismissListener() {
215                    @Override
216                    public void onKeyboardDismiss() {
217                        if (null != mSearchBarListener) {
218                            mSearchBarListener.onKeyboardDismiss(mSearchQuery);
219                        }
220                    }
221                });
222
223        mSearchTextEditor.setOnEditorActionListener(new TextView.OnEditorActionListener() {
224            @Override
225            public boolean onEditorAction(TextView textView, int action, KeyEvent keyEvent) {
226                if (DEBUG) Log.v(TAG, "onEditorAction: " + action + " event: " + keyEvent);
227                boolean handled = true;
228                if (EditorInfo.IME_ACTION_SEARCH == action && null != mSearchBarListener) {
229                    if (DEBUG) Log.v(TAG, "Action Pressed");
230                    hideNativeKeyboard();
231                    mHandler.postDelayed(new Runnable() {
232                        @Override
233                        public void run() {
234                            if (DEBUG) Log.v(TAG, "Delayed action handling (search)");
235                            submitQuery();
236                        }
237                    }, 500);
238
239                } else if (EditorInfo.IME_ACTION_NONE == action && null != mSearchBarListener) {
240                    if (DEBUG) Log.v(TAG, "Escaped North");
241                    hideNativeKeyboard();
242                    mHandler.postDelayed(new Runnable() {
243                        @Override
244                        public void run() {
245                            if (DEBUG) Log.v(TAG, "Delayed action handling (escape_north)");
246                            mSearchBarListener.onKeyboardDismiss(mSearchQuery);
247                        }
248                    }, 500);
249                } else if (EditorInfo.IME_ACTION_GO == action) {
250                    if (DEBUG) Log.v(TAG, "Voice Clicked");
251                        hideNativeKeyboard();
252                        mHandler.postDelayed(new Runnable() {
253                            @Override
254                            public void run() {
255                                if (DEBUG) Log.v(TAG, "Delayed action handling (voice_mode)");
256                                mAutoStartRecognition = true;
257                                mSpeechOrbView.requestFocus();
258                            }
259                        }, 500);
260                } else {
261                    handled = false;
262                }
263
264                return handled;
265            }
266        });
267
268        mSearchTextEditor.setPrivateImeOptions("EscapeNorth=1;VoiceDismiss=1;");
269
270        mSpeechOrbView = (SpeechOrbView)findViewById(R.id.lb_search_bar_speech_orb);
271        mSpeechOrbView.setOnOrbClickedListener(new OnClickListener() {
272            @Override
273            public void onClick(View view) {
274                toggleRecognition();
275            }
276        });
277        mSpeechOrbView.setOnFocusChangeListener(new OnFocusChangeListener() {
278            @Override
279            public void onFocusChange(View view, boolean hasFocus) {
280                if (DEBUG) Log.v(TAG, "SpeechOrb.onFocusChange " + hasFocus);
281                if (hasFocus) {
282                    hideNativeKeyboard();
283                    if (mAutoStartRecognition) {
284                        startRecognition();
285                        mAutoStartRecognition = false;
286                    }
287                } else {
288                    stopRecognition();
289                }
290                updateUi();
291            }
292        });
293
294        updateUi();
295        updateHint();
296    }
297
298    @Override
299    protected void onAttachedToWindow() {
300        super.onAttachedToWindow();
301        if (DEBUG) Log.v(TAG, "Loading soundPool");
302        mSoundPool = new SoundPool(2, AudioManager.STREAM_SYSTEM, 0);
303        loadSounds(mContext);
304    }
305
306    @Override
307    protected void onDetachedFromWindow() {
308        if (DEBUG) Log.v(TAG, "Releasing SoundPool");
309        mSoundPool.release();
310
311        super.onDetachedFromWindow();
312    }
313
314    /**
315     * Set a listener for when the term search changes
316     * @param listener
317     */
318    public void setSearchBarListener(SearchBarListener listener) {
319        mSearchBarListener = listener;
320    }
321
322    /**
323     * Set the search query
324     * @param query the search query to use
325     */
326    public void setSearchQuery(String query) {
327        mSearchTextEditor.setText(query);
328        setSearchQueryInternal(query);
329    }
330
331    private void setSearchQueryInternal(String query) {
332        if (DEBUG) Log.v(TAG, "setSearchQueryInternal " + query);
333        if (TextUtils.equals(mSearchQuery, query)) {
334            return;
335        }
336        mSearchQuery = query;
337
338        if (null != mSearchBarListener) {
339            mSearchBarListener.onSearchQueryChange(mSearchQuery);
340        }
341    }
342
343    /**
344     * Set the title text used in the hint shown in the search bar.
345     * @param title The hint to use.
346     */
347    public void setTitle(String title) {
348        mTitle = title;
349        updateHint();
350    }
351
352    /**
353     * Returns the current title
354     */
355    public String getTitle() {
356        return mTitle;
357    }
358
359    /**
360     * Returns the current search bar hint text.
361     */
362    public CharSequence getHint() {
363        return (mSearchTextEditor == null) ? null : mSearchTextEditor.getHint();
364    }
365
366    /**
367     * Set the badge drawable showing inside the search bar.
368     * @param drawable The drawable to be used in the search bar.
369     */
370    public void setBadgeDrawable(Drawable drawable) {
371        mBadgeDrawable = drawable;
372        if (null != mBadgeView) {
373            mBadgeView.setImageDrawable(drawable);
374            if (null != drawable) {
375                mBadgeView.setVisibility(View.VISIBLE);
376            } else {
377                mBadgeView.setVisibility(View.GONE);
378            }
379        }
380    }
381
382    /**
383     * Returns the badge drawable
384     */
385    public Drawable getBadgeDrawable() {
386        return mBadgeDrawable;
387    }
388
389    /**
390     * Update the completion list shown by the IME
391     *
392     * @param completions list of completions shown in the IME, can be null or empty to clear them
393     */
394    public void displayCompletions(List<String> completions) {
395        List<CompletionInfo> infos = new ArrayList<CompletionInfo>();
396        if (null != completions) {
397            for (String completion : completions) {
398                infos.add(new CompletionInfo(infos.size(), infos.size(), completion));
399            }
400        }
401
402        mInputMethodManager.displayCompletions(mSearchTextEditor,
403                infos.toArray(new CompletionInfo[] {}));
404    }
405
406    /**
407     * Set the speech recognizer to be used when doing voice search. The Activity/Fragment is in
408     * charge of creating and destroying the recognizer with its own lifecycle.
409     *
410     * @param recognizer a SpeechRecognizer
411     */
412    public void setSpeechRecognizer(SpeechRecognizer recognizer) {
413        if (null != mSpeechRecognizer) {
414            mSpeechRecognizer.setRecognitionListener(null);
415            if (mListening) {
416                mSpeechRecognizer.stopListening();
417                mListening = false;
418            }
419        }
420        mSpeechRecognizer = recognizer;
421        if (mSpeechRecognizer != null) {
422            enforceAudioRecordPermission();
423        }
424        if (mSpeechRecognitionCallback != null && mSpeechRecognizer != null) {
425            throw new IllegalStateException("Can't have speech recognizer and request");
426        }
427    }
428
429    public void setSpeechRecognitionCallback(SpeechRecognitionCallback request) {
430        mSpeechRecognitionCallback = request;
431        if (mSpeechRecognitionCallback != null && mSpeechRecognizer != null) {
432            throw new IllegalStateException("Can't have speech recognizer and request");
433        }
434    }
435
436    private void hideNativeKeyboard() {
437        mInputMethodManager.hideSoftInputFromWindow(mSearchTextEditor.getWindowToken(),
438                InputMethodManager.RESULT_UNCHANGED_SHOWN);
439    }
440
441    private void showNativeKeyboard() {
442        mHandler.post(new Runnable() {
443            @Override
444            public void run() {
445                mSearchTextEditor.requestFocusFromTouch();
446                mSearchTextEditor.dispatchTouchEvent(MotionEvent.obtain(SystemClock.uptimeMillis(),
447                        SystemClock.uptimeMillis(), MotionEvent.ACTION_DOWN,
448                        mSearchTextEditor.getWidth(), mSearchTextEditor.getHeight(), 0));
449                mSearchTextEditor.dispatchTouchEvent(MotionEvent.obtain(SystemClock.uptimeMillis(),
450                        SystemClock.uptimeMillis(), MotionEvent.ACTION_UP,
451                        mSearchTextEditor.getWidth(), mSearchTextEditor.getHeight(), 0));
452            }
453        });
454    }
455
456    /**
457     * This will update the hint for the search bar properly depending on state and provided title
458     */
459    private void updateHint() {
460        if (null == mSearchTextEditor) return;
461
462        String title = getResources().getString(R.string.lb_search_bar_hint);
463        if (!TextUtils.isEmpty(mTitle)) {
464            if (isVoiceMode()) {
465                title = getResources().getString(R.string.lb_search_bar_hint_with_title_speech, mTitle);
466            } else {
467                title = getResources().getString(R.string.lb_search_bar_hint_with_title, mTitle);
468            }
469        } else if (isVoiceMode()) {
470            title = getResources().getString(R.string.lb_search_bar_hint_speech);
471        }
472        mSearchTextEditor.setHint(title);
473    }
474
475    private void toggleRecognition() {
476        if (mRecognizing) {
477            stopRecognition();
478        } else {
479            startRecognition();
480        }
481    }
482
483    /**
484     * Stop the recognition if already started
485     */
486    public void stopRecognition() {
487        if (!mRecognizing) return;
488        mRecognizing = false;
489        if (mSpeechRecognitionCallback != null) {
490            return;
491        }
492        if (null == mSpeechRecognizer) return;
493
494        if (DEBUG) Log.v(TAG, "stopRecognition " + mListening);
495        mSpeechOrbView.showNotListening();
496
497        if (mListening) {
498            mSpeechRecognizer.cancel();
499        }
500    }
501
502    /**
503     * Start the voice recognition
504     */
505    public void startRecognition() {
506        if (mRecognizing) return;
507        mRecognizing = true;
508        if (!hasFocus()) {
509            requestFocus();
510        }
511        if (mSpeechRecognitionCallback != null) {
512            mSearchTextEditor.setText("");
513            mSpeechRecognitionCallback.recognizeSpeech();
514            return;
515        }
516        if (null == mSpeechRecognizer) return;
517
518        if (DEBUG) Log.v(TAG, "startRecognition " + mListening);
519
520        mSearchTextEditor.setText("");
521
522        Intent recognizerIntent = new Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH);
523
524        recognizerIntent.putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL,
525                RecognizerIntent.LANGUAGE_MODEL_FREE_FORM);
526        recognizerIntent.putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true);
527
528        mSpeechRecognizer.setRecognitionListener(new RecognitionListener() {
529            @Override
530            public void onReadyForSpeech(Bundle bundle) {
531                if (DEBUG) Log.v(TAG, "onReadyForSpeech");
532            }
533
534            @Override
535            public void onBeginningOfSpeech() {
536                if (DEBUG) Log.v(TAG, "onBeginningOfSpeech");
537                mListening = true;
538            }
539
540            @Override
541            public void onRmsChanged(float rmsdB) {
542                if (DEBUG) Log.v(TAG, "onRmsChanged " + rmsdB);
543                int level = rmsdB < 0 ? 0 : (int)(10 * rmsdB);
544                mSpeechOrbView.setSoundLevel(level);
545            }
546
547            @Override
548            public void onBufferReceived(byte[] bytes) {
549                if (DEBUG) Log.v(TAG, "onBufferReceived " + bytes.length);
550            }
551
552            @Override
553            public void onEndOfSpeech() {
554                if (DEBUG) Log.v(TAG, "onEndOfSpeech");
555                mListening = false;
556            }
557
558            @Override
559            public void onError(int error) {
560                if (DEBUG) Log.v(TAG, "onError " + error);
561                switch (error) {
562                    case SpeechRecognizer.ERROR_NO_MATCH:
563                        Log.d(TAG, "recognizer error no match");
564                        break;
565                    case SpeechRecognizer.ERROR_SERVER:
566                        Log.d(TAG, "recognizer error server error");
567                        break;
568                    case SpeechRecognizer.ERROR_SPEECH_TIMEOUT:
569                        Log.d(TAG, "recognizer error speech timeout");
570                        break;
571                    case SpeechRecognizer.ERROR_CLIENT:
572                        Log.d(TAG, "recognizer error client error");
573                        break;
574                    default:
575                        Log.d(TAG, "recognizer other error");
576                        break;
577                }
578
579                mSpeechRecognizer.stopListening();
580                mListening = false;
581                mSpeechRecognizer.setRecognitionListener(null);
582                mSpeechOrbView.showNotListening();
583                playSearchFailure();
584            }
585
586            @Override
587            public void onResults(Bundle bundle) {
588                if (DEBUG) Log.v(TAG, "onResults");
589                final ArrayList<String> matches =
590                        bundle.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION);
591                if (matches != null) {
592                    Log.v(TAG, "Got results" + matches);
593
594                    mSearchQuery = matches.get(0);
595                    mSearchTextEditor.setText(mSearchQuery);
596                    submitQuery();
597
598                    if (mListening) {
599                        mSpeechRecognizer.stopListening();
600                    }
601                }
602                mSpeechRecognizer.setRecognitionListener(null);
603                mSpeechOrbView.showNotListening();
604                playSearchSuccess();
605            }
606
607            @Override
608            public void onPartialResults(Bundle bundle) {
609
610            }
611
612            @Override
613            public void onEvent(int i, Bundle bundle) {
614
615            }
616        });
617
618        mSpeechOrbView.showListening();
619        playSearchOpen();
620        mSpeechRecognizer.startListening(recognizerIntent);
621        mListening = true;
622    }
623
624    private void updateUi() {
625        if (DEBUG) Log.v(TAG, String.format("Update UI %s %s",
626                isVoiceMode() ? "Voice" : "Text",
627                hasFocus() ? "Focused" : "Unfocused"));
628        if (isVoiceMode()) {
629            mBarBackground.setAlpha(mBackgroundSpeechAlpha);
630            mSearchTextEditor.setTextColor(mTextColorSpeechMode);
631            mSearchTextEditor.setHintTextColor(mTextHintColorSpeechMode);
632        } else {
633            mBarBackground.setAlpha(mBackgroundAlpha);
634            mSearchTextEditor.setTextColor(mTextColor);
635            mSearchTextEditor.setHintTextColor(mTextHintColor);
636        }
637
638        updateHint();
639    }
640
641    private boolean isVoiceMode() {
642        return mSpeechOrbView.isFocused();
643    }
644
645    private void submitQuery() {
646        if (!TextUtils.isEmpty(mSearchQuery) && null != mSearchBarListener) {
647            mSearchBarListener.onSearchQuerySubmit(mSearchQuery);
648        }
649    }
650
651    private void enforceAudioRecordPermission() {
652        String permission = "android.permission.RECORD_AUDIO";
653        int res = getContext().checkCallingOrSelfPermission(permission);
654        if (PackageManager.PERMISSION_GRANTED != res) {
655            throw new IllegalStateException("android.permission.RECORD_AUDIO required for search");
656        }
657    }
658
659    private void loadSounds(Context context) {
660        int[] sounds = {
661                R.raw.lb_voice_failure,
662                R.raw.lb_voice_open,
663                R.raw.lb_voice_no_input,
664                R.raw.lb_voice_success,
665        };
666        for (int sound : sounds) {
667            mSoundMap.put(sound, mSoundPool.load(context, sound, 1));
668        }
669    }
670
671    private void play(final int resId) {
672        mHandler.post(new Runnable() {
673            @Override
674            public void run() {
675                int sound = mSoundMap.get(resId);
676                mSoundPool.play(sound, FULL_LEFT_VOLUME, FULL_RIGHT_VOLUME, DEFAULT_PRIORITY,
677                        DO_NOT_LOOP, DEFAULT_RATE);
678            }
679        });
680    }
681
682    private void playSearchOpen() {
683        play(R.raw.lb_voice_open);
684    }
685
686    private void playSearchFailure() {
687        play(R.raw.lb_voice_failure);
688    }
689
690    private void playSearchNoInput() {
691        play(R.raw.lb_voice_no_input);
692    }
693
694    private void playSearchSuccess() {
695        play(R.raw.lb_voice_success);
696    }
697
698    @Override
699    public void setNextFocusDownId(int viewId) {
700        mSpeechOrbView.setNextFocusDownId(viewId);
701        mSearchTextEditor.setNextFocusDownId(viewId);
702    }
703
704}
705