SearchActivity.java revision 9ad03a750a66b26441a19ff54b6057729c145eae
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.quicksearchbox.ui.SearchSourceSelector; 20import com.android.quicksearchbox.ui.SuggestionClickListener; 21import com.android.quicksearchbox.ui.SuggestionViewFactory; 22import com.android.quicksearchbox.ui.SuggestionsAdapter; 23import com.android.quicksearchbox.ui.SuggestionsView; 24 25import android.app.Activity; 26import android.app.SearchManager; 27import android.content.ComponentName; 28import android.content.Intent; 29import android.database.DataSetObserver; 30import android.graphics.Rect; 31import android.graphics.drawable.Animatable; 32import android.graphics.drawable.Drawable; 33import android.os.Bundle; 34import android.text.Editable; 35import android.text.TextUtils; 36import android.text.TextWatcher; 37import android.util.Log; 38import android.view.KeyEvent; 39import android.view.Menu; 40import android.view.View; 41import android.view.View.OnFocusChangeListener; 42import android.view.inputmethod.InputMethodManager; 43import android.widget.EditText; 44import android.widget.ImageButton; 45 46// TODO: restore user query when using dpad to move up from top suggestion 47// TODO: nicer progress animation. one per source? 48// TODO: handle long clicks 49// TODO: support action keys 50// TODO: support IME search action 51// TODO: allow typing everywhere in the UI 52 53// TODO: don't show new results until there is at least one, or it's done 54// TODO: add timeout for source queries 55// TODO: group refreshes that happen close to each other. 56// TODO: use queryAfterZeroResults() 57 58// TODO: use permission to get extended Genie suggestions 59// TODO: support intent extras for source (e.g. launcher widget) 60// TODO: support intent extras for initial state, e.g. query, selection 61// TODO: make Config server-side configurable 62// TODO: log impressions 63// TODO: use source ranking 64 65/** 66 * The main activity for Quick Search Box. Shows the search UI. 67 * 68 */ 69public class SearchActivity extends Activity { 70 71 private static final boolean DBG = true; 72 private static final String TAG = "SearchActivity"; 73 74 // TODO: This is hidden in SearchManager 75 public final static String INTENT_ACTION_SEARCH_SETTINGS 76 = "android.search.action.SEARCH_SETTINGS"; 77 78 public static final String EXTRA_KEY_SEARCH_SOURCE 79 = "search_source"; 80 81 // Keys for the saved instance state. 82 private static final String INSTANCE_KEY_SOURCE = "source"; 83 private static final String INSTANCE_KEY_USER_QUERY = "query"; 84 85 protected SuggestionsAdapter mSuggestionsAdapter; 86 87 protected EditText mQueryTextView; 88 89 protected SuggestionsView mSuggestionsView; 90 91 protected ImageButton mSearchGoButton; 92 protected ImageButton mVoiceSearchButton; 93 protected SearchSourceSelector mSourceSelector; 94 95 private Launcher mLauncher; 96 97 private ComponentName mSourceName; 98 private boolean mUpdateSuggestions; 99 private String mUserQuery; 100 private boolean mSelectAll; 101 102 /** Called when the activity is first created. */ 103 @Override 104 public void onCreate(Bundle savedInstanceState) { 105 if (DBG) Log.d(TAG, "onCreate()"); 106 super.onCreate(savedInstanceState); 107 108 setContentView(R.layout.search_bar); 109 110 Config config = getConfig(); 111 SuggestionViewFactory viewFactory = getSuggestionViewFactory(); 112 mSuggestionsAdapter = new SuggestionsAdapter(viewFactory); 113 mSuggestionsAdapter 114 .setInitialSourceResultWaitMillis(config.getInitialSourceResultWaitMillis()); 115 mSuggestionsAdapter 116 .setSourceResultPublishDelayMillis(config.getSourceResultPublishDelayMillis()); 117 118 mQueryTextView = (EditText) findViewById(R.id.search_src_text); 119 mSuggestionsView = (SuggestionsView) findViewById(R.id.suggestions); 120 mSuggestionsView.setSuggestionClickListener(new ClickHandler()); 121 mSuggestionsView.setInteractionListener(new InputMethodCloser()); 122 mSuggestionsView.setOnKeyListener(new SuggestionsViewKeyListener()); 123 124 mSearchGoButton = (ImageButton) findViewById(R.id.search_go_btn); 125 mVoiceSearchButton = (ImageButton) findViewById(R.id.search_voice_btn); 126 mSourceSelector = new SearchSourceSelector(findViewById(R.id.search_source_selector)); 127 128 mLauncher = new Launcher(this); 129 mVoiceSearchButton.setVisibility( 130 mLauncher.isVoiceSearchAvailable() ? View.VISIBLE : View.GONE); 131 132 mQueryTextView.addTextChangedListener(new SearchTextWatcher()); 133 mQueryTextView.setOnKeyListener(new QueryTextViewKeyListener()); 134 mQueryTextView.setOnFocusChangeListener(new SuggestListFocusListener()); 135 136 mSearchGoButton.setOnClickListener(new SearchGoButtonClickListener()); 137 mSearchGoButton.setOnKeyListener(new ButtonsKeyListener()); 138 139 mVoiceSearchButton.setOnClickListener(new VoiceSearchButtonClickListener()); 140 mVoiceSearchButton.setOnKeyListener(new ButtonsKeyListener()); 141 142 mUpdateSuggestions = true; 143 144 // First get setup from intent 145 Intent intent = getIntent(); 146 setupFromIntent(intent); 147 // Then restore any saved instance state 148 restoreInstanceState(savedInstanceState); 149 150 // Do this at the end, to avoid updating the list view when setSource() 151 // is called. 152 mSuggestionsView.setAdapter(mSuggestionsAdapter); 153 } 154 155 @Override 156 protected void onNewIntent(Intent intent) { 157 setIntent(intent); 158 setupFromIntent(intent); 159 } 160 161 protected void restoreInstanceState(Bundle savedInstanceState) { 162 if (savedInstanceState == null) return; 163 ComponentName sourceName = savedInstanceState.getParcelable(INSTANCE_KEY_SOURCE); 164 String query = savedInstanceState.getString(INSTANCE_KEY_USER_QUERY); 165 setSource(getSourceByComponentName(sourceName)); 166 setUserQuery(query); 167 } 168 169 @Override 170 protected void onSaveInstanceState(Bundle outState) { 171 super.onSaveInstanceState(outState); 172 // We don't save appSearchData, since we always get the value 173 // from the intent and the user can't change it. 174 outState.putParcelable(INSTANCE_KEY_SOURCE, mSourceName); 175 outState.putString(INSTANCE_KEY_USER_QUERY, mUserQuery); 176 } 177 178 private void setupFromIntent(Intent intent) { 179 if (DBG) Log.d(TAG, "setupFromIntent(" + intent.toUri(0) + ")"); 180 Source source = getSourceByName(intent.getStringExtra(EXTRA_KEY_SEARCH_SOURCE)); 181 setSource(source); 182 // TODO: Should this be SearchManager.INITIAL_QUERY? 183 setUserQuery(intent.getStringExtra(SearchManager.QUERY)); 184 // TODO: Expose SearchManager.SELECT_INITIAL_QUERY 185 mSelectAll = false; 186 setAppSearchData(intent.getBundleExtra(SearchManager.APP_DATA)); 187 } 188 189 private Source getSourceByName(String sourceNameStr) { 190 if (sourceNameStr == null) return null; 191 ComponentName sourceName = ComponentName.unflattenFromString(sourceNameStr); 192 if (sourceName == null) { 193 Log.w(TAG, "Malformed source name: " + sourceName); 194 return null; 195 } 196 return getSourceByComponentName(sourceName); 197 } 198 199 private Source getSourceByComponentName(ComponentName sourceName) { 200 Source source = getSources().getSourceByComponentName(sourceName); 201 if (source == null) { 202 Log.w(TAG, "Unknown source " + sourceName); 203 return null; 204 } 205 return source; 206 } 207 208 private void setSource(Source source) { 209 mSourceName = source == null ? null : source.getComponentName(); 210 Drawable sourceIcon; 211 if (source == null) { 212 sourceIcon = getSuggestionViewFactory().getGlobalSearchIcon(); 213 } else { 214 sourceIcon = source.getSourceIcon(); 215 } 216 mSuggestionsAdapter.setSource(mSourceName); 217 mSourceSelector.setSource(mSourceName); 218 mSourceSelector.setSourceIcon(sourceIcon); 219 } 220 221 private QsbApplication getQsbApplication() { 222 return (QsbApplication) getApplication(); 223 } 224 225 private Config getConfig() { 226 return getQsbApplication().getConfig(); 227 } 228 229 private SourceLookup getSources() { 230 return getQsbApplication().getSources(); 231 } 232 233 private ShortcutRepository getShortcutRepository() { 234 return getQsbApplication().getShortcutRepository(); 235 } 236 237 private SuggestionsProvider getSuggestionsProvider() { 238 return getQsbApplication().getSuggestionsProvider(); 239 } 240 241 private SuggestionViewFactory getSuggestionViewFactory() { 242 return getQsbApplication().getSuggestionViewFactory(); 243 } 244 245 @Override 246 protected void onDestroy() { 247 if (DBG) Log.d(TAG, "onDestroy()"); 248 super.onDestroy(); 249 mSuggestionsView.setAdapter(null); // closes mSuggestionsAdapter 250 } 251 252 @Override 253 protected void onStop() { 254 if (DBG) Log.d(TAG, "onStop()"); 255 // Close all open suggestion cursors. The query will be redone in onResume() 256 // if we come back to this activity. 257 mSuggestionsAdapter.setSuggestions(null); 258 super.onStop(); 259 } 260 261 @Override 262 protected void onResume() { 263 if (DBG) Log.d(TAG, "onResume()"); 264 super.onResume(); 265 setQuery(mUserQuery, mSelectAll); 266 // Only select everything the first time after creating the activity. 267 mSelectAll = false; 268 updateSuggestions(mUserQuery); 269 mQueryTextView.requestFocus(); 270 } 271 272 @Override 273 public boolean onCreateOptionsMenu(Menu menu) { 274 super.onCreateOptionsMenu(menu); 275 276 Intent settings = new Intent(INTENT_ACTION_SEARCH_SETTINGS); 277 settings.addFlags(Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET); 278 // Don't show activity chooser if there are multiple search settings activities, 279 // e.g. from different QSB implementations. 280 settings.setPackage(this.getPackageName()); 281 menu.add(Menu.NONE, Menu.NONE, 0, R.string.menu_settings) 282 .setIcon(android.R.drawable.ic_menu_preferences).setAlphabeticShortcut('P') 283 .setIntent(settings); 284 285 return true; 286 } 287 288 /** 289 * Sets the query as typed by the user. Does not update the suggestions 290 * or the text in the query box. 291 */ 292 protected void setUserQuery(String userQuery) { 293 if (userQuery == null) userQuery = ""; 294 mUserQuery = userQuery; 295 mSourceSelector.setQuery(mUserQuery); 296 } 297 298 protected void setAppSearchData(Bundle appSearchData) { 299 mLauncher.setAppSearchData(appSearchData); 300 mSourceSelector.setAppSearchData(appSearchData); 301 } 302 303 protected String getQuery() { 304 CharSequence q = mQueryTextView.getText(); 305 return q == null ? "" : q.toString(); 306 } 307 308 /** 309 * Restores the query entered by the user. 310 */ 311 private void restoreUserQuery() { 312 if (DBG) Log.d(TAG, "Restoring query to '" + mUserQuery + "'"); 313 setQuery(mUserQuery, false); 314 } 315 316 /** 317 * Sets the text in the query box. Does not update the suggestions, 318 * and does not change the saved user-entered query. 319 * {@link #restoreUserQuery()} will restore the query to the last 320 * user-entered query. 321 */ 322 private void setQuery(String query, boolean selectAll) { 323 mUpdateSuggestions = false; 324 mQueryTextView.setText(query); 325 setTextSelection(selectAll); 326 mUpdateSuggestions = true; 327 } 328 329 /** 330 * Sets the text selection in the query text view. 331 * 332 * @param selectAll If {@code true}, selects the entire query. 333 * If {@false}, no characters are selected, and the cursor is placed 334 * at the end of the query. 335 */ 336 private void setTextSelection(boolean selectAll) { 337 if (selectAll) { 338 mQueryTextView.setSelection(0, mQueryTextView.length()); 339 } else { 340 mQueryTextView.setSelection(mQueryTextView.length()); 341 } 342 } 343 344 protected void onSearchClicked() { 345 String query = getQuery(); 346 if (DBG) Log.d(TAG, "Search clicked, query=" + query); 347 mLauncher.startWebSearch(query); 348 } 349 350 protected void onVoiceSearchClicked() { 351 if (DBG) Log.d(TAG, "Voice Search clicked"); 352 mLauncher.startVoiceSearch(); 353 } 354 355 protected boolean launchSuggestion(SuggestionPosition suggestion) { 356 return launchSuggestion(suggestion, KeyEvent.KEYCODE_UNKNOWN, null); 357 } 358 359 protected boolean launchSuggestion(SuggestionPosition suggestion, 360 int actionKey, String actionMsg) { 361 if (DBG) Log.d(TAG, "Launching suggestion " + suggestion); 362 mLauncher.launchSuggestion(suggestion, actionKey, actionMsg); 363 getShortcutRepository().reportClick(suggestion); 364 // Update search widgets, since the top shortcuts can have changed. 365 SearchWidgetProvider.updateSearchWidgets(this); 366 return true; 367 } 368 369 protected boolean launchSuggestionSecondary(SuggestionPosition suggestion, Rect target) { 370 if (DBG) Log.d(TAG, "Clicked on suggestion icon " + suggestion); 371 mLauncher.launchSuggestionSecondary(suggestion, target); 372 getShortcutRepository().reportClick(suggestion); 373 return true; 374 } 375 376 protected boolean onSuggestionLongClicked(SuggestionPosition suggestion) { 377 SuggestionCursor sourceResult = suggestion.getSuggestion(); 378 if (DBG) Log.d(TAG, "Long clicked on suggestion " + sourceResult.getSuggestionText1()); 379 return false; 380 } 381 382 protected void onSuggestionSelected(SuggestionPosition suggestion) { 383 SuggestionCursor sourceResult = suggestion.getSuggestion(); 384 String displayQuery = sourceResult.getSuggestionDisplayQuery(); 385 if (DBG) { 386 Log.d(TAG, "Selected suggestion " + sourceResult.getSuggestionText1() 387 + ",displayQuery="+ displayQuery); 388 } 389 if (TextUtils.isEmpty(displayQuery)) { 390 restoreUserQuery(); 391 } else { 392 setQuery(displayQuery, false); 393 } 394 } 395 396 protected boolean onSuggestionKeyDown(SuggestionPosition suggestion, 397 int keyCode, KeyEvent event) { 398 // Treat enter or search as a click 399 if (keyCode == KeyEvent.KEYCODE_ENTER || keyCode == KeyEvent.KEYCODE_SEARCH) { 400 return launchSuggestion(suggestion); 401 } 402 403 // Handle source-specified action keys 404 String actionMsg = suggestion.getSuggestion().getActionKeyMsg(keyCode); 405 if (actionMsg != null) { 406 return launchSuggestion(suggestion, keyCode, actionMsg); 407 } 408 409 return false; 410 } 411 412 protected void onSourceSelected() { 413 if (DBG) Log.d(TAG, "No suggestion selected"); 414 restoreUserQuery(); 415 } 416 417 protected int getSelectedPosition() { 418 return mSuggestionsView.getSelectedPosition(); 419 } 420 421 protected SuggestionPosition getSelectedSuggestion() { 422 return mSuggestionsView.getSelectedSuggestion(); 423 } 424 425 /** 426 * Hides the input method. 427 */ 428 protected void hideInputMethod() { 429 InputMethodManager imm = (InputMethodManager) getSystemService(INPUT_METHOD_SERVICE); 430 if (imm != null) { 431 imm.hideSoftInputFromWindow(mQueryTextView.getWindowToken(), 0); 432 } 433 } 434 435 /** 436 * Hides the input method when the suggestions get focus. 437 */ 438 private class SuggestListFocusListener implements OnFocusChangeListener { 439 public void onFocusChange(View v, boolean focused) { 440 if (v == mQueryTextView) { 441 if (!focused) { 442 hideInputMethod(); 443 } else { 444 // TODO: clear list selection? 445 } 446 } 447 } 448 } 449 450 private void startSearchProgress() { 451 // TODO: Cache animation between calls? 452 mSearchGoButton.setImageResource(R.drawable.searching); 453 Animatable animation = (Animatable) mSearchGoButton.getDrawable(); 454 animation.start(); 455 } 456 457 private void stopSearchProgress() { 458 Drawable animation = mSearchGoButton.getDrawable(); 459 if (animation instanceof Animatable) { 460 // TODO: Is this needed, or is it done automatically when the 461 // animation is removed? 462 ((Animatable) animation).stop(); 463 } 464 mSearchGoButton.setImageResource(R.drawable.ic_btn_search); 465 } 466 467 private void updateSuggestions(String query) { 468 LatencyTracker latency = new LatencyTracker(TAG); 469 Suggestions suggestions = getSuggestionsProvider().getSuggestions(query); 470 latency.addEvent("getSuggestions_done"); 471 if (!suggestions.isDone()) { 472 suggestions.registerDataSetObserver(new ProgressUpdater(suggestions)); 473 startSearchProgress(); 474 } else { 475 stopSearchProgress(); 476 } 477 mSuggestionsAdapter.setSuggestions(suggestions); 478 latency.addEvent("shortcuts_shown"); 479 long userVisibleLatency = latency.getUserVisibleLatency(); 480 if (DBG) { 481 Log.d(TAG, "User visible latency (shortcuts): " + userVisibleLatency + " ms."); 482 } 483 } 484 485 /** 486 * Filters the suggestions list when the search text changes. 487 */ 488 private class SearchTextWatcher implements TextWatcher { 489 public void afterTextChanged(Editable s) { 490 if (mUpdateSuggestions) { 491 String query = s == null ? "" : s.toString(); 492 setUserQuery(query); 493 updateSuggestions(query); 494 } 495 } 496 497 public void beforeTextChanged(CharSequence s, int start, int count, int after) { 498 } 499 500 public void onTextChanged(CharSequence s, int start, int before, int count) { 501 } 502 } 503 504 /** 505 * Handles non-text keys in the query text view. 506 */ 507 private class QueryTextViewKeyListener implements View.OnKeyListener { 508 public boolean onKey(View view, int keyCode, KeyEvent event) { 509 // Handle IME search action key 510 if (keyCode == KeyEvent.KEYCODE_ENTER && event.getAction() == KeyEvent.ACTION_UP) { 511 onSearchClicked(); 512 } 513 return false; 514 } 515 } 516 517 /** 518 * Handles key events on the search and voice search buttons, 519 * by refocusing to EditText. 520 */ 521 private class ButtonsKeyListener implements View.OnKeyListener { 522 public boolean onKey(View v, int keyCode, KeyEvent event) { 523 if (!event.isSystem() && 524 (keyCode != KeyEvent.KEYCODE_DPAD_UP) && 525 (keyCode != KeyEvent.KEYCODE_DPAD_LEFT) && 526 (keyCode != KeyEvent.KEYCODE_DPAD_RIGHT) && 527 (keyCode != KeyEvent.KEYCODE_DPAD_CENTER)) { 528 if (mQueryTextView.requestFocus()) { 529 return mQueryTextView.dispatchKeyEvent(event); 530 } 531 } 532 return false; 533 } 534 } 535 536 /** 537 * Handles key events on the suggestions list view. 538 */ 539 private class SuggestionsViewKeyListener implements View.OnKeyListener { 540 public boolean onKey(View v, int keyCode, KeyEvent event) { 541 if (event.getAction() == KeyEvent.ACTION_DOWN) { 542 SuggestionPosition suggestion = getSelectedSuggestion(); 543 if (suggestion != null) { 544 return onSuggestionKeyDown(suggestion, keyCode, event); 545 } 546 } 547 return false; 548 } 549 } 550 551 private class InputMethodCloser implements SuggestionsView.InteractionListener { 552 public void onInteraction() { 553 hideInputMethod(); 554 } 555 } 556 557 private class ClickHandler implements SuggestionClickListener { 558 public void onSuggestionClicked(SuggestionPosition suggestion) { 559 launchSuggestion(suggestion); 560 } 561 562 public boolean onSuggestionLongClicked(SuggestionPosition suggestion) { 563 return SearchActivity.this.onSuggestionLongClicked(suggestion); 564 } 565 566 public void onSuggestionSelected(SuggestionPosition suggestion) { 567 SearchActivity.this.onSuggestionSelected(suggestion); 568 } 569 570 public void onSuggestionIconClicked(SuggestionPosition suggestion, Rect rect) { 571 launchSuggestionSecondary(suggestion, rect); 572 } 573 } 574 575 /** 576 * Listens for clicks on the search button. 577 */ 578 private class SearchGoButtonClickListener implements View.OnClickListener { 579 public void onClick(View view) { 580 onSearchClicked(); 581 } 582 } 583 584 /** 585 * Listens for clicks on the voice search button. 586 */ 587 private class VoiceSearchButtonClickListener implements View.OnClickListener { 588 public void onClick(View view) { 589 onVoiceSearchClicked(); 590 } 591 } 592 593 /** 594 * Updates the progress bar when the suggestions adapter changes its progress. 595 */ 596 private class ProgressUpdater extends DataSetObserver { 597 private final Suggestions mSuggestions; 598 599 public ProgressUpdater(Suggestions suggestions) { 600 mSuggestions = suggestions; 601 } 602 603 @Override 604 public void onChanged() { 605 if (mSuggestions.isDone()) { 606 stopSearchProgress(); 607 } 608 } 609 } 610 611} 612