diff options
Diffstat (limited to 'mobile/android/base/java/org/mozilla/gecko/db/DBUtils.java')
-rw-r--r-- | mobile/android/base/java/org/mozilla/gecko/db/DBUtils.java | 450 |
1 files changed, 450 insertions, 0 deletions
diff --git a/mobile/android/base/java/org/mozilla/gecko/db/DBUtils.java b/mobile/android/base/java/org/mozilla/gecko/db/DBUtils.java new file mode 100644 index 000000000..cfa2f870f --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/db/DBUtils.java @@ -0,0 +1,450 @@ +/* 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 android.annotation.TargetApi; +import android.database.DatabaseUtils; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteStatement; +import android.os.Build; +import org.mozilla.gecko.AppConstants; +import org.mozilla.gecko.GeckoAppShell; +import org.mozilla.gecko.GeckoProfile; + +import android.content.ContentValues; +import android.database.Cursor; +import android.database.sqlite.SQLiteOpenHelper; +import android.net.Uri; +import android.text.TextUtils; +import android.util.Log; +import org.mozilla.gecko.annotation.RobocopTarget; +import org.mozilla.gecko.Telemetry; + +import java.util.Map; + +public class DBUtils { + private static final String LOGTAG = "GeckoDBUtils"; + + public static final int SQLITE_MAX_VARIABLE_NUMBER = 999; + + public static final String qualifyColumn(String table, String column) { + return table + "." + column; + } + + // This is available in Android >= 11. Implemented locally to be + // compatible with older versions. + public static String concatenateWhere(String a, String b) { + if (TextUtils.isEmpty(a)) { + return b; + } + + if (TextUtils.isEmpty(b)) { + return a; + } + + return "(" + a + ") AND (" + b + ")"; + } + + // This is available in Android >= 11. Implemented locally to be + // compatible with older versions. + public static String[] appendSelectionArgs(String[] originalValues, String[] newValues) { + if (originalValues == null || originalValues.length == 0) { + return newValues; + } + + if (newValues == null || newValues.length == 0) { + return originalValues; + } + + String[] result = new String[originalValues.length + newValues.length]; + System.arraycopy(originalValues, 0, result, 0, originalValues.length); + System.arraycopy(newValues, 0, result, originalValues.length, newValues.length); + + return result; + } + + /** + * Concatenate multiple lists of selection arguments. <code>values</code> may be <code>null</code>. + */ + public static String[] concatenateSelectionArgs(String[]... values) { + // Since we're most likely to be concatenating a few arrays of many values, it is most + // efficient to iterate over the arrays once to obtain their lengths, allowing us to create one target array + // (as opposed to copying arrays on every iteration, which would result in many more copies). + int totalLength = 0; + for (String[] v : values) { + if (v != null) { + totalLength += v.length; + } + } + + String[] result = new String[totalLength]; + + int position = 0; + for (String[] v: values) { + if (v != null) { + int currentLength = v.length; + System.arraycopy(v, 0, result, position, currentLength); + position += currentLength; + } + } + + return result; + } + + public static void replaceKey(ContentValues aValues, String aOriginalKey, + String aNewKey, String aDefault) { + String value = aDefault; + if (aOriginalKey != null && aValues.containsKey(aOriginalKey)) { + value = aValues.get(aOriginalKey).toString(); + aValues.remove(aOriginalKey); + } + + if (!aValues.containsKey(aNewKey)) { + aValues.put(aNewKey, value); + } + } + + private static String HISTOGRAM_DATABASE_LOCKED = "DATABASE_LOCKED_EXCEPTION"; + private static String HISTOGRAM_DATABASE_UNLOCKED = "DATABASE_SUCCESSFUL_UNLOCK"; + public static void ensureDatabaseIsNotLocked(SQLiteOpenHelper dbHelper, String databasePath) { + final int maxAttempts = 5; + int attempt = 0; + SQLiteDatabase db = null; + for (; attempt < maxAttempts; attempt++) { + try { + // Try a simple test and exit the loop. + db = dbHelper.getWritableDatabase(); + break; + } catch (Exception e) { + // We assume that this is a android.database.sqlite.SQLiteDatabaseLockedException. + // That class is only available on API 11+. + Telemetry.addToHistogram(HISTOGRAM_DATABASE_LOCKED, attempt); + + // Things could get very bad if we don't find a way to unlock the DB. + Log.d(LOGTAG, "Database is locked, trying to kill any zombie processes: " + databasePath); + GeckoAppShell.killAnyZombies(); + try { + Thread.sleep(attempt * 100); + } catch (InterruptedException ie) { + } + } + } + + if (db == null) { + Log.w(LOGTAG, "Failed to unlock database."); + GeckoAppShell.listOfOpenFiles(); + return; + } + + // If we needed to retry, but we succeeded, report that in telemetry. + // Failures are indicated by a lower frequency of UNLOCKED than LOCKED. + if (attempt > 1) { + Telemetry.addToHistogram(HISTOGRAM_DATABASE_UNLOCKED, attempt - 1); + } + } + + /** + * Copies a table <b>between</b> database files. + * + * This method assumes that the source table and destination table already exist in the + * source and destination databases, respectively. + * + * The table is copied row-by-row in a single transaction. + * + * @param source The source database that the table will be copied from. + * @param sourceTableName The name of the source table. + * @param destination The destination database that the table will be copied to. + * @param destinationTableName The name of the destination table. + * @return true if all rows were copied; false otherwise. + */ + public static boolean copyTable(SQLiteDatabase source, String sourceTableName, + SQLiteDatabase destination, String destinationTableName) { + Cursor cursor = null; + try { + destination.beginTransaction(); + + cursor = source.query(sourceTableName, null, null, null, null, null, null); + Log.d(LOGTAG, "Trying to copy " + cursor.getCount() + " rows from " + sourceTableName + " to " + destinationTableName); + + final ContentValues contentValues = new ContentValues(); + while (cursor.moveToNext()) { + contentValues.clear(); + DatabaseUtils.cursorRowToContentValues(cursor, contentValues); + destination.insert(destinationTableName, null, contentValues); + } + + destination.setTransactionSuccessful(); + Log.d(LOGTAG, "Successfully copied " + cursor.getCount() + " rows from " + sourceTableName + " to " + destinationTableName); + return true; + } catch (Exception e) { + Log.w(LOGTAG, "Got exception copying rows from " + sourceTableName + " to " + destinationTableName + "; ignoring.", e); + return false; + } finally { + destination.endTransaction(); + if (cursor != null) { + cursor.close(); + } + } + } + + /** + * Verifies that 0-byte arrays aren't added as favicon or thumbnail data. + * @param values ContentValues of query + * @param columnName Name of data column to verify + */ + public static void stripEmptyByteArray(ContentValues values, String columnName) { + if (values.containsKey(columnName)) { + byte[] data = values.getAsByteArray(columnName); + if (data == null || data.length == 0) { + Log.w(LOGTAG, "Tried to insert an empty or non-byte-array image. Ignoring."); + values.putNull(columnName); + } + } + } + + /** + * Builds a selection string that searches for a list of arguments in a particular column. + * For example URL in (?,?,?). Callers should pass the actual arguments into their query + * as selection args. + * @para columnName The column to search in + * @para size The number of arguments to search for + */ + public static String computeSQLInClause(int items, String field) { + final StringBuilder builder = new StringBuilder(field); + builder.append(" IN ("); + int i = 0; + for (; i < items - 1; ++i) { + builder.append("?, "); + } + if (i < items) { + builder.append("?"); + } + builder.append(")"); + return builder.toString(); + } + + /** + * Turn a single-column cursor of longs into a single SQL "IN" clause. + * We can do this without using selection arguments because Long isn't + * vulnerable to injection. + */ + public static String computeSQLInClauseFromLongs(final Cursor cursor, String field) { + final StringBuilder builder = new StringBuilder(field); + builder.append(" IN ("); + final int commaLimit = cursor.getCount() - 1; + int i = 0; + while (cursor.moveToNext()) { + builder.append(cursor.getLong(0)); + if (i++ < commaLimit) { + builder.append(", "); + } + } + builder.append(")"); + return builder.toString(); + } + + public static Uri appendProfile(final String profile, final Uri uri) { + return uri.buildUpon().appendQueryParameter(BrowserContract.PARAM_PROFILE, profile).build(); + } + + public static Uri appendProfileWithDefault(final String profile, final Uri uri) { + if (profile == null) { + return appendProfile(GeckoProfile.DEFAULT_PROFILE, uri); + } + return appendProfile(profile, uri); + } + + /** + * Use the following when no conflict action is specified. + */ + private static final int CONFLICT_NONE = 0; + private static final String[] CONFLICT_VALUES = new String[] {"", " OR ROLLBACK ", " OR ABORT ", " OR FAIL ", " OR IGNORE ", " OR REPLACE "}; + + /** + * Convenience method for updating rows in the database. + * + * @param table the table to update in + * @param values a map from column names to new column values. null is a + * valid value that will be translated to NULL. + * @param whereClause the optional WHERE clause to apply when updating. + * Passing null will update all rows. + * @param whereArgs You may include ?s in the where clause, which + * will be replaced by the values from whereArgs. The values + * will be bound as Strings. + * @return the number of rows affected + */ + @RobocopTarget + public static int updateArrays(SQLiteDatabase db, String table, ContentValues[] values, UpdateOperation[] ops, String whereClause, String[] whereArgs) { + return updateArraysWithOnConflict(db, table, values, ops, whereClause, whereArgs, CONFLICT_NONE, true); + } + + public static void updateArraysBlindly(SQLiteDatabase db, String table, ContentValues[] values, UpdateOperation[] ops, String whereClause, String[] whereArgs) { + updateArraysWithOnConflict(db, table, values, ops, whereClause, whereArgs, CONFLICT_NONE, false); + } + + @RobocopTarget + public enum UpdateOperation { + /** + * ASSIGN is the usual update: replaces the value in the named column with the provided value. + * + * foo = ? + */ + ASSIGN, + + /** + * BITWISE_OR applies the provided value to the existing value with a bitwise OR. This is useful for adding to flags. + * + * foo |= ? + */ + BITWISE_OR, + + /** + * EXPRESSION is an end-run around the API: it allows callers to specify a fragment of SQL to splice into the + * SET part of the query. + * + * foo = $value + * + * Be very careful not to use user input in this. + */ + EXPRESSION, + } + + /** + * This is an evil reimplementation of SQLiteDatabase's methods to allow for + * smarter updating. + * + * Each ContentValues has an associated enum that describes how to unify input values with the existing column values. + */ + private static int updateArraysWithOnConflict(SQLiteDatabase db, String table, + ContentValues[] values, + UpdateOperation[] ops, + String whereClause, + String[] whereArgs, + int conflictAlgorithm, + boolean returnChangedRows) { + if (values == null || values.length == 0) { + throw new IllegalArgumentException("Empty values"); + } + + if (ops == null || ops.length != values.length) { + throw new IllegalArgumentException("ops and values don't match"); + } + + StringBuilder sql = new StringBuilder(120); + sql.append("UPDATE "); + sql.append(CONFLICT_VALUES[conflictAlgorithm]); + sql.append(table); + sql.append(" SET "); + + // move all bind args to one array + int setValuesSize = 0; + for (int i = 0; i < values.length; i++) { + // EXPRESSION types don't contribute any placeholders. + if (ops[i] != UpdateOperation.EXPRESSION) { + setValuesSize += values[i].size(); + } + } + + int bindArgsSize = (whereArgs == null) ? setValuesSize : (setValuesSize + whereArgs.length); + Object[] bindArgs = new Object[bindArgsSize]; + + int arg = 0; + for (int i = 0; i < values.length; i++) { + final ContentValues v = values[i]; + final UpdateOperation op = ops[i]; + + // Alas, code duplication. + switch (op) { + case ASSIGN: + for (Map.Entry<String, Object> entry : v.valueSet()) { + final String colName = entry.getKey(); + sql.append((arg > 0) ? "," : ""); + sql.append(colName); + bindArgs[arg++] = entry.getValue(); + sql.append("= ?"); + } + break; + case BITWISE_OR: + for (Map.Entry<String, Object> entry : v.valueSet()) { + final String colName = entry.getKey(); + sql.append((arg > 0) ? "," : ""); + sql.append(colName); + bindArgs[arg++] = entry.getValue(); + sql.append("= ? | "); + sql.append(colName); + } + break; + case EXPRESSION: + // Treat each value as a literal SQL string. + for (Map.Entry<String, Object> entry : v.valueSet()) { + final String colName = entry.getKey(); + sql.append((arg > 0) ? "," : ""); + sql.append(colName); + sql.append(" = "); + sql.append(entry.getValue()); + } + break; + } + } + + if (whereArgs != null) { + for (arg = setValuesSize; arg < bindArgsSize; arg++) { + bindArgs[arg] = whereArgs[arg - setValuesSize]; + } + } + if (!TextUtils.isEmpty(whereClause)) { + sql.append(" WHERE "); + sql.append(whereClause); + } + + // What a huge pain in the ass, all because SQLiteDatabase doesn't expose .executeSql, + // and we can't get a DB handle. Nor can we easily construct a statement with arguments + // already bound. + final SQLiteStatement statement = db.compileStatement(sql.toString()); + try { + bindAllArgs(statement, bindArgs); + if (!returnChangedRows) { + statement.execute(); + return 0; + } + // This is a separate method so we can annotate it with @TargetApi. + return executeStatementReturningChangedRows(statement); + } finally { + statement.close(); + } + } + + @TargetApi(Build.VERSION_CODES.HONEYCOMB) + private static int executeStatementReturningChangedRows(SQLiteStatement statement) { + return statement.executeUpdateDelete(); + } + + // All because {@link SQLiteProgram#bind(integer, Object)} is private. + private static void bindAllArgs(SQLiteStatement statement, Object[] bindArgs) { + if (bindArgs == null) { + return; + } + for (int i = bindArgs.length; i != 0; i--) { + Object v = bindArgs[i - 1]; + if (v == null) { + statement.bindNull(i); + } else if (v instanceof String) { + statement.bindString(i, (String) v); + } else if (v instanceof Double) { + statement.bindDouble(i, (Double) v); + } else if (v instanceof Float) { + statement.bindDouble(i, (Float) v); + } else if (v instanceof Long) { + statement.bindLong(i, (Long) v); + } else if (v instanceof Integer) { + statement.bindLong(i, (Integer) v); + } else if (v instanceof Byte) { + statement.bindLong(i, (Byte) v); + } else if (v instanceof byte[]) { + statement.bindBlob(i, (byte[]) v); + } + } + } +} |