blob: 9a0f8ecb17f800debd05a12cb8e3baa53adacd4b [file] [log] [blame]
/*
* Copyright (C) 2016 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// We make some strong assumptions about the databases we manipulate.
// We maintain a single table containg expressions, their indices in the sequence of
// expressions, and some data associated with each expression.
// All indices are used, except for a small gap around zero. New rows are added
// either just below the current minimum (negative) index, or just above the current
// maximum index. Currently no rows are deleted unless we clear the whole table.
// TODO: Especially if we notice serious performance issues on rotation in the history
// view, we may need to use a CursorLoader or some other scheme to preserve the database
// across rotations.
// TODO: We may want to switch to a scheme in which all expressions saved in the database have
// a positive index, and a flag indicates whether the expression is displayed as part of
// the history or not. That would avoid potential thrashing between CursorWindows when accessing
// with a negative index. It would also make it easy to sort expressions in dependency order,
// which helps with avoiding deep recursion during evaluation. But it makes the history UI
// implementation more complicated. It should be possible to make this change without a
// database version bump.
// This ensures strong thread-safety, i.e. each call looks atomic to other threads. We need some
// such property, since expressions may be read by one thread while the main thread is updating
// another expression.
package com.android.calculator2;
import android.app.Activity;
import android.content.ContentValues;
import android.content.Context;
import android.database.AbstractWindowedCursor;
import android.database.Cursor;
import android.database.CursorWindow;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteException;
import android.database.sqlite.SQLiteOpenHelper;
import android.os.AsyncTask;
import android.provider.BaseColumns;
import android.util.Log;
import android.view.View;
public class ExpressionDB {
private final boolean CONTINUE_WITH_BAD_DB = false;
/* Table contents */
public static class ExpressionEntry implements BaseColumns {
public static final String TABLE_NAME = "expressions";
public static final String COLUMN_NAME_EXPRESSION = "expression";
public static final String COLUMN_NAME_FLAGS = "flags";
// Time stamp as returned by currentTimeMillis().
public static final String COLUMN_NAME_TIMESTAMP = "timeStamp";
}
/* Data to be written to or read from a row in the table */
public static class RowData {
private static final int DEGREE_MODE = 2;
private static final int LONG_TIMEOUT = 1;
public final byte[] mExpression;
public final int mFlags;
public long mTimeStamp; // 0 ==> this and next field to be filled in when written.
private static int flagsFromDegreeAndTimeout(Boolean DegreeMode, Boolean LongTimeout) {
return (DegreeMode ? DEGREE_MODE : 0) | (LongTimeout ? LONG_TIMEOUT : 0);
}
private boolean degreeModeFromFlags(int flags) {
return (flags & DEGREE_MODE) != 0;
}
private boolean longTimeoutFromFlags(int flags) {
return (flags & LONG_TIMEOUT) != 0;
}
private static final int MILLIS_IN_15_MINS = 15 * 60 * 1000;
private RowData(byte[] expr, int flags, long timeStamp) {
mExpression = expr;
mFlags = flags;
mTimeStamp = timeStamp;
}
/**
* More client-friendly constructor that hides implementation ugliness.
* utcOffset here is uncompressed, in milliseconds.
* A zero timestamp will cause it to be automatically filled in.
*/
public RowData(byte[] expr, boolean degreeMode, boolean longTimeout, long timeStamp) {
this(expr, flagsFromDegreeAndTimeout(degreeMode, longTimeout), timeStamp);
}
public boolean degreeMode() {
return degreeModeFromFlags(mFlags);
}
public boolean longTimeout() {
return longTimeoutFromFlags(mFlags);
}
/**
* Return a ContentValues object representing the current data.
*/
public ContentValues toContentValues() {
ContentValues cvs = new ContentValues();
cvs.put(ExpressionEntry.COLUMN_NAME_EXPRESSION, mExpression);
cvs.put(ExpressionEntry.COLUMN_NAME_FLAGS, mFlags);
if (mTimeStamp == 0) {
mTimeStamp = System.currentTimeMillis();
}
cvs.put(ExpressionEntry.COLUMN_NAME_TIMESTAMP, mTimeStamp);
return cvs;
}
}
private static final String SQL_CREATE_ENTRIES =
"CREATE TABLE " + ExpressionEntry.TABLE_NAME + " ("
+ ExpressionEntry._ID + " INTEGER PRIMARY KEY,"
+ ExpressionEntry.COLUMN_NAME_EXPRESSION + " BLOB,"
+ ExpressionEntry.COLUMN_NAME_FLAGS + " INTEGER,"
+ ExpressionEntry.COLUMN_NAME_TIMESTAMP + " INTEGER)";
private static final String SQL_DROP_TABLE =
"DROP TABLE IF EXISTS " + ExpressionEntry.TABLE_NAME;
private static final String SQL_GET_MIN = "SELECT MIN(" + ExpressionEntry._ID
+ ") FROM " + ExpressionEntry.TABLE_NAME;
private static final String SQL_GET_MAX = "SELECT MAX(" + ExpressionEntry._ID
+ ") FROM " + ExpressionEntry.TABLE_NAME;
private static final String SQL_GET_ROW = "SELECT * FROM " + ExpressionEntry.TABLE_NAME
+ " WHERE " + ExpressionEntry._ID + " = ?";
private static final String SQL_GET_ALL = "SELECT * FROM " + ExpressionEntry.TABLE_NAME
+ " WHERE " + ExpressionEntry._ID + " <= ? AND " +
ExpressionEntry._ID + " >= ?" + " ORDER BY " + ExpressionEntry._ID + " DESC ";
// We may eventually need an index by timestamp. We don't use it yet.
private static final String SQL_CREATE_TIMESTAMP_INDEX =
"CREATE INDEX timestamp_index ON " + ExpressionEntry.TABLE_NAME + "("
+ ExpressionEntry.COLUMN_NAME_TIMESTAMP + ")";
private static final String SQL_DROP_TIMESTAMP_INDEX = "DROP INDEX IF EXISTS timestamp_index";
private class ExpressionDBHelper extends SQLiteOpenHelper {
// If you change the database schema, you must increment the database version.
public static final int DATABASE_VERSION = 1;
public static final String DATABASE_NAME = "Expressions.db";
public ExpressionDBHelper(Context context) {
super(context, DATABASE_NAME, null, DATABASE_VERSION);
}
public void onCreate(SQLiteDatabase db) {
db.execSQL(SQL_CREATE_ENTRIES);
db.execSQL(SQL_CREATE_TIMESTAMP_INDEX);
}
public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
// For now just throw away history on database version upgrade/downgrade.
db.execSQL(SQL_DROP_TIMESTAMP_INDEX);
db.execSQL(SQL_DROP_TABLE);
onCreate(db);
}
public void onDowngrade(SQLiteDatabase db, int oldVersion, int newVersion) {
onUpgrade(db, oldVersion, newVersion);
}
}
private ExpressionDBHelper mExpressionDBHelper;
private SQLiteDatabase mExpressionDB; // Constant after initialization.
// Expression indices between mMinAccessible and mMaxAccessible inclusive can be accessed.
// We set these to more interesting values if a database access fails.
// We punt on writes outside this range. We should never read outside this range.
// If higher layers refer to an index outside this range, it will already be cached.
// This also somewhat limits the size of the database, but only to an unreasonably
// huge value.
private long mMinAccessible = -10000000L;
private long mMaxAccessible = 10000000L;
// Never allocate new negative indicees (row ids) >= MAXIMUM_MIN_INDEX.
public static final long MAXIMUM_MIN_INDEX = -10;
// Minimum index value in DB.
private long mMinIndex;
// Maximum index value in DB.
private long mMaxIndex;
// A cursor that refers to the whole table, in reverse order.
private AbstractWindowedCursor mAllCursor;
// Expression index corresponding to a zero absolute offset for mAllCursor.
// This is the argument we passed to the query.
// We explicitly query only for entries that existed when we started, to avoid
// interference from updates as we're running. It's unclear whether or not this matters.
private int mAllCursorBase;
// Database has been opened, mMinIndex and mMaxIndex are correct, mAllCursorBase and
// mAllCursor have been set.
private boolean mDBInitialized;
// Gap between negative and positive row ids in the database.
// Expressions with index [MAXIMUM_MIN_INDEX .. 0] are not stored.
private static final long GAP = -MAXIMUM_MIN_INDEX + 1;
// mLock protects mExpressionDB, mMinAccessible, and mMaxAccessible, mAllCursor,
// mAllCursorBase, mMinIndex, mMaxIndex, and mDBInitialized. We access mExpressionDB without
// synchronization after it's known to be initialized. Used to wait for database
// initialization.
private Object mLock = new Object();
public ExpressionDB(Context context) {
mExpressionDBHelper = new ExpressionDBHelper(context);
AsyncInitializer initializer = new AsyncInitializer();
// All calls that create background database accesses are made from the UI thread, and
// use a SERIAL_EXECUTOR. Thus they execute in order.
initializer.executeOnExecutor(AsyncTask.SERIAL_EXECUTOR, mExpressionDBHelper);
}
// Is database completely unusable?
private boolean isDBBad() {
if (!CONTINUE_WITH_BAD_DB) {
return false;
}
synchronized(mLock) {
return mMinAccessible > mMaxAccessible;
}
}
// Is the index in the accessible range of the database?
private boolean inAccessibleRange(long index) {
if (!CONTINUE_WITH_BAD_DB) {
return true;
}
synchronized(mLock) {
return index >= mMinAccessible && index <= mMaxAccessible;
}
}
private void setBadDB() {
if (!CONTINUE_WITH_BAD_DB) {
Log.e("Calculator", "Database access failed");
throw new RuntimeException("Database access failed");
}
displayDatabaseWarning();
synchronized(mLock) {
mMinAccessible = 1L;
mMaxAccessible = -1L;
}
}
/**
* Initialize the database in the background.
*/
private class AsyncInitializer extends AsyncTask<ExpressionDBHelper, Void, SQLiteDatabase> {
@Override
protected SQLiteDatabase doInBackground(ExpressionDBHelper... helper) {
try {
SQLiteDatabase db = helper[0].getWritableDatabase();
synchronized(mLock) {
mExpressionDB = db;
try (Cursor minResult = db.rawQuery(SQL_GET_MIN, null)) {
if (!minResult.moveToFirst()) {
// Empty database.
mMinIndex = MAXIMUM_MIN_INDEX;
} else {
mMinIndex = Math.min(minResult.getLong(0), MAXIMUM_MIN_INDEX);
}
}
try (Cursor maxResult = db.rawQuery(SQL_GET_MAX, null)) {
if (!maxResult.moveToFirst()) {
// Empty database.
mMaxIndex = 0L;
} else {
mMaxIndex = Math.max(maxResult.getLong(0), 0L);
}
}
if (mMaxIndex > Integer.MAX_VALUE) {
throw new AssertionError("Expression index absurdly large");
}
mAllCursorBase = (int)mMaxIndex;
if (mMaxIndex != 0L || mMinIndex != MAXIMUM_MIN_INDEX) {
// Set up a cursor for reading the entire database.
String args[] = new String[]
{ Long.toString(mAllCursorBase), Long.toString(mMinIndex) };
mAllCursor = (AbstractWindowedCursor) db.rawQuery(SQL_GET_ALL, args);
if (!mAllCursor.moveToFirst()) {
setBadDB();
return null;
}
}
mDBInitialized = true;
// We notify here, since there are unlikely cases in which the UI thread
// may be blocked on us, preventing onPostExecute from running.
mLock.notifyAll();
}
return db;
} catch(SQLiteException e) {
Log.e("Calculator", "Database initialization failed.\n", e);
synchronized(mLock) {
setBadDB();
mLock.notifyAll();
}
return null;
}
}
@Override
protected void onPostExecute(SQLiteDatabase result) {
if (result == null) {
displayDatabaseWarning();
} // else doInBackground already set expressionDB.
}
// On cancellation we do nothing;
}
private boolean databaseWarningIssued;
/**
* Display a warning message that a database access failed.
* Do this only once. TODO: Replace with a real UI message.
*/
void displayDatabaseWarning() {
if (!databaseWarningIssued) {
Log.e("Calculator", "Calculator restarting due to database error");
databaseWarningIssued = true;
}
}
/**
* Wait until the database and mAllCursor, etc. have been initialized.
*/
private void waitForDBInitialized() {
synchronized(mLock) {
// InterruptedExceptions are inconvenient here. Defer.
boolean caught = false;
while (!mDBInitialized && !isDBBad()) {
try {
mLock.wait();
} catch(InterruptedException e) {
caught = true;
}
}
if (caught) {
Thread.currentThread().interrupt();
}
}
}
/**
* Erase the entire database. Assumes no other accesses to the database are
* currently in progress
* These tasks must be executed on a serial executor to avoid reordering writes.
*/
private class AsyncEraser extends AsyncTask<Void, Void, Void> {
@Override
protected Void doInBackground(Void... nothings) {
mExpressionDB.execSQL(SQL_DROP_TIMESTAMP_INDEX);
mExpressionDB.execSQL(SQL_DROP_TABLE);
try {
mExpressionDB.execSQL("VACUUM");
} catch(Exception e) {
Log.v("Calculator", "Database VACUUM failed\n", e);
// Should only happen with concurrent execution, which should be impossible.
}
mExpressionDB.execSQL(SQL_CREATE_ENTRIES);
mExpressionDB.execSQL(SQL_CREATE_TIMESTAMP_INDEX);
return null;
}
@Override
protected void onPostExecute(Void nothing) {
synchronized(mLock) {
// Reinitialize everything to an empty and fully functional database.
mMinAccessible = -10000000L;
mMaxAccessible = 10000000L;
mMinIndex = MAXIMUM_MIN_INDEX;
mMaxIndex = mAllCursorBase = 0;
mDBInitialized = true;
mLock.notifyAll();
}
}
// On cancellation we do nothing;
}
/**
* Erase ALL database entries.
* This is currently only safe if expressions that may refer to them are also erased.
* Should only be called when concurrent references to the database are impossible.
* TODO: Look at ways to more selectively clear the database.
*/
public void eraseAll() {
waitForDBInitialized();
synchronized(mLock) {
mDBInitialized = false;
}
AsyncEraser eraser = new AsyncEraser();
eraser.executeOnExecutor(AsyncTask.SERIAL_EXECUTOR);
}
// We track the number of outstanding writes to prevent onSaveInstanceState from
// completing with in-flight database writes.
private int mIncompleteWrites = 0;
private Object mWriteCountsLock = new Object(); // Protects the preceding field.
private void writeCompleted() {
synchronized(mWriteCountsLock) {
if (--mIncompleteWrites == 0) {
mWriteCountsLock.notifyAll();
}
}
}
private void writeStarted() {
synchronized(mWriteCountsLock) {
++mIncompleteWrites;
}
}
/**
* Wait for in-flight writes to complete.
* This is not safe to call from one of our background tasks, since the writing
* tasks may be waiting for the same underlying thread that we're using, resulting
* in deadlock.
*/
public void waitForWrites() {
synchronized(mWriteCountsLock) {
boolean caught = false;
while (mIncompleteWrites != 0) {
try {
mWriteCountsLock.wait();
} catch (InterruptedException e) {
caught = true;
}
}
if (caught) {
Thread.currentThread().interrupt();
}
}
}
/**
* Insert the given row in the database without blocking the UI thread.
* These tasks must be executed on a serial executor to avoid reordering writes.
*/
private class AsyncWriter extends AsyncTask<ContentValues, Void, Long> {
@Override
protected Long doInBackground(ContentValues... cvs) {
long index = cvs[0].getAsLong(ExpressionEntry._ID);
long result = mExpressionDB.insert(ExpressionEntry.TABLE_NAME, null, cvs[0]);
writeCompleted();
// Return 0 on success, row id on failure.
if (result == -1) {
return index;
} else if (result != index) {
throw new AssertionError("Expected row id " + index + ", got " + result);
} else {
return 0L;
}
}
@Override
protected void onPostExecute(Long result) {
if (result != 0) {
synchronized(mLock) {
if (result > 0) {
mMaxAccessible = result - 1;
} else {
mMinAccessible = result + 1;
}
}
displayDatabaseWarning();
}
}
// On cancellation we do nothing;
}
/**
* Add a row with index outside existing range.
* The returned index will be just larger than any existing index unless negative_index is true.
* In that case it will be smaller than any existing index and smaller than MAXIMUM_MIN_INDEX.
* This ensures that prior additions have completed, but does not wait for this insertion
* to complete.
*/
public long addRow(boolean negativeIndex, RowData data) {
long result;
long newIndex;
waitForDBInitialized();
synchronized(mLock) {
if (negativeIndex) {
newIndex = mMinIndex - 1;
mMinIndex = newIndex;
} else {
newIndex = mMaxIndex + 1;
mMaxIndex = newIndex;
}
if (!inAccessibleRange(newIndex)) {
// Just drop it, but go ahead and return a new index to use for the cache.
// So long as reads of previously written expressions continue to work,
// we should be fine. When the application is restarted, history will revert
// to just include values between mMinAccessible and mMaxAccessible.
return newIndex;
}
writeStarted();
ContentValues cvs = data.toContentValues();
cvs.put(ExpressionEntry._ID, newIndex);
AsyncWriter awriter = new AsyncWriter();
// Ensure that writes are executed in order.
awriter.executeOnExecutor(AsyncTask.SERIAL_EXECUTOR, cvs);
}
return newIndex;
}
/**
* Generate a fake database row that's good enough to hopefully prevent crashes,
* but bad enough to avoid confusion with real data. In particular, the result
* will fail to evaluate.
*/
RowData makeBadRow() {
CalculatorExpr badExpr = new CalculatorExpr();
badExpr.add(R.id.lparen);
badExpr.add(R.id.rparen);
return new RowData(badExpr.toBytes(), false, false, 0);
}
/**
* Retrieve the row with the given index using a direct query.
* Such a row must exist.
* We assume that the database has been initialized, and the argument has been range checked.
*/
private RowData getRowDirect(long index) {
RowData result;
String args[] = new String[] { Long.toString(index) };
try (Cursor resultC = mExpressionDB.rawQuery(SQL_GET_ROW, args)) {
if (!resultC.moveToFirst()) {
setBadDB();
return makeBadRow();
} else {
result = new RowData(resultC.getBlob(1), resultC.getInt(2) /* flags */,
resultC.getLong(3) /* timestamp */);
}
}
return result;
}
/**
* Retrieve the row at the given offset from mAllCursorBase.
* Note the argument is NOT an expression index!
* We assume that the database has been initialized, and the argument has been range checked.
*/
private RowData getRowFromCursor(int offset) {
RowData result;
synchronized(mLock) {
if (!mAllCursor.moveToPosition(offset)) {
Log.e("Calculator", "Failed to move cursor to position " + offset);
setBadDB();
return makeBadRow();
}
return new RowData(mAllCursor.getBlob(1), mAllCursor.getInt(2) /* flags */,
mAllCursor.getLong(3) /* timestamp */);
}
}
/**
* Retrieve the database row at the given index.
* We currently assume that we never read data that we added since we initialized the database.
* This makes sense, since we cache it anyway. And we should always cache recently added data.
*/
public RowData getRow(long index) {
waitForDBInitialized();
if (!inAccessibleRange(index)) {
// Even if something went wrong opening or writing the database, we should
// not see such read requests, unless they correspond to a persistently
// saved index, and we can't retrieve that expression.
displayDatabaseWarning();
return makeBadRow();
}
int position = mAllCursorBase - (int)index;
// We currently assume that the only gap between expression indices is the one around 0.
if (index < 0) {
position -= GAP;
}
if (position < 0) {
throw new AssertionError("Database access out of range, index = " + index
+ " rel. pos. = " + position);
}
if (index < 0) {
// Avoid using mAllCursor to read data that's far away from the current position,
// since we're likely to have to return to the current position.
// This is a heuristic; we don't worry about doing the "wrong" thing in the race case.
int endPosition;
synchronized(mLock) {
CursorWindow window = mAllCursor.getWindow();
endPosition = window.getStartPosition() + window.getNumRows();
}
if (position >= endPosition) {
return getRowDirect(index);
}
}
// In the positive index case, it's probably OK to cross a cursor boundary, since
// we're much more likely to stay in the new window.
return getRowFromCursor(position);
}
public long getMinIndex() {
waitForDBInitialized();
synchronized(mLock) {
return mMinIndex;
}
}
public long getMaxIndex() {
waitForDBInitialized();
synchronized(mLock) {
return mMaxIndex;
}
}
public void close() {
mExpressionDBHelper.close();
}
}