SearchBar.java revision a1798f408d3acafffc93cce1b309a54dce52d22d
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.HashMap;
51import java.util.List;
52import java.util.Map;
53
54/**
55 * <p>SearchBar is a search widget.</p>
56 *
57 * <p>Note: Your application will need to request android.permission.RECORD_AUDIO</p>
58 */
59public class SearchBar extends RelativeLayout {
60    private static final String TAG = SearchBar.class.getSimpleName();
61    private static final boolean DEBUG = false;
62
63    private static final float FULL_LEFT_VOLUME = 1.0f;
64    private static final float FULL_RIGHT_VOLUME = 1.0f;
65    private static final int DEFAULT_PRIORITY = 1;
66    private static final int DO_NOT_LOOP = 0;
67    private static final float DEFAULT_RATE = 1.0f;
68
69    /**
70     * Listener for search query changes
71     */
72    public interface SearchBarListener {
73
74        /**
75         * Method invoked when the search bar detects a change in the query.
76         *
77         * @param query The current full query.
78         */
79        public void onSearchQueryChange(String query);
80
81        /**
82         * <p>Method invoked when the search query is submitted.</p>
83         *
84         * <p>This method can be called without a preceeding onSearchQueryChange,
85         * in particular in the case of a voice input.</p>
86         *
87         * @param query The query being submitted.
88         */
89        public void onSearchQuerySubmit(String query);
90
91        /**
92         * Method invoked when the IME is being dismissed.
93         *
94         * @param query The query set in the search bar at the time the IME is being dismissed.
95         */
96        public void onKeyboardDismiss(String query);
97    }
98
99    private SearchBarListener mSearchBarListener;
100    private SearchEditText mSearchTextEditor;
101    private SpeechOrbView mSpeechOrbView;
102    private ImageView mBadgeView;
103    private String mSearchQuery;
104    private String mTitle;
105    private Drawable mBadgeDrawable;
106    private final Handler mHandler = new Handler();
107    private final InputMethodManager mInputMethodManager;
108    private boolean mAutoStartRecognition = false;
109    private Drawable mBarBackground;
110
111    private int mTextColor;
112    private int mTextSpeechColor;
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
121    public SearchBar(Context context) {
122        this(context, null);
123    }
124
125    public SearchBar(Context context, AttributeSet attrs) {
126        this(context, attrs, 0);
127    }
128
129    public SearchBar(Context context, AttributeSet attrs, int defStyle) {
130        super(context, attrs, defStyle);
131        enforceAudioRecordPermission();
132
133        Resources r = getResources();
134
135        LayoutInflater inflater = LayoutInflater.from(getContext());
136        inflater.inflate(R.layout.lb_search_bar, this, true);
137
138        mBarHeight = getResources().getDimensionPixelSize(R.dimen.lb_search_bar_height);
139        RelativeLayout.LayoutParams params = new LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,
140                mBarHeight);
141        params.addRule(ALIGN_PARENT_TOP, RelativeLayout.TRUE);
142        setLayoutParams(params);
143        setBackgroundColor(Color.TRANSPARENT);
144        setClipChildren(false);
145
146        mSearchQuery = "";
147        mInputMethodManager =
148                (InputMethodManager)context.getSystemService(Context.INPUT_METHOD_SERVICE);
149
150        mTextSpeechColor = r.getColor(R.color.lb_search_bar_text_speech_color);
151        mBackgroundSpeechAlpha = r.getInteger(R.integer.lb_search_bar_speech_mode_background_alpha);
152
153        mTextColor = r.getColor(R.color.lb_search_bar_text_color);
154        mBackgroundAlpha = r.getInteger(R.integer.lb_search_bar_text_mode_background_alpha);
155
156        mSoundPool = new SoundPool(8, AudioManager.STREAM_SYSTEM, 0);
157        loadSounds(context);
158    }
159
160    @Override
161    protected void onFinishInflate() {
162        super.onFinishInflate();
163
164        RelativeLayout items = (RelativeLayout)findViewById(R.id.lb_search_bar_items);
165        mBarBackground = items.getBackground();
166
167        mSearchTextEditor = (SearchEditText)findViewById(R.id.lb_search_text_editor);
168        mBadgeView = (ImageView)findViewById(R.id.lb_search_bar_badge);
169        if (null != mBadgeDrawable) {
170            mBadgeView.setImageDrawable(mBadgeDrawable);
171        }
172
173        mSearchTextEditor.setOnFocusChangeListener(new OnFocusChangeListener() {
174            @Override
175            public void onFocusChange(View view, boolean hasFocus) {
176                if (DEBUG) Log.v(TAG, "EditText.onFocusChange " + hasFocus);
177                if (hasFocus) {
178                    showNativeKeyboard();
179                }
180                updateUi();
181            }
182        });
183        mSearchTextEditor.addTextChangedListener(new TextWatcher() {
184            @Override
185            public void beforeTextChanged(CharSequence charSequence, int i, int i2, int i3) {
186
187            }
188
189            @Override
190            public void onTextChanged(CharSequence charSequence, int i, int i2, int i3) {
191                if (mSearchTextEditor.hasFocus()) {
192                    setSearchQuery(charSequence.toString());
193                }
194            }
195
196            @Override
197            public void afterTextChanged(Editable editable) {
198
199            }
200        });
201        mSearchTextEditor.setOnKeyboardDismissListener(
202                new SearchEditText.OnKeyboardDismissListener() {
203                    @Override
204                    public void onKeyboardDismiss() {
205                        if (null != mSearchBarListener) {
206                            mSearchBarListener.onKeyboardDismiss(mSearchQuery);
207                        }
208                    }
209                });
210
211        mSearchTextEditor.setOnEditorActionListener(new TextView.OnEditorActionListener() {
212            @Override
213            public boolean onEditorAction(TextView textView, int action, KeyEvent keyEvent) {
214                if (DEBUG) Log.v(TAG, "onEditorAction: " + action + " event: " + keyEvent);
215                boolean handled = true;
216                if (EditorInfo.IME_ACTION_SEARCH == action && null != mSearchBarListener) {
217                    if (DEBUG) Log.v(TAG, "Action Pressed");
218                    hideNativeKeyboard();
219                    mHandler.postDelayed(new Runnable() {
220                        @Override
221                        public void run() {
222                            if (DEBUG) Log.v(TAG, "Delayed action handling (search)");
223                            submitQuery();
224                        }
225                    }, 500);
226
227                } else if (EditorInfo.IME_ACTION_NONE == action && null != mSearchBarListener) {
228                    if (DEBUG) Log.v(TAG, "Escaped North");
229                    hideNativeKeyboard();
230                    mHandler.postDelayed(new Runnable() {
231                        @Override
232                        public void run() {
233                            if (DEBUG) Log.v(TAG, "Delayed action handling (escape_north)");
234                            mSearchBarListener.onKeyboardDismiss(mSearchQuery);
235                        }
236                    }, 500);
237                } else if (EditorInfo.IME_ACTION_GO == action) {
238                    if (DEBUG) Log.v(TAG, "Voice Clicked");
239                        hideNativeKeyboard();
240                        mHandler.postDelayed(new Runnable() {
241                            @Override
242                            public void run() {
243                                if (DEBUG) Log.v(TAG, "Delayed action handling (voice_mode)");
244                                mAutoStartRecognition = true;
245                                mSpeechOrbView.requestFocus();
246                            }
247                        }, 500);
248                } else {
249                    handled = false;
250                }
251
252                return handled;
253            }
254        });
255
256        mSearchTextEditor.setPrivateImeOptions("EscapeNorth=1;VoiceDismiss=1;");
257
258        mSpeechOrbView = (SpeechOrbView)findViewById(R.id.lb_search_bar_speech_orb);
259        mSpeechOrbView.setOnOrbClickedListener(new OnClickListener() {
260            @Override
261            public void onClick(View view) {
262                startRecognition();
263            }
264        });
265        mSpeechOrbView.setOnFocusChangeListener(new OnFocusChangeListener() {
266            @Override
267            public void onFocusChange(View view, boolean hasFocus) {
268                if (DEBUG) Log.v(TAG, "SpeechOrb.onFocusChange " + hasFocus);
269                if (hasFocus) {
270                    hideNativeKeyboard();
271                    if (mAutoStartRecognition) {
272                        startRecognition();
273                        mAutoStartRecognition = false;
274                    }
275                } else {
276                    stopRecognition();
277                }
278                updateUi();
279            }
280        });
281
282        updateHint();
283        // Start in voice mode
284        mHandler.postDelayed(new Runnable() {
285            @Override
286            public void run() {
287                mAutoStartRecognition = true;
288                mSpeechOrbView.requestFocus();
289            }
290        }, 200);
291    }
292
293    @Override
294    protected void onAttachedToWindow() {
295        super.onAttachedToWindow();
296        mHandler.post(new Runnable() {
297            @Override
298            public void run() {
299                mSearchTextEditor.requestFocus();
300                mSearchTextEditor.requestFocusFromTouch();
301            }
302        });
303    }
304
305    /**
306     * Set a listener for when the term search changes
307     * @param listener
308     */
309    public void setSearchBarListener(SearchBarListener listener) {
310        mSearchBarListener = listener;
311    }
312
313    /**
314     * Set the search query
315     * @param query the search query to use
316     */
317    public void setSearchQuery(String query) {
318        if (query.equals(mSearchQuery)) {
319            return;
320        }
321        mSearchQuery = query;
322        if (null != mSearchBarListener) {
323            mSearchBarListener.onSearchQueryChange(mSearchQuery);
324        }
325    }
326
327    /**
328     * Set the title text used in the hint shown in the search bar.
329     * @param title The hint to use.
330     */
331    public void setTitle(String title) {
332        mTitle = title;
333        updateHint();
334    }
335
336    /**
337     * Returns the current title
338     */
339    public String getTitle() {
340        return mTitle;
341    }
342
343    /**
344     * Set the badge drawable showing inside the search bar.
345     * @param drawable The drawable to be used in the search bar.
346     */
347    public void setBadgeDrawable(Drawable drawable) {
348        mBadgeDrawable = drawable;
349        if (null != mBadgeView) {
350            mBadgeView.setImageDrawable(drawable);
351            if (null != drawable) {
352                mBadgeView.setVisibility(View.VISIBLE);
353            } else {
354                mBadgeView.setVisibility(View.GONE);
355            }
356        }
357    }
358
359    /**
360     * Returns the badge drawable
361     */
362    public Drawable getBadgeDrawable() {
363        return mBadgeDrawable;
364    }
365
366    /**
367     * Update the completion list shown by the IME
368     *
369     * @param completions list of completions shown in the IME, can be null or empty to clear them
370     */
371    public void displayCompletions(List<String> completions) {
372        List<CompletionInfo> infos = new ArrayList<CompletionInfo>();
373        if (null != completions) {
374            for (String completion : completions) {
375                infos.add(new CompletionInfo(infos.size(), infos.size(), completion));
376            }
377        }
378
379        mInputMethodManager.displayCompletions(mSearchTextEditor,
380                infos.toArray(new CompletionInfo[] {}));
381    }
382
383    /**
384     * Set the speech recognizer to be used when doing voice search. The Activity/Fragment is in
385     * charge of creating and destroying the recognizer with its own lifecycle.
386     *
387     * @param recognizer a SpeechRecognizer
388     */
389    public void setSpeechRecognizer(SpeechRecognizer recognizer) {
390        if (null != mSpeechRecognizer) {
391            mSpeechRecognizer.setRecognitionListener(null);
392            if (mListening) {
393                mSpeechRecognizer.stopListening();
394                mListening = false;
395            }
396        }
397        mSpeechRecognizer = recognizer;
398    }
399
400    private void hideNativeKeyboard() {
401        mInputMethodManager.hideSoftInputFromWindow(mSearchTextEditor.getWindowToken(),
402                InputMethodManager.RESULT_UNCHANGED_SHOWN);
403    }
404
405    private void showNativeKeyboard() {
406        mHandler.post(new Runnable() {
407            @Override
408            public void run() {
409                mSearchTextEditor.requestFocusFromTouch();
410                mSearchTextEditor.dispatchTouchEvent(MotionEvent.obtain(SystemClock.uptimeMillis(),
411                        SystemClock.uptimeMillis(), MotionEvent.ACTION_DOWN,
412                        mSearchTextEditor.getWidth(), mSearchTextEditor.getHeight(), 0));
413                mSearchTextEditor.dispatchTouchEvent(MotionEvent.obtain(SystemClock.uptimeMillis(),
414                        SystemClock.uptimeMillis(), MotionEvent.ACTION_UP,
415                        mSearchTextEditor.getWidth(), mSearchTextEditor.getHeight(), 0));
416            }
417        });
418    }
419
420    /**
421     * This will update the hint for the search bar properly depending on state and provided title
422     */
423    private void updateHint() {
424        if (null == mSearchTextEditor) return;
425
426        String title = getResources().getString(R.string.lb_search_bar_hint);
427        if (!TextUtils.isEmpty(mTitle)) {
428            if (isVoiceMode()) {
429                title = getResources().getString(R.string.lb_search_bar_hint_with_title_speech, mTitle);
430            } else {
431                title = getResources().getString(R.string.lb_search_bar_hint_with_title, mTitle);
432            }
433        } else if (isVoiceMode()) {
434            title = getResources().getString(R.string.lb_search_bar_hint_speech);
435        }
436        mSearchTextEditor.setHint(title);
437    }
438
439    private void stopRecognition() {
440        if (null == mSpeechRecognizer) return;
441
442        if (DEBUG) Log.v(TAG, "stopRecognition " + mListening);
443        mSpeechOrbView.showNotListening();
444
445        if (mListening) {
446            mSpeechRecognizer.cancel();
447        }
448    }
449
450    private void startRecognition() {
451        if (null == mSpeechRecognizer) return;
452
453        if (DEBUG) Log.v(TAG, "startRecognition " + mListening);
454
455        mSearchTextEditor.setText("");
456
457        Intent recognizerIntent = new Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH);
458
459        recognizerIntent.putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL,
460                RecognizerIntent.LANGUAGE_MODEL_FREE_FORM);
461        recognizerIntent.putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true);
462
463        mSpeechRecognizer.setRecognitionListener(new RecognitionListener() {
464            @Override
465            public void onReadyForSpeech(Bundle bundle) {
466                if (DEBUG) Log.v(TAG, "onReadyForSpeech");
467            }
468
469            @Override
470            public void onBeginningOfSpeech() {
471                if (DEBUG) Log.v(TAG, "onBeginningOfSpeech");
472                mListening = true;
473            }
474
475            @Override
476            public void onRmsChanged(float rmsdB) {
477                if (DEBUG) Log.v(TAG, "onRmsChanged " + rmsdB);
478                int level = rmsdB < 0 ? 0 : (int)(10 * rmsdB);
479                mSpeechOrbView.setSoundLevel(level);
480            }
481
482            @Override
483            public void onBufferReceived(byte[] bytes) {
484                if (DEBUG) Log.v(TAG, "onBufferReceived " + bytes.length);
485            }
486
487            @Override
488            public void onEndOfSpeech() {
489                if (DEBUG) Log.v(TAG, "onEndOfSpeech");
490                mListening = false;
491            }
492
493            @Override
494            public void onError(int error) {
495                if (DEBUG) Log.v(TAG, "onError " + error);
496                switch (error) {
497                    case SpeechRecognizer.ERROR_NO_MATCH:
498                        Log.d(TAG, "recognizer error no match");
499                        break;
500                    case SpeechRecognizer.ERROR_SERVER:
501                        Log.d(TAG, "recognizer error server error");
502                        break;
503                    case SpeechRecognizer.ERROR_SPEECH_TIMEOUT:
504                        Log.d(TAG, "recognizer error speech timeout");
505                        break;
506                    case SpeechRecognizer.ERROR_CLIENT:
507                        Log.d(TAG, "recognizer error client error");
508                        break;
509                    default:
510                        Log.d(TAG, "recognizer other error");
511                        break;
512                }
513
514                mSpeechRecognizer.stopListening();
515                mListening = false;
516                mSpeechRecognizer.setRecognitionListener(null);
517                mSpeechOrbView.showNotListening();
518                playSearchFailure();
519            }
520
521            @Override
522            public void onResults(Bundle bundle) {
523                if (DEBUG) Log.v(TAG, "onResults");
524                final ArrayList<String> matches =
525                        bundle.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION);
526                if (matches != null) {
527                    Log.v(TAG, "Got results" + matches);
528
529                    mSearchQuery = matches.get(0);
530                    mSearchTextEditor.setText(mSearchQuery);
531                    submitQuery();
532
533                    if (mListening) {
534                        mSpeechRecognizer.stopListening();
535                    }
536                }
537                mSpeechRecognizer.setRecognitionListener(null);
538                mSpeechOrbView.showNotListening();
539                playSearchSuccess();
540            }
541
542            @Override
543            public void onPartialResults(Bundle bundle) {
544
545            }
546
547            @Override
548            public void onEvent(int i, Bundle bundle) {
549
550            }
551        });
552
553        mSpeechOrbView.showListening();
554        playSearchOpen();
555        mSpeechRecognizer.startListening(recognizerIntent);
556        mListening = true;
557    }
558
559    private void updateUi() {
560        if (DEBUG) Log.v(TAG, String.format("Update UI %s %s",
561                isVoiceMode() ? "Voice" : "Text",
562                hasFocus() ? "Focused" : "Unfocused"));
563        if (isVoiceMode()) {
564            mBarBackground.setAlpha(mBackgroundSpeechAlpha);
565            mSearchTextEditor.setTextColor(mTextSpeechColor);
566        } else {
567            mBarBackground.setAlpha(mBackgroundAlpha);
568            mSearchTextEditor.setTextColor(mTextColor);
569        }
570
571        updateHint();
572    }
573
574    private boolean isVoiceMode() {
575        return mSpeechOrbView.isFocused();
576    }
577
578    private void submitQuery() {
579        if (!TextUtils.isEmpty(mSearchQuery) && null != mSearchBarListener) {
580            mSearchBarListener.onSearchQuerySubmit(mSearchQuery);
581        }
582    }
583
584    private void enforceAudioRecordPermission() {
585        String permission = "android.permission.RECORD_AUDIO";
586        int res = getContext().checkCallingOrSelfPermission(permission);
587        if (PackageManager.PERMISSION_GRANTED != res) {
588            throw new IllegalStateException("android.premission.RECORD_AUDIO required for search");
589        }
590    }
591
592    private void loadSounds(Context context) {
593        int[] sounds = {
594                R.raw.voice_failure,
595                R.raw.voice_open,
596                R.raw.voice_no_input,
597                R.raw.voice_success,
598        };
599        for (int sound : sounds) {
600            mSoundMap.put(sound, mSoundPool.load(context, sound, 1));
601        }
602    }
603
604    private void play(final int resId) {
605        mHandler.post(new Runnable() {
606            @Override
607            public void run() {
608                int sound = mSoundMap.get(resId);
609                mSoundPool.play(sound, FULL_LEFT_VOLUME, FULL_RIGHT_VOLUME, DEFAULT_PRIORITY,
610                        DO_NOT_LOOP, DEFAULT_RATE);
611            }
612        });
613    }
614
615    private void playSearchOpen() {
616        play(R.raw.voice_open);
617    }
618
619    private void playSearchFailure() {
620        play(R.raw.voice_failure);
621    }
622
623    private void playSearchNoInput() {
624        play(R.raw.voice_no_input);
625    }
626
627    private void playSearchSuccess() {
628        play(R.raw.voice_success);
629    }
630
631
632}
633