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