blob: 3c5bab221557ca298b631ebef1c4e3d6405b47f8 [file] [log] [blame]
/*
* 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);"
);
}
}
}