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