| /* |
| * Copyright (C) 2008 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 android.app; |
| |
| import android.content.ActivityNotFoundException; |
| import android.content.BroadcastReceiver; |
| import android.content.ComponentName; |
| import android.content.Context; |
| import android.content.Intent; |
| import android.content.IntentFilter; |
| import android.content.pm.PackageManager; |
| import android.content.pm.ResolveInfo; |
| import android.content.res.Configuration; |
| import android.content.res.Resources; |
| import android.content.res.Resources.NotFoundException; |
| import android.database.Cursor; |
| import android.graphics.drawable.Drawable; |
| import android.net.Uri; |
| import android.os.Bundle; |
| import android.os.Handler; |
| import android.os.RemoteException; |
| import android.os.ServiceManager; |
| import android.os.SystemClock; |
| import android.server.search.SearchableInfo; |
| import android.speech.RecognizerIntent; |
| import android.text.Editable; |
| import android.text.InputType; |
| import android.text.TextUtils; |
| import android.text.TextWatcher; |
| import android.util.AttributeSet; |
| import android.util.Log; |
| import android.view.Gravity; |
| import android.view.KeyEvent; |
| import android.view.View; |
| import android.view.ViewGroup; |
| import android.view.Window; |
| import android.view.WindowManager; |
| import android.view.inputmethod.InputMethodManager; |
| import android.widget.AdapterView; |
| import android.widget.AutoCompleteTextView; |
| import android.widget.Button; |
| import android.widget.CursorAdapter; |
| import android.widget.ImageButton; |
| import android.widget.ImageView; |
| import android.widget.ListView; |
| import android.widget.SimpleCursorAdapter; |
| import android.widget.TextView; |
| import android.widget.WrapperListAdapter; |
| import android.widget.AdapterView.OnItemClickListener; |
| import android.widget.AdapterView.OnItemSelectedListener; |
| |
| import java.lang.ref.WeakReference; |
| import java.util.concurrent.atomic.AtomicLong; |
| |
| /** |
| * In-application-process implementation of Search Bar. This is still controlled by the |
| * SearchManager, but it runs in the current activity's process to keep things lighter weight. |
| * |
| * @hide |
| */ |
| public class SearchDialog extends Dialog implements OnItemClickListener, OnItemSelectedListener { |
| |
| // Debugging support |
| final static String LOG_TAG = "SearchDialog"; |
| private static final int DBG_LOG_TIMING = 0; |
| final static int DBG_JAM_THREADING = 0; |
| |
| // interaction with runtime |
| IntentFilter mCloseDialogsFilter; |
| IntentFilter mPackageFilter; |
| |
| private static final String INSTANCE_KEY_COMPONENT = "comp"; |
| private static final String INSTANCE_KEY_APPDATA = "data"; |
| private static final String INSTANCE_KEY_GLOBALSEARCH = "glob"; |
| private static final String INSTANCE_KEY_DISPLAY_QUERY = "dQry"; |
| private static final String INSTANCE_KEY_DISPLAY_SEL_START = "sel1"; |
| private static final String INSTANCE_KEY_DISPLAY_SEL_END = "sel2"; |
| private static final String INSTANCE_KEY_USER_QUERY = "uQry"; |
| private static final String INSTANCE_KEY_SUGGESTION_QUERY = "sQry"; |
| private static final String INSTANCE_KEY_SELECTED_ELEMENT = "slEl"; |
| private static final int INSTANCE_SELECTED_BUTTON = -2; |
| private static final int INSTANCE_SELECTED_QUERY = -1; |
| |
| // views & widgets |
| private TextView mBadgeLabel; |
| private AutoCompleteTextView mSearchTextField; |
| private Button mGoButton; |
| private ImageButton mVoiceButton; |
| |
| // interaction with searchable application |
| private ComponentName mLaunchComponent; |
| private Bundle mAppSearchData; |
| private boolean mGlobalSearchMode; |
| private Context mActivityContext; |
| |
| // interaction with the search manager service |
| private SearchableInfo mSearchable; |
| |
| // support for suggestions |
| private String mUserQuery = null; |
| private int mUserQuerySelStart; |
| private int mUserQuerySelEnd; |
| private boolean mLeaveJammedQueryOnRefocus = false; |
| private String mPreviousSuggestionQuery = null; |
| private int mPresetSelection = -1; |
| private String mSuggestionAction = null; |
| private Uri mSuggestionData = null; |
| private String mSuggestionQuery = null; |
| |
| // For voice searching |
| private Intent mVoiceWebSearchIntent; |
| private Intent mVoiceAppSearchIntent; |
| |
| // support for AutoCompleteTextView suggestions display |
| private SuggestionsAdapter mSuggestionsAdapter; |
| |
| /** |
| * Constructor - fires it up and makes it look like the search UI. |
| * |
| * @param context Application Context we can use for system acess |
| */ |
| public SearchDialog(Context context) { |
| super(context, com.android.internal.R.style.Theme_SearchBar); |
| } |
| |
| /** |
| * We create the search dialog just once, and it stays around (hidden) |
| * until activated by the user. |
| */ |
| @Override |
| protected void onCreate(Bundle savedInstanceState) { |
| super.onCreate(savedInstanceState); |
| |
| Window theWindow = getWindow(); |
| theWindow.setGravity(Gravity.TOP|Gravity.FILL_HORIZONTAL); |
| |
| setContentView(com.android.internal.R.layout.search_bar); |
| |
| theWindow.setLayout(ViewGroup.LayoutParams.FILL_PARENT, |
| ViewGroup.LayoutParams.WRAP_CONTENT); |
| WindowManager.LayoutParams lp = theWindow.getAttributes(); |
| lp.setTitle("Search Dialog"); |
| lp.softInputMode = WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE; |
| theWindow.setAttributes(lp); |
| |
| // get the view elements for local access |
| mBadgeLabel = (TextView) findViewById(com.android.internal.R.id.search_badge); |
| mSearchTextField = (AutoCompleteTextView) |
| findViewById(com.android.internal.R.id.search_src_text); |
| mGoButton = (Button) findViewById(com.android.internal.R.id.search_go_btn); |
| mVoiceButton = (ImageButton) findViewById(com.android.internal.R.id.search_voice_btn); |
| |
| // attach listeners |
| mSearchTextField.addTextChangedListener(mTextWatcher); |
| mSearchTextField.setOnKeyListener(mTextKeyListener); |
| mGoButton.setOnClickListener(mGoButtonClickListener); |
| mGoButton.setOnKeyListener(mButtonsKeyListener); |
| mVoiceButton.setOnClickListener(mVoiceButtonClickListener); |
| mVoiceButton.setOnKeyListener(mButtonsKeyListener); |
| |
| // pre-hide all the extraneous elements |
| mBadgeLabel.setVisibility(View.GONE); |
| |
| // Additional adjustments to make Dialog work for Search |
| |
| // Touching outside of the search dialog will dismiss it |
| setCanceledOnTouchOutside(true); |
| |
| // Set up broadcast filters |
| mCloseDialogsFilter = new IntentFilter(Intent.ACTION_CLOSE_SYSTEM_DIALOGS); |
| mPackageFilter = new IntentFilter(); |
| mPackageFilter.addAction(Intent.ACTION_PACKAGE_ADDED); |
| mPackageFilter.addAction(Intent.ACTION_PACKAGE_REMOVED); |
| mPackageFilter.addAction(Intent.ACTION_PACKAGE_CHANGED); |
| mPackageFilter.addDataScheme("package"); |
| |
| // Save voice intent for later queries/launching |
| mVoiceWebSearchIntent = new Intent(RecognizerIntent.ACTION_WEB_SEARCH); |
| mVoiceWebSearchIntent.putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, |
| RecognizerIntent.LANGUAGE_MODEL_WEB_SEARCH); |
| |
| mVoiceAppSearchIntent = new Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH); |
| } |
| |
| /** |
| * Set up the search dialog |
| * |
| * @param Returns true if search dialog launched, false if not |
| */ |
| public boolean show(String initialQuery, boolean selectInitialQuery, |
| ComponentName componentName, Bundle appSearchData, boolean globalSearch) { |
| if (isShowing()) { |
| // race condition - already showing but not handling events yet. |
| // in this case, just discard the "show" request |
| return true; |
| } |
| |
| // Get searchable info from search manager and use to set up other elements of UI |
| // Do this first so we can get out quickly if there's nothing to search |
| ISearchManager sms; |
| sms = ISearchManager.Stub.asInterface(ServiceManager.getService(Context.SEARCH_SERVICE)); |
| try { |
| mSearchable = sms.getSearchableInfo(componentName, globalSearch); |
| } catch (RemoteException e) { |
| mSearchable = null; |
| } |
| if (mSearchable == null) { |
| // unfortunately, we can't log here. it would be logspam every time the user |
| // clicks the "search" key on a non-search app |
| return false; |
| } |
| |
| // OK, we're going to show ourselves |
| super.show(); |
| |
| setupSearchableInfo(); |
| |
| mLaunchComponent = componentName; |
| mAppSearchData = appSearchData; |
| mGlobalSearchMode = globalSearch; |
| |
| // receive broadcasts |
| getContext().registerReceiver(mBroadcastReceiver, mCloseDialogsFilter); |
| getContext().registerReceiver(mBroadcastReceiver, mPackageFilter); |
| |
| // configure the autocomplete aspects of the input box |
| mSearchTextField.setOnItemClickListener(this); |
| mSearchTextField.setOnItemSelectedListener(this); |
| |
| // This conversion is necessary to force a preload of the EditText and thus force |
| // suggestions to be presented (even for an empty query) |
| if (initialQuery == null) { |
| initialQuery = ""; // This forces the preload to happen, triggering suggestions |
| } |
| |
| // attach the suggestions adapter, if suggestions are available |
| // The existence of a suggestions authority is the proxy for "suggestions available here" |
| if (mSearchable.getSuggestAuthority() == null) { |
| mSuggestionsAdapter = null; |
| mSearchTextField.setAdapter(mSuggestionsAdapter); |
| mSearchTextField.setText(initialQuery); |
| } else { |
| mSuggestionsAdapter = new SuggestionsAdapter(getContext(), mSearchable, |
| mSearchTextField); |
| mSearchTextField.setAdapter(mSuggestionsAdapter); |
| |
| // finally, load the user's initial text (which may trigger suggestions) |
| mSuggestionsAdapter.setNonUserQuery(false); |
| mSearchTextField.setText(initialQuery); |
| } |
| |
| if (selectInitialQuery) { |
| mSearchTextField.selectAll(); |
| } else { |
| mSearchTextField.setSelection(initialQuery.length()); |
| } |
| return true; |
| } |
| |
| /** |
| * The default show() for this Dialog is not supported. |
| */ |
| @Override |
| public void show() { |
| return; |
| } |
| |
| /** |
| * The search dialog is being dismissed, so handle all of the local shutdown operations. |
| * |
| * This function is designed to be idempotent so that dismiss() can be safely called at any time |
| * (even if already closed) and more likely to really dump any memory. No leaks! |
| */ |
| @Override |
| public void onStop() { |
| super.onStop(); |
| |
| setOnCancelListener(null); |
| setOnDismissListener(null); |
| |
| // stop receiving broadcasts (throws exception if none registered) |
| try { |
| getContext().unregisterReceiver(mBroadcastReceiver); |
| } catch (RuntimeException e) { |
| // This is OK - it just means we didn't have any registered |
| } |
| |
| // close any leftover cursor |
| if (mSuggestionsAdapter != null) { |
| mSuggestionsAdapter.changeCursor(null); |
| } |
| |
| // dump extra memory we're hanging on to |
| mLaunchComponent = null; |
| mAppSearchData = null; |
| mSearchable = null; |
| mSuggestionAction = null; |
| mSuggestionData = null; |
| mSuggestionQuery = null; |
| mActivityContext = null; |
| mPreviousSuggestionQuery = null; |
| mUserQuery = null; |
| } |
| |
| /** |
| * Save the minimal set of data necessary to recreate the search |
| * |
| * @return A bundle with the state of the dialog. |
| */ |
| @Override |
| public Bundle onSaveInstanceState() { |
| Bundle bundle = new Bundle(); |
| |
| // setup info so I can recreate this particular search |
| bundle.putParcelable(INSTANCE_KEY_COMPONENT, mLaunchComponent); |
| bundle.putBundle(INSTANCE_KEY_APPDATA, mAppSearchData); |
| bundle.putBoolean(INSTANCE_KEY_GLOBALSEARCH, mGlobalSearchMode); |
| |
| // UI state |
| bundle.putString(INSTANCE_KEY_DISPLAY_QUERY, mSearchTextField.getText().toString()); |
| bundle.putInt(INSTANCE_KEY_DISPLAY_SEL_START, mSearchTextField.getSelectionStart()); |
| bundle.putInt(INSTANCE_KEY_DISPLAY_SEL_END, mSearchTextField.getSelectionEnd()); |
| bundle.putString(INSTANCE_KEY_USER_QUERY, mUserQuery); |
| bundle.putString(INSTANCE_KEY_SUGGESTION_QUERY, mPreviousSuggestionQuery); |
| |
| int selectedElement = INSTANCE_SELECTED_QUERY; |
| if (mGoButton.isFocused()) { |
| selectedElement = INSTANCE_SELECTED_BUTTON; |
| } else if (mSearchTextField.isPopupShowing()) { |
| selectedElement = 0; // TODO mSearchTextField.getListSelection() // 0..n |
| } |
| bundle.putInt(INSTANCE_KEY_SELECTED_ELEMENT, selectedElement); |
| |
| return bundle; |
| } |
| |
| /** |
| * Restore the state of the dialog from a previously saved bundle. |
| * |
| * @param savedInstanceState The state of the dialog previously saved by |
| * {@link #onSaveInstanceState()}. |
| */ |
| @Override |
| public void onRestoreInstanceState(Bundle savedInstanceState) { |
| // Get the launch info |
| ComponentName launchComponent = savedInstanceState.getParcelable(INSTANCE_KEY_COMPONENT); |
| Bundle appSearchData = savedInstanceState.getBundle(INSTANCE_KEY_APPDATA); |
| boolean globalSearch = savedInstanceState.getBoolean(INSTANCE_KEY_GLOBALSEARCH); |
| |
| // get the UI state |
| String displayQuery = savedInstanceState.getString(INSTANCE_KEY_DISPLAY_QUERY); |
| int querySelStart = savedInstanceState.getInt(INSTANCE_KEY_DISPLAY_SEL_START, -1); |
| int querySelEnd = savedInstanceState.getInt(INSTANCE_KEY_DISPLAY_SEL_END, -1); |
| String userQuery = savedInstanceState.getString(INSTANCE_KEY_USER_QUERY); |
| int selectedElement = savedInstanceState.getInt(INSTANCE_KEY_SELECTED_ELEMENT); |
| String suggestionQuery = savedInstanceState.getString(INSTANCE_KEY_SUGGESTION_QUERY); |
| |
| // show the dialog. skip any show/hide animation, we want to go fast. |
| // send the text that actually generates the suggestions here; we'll replace the display |
| // text as necessary in a moment. |
| if (!show(suggestionQuery, false, launchComponent, appSearchData, globalSearch)) { |
| // for some reason, we couldn't re-instantiate |
| return; |
| } |
| |
| if (mSuggestionsAdapter != null) { |
| mSuggestionsAdapter.setNonUserQuery(true); |
| } |
| mSearchTextField.setText(displayQuery); |
| // TODO because the new query is (not) processed in another thread, we can't just |
| // take away this flag (yet). The better solution here is going to require a new API |
| // in AutoCompleteTextView which allows us to change the text w/o changing the suggestions. |
| // mSuggestionsAdapter.setNonUserQuery(false); |
| |
| // clean up the selection state |
| switch (selectedElement) { |
| case INSTANCE_SELECTED_BUTTON: |
| mGoButton.setEnabled(true); |
| mGoButton.setFocusable(true); |
| mGoButton.requestFocus(); |
| break; |
| case INSTANCE_SELECTED_QUERY: |
| if (querySelStart >= 0 && querySelEnd >= 0) { |
| mSearchTextField.requestFocus(); |
| mSearchTextField.setSelection(querySelStart, querySelEnd); |
| } |
| break; |
| default: |
| // defer selecting a list element until suggestion list appears |
| mPresetSelection = selectedElement; |
| // TODO mSearchTextField.setListSelection(selectedElement) |
| break; |
| } |
| } |
| |
| /** |
| * Hook for updating layout on a rotation |
| * |
| */ |
| public void onConfigurationChanged(Configuration newConfig) { |
| if (isShowing()) { |
| // Redraw (resources may have changed) |
| updateSearchButton(); |
| updateSearchBadge(); |
| updateQueryHint(); |
| } |
| } |
| |
| /** |
| * Use SearchableInfo record (from search manager service) to preconfigure the UI in various |
| * ways. |
| */ |
| private void setupSearchableInfo() { |
| if (mSearchable != null) { |
| mActivityContext = mSearchable.getActivityContext(getContext()); |
| |
| updateSearchButton(); |
| updateSearchBadge(); |
| updateQueryHint(); |
| updateVoiceButton(); |
| |
| // In order to properly configure the input method (if one is being used), we |
| // need to let it know if we'll be providing suggestions. Although it would be |
| // difficult/expensive to know if every last detail has been configured properly, we |
| // can at least see if a suggestions provider has been configured, and use that |
| // as our trigger. |
| int inputType = mSearchable.getInputType(); |
| // We only touch this if the input type is set up for text (which it almost certainly |
| // should be, in the case of search!) |
| if ((inputType & InputType.TYPE_MASK_CLASS) == InputType.TYPE_CLASS_TEXT) { |
| // The existence of a suggestions authority is the proxy for "suggestions |
| // are available here" |
| inputType &= ~InputType.TYPE_TEXT_FLAG_AUTO_COMPLETE; |
| if (mSearchable.getSuggestAuthority() != null) { |
| inputType |= InputType.TYPE_TEXT_FLAG_AUTO_COMPLETE; |
| } |
| } |
| mSearchTextField.setInputType(inputType); |
| mSearchTextField.setImeOptions(mSearchable.getImeOptions()); |
| } |
| } |
| |
| /** |
| * The list of installed packages has just changed. This means that our current context |
| * may no longer be valid. This would only happen if a package is installed/removed exactly |
| * when the search bar is open. So for now we're just going to close the search |
| * bar. |
| * |
| * Anything fancier would require some checks to see if the user's context was still valid. |
| * Which would be messier. |
| */ |
| public void onPackageListChange() { |
| cancel(); |
| } |
| |
| /** |
| * Update the text in the search button. Note: This is deprecated functionality, for |
| * 1.0 compatibility only. |
| */ |
| private void updateSearchButton() { |
| String textLabel = null; |
| Drawable iconLabel = null; |
| int textId = mSearchable.getSearchButtonText(); |
| if (textId != 0) { |
| textLabel = mActivityContext.getResources().getString(textId); |
| } else { |
| iconLabel = getContext().getResources(). |
| getDrawable(com.android.internal.R.drawable.ic_btn_search); |
| } |
| mGoButton.setText(textLabel); |
| mGoButton.setCompoundDrawablesWithIntrinsicBounds(iconLabel, null, null, null); |
| } |
| |
| /** |
| * Setup the search "Badge" if request by mode flags. |
| */ |
| private void updateSearchBadge() { |
| // assume both hidden |
| int visibility = View.GONE; |
| Drawable icon = null; |
| String text = null; |
| |
| // optionally show one or the other. |
| if (mSearchable.mBadgeIcon) { |
| icon = mActivityContext.getResources().getDrawable(mSearchable.getIconId()); |
| visibility = View.VISIBLE; |
| } else if (mSearchable.mBadgeLabel) { |
| text = mActivityContext.getResources().getText(mSearchable.getLabelId()).toString(); |
| visibility = View.VISIBLE; |
| } |
| |
| mBadgeLabel.setCompoundDrawablesWithIntrinsicBounds(icon, null, null, null); |
| mBadgeLabel.setText(text); |
| mBadgeLabel.setVisibility(visibility); |
| } |
| |
| /** |
| * Update the hint in the query text field. |
| */ |
| private void updateQueryHint() { |
| if (isShowing()) { |
| String hint = null; |
| if (mSearchable != null) { |
| int hintId = mSearchable.getHintId(); |
| if (hintId != 0) { |
| hint = mActivityContext.getString(hintId); |
| } |
| } |
| mSearchTextField.setHint(hint); |
| } |
| } |
| |
| /** |
| * Update the visibility of the voice button. There are actually two voice search modes, |
| * either of which will activate the button. |
| */ |
| private void updateVoiceButton() { |
| int visibility = View.GONE; |
| if (mSearchable.getVoiceSearchEnabled()) { |
| Intent testIntent = null; |
| if (mSearchable.getVoiceSearchLaunchWebSearch()) { |
| testIntent = mVoiceWebSearchIntent; |
| } else if (mSearchable.getVoiceSearchLaunchRecognizer()) { |
| testIntent = mVoiceAppSearchIntent; |
| } |
| if (testIntent != null) { |
| ResolveInfo ri = getContext().getPackageManager(). |
| resolveActivity(testIntent, PackageManager.MATCH_DEFAULT_ONLY); |
| if (ri != null) { |
| visibility = View.VISIBLE; |
| } |
| } |
| } |
| mVoiceButton.setVisibility(visibility); |
| } |
| |
| /** |
| * Listeners of various types |
| */ |
| |
| /** |
| * Dialog's OnKeyListener implements various search-specific functionality |
| * |
| * @param keyCode This is the keycode of the typed key, and is the same value as |
| * found in the KeyEvent parameter. |
| * @param event The complete event record for the typed key |
| * |
| * @return Return true if the event was handled here, or false if not. |
| */ |
| @Override |
| public boolean onKeyDown(int keyCode, KeyEvent event) { |
| switch (keyCode) { |
| case KeyEvent.KEYCODE_BACK: |
| cancel(); |
| return true; |
| case KeyEvent.KEYCODE_SEARCH: |
| if (TextUtils.getTrimmedLength(mSearchTextField.getText()) != 0) { |
| launchQuerySearch(KeyEvent.KEYCODE_UNKNOWN, null); |
| } else { |
| cancel(); |
| } |
| return true; |
| default: |
| SearchableInfo.ActionKeyInfo actionKey = mSearchable.findActionKey(keyCode); |
| if ((actionKey != null) && (actionKey.mQueryActionMsg != null)) { |
| launchQuerySearch(keyCode, actionKey.mQueryActionMsg); |
| return true; |
| } |
| break; |
| } |
| return false; |
| } |
| |
| /** |
| * Callback to watch the textedit field for empty/non-empty |
| */ |
| private TextWatcher mTextWatcher = new TextWatcher() { |
| |
| public void beforeTextChanged(CharSequence s, int start, int |
| before, int after) { } |
| |
| public void onTextChanged(CharSequence s, int start, |
| int before, int after) { |
| if (DBG_LOG_TIMING == 1) { |
| dbgLogTiming("onTextChanged()"); |
| } |
| updateWidgetState(); |
| // Only do suggestions if actually typed by user |
| if ((mSuggestionsAdapter != null) && !mSuggestionsAdapter.getNonUserQuery()) { |
| mPreviousSuggestionQuery = s.toString(); |
| mUserQuery = mSearchTextField.getText().toString(); |
| mUserQuerySelStart = mSearchTextField.getSelectionStart(); |
| mUserQuerySelEnd = mSearchTextField.getSelectionEnd(); |
| } |
| } |
| |
| public void afterTextChanged(Editable s) { } |
| }; |
| |
| /** |
| * Enable/Disable the cancel button based on edit text state (any text?) |
| */ |
| private void updateWidgetState() { |
| // enable the button if we have one or more non-space characters |
| boolean enabled = |
| TextUtils.getTrimmedLength(mSearchTextField.getText()) != 0; |
| |
| mGoButton.setEnabled(enabled); |
| mGoButton.setFocusable(enabled); |
| } |
| |
| private final static String[] ONE_LINE_FROM = {SearchManager.SUGGEST_COLUMN_TEXT_1 }; |
| private final static String[] ONE_LINE_ICONS_FROM = {SearchManager.SUGGEST_COLUMN_TEXT_1, |
| SearchManager.SUGGEST_COLUMN_ICON_1, |
| SearchManager.SUGGEST_COLUMN_ICON_2}; |
| private final static String[] TWO_LINE_FROM = {SearchManager.SUGGEST_COLUMN_TEXT_1, |
| SearchManager.SUGGEST_COLUMN_TEXT_2 }; |
| private final static String[] TWO_LINE_ICONS_FROM = {SearchManager.SUGGEST_COLUMN_TEXT_1, |
| SearchManager.SUGGEST_COLUMN_TEXT_2, |
| SearchManager.SUGGEST_COLUMN_ICON_1, |
| SearchManager.SUGGEST_COLUMN_ICON_2 }; |
| |
| private final static int[] ONE_LINE_TO = {com.android.internal.R.id.text1}; |
| private final static int[] ONE_LINE_ICONS_TO = {com.android.internal.R.id.text1, |
| com.android.internal.R.id.icon1, |
| com.android.internal.R.id.icon2}; |
| private final static int[] TWO_LINE_TO = {com.android.internal.R.id.text1, |
| com.android.internal.R.id.text2}; |
| private final static int[] TWO_LINE_ICONS_TO = {com.android.internal.R.id.text1, |
| com.android.internal.R.id.text2, |
| com.android.internal.R.id.icon1, |
| com.android.internal.R.id.icon2}; |
| |
| /** |
| * Safely retrieve the suggestions cursor adapter from the ListView |
| * |
| * @param adapterView The ListView containing our adapter |
| * @result The CursorAdapter that we installed, or null if not set |
| */ |
| private static CursorAdapter getSuggestionsAdapter(AdapterView<?> adapterView) { |
| CursorAdapter result = null; |
| if (adapterView != null) { |
| Object ad = adapterView.getAdapter(); |
| if (ad instanceof CursorAdapter) { |
| result = (CursorAdapter) ad; |
| } else if (ad instanceof WrapperListAdapter) { |
| result = (CursorAdapter) ((WrapperListAdapter)ad).getWrappedAdapter(); |
| } |
| } |
| return result; |
| } |
| |
| /** |
| * React to typing in the GO search button by refocusing to EditText. |
| * Continue typing the query. |
| */ |
| View.OnKeyListener mButtonsKeyListener = new View.OnKeyListener() { |
| public boolean onKey(View v, int keyCode, KeyEvent event) { |
| // also guard against possible race conditions (late arrival after dismiss) |
| if (mSearchable != null) { |
| return refocusingKeyListener(v, keyCode, event); |
| } |
| return false; |
| } |
| }; |
| |
| /** |
| * React to a click in the GO button by launching a search. |
| */ |
| View.OnClickListener mGoButtonClickListener = new View.OnClickListener() { |
| public void onClick(View v) { |
| // also guard against possible race conditions (late arrival after dismiss) |
| if (mSearchable != null) { |
| launchQuerySearch(KeyEvent.KEYCODE_UNKNOWN, null); |
| } |
| } |
| }; |
| |
| /** |
| * React to a click in the voice search button. |
| */ |
| View.OnClickListener mVoiceButtonClickListener = new View.OnClickListener() { |
| public void onClick(View v) { |
| try { |
| if (mSearchable.getVoiceSearchLaunchWebSearch()) { |
| getContext().startActivity(mVoiceWebSearchIntent); |
| dismiss(); |
| } else if (mSearchable.getVoiceSearchLaunchRecognizer()) { |
| Intent appSearchIntent = createVoiceAppSearchIntent(mVoiceAppSearchIntent); |
| getContext().startActivity(appSearchIntent); |
| dismiss(); |
| } |
| } catch (ActivityNotFoundException e) { |
| // Should not happen, since we check the availability of |
| // voice search before showing the button. But just in case... |
| Log.w(LOG_TAG, "Could not find voice search activity"); |
| } |
| } |
| }; |
| |
| /** |
| * Create and return an Intent that can launch the voice search activity, perform a specific |
| * voice transcription, and forward the results to the searchable activity. |
| * |
| * @param baseIntent The voice app search intent to start from |
| * @return A completely-configured intent ready to send to the voice search activity |
| */ |
| private Intent createVoiceAppSearchIntent(Intent baseIntent) { |
| // create the necessary intent to set up a search-and-forward operation |
| // in the voice search system. We have to keep the bundle separate, |
| // because it becomes immutable once it enters the PendingIntent |
| Intent queryIntent = new Intent(Intent.ACTION_SEARCH); |
| queryIntent.setComponent(mSearchable.mSearchActivity); |
| PendingIntent pending = PendingIntent.getActivity( |
| getContext(), 0, queryIntent, PendingIntent.FLAG_ONE_SHOT); |
| |
| // Now set up the bundle that will be inserted into the pending intent |
| // when it's time to do the search. We always build it here (even if empty) |
| // because the voice search activity will always need to insert "QUERY" into |
| // it anyway. |
| Bundle queryExtras = new Bundle(); |
| if (mAppSearchData != null) { |
| queryExtras.putBundle(SearchManager.APP_DATA, mAppSearchData); |
| } |
| |
| // Now build the intent to launch the voice search. Add all necessary |
| // extras to launch the voice recognizer, and then all the necessary extras |
| // to forward the results to the searchable activity |
| Intent voiceIntent = new Intent(baseIntent); |
| |
| // Add all of the configuration options supplied by the searchable's metadata |
| String languageModel = RecognizerIntent.LANGUAGE_MODEL_FREE_FORM; |
| String prompt = null; |
| String language = null; |
| int maxResults = 1; |
| Resources resources = mActivityContext.getResources(); |
| if (mSearchable.getVoiceLanguageModeId() != 0) { |
| languageModel = resources.getString(mSearchable.getVoiceLanguageModeId()); |
| } |
| if (mSearchable.getVoicePromptTextId() != 0) { |
| prompt = resources.getString(mSearchable.getVoicePromptTextId()); |
| } |
| if (mSearchable.getVoiceLanguageId() != 0) { |
| language = resources.getString(mSearchable.getVoiceLanguageId()); |
| } |
| if (mSearchable.getVoiceMaxResults() != 0) { |
| maxResults = mSearchable.getVoiceMaxResults(); |
| } |
| voiceIntent.putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, languageModel); |
| voiceIntent.putExtra(RecognizerIntent.EXTRA_PROMPT, prompt); |
| voiceIntent.putExtra(RecognizerIntent.EXTRA_LANGUAGE, language); |
| voiceIntent.putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, maxResults); |
| |
| // Add the values that configure forwarding the results |
| voiceIntent.putExtra(RecognizerIntent.EXTRA_RESULTS_PENDINGINTENT, pending); |
| voiceIntent.putExtra(RecognizerIntent.EXTRA_RESULTS_PENDINGINTENT_BUNDLE, queryExtras); |
| |
| return voiceIntent; |
| } |
| |
| /** |
| * React to the user typing "enter" or other hardwired keys while typing in the search box. |
| * This handles these special keys while the edit box has focus. |
| */ |
| View.OnKeyListener mTextKeyListener = new View.OnKeyListener() { |
| public boolean onKey(View v, int keyCode, KeyEvent event) { |
| if (keyCode == KeyEvent.KEYCODE_BACK) { |
| cancel(); |
| return true; |
| } |
| // also guard against possible race conditions (late arrival after dismiss) |
| if (mSearchable != null && |
| TextUtils.getTrimmedLength(mSearchTextField.getText()) > 0) { |
| if (DBG_LOG_TIMING == 1) { |
| dbgLogTiming("doTextKey()"); |
| } |
| // dispatch "typing in the list" first |
| if (mSearchTextField.isPopupShowing() && |
| mSearchTextField.getListSelection() != ListView.INVALID_POSITION) { |
| return onSuggestionsKey(v, keyCode, event); |
| } |
| // otherwise, dispatch an "edit view" key |
| switch (keyCode) { |
| case KeyEvent.KEYCODE_ENTER: |
| if (event.getAction() == KeyEvent.ACTION_UP) { |
| v.cancelLongPress(); |
| launchQuerySearch(KeyEvent.KEYCODE_UNKNOWN, null); |
| return true; |
| } |
| break; |
| case KeyEvent.KEYCODE_DPAD_DOWN: |
| // capture the EditText state, so we can restore the user entry later |
| mUserQuery = mSearchTextField.getText().toString(); |
| mUserQuerySelStart = mSearchTextField.getSelectionStart(); |
| mUserQuerySelEnd = mSearchTextField.getSelectionEnd(); |
| // pass through - we're just watching here |
| break; |
| default: |
| if (event.getAction() == KeyEvent.ACTION_DOWN) { |
| SearchableInfo.ActionKeyInfo actionKey = mSearchable.findActionKey(keyCode); |
| if ((actionKey != null) && (actionKey.mQueryActionMsg != null)) { |
| launchQuerySearch(keyCode, actionKey.mQueryActionMsg); |
| return true; |
| } |
| } |
| break; |
| } |
| } |
| return false; |
| } |
| }; |
| |
| /** |
| * React to the user typing while the suggestions are focused. First, check for action |
| * keys. If not handled, try refocusing regular characters into the EditText. In this case, |
| * replace the query text (start typing fresh text). |
| */ |
| private boolean onSuggestionsKey(View v, int keyCode, KeyEvent event) { |
| boolean handled = false; |
| // also guard against possible race conditions (late arrival after dismiss) |
| if (mSearchable != null) { |
| handled = doSuggestionsKey(v, keyCode, event); |
| } |
| return handled; |
| } |
| |
| /** |
| * Per UI design, we're going to "steer" any typed keystrokes back into the EditText |
| * box, even if the user has navigated the focus to the dropdown or to the GO button. |
| * |
| * @param v The view into which the keystroke was typed |
| * @param keyCode keyCode of entered key |
| * @param event Full KeyEvent record of entered key |
| */ |
| private boolean refocusingKeyListener(View v, int keyCode, KeyEvent event) { |
| boolean handled = false; |
| |
| if (!event.isSystem() && |
| (keyCode != KeyEvent.KEYCODE_DPAD_UP) && |
| (keyCode != KeyEvent.KEYCODE_DPAD_DOWN) && |
| (keyCode != KeyEvent.KEYCODE_DPAD_LEFT) && |
| (keyCode != KeyEvent.KEYCODE_DPAD_RIGHT) && |
| (keyCode != KeyEvent.KEYCODE_DPAD_CENTER)) { |
| // restore focus and give key to EditText ... |
| // but don't replace the user's query |
| mLeaveJammedQueryOnRefocus = true; |
| if (mSearchTextField.requestFocus()) { |
| handled = mSearchTextField.dispatchKeyEvent(event); |
| } |
| mLeaveJammedQueryOnRefocus = false; |
| } |
| return handled; |
| } |
| |
| /** |
| * Update query text based on transitions in and out of suggestions list. |
| */ |
| /* |
| * TODO - figure out if this logic is required for the autocomplete text view version |
| |
| OnFocusChangeListener mSuggestFocusListener = new OnFocusChangeListener() { |
| public void onFocusChange(View v, boolean hasFocus) { |
| // also guard against possible race conditions (late arrival after dismiss) |
| if (mSearchable == null) { |
| return; |
| } |
| // Update query text based on navigation in to/out of the suggestions list |
| if (hasFocus) { |
| // Entering the list view - record selection point from user's query |
| mUserQuery = mSearchTextField.getText().toString(); |
| mUserQuerySelStart = mSearchTextField.getSelectionStart(); |
| mUserQuerySelEnd = mSearchTextField.getSelectionEnd(); |
| // then update the query to match the entered selection |
| jamSuggestionQuery(true, mSuggestionsList, |
| mSuggestionsList.getSelectedItemPosition()); |
| } else { |
| // Exiting the list view |
| |
| if (mSuggestionsList.getSelectedItemPosition() < 0) { |
| // Direct exit - Leave new suggestion in place (do nothing) |
| } else { |
| // Navigation exit - restore user's query text |
| if (!mLeaveJammedQueryOnRefocus) { |
| jamSuggestionQuery(false, null, -1); |
| } |
| } |
| } |
| |
| } |
| }; |
| */ |
| |
| /** |
| * This is the listener for the ACTION_CLOSE_SYSTEM_DIALOGS intent. It's an indication that |
| * we should close ourselves immediately, in order to allow a higher-priority UI to take over |
| * (e.g. phone call received). |
| */ |
| private BroadcastReceiver mBroadcastReceiver = new BroadcastReceiver() { |
| @Override |
| public void onReceive(Context context, Intent intent) { |
| String action = intent.getAction(); |
| if (Intent.ACTION_CLOSE_SYSTEM_DIALOGS.equals(action)) { |
| cancel(); |
| } else if (Intent.ACTION_PACKAGE_ADDED.equals(action) |
| || Intent.ACTION_PACKAGE_REMOVED.equals(action) |
| || Intent.ACTION_PACKAGE_CHANGED.equals(action)) { |
| onPackageListChange(); |
| } |
| } |
| }; |
| |
| @Override |
| public void cancel() { |
| // We made sure the IME was displayed, so also make sure it is closed |
| // when we go away. |
| InputMethodManager imm = (InputMethodManager)getContext() |
| .getSystemService(Context.INPUT_METHOD_SERVICE); |
| if (imm != null) { |
| imm.hideSoftInputFromWindow( |
| getWindow().getDecorView().getWindowToken(), 0); |
| } |
| |
| super.cancel(); |
| } |
| |
| /** |
| * Various ways to launch searches |
| */ |
| |
| /** |
| * React to the user clicking the "GO" button. Hide the UI and launch a search. |
| * |
| * @param actionKey Pass a keycode if the launch was triggered by an action key. Pass |
| * KeyEvent.KEYCODE_UNKNOWN for no actionKey code. |
| * @param actionMsg Pass the suggestion-provided message if the launch was triggered by an |
| * action key. Pass null for no actionKey message. |
| */ |
| private void launchQuerySearch(int actionKey, final String actionMsg) { |
| final String query = mSearchTextField.getText().toString(); |
| final Bundle appData = mAppSearchData; |
| final SearchableInfo si = mSearchable; // cache briefly (dismiss() nulls it) |
| dismiss(); |
| sendLaunchIntent(Intent.ACTION_SEARCH, null, query, appData, actionKey, actionMsg, si); |
| } |
| |
| /** |
| * React to the user typing an action key while in the suggestions list |
| */ |
| private boolean doSuggestionsKey(View v, int keyCode, KeyEvent event) { |
| // Exit early in case of race condition |
| if (mSuggestionsAdapter == null) { |
| return false; |
| } |
| if (event.getAction() == KeyEvent.ACTION_DOWN) { |
| if (DBG_LOG_TIMING == 1) { |
| dbgLogTiming("doSuggestionsKey()"); |
| } |
| |
| // First, check for enter or search (both of which we'll treat as a "click") |
| if (keyCode == KeyEvent.KEYCODE_ENTER || keyCode == KeyEvent.KEYCODE_SEARCH) { |
| int position = mSearchTextField.getListSelection(); |
| return launchSuggestion(mSuggestionsAdapter, position); |
| } |
| |
| // Next, check for left/right moves, which we use to "return" the user to the edit view |
| if (keyCode == KeyEvent.KEYCODE_DPAD_LEFT || keyCode == KeyEvent.KEYCODE_DPAD_RIGHT) { |
| // give "focus" to text editor, but don't restore the user's original query |
| int selPoint = (keyCode == KeyEvent.KEYCODE_DPAD_LEFT) ? |
| 0 : mSearchTextField.length(); |
| mSearchTextField.setSelection(selPoint); |
| mSearchTextField.setListSelection(0); |
| mSearchTextField.clearListSelection(); |
| return true; |
| } |
| |
| // Next, check for an "up and out" move |
| if (keyCode == KeyEvent.KEYCODE_DPAD_UP && 0 == mSearchTextField.getListSelection()) { |
| jamSuggestionQuery(false, null, -1); |
| // let ACTV complete the move |
| return false; |
| } |
| |
| // Next, check for an "action key" |
| SearchableInfo.ActionKeyInfo actionKey = mSearchable.findActionKey(keyCode); |
| if ((actionKey != null) && |
| ((actionKey.mSuggestActionMsg != null) || |
| (actionKey.mSuggestActionMsgColumn != null))) { |
| // launch suggestion using action key column |
| int position = mSearchTextField.getListSelection(); |
| if (position >= 0) { |
| Cursor c = mSuggestionsAdapter.getCursor(); |
| if (c.moveToPosition(position)) { |
| final String actionMsg = getActionKeyMessage(c, actionKey); |
| if (actionMsg != null && (actionMsg.length() > 0)) { |
| // shut down search bar and launch the activity |
| // cache everything we need because dismiss releases mems |
| setupSuggestionIntent(c, mSearchable); |
| final String query = mSearchTextField.getText().toString(); |
| final Bundle appData = mAppSearchData; |
| SearchableInfo si = mSearchable; |
| String suggestionAction = mSuggestionAction; |
| Uri suggestionData = mSuggestionData; |
| String suggestionQuery = mSuggestionQuery; |
| dismiss(); |
| sendLaunchIntent(suggestionAction, suggestionData, |
| suggestionQuery, appData, |
| keyCode, actionMsg, si); |
| return true; |
| } |
| } |
| } |
| } |
| } |
| return false; |
| } |
| |
| /** |
| * Set or reset the user query to follow the selections in the suggestions |
| * |
| * @param jamQuery True means to set the query, false means to reset it to the user's choice |
| */ |
| private void jamSuggestionQuery(boolean jamQuery, AdapterView<?> parent, int position) { |
| // quick check against race conditions |
| if (mSearchable == null) { |
| return; |
| } |
| |
| mSuggestionsAdapter.setNonUserQuery(true); // disables any suggestions processing |
| if (jamQuery) { |
| CursorAdapter ca = getSuggestionsAdapter(parent); |
| Cursor c = ca.getCursor(); |
| if (c.moveToPosition(position)) { |
| setupSuggestionIntent(c, mSearchable); |
| String jamText = null; |
| |
| // Simple heuristic for selecting text with which to rewrite the query. |
| if (mSuggestionQuery != null) { |
| jamText = mSuggestionQuery; |
| } else if (mSearchable.mQueryRewriteFromData && (mSuggestionData != null)) { |
| jamText = mSuggestionData.toString(); |
| } else if (mSearchable.mQueryRewriteFromText) { |
| try { |
| int column = c.getColumnIndexOrThrow(SearchManager.SUGGEST_COLUMN_TEXT_1); |
| jamText = c.getString(column); |
| } catch (RuntimeException e) { |
| // no work here, jamText is null |
| } |
| } |
| if (jamText != null) { |
| mSearchTextField.setText(jamText); |
| /* mSearchTextField.selectAll(); */ // this didn't work anyway in the old UI |
| // TODO this is only needed in the model where we have a selection in the ACTV |
| // and in the dropdown at the same time. |
| mSearchTextField.setSelection(jamText.length()); |
| } |
| } |
| } else { |
| // reset user query |
| mSearchTextField.setText(mUserQuery); |
| try { |
| mSearchTextField.setSelection(mUserQuerySelStart, mUserQuerySelEnd); |
| } catch (IndexOutOfBoundsException e) { |
| // In case of error, just select all |
| Log.e(LOG_TAG, "Caught IndexOutOfBoundsException while setting selection. " + |
| "start=" + mUserQuerySelStart + " end=" + mUserQuerySelEnd + |
| " text=\"" + mUserQuery + "\""); |
| mSearchTextField.selectAll(); |
| } |
| } |
| // TODO because the new query is (not) processed in another thread, we can't just |
| // take away this flag (yet). The better solution here is going to require a new API |
| // in AutoCompleteTextView which allows us to change the text w/o changing the suggestions. |
| // mSuggestionsAdapter.setNonUserQuery(false); |
| } |
| |
| /** |
| * Assemble a search intent and send it. |
| * |
| * @param action The intent to send, typically Intent.ACTION_SEARCH |
| * @param data The data for the intent |
| * @param query The user text entered (so far) |
| * @param appData The app data bundle (if supplied) |
| * @param actionKey If the intent was triggered by an action key, e.g. KEYCODE_CALL, it will |
| * be sent here. Pass KeyEvent.KEYCODE_UNKNOWN for no actionKey code. |
| * @param actionMsg If the intent was triggered by an action key, e.g. KEYCODE_CALL, the |
| * corresponding tag message will be sent here. Pass null for no actionKey message. |
| * @param si Reference to the current SearchableInfo. Passed here so it can be used even after |
| * we've called dismiss(), which attempts to null mSearchable. |
| */ |
| private void sendLaunchIntent(final String action, final Uri data, final String query, |
| final Bundle appData, int actionKey, final String actionMsg, final SearchableInfo si) { |
| Intent launcher = new Intent(action); |
| |
| if (query != null) { |
| launcher.putExtra(SearchManager.QUERY, query); |
| } |
| |
| if (data != null) { |
| launcher.setData(data); |
| } |
| |
| if (appData != null) { |
| launcher.putExtra(SearchManager.APP_DATA, appData); |
| } |
| |
| // add launch info (action key, etc.) |
| if (actionKey != KeyEvent.KEYCODE_UNKNOWN) { |
| launcher.putExtra(SearchManager.ACTION_KEY, actionKey); |
| launcher.putExtra(SearchManager.ACTION_MSG, actionMsg); |
| } |
| |
| // attempt to enforce security requirement (no 3rd-party intents) |
| launcher.setComponent(si.mSearchActivity); |
| |
| getContext().startActivity(launcher); |
| } |
| |
| /** |
| * Shared code for launching a query from a suggestion. |
| * @param ca The cursor adapter containing the suggestions |
| * @param position The suggestion we'll be launching from |
| * @return true if a successful launch, false if could not (e.g. bad position) |
| */ |
| private boolean launchSuggestion(CursorAdapter ca, int position) { |
| Cursor c = ca.getCursor(); |
| if ((c != null) && c.moveToPosition(position)) { |
| setupSuggestionIntent(c, mSearchable); |
| |
| final Bundle appData = mAppSearchData; |
| SearchableInfo si = mSearchable; |
| String suggestionAction = mSuggestionAction; |
| Uri suggestionData = mSuggestionData; |
| String suggestionQuery = mSuggestionQuery; |
| dismiss(); |
| sendLaunchIntent(suggestionAction, suggestionData, suggestionQuery, appData, |
| KeyEvent.KEYCODE_UNKNOWN, null, si); |
| return true; |
| } |
| return false; |
| } |
| |
| /** |
| * When a particular suggestion has been selected, perform the various lookups required |
| * to use the suggestion. This includes checking the cursor for suggestion-specific data, |
| * and/or falling back to the XML for defaults; It also creates REST style Uri data when |
| * the suggestion includes a data id. |
| * |
| * NOTE: Return values are in member variables mSuggestionAction & mSuggestionData. |
| * |
| * @param c The suggestions cursor, moved to the row of the user's selection |
| * @param si The searchable activity's info record |
| */ |
| void setupSuggestionIntent(Cursor c, SearchableInfo si) { |
| try { |
| // use specific action if supplied, or default action if supplied, or fixed default |
| mSuggestionAction = null; |
| int mColumn = c.getColumnIndex(SearchManager.SUGGEST_COLUMN_INTENT_ACTION); |
| if (mColumn >= 0) { |
| final String action = c.getString(mColumn); |
| if (action != null) { |
| mSuggestionAction = action; |
| } |
| } |
| if (mSuggestionAction == null) { |
| mSuggestionAction = si.getSuggestIntentAction(); |
| } |
| if (mSuggestionAction == null) { |
| mSuggestionAction = Intent.ACTION_SEARCH; |
| } |
| |
| // use specific data if supplied, or default data if supplied |
| String data = null; |
| mColumn = c.getColumnIndex(SearchManager.SUGGEST_COLUMN_INTENT_DATA); |
| if (mColumn >= 0) { |
| final String rowData = c.getString(mColumn); |
| if (rowData != null) { |
| data = rowData; |
| } |
| } |
| if (data == null) { |
| data = si.getSuggestIntentData(); |
| } |
| |
| // then, if an ID was provided, append it. |
| if (data != null) { |
| mColumn = c.getColumnIndex(SearchManager.SUGGEST_COLUMN_INTENT_DATA_ID); |
| if (mColumn >= 0) { |
| final String id = c.getString(mColumn); |
| if (id != null) { |
| data = data + "/" + Uri.encode(id); |
| } |
| } |
| } |
| mSuggestionData = (data == null) ? null : Uri.parse(data); |
| |
| mSuggestionQuery = null; |
| mColumn = c.getColumnIndex(SearchManager.SUGGEST_COLUMN_QUERY); |
| if (mColumn >= 0) { |
| final String query = c.getString(mColumn); |
| if (query != null) { |
| mSuggestionQuery = query; |
| } |
| } |
| } catch (RuntimeException e ) { |
| int rowNum; |
| try { // be really paranoid now |
| rowNum = c.getPosition(); |
| } catch (RuntimeException e2 ) { |
| rowNum = -1; |
| } |
| Log.w(LOG_TAG, "Search Suggestions cursor at row " + rowNum + |
| " returned exception" + e.toString()); |
| } |
| } |
| |
| /** |
| * For a given suggestion and a given cursor row, get the action message. If not provided |
| * by the specific row/column, also check for a single definition (for the action key). |
| * |
| * @param c The cursor providing suggestions |
| * @param actionKey The actionkey record being examined |
| * |
| * @return Returns a string, or null if no action key message for this suggestion |
| */ |
| private String getActionKeyMessage(Cursor c, final SearchableInfo.ActionKeyInfo actionKey) { |
| String result = null; |
| // check first in the cursor data, for a suggestion-specific message |
| final String column = actionKey.mSuggestActionMsgColumn; |
| if (column != null) { |
| try { |
| int colId = c.getColumnIndexOrThrow(column); |
| result = c.getString(colId); |
| } catch (RuntimeException e) { |
| // OK - result is already null |
| } |
| } |
| // If the cursor didn't give us a message, see if there's a single message defined |
| // for the actionkey (for all suggestions) |
| if (result == null) { |
| result = actionKey.mSuggestActionMsg; |
| } |
| return result; |
| } |
| |
| /** |
| * Local subclass for AutoCompleteTextView |
| * |
| * This exists entirely to override the threshold method. Otherwise we just use the class |
| * as-is. |
| */ |
| public static class SearchAutoComplete extends AutoCompleteTextView { |
| |
| public SearchAutoComplete(Context context) { |
| super(null); |
| } |
| |
| public SearchAutoComplete(Context context, AttributeSet attrs) { |
| super(context, attrs); |
| } |
| |
| public SearchAutoComplete(Context context, AttributeSet attrs, int defStyle) { |
| super(context, attrs, defStyle); |
| } |
| |
| /** |
| * We never allow ACTV to automatically replace the text, since we use "jamSuggestionQuery" |
| * to do that. There's no point in letting ACTV do this here, because in the search UI, |
| * as soon as we click a suggestion, we're going to start shutting things down. |
| */ |
| @Override |
| public void replaceText(CharSequence text) { |
| } |
| |
| /** |
| * We always return true, so that the effective threshold is "zero". This allows us |
| * to provide "null" suggestions such as "just show me some recent entries". |
| */ |
| @Override |
| public boolean enoughToFilter() { |
| return true; |
| } |
| } |
| |
| /** |
| * Support for AutoCompleteTextView-based suggestions |
| */ |
| /** |
| * This class provides the filtering-based interface to suggestions providers. |
| * It is hardwired in a couple of places to support GoogleSearch - for example, it supports |
| * two-line suggestions, but it does not support icons. |
| */ |
| private static class SuggestionsAdapter extends SimpleCursorAdapter { |
| private final String TAG = "SuggestionsAdapter"; |
| |
| SearchableInfo mSearchable; |
| private Resources mProviderResources; |
| |
| // These private variables are shared by the filter thread and must be protected |
| private WeakReference<Cursor> mRecentCursor = new WeakReference<Cursor>(null); |
| private boolean mNonUserQuery = false; |
| private AutoCompleteTextView mParentView; |
| |
| public SuggestionsAdapter(Context context, SearchableInfo searchable, |
| AutoCompleteTextView actv) { |
| super(context, -1, null, null, null); |
| mSearchable = searchable; |
| mParentView = actv; |
| |
| // set up provider resources (gives us icons, etc.) |
| Context activityContext = mSearchable.getActivityContext(mContext); |
| Context providerContext = mSearchable.getProviderContext(mContext, activityContext); |
| mProviderResources = providerContext.getResources(); |
| } |
| |
| /** |
| * Set this field (temporarily!) to disable suggestions updating. This allows us |
| * to change the string in the text view without changing the suggestions list. |
| */ |
| public void setNonUserQuery(boolean nonUserQuery) { |
| synchronized (this) { |
| mNonUserQuery = nonUserQuery; |
| } |
| } |
| |
| public boolean getNonUserQuery() { |
| synchronized (this) { |
| return mNonUserQuery; |
| } |
| } |
| |
| /** |
| * Use the search suggestions provider to obtain a live cursor. This will be called |
| * in a worker thread, so it's OK if the query is slow (e.g. round trip for suggestions). |
| * The results will be processed in the UI thread and changeCursor() will be called. |
| * |
| * In order to provide the Search Mgr functionality of seeing your query change as you |
| * scroll through the list, we have to be able to jam new text into the string without |
| * retriggering the suggestions. We do that here via the "nonUserQuery" flag. In that |
| * case we simply return the existing cursor. |
| * |
| * TODO: Dianne suggests that this should simply be promoted into an AutoCompleteTextView |
| * behavior (perhaps optionally). |
| * |
| * TODO: The "nonuserquery" logic has a race condition because it happens in another thread. |
| * This also needs to be fixed. |
| */ |
| @Override |
| public Cursor runQueryOnBackgroundThread(CharSequence constraint) { |
| String query = (constraint == null) ? "" : constraint.toString(); |
| Cursor c = null; |
| synchronized (this) { |
| if (mNonUserQuery) { |
| c = mRecentCursor.get(); |
| mNonUserQuery = false; |
| } |
| } |
| if (c == null) { |
| c = getSuggestions(mSearchable, query); |
| synchronized (this) { |
| mRecentCursor = new WeakReference<Cursor>(c); |
| } |
| } |
| return c; |
| } |
| |
| /** |
| * Overriding changeCursor() allows us to change not only the cursor, but by sampling |
| * the cursor's columns, the actual display characteristics of the list. |
| */ |
| @Override |
| public void changeCursor(Cursor c) { |
| |
| // first, check for various conditions that disqualify this cursor |
| if ((c == null) || (c.getCount() == 0)) { |
| // no cursor, or cursor with no data |
| changeCursorAndColumns(null, null, null); |
| if (c != null) { |
| c.close(); |
| } |
| return; |
| } |
| |
| // check cursor before trying to create list views from it |
| int colId = c.getColumnIndex("_id"); |
| int col1 = c.getColumnIndex(SearchManager.SUGGEST_COLUMN_TEXT_1); |
| int col2 = c.getColumnIndex(SearchManager.SUGGEST_COLUMN_TEXT_2); |
| int colIc1 = c.getColumnIndex(SearchManager.SUGGEST_COLUMN_ICON_1); |
| int colIc2 = c.getColumnIndex(SearchManager.SUGGEST_COLUMN_ICON_2); |
| |
| boolean minimal = (colId >= 0) && (col1 >= 0); |
| boolean hasIcons = (colIc1 >= 0) && (colIc2 >= 0); |
| boolean has2Lines = col2 >= 0; |
| |
| if (minimal) { |
| int layout; |
| String[] from; |
| int[] to; |
| |
| if (hasIcons) { |
| if (has2Lines) { |
| layout = com.android.internal.R.layout.search_dropdown_item_icons_2line; |
| from = TWO_LINE_ICONS_FROM; |
| to = TWO_LINE_ICONS_TO; |
| } else { |
| layout = com.android.internal.R.layout.search_dropdown_item_icons_1line; |
| from = ONE_LINE_ICONS_FROM; |
| to = ONE_LINE_ICONS_TO; |
| } |
| } else { |
| if (has2Lines) { |
| layout = com.android.internal.R.layout.search_dropdown_item_2line; |
| from = TWO_LINE_FROM; |
| to = TWO_LINE_TO; |
| } else { |
| layout = com.android.internal.R.layout.search_dropdown_item_1line; |
| from = ONE_LINE_FROM; |
| to = ONE_LINE_TO; |
| } |
| } |
| // Force the underlying ListView to discard and reload all layouts |
| // (Note, this should be optimized for cases where layout/cursor remain same) |
| mParentView.resetListAndClearViews(); |
| // Now actually set up the cursor, columns, and the list view |
| changeCursorAndColumns(c, from, to); |
| setViewResource(layout); |
| } else { |
| // Provide some help for developers instead of just silently discarding |
| Log.w(LOG_TAG, "Suggestions cursor discarded due to missing required columns."); |
| changeCursorAndColumns(null, null, null); |
| c.close(); |
| } |
| if ((colIc1 >= 0) != (colIc2 >= 0)) { |
| Log.w(LOG_TAG, "Suggestion icon column(s) discarded, must be 0 or 2 columns."); |
| } |
| } |
| |
| /** |
| * Overriding this allows us to write the selected query back into the box. |
| * NOTE: This is a vastly simplified version of SearchDialog.jamQuery() and does |
| * not universally support the search API. But it is sufficient for Google Search. |
| */ |
| @Override |
| public CharSequence convertToString(Cursor cursor) { |
| CharSequence result = null; |
| if (cursor != null) { |
| int column = cursor.getColumnIndex(SearchManager.SUGGEST_COLUMN_QUERY); |
| if (column >= 0) { |
| final String query = cursor.getString(column); |
| if (query != null) { |
| result = query; |
| } |
| } |
| } |
| return result; |
| } |
| |
| /** |
| * Get the query cursor for the search suggestions. |
| * |
| * TODO this is functionally identical to the version in SearchDialog.java. Perhaps it |
| * could be hoisted into SearchableInfo or some other shared spot. |
| * |
| * @param query The search text entered (so far) |
| * @return Returns a cursor with suggestions, or null if no suggestions |
| */ |
| private Cursor getSuggestions(final SearchableInfo searchable, final String query) { |
| Cursor cursor = null; |
| if (searchable.getSuggestAuthority() != null) { |
| try { |
| StringBuilder uriStr = new StringBuilder("content://"); |
| uriStr.append(searchable.getSuggestAuthority()); |
| |
| // if content path provided, insert it now |
| final String contentPath = searchable.getSuggestPath(); |
| if (contentPath != null) { |
| uriStr.append('/'); |
| uriStr.append(contentPath); |
| } |
| |
| // append standard suggestion query path |
| uriStr.append('/' + SearchManager.SUGGEST_URI_PATH_QUERY); |
| |
| // inject query, either as selection args or inline |
| String[] selArgs = null; |
| if (searchable.getSuggestSelection() != null) { // use selection if provided |
| selArgs = new String[] {query}; |
| } else { |
| uriStr.append('/'); // no sel, use REST pattern |
| uriStr.append(Uri.encode(query)); |
| } |
| |
| // finally, make the query |
| cursor = mContext.getContentResolver().query( |
| Uri.parse(uriStr.toString()), null, |
| searchable.getSuggestSelection(), selArgs, |
| null); |
| } catch (RuntimeException e) { |
| Log.w(TAG, "Search Suggestions query returned exception " + e.toString()); |
| cursor = null; |
| } |
| } |
| |
| return cursor; |
| } |
| |
| /** |
| * Overriding this allows us to affect the way that an icon is loaded. Specifically, |
| * we can be more controlling about the resource path (and allow icons to come from other |
| * packages). |
| * |
| * TODO: This is 100% identical to the version in SearchDialog.java |
| * |
| * @param v ImageView to receive an image |
| * @param value the value retrieved from the cursor |
| */ |
| @Override |
| public void setViewImage(ImageView v, String value) { |
| int resID; |
| Drawable img = null; |
| |
| try { |
| resID = Integer.parseInt(value); |
| if (resID != 0) { |
| img = mProviderResources.getDrawable(resID); |
| } |
| } catch (NumberFormatException nfe) { |
| // img = null; |
| } catch (NotFoundException e2) { |
| // img = null; |
| } |
| |
| // finally, set the image to whatever we've gotten |
| v.setImageDrawable(img); |
| } |
| |
| /** |
| * This method is overridden purely to provide a bit of protection against |
| * flaky content providers. |
| * |
| * TODO: This is 100% identical to the version in SearchDialog.java |
| * |
| * @see android.widget.ListAdapter#getView(int, View, ViewGroup) |
| */ |
| @Override |
| public View getView(int position, View convertView, ViewGroup parent) { |
| try { |
| return super.getView(position, convertView, parent); |
| } catch (RuntimeException e) { |
| Log.w(TAG, "Search Suggestions cursor returned exception " + e.toString()); |
| // what can I return here? |
| View v = newView(mContext, mCursor, parent); |
| if (v != null) { |
| TextView tv = (TextView) v.findViewById(com.android.internal.R.id.text1); |
| tv.setText(e.toString()); |
| } |
| return v; |
| } |
| } |
| |
| } |
| |
| /** |
| * Implements OnItemClickListener |
| */ |
| public void onItemClick(AdapterView<?> parent, View view, int position, long id) { |
| // Log.d(LOG_TAG, "onItemClick() position " + position); |
| launchSuggestion(mSuggestionsAdapter, position); |
| } |
| |
| /** |
| * Implements OnItemSelectedListener |
| */ |
| public void onItemSelected(AdapterView<?> parent, View view, int position, long id) { |
| // Log.d(LOG_TAG, "onItemSelected() position " + position); |
| jamSuggestionQuery(true, parent, position); |
| } |
| |
| /** |
| * Implements OnItemSelectedListener |
| */ |
| public void onNothingSelected(AdapterView<?> parent) { |
| // Log.d(LOG_TAG, "onNothingSelected()"); |
| } |
| |
| /** |
| * Debugging Support |
| */ |
| |
| /** |
| * For debugging only, sample the millisecond clock and log it. |
| * Uses AtomicLong so we can use in multiple threads |
| */ |
| private AtomicLong mLastLogTime = new AtomicLong(SystemClock.uptimeMillis()); |
| private void dbgLogTiming(final String caller) { |
| long millis = SystemClock.uptimeMillis(); |
| long oldTime = mLastLogTime.getAndSet(millis); |
| long delta = millis - oldTime; |
| final String report = millis + " (+" + delta + ") ticks for Search keystroke in " + caller; |
| Log.d(LOG_TAG,report); |
| } |
| } |