SearchActivity.java revision dfc1772caf35942837d83331d787eb10734c37cb
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.common.Search; 20import com.android.quicksearchbox.ui.SearchActivityView; 21import com.android.quicksearchbox.ui.SuggestionClickListener; 22import com.android.quicksearchbox.ui.SuggestionsAdapter; 23import com.android.quicksearchbox.util.Consumer; 24import com.android.quicksearchbox.util.Consumers; 25import com.google.common.annotations.VisibleForTesting; 26import com.google.common.base.CharMatcher; 27 28import android.app.Activity; 29import android.app.SearchManager; 30import android.content.DialogInterface; 31import android.content.Intent; 32import android.database.DataSetObserver; 33import android.net.Uri; 34import android.os.Bundle; 35import android.os.Debug; 36import android.os.Handler; 37import android.text.TextUtils; 38import android.util.Log; 39import android.view.Menu; 40import android.view.View; 41 42import java.io.File; 43import java.util.ArrayList; 44import java.util.Collection; 45import java.util.List; 46import java.util.Set; 47 48/** 49 * The main activity for Quick Search Box. Shows the search UI. 50 * 51 */ 52public class SearchActivity extends Activity { 53 54 private static final boolean DBG = false; 55 private static final String TAG = "QSB.SearchActivity"; 56 private static final boolean TRACE = false; 57 58 private static final String SCHEME_CORPUS = "qsb.corpus"; 59 60 public static final String INTENT_ACTION_QSB_AND_SELECT_CORPUS 61 = "com.android.quicksearchbox.action.QSB_AND_SELECT_CORPUS"; 62 63 // Keys for the saved instance state. 64 private static final String INSTANCE_KEY_CORPUS = "corpus"; 65 private static final String INSTANCE_KEY_QUERY = "query"; 66 67 // Measures time from for last onCreate()/onNewIntent() call. 68 private LatencyTracker mStartLatencyTracker; 69 // Whether QSB is starting. True between the calls to onCreate()/onNewIntent() and onResume(). 70 private boolean mStarting; 71 // True if the user has taken some action, e.g. launching a search, voice search, 72 // or suggestions, since QSB was last started. 73 private boolean mTookAction; 74 75 private SearchActivityView mSearchActivityView; 76 77 private CorporaObserver mCorporaObserver; 78 79 private Bundle mAppSearchData; 80 81 private final Handler mHandler = new Handler(); 82 private final Runnable mUpdateSuggestionsTask = new Runnable() { 83 public void run() { 84 updateSuggestions(getQuery()); 85 } 86 }; 87 88 private final Runnable mShowInputMethodTask = new Runnable() { 89 public void run() { 90 mSearchActivityView.showInputMethodForQuery(); 91 } 92 }; 93 94 private OnDestroyListener mDestroyListener; 95 96 /** Called when the activity is first created. */ 97 @Override 98 public void onCreate(Bundle savedInstanceState) { 99 if (TRACE) startMethodTracing(); 100 recordStartTime(); 101 if (DBG) Log.d(TAG, "onCreate()"); 102 super.onCreate(savedInstanceState); 103 104 mSearchActivityView = setupContentView(); 105 106 if (getConfig().showScrollingSuggestions()) { 107 mSearchActivityView.setMaxPromotedSuggestions(getConfig().getMaxPromotedSuggestions()); 108 } else { 109 mSearchActivityView.limitSuggestionsToViewHeight(); 110 } 111 if (getConfig().showScrollingResults()) { 112 mSearchActivityView.setMaxPromotedResults(getConfig().getMaxPromotedSuggestions()); 113 } else { 114 mSearchActivityView.limitResultsToViewHeight(); 115 } 116 117 mSearchActivityView.setSearchClickListener(new SearchActivityView.SearchClickListener() { 118 public boolean onSearchClicked(int method) { 119 return SearchActivity.this.onSearchClicked(method); 120 } 121 }); 122 123 mSearchActivityView.setQueryListener(new SearchActivityView.QueryListener() { 124 public void onQueryChanged() { 125 updateSuggestionsBuffered(); 126 } 127 }); 128 129 mSearchActivityView.setSuggestionClickListener(new ClickHandler()); 130 131 mSearchActivityView.setSettingsButtonClickListener(new View.OnClickListener() { 132 public void onClick(View v) { 133 onSettingsClicked(); 134 } 135 }); 136 137 mSearchActivityView.setVoiceSearchButtonClickListener(new View.OnClickListener() { 138 public void onClick(View view) { 139 onVoiceSearchClicked(); 140 } 141 }); 142 143 mSearchActivityView.setExitClickListener(new View.OnClickListener() { 144 public void onClick(View v) { 145 finish(); 146 } 147 }); 148 149 // First get setup from intent 150 Intent intent = getIntent(); 151 setupFromIntent(intent); 152 // Then restore any saved instance state 153 restoreInstanceState(savedInstanceState); 154 155 // Do this at the end, to avoid updating the list view when setSource() 156 // is called. 157 mSearchActivityView.start(); 158 159 mCorporaObserver = new CorporaObserver(); 160 getCorpora().registerDataSetObserver(mCorporaObserver); 161 } 162 163 protected SearchActivityView setupContentView() { 164 setContentView(R.layout.search_activity); 165 return (SearchActivityView) findViewById(R.id.search_activity_view); 166 } 167 168 protected SearchActivityView getSearchActivityView() { 169 return mSearchActivityView; 170 } 171 172 private void startMethodTracing() { 173 File traceDir = getDir("traces", 0); 174 String traceFile = new File(traceDir, "qsb.trace").getAbsolutePath(); 175 Debug.startMethodTracing(traceFile); 176 } 177 178 @Override 179 protected void onNewIntent(Intent intent) { 180 if (DBG) Log.d(TAG, "onNewIntent()"); 181 recordStartTime(); 182 setIntent(intent); 183 setupFromIntent(intent); 184 } 185 186 private void recordStartTime() { 187 mStartLatencyTracker = new LatencyTracker(); 188 mStarting = true; 189 mTookAction = false; 190 } 191 192 protected void restoreInstanceState(Bundle savedInstanceState) { 193 if (savedInstanceState == null) return; 194 String corpusName = savedInstanceState.getString(INSTANCE_KEY_CORPUS); 195 String query = savedInstanceState.getString(INSTANCE_KEY_QUERY); 196 setCorpus(corpusName); 197 setQuery(query, false); 198 } 199 200 @Override 201 protected void onSaveInstanceState(Bundle outState) { 202 super.onSaveInstanceState(outState); 203 // We don't save appSearchData, since we always get the value 204 // from the intent and the user can't change it. 205 206 outState.putString(INSTANCE_KEY_CORPUS, getCorpusName()); 207 outState.putString(INSTANCE_KEY_QUERY, getQuery()); 208 } 209 210 private void setupFromIntent(Intent intent) { 211 if (DBG) Log.d(TAG, "setupFromIntent(" + intent.toUri(0) + ")"); 212 String corpusName = getCorpusNameFromUri(intent.getData()); 213 String query = intent.getStringExtra(SearchManager.QUERY); 214 Bundle appSearchData = intent.getBundleExtra(SearchManager.APP_DATA); 215 boolean selectAll = intent.getBooleanExtra(SearchManager.EXTRA_SELECT_QUERY, false); 216 217 setCorpus(corpusName); 218 setQuery(query, selectAll); 219 mAppSearchData = appSearchData; 220 221 if (startedIntoCorpusSelectionDialog()) { 222 mSearchActivityView.showCorpusSelectionDialog(); 223 } 224 } 225 226 public boolean startedIntoCorpusSelectionDialog() { 227 return INTENT_ACTION_QSB_AND_SELECT_CORPUS.equals(getIntent().getAction()); 228 } 229 230 /** 231 * Removes corpus selector intent action, so that BACK works normally after 232 * dismissing and reopening the corpus selector. 233 */ 234 public void clearStartedIntoCorpusSelectionDialog() { 235 Intent oldIntent = getIntent(); 236 if (SearchActivity.INTENT_ACTION_QSB_AND_SELECT_CORPUS.equals(oldIntent.getAction())) { 237 Intent newIntent = new Intent(oldIntent); 238 newIntent.setAction(SearchManager.INTENT_ACTION_GLOBAL_SEARCH); 239 setIntent(newIntent); 240 } 241 } 242 243 public static Uri getCorpusUri(Corpus corpus) { 244 if (corpus == null) return null; 245 return new Uri.Builder() 246 .scheme(SCHEME_CORPUS) 247 .authority(corpus.getName()) 248 .build(); 249 } 250 251 private String getCorpusNameFromUri(Uri uri) { 252 if (uri == null) return null; 253 if (!SCHEME_CORPUS.equals(uri.getScheme())) return null; 254 return uri.getAuthority(); 255 } 256 257 private Corpus getCorpus() { 258 return mSearchActivityView.getCorpus(); 259 } 260 261 private String getCorpusName() { 262 return mSearchActivityView.getCorpusName(); 263 } 264 265 private void setCorpus(String name) { 266 mSearchActivityView.setCorpus(name); 267 } 268 269 private QsbApplication getQsbApplication() { 270 return QsbApplication.get(this); 271 } 272 273 private Config getConfig() { 274 return getQsbApplication().getConfig(); 275 } 276 277 protected SearchSettings getSettings() { 278 return getQsbApplication().getSettings(); 279 } 280 281 private Corpora getCorpora() { 282 return getQsbApplication().getCorpora(); 283 } 284 285 private CorpusRanker getCorpusRanker() { 286 return getQsbApplication().getCorpusRanker(); 287 } 288 289 private ShortcutRepository getShortcutRepository() { 290 return getQsbApplication().getShortcutRepository(); 291 } 292 293 private SuggestionsProvider getSuggestionsProvider() { 294 return getQsbApplication().getSuggestionsProvider(); 295 } 296 297 private Logger getLogger() { 298 return getQsbApplication().getLogger(); 299 } 300 301 @VisibleForTesting 302 public void setOnDestroyListener(OnDestroyListener l) { 303 mDestroyListener = l; 304 } 305 306 @Override 307 protected void onDestroy() { 308 if (DBG) Log.d(TAG, "onDestroy()"); 309 getCorpora().unregisterDataSetObserver(mCorporaObserver); 310 mSearchActivityView.destroy(); 311 super.onDestroy(); 312 if (mDestroyListener != null) { 313 mDestroyListener.onDestroyed(); 314 } 315 } 316 317 @Override 318 protected void onStop() { 319 if (DBG) Log.d(TAG, "onStop()"); 320 if (!mTookAction) { 321 // TODO: This gets logged when starting other activities, e.g. by opening the search 322 // settings, or clicking a notification in the status bar. 323 // TODO we should log both sets of suggestions in 2-pane mode 324 getLogger().logExit(getCurrentSuggestions(), getQuery().length()); 325 } 326 // Close all open suggestion cursors. The query will be redone in onResume() 327 // if we come back to this activity. 328 mSearchActivityView.clearSuggestions(); 329 getQsbApplication().getShortcutRefresher().reset(); 330 mSearchActivityView.onStop(); 331 super.onStop(); 332 } 333 334 @Override 335 protected void onRestart() { 336 if (DBG) Log.d(TAG, "onRestart()"); 337 super.onRestart(); 338 } 339 340 @Override 341 protected void onResume() { 342 if (DBG) Log.d(TAG, "onResume()"); 343 super.onResume(); 344 updateSuggestionsBuffered(); 345 mSearchActivityView.onResume(); 346 if (TRACE) Debug.stopMethodTracing(); 347 } 348 349 @Override 350 public boolean onCreateOptionsMenu(Menu menu) { 351 super.onCreateOptionsMenu(menu); 352 getSettings().addMenuItems(menu); 353 return true; 354 } 355 356 @Override 357 public boolean onPrepareOptionsMenu(Menu menu) { 358 super.onPrepareOptionsMenu(menu); 359 getSettings().updateMenuItems(menu); 360 return true; 361 } 362 363 @Override 364 public void onWindowFocusChanged(boolean hasFocus) { 365 super.onWindowFocusChanged(hasFocus); 366 if (hasFocus) { 367 // Launch the IME after a bit 368 mHandler.postDelayed(mShowInputMethodTask, 0); 369 } 370 } 371 372 protected String getQuery() { 373 return mSearchActivityView.getQuery(); 374 } 375 376 protected void setQuery(String query, boolean selectAll) { 377 mSearchActivityView.setQuery(query, selectAll); 378 } 379 380 public CorpusSelectionDialog getCorpusSelectionDialog() { 381 CorpusSelectionDialog dialog = createCorpusSelectionDialog(); 382 dialog.setOwnerActivity(this); 383 dialog.setOnDismissListener(new CorpusSelectorDismissListener()); 384 return dialog; 385 } 386 387 protected CorpusSelectionDialog createCorpusSelectionDialog() { 388 return new CorpusSelectionDialog(this, getSettings()); 389 } 390 391 /** 392 * @return true if a search was performed as a result of this click, false otherwise. 393 */ 394 protected boolean onSearchClicked(int method) { 395 String query = CharMatcher.WHITESPACE.trimAndCollapseFrom(getQuery(), ' '); 396 if (DBG) Log.d(TAG, "Search clicked, query=" + query); 397 398 // Don't do empty queries 399 if (TextUtils.getTrimmedLength(query) == 0) return false; 400 401 Corpus searchCorpus = getSearchCorpus(); 402 if (searchCorpus == null) return false; 403 404 mTookAction = true; 405 406 // Log search start 407 getLogger().logSearch(getCorpus(), method, query.length()); 408 409 // Create shortcut 410 SuggestionData searchShortcut = searchCorpus.createSearchShortcut(query); 411 if (searchShortcut != null) { 412 ListSuggestionCursor cursor = new ListSuggestionCursor(query); 413 cursor.add(searchShortcut); 414 getShortcutRepository().reportClick(cursor, 0); 415 } 416 417 // Start search 418 startSearch(searchCorpus, query); 419 return true; 420 } 421 422 protected void startSearch(Corpus searchCorpus, String query) { 423 Intent intent = searchCorpus.createSearchIntent(query, mAppSearchData); 424 launchIntent(intent); 425 } 426 427 protected void onVoiceSearchClicked() { 428 if (DBG) Log.d(TAG, "Voice Search clicked"); 429 Corpus searchCorpus = getSearchCorpus(); 430 if (searchCorpus == null) return; 431 432 mTookAction = true; 433 434 // Log voice search start 435 getLogger().logVoiceSearch(searchCorpus); 436 437 // Start voice search 438 Intent intent = searchCorpus.createVoiceSearchIntent(mAppSearchData); 439 launchIntent(intent); 440 } 441 442 protected void onSettingsClicked() { 443 startActivity(getSettings().getSearchSettingsIntent()); 444 } 445 446 protected Corpus getSearchCorpus() { 447 return mSearchActivityView.getSearchCorpus(); 448 } 449 450 protected SuggestionCursor getCurrentSuggestions() { 451 return mSearchActivityView.getCurrentSuggestions(); 452 } 453 454 protected SuggestionCursor getCurrentSuggestions(SuggestionsAdapter adapter, int position) { 455 SuggestionCursor suggestions = adapter.getCurrentSuggestions(); 456 if (suggestions == null) { 457 return null; 458 } 459 int count = suggestions.getCount(); 460 if (position < 0 || position >= count) { 461 Log.w(TAG, "Invalid suggestion position " + position + ", count = " + count); 462 return null; 463 } 464 suggestions.moveTo(position); 465 return suggestions; 466 } 467 468 protected Set<Corpus> getCurrentIncludedCorpora() { 469 Suggestions suggestions = mSearchActivityView.getSuggestions(); 470 return suggestions == null ? null : suggestions.getIncludedCorpora(); 471 } 472 473 protected void launchIntent(Intent intent) { 474 if (DBG) Log.d(TAG, "launchIntent " + intent); 475 if (intent == null) { 476 return; 477 } 478 try { 479 startActivity(intent); 480 if (!getConfig().keepSearchActivityInBackStack()) { 481 finish(); 482 } 483 } catch (RuntimeException ex) { 484 // Since the intents for suggestions specified by suggestion providers, 485 // guard against them not being handled, not allowed, etc. 486 Log.e(TAG, "Failed to start " + intent.toUri(0), ex); 487 } 488 } 489 490 private boolean launchSuggestion(SuggestionsAdapter adapter, int position) { 491 SuggestionCursor suggestions = getCurrentSuggestions(adapter, position); 492 if (suggestions == null) return false; 493 494 if (DBG) Log.d(TAG, "Launching suggestion " + position); 495 mTookAction = true; 496 497 // Log suggestion click 498 getLogger().logSuggestionClick(position, suggestions, getCurrentIncludedCorpora(), 499 Logger.SUGGESTION_CLICK_TYPE_LAUNCH); 500 501 // Create shortcut 502 getShortcutRepository().reportClick(suggestions, position); 503 504 // Launch intent 505 launchSuggestion(suggestions, position); 506 507 return true; 508 } 509 510 protected void launchSuggestion(SuggestionCursor suggestions, int position) { 511 suggestions.moveTo(position); 512 Intent intent = SuggestionUtils.getSuggestionIntent(suggestions, mAppSearchData); 513 launchIntent(intent); 514 } 515 516 protected void clickedQuickContact(SuggestionsAdapter adapter, int position) { 517 SuggestionCursor suggestions = getCurrentSuggestions(adapter, position); 518 if (suggestions == null) return; 519 520 if (DBG) Log.d(TAG, "Used suggestion " + position); 521 mTookAction = true; 522 523 // Log suggestion click 524 getLogger().logSuggestionClick(position, suggestions, getCurrentIncludedCorpora(), 525 Logger.SUGGESTION_CLICK_TYPE_QUICK_CONTACT); 526 527 // Create shortcut 528 getShortcutRepository().reportClick(suggestions, position); 529 } 530 531 protected void refineSuggestion(SuggestionsAdapter adapter, int position) { 532 if (DBG) Log.d(TAG, "query refine clicked, pos " + position); 533 SuggestionCursor suggestions = getCurrentSuggestions(adapter, position); 534 if (suggestions == null) { 535 return; 536 } 537 String query = suggestions.getSuggestionQuery(); 538 if (TextUtils.isEmpty(query)) { 539 return; 540 } 541 542 // Log refine click 543 getLogger().logSuggestionClick(position, suggestions, getCurrentIncludedCorpora(), 544 Logger.SUGGESTION_CLICK_TYPE_REFINE); 545 546 // Put query + space in query text view 547 String queryWithSpace = query + ' '; 548 setQuery(queryWithSpace, false); 549 updateSuggestions(); 550 mSearchActivityView.focusQueryTextView(); 551 } 552 553 protected boolean onSuggestionLongClicked(SuggestionsAdapter adapter, int position) { 554 if (DBG) Log.d(TAG, "Long clicked on suggestion " + position); 555 return false; 556 } 557 558 private void updateSuggestionsBuffered() { 559 mHandler.removeCallbacks(mUpdateSuggestionsTask); 560 long delay = getConfig().getTypingUpdateSuggestionsDelayMillis(); 561 mHandler.postDelayed(mUpdateSuggestionsTask, delay); 562 } 563 564 private void gotSuggestions(Suggestions suggestions) { 565 if (mStarting) { 566 mStarting = false; 567 String source = getIntent().getStringExtra(Search.SOURCE); 568 int latency = mStartLatencyTracker.getLatency(); 569 getLogger().logStart(latency, source, getCorpus(), 570 suggestions == null ? null : suggestions.getExpectedCorpora()); 571 getQsbApplication().onStartupComplete(); 572 } 573 } 574 575 private void getCorporaToQuery(Consumer<List<Corpus>> consumer) { 576 Corpus corpus = getCorpus(); 577 if (corpus == null) { 578 getCorpusRanker().getCorporaInAll(Consumers.createAsyncConsumer(mHandler, consumer)); 579 } else { 580 List<Corpus> corpora = new ArrayList<Corpus>(); 581 Corpus searchCorpus = getSearchCorpus(); 582 if (searchCorpus != null) corpora.add(searchCorpus); 583 consumer.consume(corpora); 584 } 585 } 586 587 protected void getShortcutsForQuery(String query, Collection<Corpus> corporaToQuery, 588 final Suggestions suggestions) { 589 ShortcutRepository shortcutRepo = getShortcutRepository(); 590 if (shortcutRepo == null) return; 591 if (query.length() == 0 && !getConfig().showShortcutsForZeroQuery()) { 592 return; 593 } 594 Consumer<ShortcutCursor> consumer = Consumers.createAsyncCloseableConsumer(mHandler, 595 new Consumer<ShortcutCursor>() { 596 public boolean consume(ShortcutCursor shortcuts) { 597 suggestions.setShortcuts(shortcuts); 598 return true; 599 } 600 }); 601 shortcutRepo.getShortcutsForQuery(query, corporaToQuery, consumer); 602 } 603 604 public void updateSuggestions() { 605 updateSuggestions(getQuery()); 606 } 607 608 public void updateSuggestions(String untrimmedQuery) { 609 final String query = CharMatcher.WHITESPACE.trimLeadingFrom(untrimmedQuery); 610 if (DBG) Log.d(TAG, "updateSuggestions(\"" + query+"\"," + getCorpus() + ")"); 611 getQsbApplication().getSourceTaskExecutor().cancelPendingTasks(); 612 getCorporaToQuery(new Consumer<List<Corpus>>(){ 613 @Override 614 public boolean consume(List<Corpus> corporaToQuery) { 615 updateSuggestions(query, corporaToQuery); 616 return true; 617 } 618 }); 619 } 620 621 protected void updateSuggestions(String query, List<Corpus> corporaToQuery) { 622 Suggestions suggestions = getSuggestionsProvider().getSuggestions( 623 query, corporaToQuery); 624 getShortcutsForQuery(query, corporaToQuery, suggestions); 625 626 // Log start latency if this is the first suggestions update 627 gotSuggestions(suggestions); 628 629 showSuggestions(suggestions); 630 } 631 632 protected void showSuggestions(Suggestions suggestions) { 633 mSearchActivityView.setSuggestions(suggestions); 634 } 635 636 private class ClickHandler implements SuggestionClickListener { 637 638 public void onSuggestionQuickContactClicked(SuggestionsAdapter adapter, int position) { 639 clickedQuickContact(adapter, position); 640 } 641 642 public void onSuggestionClicked(SuggestionsAdapter adapter, int position) { 643 launchSuggestion(adapter, position); 644 } 645 646 public boolean onSuggestionLongClicked(SuggestionsAdapter adapter, int position) { 647 return SearchActivity.this.onSuggestionLongClicked(adapter, position); 648 } 649 650 public void onSuggestionQueryRefineClicked(SuggestionsAdapter adapter, int position) { 651 refineSuggestion(adapter, position); 652 } 653 } 654 655 private class CorpusSelectorDismissListener implements DialogInterface.OnDismissListener { 656 public void onDismiss(DialogInterface dialog) { 657 if (DBG) Log.d(TAG, "Corpus selector dismissed"); 658 clearStartedIntoCorpusSelectionDialog(); 659 } 660 } 661 662 private class CorporaObserver extends DataSetObserver { 663 @Override 664 public void onChanged() { 665 setCorpus(getCorpusName()); 666 updateSuggestions(); 667 } 668 } 669 670 public interface OnDestroyListener { 671 void onDestroyed(); 672 } 673 674} 675