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