SuggestionsAdapter.java revision e678f46ec45076203f6260f8a26f56d838c6b6ff
1/*
2 * Copyright (C) 2009 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17package android.widget;
18
19import com.android.internal.R;
20
21import android.app.SearchDialog;
22import android.app.SearchManager;
23import android.app.SearchableInfo;
24import android.content.ComponentName;
25import android.content.ContentResolver;
26import android.content.Context;
27import android.content.ContentResolver.OpenResourceIdResult;
28import android.content.pm.ActivityInfo;
29import android.content.pm.PackageManager;
30import android.content.pm.PackageManager.NameNotFoundException;
31import android.content.res.ColorStateList;
32import android.content.res.Resources;
33import android.database.Cursor;
34import android.graphics.drawable.ColorDrawable;
35import android.graphics.drawable.Drawable;
36import android.graphics.drawable.StateListDrawable;
37import android.net.Uri;
38import android.os.Bundle;
39import android.text.Spannable;
40import android.text.SpannableString;
41import android.text.TextUtils;
42import android.text.style.TextAppearanceSpan;
43import android.util.Log;
44import android.util.SparseArray;
45import android.util.TypedValue;
46import android.view.View;
47import android.view.ViewGroup;
48import android.view.View.OnClickListener;
49
50import java.io.FileNotFoundException;
51import java.io.IOException;
52import java.io.InputStream;
53import java.util.WeakHashMap;
54
55/**
56 * Provides the contents for the suggestion drop-down list.in {@link SearchDialog}.
57 *
58 * @hide
59 */
60class SuggestionsAdapter extends ResourceCursorAdapter implements OnClickListener {
61
62    private static final boolean DBG = false;
63    private static final String LOG_TAG = "SuggestionsAdapter";
64    private static final int QUERY_LIMIT = 50;
65
66    static final int REFINE_NONE = 0;
67    static final int REFINE_BY_ENTRY = 1;
68    static final int REFINE_ALL = 2;
69
70    private SearchManager mSearchManager;
71    private SearchView mSearchView;
72    private SearchableInfo mSearchable;
73    private Context mProviderContext;
74    private WeakHashMap<String, Drawable.ConstantState> mOutsideDrawablesCache;
75    private boolean mClosed = false;
76    private int mQueryRefinement = REFINE_BY_ENTRY;
77
78    // URL color
79    private ColorStateList mUrlColor;
80
81    // Cached column indexes, updated when the cursor changes.
82    private int mText1Col;
83    private int mText2Col;
84    private int mText2UrlCol;
85    private int mIconName1Col;
86    private int mIconName2Col;
87    private int mFlagsCol;
88
89    static final int NONE = -1;
90
91    private final Runnable mStartSpinnerRunnable;
92    private final Runnable mStopSpinnerRunnable;
93
94    /**
95     * The amount of time we delay in the filter when the user presses the delete key.
96     * @see Filter#setDelayer(android.widget.Filter.Delayer).
97     */
98    private static final long DELETE_KEY_POST_DELAY = 500L;
99
100    public SuggestionsAdapter(Context context, SearchView searchView,
101            SearchableInfo searchable,
102            WeakHashMap<String, Drawable.ConstantState> outsideDrawablesCache) {
103        super(context,
104                com.android.internal.R.layout.search_dropdown_item_icons_2line,
105                null,   // no initial cursor
106                true);  // auto-requery
107        mSearchManager = (SearchManager) mContext.getSystemService(Context.SEARCH_SERVICE);
108        mSearchView = searchView;
109        mSearchable = searchable;
110        // set up provider resources (gives us icons, etc.)
111        Context activityContext = mSearchable.getActivityContext(mContext);
112        mProviderContext = mSearchable.getProviderContext(mContext, activityContext);
113
114        mOutsideDrawablesCache = outsideDrawablesCache;
115
116        mStartSpinnerRunnable = new Runnable() {
117                public void run() {
118                // mSearchView.setWorking(true); // TODO:
119                }
120            };
121
122        mStopSpinnerRunnable = new Runnable() {
123            public void run() {
124                // mSearchView.setWorking(false); // TODO:
125            }
126        };
127
128        // delay 500ms when deleting
129        getFilter().setDelayer(new Filter.Delayer() {
130
131            private int mPreviousLength = 0;
132
133            public long getPostingDelay(CharSequence constraint) {
134                if (constraint == null) return 0;
135
136                long delay = constraint.length() < mPreviousLength ? DELETE_KEY_POST_DELAY : 0;
137                mPreviousLength = constraint.length();
138                return delay;
139            }
140        });
141    }
142
143    /**
144     * Enables query refinement for all suggestions. This means that an additional icon
145     * will be shown for each entry. When clicked, the suggested text on that line will be
146     * copied to the query text field.
147     * <p>
148     *
149     * @param refine which queries to refine. Possible values are {@link #REFINE_NONE},
150     * {@link #REFINE_BY_ENTRY}, and {@link #REFINE_ALL}.
151     */
152    public void setQueryRefinement(int refineWhat) {
153        mQueryRefinement = refineWhat;
154    }
155
156    /**
157     * Returns the current query refinement preference.
158     * @return value of query refinement preference
159     */
160    public int getQueryRefinement() {
161        return mQueryRefinement;
162    }
163
164    /**
165     * Overridden to always return <code>false</code>, since we cannot be sure that
166     * suggestion sources return stable IDs.
167     */
168    @Override
169    public boolean hasStableIds() {
170        return false;
171    }
172
173    /**
174     * Use the search suggestions provider to obtain a live cursor.  This will be called
175     * in a worker thread, so it's OK if the query is slow (e.g. round trip for suggestions).
176     * The results will be processed in the UI thread and changeCursor() will be called.
177     */
178    @Override
179    public Cursor runQueryOnBackgroundThread(CharSequence constraint) {
180        if (DBG) Log.d(LOG_TAG, "runQueryOnBackgroundThread(" + constraint + ")");
181        String query = (constraint == null) ? "" : constraint.toString();
182        /**
183         * for in app search we show the progress spinner until the cursor is returned with
184         * the results.
185         */
186        Cursor cursor = null;
187        //mSearchView.getWindow().getDecorView().post(mStartSpinnerRunnable); // TODO:
188        try {
189            cursor = mSearchManager.getSuggestions(mSearchable, query, QUERY_LIMIT);
190            // trigger fill window so the spinner stays up until the results are copied over and
191            // closer to being ready
192            if (cursor != null) {
193                cursor.getCount();
194                return cursor;
195            }
196        } catch (RuntimeException e) {
197            Log.w(LOG_TAG, "Search suggestions query threw an exception.", e);
198        }
199        // If cursor is null or an exception was thrown, stop the spinner and return null.
200        // changeCursor doesn't get called if cursor is null
201        // mSearchView.getWindow().getDecorView().post(mStopSpinnerRunnable); // TODO:
202        return null;
203    }
204
205    public void close() {
206        if (DBG) Log.d(LOG_TAG, "close()");
207        changeCursor(null);
208        mClosed = true;
209    }
210
211    @Override
212    public void notifyDataSetChanged() {
213        if (DBG) Log.d(LOG_TAG, "notifyDataSetChanged");
214        super.notifyDataSetChanged();
215
216        // mSearchView.onDataSetChanged(); // TODO:
217
218        updateSpinnerState(getCursor());
219    }
220
221    @Override
222    public void notifyDataSetInvalidated() {
223        if (DBG) Log.d(LOG_TAG, "notifyDataSetInvalidated");
224        super.notifyDataSetInvalidated();
225
226        updateSpinnerState(getCursor());
227    }
228
229    private void updateSpinnerState(Cursor cursor) {
230        Bundle extras = cursor != null ? cursor.getExtras() : null;
231        if (DBG) {
232            Log.d(LOG_TAG, "updateSpinnerState - extra = "
233                + (extras != null
234                        ? extras.getBoolean(SearchManager.CURSOR_EXTRA_KEY_IN_PROGRESS)
235                        : null));
236        }
237        // Check if the Cursor indicates that the query is not complete and show the spinner
238        if (extras != null
239                && extras.getBoolean(SearchManager.CURSOR_EXTRA_KEY_IN_PROGRESS)) {
240            // mSearchView.getWindow().getDecorView().post(mStartSpinnerRunnable); // TODO:
241            return;
242        }
243        // If cursor is null or is done, stop the spinner
244        // mSearchView.getWindow().getDecorView().post(mStopSpinnerRunnable); // TODO:
245    }
246
247    /**
248     * Cache columns.
249     */
250    @Override
251    public void changeCursor(Cursor c) {
252        if (DBG) Log.d(LOG_TAG, "changeCursor(" + c + ")");
253
254        if (mClosed) {
255            Log.w(LOG_TAG, "Tried to change cursor after adapter was closed.");
256            if (c != null) c.close();
257            return;
258        }
259
260        try {
261            super.changeCursor(c);
262
263            if (c != null) {
264                mText1Col = c.getColumnIndex(SearchManager.SUGGEST_COLUMN_TEXT_1);
265                mText2Col = c.getColumnIndex(SearchManager.SUGGEST_COLUMN_TEXT_2);
266                mText2UrlCol = c.getColumnIndex(SearchManager.SUGGEST_COLUMN_TEXT_2_URL);
267                mIconName1Col = c.getColumnIndex(SearchManager.SUGGEST_COLUMN_ICON_1);
268                mIconName2Col = c.getColumnIndex(SearchManager.SUGGEST_COLUMN_ICON_2);
269                mFlagsCol = c.getColumnIndex(SearchManager.SUGGEST_COLUMN_FLAGS);
270            }
271        } catch (Exception e) {
272            Log.e(LOG_TAG, "error changing cursor and caching columns", e);
273        }
274    }
275
276    /**
277     * Tags the view with cached child view look-ups.
278     */
279    @Override
280    public View newView(Context context, Cursor cursor, ViewGroup parent) {
281        View v = super.newView(context, cursor, parent);
282        v.setTag(new ChildViewCache(v));
283        return v;
284    }
285
286    /**
287     * Cache of the child views of drop-drown list items, to avoid looking up the children
288     * each time the contents of a list item are changed.
289     */
290    private final static class ChildViewCache {
291        public final TextView mText1;
292        public final TextView mText2;
293        public final ImageView mIcon1;
294        public final ImageView mIcon2;
295        public final ImageView mIconRefine;
296
297        public ChildViewCache(View v) {
298            mText1 = (TextView) v.findViewById(com.android.internal.R.id.text1);
299            mText2 = (TextView) v.findViewById(com.android.internal.R.id.text2);
300            mIcon1 = (ImageView) v.findViewById(com.android.internal.R.id.icon1);
301            mIcon2 = (ImageView) v.findViewById(com.android.internal.R.id.icon2);
302            mIconRefine = (ImageView) v.findViewById(com.android.internal.R.id.edit_query);
303        }
304    }
305
306    @Override
307    public void bindView(View view, Context context, Cursor cursor) {
308        ChildViewCache views = (ChildViewCache) view.getTag();
309
310        int flags = 0;
311        if (mFlagsCol != -1) {
312            flags = cursor.getInt(mFlagsCol);
313        }
314        if (views.mText1 != null) {
315            String text1 = getStringOrNull(cursor, mText1Col);
316            setViewText(views.mText1, text1);
317        }
318        if (views.mText2 != null) {
319            // First check TEXT_2_URL
320            CharSequence text2 = getStringOrNull(cursor, mText2UrlCol);
321            if (text2 != null) {
322                text2 = formatUrl(text2);
323            } else {
324                text2 = getStringOrNull(cursor, mText2Col);
325            }
326
327            // If no second line of text is indicated, allow the first line of text
328            // to be up to two lines if it wants to be.
329            if (TextUtils.isEmpty(text2)) {
330                if (views.mText1 != null) {
331                    views.mText1.setSingleLine(false);
332                    views.mText1.setMaxLines(2);
333                }
334            } else {
335                if (views.mText1 != null) {
336                    views.mText1.setSingleLine(true);
337                    views.mText1.setMaxLines(1);
338                }
339            }
340            setViewText(views.mText2, text2);
341        }
342
343        if (views.mIcon1 != null) {
344            setViewDrawable(views.mIcon1, getIcon1(cursor));
345        }
346        if (views.mIcon2 != null) {
347            setViewDrawable(views.mIcon2, getIcon2(cursor));
348        }
349        if (mQueryRefinement == REFINE_ALL
350                || (mQueryRefinement == REFINE_BY_ENTRY
351                        && (flags & SearchManager.FLAG_QUERY_REFINEMENT) != 0)) {
352            views.mIconRefine.setVisibility(View.VISIBLE);
353            views.mIconRefine.setTag(views.mText1.getText());
354            views.mIconRefine.setOnClickListener(this);
355        } else {
356            views.mIconRefine.setVisibility(View.GONE);
357        }
358    }
359
360    public void onClick(View v) {
361        Object tag = v.getTag();
362        if (tag instanceof CharSequence) {
363            mSearchView.onQueryRefine((CharSequence) tag);
364        }
365    }
366
367    private CharSequence formatUrl(CharSequence url) {
368        if (mUrlColor == null) {
369            // Lazily get the URL color from the current theme.
370            TypedValue colorValue = new TypedValue();
371            mContext.getTheme().resolveAttribute(R.attr.textColorSearchUrl, colorValue, true);
372            mUrlColor = mContext.getResources().getColorStateList(colorValue.resourceId);
373        }
374
375        SpannableString text = new SpannableString(url);
376        text.setSpan(new TextAppearanceSpan(null, 0, 0, mUrlColor, null),
377                0, url.length(),
378                Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
379        return text;
380    }
381
382    private void setViewText(TextView v, CharSequence text) {
383        // Set the text even if it's null, since we need to clear any previous text.
384        v.setText(text);
385
386        if (TextUtils.isEmpty(text)) {
387            v.setVisibility(View.GONE);
388        } else {
389            v.setVisibility(View.VISIBLE);
390        }
391    }
392
393    private Drawable getIcon1(Cursor cursor) {
394        if (mIconName1Col < 0) {
395            return null;
396        }
397        String value = cursor.getString(mIconName1Col);
398        Drawable drawable = getDrawableFromResourceValue(value);
399        if (drawable != null) {
400            return drawable;
401        }
402        return getDefaultIcon1(cursor);
403    }
404
405    private Drawable getIcon2(Cursor cursor) {
406        if (mIconName2Col < 0) {
407            return null;
408        }
409        String value = cursor.getString(mIconName2Col);
410        return getDrawableFromResourceValue(value);
411    }
412
413    /**
414     * Sets the drawable in an image view, makes sure the view is only visible if there
415     * is a drawable.
416     */
417    private void setViewDrawable(ImageView v, Drawable drawable) {
418        // Set the icon even if the drawable is null, since we need to clear any
419        // previous icon.
420        v.setImageDrawable(drawable);
421
422        if (drawable == null) {
423            v.setVisibility(View.GONE);
424        } else {
425            v.setVisibility(View.VISIBLE);
426
427            // This is a hack to get any animated drawables (like a 'working' spinner)
428            // to animate. You have to setVisible true on an AnimationDrawable to get
429            // it to start animating, but it must first have been false or else the
430            // call to setVisible will be ineffective. We need to clear up the story
431            // about animated drawables in the future, see http://b/1878430.
432            drawable.setVisible(false, false);
433            drawable.setVisible(true, false);
434        }
435    }
436
437    /**
438     * Gets the text to show in the query field when a suggestion is selected.
439     *
440     * @param cursor The Cursor to read the suggestion data from. The Cursor should already
441     *        be moved to the suggestion that is to be read from.
442     * @return The text to show, or <code>null</code> if the query should not be
443     *         changed when selecting this suggestion.
444     */
445    @Override
446    public CharSequence convertToString(Cursor cursor) {
447        if (cursor == null) {
448            return null;
449        }
450
451        String query = getColumnString(cursor, SearchManager.SUGGEST_COLUMN_QUERY);
452        if (query != null) {
453            return query;
454        }
455
456        if (mSearchable.shouldRewriteQueryFromData()) {
457            String data = getColumnString(cursor, SearchManager.SUGGEST_COLUMN_INTENT_DATA);
458            if (data != null) {
459                return data;
460            }
461        }
462
463        if (mSearchable.shouldRewriteQueryFromText()) {
464            String text1 = getColumnString(cursor, SearchManager.SUGGEST_COLUMN_TEXT_1);
465            if (text1 != null) {
466                return text1;
467            }
468        }
469
470        return null;
471    }
472
473    /**
474     * This method is overridden purely to provide a bit of protection against
475     * flaky content providers.
476     *
477     * @see android.widget.ListAdapter#getView(int, View, ViewGroup)
478     */
479    @Override
480    public View getView(int position, View convertView, ViewGroup parent) {
481        try {
482            return super.getView(position, convertView, parent);
483        } catch (RuntimeException e) {
484            Log.w(LOG_TAG, "Search suggestions cursor threw exception.", e);
485            // Put exception string in item title
486            View v = newView(mContext, mCursor, parent);
487            if (v != null) {
488                ChildViewCache views = (ChildViewCache) v.getTag();
489                TextView tv = views.mText1;
490                tv.setText(e.toString());
491            }
492            return v;
493        }
494    }
495
496    /**
497     * Gets a drawable given a value provided by a suggestion provider.
498     *
499     * This value could be just the string value of a resource id
500     * (e.g., "2130837524"), in which case we will try to retrieve a drawable from
501     * the provider's resources. If the value is not an integer, it is
502     * treated as a Uri and opened with
503     * {@link ContentResolver#openOutputStream(android.net.Uri, String)}.
504     *
505     * All resources and URIs are read using the suggestion provider's context.
506     *
507     * If the string is not formatted as expected, or no drawable can be found for
508     * the provided value, this method returns null.
509     *
510     * @param drawableId a string like "2130837524",
511     *        "android.resource://com.android.alarmclock/2130837524",
512     *        or "content://contacts/photos/253".
513     * @return a Drawable, or null if none found
514     */
515    private Drawable getDrawableFromResourceValue(String drawableId) {
516        if (drawableId == null || drawableId.length() == 0 || "0".equals(drawableId)) {
517            return null;
518        }
519        try {
520            // First, see if it's just an integer
521            int resourceId = Integer.parseInt(drawableId);
522            // It's an int, look for it in the cache
523            String drawableUri = ContentResolver.SCHEME_ANDROID_RESOURCE
524                    + "://" + mProviderContext.getPackageName() + "/" + resourceId;
525            // Must use URI as cache key, since ints are app-specific
526            Drawable drawable = checkIconCache(drawableUri);
527            if (drawable != null) {
528                return drawable;
529            }
530            // Not cached, find it by resource ID
531            drawable = mProviderContext.getResources().getDrawable(resourceId);
532            // Stick it in the cache, using the URI as key
533            storeInIconCache(drawableUri, drawable);
534            return drawable;
535        } catch (NumberFormatException nfe) {
536            // It's not an integer, use it as a URI
537            Drawable drawable = checkIconCache(drawableId);
538            if (drawable != null) {
539                return drawable;
540            }
541            Uri uri = Uri.parse(drawableId);
542            drawable = getDrawable(uri);
543            storeInIconCache(drawableId, drawable);
544            return drawable;
545        } catch (Resources.NotFoundException nfe) {
546            // It was an integer, but it couldn't be found, bail out
547            Log.w(LOG_TAG, "Icon resource not found: " + drawableId);
548            return null;
549        }
550    }
551
552    /**
553     * Gets a drawable by URI, without using the cache.
554     *
555     * @return A drawable, or {@code null} if the drawable could not be loaded.
556     */
557    private Drawable getDrawable(Uri uri) {
558        try {
559            String scheme = uri.getScheme();
560            if (ContentResolver.SCHEME_ANDROID_RESOURCE.equals(scheme)) {
561                // Load drawables through Resources, to get the source density information
562                OpenResourceIdResult r =
563                    mProviderContext.getContentResolver().getResourceId(uri);
564                try {
565                    return r.r.getDrawable(r.id);
566                } catch (Resources.NotFoundException ex) {
567                    throw new FileNotFoundException("Resource does not exist: " + uri);
568                }
569            } else {
570                // Let the ContentResolver handle content and file URIs.
571                InputStream stream = mProviderContext.getContentResolver().openInputStream(uri);
572                if (stream == null) {
573                    throw new FileNotFoundException("Failed to open " + uri);
574                }
575                try {
576                    return Drawable.createFromStream(stream, null);
577                } finally {
578                    try {
579                        stream.close();
580                    } catch (IOException ex) {
581                        Log.e(LOG_TAG, "Error closing icon stream for " + uri, ex);
582                    }
583                }
584            }
585        } catch (FileNotFoundException fnfe) {
586            Log.w(LOG_TAG, "Icon not found: " + uri + ", " + fnfe.getMessage());
587            return null;
588        }
589    }
590
591    private Drawable checkIconCache(String resourceUri) {
592        Drawable.ConstantState cached = mOutsideDrawablesCache.get(resourceUri);
593        if (cached == null) {
594            return null;
595        }
596        if (DBG) Log.d(LOG_TAG, "Found icon in cache: " + resourceUri);
597        return cached.newDrawable();
598    }
599
600    private void storeInIconCache(String resourceUri, Drawable drawable) {
601        if (drawable != null) {
602            mOutsideDrawablesCache.put(resourceUri, drawable.getConstantState());
603        }
604    }
605
606    /**
607     * Gets the left-hand side icon that will be used for the current suggestion
608     * if the suggestion contains an icon column but no icon or a broken icon.
609     *
610     * @param cursor A cursor positioned at the current suggestion.
611     * @return A non-null drawable.
612     */
613    private Drawable getDefaultIcon1(Cursor cursor) {
614        // Check the component that gave us the suggestion
615        Drawable drawable = getActivityIconWithCache(mSearchable.getSearchActivity());
616        if (drawable != null) {
617            return drawable;
618        }
619
620        // Fall back to a default icon
621        return mContext.getPackageManager().getDefaultActivityIcon();
622    }
623
624    /**
625     * Gets the activity or application icon for an activity.
626     * Uses the local icon cache for fast repeated lookups.
627     *
628     * @param component Name of an activity.
629     * @return A drawable, or {@code null} if neither the activity nor the application
630     *         has an icon set.
631     */
632    private Drawable getActivityIconWithCache(ComponentName component) {
633        // First check the icon cache
634        String componentIconKey = component.flattenToShortString();
635        // Using containsKey() since we also store null values.
636        if (mOutsideDrawablesCache.containsKey(componentIconKey)) {
637            Drawable.ConstantState cached = mOutsideDrawablesCache.get(componentIconKey);
638            return cached == null ? null : cached.newDrawable(mProviderContext.getResources());
639        }
640        // Then try the activity or application icon
641        Drawable drawable = getActivityIcon(component);
642        // Stick it in the cache so we don't do this lookup again.
643        Drawable.ConstantState toCache = drawable == null ? null : drawable.getConstantState();
644        mOutsideDrawablesCache.put(componentIconKey, toCache);
645        return drawable;
646    }
647
648    /**
649     * Gets the activity or application icon for an activity.
650     *
651     * @param component Name of an activity.
652     * @return A drawable, or {@code null} if neither the acitivy or the application
653     *         have an icon set.
654     */
655    private Drawable getActivityIcon(ComponentName component) {
656        PackageManager pm = mContext.getPackageManager();
657        final ActivityInfo activityInfo;
658        try {
659            activityInfo = pm.getActivityInfo(component, PackageManager.GET_META_DATA);
660        } catch (NameNotFoundException ex) {
661            Log.w(LOG_TAG, ex.toString());
662            return null;
663        }
664        int iconId = activityInfo.getIconResource();
665        if (iconId == 0) return null;
666        String pkg = component.getPackageName();
667        Drawable drawable = pm.getDrawable(pkg, iconId, activityInfo.applicationInfo);
668        if (drawable == null) {
669            Log.w(LOG_TAG, "Invalid icon resource " + iconId + " for "
670                    + component.flattenToShortString());
671            return null;
672        }
673        return drawable;
674    }
675
676    /**
677     * Gets the value of a string column by name.
678     *
679     * @param cursor Cursor to read the value from.
680     * @param columnName The name of the column to read.
681     * @return The value of the given column, or <code>null</null>
682     *         if the cursor does not contain the given column.
683     */
684    public static String getColumnString(Cursor cursor, String columnName) {
685        int col = cursor.getColumnIndex(columnName);
686        return getStringOrNull(cursor, col);
687    }
688
689    private static String getStringOrNull(Cursor cursor, int col) {
690        if (col == NONE) {
691            return null;
692        }
693        try {
694            return cursor.getString(col);
695        } catch (Exception e) {
696            Log.e(LOG_TAG,
697                    "unexpected error retrieving valid column from cursor, "
698                            + "did the remote process die?", e);
699            return null;
700        }
701    }
702}
703