com.android.quicksearchbox.ShortcutRepositoryImplLog.java Source code

Java tutorial

Introduction

Here is the source code for com.android.quicksearchbox.ShortcutRepositoryImplLog.java

Source

/*
 * Copyright (C) 2009 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.android.quicksearchbox;

import com.android.quicksearchbox.util.Consumer;
import com.android.quicksearchbox.util.Consumers;
import com.android.quicksearchbox.util.SQLiteAsyncQuery;
import com.android.quicksearchbox.util.SQLiteTransaction;
import com.android.quicksearchbox.util.Util;
import com.google.common.annotations.VisibleForTesting;

import org.json.JSONException;

import android.app.SearchManager;
import android.content.ComponentName;
import android.content.ContentResolver;
import android.content.ContentValues;
import android.content.Context;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteOpenHelper;
import android.database.sqlite.SQLiteQueryBuilder;
import android.net.Uri;
import android.os.Handler;
import android.text.TextUtils;
import android.util.Log;

import java.io.File;
import java.util.Collection;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.Executor;

/**
 * A shortcut repository implementation that uses a log of every click.
 *
 * To inspect DB:
 * # sqlite3 /data/data/com.android.quicksearchbox/databases/qsb-log.db
 *
 * TODO: Refactor this class.
 */
public class ShortcutRepositoryImplLog implements ShortcutRepository {

    private static final boolean DBG = false;
    private static final String TAG = "QSB.ShortcutRepositoryImplLog";

    private static final String DB_NAME = "qsb-log.db";
    private static final int DB_VERSION = 32;

    private static final String HAS_HISTORY_QUERY = "SELECT " + Shortcuts.intent_key.fullName + " FROM "
            + Shortcuts.TABLE_NAME;
    private String mEmptyQueryShortcutQuery;
    private String mShortcutQuery;

    private static final String SHORTCUT_BY_ID_WHERE = Shortcuts.shortcut_id.name() + "=? AND "
            + Shortcuts.source.name() + "=?";

    private static final String SOURCE_RANKING_SQL = buildSourceRankingSql();

    private final Context mContext;
    private final Config mConfig;
    private final Corpora mCorpora;
    private final ShortcutRefresher mRefresher;
    private final Handler mUiThread;
    // Used to perform log write operations asynchronously
    private final Executor mLogExecutor;
    private final DbOpenHelper mOpenHelper;
    private final String mSearchSpinner;

    /**
     * Create an instance to the repo.
     */
    public static ShortcutRepository create(Context context, Config config, Corpora sources,
            ShortcutRefresher refresher, Handler uiThread, Executor logExecutor) {
        return new ShortcutRepositoryImplLog(context, config, sources, refresher, uiThread, logExecutor, DB_NAME);
    }

    /**
     * @param context Used to create / open db
     * @param name The name of the database to create.
     */
    @VisibleForTesting
    ShortcutRepositoryImplLog(Context context, Config config, Corpora corpora, ShortcutRefresher refresher,
            Handler uiThread, Executor logExecutor, String name) {
        mContext = context;
        mConfig = config;
        mCorpora = corpora;
        mRefresher = refresher;
        mUiThread = uiThread;
        mLogExecutor = logExecutor;
        mOpenHelper = new DbOpenHelper(context, name, DB_VERSION, config);
        buildShortcutQueries();

        mSearchSpinner = Util.getResourceUri(mContext, R.drawable.search_spinner).toString();
    }

    // clicklog first, since that's where restrict the result set
    private static final String TABLES = ClickLog.TABLE_NAME + " INNER JOIN " + Shortcuts.TABLE_NAME + " ON "
            + ClickLog.intent_key.fullName + " = " + Shortcuts.intent_key.fullName;

    private static final String AS = " AS ";

    private static final String[] SHORTCUT_QUERY_COLUMNS = { Shortcuts.intent_key.fullName,
            Shortcuts.source.fullName, Shortcuts.source_version_code.fullName,
            Shortcuts.format.fullName + AS + SearchManager.SUGGEST_COLUMN_FORMAT,
            Shortcuts.title + AS + SearchManager.SUGGEST_COLUMN_TEXT_1,
            Shortcuts.description + AS + SearchManager.SUGGEST_COLUMN_TEXT_2,
            Shortcuts.description_url + AS + SearchManager.SUGGEST_COLUMN_TEXT_2_URL,
            Shortcuts.icon1 + AS + SearchManager.SUGGEST_COLUMN_ICON_1,
            Shortcuts.icon2 + AS + SearchManager.SUGGEST_COLUMN_ICON_2,
            Shortcuts.intent_action + AS + SearchManager.SUGGEST_COLUMN_INTENT_ACTION,
            Shortcuts.intent_component.fullName,
            Shortcuts.intent_data + AS + SearchManager.SUGGEST_COLUMN_INTENT_DATA,
            Shortcuts.intent_query + AS + SearchManager.SUGGEST_COLUMN_QUERY,
            Shortcuts.intent_extradata + AS + SearchManager.SUGGEST_COLUMN_INTENT_EXTRA_DATA,
            Shortcuts.shortcut_id + AS + SearchManager.SUGGEST_COLUMN_SHORTCUT_ID,
            Shortcuts.spinner_while_refreshing + AS + SearchManager.SUGGEST_COLUMN_SPINNER_WHILE_REFRESHING,
            Shortcuts.log_type + AS + CursorBackedSuggestionCursor.SUGGEST_COLUMN_LOG_TYPE,
            Shortcuts.custom_columns.fullName, };

