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