| /* |
| * Copyright (C) 2009 The Android Open Source Project |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| |
| package com.android.quicksearchbox; |
| |
| import com.android.quicksearchbox.util.Consumer; |
| import com.android.quicksearchbox.util.Consumers; |
| import com.android.quicksearchbox.util.SQLiteAsyncQuery; |
| import com.android.quicksearchbox.util.SQLiteTransaction; |
| import com.android.quicksearchbox.util.Util; |
| import com.google.common.annotations.VisibleForTesting; |
| |
| import org.json.JSONException; |
| |
| import android.app.SearchManager; |
| import android.content.ComponentName; |
| import android.content.ContentResolver; |
| import android.content.ContentValues; |
| import android.content.Context; |
| import android.database.Cursor; |
| import android.database.sqlite.SQLiteDatabase; |
| import android.database.sqlite.SQLiteOpenHelper; |
| import android.database.sqlite.SQLiteQueryBuilder; |
| import android.net.Uri; |
| import android.os.Handler; |
| import android.text.TextUtils; |
| import android.util.Log; |
| |
| import java.io.File; |
| import java.util.Collection; |
| import java.util.HashMap; |
| import java.util.Map; |
| import java.util.concurrent.Executor; |
| |
| /** |
| * A shortcut repository implementation that uses a log of every click. |
| * |
| * To inspect DB: |
| * # sqlite3 /data/data/com.android.quicksearchbox/databases/qsb-log.db |
| * |
| * TODO: Refactor this class. |
| */ |
| public class ShortcutRepositoryImplLog implements ShortcutRepository { |
| |
| private static final boolean DBG = false; |
| private static final String TAG = "QSB.ShortcutRepositoryImplLog"; |
| |
| private static final String DB_NAME = "qsb-log.db"; |
| private static final int DB_VERSION = 32; |
| |
| private static final String HAS_HISTORY_QUERY = |
| "SELECT " + Shortcuts.intent_key.fullName + " FROM " + Shortcuts.TABLE_NAME; |
| private String mEmptyQueryShortcutQuery ; |
| private String mShortcutQuery; |
| |
| private static final String SHORTCUT_BY_ID_WHERE = |
| Shortcuts.shortcut_id.name() + "=? AND " + Shortcuts.source.name() + "=?"; |
| |
| private static final String SOURCE_RANKING_SQL = buildSourceRankingSql(); |
| |
| private final Context mContext; |
| private final Config mConfig; |
| private final Corpora mCorpora; |
| private final ShortcutRefresher mRefresher; |
| private final Handler mUiThread; |
| // Used to perform log write operations asynchronously |
| private final Executor mLogExecutor; |
| private final DbOpenHelper mOpenHelper; |
| private final String mSearchSpinner; |
| |
| /** |
| * Create an instance to the repo. |
| */ |
| public static ShortcutRepository create(Context context, Config config, |
| Corpora sources, ShortcutRefresher refresher, Handler uiThread, |
| Executor logExecutor) { |
| return new ShortcutRepositoryImplLog(context, config, sources, refresher, |
| uiThread, logExecutor, DB_NAME); |
| } |
| |
| /** |
| * @param context Used to create / open db |
| * @param name The name of the database to create. |
| */ |
| @VisibleForTesting |
| ShortcutRepositoryImplLog(Context context, Config config, Corpora corpora, |
| ShortcutRefresher refresher, Handler uiThread, Executor logExecutor, String name) { |
| mContext = context; |
| mConfig = config; |
| mCorpora = corpora; |
| mRefresher = refresher; |
| mUiThread = uiThread; |
| mLogExecutor = logExecutor; |
| mOpenHelper = new DbOpenHelper(context, name, DB_VERSION, config); |
| buildShortcutQueries(); |
| |
| mSearchSpinner = Util.getResourceUri(mContext, R.drawable.search_spinner).toString(); |
| } |
| |
| // clicklog first, since that's where restrict the result set |
| private static final String TABLES = ClickLog.TABLE_NAME + " INNER JOIN " + |
| Shortcuts.TABLE_NAME + " ON " + ClickLog.intent_key.fullName + " = " + |
| Shortcuts.intent_key.fullName; |
| |
| private static final String AS = " AS "; |
| |
| private static final String[] SHORTCUT_QUERY_COLUMNS = { |
| Shortcuts.intent_key.fullName, |
| Shortcuts.source.fullName, |
| Shortcuts.source_version_code.fullName, |
| Shortcuts.format.fullName + AS + SearchManager.SUGGEST_COLUMN_FORMAT, |
| Shortcuts.title + AS + SearchManager.SUGGEST_COLUMN_TEXT_1, |
| Shortcuts.description + AS + SearchManager.SUGGEST_COLUMN_TEXT_2, |
| Shortcuts.description_url + AS + SearchManager.SUGGEST_COLUMN_TEXT_2_URL, |
| Shortcuts.icon1 + AS + SearchManager.SUGGEST_COLUMN_ICON_1, |
| Shortcuts.icon2 + AS + SearchManager.SUGGEST_COLUMN_ICON_2, |
| Shortcuts.intent_action + AS + SearchManager.SUGGEST_COLUMN_INTENT_ACTION, |
| Shortcuts.intent_component.fullName, |
| Shortcuts.intent_data + AS + SearchManager.SUGGEST_COLUMN_INTENT_DATA, |
| Shortcuts.intent_query + AS + SearchManager.SUGGEST_COLUMN_QUERY, |
| Shortcuts.intent_extradata + AS + SearchManager.SUGGEST_COLUMN_INTENT_EXTRA_DATA, |
| Shortcuts.shortcut_id + AS + SearchManager.SUGGEST_COLUMN_SHORTCUT_ID, |
| Shortcuts.spinner_while_refreshing + AS + |
| SearchManager.SUGGEST_COLUMN_SPINNER_WHILE_REFRESHING, |
| Shortcuts.log_type + AS + CursorBackedSuggestionCursor.SUGGEST_COLUMN_LOG_TYPE, |
| Shortcuts.custom_columns.fullName, |
| }; |
| |
| // Avoid GLOB by using >= AND <, with some manipulation (see nextString(String)). |
| // to figure out the upper bound (e.g. >= "abc" AND < "abd" |
| // This allows us to use parameter binding and still take advantage of the |
| // index on the query column. |
| private static final String PREFIX_RESTRICTION = |
| ClickLog.query.fullName + " >= ?1 AND " + ClickLog.query.fullName + " < ?2"; |
| |
| private static final String LAST_HIT_TIME_EXPR = "MAX(" + ClickLog.hit_time.fullName + ")"; |
| private static final String GROUP_BY = ClickLog.intent_key.fullName; |
| private static final String PREFER_LATEST_PREFIX = |
| "(" + LAST_HIT_TIME_EXPR + " = (SELECT " + LAST_HIT_TIME_EXPR + " FROM " + |
| ClickLog.TABLE_NAME + " WHERE "; |
| private static final String PREFER_LATEST_SUFFIX = "))"; |
| |
| private void buildShortcutQueries() { |
| // SQL expression for the time before which no clicks should be counted. |
| String cutOffTime_expr = "(?3 - " + mConfig.getMaxStatAgeMillis() + ")"; |
| // Filter out clicks that are too old |
| String ageRestriction = ClickLog.hit_time.fullName + " >= " + cutOffTime_expr; |
| String having = null; |
| // Order by sum of hit times (seconds since cutoff) for the clicks for each shortcut. |
| // This has the effect of multiplying the average hit time with the click count |
| String ordering_expr = |
| "SUM((" + ClickLog.hit_time.fullName + " - " + cutOffTime_expr + ") / 1000)"; |
| |
| String where = ageRestriction; |
| String preferLatest = PREFER_LATEST_PREFIX + where + PREFER_LATEST_SUFFIX; |
| String orderBy = preferLatest + " DESC, " + ordering_expr + " DESC"; |
| mEmptyQueryShortcutQuery = SQLiteQueryBuilder.buildQueryString( |
| false, TABLES, SHORTCUT_QUERY_COLUMNS, where, GROUP_BY, having, orderBy, null); |
| if (DBG) Log.d(TAG, "Empty shortcut query:\n" + mEmptyQueryShortcutQuery); |
| |
| where = PREFIX_RESTRICTION + " AND " + ageRestriction; |
| preferLatest = PREFER_LATEST_PREFIX + where + PREFER_LATEST_SUFFIX; |
| orderBy = preferLatest + " DESC, " + ordering_expr + " DESC"; |
| mShortcutQuery = SQLiteQueryBuilder.buildQueryString( |
| false, TABLES, SHORTCUT_QUERY_COLUMNS, where, GROUP_BY, having, orderBy, null); |
| if (DBG) Log.d(TAG, "Empty shortcut:\n" + mShortcutQuery); |
| } |
| |
| /** |
| * @return sql that ranks sources by total clicks, filtering out sources |
| * without enough clicks. |
| */ |
| private static String buildSourceRankingSql() { |
| final String orderingExpr = SourceStats.total_clicks.name(); |
| final String tables = SourceStats.TABLE_NAME; |
| final String[] columns = SourceStats.COLUMNS; |
| final String where = SourceStats.total_clicks + " >= $1"; |
| final String groupBy = null; |
| final String having = null; |
| final String orderBy = orderingExpr + " DESC"; |
| final String limit = null; |
| return SQLiteQueryBuilder.buildQueryString( |
| false, tables, columns, where, groupBy, having, orderBy, limit); |
| } |
| |
| protected DbOpenHelper getOpenHelper() { |
| return mOpenHelper; |
| } |
| |
| private void runTransactionAsync(final SQLiteTransaction transaction) { |
| mLogExecutor.execute(new Runnable() { |
| public void run() { |
| transaction.run(mOpenHelper.getWritableDatabase()); |
| } |
| }); |
| } |
| |
| private <A> void runQueryAsync(final SQLiteAsyncQuery<A> query, final Consumer<A> consumer) { |
| mLogExecutor.execute(new Runnable() { |
| public void run() { |
| query.run(mOpenHelper.getReadableDatabase(), consumer); |
| } |
| }); |
| } |
| |
| // --------------------- Interface ShortcutRepository --------------------- |
| |
| public void hasHistory(Consumer<Boolean> consumer) { |
| runQueryAsync(new SQLiteAsyncQuery<Boolean>() { |
| @Override |
| protected Boolean performQuery(SQLiteDatabase db) { |
| return hasHistory(db); |
| } |
| }, consumer); |
| } |
| |
| public void removeFromHistory(SuggestionCursor suggestions, int position) { |
| suggestions.moveTo(position); |
| final String intentKey = makeIntentKey(suggestions); |
| runTransactionAsync(new SQLiteTransaction() { |
| @Override |
| public boolean performTransaction(SQLiteDatabase db) { |
| db.delete(Shortcuts.TABLE_NAME, Shortcuts.intent_key.fullName + " = ?", |
| new String[]{ intentKey }); |
| return true; |
| } |
| }); |
| } |
| |
| public void clearHistory() { |
| runTransactionAsync(new SQLiteTransaction() { |
| @Override |
| public boolean performTransaction(SQLiteDatabase db) { |
| db.delete(ClickLog.TABLE_NAME, null, null); |
| db.delete(Shortcuts.TABLE_NAME, null, null); |
| db.delete(SourceStats.TABLE_NAME, null, null); |
| return true; |
| } |
| }); |
| } |
| |
| @VisibleForTesting |
| public void deleteRepository() { |
| getOpenHelper().deleteDatabase(); |
| } |
| |
| public void close() { |
| getOpenHelper().close(); |
| } |
| |
| public void reportClick(final SuggestionCursor suggestions, final int position) { |
| final long now = System.currentTimeMillis(); |
| reportClickAtTime(suggestions, position, now); |
| } |
| |
| public void getShortcutsForQuery(final String query, final Collection<Corpus> allowedCorpora, |
| final boolean allowWebSearchShortcuts, final Consumer<ShortcutCursor> consumer) { |
| final long now = System.currentTimeMillis(); |
| mLogExecutor.execute(new Runnable() { |
| public void run() { |
| ShortcutCursor shortcuts = getShortcutsForQuery(query, allowedCorpora, |
| allowWebSearchShortcuts, now); |
| Consumers.consumeCloseable(consumer, shortcuts); |
| } |
| }); |
| } |
| |
| public void updateShortcut(Source source, String shortcutId, SuggestionCursor refreshed) { |
| refreshShortcut(source, shortcutId, refreshed); |
| } |
| |
| public void getCorpusScores(final Consumer<Map<String, Integer>> consumer) { |
| runQueryAsync(new SQLiteAsyncQuery<Map<String, Integer>>() { |
| @Override |
| protected Map<String, Integer> performQuery(SQLiteDatabase db) { |
| return getCorpusScores(); |
| } |
| }, consumer); |
| } |
| |
| // -------------------------- end ShortcutRepository -------------------------- |
| |
| private boolean hasHistory(SQLiteDatabase db) { |
| Cursor cursor = db.rawQuery(HAS_HISTORY_QUERY, null); |
| try { |
| if (DBG) Log.d(TAG, "hasHistory(): cursor=" + cursor); |
| return cursor != null && cursor.getCount() > 0; |
| } finally { |
| if (cursor != null) cursor.close(); |
| } |
| } |
| |
| private Map<String,Integer> getCorpusScores() { |
| return getCorpusScores(mConfig.getMinClicksForSourceRanking()); |
| } |
| |
| private boolean shouldRefresh(Suggestion suggestion) { |
| return mRefresher.shouldRefresh(suggestion.getSuggestionSource(), |
| suggestion.getShortcutId()); |
| } |
| |
| @VisibleForTesting |
| ShortcutCursor getShortcutsForQuery(String query, Collection<Corpus> allowedCorpora, |
| boolean allowWebSearchShortcuts, long now) { |
| if (DBG) Log.d(TAG, "getShortcutsForQuery(" + query + "," + allowedCorpora + ")"); |
| String sql = query.length() == 0 ? mEmptyQueryShortcutQuery : mShortcutQuery; |
| String[] params = buildShortcutQueryParams(query, now); |
| |
| SQLiteDatabase db = mOpenHelper.getReadableDatabase(); |
| Cursor cursor = db.rawQuery(sql, params); |
| if (cursor.getCount() == 0) { |
| cursor.close(); |
| return null; |
| } |
| |
| if (DBG) Log.d(TAG, "Allowed sources: "); |
| HashMap<String,Source> allowedSources = new HashMap<String,Source>(); |
| for (Corpus corpus : allowedCorpora) { |
| for (Source source : corpus.getSources()) { |
| if (DBG) Log.d(TAG, "\t" + source.getName()); |
| allowedSources.put(source.getName(), source); |
| } |
| } |
| |
| return new ShortcutCursor(new SuggestionCursorImpl(allowedSources, query, cursor), |
| allowWebSearchShortcuts, mUiThread, mRefresher, this); |
| } |
| |
| @VisibleForTesting |
| void refreshShortcut(Source source, final String shortcutId, |
| SuggestionCursor refreshed) { |
| if (source == null) throw new NullPointerException("source"); |
| if (shortcutId == null) throw new NullPointerException("shortcutId"); |
| |
| final String[] whereArgs = { shortcutId, source.getName() }; |
| final ContentValues shortcut; |
| if (refreshed == null || refreshed.getCount() == 0) { |
| shortcut = null; |
| } else { |
| refreshed.moveTo(0); |
| shortcut = makeShortcutRow(refreshed); |
| } |
| |
| runTransactionAsync(new SQLiteTransaction() { |
| @Override |
| protected boolean performTransaction(SQLiteDatabase db) { |
| if (shortcut == null) { |
| if (DBG) Log.d(TAG, "Deleting shortcut: " + shortcutId); |
| db.delete(Shortcuts.TABLE_NAME, SHORTCUT_BY_ID_WHERE, whereArgs); |
| } else { |
| if (DBG) Log.d(TAG, "Updating shortcut: " + shortcut); |
| db.updateWithOnConflict(Shortcuts.TABLE_NAME, shortcut, |
| SHORTCUT_BY_ID_WHERE, whereArgs, SQLiteDatabase.CONFLICT_REPLACE); |
| } |
| return true; |
| } |
| }); |
| } |
| |
| private class SuggestionCursorImpl extends CursorBackedSuggestionCursor { |
| |
| private final HashMap<String, Source> mAllowedSources; |
| private final int mExtrasColumn; |
| |
| public SuggestionCursorImpl(HashMap<String,Source> allowedSources, |
| String userQuery, Cursor cursor) { |
| super(userQuery, cursor); |
| mAllowedSources = allowedSources; |
| mExtrasColumn = cursor.getColumnIndex(Shortcuts.custom_columns.name()); |
| } |
| |
| @Override |
| public Source getSuggestionSource() { |
| int srcCol = mCursor.getColumnIndex(Shortcuts.source.name()); |
| String srcStr = mCursor.getString(srcCol); |
| if (srcStr == null) { |
| throw new NullPointerException("Missing source for shortcut."); |
| } |
| Source source = mAllowedSources.get(srcStr); |
| if (source == null) { |
| if (DBG) { |
| Log.d(TAG, "Source " + srcStr + " (position " + mCursor.getPosition() + |
| ") not allowed"); |
| } |
| return null; |
| } |
| int versionCode = mCursor.getInt(Shortcuts.source_version_code.ordinal()); |
| if (!source.isVersionCodeCompatible(versionCode)) { |
| if (DBG) { |
| Log.d(TAG, "Version " + versionCode + " not compatible with " + |
| source.getVersionCode() + " for source " + srcStr); |
| } |
| return null; |
| } |
| return source; |
| } |
| |
| @Override |
| public ComponentName getSuggestionIntentComponent() { |
| int componentCol = mCursor.getColumnIndex(Shortcuts.intent_component.name()); |
| // We don't fall back to getSuggestionSource().getIntentComponent() because |
| // we want to return the same value that getSuggestionIntentComponent() did for the |
| // original suggestion. |
| return stringToComponentName(mCursor.getString(componentCol)); |
| } |
| |
| @Override |
| public String getSuggestionIcon2() { |
| if (isSpinnerWhileRefreshing() && shouldRefresh(this)) { |
| if (DBG) Log.d(TAG, "shortcut " + getShortcutId() + " refreshing"); |
| return mSearchSpinner; |
| } |
| if (DBG) Log.d(TAG, "shortcut " + getShortcutId() + " NOT refreshing"); |
| return super.getSuggestionIcon2(); |
| } |
| |
| public boolean isSuggestionShortcut() { |
| return true; |
| } |
| |
| public boolean isHistorySuggestion() { |
| // This always returns false, even for suggestions that originally came |
| // from server-side history, since we'd otherwise have to parse the Genie |
| // extra data. This is ok, since this method is only used for the |
| // "Remove from history" UI, which is also shown for all shortcuts. |
| return false; |
| } |
| |
| @Override |
| public SuggestionExtras getExtras() { |
| String json = mCursor.getString(mExtrasColumn); |
| if (!TextUtils.isEmpty(json)) { |
| try { |
| return new JsonBackedSuggestionExtras(json); |
| } catch (JSONException e) { |
| Log.e(TAG, "Could not parse JSON extras from DB: " + json); |
| } |
| } |
| return null; |
| } |
| |
| public Collection<String> getExtraColumns() { |
| /* |
| * We always return null here because: |
| * - to return an accurate value, we'd have to aggregate all the extra columns in all |
| * shortcuts in the shortcuts table, which would mean parsing ALL the JSON contained |
| * therein |
| * - ListSuggestionCursor does this aggregation, and does it lazily |
| * - All shortcuts are put into a ListSuggestionCursor during the promotion process, so |
| * relying on ListSuggestionCursor to do the aggregation means that we only parse the |
| * JSON for shortcuts that are actually displayed. |
| */ |
| return null; |
| } |
| } |
| |
| /** |
| * Builds a parameter list for the queries built by {@link #buildShortcutQueries}. |
| */ |
| private static String[] buildShortcutQueryParams(String query, long now) { |
| return new String[]{ query, nextString(query), String.valueOf(now) }; |
| } |
| |
| /** |
| * Given a string x, this method returns the least string y such that x is not a prefix of y. |
| * This is useful to implement prefix filtering by comparison, since the only strings z that |
| * have x as a prefix are such that z is greater than or equal to x and z is less than y. |
| * |
| * @param str A non-empty string. The contract above is not honored for an empty input string, |
| * since all strings have the empty string as a prefix. |
| */ |
| private static String nextString(String str) { |
| int len = str.length(); |
| if (len == 0) { |
| return str; |
| } |
| // The last code point in the string. Within the Basic Multilingual Plane, |
| // this is the same as str.charAt(len-1) |
| int codePoint = str.codePointBefore(len); |
| // This should be safe from overflow, since the largest code point |
| // representable in UTF-16 is U+10FFFF. |
| int nextCodePoint = codePoint + 1; |
| // The index of the start of the last code point. |
| // Character.charCount(codePoint) is always 1 (in the BMP) or 2 |
| int lastIndex = len - Character.charCount(codePoint); |
| return new StringBuilder(len) |
| .append(str, 0, lastIndex) // append everything but the last code point |
| .appendCodePoint(nextCodePoint) // instead of the last code point, use successor |
| .toString(); |
| } |
| |
| /** |
| * Returns the source ranking for sources with a minimum number of clicks. |
| * |
| * @param minClicks The minimum number of clicks a source must have. |
| * @return The list of sources, ranked by total clicks. |
| */ |
| Map<String,Integer> getCorpusScores(int minClicks) { |
| SQLiteDatabase db = mOpenHelper.getReadableDatabase(); |
| final Cursor cursor = db.rawQuery( |
| SOURCE_RANKING_SQL, new String[] { String.valueOf(minClicks) }); |
| try { |
| Map<String,Integer> corpora = new HashMap<String,Integer>(cursor.getCount()); |
| while (cursor.moveToNext()) { |
| String name = cursor.getString(SourceStats.corpus.ordinal()); |
| int clicks = cursor.getInt(SourceStats.total_clicks.ordinal()); |
| corpora.put(name, clicks); |
| } |
| return corpora; |
| } finally { |
| cursor.close(); |
| } |
| } |
| |
| private ContentValues makeShortcutRow(Suggestion suggestion) { |
| String intentAction = suggestion.getSuggestionIntentAction(); |
| String intentComponent = componentNameToString(suggestion.getSuggestionIntentComponent()); |
| String intentData = suggestion.getSuggestionIntentDataString(); |
| String intentQuery = suggestion.getSuggestionQuery(); |
| String intentExtraData = suggestion.getSuggestionIntentExtraData(); |
| |
| Source source = suggestion.getSuggestionSource(); |
| String sourceName = source.getName(); |
| |
| String intentKey = makeIntentKey(suggestion); |
| |
| // Get URIs for all icons, to make sure that they are stable |
| String icon1Uri = getIconUriString(source, suggestion.getSuggestionIcon1()); |
| String icon2Uri = getIconUriString(source, suggestion.getSuggestionIcon2()); |
| |
| String extrasJson = null; |
| SuggestionExtras extras = suggestion.getExtras(); |
| if (extras != null) { |
| // flatten any custom columns to JSON. We need to keep any custom columns so that |
| // shortcuts for custom suggestion views work properly. |
| try { |
| extrasJson = extras.toJsonString(); |
| } catch (JSONException e) { |
| Log.e(TAG, "Could not flatten extras to JSON from " + suggestion, e); |
| } |
| } |
| |
| ContentValues cv = new ContentValues(); |
| cv.put(Shortcuts.intent_key.name(), intentKey); |
| cv.put(Shortcuts.source.name(), sourceName); |
| cv.put(Shortcuts.source_version_code.name(), source.getVersionCode()); |
| cv.put(Shortcuts.format.name(), suggestion.getSuggestionFormat()); |
| cv.put(Shortcuts.title.name(), suggestion.getSuggestionText1()); |
| cv.put(Shortcuts.description.name(), suggestion.getSuggestionText2()); |
| cv.put(Shortcuts.description_url.name(), suggestion.getSuggestionText2Url()); |
| cv.put(Shortcuts.icon1.name(), icon1Uri); |
| cv.put(Shortcuts.icon2.name(), icon2Uri); |
| cv.put(Shortcuts.intent_action.name(), intentAction); |
| cv.put(Shortcuts.intent_component.name(), intentComponent); |
| cv.put(Shortcuts.intent_data.name(), intentData); |
| cv.put(Shortcuts.intent_query.name(), intentQuery); |
| cv.put(Shortcuts.intent_extradata.name(), intentExtraData); |
| cv.put(Shortcuts.shortcut_id.name(), suggestion.getShortcutId()); |
| if (suggestion.isSpinnerWhileRefreshing()) { |
| cv.put(Shortcuts.spinner_while_refreshing.name(), "true"); |
| } |
| cv.put(Shortcuts.log_type.name(), suggestion.getSuggestionLogType()); |
| cv.put(Shortcuts.custom_columns.name(), extrasJson); |
| |
| return cv; |
| } |
| |
| /** |
| * Makes a string of the form source#intentData#intentAction#intentQuery |
| * for use as a unique identifier of a suggestion. |
| * */ |
| private String makeIntentKey(Suggestion suggestion) { |
| String intentAction = suggestion.getSuggestionIntentAction(); |
| String intentComponent = componentNameToString(suggestion.getSuggestionIntentComponent()); |
| String intentData = suggestion.getSuggestionIntentDataString(); |
| String intentQuery = suggestion.getSuggestionQuery(); |
| |
| Source source = suggestion.getSuggestionSource(); |
| String sourceName = source.getName(); |
| StringBuilder key = new StringBuilder(sourceName); |
| key.append("#"); |
| if (intentData != null) { |
| key.append(intentData); |
| } |
| key.append("#"); |
| if (intentAction != null) { |
| key.append(intentAction); |
| } |
| key.append("#"); |
| if (intentComponent != null) { |
| key.append(intentComponent); |
| } |
| key.append("#"); |
| if (intentQuery != null) { |
| key.append(intentQuery); |
| } |
| |
| return key.toString(); |
| } |
| |
| private String componentNameToString(ComponentName component) { |
| return component == null ? null : component.flattenToShortString(); |
| } |
| |
| private ComponentName stringToComponentName(String str) { |
| return str == null ? null : ComponentName.unflattenFromString(str); |
| } |
| |
| private String getIconUriString(Source source, String drawableId) { |
| // Fast path for empty icons |
| if (TextUtils.isEmpty(drawableId) || "0".equals(drawableId)) { |
| return null; |
| } |
| // Fast path for icon URIs |
| if (drawableId.startsWith(ContentResolver.SCHEME_ANDROID_RESOURCE) |
| || drawableId.startsWith(ContentResolver.SCHEME_CONTENT) |
| || drawableId.startsWith(ContentResolver.SCHEME_FILE)) { |
| return drawableId; |
| } |
| Uri uri = source.getIconUri(drawableId); |
| return uri == null ? null : uri.toString(); |
| } |
| |
| @VisibleForTesting |
| void reportClickAtTime(SuggestionCursor suggestion, |
| int position, long now) { |
| suggestion.moveTo(position); |
| if (DBG) { |
| Log.d(TAG, "logClicked(" + suggestion + ")"); |
| } |
| |
| if (SearchManager.SUGGEST_NEVER_MAKE_SHORTCUT.equals(suggestion.getShortcutId())) { |
| if (DBG) Log.d(TAG, "clicked suggestion requested not to be shortcuted"); |
| return; |
| } |
| |
| Corpus corpus = mCorpora.getCorpusForSource(suggestion.getSuggestionSource()); |
| if (corpus == null) { |
| Log.w(TAG, "no corpus for clicked suggestion"); |
| return; |
| } |
| |
| // Once the user has clicked on a shortcut, don't bother refreshing |
| // (especially if this is a new shortcut) |
| mRefresher.markShortcutRefreshed(suggestion.getSuggestionSource(), |
| suggestion.getShortcutId()); |
| |
| // Add or update suggestion info |
| // Since intent_key is the primary key, any existing |
| // suggestion with the same source+data+action will be replaced |
| final ContentValues shortcut = makeShortcutRow(suggestion); |
| String intentKey = shortcut.getAsString(Shortcuts.intent_key.name()); |
| |
| // Log click for shortcut |
| final ContentValues click = new ContentValues(); |
| click.put(ClickLog.intent_key.name(), intentKey); |
| click.put(ClickLog.query.name(), suggestion.getUserQuery()); |
| click.put(ClickLog.hit_time.name(), now); |
| click.put(ClickLog.corpus.name(), corpus.getName()); |
| |
| runTransactionAsync(new SQLiteTransaction() { |
| @Override |
| protected boolean performTransaction(SQLiteDatabase db) { |
| if (DBG) Log.d(TAG, "Adding shortcut: " + shortcut); |
| db.replaceOrThrow(Shortcuts.TABLE_NAME, null, shortcut); |
| db.insertOrThrow(ClickLog.TABLE_NAME, null, click); |
| return true; |
| } |
| }); |
| } |
| |
| // -------------------------- TABLES -------------------------- |
| |
| /** |
| * shortcuts table |
| */ |
| enum Shortcuts { |
| intent_key, |
| source, |
| source_version_code, |
| format, |
| title, |
| description, |
| description_url, |
| icon1, |
| icon2, |
| intent_action, |
| intent_component, |
| intent_data, |
| intent_query, |
| intent_extradata, |
| shortcut_id, |
| spinner_while_refreshing, |
| log_type, |
| custom_columns; |
| |
| static final String TABLE_NAME = "shortcuts"; |
| |
| public final String fullName; |
| |
| Shortcuts() { |
| fullName = TABLE_NAME + "." + name(); |
| } |
| } |
| |
| /** |
| * clicklog table. Has one record for each click. |
| */ |
| enum ClickLog { |
| _id, |
| intent_key, |
| query, |
| hit_time, |
| corpus; |
| |
| static final String[] COLUMNS = initColumns(); |
| |
| static final String TABLE_NAME = "clicklog"; |
| |
| private static String[] initColumns() { |
| ClickLog[] vals = ClickLog.values(); |
| String[] columns = new String[vals.length]; |
| for (int i = 0; i < vals.length; i++) { |
| columns[i] = vals[i].fullName; |
| } |
| return columns; |
| } |
| |
| public final String fullName; |
| |
| ClickLog() { |
| fullName = TABLE_NAME + "." + name(); |
| } |
| } |
| |
| /** |
| * This is an aggregate table of {@link ClickLog} that stays up to date with the total |
| * clicks for each corpus. This makes computing the corpus ranking more |
| * more efficient, at the expense of some extra work when the clicks are reported. |
| */ |
| enum SourceStats { |
| corpus, |
| total_clicks; |
| |
| static final String TABLE_NAME = "sourcetotals"; |
| |
| static final String[] COLUMNS = initColumns(); |
| |
| private static String[] initColumns() { |
| SourceStats[] vals = SourceStats.values(); |
| String[] columns = new String[vals.length]; |
| for (int i = 0; i < vals.length; i++) { |
| columns[i] = vals[i].fullName; |
| } |
| return columns; |
| } |
| |
| public final String fullName; |
| |
| SourceStats() { |
| fullName = TABLE_NAME + "." + name(); |
| } |
| } |
| |
| // -------------------------- END TABLES -------------------------- |
| |
| // contains creation and update logic |
| private static class DbOpenHelper extends SQLiteOpenHelper { |
| private final Config mConfig; |
| private String mPath; |
| private static final String SHORTCUT_ID_INDEX |
| = Shortcuts.TABLE_NAME + "_" + Shortcuts.shortcut_id.name(); |
| private static final String CLICKLOG_QUERY_INDEX |
| = ClickLog.TABLE_NAME + "_" + ClickLog.query.name(); |
| private static final String CLICKLOG_HIT_TIME_INDEX |
| = ClickLog.TABLE_NAME + "_" + ClickLog.hit_time.name(); |
| private static final String CLICKLOG_INSERT_TRIGGER |
| = ClickLog.TABLE_NAME + "_insert"; |
| private static final String SHORTCUTS_DELETE_TRIGGER |
| = Shortcuts.TABLE_NAME + "_delete"; |
| private static final String SHORTCUTS_UPDATE_INTENT_KEY_TRIGGER |
| = Shortcuts.TABLE_NAME + "_update_intent_key"; |
| |
| public DbOpenHelper(Context context, String name, int version, Config config) { |
| super(context, name, null, version); |
| mConfig = config; |
| } |
| |
| @Override |
| public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { |
| // The shortcuts info is not all that important, so we just drop the tables |
| // and re-create empty ones. |
| Log.i(TAG, "Upgrading shortcuts DB from version " + |
| + oldVersion + " to " + newVersion + ". This deletes all shortcuts."); |
| dropTables(db); |
| onCreate(db); |
| } |
| |
| private void dropTables(SQLiteDatabase db) { |
| db.execSQL("DROP TRIGGER IF EXISTS " + CLICKLOG_INSERT_TRIGGER); |
| db.execSQL("DROP TRIGGER IF EXISTS " + SHORTCUTS_DELETE_TRIGGER); |
| db.execSQL("DROP TRIGGER IF EXISTS " + SHORTCUTS_UPDATE_INTENT_KEY_TRIGGER); |
| db.execSQL("DROP INDEX IF EXISTS " + CLICKLOG_HIT_TIME_INDEX); |
| db.execSQL("DROP INDEX IF EXISTS " + CLICKLOG_QUERY_INDEX); |
| db.execSQL("DROP INDEX IF EXISTS " + SHORTCUT_ID_INDEX); |
| db.execSQL("DROP TABLE IF EXISTS " + ClickLog.TABLE_NAME); |
| db.execSQL("DROP TABLE IF EXISTS " + Shortcuts.TABLE_NAME); |
| db.execSQL("DROP TABLE IF EXISTS " + SourceStats.TABLE_NAME); |
| } |
| |
| /** |
| * Deletes the database file. |
| */ |
| public void deleteDatabase() { |
| close(); |
| if (mPath == null) return; |
| try { |
| new File(mPath).delete(); |
| if (DBG) Log.d(TAG, "deleted " + mPath); |
| } catch (Exception e) { |
| Log.w(TAG, "couldn't delete " + mPath, e); |
| } |
| } |
| |
| @Override |
| public void onOpen(SQLiteDatabase db) { |
| super.onOpen(db); |
| mPath = db.getPath(); |
| } |
| |
| @Override |
| public void onCreate(SQLiteDatabase db) { |
| db.execSQL("CREATE TABLE " + Shortcuts.TABLE_NAME + " (" + |
| // COLLATE UNICODE is needed to make it possible to use nextString() |
| // to implement fast prefix filtering. |
| Shortcuts.intent_key.name() + " TEXT NOT NULL COLLATE UNICODE PRIMARY KEY, " + |
| Shortcuts.source.name() + " TEXT NOT NULL, " + |
| Shortcuts.source_version_code.name() + " INTEGER NOT NULL, " + |
| Shortcuts.format.name() + " TEXT, " + |
| Shortcuts.title.name() + " TEXT, " + |
| Shortcuts.description.name() + " TEXT, " + |
| Shortcuts.description_url.name() + " TEXT, " + |
| Shortcuts.icon1.name() + " TEXT, " + |
| Shortcuts.icon2.name() + " TEXT, " + |
| Shortcuts.intent_action.name() + " TEXT, " + |
| Shortcuts.intent_component.name() + " TEXT, " + |
| Shortcuts.intent_data.name() + " TEXT, " + |
| Shortcuts.intent_query.name() + " TEXT, " + |
| Shortcuts.intent_extradata.name() + " TEXT, " + |
| Shortcuts.shortcut_id.name() + " TEXT, " + |
| Shortcuts.spinner_while_refreshing.name() + " TEXT, " + |
| Shortcuts.log_type.name() + " TEXT, " + |
| Shortcuts.custom_columns.name() + " TEXT" + |
| ");"); |
| |
| // index for fast lookup of shortcuts by shortcut_id |
| db.execSQL("CREATE INDEX " + SHORTCUT_ID_INDEX |
| + " ON " + Shortcuts.TABLE_NAME |
| + "(" + Shortcuts.shortcut_id.name() + ", " + Shortcuts.source.name() + ")"); |
| |
| db.execSQL("CREATE TABLE " + ClickLog.TABLE_NAME + " ( " + |
| ClickLog._id.name() + " INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, " + |
| // type must match Shortcuts.intent_key |
| ClickLog.intent_key.name() + " TEXT NOT NULL COLLATE UNICODE REFERENCES " |
| + Shortcuts.TABLE_NAME + "(" + Shortcuts.intent_key + "), " + |
| ClickLog.query.name() + " TEXT, " + |
| ClickLog.hit_time.name() + " INTEGER," + |
| ClickLog.corpus.name() + " TEXT" + |
| ");"); |
| |
| // index for fast lookup of clicks by query |
| db.execSQL("CREATE INDEX " + CLICKLOG_QUERY_INDEX |
| + " ON " + ClickLog.TABLE_NAME + "(" + ClickLog.query.name() + ")"); |
| |
| // index for finding old clicks quickly |
| db.execSQL("CREATE INDEX " + CLICKLOG_HIT_TIME_INDEX |
| + " ON " + ClickLog.TABLE_NAME + "(" + ClickLog.hit_time.name() + ")"); |
| |
| // trigger for purging old clicks, i.e. those such that |
| // hit_time < now - MAX_MAX_STAT_AGE_MILLIS, where now is the |
| // hit_time of the inserted record, and for updating the SourceStats table |
| db.execSQL("CREATE TRIGGER " + CLICKLOG_INSERT_TRIGGER + " AFTER INSERT ON " |
| + ClickLog.TABLE_NAME |
| + " BEGIN" |
| + " DELETE FROM " + ClickLog.TABLE_NAME + " WHERE " |
| + ClickLog.hit_time.name() + " <" |
| + " NEW." + ClickLog.hit_time.name() |
| + " - " + mConfig.getMaxStatAgeMillis() + ";" |
| + " DELETE FROM " + SourceStats.TABLE_NAME + ";" |
| + " INSERT INTO " + SourceStats.TABLE_NAME + " " |
| + "SELECT " + ClickLog.corpus + "," + "COUNT(*) FROM " |
| + ClickLog.TABLE_NAME + " GROUP BY " + ClickLog.corpus.name() + ";" |
| + " END"); |
| |
| // trigger for deleting clicks about a shortcut once that shortcut has been |
| // deleted |
| db.execSQL("CREATE TRIGGER " + SHORTCUTS_DELETE_TRIGGER + " AFTER DELETE ON " |
| + Shortcuts.TABLE_NAME |
| + " BEGIN" |
| + " DELETE FROM " + ClickLog.TABLE_NAME + " WHERE " |
| + ClickLog.intent_key.name() |
| + " = OLD." + Shortcuts.intent_key.name() + ";" |
| + " END"); |
| |
| // trigger for updating click log entries when a shortcut changes its intent_key |
| db.execSQL("CREATE TRIGGER " + SHORTCUTS_UPDATE_INTENT_KEY_TRIGGER |
| + " AFTER UPDATE ON " + Shortcuts.TABLE_NAME |
| + " WHEN NEW." + Shortcuts.intent_key.name() |
| + " != OLD." + Shortcuts.intent_key.name() |
| + " BEGIN" |
| + " UPDATE " + ClickLog.TABLE_NAME + " SET " |
| + ClickLog.intent_key.name() + " = NEW." + Shortcuts.intent_key.name() |
| + " WHERE " |
| + ClickLog.intent_key.name() + " = OLD." + Shortcuts.intent_key.name() |
| + ";" |
| + " END"); |
| |
| db.execSQL("CREATE TABLE " + SourceStats.TABLE_NAME + " ( " + |
| SourceStats.corpus.name() + " TEXT NOT NULL COLLATE UNICODE PRIMARY KEY, " + |
| SourceStats.total_clicks + " INTEGER);" |
| ); |
| } |
| } |
| } |