/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- */ /* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this file, * You can obtain one at http://mozilla.org/MPL/2.0/. */ #filter substitution package @ANDROID_PACKAGE_NAME@.db; import java.io.ByteArrayOutputStream; import java.io.File; import java.io.IOException; import java.lang.Class; import java.lang.reflect.Field; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Iterator; import java.util.Map; import java.util.Random; import java.util.regex.Pattern; import java.util.regex.Matcher; import org.mozilla.gecko.GeckoAppShell; import org.mozilla.gecko.GeckoBackgroundThread; import org.mozilla.gecko.GeckoProfile; import org.mozilla.gecko.R; import org.mozilla.gecko.db.BrowserContract.Bookmarks; import org.mozilla.gecko.db.BrowserContract.Combined; import org.mozilla.gecko.db.BrowserContract.CommonColumns; import org.mozilla.gecko.db.BrowserContract.Control; 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.ProfileMigrator; import org.mozilla.gecko.sync.Utils; import android.content.ContentProvider; import android.content.ContentUris; import android.content.ContentValues; import android.content.ContentProviderResult; import android.content.ContentProviderOperation; import android.content.OperationApplicationException; import android.content.Context; import android.content.UriMatcher; import android.database.Cursor; import android.database.DatabaseUtils; import android.database.MatrixCursor; import android.database.SQLException; import android.database.sqlite.SQLiteDatabase; import android.database.sqlite.SQLiteOpenHelper; import android.database.sqlite.SQLiteQueryBuilder; import android.graphics.BitmapFactory; import android.graphics.Bitmap; 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 = 10; // 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; // How many records to reposition in a single query. // This should be less than the SQLite maximum number of query variables // (currently 999) divided by the number of variables used per positioning // query (currently 3). static final int MAX_POSITION_UPDATES_PER_QUERY = 100; 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 TABLE_HISTORY_TMP = TABLE_HISTORY + "_tmp"; static final String TABLE_IMAGES_TMP = TABLE_IMAGES + "_tmp"; static final String VIEW_BOOKMARKS_WITH_IMAGES = "bookmarks_with_images"; static final String VIEW_HISTORY_WITH_IMAGES = "history_with_images"; static final String VIEW_COMBINED_WITH_IMAGES = "combined_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; static final int BOOKMARKS_POSITIONS = 104; // 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; // Combined bookmarks and history matches static final int COMBINED = 500; // Control matches static final int CONTROL = 600; static final String DEFAULT_BOOKMARKS_SORT_ORDER = Bookmarks.TYPE + " ASC, " + 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 " + TABLE_IMAGES + " ON " + qualifyColumn(TABLE_BOOKMARKS, Bookmarks.URL) + " = " + qualifyColumn(TABLE_IMAGES, Images.URL); static final String TABLE_HISTORY_JOIN_IMAGES = TABLE_HISTORY + " LEFT OUTER JOIN " + TABLE_IMAGES + " ON " + qualifyColumn(TABLE_HISTORY, History.URL) + " = " + qualifyColumn(TABLE_IMAGES, Images.URL); static final UriMatcher URI_MATCHER = new UriMatcher(UriMatcher.NO_MATCH); static final Map BOOKMARKS_PROJECTION_MAP; static final Map HISTORY_PROJECTION_MAP; static final Map IMAGES_PROJECTION_MAP; static final Map COMBINED_PROJECTION_MAP; static final Map SCHEMA_PROJECTION_MAP; static { // We will reuse this. 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/positions", BOOKMARKS_POSITIONS); URI_MATCHER.addURI(BrowserContract.AUTHORITY, "bookmarks/folder/#", BOOKMARKS_FOLDER_ID); map = new HashMap(); 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.TYPE, Bookmarks.TYPE); 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); BOOKMARKS_PROJECTION_MAP = Collections.unmodifiableMap(map); // History URI_MATCHER.addURI(BrowserContract.AUTHORITY, "history", HISTORY); URI_MATCHER.addURI(BrowserContract.AUTHORITY, "history/#", HISTORY_ID); map = new HashMap(); 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); HISTORY_PROJECTION_MAP = Collections.unmodifiableMap(map); // Images URI_MATCHER.addURI(BrowserContract.AUTHORITY, "images", IMAGES); URI_MATCHER.addURI(BrowserContract.AUTHORITY, "images/#", IMAGES_ID); map = new HashMap(); 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); IMAGES_PROJECTION_MAP = Collections.unmodifiableMap(map); // Combined bookmarks and history URI_MATCHER.addURI(BrowserContract.AUTHORITY, "combined", COMBINED); map = new HashMap(); map.put(Combined._ID, Combined._ID); map.put(Combined.BOOKMARK_ID, Combined.BOOKMARK_ID); map.put(Combined.HISTORY_ID, Combined.HISTORY_ID); map.put(Combined.DISPLAY, Combined.DISPLAY); map.put(Combined.URL, Combined.URL); map.put(Combined.TITLE, Combined.TITLE); map.put(Combined.VISITS, Combined.VISITS); map.put(Combined.DATE_LAST_VISITED, Combined.DATE_LAST_VISITED); map.put(Combined.FAVICON, Combined.FAVICON); map.put(Combined.THUMBNAIL, Combined.THUMBNAIL); COMBINED_PROJECTION_MAP = Collections.unmodifiableMap(map); // Schema URI_MATCHER.addURI(BrowserContract.AUTHORITY, "schema", SCHEMA); map = new HashMap(); map.put(Schema.VERSION, Schema.VERSION); SCHEMA_PROJECTION_MAP = Collections.unmodifiableMap(map); // Control URI_MATCHER.addURI(BrowserContract.AUTHORITY, "control", CONTROL); } private HashMap mDatabasePerProfile; private interface BookmarkMigrator { public void updateForNewTable(ContentValues bookmark); } private class BookmarkMigrator3to4 implements BookmarkMigrator { public void updateForNewTable(ContentValues bookmark) { Integer isFolder = bookmark.getAsInteger("folder"); if (isFolder == null || isFolder != 1) { bookmark.put(Bookmarks.TYPE, Bookmarks.TYPE_BOOKMARK); } else { bookmark.put(Bookmarks.TYPE, Bookmarks.TYPE_FOLDER); } bookmark.remove("folder"); } } 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.TYPE + " INTEGER NOT NULL DEFAULT " + Bookmarks.TYPE_BOOKMARK + "," + 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 NOT NULL," + 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 NOT NULL," + 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 NOT NULL," + 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) { debug("Creating " + VIEW_BOOKMARKS_WITH_IMAGES + " view"); 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) { debug("Creating " + VIEW_HISTORY_WITH_IMAGES + " view"); 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); } private void createCombinedWithImagesView(SQLiteDatabase db) { debug("Creating " + VIEW_COMBINED_WITH_IMAGES + " view"); db.execSQL("CREATE VIEW IF NOT EXISTS " + VIEW_COMBINED_WITH_IMAGES + " AS" + " SELECT " + Combined.BOOKMARK_ID + ", " + Combined.HISTORY_ID + ", " + // We need to return an _id column because CursorAdapter requires it for its // default implementation for the getItemId() method. However, since // we're not using this feature in the parts of the UI using this view, // we can just use 0 for all rows. "0 AS " + Combined._ID + ", " + Combined.URL + ", " + Combined.TITLE + ", " + Combined.VISITS + ", " + Combined.DATE_LAST_VISITED + ", " + qualifyColumn(TABLE_IMAGES, Images.FAVICON) + " AS " + Combined.FAVICON + ", " + qualifyColumn(TABLE_IMAGES, Images.THUMBNAIL) + " AS " + Combined.THUMBNAIL + " FROM (" + // Bookmarks without history. " SELECT " + qualifyColumn(TABLE_BOOKMARKS, Bookmarks._ID) + " AS " + Combined.BOOKMARK_ID + ", " + qualifyColumn(TABLE_BOOKMARKS, Bookmarks.URL) + " AS " + Combined.URL + ", " + qualifyColumn(TABLE_BOOKMARKS, Bookmarks.TITLE) + " AS " + Combined.TITLE + ", " + "-1 AS " + Combined.HISTORY_ID + ", " + "-1 AS " + Combined.VISITS + ", " + "-1 AS " + Combined.DATE_LAST_VISITED + " FROM " + TABLE_BOOKMARKS + " WHERE " + qualifyColumn(TABLE_BOOKMARKS, Bookmarks.TYPE) + " = " + Bookmarks.TYPE_BOOKMARK + " AND " + qualifyColumn(TABLE_BOOKMARKS, Bookmarks.IS_DELETED) + " = 0 AND " + qualifyColumn(TABLE_BOOKMARKS, Bookmarks.URL) + " NOT IN (SELECT " + History.URL + " FROM " + TABLE_HISTORY + ")" + " UNION ALL" + // History with and without bookmark. " SELECT " + qualifyColumn(TABLE_BOOKMARKS, Bookmarks._ID) + " AS " + Combined.BOOKMARK_ID + ", " + qualifyColumn(TABLE_HISTORY, History.URL) + " AS " + Combined.URL + ", " + // Prioritze bookmark titles over history titles, since the user may have // customized the title for a bookmark. "COALESCE(" + qualifyColumn(TABLE_BOOKMARKS, Bookmarks.TITLE) + ", " + qualifyColumn(TABLE_HISTORY, History.TITLE) +")" + " AS " + Combined.TITLE + ", " + qualifyColumn(TABLE_HISTORY, History._ID) + " AS " + Combined.HISTORY_ID + ", " + qualifyColumn(TABLE_HISTORY, History.VISITS) + " AS " + Combined.VISITS + ", " + qualifyColumn(TABLE_HISTORY, History.DATE_LAST_VISITED) + " AS " + Combined.DATE_LAST_VISITED + " FROM " + TABLE_HISTORY + " LEFT OUTER JOIN " + TABLE_BOOKMARKS + " ON " + qualifyColumn(TABLE_BOOKMARKS, Bookmarks.URL) + " = " + qualifyColumn(TABLE_HISTORY, History.URL) + " WHERE " + qualifyColumn(TABLE_HISTORY, History.URL) + " IS NOT NULL AND " + qualifyColumn(TABLE_HISTORY, History.IS_DELETED) + " = 0 AND (" + qualifyColumn(TABLE_BOOKMARKS, Bookmarks.TYPE) + " IS NULL OR " + qualifyColumn(TABLE_BOOKMARKS, Bookmarks.TYPE) + " = " + Bookmarks.TYPE_BOOKMARK + ")" + ") LEFT OUTER JOIN " + TABLE_IMAGES + " ON " + Combined.URL + " = " + qualifyColumn(TABLE_IMAGES, Images.URL)); } private void createCombinedWithImagesViewOn9(SQLiteDatabase db) { debug("Creating " + VIEW_COMBINED_WITH_IMAGES + " view"); db.execSQL("CREATE VIEW IF NOT EXISTS " + VIEW_COMBINED_WITH_IMAGES + " AS" + " SELECT " + Combined.BOOKMARK_ID + ", " + Combined.HISTORY_ID + ", " + // We need to return an _id column because CursorAdapter requires it for its // default implementation for the getItemId() method. However, since // we're not using this feature in the parts of the UI using this view, // we can just use 0 for all rows. "0 AS " + Combined._ID + ", " + Combined.URL + ", " + Combined.TITLE + ", " + Combined.VISITS + ", " + Combined.DISPLAY + ", " + Combined.DATE_LAST_VISITED + ", " + qualifyColumn(TABLE_IMAGES, Images.FAVICON) + " AS " + Combined.FAVICON + ", " + qualifyColumn(TABLE_IMAGES, Images.THUMBNAIL) + " AS " + Combined.THUMBNAIL + " FROM (" + // Bookmarks without history. " SELECT " + qualifyColumn(TABLE_BOOKMARKS, Bookmarks._ID) + " AS " + Combined.BOOKMARK_ID + ", " + qualifyColumn(TABLE_BOOKMARKS, Bookmarks.URL) + " AS " + Combined.URL + ", " + qualifyColumn(TABLE_BOOKMARKS, Bookmarks.TITLE) + " AS " + Combined.TITLE + ", " + "CASE " + qualifyColumn(TABLE_BOOKMARKS, Bookmarks.PARENT) + " WHEN " + Bookmarks.FIXED_READING_LIST_ID + " THEN " + Combined.DISPLAY_READER + " ELSE " + Combined.DISPLAY_NORMAL + " END AS " + Combined.DISPLAY + ", " + "-1 AS " + Combined.HISTORY_ID + ", " + "-1 AS " + Combined.VISITS + ", " + "-1 AS " + Combined.DATE_LAST_VISITED + " FROM " + TABLE_BOOKMARKS + " WHERE " + qualifyColumn(TABLE_BOOKMARKS, Bookmarks.TYPE) + " = " + Bookmarks.TYPE_BOOKMARK + " AND " + qualifyColumn(TABLE_BOOKMARKS, Bookmarks.IS_DELETED) + " = 0 AND " + qualifyColumn(TABLE_BOOKMARKS, Bookmarks.URL) + " NOT IN (SELECT " + History.URL + " FROM " + TABLE_HISTORY + ")" + " UNION ALL" + // History with and without bookmark. " SELECT " + qualifyColumn(TABLE_BOOKMARKS, Bookmarks._ID) + " AS " + Combined.BOOKMARK_ID + ", " + qualifyColumn(TABLE_HISTORY, History.URL) + " AS " + Combined.URL + ", " + // Prioritze bookmark titles over history titles, since the user may have // customized the title for a bookmark. "COALESCE(" + qualifyColumn(TABLE_BOOKMARKS, Bookmarks.TITLE) + ", " + qualifyColumn(TABLE_HISTORY, History.TITLE) +")" + " AS " + Combined.TITLE + ", " + "CASE " + qualifyColumn(TABLE_BOOKMARKS, Bookmarks.PARENT) + " WHEN " + Bookmarks.FIXED_READING_LIST_ID + " THEN " + Combined.DISPLAY_READER + " ELSE " + Combined.DISPLAY_NORMAL + " END AS " + Combined.DISPLAY + ", " + qualifyColumn(TABLE_HISTORY, History._ID) + " AS " + Combined.HISTORY_ID + ", " + qualifyColumn(TABLE_HISTORY, History.VISITS) + " AS " + Combined.VISITS + ", " + qualifyColumn(TABLE_HISTORY, History.DATE_LAST_VISITED) + " AS " + Combined.DATE_LAST_VISITED + " FROM " + TABLE_HISTORY + " LEFT OUTER JOIN " + TABLE_BOOKMARKS + " ON " + qualifyColumn(TABLE_BOOKMARKS, Bookmarks.URL) + " = " + qualifyColumn(TABLE_HISTORY, History.URL) + " WHERE " + qualifyColumn(TABLE_HISTORY, History.URL) + " IS NOT NULL AND " + qualifyColumn(TABLE_HISTORY, History.IS_DELETED) + " = 0 AND (" + qualifyColumn(TABLE_BOOKMARKS, Bookmarks.TYPE) + " IS NULL OR " + qualifyColumn(TABLE_BOOKMARKS, Bookmarks.TYPE) + " = " + Bookmarks.TYPE_BOOKMARK + ")" + ") LEFT OUTER JOIN " + TABLE_IMAGES + " ON " + Combined.URL + " = " + qualifyColumn(TABLE_IMAGES, Images.URL)); } private void createCombinedWithImagesViewOn10(SQLiteDatabase db) { debug("Creating " + VIEW_COMBINED_WITH_IMAGES + " view"); db.execSQL("CREATE VIEW IF NOT EXISTS " + VIEW_COMBINED_WITH_IMAGES + " AS" + " SELECT " + Combined.BOOKMARK_ID + ", " + Combined.HISTORY_ID + ", " + // We need to return an _id column because CursorAdapter requires it for its // default implementation for the getItemId() method. However, since // we're not using this feature in the parts of the UI using this view, // we can just use 0 for all rows. "0 AS " + Combined._ID + ", " + Combined.URL + ", " + Combined.TITLE + ", " + Combined.VISITS + ", " + Combined.DISPLAY + ", " + Combined.DATE_LAST_VISITED + ", " + qualifyColumn(TABLE_IMAGES, Images.FAVICON) + " AS " + Combined.FAVICON + ", " + qualifyColumn(TABLE_IMAGES, Images.THUMBNAIL) + " AS " + Combined.THUMBNAIL + " FROM (" + // Bookmarks without history. " SELECT " + qualifyColumn(TABLE_BOOKMARKS, Bookmarks._ID) + " AS " + Combined.BOOKMARK_ID + ", " + qualifyColumn(TABLE_BOOKMARKS, Bookmarks.URL) + " AS " + Combined.URL + ", " + qualifyColumn(TABLE_BOOKMARKS, Bookmarks.TITLE) + " AS " + Combined.TITLE + ", " + "CASE " + qualifyColumn(TABLE_BOOKMARKS, Bookmarks.PARENT) + " WHEN " + Bookmarks.FIXED_READING_LIST_ID + " THEN " + Combined.DISPLAY_READER + " ELSE " + Combined.DISPLAY_NORMAL + " END AS " + Combined.DISPLAY + ", " + "-1 AS " + Combined.HISTORY_ID + ", " + "-1 AS " + Combined.VISITS + ", " + "-1 AS " + Combined.DATE_LAST_VISITED + " FROM " + TABLE_BOOKMARKS + " WHERE " + qualifyColumn(TABLE_BOOKMARKS, Bookmarks.TYPE) + " = " + Bookmarks.TYPE_BOOKMARK + " AND " + qualifyColumn(TABLE_BOOKMARKS, Bookmarks.IS_DELETED) + " = 0 AND " + qualifyColumn(TABLE_BOOKMARKS, Bookmarks.URL) + " NOT IN (SELECT " + History.URL + " FROM " + TABLE_HISTORY + ")" + " UNION ALL" + // History with and without bookmark. " SELECT " + "CASE " + qualifyColumn(TABLE_BOOKMARKS, Bookmarks.IS_DELETED) + " WHEN 0 THEN " + qualifyColumn(TABLE_BOOKMARKS, Bookmarks._ID) + " ELSE NULL END AS " + Combined.BOOKMARK_ID + ", " + qualifyColumn(TABLE_HISTORY, History.URL) + " AS " + Combined.URL + ", " + // Prioritze bookmark titles over history titles, since the user may have // customized the title for a bookmark. "COALESCE(" + qualifyColumn(TABLE_BOOKMARKS, Bookmarks.TITLE) + ", " + qualifyColumn(TABLE_HISTORY, History.TITLE) +")" + " AS " + Combined.TITLE + ", " + "CASE " + qualifyColumn(TABLE_BOOKMARKS, Bookmarks.PARENT) + " WHEN " + Bookmarks.FIXED_READING_LIST_ID + " THEN " + Combined.DISPLAY_READER + " ELSE " + Combined.DISPLAY_NORMAL + " END AS " + Combined.DISPLAY + ", " + qualifyColumn(TABLE_HISTORY, History._ID) + " AS " + Combined.HISTORY_ID + ", " + qualifyColumn(TABLE_HISTORY, History.VISITS) + " AS " + Combined.VISITS + ", " + qualifyColumn(TABLE_HISTORY, History.DATE_LAST_VISITED) + " AS " + Combined.DATE_LAST_VISITED + " FROM " + TABLE_HISTORY + " LEFT OUTER JOIN " + TABLE_BOOKMARKS + " ON " + qualifyColumn(TABLE_BOOKMARKS, Bookmarks.URL) + " = " + qualifyColumn(TABLE_HISTORY, History.URL) + " WHERE " + qualifyColumn(TABLE_HISTORY, History.URL) + " IS NOT NULL AND " + qualifyColumn(TABLE_HISTORY, History.IS_DELETED) + " = 0 AND (" + qualifyColumn(TABLE_BOOKMARKS, Bookmarks.TYPE) + " IS NULL OR " + qualifyColumn(TABLE_BOOKMARKS, Bookmarks.TYPE) + " = " + Bookmarks.TYPE_BOOKMARK + ")" + ") LEFT OUTER JOIN " + TABLE_IMAGES + " ON " + Combined.URL + " = " + qualifyColumn(TABLE_IMAGES, Images.URL)); } @Override public void onCreate(SQLiteDatabase db) { debug("Creating browser.db: " + db.getPath()); createBookmarksTable(db); createHistoryTable(db); createImagesTable(db); createBookmarksWithImagesView(db); createHistoryWithImagesView(db); createCombinedWithImagesViewOn10(db); createOrUpdateSpecialFolder(db, Bookmarks.PLACES_FOLDER_GUID, R.string.bookmarks_folder_places, 0); createOrUpdateAllSpecialFolders(db); createDefaultBookmarks(db, "^bookmarkdefaults_title_"); } private void createDefaultBookmarks(SQLiteDatabase db, String pattern) { Class stringsClass = R.string.class; Field[] fields = stringsClass.getFields(); Pattern p = Pattern.compile(pattern); ContentValues bookmarksValues = new ContentValues(); bookmarksValues.put(Bookmarks.PARENT, guidToID(db, Bookmarks.MOBILE_FOLDER_GUID)); long now = System.currentTimeMillis(); bookmarksValues.put(Bookmarks.DATE_CREATED, now); bookmarksValues.put(Bookmarks.DATE_MODIFIED, now); int pos = 0; for (int i = 0; i < fields.length; i++) { String name = fields[i].getName(); Matcher m = p.matcher(name); if (!m.find()) continue; try { int titleid = fields[i].getInt(null); String title = mContext.getString(titleid); Field urlField = stringsClass.getField(name.replace("_title_", "_url_")); int urlId = urlField.getInt(null); String url = mContext.getString(urlId); bookmarksValues.put(Bookmarks.TITLE, title); bookmarksValues.put(Bookmarks.URL, url); bookmarksValues.put(Bookmarks.GUID, Utils.generateGuid()); bookmarksValues.put(Bookmarks.POSITION, pos); db.insertOrThrow(TABLE_BOOKMARKS, Bookmarks.TITLE, bookmarksValues); setDefaultFavicon(db, name, url); pos++; } catch (java.lang.IllegalAccessException ex) { Log.e(LOGTAG, "Can't create bookmark " + name, ex); } catch (java.lang.NoSuchFieldException ex) { Log.e(LOGTAG, "Can't create bookmark " + name, ex); } } } private void setDefaultFavicon(SQLiteDatabase db, String name, String url) { Class drawablesClass = R.drawable.class; ByteArrayOutputStream stream = null; try { // Look for a drawable with the id R.drawable.bookmarkdefaults_favicon_* Field faviconField = drawablesClass.getField(name.replace("_title_", "_favicon_")); if (faviconField == null) return; int faviconId = faviconField.getInt(null); Bitmap bitmap = BitmapFactory.decodeResource(mContext.getResources(), faviconId); stream = new ByteArrayOutputStream(); bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream); } catch (java.lang.IllegalAccessException ex) { Log.e(LOGTAG, "Can't create favicon " + name, ex); } catch (java.lang.NoSuchFieldException ex) { // if there is no such field, create the bookmark without a favicon Log.d(LOGTAG, "Can't create favicon " + name); } if (stream != null) { ContentValues values = new ContentValues(); values.put(Images.FAVICON, stream.toByteArray()); values.put(Images.URL, url); values.put(Images.IS_DELETED, 0); values.put(Images.GUID, Utils.generateGuid()); db.insertOrThrow(TABLE_IMAGES, Images.URL, values); } } 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); createOrUpdateSpecialFolder(db, Bookmarks.READING_LIST_FOLDER_GUID, R.string.bookmarks_folder_reading_list, 5); } private void createOrUpdateSpecialFolder(SQLiteDatabase db, String guid, int titleId, int position) { ContentValues values = new ContentValues(); values.put(Bookmarks.GUID, guid); values.put(Bookmarks.TYPE, Bookmarks.TYPE_FOLDER); values.put(Bookmarks.POSITION, position); if (guid.equals(Bookmarks.PLACES_FOLDER_GUID)) values.put(Bookmarks._ID, Bookmarks.FIXED_ROOT_ID); else if (guid.equals(Bookmarks.READING_LIST_FOLDER_GUID)) values.put(Bookmarks._ID, Bookmarks.FIXED_READING_LIST_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, BookmarkMigrator migrator) { 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; } if (migrator != null) migrator.updateForNewTable(values); debug("Migrating bookmark: " + values.getAsString(Bookmarks.TITLE)); db.insert(TABLE_BOOKMARKS, Bookmarks.URL, values); Integer type = values.getAsInteger(Bookmarks.TYPE); if (type != null && type == Bookmarks.TYPE_FOLDER) 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, migrator); } } private void migrateBookmarksTable(SQLiteDatabase db) { migrateBookmarksTable(db, null); } private void migrateBookmarksTable(SQLiteDatabase db, BookmarkMigrator migrator) { 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, migrator); // 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); } private void migrateHistoryTable(SQLiteDatabase db) { debug("Renaming history table to " + TABLE_HISTORY_TMP); db.execSQL("ALTER TABLE " + TABLE_HISTORY + " RENAME TO " + TABLE_HISTORY_TMP); debug("Dropping views and indexes related to " + TABLE_HISTORY); db.execSQL("DROP VIEW IF EXISTS " + VIEW_HISTORY_WITH_IMAGES); db.execSQL("DROP VIEW IF EXISTS " + VIEW_COMBINED_WITH_IMAGES); db.execSQL("DROP INDEX IF EXISTS history_url_index"); db.execSQL("DROP INDEX IF EXISTS history_guid_index"); db.execSQL("DROP INDEX IF EXISTS history_modified_index"); db.execSQL("DROP INDEX IF EXISTS history_visited_index"); createHistoryTable(db); createHistoryWithImagesView(db); createCombinedWithImagesView(db); db.execSQL("INSERT INTO " + TABLE_HISTORY + " SELECT * FROM " + TABLE_HISTORY_TMP); debug("Dropping history temporary table"); db.execSQL("DROP TABLE IF EXISTS " + TABLE_HISTORY_TMP); } private void migrateImagesTable(SQLiteDatabase db) { debug("Renaming images table to " + TABLE_IMAGES_TMP); db.execSQL("ALTER TABLE " + TABLE_IMAGES + " RENAME TO " + TABLE_IMAGES_TMP); debug("Dropping views and indexes related to " + TABLE_IMAGES); db.execSQL("DROP VIEW IF EXISTS " + VIEW_HISTORY_WITH_IMAGES); db.execSQL("DROP VIEW IF EXISTS " + VIEW_COMBINED_WITH_IMAGES); db.execSQL("DROP INDEX IF EXISTS images_url_index"); db.execSQL("DROP INDEX IF EXISTS images_guid_index"); db.execSQL("DROP INDEX IF EXISTS images_modified_index"); createImagesTable(db); createHistoryWithImagesView(db); createCombinedWithImagesView(db); db.execSQL("INSERT INTO " + TABLE_IMAGES + " SELECT * FROM " + TABLE_IMAGES_TMP); debug("Dropping images temporary table"); db.execSQL("DROP TABLE IF EXISTS " + TABLE_IMAGES_TMP); } private void upgradeDatabaseFrom1to2(SQLiteDatabase db) { migrateBookmarksTable(db); } private void upgradeDatabaseFrom2to3(SQLiteDatabase db) { debug("Dropping view: " + VIEW_BOOKMARKS_WITH_IMAGES); db.execSQL("DROP VIEW IF EXISTS " + VIEW_BOOKMARKS_WITH_IMAGES); createBookmarksWithImagesView(db); debug("Dropping view: " + VIEW_HISTORY_WITH_IMAGES); db.execSQL("DROP VIEW IF EXISTS " + VIEW_HISTORY_WITH_IMAGES); createHistoryWithImagesView(db); } private void upgradeDatabaseFrom3to4(SQLiteDatabase db) { migrateBookmarksTable(db, new BookmarkMigrator3to4()); } private void upgradeDatabaseFrom4to5(SQLiteDatabase db) { createCombinedWithImagesView(db); } private void upgradeDatabaseFrom5to6(SQLiteDatabase db) { debug("Dropping view: " + VIEW_COMBINED_WITH_IMAGES); db.execSQL("DROP VIEW IF EXISTS " + VIEW_COMBINED_WITH_IMAGES); createCombinedWithImagesView(db); } private void upgradeDatabaseFrom6to7(SQLiteDatabase db) { debug("Removing history visits with NULL GUIDs"); db.execSQL("DELETE FROM " + TABLE_HISTORY + " WHERE " + History.GUID + " IS NULL"); debug("Update images with NULL GUIDs"); String[] columns = new String[] { Images._ID }; Cursor cursor = null; try { cursor = db.query(TABLE_IMAGES, columns, Images.GUID + " IS NULL", null, null ,null, null, null); ContentValues values = new ContentValues(); if (cursor.moveToFirst()) { do { values.put(Images.GUID, Utils.generateGuid()); db.update(TABLE_IMAGES, values, Images._ID + " = ?", new String[] { cursor.getString(cursor.getColumnIndexOrThrow(Images._ID)) }); } while (cursor.moveToNext()); } } finally { if (cursor != null) cursor.close(); } migrateBookmarksTable(db); migrateHistoryTable(db); migrateImagesTable(db); } private void upgradeDatabaseFrom7to8(SQLiteDatabase db) { debug("Combining history entries with the same URL"); final String TABLE_DUPES = "duped_urls"; final String TOTAL = "total"; final String LATEST = "latest"; final String WINNER = "winner"; db.execSQL("CREATE TEMP TABLE " + TABLE_DUPES + " AS" + " SELECT " + History.URL + ", " + "SUM(" + History.VISITS + ") AS " + TOTAL + ", " + "MAX(" + History.DATE_MODIFIED + ") AS " + LATEST + ", " + "MAX(" + History._ID + ") AS " + WINNER + " FROM " + TABLE_HISTORY + " GROUP BY " + History.URL + " HAVING count(" + History.URL + ") > 1"); db.execSQL("CREATE UNIQUE INDEX " + TABLE_DUPES + "_url_index ON " + TABLE_DUPES + " (" + History.URL + ")"); final String fromClause = " FROM " + TABLE_DUPES + " WHERE " + qualifyColumn(TABLE_DUPES, History.URL) + " = " + qualifyColumn(TABLE_HISTORY, History.URL); db.execSQL("UPDATE " + TABLE_HISTORY + " SET " + History.VISITS + " = (SELECT " + TOTAL + fromClause + "), " + History.DATE_MODIFIED + " = (SELECT " + LATEST + fromClause + "), " + History.IS_DELETED + " = " + "(" + History._ID + " <> (SELECT " + WINNER + fromClause + "))" + " WHERE " + History.URL + " IN (SELECT " + History.URL + " FROM " + TABLE_DUPES + ")"); db.execSQL("DROP TABLE " + TABLE_DUPES); } private void upgradeDatabaseFrom8to9(SQLiteDatabase db) { createOrUpdateSpecialFolder(db, Bookmarks.READING_LIST_FOLDER_GUID, R.string.bookmarks_folder_reading_list, 5); debug("Dropping view: " + VIEW_COMBINED_WITH_IMAGES); db.execSQL("DROP VIEW IF EXISTS " + VIEW_COMBINED_WITH_IMAGES); createCombinedWithImagesViewOn9(db); } private void upgradeDatabaseFrom9to10(SQLiteDatabase db) { debug("Dropping view: " + VIEW_COMBINED_WITH_IMAGES); db.execSQL("DROP VIEW IF EXISTS " + VIEW_COMBINED_WITH_IMAGES); createCombinedWithImagesViewOn10(db); } @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; case 3: upgradeDatabaseFrom2to3(db); break; case 4: upgradeDatabaseFrom3to4(db); break; case 5: upgradeDatabaseFrom4to5(db); break; case 6: upgradeDatabaseFrom5to6(db); break; case 7: upgradeDatabaseFrom6to7(db); break; case 8: upgradeDatabaseFrom7to8(db); case 9: upgradeDatabaseFrom8to9(db); case 10: upgradeDatabaseFrom9to10(db); } } db.setTransactionSuccessful(); db.endTransaction(); } @Override public void onOpen(SQLiteDatabase db) { debug("Opening browser.db: " + db.getPath()); Cursor cursor = null; if (Build.VERSION.SDK_INT >= 8) { try { cursor = db.rawQuery("PRAGMA foreign_keys=ON", null); } finally { if (cursor != null) cursor.close(); } } // 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 = 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, boolean isTest) { // 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 = GeckoProfile.get(mContext).getName(); } DatabaseHelper dbHelper; synchronized (this) { dbHelper = mDatabasePerProfile.get(profile); if (dbHelper != null) { return dbHelper; } String databasePath = getDatabasePath(profile, isTest); dbHelper = new DatabaseHelper(getContext(), databasePath); mDatabasePerProfile.put(profile, dbHelper); // When running inside a test or on Android releases older than 8, // the returned database path is just filename, not the full path. // We need the full path when unlocking the database. if (isTest || Build.VERSION.SDK_INT <= 8) { databasePath = mContext.getDatabasePath(databasePath).getAbsolutePath(); } DBUtils.ensureDatabaseIsNotLocked(dbHelper, databasePath); } debug("Created database helper for profile: " + profile); return dbHelper; } public String getDatabasePath(String profile, boolean isTest) { 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 (isTest) { return DATABASE_NAME; } else if(Build.VERSION.SDK_INT <= 8) { return "browser-" + profile + ".db"; } File profileDir = GeckoProfile.get(mContext, profile).getDir(); 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, isTest(uri)).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, isTest(uri)).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. // 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.Builder uriBuilder = targetUri.buildUpon() .appendQueryParameter(BrowserContract.PARAM_LIMIT, String.valueOf(DELETED_RECORDS_PURGE_LIMIT)) .appendQueryParameter(BrowserContract.PARAM_SHOW_DELETED, "1") .appendQueryParameter(BrowserContract.PARAM_IS_SYNC, "1"); String profile = fromUri.getQueryParameter(BrowserContract.PARAM_PROFILE); if (!TextUtils.isEmpty(profile)) uriBuilder = uriBuilder.appendQueryParameter(BrowserContract.PARAM_PROFILE, profile); if (isTest(fromUri)) uriBuilder = uriBuilder.appendQueryParameter(BrowserContract.PARAM_IS_TEST, "1"); Uri uriWithArgs = uriBuilder.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 isTest(Uri uri) { String isTest = uri.getQueryParameter(BrowserContract.PARAM_IS_TEST); return !TextUtils.isEmpty(isTest); } private boolean shouldShowDeleted(Uri uri) { String showDeleted = uri.getQueryParameter(BrowserContract.PARAM_SHOW_DELETED); return !TextUtils.isEmpty(showDeleted); } private boolean shouldUpdateOrInsert(Uri uri) { String insertIfNeeded = uri.getQueryParameter(BrowserContract.PARAM_INSERT_IF_NEEDED); return Boolean.parseBoolean(insertIfNeeded); } private boolean shouldIncrementVisits(Uri uri) { String incrementVisits = uri.getQueryParameter(BrowserContract.PARAM_INCREMENT_VISITS); return Boolean.parseBoolean(incrementVisits); } @Override public boolean onCreate() { debug("Creating BrowserProvider"); GeckoBackgroundThread.post(new Runnable() { @Override public void run() { // Kick this off early. It is synchronized so that other callers will wait try { GeckoProfile.get(getContext()).getDir(); } 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; try { 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); } } catch (SQLException sqle) { Log.e(LOGTAG, "exception in DB operation", sqle); } catch (UnsupportedOperationException uoe) { Log.e(LOGTAG, "don't know how to perform that insert", uoe); } 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); int match = URI_MATCHER.match(uri); long id = -1; switch (match) { case BOOKMARKS: { trace("Insert on BOOKMARKS: " + uri); id = insertBookmark(uri, values); break; } case HISTORY: { trace("Insert on HISTORY: " + uri); id = insertHistory(uri, 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); final SQLiteDatabase db = getWritableDatabase(uri); 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) { // We provide a dedicated (hacky) API for callers to bulk-update the positions of // folder children by passing an array of GUID strings as `selectionArgs`. // Each child will have its position column set to its index in the provided array. // // This avoids callers having to issue a large number of UPDATE queries through // the usual channels. See Bug 728783. // // Note that this is decidedly not a general-purpose API; use at your own risk. // `values` and `selection` are ignored. case BOOKMARKS_POSITIONS: { debug("Update on BOOKMARKS_POSITIONS: " + uri); updated = updateBookmarkPositions(uri, selectionArgs); break; } 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); if (shouldUpdateOrInsert(uri)) updated = updateOrInsertBookmark(uri, values, selection, selectionArgs); else 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); if (shouldUpdateOrInsert(uri)) updated = updateOrInsertHistory(uri, values, selection, selectionArgs); else 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"); if (shouldUpdateOrInsert(uri)) updated = updateOrInsertImage(uri, values, Images.URL + " = ?", new String[] { url }); else 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; } private Cursor controlQuery(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) { trace("controlQuery projection = " + projection); final String[] allFields = { Control.ENSURE_BOOKMARKS_MIGRATED, Control.ENSURE_HISTORY_MIGRATED }; // null projection must return all fields. if (projection == null) { projection = allFields; } if (selection != null) { throw new UnsupportedOperationException("No selection in virtual CONTROL queries"); } File profileDir = GeckoProfile.get(mContext).getDir(); if (uri != null) { String profile = uri.getQueryParameter(BrowserContract.PARAM_PROFILE); if (!TextUtils.isEmpty(profile)) { profileDir = GeckoProfile.get(mContext, profile).getDir(); } } MatrixCursor cursor = new MatrixCursor(projection); MatrixCursor.RowBuilder row = cursor.newRow(); synchronized (this) { boolean wantBookmarks = false; boolean wantHistory = false; for (String key : projection) { if (key.equals(Control.ENSURE_BOOKMARKS_MIGRATED)) { wantBookmarks = true; } else if (key.equals(Control.ENSURE_HISTORY_MIGRATED)) { wantHistory = true; } } if (wantHistory || wantBookmarks) { ProfileMigrator migrator = new ProfileMigrator(mContext); boolean needBookmarks = wantBookmarks && !migrator.areBookmarksMigrated(); boolean needHistory = wantHistory && !migrator.isHistoryMigrated(); if (needBookmarks || needHistory) { migrator.launchPlaces(profileDir); needBookmarks = wantBookmarks && !migrator.areBookmarksMigrated(); needHistory = wantHistory && !migrator.isHistoryMigrated(); // Bookmarks are expected to finish at the first run. if (needBookmarks) { Log.w(LOGTAG, "Bookmarks migration did not finish."); } } // Now set the results. for (String key: projection) { if (key.equals(Control.ENSURE_BOOKMARKS_MIGRATED)) { row.add(needBookmarks ? 0 : 1); } else if (key.equals(Control.ENSURE_HISTORY_MIGRATED)) { row.add(needHistory ? 0 : 1); } } } } return cursor; } @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; } case COMBINED: { debug("Query is on combined: " + uri); if (TextUtils.isEmpty(sortOrder)) sortOrder = DEFAULT_HISTORY_SORT_ORDER; qb.setProjectionMap(COMBINED_PROJECTION_MAP); qb.setTables(VIEW_COMBINED_WITH_IMAGES); break; } case CONTROL: { debug("Query is on control: " + uri); Cursor controlCursor = controlQuery(uri, projection, selection, selectionArgs, sortOrder); return controlCursor; } 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; } /** * Update the positions of bookmarks in batches. * * @see #updateBookmarkPositionsInTransaction(SQLiteDatabase, String[], int, int) */ int updateBookmarkPositions(Uri uri, String[] guids) { if (guids == null) return 0; int guidsCount = guids.length; if (guidsCount == 0) return 0; final SQLiteDatabase db = getWritableDatabase(uri); int offset = 0; int updated = 0; db.beginTransaction(); while (offset < guidsCount) { try { updated += updateBookmarkPositionsInTransaction(db, guids, offset, MAX_POSITION_UPDATES_PER_QUERY); } catch (SQLException e) { Log.e(LOGTAG, "Got SQLite exception updating bookmark positions at offset " + offset, e); // Need to restart the transaction. // The only way a caller knows that anything failed is that the // returned update count will be smaller than the requested // number of records. db.setTransactionSuccessful(); db.endTransaction(); db.beginTransaction(); } offset += MAX_POSITION_UPDATES_PER_QUERY; } db.setTransactionSuccessful(); db.endTransaction(); return updated; } /** * Construct and execute an update expression that will modify the positions * of records in-place. */ int updateBookmarkPositionsInTransaction(final SQLiteDatabase db, final String[] guids, final int offset, final int max) { int guidsCount = guids.length; int processCount = Math.min(max, guidsCount - offset); // Each must appear twice: once in a CASE, and once in the IN clause. String[] args = new String[processCount * 2]; System.arraycopy(guids, offset, args, 0, processCount); System.arraycopy(guids, offset, args, processCount, processCount); StringBuilder b = new StringBuilder("UPDATE " + TABLE_BOOKMARKS + " SET " + Bookmarks.POSITION + " = CASE guid"); // Build the CASE statement body for GUID/index pairs from offset up to // the computed limit. final int end = offset + processCount; int i = offset; for (; i < end; ++i) { if (guids[i] == null) { // We don't want to issue the query if not every GUID is specified. debug("updateBookmarkPositions called with null GUID at index " + i); return 0; } b.append(" WHEN ? THEN " + i); } b.append(" END WHERE " + Bookmarks.GUID + " IN ("); i = 1; while (i++ < processCount) { b.append("?, "); } b.append("?)"); db.execSQL(b.toString(), args); // We can't easily get a modified count without calling something like changes(). return processCount; } /** * 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); } long insertBookmark(Uri uri, ContentValues values) { // 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(BrowserContract.Bookmarks.DEFAULT_POSITION)); } String url = values.getAsString(Bookmarks.URL); ContentValues imageValues = extractImageValues(values, url); Integer type = values.getAsInteger(Bookmarks.TYPE); if ((type == null || type != Bookmarks.TYPE_FOLDER) && 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); final SQLiteDatabase db = getWritableDatabase(uri); return db.insertOrThrow(TABLE_BOOKMARKS, Bookmarks.TITLE, values); } int updateOrInsertBookmark(Uri uri, ContentValues values, String selection, String[] selectionArgs) { int updated = updateBookmarks(uri, values, selection, selectionArgs); if (updated > 0) return updated; insertBookmark(uri, values); // Return 0 if we added a new row return 0; } 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; } long insertHistory(Uri uri, ContentValues values) { final SQLiteDatabase db = getWritableDatabase(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, 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); return db.insertOrThrow(TABLE_HISTORY, History.VISITS, values); } int updateOrInsertHistory(Uri uri, ContentValues values, String selection, String[] selectionArgs) { int updated = updateHistory(uri, values, selection, selectionArgs); if (updated > 0) return updated; // Insert a new entry if necessary if (!values.containsKey(History.VISITS)) values.put(History.VISITS, 1); if (!values.containsKey(History.TITLE)) values.put(History.TITLE, values.getAsString(History.URL)); insertHistory(uri, values); // Return 0 if we added a new row return 0; } 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 History.VISITS // 2 }; 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); if (shouldIncrementVisits(uri)) { long existing = cursor.getLong(2); Long additional = values.getAsLong(History.VISITS); // Increment visit count by a specified amount, or default to increment by 1 values.put(History.VISITS, existing + ((additional != null) ? additional.longValue() : 1)); } 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); } debug("Marking history entry as deleted for URI: " + uri); ContentValues values = new ContentValues(); values.put(History.IS_DELETED, 1); // Wipe sensitive data. values.putNull(History.TITLE); values.put(History.URL, ""); // Column is NOT NULL. values.put(History.DATE_CREATED, 0); values.put(History.DATE_LAST_VISITED, 0); values.put(History.VISITS, 0); values.put(History.DATE_MODIFIED, System.currentTimeMillis()); cleanupSomeDeletedRecords(uri, History.CONTENT_URI, TABLE_HISTORY); return db.update(TABLE_HISTORY, 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); } 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); } 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); } @Override public ContentProviderResult[] applyBatch (ArrayList operations) throws OperationApplicationException { final int numOperations = operations.size(); final ContentProviderResult[] results = new ContentProviderResult[numOperations]; boolean failures = false; SQLiteDatabase db = null; if (numOperations >= 1) { // We only have 1 database for all Uri's that we can get db = getWritableDatabase(operations.get(0).getUri()); } else { // The original Android implementation returns a zero-length // array in this case, we do the same. return results; } // Note that the apply() call may cause us to generate // additional transactions for the invidual operations. // But Android's wrapper for SQLite supports nested transactions, // so this will do the right thing. db.beginTransaction(); for (int i = 0; i < numOperations; i++) { try { results[i] = operations.get(i).apply(this, results, i); } catch (SQLException e) { Log.w(LOGTAG, "SQLite Exception during applyBatch: ", e); // The Android API makes it implementation-defined whether // the failure of a single operation makes all others abort // or not. For our use cases, best-effort operation makes // more sense. Rolling back and forcing the caller to retry // after it figures out what went wrong isn't very convenient // anyway. // Signal failed operation back, so the caller knows what // went through and what didn't. results[i] = new ContentProviderResult(0); failures = true; // http://www.sqlite.org/lang_conflict.html // Note that we need a new transaction, subsequent operations // on this one will fail (we're in ABORT by default, which // isn't IGNORE). We still need to set it as successful to let // everything before the failed op go through. // We can't set conflict resolution on API level < 8, and even // above 8 it requires splitting the call per operation // (insert/update/delete). db.setTransactionSuccessful(); db.endTransaction(); db.beginTransaction(); } catch (OperationApplicationException e) { // Repeat of above. results[i] = new ContentProviderResult(0); failures = true; db.setTransactionSuccessful(); db.endTransaction(); db.beginTransaction(); } } trace("Flushing DB applyBatch..."); db.setTransactionSuccessful(); db.endTransaction(); if (failures) { throw new OperationApplicationException(); } return results; } @Override public int bulkInsert(Uri uri, ContentValues[] values) { if (values == null) return 0; int numValues = values.length; int successes = 0; final SQLiteDatabase db = getWritableDatabase(uri); db.beginTransaction(); try { for (int i = 0; i < numValues; i++) { insertInTransaction(uri, values[i]); successes++; } trace("Flushing DB bulkinsert..."); db.setTransactionSuccessful(); } finally { db.endTransaction(); } return successes; } }