SearchBar.java revision a6c18317f09969688b7d329a5b4ce35c8d648e4f
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 boolean mListening;
118    private SoundPool mSoundPool;
119    private SparseIntArray mSoundMap = new SparseIntArray();
120    private boolean mRecognizing = false;
121    private final Context mContext;
122
123    public SearchBar(Context context) {
124        this(context, null);
125    }
126
127    public SearchBar(Context context, AttributeSet attrs) {
128        this(context, attrs, 0);
129    }
130
131    public SearchBar(Context context, AttributeSet attrs, int defStyle) {
132        super(context, attrs, defStyle);
133        mContext = context;
134        enforceAudioRecordPermission();
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                    setSearchQuery(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        updateHint();
286    }
287
288    @Override
289    protected void onAttachedToWindow() {
290        super.onAttachedToWindow();
291        if (DEBUG) Log.v(TAG, "Loading soundPool");
292        mSoundPool = new SoundPool(2, AudioManager.STREAM_SYSTEM, 0);
293        loadSounds(mContext);
294
295        mHandler.postDelayed(new Runnable() {
296            @Override
297            public void run() {
298                startRecognition();
299            }
300        }, 300);
301    }
302
303    @Override
304    protected void onDetachedFromWindow() {
305        if (DEBUG) Log.v(TAG, "Releasing SoundPool");
306        mSoundPool.release();
307
308        super.onDetachedFromWindow();
309    }
310
311    /**
312     * Set a listener for when the term search changes
313     * @param listener
314     */
315    public void setSearchBarListener(SearchBarListener listener) {
316        mSearchBarListener = listener;
317    }
318
319    /**
320     * Set the search query
321     * @param query the search query to use
322     */
323    public void setSearchQuery(String query) {
324        if (query.equals(mSearchQuery)) {
325            return;
326        }
327        mSearchQuery = query;
328        if (null != mSearchBarListener) {
329            mSearchBarListener.onSearchQueryChange(mSearchQuery);
330        }
331    }
332
333    /**
334     * Set the title text used in the hint shown in the search bar.
335     * @param title The hint to use.
336     */
337    public void setTitle(String title) {
338        mTitle = title;
339        updateHint();
340    }
341
342    /**
343     * Returns the current title
344     */
345    public String getTitle() {
346        return mTitle;
347    }
348
349    /**
350     * Set the badge drawable showing inside the search bar.
351     * @param drawable The drawable to be used in the search bar.
352     */
353    public void setBadgeDrawable(Drawable drawable) {
354        mBadgeDrawable = drawable;
355        if (null != mBadgeView) {
356            mBadgeView.setImageDrawable(drawable);
357            if (null != drawable) {
358                mBadgeView.setVisibility(View.VISIBLE);
359            } else {
360                mBadgeView.setVisibility(View.GONE);
361            }
362        }
363    }
364
365    /**
366     * Returns the badge drawable
367     */
368    public Drawable getBadgeDrawable() {
369        return mBadgeDrawable;
370    }
371
372    /**
373     * Update the completion list shown by the IME
374     *
375     * @param completions list of completions shown in the IME, can be null or empty to clear them
376     */
377    public void displayCompletions(List<String> completions) {
378        List<CompletionInfo> infos = new ArrayList<CompletionInfo>();
379        if (null != completions) {
380            for (String completion : completions) {
381                infos.add(new CompletionInfo(infos.size(), infos.size(), completion));
382            }
383        }
384
385        mInputMethodManager.displayCompletions(mSearchTextEditor,
386                infos.toArray(new CompletionInfo[] {}));
387    }
388
389    /**
390     * Set the speech recognizer to be used when doing voice search. The Activity/Fragment is in
391     * charge of creating and destroying the recognizer with its own lifecycle.
392     *
393     * @param recognizer a SpeechRecognizer
394     */
395    public void setSpeechRecognizer(SpeechRecognizer recognizer) {
396        if (null != mSpeechRecognizer) {
397            mSpeechRecognizer.setRecognitionListener(null);
398            if (mListening) {
399                mSpeechRecognizer.stopListening();
400                mListening = false;
401            }
402        }
403        mSpeechRecognizer = recognizer;
404    }
405
406    private void hideNativeKeyboard() {
407        mInputMethodManager.hideSoftInputFromWindow(mSearchTextEditor.getWindowToken(),
408                InputMethodManager.RESULT_UNCHANGED_SHOWN);
409    }
410
411    private void showNativeKeyboard() {
412        mHandler.post(new Runnable() {
413            @Override
414            public void run() {
415                mSearchTextEditor.requestFocusFromTouch();
416                mSearchTextEditor.dispatchTouchEvent(MotionEvent.obtain(SystemClock.uptimeMillis(),
417                        SystemClock.uptimeMillis(), MotionEvent.ACTION_DOWN,
418                        mSearchTextEditor.getWidth(), mSearchTextEditor.getHeight(), 0));
419                mSearchTextEditor.dispatchTouchEvent(MotionEvent.obtain(SystemClock.uptimeMillis(),
420                        SystemClock.uptimeMillis(), MotionEvent.ACTION_UP,
421                        mSearchTextEditor.getWidth(), mSearchTextEditor.getHeight(), 0));
422            }
423        });
424    }
425
426    /**
427     * This will update the hint for the search bar properly depending on state and provided title
428     */
429    private void updateHint() {
430        if (null == mSearchTextEditor) return;
431
432        String title = getResources().getString(R.string.lb_search_bar_hint);
433        if (!TextUtils.isEmpty(mTitle)) {
434            if (isVoiceMode()) {
435                title = getResources().getString(R.string.lb_search_bar_hint_with_title_speech, mTitle);
436            } else {
437                title = getResources().getString(R.string.lb_search_bar_hint_with_title, mTitle);
438            }
439        } else if (isVoiceMode()) {
440            title = getResources().getString(R.string.lb_search_bar_hint_speech);
441        }
442        mSearchTextEditor.setHint(title);
443    }
444
445    private void toggleRecognition() {
446        if (mRecognizing) {
447            stopRecognition();
448        } else {
449            startRecognition();
450        }
451    }
452
453    private void stopRecognition() {
454        if (null == mSpeechRecognizer) return;
455        if (!mRecognizing) return;
456        mRecognizing = false;
457
458        if (DEBUG) Log.v(TAG, "stopRecognition " + mListening);
459        mSpeechOrbView.showNotListening();
460
461        if (mListening) {
462            mSpeechRecognizer.cancel();
463        }
464    }
465
466    private void startRecognition() {
467        if (null == mSpeechRecognizer) return;
468        if (mRecognizing) return;
469        mRecognizing = true;
470
471        if (DEBUG) Log.v(TAG, "startRecognition " + mListening);
472
473        mSearchTextEditor.setText("");
474
475        Intent recognizerIntent = new Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH);
476
477        recognizerIntent.putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL,
478                RecognizerIntent.LANGUAGE_MODEL_FREE_FORM);
479        recognizerIntent.putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true);
480
481        mSpeechRecognizer.setRecognitionListener(new RecognitionListener() {
482            @Override
483            public void onReadyForSpeech(Bundle bundle) {
484                if (DEBUG) Log.v(TAG, "onReadyForSpeech");
485            }
486
487            @Override
488            public void onBeginningOfSpeech() {
489                if (DEBUG) Log.v(TAG, "onBeginningOfSpeech");
490                mListening = true;
491            }
492
493            @Override
494            public void onRmsChanged(float rmsdB) {
495                if (DEBUG) Log.v(TAG, "onRmsChanged " + rmsdB);
496                int level = rmsdB < 0 ? 0 : (int)(10 * rmsdB);
497                mSpeechOrbView.setSoundLevel(level);
498            }
499
500            @Override
501            public void onBufferReceived(byte[] bytes) {
502                if (DEBUG) Log.v(TAG, "onBufferReceived " + bytes.length);
503            }
504
505            @Override
506            public void onEndOfSpeech() {
507                if (DEBUG) Log.v(TAG, "onEndOfSpeech");
508                mListening = false;
509            }
510
511            @Override
512            public void onError(int error) {
513                if (DEBUG) Log.v(TAG, "onError " + error);
514                switch (error) {
515                    case SpeechRecognizer.ERROR_NO_MATCH:
516                        Log.d(TAG, "recognizer error no match");
517                        break;
518                    case SpeechRecognizer.ERROR_SERVER:
519                        Log.d(TAG, "recognizer error server error");
520                        break;
521                    case SpeechRecognizer.ERROR_SPEECH_TIMEOUT:
522                        Log.d(TAG, "recognizer error speech timeout");
523                        break;
524                    case SpeechRecognizer.ERROR_CLIENT:
525                        Log.d(TAG, "recognizer error client error");
526                        break;
527                    default:
528                        Log.d(TAG, "recognizer other error");
529                        break;
530                }
531
532                mSpeechRecognizer.stopListening();
533                mListening = false;
534                mSpeechRecognizer.setRecognitionListener(null);
535                mSpeechOrbView.showNotListening();
536                playSearchFailure();
537            }
538
539            @Override
540            public void onResults(Bundle bundle) {
541                if (DEBUG) Log.v(TAG, "onResults");
542                final ArrayList<String> matches =
543                        bundle.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION);
544                if (matches != null) {
545                    Log.v(TAG, "Got results" + matches);
546
547                    mSearchQuery = matches.get(0);
548                    mSearchTextEditor.setText(mSearchQuery);
549                    submitQuery();
550
551                    if (mListening) {
552                        mSpeechRecognizer.stopListening();
553                    }
554                }
555                mSpeechRecognizer.setRecognitionListener(null);
556                mSpeechOrbView.showNotListening();
557                playSearchSuccess();
558            }
559
560            @Override
561            public void onPartialResults(Bundle bundle) {
562
563            }
564
565            @Override
566            public void onEvent(int i, Bundle bundle) {
567
568            }
569        });
570
571        mSpeechOrbView.showListening();
572        playSearchOpen();
573        mSpeechRecognizer.startListening(recognizerIntent);
574        mListening = true;
575    }
576
577    private void updateUi() {
578        if (DEBUG) Log.v(TAG, String.format("Update UI %s %s",
579                isVoiceMode() ? "Voice" : "Text",
580                hasFocus() ? "Focused" : "Unfocused"));
581        if (isVoiceMode()) {
582            mBarBackground.setAlpha(mBackgroundSpeechAlpha);
583            mSearchTextEditor.setTextColor(mTextColorSpeechMode);
584            mSearchTextEditor.setHintTextColor(mTextHintColorSpeechMode);
585        } else {
586            mBarBackground.setAlpha(mBackgroundAlpha);
587            mSearchTextEditor.setTextColor(mTextColor);
588            mSearchTextEditor.setHintTextColor(mTextHintColor);
589        }
590
591        updateHint();
592    }
593
594    private boolean isVoiceMode() {
595        return mSpeechOrbView.isFocused();
596    }
597
598    private void submitQuery() {
599        if (!TextUtils.isEmpty(mSearchQuery) && null != mSearchBarListener) {
600            mSearchBarListener.onSearchQuerySubmit(mSearchQuery);
601        }
602    }
603
604    private void enforceAudioRecordPermission() {
605        String permission = "android.permission.RECORD_AUDIO";
606        int res = getContext().checkCallingOrSelfPermission(permission);
607        if (PackageManager.PERMISSION_GRANTED != res) {
608            throw new IllegalStateException("android.permission.RECORD_AUDIO required for search");
609        }
610    }
611
612    private void loadSounds(Context context) {
613        int[] sounds = {
614                R.raw.lb_voice_failure,
615                R.raw.lb_voice_open,
616                R.raw.lb_voice_no_input,
617                R.raw.lb_voice_success,
618        };
619        for (int sound : sounds) {
620            mSoundMap.put(sound, mSoundPool.load(context, sound, 1));
621        }
622    }
623
624    private void play(final int resId) {
625        mHandler.post(new Runnable() {
626            @Override
627            public void run() {
628                int sound = mSoundMap.get(resId);
629                mSoundPool.play(sound, FULL_LEFT_VOLUME, FULL_RIGHT_VOLUME, DEFAULT_PRIORITY,
630                        DO_NOT_LOOP, DEFAULT_RATE);
631            }
632        });
633    }
634
635    private void playSearchOpen() {
636        play(R.raw.lb_voice_open);
637    }
638
639    private void playSearchFailure() {
640        play(R.raw.lb_voice_failure);
641    }
642
643    private void playSearchNoInput() {
644        play(R.raw.lb_voice_no_input);
645    }
646
647    private void playSearchSuccess() {
648        play(R.raw.lb_voice_success);
649    }
650
651    @Override
652    public void setNextFocusDownId(int viewId) {
653        mSpeechOrbView.setNextFocusDownId(viewId);
654        mSearchTextEditor.setNextFocusDownId(viewId);
655    }
656
657}
658