    // Avoid GLOB by using >= AND <, with some manipulation (see nextString(String)).
    // to figure out the upper bound (e.g. >= "abc" AND < "abd"
    // This allows us to use parameter binding and still take advantage of the
    // index on the query column.
    private static final String PREFIX_RESTRICTION = ClickLog.query.fullName + " >= ?1 AND "
            + ClickLog.query.fullName + " < ?2";

    private static final String LAST_HIT_TIME_EXPR = "MAX(" + ClickLog.hit_time.fullName + ")";
    private static final String GROUP_BY = ClickLog.intent_key.fullName;
    private static final String PREFER_LATEST_PREFIX = "(" + LAST_HIT_TIME_EXPR + " = (SELECT " + LAST_HIT_TIME_EXPR
            + " FROM " + ClickLog.TABLE_NAME + " WHERE ";
    private static final String PREFER_LATEST_SUFFIX = "))";

    private void buildShortcutQueries() {
        // SQL expression for the time before which no clicks should be counted.
        String cutOffTime_expr = "(?3 - " + mConfig.getMaxStatAgeMillis() + ")";
        // Filter out clicks that are too old
        String ageRestriction = ClickLog.hit_time.fullName + " >= " + cutOffTime_expr;
        String having = null;
        // Order by sum of hit times (seconds since cutoff) for the clicks for each shortcut.
        // This has the effect of multiplying the average hit time with the click count
        String ordering_expr = "SUM((" + ClickLog.hit_time.fullName + " - " + cutOffTime_expr + ") / 1000)";

        String where = ageRestriction;
        String preferLatest = PREFER_LATEST_PREFIX + where + PREFER_LATEST_SUFFIX;
        String orderBy = preferLatest + " DESC, " + ordering_expr + " DESC";
        mEmptyQueryShortcutQuery = SQLiteQueryBuilder.buildQueryString(false, TABLES, SHORTCUT_QUERY_COLUMNS, where,
                GROUP_BY, having, orderBy, null);
        if (DBG)
            Log.d(TAG, "Empty shortcut query:\n" + mEmptyQueryShortcutQuery);

        where = PREFIX_RESTRICTION + " AND " + ageRestriction;
        preferLatest = PREFER_LATEST_PREFIX + where + PREFER_LATEST_SUFFIX;
        orderBy = preferLatest + " DESC, " + ordering_expr + " DESC";
        mShortcutQuery = SQLiteQueryBuilder.buildQueryString(false, TABLES, SHORTCUT_QUERY_COLUMNS, where, GROUP_BY,
                having, orderBy, null);
        if (DBG)
            Log.d(TAG, "Empty shortcut:\n" + mShortcutQuery);
    }

    /**
     * @return sql that ranks sources by total clicks, filtering out sources
     *         without enough clicks.
     */
    private static String buildSourceRankingSql() {
        final String orderingExpr = SourceStats.total_clicks.name();
        final String tables = SourceStats.TABLE_NAME;
        final String[] columns = SourceStats.COLUMNS;
        final String where = SourceStats.total_clicks + " >= $1";
        final String groupBy = null;
        final String having = null;
        final String orderBy = orderingExpr + " DESC";
        final String limit = null;
        return SQLiteQueryBuilder.buildQueryString(false, tables, columns, where, groupBy, having, orderBy, limit);
    }

    protected DbOpenHelper getOpenHelper() {
        return mOpenHelper;
    }

    private void runTransactionAsync(final SQLiteTransaction transaction) {
        mLogExecutor.execute(new Runnable() {
            public void run() {
                transaction.run(mOpenHelper.getWritableDatabase());
            }
        });
    }

    private <A> void runQueryAsync(final SQLiteAsyncQuery<A> query, final Consumer<A> consumer) {
        mLogExecutor.execute(new Runnable() {
            public void run() {
                query.run(mOpenHelper.getReadableDatabase(), consumer);
            }
        });
    }

