Bug 946857 - Part 1 : Android Login provider r?nalexander draft
authorvivek <vivekb.balakrishnan@gmail.com>
Wed, 23 Dec 2015 23:01:41 +0200
changeset 319352 ed2b990adc5f908f15f1e74d197974f714b0eb2f
parent 319351 73d13e2ab128cfaa6b99f507466c9138258c68df
child 319353 e736463511bd01392c58e928cf811ed40547e2c5
child 319379 ef6d060714e220e32f7461532548e0baf29fd149
push id9014
push userbmo:vivekb.balakrishnan@gmail.com
push dateWed, 06 Jan 2016 17:33:35 +0000
reviewersnalexander
bugs946857
milestone46.0a1
Bug 946857 - Part 1 : Android Login provider r?nalexander
mobile/android/base/AndroidManifest.xml.in
mobile/android/base/java/org/mozilla/gecko/db/BrowserContract.java
mobile/android/base/java/org/mozilla/gecko/db/BrowserDatabaseHelper.java
mobile/android/base/java/org/mozilla/gecko/db/LoginsProvider.java
mobile/android/base/moz.build
mobile/android/tests/browser/robocop/robocop.ini
mobile/android/tests/browser/robocop/src/org/mozilla/gecko/tests/testLoginProvider.java
--- a/mobile/android/base/AndroidManifest.xml.in
+++ b/mobile/android/base/AndroidManifest.xml.in
@@ -420,16 +420,21 @@
                   android:exported="false"
                   android:process="@MANGLED_ANDROID_PACKAGE_NAME@.PasswordsProvider"/>
 
         <provider android:name="org.mozilla.gecko.db.FormHistoryProvider"
                   android:label="@string/sync_configure_engines_title_history"
                   android:authorities="@ANDROID_PACKAGE_NAME@.db.formhistory"
                   android:exported="false"/>
 
+        <provider android:name="org.mozilla.gecko.db.LoginsProvider"
+                  android:label="@string/sync_configure_engines_title_passwords"
+                  android:authorities="@ANDROID_PACKAGE_NAME@.db.logins"
+                  android:exported="false"/>
+
         <provider android:name="org.mozilla.gecko.GeckoProfilesProvider"
                   android:authorities="@ANDROID_PACKAGE_NAME@.profiles"
                   android:exported="false"/>
 
         <provider android:name="org.mozilla.gecko.db.TabsProvider"
                   android:label="@string/sync_configure_engines_title_tabs"
                   android:authorities="@ANDROID_PACKAGE_NAME@.db.tabs"
                   android:exported="false"/>
--- a/mobile/android/base/java/org/mozilla/gecko/db/BrowserContract.java
+++ b/mobile/android/base/java/org/mozilla/gecko/db/BrowserContract.java
@@ -31,16 +31,19 @@ public class BrowserContract {
     public static final Uri PROFILES_AUTHORITY_URI = Uri.parse("content://" + PROFILES_AUTHORITY);
 
     public static final String READING_LIST_AUTHORITY = AppConstants.ANDROID_PACKAGE_NAME + ".db.readinglist";
     public static final Uri READING_LIST_AUTHORITY_URI = Uri.parse("content://" + READING_LIST_AUTHORITY);
 
     public static final String SEARCH_HISTORY_AUTHORITY = AppConstants.ANDROID_PACKAGE_NAME + ".db.searchhistory";
     public static final Uri SEARCH_HISTORY_AUTHORITY_URI = Uri.parse("content://" + SEARCH_HISTORY_AUTHORITY);
 
+    public static final String LOGINS_AUTHORITY = AppConstants.ANDROID_PACKAGE_NAME + ".db.logins";
+    public static final Uri LOGINS_AUTHORITY_URI = Uri.parse("content://" + LOGINS_AUTHORITY);
+
     public static final String PARAM_PROFILE = "profile";
     public static final String PARAM_PROFILE_PATH = "profilePath";
     public static final String PARAM_LIMIT = "limit";
     public static final String PARAM_IS_SYNC = "sync";
     public static final String PARAM_SHOW_DELETED = "show_deleted";
     public static final String PARAM_IS_TEST = "test";
     public static final String PARAM_INSERT_IF_NEEDED = "insert_if_needed";
     public static final String PARAM_INCREMENT_VISITS = "increment_visits";
@@ -489,15 +492,64 @@ public class BrowserContract {
 
     @RobocopTarget
     public static final class SuggestedSites implements CommonColumns, URLColumns {
         private SuggestedSites() {}
 
         public static final Uri CONTENT_URI = Uri.withAppendedPath(AUTHORITY_URI, "suggestedsites");
     }
 
+    @RobocopTarget
+    public static final class Logins implements CommonColumns {
+        private Logins() {}
+
+        public static final Uri CONTENT_URI = Uri.withAppendedPath(LOGINS_AUTHORITY_URI, "logins");
+        public static final String CONTENT_TYPE = "vnd.android.cursor.dir/logins";
+        public static final String CONTENT_ITEM_TYPE = "vnd.android.cursor.item/logins";
+        public static final String TABLE_LOGINS = "logins";
+
+        public static final String HOSTNAME = "hostname";
+        public static final String HTTP_REALM = "httpRealm";
+        public static final String FORM_SUBMIT_URL = "formSubmitURL";
+        public static final String USERNAME_FIELD = "usernameField";
+        public static final String PASSWORD_FIELD = "passwordField";
+        public static final String ENCRYPTED_USERNAME = "encryptedUsername";
+        public static final String ENCRYPTED_PASSWORD = "encryptedPassword";
+        public static final String ENC_TYPE = "encType";
+        public static final String TIME_CREATED = "timeCreated";
+        public static final String TIME_LAST_USED = "timeLastUsed";
+        public static final String TIME_PASSWORD_CHANGED = "timePasswordChanged";
+        public static final String TIMES_USED = "timesUsed";
+        public static final String GUID = "guid";
+    }
+
+    @RobocopTarget
+    public static final class DeletedLogins implements CommonColumns {
+        private DeletedLogins() {}
+
+        public static final Uri CONTENT_URI = Uri.withAppendedPath(LOGINS_AUTHORITY_URI, "deleted-logins");
+        public static final String CONTENT_TYPE = "vnd.android.cursor.dir/deleted-logins";
+        public static final String CONTENT_ITEM_TYPE = "vnd.android.cursor.item/deleted-logins";
+        public static final String TABLE_DELETED_LOGINS = "deleted_logins";
+
+        public static final String GUID = "guid";
+        public static final String TIME_DELETED = "timeDeleted";
+    }
+
+    @RobocopTarget
+    public static final class DisabledHosts implements CommonColumns {
+        private DisabledHosts() {}
+
+        public static final Uri CONTENT_URI = Uri.withAppendedPath(LOGINS_AUTHORITY_URI, "disabled-hosts");
+        public static final String CONTENT_TYPE = "vnd.android.cursor.dir/disabled-hosts";
+        public static final String CONTENT_ITEM_TYPE = "vnd.android.cursor.item/disabled-hosts";
+        public static final String TABLE_DISABLED_HOSTS = "disabled_hosts";
+
+        public static final String HOSTNAME = "hostname";
+    }
+
     // We refer to the service by name to decouple services from the rest of the code base.
     public static final String TAB_RECEIVED_SERVICE_CLASS_NAME = "org.mozilla.gecko.tabqueue.TabReceivedService";
 
     public static final String SKIP_TAB_QUEUE_FLAG = "skip_tab_queue";
 
     public static final String EXTRA_CLIENT_GUID = "org.mozilla.gecko.extra.CLIENT_ID";
 }
--- a/mobile/android/base/java/org/mozilla/gecko/db/BrowserDatabaseHelper.java
+++ b/mobile/android/base/java/org/mozilla/gecko/db/BrowserDatabaseHelper.java
@@ -34,28 +34,31 @@ import android.database.sqlite.SQLiteOpe
 import android.net.Uri;
 import android.os.Build;
 import android.util.Log;
 
 
 final class BrowserDatabaseHelper extends SQLiteOpenHelper {
     private static final String LOGTAG = "GeckoBrowserDBHelper";
 
-    public static final int DATABASE_VERSION = 26; // Bug 1128675
+    public static final int DATABASE_VERSION = 27;
     public static final String DATABASE_NAME = "browser.db";
 
     final protected Context mContext;
 
     static final String TABLE_BOOKMARKS = Bookmarks.TABLE_NAME;
     static final String TABLE_HISTORY = History.TABLE_NAME;
     static final String TABLE_FAVICONS = Favicons.TABLE_NAME;
     static final String TABLE_THUMBNAILS = Thumbnails.TABLE_NAME;
     static final String TABLE_READING_LIST = ReadingListItems.TABLE_NAME;
     static final String TABLE_TABS = TabsProvider.TABLE_TABS;
     static final String TABLE_CLIENTS = TabsProvider.TABLE_CLIENTS;
+    static final String TABLE_LOGINS = BrowserContract.Logins.TABLE_LOGINS;
+    static final String TABLE_DELETED_LOGINS = BrowserContract.DeletedLogins.TABLE_DELETED_LOGINS;
+    static final String TABLE_DISABLED_HOSTS = BrowserContract.DisabledHosts.TABLE_DISABLED_HOSTS;
 
     static final String VIEW_COMBINED = Combined.VIEW_NAME;
     static final String VIEW_BOOKMARKS_WITH_FAVICONS = Bookmarks.VIEW_WITH_FAVICONS;
     static final String VIEW_HISTORY_WITH_FAVICONS = History.VIEW_WITH_FAVICONS;
     static final String VIEW_COMBINED_WITH_FAVICONS = Combined.VIEW_WITH_FAVICONS;
 
     static final String TABLE_BOOKMARKS_JOIN_FAVICONS = TABLE_BOOKMARKS + " LEFT OUTER JOIN " +
             TABLE_FAVICONS + " ON " + qualifyColumn(TABLE_BOOKMARKS, Bookmarks.FAVICON_ID) + " = " +
@@ -320,16 +323,73 @@ final class BrowserDatabaseHelper extend
                 " SELECT " + qualifyColumn(VIEW_COMBINED, "*") + ", " +
                     qualifyColumn(TABLE_FAVICONS, Favicons.URL) + " AS " + Combined.FAVICON_URL + ", " +
                     qualifyColumn(TABLE_FAVICONS, Favicons.DATA) + " AS " + Combined.FAVICON +
                 " FROM " + VIEW_COMBINED + " LEFT OUTER JOIN " + TABLE_FAVICONS +
                     " ON " + Combined.FAVICON_ID + " = " + qualifyColumn(TABLE_FAVICONS, Favicons._ID));
 
     }
 
