• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
2  * Copyright (C) 2009 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.android.quicksearchbox;
18 
19 import com.android.quicksearchbox.util.Consumer;
20 import com.android.quicksearchbox.util.Consumers;
21 import com.android.quicksearchbox.util.SQLiteAsyncQuery;
22 import com.android.quicksearchbox.util.SQLiteTransaction;
23 import com.android.quicksearchbox.util.Util;
24 import com.google.common.annotations.VisibleForTesting;
25 
26 import org.json.JSONException;
27 
28 import android.app.SearchManager;
29 import android.content.ComponentName;
30 import android.content.ContentResolver;
31 import android.content.ContentValues;
32 import android.content.Context;
33 import android.database.Cursor;
34 import android.database.sqlite.SQLiteDatabase;
35 import android.database.sqlite.SQLiteOpenHelper;
36 import android.database.sqlite.SQLiteQueryBuilder;
37 import android.net.Uri;
38 import android.os.Handler;
39 import android.text.TextUtils;
40 import android.util.Log;
41 
42 import java.io.File;
43 import java.util.Collection;
44 import java.util.HashMap;
45 import java.util.Map;
46 import java.util.concurrent.Executor;
47 
48 /**
49  * A shortcut repository implementation that uses a log of every click.
50  *
51  * To inspect DB:
52  * # sqlite3 /data/data/com.android.quicksearchbox/databases/qsb-log.db
53  *
54  * TODO: Refactor this class.
55  */
56 public class ShortcutRepositoryImplLog implements ShortcutRepository {
57 
58     private static final boolean DBG = false;
59     private static final String TAG = "QSB.ShortcutRepositoryImplLog";
60 
61     private static final String DB_NAME = "qsb-log.db";
62     private static final int DB_VERSION = 32;
63 
64     private static final String HAS_HISTORY_QUERY =
65         "SELECT " + Shortcuts.intent_key.fullName + " FROM " + Shortcuts.TABLE_NAME;
66     private String mEmptyQueryShortcutQuery ;
67     private String mShortcutQuery;
68 
69     private static final String SHORTCUT_BY_ID_WHERE =
70             Shortcuts.shortcut_id.name() + "=? AND " + Shortcuts.source.name() + "=?";
71 
72     private static final String SOURCE_RANKING_SQL = buildSourceRankingSql();
73 
74     private final Context mContext;
75     private final Config mConfig;
76     private final Corpora mCorpora;
77     private final ShortcutRefresher mRefresher;
78     private final Handler mUiThread;
79     // Used to perform log write operations asynchronously
80     private final Executor mLogExecutor;
81     private final DbOpenHelper mOpenHelper;
82     private final String mSearchSpinner;
83 
84     /**
85      * Create an instance to the repo.
86      */
create(Context context, Config config, Corpora sources, ShortcutRefresher refresher, Handler uiThread, Executor logExecutor)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
ShortcutRepositoryImplLog(Context context, Config config, Corpora corpora, ShortcutRefresher refresher, Handler uiThread, Executor logExecutor, String name)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 
buildShortcutQueries()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      */
buildSourceRankingSql()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 
getOpenHelper()199     protected DbOpenHelper getOpenHelper() {
200         return mOpenHelper;
201     }
202 
runTransactionAsync(final SQLiteTransaction transaction)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 
runQueryAsync(final SQLiteAsyncQuery<A> query, final Consumer<A> consumer)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 
hasHistory(Consumer<Boolean> consumer)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 
removeFromHistory(SuggestionCursor suggestions, int position)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 
clearHistory()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
deleteRepository()256     public void deleteRepository() {
257         getOpenHelper().deleteDatabase();
258     }
259 
close()260     public void close() {
261         getOpenHelper().close();
262     }
263 
reportClick(final SuggestionCursor suggestions, final int position)264     public void reportClick(final SuggestionCursor suggestions, final int position) {
265         final long now = System.currentTimeMillis();
266         reportClickAtTime(suggestions, position, now);
267     }
268 
getShortcutsForQuery(final String query, final Collection<Corpus> allowedCorpora, final boolean allowWebSearchShortcuts, final Consumer<ShortcutCursor> consumer)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 
updateShortcut(Source source, String shortcutId, SuggestionCursor refreshed)281     public void updateShortcut(Source source, String shortcutId, SuggestionCursor refreshed) {
282         refreshShortcut(source, shortcutId, refreshed);
283     }
284 
getCorpusScores(final Consumer<Map<String, Integer>> consumer)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 
hasHistory(SQLiteDatabase db)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 
getCorpusScores()306     private Map<String,Integer> getCorpusScores() {
307         return getCorpusScores(mConfig.getMinClicksForSourceRanking());
308     }
309 
shouldRefresh(Suggestion suggestion)310     private boolean shouldRefresh(Suggestion suggestion) {
311         return mRefresher.shouldRefresh(suggestion.getSuggestionSource(),
312                 suggestion.getShortcutId());
313     }
314 
315     @VisibleForTesting
getShortcutsForQuery(String query, Collection<Corpus> allowedCorpora, boolean allowWebSearchShortcuts, long now)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
refreshShortcut(Source source, final String shortcutId, SuggestionCursor refreshed)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 
SuggestionCursorImpl(HashMap<String,Source> allowedSources, String userQuery, Cursor cursor)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
getSuggestionSource()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
getSuggestionIntentComponent()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
getSuggestionIcon2()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 
isSuggestionShortcut()430         public boolean isSuggestionShortcut() {
431             return true;
432         }
433 
isHistorySuggestion()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
getExtras()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 
getExtraColumns()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      */
buildShortcutQueryParams(String query, long now)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      */
nextString(String str)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      */
getCorpusScores(int minClicks)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 
makeShortcutRow(Suggestion suggestion)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      * */
makeIntentKey(Suggestion suggestion)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 
componentNameToString(ComponentName component)614     private String componentNameToString(ComponentName component) {
615         return component == null ? null : component.flattenToShortString();
616     }
617 
stringToComponentName(String str)618     private ComponentName stringToComponentName(String str) {
619         return str == null ? null : ComponentName.unflattenFromString(str);
620     }
621 
getIconUriString(Source source, String drawableId)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
reportClickAtTime(SuggestionCursor suggestion, int position, long now)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 
Shortcuts()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 
initColumns()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 
ClickLog()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 
initColumns()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 
SourceStats()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 
DbOpenHelper(Context context, String name, int version, Config config)797         public DbOpenHelper(Context context, String name, int version, Config config) {
798             super(context, name, null, version);
799             mConfig = config;
800         }
801 
802         @Override
onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion)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 
dropTables(SQLiteDatabase db)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          */
deleteDatabase()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
onOpen(SQLiteDatabase db)839         public void onOpen(SQLiteDatabase db) {
840             super.onOpen(db);
841             mPath = db.getPath();
842         }
843 
844         @Override
onCreate(SQLiteDatabase db)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