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