From 0e1595c177e40428b267a8696dfc05d015ce6a2f Mon Sep 17 00:00:00 2001 From: Marc Blank Date: Wed, 18 Nov 2009 17:11:33 -0800 Subject: [PATCH] Handle unexpected deletion of EmailProvider database * Addresses #2226426 * If the user deletes Email data, or if data corruption causes EmailProvider.db to be deleted, we will be in an inconsistent state with any existing Exchange accounts, since the AccountManager will still know about them, contacts (and eventually calendar) items will continue to exist, etc. * Run an integrity check when the provider is created, deleting any orphaned EmailProvider.db or EmailProviderBody.db * Catch SQLiteException's in the Provider and do an integrity check if any is caught Change-Id: I47d523b90a6b8f71ba8e13fba4b04846b3da1b1d --- .../android/email/provider/EmailProvider.java | 351 +++++++++++------- .../android/email/provider/ProviderTests.java | 122 +++++- 2 files changed, 330 insertions(+), 143 deletions(-) diff --git a/src/com/android/email/provider/EmailProvider.java b/src/com/android/email/provider/EmailProvider.java index 4730f164b..e1bf4381d 100644 --- a/src/com/android/email/provider/EmailProvider.java +++ b/src/com/android/email/provider/EmailProvider.java @@ -44,18 +44,23 @@ import android.content.UriMatcher; import android.database.Cursor; import android.database.SQLException; import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteException; import android.database.sqlite.SQLiteOpenHelper; import android.net.Uri; import android.util.Log; +import java.io.File; import java.util.ArrayList; public class EmailProvider extends ContentProvider { private static final String TAG = "EmailProvider"; - private static final String DATABASE_NAME = "EmailProvider.db"; - private static final String BODY_DATABASE_NAME = "EmailProviderBody.db"; + protected static final String DATABASE_NAME = "EmailProvider.db"; + protected static final String BODY_DATABASE_NAME = "EmailProviderBody.db"; + + public static final Uri INTEGRITY_CHECK_URI = + Uri.parse("content://" + EmailContent.AUTHORITY + "/integrityCheck"); // Definitions for our queries looking for orphaned messages private static final String[] ORPHANS_PROJECTION @@ -65,6 +70,8 @@ public class EmailProvider extends ContentProvider { private static final String WHERE_ID = EmailContent.RECORD_ID + "=?"; + private static final String[] COUNT_COLUMNS = new String[]{"count(*)"}; + // Any changes to the database format *must* include update-in-place code. // Original version: 3 // Version 4: Database wipe required; changing AccountManager interface w/Exchange @@ -543,9 +550,15 @@ public class EmailProvider extends ContentProvider { private SQLiteDatabase mBodyDatabase; public synchronized SQLiteDatabase getDatabase(Context context) { - if (mDatabase != null) { + // Always return the cached database, if we've got one + if (mDatabase != null) { return mDatabase; } + + // Whenever we create or re-cache the databases, make sure that we haven't lost one + // to corruption + checkDatabases(); + DatabaseHelper helper = new DatabaseHelper(context, DATABASE_NAME); mDatabase = helper.getWritableDatabase(); if (mDatabase != null) { @@ -626,7 +639,7 @@ public class EmailProvider extends ContentProvider { @Override public void onCreate(SQLiteDatabase db) { - // Create all tables here; each class has its own method + Log.d(TAG, "Creating EmailProviderBody database"); createBodyTable(db); } @@ -650,6 +663,7 @@ public class EmailProvider extends ContentProvider { @Override public void onCreate(SQLiteDatabase db) { + Log.d(TAG, "Creating EmailProvider database"); // Create all tables here; each class has its own method createMessageTable(db); createAttachmentTable(db); @@ -797,6 +811,9 @@ public class EmailProvider extends ContentProvider { } db.setTransactionSuccessful(); } + } catch (SQLiteException e) { + checkDatabases(); + throw e; } finally { if (messageDeletion) { db.endTransaction(); @@ -860,51 +877,56 @@ public class EmailProvider extends ContentProvider { Uri resultUri = null; - switch (match) { - case UPDATED_MESSAGE: - case DELETED_MESSAGE: - case BODY: - case MESSAGE: - case ATTACHMENT: - case MAILBOX: - case ACCOUNT: - case HOSTAUTH: - id = db.insert(TABLE_NAMES[table], "foo", values); - resultUri = ContentUris.withAppendedId(uri, id); - // Clients shouldn't normally be adding rows to these tables, as they are - // maintained by triggers. However, we need to be able to do this for unit - // testing, so we allow the insert and then throw the same exception that we - // would if this weren't allowed. - if (match == UPDATED_MESSAGE || match == DELETED_MESSAGE) { + try { + switch (match) { + case UPDATED_MESSAGE: + case DELETED_MESSAGE: + case BODY: + case MESSAGE: + case ATTACHMENT: + case MAILBOX: + case ACCOUNT: + case HOSTAUTH: + id = db.insert(TABLE_NAMES[table], "foo", values); + resultUri = ContentUris.withAppendedId(uri, id); + // Clients shouldn't normally be adding rows to these tables, as they are + // maintained by triggers. However, we need to be able to do this for unit + // testing, so we allow the insert and then throw the same exception that we + // would if this weren't allowed. + if (match == UPDATED_MESSAGE || match == DELETED_MESSAGE) { + throw new IllegalArgumentException("Unknown URL " + uri); + } + break; + case MAILBOX_ID: + // This implies adding a message to a mailbox + // Hmm, a problem here is that we can't link the account as well, so it must be + // already in the values... + id = Long.parseLong(uri.getPathSegments().get(1)); + values.put(MessageColumns.MAILBOX_KEY, id); + resultUri = insert(Message.CONTENT_URI, values); + break; + case MESSAGE_ID: + // This implies adding an attachment to a message. + id = Long.parseLong(uri.getPathSegments().get(1)); + values.put(AttachmentColumns.MESSAGE_KEY, id); + resultUri = insert(Attachment.CONTENT_URI, values); + break; + case ACCOUNT_ID: + // This implies adding a mailbox to an account. + id = Long.parseLong(uri.getPathSegments().get(1)); + values.put(MailboxColumns.ACCOUNT_KEY, id); + resultUri = insert(Mailbox.CONTENT_URI, values); + break; + case ATTACHMENTS_MESSAGE_ID: + id = db.insert(TABLE_NAMES[table], "foo", values); + resultUri = ContentUris.withAppendedId(Attachment.CONTENT_URI, id); + break; + default: throw new IllegalArgumentException("Unknown URL " + uri); - } - break; - case MAILBOX_ID: - // This implies adding a message to a mailbox - // Hmm, one problem here is that we can't link the account as well, so it must be - // already in the values... - id = Long.parseLong(uri.getPathSegments().get(1)); - values.put(MessageColumns.MAILBOX_KEY, id); - resultUri = insert(Message.CONTENT_URI, values); - break; - case MESSAGE_ID: - // This implies adding an attachment to a message. - id = Long.parseLong(uri.getPathSegments().get(1)); - values.put(AttachmentColumns.MESSAGE_KEY, id); - resultUri = insert(Attachment.CONTENT_URI, values); - break; - case ACCOUNT_ID: - // This implies adding a mailbox to an account. - id = Long.parseLong(uri.getPathSegments().get(1)); - values.put(MailboxColumns.ACCOUNT_KEY, id); - resultUri = insert(Mailbox.CONTENT_URI, values); - break; - case ATTACHMENTS_MESSAGE_ID: - id = db.insert(TABLE_NAMES[table], "foo", values); - resultUri = ContentUris.withAppendedId(Attachment.CONTENT_URI, id); - break; - default: - throw new IllegalArgumentException("Unknown URL " + uri); + } + } catch (SQLiteException e) { + checkDatabases(); + throw e; } // Notify with the base uri, not the new uri (nobody is watching a new record) @@ -914,10 +936,38 @@ public class EmailProvider extends ContentProvider { @Override public boolean onCreate() { - // TODO Auto-generated method stub + checkDatabases(); return false; } + /** + * The idea here is that the two databases (EmailProvider.db and EmailProviderBody.db must + * always be in sync (i.e. there are two database or NO databases). This code will delete + * any "orphan" database, so that both will be created together. Note that an "orphan" database + * will exist after either of the individual databases is deleted due to data corruption. + */ + public void checkDatabases() { + // Uncache the databases + if (mDatabase != null) { + mDatabase = null; + } + if (mBodyDatabase != null) { + mBodyDatabase = null; + } + // Look for orphans, and delete as necessary; these must always be in sync + File databaseFile = getContext().getDatabasePath(DATABASE_NAME); + File bodyFile = getContext().getDatabasePath(BODY_DATABASE_NAME); + + // TODO Make sure attachments are deleted + if (databaseFile.exists() && !bodyFile.exists()) { + Log.w(TAG, "Deleting orphaned EmailProvider database..."); + databaseFile.delete(); + } else if (bodyFile.exists() && !databaseFile.exists()) { + Log.w(TAG, "Deleting orphaned EmailProviderBody database..."); + bodyFile.delete(); + } + } + @Override public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) { @@ -934,39 +984,44 @@ public class EmailProvider extends ContentProvider { Log.v(TAG, "EmailProvider.query: uri=" + uri + ", match is " + match); } - switch (match) { - case BODY: - case MESSAGE: - case UPDATED_MESSAGE: - case DELETED_MESSAGE: - case ATTACHMENT: - case MAILBOX: - case ACCOUNT: - case HOSTAUTH: - c = db.query(TABLE_NAMES[table], projection, - selection, selectionArgs, null, null, sortOrder); - break; - case BODY_ID: - case MESSAGE_ID: - case DELETED_MESSAGE_ID: - case UPDATED_MESSAGE_ID: - case ATTACHMENT_ID: - case MAILBOX_ID: - case ACCOUNT_ID: - case HOSTAUTH_ID: - id = uri.getPathSegments().get(1); - c = db.query(TABLE_NAMES[table], projection, - whereWithId(id, selection), selectionArgs, null, null, sortOrder); - break; - case ATTACHMENTS_MESSAGE_ID: - // All attachments for the given message - id = uri.getPathSegments().get(2); - c = db.query(Attachment.TABLE_NAME, projection, - whereWith(Attachment.MESSAGE_KEY + "=" + id, selection), - selectionArgs, null, null, sortOrder); - break; - default: - throw new IllegalArgumentException("Unknown URI " + uri); + try { + switch (match) { + case BODY: + case MESSAGE: + case UPDATED_MESSAGE: + case DELETED_MESSAGE: + case ATTACHMENT: + case MAILBOX: + case ACCOUNT: + case HOSTAUTH: + c = db.query(TABLE_NAMES[table], projection, + selection, selectionArgs, null, null, sortOrder); + break; + case BODY_ID: + case MESSAGE_ID: + case DELETED_MESSAGE_ID: + case UPDATED_MESSAGE_ID: + case ATTACHMENT_ID: + case MAILBOX_ID: + case ACCOUNT_ID: + case HOSTAUTH_ID: + id = uri.getPathSegments().get(1); + c = db.query(TABLE_NAMES[table], projection, + whereWithId(id, selection), selectionArgs, null, null, sortOrder); + break; + case ATTACHMENTS_MESSAGE_ID: + // All attachments for the given message + id = uri.getPathSegments().get(2); + c = db.query(Attachment.TABLE_NAME, projection, + whereWith(Attachment.MESSAGE_KEY + "=" + id, selection), + selectionArgs, null, null, sortOrder); + break; + default: + throw new IllegalArgumentException("Unknown URI " + uri); + } + } catch (SQLiteException e) { + checkDatabases(); + throw e; } if ((c != null) && !isTemporary()) { @@ -1028,68 +1083,80 @@ public class EmailProvider extends ContentProvider { values.remove(MailboxColumns.UNREAD_COUNT); } + // Handle this special case the fastest possible way + if (uri == INTEGRITY_CHECK_URI) { + checkDatabases(); + return 0; + } + String id; - switch (match) { - case MAILBOX_ID_ADD_TO_FIELD: - case ACCOUNT_ID_ADD_TO_FIELD: - db.beginTransaction(); - id = uri.getPathSegments().get(1); - String field = values.getAsString(EmailContent.FIELD_COLUMN_NAME); - Long add = values.getAsLong(EmailContent.ADD_COLUMN_NAME); - if (field == null || add == null) { - throw new IllegalArgumentException("No field/add specified " + uri); - } - Cursor c = db.query(TABLE_NAMES[table], - new String[] {EmailContent.RECORD_ID, field}, whereWithId(id, selection), - selectionArgs, null, null, null); - try { - result = 0; - ContentValues cv = new ContentValues(); - String[] bind = new String[1]; - while (c.moveToNext()) { - bind[0] = c.getString(0); - long value = c.getLong(1) + add; - cv.put(field, value); - result = db.update(TABLE_NAMES[table], cv, ID_EQUALS, bind); + try { + switch (match) { + case MAILBOX_ID_ADD_TO_FIELD: + case ACCOUNT_ID_ADD_TO_FIELD: + db.beginTransaction(); + id = uri.getPathSegments().get(1); + String field = values.getAsString(EmailContent.FIELD_COLUMN_NAME); + Long add = values.getAsLong(EmailContent.ADD_COLUMN_NAME); + if (field == null || add == null) { + throw new IllegalArgumentException("No field/add specified " + uri); } - } finally { - c.close(); - } - db.setTransactionSuccessful(); - db.endTransaction(); - break; - case BODY_ID: - case MESSAGE_ID: - case SYNCED_MESSAGE_ID: - case UPDATED_MESSAGE_ID: - case ATTACHMENT_ID: - case MAILBOX_ID: - case ACCOUNT_ID: - case HOSTAUTH_ID: - id = uri.getPathSegments().get(1); - if (match == SYNCED_MESSAGE_ID) { - // For synced messages, first copy the old message to the updated table - // Note the insert or ignore semantics, guaranteeing that only the first - // update will be reflected in the updated message table; therefore this row - // will always have the "original" data - db.execSQL(UPDATED_MESSAGE_INSERT + id); - } else if (match == MESSAGE_ID) { - db.execSQL(UPDATED_MESSAGE_DELETE + id); - } - result = db.update(TABLE_NAMES[table], values, whereWithId(id, selection), - selectionArgs); - break; - case BODY: - case MESSAGE: - case UPDATED_MESSAGE: - case ATTACHMENT: - case MAILBOX: - case ACCOUNT: - case HOSTAUTH: - result = db.update(TABLE_NAMES[table], values, selection, selectionArgs); - break; - default: - throw new IllegalArgumentException("Unknown URI " + uri); + Cursor c = db.query(TABLE_NAMES[table], + new String[] {EmailContent.RECORD_ID, field}, + whereWithId(id, selection), + selectionArgs, null, null, null); + try { + result = 0; + ContentValues cv = new ContentValues(); + String[] bind = new String[1]; + while (c.moveToNext()) { + bind[0] = c.getString(0); + long value = c.getLong(1) + add; + cv.put(field, value); + result = db.update(TABLE_NAMES[table], cv, ID_EQUALS, bind); + } + } finally { + c.close(); + } + db.setTransactionSuccessful(); + db.endTransaction(); + break; + case BODY_ID: + case MESSAGE_ID: + case SYNCED_MESSAGE_ID: + case UPDATED_MESSAGE_ID: + case ATTACHMENT_ID: + case MAILBOX_ID: + case ACCOUNT_ID: + case HOSTAUTH_ID: + id = uri.getPathSegments().get(1); + if (match == SYNCED_MESSAGE_ID) { + // For synced messages, first copy the old message to the updated table + // Note the insert or ignore semantics, guaranteeing that only the first + // update will be reflected in the updated message table; therefore this row + // will always have the "original" data + db.execSQL(UPDATED_MESSAGE_INSERT + id); + } else if (match == MESSAGE_ID) { + db.execSQL(UPDATED_MESSAGE_DELETE + id); + } + result = db.update(TABLE_NAMES[table], values, whereWithId(id, selection), + selectionArgs); + break; + case BODY: + case MESSAGE: + case UPDATED_MESSAGE: + case ATTACHMENT: + case MAILBOX: + case ACCOUNT: + case HOSTAUTH: + result = db.update(TABLE_NAMES[table], values, selection, selectionArgs); + break; + default: + throw new IllegalArgumentException("Unknown URI " + uri); + } + } catch (SQLiteException e) { + checkDatabases(); + throw e; } getContext().getContentResolver().notifyChange(uri, null); diff --git a/tests/src/com/android/email/provider/ProviderTests.java b/tests/src/com/android/email/provider/ProviderTests.java index 9d7df0509..16abc82cc 100644 --- a/tests/src/com/android/email/provider/ProviderTests.java +++ b/tests/src/com/android/email/provider/ProviderTests.java @@ -1102,7 +1102,7 @@ public class ProviderTests extends ProviderTestCase2 { EmailContent.MessageColumns.MAILBOX_KEY + "=?"; String[] selArgs = new String[] { String.valueOf(account1Id), String.valueOf(box1Id) }; - // make sure there are two messages + // make sure there are six messages int numMessages = EmailContent.count(mMockContext, Message.CONTENT_URI, selection, selArgs); assertEquals(6, numMessages); @@ -1515,4 +1515,124 @@ public class ProviderTests extends ProviderTestCase2 { Mailbox restoredBoxA = Mailbox.restoreMailboxWithId(mMockContext, boxA.mId); assertEquals(11, restoredBoxA.mUnreadCount); } + + public void testDatabaseCorruptionRecovery() { + final ContentResolver resolver = mMockContext.getContentResolver(); + final Context context = mMockContext; + + // Create account and two mailboxes + Account acct = ProviderTestUtils.setupAccount("acct1", true, context); + Mailbox box1 = ProviderTestUtils.setupMailbox("box1", acct.mId, true, context); + + // Create 4 messages in box1 with bodies + ProviderTestUtils.setupMessage("message1", acct.mId, box1.mId, true, true, context); + ProviderTestUtils.setupMessage("message2", acct.mId, box1.mId, true, true, context); + ProviderTestUtils.setupMessage("message3", acct.mId, box1.mId, true, true, context); + ProviderTestUtils.setupMessage("message4", acct.mId, box1.mId, true, true, context); + + // Confirm there are four messages + int count = EmailContent.count(mMockContext, Message.CONTENT_URI, null, null); + assertEquals(4, count); + // Confirm there are four bodies + count = EmailContent.count(mMockContext, Body.CONTENT_URI, null, null); + assertEquals(4, count); + + // Find the EmailProvider.db file + File dbFile = mMockContext.getDatabasePath(EmailProvider.DATABASE_NAME); + // The EmailProvider.db database should exist (the provider creates it automatically) + assertTrue(dbFile != null); + assertTrue(dbFile.exists()); + // Delete it, and confirm it is gone + assertTrue(dbFile.delete()); + assertFalse(dbFile.exists()); + + // Find the EmailProviderBody.db file + dbFile = mMockContext.getDatabasePath(EmailProvider.BODY_DATABASE_NAME); + // The EmailProviderBody.db database should still exist + assertTrue(dbFile != null); + assertTrue(dbFile.exists()); + + // URI to uncache the databases + // This simulates the Provider starting up again (otherwise, it will still be pointing to + // the already opened files) + // Note that we only have access to the EmailProvider via the ContentResolver; therefore, + // we cannot directly call into the provider and use a URI for this + resolver.update(EmailProvider.INTEGRITY_CHECK_URI, null, null, null); + + // TODO We should check for the deletion of attachment files once this is implemented in + // the provider + + // Explanation for what happens below... + // The next time the database is created by the provider, it will notice that there's + // already a EmailProviderBody.db file. In this case, it will delete that database to + // ensure that both are in sync (and empty) + + // Confirm there are no bodies + count = EmailContent.count(mMockContext, Body.CONTENT_URI, null, null); + assertEquals(0, count); + + // Confirm there are no messages + count = EmailContent.count(mMockContext, Message.CONTENT_URI, null, null); + assertEquals(0, count); + } + + public void testBodyDatabaseCorruptionRecovery() { + final ContentResolver resolver = mMockContext.getContentResolver(); + final Context context = mMockContext; + + // Create account and two mailboxes + Account acct = ProviderTestUtils.setupAccount("acct1", true, context); + Mailbox box1 = ProviderTestUtils.setupMailbox("box1", acct.mId, true, context); + + // Create 4 messages in box1 with bodies + ProviderTestUtils.setupMessage("message1", acct.mId, box1.mId, true, true, context); + ProviderTestUtils.setupMessage("message2", acct.mId, box1.mId, true, true, context); + ProviderTestUtils.setupMessage("message3", acct.mId, box1.mId, true, true, context); + ProviderTestUtils.setupMessage("message4", acct.mId, box1.mId, true, true, context); + + // Confirm there are four messages + int count = EmailContent.count(mMockContext, Message.CONTENT_URI, null, null); + assertEquals(4, count); + // Confirm there are four bodies + count = EmailContent.count(mMockContext, Body.CONTENT_URI, null, null); + assertEquals(4, count); + + // Find the EmailProviderBody.db file + File dbFile = mMockContext.getDatabasePath(EmailProvider.BODY_DATABASE_NAME); + // The EmailProviderBody.db database should exist (the provider creates it automatically) + assertTrue(dbFile != null); + assertTrue(dbFile.exists()); + // Delete it, and confirm it is gone + assertTrue(dbFile.delete()); + assertFalse(dbFile.exists()); + + // Find the EmailProvider.db file + dbFile = mMockContext.getDatabasePath(EmailProvider.DATABASE_NAME); + // The EmailProviderBody.db database should still exist + assertTrue(dbFile != null); + assertTrue(dbFile.exists()); + + // URI to uncache the databases + // This simulates the Provider starting up again (otherwise, it will still be pointing to + // the already opened files) + // Note that we only have access to the EmailProvider via the ContentResolver; therefore, + // we cannot directly call into the provider and use a URI for this + resolver.update(EmailProvider.INTEGRITY_CHECK_URI, null, null, null); + + // TODO We should check for the deletion of attachment files once this is implemented in + // the provider + + // Explanation for what happens below... + // The next time the body database is created by the provider, it will notice that there's + // already a populated EmailProvider.db file. In this case, it will delete that database to + // ensure that both are in sync (and empty) + + // Confirm there are no messages + count = EmailContent.count(mMockContext, Message.CONTENT_URI, null, null); + assertEquals(0, count); + + // Confirm there are no bodies + count = EmailContent.count(mMockContext, Body.CONTENT_URI, null, null); + assertEquals(0, count); + } }