+    private boolean didCreateLoginsTable = false;
+    private void createLoginsTable(SQLiteDatabase db, final String tableName) {
+        debug("Creating logins.db: " + db.getPath());
+        debug("Creating " + tableName + " table");
+
+        // Table for each login.
+        db.execSQL("CREATE TABLE " + tableName + "(" +
+                BrowserContract.Logins._ID + " INTEGER PRIMARY KEY AUTOINCREMENT," +
+                BrowserContract.Logins.HOSTNAME + " TEXT NOT NULL," +
+                BrowserContract.Logins.HTTP_REALM + " TEXT," +
+                BrowserContract.Logins.FORM_SUBMIT_URL + " TEXT," +
+                BrowserContract.Logins.USERNAME_FIELD + " TEXT NOT NULL," +
+                BrowserContract.Logins.PASSWORD_FIELD + " TEXT NOT NULL," +
+                BrowserContract.Logins.ENCRYPTED_USERNAME + " TEXT NOT NULL," +
+                BrowserContract.Logins.ENCRYPTED_PASSWORD + " TEXT NOT NULL," +
+                BrowserContract.Logins.GUID + " TEXT UNIQUE NOT NULL," +
+                BrowserContract.Logins.ENC_TYPE + " INTEGER NOT NULL, " +
+                BrowserContract.Logins.TIME_CREATED + " INTEGER," +
+                BrowserContract.Logins.TIME_LAST_USED + " INTEGER," +
+                BrowserContract.Logins.TIME_PASSWORD_CHANGED + " INTEGER," +
+                BrowserContract.Logins.TIMES_USED + " INTEGER" +
+                ");");
+    }
+
+    private void createLoginsTableIndices(SQLiteDatabase db, final String tableName) {
+        // No need to create an index on GUID, it is an unique column.
+        db.execSQL("CREATE INDEX " + LoginsProvider.INDEX_LOGINS_HOSTNAME +
+                " ON " + tableName + "(" + BrowserContract.Logins.HOSTNAME + ")");
+        db.execSQL("CREATE INDEX " + LoginsProvider.INDEX_LOGINS_HOSTNAME_FORM_SUBMIT_URL +
+                " ON " + tableName + "(" + BrowserContract.Logins.HOSTNAME + "," + BrowserContract.Logins.FORM_SUBMIT_URL + ")");
+        db.execSQL("CREATE INDEX " + LoginsProvider.INDEX_LOGINS_HOSTNAME_HTTP_REALM +
+                " ON " + tableName + "(" + BrowserContract.Logins.HOSTNAME + "," + BrowserContract.Logins.HTTP_REALM + ")");
+    }
+
+    private void createDeletedLoginsTable(SQLiteDatabase db, final String tableName) {
+        debug("Creating deleted_logins.db: " + db.getPath());
+        debug("Creating " + tableName + " table");
+
+        // Table for each deleted login.
+        db.execSQL("CREATE TABLE " + tableName + "(" +
+                BrowserContract.DeletedLogins._ID + " INTEGER PRIMARY KEY AUTOINCREMENT," +
+                BrowserContract.DeletedLogins.GUID + " TEXT UNIQUE NOT NULL," +
+                BrowserContract.DeletedLogins.TIME_DELETED + " INTEGER NOT NULL" +
+                ");");
+    }
+
+    private void createDisabledHostsTable(SQLiteDatabase db, final String tableName) {
+        debug("Creating disabled_hosts.db: " + db.getPath());
+        debug("Creating " + tableName + " table");
+
+        // Table for each disabled host.
+        db.execSQL("CREATE TABLE " + tableName + "(" +
+                BrowserContract.DisabledHosts._ID + " INTEGER PRIMARY KEY AUTOINCREMENT," +
+                BrowserContract.DisabledHosts.HOSTNAME + " TEXT UNIQUE NOT NULL ON CONFLICT REPLACE" +
+                ");");
+    }
+
     @Override
     public void onCreate(SQLiteDatabase db) {
         debug("Creating browser.db: " + db.getPath());
 
         for (Table table : BrowserProvider.sTables) {
             table.onCreate(db);
         }
 
@@ -351,16 +411,22 @@ final class BrowserDatabaseHelper extend
         createOrUpdateSpecialFolder(db, Bookmarks.PLACES_FOLDER_GUID,
             R.string.bookmarks_folder_places, 0);
 
         createOrUpdateAllSpecialFolders(db);
         createSearchHistoryTable(db);
         createReadingListTable(db, TABLE_READING_LIST);
         didCreateCurrentReadingListTable = true;      // Mostly correct, in the absence of transactions.
         createReadingListIndices(db, TABLE_READING_LIST);
+
+        createDeletedLoginsTable(db, TABLE_DELETED_LOGINS);
+        createDisabledHostsTable(db, TABLE_DISABLED_HOSTS);
+        createLoginsTable(db, TABLE_LOGINS);
+        didCreateLoginsTable = true;
+        createLoginsTableIndices(db, TABLE_LOGINS);
     }
 
     /**
      * Copies the tabs and clients tables out of the given tabs.db file and into the destinationDB.
      *
      * @param tabsDBFile Path to existing tabs.db.
      * @param destinationDB The destination database.
      */
