/* 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/. */ package org.mozilla.gecko.db; import java.io.File; import java.util.Collections; import java.util.HashMap; import java.util.Map; import org.mozilla.gecko.GeckoProfile; import org.mozilla.gecko.db.BrowserContract.Clients; import org.mozilla.gecko.db.BrowserContract.Tabs; import org.mozilla.gecko.db.BrowserContract; import org.mozilla.gecko.db.DBUtils; import org.mozilla.gecko.util.ThreadUtils; 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.SQLException; 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 TabsProvider extends ContentProvider { private static final String LOGTAG = "GeckoTabsProvider"; private Context mContext; static final String DATABASE_NAME = "tabs.db"; static final int DATABASE_VERSION = 2; static final String TABLE_TABS = "tabs"; static final String TABLE_CLIENTS = "clients"; static final int TABS = 600; static final int TABS_ID = 601; static final int CLIENTS = 602; static final int CLIENTS_ID = 603; static final String DEFAULT_TABS_SORT_ORDER = Clients.LAST_MODIFIED + " DESC, " + Tabs.LAST_USED + " DESC"; static final String DEFAULT_CLIENTS_SORT_ORDER = Clients.LAST_MODIFIED + " DESC"; static final String INDEX_TABS_GUID = "tabs_guid_index"; static final String INDEX_TABS_POSITION = "tabs_position_index"; static final String INDEX_CLIENTS_GUID = "clients_guid_index"; static final UriMatcher URI_MATCHER = new UriMatcher(UriMatcher.NO_MATCH); static final Map TABS_PROJECTION_MAP; static final Map CLIENTS_PROJECTION_MAP; static { URI_MATCHER.addURI(BrowserContract.TABS_AUTHORITY, "tabs", TABS); URI_MATCHER.addURI(BrowserContract.TABS_AUTHORITY, "tabs/#", TABS_ID); URI_MATCHER.addURI(BrowserContract.TABS_AUTHORITY, "clients", CLIENTS); URI_MATCHER.addURI(BrowserContract.TABS_AUTHORITY, "clients/#", CLIENTS_ID); HashMap map; map = new HashMap(); map.put(Tabs._ID, Tabs._ID); map.put(Tabs.TITLE, Tabs.TITLE); map.put(Tabs.URL, Tabs.URL); map.put(Tabs.HISTORY, Tabs.HISTORY); map.put(Tabs.FAVICON, Tabs.FAVICON); map.put(Tabs.LAST_USED, Tabs.LAST_USED); map.put(Tabs.POSITION, Tabs.POSITION); map.put(Clients.GUID, Clients.GUID); map.put(Clients.NAME, Clients.NAME); map.put(Clients.LAST_MODIFIED, Clients.LAST_MODIFIED); TABS_PROJECTION_MAP = Collections.unmodifiableMap(map); map = new HashMap(); map.put(Clients.GUID, Clients.GUID); map.put(Clients.NAME, Clients.NAME); map.put(Clients.LAST_MODIFIED, Clients.LAST_MODIFIED); CLIENTS_PROJECTION_MAP = Collections.unmodifiableMap(map); } private HashMap mDatabasePerProfile; static final String selectColumn(String table, String column) { return table + "." + column + " = ?"; } // 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); } @Override public void onCreate(SQLiteDatabase db) { debug("Creating tabs.db: " + db.getPath()); debug("Creating " + TABLE_TABS + " table"); // Table for each tab on any client. db.execSQL("CREATE TABLE " + TABLE_TABS + "(" + Tabs._ID + " INTEGER PRIMARY KEY AUTOINCREMENT," + Tabs.CLIENT_GUID + " TEXT," + Tabs.TITLE + " TEXT," + Tabs.URL + " TEXT," + Tabs.HISTORY + " TEXT," + Tabs.FAVICON + " TEXT," + Tabs.LAST_USED + " INTEGER," + Tabs.POSITION + " INTEGER" + ");"); // Indices on CLIENT_GUID and POSITION. db.execSQL("CREATE INDEX " + INDEX_TABS_GUID + " ON " + TABLE_TABS + "(" + Tabs.CLIENT_GUID + ")"); db.execSQL("CREATE INDEX " + INDEX_TABS_POSITION + " ON " + TABLE_TABS + "(" + Tabs.POSITION + ")"); debug("Creating " + TABLE_CLIENTS + " table"); // Table for client's name-guid mapping. db.execSQL("CREATE TABLE " + TABLE_CLIENTS + "(" + Clients.GUID + " TEXT PRIMARY KEY," + Clients.NAME + " TEXT," + Clients.LAST_MODIFIED + " INTEGER" + ");"); // Index on GUID. db.execSQL("CREATE INDEX " + INDEX_CLIENTS_GUID + " ON " + TABLE_CLIENTS + "(" + Clients.GUID + ")"); createLocalClient(db); } // Insert a client row for our local Fennec client. private void createLocalClient(SQLiteDatabase db) { debug("Inserting local Fennec client into " + TABLE_CLIENTS + " table"); ContentValues values = new ContentValues(); values.put(BrowserContract.Clients.LAST_MODIFIED, System.currentTimeMillis()); db.insertOrThrow(TABLE_CLIENTS, null, values); } @Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { debug("Upgrading tabs.db: " + db.getPath() + " from " + oldVersion + " to " + newVersion); // 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: createLocalClient(db); break; } } } @Override public void onOpen(SQLiteDatabase db) { debug("Opening tabs.db: " + db.getPath()); Cursor cursor = null; try { cursor = db.rawQuery("PRAGMA synchronous=OFF", 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(); db.setLockingEnabled(false); } else { // Pre-Honeycomb, we can do some lesser optimizations. cursor = null; try { cursor = db.rawQuery("PRAGMA journal_mode=PERSIST", null); } finally { if (cursor != null) cursor.close(); } } } } private DatabaseHelper getDatabaseHelperForProfile(String profile) { // Each profile has a separate tabs.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(getContext()).getName(); } DatabaseHelper dbHelper; synchronized (this) { dbHelper = mDatabasePerProfile.get(profile); if (dbHelper != null) { return dbHelper; } String databasePath = getDatabasePath(profile); // Before bug 768532, the database was located outside if the // profile on Android 2.2. Make sure it is moved inside the profile // directory. if (Build.VERSION.SDK_INT == 8) { File oldPath = mContext.getDatabasePath("tabs-" + profile + ".db"); if (oldPath.exists()) { oldPath.renameTo(new File(databasePath)); } } dbHelper = new DatabaseHelper(getContext(), databasePath); mDatabasePerProfile.put(profile, dbHelper); DBUtils.ensureDatabaseIsNotLocked(dbHelper, databasePath); } debug("Created database helper for profile: " + profile); return dbHelper; } private String getDatabasePath(String profile) { trace("Getting database path for profile: " + profile); 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).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(); } @Override public boolean onCreate() { debug("Creating TabsProvider"); 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 TABS: trace("URI is TABS: " + uri); return Tabs.CONTENT_TYPE; case TABS_ID: trace("URI is TABS_ID: " + uri); return Tabs.CONTENT_ITEM_TYPE; case CLIENTS: trace("URI is CLIENTS: " + uri); return Clients.CONTENT_TYPE; case CLIENTS_ID: trace("URI is CLIENTS_ID: " + uri); return Clients.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 CLIENTS_ID: trace("Delete on CLIENTS_ID: " + uri); selection = DBUtils.concatenateWhere(selection, selectColumn(TABLE_CLIENTS, Clients.ROWID)); selectionArgs = DBUtils.appendSelectionArgs(selectionArgs, new String[] { Long.toString(ContentUris.parseId(uri)) }); // fall through case CLIENTS: trace("Delete on CLIENTS: " + uri); // Delete from both TABLE_TABS and TABLE_CLIENTS. deleteValues(uri, selection, selectionArgs, TABLE_TABS); deleted = deleteValues(uri, selection, selectionArgs, TABLE_CLIENTS); break; case TABS_ID: trace("Delete on TABS_ID: " + uri); selection = DBUtils.concatenateWhere(selection, selectColumn(TABLE_TABS, Tabs._ID)); selectionArgs = DBUtils.appendSelectionArgs(selectionArgs, new String[] { Long.toString(ContentUris.parseId(uri)) }); // fall through case TABS: trace("Deleting on TABS: " + uri); deleted = deleteValues(uri, selection, selectionArgs, TABLE_TABS); 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 CLIENTS: String guid = values.getAsString(Clients.GUID); debug("Inserting client in database with GUID: " + guid); id = db.insertOrThrow(TABLE_CLIENTS, Clients.GUID, values); break; case TABS: String url = values.getAsString(Tabs.URL); debug("Inserting tab in database with URL: " + url); id = db.insertOrThrow(TABLE_TABS, Tabs.TITLE, 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; } 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 CLIENTS_ID: trace("Update on CLIENTS_ID: " + uri); selection = DBUtils.concatenateWhere(selection, selectColumn(TABLE_CLIENTS, Clients.ROWID)); selectionArgs = DBUtils.appendSelectionArgs(selectionArgs, new String[] { Long.toString(ContentUris.parseId(uri)) }); // fall through case CLIENTS: trace("Update on CLIENTS: " + uri); updated = updateValues(uri, values, selection, selectionArgs, TABLE_CLIENTS); break; case TABS_ID: trace("Update on TABS_ID: " + uri); selection = DBUtils.concatenateWhere(selection, selectColumn(TABLE_TABS, Tabs._ID)); selectionArgs = DBUtils.appendSelectionArgs(selectionArgs, new String[] { Long.toString(ContentUris.parseId(uri)) }); // fall through case TABS: trace("Update on TABS: " + uri); updated = updateValues(uri, values, selection, selectionArgs, TABLE_TABS); break; default: throw new UnsupportedOperationException("Unknown update URI " + uri); } debug("Updated " + updated + " rows for URI: " + uri); return updated; } @Override @SuppressWarnings("fallthrough") 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 TABS_ID: trace("Query is on TABS_ID: " + uri); selection = DBUtils.concatenateWhere(selection, selectColumn(TABLE_TABS, Tabs._ID)); selectionArgs = DBUtils.appendSelectionArgs(selectionArgs, new String[] { Long.toString(ContentUris.parseId(uri)) }); // fall through case TABS: trace("Query is on TABS: " + uri); if (TextUtils.isEmpty(sortOrder)) { sortOrder = DEFAULT_TABS_SORT_ORDER; } else { debug("Using sort order " + sortOrder + "."); } qb.setProjectionMap(TABS_PROJECTION_MAP); qb.setTables(TABLE_TABS + " LEFT OUTER JOIN " + TABLE_CLIENTS + " ON (" + TABLE_TABS + "." + Tabs.CLIENT_GUID + " = " + TABLE_CLIENTS + "." + Clients.GUID + ")"); break; case CLIENTS_ID: trace("Query is on CLIENTS_ID: " + uri); selection = DBUtils.concatenateWhere(selection, selectColumn(TABLE_CLIENTS, Clients.ROWID)); selectionArgs = DBUtils.appendSelectionArgs(selectionArgs, new String[] { Long.toString(ContentUris.parseId(uri)) }); // fall through case CLIENTS: trace("Query is on CLIENTS: " + uri); if (TextUtils.isEmpty(sortOrder)) { sortOrder = DEFAULT_CLIENTS_SORT_ORDER; } else { debug("Using sort order " + sortOrder + "."); } qb.setProjectionMap(CLIENTS_PROJECTION_MAP); qb.setTables(TABLE_CLIENTS); break; 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.TABS_AUTHORITY_URI); return cursor; } int updateValues(Uri uri, ContentValues values, String selection, String[] selectionArgs, String table) { trace("Updating tabs on URI: " + uri); final SQLiteDatabase db = getWritableDatabase(uri); return db.update(table, values, selection, selectionArgs); } int deleteValues(Uri uri, String selection, String[] selectionArgs, String table) { debug("Deleting tabs for URI: " + uri); final SQLiteDatabase db = getWritableDatabase(uri); return db.delete(table, selection, selectionArgs); } @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++) { try { insertInTransaction(uri, values[i]); successes++; } catch (SQLException e) { Log.e(LOGTAG, "SQLException in bulkInsert", e); // Restart the transaction to continue insertions. db.setTransactionSuccessful(); db.endTransaction(); db.beginTransaction(); } } trace("Flushing DB bulkinsert..."); db.setTransactionSuccessful(); } finally { db.endTransaction(); } if (successes > 0) mContext.getContentResolver().notifyChange(uri, null); return successes; } }