SearchActivity.java revision 9d731c0bae275ee9b1c87b679820259c18cc68c8
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 (intent == null) { 449 return; 450 } 451 try { 452 startActivity(intent); 453 } catch (RuntimeException ex) { 454 // Since the intents for suggestions specified by suggestion providers, 455 // guard against them not being handled, not allowed, etc. 456 Log.e(TAG, "Failed to start " + intent.toUri(0), ex); 457 } 458 } 459 460 private boolean launchSuggestion(SuggestionsAdapter adapter, int position) { 461 SuggestionCursor suggestions = getCurrentSuggestions(adapter, position); 462 if (suggestions == null) return false; 463 464 if (DBG) Log.d(TAG, "Launching suggestion " + position); 465 mTookAction = true; 466 467 // Log suggestion click 468 getLogger().logSuggestionClick(position, suggestions, getCurrentIncludedCorpora(), 469 Logger.SUGGESTION_CLICK_TYPE_LAUNCH); 470 471 // Create shortcut 472 getShortcutRepository().reportClick(suggestions, position); 473 474 // Launch intent 475 launchSuggestion(suggestions, position); 476 477 return true; 478 } 479 480 protected void launchSuggestion(SuggestionCursor suggestions, int position) { 481 suggestions.moveTo(position); 482 Intent intent = SuggestionUtils.getSuggestionIntent(suggestions, mAppSearchData); 483 launchIntent(intent); 484 } 485 486 protected void clickedQuickContact(SuggestionsAdapter adapter, int position) { 487 SuggestionCursor suggestions = getCurrentSuggestions(adapter, position); 488 if (suggestions == null) return; 489 490 if (DBG) Log.d(TAG, "Used suggestion " + position); 491 mTookAction = true; 492 493 // Log suggestion click 494 getLogger().logSuggestionClick(position, suggestions, getCurrentIncludedCorpora(), 495 Logger.SUGGESTION_CLICK_TYPE_QUICK_CONTACT); 496 497 // Create shortcut 498 getShortcutRepository().reportClick(suggestions, position); 499 } 500 501 protected void refineSuggestion(SuggestionsAdapter adapter, int position) { 502 if (DBG) Log.d(TAG, "query refine clicked, pos " + position); 503 SuggestionCursor suggestions = getCurrentSuggestions(adapter, position); 504 if (suggestions == null) { 505 return; 506 } 507 String query = suggestions.getSuggestionQuery(); 508 if (TextUtils.isEmpty(query)) { 509 return; 510 } 511 512 // Log refine click 513 getLogger().logSuggestionClick(position, suggestions, getCurrentIncludedCorpora(), 514 Logger.SUGGESTION_CLICK_TYPE_REFINE); 515 516 // Put query + space in query text view 517 String queryWithSpace = query + ' '; 518 setQuery(queryWithSpace, false); 519 updateSuggestions(queryWithSpace); 520 mSearchActivityView.focusQueryTextView(); 521 } 522 523 protected boolean onSuggestionLongClicked(SuggestionsAdapter adapter, int position) { 524 if (DBG) Log.d(TAG, "Long clicked on suggestion " + position); 525 return false; 526 } 527 528 private void updateSuggestionsBuffered() { 529 mHandler.removeCallbacks(mUpdateSuggestionsTask); 530 long delay = getConfig().getTypingUpdateSuggestionsDelayMillis(); 531 mHandler.postDelayed(mUpdateSuggestionsTask, delay); 532 } 533 534 private void gotSuggestions(Suggestions suggestions) { 535 if (mStarting) { 536 mStarting = false; 537 String source = getIntent().getStringExtra(Search.SOURCE); 538 int latency = mStartLatencyTracker.getLatency(); 539 getLogger().logStart(latency, source, getCorpus(), 540 suggestions == null ? null : suggestions.getExpectedCorpora()); 541 getQsbApplication().onStartupComplete(); 542 } 543 } 544 545 private void getCorporaToQuery(Consumer<List<Corpus>> consumer) { 546 Corpus corpus = getCorpus(); 547 if (corpus == null) { 548 // No corpus selected, use all enabled corpora 549 getCorpusRanker().getRankedCorpora(Consumers.createAsyncConsumer(mHandler, consumer)); 550 } else { 551 List<Corpus> corpora = new ArrayList<Corpus>(); 552 Corpus searchCorpus = getSearchCorpus(); 553 // Query the selected corpus, and also the search corpus if it' 554 // different (= web corpus). 555 if (searchCorpus != null) corpora.add(searchCorpus); 556 if (corpus != searchCorpus) corpora.add(corpus); 557 consumer.consume(corpora); 558 } 559 } 560 561 protected void getShortcutsForQuery(String query, Collection<Corpus> corporaToQuery, 562 final Suggestions suggestions) { 563 ShortcutRepository shortcutRepo = getShortcutRepository(); 564 if (shortcutRepo == null) return; 565 Consumer<ShortcutCursor> consumer = Consumers.createAsyncCloseableConsumer(mHandler, 566 new Consumer<ShortcutCursor>() { 567 public boolean consume(ShortcutCursor shortcuts) { 568 suggestions.setShortcuts(shortcuts); 569 return true; 570 } 571 }); 572 shortcutRepo.getShortcutsForQuery(query, corporaToQuery, consumer); 573 } 574 575 public void updateSuggestions(String untrimmedQuery) { 576 final String query = CharMatcher.WHITESPACE.trimLeadingFrom(untrimmedQuery); 577 if (DBG) Log.d(TAG, "getSuggestions(\"" + query+"\"," + getCorpus() + ")"); 578 getQsbApplication().getSourceTaskExecutor().cancelPendingTasks(); 579 getCorporaToQuery(new Consumer<List<Corpus>>(){ 580 @Override 581 public boolean consume(List<Corpus> corporaToQuery) { 582 updateSuggestions(query, corporaToQuery); 583 return true; 584 } 585 }); 586 } 587 588 protected void updateSuggestions(String query, List<Corpus> corporaToQuery) { 589 Suggestions suggestions = getSuggestionsProvider().getSuggestions( 590 query, corporaToQuery); 591 getShortcutsForQuery(query, corporaToQuery, suggestions); 592 593 // Log start latency if this is the first suggestions update 594 gotSuggestions(suggestions); 595 596 showSuggestions(suggestions); 597 } 598 599 protected void showSuggestions(Suggestions suggestions) { 600 mSearchActivityView.setSuggestions(suggestions); 601 } 602 603 private class ClickHandler implements SuggestionClickListener { 604 605 public void onSuggestionQuickContactClicked(SuggestionsAdapter adapter, int position) { 606 clickedQuickContact(adapter, position); 607 } 608 609 public void onSuggestionClicked(SuggestionsAdapter adapter, int position) { 610 launchSuggestion(adapter, position); 611 } 612 613 public boolean onSuggestionLongClicked(SuggestionsAdapter adapter, int position) { 614 return SearchActivity.this.onSuggestionLongClicked(adapter, position); 615 } 616 617 public void onSuggestionQueryRefineClicked(SuggestionsAdapter adapter, int position) { 618 refineSuggestion(adapter, position); 619 } 620 } 621 622 private class CorpusSelectorDismissListener implements DialogInterface.OnDismissListener { 623 public void onDismiss(DialogInterface dialog) { 624 if (DBG) Log.d(TAG, "Corpus selector dismissed"); 625 clearStartedIntoCorpusSelectionDialog(); 626 } 627 } 628 629 private class CorporaObserver extends DataSetObserver { 630 @Override 631 public void onChanged() { 632 setCorpus(getCorpusName()); 633 updateSuggestions(getQuery()); 634 } 635 } 636 637 public interface OnDestroyListener { 638 void onDestroyed(); 639 } 640 641} 642