@@ -1025,16 +1091,108 @@ final class BrowserDatabaseHelper extend
 
     private void upgradeDatabaseFrom25to26(SQLiteDatabase db) {
         debug("Dropping unnecessary indices");
         db.execSQL("DROP INDEX IF EXISTS clients_guid_index");
         db.execSQL("DROP INDEX IF EXISTS thumbnails_url_index");
         db.execSQL("DROP INDEX IF EXISTS favicons_url_index");
     }
 
+    private void upgradeDatabaseFrom26to27(final SQLiteDatabase db) {
+        if (didCreateLoginsTable) {
+            debug("No need to rev login schemas; we just created the android schemas.");
+            return;
+        }
+
+        createDeletedLoginsTable(db, TABLE_DELETED_LOGINS);
+        createDisabledHostsTable(db, TABLE_DISABLED_HOSTS);
+        createLoginsTable(db, TABLE_LOGINS);
+        createLoginsTableIndices(db, TABLE_LOGINS);
+        didCreateLoginsTable = true;
+
+        migrateLogins(db);
+        migrateDeletedLogins(db);
+        migrateDisabledHosts(db);
+    }
+
+    private void migrateLogins(final SQLiteDatabase db) {
+        Cursor cursor = null;
+        try {
+            cursor = mContext.getContentResolver().query(BrowserContract.Passwords.CONTENT_URI, null, null, null, null);
+            if (cursor == null) {
+                return;
+            }
+
+            for (cursor.moveToFirst(); !cursor.isAfterLast(); cursor.moveToNext()) {
+                final ContentValues contentValues = new ContentValues();
+                try {
+                    DatabaseUtils.cursorRowToContentValues(cursor, contentValues);
+                    contentValues.remove(BrowserContract.Passwords.ID);
+                    db.insertOrThrow(TABLE_LOGINS, BrowserContract.Logins.GUID, contentValues);
+                } catch (Exception e) {
+                    Log.w(LOGTAG, "Ignoring exception caught while migrating logins.", e);
+                }
+            }
+        } finally {
+            if (cursor != null) {
+                cursor.close();
+            }
+        }
+    }
+
+    private void migrateDeletedLogins(final SQLiteDatabase db) {
+        Cursor cursor = null;
+        try {
+            cursor = mContext.getContentResolver().query(BrowserContract.DeletedPasswords.CONTENT_URI, null, null, null, null);
+            if (cursor == null) {
+                return;
+            }
+
+            for (cursor.moveToFirst(); !cursor.isAfterLast(); cursor.moveToNext()) {
+                final ContentValues contentValues = new ContentValues();
+                try {
+                    DatabaseUtils.cursorRowToContentValues(cursor, contentValues);
+                    contentValues.remove(BrowserContract.DeletedPasswords.ID);
+                    db.insertOrThrow(TABLE_DELETED_LOGINS, BrowserContract.DeletedLogins.GUID, contentValues);
+                } catch (Exception e) {
+                    Log.w(LOGTAG, "Ignoring exception caught while migrating deleted logins.", e);
+                }
+            }
+        } finally {
+            if (cursor != null) {
+                cursor.close();
+            }
+        }
+    }
+
+
+    private void migrateDisabledHosts(final SQLiteDatabase db) {
+        Cursor cursor = null;
+        try {
+            cursor = mContext.getContentResolver().query(BrowserContract.GeckoDisabledHosts.CONTENT_URI, null, null, null, null);
+            if (cursor == null) {
+                return;
+            }
+
+            for (cursor.moveToFirst(); !cursor.isAfterLast(); cursor.moveToNext()) {
+                final ContentValues contentValues = new ContentValues();
+                try {
+                    DatabaseUtils.cursorRowToContentValues(cursor, contentValues);
+                    db.insertOrThrow(TABLE_DISABLED_HOSTS, BrowserContract.DisabledHosts.HOSTNAME, contentValues);
+                } catch (Exception e) {
+                    Log.w(LOGTAG, "Ignoring exception caught while migrating disabled hosts.", e);
+                }
+            }
+        } finally {
+            if (cursor != null) {
+                cursor.close();
+            }
+        }
+    }
+
     private void createV19CombinedView(SQLiteDatabase db) {
         db.execSQL("DROP VIEW IF EXISTS " + VIEW_COMBINED);
         db.execSQL("DROP VIEW IF EXISTS " + VIEW_COMBINED_WITH_FAVICONS);
 
         createCombinedViewOn19(db);
     }
 
     @Override
@@ -1108,16 +1266,20 @@ final class BrowserDatabaseHelper extend
 
                 case 25:
                     upgradeDatabaseFrom24to25(db);
                     break;
 
                 case 26:
                     upgradeDatabaseFrom25to26(db);
                     break;
