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