/* * 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 { @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 { @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 { @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(); } }