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