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