    // --------------------- Interface ShortcutRepository ---------------------

    public void hasHistory(Consumer<Boolean> consumer) {
        runQueryAsync(new SQLiteAsyncQuery<Boolean>() {
            @Override
            protected Boolean performQuery(SQLiteDatabase db) {
                return hasHistory(db);
            }
        }, consumer);
    }

    public void removeFromHistory(SuggestionCursor suggestions, int position) {
        suggestions.moveTo(position);
        final String intentKey = makeIntentKey(suggestions);
        runTransactionAsync(new SQLiteTransaction() {
            @Override
            public boolean performTransaction(SQLiteDatabase db) {
                db.delete(Shortcuts.TABLE_NAME, Shortcuts.intent_key.fullName + " = ?", new String[] { intentKey });
                return true;
            }
        });
    }

    public void clearHistory() {
        runTransactionAsync(new SQLiteTransaction() {
            @Override
            public boolean performTransaction(SQLiteDatabase db) {
                db.delete(ClickLog.TABLE_NAME, null, null);
                db.delete(Shortcuts.TABLE_NAME, null, null);
                db.delete(SourceStats.TABLE_NAME, null, null);
                return true;
            }
        });
    }

    @VisibleForTesting
    public void deleteRepository() {
        getOpenHelper().deleteDatabase();
    }

    public void close() {
        getOpenHelper().close();
    }

    public void reportClick(final SuggestionCursor suggestions, final int position) {
        final long now = System.currentTimeMillis();
        reportClickAtTime(suggestions, position, now);
    }

    public void getShortcutsForQuery(final String query, final Collection<Corpus> allowedCorpora,
            final boolean allowWebSearchShortcuts, final Consumer<ShortcutCursor> consumer) {
        final long now = System.currentTimeMillis();
        mLogExecutor.execute(new Runnable() {
            public void run() {
                ShortcutCursor shortcuts = getShortcutsForQuery(query, allowedCorpora, allowWebSearchShortcuts,
                        now);
                Consumers.consumeCloseable(consumer, shortcuts);
            }
        });
    }

    public void updateShortcut(Source source, String shortcutId, SuggestionCursor refreshed) {
        refreshShortcut(source, shortcutId, refreshed);
    }

    public void getCorpusScores(final Consumer<Map<String, Integer>> consumer) {
        runQueryAsync(new SQLiteAsyncQuery<Map<String, Integer>>() {
            @Override
            protected Map<String, Integer> performQuery(SQLiteDatabase db) {
                return getCorpusScores();
            }
        }, consumer);
    }

    // -------------------------- end ShortcutRepository --------------------------

    private boolean hasHistory(SQLiteDatabase db) {
        Cursor cursor = db.rawQuery(HAS_HISTORY_QUERY, null);
        try {
            if (DBG)
                Log.d(TAG, "hasHistory(): cursor=" + cursor);
            return cursor != null && cursor.getCount() > 0;
        } finally {
            if (cursor != null)
                cursor.close();
        }
    }

    private Map<String, Integer> getCorpusScores() {
        return getCorpusScores(mConfig.getMinClicksForSourceRanking());
    }

    private boolean shouldRefresh(Suggestion suggestion) {
        return mRefresher.shouldRefresh(suggestion.getSuggestionSource(), suggestion.getShortcutId());
    }

    @VisibleForTesting
    ShortcutCursor getShortcutsForQuery(String query, Collection<Corpus> allowedCorpora,
            boolean allowWebSearchShortcuts, long now) {
        if (DBG)
            Log.d(TAG, "getShortcutsForQuery(" + query + "," + allowedCorpora + ")");
        String sql = query.length() == 0 ? mEmptyQueryShortcutQuery : mShortcutQuery;
        String[] params = buildShortcutQueryParams(query, now);

        SQLiteDatabase db = mOpenHelper.getReadableDatabase();
        Cursor cursor = db.rawQuery(sql, params);
        if (cursor.getCount() == 0) {
            cursor.close();
            return null;
        }

        if (DBG)
            Log.d(TAG, "Allowed sources: ");
        HashMap<String, Source> allowedSources = new HashMap<String, Source>();
        for (Corpus corpus : allowedCorpora) {
            for (Source source : corpus.getSources()) {
                if (DBG)
                    Log.d(TAG, "\t" + source.getName());
                allowedSources.put(source.getName(), source);
            }
        }

        return new ShortcutCursor(new SuggestionCursorImpl(allowedSources, query, cursor), allowWebSearchShortcuts,
                mUiThread, mRefresher, this);
    }

