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