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