blob: 0e3a9fa74bc8b23fe1ba3c4d22c3d2dd3e9c86ea [file] [log] [blame]
Hans Boehm8f051c32016-10-03 16:53:58 -07001/*
2 * Copyright (C) 2016 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 * http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17// FIXME: We need to rethink the error handling here. Do we want to revert to history-less
18// operation if something goes wrong with the database?
19// TODO: This tries to ensure strong thread-safety, i.e. each call looks atomic, both to
20// other threads and other database users. Is this useful?
21// TODO: Especially if we notice serious performance issues on rotation in the history
22// view, we may need to use a CursorLoader or some other scheme to preserve the database
23// across rotations.
24
25package com.android.calculator2;
26
27import android.content.ContentValues;
28import android.content.Context;
29import android.database.Cursor;
30import android.database.sqlite.SQLiteDatabase;
31import android.database.sqlite.SQLiteException;
32import android.database.sqlite.SQLiteOpenHelper;
33import android.os.AsyncTask;
34import android.provider.BaseColumns;
35import android.util.Log;
36
37import java.util.Calendar;
38import java.util.TimeZone;
39
40public class ExpressionDB {
41 /* Table contents */
42 public static class ExpressionEntry implements BaseColumns {
43 public static final String TABLE_NAME = "expressions";
44 public static final String COLUMN_NAME_EXPRESSION = "expression";
45 public static final String COLUMN_NAME_FLAGS = "flags";
46 // Time stamp as returned by currentTimeMillis().
47 public static final String COLUMN_NAME_TIMESTAMP = "timeStamp";
48 // UTC offset at the locale when the expression was saved. In multiples of 15 minutes.
49 public static final String COLUMN_NAME_COMPRESSED_UTC_OFFSET = "compressedUtcOffset";
50 }
51
52 /* Data to be written to or read from a row in the table */
53 public static class RowData {
54 private static final int DEGREE_MODE = 2;
55 private static final int LONG_TIMEOUT = 1;
56 public final byte[] mExpression;
57 public final int mFlags;
58 public long mTimeStamp; // 0 ==> this and next field to be filled in when written.
59 public byte mCompressedUtcOffset; // multiples of 15 minutes.
60 private static int flagsFromDegreeAndTimeout(Boolean DegreeMode, Boolean LongTimeout) {
61 return (DegreeMode ? DEGREE_MODE : 0) | (LongTimeout ? LONG_TIMEOUT : 0);
62 }
63 private boolean degreeModeFromFlags(int flags) {
64 return (flags & DEGREE_MODE) != 0;
65 }
66 private boolean longTimeoutFromFlags(int flags) {
67 return (flags & LONG_TIMEOUT) != 0;
68 }
69 private static final int MILLIS_IN_15_MINS = 15 * 60 * 1000;
70 private static byte compressUtcOffset(int utcOffsetMillis) {
71 // Rounded division, though it shouldn't really matter.
72 if (utcOffsetMillis > 0) {
73 return (byte) ((utcOffsetMillis + MILLIS_IN_15_MINS / 2) / MILLIS_IN_15_MINS);
74 } else {
75 return (byte) ((utcOffsetMillis - MILLIS_IN_15_MINS / 2) / MILLIS_IN_15_MINS);
76 }
77 }
78 private static int uncompressUtcOffset(byte compressedUtcOffset) {
79 return MILLIS_IN_15_MINS * (int)compressedUtcOffset;
80 }
81 private RowData(byte[] expr, int flags, long timeStamp, byte compressedUtcOffset) {
82 mExpression = expr;
83 mFlags = flags;
84 mTimeStamp = timeStamp;
85 mCompressedUtcOffset = compressedUtcOffset;
86 }
87 /**
88 * More client-friendly constructor that hides implementation ugliness.
89 * utcOffset here is uncompressed, in milliseconds.
90 * A zero timestamp will cause it to be automatically filled in.
91 */
92 public RowData(byte[] expr, boolean degreeMode, boolean longTimeout, long timeStamp,
93 int utcOffset) {
94 this(expr, flagsFromDegreeAndTimeout(degreeMode, longTimeout), timeStamp,
95 compressUtcOffset(utcOffset));
96 }
97 public boolean degreeMode() {
98 return degreeModeFromFlags(mFlags);
99 }
100 public boolean longTimeout() {
101 return longTimeoutFromFlags(mFlags);
102 }
103 /**
104 * Return UTC offset for timestamp in milliseconds.
105 */
106 public int utcOffset() {
107 return uncompressUtcOffset(mCompressedUtcOffset);
108 }
109 /**
110 * Return a ContentValues object representing the current data.
111 */
112 public ContentValues toContentValues() {
113 ContentValues cvs = new ContentValues();
114 cvs.put(ExpressionEntry.COLUMN_NAME_EXPRESSION, mExpression);
115 cvs.put(ExpressionEntry.COLUMN_NAME_FLAGS, mFlags);
116 if (mTimeStamp == 0) {
117 mTimeStamp = System.currentTimeMillis();
118 final TimeZone timeZone = Calendar.getInstance().getTimeZone();
119 mCompressedUtcOffset = compressUtcOffset(timeZone.getOffset(mTimeStamp));
120 }
121 cvs.put(ExpressionEntry.COLUMN_NAME_TIMESTAMP, mTimeStamp);
122 cvs.put(ExpressionEntry.COLUMN_NAME_COMPRESSED_UTC_OFFSET, mCompressedUtcOffset);
123 return cvs;
124 }
125 }
126
127 private static final String SQL_CREATE_ENTRIES =
128 "CREATE TABLE " + ExpressionEntry.TABLE_NAME + " (" +
129 ExpressionEntry._ID + " INTEGER PRIMARY KEY," +
130 ExpressionEntry.COLUMN_NAME_EXPRESSION + " BLOB," +
131 ExpressionEntry.COLUMN_NAME_FLAGS + " INTEGER," +
132 ExpressionEntry.COLUMN_NAME_TIMESTAMP + " INTEGER," +
133 ExpressionEntry.COLUMN_NAME_COMPRESSED_UTC_OFFSET + " INTEGER)";
134 private static final String SQL_DELETE_ENTRIES =
135 "DROP TABLE IF EXISTS " + ExpressionEntry.TABLE_NAME;
136 private static final String SQL_GET_MIN = "SELECT MIN(" + ExpressionEntry._ID +
137 ") FROM " + ExpressionEntry.TABLE_NAME;
138 private static final String SQL_GET_MAX = "SELECT MAX(" + ExpressionEntry._ID +
139 ") FROM " + ExpressionEntry.TABLE_NAME;
140 private static final String SQL_GET_ROW = "SELECT * FROM " + ExpressionEntry.TABLE_NAME +
141 " WHERE " + ExpressionEntry._ID + " = ?";
142
143 private class ExpressionDBHelper extends SQLiteOpenHelper {
144 // If you change the database schema, you must increment the database version.
145 public static final int DATABASE_VERSION = 1;
146 public static final String DATABASE_NAME = "Expressions.db";
147
148 public ExpressionDBHelper(Context context) {
149 super(context, DATABASE_NAME, null, DATABASE_VERSION);
150 }
151 public void onCreate(SQLiteDatabase db) {
152 db.execSQL(SQL_CREATE_ENTRIES);
153 }
154 public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
155 // For now just throw away history on database version upgrade/downgrade.
156 db.execSQL(SQL_DELETE_ENTRIES);
157 onCreate(db);
158 }
159 public void onDowngrade(SQLiteDatabase db, int oldVersion, int newVersion) {
160 onUpgrade(db, oldVersion, newVersion);
161 }
162 }
163
164 private ExpressionDBHelper mExpressionDBHelper;
165
166 private SQLiteDatabase mExpressionDB; // Constant after initialization.
167
168 private boolean mBadDB = false; // Database initialization failed.
169
170 // Never allocate new negative indicees (row ids) >= MAXIMUM_MIN_INDEX.
171 public static final long MAXIMUM_MIN_INDEX = -10;
172
173 // Minimum index value in DB.
174 private long mMinIndex;
175 // Maximum index value in DB.
176 private long mMaxIndex;
177 // mMinIndex and mMaxIndex are correct.
178 private boolean mMinMaxValid;
179
180 // mLock protects mExpressionDB and mBadDB, though we access mExpressionDB without
181 // synchronization after it's known to be initialized. Used to wait for database
182 // initialization. Also protects mMinIndex, mMaxIndex, and mMinMaxValid.
183 private Object mLock = new Object();
184
185 public ExpressionDB(Context context) {
186 mExpressionDBHelper = new ExpressionDBHelper(context);
187 AsyncInitializer initializer = new AsyncInitializer();
188 initializer.execute(mExpressionDBHelper);
189 }
190
191 private boolean getBadDB() {
192 synchronized(mLock) {
193 return mBadDB;
194 }
195 }
196
197 private void setBadDB() {
198 synchronized(mLock) {
199 mBadDB = true;
200 }
201 }
202
203 /**
204 * Set mExpressionDB and compute minimum and maximum indices in the background.
205 */
206 private class AsyncInitializer extends AsyncTask<ExpressionDBHelper, Void, SQLiteDatabase> {
207 @Override
208 protected SQLiteDatabase doInBackground(ExpressionDBHelper... helper) {
209 SQLiteDatabase result;
210 try {
211 result = helper[0].getWritableDatabase();
212 // We notify here, since there are unlikely cases in which the UI thread
213 // may be blocked on us, preventing onPostExecute from running.
214 synchronized(mLock) {
215 mExpressionDB = result;
216 mLock.notifyAll();
217 }
218 long min, max;
219 try (Cursor minResult = result.rawQuery(SQL_GET_MIN, null)) {
220 if (!minResult.moveToFirst()) {
221 // Empty database.
222 min = MAXIMUM_MIN_INDEX;
223 } else {
224 min = Math.min(minResult.getLong(0), MAXIMUM_MIN_INDEX);
225 }
226 }
227 try (Cursor maxResult = result.rawQuery(SQL_GET_MAX, null)) {
228 if (!maxResult.moveToFirst()) {
229 // Empty database.
230 max = 0L;
231 } else {
232 max = Math.max(maxResult.getLong(0), 0L);
233 }
234 }
235 synchronized(mLock) {
236 mMinIndex = min;
237 mMaxIndex = max;
238 mMinMaxValid = true;
239 mLock.notifyAll();
240 }
241 } catch(SQLiteException e) {
242 Log.e("Calculator", "Database initialization failed.\n", e);
243 synchronized(mLock) {
244 mBadDB = true;
245 mLock.notifyAll();
246 }
247 return null;
248 }
249 return result;
250 }
251
252 @Override
253 protected void onPostExecute(SQLiteDatabase result) {
254 if (result == null) {
255 throw new AssertionError("Failed to open history DB");
256 // TODO: Should we try to run without persistent history instead?
257 } // else doInBackground already set expressionDB.
258 }
259 // On cancellation we do nothing;
260 }
261
262 /**
263 * Wait until expression DB is ready.
264 * This should usually be a no-op, since we set up the DB on creation. But there are a few
265 * cases, such as restarting the calculator in history mode, when we currently can't do
266 * anything but wait, possibly even in the UI thread.
267 */
268 private void waitForExpressionDB() {
269 synchronized(mLock) {
270 while (mExpressionDB == null && !mBadDB) {
271 try {
272 mLock.wait();
273 } catch(InterruptedException e) {
274 mBadDB = true;
275 }
276 }
277 if (mBadDB) {
278 throw new AssertionError("Failed to open history DB");
279 }
280 }
281 }
282
283 /**
284 * Wait until the minimum key has been computed.
285 */
286 private void waitForMinMaxValid() {
287 synchronized(mLock) {
288 while (!mMinMaxValid && !mBadDB) {
289 try {
290 mLock.wait();
291 } catch(InterruptedException e) {
292 mBadDB = true;
293 }
294 }
295 if (mBadDB) {
296 throw new AssertionError("Failed to compute minimum key");
297 }
298 }
299 }
300
301 public synchronized void eraseAll() {
302 waitForExpressionDB();
303 mExpressionDB.execSQL("SQL_DELETE_ENTRIES");
304 try {
305 mExpressionDB.execSQL("VACUUM");
306 } catch(Exception e) {
307 Log.v("Calculator", "Database VACUUM failed\n", e);
308 // FIXME: probably needed only if there is danger of concurrent execution.
309 }
310 }
311
312 /**
313 * Update a row in place.
314 * Currently unused, since only the main expression is updated in place, and we
315 * don't save that in the DB.
316 */
317 public void updateRow(long index, RowData data) {
318 if (index == -1) {
319 setBadDB();
320 throw new AssertionError("Can't insert row index of -1");
321 }
322 ContentValues cvs = data.toContentValues();
323 cvs.put(ExpressionEntry._ID, index);
324 waitForExpressionDB();
325 long result = mExpressionDB.replace(ExpressionEntry.TABLE_NAME, null, cvs);
326 if (result == -1) {
327 throw new AssertionError("Row update failed");
328 }
329 }
330
331 /**
332 * Add a row with index outside existing range.
333 * The returned index will be larger than any existing index unless negative_index is true.
334 * In that case it will be smaller than any existing index and smaller than MAXIMUM_MIN_INDEX.
335 */
336 public long addRow(boolean negative_index, RowData data) {
337 long result;
338 long newIndex;
339 waitForMinMaxValid();
340 synchronized(mLock) {
341 if (negative_index) {
342 newIndex = mMinIndex - 1;
343 mMinIndex = newIndex;
344 } else {
345 newIndex = mMaxIndex + 1;
346 mMaxIndex = newIndex;
347 }
348 ContentValues cvs = data.toContentValues();
349 cvs.put(ExpressionEntry._ID, newIndex);
350 result = mExpressionDB.insert(ExpressionEntry.TABLE_NAME, null, cvs);
351 }
352 if (result != newIndex) {
353 throw new AssertionError("Expected row id " + newIndex + ", got " + result);
354 }
355 return result;
356 }
357
358 /**
359 * Retrieve the row with the given index.
360 * Such a row must exist.
361 */
362 public RowData getRow(long index) {
363 RowData result;
364 waitForExpressionDB();
365 String args[] = new String[] { Long.toString(index) };
366 Cursor resultC = mExpressionDB.rawQuery(SQL_GET_ROW, args);
367 if (!resultC.moveToFirst()) {
368 throw new AssertionError("Missing Row");
369 } else {
370 result = new RowData(resultC.getBlob(1), resultC.getInt(2) /* flags */,
371 resultC.getLong(3) /* timestamp */, (byte)resultC.getInt(4) /* UTC offset */);
372 }
373 return result;
374 }
375
376 public long getMinIndex() {
377 waitForMinMaxValid();
378 synchronized(mLock) {
379 return mMinIndex;
380 }
381 }
382
383 public long getMaxIndex() {
384 waitForMinMaxValid();
385 synchronized(mLock) {
386 return mMaxIndex;
387 }
388 }
389
390 public void close() {
391 mExpressionDBHelper.close();
392 }
393
394}