SearchBar.java revision 2eac9924967d79de4a4488eb9bf0fc2950819670
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                // while IME opens,  text editor becomes "" then restores to current value
200                mHandler.removeCallbacks(mOnTextChangedRunnable);
201                mHandler.post(mOnTextChangedRunnable);
202            }
203
204            @Override
205            public void afterTextChanged(Editable editable) {
206
207            }
208        });
209        mSearchTextEditor.setOnKeyboardDismissListener(
210                new SearchEditText.OnKeyboardDismissListener() {
211                    @Override
212                    public void onKeyboardDismiss() {
213                        if (null != mSearchBarListener) {
214                            mSearchBarListener.onKeyboardDismiss(mSearchQuery);
215                        }
216                    }
217                });
218
219        mSearchTextEditor.setOnEditorActionListener(new TextView.OnEditorActionListener() {
220            @Override
221            public boolean onEditorAction(TextView textView, int action, KeyEvent keyEvent) {
222                if (DEBUG) Log.v(TAG, "onEditorAction: " + action + " event: " + keyEvent);
223                boolean handled = true;
224                if (EditorInfo.IME_ACTION_SEARCH == action && null != mSearchBarListener) {
225                    if (DEBUG) Log.v(TAG, "Action Pressed");
226                    hideNativeKeyboard();
227                    mHandler.postDelayed(new Runnable() {
228                        @Override
229                        public void run() {
230                            if (DEBUG) Log.v(TAG, "Delayed action handling (search)");
231                            submitQuery();
232                        }
233                    }, 500);
234
235                } else if (EditorInfo.IME_ACTION_NONE == action && null != mSearchBarListener) {
236                    if (DEBUG) Log.v(TAG, "Escaped North");
237                    hideNativeKeyboard();
238                    mHandler.postDelayed(new Runnable() {
239                        @Override
240                        public void run() {
241                            if (DEBUG) Log.v(TAG, "Delayed action handling (escape_north)");
242                            mSearchBarListener.onKeyboardDismiss(mSearchQuery);
243                        }
244                    }, 500);
245                } else if (EditorInfo.IME_ACTION_GO == action) {
246                    if (DEBUG) Log.v(TAG, "Voice Clicked");
247                        hideNativeKeyboard();
248                        mHandler.postDelayed(new Runnable() {
249                            @Override
250                            public void run() {
251                                if (DEBUG) Log.v(TAG, "Delayed action handling (voice_mode)");
252                                mAutoStartRecognition = true;
253                                mSpeechOrbView.requestFocus();
254                            }
255                        }, 500);
256                } else {
257                    handled = false;
258                }
259
260                return handled;
261            }
262        });
263
264        mSearchTextEditor.setPrivateImeOptions("EscapeNorth=1;VoiceDismiss=1;");
265
266        mSpeechOrbView = (SpeechOrbView)findViewById(R.id.lb_search_bar_speech_orb);
267        mSpeechOrbView.setOnOrbClickedListener(new OnClickListener() {
268            @Override
269            public void onClick(View view) {
270                toggleRecognition();
271            }
272        });
273        mSpeechOrbView.setOnFocusChangeListener(new OnFocusChangeListener() {
274            @Override
275            public void onFocusChange(View view, boolean hasFocus) {
276                if (DEBUG) Log.v(TAG, "SpeechOrb.onFocusChange " + hasFocus);
277                if (hasFocus) {
278                    hideNativeKeyboard();
279                    if (mAutoStartRecognition) {
280                        startRecognition();
281                        mAutoStartRecognition = false;
282                    }
283                } else {
284                    stopRecognition();
285                }
286                updateUi();
287            }
288        });
289
290        updateUi();
291        updateHint();
292    }
293
294    @Override
295    protected void onAttachedToWindow() {
296        super.onAttachedToWindow();
297        if (DEBUG) Log.v(TAG, "Loading soundPool");
298        mSoundPool = new SoundPool(2, AudioManager.STREAM_SYSTEM, 0);
299        loadSounds(mContext);
300    }
301
302    @Override
303    protected void onDetachedFromWindow() {
304        if (DEBUG) Log.v(TAG, "Releasing SoundPool");
305        mSoundPool.release();
306
307        super.onDetachedFromWindow();
308    }
309
310    /**
311     * Set a listener for when the term search changes
312     * @param listener
313     */
314    public void setSearchBarListener(SearchBarListener listener) {
315        mSearchBarListener = listener;
316    }
317
318    /**
319     * Set the search query
320     * @param query the search query to use
321     */
322    public void setSearchQuery(String query) {
323        mSearchTextEditor.setText(query);
324        setSearchQueryInternal(query);
325    }
326
327    private void setSearchQueryInternal(String query) {
328        if (DEBUG) Log.v(TAG, "setSearchQueryInternal " + query);
329        if (TextUtils.equals(mSearchQuery, query)) {
330            return;
331        }
332        mSearchQuery = query;
333
334        if (null != mSearchBarListener) {
335            mSearchBarListener.onSearchQueryChange(mSearchQuery);
336        }
337    }
338
339    /**
340     * Set the title text used in the hint shown in the search bar.
341     * @param title The hint to use.
342     */
343    public void setTitle(String title) {
344        mTitle = title;
345        updateHint();
346    }
347
348    /**
349     * Returns the current title
350     */
351    public String getTitle() {
352        return mTitle;
353    }
354
355    /**
356     * Returns the current search bar hint text.
357     */
358    public CharSequence getHint() {
359        return (mSearchTextEditor == null) ? null : mSearchTextEditor.getHint();
360    }
361
362    /**
363     * Set the badge drawable showing inside the search bar.
364     * @param drawable The drawable to be used in the search bar.
365     */
366    public void setBadgeDrawable(Drawable drawable) {
367        mBadgeDrawable = drawable;
368        if (null != mBadgeView) {
369            mBadgeView.setImageDrawable(drawable);
370            if (null != drawable) {
371                mBadgeView.setVisibility(View.VISIBLE);
372            } else {
373                mBadgeView.setVisibility(View.GONE);
374            }
375        }
376    }
377
378    /**
379     * Returns the badge drawable
380     */
381    public Drawable getBadgeDrawable() {
382        return mBadgeDrawable;
383    }
384
385    /**
386     * Update the completion list shown by the IME
387     *
388     * @param completions list of completions shown in the IME, can be null or empty to clear them
389     */
390    public void displayCompletions(List<String> completions) {
391        List<CompletionInfo> infos = new ArrayList<CompletionInfo>();
392        if (null != completions) {
393            for (String completion : completions) {
394                infos.add(new CompletionInfo(infos.size(), infos.size(), completion));
395            }
396        }
397
398        mInputMethodManager.displayCompletions(mSearchTextEditor,
399                infos.toArray(new CompletionInfo[] {}));
400    }
401
402    /**
403     * Set the speech recognizer to be used when doing voice search. The Activity/Fragment is in
404     * charge of creating and destroying the recognizer with its own lifecycle.
405     *
406     * @param recognizer a SpeechRecognizer
407     */
408    public void setSpeechRecognizer(SpeechRecognizer recognizer) {
409        if (null != mSpeechRecognizer) {
410            mSpeechRecognizer.setRecognitionListener(null);
411            if (mListening) {
412                mSpeechRecognizer.stopListening();
413                mListening = false;
414            }
415        }
416        mSpeechRecognizer = recognizer;
417        if (mSpeechRecognizer != null) {
418            enforceAudioRecordPermission();
419        }
420        if (mSpeechRecognitionCallback != null && mSpeechRecognizer != null) {
421            throw new IllegalStateException("Can't have speech recognizer and request");
422        }
423    }
424
425    public void setSpeechRecognitionCallback(SpeechRecognitionCallback request) {
426        mSpeechRecognitionCallback = request;
427        if (mSpeechRecognitionCallback != null && mSpeechRecognizer != null) {
428            throw new IllegalStateException("Can't have speech recognizer and request");
429        }
430    }
431
432    private void hideNativeKeyboard() {
433        mInputMethodManager.hideSoftInputFromWindow(mSearchTextEditor.getWindowToken(),
434                InputMethodManager.RESULT_UNCHANGED_SHOWN);
435    }
436
437    private void showNativeKeyboard() {
438        mHandler.post(new Runnable() {
439            @Override
440            public void run() {
441                mSearchTextEditor.requestFocusFromTouch();
442                mSearchTextEditor.dispatchTouchEvent(MotionEvent.obtain(SystemClock.uptimeMillis(),
443                        SystemClock.uptimeMillis(), MotionEvent.ACTION_DOWN,
444                        mSearchTextEditor.getWidth(), mSearchTextEditor.getHeight(), 0));
445                mSearchTextEditor.dispatchTouchEvent(MotionEvent.obtain(SystemClock.uptimeMillis(),
446                        SystemClock.uptimeMillis(), MotionEvent.ACTION_UP,
447                        mSearchTextEditor.getWidth(), mSearchTextEditor.getHeight(), 0));
448            }
449        });
450    }
451
452    /**
453     * This will update the hint for the search bar properly depending on state and provided title
454     */
455    private void updateHint() {
456        if (null == mSearchTextEditor) return;
457
458        String title = getResources().getString(R.string.lb_search_bar_hint);
459        if (!TextUtils.isEmpty(mTitle)) {
460            if (isVoiceMode()) {
461                title = getResources().getString(R.string.lb_search_bar_hint_with_title_speech, mTitle);
462            } else {
463                title = getResources().getString(R.string.lb_search_bar_hint_with_title, mTitle);
464            }
465        } else if (isVoiceMode()) {
466            title = getResources().getString(R.string.lb_search_bar_hint_speech);
467        }
468        mSearchTextEditor.setHint(title);
469    }
470
471    private void toggleRecognition() {
472        if (mRecognizing) {
473            stopRecognition();
474        } else {
475            startRecognition();
476        }
477    }
478
479    /**
480     * Stop the recognition if already started
481     */
482    public void stopRecognition() {
483        if (mSpeechRecognitionCallback != null) {
484            return;
485        }
486        if (null == mSpeechRecognizer) return;
487        if (!mRecognizing) return;
488        mRecognizing = false;
489
490        if (DEBUG) Log.v(TAG, "stopRecognition " + mListening);
491        mSpeechOrbView.showNotListening();
492
493        if (mListening) {
494            mSpeechRecognizer.cancel();
495        }
496    }
497
498    /**
499     * Start the voice recognition
500     */
501    public void startRecognition() {
502        if (mSpeechRecognitionCallback != null) {
503            mSearchTextEditor.setText("");
504            mSpeechRecognitionCallback.recognizeSpeech();
505            return;
506        }
507        if (null == mSpeechRecognizer) return;
508        if (mRecognizing) return;
509        mRecognizing = true;
510
511        if (DEBUG) Log.v(TAG, "startRecognition " + mListening);
512
513        mSearchTextEditor.setText("");
514
515        Intent recognizerIntent = new Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH);
516
517        recognizerIntent.putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL,
518                RecognizerIntent.LANGUAGE_MODEL_FREE_FORM);
519        recognizerIntent.putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true);
520
521        mSpeechRecognizer.setRecognitionListener(new RecognitionListener() {
522            @Override
523            public void onReadyForSpeech(Bundle bundle) {
524                if (DEBUG) Log.v(TAG, "onReadyForSpeech");
525            }
526
527            @Override
528            public void onBeginningOfSpeech() {
529                if (DEBUG) Log.v(TAG, "onBeginningOfSpeech");
530                mListening = true;
531            }
532
533            @Override
534            public void onRmsChanged(float rmsdB) {
535                if (DEBUG) Log.v(TAG, "onRmsChanged " + rmsdB);
536                int level = rmsdB < 0 ? 0 : (int)(10 * rmsdB);
537                mSpeechOrbView.setSoundLevel(level);
538            }
539
540            @Override
541            public void onBufferReceived(byte[] bytes) {
542                if (DEBUG) Log.v(TAG, "onBufferReceived " + bytes.length);
543            }
544
545            @Override
546            public void onEndOfSpeech() {
547                if (DEBUG) Log.v(TAG, "onEndOfSpeech");
548                mListening = false;
549            }
550
551            @Override
552            public void onError(int error) {
553                if (DEBUG) Log.v(TAG, "onError " + error);
554                switch (error) {
555                    case SpeechRecognizer.ERROR_NO_MATCH:
556                        Log.d(TAG, "recognizer error no match");
557                        break;
558                    case SpeechRecognizer.ERROR_SERVER:
559                        Log.d(TAG, "recognizer error server error");
560                        break;
561                    case SpeechRecognizer.ERROR_SPEECH_TIMEOUT:
562                        Log.d(TAG, "recognizer error speech timeout");
563                        break;
564                    case SpeechRecognizer.ERROR_CLIENT:
565                        Log.d(TAG, "recognizer error client error");
566                        break;
567                    default:
568                        Log.d(TAG, "recognizer other error");
569                        break;
570                }
571
572                mSpeechRecognizer.stopListening();
573                mListening = false;
574                mSpeechRecognizer.setRecognitionListener(null);
575                mSpeechOrbView.showNotListening();
576                playSearchFailure();
577            }
578
579            @Override
580            public void onResults(Bundle bundle) {
581                if (DEBUG) Log.v(TAG, "onResults");
582                final ArrayList<String> matches =
583                        bundle.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION);
584                if (matches != null) {
585                    Log.v(TAG, "Got results" + matches);
586
587                    mSearchQuery = matches.get(0);
588                    mSearchTextEditor.setText(mSearchQuery);
589                    submitQuery();
590
591                    if (mListening) {
592                        mSpeechRecognizer.stopListening();
593                    }
594                }
595                mSpeechRecognizer.setRecognitionListener(null);
596                mSpeechOrbView.showNotListening();
597                playSearchSuccess();
598            }
599
600            @Override
601            public void onPartialResults(Bundle bundle) {
602
603            }
604
605            @Override
606            public void onEvent(int i, Bundle bundle) {
607
608            }
609        });
610
611        mSpeechOrbView.showListening();
612        playSearchOpen();
613        mSpeechRecognizer.startListening(recognizerIntent);
614        mListening = true;
615    }
616
617    private void updateUi() {
618        if (DEBUG) Log.v(TAG, String.format("Update UI %s %s",
619                isVoiceMode() ? "Voice" : "Text",
620                hasFocus() ? "Focused" : "Unfocused"));
621        if (isVoiceMode()) {
622            mBarBackground.setAlpha(mBackgroundSpeechAlpha);
623            mSearchTextEditor.setTextColor(mTextColorSpeechMode);
624            mSearchTextEditor.setHintTextColor(mTextHintColorSpeechMode);
625        } else {
626            mBarBackground.setAlpha(mBackgroundAlpha);
627            mSearchTextEditor.setTextColor(mTextColor);
628            mSearchTextEditor.setHintTextColor(mTextHintColor);
629        }
630
631        updateHint();
632    }
633
634    private boolean isVoiceMode() {
635        return mSpeechOrbView.isFocused();
636    }
637
638    private void submitQuery() {
639        if (!TextUtils.isEmpty(mSearchQuery) && null != mSearchBarListener) {
640            mSearchBarListener.onSearchQuerySubmit(mSearchQuery);
641        }
642    }
643
644    private void enforceAudioRecordPermission() {
645        String permission = "android.permission.RECORD_AUDIO";
646        int res = getContext().checkCallingOrSelfPermission(permission);
647        if (PackageManager.PERMISSION_GRANTED != res) {
648            throw new IllegalStateException("android.permission.RECORD_AUDIO required for search");
649        }
650    }
651
652    private void loadSounds(Context context) {
653        int[] sounds = {
654                R.raw.lb_voice_failure,
655                R.raw.lb_voice_open,
656                R.raw.lb_voice_no_input,
657                R.raw.lb_voice_success,
658        };
659        for (int sound : sounds) {
660            mSoundMap.put(sound, mSoundPool.load(context, sound, 1));
661        }
662    }
663
664    private void play(final int resId) {
665        mHandler.post(new Runnable() {
666            @Override
667            public void run() {
668                int sound = mSoundMap.get(resId);
669                mSoundPool.play(sound, FULL_LEFT_VOLUME, FULL_RIGHT_VOLUME, DEFAULT_PRIORITY,
670                        DO_NOT_LOOP, DEFAULT_RATE);
671            }
672        });
673    }
674
675    private void playSearchOpen() {
676        play(R.raw.lb_voice_open);
677    }
678
679    private void playSearchFailure() {
680        play(R.raw.lb_voice_failure);
681    }
682
683    private void playSearchNoInput() {
684        play(R.raw.lb_voice_no_input);
685    }
686
687    private void playSearchSuccess() {
688        play(R.raw.lb_voice_success);
689    }
690
691    @Override
692    public void setNextFocusDownId(int viewId) {
693        mSpeechOrbView.setNextFocusDownId(viewId);
694        mSearchTextEditor.setNextFocusDownId(viewId);
695    }
696
697}
698