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