/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- * ***** BEGIN LICENSE BLOCK ***** * Version: MPL 1.1/GPL 2.0/LGPL 2.1 * * The contents of this file are subject to the Mozilla Public License Version * 1.1 (the "License"); you may not use this file except in compliance with * the License. You may obtain a copy of the License at * http://www.mozilla.org/MPL/ * * Software distributed under the License is distributed on an "AS IS" basis, * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License * for the specific language governing rights and limitations under the * License. * * The Original Code is Mozilla Android code. * * The Initial Developer of the Original Code is Mozilla Foundation. * Portions created by the Initial Developer are Copyright (C) 2011 * the Initial Developer. All Rights Reserved. * * Contributor(s): * Lucas Rocha * Jason Voll * Richard Newman * * Alternatively, the contents of this file may be used under the terms of * either the GNU General Public License Version 2 or later (the "GPL"), or * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"), * in which case the provisions of the GPL or the LGPL are applicable instead * of those above. If you wish to allow use of your version of this file only * under the terms of either the GPL or the LGPL, and not to allow others to * use your version of this file under the terms of the MPL, indicate your * decision by deleting the provisions above and replace them with the notice * and other provisions required by the GPL or the LGPL. If you do not delete * the provisions above, a recipient may use your version of this file under * the terms of any one of the MPL, the GPL or the LGPL. * * ***** END LICENSE BLOCK ***** */ #filter substitution package @ANDROID_PACKAGE_NAME@.db; import java.io.File; import java.io.IOException; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Random; import org.mozilla.gecko.GeckoAppShell; import org.mozilla.gecko.GeckoDirProvider; import org.mozilla.gecko.R; import org.mozilla.gecko.db.BrowserContract.Bookmarks; import org.mozilla.gecko.db.BrowserContract.CommonColumns; import org.mozilla.gecko.db.BrowserContract.History; import org.mozilla.gecko.db.BrowserContract.Images; import org.mozilla.gecko.db.BrowserContract.Schema; import org.mozilla.gecko.db.BrowserContract.SyncColumns; import org.mozilla.gecko.db.BrowserContract.URLColumns; import org.mozilla.gecko.db.BrowserContract; import org.mozilla.gecko.db.DBUtils; import org.mozilla.gecko.sync.Utils; import android.content.ContentProvider; import android.content.ContentUris; import android.content.ContentValues; import android.content.Context; import android.content.UriMatcher; import android.database.Cursor; import android.database.DatabaseUtils; import android.database.MatrixCursor; import android.database.sqlite.SQLiteDatabase; import android.database.sqlite.SQLiteOpenHelper; import android.database.sqlite.SQLiteQueryBuilder; import android.net.Uri; import android.os.Build; import android.text.TextUtils; import android.util.Log; public class BrowserProvider extends ContentProvider { private static final String LOGTAG = "GeckoBrowserProvider"; private Context mContext; static final String DATABASE_NAME = "browser.db"; static final int DATABASE_VERSION = 2; // Maximum age of deleted records to be cleaned up (20 days in ms) static final long MAX_AGE_OF_DELETED_RECORDS = 86400000 * 20; // Number of records marked as deleted to be removed static final long DELETED_RECORDS_PURGE_LIMIT = 5; static final String TABLE_BOOKMARKS = "bookmarks"; static final String TABLE_HISTORY = "history"; static final String TABLE_IMAGES = "images"; static final String TABLE_BOOKMARKS_TMP = TABLE_BOOKMARKS + "_tmp"; static final String VIEW_BOOKMARKS_WITH_IMAGES = "bookmarks_with_images"; static final String VIEW_HISTORY_WITH_IMAGES = "history_with_images"; // Bookmark matches static final int BOOKMARKS = 100; static final int BOOKMARKS_ID = 101; static final int BOOKMARKS_FOLDER_ID = 102; static final int BOOKMARKS_PARENT = 103; // History matches static final int HISTORY = 200; static final int HISTORY_ID = 201; // Image matches static final int IMAGES = 300; static final int IMAGES_ID = 301; // Schema matches static final int SCHEMA = 400; static final String DEFAULT_BOOKMARKS_SORT_ORDER = Bookmarks.IS_FOLDER + " DESC, " + Bookmarks.POSITION + " ASC, " + Bookmarks._ID + " ASC"; static final String DEFAULT_HISTORY_SORT_ORDER = History.DATE_LAST_VISITED + " DESC"; static final String TABLE_BOOKMARKS_JOIN_IMAGES = TABLE_BOOKMARKS + " LEFT OUTER JOIN " + "(SELECT " + Images.URL + ", " + Images.FAVICON + ", " + Images.THUMBNAIL + " FROM " + TABLE_IMAGES + ", " + TABLE_BOOKMARKS + " WHERE " + qualifyColumn(TABLE_BOOKMARKS, Bookmarks.URL) + " = " + qualifyColumn(TABLE_IMAGES, Images.URL) + ") AS bookmark_images ON " + qualifyColumn(TABLE_BOOKMARKS, Bookmarks.URL) + " = " + qualifyColumn("bookmark_images", Images.URL); static final String TABLE_HISTORY_JOIN_IMAGES = TABLE_HISTORY + " LEFT OUTER JOIN " + "(SELECT " + Images.URL + ", " + Images.FAVICON + ", " + Images.THUMBNAIL + " FROM " + TABLE_IMAGES + ", " + TABLE_HISTORY + " WHERE " + qualifyColumn(TABLE_HISTORY, History.URL) + " = " + qualifyColumn(TABLE_IMAGES, Images.URL) + ") AS history_images ON " + qualifyColumn(TABLE_HISTORY, History.URL) + " = " + qualifyColumn("history_images", Images.URL); static final UriMatcher URI_MATCHER = new UriMatcher(UriMatcher.NO_MATCH); static final HashMap BOOKMARKS_PROJECTION_MAP = new HashMap(); static final HashMap HISTORY_PROJECTION_MAP = new HashMap(); static final HashMap IMAGES_PROJECTION_MAP = new HashMap(); static final HashMap SCHEMA_PROJECTION_MAP = new HashMap(); static { HashMap map; // Bookmarks URI_MATCHER.addURI(BrowserContract.AUTHORITY, "bookmarks", BOOKMARKS); URI_MATCHER.addURI(BrowserContract.AUTHORITY, "bookmarks/#", BOOKMARKS_ID); URI_MATCHER.addURI(BrowserContract.AUTHORITY, "bookmarks/parents", BOOKMARKS_PARENT); URI_MATCHER.addURI(BrowserContract.AUTHORITY, "bookmarks/folder/#", BOOKMARKS_FOLDER_ID); map = BOOKMARKS_PROJECTION_MAP; map.put(Bookmarks._ID, Bookmarks._ID); map.put(Bookmarks.TITLE, Bookmarks.TITLE); map.put(Bookmarks.URL, Bookmarks.URL); map.put(Bookmarks.FAVICON, Bookmarks.FAVICON); map.put(Bookmarks.THUMBNAIL, Bookmarks.THUMBNAIL); map.put(Bookmarks.IS_FOLDER, Bookmarks.IS_FOLDER); map.put(Bookmarks.PARENT, Bookmarks.PARENT); map.put(Bookmarks.POSITION, Bookmarks.POSITION); map.put(Bookmarks.TAGS, Bookmarks.TAGS); map.put(Bookmarks.DESCRIPTION, Bookmarks.DESCRIPTION); map.put(Bookmarks.KEYWORD, Bookmarks.KEYWORD); map.put(Bookmarks.DATE_CREATED, Bookmarks.DATE_CREATED); map.put(Bookmarks.DATE_MODIFIED, Bookmarks.DATE_MODIFIED); map.put(Bookmarks.GUID, Bookmarks.GUID); map.put(Bookmarks.IS_DELETED, Bookmarks.IS_DELETED); // History URI_MATCHER.addURI(BrowserContract.AUTHORITY, "history", HISTORY); URI_MATCHER.addURI(BrowserContract.AUTHORITY, "history/#", HISTORY_ID); map = HISTORY_PROJECTION_MAP; map.put(History._ID, History._ID); map.put(History.TITLE, History.TITLE); map.put(History.URL, History.URL); map.put(History.FAVICON, History.FAVICON); map.put(History.THUMBNAIL, History.THUMBNAIL); map.put(History.VISITS, History.VISITS); map.put(History.DATE_LAST_VISITED, History.DATE_LAST_VISITED); map.put(History.DATE_CREATED, History.DATE_CREATED); map.put(History.DATE_MODIFIED, History.DATE_MODIFIED); map.put(History.GUID, History.GUID); map.put(History.IS_DELETED, History.IS_DELETED); // Images URI_MATCHER.addURI(BrowserContract.AUTHORITY, "images", IMAGES); URI_MATCHER.addURI(BrowserContract.AUTHORITY, "images/#", IMAGES_ID); map = IMAGES_PROJECTION_MAP; map.put(Images._ID, Images._ID); map.put(Images.URL, Images.URL); map.put(Images.FAVICON, Images.FAVICON); map.put(Images.FAVICON_URL, Images.FAVICON_URL); map.put(Images.THUMBNAIL, Images.THUMBNAIL); map.put(Images.DATE_CREATED, Images.DATE_CREATED); map.put(Images.DATE_MODIFIED, Images.DATE_MODIFIED); map.put(Images.GUID, Images.GUID); map.put(Images.IS_DELETED, Images.IS_DELETED); // Schema URI_MATCHER.addURI(BrowserContract.AUTHORITY, "schema", SCHEMA); map = SCHEMA_PROJECTION_MAP; map.put(Schema.VERSION, Schema.VERSION); } private HashMap mDatabasePerProfile; static final String qualifyColumn(String table, String column) { return table + "." + column; } private static boolean hasImagesInProjection(String[] projection) { if (projection == null) return true; for (int i = 0; i < projection.length; ++i) { if (projection[i].equals(Images.FAVICON) || projection[i].equals(Images.THUMBNAIL)) return true; } return false; } // Calculate these once, at initialization. isLoggable is too expensive to // have in-line in each log call. private static boolean logDebug = Log.isLoggable(LOGTAG, Log.DEBUG); private static boolean logVerbose = Log.isLoggable(LOGTAG, Log.VERBOSE); protected static void trace(String message) { if (logVerbose) { Log.v(LOGTAG, message); } } protected static void debug(String message) { if (logDebug) { Log.d(LOGTAG, message); } } final class DatabaseHelper extends SQLiteOpenHelper { public DatabaseHelper(Context context, String databasePath) { super(context, databasePath, null, DATABASE_VERSION); } private void createBookmarksTable(SQLiteDatabase db) { debug("Creating " + TABLE_BOOKMARKS + " table"); // Android versions older than Froyo ship with an sqlite // that doesn't support foreign keys. String foreignKeyOnParent = null; if (Build.VERSION.SDK_INT >= 8) { foreignKeyOnParent = ", FOREIGN KEY (" + Bookmarks.PARENT + ") REFERENCES " + TABLE_BOOKMARKS + "(" + Bookmarks._ID + ")"; } db.execSQL("CREATE TABLE " + TABLE_BOOKMARKS + "(" + Bookmarks._ID + " INTEGER PRIMARY KEY AUTOINCREMENT," + Bookmarks.TITLE + " TEXT," + Bookmarks.URL + " TEXT," + Bookmarks.IS_FOLDER + " INTEGER NOT NULL DEFAULT 0," + Bookmarks.PARENT + " INTEGER," + Bookmarks.POSITION + " INTEGER NOT NULL," + Bookmarks.KEYWORD + " TEXT," + Bookmarks.DESCRIPTION + " TEXT," + Bookmarks.TAGS + " TEXT," + Bookmarks.DATE_CREATED + " INTEGER," + Bookmarks.DATE_MODIFIED + " INTEGER," + Bookmarks.GUID + " TEXT," + Bookmarks.IS_DELETED + " INTEGER NOT NULL DEFAULT 0" + (foreignKeyOnParent != null ? foreignKeyOnParent : "") + ");"); db.execSQL("CREATE INDEX bookmarks_url_index ON " + TABLE_BOOKMARKS + "(" + Bookmarks.URL + ")"); db.execSQL("CREATE UNIQUE INDEX bookmarks_guid_index ON " + TABLE_BOOKMARKS + "(" + Bookmarks.GUID + ")"); db.execSQL("CREATE INDEX bookmarks_modified_index ON " + TABLE_BOOKMARKS + "(" + Bookmarks.DATE_MODIFIED + ")"); } private void createHistoryTable(SQLiteDatabase db) { debug("Creating " + TABLE_HISTORY + " table"); db.execSQL("CREATE TABLE " + TABLE_HISTORY + "(" + History._ID + " INTEGER PRIMARY KEY AUTOINCREMENT," + History.TITLE + " TEXT," + History.URL + " TEXT NOT NULL," + History.VISITS + " INTEGER NOT NULL DEFAULT 0," + History.DATE_LAST_VISITED + " INTEGER," + History.DATE_CREATED + " INTEGER," + History.DATE_MODIFIED + " INTEGER," + History.GUID + " TEXT," + History.IS_DELETED + " INTEGER NOT NULL DEFAULT 0" + ");"); db.execSQL("CREATE INDEX history_url_index ON " + TABLE_HISTORY + "(" + History.URL + ")"); db.execSQL("CREATE UNIQUE INDEX history_guid_index ON " + TABLE_HISTORY + "(" + History.GUID + ")"); db.execSQL("CREATE INDEX history_modified_index ON " + TABLE_HISTORY + "(" + History.DATE_MODIFIED + ")"); db.execSQL("CREATE INDEX history_visited_index ON " + TABLE_HISTORY + "(" + History.DATE_LAST_VISITED + ")"); } private void createImagesTable(SQLiteDatabase db) { debug("Creating " + TABLE_IMAGES + " table"); db.execSQL("CREATE TABLE " + TABLE_IMAGES + " (" + Images._ID + " INTEGER PRIMARY KEY AUTOINCREMENT," + Images.URL + " TEXT UNIQUE NOT NULL," + Images.FAVICON + " BLOB," + Images.FAVICON_URL + " TEXT," + Images.THUMBNAIL + " BLOB," + Images.DATE_CREATED + " INTEGER," + Images.DATE_MODIFIED + " INTEGER," + Images.GUID + " TEXT," + Images.IS_DELETED + " INTEGER NOT NULL DEFAULT 0" + ");"); db.execSQL("CREATE INDEX images_url_index ON " + TABLE_IMAGES + "(" + Images.URL + ")"); db.execSQL("CREATE UNIQUE INDEX images_guid_index ON " + TABLE_IMAGES + "(" + Images.GUID + ")"); db.execSQL("CREATE INDEX images_modified_index ON " + TABLE_IMAGES + "(" + Images.DATE_MODIFIED + ")"); } private void createBookmarksWithImagesView(SQLiteDatabase db) { db.execSQL("CREATE VIEW IF NOT EXISTS " + VIEW_BOOKMARKS_WITH_IMAGES + " AS " + "SELECT " + qualifyColumn(TABLE_BOOKMARKS, "*") + ", " + Images.FAVICON + ", " + Images.THUMBNAIL + " FROM " + TABLE_BOOKMARKS_JOIN_IMAGES); } private void createHistoryWithImagesView(SQLiteDatabase db) { db.execSQL("CREATE VIEW IF NOT EXISTS " + VIEW_HISTORY_WITH_IMAGES + " AS " + "SELECT " + qualifyColumn(TABLE_HISTORY, "*") + ", " + Images.FAVICON + ", " + Images.THUMBNAIL + " FROM " + TABLE_HISTORY_JOIN_IMAGES); } @Override public void onCreate(SQLiteDatabase db) { debug("Creating browser.db: " + db.getPath()); createBookmarksTable(db); createHistoryTable(db); createImagesTable(db); createBookmarksWithImagesView(db); createHistoryWithImagesView(db); createOrUpdateSpecialFolder(db, Bookmarks.PLACES_FOLDER_GUID, R.string.bookmarks_folder_places, 0); createOrUpdateAllSpecialFolders(db); // FIXME: Create default bookmarks here (bug 728224) } private void createOrUpdateAllSpecialFolders(SQLiteDatabase db) { createOrUpdateSpecialFolder(db, Bookmarks.MOBILE_FOLDER_GUID, R.string.bookmarks_folder_mobile, 0); createOrUpdateSpecialFolder(db, Bookmarks.TOOLBAR_FOLDER_GUID, R.string.bookmarks_folder_toolbar, 1); createOrUpdateSpecialFolder(db, Bookmarks.MENU_FOLDER_GUID, R.string.bookmarks_folder_menu, 2); createOrUpdateSpecialFolder(db, Bookmarks.TAGS_FOLDER_GUID, R.string.bookmarks_folder_tags, 3); createOrUpdateSpecialFolder(db, Bookmarks.UNFILED_FOLDER_GUID, R.string.bookmarks_folder_unfiled, 4); } private void createOrUpdateSpecialFolder(SQLiteDatabase db, String guid, int titleId, int position) { ContentValues values = new ContentValues(); values.put(Bookmarks.GUID, guid); values.put(Bookmarks.IS_FOLDER, 1); values.put(Bookmarks.POSITION, position); if (guid.equals(Bookmarks.PLACES_FOLDER_GUID)) values.put(Bookmarks._ID, Bookmarks.FIXED_ROOT_ID); // Set the parent to 0, which sync assumes is the root values.put(Bookmarks.PARENT, Bookmarks.FIXED_ROOT_ID); String title = mContext.getResources().getString(titleId); values.put(Bookmarks.TITLE, title); long now = System.currentTimeMillis(); values.put(Bookmarks.DATE_CREATED, now); values.put(Bookmarks.DATE_MODIFIED, now); int updated = db.update(TABLE_BOOKMARKS, values, Bookmarks.GUID + " = ?", new String[] { guid }); if (updated == 0) { db.insert(TABLE_BOOKMARKS, Bookmarks.GUID, values); debug("Inserted special folder: " + guid); } else { debug("Updated special folder: " + guid); } } private boolean isSpecialFolder(ContentValues values) { String guid = values.getAsString(Bookmarks.GUID); if (guid == null) return false; return guid.equals(Bookmarks.MOBILE_FOLDER_GUID) || guid.equals(Bookmarks.MENU_FOLDER_GUID) || guid.equals(Bookmarks.TOOLBAR_FOLDER_GUID) || guid.equals(Bookmarks.UNFILED_FOLDER_GUID) || guid.equals(Bookmarks.TAGS_FOLDER_GUID); } private void migrateBookmarkFolder(SQLiteDatabase db, int folderId) { Cursor c = null; debug("Migrating bookmark folder with id = " + folderId); String selection = Bookmarks.PARENT + " = " + folderId; String[] selectionArgs = null; boolean isRootFolder = (folderId == Bookmarks.FIXED_ROOT_ID); // If we're loading the root folder, we have to account for // any previously created special folder that was created without // setting a parent id (e.g. mobile folder) and making sure we're // not adding any infinite recursion as root's parent is root itself. if (isRootFolder) { selection = Bookmarks.GUID + " != ?" + " AND (" + selection + " OR " + Bookmarks.PARENT + " = NULL)"; selectionArgs = new String[] { Bookmarks.PLACES_FOLDER_GUID }; } List subFolders = new ArrayList(); List invalidSpecialEntries = new ArrayList(); try { c = db.query(TABLE_BOOKMARKS_TMP, null, selection, selectionArgs, null, null, null); // The key point here is that bookmarks should be added in // parent order to avoid any problems with the foreign key // in Bookmarks.PARENT. while (c.moveToNext()) { ContentValues values = new ContentValues(); // We're using a null projection in the query which // means we're getting all columns from the table. // It's safe to simply transform the row into the // values to be inserted on the new table. DatabaseUtils.cursorRowToContentValues(c, values); boolean isSpecialFolder = isSpecialFolder(values); // The mobile folder used to be created with PARENT = NULL. // We want fix that here. if (values.getAsLong(Bookmarks.PARENT) == null && isSpecialFolder) values.put(Bookmarks.PARENT, Bookmarks.FIXED_ROOT_ID); if (isRootFolder && !isSpecialFolder) { invalidSpecialEntries.add(values); continue; } debug("Migrating bookmark: " + values.getAsString(Bookmarks.TITLE)); db.insert(TABLE_BOOKMARKS, Bookmarks.URL, values); Integer isFolder = values.getAsInteger(Bookmarks.IS_FOLDER); if (isFolder != null && isFolder == 1) subFolders.add(values.getAsInteger(Bookmarks._ID)); } } finally { if (c != null) c.close(); } // At this point is safe to assume that the mobile folder is // in the new table given that we've always created it on // database creation time. final int nInvalidSpecialEntries = invalidSpecialEntries.size(); if (nInvalidSpecialEntries > 0) { Long mobileFolderId = guidToID(db, Bookmarks.MOBILE_FOLDER_GUID); debug("Found " + nInvalidSpecialEntries + " invalid special folder entries"); for (int i = 0; i < nInvalidSpecialEntries; i++) { ContentValues values = invalidSpecialEntries.get(i); values.put(Bookmarks.PARENT, mobileFolderId); db.insert(TABLE_BOOKMARKS, Bookmarks.URL, values); } } final int nSubFolders = subFolders.size(); for (int i = 0; i < nSubFolders; i++) { int subFolderId = subFolders.get(i); migrateBookmarkFolder(db, subFolderId); } } private void upgradeDatabaseFrom1to2(SQLiteDatabase db) { debug("Renaming bookmarks table to " + TABLE_BOOKMARKS_TMP); db.execSQL("ALTER TABLE " + TABLE_BOOKMARKS + " RENAME TO " + TABLE_BOOKMARKS_TMP); debug("Dropping views and indexes related to " + TABLE_BOOKMARKS); db.execSQL("DROP VIEW IF EXISTS " + VIEW_BOOKMARKS_WITH_IMAGES); db.execSQL("DROP INDEX IF EXISTS bookmarks_url_index"); db.execSQL("DROP INDEX IF EXISTS bookmarks_guid_index"); db.execSQL("DROP INDEX IF EXISTS bookmarks_modified_index"); createBookmarksTable(db); createBookmarksWithImagesView(db); createOrUpdateSpecialFolder(db, Bookmarks.PLACES_FOLDER_GUID, R.string.bookmarks_folder_places, 0); migrateBookmarkFolder(db, Bookmarks.FIXED_ROOT_ID); // Ensure all special folders exist and have the // right folder hierarchy. createOrUpdateAllSpecialFolders(db); debug("Dropping bookmarks temporary table"); db.execSQL("DROP TABLE IF EXISTS " + TABLE_BOOKMARKS_TMP); } @Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { debug("Upgrading browser.db: " + db.getPath() + " from " + oldVersion + " to " + newVersion); db.beginTransaction(); // We have to do incremental upgrades until we reach the current // database schema version. for (int v = oldVersion + 1; v <= newVersion; v++) { switch(v) { case 2: upgradeDatabaseFrom1to2(db); break; } } db.endTransaction(); } @Override public void onOpen(SQLiteDatabase db) { debug("Opening browser.db: " + db.getPath()); // From Honeycomb on, it's possible to run several db // commands in parallel using multiple connections. if (Build.VERSION.SDK_INT >= 11) { db.enableWriteAheadLogging(); } else { // Pre-Honeycomb, we can do some lesser optimizations. Cursor cursor = null; try { cursor = db.rawQuery("PRAGMA synchronous=NORMAL", null); } finally { if (cursor != null) cursor.close(); } cursor = null; try { cursor = db.rawQuery("PRAGMA journal_mode=PERSIST", null); } finally { if (cursor != null) cursor.close(); } } } } private Long guidToID(SQLiteDatabase db, String guid) { Cursor c = null; try { c = db.query(TABLE_BOOKMARKS, new String[] { Bookmarks._ID }, Bookmarks.GUID + " = ?", new String[] { guid }, null, null, null); if (c == null || !c.moveToFirst()) return null; return c.getLong(c.getColumnIndex(Bookmarks._ID)); } finally { if (c != null) c.close(); } } private DatabaseHelper getDatabaseHelperForProfile(String profile) { // Each profile has a separate browser.db database. The target // profile is provided using a URI query argument in each request // to our content provider. // Always fallback to default profile if none has been provided. if (TextUtils.isEmpty(profile)) { profile = BrowserContract.DEFAULT_PROFILE; } DatabaseHelper dbHelper; synchronized (this) { dbHelper = mDatabasePerProfile.get(profile); if (dbHelper != null) { return dbHelper; } dbHelper = new DatabaseHelper(getContext(), getDatabasePath(profile)); mDatabasePerProfile.put(profile, dbHelper); } debug("Created database helper for profile: " + profile); return dbHelper; } private String getDatabasePath(String profile) { trace("Getting database path for profile: " + profile); // On Android releases older than 2.3, it's not possible to use // SQLiteOpenHelper with a full path. Fallback to using separate // db files per profile in the app directory. if (Build.VERSION.SDK_INT <= 8) { return "browser-" + profile + ".db"; } File profileDir = null; try { profileDir = GeckoDirProvider.getProfileDir(mContext, profile); } catch (IOException ex) { Log.e(LOGTAG, "Error getting profile dir", ex); } if (profileDir == null) { debug("Couldn't find directory for profile: " + profile); return null; } String databasePath = new File(profileDir, DATABASE_NAME).getAbsolutePath(); debug("Successfully created database path for profile: " + databasePath); return databasePath; } private SQLiteDatabase getReadableDatabase(Uri uri) { trace("Getting readable database for URI: " + uri); String profile = null; if (uri != null) profile = uri.getQueryParameter(BrowserContract.PARAM_PROFILE); return getDatabaseHelperForProfile(profile).getReadableDatabase(); } private SQLiteDatabase getWritableDatabase(Uri uri) { trace("Getting writable database for URI: " + uri); String profile = null; if (uri != null) profile = uri.getQueryParameter(BrowserContract.PARAM_PROFILE); return getDatabaseHelperForProfile(profile).getWritableDatabase(); } private void cleanupSomeDeletedRecords(Uri fromUri, Uri targetUri, String tableName) { // we cleanup records marked as deleted that are older than a // predefined max age. It's important not be too greedy here and // remove only a few old deleted records at a time. String profile = fromUri.getQueryParameter(BrowserContract.PARAM_PROFILE); // The PARAM_SHOW_DELETED argument is necessary to return the records // that were marked as deleted. We use PARAM_IS_SYNC here to ensure // that we'll be actually deleting records instead of flagging them. Uri uriWithArgs = targetUri.buildUpon() .appendQueryParameter(BrowserContract.PARAM_PROFILE, profile) .appendQueryParameter(BrowserContract.PARAM_LIMIT, String.valueOf(DELETED_RECORDS_PURGE_LIMIT)) .appendQueryParameter(BrowserContract.PARAM_SHOW_DELETED, "1") .appendQueryParameter(BrowserContract.PARAM_IS_SYNC, "1") .build(); Cursor cursor = null; try { long now = System.currentTimeMillis(); String selection = SyncColumns.IS_DELETED + " = 1 AND " + SyncColumns.DATE_MODIFIED + " <= " + (now - MAX_AGE_OF_DELETED_RECORDS); cursor = query(uriWithArgs, new String[] { CommonColumns._ID }, selection, null, null); while (cursor.moveToNext()) { Uri uriWithId = ContentUris.withAppendedId(uriWithArgs, cursor.getLong(0)); delete(uriWithId, null, null); debug("Removed old deleted item with URI: " + uriWithId); } } finally { if (cursor != null) cursor.close(); } } private boolean isCallerSync(Uri uri) { String isSync = uri.getQueryParameter(BrowserContract.PARAM_IS_SYNC); return !TextUtils.isEmpty(isSync); } private boolean shouldShowDeleted(Uri uri) { String showDeleted = uri.getQueryParameter(BrowserContract.PARAM_SHOW_DELETED); return !TextUtils.isEmpty(showDeleted); } @Override public boolean onCreate() { debug("Creating BrowserProvider"); GeckoAppShell.getHandler().post(new Runnable() { public void run() { // Kick this off early. It is synchronized so that other callers will wait try { GeckoDirProvider.getProfileDir(getContext()); } catch (Exception ex) { Log.e(LOGTAG, "Error getting profile dir", ex); } } }); synchronized (this) { mContext = getContext(); mDatabasePerProfile = new HashMap(); } return true; } @Override public String getType(Uri uri) { final int match = URI_MATCHER.match(uri); trace("Getting URI type: " + uri); switch (match) { case BOOKMARKS: trace("URI is BOOKMARKS: " + uri); return Bookmarks.CONTENT_TYPE; case BOOKMARKS_ID: trace("URI is BOOKMARKS_ID: " + uri); return Bookmarks.CONTENT_ITEM_TYPE; case HISTORY: trace("URI is HISTORY: " + uri); return History.CONTENT_TYPE; case HISTORY_ID: trace("URI is HISTORY_ID: " + uri); return History.CONTENT_ITEM_TYPE; } debug("URI has unrecognized type: " + uri); return null; } @Override public int delete(Uri uri, String selection, String[] selectionArgs) { trace("Calling delete on URI: " + uri); final SQLiteDatabase db = getWritableDatabase(uri); int deleted = 0; if (Build.VERSION.SDK_INT >= 11) { trace("Beginning delete transaction: " + uri); db.beginTransaction(); try { deleted = deleteInTransaction(uri, selection, selectionArgs); db.setTransactionSuccessful(); trace("Successful delete transaction: " + uri); } finally { db.endTransaction(); } } else { deleted = deleteInTransaction(uri, selection, selectionArgs); } if (deleted > 0) getContext().getContentResolver().notifyChange(uri, null); return deleted; } @SuppressWarnings("fallthrough") public int deleteInTransaction(Uri uri, String selection, String[] selectionArgs) { trace("Calling delete in transaction on URI: " + uri); final int match = URI_MATCHER.match(uri); int deleted = 0; switch (match) { case BOOKMARKS_ID: trace("Delete on BOOKMARKS_ID: " + uri); selection = DBUtils.concatenateWhere(selection, TABLE_BOOKMARKS + "._id = ?"); selectionArgs = DBUtils.appendSelectionArgs(selectionArgs, new String[] { Long.toString(ContentUris.parseId(uri)) }); // fall through case BOOKMARKS: { trace("Deleting bookmarks: " + uri); deleted = deleteBookmarks(uri, selection, selectionArgs); deleteUnusedImages(uri); break; } case HISTORY_ID: trace("Delete on HISTORY_ID: " + uri); selection = DBUtils.concatenateWhere(selection, TABLE_HISTORY + "._id = ?"); selectionArgs = DBUtils.appendSelectionArgs(selectionArgs, new String[] { Long.toString(ContentUris.parseId(uri)) }); // fall through case HISTORY: { trace("Deleting history: " + uri); deleted = deleteHistory(uri, selection, selectionArgs); deleteUnusedImages(uri); break; } case IMAGES_ID: debug("Delete on IMAGES_ID: " + uri); selection = DBUtils.concatenateWhere(selection, TABLE_IMAGES + "._id = ?"); selectionArgs = DBUtils.appendSelectionArgs(selectionArgs, new String[] { Long.toString(ContentUris.parseId(uri)) }); // fall through case IMAGES: { trace("Deleting images: " + uri); deleted = deleteImages(uri, selection, selectionArgs); break; } default: throw new UnsupportedOperationException("Unknown delete URI " + uri); } debug("Deleted " + deleted + " rows for URI: " + uri); return deleted; } @Override public Uri insert(Uri uri, ContentValues values) { trace("Calling insert on URI: " + uri); final SQLiteDatabase db = getWritableDatabase(uri); Uri result = null; if (Build.VERSION.SDK_INT >= 11) { trace("Beginning insert transaction: " + uri); db.beginTransaction(); try { result = insertInTransaction(uri, values); db.setTransactionSuccessful(); trace("Successful insert transaction: " + uri); } finally { db.endTransaction(); } } else { result = insertInTransaction(uri, values); } if (result != null) getContext().getContentResolver().notifyChange(uri, null); return result; } public Uri insertInTransaction(Uri uri, ContentValues values) { trace("Calling insert in transaction on URI: " + uri); final SQLiteDatabase db = getWritableDatabase(uri); int match = URI_MATCHER.match(uri); long id = -1; switch (match) { case BOOKMARKS: { trace("Insert on BOOKMARKS: " + uri); // Generate values if not specified. Don't overwrite // if specified by caller. long now = System.currentTimeMillis(); if (!values.containsKey(Bookmarks.DATE_CREATED)) { values.put(Bookmarks.DATE_CREATED, now); } if (!values.containsKey(Bookmarks.DATE_MODIFIED)) { values.put(Bookmarks.DATE_MODIFIED, now); } if (!values.containsKey(Bookmarks.GUID)) { values.put(Bookmarks.GUID, Utils.generateGuid()); } if (!values.containsKey(Bookmarks.POSITION)) { debug("Inserting bookmark with no position for URI"); values.put(Bookmarks.POSITION, Long.toString(Long.MIN_VALUE)); } String url = values.getAsString(Bookmarks.URL); ContentValues imageValues = extractImageValues(values, url); Integer isFolder = values.getAsInteger(Bookmarks.IS_FOLDER); if ((isFolder == null || isFolder != 1) && imageValues != null && !TextUtils.isEmpty(url)) { debug("Inserting bookmark image for URL: " + url); updateOrInsertImage(uri, imageValues, Images.URL + " = ?", new String[] { url }); } debug("Inserting bookmark in database with URL: " + url); id = db.insertOrThrow(TABLE_BOOKMARKS, Bookmarks.TITLE, values); break; } case HISTORY: { trace("Insert on HISTORY: " + uri); long now = System.currentTimeMillis(); values.put(History.DATE_CREATED, now); values.put(History.DATE_MODIFIED, now); // Generate GUID for new history entry. Don't override specified GUIDs. if (!values.containsKey(History.GUID)) { values.put(History.GUID, Utils.generateGuid()); } String url = values.getAsString(History.URL); ContentValues imageValues = extractImageValues(values, values.getAsString(History.URL)); if (imageValues != null) { debug("Inserting history image for URL: " + url); updateOrInsertImage(uri, imageValues, Images.URL + " = ?", new String[] { url }); } debug("Inserting history in database with URL: " + url); id = db.insertOrThrow(TABLE_HISTORY, History.VISITS, values); break; } case IMAGES: { trace("Insert on IMAGES: " + uri); long now = System.currentTimeMillis(); values.put(History.DATE_CREATED, now); values.put(History.DATE_MODIFIED, now); // Generate GUID for new history entry values.put(History.GUID, Utils.generateGuid()); String url = values.getAsString(Images.URL); debug("Inserting image in database with URL: " + url); id = db.insertOrThrow(TABLE_IMAGES, Images.URL, values); break; } default: throw new UnsupportedOperationException("Unknown insert URI " + uri); } debug("Inserted ID in database: " + id); if (id >= 0) return ContentUris.withAppendedId(uri, id); return null; } @Override public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) { trace("Calling update on URI: " + uri); final SQLiteDatabase db = getWritableDatabase(uri); int updated = 0; if (Build.VERSION.SDK_INT >= 11) { trace("Beginning update transaction: " + uri); db.beginTransaction(); try { updated = updateInTransaction(uri, values, selection, selectionArgs); db.setTransactionSuccessful(); trace("Successful update transaction: " + uri); } finally { db.endTransaction(); } } else { updated = updateInTransaction(uri, values, selection, selectionArgs); } if (updated > 0) getContext().getContentResolver().notifyChange(uri, null); return updated; } @SuppressWarnings("fallthrough") public int updateInTransaction(Uri uri, ContentValues values, String selection, String[] selectionArgs) { trace("Calling update in transaction on URI: " + uri); int match = URI_MATCHER.match(uri); int updated = 0; switch (match) { case BOOKMARKS_PARENT: { debug("Update on BOOKMARKS_PARENT: " + uri); updated = updateBookmarkParents(uri, values, selection, selectionArgs); break; } case BOOKMARKS_ID: debug("Update on BOOKMARKS_ID: " + uri); selection = DBUtils.concatenateWhere(selection, TABLE_BOOKMARKS + "._id = ?"); selectionArgs = DBUtils.appendSelectionArgs(selectionArgs, new String[] { Long.toString(ContentUris.parseId(uri)) }); // fall through case BOOKMARKS: { debug("Updating bookmark: " + uri); updated = updateBookmarks(uri, values, selection, selectionArgs); break; } case HISTORY_ID: debug("Update on HISTORY_ID: " + uri); selection = DBUtils.concatenateWhere(selection, TABLE_HISTORY + "._id = ?"); selectionArgs = DBUtils.appendSelectionArgs(selectionArgs, new String[] { Long.toString(ContentUris.parseId(uri)) }); // fall through case HISTORY: { debug("Updating history: " + uri); updated = updateHistory(uri, values, selection, selectionArgs); break; } case IMAGES: { debug("Update on IMAGES: " + uri); String url = values.getAsString(Images.URL); if (TextUtils.isEmpty(url)) throw new IllegalArgumentException("Images.URL is required"); updated = updateExistingImage(uri, values, Images.URL + " = ?", new String[] { url }); break; } default: throw new UnsupportedOperationException("Unknown update URI " + uri); } debug("Updated " + updated + " rows for URI: " + uri); return updated; } @Override public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) { SQLiteDatabase db = getReadableDatabase(uri); final int match = URI_MATCHER.match(uri); SQLiteQueryBuilder qb = new SQLiteQueryBuilder(); String limit = uri.getQueryParameter(BrowserContract.PARAM_LIMIT); switch (match) { case BOOKMARKS_FOLDER_ID: case BOOKMARKS_ID: case BOOKMARKS: { debug("Query is on bookmarks: " + uri); if (match == BOOKMARKS_ID) { selection = DBUtils.concatenateWhere(selection, Bookmarks._ID + " = ?"); selectionArgs = DBUtils.appendSelectionArgs(selectionArgs, new String[] { Long.toString(ContentUris.parseId(uri)) }); } else if (match == BOOKMARKS_FOLDER_ID) { selection = DBUtils.concatenateWhere(selection, Bookmarks.PARENT + " = ?"); selectionArgs = DBUtils.appendSelectionArgs(selectionArgs, new String[] { Long.toString(ContentUris.parseId(uri)) }); } if (!shouldShowDeleted(uri)) selection = DBUtils.concatenateWhere(Bookmarks.IS_DELETED + " = 0", selection); if (TextUtils.isEmpty(sortOrder)) { sortOrder = DEFAULT_BOOKMARKS_SORT_ORDER; } else { debug("Using sort order " + sortOrder + "."); } qb.setProjectionMap(BOOKMARKS_PROJECTION_MAP); if (hasImagesInProjection(projection)) qb.setTables(VIEW_BOOKMARKS_WITH_IMAGES); else qb.setTables(TABLE_BOOKMARKS); break; } case HISTORY_ID: case HISTORY: { debug("Query is on history: " + uri); if (match == HISTORY_ID) { selection = DBUtils.concatenateWhere(selection, History._ID + " = ?"); selectionArgs = DBUtils.appendSelectionArgs(selectionArgs, new String[] { Long.toString(ContentUris.parseId(uri)) }); } if (!shouldShowDeleted(uri)) selection = DBUtils.concatenateWhere(History.IS_DELETED + " = 0", selection); if (TextUtils.isEmpty(sortOrder)) sortOrder = DEFAULT_HISTORY_SORT_ORDER; qb.setProjectionMap(HISTORY_PROJECTION_MAP); if (hasImagesInProjection(projection)) qb.setTables(VIEW_HISTORY_WITH_IMAGES); else qb.setTables(TABLE_HISTORY); break; } case IMAGES_ID: case IMAGES: { debug("Query is on images: " + uri); if (match == IMAGES_ID) { selection = DBUtils.concatenateWhere(selection, Images._ID + " = ?"); selectionArgs = DBUtils.appendSelectionArgs(selectionArgs, new String[] { Long.toString(ContentUris.parseId(uri)) }); } if (!shouldShowDeleted(uri)) selection = DBUtils.concatenateWhere(Images.IS_DELETED + " = 0", selection); qb.setProjectionMap(IMAGES_PROJECTION_MAP); qb.setTables(TABLE_IMAGES); break; } case SCHEMA: { debug("Query is on schema."); MatrixCursor schemaCursor = new MatrixCursor(new String[] { Schema.VERSION }); schemaCursor.newRow().add(DATABASE_VERSION); return schemaCursor; } default: throw new UnsupportedOperationException("Unknown query URI " + uri); } trace("Running built query."); Cursor cursor = qb.query(db, projection, selection, selectionArgs, null, null, sortOrder, limit); cursor.setNotificationUri(getContext().getContentResolver(), BrowserContract.AUTHORITY_URI); return cursor; } ContentValues extractImageValues(ContentValues values, String url) { trace("Extracting image values for URI: " + url); ContentValues imageValues = null; if (values.containsKey(Bookmarks.FAVICON)) { debug("Has favicon value on URL: " + url); imageValues = new ContentValues(); imageValues.put(Images.FAVICON, values.getAsByteArray(Bookmarks.FAVICON)); values.remove(Bookmarks.FAVICON); } if (values.containsKey(Bookmarks.THUMBNAIL)) { debug("Has favicon value on URL: " + url); if (imageValues == null) imageValues = new ContentValues(); imageValues.put(Images.THUMBNAIL, values.getAsByteArray(Bookmarks.THUMBNAIL)); values.remove(Bookmarks.THUMBNAIL); } if (imageValues != null && url != null) { debug("Has URL value"); imageValues.put(Images.URL, url); } return imageValues; } int getUrlCount(SQLiteDatabase db, String table, String url) { Cursor c = db.query(table, new String[] { "COUNT(*)" }, URLColumns.URL + " = ?", new String[] { url }, null, null, null); int count = 0; try { if (c.moveToFirst()) count = c.getInt(0); } finally { c.close(); } return count; } /** * Construct an update expression that will modify the parents of any records * that match. */ int updateBookmarkParents(Uri uri, ContentValues values, String selection, String[] selectionArgs) { trace("Updating bookmark parents of " + selection + " (" + selectionArgs[0] + ")"); String where = Bookmarks._ID + " IN (" + " SELECT DISTINCT " + Bookmarks.PARENT + " FROM " + TABLE_BOOKMARKS + " WHERE " + selection + " )"; return getWritableDatabase(uri).update(TABLE_BOOKMARKS, values, where, selectionArgs); } int updateBookmarks(Uri uri, ContentValues values, String selection, String[] selectionArgs) { trace("Updating bookmarks on URI: " + uri); final SQLiteDatabase db = getWritableDatabase(uri); int updated = 0; final String[] bookmarksProjection = new String[] { Bookmarks._ID, // 0 Bookmarks.URL, // 1 }; trace("Quering bookmarks to update on URI: " + uri); Cursor cursor = db.query(TABLE_BOOKMARKS, bookmarksProjection, selection, selectionArgs, null, null, null); try { if (!values.containsKey(Bookmarks.DATE_MODIFIED)) values.put(Bookmarks.DATE_MODIFIED, System.currentTimeMillis()); boolean updatingUrl = values.containsKey(Bookmarks.URL); String url = null; if (updatingUrl) url = values.getAsString(Bookmarks.URL); ContentValues imageValues = extractImageValues(values, url); while (cursor.moveToNext()) { long id = cursor.getLong(0); trace("Updating bookmark with ID: " + id); updated += db.update(TABLE_BOOKMARKS, values, "_id = ?", new String[] { Long.toString(id) }); if (imageValues == null) continue; if (!updatingUrl) { url = cursor.getString(1); imageValues.put(Images.URL, url); } if (!TextUtils.isEmpty(url)) { trace("Updating bookmark image for URL: " + url); updateOrInsertImage(uri, imageValues, Images.URL + " = ?", new String[] { url }); } } } finally { if (cursor != null) cursor.close(); } return updated; } int updateHistory(Uri uri, ContentValues values, String selection, String[] selectionArgs) { trace("Updating history on URI: " + uri); final SQLiteDatabase db = getWritableDatabase(uri); int updated = 0; final String[] historyProjection = new String[] { History._ID, // 0 History.URL, // 1 }; Cursor cursor = db.query(TABLE_HISTORY, historyProjection, selection, selectionArgs, null, null, null); try { values.put(History.DATE_MODIFIED, System.currentTimeMillis()); boolean updatingUrl = values.containsKey(History.URL); String url = null; if (updatingUrl) url = values.getAsString(History.URL); ContentValues imageValues = extractImageValues(values, url); while (cursor.moveToNext()) { long id = cursor.getLong(0); trace("Updating history entry with ID: " + id); updated += db.update(TABLE_HISTORY, values, "_id = ?", new String[] { Long.toString(id) }); if (imageValues == null) continue; if (!updatingUrl) { url = cursor.getString(1); imageValues.put(Images.URL, url); } if (!TextUtils.isEmpty(url)) { trace("Updating history image for URL: " + url); updateOrInsertImage(uri, imageValues, Images.URL + " = ?", new String[] { url }); } } } finally { if (cursor != null) cursor.close(); } return updated; } int updateOrInsertImage(Uri uri, ContentValues values, String selection, String[] selectionArgs) { return updateImage(uri, values, selection, selectionArgs, true /* insert if needed */); } int updateExistingImage(Uri uri, ContentValues values, String selection, String[] selectionArgs) { return updateImage(uri, values, selection, selectionArgs, false /* only update, no insert */); } int updateImage(Uri uri, ContentValues values, String selection, String[] selectionArgs, boolean insertIfNeeded) { String url = values.getAsString(Images.URL); trace("Updating image for URL: " + url); final SQLiteDatabase db = getWritableDatabase(uri); long now = System.currentTimeMillis(); // Thumbnails update on every page load. We don't want to flood // sync with meaningless last modified date. Only update modified // date when favicons bits change. if (values.containsKey(Images.FAVICON) || values.containsKey(Images.FAVICON_URL)) values.put(Images.DATE_MODIFIED, now); // Restore and update an existing image record marked as // deleted if possible. if (insertIfNeeded) values.put(Images.IS_DELETED, 0); debug("Trying to update image for URL: " + url); int updated = db.update(TABLE_IMAGES, values, selection, selectionArgs); if (updated == 0 && insertIfNeeded) { // Generate GUID for new image, if one is not already provided. if (!values.containsKey(Images.GUID)) { values.put(Images.GUID, Utils.generateGuid()); } values.put(Images.DATE_CREATED, now); values.put(Images.DATE_MODIFIED, now); trace("No update, inserting image for URL: " + url); db.insert(TABLE_IMAGES, Images.FAVICON, values); updated = 1; } return updated; } int deleteHistory(Uri uri, String selection, String[] selectionArgs) { debug("Deleting history entry for URI: " + uri); final SQLiteDatabase db = getWritableDatabase(uri); if (isCallerSync(uri)) { return db.delete(TABLE_HISTORY, selection, selectionArgs); } else { debug("Marking history entry as deleted for URI: " + uri); ContentValues values = new ContentValues(); values.put(History.IS_DELETED, 1); cleanupSomeDeletedRecords(uri, History.CONTENT_URI, TABLE_HISTORY); return updateHistory(uri, values, selection, selectionArgs); } } int deleteBookmarks(Uri uri, String selection, String[] selectionArgs) { debug("Deleting bookmarks for URI: " + uri); final SQLiteDatabase db = getWritableDatabase(uri); if (isCallerSync(uri)) { return db.delete(TABLE_BOOKMARKS, selection, selectionArgs); } else { debug("Marking bookmarks as deleted for URI: " + uri); ContentValues values = new ContentValues(); values.put(Bookmarks.IS_DELETED, 1); cleanupSomeDeletedRecords(uri, Bookmarks.CONTENT_URI, TABLE_BOOKMARKS); return updateBookmarks(uri, values, selection, selectionArgs); } } int deleteImages(Uri uri, String selection, String[] selectionArgs) { debug("Deleting images for URI: " + uri); final SQLiteDatabase db = getWritableDatabase(uri); if (isCallerSync(uri)) { return db.delete(TABLE_IMAGES, selection, null); } else { debug("Marking images as deleted for URI: " + uri); ContentValues values = new ContentValues(); values.put(History.IS_DELETED, 1); cleanupSomeDeletedRecords(uri, Images.CONTENT_URI, TABLE_IMAGES); return updateExistingImage(uri, values, selection, null); } } int deleteUnusedImages(Uri uri) { debug("Deleting all unused images for URI: " + uri); String selection = Images.URL + " NOT IN (SELECT " + Bookmarks.URL + " FROM " + TABLE_BOOKMARKS + " WHERE " + Bookmarks.URL + " IS NOT NULL AND " + qualifyColumn(TABLE_BOOKMARKS, Bookmarks.IS_DELETED) + " = 0) AND " + Images.URL + " NOT IN (SELECT " + History.URL + " FROM " + TABLE_HISTORY + " WHERE " + History.URL + " IS NOT NULL AND " + qualifyColumn(TABLE_HISTORY, History.IS_DELETED) + " = 0)"; return deleteImages(uri, selection, null); } }