SearchBar.java revision 963e336e8839f26c739007242caf7ca4e50e792d
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        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(query);
326        setSearchQueryInternal(query);
327    }
328
329    private void setSearchQueryInternal(String query) {
330        if (DEBUG) Log.v(TAG, "setSearchQueryInternal " + 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     * Returns the current search bar hint text.
359     */
360    public CharSequence getHint() {
361        return (mSearchTextEditor == null) ? null : mSearchTextEditor.getHint();
362    }
363
364    /**
365     * Set the badge drawable showing inside the search bar.
366     * @param drawable The drawable to be used in the search bar.
367     */
368    public void setBadgeDrawable(Drawable drawable) {
369        mBadgeDrawable = drawable;
370        if (null != mBadgeView) {
371            mBadgeView.setImageDrawable(drawable);
372            if (null != drawable) {
373                mBadgeView.setVisibility(View.VISIBLE);
374            } else {
375                mBadgeView.setVisibility(View.GONE);
376            }
377        }
378    }
379
380    /**
381     * Returns the badge drawable
382     */
383    public Drawable getBadgeDrawable() {
384        return mBadgeDrawable;
385    }
386
387    /**
388     * Update the completion list shown by the IME
389     *
390     * @param completions list of completions shown in the IME, can be null or empty to clear them
391     */
392    public void displayCompletions(List<String> completions) {
393        List<CompletionInfo> infos = new ArrayList<CompletionInfo>();
394        if (null != completions) {
395            for (String completion : completions) {
396                infos.add(new CompletionInfo(infos.size(), infos.size(), completion));
397            }
398        }
399
400        mInputMethodManager.displayCompletions(mSearchTextEditor,
401                infos.toArray(new CompletionInfo[] {}));
402    }
403
404    /**
405     * Set the speech recognizer to be used when doing voice search. The Activity/Fragment is in
406     * charge of creating and destroying the recognizer with its own lifecycle.
407     *
408     * @param recognizer a SpeechRecognizer
409     */
410    public void setSpeechRecognizer(SpeechRecognizer recognizer) {
411        if (null != mSpeechRecognizer) {
412            mSpeechRecognizer.setRecognitionListener(null);
413            if (mListening) {
414                mSpeechRecognizer.stopListening();
415                mListening = false;
416            }
417        }
418        mSpeechRecognizer = recognizer;
419        if (mSpeechRecognizer != null) {
420            enforceAudioRecordPermission();
421        }
422        if (mSpeechRecognitionCallback != null && mSpeechRecognizer != null) {
423            throw new IllegalStateException("Can't have speech recognizer and request");
424        }
425    }
426
427    public void setSpeechRecognitionCallback(SpeechRecognitionCallback request) {
428        mSpeechRecognitionCallback = request;
429        if (mSpeechRecognitionCallback != null && mSpeechRecognizer != null) {
430            throw new IllegalStateException("Can't have speech recognizer and request");
431        }
432    }
433
434    private void hideNativeKeyboard() {
435        mInputMethodManager.hideSoftInputFromWindow(mSearchTextEditor.getWindowToken(),
436                InputMethodManager.RESULT_UNCHANGED_SHOWN);
437    }
438
439    private void showNativeKeyboard() {
440        mHandler.post(new Runnable() {
441            @Override
442            public void run() {
443                mSearchTextEditor.requestFocusFromTouch();
444                mSearchTextEditor.dispatchTouchEvent(MotionEvent.obtain(SystemClock.uptimeMillis(),
445                        SystemClock.uptimeMillis(), MotionEvent.ACTION_DOWN,
446                        mSearchTextEditor.getWidth(), mSearchTextEditor.getHeight(), 0));
447                mSearchTextEditor.dispatchTouchEvent(MotionEvent.obtain(SystemClock.uptimeMillis(),
448                        SystemClock.uptimeMillis(), MotionEvent.ACTION_UP,
449                        mSearchTextEditor.getWidth(), mSearchTextEditor.getHeight(), 0));
450            }
451        });
452    }
453
454    /**
455     * This will update the hint for the search bar properly depending on state and provided title
456     */
457    private void updateHint() {
458        if (null == mSearchTextEditor) return;
459
460        String title = getResources().getString(R.string.lb_search_bar_hint);
461        if (!TextUtils.isEmpty(mTitle)) {
462            if (isVoiceMode()) {
463                title = getResources().getString(R.string.lb_search_bar_hint_with_title_speech, mTitle);
464            } else {
465                title = getResources().getString(R.string.lb_search_bar_hint_with_title, mTitle);
466            }
467        } else if (isVoiceMode()) {
468            title = getResources().getString(R.string.lb_search_bar_hint_speech);
469        }
470        mSearchTextEditor.setHint(title);
471    }
472
473    private void toggleRecognition() {
474        if (mRecognizing) {
475            stopRecognition();
476        } else {
477            startRecognition();
478        }
479    }
480
481    private void stopRecognition() {
482        if (mSpeechRecognitionCallback != null) {
483            return;
484        }
485        if (null == mSpeechRecognizer) return;
486        if (!mRecognizing) return;
487        mRecognizing = false;
488
489        if (DEBUG) Log.v(TAG, "stopRecognition " + mListening);
490        mSpeechOrbView.showNotListening();
491
492        if (mListening) {
493            mSpeechRecognizer.cancel();
494        }
495    }
496
497    private void startRecognition() {
498        if (mSpeechRecognitionCallback != null) {
499            mSearchTextEditor.setText("");
500            mSpeechRecognitionCallback.recognizeSpeech();
501            return;
502        }
503        if (null == mSpeechRecognizer) return;
504        if (mRecognizing) return;
505        mRecognizing = true;
506
507        if (DEBUG) Log.v(TAG, "startRecognition " + mListening);
508
509        mSearchTextEditor.setText("");
510
511        Intent recognizerIntent = new Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH);
512
513        recognizerIntent.putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL,
514                RecognizerIntent.LANGUAGE_MODEL_FREE_FORM);
515        recognizerIntent.putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true);
516
517        mSpeechRecognizer.setRecognitionListener(new RecognitionListener() {
518            @Override
519            public void onReadyForSpeech(Bundle bundle) {
520                if (DEBUG) Log.v(TAG, "onReadyForSpeech");
521            }
522
523            @Override
524            public void onBeginningOfSpeech() {
525                if (DEBUG) Log.v(TAG, "onBeginningOfSpeech");
526                mListening = true;
527            }
528
529            @Override
530            public void onRmsChanged(float rmsdB) {
531                if (DEBUG) Log.v(TAG, "onRmsChanged " + rmsdB);
532                int level = rmsdB < 0 ? 0 : (int)(10 * rmsdB);
533                mSpeechOrbView.setSoundLevel(level);
534            }
535
536            @Override
537            public void onBufferReceived(byte[] bytes) {
538                if (DEBUG) Log.v(TAG, "onBufferReceived " + bytes.length);
539            }
540
541            @Override
542            public void onEndOfSpeech() {
543                if (DEBUG) Log.v(TAG, "onEndOfSpeech");
544                mListening = false;
545            }
546
547            @Override
548            public void onError(int error) {
549                if (DEBUG) Log.v(TAG, "onError " + error);
550                switch (error) {
551                    case SpeechRecognizer.ERROR_NO_MATCH:
552                        Log.d(TAG, "recognizer error no match");
553                        break;
554                    case SpeechRecognizer.ERROR_SERVER:
555                        Log.d(TAG, "recognizer error server error");
556                        break;
557                    case SpeechRecognizer.ERROR_SPEECH_TIMEOUT:
558                        Log.d(TAG, "recognizer error speech timeout");
559                        break;
560                    case SpeechRecognizer.ERROR_CLIENT:
561                        Log.d(TAG, "recognizer error client error");
562                        break;
563                    default:
564                        Log.d(TAG, "recognizer other error");
565                        break;
566                }
567
568                mSpeechRecognizer.stopListening();
569                mListening = false;
570                mSpeechRecognizer.setRecognitionListener(null);
571                mSpeechOrbView.showNotListening();
572                playSearchFailure();
573            }
574
575            @Override
576            public void onResults(Bundle bundle) {
577                if (DEBUG) Log.v(TAG, "onResults");
578                final ArrayList<String> matches =
579                        bundle.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION);
580                if (matches != null) {
581                    Log.v(TAG, "Got results" + matches);
582
583                    mSearchQuery = matches.get(0);
584                    mSearchTextEditor.setText(mSearchQuery);
585                    submitQuery();
586
587                    if (mListening) {
588                        mSpeechRecognizer.stopListening();
589                    }
590                }
591                mSpeechRecognizer.setRecognitionListener(null);
592                mSpeechOrbView.showNotListening();
593                playSearchSuccess();
594            }
595
596            @Override
597            public void onPartialResults(Bundle bundle) {
598
599            }
600
601            @Override
602            public void onEvent(int i, Bundle bundle) {
603
604            }
605        });
606
607        mSpeechOrbView.showListening();
608        playSearchOpen();
609        mSpeechRecognizer.startListening(recognizerIntent);
610        mListening = true;
611    }
612
613    private void updateUi() {
614        if (DEBUG) Log.v(TAG, String.format("Update UI %s %s",
615                isVoiceMode() ? "Voice" : "Text",
616                hasFocus() ? "Focused" : "Unfocused"));
617        if (isVoiceMode()) {
618            mBarBackground.setAlpha(mBackgroundSpeechAlpha);
619            mSearchTextEditor.setTextColor(mTextColorSpeechMode);
620            mSearchTextEditor.setHintTextColor(mTextHintColorSpeechMode);
621        } else {
622            mBarBackground.setAlpha(mBackgroundAlpha);
623            mSearchTextEditor.setTextColor(mTextColor);
624            mSearchTextEditor.setHintTextColor(mTextHintColor);
625        }
626
627        updateHint();
628    }
629
630    private boolean isVoiceMode() {
631        return mSpeechOrbView.isFocused();
632    }
633
634    private void submitQuery() {
635        if (!TextUtils.isEmpty(mSearchQuery) && null != mSearchBarListener) {
636            mSearchBarListener.onSearchQuerySubmit(mSearchQuery);
637        }
638    }
639
640    private void enforceAudioRecordPermission() {
641        String permission = "android.permission.RECORD_AUDIO";
642        int res = getContext().checkCallingOrSelfPermission(permission);
643        if (PackageManager.PERMISSION_GRANTED != res) {
644            throw new IllegalStateException("android.permission.RECORD_AUDIO required for search");
645        }
646    }
647
648    private void loadSounds(Context context) {
649        int[] sounds = {
650                R.raw.lb_voice_failure,
651                R.raw.lb_voice_open,
652                R.raw.lb_voice_no_input,
653                R.raw.lb_voice_success,
654        };
655        for (int sound : sounds) {
656            mSoundMap.put(sound, mSoundPool.load(context, sound, 1));
657        }
658    }
659
660    private void play(final int resId) {
661        mHandler.post(new Runnable() {
662            @Override
663            public void run() {
664                int sound = mSoundMap.get(resId);
665                mSoundPool.play(sound, FULL_LEFT_VOLUME, FULL_RIGHT_VOLUME, DEFAULT_PRIORITY,
666                        DO_NOT_LOOP, DEFAULT_RATE);
667            }
668        });
669    }
670
671    private void playSearchOpen() {
672        play(R.raw.lb_voice_open);
673    }
674
675    private void playSearchFailure() {
676        play(R.raw.lb_voice_failure);
677    }
678
679    private void playSearchNoInput() {
680        play(R.raw.lb_voice_no_input);
681    }
682
683    private void playSearchSuccess() {
684        play(R.raw.lb_voice_success);
685    }
686
687    @Override
688    public void setNextFocusDownId(int viewId) {
689        mSpeechOrbView.setNextFocusDownId(viewId);
690        mSearchTextEditor.setNextFocusDownId(viewId);
691    }
692
693}
694