| /* |
| * Copyright (C) 2010 The Android Open Source Project |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| |
| package com.android.quicksearchbox.ui; |
| |
| import com.android.quicksearchbox.Corpora; |
| import com.android.quicksearchbox.Corpus; |
| import com.android.quicksearchbox.CorpusResult; |
| import com.android.quicksearchbox.Logger; |
| import com.android.quicksearchbox.Promoter; |
| import com.android.quicksearchbox.QsbApplication; |
| import com.android.quicksearchbox.R; |
| import com.android.quicksearchbox.SearchActivity; |
| import com.android.quicksearchbox.SuggestionCursor; |
| import com.android.quicksearchbox.Suggestions; |
| import com.android.quicksearchbox.VoiceSearch; |
| |
| import android.content.Context; |
| import android.database.DataSetObserver; |
| import android.graphics.drawable.Drawable; |
| import android.text.Editable; |
| import android.text.TextUtils; |
| import android.text.TextWatcher; |
| import android.util.AttributeSet; |
| import android.util.Log; |
| import android.view.KeyEvent; |
| import android.view.View; |
| import android.view.inputmethod.CompletionInfo; |
| import android.view.inputmethod.InputMethodManager; |
| import android.widget.AbsListView; |
| import android.widget.ImageButton; |
| import android.widget.ListAdapter; |
| import android.widget.RelativeLayout; |
| import android.widget.TextView; |
| import android.widget.TextView.OnEditorActionListener; |
| |
| import java.util.ArrayList; |
| import java.util.Arrays; |
| |
| public abstract class SearchActivityView extends RelativeLayout { |
| protected static final boolean DBG = false; |
| protected static final String TAG = "QSB.SearchActivityView"; |
| |
| // The string used for privateImeOptions to identify to the IME that it should not show |
| // a microphone button since one already exists in the search dialog. |
| // TODO: This should move to android-common or something. |
| private static final String IME_OPTION_NO_MICROPHONE = "nm"; |
| |
| private Corpus mCorpus; |
| |
| protected QueryTextView mQueryTextView; |
| // True if the query was empty on the previous call to updateQuery() |
| protected boolean mQueryWasEmpty = true; |
| protected Drawable mQueryTextEmptyBg; |
| protected Drawable mQueryTextNotEmptyBg; |
| |
| protected SuggestionsListView<ListAdapter> mSuggestionsView; |
| protected SuggestionsAdapter<ListAdapter> mSuggestionsAdapter; |
| |
| protected ImageButton mSearchCloseButton; |
| protected ImageButton mSearchGoButton; |
| protected ImageButton mVoiceSearchButton; |
| |
| protected ButtonsKeyListener mButtonsKeyListener; |
| |
| private boolean mUpdateSuggestions; |
| |
| private QueryListener mQueryListener; |
| private SearchClickListener mSearchClickListener; |
| protected View.OnClickListener mExitClickListener; |
| |
| public SearchActivityView(Context context) { |
| super(context); |
| } |
| |
| public SearchActivityView(Context context, AttributeSet attrs) { |
| super(context, attrs); |
| } |
| |
| public SearchActivityView(Context context, AttributeSet attrs, int defStyle) { |
| super(context, attrs, defStyle); |
| } |
| |
| @Override |
| protected void onFinishInflate() { |
| mQueryTextView = (QueryTextView) findViewById(R.id.search_src_text); |
| |
| mSuggestionsView = (SuggestionsView) findViewById(R.id.suggestions); |
| mSuggestionsView.setOnScrollListener(new InputMethodCloser()); |
| mSuggestionsView.setOnKeyListener(new SuggestionsViewKeyListener()); |
| mSuggestionsView.setOnFocusChangeListener(new SuggestListFocusListener()); |
| |
| mSuggestionsAdapter = createSuggestionsAdapter(); |
| // TODO: why do we need focus listeners both on the SuggestionsView and the individual |
| // suggestions? |
| mSuggestionsAdapter.setOnFocusChangeListener(new SuggestListFocusListener()); |
| |
| mSearchCloseButton = (ImageButton) findViewById(R.id.search_close_btn); |
| mSearchGoButton = (ImageButton) findViewById(R.id.search_go_btn); |
| mVoiceSearchButton = (ImageButton) findViewById(R.id.search_voice_btn); |
| mVoiceSearchButton.setImageDrawable(getVoiceSearchIcon()); |
| |
| mQueryTextView.addTextChangedListener(new SearchTextWatcher()); |
| mQueryTextView.setOnEditorActionListener(new QueryTextEditorActionListener()); |
| mQueryTextView.setOnFocusChangeListener(new QueryTextViewFocusListener()); |
| mQueryTextEmptyBg = mQueryTextView.getBackground(); |
| |
| mSearchGoButton.setOnClickListener(new SearchGoButtonClickListener()); |
| |
| mButtonsKeyListener = new ButtonsKeyListener(); |
| mSearchGoButton.setOnKeyListener(mButtonsKeyListener); |
| mVoiceSearchButton.setOnKeyListener(mButtonsKeyListener); |
| if (mSearchCloseButton != null) { |
| mSearchCloseButton.setOnKeyListener(mButtonsKeyListener); |
| mSearchCloseButton.setOnClickListener(new CloseClickListener()); |
| } |
| |
| mUpdateSuggestions = true; |
| } |
| |
| public abstract void onResume(); |
| |
| public abstract void onStop(); |
| |
| public void onPause() { |
| // Override if necessary |
| } |
| |
| public void start() { |
| mSuggestionsAdapter.getListAdapter().registerDataSetObserver(new SuggestionsObserver()); |
| mSuggestionsView.setSuggestionsAdapter(mSuggestionsAdapter); |
| } |
| |
| public void destroy() { |
| mSuggestionsView.setSuggestionsAdapter(null); // closes mSuggestionsAdapter |
| } |
| |
| // TODO: Get rid of this. To make it more easily testable, |
| // the SearchActivityView should not depend on QsbApplication. |
| protected QsbApplication getQsbApplication() { |
| return QsbApplication.get(getContext()); |
| } |
| |
| protected Drawable getVoiceSearchIcon() { |
| return getResources().getDrawable(R.drawable.ic_btn_speak_now); |
| } |
| |
| protected VoiceSearch getVoiceSearch() { |
| return getQsbApplication().getVoiceSearch(); |
| } |
| |
| protected SuggestionsAdapter<ListAdapter> createSuggestionsAdapter() { |
| return new DelayingSuggestionsAdapter<ListAdapter>(new SuggestionsListAdapter( |
| getQsbApplication().getSuggestionViewFactory())); |
| } |
| |
| protected Corpora getCorpora() { |
| return getQsbApplication().getCorpora(); |
| } |
| |
| public Corpus getCorpus() { |
| return mCorpus; |
| } |
| |
| protected abstract Promoter createSuggestionsPromoter(); |
| |
| protected Corpus getCorpus(String sourceName) { |
| if (sourceName == null) return null; |
| Corpus corpus = getCorpora().getCorpus(sourceName); |
| if (corpus == null) { |
| Log.w(TAG, "Unknown corpus " + sourceName); |
| return null; |
| } |
| return corpus; |
| } |
| |
| public void onCorpusSelected(String corpusName) { |
| setCorpus(corpusName); |
| focusQueryTextView(); |
| showInputMethodForQuery(); |
| } |
| |
| public void setCorpus(String corpusName) { |
| if (DBG) Log.d(TAG, "setCorpus(" + corpusName + ")"); |
| Corpus corpus = getCorpus(corpusName); |
| setCorpus(corpus); |
| updateUi(); |
| } |
| |
| protected void setCorpus(Corpus corpus) { |
| mCorpus = corpus; |
| mSuggestionsAdapter.setPromoter(createSuggestionsPromoter()); |
| Suggestions suggestions = getSuggestions(); |
| if (corpus == null || suggestions == null || !suggestions.expectsCorpus(corpus)) { |
| getActivity().updateSuggestions(); |
| } |
| } |
| |
| public String getCorpusName() { |
| Corpus corpus = getCorpus(); |
| return corpus == null ? null : corpus.getName(); |
| } |
| |
| public abstract Corpus getSearchCorpus(); |
| |
| public Corpus getWebCorpus() { |
| Corpus webCorpus = getCorpora().getWebCorpus(); |
| if (webCorpus == null) { |
| Log.e(TAG, "No web corpus"); |
| } |
| return webCorpus; |
| } |
| |
| public void setMaxPromotedSuggestions(int maxPromoted) { |
| mSuggestionsView.setLimitSuggestionsToViewHeight(false); |
| mSuggestionsAdapter.setMaxPromoted(maxPromoted); |
| } |
| |
| public void limitSuggestionsToViewHeight() { |
| mSuggestionsView.setLimitSuggestionsToViewHeight(true); |
| } |
| |
| public void setMaxPromotedResults(int maxPromoted) { |
| } |
| |
| public void limitResultsToViewHeight() { |
| } |
| |
| public void setQueryListener(QueryListener listener) { |
| mQueryListener = listener; |
| } |
| |
| public void setSearchClickListener(SearchClickListener listener) { |
| mSearchClickListener = listener; |
| } |
| |
| public abstract void showCorpusSelectionDialog(); |
| |
| public void setVoiceSearchButtonClickListener(View.OnClickListener listener) { |
| if (mVoiceSearchButton != null) { |
| mVoiceSearchButton.setOnClickListener(listener); |
| } |
| } |
| |
| public void setSuggestionClickListener(final SuggestionClickListener listener) { |
| mSuggestionsAdapter.setSuggestionClickListener(listener); |
| mQueryTextView.setCommitCompletionListener(new QueryTextView.CommitCompletionListener() { |
| @Override |
| public void onCommitCompletion(int position) { |
| mSuggestionsAdapter.onSuggestionClicked(position); |
| } |
| }); |
| } |
| |
| public void setExitClickListener(final View.OnClickListener listener) { |
| mExitClickListener = listener; |
| } |
| |
| public Suggestions getSuggestions() { |
| return mSuggestionsAdapter.getSuggestions(); |
| } |
| |
| public SuggestionCursor getCurrentPromotedSuggestions() { |
| return mSuggestionsAdapter.getCurrentPromotedSuggestions(); |
| } |
| |
| public void setSuggestions(Suggestions suggestions) { |
| suggestions.acquire(); |
| mSuggestionsAdapter.setSuggestions(suggestions); |
| } |
| |
| public void clearSuggestions() { |
| mSuggestionsAdapter.setSuggestions(null); |
| } |
| |
| public String getQuery() { |
| CharSequence q = mQueryTextView.getText(); |
| return q == null ? "" : q.toString(); |
| } |
| |
| public boolean isQueryEmpty() { |
| return TextUtils.isEmpty(getQuery()); |
| } |
| |
| /** |
| * Sets the text in the query box. Does not update the suggestions. |
| */ |
| public void setQuery(String query, boolean selectAll) { |
| mUpdateSuggestions = false; |
| mQueryTextView.setText(query); |
| mQueryTextView.setTextSelection(selectAll); |
| mUpdateSuggestions = true; |
| } |
| |
| protected SearchActivity getActivity() { |
| Context context = getContext(); |
| if (context instanceof SearchActivity) { |
| return (SearchActivity) context; |
| } else { |
| return null; |
| } |
| } |
| |
| public void hideSuggestions() { |
| mSuggestionsView.setVisibility(GONE); |
| } |
| |
| public void showSuggestions() { |
| mSuggestionsView.setVisibility(VISIBLE); |
| } |
| |
| public void focusQueryTextView() { |
| mQueryTextView.requestFocus(); |
| } |
| |
| protected void updateUi() { |
| updateUi(isQueryEmpty()); |
| } |
| |
| protected void updateUi(boolean queryEmpty) { |
| updateQueryTextView(queryEmpty); |
| updateSearchGoButton(queryEmpty); |
| updateVoiceSearchButton(queryEmpty); |
| } |
| |
| protected void updateQueryTextView(boolean queryEmpty) { |
| if (queryEmpty) { |
| if (isSearchCorpusWeb()) { |
| mQueryTextView.setBackgroundDrawable(mQueryTextEmptyBg); |
| mQueryTextView.setHint(null); |
| } else { |
| if (mQueryTextNotEmptyBg == null) { |
| mQueryTextNotEmptyBg = |
| getResources().getDrawable(R.drawable.textfield_search_empty); |
| } |
| mQueryTextView.setBackgroundDrawable(mQueryTextNotEmptyBg); |
| Corpus corpus = getCorpus(); |
| mQueryTextView.setHint(corpus == null ? "" : corpus.getHint()); |
| } |
| } else { |
| mQueryTextView.setBackgroundResource(R.drawable.textfield_search); |
| } |
| } |
| |
| private void updateSearchGoButton(boolean queryEmpty) { |
| if (queryEmpty) { |
| mSearchGoButton.setVisibility(View.GONE); |
| } else { |
| mSearchGoButton.setVisibility(View.VISIBLE); |
| } |
| } |
| |
| protected void updateVoiceSearchButton(boolean queryEmpty) { |
| if (shouldShowVoiceSearch(queryEmpty) |
| && getVoiceSearch().shouldShowVoiceSearch(getCorpus())) { |
| mVoiceSearchButton.setVisibility(View.VISIBLE); |
| mQueryTextView.setPrivateImeOptions(IME_OPTION_NO_MICROPHONE); |
| } else { |
| mVoiceSearchButton.setVisibility(View.GONE); |
| mQueryTextView.setPrivateImeOptions(null); |
| } |
| } |
| |
| protected boolean shouldShowVoiceSearch(boolean queryEmpty) { |
| return queryEmpty; |
| } |
| |
| /** |
| * Hides the input method. |
| */ |
| protected void hideInputMethod() { |
| InputMethodManager imm = (InputMethodManager) |
| getContext().getSystemService(Context.INPUT_METHOD_SERVICE); |
| if (imm != null) { |
| imm.hideSoftInputFromWindow(getWindowToken(), 0); |
| } |
| } |
| |
| public abstract void considerHidingInputMethod(); |
| |
| public void showInputMethodForQuery() { |
| mQueryTextView.showInputMethod(); |
| } |
| |
| /** |
| * Dismiss the activity if BACK is pressed when the search box is empty. |
| */ |
| @Override |
| public boolean dispatchKeyEventPreIme(KeyEvent event) { |
| SearchActivity activity = getActivity(); |
| if (activity != null && event.getKeyCode() == KeyEvent.KEYCODE_BACK |
| && isQueryEmpty()) { |
| KeyEvent.DispatcherState state = getKeyDispatcherState(); |
| if (state != null) { |
| if (event.getAction() == KeyEvent.ACTION_DOWN |
| && event.getRepeatCount() == 0) { |
| state.startTracking(event, this); |
| return true; |
| } else if (event.getAction() == KeyEvent.ACTION_UP |
| && !event.isCanceled() && state.isTracking(event)) { |
| hideInputMethod(); |
| activity.onBackPressed(); |
| return true; |
| } |
| } |
| } |
| return super.dispatchKeyEventPreIme(event); |
| } |
| |
| /** |
| * If the input method is in fullscreen mode, and the selector corpus |
| * is All or Web, use the web search suggestions as completions. |
| */ |
| protected void updateInputMethodSuggestions() { |
| InputMethodManager imm = (InputMethodManager) |
| getContext().getSystemService(Context.INPUT_METHOD_SERVICE); |
| if (imm == null || !imm.isFullscreenMode()) return; |
| Suggestions suggestions = mSuggestionsAdapter.getSuggestions(); |
| if (suggestions == null) return; |
| CompletionInfo[] completions = webSuggestionsToCompletions(suggestions); |
| if (DBG) Log.d(TAG, "displayCompletions(" + Arrays.toString(completions) + ")"); |
| imm.displayCompletions(mQueryTextView, completions); |
| } |
| |
| private CompletionInfo[] webSuggestionsToCompletions(Suggestions suggestions) { |
| // TODO: This should also include include web search shortcuts |
| CorpusResult cursor = suggestions.getWebResult(); |
| if (cursor == null) return null; |
| int count = cursor.getCount(); |
| ArrayList<CompletionInfo> completions = new ArrayList<CompletionInfo>(count); |
| boolean usingWebCorpus = isSearchCorpusWeb(); |
| for (int i = 0; i < count; i++) { |
| cursor.moveTo(i); |
| if (!usingWebCorpus || cursor.isWebSearchSuggestion()) { |
| String text1 = cursor.getSuggestionText1(); |
| completions.add(new CompletionInfo(i, i, text1)); |
| } |
| } |
| return completions.toArray(new CompletionInfo[completions.size()]); |
| } |
| |
| protected void onSuggestionsChanged() { |
| updateInputMethodSuggestions(); |
| } |
| |
| /** |
| * Checks if the corpus used for typed searches is the web corpus. |
| */ |
| protected boolean isSearchCorpusWeb() { |
| Corpus corpus = getSearchCorpus(); |
| return corpus != null && corpus.isWebCorpus(); |
| } |
| |
| protected boolean onSuggestionKeyDown(SuggestionsAdapter<?> adapter, |
| long suggestionId, int keyCode, KeyEvent event) { |
| // Treat enter or search as a click |
| if ( keyCode == KeyEvent.KEYCODE_ENTER |
| || keyCode == KeyEvent.KEYCODE_SEARCH |
| || keyCode == KeyEvent.KEYCODE_DPAD_CENTER) { |
| if (adapter != null) { |
| adapter.onSuggestionClicked(suggestionId); |
| return true; |
| } else { |
| return false; |
| } |
| } |
| |
| return false; |
| } |
| |
| protected boolean onSearchClicked(int method) { |
| if (mSearchClickListener != null) { |
| return mSearchClickListener.onSearchClicked(method); |
| } |
| return false; |
| } |
| |
| /** |
| * Filters the suggestions list when the search text changes. |
| */ |
| private class SearchTextWatcher implements TextWatcher { |
| public void afterTextChanged(Editable s) { |
| boolean empty = s.length() == 0; |
| if (empty != mQueryWasEmpty) { |
| mQueryWasEmpty = empty; |
| updateUi(empty); |
| } |
| if (mUpdateSuggestions) { |
| if (mQueryListener != null) { |
| mQueryListener.onQueryChanged(); |
| } |
| } |
| } |
| |
| public void beforeTextChanged(CharSequence s, int start, int count, int after) { |
| } |
| |
| public void onTextChanged(CharSequence s, int start, int before, int count) { |
| } |
| } |
| |
| /** |
| * Handles key events on the suggestions list view. |
| */ |
| protected class SuggestionsViewKeyListener implements View.OnKeyListener { |
| public boolean onKey(View v, int keyCode, KeyEvent event) { |
| if (event.getAction() == KeyEvent.ACTION_DOWN |
| && v instanceof SuggestionsListView<?>) { |
| SuggestionsListView<?> listView = (SuggestionsListView<?>) v; |
| if (onSuggestionKeyDown(listView.getSuggestionsAdapter(), |
| listView.getSelectedItemId(), keyCode, event)) { |
| return true; |
| } |
| } |
| return forwardKeyToQueryTextView(keyCode, event); |
| } |
| } |
| |
| private class InputMethodCloser implements SuggestionsView.OnScrollListener { |
| |
| public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, |
| int totalItemCount) { |
| } |
| |
| public void onScrollStateChanged(AbsListView view, int scrollState) { |
| considerHidingInputMethod(); |
| } |
| } |
| |
| /** |
| * Listens for clicks on the source selector. |
| */ |
| private class SearchGoButtonClickListener implements View.OnClickListener { |
| public void onClick(View view) { |
| onSearchClicked(Logger.SEARCH_METHOD_BUTTON); |
| } |
| } |
| |
| /** |
| * This class handles enter key presses in the query text view. |
| */ |
| private class QueryTextEditorActionListener implements OnEditorActionListener { |
| public boolean onEditorAction(TextView v, int actionId, KeyEvent event) { |
| boolean consumed = false; |
| if (event != null) { |
| if (event.getAction() == KeyEvent.ACTION_UP) { |
| consumed = onSearchClicked(Logger.SEARCH_METHOD_KEYBOARD); |
| } else if (event.getAction() == KeyEvent.ACTION_DOWN) { |
| // we have to consume the down event so that we receive the up event too |
| consumed = true; |
| } |
| } |
| if (DBG) Log.d(TAG, "onEditorAction consumed=" + consumed); |
| return consumed; |
| } |
| } |
| |
| /** |
| * Handles key events on the search and voice search buttons, |
| * by refocusing to EditText. |
| */ |
| private class ButtonsKeyListener implements View.OnKeyListener { |
| public boolean onKey(View v, int keyCode, KeyEvent event) { |
| return forwardKeyToQueryTextView(keyCode, event); |
| } |
| } |
| |
| private boolean forwardKeyToQueryTextView(int keyCode, KeyEvent event) { |
| if (!event.isSystem() && shouldForwardToQueryTextView(keyCode)) { |
| if (DBG) Log.d(TAG, "Forwarding key to query box: " + event); |
| if (mQueryTextView.requestFocus()) { |
| return mQueryTextView.dispatchKeyEvent(event); |
| } |
| } |
| return false; |
| } |
| |
| private boolean shouldForwardToQueryTextView(int keyCode) { |
| switch (keyCode) { |
| case KeyEvent.KEYCODE_DPAD_UP: |
| case KeyEvent.KEYCODE_DPAD_DOWN: |
| case KeyEvent.KEYCODE_DPAD_LEFT: |
| case KeyEvent.KEYCODE_DPAD_RIGHT: |
| case KeyEvent.KEYCODE_DPAD_CENTER: |
| case KeyEvent.KEYCODE_ENTER: |
| case KeyEvent.KEYCODE_SEARCH: |
| return false; |
| default: |
| return true; |
| } |
| } |
| |
| /** |
| * Hides the input method when the suggestions get focus. |
| */ |
| private class SuggestListFocusListener implements OnFocusChangeListener { |
| public void onFocusChange(View v, boolean focused) { |
| if (DBG) Log.d(TAG, "Suggestions focus change, now: " + focused); |
| if (focused) { |
| considerHidingInputMethod(); |
| } |
| } |
| } |
| |
| private class QueryTextViewFocusListener implements OnFocusChangeListener { |
| public void onFocusChange(View v, boolean focused) { |
| if (DBG) Log.d(TAG, "Query focus change, now: " + focused); |
| if (focused) { |
| // The query box got focus, show the input method |
| showInputMethodForQuery(); |
| } |
| } |
| } |
| |
| protected class SuggestionsObserver extends DataSetObserver { |
| @Override |
| public void onChanged() { |
| onSuggestionsChanged(); |
| } |
| } |
| |
| public interface QueryListener { |
| void onQueryChanged(); |
| } |
| |
| public interface SearchClickListener { |
| boolean onSearchClicked(int method); |
| } |
| |
| private class CloseClickListener implements OnClickListener { |
| public void onClick(View v) { |
| if (!isQueryEmpty()) { |
| mQueryTextView.setText(""); |
| } else { |
| mExitClickListener.onClick(v); |
| } |
| } |
| } |
| } |