Home | History | Annotate | Download | only in quicksearchbox
      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 
     17 package com.android.quicksearchbox;
     18 
     19 import com.android.quicksearchbox.util.Consumer;
     20 import com.android.quicksearchbox.util.Consumers;
     21 import com.android.quicksearchbox.util.SQLiteAsyncQuery;
     22 import com.android.quicksearchbox.util.SQLiteTransaction;
     23 import com.android.quicksearchbox.util.Util;
     24 import com.google.common.annotations.VisibleForTesting;
     25 
     26 import org.json.JSONException;
     27 
     28 import android.app.SearchManager;
     29 import android.content.ComponentName;
     30 import android.content.ContentResolver;
     31 import android.content.ContentValues;
     32 import android.content.Context;
     33 import android.database.Cursor;
     34 import android.database.sqlite.SQLiteDatabase;
     35 import android.database.sqlite.SQLiteOpenHelper;
     36 import android.database.sqlite.SQLiteQueryBuilder;
     37 import android.net.Uri;
     38 import android.os.Handler;
     39 import android.text.TextUtils;
     40 import android.util.Log;
     41 
     42 import java.io.File;
     43 import java.util.Collection;
     44 import java.util.HashMap;
     45 import java.util.Map;
     46 import java.util.concurrent.Executor;
     47 
     48 /**
     49  * A shortcut repository implementation that uses a log of every click.
     50  *
     51  * To inspect DB:
     52  * # sqlite3 /data/data/com.android.quicksearchbox/databases/qsb-log.db
     53  *
     54  * TODO: Refactor this class.
     55  */
     56 public class ShortcutRepositoryImplLog implements ShortcutRepository {
     57 
     58     private static final boolean DBG = false;
     59     private static final String TAG = "QSB.ShortcutRepositoryImplLog";
     60 
     61     private static final String DB_NAME = "qsb-log.db";
     62     private static final int DB_VERSION = 32;
     63 
     64     private static final String HAS_HISTORY_QUERY =
     65         "SELECT " + Shortcuts.intent_key.fullName + " FROM " + Shortcuts.TABLE_NAME;
     66     private String mEmptyQueryShortcutQuery ;
     67     private String mShortcutQuery;
     68 
     69     private static final String SHORTCUT_BY_ID_WHERE =
     70             Shortcuts.shortcut_id.name() + "=? AND " + Shortcuts.source.name() + "=?";
     71 
     72     private static final String SOURCE_RANKING_SQL = buildSourceRankingSql();
     73 
     74     private final Context mContext;
     75     private final Config mConfig;
     76     private final Corpora mCorpora;
     77     private final ShortcutRefresher mRefresher;
     78     private final Handler mUiThread;
     79     // Used to perform log write operations asynchronously
     80     private final Executor mLogExecutor;
     81     private final DbOpenHelper mOpenHelper;
     82     private final String mSearchSpinner;
     83 
     84     /**
     85      * Create an instance to the repo.
     86      */
     87     public static ShortcutRepository create(Context context, Config config,
     88             Corpora sources, ShortcutRefresher refresher, Handler uiThread,
     89             Executor logExecutor) {
     90         return new ShortcutRepositoryImplLog(context, config, sources, refresher,
     91                 uiThread, logExecutor, DB_NAME);
     92     }
     93 
     94     /**
     95      * @param context Used to create / open db
     96      * @param name The name of the database to create.
     97      */
     98     @VisibleForTesting
     99     ShortcutRepositoryImplLog(Context context, Config config, Corpora corpora,
    100             ShortcutRefresher refresher, Handler uiThread, Executor logExecutor, String name) {
    101         mContext = context;
    102         mConfig = config;
    103         mCorpora = corpora;
    104         mRefresher = refresher;
    105         mUiThread = uiThread;
    106         mLogExecutor = logExecutor;
    107         mOpenHelper = new DbOpenHelper(context, name, DB_VERSION, config);
    108         buildShortcutQueries();
    109 
    110         mSearchSpinner = Util.getResourceUri(mContext, R.drawable.search_spinner).toString();
    111     }
    112 
    113     // clicklog first, since that's where restrict the result set
    114     private static final String TABLES = ClickLog.TABLE_NAME + " INNER JOIN " +
    115             Shortcuts.TABLE_NAME + " ON " + ClickLog.intent_key.fullName + " = " +
    116             Shortcuts.intent_key.fullName;
    117 
    118     private static final String AS = " AS ";
    119 
    120     private static final String[] SHORTCUT_QUERY_COLUMNS = {
    121             Shortcuts.intent_key.fullName,
    122             Shortcuts.source.fullName,
    123             Shortcuts.source_version_code.fullName,
    124             Shortcuts.format.fullName + AS + SearchManager.SUGGEST_COLUMN_FORMAT,
    125             Shortcuts.title + AS + SearchManager.SUGGEST_COLUMN_TEXT_1,
    126             Shortcuts.description + AS + SearchManager.SUGGEST_COLUMN_TEXT_2,
    127             Shortcuts.description_url + AS + SearchManager.SUGGEST_COLUMN_TEXT_2_URL,
    128             Shortcuts.icon1 + AS + SearchManager.SUGGEST_COLUMN_ICON_1,
    129             Shortcuts.icon2 + AS + SearchManager.SUGGEST_COLUMN_ICON_2,
    130             Shortcuts.intent_action + AS + SearchManager.SUGGEST_COLUMN_INTENT_ACTION,
    131             Shortcuts.intent_component.fullName,
    132             Shortcuts.intent_data + AS + SearchManager.SUGGEST_COLUMN_INTENT_DATA,
    133             Shortcuts.intent_query + AS + SearchManager.SUGGEST_COLUMN_QUERY,
    134             Shortcuts.intent_extradata + AS + SearchManager.SUGGEST_COLUMN_INTENT_EXTRA_DATA,
    135             Shortcuts.shortcut_id + AS + SearchManager.SUGGEST_COLUMN_SHORTCUT_ID,
    136             Shortcuts.spinner_while_refreshing + AS +
    137                     SearchManager.SUGGEST_COLUMN_SPINNER_WHILE_REFRESHING,
    138             Shortcuts.log_type + AS + CursorBackedSuggestionCursor.SUGGEST_COLUMN_LOG_TYPE,
    139             Shortcuts.custom_columns.fullName,
    140         };
    141 
    142     // Avoid GLOB by using >= AND <, with some manipulation (see nextString(String)).
    143     // to figure out the upper bound (e.g. >= "abc" AND < "abd"
    144     // This allows us to use parameter binding and still take advantage of the
    145     // index on the query column.
    146     private static final String PREFIX_RESTRICTION =
    147             ClickLog.query.fullName + " >= ?1 AND " + ClickLog.query.fullName + " < ?2";
    148 
    149     private static final String LAST_HIT_TIME_EXPR = "MAX(" + ClickLog.hit_time.fullName + ")";
    150     private static final String GROUP_BY = ClickLog.intent_key.fullName;
    151     private static final String PREFER_LATEST_PREFIX =
    152         "(" + LAST_HIT_TIME_EXPR + " = (SELECT " + LAST_HIT_TIME_EXPR + " FROM " +
    153         ClickLog.TABLE_NAME + " WHERE ";
    154     private static final String PREFER_LATEST_SUFFIX = "))";
    155 
    156     private void buildShortcutQueries() {
    157         // SQL expression for the time before which no clicks should be counted.
    158         String cutOffTime_expr = "(?3 - " + mConfig.getMaxStatAgeMillis() + ")";
    159         // Filter out clicks that are too old
    160         String ageRestriction = ClickLog.hit_time.fullName + " >= " + cutOffTime_expr;
    161         String having = null;
    162         // Order by sum of hit times (seconds since cutoff) for the clicks for each shortcut.
    163         // This has the effect of multiplying the average hit time with the click count
    164         String ordering_expr =
    165                 "SUM((" + ClickLog.hit_time.fullName + " - " + cutOffTime_expr + ") / 1000)";
    166 
    167         String where = ageRestriction;
    168         String preferLatest = PREFER_LATEST_PREFIX + where + PREFER_LATEST_SUFFIX;
    169         String orderBy = preferLatest + " DESC, " + ordering_expr + " DESC";
    170         mEmptyQueryShortcutQuery = SQLiteQueryBuilder.buildQueryString(
    171                 false, TABLES, SHORTCUT_QUERY_COLUMNS, where, GROUP_BY, having, orderBy, null);
    172         if (DBG) Log.d(TAG, "Empty shortcut query:\n" + mEmptyQueryShortcutQuery);
    173 
    174         where = PREFIX_RESTRICTION + " AND " + ageRestriction;
    175         preferLatest = PREFER_LATEST_PREFIX + where + PREFER_LATEST_SUFFIX;
    176         orderBy = preferLatest + " DESC, " + ordering_expr + " DESC";
    177         mShortcutQuery = SQLiteQueryBuilder.buildQueryString(
    178                 false, TABLES, SHORTCUT_QUERY_COLUMNS, where, GROUP_BY, having, orderBy, null);
    179         if (DBG) Log.d(TAG, "Empty shortcut:\n" + mShortcutQuery);
    180     }
    181 
    182     /**
    183      * @return sql that ranks sources by total clicks, filtering out sources
    184      *         without enough clicks.
    185      */
    186     private static String buildSourceRankingSql() {
    187         final String orderingExpr = SourceStats.total_clicks.name();
    188         final String tables = SourceStats.TABLE_NAME;
    189         final String[] columns = SourceStats.COLUMNS;
    190         final String where = SourceStats.total_clicks + " >= $1";
    191         final String groupBy = null;
    192         final String having = null;
    193         final String orderBy = orderingExpr + " DESC";
    194         final String limit = null;
    195         return SQLiteQueryBuilder.buildQueryString(
    196                 false, tables, columns, where, groupBy, having, orderBy, limit);
    197     }
    198 
    199     protected DbOpenHelper getOpenHelper() {
    200         return mOpenHelper;
    201     }
    202 
    203     private void runTransactionAsync(final SQLiteTransaction transaction) {
    204         mLogExecutor.execute(new Runnable() {
    205             public void run() {
    206                 transaction.run(mOpenHelper.getWritableDatabase());
    207             }
    208         });
    209     }
    210 
    211     private <A> void runQueryAsync(final SQLiteAsyncQuery<A> query, final Consumer<A> consumer) {
    212         mLogExecutor.execute(new Runnable() {
    213             public void run() {
    214                 query.run(mOpenHelper.getReadableDatabase(), consumer);
    215             }
    216         });
    217     }
    218 
    219 // --------------------- Interface ShortcutRepository ---------------------
    220 
    221     public void hasHistory(Consumer<Boolean> consumer) {
    222         runQueryAsync(new SQLiteAsyncQuery<Boolean>() {
    223             @Override
    224             protected Boolean performQuery(SQLiteDatabase db) {
    225                 return hasHistory(db);
    226             }
    227         }, consumer);
    228     }
    229 
    230     public void removeFromHistory(SuggestionCursor suggestions, int position) {
    231         suggestions.moveTo(position);
    232         final String intentKey = makeIntentKey(suggestions);
    233         runTransactionAsync(new SQLiteTransaction() {
    234             @Override
    235             public boolean performTransaction(SQLiteDatabase db) {
    236                 db.delete(Shortcuts.TABLE_NAME, Shortcuts.intent_key.fullName + " = ?",
    237                         new String[]{ intentKey });
    238                 return true;
    239             }
    240         });
    241     }
    242 
    243     public void clearHistory() {
    244         runTransactionAsync(new SQLiteTransaction() {
    245             @Override
    246             public boolean performTransaction(SQLiteDatabase db) {
    247                 db.delete(ClickLog.TABLE_NAME, null, null);
    248                 db.delete(Shortcuts.TABLE_NAME, null, null);
    249                 db.delete(SourceStats.TABLE_NAME, null, null);
    250                 return true;
    251             }
    252         });
    253     }
    254 
    255     @VisibleForTesting
    256     public void deleteRepository() {
    257         getOpenHelper().deleteDatabase();
    258     }
    259 
    260     public void close() {
    261         getOpenHelper().close();
    262     }
    263 
    264     public void reportClick(final SuggestionCursor suggestions, final int position) {
    265         final long now = System.currentTimeMillis();
    266         reportClickAtTime(suggestions, position, now);
    267     }
    268 
    269     public void getShortcutsForQuery(final String query, final Collection<Corpus> allowedCorpora,
    270             final boolean allowWebSearchShortcuts, final Consumer<ShortcutCursor> consumer) {
    271         final long now = System.currentTimeMillis();
    272         mLogExecutor.execute(new Runnable() {
    273             public void run() {
    274                 ShortcutCursor shortcuts = getShortcutsForQuery(query, allowedCorpora,
    275                         allowWebSearchShortcuts, now);
    276                 Consumers.consumeCloseable(consumer, shortcuts);
    277             }
    278         });
    279     }
    280 
    281     public void updateShortcut(Source source, String shortcutId, SuggestionCursor refreshed) {
    282         refreshShortcut(source, shortcutId, refreshed);
    283     }
    284 
    285     public void getCorpusScores(final Consumer<Map<String, Integer>> consumer) {
    286         runQueryAsync(new SQLiteAsyncQuery<Map<String, Integer>>() {
    287             @Override
    288             protected Map<String, Integer> performQuery(SQLiteDatabase db) {
    289                 return getCorpusScores();
    290             }
    291         }, consumer);
    292     }
    293 
    294 // -------------------------- end ShortcutRepository --------------------------
    295 
    296     private boolean hasHistory(SQLiteDatabase db) {
    297         Cursor cursor = db.rawQuery(HAS_HISTORY_QUERY, null);
    298         try {
    299             if (DBG) Log.d(TAG, "hasHistory(): cursor=" + cursor);
    300             return cursor != null && cursor.getCount() > 0;
    301         } finally {
    302             if (cursor != null) cursor.close();
    303         }
    304     }
    305 
    306     private Map<String,Integer> getCorpusScores() {
    307         return getCorpusScores(mConfig.getMinClicksForSourceRanking());
    308     }
    309 
    310     private boolean shouldRefresh(Suggestion suggestion) {
    311         return mRefresher.shouldRefresh(suggestion.getSuggestionSource(),
    312                 suggestion.getShortcutId());
    313     }
    314 
    315     @VisibleForTesting
    316     ShortcutCursor getShortcutsForQuery(String query, Collection<Corpus> allowedCorpora,
    317             boolean allowWebSearchShortcuts, long now) {
    318         if (DBG) Log.d(TAG, "getShortcutsForQuery(" + query + "," + allowedCorpora + ")");
    319         String sql = query.length() == 0 ? mEmptyQueryShortcutQuery : mShortcutQuery;
    320         String[] params = buildShortcutQueryParams(query, now);
    321 
    322         SQLiteDatabase db = mOpenHelper.getReadableDatabase();
    323         Cursor cursor = db.rawQuery(sql, params);
    324         if (cursor.getCount() == 0) {
    325             cursor.close();
    326             return null;
    327         }
    328 
    329         if (DBG) Log.d(TAG, "Allowed sources: ");
    330         HashMap<String,Source> allowedSources = new HashMap<String,Source>();
    331         for (Corpus corpus : allowedCorpora) {
    332             for (Source source : corpus.getSources()) {
    333                 if (DBG) Log.d(TAG, "\t" + source.getName());
    334                 allowedSources.put(source.getName(), source);
    335             }
    336         }
    337 
    338         return new ShortcutCursor(new SuggestionCursorImpl(allowedSources, query, cursor),
    339                 allowWebSearchShortcuts, mUiThread, mRefresher, this);
    340     }
    341 
    342     @VisibleForTesting
    343     void refreshShortcut(Source source, final String shortcutId,
    344             SuggestionCursor refreshed) {
    345         if (source == null) throw new NullPointerException("source");
    346         if (shortcutId == null) throw new NullPointerException("shortcutId");
    347 
    348         final String[] whereArgs = { shortcutId, source.getName() };
    349         final ContentValues shortcut;
    350         if (refreshed == null || refreshed.getCount() == 0) {
    351             shortcut = null;
    352         } else {
    353             refreshed.moveTo(0);
    354             shortcut = makeShortcutRow(refreshed);
    355         }
    356 
    357         runTransactionAsync(new SQLiteTransaction() {
    358             @Override
    359             protected boolean performTransaction(SQLiteDatabase db) {
    360                 if (shortcut == null) {
    361                     if (DBG) Log.d(TAG, "Deleting shortcut: " + shortcutId);
    362                     db.delete(Shortcuts.TABLE_NAME, SHORTCUT_BY_ID_WHERE, whereArgs);
    363                 } else {
    364                     if (DBG) Log.d(TAG, "Updating shortcut: " + shortcut);
    365                     db.updateWithOnConflict(Shortcuts.TABLE_NAME, shortcut,
    366                             SHORTCUT_BY_ID_WHERE, whereArgs, SQLiteDatabase.CONFLICT_REPLACE);
    367                 }
    368                 return true;
    369             }
    370         });
    371     }
    372 
    373     private class SuggestionCursorImpl extends CursorBackedSuggestionCursor {
    374 
    375         private final HashMap<String, Source> mAllowedSources;
    376         private final int mExtrasColumn;
    377 
    378         public SuggestionCursorImpl(HashMap<String,Source> allowedSources,
    379                 String userQuery, Cursor cursor) {
    380             super(userQuery, cursor);
    381             mAllowedSources = allowedSources;
    382             mExtrasColumn = cursor.getColumnIndex(Shortcuts.custom_columns.name());
    383         }
    384 
    385         @Override
    386         public Source getSuggestionSource() {
    387             int srcCol = mCursor.getColumnIndex(Shortcuts.source.name());
    388             String srcStr = mCursor.getString(srcCol);
    389             if (srcStr == null) {
    390                 throw new NullPointerException("Missing source for shortcut.");
    391             }
    392             Source source = mAllowedSources.get(srcStr);
    393             if (source == null) {
    394                 if (DBG) {
    395                     Log.d(TAG, "Source " + srcStr + " (position " + mCursor.getPosition() +
    396                             ") not allowed");
    397                 }
    398                 return null;
    399             }
    400             int versionCode = mCursor.getInt(Shortcuts.source_version_code.ordinal());
    401             if (!source.isVersionCodeCompatible(versionCode)) {
    402                 if (DBG) {
    403                     Log.d(TAG, "Version " + versionCode + " not compatible with " +
    404                             source.getVersionCode() + " for source " + srcStr);
    405                 }
    406                 return null;
    407             }
    408             return source;
    409         }
    410 
    411         @Override
    412         public ComponentName getSuggestionIntentComponent() {
    413             int componentCol = mCursor.getColumnIndex(Shortcuts.intent_component.name());
    414             // We don't fall back to getSuggestionSource().getIntentComponent() because
    415             // we want to return the same value that getSuggestionIntentComponent() did for the
    416             // original suggestion.
    417             return stringToComponentName(mCursor.getString(componentCol));
    418         }
    419 
    420         @Override
    421         public String getSuggestionIcon2() {
    422             if (isSpinnerWhileRefreshing() && shouldRefresh(this)) {
    423                 if (DBG) Log.d(TAG, "shortcut " + getShortcutId() + " refreshing");
    424                 return mSearchSpinner;
    425             }
    426             if (DBG) Log.d(TAG, "shortcut " + getShortcutId() + " NOT refreshing");
    427             return super.getSuggestionIcon2();
    428         }
    429 
    430         public boolean isSuggestionShortcut() {
    431             return true;
    432         }
    433 
    434         public boolean isHistorySuggestion() {
    435             // This always returns false, even for suggestions that originally came
    436             // from server-side history, since we'd otherwise have to parse the Genie
    437             // extra data. This is ok, since this method is only used for the
    438             // "Remove from history" UI, which is also shown for all shortcuts.
    439             return false;
    440         }
    441 
    442         @Override
    443         public SuggestionExtras getExtras() {
    444             String json = mCursor.getString(mExtrasColumn);
    445             if (!TextUtils.isEmpty(json)) {
    446                 try {
    447                     return new JsonBackedSuggestionExtras(json);
    448                 } catch (JSONException e) {
    449                     Log.e(TAG, "Could not parse JSON extras from DB: " + json);
    450                 }
    451             }
    452             return null;
    453         }
    454 
    455         public Collection<String> getExtraColumns() {
    456             /*
    457              * We always return null here because:
    458              * - to return an accurate value, we'd have to aggregate all the extra columns in all
    459              *   shortcuts in the shortcuts table, which would mean parsing ALL the JSON contained
    460              *   therein
    461              * - ListSuggestionCursor does this aggregation, and does it lazily
    462              * - All shortcuts are put into a ListSuggestionCursor during the promotion process, so
    463              *   relying on ListSuggestionCursor to do the aggregation means that we only parse the
    464              *   JSON for shortcuts that are actually displayed.
    465              */
    466             return null;
    467         }
    468     }
    469 
    470     /**
    471      * Builds a parameter list for the queries built by {@link #buildShortcutQueries}.
    472      */
    473     private static String[] buildShortcutQueryParams(String query, long now) {
    474         return new String[]{ query, nextString(query), String.valueOf(now) };
    475     }
    476 
    477     /**
    478      * Given a string x, this method returns the least string y such that x is not a prefix of y.
    479      * This is useful to implement prefix filtering by comparison, since the only strings z that
    480      * have x as a prefix are such that z is greater than or equal to x and z is less than y.
    481      *
    482      * @param str A non-empty string. The contract above is not honored for an empty input string,
    483      *        since all strings have the empty string as a prefix.
    484      */
    485     private static String nextString(String str) {
    486         int len = str.length();
    487         if (len == 0) {
    488             return str;
    489         }
    490         // The last code point in the string. Within the Basic Multilingual Plane,
    491         // this is the same as str.charAt(len-1)
    492         int codePoint = str.codePointBefore(len);
    493         // This should be safe from overflow, since the largest code point
    494         // representable in UTF-16 is U+10FFFF.
    495         int nextCodePoint = codePoint + 1;
    496         // The index of the start of the last code point.
    497         // Character.charCount(codePoint) is always 1 (in the BMP) or 2
    498         int lastIndex = len - Character.charCount(codePoint);
    499         return new StringBuilder(len)
    500                 .append(str, 0, lastIndex)  // append everything but the last code point
    501                 .appendCodePoint(nextCodePoint)  // instead of the last code point, use successor
    502                 .toString();
    503     }
    504 
    505     /**
    506      * Returns the source ranking for sources with a minimum number of clicks.
    507      *
    508      * @param minClicks The minimum number of clicks a source must have.
    509      * @return The list of sources, ranked by total clicks.
    510      */
    511     Map<String,Integer> getCorpusScores(int minClicks) {
    512         SQLiteDatabase db = mOpenHelper.getReadableDatabase();
    513         final Cursor cursor = db.rawQuery(
    514                 SOURCE_RANKING_SQL, new String[] { String.valueOf(minClicks) });
    515         try {
    516             Map<String,Integer> corpora = new HashMap<String,Integer>(cursor.getCount());
    517             while (cursor.moveToNext()) {
    518                 String name = cursor.getString(SourceStats.corpus.ordinal());
    519                 int clicks = cursor.getInt(SourceStats.total_clicks.ordinal());
    520                 corpora.put(name, clicks);
    521             }
    522             return corpora;
    523         } finally {
    524             cursor.close();
    525         }
    526     }
    527 
    528     private ContentValues makeShortcutRow(Suggestion suggestion) {
    529         String intentAction = suggestion.getSuggestionIntentAction();
    530         String intentComponent = componentNameToString(suggestion.getSuggestionIntentComponent());
    531         String intentData = suggestion.getSuggestionIntentDataString();
    532         String intentQuery = suggestion.getSuggestionQuery();
    533         String intentExtraData = suggestion.getSuggestionIntentExtraData();
    534 
    535         Source source = suggestion.getSuggestionSource();
    536         String sourceName = source.getName();
    537 
    538         String intentKey = makeIntentKey(suggestion);
    539 
    540         // Get URIs for all icons, to make sure that they are stable
    541         String icon1Uri = getIconUriString(source, suggestion.getSuggestionIcon1());
    542         String icon2Uri = getIconUriString(source, suggestion.getSuggestionIcon2());
    543 
    544         String extrasJson = null;
    545         SuggestionExtras extras = suggestion.getExtras();
    546         if (extras != null) {
    547             // flatten any custom columns to JSON. We need to keep any custom columns so that
    548             // shortcuts for custom suggestion views work properly.
    549             try {
    550                 extrasJson = extras.toJsonString();
    551             } catch (JSONException e) {
    552                 Log.e(TAG, "Could not flatten extras to JSON from " + suggestion, e);
    553             }
    554         }
    555 
    556         ContentValues cv = new ContentValues();
    557         cv.put(Shortcuts.intent_key.name(), intentKey);
    558         cv.put(Shortcuts.source.name(), sourceName);
    559         cv.put(Shortcuts.source_version_code.name(), source.getVersionCode());
    560         cv.put(Shortcuts.format.name(), suggestion.getSuggestionFormat());
    561         cv.put(Shortcuts.title.name(), suggestion.getSuggestionText1());
    562         cv.put(Shortcuts.description.name(), suggestion.getSuggestionText2());
    563         cv.put(Shortcuts.description_url.name(), suggestion.getSuggestionText2Url());
    564         cv.put(Shortcuts.icon1.name(), icon1Uri);
    565         cv.put(Shortcuts.icon2.name(), icon2Uri);
    566         cv.put(Shortcuts.intent_action.name(), intentAction);
    567         cv.put(Shortcuts.intent_component.name(), intentComponent);
    568         cv.put(Shortcuts.intent_data.name(), intentData);
    569         cv.put(Shortcuts.intent_query.name(), intentQuery);
    570         cv.put(Shortcuts.intent_extradata.name(), intentExtraData);
    571         cv.put(Shortcuts.shortcut_id.name(), suggestion.getShortcutId());
    572         if (suggestion.isSpinnerWhileRefreshing()) {
    573             cv.put(Shortcuts.spinner_while_refreshing.name(), "true");
    574         }
    575         cv.put(Shortcuts.log_type.name(), suggestion.getSuggestionLogType());
    576         cv.put(Shortcuts.custom_columns.name(), extrasJson);
    577 
    578         return cv;
    579     }
    580 
    581     /**
    582      * Makes a string of the form source#intentData#intentAction#intentQuery
    583      * for use as a unique identifier of a suggestion.
    584      * */
    585     private String makeIntentKey(Suggestion suggestion) {
    586         String intentAction = suggestion.getSuggestionIntentAction();
    587         String intentComponent = componentNameToString(suggestion.getSuggestionIntentComponent());
    588         String intentData = suggestion.getSuggestionIntentDataString();
    589         String intentQuery = suggestion.getSuggestionQuery();
    590 
    591         Source source = suggestion.getSuggestionSource();
    592         String sourceName = source.getName();
    593         StringBuilder key = new StringBuilder(sourceName);
    594         key.append("#");
    595         if (intentData != null) {
    596             key.append(intentData);
    597         }
    598         key.append("#");
    599         if (intentAction != null) {
    600             key.append(intentAction);
    601         }
    602         key.append("#");
    603         if (intentComponent != null) {
    604             key.append(intentComponent);
    605         }
    606         key.append("#");
    607         if (intentQuery != null) {
    608             key.append(intentQuery);
    609         }
    610 
    611         return key.toString();
    612     }
    613 
    614     private String componentNameToString(ComponentName component) {
    615         return component == null ? null : component.flattenToShortString();
    616     }
    617 
    618     private ComponentName stringToComponentName(String str) {
    619         return str == null ? null : ComponentName.unflattenFromString(str);
    620     }
    621 
    622     private String getIconUriString(Source source, String drawableId) {
    623         // Fast path for empty icons
    624         if (TextUtils.isEmpty(drawableId) || "0".equals(drawableId)) {
    625             return null;
    626         }
    627         // Fast path for icon URIs
    628         if (drawableId.startsWith(ContentResolver.SCHEME_ANDROID_RESOURCE)
    629                 || drawableId.startsWith(ContentResolver.SCHEME_CONTENT)
    630                 || drawableId.startsWith(ContentResolver.SCHEME_FILE)) {
    631             return drawableId;
    632         }
    633         Uri uri = source.getIconUri(drawableId);
    634         return uri == null ? null : uri.toString();
    635     }
    636 
    637     @VisibleForTesting
    638     void reportClickAtTime(SuggestionCursor suggestion,
    639             int position, long now) {
    640         suggestion.moveTo(position);
    641         if (DBG) {
    642             Log.d(TAG, "logClicked(" + suggestion + ")");
    643         }
    644 
    645         if (SearchManager.SUGGEST_NEVER_MAKE_SHORTCUT.equals(suggestion.getShortcutId())) {
    646             if (DBG) Log.d(TAG, "clicked suggestion requested not to be shortcuted");
    647             return;
    648         }
    649 
    650         Corpus corpus = mCorpora.getCorpusForSource(suggestion.getSuggestionSource());
    651         if (corpus == null) {
    652             Log.w(TAG, "no corpus for clicked suggestion");
    653             return;
    654         }
    655 
    656         // Once the user has clicked on a shortcut, don't bother refreshing
    657         // (especially if this is a new shortcut)
    658         mRefresher.markShortcutRefreshed(suggestion.getSuggestionSource(),
    659                 suggestion.getShortcutId());
    660 
    661         // Add or update suggestion info
    662         // Since intent_key is the primary key, any existing
    663         // suggestion with the same source+data+action will be replaced
    664         final ContentValues shortcut = makeShortcutRow(suggestion);
    665         String intentKey = shortcut.getAsString(Shortcuts.intent_key.name());
    666 
    667         // Log click for shortcut
    668         final ContentValues click = new ContentValues();
    669         click.put(ClickLog.intent_key.name(), intentKey);
    670         click.put(ClickLog.query.name(), suggestion.getUserQuery());
    671         click.put(ClickLog.hit_time.name(), now);
    672         click.put(ClickLog.corpus.name(), corpus.getName());
    673 
    674         runTransactionAsync(new SQLiteTransaction() {
    675             @Override
    676             protected boolean performTransaction(SQLiteDatabase db) {
    677                 if (DBG) Log.d(TAG, "Adding shortcut: " + shortcut);
    678                 db.replaceOrThrow(Shortcuts.TABLE_NAME, null, shortcut);
    679                 db.insertOrThrow(ClickLog.TABLE_NAME, null, click);
    680                 return true;
    681             }
    682         });
    683     }
    684 
    685 // -------------------------- TABLES --------------------------
    686 
    687     /**
    688      * shortcuts table
    689      */
    690     enum Shortcuts {
    691         intent_key,
    692         source,
    693         source_version_code,
    694         format,
    695         title,
    696         description,
    697         description_url,
    698         icon1,
    699         icon2,
    700         intent_action,
    701         intent_component,
    702         intent_data,
    703         intent_query,
    704         intent_extradata,
    705         shortcut_id,
    706         spinner_while_refreshing,
    707         log_type,
    708         custom_columns;
    709 
    710         static final String TABLE_NAME = "shortcuts";
    711 
    712         public final String fullName;
    713 
    714         Shortcuts() {
    715             fullName = TABLE_NAME + "." + name();
    716         }
    717     }
    718 
    719     /**
    720      * clicklog table. Has one record for each click.
    721      */
    722     enum ClickLog {
    723         _id,
    724         intent_key,
    725         query,
    726         hit_time,
    727         corpus;
    728 
    729         static final String[] COLUMNS = initColumns();
    730 
    731         static final String TABLE_NAME = "clicklog";
    732 
    733         private static String[] initColumns() {
    734             ClickLog[] vals = ClickLog.values();
    735             String[] columns = new String[vals.length];
    736             for (int i = 0; i < vals.length; i++) {
    737                 columns[i] = vals[i].fullName;
    738             }
    739             return columns;
    740         }
    741 
    742         public final String fullName;
    743 
    744         ClickLog() {
    745             fullName = TABLE_NAME + "." + name();
    746         }
    747     }
    748 
    749     /**
    750      * This is an aggregate table of {@link ClickLog} that stays up to date with the total
    751      * clicks for each corpus. This makes computing the corpus ranking more
    752      * more efficient, at the expense of some extra work when the clicks are reported.
    753      */
    754     enum SourceStats {
    755         corpus,
    756         total_clicks;
    757 
    758         static final String TABLE_NAME = "sourcetotals";
    759 
    760         static final String[] COLUMNS = initColumns();
    761 
    762         private static String[] initColumns() {
    763             SourceStats[] vals = SourceStats.values();
    764             String[] columns = new String[vals.length];
    765             for (int i = 0; i < vals.length; i++) {
    766                 columns[i] = vals[i].fullName;
    767             }
    768             return columns;
    769         }
    770 
    771         public final String fullName;
    772 
    773         SourceStats() {
    774             fullName = TABLE_NAME + "." + name();
    775         }
    776     }
    777 
    778 // -------------------------- END TABLES --------------------------
    779 
    780     // contains creation and update logic
    781     private static class DbOpenHelper extends SQLiteOpenHelper {
    782         private final Config mConfig;
    783         private String mPath;
    784         private static final String SHORTCUT_ID_INDEX
    785                 = Shortcuts.TABLE_NAME + "_" + Shortcuts.shortcut_id.name();
    786         private static final String CLICKLOG_QUERY_INDEX
    787                 = ClickLog.TABLE_NAME + "_" + ClickLog.query.name();
    788         private static final String CLICKLOG_HIT_TIME_INDEX
    789                 = ClickLog.TABLE_NAME + "_" + ClickLog.hit_time.name();
    790         private static final String CLICKLOG_INSERT_TRIGGER
    791                 = ClickLog.TABLE_NAME + "_insert";
    792         private static final String SHORTCUTS_DELETE_TRIGGER
    793                 = Shortcuts.TABLE_NAME + "_delete";
    794         private static final String SHORTCUTS_UPDATE_INTENT_KEY_TRIGGER
    795                 = Shortcuts.TABLE_NAME + "_update_intent_key";
    796 
    797         public DbOpenHelper(Context context, String name, int version, Config config) {
    798             super(context, name, null, version);
    799             mConfig = config;
    800         }
    801 
    802         @Override
    803         public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
    804             // The shortcuts info is not all that important, so we just drop the tables
    805             // and re-create empty ones.
    806             Log.i(TAG, "Upgrading shortcuts DB from version " +
    807                     + oldVersion + " to " + newVersion + ". This deletes all shortcuts.");
    808             dropTables(db);
    809             onCreate(db);
    810         }
    811 
    812         private void dropTables(SQLiteDatabase db) {
    813             db.execSQL("DROP TRIGGER IF EXISTS " + CLICKLOG_INSERT_TRIGGER);
    814             db.execSQL("DROP TRIGGER IF EXISTS " + SHORTCUTS_DELETE_TRIGGER);
    815             db.execSQL("DROP TRIGGER IF EXISTS " + SHORTCUTS_UPDATE_INTENT_KEY_TRIGGER);
    816             db.execSQL("DROP INDEX IF EXISTS " + CLICKLOG_HIT_TIME_INDEX);
    817             db.execSQL("DROP INDEX IF EXISTS " + CLICKLOG_QUERY_INDEX);
    818             db.execSQL("DROP INDEX IF EXISTS " + SHORTCUT_ID_INDEX);
    819             db.execSQL("DROP TABLE IF EXISTS " + ClickLog.TABLE_NAME);
    820             db.execSQL("DROP TABLE IF EXISTS " + Shortcuts.TABLE_NAME);
    821             db.execSQL("DROP TABLE IF EXISTS " + SourceStats.TABLE_NAME);
    822         }
    823 
    824         /**
    825          * Deletes the database file.
    826          */
    827         public void deleteDatabase() {
    828             close();
    829             if (mPath == null) return;
    830             try {
    831                 new File(mPath).delete();
    832                 if (DBG) Log.d(TAG, "deleted " + mPath);
    833             } catch (Exception e) {
    834                 Log.w(TAG, "couldn't delete " + mPath, e);
    835             }
    836         }
    837 
    838         @Override
    839         public void onOpen(SQLiteDatabase db) {
    840             super.onOpen(db);
    841             mPath = db.getPath();
    842         }
    843 
    844         @Override
    845         public void onCreate(SQLiteDatabase db) {
    846             db.execSQL("CREATE TABLE " + Shortcuts.TABLE_NAME + " (" +
    847                     // COLLATE UNICODE is needed to make it possible to use nextString()
    848                     // to implement fast prefix filtering.
    849                     Shortcuts.intent_key.name() + " TEXT NOT NULL COLLATE UNICODE PRIMARY KEY, " +
    850                     Shortcuts.source.name() + " TEXT NOT NULL, " +
    851                     Shortcuts.source_version_code.name() + " INTEGER NOT NULL, " +
    852                     Shortcuts.format.name() + " TEXT, " +
    853                     Shortcuts.title.name() + " TEXT, " +
    854                     Shortcuts.description.name() + " TEXT, " +
    855                     Shortcuts.description_url.name() + " TEXT, " +
    856                     Shortcuts.icon1.name() + " TEXT, " +
    857                     Shortcuts.icon2.name() + " TEXT, " +
    858                     Shortcuts.intent_action.name() + " TEXT, " +
    859                     Shortcuts.intent_component.name() + " TEXT, " +
    860                     Shortcuts.intent_data.name() + " TEXT, " +
    861                     Shortcuts.intent_query.name() + " TEXT, " +
    862                     Shortcuts.intent_extradata.name() + " TEXT, " +
    863                     Shortcuts.shortcut_id.name() + " TEXT, " +
    864                     Shortcuts.spinner_while_refreshing.name() + " TEXT, " +
    865                     Shortcuts.log_type.name() + " TEXT, " +
    866                     Shortcuts.custom_columns.name() + " TEXT" +
    867                     ");");
    868 
    869             // index for fast lookup of shortcuts by shortcut_id
    870             db.execSQL("CREATE INDEX " + SHORTCUT_ID_INDEX
    871                     + " ON " + Shortcuts.TABLE_NAME
    872                     + "(" + Shortcuts.shortcut_id.name() + ", " + Shortcuts.source.name() + ")");
    873 
    874             db.execSQL("CREATE TABLE " + ClickLog.TABLE_NAME + " ( " +
    875                     ClickLog._id.name() + " INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, " +
    876                     // type must match Shortcuts.intent_key
    877                     ClickLog.intent_key.name() + " TEXT NOT NULL COLLATE UNICODE REFERENCES "
    878                         + Shortcuts.TABLE_NAME + "(" + Shortcuts.intent_key + "), " +
    879                     ClickLog.query.name() + " TEXT, " +
    880                     ClickLog.hit_time.name() + " INTEGER," +
    881                     ClickLog.corpus.name() + " TEXT" +
    882                     ");");
    883 
    884             // index for fast lookup of clicks by query
    885             db.execSQL("CREATE INDEX " + CLICKLOG_QUERY_INDEX
    886                     + " ON " + ClickLog.TABLE_NAME + "(" + ClickLog.query.name() + ")");
    887 
    888             // index for finding old clicks quickly
    889             db.execSQL("CREATE INDEX " + CLICKLOG_HIT_TIME_INDEX
    890                     + " ON " + ClickLog.TABLE_NAME + "(" + ClickLog.hit_time.name() + ")");
    891 
    892             // trigger for purging old clicks, i.e. those such that
    893             // hit_time < now - MAX_MAX_STAT_AGE_MILLIS, where now is the
    894             // hit_time of the inserted record, and for updating the SourceStats table
    895             db.execSQL("CREATE TRIGGER " + CLICKLOG_INSERT_TRIGGER + " AFTER INSERT ON "
    896                     + ClickLog.TABLE_NAME
    897                     + " BEGIN"
    898                     + " DELETE FROM " + ClickLog.TABLE_NAME + " WHERE "
    899                             + ClickLog.hit_time.name() + " <"
    900                             + " NEW." + ClickLog.hit_time.name()
    901                                     + " - " + mConfig.getMaxStatAgeMillis() + ";"
    902                     + " DELETE FROM " + SourceStats.TABLE_NAME + ";"
    903                     + " INSERT INTO " + SourceStats.TABLE_NAME  + " "
    904                             + "SELECT " + ClickLog.corpus + "," + "COUNT(*) FROM "
    905                             + ClickLog.TABLE_NAME + " GROUP BY " + ClickLog.corpus.name() + ";"
    906                     + " END");
    907 
    908             // trigger for deleting clicks about a shortcut once that shortcut has been
    909             // deleted
    910             db.execSQL("CREATE TRIGGER " + SHORTCUTS_DELETE_TRIGGER + " AFTER DELETE ON "
    911                     + Shortcuts.TABLE_NAME
    912                     + " BEGIN"
    913                     + " DELETE FROM " + ClickLog.TABLE_NAME + " WHERE "
    914                             + ClickLog.intent_key.name()
    915                             + " = OLD." + Shortcuts.intent_key.name() + ";"
    916                     + " END");
    917 
    918             // trigger for updating click log entries when a shortcut changes its intent_key
    919             db.execSQL("CREATE TRIGGER " + SHORTCUTS_UPDATE_INTENT_KEY_TRIGGER
    920                     + " AFTER UPDATE ON " + Shortcuts.TABLE_NAME
    921                     + " WHEN NEW." + Shortcuts.intent_key.name()
    922                             + " != OLD." + Shortcuts.intent_key.name()
    923                     + " BEGIN"
    924                     + " UPDATE " + ClickLog.TABLE_NAME + " SET "
    925                             + ClickLog.intent_key.name() + " = NEW." + Shortcuts.intent_key.name()
    926                             + " WHERE "
    927                             + ClickLog.intent_key.name() + " = OLD." + Shortcuts.intent_key.name()
    928                             + ";"
    929                     + " END");
    930 
    931             db.execSQL("CREATE TABLE " + SourceStats.TABLE_NAME + " ( " +
    932                     SourceStats.corpus.name() + " TEXT NOT NULL COLLATE UNICODE PRIMARY KEY, " +
    933                     SourceStats.total_clicks + " INTEGER);"
    934                     );
    935         }
    936     }
    937 }
    938