    @VisibleForTesting
    void refreshShortcut(Source source, final String shortcutId, SuggestionCursor refreshed) {
        if (source == null)
            throw new NullPointerException("source");
        if (shortcutId == null)
            throw new NullPointerException("shortcutId");

        final String[] whereArgs = { shortcutId, source.getName() };
        final ContentValues shortcut;
        if (refreshed == null || refreshed.getCount() == 0) {
            shortcut = null;
        } else {
            refreshed.moveTo(0);
            shortcut = makeShortcutRow(refreshed);
        }

        runTransactionAsync(new SQLiteTransaction() {
            @Override
            protected boolean performTransaction(SQLiteDatabase db) {
                if (shortcut == null) {
                    if (DBG)
                        Log.d(TAG, "Deleting shortcut: " + shortcutId);
                    db.delete(Shortcuts.TABLE_NAME, SHORTCUT_BY_ID_WHERE, whereArgs);
                } else {
                    if (DBG)
                        Log.d(TAG, "Updating shortcut: " + shortcut);
                    db.updateWithOnConflict(Shortcuts.TABLE_NAME, shortcut, SHORTCUT_BY_ID_WHERE, whereArgs,
                            SQLiteDatabase.CONFLICT_REPLACE);
                }
                return true;
            }
        });
    }

    private class SuggestionCursorImpl extends CursorBackedSuggestionCursor {

        private final HashMap<String, Source> mAllowedSources;
        private final int mExtrasColumn;

        public SuggestionCursorImpl(HashMap<String, Source> allowedSources, String userQuery, Cursor cursor) {
            super(userQuery, cursor);
            mAllowedSources = allowedSources;
            mExtrasColumn = cursor.getColumnIndex(Shortcuts.custom_columns.name());
        }

        @Override
        public Source getSuggestionSource() {
            int srcCol = mCursor.getColumnIndex(Shortcuts.source.name());
            String srcStr = mCursor.getString(srcCol);
            if (srcStr == null) {
                throw new NullPointerException("Missing source for shortcut.");
            }
            Source source = mAllowedSources.get(srcStr);
            if (source == null) {
                if (DBG) {
                    Log.d(TAG, "Source " + srcStr + " (position " + mCursor.getPosition() + ") not allowed");
                }
                return null;
            }
            int versionCode = mCursor.getInt(Shortcuts.source_version_code.ordinal());
            if (!source.isVersionCodeCompatible(versionCode)) {
                if (DBG) {
                    Log.d(TAG, "Version " + versionCode + " not compatible with " + source.getVersionCode()
                            + " for source " + srcStr);
                }
                return null;
            }
            return source;
        }

        @Override
        public ComponentName getSuggestionIntentComponent() {
            int componentCol = mCursor.getColumnIndex(Shortcuts.intent_component.name());
            // We don't fall back to getSuggestionSource().getIntentComponent() because
            // we want to return the same value that getSuggestionIntentComponent() did for the
            // original suggestion.
            return stringToComponentName(mCursor.getString(componentCol));
        }

        @Override
        public String getSuggestionIcon2() {
            if (isSpinnerWhileRefreshing() && shouldRefresh(this)) {
                if (DBG)
                    Log.d(TAG, "shortcut " + getShortcutId() + " refreshing");
                return mSearchSpinner;
            }
            if (DBG)
                Log.d(TAG, "shortcut " + getShortcutId() + " NOT refreshing");
            return super.getSuggestionIcon2();
        }

        public boolean isSuggestionShortcut() {
            return true;
        }

        public boolean isHistorySuggestion() {
            // This always returns false, even for suggestions that originally came
            // from server-side history, since we'd otherwise have to parse the Genie
            // extra data. This is ok, since this method is only used for the
            // "Remove from history" UI, which is also shown for all shortcuts.
            return false;
        }

        @Override
        public SuggestionExtras getExtras() {
            String json = mCursor.getString(mExtrasColumn);
            if (!TextUtils.isEmpty(json)) {
                try {
                    return new JsonBackedSuggestionExtras(json);
                } catch (JSONException e) {
                    Log.e(TAG, "Could not parse JSON extras from DB: " + json);
                }
            }
            return null;
        }

        public Collection<String> getExtraColumns() {
            /*
             * We always return null here because:
             * - to return an accurate value, we'd have to aggregate all the extra columns in all
             *   shortcuts in the shortcuts table, which would mean parsing ALL the JSON contained
             *   therein
             * - ListSuggestionCursor does this aggregation, and does it lazily
             * - All shortcuts are put into a ListSuggestionCursor during the promotion process, so
             *   relying on ListSuggestionCursor to do the aggregation means that we only parse the
             *   JSON for shortcuts that are actually displayed.
             */
            return null;
        }
    }

