ShortcutRepositoryImplLog.java revision 5229b06f00d20aac76cd8519b37f2a579d61c54f
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.android.quicksearchbox.util.Consumer;
20import com.android.quicksearchbox.util.Consumers;
21import com.android.quicksearchbox.util.SQLiteAsyncQuery;
22import com.android.quicksearchbox.util.SQLiteTransaction;
23import com.android.quicksearchbox.util.Util;
24import com.google.common.annotations.VisibleForTesting;
25
26import org.json.JSONException;
27
28import android.app.SearchManager;
29import android.content.ComponentName;
30import android.content.ContentResolver;
31import android.content.ContentValues;
32import android.content.Context;
33import android.database.Cursor;
34import android.database.sqlite.SQLiteDatabase;
35import android.database.sqlite.SQLiteOpenHelper;
36import android.database.sqlite.SQLiteQueryBuilder;
37import android.net.Uri;
38import android.os.Handler;
39import android.text.TextUtils;
40import android.util.Log;
41
42import java.io.File;
43import java.util.Collection;
44import java.util.HashMap;
45import java.util.Map;
46import 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 */
56public 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 clearHistory() {
231        runTransactionAsync(new SQLiteTransaction() {
232            @Override
233            public boolean performTransaction(SQLiteDatabase db) {
234                db.delete(ClickLog.TABLE_NAME, null, null);
235                db.delete(Shortcuts.TABLE_NAME, null, null);
236                db.delete(SourceStats.TABLE_NAME, null, null);
237                return true;
238            }
239        });
240    }
241
242    @VisibleForTesting
243    public void deleteRepository() {
244        getOpenHelper().deleteDatabase();
245    }
246
247    public void close() {
248        getOpenHelper().close();
249    }
250
251    public void reportClick(final SuggestionCursor suggestions, final int position) {
252        final long now = System.currentTimeMillis();
253        reportClickAtTime(suggestions, position, now);
254    }
255
256    public void getShortcutsForQuery(final String query, final Collection<Corpus> allowedCorpora,
257            final Consumer<ShortcutCursor> consumer) {
258        final long now = System.currentTimeMillis();
259        mLogExecutor.execute(new Runnable() {
260            public void run() {
261                ShortcutCursor shortcuts = getShortcutsForQuery(query, allowedCorpora, now);
262                Consumers.consumeCloseable(consumer, shortcuts);
263            }
264        });
265    }
266
267    public void updateShortcut(Source source, String shortcutId, SuggestionCursor refreshed) {
268        refreshShortcut(source, shortcutId, refreshed);
269    }
270
271    public void getCorpusScores(final Consumer<Map<String, Integer>> consumer) {
272        runQueryAsync(new SQLiteAsyncQuery<Map<String, Integer>>() {
273            @Override
274            protected Map<String, Integer> performQuery(SQLiteDatabase db) {
275                return getCorpusScores();
276            }
277        }, consumer);
278    }
279
280// -------------------------- end ShortcutRepository --------------------------
281
282    private boolean hasHistory(SQLiteDatabase db) {
283        Cursor cursor = db.rawQuery(HAS_HISTORY_QUERY, null);
284        try {
285            if (DBG) Log.d(TAG, "hasHistory(): cursor=" + cursor);
286            return cursor != null && cursor.getCount() > 0;
287        } finally {
288            if (cursor != null) cursor.close();
289        }
290    }
291
292    private Map<String,Integer> getCorpusScores() {
293        return getCorpusScores(mConfig.getMinClicksForSourceRanking());
294    }
295
296    private boolean shouldRefresh(Suggestion suggestion) {
297        return mRefresher.shouldRefresh(suggestion.getSuggestionSource(),
298                suggestion.getShortcutId());
299    }
300
301    @VisibleForTesting
302    ShortcutCursor getShortcutsForQuery(String query, Collection<Corpus> allowedCorpora, long now) {
303        if (DBG) Log.d(TAG, "getShortcutsForQuery(" + query + "," + allowedCorpora + ")");
304        String sql = query.length() == 0 ? mEmptyQueryShortcutQuery : mShortcutQuery;
305        String[] params = buildShortcutQueryParams(query, now);
306
307        SQLiteDatabase db = mOpenHelper.getReadableDatabase();
308        Cursor cursor = db.rawQuery(sql, params);
309        if (cursor.getCount() == 0) {
310            cursor.close();
311            return null;
312        }
313
314        if (DBG) Log.d(TAG, "Allowed sources: ");
315        HashMap<String,Source> allowedSources = new HashMap<String,Source>();
316        for (Corpus corpus : allowedCorpora) {
317            for (Source source : corpus.getSources()) {
318                if (DBG) Log.d(TAG, "\t" + source.getName());
319                allowedSources.put(source.getName(), source);
320            }
321        }
322
323        return new ShortcutCursor(new SuggestionCursorImpl(allowedSources, query, cursor),
324                mUiThread, mRefresher, this);
325    }
326
327    @VisibleForTesting
328    void refreshShortcut(Source source, final String shortcutId,
329            SuggestionCursor refreshed) {
330        if (source == null) throw new NullPointerException("source");
331        if (shortcutId == null) throw new NullPointerException("shortcutId");
332
333        final String[] whereArgs = { shortcutId, source.getName() };
334        final ContentValues shortcut;
335        if (refreshed == null || refreshed.getCount() == 0) {
336            shortcut = null;
337        } else {
338            refreshed.moveTo(0);
339            shortcut = makeShortcutRow(refreshed);
340        }
341
342        runTransactionAsync(new SQLiteTransaction() {
343            @Override
344            protected boolean performTransaction(SQLiteDatabase db) {
345                if (shortcut == null) {
346                    if (DBG) Log.d(TAG, "Deleting shortcut: " + shortcutId);
347                    db.delete(Shortcuts.TABLE_NAME, SHORTCUT_BY_ID_WHERE, whereArgs);
348                } else {
349                    if (DBG) Log.d(TAG, "Updating shortcut: " + shortcut);
350                    db.updateWithOnConflict(Shortcuts.TABLE_NAME, shortcut,
351                            SHORTCUT_BY_ID_WHERE, whereArgs, SQLiteDatabase.CONFLICT_REPLACE);
352                }
353                return true;
354            }
355        });
356    }
357
358    private class SuggestionCursorImpl extends CursorBackedSuggestionCursor {
359
360        private final HashMap<String, Source> mAllowedSources;
361        private final int mExtrasColumn;
362
363        public SuggestionCursorImpl(HashMap<String,Source> allowedSources,
364                String userQuery, Cursor cursor) {
365            super(userQuery, cursor);
366            mAllowedSources = allowedSources;
367            mExtrasColumn = cursor.getColumnIndex(Shortcuts.custom_columns.name());
368        }
369
370        @Override
371        public Source getSuggestionSource() {
372            int srcCol = mCursor.getColumnIndex(Shortcuts.source.name());
373            String srcStr = mCursor.getString(srcCol);
374            if (srcStr == null) {
375                throw new NullPointerException("Missing source for shortcut.");
376            }
377            Source source = mAllowedSources.get(srcStr);
378            if (source == null) {
379                if (DBG) {
380                    Log.d(TAG, "Source " + srcStr + " (position " + mCursor.getPosition() +
381                            ") not allowed");
382                }
383                return null;
384            }
385            int versionCode = mCursor.getInt(Shortcuts.source_version_code.ordinal());
386            if (!source.isVersionCodeCompatible(versionCode)) {
387                if (DBG) {
388                    Log.d(TAG, "Version " + versionCode + " not compatible with " +
389                            source.getVersionCode() + " for source " + srcStr);
390                }
391                return null;
392            }
393            return source;
394        }
395
396        @Override
397        public ComponentName getSuggestionIntentComponent() {
398            int componentCol = mCursor.getColumnIndex(Shortcuts.intent_component.name());
399            // We don't fall back to getSuggestionSource().getIntentComponent() because
400            // we want to return the same value that getSuggestionIntentComponent() did for the
401            // original suggestion.
402            return stringToComponentName(mCursor.getString(componentCol));
403        }
404
405        @Override
406        public String getSuggestionIcon2() {
407            if (isSpinnerWhileRefreshing() && shouldRefresh(this)) {
408                if (DBG) Log.d(TAG, "shortcut " + getShortcutId() + " refreshing");
409                return mSearchSpinner;
410            }
411            if (DBG) Log.d(TAG, "shortcut " + getShortcutId() + " NOT refreshing");
412            return super.getSuggestionIcon2();
413        }
414
415        public boolean isSuggestionShortcut() {
416            return true;
417        }
418
419        @Override
420        public SuggestionExtras getExtras() {
421            String json = mCursor.getString(mExtrasColumn);
422            if (!TextUtils.isEmpty(json)) {
423                try {
424                    return new JsonBackedSuggestionExtras(json);
425                } catch (JSONException e) {
426                    Log.e(TAG, "Could not parse JSON extras from DB: " + json);
427                }
428            }
429            return null;
430        }
431
432        public Collection<String> getExtraColumns() {
433            /*
434             * We always return null here because:
435             * - to return an accurate value, we'd have to aggregate all the extra columns in all
436             *   shortcuts in the shortcuts table, which would mean parsing ALL the JSON contained
437             *   therein
438             * - ListSuggestionCursor does this aggregation, and does it lazily
439             * - All shortcuts are put into a ListSuggestionCursor during the promotion process, so
440             *   relying on ListSuggestionCursor to do the aggregation means that we only parse the
441             *   JSON for shortcuts that are actually displayed.
442             */
443            return null;
444        }
445    }
446
447    /**
448     * Builds a parameter list for the queries built by {@link #buildShortcutQueries}.
449     */
450    private static String[] buildShortcutQueryParams(String query, long now) {
451        return new String[]{ query, nextString(query), String.valueOf(now) };
452    }
453
454    /**
455     * Given a string x, this method returns the least string y such that x is not a prefix of y.
456     * This is useful to implement prefix filtering by comparison, since the only strings z that
457     * have x as a prefix are such that z is greater than or equal to x and z is less than y.
458     *
459     * @param str A non-empty string. The contract above is not honored for an empty input string,
460     *        since all strings have the empty string as a prefix.
461     */
462    private static String nextString(String str) {
463        int len = str.length();
464        if (len == 0) {
465            return str;
466        }
467        // The last code point in the string. Within the Basic Multilingual Plane,
468        // this is the same as str.charAt(len-1)
469        int codePoint = str.codePointBefore(len);
470        // This should be safe from overflow, since the largest code point
471        // representable in UTF-16 is U+10FFFF.
472        int nextCodePoint = codePoint + 1;
473        // The index of the start of the last code point.
474        // Character.charCount(codePoint) is always 1 (in the BMP) or 2
475        int lastIndex = len - Character.charCount(codePoint);
476        return new StringBuilder(len)
477                .append(str, 0, lastIndex)  // append everything but the last code point
478                .appendCodePoint(nextCodePoint)  // instead of the last code point, use successor
479                .toString();
480    }
481
482    /**
483     * Returns the source ranking for sources with a minimum number of clicks.
484     *
485     * @param minClicks The minimum number of clicks a source must have.
486     * @return The list of sources, ranked by total clicks.
487     */
488    Map<String,Integer> getCorpusScores(int minClicks) {
489        SQLiteDatabase db = mOpenHelper.getReadableDatabase();
490        final Cursor cursor = db.rawQuery(
491                SOURCE_RANKING_SQL, new String[] { String.valueOf(minClicks) });
492        try {
493            Map<String,Integer> corpora = new HashMap<String,Integer>(cursor.getCount());
494            while (cursor.moveToNext()) {
495                String name = cursor.getString(SourceStats.corpus.ordinal());
496                int clicks = cursor.getInt(SourceStats.total_clicks.ordinal());
497                corpora.put(name, clicks);
498            }
499            return corpora;
500        } finally {
501            cursor.close();
502        }
503    }
504
505    private ContentValues makeShortcutRow(Suggestion suggestion) {
506        String intentAction = suggestion.getSuggestionIntentAction();
507        String intentComponent = componentNameToString(suggestion.getSuggestionIntentComponent());
508        String intentData = suggestion.getSuggestionIntentDataString();
509        String intentQuery = suggestion.getSuggestionQuery();
510        String intentExtraData = suggestion.getSuggestionIntentExtraData();
511
512        Source source = suggestion.getSuggestionSource();
513        String sourceName = source.getName();
514        StringBuilder key = new StringBuilder(sourceName);
515        key.append("#");
516        if (intentData != null) {
517            key.append(intentData);
518        }
519        key.append("#");
520        if (intentAction != null) {
521            key.append(intentAction);
522        }
523        key.append("#");
524        if (intentComponent != null) {
525            key.append(intentComponent);
526        }
527        key.append("#");
528        if (intentQuery != null) {
529            key.append(intentQuery);
530        }
531        // A string of the form source#intentData#intentAction#intentQuery
532        // for use as a unique identifier of a suggestion.
533        String intentKey = key.toString();
534
535        // Get URIs for all icons, to make sure that they are stable
536        String icon1Uri = getIconUriString(source, suggestion.getSuggestionIcon1());
537        String icon2Uri = getIconUriString(source, suggestion.getSuggestionIcon2());
538
539        String extrasJson = null;
540        SuggestionExtras extras = suggestion.getExtras();
541        if (extras != null) {
542            // flatten any custom columns to JSON. We need to keep any custom columns so that
543            // shortcuts for custom suggestion views work properly.
544            try {
545                extrasJson = extras.toJsonString();
546            } catch (JSONException e) {
547                Log.e(TAG, "Could not flatten extras to JSON from " + suggestion, e);
548            }
549        }
550
551        ContentValues cv = new ContentValues();
552        cv.put(Shortcuts.intent_key.name(), intentKey);
553        cv.put(Shortcuts.source.name(), sourceName);
554        cv.put(Shortcuts.source_version_code.name(), source.getVersionCode());
555        cv.put(Shortcuts.format.name(), suggestion.getSuggestionFormat());
556        cv.put(Shortcuts.title.name(), suggestion.getSuggestionText1());
557        cv.put(Shortcuts.description.name(), suggestion.getSuggestionText2());
558        cv.put(Shortcuts.description_url.name(), suggestion.getSuggestionText2Url());
559        cv.put(Shortcuts.icon1.name(), icon1Uri);
560        cv.put(Shortcuts.icon2.name(), icon2Uri);
561        cv.put(Shortcuts.intent_action.name(), intentAction);
562        cv.put(Shortcuts.intent_component.name(), intentComponent);
563        cv.put(Shortcuts.intent_data.name(), intentData);
564        cv.put(Shortcuts.intent_query.name(), intentQuery);
565        cv.put(Shortcuts.intent_extradata.name(), intentExtraData);
566        cv.put(Shortcuts.shortcut_id.name(), suggestion.getShortcutId());
567        if (suggestion.isSpinnerWhileRefreshing()) {
568            cv.put(Shortcuts.spinner_while_refreshing.name(), "true");
569        }
570        cv.put(Shortcuts.log_type.name(), suggestion.getSuggestionLogType());
571        cv.put(Shortcuts.custom_columns.name(), extrasJson);
572
573        return cv;
574    }
575
576    private String componentNameToString(ComponentName component) {
577        return component == null ? null : component.flattenToShortString();
578    }
579
580    private ComponentName stringToComponentName(String str) {
581        return str == null ? null : ComponentName.unflattenFromString(str);
582    }
583
584    private String getIconUriString(Source source, String drawableId) {
585        // Fast path for empty icons
586        if (TextUtils.isEmpty(drawableId) || "0".equals(drawableId)) {
587            return null;
588        }
589        // Fast path for icon URIs
590        if (drawableId.startsWith(ContentResolver.SCHEME_ANDROID_RESOURCE)
591                || drawableId.startsWith(ContentResolver.SCHEME_CONTENT)
592                || drawableId.startsWith(ContentResolver.SCHEME_FILE)) {
593            return drawableId;
594        }
595        Uri uri = source.getIconUri(drawableId);
596        return uri == null ? null : uri.toString();
597    }
598
599    @VisibleForTesting
600    void reportClickAtTime(SuggestionCursor suggestion,
601            int position, long now) {
602        suggestion.moveTo(position);
603        if (DBG) {
604            Log.d(TAG, "logClicked(" + suggestion + ")");
605        }
606
607        if (SearchManager.SUGGEST_NEVER_MAKE_SHORTCUT.equals(suggestion.getShortcutId())) {
608            if (DBG) Log.d(TAG, "clicked suggestion requested not to be shortcuted");
609            return;
610        }
611
612        Corpus corpus = mCorpora.getCorpusForSource(suggestion.getSuggestionSource());
613        if (corpus == null) {
614            Log.w(TAG, "no corpus for clicked suggestion");
615            return;
616        }
617
618        // Once the user has clicked on a shortcut, don't bother refreshing
619        // (especially if this is a new shortcut)
620        mRefresher.markShortcutRefreshed(suggestion.getSuggestionSource(),
621                suggestion.getShortcutId());
622
623        // Add or update suggestion info
624        // Since intent_key is the primary key, any existing
625        // suggestion with the same source+data+action will be replaced
626        final ContentValues shortcut = makeShortcutRow(suggestion);
627        String intentKey = shortcut.getAsString(Shortcuts.intent_key.name());
628
629        // Log click for shortcut
630        final ContentValues click = new ContentValues();
631        click.put(ClickLog.intent_key.name(), intentKey);
632        click.put(ClickLog.query.name(), suggestion.getUserQuery());
633        click.put(ClickLog.hit_time.name(), now);
634        click.put(ClickLog.corpus.name(), corpus.getName());
635
636        runTransactionAsync(new SQLiteTransaction() {
637            @Override
638            protected boolean performTransaction(SQLiteDatabase db) {
639                if (DBG) Log.d(TAG, "Adding shortcut: " + shortcut);
640                db.replaceOrThrow(Shortcuts.TABLE_NAME, null, shortcut);
641                db.insertOrThrow(ClickLog.TABLE_NAME, null, click);
642                return true;
643            }
644        });
645    }
646
647// -------------------------- TABLES --------------------------
648
649    /**
650     * shortcuts table
651     */
652    enum Shortcuts {
653        intent_key,
654        source,
655        source_version_code,
656        format,
657        title,
658        description,
659        description_url,
660        icon1,
661        icon2,
662        intent_action,
663        intent_component,
664        intent_data,
665        intent_query,
666        intent_extradata,
667        shortcut_id,
668        spinner_while_refreshing,
669        log_type,
670        custom_columns;
671
672        static final String TABLE_NAME = "shortcuts";
673
674        public final String fullName;
675
676        Shortcuts() {
677            fullName = TABLE_NAME + "." + name();
678        }
679    }
680
681    /**
682     * clicklog table. Has one record for each click.
683     */
684    enum ClickLog {
685        _id,
686        intent_key,
687        query,
688        hit_time,
689        corpus;
690
691        static final String[] COLUMNS = initColumns();
692
693        static final String TABLE_NAME = "clicklog";
694
695        private static String[] initColumns() {
696            ClickLog[] vals = ClickLog.values();
697            String[] columns = new String[vals.length];
698            for (int i = 0; i < vals.length; i++) {
699                columns[i] = vals[i].fullName;
700            }
701            return columns;
702        }
703
704        public final String fullName;
705
706        ClickLog() {
707            fullName = TABLE_NAME + "." + name();
708        }
709    }
710
711    /**
712     * This is an aggregate table of {@link ClickLog} that stays up to date with the total
713     * clicks for each corpus. This makes computing the corpus ranking more
714     * more efficient, at the expense of some extra work when the clicks are reported.
715     */
716    enum SourceStats {
717        corpus,
718        total_clicks;
719
720        static final String TABLE_NAME = "sourcetotals";
721
722        static final String[] COLUMNS = initColumns();
723
724        private static String[] initColumns() {
725            SourceStats[] vals = SourceStats.values();
726            String[] columns = new String[vals.length];
727            for (int i = 0; i < vals.length; i++) {
728                columns[i] = vals[i].fullName;
729            }
730            return columns;
731        }
732
733        public final String fullName;
734
735        SourceStats() {
736            fullName = TABLE_NAME + "." + name();
737        }
738    }
739
740// -------------------------- END TABLES --------------------------
741
742    // contains creation and update logic
743    private static class DbOpenHelper extends SQLiteOpenHelper {
744        private final Config mConfig;
745        private String mPath;
746        private static final String SHORTCUT_ID_INDEX
747                = Shortcuts.TABLE_NAME + "_" + Shortcuts.shortcut_id.name();
748        private static final String CLICKLOG_QUERY_INDEX
749                = ClickLog.TABLE_NAME + "_" + ClickLog.query.name();
750        private static final String CLICKLOG_HIT_TIME_INDEX
751                = ClickLog.TABLE_NAME + "_" + ClickLog.hit_time.name();
752        private static final String CLICKLOG_INSERT_TRIGGER
753                = ClickLog.TABLE_NAME + "_insert";
754        private static final String SHORTCUTS_DELETE_TRIGGER
755                = Shortcuts.TABLE_NAME + "_delete";
756        private static final String SHORTCUTS_UPDATE_INTENT_KEY_TRIGGER
757                = Shortcuts.TABLE_NAME + "_update_intent_key";
758
759        public DbOpenHelper(Context context, String name, int version, Config config) {
760            super(context, name, null, version);
761            mConfig = config;
762        }
763
764        @Override
765        public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
766            // The shortcuts info is not all that important, so we just drop the tables
767            // and re-create empty ones.
768            Log.i(TAG, "Upgrading shortcuts DB from version " +
769                    + oldVersion + " to " + newVersion + ". This deletes all shortcuts.");
770            dropTables(db);
771            onCreate(db);
772        }
773
774        private void dropTables(SQLiteDatabase db) {
775            db.execSQL("DROP TRIGGER IF EXISTS " + CLICKLOG_INSERT_TRIGGER);
776            db.execSQL("DROP TRIGGER IF EXISTS " + SHORTCUTS_DELETE_TRIGGER);
777            db.execSQL("DROP TRIGGER IF EXISTS " + SHORTCUTS_UPDATE_INTENT_KEY_TRIGGER);
778            db.execSQL("DROP INDEX IF EXISTS " + CLICKLOG_HIT_TIME_INDEX);
779            db.execSQL("DROP INDEX IF EXISTS " + CLICKLOG_QUERY_INDEX);
780            db.execSQL("DROP INDEX IF EXISTS " + SHORTCUT_ID_INDEX);
781            db.execSQL("DROP TABLE IF EXISTS " + ClickLog.TABLE_NAME);
782            db.execSQL("DROP TABLE IF EXISTS " + Shortcuts.TABLE_NAME);
783            db.execSQL("DROP TABLE IF EXISTS " + SourceStats.TABLE_NAME);
784        }
785
786        /**
787         * Deletes the database file.
788         */
789        public void deleteDatabase() {
790            close();
791            if (mPath == null) return;
792            try {
793                new File(mPath).delete();
794                if (DBG) Log.d(TAG, "deleted " + mPath);
795            } catch (Exception e) {
796                Log.w(TAG, "couldn't delete " + mPath, e);
797            }
798        }
799
800        @Override
801        public void onOpen(SQLiteDatabase db) {
802            super.onOpen(db);
803            mPath = db.getPath();
804        }
805
806        @Override
807        public void onCreate(SQLiteDatabase db) {
808            db.execSQL("CREATE TABLE " + Shortcuts.TABLE_NAME + " (" +
809                    // COLLATE UNICODE is needed to make it possible to use nextString()
810                    // to implement fast prefix filtering.
811                    Shortcuts.intent_key.name() + " TEXT NOT NULL COLLATE UNICODE PRIMARY KEY, " +
812                    Shortcuts.source.name() + " TEXT NOT NULL, " +
813                    Shortcuts.source_version_code.name() + " INTEGER NOT NULL, " +
814                    Shortcuts.format.name() + " TEXT, " +
815                    Shortcuts.title.name() + " TEXT, " +
816                    Shortcuts.description.name() + " TEXT, " +
817                    Shortcuts.description_url.name() + " TEXT, " +
818                    Shortcuts.icon1.name() + " TEXT, " +
819                    Shortcuts.icon2.name() + " TEXT, " +
820                    Shortcuts.intent_action.name() + " TEXT, " +
821                    Shortcuts.intent_component.name() + " TEXT, " +
822                    Shortcuts.intent_data.name() + " TEXT, " +
823                    Shortcuts.intent_query.name() + " TEXT, " +
824                    Shortcuts.intent_extradata.name() + " TEXT, " +
825                    Shortcuts.shortcut_id.name() + " TEXT, " +
826                    Shortcuts.spinner_while_refreshing.name() + " TEXT, " +
827                    Shortcuts.log_type.name() + " TEXT, " +
828                    Shortcuts.custom_columns.name() + " TEXT" +
829                    ");");
830
831            // index for fast lookup of shortcuts by shortcut_id
832            db.execSQL("CREATE INDEX " + SHORTCUT_ID_INDEX
833                    + " ON " + Shortcuts.TABLE_NAME
834                    + "(" + Shortcuts.shortcut_id.name() + ", " + Shortcuts.source.name() + ")");
835
836            db.execSQL("CREATE TABLE " + ClickLog.TABLE_NAME + " ( " +
837                    ClickLog._id.name() + " INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, " +
838                    // type must match Shortcuts.intent_key
839                    ClickLog.intent_key.name() + " TEXT NOT NULL COLLATE UNICODE REFERENCES "
840                        + Shortcuts.TABLE_NAME + "(" + Shortcuts.intent_key + "), " +
841                    ClickLog.query.name() + " TEXT, " +
842                    ClickLog.hit_time.name() + " INTEGER," +
843                    ClickLog.corpus.name() + " TEXT" +
844                    ");");
845
846            // index for fast lookup of clicks by query
847            db.execSQL("CREATE INDEX " + CLICKLOG_QUERY_INDEX
848                    + " ON " + ClickLog.TABLE_NAME + "(" + ClickLog.query.name() + ")");
849
850            // index for finding old clicks quickly
851            db.execSQL("CREATE INDEX " + CLICKLOG_HIT_TIME_INDEX
852                    + " ON " + ClickLog.TABLE_NAME + "(" + ClickLog.hit_time.name() + ")");
853
854            // trigger for purging old clicks, i.e. those such that
855            // hit_time < now - MAX_MAX_STAT_AGE_MILLIS, where now is the
856            // hit_time of the inserted record, and for updating the SourceStats table
857            db.execSQL("CREATE TRIGGER " + CLICKLOG_INSERT_TRIGGER + " AFTER INSERT ON "
858                    + ClickLog.TABLE_NAME
859                    + " BEGIN"
860                    + " DELETE FROM " + ClickLog.TABLE_NAME + " WHERE "
861                            + ClickLog.hit_time.name() + " <"
862                            + " NEW." + ClickLog.hit_time.name()
863                                    + " - " + mConfig.getMaxStatAgeMillis() + ";"
864                    + " DELETE FROM " + SourceStats.TABLE_NAME + ";"
865                    + " INSERT INTO " + SourceStats.TABLE_NAME  + " "
866                            + "SELECT " + ClickLog.corpus + "," + "COUNT(*) FROM "
867                            + ClickLog.TABLE_NAME + " GROUP BY " + ClickLog.corpus.name() + ";"
868                    + " END");
869
870            // trigger for deleting clicks about a shortcut once that shortcut has been
871            // deleted
872            db.execSQL("CREATE TRIGGER " + SHORTCUTS_DELETE_TRIGGER + " AFTER DELETE ON "
873                    + Shortcuts.TABLE_NAME
874                    + " BEGIN"
875                    + " DELETE FROM " + ClickLog.TABLE_NAME + " WHERE "
876                            + ClickLog.intent_key.name()
877                            + " = OLD." + Shortcuts.intent_key.name() + ";"
878                    + " END");
879
880            // trigger for updating click log entries when a shortcut changes its intent_key
881            db.execSQL("CREATE TRIGGER " + SHORTCUTS_UPDATE_INTENT_KEY_TRIGGER
882                    + " AFTER UPDATE ON " + Shortcuts.TABLE_NAME
883                    + " WHEN NEW." + Shortcuts.intent_key.name()
884                            + " != OLD." + Shortcuts.intent_key.name()
885                    + " BEGIN"
886                    + " UPDATE " + ClickLog.TABLE_NAME + " SET "
887                            + ClickLog.intent_key.name() + " = NEW." + Shortcuts.intent_key.name()
888                            + " WHERE "
889                            + ClickLog.intent_key.name() + " = OLD." + Shortcuts.intent_key.name()
890                            + ";"
891                    + " END");
892
893            db.execSQL("CREATE TABLE " + SourceStats.TABLE_NAME + " ( " +
894                    SourceStats.corpus.name() + " TEXT NOT NULL COLLATE UNICODE PRIMARY KEY, " +
895                    SourceStats.total_clicks + " INTEGER);"
896                    );
897        }
898    }
899}
900