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