ShortcutRepositoryImplLog.java revision 5229b06f00d20aac76cd8519b37f2a579d61c54f
1/* 2 * Copyright (C) 2009 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17package com.android.quicksearchbox; 18 19import com.android.quicksearchbox.util.Consumer; 20import com.android.quicksearchbox.util.Consumers; 21import com.android.quicksearchbox.util.SQLiteAsyncQuery; 22import com.android.quicksearchbox.util.SQLiteTransaction; 23import com.android.quicksearchbox.util.Util; 24import com.google.common.annotations.VisibleForTesting; 25 26import org.json.JSONException; 27 28import android.app.SearchManager; 29import android.content.ComponentName; 30import android.content.ContentResolver; 31import android.content.ContentValues; 32import android.content.Context; 33import android.database.Cursor; 34import android.database.sqlite.SQLiteDatabase; 35import android.database.sqlite.SQLiteOpenHelper; 36import android.database.sqlite.SQLiteQueryBuilder; 37import android.net.Uri; 38import android.os.Handler; 39import android.text.TextUtils; 40import android.util.Log; 41 42import java.io.File; 43import java.util.Collection; 44import java.util.HashMap; 45import java.util.Map; 46import java.util.concurrent.Executor; 47 48/** 49 * A shortcut repository implementation that uses a log of every click. 50 * 51 * To inspect DB: 52 * # sqlite3 /data/data/com.android.quicksearchbox/databases/qsb-log.db 53 * 54 * TODO: Refactor this class. 55 */ 56public class ShortcutRepositoryImplLog implements ShortcutRepository { 57 58 private static final boolean DBG = false; 59 private static final String TAG = "QSB.ShortcutRepositoryImplLog"; 60 61 private static final String DB_NAME = "qsb-log.db"; 62 private static final int DB_VERSION = 32; 63 64 private static final String HAS_HISTORY_QUERY = 65 "SELECT " + Shortcuts.intent_key.fullName + " FROM " + Shortcuts.TABLE_NAME; 66 private String mEmptyQueryShortcutQuery ; 67 private String mShortcutQuery; 68 69 private static final String SHORTCUT_BY_ID_WHERE = 70 Shortcuts.shortcut_id.name() + "=? AND " + Shortcuts.source.name() + "=?"; 71 72 private static final String SOURCE_RANKING_SQL = buildSourceRankingSql(); 73 74 private final Context mContext; 75 private final Config mConfig; 76 private final Corpora mCorpora; 77 private final ShortcutRefresher mRefresher; 78 private final Handler mUiThread; 79 // Used to perform log write operations asynchronously 80 private final Executor mLogExecutor; 81 private final DbOpenHelper mOpenHelper; 82 private final String mSearchSpinner; 83 84 /** 85 * Create an instance to the repo. 86 */ 87 public static ShortcutRepository create(Context context, Config config, 88 Corpora sources, ShortcutRefresher refresher, Handler uiThread, 89 Executor logExecutor) { 90 return new ShortcutRepositoryImplLog(context, config, sources, refresher, 91 uiThread, logExecutor, DB_NAME); 92 } 93 94 /** 95 * @param context Used to create / open db 96 * @param name The name of the database to create. 97 */ 98 @VisibleForTesting 99 ShortcutRepositoryImplLog(Context context, Config config, Corpora corpora, 100 ShortcutRefresher refresher, Handler uiThread, Executor logExecutor, String name) { 101 mContext = context; 102 mConfig = config; 103 mCorpora = corpora; 104 mRefresher = refresher; 105 mUiThread = uiThread; 106 mLogExecutor = logExecutor; 107 mOpenHelper = new DbOpenHelper(context, name, DB_VERSION, config); 108 buildShortcutQueries(); 109 110 mSearchSpinner = Util.getResourceUri(mContext, R.drawable.search_spinner).toString(); 111 } 112 113 // clicklog first, since that's where restrict the result set 114 private static final String TABLES = ClickLog.TABLE_NAME + " INNER JOIN " + 115 Shortcuts.TABLE_NAME + " ON " + ClickLog.intent_key.fullName + " = " + 116 Shortcuts.intent_key.fullName; 117 118 private static final String AS = " AS "; 119 120 private static final String[] SHORTCUT_QUERY_COLUMNS = { 121 Shortcuts.intent_key.fullName, 122 Shortcuts.source.fullName, 123 Shortcuts.source_version_code.fullName, 124 Shortcuts.format.fullName + AS + SearchManager.SUGGEST_COLUMN_FORMAT, 125 Shortcuts.title + AS + SearchManager.SUGGEST_COLUMN_TEXT_1, 126 Shortcuts.description + AS + SearchManager.SUGGEST_COLUMN_TEXT_2, 127 Shortcuts.description_url + AS + SearchManager.SUGGEST_COLUMN_TEXT_2_URL, 128 Shortcuts.icon1 + AS + SearchManager.SUGGEST_COLUMN_ICON_1, 129 Shortcuts.icon2 + AS + SearchManager.SUGGEST_COLUMN_ICON_2, 130 Shortcuts.intent_action + AS + SearchManager.SUGGEST_COLUMN_INTENT_ACTION, 131 Shortcuts.intent_component.fullName, 132 Shortcuts.intent_data + AS + SearchManager.SUGGEST_COLUMN_INTENT_DATA, 133 Shortcuts.intent_query + AS + SearchManager.SUGGEST_COLUMN_QUERY, 134 Shortcuts.intent_extradata + AS + SearchManager.SUGGEST_COLUMN_INTENT_EXTRA_DATA, 135 Shortcuts.shortcut_id + AS + SearchManager.SUGGEST_COLUMN_SHORTCUT_ID, 136 Shortcuts.spinner_while_refreshing + AS + 137 SearchManager.SUGGEST_COLUMN_SPINNER_WHILE_REFRESHING, 138 Shortcuts.log_type + AS + CursorBackedSuggestionCursor.SUGGEST_COLUMN_LOG_TYPE, 139 Shortcuts.custom_columns.fullName, 140 }; 141 142 // Avoid GLOB by using >= AND <, with some manipulation (see nextString(String)). 143 // to figure out the upper bound (e.g. >= "abc" AND < "abd" 144 // This allows us to use parameter binding and still take advantage of the 145 // index on the query column. 146 private static final String PREFIX_RESTRICTION = 147 ClickLog.query.fullName + " >= ?1 AND " + ClickLog.query.fullName + " < ?2"; 148 149 private static final String LAST_HIT_TIME_EXPR = "MAX(" + ClickLog.hit_time.fullName + ")"; 150 private static final String GROUP_BY = ClickLog.intent_key.fullName; 151 private static final String PREFER_LATEST_PREFIX = 152 "(" + LAST_HIT_TIME_EXPR + " = (SELECT " + LAST_HIT_TIME_EXPR + " FROM " + 153 ClickLog.TABLE_NAME + " WHERE "; 154 private static final String PREFER_LATEST_SUFFIX = "))"; 155 156 private void buildShortcutQueries() { 157 // SQL expression for the time before which no clicks should be counted. 158 String cutOffTime_expr = "(?3 - " + mConfig.getMaxStatAgeMillis() + ")"; 159 // Filter out clicks that are too old 160 String ageRestriction = ClickLog.hit_time.fullName + " >= " + cutOffTime_expr; 161 String having = null; 162 // Order by sum of hit times (seconds since cutoff) for the clicks for each shortcut. 163 // This has the effect of multiplying the average hit time with the click count 164 String ordering_expr = 165 "SUM((" + ClickLog.hit_time.fullName + " - " + cutOffTime_expr + ") / 1000)"; 166 167 String where = ageRestriction; 168 String preferLatest = PREFER_LATEST_PREFIX + where + PREFER_LATEST_SUFFIX; 169 String orderBy = preferLatest + " DESC, " + ordering_expr + " DESC"; 170 mEmptyQueryShortcutQuery = SQLiteQueryBuilder.buildQueryString( 171 false, TABLES, SHORTCUT_QUERY_COLUMNS, where, GROUP_BY, having, orderBy, null); 172 if (DBG) Log.d(TAG, "Empty shortcut query:\n" + mEmptyQueryShortcutQuery); 173 174 where = PREFIX_RESTRICTION + " AND " + ageRestriction; 175 preferLatest = PREFER_LATEST_PREFIX + where + PREFER_LATEST_SUFFIX; 176 orderBy = preferLatest + " DESC, " + ordering_expr + " DESC"; 177 mShortcutQuery = SQLiteQueryBuilder.buildQueryString( 178 false, TABLES, SHORTCUT_QUERY_COLUMNS, where, GROUP_BY, having, orderBy, null); 179 if (DBG) Log.d(TAG, "Empty shortcut:\n" + mShortcutQuery); 180 } 181 182 /** 183 * @return sql that ranks sources by total clicks, filtering out sources 184 * without enough clicks. 185 */ 186 private static String buildSourceRankingSql() { 187 final String orderingExpr = SourceStats.total_clicks.name(); 188 final String tables = SourceStats.TABLE_NAME; 189 final String[] columns = SourceStats.COLUMNS; 190 final String where = SourceStats.total_clicks + " >= $1"; 191 final String groupBy = null; 192 final String having = null; 193 final String orderBy = orderingExpr + " DESC"; 194 final String limit = null; 195 return SQLiteQueryBuilder.buildQueryString( 196 false, tables, columns, where, groupBy, having, orderBy, limit); 197 } 198 199 protected DbOpenHelper getOpenHelper() { 200 return mOpenHelper; 201 } 202 203 private void runTransactionAsync(final SQLiteTransaction transaction) { 204 mLogExecutor.execute(new Runnable() { 205 public void run() { 206 transaction.run(mOpenHelper.getWritableDatabase()); 207 } 208 }); 209 } 210 211 private <A> void runQueryAsync(final SQLiteAsyncQuery<A> query, final Consumer<A> consumer) { 212 mLogExecutor.execute(new Runnable() { 213 public void run() { 214 query.run(mOpenHelper.getReadableDatabase(), consumer); 215 } 216 }); 217 } 218 219// --------------------- Interface ShortcutRepository --------------------- 220 221 public void hasHistory(Consumer<Boolean> consumer) { 222 runQueryAsync(new SQLiteAsyncQuery<Boolean>() { 223 @Override 224 protected Boolean performQuery(SQLiteDatabase db) { 225 return hasHistory(db); 226 } 227 }, consumer); 228 } 229 230 public void clearHistory() { 231 runTransactionAsync(new SQLiteTransaction() { 232 @Override 233 public boolean performTransaction(SQLiteDatabase db) { 234 db.delete(ClickLog.TABLE_NAME, null, null); 235 db.delete(Shortcuts.TABLE_NAME, null, null); 236 db.delete(SourceStats.TABLE_NAME, null, null); 237 return true; 238 } 239 }); 240 } 241 242 @VisibleForTesting 243 public void deleteRepository() { 244 getOpenHelper().deleteDatabase(); 245 } 246 247 public void close() { 248 getOpenHelper().close(); 249 } 250 251 public void reportClick(final SuggestionCursor suggestions, final int position) { 252 final long now = System.currentTimeMillis(); 253 reportClickAtTime(suggestions, position, now); 254 } 255 256 public void getShortcutsForQuery(final String query, final Collection<Corpus> allowedCorpora, 257 final Consumer<ShortcutCursor> consumer) { 258 final long now = System.currentTimeMillis(); 259 mLogExecutor.execute(new Runnable() { 260 public void run() { 261 ShortcutCursor shortcuts = getShortcutsForQuery(query, allowedCorpora, now); 262 Consumers.consumeCloseable(consumer, shortcuts); 263 } 264 }); 265 } 266 267 public void updateShortcut(Source source, String shortcutId, SuggestionCursor refreshed) { 268 refreshShortcut(source, shortcutId, refreshed); 269 } 270 271 public void getCorpusScores(final Consumer<Map<String, Integer>> consumer) { 272 runQueryAsync(new SQLiteAsyncQuery<Map<String, Integer>>() { 273 @Override 274 protected Map<String, Integer> performQuery(SQLiteDatabase db) { 275 return getCorpusScores(); 276 } 277 }, consumer); 278 } 279 280// -------------------------- end ShortcutRepository -------------------------- 281 282 private boolean hasHistory(SQLiteDatabase db) { 283 Cursor cursor = db.rawQuery(HAS_HISTORY_QUERY, null); 284 try { 285 if (DBG) Log.d(TAG, "hasHistory(): cursor=" + cursor); 286 return cursor != null && cursor.getCount() > 0; 287 } finally { 288 if (cursor != null) cursor.close(); 289 } 290 } 291 292 private Map<String,Integer> getCorpusScores() { 293 return getCorpusScores(mConfig.getMinClicksForSourceRanking()); 294 } 295 296 private boolean shouldRefresh(Suggestion suggestion) { 297 return mRefresher.shouldRefresh(suggestion.getSuggestionSource(), 298 suggestion.getShortcutId()); 299 } 300 301 @VisibleForTesting 302 ShortcutCursor getShortcutsForQuery(String query, Collection<Corpus> allowedCorpora, long now) { 303 if (DBG) Log.d(TAG, "getShortcutsForQuery(" + query + "," + allowedCorpora + ")"); 304 String sql = query.length() == 0 ? mEmptyQueryShortcutQuery : mShortcutQuery; 305 String[] params = buildShortcutQueryParams(query, now); 306 307 SQLiteDatabase db = mOpenHelper.getReadableDatabase(); 308 Cursor cursor = db.rawQuery(sql, params); 309 if (cursor.getCount() == 0) { 310 cursor.close(); 311 return null; 312 } 313 314 if (DBG) Log.d(TAG, "Allowed sources: "); 315 HashMap<String,Source> allowedSources = new HashMap<String,Source>(); 316 for (Corpus corpus : allowedCorpora) { 317 for (Source source : corpus.getSources()) { 318 if (DBG) Log.d(TAG, "\t" + source.getName()); 319 allowedSources.put(source.getName(), source); 320 } 321 } 322 323 return new ShortcutCursor(new SuggestionCursorImpl(allowedSources, query, cursor), 324 mUiThread, mRefresher, this); 325 } 326 327 @VisibleForTesting 328 void refreshShortcut(Source source, final String shortcutId, 329 SuggestionCursor refreshed) { 330 if (source == null) throw new NullPointerException("source"); 331 if (shortcutId == null) throw new NullPointerException("shortcutId"); 332 333 final String[] whereArgs = { shortcutId, source.getName() }; 334 final ContentValues shortcut; 335 if (refreshed == null || refreshed.getCount() == 0) { 336 shortcut = null; 337 } else { 338 refreshed.moveTo(0); 339 shortcut = makeShortcutRow(refreshed); 340 } 341 342 runTransactionAsync(new SQLiteTransaction() { 343 @Override 344 protected boolean performTransaction(SQLiteDatabase db) { 345 if (shortcut == null) { 346 if (DBG) Log.d(TAG, "Deleting shortcut: " + shortcutId); 347 db.delete(Shortcuts.TABLE_NAME, SHORTCUT_BY_ID_WHERE, whereArgs); 348 } else { 349 if (DBG) Log.d(TAG, "Updating shortcut: " + shortcut); 350 db.updateWithOnConflict(Shortcuts.TABLE_NAME, shortcut, 351 SHORTCUT_BY_ID_WHERE, whereArgs, SQLiteDatabase.CONFLICT_REPLACE); 352 } 353 return true; 354 } 355 }); 356 } 357 358 private class SuggestionCursorImpl extends CursorBackedSuggestionCursor { 359 360 private final HashMap<String, Source> mAllowedSources; 361 private final int mExtrasColumn; 362 363 public SuggestionCursorImpl(HashMap<String,Source> allowedSources, 364 String userQuery, Cursor cursor) { 365 super(userQuery, cursor); 366 mAllowedSources = allowedSources; 367 mExtrasColumn = cursor.getColumnIndex(Shortcuts.custom_columns.name()); 368 } 369 370 @Override 371 public Source getSuggestionSource() { 372 int srcCol = mCursor.getColumnIndex(Shortcuts.source.name()); 373 String srcStr = mCursor.getString(srcCol); 374 if (srcStr == null) { 375 throw new NullPointerException("Missing source for shortcut."); 376 } 377 Source source = mAllowedSources.get(srcStr); 378 if (source == null) { 379 if (DBG) { 380 Log.d(TAG, "Source " + srcStr + " (position " + mCursor.getPosition() + 381 ") not allowed"); 382 } 383 return null; 384 } 385 int versionCode = mCursor.getInt(Shortcuts.source_version_code.ordinal()); 386 if (!source.isVersionCodeCompatible(versionCode)) { 387 if (DBG) { 388 Log.d(TAG, "Version " + versionCode + " not compatible with " + 389 source.getVersionCode() + " for source " + srcStr); 390 } 391 return null; 392 } 393 return source; 394 } 395 396 @Override 397 public ComponentName getSuggestionIntentComponent() { 398 int componentCol = mCursor.getColumnIndex(Shortcuts.intent_component.name()); 399 // We don't fall back to getSuggestionSource().getIntentComponent() because 400 // we want to return the same value that getSuggestionIntentComponent() did for the 401 // original suggestion. 402 return stringToComponentName(mCursor.getString(componentCol)); 403 } 404 405 @Override 406 public String getSuggestionIcon2() { 407 if (isSpinnerWhileRefreshing() && shouldRefresh(this)) { 408 if (DBG) Log.d(TAG, "shortcut " + getShortcutId() + " refreshing"); 409 return mSearchSpinner; 410 } 411 if (DBG) Log.d(TAG, "shortcut " + getShortcutId() + " NOT refreshing"); 412 return super.getSuggestionIcon2(); 413 } 414 415 public boolean isSuggestionShortcut() { 416 return true; 417 } 418 419 @Override 420 public SuggestionExtras getExtras() { 421 String json = mCursor.getString(mExtrasColumn); 422 if (!TextUtils.isEmpty(json)) { 423 try { 424 return new JsonBackedSuggestionExtras(json); 425 } catch (JSONException e) { 426 Log.e(TAG, "Could not parse JSON extras from DB: " + json); 427 } 428 } 429 return null; 430 } 431 432 public Collection<String> getExtraColumns() { 433 /* 434 * We always return null here because: 435 * - to return an accurate value, we'd have to aggregate all the extra columns in all 436 * shortcuts in the shortcuts table, which would mean parsing ALL the JSON contained 437 * therein 438 * - ListSuggestionCursor does this aggregation, and does it lazily 439 * - All shortcuts are put into a ListSuggestionCursor during the promotion process, so 440 * relying on ListSuggestionCursor to do the aggregation means that we only parse the 441 * JSON for shortcuts that are actually displayed. 442 */ 443 return null; 444 } 445 } 446 447 /** 448 * Builds a parameter list for the queries built by {@link #buildShortcutQueries}. 449 */ 450 private static String[] buildShortcutQueryParams(String query, long now) { 451 return new String[]{ query, nextString(query), String.valueOf(now) }; 452 } 453 454 /** 455 * Given a string x, this method returns the least string y such that x is not a prefix of y. 456 * This is useful to implement prefix filtering by comparison, since the only strings z that 457 * have x as a prefix are such that z is greater than or equal to x and z is less than y. 458 * 459 * @param str A non-empty string. The contract above is not honored for an empty input string, 460 * since all strings have the empty string as a prefix. 461 */ 462 private static String nextString(String str) { 463 int len = str.length(); 464 if (len == 0) { 465 return str; 466 } 467 // The last code point in the string. Within the Basic Multilingual Plane, 468 // this is the same as str.charAt(len-1) 469 int codePoint = str.codePointBefore(len); 470 // This should be safe from overflow, since the largest code point 471 // representable in UTF-16 is U+10FFFF. 472 int nextCodePoint = codePoint + 1; 473 // The index of the start of the last code point. 474 // Character.charCount(codePoint) is always 1 (in the BMP) or 2 475 int lastIndex = len - Character.charCount(codePoint); 476 return new StringBuilder(len) 477 .append(str, 0, lastIndex) // append everything but the last code point 478 .appendCodePoint(nextCodePoint) // instead of the last code point, use successor 479 .toString(); 480 } 481 482 /** 483 * Returns the source ranking for sources with a minimum number of clicks. 484 * 485 * @param minClicks The minimum number of clicks a source must have. 486 * @return The list of sources, ranked by total clicks. 487 */ 488 Map<String,Integer> getCorpusScores(int minClicks) { 489 SQLiteDatabase db = mOpenHelper.getReadableDatabase(); 490 final Cursor cursor = db.rawQuery( 491 SOURCE_RANKING_SQL, new String[] { String.valueOf(minClicks) }); 492 try { 493 Map<String,Integer> corpora = new HashMap<String,Integer>(cursor.getCount()); 494 while (cursor.moveToNext()) { 495 String name = cursor.getString(SourceStats.corpus.ordinal()); 496 int clicks = cursor.getInt(SourceStats.total_clicks.ordinal()); 497 corpora.put(name, clicks); 498 } 499 return corpora; 500 } finally { 501 cursor.close(); 502 } 503 } 504 505 private ContentValues makeShortcutRow(Suggestion suggestion) { 506 String intentAction = suggestion.getSuggestionIntentAction(); 507 String intentComponent = componentNameToString(suggestion.getSuggestionIntentComponent()); 508 String intentData = suggestion.getSuggestionIntentDataString(); 509 String intentQuery = suggestion.getSuggestionQuery(); 510 String intentExtraData = suggestion.getSuggestionIntentExtraData(); 511 512 Source source = suggestion.getSuggestionSource(); 513 String sourceName = source.getName(); 514 StringBuilder key = new StringBuilder(sourceName); 515 key.append("#"); 516 if (intentData != null) { 517 key.append(intentData); 518 } 519 key.append("#"); 520 if (intentAction != null) { 521 key.append(intentAction); 522 } 523 key.append("#"); 524 if (intentComponent != null) { 525 key.append(intentComponent); 526 } 527 key.append("#"); 528 if (intentQuery != null) { 529 key.append(intentQuery); 530 } 531 // A string of the form source#intentData#intentAction#intentQuery 532 // for use as a unique identifier of a suggestion. 533 String intentKey = key.toString(); 534 535 // Get URIs for all icons, to make sure that they are stable 536 String icon1Uri = getIconUriString(source, suggestion.getSuggestionIcon1()); 537 String icon2Uri = getIconUriString(source, suggestion.getSuggestionIcon2()); 538 539 String extrasJson = null; 540 SuggestionExtras extras = suggestion.getExtras(); 541 if (extras != null) { 542 // flatten any custom columns to JSON. We need to keep any custom columns so that 543 // shortcuts for custom suggestion views work properly. 544 try { 545 extrasJson = extras.toJsonString(); 546 } catch (JSONException e) { 547 Log.e(TAG, "Could not flatten extras to JSON from " + suggestion, e); 548 } 549 } 550 551 ContentValues cv = new ContentValues(); 552 cv.put(Shortcuts.intent_key.name(), intentKey); 553 cv.put(Shortcuts.source.name(), sourceName); 554 cv.put(Shortcuts.source_version_code.name(), source.getVersionCode()); 555 cv.put(Shortcuts.format.name(), suggestion.getSuggestionFormat()); 556 cv.put(Shortcuts.title.name(), suggestion.getSuggestionText1()); 557 cv.put(Shortcuts.description.name(), suggestion.getSuggestionText2()); 558 cv.put(Shortcuts.description_url.name(), suggestion.getSuggestionText2Url()); 559 cv.put(Shortcuts.icon1.name(), icon1Uri); 560 cv.put(Shortcuts.icon2.name(), icon2Uri); 561 cv.put(Shortcuts.intent_action.name(), intentAction); 562 cv.put(Shortcuts.intent_component.name(), intentComponent); 563 cv.put(Shortcuts.intent_data.name(), intentData); 564 cv.put(Shortcuts.intent_query.name(), intentQuery); 565 cv.put(Shortcuts.intent_extradata.name(), intentExtraData); 566 cv.put(Shortcuts.shortcut_id.name(), suggestion.getShortcutId()); 567 if (suggestion.isSpinnerWhileRefreshing()) { 568 cv.put(Shortcuts.spinner_while_refreshing.name(), "true"); 569 } 570 cv.put(Shortcuts.log_type.name(), suggestion.getSuggestionLogType()); 571 cv.put(Shortcuts.custom_columns.name(), extrasJson); 572 573 return cv; 574 } 575 576 private String componentNameToString(ComponentName component) { 577 return component == null ? null : component.flattenToShortString(); 578 } 579 580 private ComponentName stringToComponentName(String str) { 581 return str == null ? null : ComponentName.unflattenFromString(str); 582 } 583 584 private String getIconUriString(Source source, String drawableId) { 585 // Fast path for empty icons 586 if (TextUtils.isEmpty(drawableId) || "0".equals(drawableId)) { 587 return null; 588 } 589 // Fast path for icon URIs 590 if (drawableId.startsWith(ContentResolver.SCHEME_ANDROID_RESOURCE) 591 || drawableId.startsWith(ContentResolver.SCHEME_CONTENT) 592 || drawableId.startsWith(ContentResolver.SCHEME_FILE)) { 593 return drawableId; 594 } 595 Uri uri = source.getIconUri(drawableId); 596 return uri == null ? null : uri.toString(); 597 } 598 599 @VisibleForTesting 600 void reportClickAtTime(SuggestionCursor suggestion, 601 int position, long now) { 602 suggestion.moveTo(position); 603 if (DBG) { 604 Log.d(TAG, "logClicked(" + suggestion + ")"); 605 } 606 607 if (SearchManager.SUGGEST_NEVER_MAKE_SHORTCUT.equals(suggestion.getShortcutId())) { 608 if (DBG) Log.d(TAG, "clicked suggestion requested not to be shortcuted"); 609 return; 610 } 611 612 Corpus corpus = mCorpora.getCorpusForSource(suggestion.getSuggestionSource()); 613 if (corpus == null) { 614 Log.w(TAG, "no corpus for clicked suggestion"); 615 return; 616 } 617 618 // Once the user has clicked on a shortcut, don't bother refreshing 619 // (especially if this is a new shortcut) 620 mRefresher.markShortcutRefreshed(suggestion.getSuggestionSource(), 621 suggestion.getShortcutId()); 622 623 // Add or update suggestion info 624 // Since intent_key is the primary key, any existing 625 // suggestion with the same source+data+action will be replaced 626 final ContentValues shortcut = makeShortcutRow(suggestion); 627 String intentKey = shortcut.getAsString(Shortcuts.intent_key.name()); 628 629 // Log click for shortcut 630 final ContentValues click = new ContentValues(); 631 click.put(ClickLog.intent_key.name(), intentKey); 632 click.put(ClickLog.query.name(), suggestion.getUserQuery()); 633 click.put(ClickLog.hit_time.name(), now); 634 click.put(ClickLog.corpus.name(), corpus.getName()); 635 636 runTransactionAsync(new SQLiteTransaction() { 637 @Override 638 protected boolean performTransaction(SQLiteDatabase db) { 639 if (DBG) Log.d(TAG, "Adding shortcut: " + shortcut); 640 db.replaceOrThrow(Shortcuts.TABLE_NAME, null, shortcut); 641 db.insertOrThrow(ClickLog.TABLE_NAME, null, click); 642 return true; 643 } 644 }); 645 } 646 647// -------------------------- TABLES -------------------------- 648 649 /** 650 * shortcuts table 651 */ 652 enum Shortcuts { 653 intent_key, 654 source, 655 source_version_code, 656 format, 657 title, 658 description, 659 description_url, 660 icon1, 661 icon2, 662 intent_action, 663 intent_component, 664 intent_data, 665 intent_query, 666 intent_extradata, 667 shortcut_id, 668 spinner_while_refreshing, 669 log_type, 670 custom_columns; 671 672 static final String TABLE_NAME = "shortcuts"; 673 674 public final String fullName; 675 676 Shortcuts() { 677 fullName = TABLE_NAME + "." + name(); 678 } 679 } 680 681 /** 682 * clicklog table. Has one record for each click. 683 */ 684 enum ClickLog { 685 _id, 686 intent_key, 687 query, 688 hit_time, 689 corpus; 690 691 static final String[] COLUMNS = initColumns(); 692 693 static final String TABLE_NAME = "clicklog"; 694 695 private static String[] initColumns() { 696 ClickLog[] vals = ClickLog.values(); 697 String[] columns = new String[vals.length]; 698 for (int i = 0; i < vals.length; i++) { 699 columns[i] = vals[i].fullName; 700 } 701 return columns; 702 } 703 704 public final String fullName; 705 706 ClickLog() { 707 fullName = TABLE_NAME + "." + name(); 708 } 709 } 710 711 /** 712 * This is an aggregate table of {@link ClickLog} that stays up to date with the total 713 * clicks for each corpus. This makes computing the corpus ranking more 714 * more efficient, at the expense of some extra work when the clicks are reported. 715 */ 716 enum SourceStats { 717 corpus, 718 total_clicks; 719 720 static final String TABLE_NAME = "sourcetotals"; 721 722 static final String[] COLUMNS = initColumns(); 723 724 private static String[] initColumns() { 725 SourceStats[] vals = SourceStats.values(); 726 String[] columns = new String[vals.length]; 727 for (int i = 0; i < vals.length; i++) { 728 columns[i] = vals[i].fullName; 729 } 730 return columns; 731 } 732 733 public final String fullName; 734 735 SourceStats() { 736 fullName = TABLE_NAME + "." + name(); 737 } 738 } 739 740// -------------------------- END TABLES -------------------------- 741 742 // contains creation and update logic 743 private static class DbOpenHelper extends SQLiteOpenHelper { 744 private final Config mConfig; 745 private String mPath; 746 private static final String SHORTCUT_ID_INDEX 747 = Shortcuts.TABLE_NAME + "_" + Shortcuts.shortcut_id.name(); 748 private static final String CLICKLOG_QUERY_INDEX 749 = ClickLog.TABLE_NAME + "_" + ClickLog.query.name(); 750 private static final String CLICKLOG_HIT_TIME_INDEX 751 = ClickLog.TABLE_NAME + "_" + ClickLog.hit_time.name(); 752 private static final String CLICKLOG_INSERT_TRIGGER 753 = ClickLog.TABLE_NAME + "_insert"; 754 private static final String SHORTCUTS_DELETE_TRIGGER 755 = Shortcuts.TABLE_NAME + "_delete"; 756 private static final String SHORTCUTS_UPDATE_INTENT_KEY_TRIGGER 757 = Shortcuts.TABLE_NAME + "_update_intent_key"; 758 759 public DbOpenHelper(Context context, String name, int version, Config config) { 760 super(context, name, null, version); 761 mConfig = config; 762 } 763 764 @Override 765 public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { 766 // The shortcuts info is not all that important, so we just drop the tables 767 // and re-create empty ones. 768 Log.i(TAG, "Upgrading shortcuts DB from version " + 769 + oldVersion + " to " + newVersion + ". This deletes all shortcuts."); 770 dropTables(db); 771 onCreate(db); 772 } 773 774 private void dropTables(SQLiteDatabase db) { 775 db.execSQL("DROP TRIGGER IF EXISTS " + CLICKLOG_INSERT_TRIGGER); 776 db.execSQL("DROP TRIGGER IF EXISTS " + SHORTCUTS_DELETE_TRIGGER); 777 db.execSQL("DROP TRIGGER IF EXISTS " + SHORTCUTS_UPDATE_INTENT_KEY_TRIGGER); 778 db.execSQL("DROP INDEX IF EXISTS " + CLICKLOG_HIT_TIME_INDEX); 779 db.execSQL("DROP INDEX IF EXISTS " + CLICKLOG_QUERY_INDEX); 780 db.execSQL("DROP INDEX IF EXISTS " + SHORTCUT_ID_INDEX); 781 db.execSQL("DROP TABLE IF EXISTS " + ClickLog.TABLE_NAME); 782 db.execSQL("DROP TABLE IF EXISTS " + Shortcuts.TABLE_NAME); 783 db.execSQL("DROP TABLE IF EXISTS " + SourceStats.TABLE_NAME); 784 } 785 786 /** 787 * Deletes the database file. 788 */ 789 public void deleteDatabase() { 790 close(); 791 if (mPath == null) return; 792 try { 793 new File(mPath).delete(); 794 if (DBG) Log.d(TAG, "deleted " + mPath); 795 } catch (Exception e) { 796 Log.w(TAG, "couldn't delete " + mPath, e); 797 } 798 } 799 800 @Override 801 public void onOpen(SQLiteDatabase db) { 802 super.onOpen(db); 803 mPath = db.getPath(); 804 } 805 806 @Override 807 public void onCreate(SQLiteDatabase db) { 808 db.execSQL("CREATE TABLE " + Shortcuts.TABLE_NAME + " (" + 809 // COLLATE UNICODE is needed to make it possible to use nextString() 810 // to implement fast prefix filtering. 811 Shortcuts.intent_key.name() + " TEXT NOT NULL COLLATE UNICODE PRIMARY KEY, " + 812 Shortcuts.source.name() + " TEXT NOT NULL, " + 813 Shortcuts.source_version_code.name() + " INTEGER NOT NULL, " + 814 Shortcuts.format.name() + " TEXT, " + 815 Shortcuts.title.name() + " TEXT, " + 816 Shortcuts.description.name() + " TEXT, " + 817 Shortcuts.description_url.name() + " TEXT, " + 818 Shortcuts.icon1.name() + " TEXT, " + 819 Shortcuts.icon2.name() + " TEXT, " + 820 Shortcuts.intent_action.name() + " TEXT, " + 821 Shortcuts.intent_component.name() + " TEXT, " + 822 Shortcuts.intent_data.name() + " TEXT, " + 823 Shortcuts.intent_query.name() + " TEXT, " + 824 Shortcuts.intent_extradata.name() + " TEXT, " + 825 Shortcuts.shortcut_id.name() + " TEXT, " + 826 Shortcuts.spinner_while_refreshing.name() + " TEXT, " + 827 Shortcuts.log_type.name() + " TEXT, " + 828 Shortcuts.custom_columns.name() + " TEXT" + 829 ");"); 830 831 // index for fast lookup of shortcuts by shortcut_id 832 db.execSQL("CREATE INDEX " + SHORTCUT_ID_INDEX 833 + " ON " + Shortcuts.TABLE_NAME 834 + "(" + Shortcuts.shortcut_id.name() + ", " + Shortcuts.source.name() + ")"); 835 836 db.execSQL("CREATE TABLE " + ClickLog.TABLE_NAME + " ( " + 837 ClickLog._id.name() + " INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, " + 838 // type must match Shortcuts.intent_key 839 ClickLog.intent_key.name() + " TEXT NOT NULL COLLATE UNICODE REFERENCES " 840 + Shortcuts.TABLE_NAME + "(" + Shortcuts.intent_key + "), " + 841 ClickLog.query.name() + " TEXT, " + 842 ClickLog.hit_time.name() + " INTEGER," + 843 ClickLog.corpus.name() + " TEXT" + 844 ");"); 845 846 // index for fast lookup of clicks by query 847 db.execSQL("CREATE INDEX " + CLICKLOG_QUERY_INDEX 848 + " ON " + ClickLog.TABLE_NAME + "(" + ClickLog.query.name() + ")"); 849 850 // index for finding old clicks quickly 851 db.execSQL("CREATE INDEX " + CLICKLOG_HIT_TIME_INDEX 852 + " ON " + ClickLog.TABLE_NAME + "(" + ClickLog.hit_time.name() + ")"); 853 854 // trigger for purging old clicks, i.e. those such that 855 // hit_time < now - MAX_MAX_STAT_AGE_MILLIS, where now is the 856 // hit_time of the inserted record, and for updating the SourceStats table 857 db.execSQL("CREATE TRIGGER " + CLICKLOG_INSERT_TRIGGER + " AFTER INSERT ON " 858 + ClickLog.TABLE_NAME 859 + " BEGIN" 860 + " DELETE FROM " + ClickLog.TABLE_NAME + " WHERE " 861 + ClickLog.hit_time.name() + " <" 862 + " NEW." + ClickLog.hit_time.name() 863 + " - " + mConfig.getMaxStatAgeMillis() + ";" 864 + " DELETE FROM " + SourceStats.TABLE_NAME + ";" 865 + " INSERT INTO " + SourceStats.TABLE_NAME + " " 866 + "SELECT " + ClickLog.corpus + "," + "COUNT(*) FROM " 867 + ClickLog.TABLE_NAME + " GROUP BY " + ClickLog.corpus.name() + ";" 868 + " END"); 869 870 // trigger for deleting clicks about a shortcut once that shortcut has been 871 // deleted 872 db.execSQL("CREATE TRIGGER " + SHORTCUTS_DELETE_TRIGGER + " AFTER DELETE ON " 873 + Shortcuts.TABLE_NAME 874 + " BEGIN" 875 + " DELETE FROM " + ClickLog.TABLE_NAME + " WHERE " 876 + ClickLog.intent_key.name() 877 + " = OLD." + Shortcuts.intent_key.name() + ";" 878 + " END"); 879 880 // trigger for updating click log entries when a shortcut changes its intent_key 881 db.execSQL("CREATE TRIGGER " + SHORTCUTS_UPDATE_INTENT_KEY_TRIGGER 882 + " AFTER UPDATE ON " + Shortcuts.TABLE_NAME 883 + " WHEN NEW." + Shortcuts.intent_key.name() 884 + " != OLD." + Shortcuts.intent_key.name() 885 + " BEGIN" 886 + " UPDATE " + ClickLog.TABLE_NAME + " SET " 887 + ClickLog.intent_key.name() + " = NEW." + Shortcuts.intent_key.name() 888 + " WHERE " 889 + ClickLog.intent_key.name() + " = OLD." + Shortcuts.intent_key.name() 890 + ";" 891 + " END"); 892 893 db.execSQL("CREATE TABLE " + SourceStats.TABLE_NAME + " ( " + 894 SourceStats.corpus.name() + " TEXT NOT NULL COLLATE UNICODE PRIMARY KEY, " + 895 SourceStats.total_clicks + " INTEGER);" 896 ); 897 } 898 } 899} 900