    /**
     * Builds a parameter list for the queries built by {@link #buildShortcutQueries}.
     */
    private static String[] buildShortcutQueryParams(String query, long now) {
        return new String[] { query, nextString(query), String.valueOf(now) };
    }

    /**
     * Given a string x, this method returns the least string y such that x is not a prefix of y.
     * This is useful to implement prefix filtering by comparison, since the only strings z that
     * have x as a prefix are such that z is greater than or equal to x and z is less than y.
     *
     * @param str A non-empty string. The contract above is not honored for an empty input string,
     *        since all strings have the empty string as a prefix.
     */
    private static String nextString(String str) {
        int len = str.length();
        if (len == 0) {
            return str;
        }
        // The last code point in the string. Within the Basic Multilingual Plane,
        // this is the same as str.charAt(len-1)
        int codePoint = str.codePointBefore(len);
        // This should be safe from overflow, since the largest code point
        // representable in UTF-16 is U+10FFFF.
        int nextCodePoint = codePoint + 1;
        // The index of the start of the last code point.
        // Character.charCount(codePoint) is always 1 (in the BMP) or 2
        int lastIndex = len - Character.charCount(codePoint);
        return new StringBuilder(len).append(str, 0, lastIndex) // append everything but the last code point
                .appendCodePoint(nextCodePoint) // instead of the last code point, use successor
                .toString();
    }

    /**
     * Returns the source ranking for sources with a minimum number of clicks.
     *
     * @param minClicks The minimum number of clicks a source must have.
     * @return The list of sources, ranked by total clicks.
     */
    Map<String, Integer> getCorpusScores(int minClicks) {
        SQLiteDatabase db = mOpenHelper.getReadableDatabase();
        final Cursor cursor = db.rawQuery(SOURCE_RANKING_SQL, new String[] { String.valueOf(minClicks) });
        try {
            Map<String, Integer> corpora = new HashMap<String, Integer>(cursor.getCount());
            while (cursor.moveToNext()) {
                String name = cursor.getString(SourceStats.corpus.ordinal());
                int clicks = cursor.getInt(SourceStats.total_clicks.ordinal());
                corpora.put(name, clicks);
            }
            return corpora;
        } finally {
            cursor.close();
        }
    }

    private ContentValues makeShortcutRow(Suggestion suggestion) {
        String intentAction = suggestion.getSuggestionIntentAction();
        String intentComponent = componentNameToString(suggestion.getSuggestionIntentComponent());
        String intentData = suggestion.getSuggestionIntentDataString();
        String intentQuery = suggestion.getSuggestionQuery();
        String intentExtraData = suggestion.getSuggestionIntentExtraData();

        Source source = suggestion.getSuggestionSource();
        String sourceName = source.getName();

        String intentKey = makeIntentKey(suggestion);

        // Get URIs for all icons, to make sure that they are stable
        String icon1Uri = getIconUriString(source, suggestion.getSuggestionIcon1());
        String icon2Uri = getIconUriString(source, suggestion.getSuggestionIcon2());

        String extrasJson = null;
        SuggestionExtras extras = suggestion.getExtras();
        if (extras != null) {
            // flatten any custom columns to JSON. We need to keep any custom columns so that
            // shortcuts for custom suggestion views work properly.
            try {
                extrasJson = extras.toJsonString();
            } catch (JSONException e) {
                Log.e(TAG, "Could not flatten extras to JSON from " + suggestion, e);
            }
        }

        ContentValues cv = new ContentValues();
        cv.put(Shortcuts.intent_key.name(), intentKey);
        cv.put(Shortcuts.source.name(), sourceName);
        cv.put(Shortcuts.source_version_code.name(), source.getVersionCode());
        cv.put(Shortcuts.format.name(), suggestion.getSuggestionFormat());
        cv.put(Shortcuts.title.name(), suggestion.getSuggestionText1());
        cv.put(Shortcuts.description.name(), suggestion.getSuggestionText2());
        cv.put(Shortcuts.description_url.name(), suggestion.getSuggestionText2Url());
        cv.put(Shortcuts.icon1.name(), icon1Uri);
        cv.put(Shortcuts.icon2.name(), icon2Uri);
        cv.put(Shortcuts.intent_action.name(), intentAction);
        cv.put(Shortcuts.intent_component.name(), intentComponent);
        cv.put(Shortcuts.intent_data.name(), intentData);
        cv.put(Shortcuts.intent_query.name(), intentQuery);
        cv.put(Shortcuts.intent_extradata.name(), intentExtraData);
        cv.put(Shortcuts.shortcut_id.name(), suggestion.getShortcutId());
        if (suggestion.isSpinnerWhileRefreshing()) {
            cv.put(Shortcuts.spinner_while_refreshing.name(), "true");
        }
        cv.put(Shortcuts.log_type.name(), suggestion.getSuggestionLogType());
        cv.put(Shortcuts.custom_columns.name(), extrasJson);

        return cv;
    }

