SearchActivity.java revision 7010c51b51c97fa43d7b24d2158ecbc1d064e0a6
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 setContentView(); 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 void setContentView() { 158 setContentView(R.layout.search_activity); 159 mSearchActivityView = (SearchActivityView) findViewById(R.id.search_activity_view); 160 } 161 162 private void startMethodTracing() { 163 File traceDir = getDir("traces", 0); 164 String traceFile = new File(traceDir, "qsb.trace").getAbsolutePath(); 165 Debug.startMethodTracing(traceFile); 166 } 167 168 @Override 169 protected void onNewIntent(Intent intent) { 170 if (DBG) Log.d(TAG, "onNewIntent()"); 171 recordStartTime(); 172 setIntent(intent); 173 setupFromIntent(intent); 174 } 175 176 private void recordStartTime() { 177 mStartLatencyTracker = new LatencyTracker(); 178 mStarting = true; 179 mTookAction = false; 180 } 181 182 protected void restoreInstanceState(Bundle savedInstanceState) { 183 if (savedInstanceState == null) return; 184 String corpusName = savedInstanceState.getString(INSTANCE_KEY_CORPUS); 185 String query = savedInstanceState.getString(INSTANCE_KEY_QUERY); 186 setCorpus(corpusName); 187 setQuery(query, false); 188 } 189 190 @Override 191 protected void onSaveInstanceState(Bundle outState) { 192 super.onSaveInstanceState(outState); 193 // We don't save appSearchData, since we always get the value 194 // from the intent and the user can't change it. 195 196 outState.putString(INSTANCE_KEY_CORPUS, getCorpusName()); 197 outState.putString(INSTANCE_KEY_QUERY, getQuery()); 198 } 199 200 private void setupFromIntent(Intent intent) { 201 if (DBG) Log.d(TAG, "setupFromIntent(" + intent.toUri(0) + ")"); 202 String corpusName = getCorpusNameFromUri(intent.getData()); 203 String query = intent.getStringExtra(SearchManager.QUERY); 204 Bundle appSearchData = intent.getBundleExtra(SearchManager.APP_DATA); 205 boolean selectAll = intent.getBooleanExtra(SearchManager.EXTRA_SELECT_QUERY, false); 206 207 setCorpus(corpusName); 208 setQuery(query, selectAll); 209 mAppSearchData = appSearchData; 210 211 if (startedIntoCorpusSelectionDialog()) { 212 showCorpusSelectionDialog(); 213 } 214 } 215 216 public boolean startedIntoCorpusSelectionDialog() { 217 return INTENT_ACTION_QSB_AND_SELECT_CORPUS.equals(getIntent().getAction()); 218 } 219 220 /** 221 * Removes corpus selector intent action, so that BACK works normally after 222 * dismissing and reopening the corpus selector. 223 */ 224 private void clearStartedIntoCorpusSelectionDialog() { 225 Intent oldIntent = getIntent(); 226 if (SearchActivity.INTENT_ACTION_QSB_AND_SELECT_CORPUS.equals(oldIntent.getAction())) { 227 Intent newIntent = new Intent(oldIntent); 228 newIntent.setAction(SearchManager.INTENT_ACTION_GLOBAL_SEARCH); 229 setIntent(newIntent); 230 } 231 } 232 233 public static Uri getCorpusUri(Corpus corpus) { 234 if (corpus == null) return null; 235 return new Uri.Builder() 236 .scheme(SCHEME_CORPUS) 237 .authority(corpus.getName()) 238 .build(); 239 } 240 241 private String getCorpusNameFromUri(Uri uri) { 242 if (uri == null) return null; 243 if (!SCHEME_CORPUS.equals(uri.getScheme())) return null; 244 return uri.getAuthority(); 245 } 246 247 private Corpus getCorpus(String sourceName) { 248 if (sourceName == null) return null; 249 Corpus corpus = getCorpora().getCorpus(sourceName); 250 if (corpus == null) { 251 Log.w(TAG, "Unknown corpus " + sourceName); 252 return null; 253 } 254 return corpus; 255 } 256 257 private void setCorpus(String corpusName) { 258 if (DBG) Log.d(TAG, "setCorpus(" + corpusName + ")"); 259 mCorpus = getCorpus(corpusName); 260 261 mSearchActivityView.setCorpus(mCorpus); 262 } 263 264 private String getCorpusName() { 265 return mCorpus == null ? null : mCorpus.getName(); 266 } 267 268 private QsbApplication getQsbApplication() { 269 return QsbApplication.get(this); 270 } 271 272 private Config getConfig() { 273 return getQsbApplication().getConfig(); 274 } 275 276 private Corpora getCorpora() { 277 return getQsbApplication().getCorpora(); 278 } 279 280 private CorpusRanker getCorpusRanker() { 281 return getQsbApplication().getCorpusRanker(); 282 } 283 284 private ShortcutRepository getShortcutRepository() { 285 return getQsbApplication().getShortcutRepository(); 286 } 287 288 private SuggestionsProvider getSuggestionsProvider() { 289 return getQsbApplication().getSuggestionsProvider(); 290 } 291 292 private Logger getLogger() { 293 return getQsbApplication().getLogger(); 294 } 295 296 @VisibleForTesting 297 public void setOnDestroyListener(OnDestroyListener l) { 298 mDestroyListener = l; 299 } 300 301 @Override 302 protected void onDestroy() { 303 if (DBG) Log.d(TAG, "onDestroy()"); 304 getCorpora().unregisterDataSetObserver(mCorporaObserver); 305 mSearchActivityView.destroy(); 306 super.onDestroy(); 307 if (mDestroyListener != null) { 308 mDestroyListener.onDestroyed(); 309 } 310 } 311 312 @Override 313 protected void onStop() { 314 if (DBG) Log.d(TAG, "onStop()"); 315 if (!mTookAction) { 316 // TODO: This gets logged when starting other activities, e.g. by opening the search 317 // settings, or clicking a notification in the status bar. 318 // TODO we should log both sets of suggestions in 2-pane mode 319 getLogger().logExit(getCurrentSuggestions(), getQuery().length()); 320 } 321 // Close all open suggestion cursors. The query will be redone in onResume() 322 // if we come back to this activity. 323 mSearchActivityView.clearSuggestions(); 324 getQsbApplication().getShortcutRefresher().reset(); 325 dismissCorpusSelectionDialog(); 326 super.onStop(); 327 } 328 329 @Override 330 protected void onRestart() { 331 if (DBG) Log.d(TAG, "onRestart()"); 332 super.onRestart(); 333 } 334 335 @Override 336 protected void onResume() { 337 if (DBG) Log.d(TAG, "onResume()"); 338 super.onResume(); 339 updateSuggestionsBuffered(); 340 if (!isCorpusSelectionDialogShowing()) { 341 mSearchActivityView.focusQueryTextView(); 342 } 343 if (TRACE) Debug.stopMethodTracing(); 344 } 345 346 @Override 347 public boolean onCreateOptionsMenu(Menu menu) { 348 super.onCreateOptionsMenu(menu); 349 SearchSettings.addSearchSettingsMenuItem(this, menu); 350 return true; 351 } 352 353 @Override 354 public void onWindowFocusChanged(boolean hasFocus) { 355 super.onWindowFocusChanged(hasFocus); 356 if (hasFocus) { 357 // Launch the IME after a bit 358 mHandler.postDelayed(mShowInputMethodTask, 0); 359 } 360 } 361 362 protected String getQuery() { 363 return mSearchActivityView.getQuery(); 364 } 365 366 protected void setQuery(String query, boolean selectAll) { 367 mSearchActivityView.setQuery(query, selectAll); 368 } 369 370 protected void showCorpusSelectionDialog() { 371 if (mCorpusSelectionDialog == null) { 372 mCorpusSelectionDialog = createCorpusSelectionDialog(); 373 mCorpusSelectionDialog.setOwnerActivity(this); 374 mCorpusSelectionDialog.setOnDismissListener(new CorpusSelectorDismissListener()); 375 mCorpusSelectionDialog.setOnCorpusSelectedListener(new CorpusSelectionListener()); 376 } 377 mCorpusSelectionDialog.show(mCorpus); 378 } 379 380 protected CorpusSelectionDialog createCorpusSelectionDialog() { 381 return new CorpusSelectionDialog(this); 382 } 383 384 protected boolean isCorpusSelectionDialogShowing() { 385 return mCorpusSelectionDialog != null && mCorpusSelectionDialog.isShowing(); 386 } 387 388 protected void dismissCorpusSelectionDialog() { 389 if (mCorpusSelectionDialog != null) { 390 mCorpusSelectionDialog.dismiss(); 391 } 392 } 393 394 /** 395 * @return true if a search was performed as a result of this click, false otherwise. 396 */ 397 protected boolean onSearchClicked(int method) { 398 String query = CharMatcher.WHITESPACE.trimAndCollapseFrom(getQuery(), ' '); 399 if (DBG) Log.d(TAG, "Search clicked, query=" + query); 400 401 // Don't do empty queries 402 if (TextUtils.getTrimmedLength(query) == 0) return false; 403 404 Corpus searchCorpus = getSearchCorpus(); 405 if (searchCorpus == null) return false; 406 407 mTookAction = true; 408 409 // Log search start 410 getLogger().logSearch(mCorpus, method, query.length()); 411 412 // Create shortcut 413 SuggestionData searchShortcut = searchCorpus.createSearchShortcut(query); 414 if (searchShortcut != null) { 415 ListSuggestionCursor cursor = new ListSuggestionCursor(query); 416 cursor.add(searchShortcut); 417 getShortcutRepository().reportClick(cursor, 0); 418 } 419 420 // Start search 421 Intent intent = searchCorpus.createSearchIntent(query, mAppSearchData); 422 launchIntent(intent); 423 return true; 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 SearchSettings.launchSettings(this); 443 } 444 445 protected Corpus getSearchCorpus() { 446 return mSearchActivityView.getSearchCorpus(); 447 } 448 449 @Deprecated 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 (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 suggestions.moveTo(position); 502 Intent intent = SuggestionUtils.getSuggestionIntent(suggestions, mAppSearchData); 503 launchIntent(intent); 504 505 return true; 506 } 507 508 protected void clickedQuickContact(SuggestionsAdapter adapter, int position) { 509 SuggestionCursor suggestions = getCurrentSuggestions(adapter, position); 510 if (suggestions == null) return; 511 512 if (DBG) Log.d(TAG, "Used suggestion " + position); 513 mTookAction = true; 514 515 // Log suggestion click 516 getLogger().logSuggestionClick(position, suggestions, getCurrentIncludedCorpora(), 517 Logger.SUGGESTION_CLICK_TYPE_QUICK_CONTACT); 518 519 // Create shortcut 520 getShortcutRepository().reportClick(suggestions, position); 521 } 522 523 protected void refineSuggestion(SuggestionsAdapter adapter, int position) { 524 if (DBG) Log.d(TAG, "query refine clicked, pos " + position); 525 SuggestionCursor suggestions = getCurrentSuggestions(adapter, position); 526 if (suggestions == null) { 527 return; 528 } 529 String query = suggestions.getSuggestionQuery(); 530 if (TextUtils.isEmpty(query)) { 531 return; 532 } 533 534 // Log refine click 535 getLogger().logSuggestionClick(position, suggestions, getCurrentIncludedCorpora(), 536 Logger.SUGGESTION_CLICK_TYPE_REFINE); 537 538 // Put query + space in query text view 539 String queryWithSpace = query + ' '; 540 setQuery(queryWithSpace, false); 541 updateSuggestions(queryWithSpace); 542 mSearchActivityView.focusQueryTextView(); 543 } 544 545 protected boolean onSuggestionLongClicked(SuggestionsAdapter adapter, int position) { 546 if (DBG) Log.d(TAG, "Long clicked on suggestion " + position); 547 return false; 548 } 549 550 private void updateSuggestionsBuffered() { 551 mHandler.removeCallbacks(mUpdateSuggestionsTask); 552 long delay = getConfig().getTypingUpdateSuggestionsDelayMillis(); 553 mHandler.postDelayed(mUpdateSuggestionsTask, delay); 554 } 555 556 private void gotSuggestions(Suggestions suggestions) { 557 if (mStarting) { 558 mStarting = false; 559 String source = getIntent().getStringExtra(Search.SOURCE); 560 int latency = mStartLatencyTracker.getLatency(); 561 getLogger().logStart(latency, source, mCorpus, 562 suggestions == null ? null : suggestions.getExpectedCorpora()); 563 getQsbApplication().onStartupComplete(); 564 } 565 } 566 567 private void getCorporaToQuery(Consumer<List<Corpus>> consumer) { 568 if (mCorpus == null) { 569 // No corpus selected, use all enabled corpora 570 // TODO: This should be done asynchronously, since it can be expensive 571 getCorpusRanker().getRankedCorpora(Consumers.createAsyncConsumer(mHandler, consumer)); 572 } else { 573 List<Corpus> corpora = new ArrayList<Corpus>(); 574 Corpus searchCorpus = getSearchCorpus(); 575 // Query the selected corpus, and also the search corpus if it' 576 // different (= web corpus). 577 if (searchCorpus != null) corpora.add(searchCorpus); 578 if (mCorpus != searchCorpus) corpora.add(mCorpus); 579 consumer.consume(corpora); 580 } 581 } 582 583 protected void getShortcutsForQuery(String query, Collection<Corpus> corporaToQuery, 584 final Suggestions suggestions) { 585 ShortcutRepository shortcutRepo = getShortcutRepository(); 586 if (shortcutRepo == null) return; 587 Consumer<ShortcutCursor> consumer = Consumers.createAsyncCloseableConsumer(mHandler, 588 new Consumer<ShortcutCursor>() { 589 public boolean consume(ShortcutCursor shortcuts) { 590 suggestions.setShortcuts(shortcuts); 591 return true; 592 } 593 }); 594 shortcutRepo.getShortcutsForQuery(query, corporaToQuery, consumer); 595 } 596 597 protected void updateSuggestions(String untrimmedQuery) { 598 final String query = CharMatcher.WHITESPACE.trimLeadingFrom(untrimmedQuery); 599 if (DBG) Log.d(TAG, "getSuggestions(\""+query+"\","+mCorpus + ")"); 600 getQsbApplication().getSourceTaskExecutor().cancelPendingTasks(); 601 getCorporaToQuery(new Consumer<List<Corpus>>(){ 602 @Override 603 public boolean consume(List<Corpus> corporaToQuery) { 604 updateSuggestions(query, corporaToQuery); 605 return true; 606 } 607 }); 608 } 609 610 protected void updateSuggestions(String query, List<Corpus> corporaToQuery) { 611 Suggestions suggestions = getSuggestionsProvider().getSuggestions( 612 query, corporaToQuery); 613 getShortcutsForQuery(query, corporaToQuery, suggestions); 614 615 // Log start latency if this is the first suggestions update 616 gotSuggestions(suggestions); 617 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 CorpusSelectionListener 648 implements CorpusSelectionDialog.OnCorpusSelectedListener { 649 public void onCorpusSelected(String corpusName) { 650 setCorpus(corpusName); 651 updateSuggestions(getQuery()); 652 mSearchActivityView.focusQueryTextView(); 653 mSearchActivityView.showInputMethodForQuery(); 654 } 655 } 656 657 private class CorporaObserver extends DataSetObserver { 658 @Override 659 public void onChanged() { 660 setCorpus(getCorpusName()); 661 updateSuggestions(getQuery()); 662 } 663 } 664 665 public interface OnDestroyListener { 666 void onDestroyed(); 667 } 668 669} 670