SearchActivity.java revision 0484fb4d652bfa9d5c7fb238a7cec1a6f2244e44
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 = "QSB.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 Source mSource; 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, getSourceName()); 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 mSource = source; 210 Drawable sourceIcon; 211 if (source == null) { 212 sourceIcon = getSuggestionViewFactory().getGlobalSearchIcon(); 213 } else { 214 sourceIcon = source.getSourceIcon(); 215 } 216 ComponentName sourceName = getSourceName(); 217 mSuggestionsAdapter.setSource(sourceName); 218 mSourceSelector.setSource(sourceName); 219 mSourceSelector.setSourceIcon(sourceIcon); 220 } 221 222 private ComponentName getSourceName() { 223 return mSource == null ? null : mSource.getComponentName(); 224 } 225 226 private QsbApplication getQsbApplication() { 227 return (QsbApplication) getApplication(); 228 } 229 230 private Config getConfig() { 231 return getQsbApplication().getConfig(); 232 } 233 234 private SourceLookup getSources() { 235 return getQsbApplication().getSources(); 236 } 237 238 private ShortcutRepository getShortcutRepository() { 239 return getQsbApplication().getShortcutRepository(); 240 } 241 242 private SuggestionsProvider getSuggestionsProvider(Source source) { 243 return getQsbApplication().getSuggestionsProvider(source); 244 } 245 246 private SuggestionViewFactory getSuggestionViewFactory() { 247 return getQsbApplication().getSuggestionViewFactory(); 248 } 249 250 @Override 251 protected void onDestroy() { 252 if (DBG) Log.d(TAG, "onDestroy()"); 253 super.onDestroy(); 254 mSuggestionsView.setAdapter(null); // closes mSuggestionsAdapter 255 } 256 257 @Override 258 protected void onStop() { 259 if (DBG) Log.d(TAG, "onStop()"); 260 // Close all open suggestion cursors. The query will be redone in onResume() 261 // if we come back to this activity. 262 mSuggestionsAdapter.setSuggestions(null); 263 super.onStop(); 264 } 265 266 @Override 267 protected void onResume() { 268 if (DBG) Log.d(TAG, "onResume()"); 269 super.onResume(); 270 setQuery(mUserQuery, mSelectAll); 271 // Only select everything the first time after creating the activity. 272 mSelectAll = false; 273 updateSuggestions(mUserQuery); 274 mQueryTextView.requestFocus(); 275 } 276 277 @Override 278 public boolean onCreateOptionsMenu(Menu menu) { 279 super.onCreateOptionsMenu(menu); 280 281 Intent settings = new Intent(INTENT_ACTION_SEARCH_SETTINGS); 282 settings.addFlags(Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET); 283 // Don't show activity chooser if there are multiple search settings activities, 284 // e.g. from different QSB implementations. 285 settings.setPackage(this.getPackageName()); 286 menu.add(Menu.NONE, Menu.NONE, 0, R.string.menu_settings) 287 .setIcon(android.R.drawable.ic_menu_preferences).setAlphabeticShortcut('P') 288 .setIntent(settings); 289 290 return true; 291 } 292 293 /** 294 * Sets the query as typed by the user. Does not update the suggestions 295 * or the text in the query box. 296 */ 297 protected void setUserQuery(String userQuery) { 298 if (userQuery == null) userQuery = ""; 299 mUserQuery = userQuery; 300 mSourceSelector.setQuery(mUserQuery); 301 } 302 303 protected void setAppSearchData(Bundle appSearchData) { 304 mLauncher.setAppSearchData(appSearchData); 305 mSourceSelector.setAppSearchData(appSearchData); 306 } 307 308 protected String getQuery() { 309 CharSequence q = mQueryTextView.getText(); 310 return q == null ? "" : q.toString(); 311 } 312 313 /** 314 * Restores the query entered by the user. 315 */ 316 private void restoreUserQuery() { 317 if (DBG) Log.d(TAG, "Restoring query to '" + mUserQuery + "'"); 318 setQuery(mUserQuery, false); 319 } 320 321 /** 322 * Sets the text in the query box. Does not update the suggestions, 323 * and does not change the saved user-entered query. 324 * {@link #restoreUserQuery()} will restore the query to the last 325 * user-entered query. 326 */ 327 private void setQuery(String query, boolean selectAll) { 328 mUpdateSuggestions = false; 329 mQueryTextView.setText(query); 330 setTextSelection(selectAll); 331 mUpdateSuggestions = true; 332 } 333 334 /** 335 * Sets the text selection in the query text view. 336 * 337 * @param selectAll If {@code true}, selects the entire query. 338 * If {@false}, no characters are selected, and the cursor is placed 339 * at the end of the query. 340 */ 341 private void setTextSelection(boolean selectAll) { 342 if (selectAll) { 343 mQueryTextView.setSelection(0, mQueryTextView.length()); 344 } else { 345 mQueryTextView.setSelection(mQueryTextView.length()); 346 } 347 } 348 349 protected void onSearchClicked() { 350 String query = getQuery(); 351 if (DBG) Log.d(TAG, "Search clicked, query=" + query); 352 mLauncher.startWebSearch(query); 353 } 354 355 protected void onVoiceSearchClicked() { 356 if (DBG) Log.d(TAG, "Voice Search clicked"); 357 mLauncher.startVoiceSearch(); 358 } 359 360 protected boolean launchSuggestion(SuggestionPosition suggestion) { 361 return launchSuggestion(suggestion, KeyEvent.KEYCODE_UNKNOWN, null); 362 } 363 364 protected boolean launchSuggestion(SuggestionPosition suggestion, 365 int actionKey, String actionMsg) { 366 if (DBG) Log.d(TAG, "Launching suggestion " + suggestion); 367 mLauncher.launchSuggestion(suggestion, actionKey, actionMsg); 368 getShortcutRepository().reportClick(suggestion); 369 // Update search widgets, since the top shortcuts can have changed. 370 SearchWidgetProvider.updateSearchWidgets(this); 371 return true; 372 } 373 374 protected boolean launchSuggestionSecondary(SuggestionPosition suggestion, Rect target) { 375 if (DBG) Log.d(TAG, "Clicked on suggestion icon " + suggestion); 376 mLauncher.launchSuggestionSecondary(suggestion, target); 377 getShortcutRepository().reportClick(suggestion); 378 return true; 379 } 380 381 protected boolean onSuggestionLongClicked(SuggestionPosition suggestion) { 382 SuggestionCursor sourceResult = suggestion.getSuggestion(); 383 if (DBG) Log.d(TAG, "Long clicked on suggestion " + sourceResult.getSuggestionText1()); 384 return false; 385 } 386 387 protected void onSuggestionSelected(SuggestionPosition suggestion) { 388 SuggestionCursor sourceResult = suggestion.getSuggestion(); 389 String displayQuery = sourceResult.getSuggestionDisplayQuery(); 390 if (DBG) { 391 Log.d(TAG, "Selected suggestion " + sourceResult.getSuggestionText1() 392 + ",displayQuery="+ displayQuery); 393 } 394 if (TextUtils.isEmpty(displayQuery)) { 395 restoreUserQuery(); 396 } else { 397 setQuery(displayQuery, false); 398 } 399 } 400 401 protected boolean onSuggestionKeyDown(SuggestionPosition suggestion, 402 int keyCode, KeyEvent event) { 403 // Treat enter or search as a click 404 if (keyCode == KeyEvent.KEYCODE_ENTER || keyCode == KeyEvent.KEYCODE_SEARCH) { 405 return launchSuggestion(suggestion); 406 } 407 408 // Handle source-specified action keys 409 String actionMsg = suggestion.getSuggestion().getActionKeyMsg(keyCode); 410 if (actionMsg != null) { 411 return launchSuggestion(suggestion, keyCode, actionMsg); 412 } 413 414 return false; 415 } 416 417 protected void onSourceSelected() { 418 if (DBG) Log.d(TAG, "No suggestion selected"); 419 restoreUserQuery(); 420 } 421 422 protected int getSelectedPosition() { 423 return mSuggestionsView.getSelectedPosition(); 424 } 425 426 protected SuggestionPosition getSelectedSuggestion() { 427 return mSuggestionsView.getSelectedSuggestion(); 428 } 429 430 /** 431 * Hides the input method. 432 */ 433 protected void hideInputMethod() { 434 InputMethodManager imm = (InputMethodManager) getSystemService(INPUT_METHOD_SERVICE); 435 if (imm != null) { 436 imm.hideSoftInputFromWindow(mQueryTextView.getWindowToken(), 0); 437 } 438 } 439 440 /** 441 * Hides the input method when the suggestions get focus. 442 */ 443 private class SuggestListFocusListener implements OnFocusChangeListener { 444 public void onFocusChange(View v, boolean focused) { 445 if (v == mQueryTextView) { 446 if (!focused) { 447 hideInputMethod(); 448 } else { 449 // TODO: clear list selection? 450 } 451 } 452 } 453 } 454 455 private void startSearchProgress() { 456 // TODO: Cache animation between calls? 457 mSearchGoButton.setImageResource(R.drawable.searching); 458 Animatable animation = (Animatable) mSearchGoButton.getDrawable(); 459 animation.start(); 460 } 461 462 private void stopSearchProgress() { 463 Drawable animation = mSearchGoButton.getDrawable(); 464 if (animation instanceof Animatable) { 465 // TODO: Is this needed, or is it done automatically when the 466 // animation is removed? 467 ((Animatable) animation).stop(); 468 } 469 mSearchGoButton.setImageResource(R.drawable.ic_btn_search); 470 } 471 472 private void updateSuggestions(String query) { 473 LatencyTracker latency = new LatencyTracker(TAG); 474 Suggestions suggestions = getSuggestionsProvider(mSource).getSuggestions(query); 475 latency.addEvent("getSuggestions_done"); 476 if (!suggestions.isDone()) { 477 suggestions.registerDataSetObserver(new ProgressUpdater(suggestions)); 478 startSearchProgress(); 479 } else { 480 stopSearchProgress(); 481 } 482 mSuggestionsAdapter.setSuggestions(suggestions); 483 latency.addEvent("shortcuts_shown"); 484 long userVisibleLatency = latency.getUserVisibleLatency(); 485 if (DBG) { 486 Log.d(TAG, "User visible latency (shortcuts): " + userVisibleLatency + " ms."); 487 } 488 } 489 490 /** 491 * Filters the suggestions list when the search text changes. 492 */ 493 private class SearchTextWatcher implements TextWatcher { 494 public void afterTextChanged(Editable s) { 495 if (mUpdateSuggestions) { 496 String query = s == null ? "" : s.toString(); 497 setUserQuery(query); 498 updateSuggestions(query); 499 } 500 } 501 502 public void beforeTextChanged(CharSequence s, int start, int count, int after) { 503 } 504 505 public void onTextChanged(CharSequence s, int start, int before, int count) { 506 } 507 } 508 509 /** 510 * Handles non-text keys in the query text view. 511 */ 512 private class QueryTextViewKeyListener implements View.OnKeyListener { 513 public boolean onKey(View view, int keyCode, KeyEvent event) { 514 // Handle IME search action key 515 if (keyCode == KeyEvent.KEYCODE_ENTER && event.getAction() == KeyEvent.ACTION_UP) { 516 onSearchClicked(); 517 } 518 return false; 519 } 520 } 521 522 /** 523 * Handles key events on the search and voice search buttons, 524 * by refocusing to EditText. 525 */ 526 private class ButtonsKeyListener implements View.OnKeyListener { 527 public boolean onKey(View v, int keyCode, KeyEvent event) { 528 if (!event.isSystem() && 529 (keyCode != KeyEvent.KEYCODE_DPAD_UP) && 530 (keyCode != KeyEvent.KEYCODE_DPAD_LEFT) && 531 (keyCode != KeyEvent.KEYCODE_DPAD_RIGHT) && 532 (keyCode != KeyEvent.KEYCODE_DPAD_CENTER)) { 533 if (mQueryTextView.requestFocus()) { 534 return mQueryTextView.dispatchKeyEvent(event); 535 } 536 } 537 return false; 538 } 539 } 540 541 /** 542 * Handles key events on the suggestions list view. 543 */ 544 private class SuggestionsViewKeyListener implements View.OnKeyListener { 545 public boolean onKey(View v, int keyCode, KeyEvent event) { 546 if (event.getAction() == KeyEvent.ACTION_DOWN) { 547 SuggestionPosition suggestion = getSelectedSuggestion(); 548 if (suggestion != null) { 549 return onSuggestionKeyDown(suggestion, keyCode, event); 550 } 551 } 552 return false; 553 } 554 } 555 556 private class InputMethodCloser implements SuggestionsView.InteractionListener { 557 public void onInteraction() { 558 hideInputMethod(); 559 } 560 } 561 562 private class ClickHandler implements SuggestionClickListener { 563 public void onSuggestionClicked(SuggestionPosition suggestion) { 564 launchSuggestion(suggestion); 565 } 566 567 public boolean onSuggestionLongClicked(SuggestionPosition suggestion) { 568 return SearchActivity.this.onSuggestionLongClicked(suggestion); 569 } 570 571 public void onSuggestionSelected(SuggestionPosition suggestion) { 572 SearchActivity.this.onSuggestionSelected(suggestion); 573 } 574 575 public void onSuggestionIconClicked(SuggestionPosition suggestion, Rect rect) { 576 launchSuggestionSecondary(suggestion, rect); 577 } 578 } 579 580 /** 581 * Listens for clicks on the search button. 582 */ 583 private class SearchGoButtonClickListener implements View.OnClickListener { 584 public void onClick(View view) { 585 onSearchClicked(); 586 } 587 } 588 589 /** 590 * Listens for clicks on the voice search button. 591 */ 592 private class VoiceSearchButtonClickListener implements View.OnClickListener { 593 public void onClick(View view) { 594 onVoiceSearchClicked(); 595 } 596 } 597 598 /** 599 * Updates the progress bar when the suggestions adapter changes its progress. 600 */ 601 private class ProgressUpdater extends DataSetObserver { 602 private final Suggestions mSuggestions; 603 604 public ProgressUpdater(Suggestions suggestions) { 605 mSuggestions = suggestions; 606 } 607 608 @Override 609 public void onChanged() { 610 if (mSuggestions.isDone()) { 611 stopSearchProgress(); 612 } 613 } 614 } 615 616} 617