--- a/mobile/android/base/AndroidManifest.xml.in
+++ b/mobile/android/base/AndroidManifest.xml.in
@@ -311,16 +311,21 @@
Process name is a mangled version to avoid a Talos bug. (Bug 750548.)
-->
<provider android:name="org.mozilla.gecko.db.PasswordsProvider"
android:label="@string/sync_configure_engines_title_passwords"
android:authorities="@ANDROID_PACKAGE_NAME@.db.passwords"
android:exported="false"
android:process="@MANGLED_ANDROID_PACKAGE_NAME@.PasswordsProvider"/>
+ <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.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.GeckoProfilesProvider"
android:authorities="@ANDROID_PACKAGE_NAME@.profiles"
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 LoginsDisabledHosts implements CommonColumns {
+ private LoginsDisabledHosts() {}
+
+ public static final Uri CONTENT_URI = Uri.withAppendedPath(LOGINS_AUTHORITY_URI, "logins-disabled-hosts");
+ public static final String CONTENT_TYPE = "vnd.android.cursor.dir/logins-disabled-hosts";
+ public static final String CONTENT_ITEM_TYPE = "vnd.android.cursor.item/logins-disabled-hosts";
+ public static final String TABLE_DISABLED_HOSTS = "logins_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
@@ -1,17 +1,16 @@
/* -*- 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/. */
package org.mozilla.gecko.db;
import java.io.File;
-import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
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.Favicons;
@@ -36,28 +35,31 @@ import android.os.Build;
import android.util.Log;
final class BrowserDatabaseHelper extends SQLiteOpenHelper {
private static final String LOGTAG = "GeckoBrowserDBHelper";
// Replace the Bug number below with your Bug that is conducting a DB upgrade, as to force a merge conflict with any
// other patches that require a DB upgrade.
- public static final int DATABASE_VERSION = 27; // Bug 826400
+ public static final int DATABASE_VERSION = 28; // Bug 946857
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.LoginsDisabledHosts.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) + " = " +
@@ -322,16 +324,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.LoginsDisabledHosts._ID + " INTEGER PRIMARY KEY AUTOINCREMENT," +
+ BrowserContract.LoginsDisabledHosts.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);
}
@@ -353,16 +412,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.
*/
@@ -1027,16 +1092,24 @@ 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 upgradeDatabaseFrom27to28(final SQLiteDatabase db) {
+ createDeletedLoginsTable(db, TABLE_DELETED_LOGINS);
+ createDisabledHostsTable(db, TABLE_DISABLED_HOSTS);
+ createLoginsTable(db, TABLE_LOGINS);
+ createLoginsTableIndices(db, TABLE_LOGINS);
+ didCreateLoginsTable = true;
+ }
+
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
@@ -1110,16 +1183,20 @@ final class BrowserDatabaseHelper extend
case 25:
upgradeDatabaseFrom24to25(db);
break;
case 26:
upgradeDatabaseFrom25to26(db);
break;
+
+ case 28:
+ upgradeDatabaseFrom27to28(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,549 @@
+/* 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.LoginsDisabledHosts;
+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 android.util.Base64;
+
+import java.io.UnsupportedEncodingException;
+import java.security.InvalidAlgorithmParameterException;
+import java.security.InvalidKeyException;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.Arrays;
+import java.util.HashMap;
+
+import javax.crypto.Cipher;
+import javax.crypto.NoSuchPaddingException;
+import javax.crypto.spec.IvParameterSpec;
+import javax.crypto.spec.SecretKeySpec;
+
+import static org.mozilla.gecko.db.BrowserContract.DeletedLogins.TABLE_DELETED_LOGINS;
+import static org.mozilla.gecko.db.BrowserContract.LoginsDisabledHosts.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 = LoginsDisabledHosts.HOSTNAME + " ASC";
+ private static final String WHERE_GUID_IS_NULL = DeletedLogins.GUID + " IS NULL";
+ private static final String WHERE_GUID_IS_VALUE = DeletedLogins.GUID + " = ?";
+
+ private static final String TRANSFORMATION = "AES/CBC/PKCS5Padding";
+ private static final String ALGORITHM_SHA_256 = "SHA-256";
+ private static final String SECRET_KEY = "mozilla";
+ private static final String INTIAL_VECTOR = "firefox";
+ private static final String UTF_8 = "UTF-8";
+ private static final String ALGORITHM_AES = "AES";
+
+ 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, "logins-disabled-hosts", DISABLED_HOSTS);
+ URI_MATCHER.addURI(BrowserContract.LOGINS_AUTHORITY, "logins-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(LoginsDisabledHosts._ID, LoginsDisabledHosts._ID);
+ DISABLED_HOSTS_PROJECTION_MAP.put(LoginsDisabledHosts.HOSTNAME, LoginsDisabledHosts.HOSTNAME);
+ }
+
+ private static String projectColumn(String table, String column) {
+ return table + "." + column;
+ }
+
+ private static 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 = null;
+
+ 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(LoginsDisabledHosts.HOSTNAME);
+ debug("Inserting disabled-host in database with hostname: " + hostname);
+ id = db.insertOrThrow(TABLE_DISABLED_HOSTS, LoginsDisabledHosts.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.
+ final String guid = getLoginGUIDByID(selection, selectionArgs, db);
+ if (TextUtils.isEmpty(guid)) {
+ // No matching logins found for the id.
+ return 0;
+
+ }
+ boolean isInsertSuccessful = storeDeletedLoginForGUIDInTranscation(guid, 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, LoginsDisabledHosts.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
+ @SuppressWarnings("fallthrough")
+ 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);
+ selection = DBUtils.concatenateWhere(selection, selectColumn(TABLE_LOGINS, Logins._ID));
+ selectionArgs = DBUtils.appendSelectionArgs(selectionArgs,
+ new String[]{Long.toString(ContentUris.parseId(uri))});
+
+ case LOGINS:
+ trace("Update on LOGINS: " + uri);
+ table = TABLE_LOGINS;
+ // Encrypt sensitive data.
+ encryptLogins(values);
+ break;
+
+ default:
+ throw new UnsupportedOperationException("Unknown update 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, LoginsDisabledHosts.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.");
+ Cursor cursor = qb.query(db, projection, selection, selectionArgs, groupBy, null, sortOrder, limit);
+ cursor = decryptLogins(cursor);
+ 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 LoginsDisabledHosts.CONTENT_TYPE;
+
+ case DISABLED_HOSTS_HOSTNAME:
+ return LoginsDisabledHosts.CONTENT_ITEM_TYPE;
+
+ default:
+ throw new UnsupportedOperationException("Unknown type " + uri);
+ }
+ }
+
+ /**
+ * Caller is responsible for invoking this method inside a transaction.
+ */
+ private String getLoginGUIDByID(final String selection, final String[] selectionArgs, final SQLiteDatabase db) {
+ final Cursor cursor = db.query(Logins.TABLE_LOGINS, new String[]{Logins.GUID}, selection, selectionArgs, null, null, DEFAULT_LOGINS_SORT_ORDER);
+ if (cursor == null) {
+ return null;
+ }
+
+ try {
+ if (!cursor.moveToFirst()) {
+ return null;
+ }
+ return cursor.getString(cursor.getColumnIndex(Logins.GUID));
+ } finally {
+ cursor.close();
+ }
+ }
+
+ /**
+ * Caller is responsible for invoking this method inside a transaction.
+ */
+ private boolean storeDeletedLoginForGUIDInTranscation(final String guid, final SQLiteDatabase db) {
+ if (TextUtils.isEmpty(guid)) {
+ return false;
+ }
+ 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(LoginsDisabledHosts.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 = encrypt(values.getAsString(Logins.ENCRYPTED_PASSWORD));
+ values.put(Logins.ENCRYPTED_PASSWORD, res);
+ }
+
+ if (values.containsKey(Logins.ENCRYPTED_USERNAME)) {
+ final String res = encrypt(values.getAsString(Logins.ENCRYPTED_USERNAME));
+ 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 = decrypt(values.getAsString(Logins.ENCRYPTED_PASSWORD));
+ values.put(Logins.ENCRYPTED_PASSWORD, decrypted);
+ }
+
+ if (usernameIndex > -1) {
+ String decrypted = decrypt(values.getAsString(Logins.ENCRYPTED_USERNAME));
+ 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;
+ }
+
+ private String encrypt(String initialValue) {
+ if (TextUtils.isEmpty(initialValue)) {
+ return initialValue;
+ }
+
+ try {
+ final Cipher cipher = getCipher(Cipher.ENCRYPT_MODE);
+ return Base64.encodeToString(cipher.doFinal(initialValue.getBytes(UTF_8)), Base64.URL_SAFE);
+ } catch (Exception e) {
+ debug("encryption failed : " + e);
+ throw new IllegalStateException("Logins encryption failed", e);
+ }
+ }
+
+ private String decrypt(String initialValue) {
+ if (TextUtils.isEmpty(initialValue)) {
+ return initialValue;
+ }
+
+ try {
+ final Cipher cipher = getCipher(Cipher.DECRYPT_MODE);
+ return new String(cipher.doFinal(Base64.decode(initialValue.getBytes(UTF_8), Base64.URL_SAFE)));
+ } catch (Exception e) {
+ debug("Decryption failed : " + e);
+ throw new IllegalStateException("Logins decryption failed", e);
+ }
+ }
+
+ private Cipher getCipher(int mode) throws NoSuchAlgorithmException, NoSuchPaddingException,
+ UnsupportedEncodingException, InvalidKeyException, InvalidAlgorithmParameterException {
+ final Cipher cipher = Cipher.getInstance(TRANSFORMATION);
+ final MessageDigest sha = MessageDigest.getInstance(ALGORITHM_SHA_256);
+ final byte[] secretKey = Arrays.copyOf(sha.digest(SECRET_KEY.getBytes(UTF_8)), 32);
+ final IvParameterSpec ivParameterSpec = new IvParameterSpec(Arrays.copyOf(sha.digest(INTIAL_VECTOR.getBytes(UTF_8)), 16));
+ cipher.init(mode, new SecretKeySpec(secretKey, ALGORITHM_AES), ivParameterSpec);
+ return cipher;
+ }
+}
--- 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
@@ -138,8 +138,10 @@ skip-if = android_version == "10"
[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/testLoginsProvider.java]
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/browser/robocop/src/org/mozilla/gecko/tests/testLoginsProvider.java
@@ -0,0 +1,365 @@
+/* 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.LoginsDisabledHosts;
+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.LoginsDisabledHosts.TABLE_DISABLED_HOSTS;
+import static org.mozilla.gecko.db.BrowserContract.Logins.TABLE_LOGINS;
+
+public class testLoginsProvider extends ContentProviderTest {
+
+ private static final String DB_NAME = "browser.db";
+
+ private final TestCase[] TESTS_TO_RUN = {
+ new InsertLoginsTest(),
+ new UpdateLoginsTest(),
+ new DeleteLoginsTest(),
+ new InsertDeletedLoginsTest(),
+ new InsertDeletedLoginsFailureTest(),
+ new DisabledHostsInsertTest(),
+ new DisabledHostsInsertFailureTest(),
+ new InsertLoginsWithDefaultValuesTest(),
+ new InsertLoginsWithDuplicateGuidFailureTest(),
+ new DeleteLoginsByNonExistentGuidTest(),
+ };
+
+ /**
+ * 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("testLoginsProvider: 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(LoginsDisabledHosts.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 logins test.
+ */
+ private class InsertLoginsTest extends TestCase {
+ @Override
+ public void test() throws Exception {
+ ContentValues contentValues = createLogins("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));
+ verifyLoginsExists(contentValues, id);
+ Cursor cursor = mProvider.query(Logins.CONTENT_URI, null, Logins.GUID + " = ?", new String[] { "guid1" }, null);
+ verifyRowsMatches(contentValues, cursor, cursor.moveToFirst(), "logins found");
+ }
+ }
+
+ /**
+ * LoginsProvider updates logins test.
+ */
+ private class UpdateLoginsTest extends TestCase {
+ @Override
+ public void test() throws Exception {
+ final String guid1 = "guid1";
+ ContentValues contentValues = createLogins("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));
+ verifyLoginsExists(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");
+ verifyLoginsExists(contentValues, id);
+
+ contentValues.put(BrowserContract.Logins.ENCRYPTED_USERNAME, "username1");
+ contentValues.put(Logins.ENCRYPTED_PASSWORD, "password1");
+
+ updateUri = Logins.CONTENT_URI;
+ numUpdated = mProvider.update(updateUri, contentValues, Logins.GUID + " = ?", new String[] { guid1 });
+ mAsserter.is(1, numUpdated, "Correct number updated");
+ verifyLoginsExists(contentValues, id);
+ }
+ }
+
+ /**
+ * LoginsProvider deletion logins test.
+ * - inserts a new logins
+ * - deletes the logins and verify deleted-logins table has entry for deleted guid.
+ */
+ private class DeleteLoginsTest extends TestCase {
+ @Override
+ public void test() throws Exception {
+ final String guid1 = "guid1";
+ ContentValues contentValues = createLogins("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));
+ verifyLoginsExists(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");
+
+ contentValues = new ContentValues();
+ contentValues.put(DeletedLogins.GUID, guid1);
+ Cursor cursor = mProvider.query(DeletedLogins.CONTENT_URI, null, null, null, null);
+ verifyRowsMatches(contentValues, cursor, cursor.moveToFirst(), "deleted-login found");
+ cursor = mProvider.query(DeletedLogins.CONTENT_URI, null, DeletedLogins.GUID + " = ?", new String[] { guid1 }, null);
+ verifyRowsMatches(contentValues, cursor, cursor.moveToFirst(), "deleted-login found");
+ }
+ }
+
+ /**
+ * LoginsProvider re-insert logins test.
+ * - inserts a row into deleted-logins
+ * - insert the same login (matching guid) and verify deleted-logins table is empty.
+ */
+ private class InsertDeletedLoginsTest 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 = createLogins("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));
+ verifyLoginsExists(contentValues, id);
+ verifyNoRowExists(DeletedLogins.CONTENT_URI, "No deleted-login entry found");
+ }
+ }
+
+ /**
+ * LoginsProvider insert Deleted logins test.
+ * - inserts a row into deleted-login without GUID.
+ */
+ private class InsertDeletedLoginsFailureTest 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(LoginsDisabledHosts.HOSTNAME, hostname);
+ mProvider.insert(LoginsDisabledHosts.CONTENT_URI, contentValues);
+ final Uri insertedUri = LoginsDisabledHosts.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 = LoginsDisabledHosts.CONTENT_URI.buildUpon().appendPath("hostname").appendPath(hostname).build();
+ final int numDeleted = mProvider.delete(deletedUri, null, null);
+ mAsserter.is(1, numDeleted, "Correct number deleted");
+ verifyNoRowExists(LoginsDisabledHosts.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(LoginsDisabledHosts.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 = createLogins("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 InsertLoginsWithDuplicateGuidFailureTest extends TestCase {
+ @Override
+ protected void test() throws Exception {
+ final String guid = "guid1";
+ ContentValues contentValues = createLogins("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));
+ verifyLoginsExists(contentValues, id1);
+
+ // Insert another login with duplicate GUID.
+ contentValues = createLogins("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.
+ verifyLoginsExists(contentValues, id1);
+ }
+ }
+
+ /**
+ * LoginsProvider deletion by non-existent GUID test.
+ * - delete a login with random GUID and verify that no entry was deleted.
+ */
+ private class DeleteLoginsByNonExistentGuidTest 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 verifyLoginsExists(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 createLogins(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;
+ }
+}