1 /* 2 * Copyright (C) 2010 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 17 package com.android.quicksearchbox.ui; 18 19 import com.android.quicksearchbox.Corpora; 20 import com.android.quicksearchbox.Corpus; 21 import com.android.quicksearchbox.CorpusResult; 22 import com.android.quicksearchbox.Logger; 23 import com.android.quicksearchbox.Promoter; 24 import com.android.quicksearchbox.QsbApplication; 25 import com.android.quicksearchbox.R; 26 import com.android.quicksearchbox.SearchActivity; 27 import com.android.quicksearchbox.SuggestionCursor; 28 import com.android.quicksearchbox.Suggestions; 29 import com.android.quicksearchbox.VoiceSearch; 30 31 import android.content.Context; 32 import android.database.DataSetObserver; 33 import android.graphics.drawable.Drawable; 34 import android.text.Editable; 35 import android.text.TextUtils; 36 import android.text.TextWatcher; 37 import android.util.AttributeSet; 38 import android.util.Log; 39 import android.view.KeyEvent; 40 import android.view.View; 41 import android.view.inputmethod.CompletionInfo; 42 import android.view.inputmethod.InputMethodManager; 43 import android.widget.AbsListView; 44 import android.widget.ImageButton; 45 import android.widget.ListAdapter; 46 import android.widget.RelativeLayout; 47 import android.widget.TextView; 48 import android.widget.TextView.OnEditorActionListener; 49 50 import java.util.ArrayList; 51 import java.util.Arrays; 52 53 public abstract class SearchActivityView extends RelativeLayout { 54 protected static final boolean DBG = false; 55 protected static final String TAG = "QSB.SearchActivityView"; 56 57 // The string used for privateImeOptions to identify to the IME that it should not show 58 // a microphone button since one already exists in the search dialog. 59 // TODO: This should move to android-common or something. 60 private static final String IME_OPTION_NO_MICROPHONE = "nm"; 61 62 private Corpus mCorpus; 63 64 protected QueryTextView mQueryTextView; 65 // True if the query was empty on the previous call to updateQuery() 66 protected boolean mQueryWasEmpty = true; 67 protected Drawable mQueryTextEmptyBg; 68 protected Drawable mQueryTextNotEmptyBg; 69 70 protected SuggestionsListView<ListAdapter> mSuggestionsView; 71 protected SuggestionsAdapter<ListAdapter> mSuggestionsAdapter; 72 73 protected ImageButton mSearchCloseButton; 74 protected ImageButton mSearchGoButton; 75 protected ImageButton mVoiceSearchButton; 76 77 protected ButtonsKeyListener mButtonsKeyListener; 78 79 private boolean mUpdateSuggestions; 80 81 private QueryListener mQueryListener; 82 private SearchClickListener mSearchClickListener; 83 protected View.OnClickListener mExitClickListener; 84 85 public SearchActivityView(Context context) { 86 super(context); 87 } 88 89 public SearchActivityView(Context context, AttributeSet attrs) { 90 super(context, attrs); 91 } 92 93 public SearchActivityView(Context context, AttributeSet attrs, int defStyle) { 94 super(context, attrs, defStyle); 95 } 96 97 @Override 98 protected void onFinishInflate() { 99 mQueryTextView = (QueryTextView) findViewById(R.id.search_src_text); 100 101 mSuggestionsView = (SuggestionsView) findViewById(R.id.suggestions); 102 mSuggestionsView.setOnScrollListener(new InputMethodCloser()); 103 mSuggestionsView.setOnKeyListener(new SuggestionsViewKeyListener()); 104 mSuggestionsView.setOnFocusChangeListener(new SuggestListFocusListener()); 105 106 mSuggestionsAdapter = createSuggestionsAdapter(); 107 // TODO: why do we need focus listeners both on the SuggestionsView and the individual 108 // suggestions? 109 mSuggestionsAdapter.setOnFocusChangeListener(new SuggestListFocusListener()); 110 111 mSearchCloseButton = (ImageButton) findViewById(R.id.search_close_btn); 112 mSearchGoButton = (ImageButton) findViewById(R.id.search_go_btn); 113 mVoiceSearchButton = (ImageButton) findViewById(R.id.search_voice_btn); 114 mVoiceSearchButton.setImageDrawable(getVoiceSearchIcon()); 115 116 mQueryTextView.addTextChangedListener(new SearchTextWatcher()); 117 mQueryTextView.setOnEditorActionListener(new QueryTextEditorActionListener()); 118 mQueryTextView.setOnFocusChangeListener(new QueryTextViewFocusListener()); 119 mQueryTextEmptyBg = mQueryTextView.getBackground(); 120 121 mSearchGoButton.setOnClickListener(new SearchGoButtonClickListener()); 122 123 mButtonsKeyListener = new ButtonsKeyListener(); 124 mSearchGoButton.setOnKeyListener(mButtonsKeyListener); 125 mVoiceSearchButton.setOnKeyListener(mButtonsKeyListener); 126 if (mSearchCloseButton != null) { 127 mSearchCloseButton.setOnKeyListener(mButtonsKeyListener); 128 mSearchCloseButton.setOnClickListener(new CloseClickListener()); 129 } 130 131 mUpdateSuggestions = true; 132 } 133 134 public abstract void onResume(); 135 136 public abstract void onStop(); 137 138 public void onPause() { 139 // Override if necessary 140 } 141 142 public void start() { 143 mSuggestionsAdapter.getListAdapter().registerDataSetObserver(new SuggestionsObserver()); 144 mSuggestionsView.setSuggestionsAdapter(mSuggestionsAdapter); 145 } 146 147 public void destroy() { 148 mSuggestionsView.setSuggestionsAdapter(null); // closes mSuggestionsAdapter 149 } 150 151 // TODO: Get rid of this. To make it more easily testable, 152 // the SearchActivityView should not depend on QsbApplication. 153 protected QsbApplication getQsbApplication() { 154 return QsbApplication.get(getContext()); 155 } 156 157 protected Drawable getVoiceSearchIcon() { 158 return getResources().getDrawable(R.drawable.ic_btn_speak_now); 159 } 160 161 protected VoiceSearch getVoiceSearch() { 162 return getQsbApplication().getVoiceSearch(); 163 } 164 165 protected SuggestionsAdapter<ListAdapter> createSuggestionsAdapter() { 166 return new DelayingSuggestionsAdapter<ListAdapter>(new SuggestionsListAdapter( 167 getQsbApplication().getSuggestionViewFactory())); 168 } 169 170 protected Corpora getCorpora() { 171 return getQsbApplication().getCorpora(); 172 } 173 174 public Corpus getCorpus() { 175 return mCorpus; 176 } 177 178 protected abstract Promoter createSuggestionsPromoter(); 179 180 protected Corpus getCorpus(String sourceName) { 181 if (sourceName == null) return null; 182 Corpus corpus = getCorpora().getCorpus(sourceName); 183 if (corpus == null) { 184 Log.w(TAG, "Unknown corpus " + sourceName); 185 return null; 186 } 187 return corpus; 188 } 189 190 public void onCorpusSelected(String corpusName) { 191 setCorpus(corpusName); 192 focusQueryTextView(); 193 showInputMethodForQuery(); 194 } 195 196 public void setCorpus(String corpusName) { 197 if (DBG) Log.d(TAG, "setCorpus(" + corpusName + ")"); 198 Corpus corpus = getCorpus(corpusName); 199 setCorpus(corpus); 200 updateUi(); 201 } 202 203 protected void setCorpus(Corpus corpus) { 204 mCorpus = corpus; 205 mSuggestionsAdapter.setPromoter(createSuggestionsPromoter()); 206 Suggestions suggestions = getSuggestions(); 207 if (corpus == null || suggestions == null || !suggestions.expectsCorpus(corpus)) { 208 getActivity().updateSuggestions(); 209 } 210 } 211 212 public String getCorpusName() { 213 Corpus corpus = getCorpus(); 214 return corpus == null ? null : corpus.getName(); 215 } 216 217 public abstract Corpus getSearchCorpus(); 218 219 public Corpus getWebCorpus() { 220 Corpus webCorpus = getCorpora().getWebCorpus(); 221 if (webCorpus == null) { 222 Log.e(TAG, "No web corpus"); 223 } 224 return webCorpus; 225 } 226 227 public void setMaxPromotedSuggestions(int maxPromoted) { 228 mSuggestionsView.setLimitSuggestionsToViewHeight(false); 229 mSuggestionsAdapter.setMaxPromoted(maxPromoted); 230 } 231 232 public void limitSuggestionsToViewHeight() { 233 mSuggestionsView.setLimitSuggestionsToViewHeight(true); 234 } 235 236 public void setMaxPromotedResults(int maxPromoted) { 237 } 238 239 public void limitResultsToViewHeight() { 240 } 241 242 public void setQueryListener(QueryListener listener) { 243 mQueryListener = listener; 244 } 245 246 public void setSearchClickListener(SearchClickListener listener) { 247 mSearchClickListener = listener; 248 } 249 250 public abstract void showCorpusSelectionDialog(); 251 252 public void setVoiceSearchButtonClickListener(View.OnClickListener listener) { 253 if (mVoiceSearchButton != null) { 254 mVoiceSearchButton.setOnClickListener(listener); 255 } 256 } 257 258 public void setSuggestionClickListener(final SuggestionClickListener listener) { 259 mSuggestionsAdapter.setSuggestionClickListener(listener); 260 mQueryTextView.setCommitCompletionListener(new QueryTextView.CommitCompletionListener() { 261 @Override 262 public void onCommitCompletion(int position) { 263 mSuggestionsAdapter.onSuggestionClicked(position); 264 } 265 }); 266 } 267 268 public void setExitClickListener(final View.OnClickListener listener) { 269 mExitClickListener = listener; 270 } 271 272 public Suggestions getSuggestions() { 273 return mSuggestionsAdapter.getSuggestions(); 274 } 275 276 public SuggestionCursor getCurrentPromotedSuggestions() { 277 return mSuggestionsAdapter.getCurrentPromotedSuggestions(); 278 } 279 280 public void setSuggestions(Suggestions suggestions) { 281 suggestions.acquire(); 282 mSuggestionsAdapter.setSuggestions(suggestions); 283 } 284 285 public void clearSuggestions() { 286 mSuggestionsAdapter.setSuggestions(null); 287 } 288 289 public String getQuery() { 290 CharSequence q = mQueryTextView.getText(); 291 return q == null ? "" : q.toString(); 292 } 293 294 public boolean isQueryEmpty() { 295 return TextUtils.isEmpty(getQuery()); 296 } 297 298 /** 299 * Sets the text in the query box. Does not update the suggestions. 300 */ 301 public void setQuery(String query, boolean selectAll) { 302 mUpdateSuggestions = false; 303 mQueryTextView.setText(query); 304 mQueryTextView.setTextSelection(selectAll); 305 mUpdateSuggestions = true; 306 } 307 308 protected SearchActivity getActivity() { 309 Context context = getContext(); 310 if (context instanceof SearchActivity) { 311 return (SearchActivity) context; 312 } else { 313 return null; 314 } 315 } 316 317 public void hideSuggestions() { 318 mSuggestionsView.setVisibility(GONE); 319 } 320 321 public void showSuggestions() { 322 mSuggestionsView.setVisibility(VISIBLE); 323 } 324 325 public void focusQueryTextView() { 326 mQueryTextView.requestFocus(); 327 } 328 329 protected void updateUi() { 330 updateUi(isQueryEmpty()); 331 } 332 333 protected void updateUi(boolean queryEmpty) { 334 updateQueryTextView(queryEmpty); 335 updateSearchGoButton(queryEmpty); 336 updateVoiceSearchButton(queryEmpty); 337 } 338 339 protected void updateQueryTextView(boolean queryEmpty) { 340 if (queryEmpty) { 341 if (isSearchCorpusWeb()) { 342 mQueryTextView.setBackgroundDrawable(mQueryTextEmptyBg); 343 mQueryTextView.setHint(null); 344 } else { 345 if (mQueryTextNotEmptyBg == null) { 346 mQueryTextNotEmptyBg = 347 getResources().getDrawable(R.drawable.textfield_search_empty); 348 } 349 mQueryTextView.setBackgroundDrawable(mQueryTextNotEmptyBg); 350 Corpus corpus = getCorpus(); 351 mQueryTextView.setHint(corpus == null ? "" : corpus.getHint()); 352 } 353 } else { 354 mQueryTextView.setBackgroundResource(R.drawable.textfield_search); 355 } 356 } 357 358 private void updateSearchGoButton(boolean queryEmpty) { 359 if (queryEmpty) { 360 mSearchGoButton.setVisibility(View.GONE); 361 } else { 362 mSearchGoButton.setVisibility(View.VISIBLE); 363 } 364 } 365 366 protected void updateVoiceSearchButton(boolean queryEmpty) { 367 if (shouldShowVoiceSearch(queryEmpty) 368 && getVoiceSearch().shouldShowVoiceSearch(getCorpus())) { 369 mVoiceSearchButton.setVisibility(View.VISIBLE); 370 mQueryTextView.setPrivateImeOptions(IME_OPTION_NO_MICROPHONE); 371 } else { 372 mVoiceSearchButton.setVisibility(View.GONE); 373 mQueryTextView.setPrivateImeOptions(null); 374 } 375 } 376 377 protected boolean shouldShowVoiceSearch(boolean queryEmpty) { 378 return queryEmpty; 379 } 380 381 /** 382 * Hides the input method. 383 */ 384 protected void hideInputMethod() { 385 InputMethodManager imm = (InputMethodManager) 386 getContext().getSystemService(Context.INPUT_METHOD_SERVICE); 387 if (imm != null) { 388 imm.hideSoftInputFromWindow(getWindowToken(), 0); 389 } 390 } 391 392 public abstract void considerHidingInputMethod(); 393 394 public void showInputMethodForQuery() { 395 mQueryTextView.showInputMethod(); 396 } 397 398 /** 399 * Dismiss the activity if BACK is pressed when the search box is empty. 400 */ 401 @Override 402 public boolean dispatchKeyEventPreIme(KeyEvent event) { 403 SearchActivity activity = getActivity(); 404 if (activity != null && event.getKeyCode() == KeyEvent.KEYCODE_BACK 405 && isQueryEmpty()) { 406 KeyEvent.DispatcherState state = getKeyDispatcherState(); 407 if (state != null) { 408 if (event.getAction() == KeyEvent.ACTION_DOWN 409 && event.getRepeatCount() == 0) { 410 state.startTracking(event, this); 411 return true; 412 } else if (event.getAction() == KeyEvent.ACTION_UP 413 && !event.isCanceled() && state.isTracking(event)) { 414 hideInputMethod(); 415 activity.onBackPressed(); 416 return true; 417 } 418 } 419 } 420 return super.dispatchKeyEventPreIme(event); 421 } 422 423 /** 424 * If the input method is in fullscreen mode, and the selector corpus 425 * is All or Web, use the web search suggestions as completions. 426 */ 427 protected void updateInputMethodSuggestions() { 428 InputMethodManager imm = (InputMethodManager) 429 getContext().getSystemService(Context.INPUT_METHOD_SERVICE); 430 if (imm == null || !imm.isFullscreenMode()) return; 431 Suggestions suggestions = mSuggestionsAdapter.getSuggestions(); 432 if (suggestions == null) return; 433 CompletionInfo[] completions = webSuggestionsToCompletions(suggestions); 434 if (DBG) Log.d(TAG, "displayCompletions(" + Arrays.toString(completions) + ")"); 435 imm.displayCompletions(mQueryTextView, completions); 436 } 437 438 private CompletionInfo[] webSuggestionsToCompletions(Suggestions suggestions) { 439 // TODO: This should also include include web search shortcuts 440 CorpusResult cursor = suggestions.getWebResult(); 441 if (cursor == null) return null; 442 int count = cursor.getCount(); 443 ArrayList<CompletionInfo> completions = new ArrayList<CompletionInfo>(count); 444 boolean usingWebCorpus = isSearchCorpusWeb(); 445 for (int i = 0; i < count; i++) { 446 cursor.moveTo(i); 447 if (!usingWebCorpus || cursor.isWebSearchSuggestion()) { 448 String text1 = cursor.getSuggestionText1(); 449 completions.add(new CompletionInfo(i, i, text1)); 450 } 451 } 452 return completions.toArray(new CompletionInfo[completions.size()]); 453 } 454 455 protected void onSuggestionsChanged() { 456 updateInputMethodSuggestions(); 457 } 458 459 /** 460 * Checks if the corpus used for typed searches is the web corpus. 461 */ 462 protected boolean isSearchCorpusWeb() { 463 Corpus corpus = getSearchCorpus(); 464 return corpus != null && corpus.isWebCorpus(); 465 } 466 467 protected boolean onSuggestionKeyDown(SuggestionsAdapter<?> adapter, 468 long suggestionId, int keyCode, KeyEvent event) { 469 // Treat enter or search as a click 470 if ( keyCode == KeyEvent.KEYCODE_ENTER 471 || keyCode == KeyEvent.KEYCODE_SEARCH 472 || keyCode == KeyEvent.KEYCODE_DPAD_CENTER) { 473 if (adapter != null) { 474 adapter.onSuggestionClicked(suggestionId); 475 return true; 476 } else { 477 return false; 478 } 479 } 480 481 return false; 482 } 483 484 protected boolean onSearchClicked(int method) { 485 if (mSearchClickListener != null) { 486 return mSearchClickListener.onSearchClicked(method); 487 } 488 return false; 489 } 490 491 /** 492 * Filters the suggestions list when the search text changes. 493 */ 494 private class SearchTextWatcher implements TextWatcher { 495 public void afterTextChanged(Editable s) { 496 boolean empty = s.length() == 0; 497 if (empty != mQueryWasEmpty) { 498 mQueryWasEmpty = empty; 499 updateUi(empty); 500 } 501 if (mUpdateSuggestions) { 502 if (mQueryListener != null) { 503 mQueryListener.onQueryChanged(); 504 } 505 } 506 } 507 508 public void beforeTextChanged(CharSequence s, int start, int count, int after) { 509 } 510 511 public void onTextChanged(CharSequence s, int start, int before, int count) { 512 } 513 } 514 515 /** 516 * Handles key events on the suggestions list view. 517 */ 518 protected class SuggestionsViewKeyListener implements View.OnKeyListener { 519 public boolean onKey(View v, int keyCode, KeyEvent event) { 520 if (event.getAction() == KeyEvent.ACTION_DOWN 521 && v instanceof SuggestionsListView<?>) { 522 SuggestionsListView<?> listView = (SuggestionsListView<?>) v; 523 if (onSuggestionKeyDown(listView.getSuggestionsAdapter(), 524 listView.getSelectedItemId(), keyCode, event)) { 525 return true; 526 } 527 } 528 return forwardKeyToQueryTextView(keyCode, event); 529 } 530 } 531 532 private class InputMethodCloser implements SuggestionsView.OnScrollListener { 533 534 public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, 535 int totalItemCount) { 536 } 537 538 public void onScrollStateChanged(AbsListView view, int scrollState) { 539 considerHidingInputMethod(); 540 } 541 } 542 543 /** 544 * Listens for clicks on the source selector. 545 */ 546 private class SearchGoButtonClickListener implements View.OnClickListener { 547 public void onClick(View view) { 548 onSearchClicked(Logger.SEARCH_METHOD_BUTTON); 549 } 550 } 551 552 /** 553 * This class handles enter key presses in the query text view. 554 */ 555 private class QueryTextEditorActionListener implements OnEditorActionListener { 556 public boolean onEditorAction(TextView v, int actionId, KeyEvent event) { 557 boolean consumed = false; 558 if (event != null) { 559 if (event.getAction() == KeyEvent.ACTION_UP) { 560 consumed = onSearchClicked(Logger.SEARCH_METHOD_KEYBOARD); 561 } else if (event.getAction() == KeyEvent.ACTION_DOWN) { 562 // we have to consume the down event so that we receive the up event too 563 consumed = true; 564 } 565 } 566 if (DBG) Log.d(TAG, "onEditorAction consumed=" + consumed); 567 return consumed; 568 } 569 } 570 571 /** 572 * Handles key events on the search and voice search buttons, 573 * by refocusing to EditText. 574 */ 575 private class ButtonsKeyListener implements View.OnKeyListener { 576 public boolean onKey(View v, int keyCode, KeyEvent event) { 577 return forwardKeyToQueryTextView(keyCode, event); 578 } 579 } 580 581 private boolean forwardKeyToQueryTextView(int keyCode, KeyEvent event) { 582 if (!event.isSystem() && shouldForwardToQueryTextView(keyCode)) { 583 if (DBG) Log.d(TAG, "Forwarding key to query box: " + event); 584 if (mQueryTextView.requestFocus()) { 585 return mQueryTextView.dispatchKeyEvent(event); 586 } 587 } 588 return false; 589 } 590 591 private boolean shouldForwardToQueryTextView(int keyCode) { 592 switch (keyCode) { 593 case KeyEvent.KEYCODE_DPAD_UP: 594 case KeyEvent.KEYCODE_DPAD_DOWN: 595 case KeyEvent.KEYCODE_DPAD_LEFT: 596 case KeyEvent.KEYCODE_DPAD_RIGHT: 597 case KeyEvent.KEYCODE_DPAD_CENTER: 598 case KeyEvent.KEYCODE_ENTER: 599 case KeyEvent.KEYCODE_SEARCH: 600 return false; 601 default: 602 return true; 603 } 604 } 605 606 /** 607 * Hides the input method when the suggestions get focus. 608 */ 609 private class SuggestListFocusListener implements OnFocusChangeListener { 610 public void onFocusChange(View v, boolean focused) { 611 if (DBG) Log.d(TAG, "Suggestions focus change, now: " + focused); 612 if (focused) { 613 considerHidingInputMethod(); 614 } 615 } 616 } 617 618 private class QueryTextViewFocusListener implements OnFocusChangeListener { 619 public void onFocusChange(View v, boolean focused) { 620 if (DBG) Log.d(TAG, "Query focus change, now: " + focused); 621 if (focused) { 622 // The query box got focus, show the input method 623 showInputMethodForQuery(); 624 } 625 } 626 } 627 628 protected class SuggestionsObserver extends DataSetObserver { 629 @Override 630 public void onChanged() { 631 onSuggestionsChanged(); 632 } 633 } 634 635 public interface QueryListener { 636 void onQueryChanged(); 637 } 638 639 public interface SearchClickListener { 640 boolean onSearchClicked(int method); 641 } 642 643 private class CloseClickListener implements OnClickListener { 644 public void onClick(View v) { 645 if (!isQueryEmpty()) { 646 mQueryTextView.setText(""); 647 } else { 648 mExitClickListener.onClick(v); 649 } 650 } 651 } 652 } 653