SearchBar.java revision edb67b0b59ee6657b2d1adb6b2822858db34e8c8
1/* 2 * Copyright (C) 2014 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except 5 * in compliance with the License. You may obtain a copy of the License at 6 * 7 * http://www.apache.org/licenses/LICENSE-2.0 8 * 9 * Unless required by applicable law or agreed to in writing, software distributed under the License 10 * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express 11 * or implied. See the License for the specific language governing permissions and limitations under 12 * the License. 13 */ 14package android.support.v17.leanback.widget; 15 16import android.content.Context; 17import android.content.Intent; 18import android.content.pm.PackageManager; 19import android.content.res.Resources; 20import android.graphics.Color; 21import android.graphics.drawable.Drawable; 22import android.media.AudioManager; 23import android.media.SoundPool; 24import android.os.Bundle; 25import android.os.Handler; 26import android.os.SystemClock; 27import android.speech.RecognitionListener; 28import android.speech.RecognizerIntent; 29import android.speech.SpeechRecognizer; 30import android.text.Editable; 31import android.text.TextUtils; 32import android.text.TextWatcher; 33import android.util.AttributeSet; 34import android.util.Log; 35import android.util.SparseIntArray; 36import android.view.LayoutInflater; 37import android.view.ViewGroup; 38import android.view.inputmethod.CompletionInfo; 39import android.view.inputmethod.EditorInfo; 40import android.view.KeyEvent; 41import android.view.MotionEvent; 42import android.view.View; 43import android.widget.ImageView; 44import android.view.inputmethod.InputMethodManager; 45import android.widget.RelativeLayout; 46import android.support.v17.leanback.R; 47import android.widget.TextView; 48 49import java.util.ArrayList; 50import java.util.List; 51 52/** 53 * <p>SearchBar is a search widget.</p> 54 * 55 * <p>Note: Your application will need to request android.permission.RECORD_AUDIO</p> 56 */ 57public class SearchBar extends RelativeLayout { 58 private static final String TAG = SearchBar.class.getSimpleName(); 59 private static final boolean DEBUG = false; 60 61 private static final float FULL_LEFT_VOLUME = 1.0f; 62 private static final float FULL_RIGHT_VOLUME = 1.0f; 63 private static final int DEFAULT_PRIORITY = 1; 64 private static final int DO_NOT_LOOP = 0; 65 private static final float DEFAULT_RATE = 1.0f; 66 67 /** 68 * Listener for search query changes 69 */ 70 public interface SearchBarListener { 71 72 /** 73 * Method invoked when the search bar detects a change in the query. 74 * 75 * @param query The current full query. 76 */ 77 public void onSearchQueryChange(String query); 78 79 /** 80 * <p>Method invoked when the search query is submitted.</p> 81 * 82 * <p>This method can be called without a preceeding onSearchQueryChange, 83 * in particular in the case of a voice input.</p> 84 * 85 * @param query The query being submitted. 86 */ 87 public void onSearchQuerySubmit(String query); 88 89 /** 90 * Method invoked when the IME is being dismissed. 91 * 92 * @param query The query set in the search bar at the time the IME is being dismissed. 93 */ 94 public void onKeyboardDismiss(String query); 95 } 96 97 private SearchBarListener mSearchBarListener; 98 private SearchEditText mSearchTextEditor; 99 private SpeechOrbView mSpeechOrbView; 100 private ImageView mBadgeView; 101 private String mSearchQuery; 102 private String mTitle; 103 private Drawable mBadgeDrawable; 104 private final Handler mHandler = new Handler(); 105 private final InputMethodManager mInputMethodManager; 106 private boolean mAutoStartRecognition = false; 107 private Drawable mBarBackground; 108 109 private final int mTextColor; 110 private final int mTextColorSpeechMode; 111 private final int mTextHintColor; 112 private final int mTextHintColorSpeechMode; 113 private int mBackgroundAlpha; 114 private int mBackgroundSpeechAlpha; 115 private int mBarHeight; 116 private SpeechRecognizer mSpeechRecognizer; 117 private SpeechRecognitionCallback mSpeechRecognitionCallback; 118 private boolean mListening; 119 private SoundPool mSoundPool; 120 private SparseIntArray mSoundMap = new SparseIntArray(); 121 private boolean mRecognizing = false; 122 private final Context mContext; 123 124 public SearchBar(Context context) { 125 this(context, null); 126 } 127 128 public SearchBar(Context context, AttributeSet attrs) { 129 this(context, attrs, 0); 130 } 131 132 public SearchBar(Context context, AttributeSet attrs, int defStyle) { 133 super(context, attrs, defStyle); 134 mContext = context; 135 136 Resources r = getResources(); 137 138 LayoutInflater inflater = LayoutInflater.from(getContext()); 139 inflater.inflate(R.layout.lb_search_bar, this, true); 140 141 mBarHeight = getResources().getDimensionPixelSize(R.dimen.lb_search_bar_height); 142 RelativeLayout.LayoutParams params = new LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 143 mBarHeight); 144 params.addRule(ALIGN_PARENT_TOP, RelativeLayout.TRUE); 145 setLayoutParams(params); 146 setBackgroundColor(Color.TRANSPARENT); 147 setClipChildren(false); 148 149 mSearchQuery = ""; 150 mInputMethodManager = 151 (InputMethodManager)context.getSystemService(Context.INPUT_METHOD_SERVICE); 152 153 mTextColorSpeechMode = r.getColor(R.color.lb_search_bar_text_speech_mode); 154 mTextColor = r.getColor(R.color.lb_search_bar_text); 155 156 mBackgroundSpeechAlpha = r.getInteger(R.integer.lb_search_bar_speech_mode_background_alpha); 157 mBackgroundAlpha = r.getInteger(R.integer.lb_search_bar_text_mode_background_alpha); 158 159 mTextHintColorSpeechMode = r.getColor(R.color.lb_search_bar_hint_speech_mode); 160 mTextHintColor = r.getColor(R.color.lb_search_bar_hint); 161 } 162 163 @Override 164 protected void onFinishInflate() { 165 super.onFinishInflate(); 166 167 RelativeLayout items = (RelativeLayout)findViewById(R.id.lb_search_bar_items); 168 mBarBackground = items.getBackground(); 169 170 mSearchTextEditor = (SearchEditText)findViewById(R.id.lb_search_text_editor); 171 mBadgeView = (ImageView)findViewById(R.id.lb_search_bar_badge); 172 if (null != mBadgeDrawable) { 173 mBadgeView.setImageDrawable(mBadgeDrawable); 174 } 175 176 mSearchTextEditor.setOnFocusChangeListener(new OnFocusChangeListener() { 177 @Override 178 public void onFocusChange(View view, boolean hasFocus) { 179 if (DEBUG) Log.v(TAG, "EditText.onFocusChange " + hasFocus); 180 if (hasFocus) { 181 showNativeKeyboard(); 182 } 183 updateUi(); 184 } 185 }); 186 final Runnable mOnTextChangedRunnable = new Runnable() { 187 @Override 188 public void run() { 189 setSearchQueryInternal(mSearchTextEditor.getText().toString()); 190 } 191 }; 192 mSearchTextEditor.addTextChangedListener(new TextWatcher() { 193 @Override 194 public void beforeTextChanged(CharSequence charSequence, int i, int i2, int i3) { 195 } 196 197 @Override 198 public void onTextChanged(CharSequence charSequence, int i, int i2, int i3) { 199 // don't propagate event during speech recognition. 200 if (mRecognizing) { 201 return; 202 } 203 // while IME opens, text editor becomes "" then restores to current value 204 mHandler.removeCallbacks(mOnTextChangedRunnable); 205 mHandler.post(mOnTextChangedRunnable); 206 } 207 208 @Override 209 public void afterTextChanged(Editable editable) { 210 211 } 212 }); 213 mSearchTextEditor.setOnKeyboardDismissListener( 214 new SearchEditText.OnKeyboardDismissListener() { 215 @Override 216 public void onKeyboardDismiss() { 217 if (null != mSearchBarListener) { 218 mSearchBarListener.onKeyboardDismiss(mSearchQuery); 219 } 220 } 221 }); 222 223 mSearchTextEditor.setOnEditorActionListener(new TextView.OnEditorActionListener() { 224 @Override 225 public boolean onEditorAction(TextView textView, int action, KeyEvent keyEvent) { 226 if (DEBUG) Log.v(TAG, "onEditorAction: " + action + " event: " + keyEvent); 227 boolean handled = true; 228 if (EditorInfo.IME_ACTION_SEARCH == action && null != mSearchBarListener) { 229 if (DEBUG) Log.v(TAG, "Action Pressed"); 230 hideNativeKeyboard(); 231 mHandler.postDelayed(new Runnable() { 232 @Override 233 public void run() { 234 if (DEBUG) Log.v(TAG, "Delayed action handling (search)"); 235 submitQuery(); 236 } 237 }, 500); 238 239 } else if (EditorInfo.IME_ACTION_NONE == action && null != mSearchBarListener) { 240 if (DEBUG) Log.v(TAG, "Escaped North"); 241 hideNativeKeyboard(); 242 mHandler.postDelayed(new Runnable() { 243 @Override 244 public void run() { 245 if (DEBUG) Log.v(TAG, "Delayed action handling (escape_north)"); 246 mSearchBarListener.onKeyboardDismiss(mSearchQuery); 247 } 248 }, 500); 249 } else if (EditorInfo.IME_ACTION_GO == action) { 250 if (DEBUG) Log.v(TAG, "Voice Clicked"); 251 hideNativeKeyboard(); 252 mHandler.postDelayed(new Runnable() { 253 @Override 254 public void run() { 255 if (DEBUG) Log.v(TAG, "Delayed action handling (voice_mode)"); 256 mAutoStartRecognition = true; 257 mSpeechOrbView.requestFocus(); 258 } 259 }, 500); 260 } else { 261 handled = false; 262 } 263 264 return handled; 265 } 266 }); 267 268 mSearchTextEditor.setPrivateImeOptions("EscapeNorth=1;VoiceDismiss=1;"); 269 270 mSpeechOrbView = (SpeechOrbView)findViewById(R.id.lb_search_bar_speech_orb); 271 mSpeechOrbView.setOnOrbClickedListener(new OnClickListener() { 272 @Override 273 public void onClick(View view) { 274 toggleRecognition(); 275 } 276 }); 277 mSpeechOrbView.setOnFocusChangeListener(new OnFocusChangeListener() { 278 @Override 279 public void onFocusChange(View view, boolean hasFocus) { 280 if (DEBUG) Log.v(TAG, "SpeechOrb.onFocusChange " + hasFocus); 281 if (hasFocus) { 282 hideNativeKeyboard(); 283 if (mAutoStartRecognition) { 284 startRecognition(); 285 mAutoStartRecognition = false; 286 } 287 } else { 288 stopRecognition(); 289 } 290 updateUi(); 291 } 292 }); 293 294 updateUi(); 295 updateHint(); 296 } 297 298 @Override 299 protected void onAttachedToWindow() { 300 super.onAttachedToWindow(); 301 if (DEBUG) Log.v(TAG, "Loading soundPool"); 302 mSoundPool = new SoundPool(2, AudioManager.STREAM_SYSTEM, 0); 303 loadSounds(mContext); 304 } 305 306 @Override 307 protected void onDetachedFromWindow() { 308 if (DEBUG) Log.v(TAG, "Releasing SoundPool"); 309 mSoundPool.release(); 310 311 super.onDetachedFromWindow(); 312 } 313 314 /** 315 * Set a listener for when the term search changes 316 * @param listener 317 */ 318 public void setSearchBarListener(SearchBarListener listener) { 319 mSearchBarListener = listener; 320 } 321 322 /** 323 * Set the search query 324 * @param query the search query to use 325 */ 326 public void setSearchQuery(String query) { 327 mSearchTextEditor.setText(query); 328 setSearchQueryInternal(query); 329 } 330 331 private void setSearchQueryInternal(String query) { 332 if (DEBUG) Log.v(TAG, "setSearchQueryInternal " + query); 333 if (TextUtils.equals(mSearchQuery, query)) { 334 return; 335 } 336 mSearchQuery = query; 337 338 if (null != mSearchBarListener) { 339 mSearchBarListener.onSearchQueryChange(mSearchQuery); 340 } 341 } 342 343 /** 344 * Set the title text used in the hint shown in the search bar. 345 * @param title The hint to use. 346 */ 347 public void setTitle(String title) { 348 mTitle = title; 349 updateHint(); 350 } 351 352 /** 353 * Returns the current title 354 */ 355 public String getTitle() { 356 return mTitle; 357 } 358 359 /** 360 * Returns the current search bar hint text. 361 */ 362 public CharSequence getHint() { 363 return (mSearchTextEditor == null) ? null : mSearchTextEditor.getHint(); 364 } 365 366 /** 367 * Set the badge drawable showing inside the search bar. 368 * @param drawable The drawable to be used in the search bar. 369 */ 370 public void setBadgeDrawable(Drawable drawable) { 371 mBadgeDrawable = drawable; 372 if (null != mBadgeView) { 373 mBadgeView.setImageDrawable(drawable); 374 if (null != drawable) { 375 mBadgeView.setVisibility(View.VISIBLE); 376 } else { 377 mBadgeView.setVisibility(View.GONE); 378 } 379 } 380 } 381 382 /** 383 * Returns the badge drawable 384 */ 385 public Drawable getBadgeDrawable() { 386 return mBadgeDrawable; 387 } 388 389 /** 390 * Update the completion list shown by the IME 391 * 392 * @param completions list of completions shown in the IME, can be null or empty to clear them 393 */ 394 public void displayCompletions(List<String> completions) { 395 List<CompletionInfo> infos = new ArrayList<CompletionInfo>(); 396 if (null != completions) { 397 for (String completion : completions) { 398 infos.add(new CompletionInfo(infos.size(), infos.size(), completion)); 399 } 400 } 401 402 mInputMethodManager.displayCompletions(mSearchTextEditor, 403 infos.toArray(new CompletionInfo[] {})); 404 } 405 406 /** 407 * Set the speech recognizer to be used when doing voice search. The Activity/Fragment is in 408 * charge of creating and destroying the recognizer with its own lifecycle. 409 * 410 * @param recognizer a SpeechRecognizer 411 */ 412 public void setSpeechRecognizer(SpeechRecognizer recognizer) { 413 if (null != mSpeechRecognizer) { 414 mSpeechRecognizer.setRecognitionListener(null); 415 if (mListening) { 416 mSpeechRecognizer.stopListening(); 417 mListening = false; 418 } 419 } 420 mSpeechRecognizer = recognizer; 421 if (mSpeechRecognizer != null) { 422 enforceAudioRecordPermission(); 423 } 424 if (mSpeechRecognitionCallback != null && mSpeechRecognizer != null) { 425 throw new IllegalStateException("Can't have speech recognizer and request"); 426 } 427 } 428 429 public void setSpeechRecognitionCallback(SpeechRecognitionCallback request) { 430 mSpeechRecognitionCallback = request; 431 if (mSpeechRecognitionCallback != null && mSpeechRecognizer != null) { 432 throw new IllegalStateException("Can't have speech recognizer and request"); 433 } 434 } 435 436 private void hideNativeKeyboard() { 437 mInputMethodManager.hideSoftInputFromWindow(mSearchTextEditor.getWindowToken(), 438 InputMethodManager.RESULT_UNCHANGED_SHOWN); 439 } 440 441 private void showNativeKeyboard() { 442 mHandler.post(new Runnable() { 443 @Override 444 public void run() { 445 mSearchTextEditor.requestFocusFromTouch(); 446 mSearchTextEditor.dispatchTouchEvent(MotionEvent.obtain(SystemClock.uptimeMillis(), 447 SystemClock.uptimeMillis(), MotionEvent.ACTION_DOWN, 448 mSearchTextEditor.getWidth(), mSearchTextEditor.getHeight(), 0)); 449 mSearchTextEditor.dispatchTouchEvent(MotionEvent.obtain(SystemClock.uptimeMillis(), 450 SystemClock.uptimeMillis(), MotionEvent.ACTION_UP, 451 mSearchTextEditor.getWidth(), mSearchTextEditor.getHeight(), 0)); 452 } 453 }); 454 } 455 456 /** 457 * This will update the hint for the search bar properly depending on state and provided title 458 */ 459 private void updateHint() { 460 if (null == mSearchTextEditor) return; 461 462 String title = getResources().getString(R.string.lb_search_bar_hint); 463 if (!TextUtils.isEmpty(mTitle)) { 464 if (isVoiceMode()) { 465 title = getResources().getString(R.string.lb_search_bar_hint_with_title_speech, mTitle); 466 } else { 467 title = getResources().getString(R.string.lb_search_bar_hint_with_title, mTitle); 468 } 469 } else if (isVoiceMode()) { 470 title = getResources().getString(R.string.lb_search_bar_hint_speech); 471 } 472 mSearchTextEditor.setHint(title); 473 } 474 475 private void toggleRecognition() { 476 if (mRecognizing) { 477 stopRecognition(); 478 } else { 479 startRecognition(); 480 } 481 } 482 483 /** 484 * Stop the recognition if already started 485 */ 486 public void stopRecognition() { 487 if (!mRecognizing) return; 488 mRecognizing = false; 489 if (mSpeechRecognitionCallback != null) { 490 return; 491 } 492 if (null == mSpeechRecognizer) return; 493 494 if (DEBUG) Log.v(TAG, "stopRecognition " + mListening); 495 mSpeechOrbView.showNotListening(); 496 497 if (mListening) { 498 mSpeechRecognizer.cancel(); 499 } 500 } 501 502 /** 503 * Start the voice recognition 504 */ 505 public void startRecognition() { 506 if (mRecognizing) return; 507 mRecognizing = true; 508 if (!hasFocus()) { 509 requestFocus(); 510 } 511 if (mSpeechRecognitionCallback != null) { 512 mSearchTextEditor.setText(""); 513 mSpeechRecognitionCallback.recognizeSpeech(); 514 return; 515 } 516 if (null == mSpeechRecognizer) return; 517 518 if (DEBUG) Log.v(TAG, "startRecognition " + mListening); 519 520 mSearchTextEditor.setText(""); 521 522 Intent recognizerIntent = new Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH); 523 524 recognizerIntent.putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, 525 RecognizerIntent.LANGUAGE_MODEL_FREE_FORM); 526 recognizerIntent.putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true); 527 528 mSpeechRecognizer.setRecognitionListener(new RecognitionListener() { 529 @Override 530 public void onReadyForSpeech(Bundle bundle) { 531 if (DEBUG) Log.v(TAG, "onReadyForSpeech"); 532 } 533 534 @Override 535 public void onBeginningOfSpeech() { 536 if (DEBUG) Log.v(TAG, "onBeginningOfSpeech"); 537 mListening = true; 538 } 539 540 @Override 541 public void onRmsChanged(float rmsdB) { 542 if (DEBUG) Log.v(TAG, "onRmsChanged " + rmsdB); 543 int level = rmsdB < 0 ? 0 : (int)(10 * rmsdB); 544 mSpeechOrbView.setSoundLevel(level); 545 } 546 547 @Override 548 public void onBufferReceived(byte[] bytes) { 549 if (DEBUG) Log.v(TAG, "onBufferReceived " + bytes.length); 550 } 551 552 @Override 553 public void onEndOfSpeech() { 554 if (DEBUG) Log.v(TAG, "onEndOfSpeech"); 555 mListening = false; 556 } 557 558 @Override 559 public void onError(int error) { 560 if (DEBUG) Log.v(TAG, "onError " + error); 561 switch (error) { 562 case SpeechRecognizer.ERROR_NO_MATCH: 563 Log.d(TAG, "recognizer error no match"); 564 break; 565 case SpeechRecognizer.ERROR_SERVER: 566 Log.d(TAG, "recognizer error server error"); 567 break; 568 case SpeechRecognizer.ERROR_SPEECH_TIMEOUT: 569 Log.d(TAG, "recognizer error speech timeout"); 570 break; 571 case SpeechRecognizer.ERROR_CLIENT: 572 Log.d(TAG, "recognizer error client error"); 573 break; 574 default: 575 Log.d(TAG, "recognizer other error"); 576 break; 577 } 578 579 mSpeechRecognizer.stopListening(); 580 mListening = false; 581 mSpeechRecognizer.setRecognitionListener(null); 582 mSpeechOrbView.showNotListening(); 583 playSearchFailure(); 584 } 585 586 @Override 587 public void onResults(Bundle bundle) { 588 if (DEBUG) Log.v(TAG, "onResults"); 589 final ArrayList<String> matches = 590 bundle.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION); 591 if (matches != null) { 592 Log.v(TAG, "Got results" + matches); 593 594 mSearchQuery = matches.get(0); 595 mSearchTextEditor.setText(mSearchQuery); 596 submitQuery(); 597 598 if (mListening) { 599 mSpeechRecognizer.stopListening(); 600 } 601 } 602 mSpeechRecognizer.setRecognitionListener(null); 603 mSpeechOrbView.showNotListening(); 604 playSearchSuccess(); 605 } 606 607 @Override 608 public void onPartialResults(Bundle bundle) { 609 610 } 611 612 @Override 613 public void onEvent(int i, Bundle bundle) { 614 615 } 616 }); 617 618 mSpeechOrbView.showListening(); 619 playSearchOpen(); 620 mSpeechRecognizer.startListening(recognizerIntent); 621 mListening = true; 622 } 623 624 private void updateUi() { 625 if (DEBUG) Log.v(TAG, String.format("Update UI %s %s", 626 isVoiceMode() ? "Voice" : "Text", 627 hasFocus() ? "Focused" : "Unfocused")); 628 if (isVoiceMode()) { 629 mBarBackground.setAlpha(mBackgroundSpeechAlpha); 630 mSearchTextEditor.setTextColor(mTextColorSpeechMode); 631 mSearchTextEditor.setHintTextColor(mTextHintColorSpeechMode); 632 } else { 633 mBarBackground.setAlpha(mBackgroundAlpha); 634 mSearchTextEditor.setTextColor(mTextColor); 635 mSearchTextEditor.setHintTextColor(mTextHintColor); 636 } 637 638 updateHint(); 639 } 640 641 private boolean isVoiceMode() { 642 return mSpeechOrbView.isFocused(); 643 } 644 645 private void submitQuery() { 646 if (!TextUtils.isEmpty(mSearchQuery) && null != mSearchBarListener) { 647 mSearchBarListener.onSearchQuerySubmit(mSearchQuery); 648 } 649 } 650 651 private void enforceAudioRecordPermission() { 652 String permission = "android.permission.RECORD_AUDIO"; 653 int res = getContext().checkCallingOrSelfPermission(permission); 654 if (PackageManager.PERMISSION_GRANTED != res) { 655 throw new IllegalStateException("android.permission.RECORD_AUDIO required for search"); 656 } 657 } 658 659 private void loadSounds(Context context) { 660 int[] sounds = { 661 R.raw.lb_voice_failure, 662 R.raw.lb_voice_open, 663 R.raw.lb_voice_no_input, 664 R.raw.lb_voice_success, 665 }; 666 for (int sound : sounds) { 667 mSoundMap.put(sound, mSoundPool.load(context, sound, 1)); 668 } 669 } 670 671 private void play(final int resId) { 672 mHandler.post(new Runnable() { 673 @Override 674 public void run() { 675 int sound = mSoundMap.get(resId); 676 mSoundPool.play(sound, FULL_LEFT_VOLUME, FULL_RIGHT_VOLUME, DEFAULT_PRIORITY, 677 DO_NOT_LOOP, DEFAULT_RATE); 678 } 679 }); 680 } 681 682 private void playSearchOpen() { 683 play(R.raw.lb_voice_open); 684 } 685 686 private void playSearchFailure() { 687 play(R.raw.lb_voice_failure); 688 } 689 690 private void playSearchNoInput() { 691 play(R.raw.lb_voice_no_input); 692 } 693 694 private void playSearchSuccess() { 695 play(R.raw.lb_voice_success); 696 } 697 698 @Override 699 public void setNextFocusDownId(int viewId) { 700 mSpeechOrbView.setNextFocusDownId(viewId); 701 mSearchTextEditor.setNextFocusDownId(viewId); 702 } 703 704} 705