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