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