    /**
     * Makes a string of the form source#intentData#intentAction#intentQuery
     * for use as a unique identifier of a suggestion.
     * */
    private String makeIntentKey(Suggestion suggestion) {
        String intentAction = suggestion.getSuggestionIntentAction();
        String intentComponent = componentNameToString(suggestion.getSuggestionIntentComponent());
        String intentData = suggestion.getSuggestionIntentDataString();
        String intentQuery = suggestion.getSuggestionQuery();

        Source source = suggestion.getSuggestionSource();
        String sourceName = source.getName();
        StringBuilder key = new StringBuilder(sourceName);
        key.append("#");
        if (intentData != null) {
            key.append(intentData);
        }
        key.append("#");
        if (intentAction != null) {
            key.append(intentAction);
        }
        key.append("#");
        if (intentComponent != null) {
            key.append(intentComponent);
        }
        key.append("#");
        if (intentQuery != null) {
            key.append(intentQuery);
        }

        return key.toString();
    }

    private String componentNameToString(ComponentName component) {
        return component == null ? null : component.flattenToShortString();
    }

    private ComponentName stringToComponentName(String str) {
        return str == null ? null : ComponentName.unflattenFromString(str);
    }

    private String getIconUriString(Source source, String drawableId) {
        // Fast path for empty icons
        if (TextUtils.isEmpty(drawableId) || "0".equals(drawableId)) {
            return null;
        }
        // Fast path for icon URIs
        if (drawableId.startsWith(ContentResolver.SCHEME_ANDROID_RESOURCE)
                || drawableId.startsWith(ContentResolver.SCHEME_CONTENT)
                || drawableId.startsWith(ContentResolver.SCHEME_FILE)) {
            return drawableId;
        }
        Uri uri = source.getIconUri(drawableId);
        return uri == null ? null : uri.toString();
    }

    @VisibleForTesting
    void reportClickAtTime(SuggestionCursor suggestion, int position, long now) {
        suggestion.moveTo(position);
        if (DBG) {
            Log.d(TAG, "logClicked(" + suggestion + ")");
        }

        if (SearchManager.SUGGEST_NEVER_MAKE_SHORTCUT.equals(suggestion.getShortcutId())) {
            if (DBG)
                Log.d(TAG, "clicked suggestion requested not to be shortcuted");
            return;
        }

        Corpus corpus = mCorpora.getCorpusForSource(suggestion.getSuggestionSource());
        if (corpus == null) {
            Log.w(TAG, "no corpus for clicked suggestion");
            return;
        }

        // Once the user has clicked on a shortcut, don't bother refreshing
        // (especially if this is a new shortcut)
        mRefresher.markShortcutRefreshed(suggestion.getSuggestionSource(), suggestion.getShortcutId());

        // Add or update suggestion info
        // Since intent_key is the primary key, any existing
        // suggestion with the same source+data+action will be replaced
        final ContentValues shortcut = makeShortcutRow(suggestion);
        String intentKey = shortcut.getAsString(Shortcuts.intent_key.name());

        // Log click for shortcut
        final ContentValues click = new ContentValues();
        click.put(ClickLog.intent_key.name(), intentKey);
        click.put(ClickLog.query.name(), suggestion.getUserQuery());
        click.put(ClickLog.hit_time.name(), now);
        click.put(ClickLog.corpus.name(), corpus.getName());

        runTransactionAsync(new SQLiteTransaction() {
            @Override
            protected boolean performTransaction(SQLiteDatabase db) {
                if (DBG)
                    Log.d(TAG, "Adding shortcut: " + shortcut);
                db.replaceOrThrow(Shortcuts.TABLE_NAME, null, shortcut);
                db.insertOrThrow(ClickLog.TABLE_NAME, null, click);
                return true;
            }
        });
    }

    // -------------------------- TABLES --------------------------

    /**
     * shortcuts table
     */
    enum Shortcuts {
        intent_key, source, source_version_code, format, title, description, description_url, icon1, icon2, intent_action, intent_component, intent_data, intent_query, intent_extradata, shortcut_id, spinner_while_refreshing, log_type, custom_columns;

        static final String TABLE_NAME = "shortcuts";

        public final String fullName;

        Shortcuts() {
            fullName = TABLE_NAME + "." + name();
        }
    }

    /**
     * clicklog table. Has one record for each click.
     */
    enum ClickLog {
        _id, intent_key, query, hit_time, corpus;

        static final String[] COLUMNS = initColumns();

        static final String TABLE_NAME = "clicklog";

