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