+
+                case 27:
+                    upgradeDatabaseFrom26to27(db);
+                    break;
             }
         }
 
         for (Table table : BrowserProvider.sTables) {
             table.onUpgrade(db, oldVersion, newVersion);
         }
 
         // Delete the obsolete favicon database after all other upgrades complete.
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/db/LoginsProvider.java
@@ -0,0 +1,463 @@
+/* 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 org.mozilla.gecko.db.BrowserContract.DeletedLogins;
+import org.mozilla.gecko.db.BrowserContract.DisabledHosts;
+import org.mozilla.gecko.db.BrowserContract.Logins;
+import org.mozilla.gecko.sync.Utils;
+
+import android.content.ContentUris;
+import android.content.ContentValues;
+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.SQLiteQueryBuilder;
+import android.net.Uri;
+import android.text.TextUtils;
+
+import java.util.HashMap;
+
+import static org.mozilla.gecko.db.BrowserContract.DeletedLogins.TABLE_DELETED_LOGINS;
+import static org.mozilla.gecko.db.BrowserContract.DisabledHosts.TABLE_DISABLED_HOSTS;
+import static org.mozilla.gecko.db.BrowserContract.Logins.TABLE_LOGINS;
+
+public class LoginsProvider extends SharedBrowserDatabaseProvider {
+
+    private static final int LOGINS = 100;
+    private static final int LOGINS_ID = 101;
+    private static final int DELETED_LOGINS = 102;
+    private static final int DELETED_LOGINS_ID = 103;
+    private static final int DISABLED_HOSTS = 104;
+    private static final int DISABLED_HOSTS_HOSTNAME = 105;
+    private static final UriMatcher URI_MATCHER = new UriMatcher(UriMatcher.NO_MATCH);
+
+    private static final HashMap<String, String> LOGIN_PROJECTION_MAP;
+    private static final HashMap<String, String> DELETED_LOGIN_PROJECTION_MAP;
+    private static final HashMap<String, String> DISABLED_HOSTS_PROJECTION_MAP;
+
+    private static final String DEFAULT_LOGINS_SORT_ORDER = Logins.HOSTNAME + " ASC";
+    private static final String DEFAULT_DELETED_LOGINS_SORT_ORDER = DeletedLogins.TIME_DELETED + " ASC";
+    private static final String DEFAULT_DISABLED_HOSTS_SORT_ORDER = DisabledHosts.HOSTNAME + " ASC";
+    private static final String WHERE_GUID_IS_NULL = DeletedLogins.GUID + " IS NULL";
+    private static final String WHERE_GUID_IS_VALUE = DeletedLogins.GUID + " = ?";
+
+    protected static final String INDEX_LOGINS_HOSTNAME = "login_hostname_index";
+    protected static final String INDEX_LOGINS_HOSTNAME_FORM_SUBMIT_URL = "login_hostname_formSubmitURL_index";
+    protected static final String INDEX_LOGINS_HOSTNAME_HTTP_REALM = "login_hostname_httpRealm_index";
+
+    static {
+        URI_MATCHER.addURI(BrowserContract.LOGINS_AUTHORITY, "logins", LOGINS);
+        URI_MATCHER.addURI(BrowserContract.LOGINS_AUTHORITY, "logins/#", LOGINS_ID);
+        URI_MATCHER.addURI(BrowserContract.LOGINS_AUTHORITY, "deleted-logins", DELETED_LOGINS);
+        URI_MATCHER.addURI(BrowserContract.LOGINS_AUTHORITY, "deleted-logins/#", DELETED_LOGINS_ID);
+        URI_MATCHER.addURI(BrowserContract.LOGINS_AUTHORITY, "disabled-hosts", DISABLED_HOSTS);
+        URI_MATCHER.addURI(BrowserContract.LOGINS_AUTHORITY, "disabled-hosts/hostname/*", DISABLED_HOSTS_HOSTNAME);
+
+        LOGIN_PROJECTION_MAP = new HashMap<>();
+        LOGIN_PROJECTION_MAP.put(Logins._ID, Logins._ID);
+        LOGIN_PROJECTION_MAP.put(Logins.HOSTNAME, Logins.HOSTNAME);
+        LOGIN_PROJECTION_MAP.put(Logins.HTTP_REALM, Logins.HTTP_REALM);
+        LOGIN_PROJECTION_MAP.put(Logins.FORM_SUBMIT_URL, Logins.FORM_SUBMIT_URL);
+        LOGIN_PROJECTION_MAP.put(Logins.USERNAME_FIELD, Logins.USERNAME_FIELD);
+        LOGIN_PROJECTION_MAP.put(Logins.PASSWORD_FIELD, Logins.PASSWORD_FIELD);
+        LOGIN_PROJECTION_MAP.put(Logins.ENCRYPTED_USERNAME, Logins.ENCRYPTED_USERNAME);
+        LOGIN_PROJECTION_MAP.put(Logins.ENCRYPTED_PASSWORD, Logins.ENCRYPTED_PASSWORD);
+        LOGIN_PROJECTION_MAP.put(Logins.GUID, Logins.GUID);
+        LOGIN_PROJECTION_MAP.put(Logins.ENC_TYPE, Logins.ENC_TYPE);
+        LOGIN_PROJECTION_MAP.put(Logins.TIME_CREATED, Logins.TIME_CREATED);
+        LOGIN_PROJECTION_MAP.put(Logins.TIME_LAST_USED, Logins.TIME_LAST_USED);
+        LOGIN_PROJECTION_MAP.put(Logins.TIME_PASSWORD_CHANGED, Logins.TIME_PASSWORD_CHANGED);
+        LOGIN_PROJECTION_MAP.put(Logins.TIMES_USED, Logins.TIMES_USED);
+
+        DELETED_LOGIN_PROJECTION_MAP = new HashMap<>();
+        DELETED_LOGIN_PROJECTION_MAP.put(DeletedLogins._ID, DeletedLogins._ID);
+        DELETED_LOGIN_PROJECTION_MAP.put(DeletedLogins.GUID, DeletedLogins.GUID);
+        DELETED_LOGIN_PROJECTION_MAP.put(DeletedLogins.TIME_DELETED, DeletedLogins.TIME_DELETED);
+
+        DISABLED_HOSTS_PROJECTION_MAP = new HashMap<>();
+        DISABLED_HOSTS_PROJECTION_MAP.put(DisabledHosts._ID, DisabledHosts._ID);
+        DISABLED_HOSTS_PROJECTION_MAP.put(DisabledHosts.HOSTNAME, DisabledHosts.HOSTNAME);
+    }
+
+    private static final String projectColumn(String table, String column) {
+        return table + "." + column;
+    }
+
+    private static final String selectColumn(String table, String column) {
+        return projectColumn(table, column) + " = ?";
+    }
+
+    @Override
+    protected Uri insertInTransaction(Uri uri, ContentValues values) {
+        trace("Calling insert in transaction on URI: " + uri);
+
+        final int match = URI_MATCHER.match(uri);
+        final SQLiteDatabase db = getWritableDatabase(uri);
+        long id = -1;
+        String guid;
+
+        setupDefaultValues(values, uri);
+        switch (match) {
+            case LOGINS:
+                removeDeletedLoginsByGUIDInTransaction(values, db);
+                // Encrypt sensitive data.
+                encryptLogins(values);
+                guid = values.getAsString(Logins.GUID);
+                debug("Inserting login in database with GUID: " + guid);
+                id = db.insertOrThrow(TABLE_LOGINS, Logins.GUID, values);
+                break;
+
+            case DELETED_LOGINS:
+                guid = values.getAsString(DeletedLogins.GUID);
+                debug("Inserting deleted-login in database with GUID: " + guid);
+                id = db.insertOrThrow(TABLE_DELETED_LOGINS, DeletedLogins.GUID, values);
+                break;
+
+            case DISABLED_HOSTS:
+                String hostname = values.getAsString(DisabledHosts.HOSTNAME);
+                debug("Inserting disabled-host in database with hostname: " + hostname);
+                id = db.insertOrThrow(TABLE_DISABLED_HOSTS, DisabledHosts.HOSTNAME, 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
+    @SuppressWarnings("fallthrough")
+    protected int deleteInTransaction(Uri uri, String selection, String[] selectionArgs) {
+        trace("Calling delete in transaction on URI: " + uri);
+
+        final int match = URI_MATCHER.match(uri);
+        final String table;
+        final SQLiteDatabase db = getWritableDatabase(uri);
+
+        beginWrite(db);
+        switch (match) {
+            case LOGINS_ID:
+                trace("Delete on LOGINS_ID: " + uri);
+                selection = DBUtils.concatenateWhere(selection, selectColumn(TABLE_LOGINS, Logins._ID));
+                selectionArgs = DBUtils.appendSelectionArgs(selectionArgs,
+                        new String[]{Long.toString(ContentUris.parseId(uri))});
+                // Store the deleted client in deleted-logins table.
+                boolean isInsertSuccessful = storeDeletedLoginForGUIDInTranscation(selectionArgs.length > 0 ? selectionArgs[0] : null, db);
+                if (!isInsertSuccessful) {
+                    // Failed to insert into deleted-logins, return early.
+                    return 0;
+                }
+            // fall through
+            case LOGINS:
+                trace("Delete on LOGINS: " + uri);
+                table = TABLE_LOGINS;
+                break;
+
+            case DELETED_LOGINS_ID:
+                trace("Delete on DELETED_LOGINS_ID: " + uri);
+                selection = DBUtils.concatenateWhere(selection, selectColumn(TABLE_DELETED_LOGINS, DeletedLogins._ID));
+                selectionArgs = DBUtils.appendSelectionArgs(selectionArgs,
+                        new String[]{Long.toString(ContentUris.parseId(uri))});
+            // fall through
+            case DELETED_LOGINS:
+                trace("Delete on DELETED_LOGINS_ID: " + uri);
+                table = TABLE_DELETED_LOGINS;
+                break;
+
+            case DISABLED_HOSTS_HOSTNAME:
+                trace("Delete on DISABLED_HOSTS_HOSTNAME: " + uri);
+                selection = DBUtils.concatenateWhere(selection, selectColumn(TABLE_DISABLED_HOSTS, DisabledHosts.HOSTNAME));
+                selectionArgs = DBUtils.appendSelectionArgs(selectionArgs,
+                        new String[]{uri.getLastPathSegment()});
+            // fall through
+            case DISABLED_HOSTS:
+                trace("Delete on DISABLED_HOSTS: " + uri);
+                table = TABLE_DISABLED_HOSTS;
+                break;
+
+            default:
+                throw new UnsupportedOperationException("Unknown delete URI " + uri);
+        }
+
+        debug("Deleting " + table + " for URI: " + uri);
+        return db.delete(table, selection, selectionArgs);
+    }
+
+    @Override
+    protected int updateInTransaction(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
+        trace("Calling update in transaction on URI: " + uri);
+
+        final int match = URI_MATCHER.match(uri);
+        final SQLiteDatabase db = getWritableDatabase(uri);
+        final String table;
+
+        beginWrite(db);
+        switch (match) {
+            case LOGINS_ID:
+                trace("Update on LOGINS_ID: " + uri);
+                table = TABLE_LOGINS;
+                selection = DBUtils.concatenateWhere(selection, selectColumn(TABLE_LOGINS, Logins._ID));
+                selectionArgs = DBUtils.appendSelectionArgs(selectionArgs,
+                        new String[]{Long.toString(ContentUris.parseId(uri))});
+                // Encrypt sensitive data.
+                encryptLogins(values);
+                break;
+
+            default:
+                throw new UnsupportedOperationException("Unknown delete URI " + uri);
+        }
+
+        trace("Updating " + table + " on URI: " + uri);
+        return db.update(table, values, selection, selectionArgs);
+
+    }
+
+    @Override
+    @SuppressWarnings("fallthrough")
+    public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) {
+        trace("Calling query on URI: " + uri);
+
+        final SQLiteDatabase db = getReadableDatabase(uri);
+        final int match = URI_MATCHER.match(uri);
+        final String groupBy = null;
+        final SQLiteQueryBuilder qb = new SQLiteQueryBuilder();
+        final String limit = uri.getQueryParameter(BrowserContract.PARAM_LIMIT);
+
+        switch (match) {
+            case LOGINS_ID:
+                trace("Query is on LOGINS_ID: " + uri);
+                selection = DBUtils.concatenateWhere(selection, selectColumn(TABLE_LOGINS, Logins._ID));
+                selectionArgs = DBUtils.appendSelectionArgs(selectionArgs,
+                        new String[] { Long.toString(ContentUris.parseId(uri)) });
+
+            // fall through
+            case LOGINS:
+                trace("Query is on LOGINS: " + uri);
+                if (TextUtils.isEmpty(sortOrder)) {
+                    sortOrder = DEFAULT_LOGINS_SORT_ORDER;
+                } else {
+                    debug("Using sort order " + sortOrder + ".");
+                }
+
+                qb.setProjectionMap(LOGIN_PROJECTION_MAP);
+                qb.setTables(TABLE_LOGINS);
+                break;
+
+            case DELETED_LOGINS_ID:
+                trace("Query is on DELETED_LOGINS_ID: " + uri);
+                selection = DBUtils.concatenateWhere(selection, selectColumn(TABLE_DELETED_LOGINS, DeletedLogins._ID));
+                selectionArgs = DBUtils.appendSelectionArgs(selectionArgs,
+                        new String[] { Long.toString(ContentUris.parseId(uri)) });
+
+            // fall through
+            case DELETED_LOGINS:
+                trace("Query is on DELETED_LOGINS: " + uri);
+                if (TextUtils.isEmpty(sortOrder)) {
+                    sortOrder = DEFAULT_DELETED_LOGINS_SORT_ORDER;
+                } else {
+                    debug("Using sort order " + sortOrder + ".");
+                }
+
+                qb.setProjectionMap(DELETED_LOGIN_PROJECTION_MAP);
+                qb.setTables(TABLE_DELETED_LOGINS);
+                break;
+
+            case DISABLED_HOSTS_HOSTNAME:
+                trace("Query is on DISABLED_HOSTS_HOSTNAME: " + uri);
+                selection = DBUtils.concatenateWhere(selection, selectColumn(TABLE_DISABLED_HOSTS, DisabledHosts.HOSTNAME));
+                selectionArgs = DBUtils.appendSelectionArgs(selectionArgs,
+                        new String[] { uri.getLastPathSegment() });
+
+            // fall through
+            case DISABLED_HOSTS:
+                trace("Query is on DISABLED_HOSTS: " + uri);
+                if (TextUtils.isEmpty(sortOrder)) {
+                    sortOrder = DEFAULT_DISABLED_HOSTS_SORT_ORDER;
+                } else {
+                    debug("Using sort order " + sortOrder + ".");
+                }
+
+                qb.setProjectionMap(DISABLED_HOSTS_PROJECTION_MAP);
+                qb.setTables(TABLE_DISABLED_HOSTS);
+                break;
+
+            default:
+                throw new UnsupportedOperationException("Unknown query URI " + uri);
+        }
+
+        trace("Running built query.");
+        final Cursor cursor = decryptLogins(qb.query(db, projection, selection, selectionArgs, groupBy, null, sortOrder, limit));
+        cursor.setNotificationUri(getContext().getContentResolver(), BrowserContract.LOGINS_AUTHORITY_URI);
+
+        return cursor;
+    }
+
+    @Override
+    public String getType(Uri uri) {
+        final int match = URI_MATCHER.match(uri);
+
+        switch (match) {
+            case LOGINS:
+                return Logins.CONTENT_TYPE;
+
+            case LOGINS_ID:
+                return Logins.CONTENT_ITEM_TYPE;
+
+            case DELETED_LOGINS:
+                return DeletedLogins.CONTENT_TYPE;
+
+            case DELETED_LOGINS_ID:
+                return DeletedLogins.CONTENT_ITEM_TYPE;
+
+            case DISABLED_HOSTS:
+                return DisabledHosts.CONTENT_TYPE;
+
+            case DISABLED_HOSTS_HOSTNAME:
+                return DisabledHosts.CONTENT_ITEM_TYPE;
+
+            default:
+                throw new UnsupportedOperationException("Unknown type " + uri);
+        }
+    }
+
+    private String doCrypto(String initialValue, Boolean encrypt) {
+        return initialValue;
+    }
+
+    /**
+     * Caller is responsible for invoking this method inside a transaction.
+     */
+    private boolean storeDeletedLoginForGUIDInTranscation(final String guid, final SQLiteDatabase db) {
+        final ContentValues values = new ContentValues();
+        values.put(DeletedLogins.GUID, guid);
+        values.put(DeletedLogins.TIME_DELETED, System.currentTimeMillis());
+        return db.insert(TABLE_DELETED_LOGINS, DeletedLogins.GUID, values) > 0;
+    }
+
+    /**
+     * Caller is responsible for invoking this method inside a transaction.
+     */
+    private void removeDeletedLoginsByGUIDInTransaction(ContentValues values, SQLiteDatabase db) {
+        if (values.containsKey(Logins.GUID)) {
+            final String guid = values.getAsString(Logins.GUID);
+            if (guid == null) {
+                db.delete(TABLE_DELETED_LOGINS, WHERE_GUID_IS_NULL, null);
+            } else {
+                String[] args = new String[]{guid};
+                db.delete(TABLE_DELETED_LOGINS, WHERE_GUID_IS_VALUE, args);
+            }
+        }
+    }
+
+    private void setupDefaultValues(ContentValues values, Uri uri) throws IllegalArgumentException {
+        final int match = URI_MATCHER.match(uri);
+        final long now = System.currentTimeMillis();
+        switch (match) {
+            case DELETED_LOGINS:
+                values.put(DeletedLogins.TIME_DELETED, now);
+                // deleted-logins must contain a guid
+                if (!values.containsKey(DeletedLogins.GUID)) {
+                    throw new IllegalArgumentException("Must provide GUID for deleted-login");
+                }
+                break;
+
+            case LOGINS:
+                values.put(Logins.TIME_CREATED, now);
+                // Generate GUID for new login. Don't override specified GUIDs.
+                if (!values.containsKey(Logins.GUID)) {
+                    String guid = Utils.generateGuid();
+                    values.put(Logins.GUID, guid);
+                }
+                String nowString = Long.toString(now);
+                DBUtils.replaceKey(values, null, Logins.HTTP_REALM, null);
+                DBUtils.replaceKey(values, null, Logins.FORM_SUBMIT_URL, null);
+                DBUtils.replaceKey(values, null, Logins.ENC_TYPE, "0");
+                DBUtils.replaceKey(values, null, Logins.TIME_LAST_USED, nowString);
+                DBUtils.replaceKey(values, null, Logins.TIME_PASSWORD_CHANGED, nowString);
+                DBUtils.replaceKey(values, null, Logins.TIMES_USED, "0");
+                break;
+
+            case DISABLED_HOSTS:
+                if (!values.containsKey(DisabledHosts.HOSTNAME)) {
+                    throw new IllegalArgumentException("Must provide hostname for disabled-host");
+                }
+                break;
+
+            default:
+                throw new UnsupportedOperationException("Unknown URI in setupDefaultValues " + uri);
+        }
+    }
+
+    private void encryptLogins(final ContentValues values) {
+        if (values.containsKey(Logins.ENCRYPTED_PASSWORD)) {
+            final String res = doCrypto(values.getAsString(Logins.ENCRYPTED_PASSWORD), true);
+            values.put(Logins.ENCRYPTED_PASSWORD, res);
+        }
+
+        if (values.containsKey(Logins.ENCRYPTED_USERNAME)) {
+            final String res = doCrypto(values.getAsString(Logins.ENCRYPTED_USERNAME), true);
+            values.put(Logins.ENCRYPTED_USERNAME, res);
+        }
+    }
+
+    private Cursor decryptLogins(final Cursor cursor) {
+        int passwordIndex = -1;
+        int usernameIndex = -1;
+
+        if (cursor == null) {
+            return cursor;
+        }
+
+        try {
+            passwordIndex = cursor.getColumnIndexOrThrow(Logins.ENCRYPTED_PASSWORD);
+        } catch(Exception ex) { }
+        try {
+            usernameIndex = cursor.getColumnIndexOrThrow(Logins.ENCRYPTED_USERNAME);
+        } catch(Exception ex) { }
+
+        if (passwordIndex == -1 && usernameIndex == -1) {
+            return cursor;
+        }
+
+        // Special case, decrypt the encrypted username or password before returning the cursor.
+        final MatrixCursor newCursor = new MatrixCursor(cursor.getColumnNames(), cursor.getColumnCount());
+        try {
+            for (cursor.moveToFirst(); !cursor.isAfterLast(); cursor.moveToNext()) {
+                final ContentValues values = new ContentValues();
+                DatabaseUtils.cursorRowToContentValues(cursor, values);
+
+                if (passwordIndex > -1) {
+                    String decrypted = doCrypto(values.getAsString(Logins.ENCRYPTED_PASSWORD), false);
+                    values.put(Logins.ENCRYPTED_PASSWORD, decrypted);
+                }
+
+                if (usernameIndex > -1) {
+                    String decrypted = doCrypto(values.getAsString(Logins.ENCRYPTED_USERNAME), false);
+                    values.put(Logins.ENCRYPTED_USERNAME, decrypted);
+                }
+
+                final MatrixCursor.RowBuilder rowBuilder = newCursor.newRow();
+                for (String key : cursor.getColumnNames()) {
+                    rowBuilder.add(values.get(key));
+                }
+            }
+        } finally {
+            // Close the old cursor before returning the new one.
+            cursor.close();
+        }
+
+        return newCursor;
+    }
+}
--- a/mobile/android/base/moz.build
+++ b/mobile/android/base/moz.build
@@ -220,16 +220,17 @@ gbjar.sources += ['java/org/mozilla/geck
     'db/DBUtils.java',
     'db/FormHistoryProvider.java',
     'db/HomeProvider.java',
     'db/LocalBrowserDB.java',
     'db/LocalReadingListAccessor.java',
     'db/LocalSearches.java',
     'db/LocalTabsAccessor.java',
     'db/LocalURLMetadata.java',
+    'db/LoginsProvider.java',
     'db/PasswordsProvider.java',
     'db/PerProfileDatabaseProvider.java',
     'db/PerProfileDatabases.java',
     'db/ReadingListAccessor.java',
     'db/ReadingListProvider.java',
     'db/RemoteClient.java',
     'db/RemoteTab.java',
     'db/Searches.java',
--- a/mobile/android/tests/browser/robocop/robocop.ini
+++ b/mobile/android/tests/browser/robocop/robocop.ini
@@ -140,8 +140,10 @@ skip-if = android_version == "10" || and
 [src/org/mozilla/gecko/tests/testSessionHistory.java]
 [src/org/mozilla/gecko/tests/testStateWhileLoading.java]
 
 [src/org/mozilla/gecko/tests/testAccessibleCarets.java]
 
 # testStumblerSetting disabled on Android 4.3, bug 1145846
 [src/org/mozilla/gecko/tests/testStumblerSetting.java]
 skip-if = android_version == "10" || android_version == "18"
+
+[src/org/mozilla/gecko/tests/testLoginProvider.java]
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/browser/robocop/src/org/mozilla/gecko/tests/testLoginProvider.java
@@ -0,0 +1,352 @@
+/* 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.tests;
+
+import android.content.ContentProvider;
+import android.content.ContentUris;
+import android.content.ContentValues;
+import android.database.Cursor;
+import android.database.sqlite.SQLiteDatabase;
+import android.net.Uri;
+
+import org.mozilla.gecko.db.BrowserContract;
+import org.mozilla.gecko.db.BrowserContract.DeletedLogins;
+import org.mozilla.gecko.db.BrowserContract.DisabledHosts;
+import org.mozilla.gecko.db.BrowserContract.Logins;
+import org.mozilla.gecko.db.LoginsProvider;
+
+import java.util.concurrent.Callable;
+
+import static org.mozilla.gecko.db.BrowserContract.CommonColumns._ID;
+import static org.mozilla.gecko.db.BrowserContract.DeletedLogins.TABLE_DELETED_LOGINS;
+import static org.mozilla.gecko.db.BrowserContract.DisabledHosts.TABLE_DISABLED_HOSTS;
+import static org.mozilla.gecko.db.BrowserContract.Logins.TABLE_LOGINS;
+
+public class testLoginProvider extends ContentProviderTest {
+
+    private static final String DB_NAME = "browser.db";
+
+    // List of tests to be run sorted by dependency.
+    private final TestCase[] TESTS_TO_RUN = {
+            new InsertLoginTest(),
+            new UpdateLoginTest(),
+            new DeleteLoginTest(),
+            new InsertDeletedLoginTest(),
+            new InsertDeletedLoginFailureTest(),
+            new DisabledHostsInsertTest(),
+            new DisabledHostsInsertFailureTest(),
+            new InsertLoginsWithDefaultValuesTest(),
+            new InsertLoginWithDuplicateGuidFailureTest(),
+            new DeleteLoginByNonExistentGuidTest(),
+    };
+
+    /**
+     * Factory function that makes new LoginsProvider instances.
+     * <p>
+     * We want a fresh provider each test, so this should be invoked in
+     * <code>setUp</code> before each individual test.
+     */
+    private static final Callable<ContentProvider> sProviderFactory = new Callable<ContentProvider>() {
+        @Override
+        public ContentProvider call() {
+            return new LoginsProvider();
+        }
+    };
+
+    @Override
+    public void setUp() throws Exception {
+        super.setUp(sProviderFactory, BrowserContract.LOGINS_AUTHORITY, DB_NAME);
+        for (TestCase test: TESTS_TO_RUN) {
+            mTests.add(test);
+        }
+    }
+
+    public void testLoginProviderTests() throws Exception {
+        for (Runnable test : mTests) {
+            final String testName = test.getClass().getSimpleName();
+            setTestName(testName);
+            ensureEmptyDatabase();
+            mAsserter.dumpLog("testLoginProvider: Database empty - Starting " + testName + ".");
+            test.run();
+        }
+    }
+
+    /**
+     * Wipe DB.
+     */
+    private void ensureEmptyDatabase() {
+        getWritableDatabase(Logins.CONTENT_URI).delete(TABLE_LOGINS, null, null);
+        getWritableDatabase(DeletedLogins.CONTENT_URI).delete(TABLE_DELETED_LOGINS, null, null);
+        getWritableDatabase(DisabledHosts.CONTENT_URI).delete(TABLE_DISABLED_HOSTS, null, null);
+    }
+
+    private SQLiteDatabase getWritableDatabase(Uri uri) {
+        Uri testUri = appendUriParam(uri, BrowserContract.PARAM_IS_TEST, "1");
+        DelegatingTestContentProvider delegateProvider = (DelegatingTestContentProvider) mProvider;
+        LoginsProvider loginsProvider = (LoginsProvider) delegateProvider.getTargetProvider();
+        return loginsProvider.getWritableDatabaseForTesting(testUri);
+    }
+
+    /**
+     * LoginsProvider insert login test.
+     */
+    private class InsertLoginTest extends TestCase {
+        @Override
+        public void test() throws Exception {
+            ContentValues contentValues = createLogin("http://www.example.com", "http://www.example.com",
+                    "http://www.example.com", "username1", "password1", "username1", "password1", "guid1");
+            long id = ContentUris.parseId(mProvider.insert(BrowserContract.Logins.CONTENT_URI, contentValues));
+            verifyLoginExists(contentValues, id);
+        }
+    }
+
+    /**
+     * LoginsProvider updates login test.
+     */
+    private class UpdateLoginTest extends TestCase {
+        @Override
+        public void test() throws Exception {
+            ContentValues contentValues = createLogin("http://www.example.com", "http://www.example.com",
+                    "http://www.example.com", "username1", "password1", "username1", "password1", "guid1");
+            long id = ContentUris.parseId(mProvider.insert(BrowserContract.Logins.CONTENT_URI, contentValues));
+            verifyLoginExists(contentValues, id);
+
+            contentValues.put(BrowserContract.Logins.ENCRYPTED_USERNAME, "username2");
+            contentValues.put(Logins.ENCRYPTED_PASSWORD, "password2");
+
+            Uri updateUri = Logins.CONTENT_URI.buildUpon().appendPath(String.valueOf(id)).build();
+            int numUpdated = mProvider.update(updateUri, contentValues, null, null);
+            mAsserter.is(1, numUpdated, "Correct number updated");
+            verifyLoginExists(contentValues, id);
+        }
+    }
+
+    /**
+     * LoginsProvider deletion login test.
+     * - inserts a new login
+     * - deletes the login and verify deleted-logins table has entry for deleted guid.
+     */
+    private class DeleteLoginTest extends TestCase {
+        @Override
+        public void test() throws Exception {
+            ContentValues contentValues = createLogin("http://www.example.com", "http://www.example.com",
+                    "http://www.example.com", "username1", "password1", "username1", "password1", "guid1");
+            long id = ContentUris.parseId(mProvider.insert(Logins.CONTENT_URI, contentValues));
+            verifyLoginExists(contentValues, id);
+
+            Uri deletedUri = Logins.CONTENT_URI.buildUpon().appendPath(String.valueOf(id)).build();
+            int numDeleted = mProvider.delete(deletedUri, null, null);
+            mAsserter.is(1, numDeleted, "Correct number deleted");
+            verifyNoRowExists(Logins.CONTENT_URI, "No login entry found");
+
+            Cursor cursor = mProvider.query(DeletedLogins.CONTENT_URI, null, null, null, null);
+            contentValues = new ContentValues();
+            contentValues.put(DeletedLogins.GUID, "guid1");
+            verifyRowsMatches(contentValues, cursor, cursor.moveToFirst(), "deleted-login found");
+        }
+    }
+
+    /**
+     * LoginsProvider re-insert login test.
+     * - inserts a row into deleted-logins
+     * - insert the same login (matching guid) and verify deleted-logins table is empty.
+     */
+    private class InsertDeletedLoginTest extends TestCase {
+        @Override
+        public void test() throws Exception {
+            ContentValues contentValues = new ContentValues();
+            contentValues.put(DeletedLogins.GUID, "guid1");
+            long id = ContentUris.parseId(mProvider.insert(DeletedLogins.CONTENT_URI, contentValues));
+            final Uri insertedUri = DeletedLogins.CONTENT_URI.buildUpon().appendPath(String.valueOf(id)).build();
+            Cursor cursor = mProvider.query(insertedUri, null, null, null, null);
+            verifyRowsMatches(contentValues, cursor, cursor.moveToFirst(), "deleted-login found");
+            verifyNoRowExists(BrowserContract.Logins.CONTENT_URI, "No login entry found");
+
+            contentValues = createLogin("http://www.example.com", "http://www.example.com",
+                    "http://www.example.com", "username1", "password1", "username1", "password1", "guid1");
+            id = ContentUris.parseId(mProvider.insert(Logins.CONTENT_URI, contentValues));
+            verifyLoginExists(contentValues, id);
+            verifyNoRowExists(DeletedLogins.CONTENT_URI, "No deleted-login entry found");
+        }
+    }
+
+    /**
+     * LoginsProvider insert Deleted login test.
+     * - inserts a row into deleted-login without GUID.
+     */
+    private class InsertDeletedLoginFailureTest extends TestCase {
+        @Override
+        public void test() throws Exception {
+            ContentValues contentValues = new ContentValues();
+            try {
+                mProvider.insert(DeletedLogins.CONTENT_URI, contentValues);
+                fail("Failed to throw IllegalArgumentException while missing GUID");
+            } catch (Exception e) {
+                mAsserter.is(e.getClass(), IllegalArgumentException.class, "IllegalArgumentException thrown for invalid GUID");
+            }
+        }
+    }
+
+    /**
+     * LoginsProvider disabled host test.
+     * - inserts a disabled-host
+     * - delete the inserted disabled-host and verify disabled-hosts table is empty.
+     */
+    private class DisabledHostsInsertTest extends TestCase {
+        @Override
+        public void test() throws Exception {
+            final String hostname = "localhost";
+            final ContentValues contentValues = new ContentValues();
+            contentValues.put(DisabledHosts.HOSTNAME, hostname);
+            mProvider.insert(DisabledHosts.CONTENT_URI, contentValues);
+            final Uri insertedUri = DisabledHosts.CONTENT_URI.buildUpon().appendPath("hostname").appendPath(hostname).build();
+            final Cursor cursor = mProvider.query(insertedUri, null, null, null, null);
+            verifyRowsMatches(contentValues, cursor, cursor.moveToFirst(), "disabled-hosts found");
+
+            final Uri deletedUri = DisabledHosts.CONTENT_URI.buildUpon().appendPath("hostname").appendPath(hostname).build();
+            final int numDeleted = mProvider.delete(deletedUri, null, null);
+            mAsserter.is(1, numDeleted, "Correct number deleted");
+            verifyNoRowExists(DisabledHosts.CONTENT_URI, "No disabled-hosts entry found");
+        }
+    }
+
+    /**
+     * LoginsProvider disabled host insert failure testcase.
+     * - inserts a disabled-host without providing hostname
+     */
+    private class DisabledHostsInsertFailureTest extends TestCase {
+        @Override
+        public void test() throws Exception {
+            final String hostname = "localhost";
+            final ContentValues contentValues = new ContentValues();
+            try {
+                mProvider.insert(DisabledHosts.CONTENT_URI, contentValues);
+                fail("Failed to throw IllegalArgumentException while missing hostname");
+            } catch (Exception e) {
+                mAsserter.is(e.getClass(), IllegalArgumentException.class, "IllegalArgumentException thrown for invalid hostname");
+            }
+        }
+    }
+
+    /**
+     * LoginsProvider login insertion with default values test.
+     * - insert a login missing GUID, FORM_SUBMIT_URL, HTTP_REALM and verify default values are set.
+     */
+    private class InsertLoginsWithDefaultValuesTest extends TestCase {
+        @Override
+        protected void test() throws Exception {
+            ContentValues contentValues = createLogin("http://www.example.com", "http://www.example.com",
+                    "http://www.example.com", "username1", "password1", "username1", "password1", null);
+            // Remove GUID, HTTP_REALM, FORM_SUBMIT_URL from content values
+            contentValues.remove(Logins.GUID);
+            contentValues.remove(Logins.FORM_SUBMIT_URL);
+            contentValues.remove(Logins.HTTP_REALM);
+
+            long id = ContentUris.parseId(mProvider.insert(BrowserContract.Logins.CONTENT_URI, contentValues));
+            Cursor cursor = getLoginById(id);
+            assertNotNull(cursor);
+            cursor.moveToFirst();
+
+            mAsserter.isnot(cursor.getString(cursor.getColumnIndex(Logins.GUID)), null, "GUID is not null");
+            mAsserter.is(cursor.getString(cursor.getColumnIndex(Logins.HTTP_REALM)), null, "HTTP_REALM is not null");
+            mAsserter.is(cursor.getString(cursor.getColumnIndex(Logins.FORM_SUBMIT_URL)), null, "FORM_SUBMIT_URL is not null");
+            mAsserter.isnot(cursor.getString(cursor.getColumnIndex(Logins.TIME_LAST_USED)), null, "TIME_LAST_USED is not null");
+            mAsserter.isnot(cursor.getString(cursor.getColumnIndex(Logins.TIME_CREATED)), null, "TIME_CREATED is not null");
+            mAsserter.isnot(cursor.getString(cursor.getColumnIndex(Logins.TIME_PASSWORD_CHANGED)), null, "TIME_PASSWORD_CHANGED is not null");
+            mAsserter.is(cursor.getString(cursor.getColumnIndex(Logins.ENC_TYPE)), "0", "ENC_TYPE is 0");
+            mAsserter.is(cursor.getString(cursor.getColumnIndex(Logins.TIMES_USED)), "0", "TIMES_USED is 0");
+
+            // Verify other values.
+            verifyRowsMatches(contentValues, cursor, cursor.moveToFirst(), "Updated login found");
+        }
+    }
+
+    /**
+     * LoginsProvider login insertion with duplicate GUID test.
+     * - insert two different logins with same GUID and verify that only one login exists.
+     */
+    private class InsertLoginWithDuplicateGuidFailureTest extends TestCase {
+        @Override
+        protected void test() throws Exception {
+            final String guid = "guid1";
+            ContentValues contentValues = createLogin("http://www.example.com", "http://www.example.com",
+                    "http://www.example.com", "username1", "password1", "username1", "password1", guid);
+            long id1 = ContentUris.parseId(mProvider.insert(BrowserContract.Logins.CONTENT_URI, contentValues));
+            verifyLoginExists(contentValues, id1);
+
+            // Insert another login with duplicate GUID.
+            contentValues = createLogin("http://www.example2.com", "http://www.example2.com",
+                    "http://www.example2.com", "username2", "password2", "username2", "password2", guid);
+            Uri insertUri = mProvider.insert(Logins.CONTENT_URI, contentValues);
+            mAsserter.is(insertUri, null, "Duplicate Guid insertion id1");
+
+            // Verify login with id1 still exists.
+            verifyLoginExists(contentValues, id1);
+        }
+    }
+
+    /**
+     * LoginsProvider deletion by non-existent GUID test.
+     * - delete a login with random GUID and verify that no entry was deleted.
+     */
+    private class DeleteLoginByNonExistentGuidTest extends TestCase {
+        @Override
+        protected void test() throws Exception {
+            Uri deletedUri = Logins.CONTENT_URI;
+            int numDeleted = mProvider.delete(deletedUri, Logins.GUID + "=?", new String[] {"guid1"});
+            mAsserter.is(0, numDeleted, "Correct number deleted");
+        }
+    }
+
+    private Cursor getById(Uri uri, long id, String[] projection) {
+        return mProvider.query(uri, projection,
+                _ID + " = ?",
+                new String[]{String.valueOf(id)},
+                null);
+    }
+
+    private Cursor getLoginById(long id) {
+        return getById(Logins.CONTENT_URI, id, null);
+    }
+
+
+    private void verifyLoginExists(ContentValues contentValues, long id) {
+        Cursor cursor = getLoginById(id);
+        verifyRowsMatches(contentValues, cursor, cursor.moveToFirst(), "Updated login found");
+    }
+
+    private void verifyRowsMatches(ContentValues contentValues, Cursor cursor, boolean condition, String name) {
+        try {
+            mAsserter.ok(condition, name, "");
+            CursorMatches(cursor, contentValues);
+        } finally {
+            cursor.close();
+        }
+    }
+
+    private void verifyNoRowExists(Uri contentUri, String name) {
+        Cursor cursor = mProvider.query(contentUri, null, null, null, null);
+        try {
+            mAsserter.is(0, cursor.getCount(), name);
+        } finally {
+            cursor.close();
+        }
+    }
+
+    private ContentValues createLogin(String hostname, String httpRealm, String formSubmitUrl,
+                                      String usernameField, String passwordField, String encryptedUsername,
+                                      String encryptedPassword, String guid) {
+        final ContentValues values = new ContentValues();
+        values.put(Logins.HOSTNAME, hostname);
+        values.put(Logins.HTTP_REALM, httpRealm);
+        values.put(Logins.FORM_SUBMIT_URL, formSubmitUrl);
+        values.put(Logins.USERNAME_FIELD, usernameField);
+        values.put(Logins.PASSWORD_FIELD, passwordField);
+        values.put(Logins.ENCRYPTED_USERNAME, encryptedUsername);
+        values.put(Logins.ENCRYPTED_PASSWORD, encryptedPassword);
+        values.put(Logins.GUID, guid);
+        return values;
+    }
+}