        private static String[] initColumns() {
            ClickLog[] vals = ClickLog.values();
            String[] columns = new String[vals.length];
            for (int i = 0; i < vals.length; i++) {
                columns[i] = vals[i].fullName;
            }
            return columns;
        }

        public final String fullName;

        ClickLog() {
            fullName = TABLE_NAME + "." + name();
        }
    }

    /**
     * This is an aggregate table of {@link ClickLog} that stays up to date with the total
     * clicks for each corpus. This makes computing the corpus ranking more
     * more efficient, at the expense of some extra work when the clicks are reported.
     */
    enum SourceStats {
        corpus, total_clicks;

        static final String TABLE_NAME = "sourcetotals";

        static final String[] COLUMNS = initColumns();

        private static String[] initColumns() {
            SourceStats[] vals = SourceStats.values();
            String[] columns = new String[vals.length];
            for (int i = 0; i < vals.length; i++) {
                columns[i] = vals[i].fullName;
            }
            return columns;
        }

        public final String fullName;

        SourceStats() {
            fullName = TABLE_NAME + "." + name();
        }
    }

    // -------------------------- END TABLES --------------------------

    // contains creation and update logic
    private static class DbOpenHelper extends SQLiteOpenHelper {
        private final Config mConfig;
        private String mPath;
        private static final String SHORTCUT_ID_INDEX = Shortcuts.TABLE_NAME + "_" + Shortcuts.shortcut_id.name();
        private static final String CLICKLOG_QUERY_INDEX = ClickLog.TABLE_NAME + "_" + ClickLog.query.name();
        private static final String CLICKLOG_HIT_TIME_INDEX = ClickLog.TABLE_NAME + "_" + ClickLog.hit_time.name();
        private static final String CLICKLOG_INSERT_TRIGGER = ClickLog.TABLE_NAME + "_insert";
        private static final String SHORTCUTS_DELETE_TRIGGER = Shortcuts.TABLE_NAME + "_delete";
        private static final String SHORTCUTS_UPDATE_INTENT_KEY_TRIGGER = Shortcuts.TABLE_NAME
                + "_update_intent_key";

        public DbOpenHelper(Context context, String name, int version, Config config) {
            super(context, name, null, version);
            mConfig = config;
        }

        @Override
        public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
            // The shortcuts info is not all that important, so we just drop the tables
            // and re-create empty ones.
            Log.i(TAG, "Upgrading shortcuts DB from version " + +oldVersion + " to " + newVersion
                    + ". This deletes all shortcuts.");
            dropTables(db);
            onCreate(db);
        }

        private void dropTables(SQLiteDatabase db) {
            db.execSQL("DROP TRIGGER IF EXISTS " + CLICKLOG_INSERT_TRIGGER);
            db.execSQL("DROP TRIGGER IF EXISTS " + SHORTCUTS_DELETE_TRIGGER);
            db.execSQL("DROP TRIGGER IF EXISTS " + SHORTCUTS_UPDATE_INTENT_KEY_TRIGGER);
            db.execSQL("DROP INDEX IF EXISTS " + CLICKLOG_HIT_TIME_INDEX);
            db.execSQL("DROP INDEX IF EXISTS " + CLICKLOG_QUERY_INDEX);
            db.execSQL("DROP INDEX IF EXISTS " + SHORTCUT_ID_INDEX);
            db.execSQL("DROP TABLE IF EXISTS " + ClickLog.TABLE_NAME);
            db.execSQL("DROP TABLE IF EXISTS " + Shortcuts.TABLE_NAME);
            db.execSQL("DROP TABLE IF EXISTS " + SourceStats.TABLE_NAME);
        }

        /**
         * Deletes the database file.
         */
        public void deleteDatabase() {
            close();
            if (mPath == null)
                return;
            try {
                new File(mPath).delete();
                if (DBG)
                    Log.d(TAG, "deleted " + mPath);
            } catch (Exception e) {
                Log.w(TAG, "couldn't delete " + mPath, e);
            }
        }

        @Override
        public void onOpen(SQLiteDatabase db) {
            super.onOpen(db);
            mPath = db.getPath();
        }

