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