SearchActivity.java revision ac17ac866d6f543c3a136e1e2361c01004c6547e
1/* 2 * Copyright (C) 2009 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17package com.android.quicksearchbox; 18 19import com.android.quicksearchbox.ui.SearchSourceSelector; 20import com.android.quicksearchbox.ui.SuggestionClickListener; 21import com.android.quicksearchbox.ui.SuggestionSelectionListener; 22import com.android.quicksearchbox.ui.SuggestionViewFactory; 23import com.android.quicksearchbox.ui.SuggestionsAdapter; 24import com.android.quicksearchbox.ui.SuggestionsView; 25 26import android.app.Activity; 27import android.app.SearchManager; 28import android.content.ComponentName; 29import android.content.Intent; 30import android.database.DataSetObserver; 31import android.graphics.Rect; 32import android.graphics.drawable.Animatable; 33import android.graphics.drawable.Drawable; 34import android.os.Bundle; 35import android.text.Editable; 36import android.text.TextUtils; 37import android.text.TextWatcher; 38import android.util.Log; 39import android.view.KeyEvent; 40import android.view.Menu; 41import android.view.View; 42import android.view.View.OnFocusChangeListener; 43import android.view.inputmethod.InputMethodManager; 44import android.widget.EditText; 45import android.widget.ImageButton; 46 47/** 48 * The main activity for Quick Search Box. Shows the search UI. 49 * 50 */ 51public class SearchActivity extends Activity { 52 53 private static final boolean DBG = true; 54 private static final String TAG = "QSB.SearchActivity"; 55 56 // TODO: This is hidden in SearchManager 57 public final static String INTENT_ACTION_SEARCH_SETTINGS 58 = "android.search.action.SEARCH_SETTINGS"; 59 60 public static final String EXTRA_KEY_SEARCH_SOURCE 61 = "search_source"; 62 63 // Keys for the saved instance state. 64 private static final String INSTANCE_KEY_SOURCE = "source"; 65 private static final String INSTANCE_KEY_USER_QUERY = "query"; 66 67 protected SuggestionsAdapter mSuggestionsAdapter; 68 69 protected EditText mQueryTextView; 70 71 protected SuggestionsView mSuggestionsView; 72 73 protected ImageButton mSearchGoButton; 74 protected ImageButton mVoiceSearchButton; 75 protected SearchSourceSelector mSourceSelector; 76 77 private Launcher mLauncher; 78 79 private Source mSource; 80 private boolean mUpdateSuggestions; 81 private String mUserQuery; 82 private boolean mSelectAll; 83 84 /** Called when the activity is first created. */ 85 @Override 86 public void onCreate(Bundle savedInstanceState) { 87 if (DBG) Log.d(TAG, "onCreate()"); 88 super.onCreate(savedInstanceState); 89 90 setContentView(R.layout.search_bar); 91 92 mSuggestionsAdapter = getQsbApplication().createSuggestionsAdapter(); 93 94 mQueryTextView = (EditText) findViewById(R.id.search_src_text); 95 mSuggestionsView = (SuggestionsView) findViewById(R.id.suggestions); 96 mSuggestionsView.setSuggestionClickListener(new ClickHandler()); 97 mSuggestionsView.setSuggestionSelectionListener(new SelectionHandler()); 98 mSuggestionsView.setInteractionListener(new InputMethodCloser()); 99 mSuggestionsView.setOnKeyListener(new SuggestionsViewKeyListener()); 100 mSuggestionsView.setOnFocusChangeListener(new SuggestListFocusListener()); 101 102 mSearchGoButton = (ImageButton) findViewById(R.id.search_go_btn); 103 mVoiceSearchButton = (ImageButton) findViewById(R.id.search_voice_btn); 104 mSourceSelector = new SearchSourceSelector(findViewById(R.id.search_source_selector)); 105 106 mLauncher = new Launcher(this); 107 // TODO: should this check for voice search in the current source? 108 mVoiceSearchButton.setVisibility( 109 mLauncher.isVoiceSearchAvailable() ? View.VISIBLE : View.GONE); 110 111 mQueryTextView.addTextChangedListener(new SearchTextWatcher()); 112 mQueryTextView.setOnKeyListener(new QueryTextViewKeyListener()); 113 mQueryTextView.setOnFocusChangeListener(new QueryTextViewFocusListener()); 114 115 mSearchGoButton.setOnClickListener(new SearchGoButtonClickListener()); 116 117 mVoiceSearchButton.setOnClickListener(new VoiceSearchButtonClickListener()); 118 119 ButtonsKeyListener buttonsKeyListener = new ButtonsKeyListener(); 120 mSearchGoButton.setOnKeyListener(buttonsKeyListener); 121 mVoiceSearchButton.setOnKeyListener(buttonsKeyListener); 122 mSourceSelector.setOnKeyListener(buttonsKeyListener); 123 124 mUpdateSuggestions = true; 125 126 // First get setup from intent 127 Intent intent = getIntent(); 128 setupFromIntent(intent); 129 // Then restore any saved instance state 130 restoreInstanceState(savedInstanceState); 131 132 // Do this at the end, to avoid updating the list view when setSource() 133 // is called. 134 mSuggestionsView.setAdapter(mSuggestionsAdapter); 135 } 136 137 @Override 138 protected void onNewIntent(Intent intent) { 139 setIntent(intent); 140 setupFromIntent(intent); 141 } 142 143 protected void restoreInstanceState(Bundle savedInstanceState) { 144 if (savedInstanceState == null) return; 145 ComponentName sourceName = savedInstanceState.getParcelable(INSTANCE_KEY_SOURCE); 146 String query = savedInstanceState.getString(INSTANCE_KEY_USER_QUERY); 147 setSource(getSourceByComponentName(sourceName)); 148 setUserQuery(query); 149 } 150 151 @Override 152 protected void onSaveInstanceState(Bundle outState) { 153 super.onSaveInstanceState(outState); 154 // We don't save appSearchData, since we always get the value 155 // from the intent and the user can't change it. 156 outState.putParcelable(INSTANCE_KEY_SOURCE, getSourceName()); 157 outState.putString(INSTANCE_KEY_USER_QUERY, mUserQuery); 158 } 159 160 private void setupFromIntent(Intent intent) { 161 if (DBG) Log.d(TAG, "setupFromIntent(" + intent.toUri(0) + ")"); 162 if (intent.hasExtra(EXTRA_KEY_SEARCH_SOURCE)) { 163 Source source = getSourceByName(intent.getStringExtra(EXTRA_KEY_SEARCH_SOURCE)); 164 setSource(source); 165 // The source was selected by the user, save it. 166 setLastSelectedSource(source); 167 } else { 168 Source source = getSources().getLastSelectedSource(); 169 if (DBG) Log.d(TAG, "Setting source from preferences: " + source); 170 setSource(source); 171 } 172 setUserQuery(intent.getStringExtra(SearchManager.QUERY)); 173 mSelectAll = intent.getBooleanExtra(SearchManager.EXTRA_SELECT_QUERY, false); 174 setAppSearchData(intent.getBundleExtra(SearchManager.APP_DATA)); 175 } 176 177 private void setLastSelectedSource(Source source) { 178 getSources().setLastSelectedSource(source); 179 } 180 181 private Source getSourceByName(String sourceNameStr) { 182 if (sourceNameStr == null) return null; 183 ComponentName sourceName = ComponentName.unflattenFromString(sourceNameStr); 184 if (sourceName == null) { 185 Log.w(TAG, "Malformed source name: " + sourceName); 186 return null; 187 } 188 return getSourceByComponentName(sourceName); 189 } 190 191 private Source getSourceByComponentName(ComponentName sourceName) { 192 Source source = getSources().getSourceByComponentName(sourceName); 193 if (source == null) { 194 Log.w(TAG, "Unknown source " + sourceName); 195 return null; 196 } 197 return source; 198 } 199 200 private void setSource(Source source) { 201 if (DBG) Log.d(TAG, "setSource(" + source + ")"); 202 mSource = source; 203 Drawable sourceIcon; 204 if (source == null) { 205 sourceIcon = getSuggestionViewFactory().getGlobalSearchIcon(); 206 } else { 207 sourceIcon = source.getSourceIcon(); 208 } 209 ComponentName sourceName = getSourceName(); 210 mSuggestionsAdapter.setSource(sourceName); 211 mSourceSelector.setSource(sourceName); 212 mSourceSelector.setSourceIcon(sourceIcon); 213 } 214 215 private ComponentName getSourceName() { 216 return mSource == null ? null : mSource.getComponentName(); 217 } 218 219 private QsbApplication getQsbApplication() { 220 return (QsbApplication) getApplication(); 221 } 222 223 private SourceLookup getSources() { 224 return getQsbApplication().getSources(); 225 } 226 227 private ShortcutRepository getShortcutRepository() { 228 return getQsbApplication().getShortcutRepository(); 229 } 230 231 private SuggestionsProvider getSuggestionsProvider(Source source) { 232 return getQsbApplication().getSuggestionsProvider(source); 233 } 234 235 private SuggestionViewFactory getSuggestionViewFactory() { 236 return getQsbApplication().getSuggestionViewFactory(); 237 } 238 239 @Override 240 protected void onDestroy() { 241 if (DBG) Log.d(TAG, "onDestroy()"); 242 super.onDestroy(); 243 mSuggestionsView.setAdapter(null); // closes mSuggestionsAdapter 244 } 245 246 @Override 247 protected void onStop() { 248 if (DBG) Log.d(TAG, "onStop()"); 249 // Close all open suggestion cursors. The query will be redone in onResume() 250 // if we come back to this activity. 251 mSuggestionsAdapter.setSuggestions(null); 252 getQsbApplication().getShortcutRefresher().reset(); 253 super.onStop(); 254 } 255 256 @Override 257 protected void onResume() { 258 if (DBG) Log.d(TAG, "onResume()"); 259 super.onResume(); 260 setQuery(mUserQuery, mSelectAll); 261 // Only select everything the first time after creating the activity. 262 mSelectAll = false; 263 updateSuggestions(mUserQuery); 264 mQueryTextView.requestFocus(); 265 } 266 267 @Override 268 public boolean onCreateOptionsMenu(Menu menu) { 269 super.onCreateOptionsMenu(menu); 270 271 Intent settings = new Intent(INTENT_ACTION_SEARCH_SETTINGS); 272 settings.addFlags(Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET); 273 // Don't show activity chooser if there are multiple search settings activities, 274 // e.g. from different QSB implementations. 275 settings.setPackage(this.getPackageName()); 276 menu.add(Menu.NONE, Menu.NONE, 0, R.string.menu_settings) 277 .setIcon(android.R.drawable.ic_menu_preferences).setAlphabeticShortcut('P') 278 .setIntent(settings); 279 280 return true; 281 } 282 283 /** 284 * Sets the query as typed by the user. Does not update the suggestions 285 * or the text in the query box. 286 */ 287 protected void setUserQuery(String userQuery) { 288 if (userQuery == null) userQuery = ""; 289 mUserQuery = userQuery; 290 mSourceSelector.setQuery(mUserQuery); 291 } 292 293 protected void setAppSearchData(Bundle appSearchData) { 294 mLauncher.setAppSearchData(appSearchData); 295 mSourceSelector.setAppSearchData(appSearchData); 296 } 297 298 protected String getQuery() { 299 CharSequence q = mQueryTextView.getText(); 300 return q == null ? "" : q.toString(); 301 } 302 303 /** 304 * Restores the query entered by the user. 305 */ 306 private void restoreUserQuery() { 307 if (DBG) Log.d(TAG, "Restoring query to '" + mUserQuery + "'"); 308 setQuery(mUserQuery, false); 309 } 310 311 /** 312 * Sets the text in the query box. Does not update the suggestions, 313 * and does not change the saved user-entered query. 314 * {@link #restoreUserQuery()} will restore the query to the last 315 * user-entered query. 316 */ 317 private void setQuery(String query, boolean selectAll) { 318 mUpdateSuggestions = false; 319 mQueryTextView.setText(query); 320 setTextSelection(selectAll); 321 mUpdateSuggestions = true; 322 } 323 324 /** 325 * Sets the text selection in the query text view. 326 * 327 * @param selectAll If {@code true}, selects the entire query. 328 * If {@false}, no characters are selected, and the cursor is placed 329 * at the end of the query. 330 */ 331 private void setTextSelection(boolean selectAll) { 332 if (selectAll) { 333 mQueryTextView.setSelection(0, mQueryTextView.length()); 334 } else { 335 mQueryTextView.setSelection(mQueryTextView.length()); 336 } 337 } 338 339 protected void onSearchClicked() { 340 String query = getQuery(); 341 if (DBG) Log.d(TAG, "Search clicked, query=" + query); 342 mLauncher.startSearch(mSource, query); 343 } 344 345 protected void onVoiceSearchClicked() { 346 if (DBG) Log.d(TAG, "Voice Search clicked"); 347 // TODO: should this start voice search in the current source? 348 mLauncher.startVoiceSearch(); 349 } 350 351 protected boolean launchSuggestion(SuggestionPosition suggestion) { 352 return launchSuggestion(suggestion, KeyEvent.KEYCODE_UNKNOWN, null); 353 } 354 355 protected boolean launchSuggestion(SuggestionPosition suggestion, 356 int actionKey, String actionMsg) { 357 if (DBG) Log.d(TAG, "Launching suggestion " + suggestion); 358 mLauncher.launchSuggestion(suggestion, actionKey, actionMsg); 359 getShortcutRepository().reportClick(suggestion); 360 return true; 361 } 362 363 protected boolean onSuggestionLongClicked(SuggestionPosition suggestion) { 364 SuggestionCursor sourceResult = suggestion.getSuggestion(); 365 if (DBG) Log.d(TAG, "Long clicked on suggestion " + sourceResult.getSuggestionText1()); 366 return false; 367 } 368 369 protected void onSuggestionSelected(SuggestionPosition suggestion) { 370 if (suggestion == null) { 371 // This happens when a suggestion has been selected with the 372 // dpad / trackball and then a different UI element is touched. 373 // Do nothing, since we want to keep the query of the selection 374 // in the search box. 375 return; 376 } 377 SuggestionCursor sourceResult = suggestion.getSuggestion(); 378 String displayQuery = sourceResult.getSuggestionDisplayQuery(); 379 if (DBG) { 380 Log.d(TAG, "Selected suggestion " + sourceResult.getSuggestionText1() 381 + ",displayQuery="+ displayQuery); 382 } 383 if (TextUtils.isEmpty(displayQuery)) { 384 restoreUserQuery(); 385 } else { 386 setQuery(displayQuery, false); 387 } 388 } 389 390 protected boolean onSuggestionKeyDown(SuggestionPosition suggestion, 391 int keyCode, KeyEvent event) { 392 // Treat enter or search as a click 393 if (keyCode == KeyEvent.KEYCODE_ENTER || keyCode == KeyEvent.KEYCODE_SEARCH) { 394 return launchSuggestion(suggestion); 395 } 396 397 if (keyCode == KeyEvent.KEYCODE_DPAD_UP 398 && mSuggestionsView.getSelectedItemPosition() == 0) { 399 // Moved up from the top suggestion, restore the user query and focus query box 400 if (DBG) Log.d(TAG, "Up and out"); 401 restoreUserQuery(); 402 return false; // let the framework handle the move 403 } 404 405 if (keyCode == KeyEvent.KEYCODE_DPAD_LEFT 406 || keyCode == KeyEvent.KEYCODE_DPAD_RIGHT) { 407 // Moved left / right from a suggestion, keep current query, move 408 // focus to query box, and move cursor to far left / right 409 if (DBG) Log.d(TAG, "Left/right on a suggestion"); 410 int cursorPos = (keyCode == KeyEvent.KEYCODE_DPAD_LEFT) ? 0 : mQueryTextView.length(); 411 mQueryTextView.setSelection(cursorPos); 412 mQueryTextView.requestFocus(); 413 // TODO: should we modify the list selection? 414 return true; 415 } 416 417 // Handle source-specified action keys 418 String actionMsg = suggestion.getSuggestion().getActionKeyMsg(keyCode); 419 if (actionMsg != null) { 420 return launchSuggestion(suggestion, keyCode, actionMsg); 421 } 422 423 return false; 424 } 425 426 protected void onSourceSelected() { 427 if (DBG) Log.d(TAG, "No suggestion selected"); 428 restoreUserQuery(); 429 } 430 431 protected int getSelectedPosition() { 432 return mSuggestionsView.getSelectedPosition(); 433 } 434 435 protected SuggestionPosition getSelectedSuggestion() { 436 return mSuggestionsView.getSelectedSuggestion(); 437 } 438 439 /** 440 * Hides the input method. 441 */ 442 protected void hideInputMethod() { 443 InputMethodManager imm = (InputMethodManager) getSystemService(INPUT_METHOD_SERVICE); 444 if (imm != null) { 445 imm.hideSoftInputFromWindow(mQueryTextView.getWindowToken(), 0); 446 } 447 } 448 449 protected void showInputMethodForQuery() { 450 InputMethodManager imm = (InputMethodManager) getSystemService(INPUT_METHOD_SERVICE); 451 if (imm != null) { 452 imm.showSoftInput(mQueryTextView, 0); 453 } 454 } 455 456 /** 457 * Hides the input method when the suggestions get focus. 458 */ 459 private class SuggestListFocusListener implements OnFocusChangeListener { 460 public void onFocusChange(View v, boolean focused) { 461 if (DBG) Log.d(TAG, "Suggestions focus change, now: " + focused); 462 if (focused) { 463 // The suggestions list got focus, hide the input method 464 hideInputMethod(); 465 } 466 } 467 } 468 469 private class QueryTextViewFocusListener implements OnFocusChangeListener { 470 public void onFocusChange(View v, boolean focused) { 471 if (DBG) Log.d(TAG, "Query focus change, now: " + focused); 472 if (focused) { 473 // The query box got focus, show the input method if the 474 // query box got focus? 475 showInputMethodForQuery(); 476 } 477 } 478 } 479 480 private void startSearchProgress() { 481 // TODO: Cache animation between calls? 482 mSearchGoButton.setImageResource(R.drawable.searching); 483 Animatable animation = (Animatable) mSearchGoButton.getDrawable(); 484 animation.start(); 485 } 486 487 private void stopSearchProgress() { 488 Drawable animation = mSearchGoButton.getDrawable(); 489 if (animation instanceof Animatable) { 490 // TODO: Is this needed, or is it done automatically when the 491 // animation is removed? 492 ((Animatable) animation).stop(); 493 } 494 mSearchGoButton.setImageResource(R.drawable.ic_btn_search); 495 } 496 497 private void updateSuggestions(String query) { 498 LatencyTracker latency = new LatencyTracker(TAG); 499 Suggestions suggestions = getSuggestionsProvider(mSource).getSuggestions(query); 500 latency.addEvent("getSuggestions_done"); 501 if (!suggestions.isDone()) { 502 suggestions.registerDataSetObserver(new ProgressUpdater(suggestions)); 503 startSearchProgress(); 504 } else { 505 stopSearchProgress(); 506 } 507 mSuggestionsAdapter.setSuggestions(suggestions); 508 latency.addEvent("shortcuts_shown"); 509 long userVisibleLatency = latency.getUserVisibleLatency(); 510 if (DBG) { 511 Log.d(TAG, "User visible latency (shortcuts): " + userVisibleLatency + " ms."); 512 } 513 } 514 515 private boolean forwardKeyToQueryTextView(int keyCode, KeyEvent event) { 516 if (!event.isSystem() && !isDpadKey(keyCode)) { 517 if (DBG) Log.d(TAG, "Forwarding key to query box: " + event); 518 if (mQueryTextView.requestFocus()) { 519 return mQueryTextView.dispatchKeyEvent(event); 520 } 521 } 522 return false; 523 } 524 525 private boolean isDpadKey(int keyCode) { 526 switch (keyCode) { 527 case KeyEvent.KEYCODE_DPAD_UP: 528 case KeyEvent.KEYCODE_DPAD_DOWN: 529 case KeyEvent.KEYCODE_DPAD_LEFT: 530 case KeyEvent.KEYCODE_DPAD_RIGHT: 531 case KeyEvent.KEYCODE_DPAD_CENTER: 532 return true; 533 default: 534 return false; 535 } 536 } 537 538 /** 539 * Filters the suggestions list when the search text changes. 540 */ 541 private class SearchTextWatcher implements TextWatcher { 542 public void afterTextChanged(Editable s) { 543 if (mUpdateSuggestions) { 544 String query = s == null ? "" : s.toString(); 545 setUserQuery(query); 546 updateSuggestions(query); 547 } 548 } 549 550 public void beforeTextChanged(CharSequence s, int start, int count, int after) { 551 } 552 553 public void onTextChanged(CharSequence s, int start, int before, int count) { 554 } 555 } 556 557 /** 558 * Handles non-text keys in the query text view. 559 */ 560 private class QueryTextViewKeyListener implements View.OnKeyListener { 561 public boolean onKey(View view, int keyCode, KeyEvent event) { 562 // Handle IME search action key 563 if (keyCode == KeyEvent.KEYCODE_ENTER && event.getAction() == KeyEvent.ACTION_UP) { 564 onSearchClicked(); 565 } 566 return false; 567 } 568 } 569 570 /** 571 * Handles key events on the search and voice search buttons, 572 * by refocusing to EditText. 573 */ 574 private class ButtonsKeyListener implements View.OnKeyListener { 575 public boolean onKey(View v, int keyCode, KeyEvent event) { 576 return forwardKeyToQueryTextView(keyCode, event); 577 } 578 } 579 580 /** 581 * Handles key events on the suggestions list view. 582 */ 583 private class SuggestionsViewKeyListener implements View.OnKeyListener { 584 public boolean onKey(View v, int keyCode, KeyEvent event) { 585 if (event.getAction() == KeyEvent.ACTION_DOWN) { 586 SuggestionPosition suggestion = getSelectedSuggestion(); 587 if (suggestion != null) { 588 if (onSuggestionKeyDown(suggestion, keyCode, event)) { 589 return true; 590 } 591 } 592 } 593 return forwardKeyToQueryTextView(keyCode, event); 594 } 595 } 596 597 private class InputMethodCloser implements SuggestionsView.InteractionListener { 598 public void onInteraction() { 599 hideInputMethod(); 600 } 601 } 602 603 private class ClickHandler implements SuggestionClickListener { 604 public void onSuggestionClicked(SuggestionPosition suggestion) { 605 launchSuggestion(suggestion); 606 } 607 608 public boolean onSuggestionLongClicked(SuggestionPosition suggestion) { 609 return SearchActivity.this.onSuggestionLongClicked(suggestion); 610 } 611 } 612 613 private class SelectionHandler implements SuggestionSelectionListener { 614 public void onSelectionChanged(SuggestionPosition suggestion) { 615 onSuggestionSelected(suggestion); 616 } 617 } 618 619 /** 620 * Listens for clicks on the search button. 621 */ 622 private class SearchGoButtonClickListener implements View.OnClickListener { 623 public void onClick(View view) { 624 onSearchClicked(); 625 } 626 } 627 628 /** 629 * Listens for clicks on the voice search button. 630 */ 631 private class VoiceSearchButtonClickListener implements View.OnClickListener { 632 public void onClick(View view) { 633 onVoiceSearchClicked(); 634 } 635 } 636 637 /** 638 * Updates the progress bar when the suggestions adapter changes its progress. 639 */ 640 private class ProgressUpdater extends DataSetObserver { 641 private final Suggestions mSuggestions; 642 643 public ProgressUpdater(Suggestions suggestions) { 644 mSuggestions = suggestions; 645 } 646 647 @Override 648 public void onChanged() { 649 if (mSuggestions.isDone()) { 650 stopSearchProgress(); 651 } 652 } 653 } 654 655} 656