        @Override
        public void onCreate(SQLiteDatabase db) {
            db.execSQL("CREATE TABLE " + Shortcuts.TABLE_NAME + " (" +
            // COLLATE UNICODE is needed to make it possible to use nextString()
            // to implement fast prefix filtering.
                    Shortcuts.intent_key.name() + " TEXT NOT NULL COLLATE UNICODE PRIMARY KEY, "
                    + Shortcuts.source.name() + " TEXT NOT NULL, " + Shortcuts.source_version_code.name()
                    + " INTEGER NOT NULL, " + Shortcuts.format.name() + " TEXT, " + Shortcuts.title.name()
                    + " TEXT, " + Shortcuts.description.name() + " TEXT, " + Shortcuts.description_url.name()
                    + " TEXT, " + Shortcuts.icon1.name() + " TEXT, " + Shortcuts.icon2.name() + " TEXT, "
                    + Shortcuts.intent_action.name() + " TEXT, " + Shortcuts.intent_component.name() + " TEXT, "
                    + Shortcuts.intent_data.name() + " TEXT, " + Shortcuts.intent_query.name() + " TEXT, "
                    + Shortcuts.intent_extradata.name() + " TEXT, " + Shortcuts.shortcut_id.name() + " TEXT, "
                    + Shortcuts.spinner_while_refreshing.name() + " TEXT, " + Shortcuts.log_type.name() + " TEXT, "
                    + Shortcuts.custom_columns.name() + " TEXT" + ");");

            // index for fast lookup of shortcuts by shortcut_id
            db.execSQL("CREATE INDEX " + SHORTCUT_ID_INDEX + " ON " + Shortcuts.TABLE_NAME + "("
                    + Shortcuts.shortcut_id.name() + ", " + Shortcuts.source.name() + ")");

            db.execSQL("CREATE TABLE " + ClickLog.TABLE_NAME + " ( " + ClickLog._id.name()
                    + " INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, " +
                    // type must match Shortcuts.intent_key
                    ClickLog.intent_key.name() + " TEXT NOT NULL COLLATE UNICODE REFERENCES " + Shortcuts.TABLE_NAME
                    + "(" + Shortcuts.intent_key + "), " + ClickLog.query.name() + " TEXT, "
                    + ClickLog.hit_time.name() + " INTEGER," + ClickLog.corpus.name() + " TEXT" + ");");

            // index for fast lookup of clicks by query
            db.execSQL("CREATE INDEX " + CLICKLOG_QUERY_INDEX + " ON " + ClickLog.TABLE_NAME + "("
                    + ClickLog.query.name() + ")");

            // index for finding old clicks quickly
            db.execSQL("CREATE INDEX " + CLICKLOG_HIT_TIME_INDEX + " ON " + ClickLog.TABLE_NAME + "("
                    + ClickLog.hit_time.name() + ")");

            // trigger for purging old clicks, i.e. those such that
            // hit_time < now - MAX_MAX_STAT_AGE_MILLIS, where now is the
            // hit_time of the inserted record, and for updating the SourceStats table
            db.execSQL("CREATE TRIGGER " + CLICKLOG_INSERT_TRIGGER + " AFTER INSERT ON " + ClickLog.TABLE_NAME
                    + " BEGIN" + " DELETE FROM " + ClickLog.TABLE_NAME + " WHERE " + ClickLog.hit_time.name() + " <"
                    + " NEW." + ClickLog.hit_time.name() + " - " + mConfig.getMaxStatAgeMillis() + ";"
                    + " DELETE FROM " + SourceStats.TABLE_NAME + ";" + " INSERT INTO " + SourceStats.TABLE_NAME
                    + " " + "SELECT " + ClickLog.corpus + "," + "COUNT(*) FROM " + ClickLog.TABLE_NAME
                    + " GROUP BY " + ClickLog.corpus.name() + ";" + " END");

            // trigger for deleting clicks about a shortcut once that shortcut has been
            // deleted
            db.execSQL("CREATE TRIGGER " + SHORTCUTS_DELETE_TRIGGER + " AFTER DELETE ON " + Shortcuts.TABLE_NAME
                    + " BEGIN" + " DELETE FROM " + ClickLog.TABLE_NAME + " WHERE " + ClickLog.intent_key.name()
                    + " = OLD." + Shortcuts.intent_key.name() + ";" + " END");

            // trigger for updating click log entries when a shortcut changes its intent_key
            db.execSQL("CREATE TRIGGER " + SHORTCUTS_UPDATE_INTENT_KEY_TRIGGER + " AFTER UPDATE ON "
                    + Shortcuts.TABLE_NAME + " WHEN NEW." + Shortcuts.intent_key.name() + " != OLD."
                    + Shortcuts.intent_key.name() + " BEGIN" + " UPDATE " + ClickLog.TABLE_NAME + " SET "
                    + ClickLog.intent_key.name() + " = NEW." + Shortcuts.intent_key.name() + " WHERE "
                    + ClickLog.intent_key.name() + " = OLD." + Shortcuts.intent_key.name() + ";" + " END");

            db.execSQL("CREATE TABLE " + SourceStats.TABLE_NAME + " ( " + SourceStats.corpus.name()
                    + " TEXT NOT NULL COLLATE UNICODE PRIMARY KEY, " + SourceStats.total_clicks + " INTEGER);");
        }
    }
}