/* * 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.Message; import android.os.RemoteException; import android.os.ServiceManager; import android.os.SystemClock; import android.server.search.SearchableInfo; import android.text.Editable; import android.text.TextUtils; import android.text.TextWatcher; import android.util.Log; import android.view.Gravity; import android.view.KeyEvent; import android.view.View; import android.view.ViewGroup; import android.view.ViewTreeObserver; import android.view.Window; import android.view.WindowManager; import android.view.View.OnFocusChangeListener; import android.view.animation.Animation; import android.view.animation.AnimationUtils; import android.view.inputmethod.InputMethodManager; import android.view.inputmethod.EditorInfo; import android.widget.AdapterView; import android.widget.CursorAdapter; import android.widget.EditText; import android.widget.ImageButton; import android.widget.ImageView; import android.widget.LinearLayout; import android.widget.ListAdapter; 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.util.List; import java.util.concurrent.LinkedBlockingQueue; 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 { // 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 final Handler mHandler = new Handler(); // why isn't Dialog.mHandler shared? 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 View mSearchBarLayout; private TextView mBadgeLabel; private LinearLayout mSearchEditLayout; private EditText mSearchTextField; private ImageButton mGoButton; private ListView mSuggestionsList; private ViewTreeObserver mViewTreeObserver = null; // 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 SuggestionsRunner mSuggestionsRunner; private String mUserQuery = null; private int mUserQuerySelStart; private int mUserQuerySelEnd; private boolean mNonUserQuery = false; private boolean mLeaveJammedQueryOnRefocus = false; private String mPreviousSuggestionQuery = null; private Context mProviderContext; private Animation mSuggestionsEntry; private Animation mSuggestionsExit; private boolean mSkipNextAnimate; private int mPresetSelection = -1; private String mSuggestionAction = null; private Uri mSuggestionData = null; private String mSuggestionQuery = null; /** * 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 mSearchBarLayout = findViewById(com.android.internal.R.id.search_bar); mBadgeLabel = (TextView) findViewById(com.android.internal.R.id.search_badge); mSearchEditLayout = (LinearLayout)findViewById(com.android.internal.R.id.search_edit_frame); mSearchTextField = (EditText) findViewById(com.android.internal.R.id.search_src_text); mGoButton = (ImageButton) findViewById(com.android.internal.R.id.search_go_btn); mSuggestionsList = (ListView) findViewById(com.android.internal.R.id.search_suggest_list); // attach listeners mSearchTextField.addTextChangedListener(mTextWatcher); mSearchTextField.setOnKeyListener(mTextKeyListener); mGoButton.setOnClickListener(mGoButtonClickListener); mGoButton.setOnKeyListener(mButtonsKeyListener); mSuggestionsList.setOnItemClickListener(mSuggestionsListItemClickListener); mSuggestionsList.setOnKeyListener(mSuggestionsKeyListener); mSuggestionsList.setOnFocusChangeListener(mSuggestFocusListener); mSuggestionsList.setOnItemSelectedListener(mSuggestSelectedListener); // pre-hide all the extraneous elements mBadgeLabel.setVisibility(View.GONE); mSuggestionsList.setVisibility(View.GONE); // Additional adjustments to make Dialog work for Search // Touching outside of the search dialog will dismiss it setCanceledOnTouchOutside(true); // Preload animations mSuggestionsEntry = AnimationUtils.loadAnimation(getContext(), com.android.internal.R.anim.grow_fade_in); mSuggestionsExit = AnimationUtils.loadAnimation(getContext(), com.android.internal.R.anim.fade_out); // 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"); } /** * 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 if (mSuggestionsList != null) { mSuggestionsList.setVisibility(View.GONE); // prevent any flicker if was visible } super.show(); setupSearchableInfo(); // start the suggestions thread (which will mainly idle) mSuggestionsRunner = new SuggestionsRunner(); new Thread(mSuggestionsRunner, "SearchSuggestions").start(); mLaunchComponent = componentName; mAppSearchData = appSearchData; mGlobalSearchMode = globalSearch; // receive broadcasts getContext().registerReceiver(mBroadcastReceiver, mCloseDialogsFilter); getContext().registerReceiver(mBroadcastReceiver, mPackageFilter); mViewTreeObserver = mSearchBarLayout.getViewTreeObserver(); mViewTreeObserver.addOnGlobalLayoutListener(mGlobalLayoutListener); // finally, load the user's initial text (which may trigger suggestions) mNonUserQuery = false; if (initialQuery == null) { initialQuery = ""; // This forces the preload to happen, triggering suggestions } mSearchTextField.setText(initialQuery); // If it is not for global search, that means the search dialog is // launched to input a web address. if (!globalSearch) { mSearchTextField.setRawInputType(EditorInfo.TYPE_CLASS_TEXT | EditorInfo.TYPE_TEXT_VARIATION_URI); } else { mSearchTextField.setRawInputType(EditorInfo.TYPE_CLASS_TEXT | EditorInfo.TYPE_TEXT_VARIATION_NORMAL); } 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; } /** * Dismiss the search dialog. * * This function is designed to be idempotent so it can be safely called at any time * (even if already closed) and more likely to really dump any memory. No leaks! */ @Override public void dismiss() { if (isShowing()) { super.dismiss(); } 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 } // ignore layout notifications try { if (mViewTreeObserver != null) { mViewTreeObserver.removeGlobalOnLayoutListener(mGlobalLayoutListener); } } catch (RuntimeException e) { // This is OK - none registered or observer "dead" } mViewTreeObserver = null; // dump extra memory we're hanging on to if (mSuggestionsRunner != null) { mSuggestionsRunner.cancelSuggestions(); mSuggestionsRunner = null; } mLaunchComponent = null; mAppSearchData = null; mSearchable = null; mSuggestionAction = null; mSuggestionData = null; mSuggestionQuery = null; mActivityContext = null; mProviderContext = 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 ((mSuggestionsList.getVisibility() == View.VISIBLE) && mSuggestionsList.isFocused()) { selectedElement = mSuggestionsList.getSelectedItemPosition(); // 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; } mSkipNextAnimate = true; mNonUserQuery = true; mSearchTextField.setText(displayQuery); mNonUserQuery = 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; break; } } /** * Hook for updating layout on a rotation * */ public void onConfigurationChanged(Configuration newConfig) { if (isShowing()) { // Redraw (resources may have changed) 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()); mProviderContext = mSearchable.getProviderContext(getContext(), mActivityContext); updateSearchBadge(); updateQueryHint(); } } /** * 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(); } /** * 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); } } /** * 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 (!mNonUserQuery) { updateSuggestions(); 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); } /** * In response to a change in the query text, update the suggestions */ private void updateSuggestions() { final String queryText = mSearchTextField.getText().toString(); mPreviousSuggestionQuery = queryText; if (DBG_LOG_TIMING == 1) { dbgLogTiming("updateSuggestions()"); } mSuggestionsRunner.requestSuggestions(mSearchable, queryText); // For debugging purposes, put in a lot of strings (really fast typist) if (DBG_JAM_THREADING > 0) { for (int ii = 1; ii < DBG_JAM_THREADING; ++ii) { final String jamQuery = queryText + ii; mSuggestionsRunner.requestSuggestions(mSearchable, jamQuery); } // one final (correct) string for cleanup mSuggestionsRunner.requestSuggestions(mSearchable, queryText); } } /** * This class defines a queued message structure for processing user keystrokes, and a * thread that allows the suggestions to be gathered out-of-band, and allows us to skip * over multiple keystrokes if the typist is faster than the content provider. */ private class SuggestionsRunner implements Runnable { private class Request { final SearchableInfo mSearchableInfo; // query will set these final String mQueryText; final boolean cancelRequest; // cancellation will set this // simple constructors Request(final SearchableInfo searchable, final String queryText) { mSearchableInfo = searchable; mQueryText = queryText; cancelRequest = false; } Request() { mSearchableInfo = null; mQueryText = null; cancelRequest = true; } } private final LinkedBlockingQueue mSuggestionsQueue = new LinkedBlockingQueue(); /** * Queue up a suggestions request (non-blocking - can safely call from UI thread) */ public void requestSuggestions(final SearchableInfo searchable, final String queryText) { Request request = new Request(searchable, queryText); try { mSuggestionsQueue.put(request); } catch (InterruptedException e) { // discard the request. } } /** * Cancel blocking suggestions, discard any results, and shut down the thread. * (non-blocking - can safely call from UI thread) */ private void cancelSuggestions() { Request request = new Request(); try { mSuggestionsQueue.put(request); } catch (InterruptedException e) { // discard the request. // TODO can we do better here? } } /** * This runnable implements the logic for decoupling keystrokes from suggestions. * The logic isn't quite obvious here, so I'll try to describe it. * * Normally we simply sleep waiting for a keystroke. When a keystroke arrives, * we immediately dispatch a request to gather suggestions. * * But this can take a while, so by the time it comes back, more keystrokes may have * arrived. If anything happened while we were gathering the suggestion, we discard its * results, and then use the most recent keystroke to start the next suggestions request. * * Any request containing cancelRequest == true will cause the thread to immediately * terminate. */ public void run() { // outer blocking loop simply waits for a suggestion while (true) { try { Request request = mSuggestionsQueue.take(); if (request.cancelRequest) { return; } // since we were idle, what we're really interested is the final element // in the queue. So keep pulling until we get the last element. // TODO Could we just do some sort of takeHead() here? while (! mSuggestionsQueue.isEmpty()) { request = mSuggestionsQueue.take(); if (request.cancelRequest) { return; } } final Request useRequest = request; // now process the final element (unless it's a cancel - that can be discarded) if (useRequest.mSearchableInfo != null) { // go get the cursor. this is what takes time. final Cursor c = getSuggestions(useRequest.mSearchableInfo, useRequest.mQueryText); // We now have a suggestions result. But, if any new requests have arrived, // we're going to discard them - we don't want to waste time displaying // out-of-date results, we just want to get going on the next set. // Note, null cursor is a valid result (no suggestions). This logic also // supports the need to discard the results *and* stop the thread if a kill // request arrives during a query. if (mSuggestionsQueue.size() > 0) { if (c != null) { c.close(); } } else { mHandler.post(new Runnable() { public void run() { updateSuggestionsWithCursor(c, useRequest.mSearchableInfo); } }); } } } catch (InterruptedException e) { // loop back for more } // At this point the queue may contain zero-to-many new requests; We simply // loop back to handle them (or, block until new requests arrive) } } } /** * Back in the UI thread, handle incoming cursors */ 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}; /** * A new cursor (with suggestions) is ready for use. Update the UI. */ void updateSuggestionsWithCursor(Cursor c, final SearchableInfo searchable) { ListAdapter adapter = null; // first, check for various conditions that disqualify this cursor if ((c == null) || (c.getCount() == 0)) { // no cursor, or cursor with no data } else if ((searchable != mSearchable) || !isShowing()) { // race condition (suggestions arrived after conditions changed) } else { // 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; } } try { if (DBG_LOG_TIMING == 1) { dbgLogTiming("updateSuggestions(3)"); } adapter = new SuggestionsCursorAdapter(getContext(), layout, c, from, to, mProviderContext); if (DBG_LOG_TIMING == 1) { dbgLogTiming("updateSuggestions(4)"); } } catch (RuntimeException e) { Log.e(LOG_TAG, "Exception while creating SuggestionsCursorAdapter", e); } } // Provide some help for developers instead of just silently discarding if ((colIc1 >= 0) != (colIc2 >= 0)) { Log.w(LOG_TAG, "Suggestion icon column(s) discarded, must be 0 or 2 columns."); } else if (adapter == null) { Log.w(LOG_TAG, "Suggestions cursor discarded due to missing required columns."); } } // if we have a cursor but we're not using it (e.g. disqualified), close it now if ((c != null) && (adapter == null)) { c.close(); c = null; } // we only made an adapter if there were 1+ suggestions. Now, based on the existence // of the adapter, we'll also show/hide the list. discardListCursor(mSuggestionsList); if (adapter == null) { showSuggestions(false, !mSkipNextAnimate); } else { layoutSuggestionsList(); showSuggestions(true, !mSkipNextAnimate); } mSkipNextAnimate = false; if (DBG_LOG_TIMING == 1) { dbgLogTiming("updateSuggestions(5)"); } mSuggestionsList.setAdapter(adapter); // now that we have an adapter, we can actually adjust the selection & scroll positions if (mPresetSelection >= 0) { boolean bTouchMode = mSuggestionsList.isInTouchMode(); mSuggestionsList.setSelection(mPresetSelection); mPresetSelection = -1; } if (DBG_LOG_TIMING == 1) { dbgLogTiming("updateSuggestions(6)"); } } /** * Utility for showing & hiding the suggestions list. This is also responsible for triggering * animation, if any, at the right time. * * @param visible If true, show the suggestions, if false, hide them. * @param animate If true, use animation. If false, "just do it." */ private void showSuggestions(boolean visible, boolean animate) { if (visible) { if (animate && (mSuggestionsList.getVisibility() != View.VISIBLE)) { mSuggestionsList.startAnimation(mSuggestionsEntry); } mSuggestionsList.setVisibility(View.VISIBLE); } else { if (animate && (mSuggestionsList.getVisibility() != View.GONE)) { mSuggestionsList.startAnimation(mSuggestionsExit); } mSuggestionsList.setVisibility(View.GONE); } } /** * This helper class supports the suggestions list by allowing 3rd party (e.g. app) resources * to be used in suggestions */ private static class SuggestionsCursorAdapter extends SimpleCursorAdapter { private Resources mProviderResources; public SuggestionsCursorAdapter(Context context, int layout, Cursor c, String[] from, int[] to, Context providerContext) { super(context, layout, c, from, to); mProviderResources = providerContext.getResources(); } /** * 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). * * @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. */ @Override /** * @see android.widget.ListAdapter#getView(int, View, ViewGroup) */ public View getView(int position, View convertView, ViewGroup parent) { try { return super.getView(position, convertView, parent); } catch (RuntimeException e) { Log.w(LOG_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; } } } /** * Cleanly close the cursor being used by a ListView. Do this before replacing the adapter * or before closing the ListView. */ private void discardListCursor(ListView list) { CursorAdapter ca = getSuggestionsAdapter(list); if (ca != null) { Cursor c = ca.getCursor(); if (c != null) { ca.changeCursor(null); } } } /** * 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; } /** * Get the query cursor for the search suggestions. * * @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) { // if selection provided, use it selArgs = new String[] {query}; } else { uriStr.append('/'); // no sel, use REST pattern uriStr.append(Uri.encode(query)); } // finally, make the query if (DBG_LOG_TIMING == 1) { dbgLogTiming("getSuggestions(1)"); } cursor = getContext().getContentResolver().query( Uri.parse(uriStr.toString()), null, searchable.getSuggestSelection(), selArgs, null); if (DBG_LOG_TIMING == 1) { dbgLogTiming("getSuggestions(2)"); } } catch (RuntimeException e) { Log.w(LOG_TAG, "Search Suggestions query returned exception " + e.toString()); cursor = null; } } return cursor; } /** * 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 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) { // 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()"); } switch (keyCode) { case KeyEvent.KEYCODE_ENTER: case KeyEvent.KEYCODE_DPAD_CENTER: if (event.getAction() == KeyEvent.ACTION_UP) { v.cancelLongPress(); launchQuerySearch(KeyEvent.KEYCODE_UNKNOWN, null); return true; } 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). */ View.OnKeyListener mSuggestionsKeyListener = new View.OnKeyListener() { public boolean onKey(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); if (!handled) { handled = refocusingKeyListener(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. */ 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); } } } } }; /** * Update query text based on movement of selection in/out of suggestion list */ OnItemSelectedListener mSuggestSelectedListener = new OnItemSelectedListener() { public void onItemSelected(AdapterView parent, View view, int position, long id) { // Update query text while user navigates through suggestions list // also guard against possible race conditions (late arrival after dismiss) if (mSearchable != null && position >= 0 && mSuggestionsList.isFocused()) { jamSuggestionQuery(true, parent, position); } } // No action needed on this callback public void onNothingSelected(AdapterView parent) { } }; /** * 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(); } } }; /** * UI-thread handling of dialog dismiss. Called by mBroadcastReceiver.onReceive(). * * TODO: This is a really heavyweight solution for something that should be so simple. * For example, we already have a handler, in our superclass, why aren't we sharing that? * I think we need to investigate simplifying this entire methodology, or perhaps boosting * it up into the Dialog class. */ private static final int MESSAGE_DISMISS = 0; private Handler mDismissHandler = new Handler() { @Override public void handleMessage(Message msg) { if (msg.what == MESSAGE_DISMISS) { dismiss(); } } }; /** * Listener for layout changes in the main layout. I use this to dynamically clean up * the layout of the dropdown and make it "pixel perfect." */ private ViewTreeObserver.OnGlobalLayoutListener mGlobalLayoutListener = new ViewTreeObserver.OnGlobalLayoutListener() { // It's very important that layoutSuggestionsList() does not reset // the values more than once, or this becomes an infinite loop. public void onGlobalLayout() { layoutSuggestionsList(); } }; /** * 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) { 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) { AdapterView av = (AdapterView) v; int position = av.getSelectedItemPosition(); return launchSuggestion(av, position); } // Next, check for left/right moves while we'll manually grab and shift focus 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 mLeaveJammedQueryOnRefocus = true; if (mSearchTextField.requestFocus()) { mLeaveJammedQueryOnRefocus = false; if (keyCode == KeyEvent.KEYCODE_DPAD_LEFT) { mSearchTextField.setSelection(0); } else { mSearchTextField.setSelection(mSearchTextField.length()); } return true; } mLeaveJammedQueryOnRefocus = 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 ListView lv = (ListView) v; int position = lv.getSelectedItemPosition(); if (position >= 0) { CursorAdapter ca = getSuggestionsAdapter(lv); Cursor c = ca.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) { mNonUserQuery = 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(); } } } 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(); } } mNonUserQuery = 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); } /** * Handler for clicks in the suggestions list */ private OnItemClickListener mSuggestionsListItemClickListener = new OnItemClickListener() { public void onItemClick(AdapterView parent, View v, int position, long id) { // this guard protects against possible race conditions (late arrival of click) if (mSearchable != null) { launchSuggestion(parent, position); } } }; /** * Shared code for launching a query from a suggestion. * * @param av The AdapterView (really a ListView) containing the suggestions * @param position The suggestion we'll be launching from * * @return Returns true if a successful launch, false if could not (e.g. bad position) */ private boolean launchSuggestion(AdapterView av, int position) { CursorAdapter ca = getSuggestionsAdapter(av); 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; } /** * Manually adjust suggestions list into its perfectly-tweaked position. * * NOTE: This MUST not adjust the parameters if they are already set correctly, * or you create an infinite loop via the ViewTreeObserver.OnGlobalLayoutListener callback. */ private void layoutSuggestionsList() { final int FUDGE_SUGG_X = 1; final int FUDGE_SUGG_WIDTH = 2; int[] itemLoc = new int[2]; mSearchTextField.getLocationOnScreen(itemLoc); int x,width; x = itemLoc[0] + FUDGE_SUGG_X; width = mSearchTextField.getMeasuredWidth() + FUDGE_SUGG_WIDTH; // now set params and relayout ViewGroup.MarginLayoutParams lp; lp = (ViewGroup.MarginLayoutParams) mSuggestionsList.getLayoutParams(); boolean changing = (lp.width != width) || (lp.leftMargin != x); if (changing) { lp.leftMargin = x; lp.width = width; mSuggestionsList.setLayoutParams(lp); } } /** * 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; } /** * 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); } }