/* * Copyright (C) 2009 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. */ package com.android.email.provider; import android.appwidget.AppWidgetManager; import android.content.ComponentName; import android.content.ContentProvider; import android.content.ContentProviderOperation; import android.content.ContentProviderResult; import android.content.ContentResolver; import android.content.ContentUris; import android.content.ContentValues; import android.content.Context; import android.content.Intent; import android.content.OperationApplicationException; import android.content.UriMatcher; import android.database.ContentObserver; import android.database.Cursor; import android.database.CursorWrapper; import android.database.MatrixCursor; import android.database.MergeCursor; import android.database.sqlite.SQLiteDatabase; import android.database.sqlite.SQLiteException; import android.net.Uri; import android.os.Bundle; import android.os.Parcel; import android.os.RemoteException; import android.provider.BaseColumns; import android.text.TextUtils; import android.util.Log; import com.android.common.content.ProjectionMap; import com.android.email.NotificationController; import com.android.email.Preferences; import com.android.email.R; import com.android.email.SecurityPolicy; import com.android.email.provider.ContentCache.CacheToken; import com.android.email.service.AttachmentDownloadService; import com.android.email.service.EmailServiceUtils; import com.android.email.service.EmailServiceUtils.EmailServiceInfo; import com.android.email2.ui.MailActivityEmail; import com.android.emailcommon.Logging; import com.android.emailcommon.mail.Address; import com.android.emailcommon.provider.Account; import com.android.emailcommon.provider.EmailContent; import com.android.emailcommon.provider.EmailContent.AccountColumns; import com.android.emailcommon.provider.EmailContent.Attachment; import com.android.emailcommon.provider.EmailContent.AttachmentColumns; import com.android.emailcommon.provider.EmailContent.Body; import com.android.emailcommon.provider.EmailContent.BodyColumns; import com.android.emailcommon.provider.EmailContent.MailboxColumns; import com.android.emailcommon.provider.EmailContent.Message; import com.android.emailcommon.provider.EmailContent.MessageColumns; import com.android.emailcommon.provider.EmailContent.PolicyColumns; import com.android.emailcommon.provider.EmailContent.SyncColumns; import com.android.emailcommon.provider.HostAuth; import com.android.emailcommon.provider.Mailbox; import com.android.emailcommon.provider.Policy; import com.android.emailcommon.provider.QuickResponse; import com.android.emailcommon.service.EmailServiceProxy; import com.android.emailcommon.service.IEmailService; import com.android.emailcommon.service.IEmailServiceCallback; import com.android.emailcommon.service.SearchParams; import com.android.emailcommon.utility.AttachmentUtilities; import com.android.emailcommon.utility.Utility; import com.android.mail.providers.Conversation; import com.android.mail.providers.Folder; import com.android.mail.providers.UIProvider; import com.android.mail.providers.UIProvider.AccountCapabilities; import com.android.mail.providers.UIProvider.AccountCursorExtraKeys; import com.android.mail.providers.UIProvider.ConversationPriority; import com.android.mail.providers.UIProvider.ConversationSendingState; import com.android.mail.providers.UIProvider.DraftType; import com.android.mail.ui.ConversationUpdater; import com.android.mail.ui.DestructiveAction; import com.android.mail.ui.FoldersSelectionDialog; import com.android.mail.utils.LogUtils; import com.android.mail.utils.MatrixCursorWithExtra; import com.android.mail.utils.Utils; import com.android.mail.widget.BaseWidgetProvider; import com.android.mail.widget.WidgetProvider; import com.google.common.annotations.VisibleForTesting; import java.io.File; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.regex.Pattern; /** * @author mblank * */ public class EmailProvider extends ContentProvider { private static final String TAG = "EmailProvider"; public static final String EMAIL_APP_MIME_TYPE = "application/email-ls"; protected static final String DATABASE_NAME = "EmailProvider.db"; protected static final String BODY_DATABASE_NAME = "EmailProviderBody.db"; protected static final String BACKUP_DATABASE_NAME = "EmailProviderBackup.db"; public static final String ACTION_ATTACHMENT_UPDATED = "com.android.email.ATTACHMENT_UPDATED"; public static final String ATTACHMENT_UPDATED_EXTRA_FLAGS = "com.android.email.ATTACHMENT_UPDATED_FLAGS"; /** * Notifies that changes happened. Certain UI components, e.g., widgets, can register for this * {@link android.content.Intent} and update accordingly. However, this can be very broad and * is NOT the preferred way of getting notification. */ public static final String ACTION_NOTIFY_MESSAGE_LIST_DATASET_CHANGED = "com.android.email.MESSAGE_LIST_DATASET_CHANGED"; public static final String EMAIL_MESSAGE_MIME_TYPE = "vnd.android.cursor.item/email-message"; public static final String EMAIL_ATTACHMENT_MIME_TYPE = "vnd.android.cursor.item/email-attachment"; public static final Uri INTEGRITY_CHECK_URI = Uri.parse("content://" + EmailContent.AUTHORITY + "/integrityCheck"); public static final Uri ACCOUNT_BACKUP_URI = Uri.parse("content://" + EmailContent.AUTHORITY + "/accountBackup"); public static final Uri FOLDER_STATUS_URI = Uri.parse("content://" + EmailContent.AUTHORITY + "/status"); public static final Uri FOLDER_REFRESH_URI = Uri.parse("content://" + EmailContent.AUTHORITY + "/refresh"); /** Appended to the notification URI for delete operations */ public static final String NOTIFICATION_OP_DELETE = "delete"; /** Appended to the notification URI for insert operations */ public static final String NOTIFICATION_OP_INSERT = "insert"; /** Appended to the notification URI for update operations */ public static final String NOTIFICATION_OP_UPDATE = "update"; // Definitions for our queries looking for orphaned messages private static final String[] ORPHANS_PROJECTION = new String[] {MessageColumns.ID, MessageColumns.MAILBOX_KEY}; private static final int ORPHANS_ID = 0; private static final int ORPHANS_MAILBOX_KEY = 1; private static final String WHERE_ID = EmailContent.RECORD_ID + "=?"; // This is not a hard limit on accounts, per se, but beyond this, we can't guarantee that all // critical mailboxes, host auth's, accounts, and policies are cached private static final int MAX_CACHED_ACCOUNTS = 16; // Inbox, Drafts, Sent, Outbox, Trash, and Search (these boxes are cached when possible) private static final int NUM_ALWAYS_CACHED_MAILBOXES = 6; // We'll cache the following four tables; sizes are best estimates of effective values private final ContentCache mCacheAccount = new ContentCache("Account", Account.CONTENT_PROJECTION, MAX_CACHED_ACCOUNTS); private final ContentCache mCacheHostAuth = new ContentCache("HostAuth", HostAuth.CONTENT_PROJECTION, MAX_CACHED_ACCOUNTS * 2); /*package*/ final ContentCache mCacheMailbox = new ContentCache("Mailbox", Mailbox.CONTENT_PROJECTION, MAX_CACHED_ACCOUNTS * (NUM_ALWAYS_CACHED_MAILBOXES + 2)); private final ContentCache mCacheMessage = new ContentCache("Message", Message.CONTENT_PROJECTION, 8); private final ContentCache mCachePolicy = new ContentCache("Policy", Policy.CONTENT_PROJECTION, MAX_CACHED_ACCOUNTS); private static final int ACCOUNT_BASE = 0; private static final int ACCOUNT = ACCOUNT_BASE; private static final int ACCOUNT_ID = ACCOUNT_BASE + 1; private static final int ACCOUNT_ID_ADD_TO_FIELD = ACCOUNT_BASE + 2; private static final int ACCOUNT_RESET_NEW_COUNT = ACCOUNT_BASE + 3; private static final int ACCOUNT_RESET_NEW_COUNT_ID = ACCOUNT_BASE + 4; private static final int ACCOUNT_DEFAULT_ID = ACCOUNT_BASE + 5; private static final int ACCOUNT_CHECK = ACCOUNT_BASE + 6; private static final int ACCOUNT_PICK_TRASH_FOLDER = ACCOUNT_BASE + 7; private static final int MAILBOX_BASE = 0x1000; private static final int MAILBOX = MAILBOX_BASE; private static final int MAILBOX_ID = MAILBOX_BASE + 1; private static final int MAILBOX_ID_FROM_ACCOUNT_AND_TYPE = MAILBOX_BASE + 2; private static final int MAILBOX_ID_ADD_TO_FIELD = MAILBOX_BASE + 3; private static final int MAILBOX_NOTIFICATION = MAILBOX_BASE + 4; private static final int MAILBOX_MOST_RECENT_MESSAGE = MAILBOX_BASE + 5; private static final int MESSAGE_BASE = 0x2000; private static final int MESSAGE = MESSAGE_BASE; private static final int MESSAGE_ID = MESSAGE_BASE + 1; private static final int SYNCED_MESSAGE_ID = MESSAGE_BASE + 2; private static final int SYNCED_MESSAGE_SELECTION = MESSAGE_BASE + 3; private static final int ATTACHMENT_BASE = 0x3000; private static final int ATTACHMENT = ATTACHMENT_BASE; private static final int ATTACHMENT_ID = ATTACHMENT_BASE + 1; private static final int ATTACHMENTS_MESSAGE_ID = ATTACHMENT_BASE + 2; private static final int HOSTAUTH_BASE = 0x4000; private static final int HOSTAUTH = HOSTAUTH_BASE; private static final int HOSTAUTH_ID = HOSTAUTH_BASE + 1; private static final int UPDATED_MESSAGE_BASE = 0x5000; private static final int UPDATED_MESSAGE = UPDATED_MESSAGE_BASE; private static final int UPDATED_MESSAGE_ID = UPDATED_MESSAGE_BASE + 1; private static final int DELETED_MESSAGE_BASE = 0x6000; private static final int DELETED_MESSAGE = DELETED_MESSAGE_BASE; private static final int DELETED_MESSAGE_ID = DELETED_MESSAGE_BASE + 1; private static final int POLICY_BASE = 0x7000; private static final int POLICY = POLICY_BASE; private static final int POLICY_ID = POLICY_BASE + 1; private static final int QUICK_RESPONSE_BASE = 0x8000; private static final int QUICK_RESPONSE = QUICK_RESPONSE_BASE; private static final int QUICK_RESPONSE_ID = QUICK_RESPONSE_BASE + 1; private static final int QUICK_RESPONSE_ACCOUNT_ID = QUICK_RESPONSE_BASE + 2; private static final int UI_BASE = 0x9000; private static final int UI_FOLDERS = UI_BASE; private static final int UI_SUBFOLDERS = UI_BASE + 1; private static final int UI_MESSAGES = UI_BASE + 2; private static final int UI_MESSAGE = UI_BASE + 3; private static final int UI_SENDMAIL = UI_BASE + 4; private static final int UI_UNDO = UI_BASE + 5; private static final int UI_SAVEDRAFT = UI_BASE + 6; private static final int UI_UPDATEDRAFT = UI_BASE + 7; private static final int UI_SENDDRAFT = UI_BASE + 8; private static final int UI_FOLDER_REFRESH = UI_BASE + 9; private static final int UI_FOLDER = UI_BASE + 10; private static final int UI_ACCOUNT = UI_BASE + 11; private static final int UI_ACCTS = UI_BASE + 12; private static final int UI_ATTACHMENTS = UI_BASE + 13; private static final int UI_ATTACHMENT = UI_BASE + 14; private static final int UI_SEARCH = UI_BASE + 15; private static final int UI_ACCOUNT_DATA = UI_BASE + 16; private static final int UI_FOLDER_LOAD_MORE = UI_BASE + 17; private static final int UI_CONVERSATION = UI_BASE + 18; private static final int UI_RECENT_FOLDERS = UI_BASE + 19; private static final int UI_DEFAULT_RECENT_FOLDERS = UI_BASE + 20; private static final int UI_ALL_FOLDERS = UI_BASE + 21; // MUST ALWAYS EQUAL THE LAST OF THE PREVIOUS BASE CONSTANTS private static final int LAST_EMAIL_PROVIDER_DB_BASE = UI_BASE; // DO NOT CHANGE BODY_BASE!! private static final int BODY_BASE = LAST_EMAIL_PROVIDER_DB_BASE + 0x1000; private static final int BODY = BODY_BASE; private static final int BODY_ID = BODY_BASE + 1; private static final int BASE_SHIFT = 12; // 12 bits to the base type: 0, 0x1000, 0x2000, etc. // TABLE_NAMES MUST remain in the order of the BASE constants above (e.g. ACCOUNT_BASE = 0x0000, // MESSAGE_BASE = 0x1000, etc.) private static final String[] TABLE_NAMES = { Account.TABLE_NAME, Mailbox.TABLE_NAME, Message.TABLE_NAME, Attachment.TABLE_NAME, HostAuth.TABLE_NAME, Message.UPDATED_TABLE_NAME, Message.DELETED_TABLE_NAME, Policy.TABLE_NAME, QuickResponse.TABLE_NAME, null, // UI Body.TABLE_NAME, }; // CONTENT_CACHES MUST remain in the order of the BASE constants above private final ContentCache[] mContentCaches = { mCacheAccount, mCacheMailbox, mCacheMessage, null, // Attachment mCacheHostAuth, null, // Updated message null, // Deleted message mCachePolicy, null, // Quick response null, // Body null // UI }; // CACHE_PROJECTIONS MUST remain in the order of the BASE constants above private static final String[][] CACHE_PROJECTIONS = { Account.CONTENT_PROJECTION, Mailbox.CONTENT_PROJECTION, Message.CONTENT_PROJECTION, null, // Attachment HostAuth.CONTENT_PROJECTION, null, // Updated message null, // Deleted message Policy.CONTENT_PROJECTION, null, // Quick response null, // Body null // UI }; private static final UriMatcher sURIMatcher = new UriMatcher(UriMatcher.NO_MATCH); private static final String MAILBOX_PRE_CACHE_SELECTION = MailboxColumns.TYPE + " IN (" + Mailbox.TYPE_INBOX + "," + Mailbox.TYPE_DRAFTS + "," + Mailbox.TYPE_TRASH + "," + Mailbox.TYPE_SENT + "," + Mailbox.TYPE_SEARCH + "," + Mailbox.TYPE_OUTBOX + ")"; /** * Let's only generate these SQL strings once, as they are used frequently * Note that this isn't relevant for table creation strings, since they are used only once */ private static final String UPDATED_MESSAGE_INSERT = "insert or ignore into " + Message.UPDATED_TABLE_NAME + " select * from " + Message.TABLE_NAME + " where " + EmailContent.RECORD_ID + '='; private static final String UPDATED_MESSAGE_DELETE = "delete from " + Message.UPDATED_TABLE_NAME + " where " + EmailContent.RECORD_ID + '='; private static final String DELETED_MESSAGE_INSERT = "insert or replace into " + Message.DELETED_TABLE_NAME + " select * from " + Message.TABLE_NAME + " where " + EmailContent.RECORD_ID + '='; private static final String DELETE_ORPHAN_BODIES = "delete from " + Body.TABLE_NAME + " where " + BodyColumns.MESSAGE_KEY + " in " + "(select " + BodyColumns.MESSAGE_KEY + " from " + Body.TABLE_NAME + " except select " + EmailContent.RECORD_ID + " from " + Message.TABLE_NAME + ')'; private static final String DELETE_BODY = "delete from " + Body.TABLE_NAME + " where " + BodyColumns.MESSAGE_KEY + '='; private static final String ID_EQUALS = EmailContent.RECORD_ID + "=?"; private static final ContentValues CONTENT_VALUES_RESET_NEW_MESSAGE_COUNT; private static final ContentValues EMPTY_CONTENT_VALUES = new ContentValues(); public static final String MESSAGE_URI_PARAMETER_MAILBOX_ID = "mailboxId"; // For undo handling private int mLastSequence = -1; private ArrayList mLastSequenceOps = new ArrayList(); // Query parameter indicating the command came from UIProvider private static final String IS_UIPROVIDER = "is_uiprovider"; static { // Email URI matching table UriMatcher matcher = sURIMatcher; // All accounts matcher.addURI(EmailContent.AUTHORITY, "account", ACCOUNT); // A specific account // insert into this URI causes a mailbox to be added to the account matcher.addURI(EmailContent.AUTHORITY, "account/#", ACCOUNT_ID); matcher.addURI(EmailContent.AUTHORITY, "account/default", ACCOUNT_DEFAULT_ID); matcher.addURI(EmailContent.AUTHORITY, "accountCheck/#", ACCOUNT_CHECK); // Special URI to reset the new message count. Only update works, and content values // will be ignored. matcher.addURI(EmailContent.AUTHORITY, "resetNewMessageCount", ACCOUNT_RESET_NEW_COUNT); matcher.addURI(EmailContent.AUTHORITY, "resetNewMessageCount/#", ACCOUNT_RESET_NEW_COUNT_ID); // All mailboxes matcher.addURI(EmailContent.AUTHORITY, "mailbox", MAILBOX); // A specific mailbox // insert into this URI causes a message to be added to the mailbox // ** NOTE For now, the accountKey must be set manually in the values! matcher.addURI(EmailContent.AUTHORITY, "mailbox/#", MAILBOX_ID); matcher.addURI(EmailContent.AUTHORITY, "mailboxIdFromAccountAndType/#/#", MAILBOX_ID_FROM_ACCOUNT_AND_TYPE); matcher.addURI(EmailContent.AUTHORITY, "mailboxNotification/#", MAILBOX_NOTIFICATION); matcher.addURI(EmailContent.AUTHORITY, "mailboxMostRecentMessage/#", MAILBOX_MOST_RECENT_MESSAGE); // All messages matcher.addURI(EmailContent.AUTHORITY, "message", MESSAGE); // A specific message // insert into this URI causes an attachment to be added to the message matcher.addURI(EmailContent.AUTHORITY, "message/#", MESSAGE_ID); // A specific attachment matcher.addURI(EmailContent.AUTHORITY, "attachment", ATTACHMENT); // A specific attachment (the header information) matcher.addURI(EmailContent.AUTHORITY, "attachment/#", ATTACHMENT_ID); // The attachments of a specific message (query only) (insert & delete TBD) matcher.addURI(EmailContent.AUTHORITY, "attachment/message/#", ATTACHMENTS_MESSAGE_ID); // All mail bodies matcher.addURI(EmailContent.AUTHORITY, "body", BODY); // A specific mail body matcher.addURI(EmailContent.AUTHORITY, "body/#", BODY_ID); // All hostauth records matcher.addURI(EmailContent.AUTHORITY, "hostauth", HOSTAUTH); // A specific hostauth matcher.addURI(EmailContent.AUTHORITY, "hostauth/#", HOSTAUTH_ID); // Atomically a constant value to a particular field of a mailbox/account matcher.addURI(EmailContent.AUTHORITY, "mailboxIdAddToField/#", MAILBOX_ID_ADD_TO_FIELD); matcher.addURI(EmailContent.AUTHORITY, "accountIdAddToField/#", ACCOUNT_ID_ADD_TO_FIELD); /** * THIS URI HAS SPECIAL SEMANTICS * ITS USE IS INTENDED FOR THE UI APPLICATION TO MARK CHANGES THAT NEED TO BE SYNCED BACK * TO A SERVER VIA A SYNC ADAPTER */ matcher.addURI(EmailContent.AUTHORITY, "syncedMessage/#", SYNCED_MESSAGE_ID); matcher.addURI(EmailContent.AUTHORITY, "syncedMessageSelection", SYNCED_MESSAGE_SELECTION); /** * THE URIs BELOW THIS POINT ARE INTENDED TO BE USED BY SYNC ADAPTERS ONLY * THEY REFER TO DATA CREATED AND MAINTAINED BY CALLS TO THE SYNCED_MESSAGE_ID URI * BY THE UI APPLICATION */ // All deleted messages matcher.addURI(EmailContent.AUTHORITY, "deletedMessage", DELETED_MESSAGE); // A specific deleted message matcher.addURI(EmailContent.AUTHORITY, "deletedMessage/#", DELETED_MESSAGE_ID); // All updated messages matcher.addURI(EmailContent.AUTHORITY, "updatedMessage", UPDATED_MESSAGE); // A specific updated message matcher.addURI(EmailContent.AUTHORITY, "updatedMessage/#", UPDATED_MESSAGE_ID); CONTENT_VALUES_RESET_NEW_MESSAGE_COUNT = new ContentValues(); CONTENT_VALUES_RESET_NEW_MESSAGE_COUNT.put(Account.NEW_MESSAGE_COUNT, 0); matcher.addURI(EmailContent.AUTHORITY, "policy", POLICY); matcher.addURI(EmailContent.AUTHORITY, "policy/#", POLICY_ID); // All quick responses matcher.addURI(EmailContent.AUTHORITY, "quickresponse", QUICK_RESPONSE); // A specific quick response matcher.addURI(EmailContent.AUTHORITY, "quickresponse/#", QUICK_RESPONSE_ID); // All quick responses associated with a particular account id matcher.addURI(EmailContent.AUTHORITY, "quickresponse/account/#", QUICK_RESPONSE_ACCOUNT_ID); matcher.addURI(EmailContent.AUTHORITY, "uifolders/#", UI_FOLDERS); matcher.addURI(EmailContent.AUTHORITY, "uiallfolders/#", UI_ALL_FOLDERS); matcher.addURI(EmailContent.AUTHORITY, "uisubfolders/#", UI_SUBFOLDERS); matcher.addURI(EmailContent.AUTHORITY, "uimessages/#", UI_MESSAGES); matcher.addURI(EmailContent.AUTHORITY, "uimessage/#", UI_MESSAGE); matcher.addURI(EmailContent.AUTHORITY, "uisendmail/#", UI_SENDMAIL); matcher.addURI(EmailContent.AUTHORITY, "uiundo", UI_UNDO); matcher.addURI(EmailContent.AUTHORITY, "uisavedraft/#", UI_SAVEDRAFT); matcher.addURI(EmailContent.AUTHORITY, "uiupdatedraft/#", UI_UPDATEDRAFT); matcher.addURI(EmailContent.AUTHORITY, "uisenddraft/#", UI_SENDDRAFT); matcher.addURI(EmailContent.AUTHORITY, "uirefresh/#", UI_FOLDER_REFRESH); matcher.addURI(EmailContent.AUTHORITY, "uifolder/#", UI_FOLDER); matcher.addURI(EmailContent.AUTHORITY, "uiaccount/#", UI_ACCOUNT); matcher.addURI(EmailContent.AUTHORITY, "uiaccts", UI_ACCTS); matcher.addURI(EmailContent.AUTHORITY, "uiattachments/#", UI_ATTACHMENTS); matcher.addURI(EmailContent.AUTHORITY, "uiattachment/#", UI_ATTACHMENT); matcher.addURI(EmailContent.AUTHORITY, "uisearch/#", UI_SEARCH); matcher.addURI(EmailContent.AUTHORITY, "uiaccountdata/#", UI_ACCOUNT_DATA); matcher.addURI(EmailContent.AUTHORITY, "uiloadmore/#", UI_FOLDER_LOAD_MORE); matcher.addURI(EmailContent.AUTHORITY, "uiconversation/#", UI_CONVERSATION); matcher.addURI(EmailContent.AUTHORITY, "uirecentfolders/#", UI_RECENT_FOLDERS); matcher.addURI(EmailContent.AUTHORITY, "uidefaultrecentfolders/#", UI_DEFAULT_RECENT_FOLDERS); matcher.addURI(EmailContent.AUTHORITY, "pickTrashFolder/#", ACCOUNT_PICK_TRASH_FOLDER); } /** * Wrap the UriMatcher call so we can throw a runtime exception if an unknown Uri is passed in * @param uri the Uri to match * @return the match value */ private static int findMatch(Uri uri, String methodName) { int match = sURIMatcher.match(uri); if (match < 0) { throw new IllegalArgumentException("Unknown uri: " + uri); } else if (Logging.LOGD) { Log.v(TAG, methodName + ": uri=" + uri + ", match is " + match); } return match; } private SQLiteDatabase mDatabase; private SQLiteDatabase mBodyDatabase; public static Uri uiUri(String type, long id) { return Uri.parse(uiUriString(type, id)); } /** * Creates a URI string from a database ID (guaranteed to be unique). * @param type of the resource: uifolder, message, etc. * @param id the id of the resource. * @return */ public static String uiUriString(String type, long id) { return "content://" + EmailContent.AUTHORITY + "/" + type + ((id == -1) ? "" : ("/" + id)); } /** * Orphan record deletion utility. Generates a sqlite statement like: * delete from where not in (select from ) * @param db the EmailProvider database * @param table the table whose orphans are to be removed * @param column the column deletion will be based on * @param foreignColumn the column in the foreign table whose absence will trigger the deletion * @param foreignTable the foreign table */ @VisibleForTesting void deleteUnlinked(SQLiteDatabase db, String table, String column, String foreignColumn, String foreignTable) { int count = db.delete(table, column + " not in (select " + foreignColumn + " from " + foreignTable + ")", null); if (count > 0) { Log.w(TAG, "Found " + count + " orphaned row(s) in " + table); } } @VisibleForTesting synchronized SQLiteDatabase getDatabase(Context context) { // 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(); DBHelper.DatabaseHelper helper = new DBHelper.DatabaseHelper(context, DATABASE_NAME); mDatabase = helper.getWritableDatabase(); DBHelper.BodyDatabaseHelper bodyHelper = new DBHelper.BodyDatabaseHelper(context, BODY_DATABASE_NAME); mBodyDatabase = bodyHelper.getWritableDatabase(); if (mBodyDatabase != null) { String bodyFileName = mBodyDatabase.getPath(); mDatabase.execSQL("attach \"" + bodyFileName + "\" as BodyDatabase"); } // Restore accounts if the database is corrupted... restoreIfNeeded(context, mDatabase); // Check for any orphaned Messages in the updated/deleted tables deleteMessageOrphans(mDatabase, Message.UPDATED_TABLE_NAME); deleteMessageOrphans(mDatabase, Message.DELETED_TABLE_NAME); // Delete orphaned mailboxes/messages/policies (account no longer exists) deleteUnlinked(mDatabase, Mailbox.TABLE_NAME, MailboxColumns.ACCOUNT_KEY, AccountColumns.ID, Account.TABLE_NAME); deleteUnlinked(mDatabase, Message.TABLE_NAME, MessageColumns.ACCOUNT_KEY, AccountColumns.ID, Account.TABLE_NAME); deleteUnlinked(mDatabase, Policy.TABLE_NAME, PolicyColumns.ID, AccountColumns.POLICY_KEY, Account.TABLE_NAME); initUiProvider(); preCacheData(); return mDatabase; } /** * Perform startup actions related to UI */ private void initUiProvider() { // Clear mailbox sync status mDatabase.execSQL("update " + Mailbox.TABLE_NAME + " set " + MailboxColumns.UI_SYNC_STATUS + "=" + UIProvider.SyncStatus.NO_SYNC); } /** * Pre-cache all of the items in a given table meeting the selection criteria * @param tableUri the table uri * @param baseProjection the base projection of that table * @param selection the selection criteria */ private void preCacheTable(Uri tableUri, String[] baseProjection, String selection) { Cursor c = query(tableUri, EmailContent.ID_PROJECTION, selection, null, null); try { while (c.moveToNext()) { long id = c.getLong(EmailContent.ID_PROJECTION_COLUMN); Cursor cachedCursor = query(ContentUris.withAppendedId( tableUri, id), baseProjection, null, null, null); if (cachedCursor != null) { // For accounts, create a mailbox type map entry (if necessary) if (tableUri == Account.CONTENT_URI) { getOrCreateAccountMailboxTypeMap(id); } cachedCursor.close(); } } } finally { c.close(); } } private final HashMap> mMailboxTypeMap = new HashMap>(); private HashMap getOrCreateAccountMailboxTypeMap(long accountId) { synchronized(mMailboxTypeMap) { HashMap accountMailboxTypeMap = mMailboxTypeMap.get(accountId); if (accountMailboxTypeMap == null) { accountMailboxTypeMap = new HashMap(); mMailboxTypeMap.put(accountId, accountMailboxTypeMap); } return accountMailboxTypeMap; } } private void addToMailboxTypeMap(Cursor c) { long accountId = c.getLong(Mailbox.CONTENT_ACCOUNT_KEY_COLUMN); int type = c.getInt(Mailbox.CONTENT_TYPE_COLUMN); synchronized(mMailboxTypeMap) { HashMap accountMailboxTypeMap = getOrCreateAccountMailboxTypeMap(accountId); accountMailboxTypeMap.put(type, c.getLong(Mailbox.CONTENT_ID_COLUMN)); } } private long getMailboxIdFromMailboxTypeMap(long accountId, int type) { synchronized(mMailboxTypeMap) { HashMap accountMap = mMailboxTypeMap.get(accountId); Long mailboxId = null; if (accountMap != null) { mailboxId = accountMap.get(type); } if (mailboxId == null) return Mailbox.NO_MAILBOX; return mailboxId; } } private void preCacheData() { synchronized(mMailboxTypeMap) { mMailboxTypeMap.clear(); // Pre-cache accounts, host auth's, policies, and special mailboxes preCacheTable(Account.CONTENT_URI, Account.CONTENT_PROJECTION, null); preCacheTable(HostAuth.CONTENT_URI, HostAuth.CONTENT_PROJECTION, null); preCacheTable(Policy.CONTENT_URI, Policy.CONTENT_PROJECTION, null); preCacheTable(Mailbox.CONTENT_URI, Mailbox.CONTENT_PROJECTION, MAILBOX_PRE_CACHE_SELECTION); // Create a map from account,type to a mailbox Map snapshot = mCacheMailbox.getSnapshot(); Collection values = snapshot.values(); if (values != null) { for (Cursor c: values) { if (c.moveToFirst()) { addToMailboxTypeMap(c); } } } } } /*package*/ static SQLiteDatabase getReadableDatabase(Context context) { DBHelper.DatabaseHelper helper = new DBHelper.DatabaseHelper(context, DATABASE_NAME); return helper.getReadableDatabase(); } /** * Restore user Account and HostAuth data from our backup database */ public static void restoreIfNeeded(Context context, SQLiteDatabase mainDatabase) { if (MailActivityEmail.DEBUG) { Log.w(TAG, "restoreIfNeeded..."); } // Check for legacy backup String legacyBackup = Preferences.getLegacyBackupPreference(context); // If there's a legacy backup, create a new-style backup and delete the legacy backup // In the 1:1000000000 chance that the user gets an app update just as his database becomes // corrupt, oh well... if (!TextUtils.isEmpty(legacyBackup)) { backupAccounts(context, mainDatabase); Preferences.clearLegacyBackupPreference(context); Log.w(TAG, "Created new EmailProvider backup database"); return; } // If we have accounts, we're done Cursor c = mainDatabase.query(Account.TABLE_NAME, EmailContent.ID_PROJECTION, null, null, null, null, null); try { if (c.moveToFirst()) { if (MailActivityEmail.DEBUG) { Log.w(TAG, "restoreIfNeeded: Account exists."); } return; // At least one account exists. } } finally { c.close(); } restoreAccounts(context, mainDatabase); } /** {@inheritDoc} */ @Override public void shutdown() { if (mDatabase != null) { mDatabase.close(); mDatabase = null; } if (mBodyDatabase != null) { mBodyDatabase.close(); mBodyDatabase = null; } } /*package*/ static void deleteMessageOrphans(SQLiteDatabase database, String tableName) { if (database != null) { // We'll look at all of the items in the table; there won't be many typically Cursor c = database.query(tableName, ORPHANS_PROJECTION, null, null, null, null, null); // Usually, there will be nothing in these tables, so make a quick check try { if (c.getCount() == 0) return; ArrayList foundMailboxes = new ArrayList(); ArrayList notFoundMailboxes = new ArrayList(); ArrayList deleteList = new ArrayList(); String[] bindArray = new String[1]; while (c.moveToNext()) { // Get the mailbox key and see if we've already found this mailbox // If so, we're fine long mailboxId = c.getLong(ORPHANS_MAILBOX_KEY); // If we already know this mailbox doesn't exist, mark the message for deletion if (notFoundMailboxes.contains(mailboxId)) { deleteList.add(c.getLong(ORPHANS_ID)); // If we don't know about this mailbox, we'll try to find it } else if (!foundMailboxes.contains(mailboxId)) { bindArray[0] = Long.toString(mailboxId); Cursor boxCursor = database.query(Mailbox.TABLE_NAME, Mailbox.ID_PROJECTION, WHERE_ID, bindArray, null, null, null); try { // If it exists, we'll add it to the "found" mailboxes if (boxCursor.moveToFirst()) { foundMailboxes.add(mailboxId); // Otherwise, we'll add to "not found" and mark the message for deletion } else { notFoundMailboxes.add(mailboxId); deleteList.add(c.getLong(ORPHANS_ID)); } } finally { boxCursor.close(); } } } // Now, delete the orphan messages for (long messageId: deleteList) { bindArray[0] = Long.toString(messageId); database.delete(tableName, WHERE_ID, bindArray); } } finally { c.close(); } } } @Override public int delete(Uri uri, String selection, String[] selectionArgs) { final int match = findMatch(uri, "delete"); Context context = getContext(); // Pick the correct database for this operation // If we're in a transaction already (which would happen during applyBatch), then the // body database is already attached to the email database and any attempt to use the // body database directly will result in a SQLiteException (the database is locked) SQLiteDatabase db = getDatabase(context); int table = match >> BASE_SHIFT; String id = "0"; boolean messageDeletion = false; ContentResolver resolver = context.getContentResolver(); ContentCache cache = mContentCaches[table]; String tableName = TABLE_NAMES[table]; int result = -1; try { if (match == MESSAGE_ID || match == SYNCED_MESSAGE_ID) { if (!uri.getBooleanQueryParameter(IS_UIPROVIDER, false)) { notifyUIConversation(uri); } } switch (match) { case UI_MESSAGE: return uiDeleteMessage(uri); case UI_ACCOUNT_DATA: return uiDeleteAccountData(uri); case UI_ACCOUNT: return uiDeleteAccount(uri); case SYNCED_MESSAGE_SELECTION: Cursor findCursor = db.query(tableName, Message.ID_COLUMN_PROJECTION, selection, selectionArgs, null, null, null); try { if (findCursor.moveToFirst()) { return delete(ContentUris.withAppendedId( Message.SYNCED_CONTENT_URI, findCursor.getLong(Message.ID_COLUMNS_ID_COLUMN)), null, null); } else { return 0; } } finally { findCursor.close(); } // These are cases in which one or more Messages might get deleted, either by // cascade or explicitly case MAILBOX_ID: case MAILBOX: case ACCOUNT_ID: case ACCOUNT: case MESSAGE: case SYNCED_MESSAGE_ID: case MESSAGE_ID: // Handle lost Body records here, since this cannot be done in a trigger // The process is: // 1) Begin a transaction, ensuring that both databases are affected atomically // 2) Do the requested deletion, with cascading deletions handled in triggers // 3) End the transaction, committing all changes atomically // // Bodies are auto-deleted here; Attachments are auto-deleted via trigger messageDeletion = true; db.beginTransaction(); break; } switch (match) { case BODY_ID: case DELETED_MESSAGE_ID: case SYNCED_MESSAGE_ID: case MESSAGE_ID: case UPDATED_MESSAGE_ID: case ATTACHMENT_ID: case MAILBOX_ID: case ACCOUNT_ID: case HOSTAUTH_ID: case POLICY_ID: case QUICK_RESPONSE_ID: id = uri.getPathSegments().get(1); if (match == SYNCED_MESSAGE_ID) { // For synced messages, first copy the old message to the deleted table and // delete it from the updated table (in case it was updated first) // Note that this is all within a transaction, for atomicity db.execSQL(DELETED_MESSAGE_INSERT + id); db.execSQL(UPDATED_MESSAGE_DELETE + id); } if (cache != null) { cache.lock(id); } try { result = db.delete(tableName, whereWithId(id, selection), selectionArgs); if (cache != null) { switch(match) { case ACCOUNT_ID: // Account deletion will clear all of the caches, as HostAuth's, // Mailboxes, and Messages will be deleted in the process mCacheMailbox.invalidate("Delete", uri, selection); mCacheHostAuth.invalidate("Delete", uri, selection); mCachePolicy.invalidate("Delete", uri, selection); //$FALL-THROUGH$ case MAILBOX_ID: // Mailbox deletion will clear the Message cache mCacheMessage.invalidate("Delete", uri, selection); //$FALL-THROUGH$ case SYNCED_MESSAGE_ID: case MESSAGE_ID: case HOSTAUTH_ID: case POLICY_ID: cache.invalidate("Delete", uri, selection); // Make sure all data is properly cached if (match != MESSAGE_ID) { preCacheData(); } break; } } } finally { if (cache != null) { cache.unlock(id); } } if (match == ACCOUNT_ID) { notifyUI(UIPROVIDER_ACCOUNT_NOTIFIER, id); resolver.notifyChange(UIPROVIDER_ACCOUNTS_NOTIFIER, null); } else if (match == MAILBOX_ID) { notifyUI(UIPROVIDER_FOLDER_NOTIFIER, id); } break; case ATTACHMENTS_MESSAGE_ID: // All attachments for the given message id = uri.getPathSegments().get(2); result = db.delete(tableName, whereWith(Attachment.MESSAGE_KEY + "=" + id, selection), selectionArgs); break; case BODY: case MESSAGE: case DELETED_MESSAGE: case UPDATED_MESSAGE: case ATTACHMENT: case MAILBOX: case ACCOUNT: case HOSTAUTH: case POLICY: switch(match) { // See the comments above for deletion of ACCOUNT_ID, etc case ACCOUNT: mCacheMailbox.invalidate("Delete", uri, selection); mCacheHostAuth.invalidate("Delete", uri, selection); mCachePolicy.invalidate("Delete", uri, selection); //$FALL-THROUGH$ case MAILBOX: mCacheMessage.invalidate("Delete", uri, selection); //$FALL-THROUGH$ case MESSAGE: case HOSTAUTH: case POLICY: cache.invalidate("Delete", uri, selection); break; } result = db.delete(tableName, selection, selectionArgs); switch(match) { case ACCOUNT: case MAILBOX: case HOSTAUTH: case POLICY: // Make sure all data is properly cached preCacheData(); break; } break; default: throw new IllegalArgumentException("Unknown URI " + uri); } if (messageDeletion) { if (match == MESSAGE_ID) { // Delete the Body record associated with the deleted message db.execSQL(DELETE_BODY + id); } else { // Delete any orphaned Body records db.execSQL(DELETE_ORPHAN_BODIES); } db.setTransactionSuccessful(); } } catch (SQLiteException e) { checkDatabases(); throw e; } finally { if (messageDeletion) { db.endTransaction(); } } // Notify all notifier cursors sendNotifierChange(getBaseNotificationUri(match), NOTIFICATION_OP_DELETE, id); // Notify all email content cursors resolver.notifyChange(EmailContent.CONTENT_URI, null); return result; } @Override // Use the email- prefix because message, mailbox, and account are so generic (e.g. SMS, IM) public String getType(Uri uri) { int match = findMatch(uri, "getType"); switch (match) { case BODY_ID: return "vnd.android.cursor.item/email-body"; case BODY: return "vnd.android.cursor.dir/email-body"; case UPDATED_MESSAGE_ID: case MESSAGE_ID: // NOTE: According to the framework folks, we're supposed to invent mime types as // a way of passing information to drag & drop recipients. // If there's a mailboxId parameter in the url, we respond with a mime type that // has -n appended, where n is the mailboxId of the message. The drag & drop code // uses this information to know not to allow dragging the item to its own mailbox String mimeType = EMAIL_MESSAGE_MIME_TYPE; String mailboxId = uri.getQueryParameter(MESSAGE_URI_PARAMETER_MAILBOX_ID); if (mailboxId != null) { mimeType += "-" + mailboxId; } return mimeType; case UPDATED_MESSAGE: case MESSAGE: return "vnd.android.cursor.dir/email-message"; case MAILBOX: return "vnd.android.cursor.dir/email-mailbox"; case MAILBOX_ID: return "vnd.android.cursor.item/email-mailbox"; case ACCOUNT: return "vnd.android.cursor.dir/email-account"; case ACCOUNT_ID: return "vnd.android.cursor.item/email-account"; case ATTACHMENTS_MESSAGE_ID: case ATTACHMENT: return "vnd.android.cursor.dir/email-attachment"; case ATTACHMENT_ID: return EMAIL_ATTACHMENT_MIME_TYPE; case HOSTAUTH: return "vnd.android.cursor.dir/email-hostauth"; case HOSTAUTH_ID: return "vnd.android.cursor.item/email-hostauth"; default: throw new IllegalArgumentException("Unknown URI " + uri); } } private static final Uri UIPROVIDER_CONVERSATION_NOTIFIER = Uri.parse("content://" + UIProvider.AUTHORITY + "/uimessages"); private static final Uri UIPROVIDER_FOLDER_NOTIFIER = Uri.parse("content://" + UIProvider.AUTHORITY + "/uifolder"); private static final Uri UIPROVIDER_ACCOUNT_NOTIFIER = Uri.parse("content://" + UIProvider.AUTHORITY + "/uiaccount"); public static final Uri UIPROVIDER_SETTINGS_NOTIFIER = Uri.parse("content://" + UIProvider.AUTHORITY + "/uisettings"); private static final Uri UIPROVIDER_ATTACHMENT_NOTIFIER = Uri.parse("content://" + UIProvider.AUTHORITY + "/uiattachment"); private static final Uri UIPROVIDER_ATTACHMENTS_NOTIFIER = Uri.parse("content://" + UIProvider.AUTHORITY + "/uiattachments"); private static final Uri UIPROVIDER_ACCOUNTS_NOTIFIER = Uri.parse("content://" + UIProvider.AUTHORITY + "/uiaccts"); private static final Uri UIPROVIDER_MESSAGE_NOTIFIER = Uri.parse("content://" + UIProvider.AUTHORITY + "/uimessage"); private static final Uri UIPROVIDER_RECENT_FOLDERS_NOTIFIER = Uri.parse("content://" + UIProvider.AUTHORITY + "/uirecentfolders"); @Override public Uri insert(Uri uri, ContentValues values) { int match = findMatch(uri, "insert"); Context context = getContext(); ContentResolver resolver = context.getContentResolver(); // See the comment at delete(), above SQLiteDatabase db = getDatabase(context); int table = match >> BASE_SHIFT; String id = "0"; long longId; // We do NOT allow setting of unreadCount/messageCount via the provider // These columns are maintained via triggers if (match == MAILBOX_ID || match == MAILBOX) { values.put(MailboxColumns.UNREAD_COUNT, 0); values.put(MailboxColumns.MESSAGE_COUNT, 0); } Uri resultUri = null; try { switch (match) { case UI_SAVEDRAFT: return uiSaveDraft(uri, values); case UI_SENDMAIL: return uiSendMail(uri, values); // NOTE: It is NOT legal for production code to insert directly into UPDATED_MESSAGE // or DELETED_MESSAGE; see the comment below for details case UPDATED_MESSAGE: case DELETED_MESSAGE: case MESSAGE: case BODY: case ATTACHMENT: case MAILBOX: case ACCOUNT: case HOSTAUTH: case POLICY: case QUICK_RESPONSE: longId = db.insert(TABLE_NAMES[table], "foo", values); resultUri = ContentUris.withAppendedId(uri, longId); switch(match) { case MESSAGE: if (!uri.getBooleanQueryParameter(IS_UIPROVIDER, false)) { notifyUIConversationMailbox(values.getAsLong(Message.MAILBOX_KEY)); } break; case MAILBOX: if (values.containsKey(MailboxColumns.TYPE)) { // Only cache special mailbox types int type = values.getAsInteger(MailboxColumns.TYPE); if (type != Mailbox.TYPE_INBOX && type != Mailbox.TYPE_OUTBOX && type != Mailbox.TYPE_DRAFTS && type != Mailbox.TYPE_SENT && type != Mailbox.TYPE_TRASH && type != Mailbox.TYPE_SEARCH) { break; } } // Notify the account when a new mailbox is added Long accountId = values.getAsLong(MailboxColumns.ACCOUNT_KEY); if (accountId != null && accountId.longValue() > 0) { notifyUI(UIPROVIDER_ACCOUNT_NOTIFIER, accountId); } //$FALL-THROUGH$ case ACCOUNT: case HOSTAUTH: case POLICY: // Cache new account, host auth, policy, and some mailbox rows Cursor c = query(resultUri, CACHE_PROJECTIONS[table], null, null, null); if (c != null) { if (match == MAILBOX) { addToMailboxTypeMap(c); } else if (match == ACCOUNT) { getOrCreateAccountMailboxTypeMap(longId); } c.close(); } break; } // 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); } else if (match == ATTACHMENT) { int flags = 0; if (values.containsKey(Attachment.FLAGS)) { flags = values.getAsInteger(Attachment.FLAGS); } // Report all new attachments to the download service mAttachmentService.attachmentChanged(getContext(), longId, flags); } else if (match == ACCOUNT) { resolver.notifyChange(UIPROVIDER_ACCOUNTS_NOTIFIER, null); } 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... longId = Long.parseLong(uri.getPathSegments().get(1)); values.put(MessageColumns.MAILBOX_KEY, longId); return insert(Message.CONTENT_URI, values); // Recurse case MESSAGE_ID: // This implies adding an attachment to a message. id = uri.getPathSegments().get(1); longId = Long.parseLong(id); values.put(AttachmentColumns.MESSAGE_KEY, longId); return insert(Attachment.CONTENT_URI, values); // Recurse case ACCOUNT_ID: // This implies adding a mailbox to an account. longId = Long.parseLong(uri.getPathSegments().get(1)); values.put(MailboxColumns.ACCOUNT_KEY, longId); return insert(Mailbox.CONTENT_URI, values); // Recurse case ATTACHMENTS_MESSAGE_ID: longId = db.insert(TABLE_NAMES[table], "foo", values); resultUri = ContentUris.withAppendedId(Attachment.CONTENT_URI, longId); break; default: throw new IllegalArgumentException("Unknown URL " + uri); } } catch (SQLiteException e) { checkDatabases(); throw e; } // Notify all notifier cursors sendNotifierChange(getBaseNotificationUri(match), NOTIFICATION_OP_INSERT, id); // Notify all existing cursors. resolver.notifyChange(EmailContent.CONTENT_URI, null); return resultUri; } @Override public boolean onCreate() { MailActivityEmail.setServicesEnabledAsync(getContext()); 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) { long time = 0L; if (MailActivityEmail.DEBUG) { time = System.nanoTime(); } Cursor c = null; int match; try { match = findMatch(uri, "query"); } catch (IllegalArgumentException e) { String uriString = uri.toString(); // If we were passed an illegal uri, see if it ends in /-1 // if so, and if substituting 0 for -1 results in a valid uri, return an empty cursor if (uriString != null && uriString.endsWith("/-1")) { uri = Uri.parse(uriString.substring(0, uriString.length() - 2) + "0"); match = findMatch(uri, "query"); switch (match) { 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: case POLICY_ID: return new MatrixCursor(projection, 0); } } throw e; } Context context = getContext(); // See the comment at delete(), above SQLiteDatabase db = getDatabase(context); int table = match >> BASE_SHIFT; String limit = uri.getQueryParameter(EmailContent.PARAMETER_LIMIT); String id; // Find the cache for this query's table (if any) ContentCache cache = null; String tableName = TABLE_NAMES[table]; // We can only use the cache if there's no selection if (selection == null) { cache = mContentCaches[table]; } if (cache == null) { ContentCache.notCacheable(uri, selection); } try { switch (match) { // First, dispatch queries from UnfiedEmail case UI_SEARCH: return uiSearch(uri, projection); case UI_ACCTS: c = uiAccounts(projection); return c; case UI_UNDO: return uiUndo(projection); case UI_SUBFOLDERS: case UI_MESSAGES: case UI_MESSAGE: case UI_FOLDER: case UI_ACCOUNT: case UI_ATTACHMENT: case UI_ATTACHMENTS: case UI_CONVERSATION: case UI_RECENT_FOLDERS: case UI_ALL_FOLDERS: // For now, we don't allow selection criteria within these queries if (selection != null || selectionArgs != null) { throw new IllegalArgumentException("UI queries can't have selection/args"); } c = uiQuery(match, uri, projection); return c; case UI_FOLDERS: c = uiFolders(uri, projection); return c; case UI_FOLDER_LOAD_MORE: c = uiFolderLoadMore(uri); return c; case UI_FOLDER_REFRESH: c = uiFolderRefresh(uri); return c; case MAILBOX_NOTIFICATION: c = notificationQuery(uri); return c; case MAILBOX_MOST_RECENT_MESSAGE: c = mostRecentMessageQuery(uri); return c; case ACCOUNT_DEFAULT_ID: // Start with a snapshot of the cache Map accountCache = mCacheAccount.getSnapshot(); long accountId = Account.NO_ACCOUNT; // Find the account with "isDefault" set, or the lowest account ID otherwise. // Note that the snapshot from the cached isn't guaranteed to be sorted in any // way. Collection accounts = accountCache.values(); for (Cursor accountCursor: accounts) { // For now, at least, we can have zero count cursors (e.g. if someone looks // up a non-existent id); we need to skip these if (accountCursor.moveToFirst()) { boolean isDefault = accountCursor.getInt(Account.CONTENT_IS_DEFAULT_COLUMN) == 1; long iterId = accountCursor.getLong(Account.CONTENT_ID_COLUMN); // We'll remember this one if it's the default or the first one we see if (isDefault) { accountId = iterId; break; } else if ((accountId == Account.NO_ACCOUNT) || (iterId < accountId)) { accountId = iterId; } } } // Return a cursor with an id projection MatrixCursor mc = new MatrixCursor(EmailContent.ID_PROJECTION); mc.addRow(new Object[] {accountId}); c = mc; break; case MAILBOX_ID_FROM_ACCOUNT_AND_TYPE: // Get accountId and type and find the mailbox in our map List pathSegments = uri.getPathSegments(); accountId = Long.parseLong(pathSegments.get(1)); int type = Integer.parseInt(pathSegments.get(2)); long mailboxId = getMailboxIdFromMailboxTypeMap(accountId, type); // Return a cursor with an id projection mc = new MatrixCursor(EmailContent.ID_PROJECTION); mc.addRow(new Object[] {mailboxId}); c = mc; break; case BODY: case MESSAGE: case UPDATED_MESSAGE: case DELETED_MESSAGE: case ATTACHMENT: case MAILBOX: case ACCOUNT: case HOSTAUTH: case POLICY: case QUICK_RESPONSE: // Special-case "count of accounts"; it's common and we always know it if (match == ACCOUNT && Arrays.equals(projection, EmailContent.COUNT_COLUMNS) && selection == null && limit.equals("1")) { int accountCount = mMailboxTypeMap.size(); // In the rare case there are MAX_CACHED_ACCOUNTS or more, we can't do this if (accountCount < MAX_CACHED_ACCOUNTS) { mc = new MatrixCursor(projection, 1); mc.addRow(new Object[] {accountCount}); c = mc; break; } } c = db.query(tableName, projection, selection, selectionArgs, null, null, sortOrder, limit); 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: case POLICY_ID: case QUICK_RESPONSE_ID: id = uri.getPathSegments().get(1); if (cache != null) { c = cache.getCachedCursor(id, projection); } if (c == null) { CacheToken token = null; if (cache != null) { token = cache.getCacheToken(id); } c = db.query(tableName, projection, whereWithId(id, selection), selectionArgs, null, null, sortOrder, limit); if (cache != null) { c = cache.putCursor(c, id, projection, token); } } 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, limit); break; case QUICK_RESPONSE_ACCOUNT_ID: // All quick responses for the given account id = uri.getPathSegments().get(2); c = db.query(QuickResponse.TABLE_NAME, projection, whereWith(QuickResponse.ACCOUNT_KEY + "=" + id, selection), selectionArgs, null, null, sortOrder); break; default: throw new IllegalArgumentException("Unknown URI " + uri); } } catch (SQLiteException e) { checkDatabases(); throw e; } catch (RuntimeException e) { checkDatabases(); e.printStackTrace(); throw e; } finally { if (cache != null && c != null && MailActivityEmail.DEBUG) { cache.recordQueryTime(c, System.nanoTime() - time); } if (c == null) { // This should never happen, but let's be sure to log it... Log.e(TAG, "Query returning null for uri: " + uri + ", selection: " + selection); } } if ((c != null) && !isTemporary()) { c.setNotificationUri(getContext().getContentResolver(), uri); } return c; } private String whereWithId(String id, String selection) { StringBuilder sb = new StringBuilder(256); sb.append("_id="); sb.append(id); if (selection != null) { sb.append(" AND ("); sb.append(selection); sb.append(')'); } return sb.toString(); } /** * Combine a locally-generated selection with a user-provided selection * * This introduces risk that the local selection might insert incorrect chars * into the SQL, so use caution. * * @param where locally-generated selection, must not be null * @param selection user-provided selection, may be null * @return a single selection string */ private String whereWith(String where, String selection) { if (selection == null) { return where; } StringBuilder sb = new StringBuilder(where); sb.append(" AND ("); sb.append(selection); sb.append(')'); return sb.toString(); } /** * Restore a HostAuth from a database, given its unique id * @param db the database * @param id the unique id (_id) of the row * @return a fully populated HostAuth or null if the row does not exist */ private static HostAuth restoreHostAuth(SQLiteDatabase db, long id) { Cursor c = db.query(HostAuth.TABLE_NAME, HostAuth.CONTENT_PROJECTION, HostAuth.RECORD_ID + "=?", new String[] {Long.toString(id)}, null, null, null); try { if (c.moveToFirst()) { HostAuth hostAuth = new HostAuth(); hostAuth.restore(c); return hostAuth; } return null; } finally { c.close(); } } /** * Copy the Account and HostAuth tables from one database to another * @param fromDatabase the source database * @param toDatabase the destination database * @return the number of accounts copied, or -1 if an error occurred */ private static int copyAccountTables(SQLiteDatabase fromDatabase, SQLiteDatabase toDatabase) { if (fromDatabase == null || toDatabase == null) return -1; // Lock both databases; for the "from" database, we don't want anyone changing it from // under us; for the "to" database, we want to make the operation atomic int copyCount = 0; fromDatabase.beginTransaction(); try { toDatabase.beginTransaction(); try { // Delete anything hanging around here toDatabase.delete(Account.TABLE_NAME, null, null); toDatabase.delete(HostAuth.TABLE_NAME, null, null); // Get our account cursor Cursor c = fromDatabase.query(Account.TABLE_NAME, Account.CONTENT_PROJECTION, null, null, null, null, null); if (c == null) return 0; Log.d(TAG, "fromDatabase accounts: " + c.getCount()); try { // Loop through accounts, copying them and associated host auth's while (c.moveToNext()) { Account account = new Account(); account.restore(c); // Clear security sync key and sync key, as these were specific to the // state of the account, and we've reset that... // Clear policy key so that we can re-establish policies from the server // TODO This is pretty EAS specific, but there's a lot of that around account.mSecuritySyncKey = null; account.mSyncKey = null; account.mPolicyKey = 0; // Copy host auth's and update foreign keys HostAuth hostAuth = restoreHostAuth(fromDatabase, account.mHostAuthKeyRecv); // The account might have gone away, though very unlikely if (hostAuth == null) continue; account.mHostAuthKeyRecv = toDatabase.insert(HostAuth.TABLE_NAME, null, hostAuth.toContentValues()); // EAS accounts have no send HostAuth if (account.mHostAuthKeySend > 0) { hostAuth = restoreHostAuth(fromDatabase, account.mHostAuthKeySend); // Belt and suspenders; I can't imagine that this is possible, // since we checked the validity of the account above, and the // database is now locked if (hostAuth == null) continue; account.mHostAuthKeySend = toDatabase.insert( HostAuth.TABLE_NAME, null, hostAuth.toContentValues()); } // Now, create the account in the "to" database toDatabase.insert(Account.TABLE_NAME, null, account.toContentValues()); copyCount++; } } finally { c.close(); } // Say it's ok to commit toDatabase.setTransactionSuccessful(); } finally { // STOPSHIP: Remove logging here and in at endTransaction() below Log.d(TAG, "ending toDatabase transaction; copyCount = " + copyCount); toDatabase.endTransaction(); } } catch (SQLiteException ex) { Log.w(TAG, "Exception while copying account tables", ex); copyCount = -1; } finally { Log.d(TAG, "ending fromDatabase transaction; copyCount = " + copyCount); fromDatabase.endTransaction(); } return copyCount; } private static SQLiteDatabase getBackupDatabase(Context context) { DBHelper.DatabaseHelper helper = new DBHelper.DatabaseHelper(context, BACKUP_DATABASE_NAME); return helper.getWritableDatabase(); } /** * Backup account data, returning the number of accounts backed up */ private static int backupAccounts(Context context, SQLiteDatabase mainDatabase) { if (MailActivityEmail.DEBUG) { Log.d(TAG, "backupAccounts..."); } SQLiteDatabase backupDatabase = getBackupDatabase(context); try { int numBackedUp = copyAccountTables(mainDatabase, backupDatabase); if (numBackedUp < 0) { Log.e(TAG, "Account backup failed!"); } else if (MailActivityEmail.DEBUG) { Log.d(TAG, "Backed up " + numBackedUp + " accounts..."); } return numBackedUp; } finally { if (backupDatabase != null) { backupDatabase.close(); } } } /** * Restore account data, returning the number of accounts restored */ private static int restoreAccounts(Context context, SQLiteDatabase mainDatabase) { if (MailActivityEmail.DEBUG) { Log.d(TAG, "restoreAccounts..."); } SQLiteDatabase backupDatabase = getBackupDatabase(context); try { int numRecovered = copyAccountTables(backupDatabase, mainDatabase); if (numRecovered > 0) { Log.e(TAG, "Recovered " + numRecovered + " accounts!"); } else if (numRecovered < 0) { Log.e(TAG, "Account recovery failed?"); } else if (MailActivityEmail.DEBUG) { Log.d(TAG, "No accounts to restore..."); } return numRecovered; } finally { if (backupDatabase != null) { backupDatabase.close(); } } } // select count(*) from (select count(*) as dupes from Mailbox where accountKey=? // group by serverId) where dupes > 1; private static final String ACCOUNT_INTEGRITY_SQL = "select count(*) from (select count(*) as dupes from " + Mailbox.TABLE_NAME + " where accountKey=? group by " + MailboxColumns.SERVER_ID + ") where dupes > 1"; @Override public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) { // Handle this special case the fastest possible way if (uri == INTEGRITY_CHECK_URI) { checkDatabases(); return 0; } else if (uri == ACCOUNT_BACKUP_URI) { return backupAccounts(getContext(), getDatabase(getContext())); } // Notify all existing cursors, except for ACCOUNT_RESET_NEW_COUNT(_ID) Uri notificationUri = EmailContent.CONTENT_URI; int match = findMatch(uri, "update"); Context context = getContext(); ContentResolver resolver = context.getContentResolver(); // See the comment at delete(), above SQLiteDatabase db = getDatabase(context); int table = match >> BASE_SHIFT; int result; // We do NOT allow setting of unreadCount/messageCount via the provider // These columns are maintained via triggers if (match == MAILBOX_ID || match == MAILBOX) { values.remove(MailboxColumns.UNREAD_COUNT); values.remove(MailboxColumns.MESSAGE_COUNT); } ContentCache cache = mContentCaches[table]; String tableName = TABLE_NAMES[table]; String id = "0"; try { outer: switch (match) { case ACCOUNT_PICK_TRASH_FOLDER: return pickTrashFolder(uri); case UI_FOLDER: return uiUpdateFolder(uri, values); case UI_RECENT_FOLDERS: return uiUpdateRecentFolders(uri, values); case UI_DEFAULT_RECENT_FOLDERS: return uiPopulateRecentFolders(uri); case UI_ATTACHMENT: return uiUpdateAttachment(uri, values); case UI_UPDATEDRAFT: return uiUpdateDraft(uri, values); case UI_SENDDRAFT: return uiSendDraft(uri, values); case UI_MESSAGE: return uiUpdateMessage(uri, values); case ACCOUNT_CHECK: id = uri.getLastPathSegment(); // With any error, return 1 (a failure) int res = 1; Cursor ic = null; try { ic = db.rawQuery(ACCOUNT_INTEGRITY_SQL, new String[] {id}); if (ic.moveToFirst()) { res = ic.getInt(0); } } finally { if (ic != null) { ic.close(); } } // Count of duplicated mailboxes return res; case MAILBOX_ID_ADD_TO_FIELD: case ACCOUNT_ID_ADD_TO_FIELD: 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); } ContentValues actualValues = new ContentValues(); if (cache != null) { cache.lock(id); } try { db.beginTransaction(); try { Cursor c = db.query(tableName, new String[] {EmailContent.RECORD_ID, field}, whereWithId(id, selection), selectionArgs, null, null, null); try { result = 0; String[] bind = new String[1]; if (c.moveToNext()) { bind[0] = c.getString(0); // _id long value = c.getLong(1) + add; actualValues.put(field, value); result = db.update(tableName, actualValues, ID_EQUALS, bind); } db.setTransactionSuccessful(); } finally { c.close(); } } finally { db.endTransaction(); } } finally { if (cache != null) { cache.unlock(id, actualValues); } } break; case SYNCED_MESSAGE_SELECTION: Cursor findCursor = db.query(tableName, Message.ID_COLUMN_PROJECTION, selection, selectionArgs, null, null, null); try { if (findCursor.moveToFirst()) { return update(ContentUris.withAppendedId( Message.SYNCED_CONTENT_URI, findCursor.getLong(Message.ID_COLUMNS_ID_COLUMN)), values, null, null); } else { return 0; } } finally { findCursor.close(); } case SYNCED_MESSAGE_ID: case UPDATED_MESSAGE_ID: case MESSAGE_ID: case BODY_ID: case ATTACHMENT_ID: case MAILBOX_ID: case ACCOUNT_ID: case HOSTAUTH_ID: case QUICK_RESPONSE_ID: case POLICY_ID: id = uri.getPathSegments().get(1); if (cache != null) { cache.lock(id); } try { 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(tableName, values, whereWithId(id, selection), selectionArgs); } catch (SQLiteException e) { // Null out values (so they aren't cached) and re-throw values = null; throw e; } finally { if (cache != null) { cache.unlock(id, values); } } if (match == MESSAGE_ID || match == SYNCED_MESSAGE_ID) { if (!uri.getBooleanQueryParameter(IS_UIPROVIDER, false)) { notifyUIConversation(uri); } } else if (match == ATTACHMENT_ID) { long attId = Integer.parseInt(id); if (values.containsKey(Attachment.FLAGS)) { int flags = values.getAsInteger(Attachment.FLAGS); mAttachmentService.attachmentChanged(context, attId, flags); } // Notify UI if necessary; there are only two columns we can change that // would be worth a notification if (values.containsKey(AttachmentColumns.UI_STATE) || values.containsKey(AttachmentColumns.UI_DOWNLOADED_SIZE)) { // Notify on individual attachment notifyUI(UIPROVIDER_ATTACHMENT_NOTIFIER, id); Attachment att = Attachment.restoreAttachmentWithId(context, attId); if (att != null) { // And on owning Message notifyUI(UIPROVIDER_ATTACHMENTS_NOTIFIER, att.mMessageKey); } } } else if (match == MAILBOX_ID && values.containsKey(Mailbox.UI_SYNC_STATUS)) { notifyUI(UIPROVIDER_FOLDER_NOTIFIER, id); } else if (match == ACCOUNT_ID) { notifyUI(UIPROVIDER_ACCOUNT_NOTIFIER, id); } break; case BODY: case MESSAGE: case UPDATED_MESSAGE: case ATTACHMENT: case MAILBOX: case ACCOUNT: case HOSTAUTH: case POLICY: switch(match) { // To avoid invalidating the cache on updates, we execute them one at a // time using the XXX_ID uri; these are all executed atomically case ACCOUNT: case MAILBOX: case HOSTAUTH: case POLICY: Cursor c = db.query(tableName, EmailContent.ID_PROJECTION, selection, selectionArgs, null, null, null); db.beginTransaction(); result = 0; try { while (c.moveToNext()) { update(ContentUris.withAppendedId( uri, c.getLong(EmailContent.ID_PROJECTION_COLUMN)), values, null, null); result++; } db.setTransactionSuccessful(); } finally { db.endTransaction(); c.close(); } break outer; // Any cached table other than those above should be invalidated here case MESSAGE: // If we're doing some generic update, the whole cache needs to be // invalidated. This case should be quite rare cache.invalidate("Update", uri, selection); //$FALL-THROUGH$ default: result = db.update(tableName, values, selection, selectionArgs); break outer; } case ACCOUNT_RESET_NEW_COUNT_ID: id = uri.getPathSegments().get(1); if (cache != null) { cache.lock(id); } ContentValues newMessageCount = CONTENT_VALUES_RESET_NEW_MESSAGE_COUNT; if (values != null) { Long set = values.getAsLong(EmailContent.SET_COLUMN_NAME); if (set != null) { newMessageCount = new ContentValues(); newMessageCount.put(Account.NEW_MESSAGE_COUNT, set); } } try { result = db.update(tableName, newMessageCount, whereWithId(id, selection), selectionArgs); } finally { if (cache != null) { cache.unlock(id, values); } } notificationUri = Account.CONTENT_URI; // Only notify account cursors. break; case ACCOUNT_RESET_NEW_COUNT: result = db.update(tableName, CONTENT_VALUES_RESET_NEW_MESSAGE_COUNT, selection, selectionArgs); // Affects all accounts. Just invalidate all account cache. cache.invalidate("Reset all new counts", null, null); notificationUri = Account.CONTENT_URI; // Only notify account cursors. break; default: throw new IllegalArgumentException("Unknown URI " + uri); } } catch (SQLiteException e) { checkDatabases(); throw e; } // Notify all notifier cursors sendNotifierChange(getBaseNotificationUri(match), NOTIFICATION_OP_UPDATE, id); resolver.notifyChange(notificationUri, null); return result; } /** * Returns the base notification URI for the given content type. * * @param match The type of content that was modified. */ private Uri getBaseNotificationUri(int match) { Uri baseUri = null; switch (match) { case MESSAGE: case MESSAGE_ID: case SYNCED_MESSAGE_ID: baseUri = Message.NOTIFIER_URI; break; case ACCOUNT: case ACCOUNT_ID: baseUri = Account.NOTIFIER_URI; break; } return baseUri; } /** * Sends a change notification to any cursors observers of the given base URI. The final * notification URI is dynamically built to contain the specified information. It will be * of the format <>/<>/<>; where <> and <> are optional depending * upon the given values. * NOTE: If <> is specified, notifications for <>/<> will NOT be invoked. * If this is necessary, it can be added. However, due to the implementation of * {@link ContentObserver}, observers of <> will receive multiple notifications. * * @param baseUri The base URI to send notifications to. Must be able to take appended IDs. * @param op Optional operation to be appended to the URI. * @param id If a positive value, the ID to append to the base URI. Otherwise, no ID will be * appended to the base URI. */ private void sendNotifierChange(Uri baseUri, String op, String id) { if (baseUri == null) return; final ContentResolver resolver = getContext().getContentResolver(); // Append the operation, if specified if (op != null) { baseUri = baseUri.buildUpon().appendEncodedPath(op).build(); } long longId = 0L; try { longId = Long.valueOf(id); } catch (NumberFormatException ignore) {} if (longId > 0) { resolver.notifyChange(ContentUris.withAppendedId(baseUri, longId), null); } else { resolver.notifyChange(baseUri, null); } // We want to send the message list changed notification if baseUri is Message.NOTIFIER_URI. if (baseUri.equals(Message.NOTIFIER_URI)) { sendMessageListDataChangedNotification(); } } private void sendMessageListDataChangedNotification() { final Context context = getContext(); final Intent intent = new Intent(ACTION_NOTIFY_MESSAGE_LIST_DATASET_CHANGED); // Ideally this intent would contain information about which account changed, to limit the // updates to that particular account. Unfortunately, that information is not available in // sendNotifierChange(). context.sendBroadcast(intent); } @Override public ContentProviderResult[] applyBatch(ArrayList operations) throws OperationApplicationException { Context context = getContext(); SQLiteDatabase db = getDatabase(context); db.beginTransaction(); try { ContentProviderResult[] results = super.applyBatch(operations); db.setTransactionSuccessful(); return results; } finally { db.endTransaction(); } } /** * For testing purposes, check whether a given row is cached * @param baseUri the base uri of the EmailContent * @param id the row id of the EmailContent * @return whether or not the row is currently cached */ @VisibleForTesting protected boolean isCached(Uri baseUri, long id) { int match = findMatch(baseUri, "isCached"); int table = match >> BASE_SHIFT; ContentCache cache = mContentCaches[table]; if (cache == null) return false; Cursor cc = cache.get(Long.toString(id)); return (cc != null); } public static interface AttachmentService { /** * Notify the service that an attachment has changed. */ void attachmentChanged(Context context, long id, int flags); } private final AttachmentService DEFAULT_ATTACHMENT_SERVICE = new AttachmentService() { @Override public void attachmentChanged(Context context, long id, int flags) { // The default implementation delegates to the real service. AttachmentDownloadService.attachmentChanged(context, id, flags); } }; private AttachmentService mAttachmentService = DEFAULT_ATTACHMENT_SERVICE; /** * Injects a custom attachment service handler. If null is specified, will reset to the * default service. */ public void injectAttachmentService(AttachmentService as) { mAttachmentService = (as == null) ? DEFAULT_ATTACHMENT_SERVICE : as; } // SELECT DISTINCT Boxes._id, Boxes.unreadCount count(Message._id) from Message, // (SELECT _id, unreadCount, messageCount, lastNotifiedMessageCount, lastNotifiedMessageKey // FROM Mailbox WHERE accountKey=6 AND ((type = 0) OR (syncInterval!=0 AND syncInterval!=-1))) // AS Boxes // WHERE Boxes.messageCount!=Boxes.lastNotifiedMessageCount // OR (Boxes._id=Message.mailboxKey AND Message._id>Boxes.lastNotifiedMessageKey) // TODO: This query can be simplified a bit private static final String NOTIFICATION_QUERY = "SELECT DISTINCT Boxes." + MailboxColumns.ID + ", Boxes." + MailboxColumns.UNREAD_COUNT + ", count(" + Message.TABLE_NAME + "." + MessageColumns.ID + ")" + " FROM " + Message.TABLE_NAME + "," + "(SELECT " + MailboxColumns.ID + "," + MailboxColumns.UNREAD_COUNT + "," + MailboxColumns.MESSAGE_COUNT + "," + MailboxColumns.LAST_NOTIFIED_MESSAGE_COUNT + "," + MailboxColumns.LAST_NOTIFIED_MESSAGE_KEY + " FROM " + Mailbox.TABLE_NAME + " WHERE " + MailboxColumns.ACCOUNT_KEY + "=?" + " AND (" + MailboxColumns.TYPE + "=" + Mailbox.TYPE_INBOX + " OR (" + MailboxColumns.SYNC_INTERVAL + "!=0 AND " + MailboxColumns.SYNC_INTERVAL + "!=-1))) AS Boxes " + "WHERE Boxes." + MailboxColumns.ID + '=' + Message.TABLE_NAME + "." + MessageColumns.MAILBOX_KEY + " AND " + Message.TABLE_NAME + "." + MessageColumns.ID + ">Boxes." + MailboxColumns.LAST_NOTIFIED_MESSAGE_KEY + " AND " + MessageColumns.FLAG_READ + "=0 AND " + MessageColumns.TIMESTAMP + "!=0"; public Cursor notificationQuery(Uri uri) { SQLiteDatabase db = getDatabase(getContext()); String accountId = uri.getLastPathSegment(); return db.rawQuery(NOTIFICATION_QUERY, new String[] {accountId}); } public Cursor mostRecentMessageQuery(Uri uri) { SQLiteDatabase db = getDatabase(getContext()); String mailboxId = uri.getLastPathSegment(); return db.rawQuery("select max(_id) from Message where mailboxKey=?", new String[] {mailboxId}); } /** * Support for UnifiedEmail below */ private static final String NOT_A_DRAFT_STRING = Integer.toString(UIProvider.DraftType.NOT_A_DRAFT); private static final String CONVERSATION_FLAGS = "CASE WHEN (" + MessageColumns.FLAGS + "&" + Message.FLAG_INCOMING_MEETING_INVITE + ") !=0 THEN " + UIProvider.ConversationFlags.CALENDAR_INVITE + " ELSE 0 END + " + "CASE WHEN (" + MessageColumns.FLAGS + "&" + Message.FLAG_FORWARDED + ") !=0 THEN " + UIProvider.ConversationFlags.FORWARDED + " ELSE 0 END + " + "CASE WHEN (" + MessageColumns.FLAGS + "&" + Message.FLAG_REPLIED_TO + ") !=0 THEN " + UIProvider.ConversationFlags.REPLIED + " ELSE 0 END"; /** * Array of pre-defined account colors (legacy colors from old email app) */ private static final int[] ACCOUNT_COLORS = new int[] { 0xff71aea7, 0xff621919, 0xff18462f, 0xffbf8e52, 0xff001f79, 0xffa8afc2, 0xff6b64c4, 0xff738359, 0xff9d50a4 }; private static final String CONVERSATION_COLOR = "@CASE (" + MessageColumns.ACCOUNT_KEY + " - 1) % " + ACCOUNT_COLORS.length + " WHEN 0 THEN " + ACCOUNT_COLORS[0] + " WHEN 1 THEN " + ACCOUNT_COLORS[1] + " WHEN 2 THEN " + ACCOUNT_COLORS[2] + " WHEN 3 THEN " + ACCOUNT_COLORS[3] + " WHEN 4 THEN " + ACCOUNT_COLORS[4] + " WHEN 5 THEN " + ACCOUNT_COLORS[5] + " WHEN 6 THEN " + ACCOUNT_COLORS[6] + " WHEN 7 THEN " + ACCOUNT_COLORS[7] + " WHEN 8 THEN " + ACCOUNT_COLORS[8] + " END"; private static final String ACCOUNT_COLOR = "@CASE (" + AccountColumns.ID + " - 1) % " + ACCOUNT_COLORS.length + " WHEN 0 THEN " + ACCOUNT_COLORS[0] + " WHEN 1 THEN " + ACCOUNT_COLORS[1] + " WHEN 2 THEN " + ACCOUNT_COLORS[2] + " WHEN 3 THEN " + ACCOUNT_COLORS[3] + " WHEN 4 THEN " + ACCOUNT_COLORS[4] + " WHEN 5 THEN " + ACCOUNT_COLORS[5] + " WHEN 6 THEN " + ACCOUNT_COLORS[6] + " WHEN 7 THEN " + ACCOUNT_COLORS[7] + " WHEN 8 THEN " + ACCOUNT_COLORS[8] + " END"; /** * Mapping of UIProvider columns to EmailProvider columns for the message list (called the * conversation list in UnifiedEmail) */ private static final ProjectionMap sMessageListMap = ProjectionMap.builder() .add(BaseColumns._ID, MessageColumns.ID) .add(UIProvider.ConversationColumns.URI, uriWithId("uimessage")) .add(UIProvider.ConversationColumns.MESSAGE_LIST_URI, uriWithId("uimessage")) .add(UIProvider.ConversationColumns.SUBJECT, MessageColumns.SUBJECT) .add(UIProvider.ConversationColumns.SNIPPET, MessageColumns.SNIPPET) .add(UIProvider.ConversationColumns.SENDER_INFO, MessageColumns.FROM_LIST) .add(UIProvider.ConversationColumns.DATE_RECEIVED_MS, MessageColumns.TIMESTAMP) .add(UIProvider.ConversationColumns.HAS_ATTACHMENTS, MessageColumns.FLAG_ATTACHMENT) .add(UIProvider.ConversationColumns.NUM_MESSAGES, "1") .add(UIProvider.ConversationColumns.NUM_DRAFTS, "0") .add(UIProvider.ConversationColumns.SENDING_STATE, Integer.toString(ConversationSendingState.OTHER)) .add(UIProvider.ConversationColumns.PRIORITY, Integer.toString(ConversationPriority.LOW)) .add(UIProvider.ConversationColumns.READ, MessageColumns.FLAG_READ) .add(UIProvider.ConversationColumns.STARRED, MessageColumns.FLAG_FAVORITE) .add(UIProvider.ConversationColumns.FOLDER_LIST, "'content://" + EmailContent.AUTHORITY + "/uifolder/' || " + MessageColumns.MAILBOX_KEY) .add(UIProvider.ConversationColumns.FLAGS, CONVERSATION_FLAGS) .add(UIProvider.ConversationColumns.ACCOUNT_URI, "'content://" + EmailContent.AUTHORITY + "/uiaccount/' || " + MessageColumns.ACCOUNT_KEY) .build(); /** * Generate UIProvider draft type; note the test for "reply all" must come before "reply" */ private static final String MESSAGE_DRAFT_TYPE = "CASE WHEN (" + MessageColumns.FLAGS + "&" + Message.FLAG_TYPE_ORIGINAL + ") !=0 THEN " + UIProvider.DraftType.COMPOSE + " WHEN (" + MessageColumns.FLAGS + "&" + (1<<20) + ") !=0 THEN " + UIProvider.DraftType.REPLY_ALL + " WHEN (" + MessageColumns.FLAGS + "&" + Message.FLAG_TYPE_REPLY + ") !=0 THEN " + UIProvider.DraftType.REPLY + " WHEN (" + MessageColumns.FLAGS + "&" + Message.FLAG_TYPE_FORWARD + ") !=0 THEN " + UIProvider.DraftType.FORWARD + " ELSE " + UIProvider.DraftType.NOT_A_DRAFT + " END"; private static final String MESSAGE_FLAGS = "CASE WHEN (" + MessageColumns.FLAGS + "&" + Message.FLAG_INCOMING_MEETING_INVITE + ") !=0 THEN " + UIProvider.MessageFlags.CALENDAR_INVITE + " ELSE 0 END"; /** * Mapping of UIProvider columns to EmailProvider columns for a detailed message view in * UnifiedEmail */ private static final ProjectionMap sMessageViewMap = ProjectionMap.builder() .add(BaseColumns._ID, Message.TABLE_NAME + "." + EmailContent.MessageColumns.ID) .add(UIProvider.MessageColumns.SERVER_ID, SyncColumns.SERVER_ID) .add(UIProvider.MessageColumns.URI, uriWithFQId("uimessage", Message.TABLE_NAME)) .add(UIProvider.MessageColumns.CONVERSATION_ID, uriWithFQId("uimessage", Message.TABLE_NAME)) .add(UIProvider.MessageColumns.SUBJECT, EmailContent.MessageColumns.SUBJECT) .add(UIProvider.MessageColumns.SNIPPET, EmailContent.MessageColumns.SNIPPET) .add(UIProvider.MessageColumns.FROM, EmailContent.MessageColumns.FROM_LIST) .add(UIProvider.MessageColumns.TO, EmailContent.MessageColumns.TO_LIST) .add(UIProvider.MessageColumns.CC, EmailContent.MessageColumns.CC_LIST) .add(UIProvider.MessageColumns.BCC, EmailContent.MessageColumns.BCC_LIST) .add(UIProvider.MessageColumns.REPLY_TO, EmailContent.MessageColumns.REPLY_TO_LIST) .add(UIProvider.MessageColumns.DATE_RECEIVED_MS, EmailContent.MessageColumns.TIMESTAMP) .add(UIProvider.MessageColumns.BODY_HTML, Body.HTML_CONTENT) .add(UIProvider.MessageColumns.BODY_TEXT, Body.TEXT_CONTENT) .add(UIProvider.MessageColumns.REF_MESSAGE_ID, "0") .add(UIProvider.MessageColumns.DRAFT_TYPE, NOT_A_DRAFT_STRING) .add(UIProvider.MessageColumns.APPEND_REF_MESSAGE_CONTENT, "0") .add(UIProvider.MessageColumns.HAS_ATTACHMENTS, EmailContent.MessageColumns.FLAG_ATTACHMENT) .add(UIProvider.MessageColumns.ATTACHMENT_LIST_URI, uriWithFQId("uiattachments", Message.TABLE_NAME)) .add(UIProvider.MessageColumns.MESSAGE_FLAGS, MESSAGE_FLAGS) .add(UIProvider.MessageColumns.SAVE_MESSAGE_URI, uriWithFQId("uiupdatedraft", Message.TABLE_NAME)) .add(UIProvider.MessageColumns.SEND_MESSAGE_URI, uriWithFQId("uisenddraft", Message.TABLE_NAME)) .add(UIProvider.MessageColumns.DRAFT_TYPE, MESSAGE_DRAFT_TYPE) .add(UIProvider.MessageColumns.MESSAGE_ACCOUNT_URI, uriWithColumn("account", MessageColumns.ACCOUNT_KEY)) .add(UIProvider.MessageColumns.STARRED, EmailContent.MessageColumns.FLAG_FAVORITE) .add(UIProvider.MessageColumns.READ, EmailContent.MessageColumns.FLAG_READ) .add(UIProvider.MessageColumns.SPAM_WARNING_STRING, null) .add(UIProvider.MessageColumns.SPAM_WARNING_LEVEL, Integer.toString(UIProvider.SpamWarningLevel.NO_WARNING)) .add(UIProvider.MessageColumns.SPAM_WARNING_LINK_TYPE, Integer.toString(UIProvider.SpamWarningLinkType.NO_LINK)) .add(UIProvider.MessageColumns.VIA_DOMAIN, null) .build(); /** * Generate UIProvider folder capabilities from mailbox flags */ private static final String FOLDER_CAPABILITIES = "CASE WHEN (" + MailboxColumns.FLAGS + "&" + Mailbox.FLAG_ACCEPTS_MOVED_MAIL + ") !=0 THEN " + UIProvider.FolderCapabilities.CAN_ACCEPT_MOVED_MESSAGES + " ELSE 0 END"; /** * Convert EmailProvider type to UIProvider type */ private static final String FOLDER_TYPE = "CASE " + MailboxColumns.TYPE + " WHEN " + Mailbox.TYPE_INBOX + " THEN " + UIProvider.FolderType.INBOX + " WHEN " + Mailbox.TYPE_DRAFTS + " THEN " + UIProvider.FolderType.DRAFT + " WHEN " + Mailbox.TYPE_OUTBOX + " THEN " + UIProvider.FolderType.OUTBOX + " WHEN " + Mailbox.TYPE_SENT + " THEN " + UIProvider.FolderType.SENT + " WHEN " + Mailbox.TYPE_TRASH + " THEN " + UIProvider.FolderType.TRASH + " WHEN " + Mailbox.TYPE_JUNK + " THEN " + UIProvider.FolderType.SPAM + " WHEN " + Mailbox.TYPE_STARRED + " THEN " + UIProvider.FolderType.STARRED + " ELSE " + UIProvider.FolderType.DEFAULT + " END"; private static final String FOLDER_ICON = "CASE " + MailboxColumns.TYPE + " WHEN " + Mailbox.TYPE_INBOX + " THEN " + R.drawable.ic_folder_inbox_holo_light + " WHEN " + Mailbox.TYPE_DRAFTS + " THEN " + R.drawable.ic_folder_drafts_holo_light + " WHEN " + Mailbox.TYPE_OUTBOX + " THEN " + R.drawable.ic_folder_outbox_holo_light + " WHEN " + Mailbox.TYPE_SENT + " THEN " + R.drawable.ic_folder_sent_holo_light + " WHEN " + Mailbox.TYPE_STARRED + " THEN " + R.drawable.ic_menu_star_holo_light + " ELSE -1 END"; private static final ProjectionMap sFolderListMap = ProjectionMap.builder() .add(BaseColumns._ID, MailboxColumns.ID) .add(UIProvider.FolderColumns.URI, uriWithId("uifolder")) .add(UIProvider.FolderColumns.NAME, "displayName") .add(UIProvider.FolderColumns.HAS_CHILDREN, MailboxColumns.FLAGS + "&" + Mailbox.FLAG_HAS_CHILDREN) .add(UIProvider.FolderColumns.CAPABILITIES, FOLDER_CAPABILITIES) .add(UIProvider.FolderColumns.SYNC_WINDOW, "3") .add(UIProvider.FolderColumns.CONVERSATION_LIST_URI, uriWithId("uimessages")) .add(UIProvider.FolderColumns.CHILD_FOLDERS_LIST_URI, uriWithId("uisubfolders")) .add(UIProvider.FolderColumns.UNREAD_COUNT, MailboxColumns.UNREAD_COUNT) .add(UIProvider.FolderColumns.TOTAL_COUNT, MailboxColumns.MESSAGE_COUNT) .add(UIProvider.FolderColumns.REFRESH_URI, uriWithId("uirefresh")) .add(UIProvider.FolderColumns.SYNC_STATUS, MailboxColumns.UI_SYNC_STATUS) .add(UIProvider.FolderColumns.LAST_SYNC_RESULT, MailboxColumns.UI_LAST_SYNC_RESULT) .add(UIProvider.FolderColumns.TYPE, FOLDER_TYPE) .add(UIProvider.FolderColumns.ICON_RES_ID, FOLDER_ICON) .add(UIProvider.FolderColumns.HIERARCHICAL_DESC, MailboxColumns.HIERARCHICAL_NAME) .build(); private static final ProjectionMap sAccountListMap = ProjectionMap.builder() .add(BaseColumns._ID, AccountColumns.ID) .add(UIProvider.AccountColumns.FOLDER_LIST_URI, uriWithId("uifolders")) .add(UIProvider.AccountColumns.FULL_FOLDER_LIST_URI, uriWithId("uiallfolders")) .add(UIProvider.AccountColumns.NAME, AccountColumns.DISPLAY_NAME) .add(UIProvider.AccountColumns.SAVE_DRAFT_URI, uriWithId("uisavedraft")) .add(UIProvider.AccountColumns.SEND_MAIL_URI, uriWithId("uisendmail")) .add(UIProvider.AccountColumns.UNDO_URI, ("'content://" + UIProvider.AUTHORITY + "/uiundo'")) .add(UIProvider.AccountColumns.URI, uriWithId("uiaccount")) .add(UIProvider.AccountColumns.SEARCH_URI, uriWithId("uisearch")) // TODO: Is provider version used? .add(UIProvider.AccountColumns.PROVIDER_VERSION, "1") .add(UIProvider.AccountColumns.SYNC_STATUS, "0") .add(UIProvider.AccountColumns.RECENT_FOLDER_LIST_URI, uriWithId("uirecentfolders")) .add(UIProvider.AccountColumns.DEFAULT_RECENT_FOLDER_LIST_URI, uriWithId("uidefaultrecentfolders")) .add(UIProvider.AccountColumns.SettingsColumns.SIGNATURE, AccountColumns.SIGNATURE) .add(UIProvider.AccountColumns.SettingsColumns.SNAP_HEADERS, Integer.toString(UIProvider.SnapHeaderValue.ALWAYS)) .add(UIProvider.AccountColumns.SettingsColumns.REPLY_BEHAVIOR, Integer.toString(UIProvider.DefaultReplyBehavior.REPLY)) .add(UIProvider.AccountColumns.SettingsColumns.CONFIRM_ARCHIVE, "0") .build(); /** * The "ORDER BY" clause for top level folders */ private static final String MAILBOX_ORDER_BY = "CASE " + MailboxColumns.TYPE + " WHEN " + Mailbox.TYPE_INBOX + " THEN 0" + " WHEN " + Mailbox.TYPE_DRAFTS + " THEN 1" + " WHEN " + Mailbox.TYPE_OUTBOX + " THEN 2" + " WHEN " + Mailbox.TYPE_SENT + " THEN 3" + " WHEN " + Mailbox.TYPE_TRASH + " THEN 4" + " WHEN " + Mailbox.TYPE_JUNK + " THEN 5" // Other mailboxes (i.e. of Mailbox.TYPE_MAIL) are shown in alphabetical order. + " ELSE 10 END" + " ," + MailboxColumns.DISPLAY_NAME + " COLLATE LOCALIZED ASC"; /** * Mapping of UIProvider columns to EmailProvider columns for a message's attachments */ private static final ProjectionMap sAttachmentMap = ProjectionMap.builder() .add(UIProvider.AttachmentColumns.NAME, AttachmentColumns.FILENAME) .add(UIProvider.AttachmentColumns.SIZE, AttachmentColumns.SIZE) .add(UIProvider.AttachmentColumns.URI, uriWithId("uiattachment")) .add(UIProvider.AttachmentColumns.CONTENT_TYPE, AttachmentColumns.MIME_TYPE) .add(UIProvider.AttachmentColumns.STATE, AttachmentColumns.UI_STATE) .add(UIProvider.AttachmentColumns.DESTINATION, AttachmentColumns.UI_DESTINATION) .add(UIProvider.AttachmentColumns.DOWNLOADED_SIZE, AttachmentColumns.UI_DOWNLOADED_SIZE) .add(UIProvider.AttachmentColumns.CONTENT_URI, AttachmentColumns.CONTENT_URI) .build(); /** * Generate the SELECT clause using a specified mapping and the original UI projection * @param map the ProjectionMap to use for this projection * @param projection the projection as sent by UnifiedEmail * @param values ContentValues to be used if the ProjectionMap entry is null * @return a StringBuilder containing the SELECT expression for a SQLite query */ private StringBuilder genSelect(ProjectionMap map, String[] projection) { return genSelect(map, projection, EMPTY_CONTENT_VALUES); } private StringBuilder genSelect(ProjectionMap map, String[] projection, ContentValues values) { StringBuilder sb = new StringBuilder("SELECT "); boolean first = true; for (String column: projection) { if (first) { first = false; } else { sb.append(','); } String val = null; // First look at values; this is an override of default behavior if (values.containsKey(column)) { String value = values.getAsString(column); if (value.startsWith("@")) { val = value.substring(1) + " AS " + column; } else { val = "'" + values.getAsString(column) + "' AS " + column; } } else { // Now, get the standard value for the column from our projection map val = map.get(column); // If we don't have the column, return "NULL AS ", and warn if (val == null) { val = "NULL AS " + column; } } sb.append(val); } return sb; } /** * Convenience method to create a Uri string given the "type" of query; we append the type * of the query and the id column name (_id) * * @param type the "type" of the query, as defined by our UriMatcher definitions * @return a Uri string */ private static String uriWithId(String type) { return uriWithColumn(type, EmailContent.RECORD_ID); } /** * Convenience method to create a Uri string given the "type" of query; we append the type * of the query and the passed in column name * * @param type the "type" of the query, as defined by our UriMatcher definitions * @param columnName the column in the table being queried * @return a Uri string */ private static String uriWithColumn(String type, String columnName) { return "'content://" + EmailContent.AUTHORITY + "/" + type + "/' || " + columnName; } /** * Convenience method to create a Uri string given the "type" of query and the table name to * which it applies; we append the type of the query and the fully qualified (FQ) id column * (i.e. including the table name); we need this for join queries where _id would otherwise * be ambiguous * * @param type the "type" of the query, as defined by our UriMatcher definitions * @param tableName the name of the table whose _id is referred to * @return a Uri string */ private static String uriWithFQId(String type, String tableName) { return "'content://" + EmailContent.AUTHORITY + "/" + type + "/' || " + tableName + "._id"; } // Regex that matches start of img tag. '<(?i)img\s+'. private static final Pattern IMG_TAG_START_REGEX = Pattern.compile("<(?i)img\\s+"); /** * Class that holds the sqlite query and the attachment (JSON) value (which might be null) */ private static class MessageQuery { final String query; final String attachmentJson; MessageQuery(String _query, String _attachmentJson) { query = _query; attachmentJson = _attachmentJson; } } /** * Generate the "view message" SQLite query, given a projection from UnifiedEmail * * @param uiProjection as passed from UnifiedEmail * @return the SQLite query to be executed on the EmailProvider database */ private MessageQuery genQueryViewMessage(String[] uiProjection, String id) { Context context = getContext(); long messageId = Long.parseLong(id); Message msg = Message.restoreMessageWithId(context, messageId); ContentValues values = new ContentValues(); String attachmentJson = null; if (msg != null) { if (msg.mFlagLoaded == Message.FLAG_LOADED_PARTIAL) { EmailServiceProxy service = EmailServiceUtils.getServiceForAccount(context, null, msg.mAccountKey); try { service.loadMore(messageId); } catch (RemoteException e) { // Nothing to do } } Body body = Body.restoreBodyWithMessageId(context, messageId); if (body != null) { if (body.mHtmlContent != null) { if (IMG_TAG_START_REGEX.matcher(body.mHtmlContent).find()) { values.put(UIProvider.MessageColumns.EMBEDS_EXTERNAL_RESOURCES, 1); } } } Address[] fromList = Address.unpack(msg.mFrom); int autoShowImages = 0; Preferences prefs = Preferences.getPreferences(context); for (Address sender : fromList) { String email = sender.getAddress(); if (prefs.shouldShowImagesFor(email)) { autoShowImages = 1; break; } } values.put(UIProvider.MessageColumns.ALWAYS_SHOW_IMAGES, autoShowImages); // Add attachments... Attachment[] atts = Attachment.restoreAttachmentsWithMessageId(context, messageId); if (atts.length > 0) { ArrayList uiAtts = new ArrayList(); for (Attachment att : atts) { if (att.mContentId != null && att.mContentUri != null) { continue; } com.android.mail.providers.Attachment uiAtt = new com.android.mail.providers.Attachment(); uiAtt.name = att.mFileName; uiAtt.contentType = att.mMimeType; uiAtt.size = (int) att.mSize; uiAtt.uri = uiUri("uiattachment", att.mId); uiAtts.add(uiAtt); } values.put(UIProvider.MessageColumns.ATTACHMENTS, "@?"); // @ for literal attachmentJson = com.android.mail.providers.Attachment.toJSONArray(uiAtts); } if (msg.mDraftInfo != 0) { values.put(UIProvider.MessageColumns.APPEND_REF_MESSAGE_CONTENT, (msg.mDraftInfo & Message.DRAFT_INFO_APPEND_REF_MESSAGE) != 0 ? 1 : 0); values.put(UIProvider.MessageColumns.QUOTE_START_POS, msg.mDraftInfo & Message.DRAFT_INFO_QUOTE_POS_MASK); } } if ((msg.mFlags & Message.FLAG_INCOMING_MEETING_INVITE) != 0) { values.put(UIProvider.MessageColumns.EVENT_INTENT_URI, "content://ui.email2.android.com/event/" + msg.mId); } StringBuilder sb = genSelect(sMessageViewMap, uiProjection, values); sb.append(" FROM " + Message.TABLE_NAME + "," + Body.TABLE_NAME + " WHERE " + Body.MESSAGE_KEY + "=" + Message.TABLE_NAME + "." + Message.RECORD_ID + " AND " + Message.TABLE_NAME + "." + Message.RECORD_ID + "=?"); String sql = sb.toString(); return new MessageQuery(sql, attachmentJson); } /** * Generate the "message list" SQLite query, given a projection from UnifiedEmail * * @param uiProjection as passed from UnifiedEmail * @return the SQLite query to be executed on the EmailProvider database */ private String genQueryMailboxMessages(String[] uiProjection) { StringBuilder sb = genSelect(sMessageListMap, uiProjection); sb.append(" FROM " + Message.TABLE_NAME + " WHERE " + Message.MAILBOX_KEY + "=? ORDER BY " + MessageColumns.TIMESTAMP + " DESC"); return sb.toString(); } /** * Generate various virtual mailbox SQLite queries, given a projection from UnifiedEmail * * @param uiProjection as passed from UnifiedEmail * @param id the id of the virtual mailbox * @return the SQLite query to be executed on the EmailProvider database */ private Cursor getVirtualMailboxMessagesCursor(SQLiteDatabase db, String[] uiProjection, long mailboxId) { ContentValues values = new ContentValues(); values.put(UIProvider.ConversationColumns.COLOR, CONVERSATION_COLOR); StringBuilder sb = genSelect(sMessageListMap, uiProjection, values); if (isCombinedMailbox(mailboxId)) { switch (getVirtualMailboxType(mailboxId)) { case Mailbox.TYPE_INBOX: sb.append(" FROM " + Message.TABLE_NAME + " WHERE " + MessageColumns.MAILBOX_KEY + " IN (SELECT " + MailboxColumns.ID + " FROM " + Mailbox.TABLE_NAME + " WHERE " + MailboxColumns.TYPE + "=" + Mailbox.TYPE_INBOX + ") ORDER BY " + MessageColumns.TIMESTAMP + " DESC"); break; case Mailbox.TYPE_STARRED: sb.append(" FROM " + Message.TABLE_NAME + " WHERE " + MessageColumns.FLAG_FAVORITE + "=1 ORDER BY " + MessageColumns.TIMESTAMP + " DESC"); break; default: throw new IllegalArgumentException("No virtual mailbox for: " + mailboxId); } return db.rawQuery(sb.toString(), null); } else { switch (getVirtualMailboxType(mailboxId)) { case Mailbox.TYPE_STARRED: sb.append(" FROM " + Message.TABLE_NAME + " WHERE " + MessageColumns.ACCOUNT_KEY + "=? AND " + MessageColumns.FLAG_FAVORITE + "=1 ORDER BY " + MessageColumns.TIMESTAMP + " DESC"); break; default: throw new IllegalArgumentException("No virtual mailbox for: " + mailboxId); } return db.rawQuery(sb.toString(), new String[] {getVirtualMailboxAccountIdString(mailboxId)}); } } /** * Generate the "message list" SQLite query, given a projection from UnifiedEmail * * @param uiProjection as passed from UnifiedEmail * @return the SQLite query to be executed on the EmailProvider database */ private String genQueryConversation(String[] uiProjection) { StringBuilder sb = genSelect(sMessageListMap, uiProjection); sb.append(" FROM " + Message.TABLE_NAME + " WHERE " + Message.RECORD_ID + "=?"); return sb.toString(); } /** * Generate the "top level folder list" SQLite query, given a projection from UnifiedEmail * * @param uiProjection as passed from UnifiedEmail * @return the SQLite query to be executed on the EmailProvider database */ private String genQueryAccountMailboxes(String[] uiProjection) { StringBuilder sb = genSelect(sFolderListMap, uiProjection); sb.append(" FROM " + Mailbox.TABLE_NAME + " WHERE " + MailboxColumns.ACCOUNT_KEY + "=? AND " + MailboxColumns.TYPE + " < " + Mailbox.TYPE_NOT_EMAIL + " AND " + MailboxColumns.PARENT_KEY + " < 0 ORDER BY "); sb.append(MAILBOX_ORDER_BY); return sb.toString(); } /** * Generate the "all folders" SQLite query, given a projection from UnifiedEmail. The list is * sorted by the name as it appears in a hierarchical listing * * @param uiProjection as passed from UnifiedEmail * @return the SQLite query to be executed on the EmailProvider database */ private String genQueryAccountAllMailboxes(String[] uiProjection) { StringBuilder sb = genSelect(sFolderListMap, uiProjection); // Use a derived column to choose either hierarchicalName or displayName sb.append(", case when " + MailboxColumns.HIERARCHICAL_NAME + " is null then " + MailboxColumns.DISPLAY_NAME + " else " + MailboxColumns.HIERARCHICAL_NAME + " end as h_name"); // Order by the derived column sb.append(" FROM " + Mailbox.TABLE_NAME + " WHERE " + MailboxColumns.ACCOUNT_KEY + "=? AND " + MailboxColumns.TYPE + " < " + Mailbox.TYPE_NOT_EMAIL + " ORDER BY h_name"); return sb.toString(); } /** * Generate the "recent folder list" SQLite query, given a projection from UnifiedEmail * * @param uiProjection as passed from UnifiedEmail * @return the SQLite query to be executed on the EmailProvider database */ private String genQueryRecentMailboxes(String[] uiProjection) { StringBuilder sb = genSelect(sFolderListMap, uiProjection); sb.append(" FROM " + Mailbox.TABLE_NAME + " WHERE " + MailboxColumns.ACCOUNT_KEY + "=? AND " + MailboxColumns.TYPE + " < " + Mailbox.TYPE_NOT_EMAIL + " AND " + MailboxColumns.PARENT_KEY + " < 0 AND " + MailboxColumns.LAST_TOUCHED_TIME + " > 0 ORDER BY " + MailboxColumns.LAST_TOUCHED_TIME + " DESC"); return sb.toString(); } /** * Generate a "single mailbox" SQLite query, given a projection from UnifiedEmail * * @param uiProjection as passed from UnifiedEmail * @return the SQLite query to be executed on the EmailProvider database */ private String genQueryMailbox(String[] uiProjection, String id) { long mailboxId = Long.parseLong(id); ContentValues values = new ContentValues(); if (mSearchParams != null && mailboxId == mSearchParams.mSearchMailboxId) { // This is the current search mailbox; use the total count values = new ContentValues(); values.put(UIProvider.FolderColumns.TOTAL_COUNT, mSearchParams.mTotalCount); // "load more" is valid for search results values.put(UIProvider.FolderColumns.LOAD_MORE_URI, uiUriString("uiloadmore", mailboxId)); } else { Context context = getContext(); Mailbox mailbox = Mailbox.restoreMailboxWithId(context, mailboxId); // Make sure we can't get NPE if mailbox has disappeared (the result will end up moot) if (mailbox != null) { String protocol = Account.getProtocol(context, mailbox.mAccountKey); EmailServiceInfo info = EmailServiceUtils.getServiceInfo(context, protocol); // "load more" is valid for protocols not supporting "lookback" if (info != null && !info.offerLookback) { values.put(UIProvider.FolderColumns.LOAD_MORE_URI, uiUriString("uiloadmore", mailboxId)); } else { int caps = UIProvider.FolderCapabilities.SUPPORTS_SETTINGS; if ((mailbox.mFlags & Mailbox.FLAG_ACCEPTS_MOVED_MAIL) != 0) { caps |= UIProvider.FolderCapabilities.CAN_ACCEPT_MOVED_MESSAGES; } values.put(UIProvider.FolderColumns.CAPABILITIES, caps); } // For trash, we don't allow undo if (mailbox.mType == Mailbox.TYPE_TRASH) { values.put(UIProvider.FolderColumns.CAPABILITIES, UIProvider.FolderCapabilities.CAN_ACCEPT_MOVED_MESSAGES | UIProvider.FolderCapabilities.CAN_HOLD_MAIL | UIProvider.FolderCapabilities.DELETE_ACTION_FINAL); } if (isVirtualMailbox(mailboxId)) { int capa = values.getAsInteger(UIProvider.FolderColumns.CAPABILITIES); values.put(UIProvider.FolderColumns.CAPABILITIES, capa | UIProvider.FolderCapabilities.IS_VIRTUAL); } } } StringBuilder sb = genSelect(sFolderListMap, uiProjection, values); sb.append(" FROM " + Mailbox.TABLE_NAME + " WHERE " + MailboxColumns.ID + "=?"); return sb.toString(); } private static final Uri BASE_EXTERNAL_URI = Uri.parse("content://ui.email.android.com"); private static final Uri BASE_EXTERAL_URI2 = Uri.parse("content://ui.email2.android.com"); private static String getExternalUriString(String segment, String account) { return BASE_EXTERNAL_URI.buildUpon().appendPath(segment) .appendQueryParameter("account", account).build().toString(); } private static String getExternalUriStringEmail2(String segment, String account) { return BASE_EXTERAL_URI2.buildUpon().appendPath(segment) .appendQueryParameter("account", account).build().toString(); } /** * Generate a "single account" SQLite query, given a projection from UnifiedEmail * * @param uiProjection as passed from UnifiedEmail * @return the SQLite query to be executed on the EmailProvider database */ private String genQueryAccount(String[] uiProjection, String id) { ContentValues values = new ContentValues(); long accountId = Long.parseLong(id); // Get account capabilities from the service EmailServiceProxy service = EmailServiceUtils.getServiceForAccount(getContext(), mServiceCallback, accountId); int capabilities = 0; try { capabilities = service.getCapabilities(accountId); } catch (RemoteException e) { } values.put(UIProvider.AccountColumns.CAPABILITIES, capabilities); values.put(UIProvider.AccountColumns.SETTINGS_INTENT_URI, getExternalUriString("settings", id)); values.put(UIProvider.AccountColumns.COMPOSE_URI, getExternalUriStringEmail2("compose", id)); values.put(UIProvider.AccountColumns.MIME_TYPE, EMAIL_APP_MIME_TYPE); values.put(UIProvider.AccountColumns.COLOR, ACCOUNT_COLOR); Preferences prefs = Preferences.getPreferences(getContext()); values.put(UIProvider.AccountColumns.SettingsColumns.CONFIRM_DELETE, prefs.getConfirmDelete() ? "1" : "0"); values.put(UIProvider.AccountColumns.SettingsColumns.CONFIRM_SEND, prefs.getConfirmSend() ? "1" : "0"); values.put(UIProvider.AccountColumns.SettingsColumns.HIDE_CHECKBOXES, prefs.getHideCheckboxes() ? "1" : "0"); int autoAdvance = prefs.getAutoAdvanceDirection(); values.put(UIProvider.AccountColumns.SettingsColumns.AUTO_ADVANCE, autoAdvanceToUiValue(autoAdvance)); int textZoom = prefs.getTextZoom(); values.put(UIProvider.AccountColumns.SettingsColumns.MESSAGE_TEXT_SIZE, textZoomToUiValue(textZoom)); // Set default inbox, if we've got an inbox; otherwise, say initial sync needed long mailboxId = Mailbox.findMailboxOfType(getContext(), accountId, Mailbox.TYPE_INBOX); if (mailboxId != Mailbox.NO_MAILBOX) { values.put(UIProvider.AccountColumns.SettingsColumns.DEFAULT_INBOX, uiUriString("uifolder", mailboxId)); values.put(UIProvider.AccountColumns.SYNC_STATUS, UIProvider.SyncStatus.NO_SYNC); } else { values.put(UIProvider.AccountColumns.SYNC_STATUS, UIProvider.SyncStatus.INITIAL_SYNC_NEEDED); } StringBuilder sb = genSelect(sAccountListMap, uiProjection, values); sb.append(" FROM " + Account.TABLE_NAME + " WHERE " + AccountColumns.ID + "=?"); return sb.toString(); } private int autoAdvanceToUiValue(int autoAdvance) { switch(autoAdvance) { case Preferences.AUTO_ADVANCE_OLDER: return UIProvider.AutoAdvance.OLDER; case Preferences.AUTO_ADVANCE_NEWER: return UIProvider.AutoAdvance.NEWER; case Preferences.AUTO_ADVANCE_MESSAGE_LIST: default: return UIProvider.AutoAdvance.LIST; } } private int textZoomToUiValue(int textZoom) { switch(textZoom) { case Preferences.TEXT_ZOOM_HUGE: return UIProvider.MessageTextSize.HUGE; case Preferences.TEXT_ZOOM_LARGE: return UIProvider.MessageTextSize.LARGE; case Preferences.TEXT_ZOOM_NORMAL: return UIProvider.MessageTextSize.NORMAL; case Preferences.TEXT_ZOOM_SMALL: return UIProvider.MessageTextSize.SMALL; case Preferences.TEXT_ZOOM_TINY: return UIProvider.MessageTextSize.TINY; default: return UIProvider.MessageTextSize.NORMAL; } } /** * Generate a Uri string for a combined mailbox uri * @param type the uri command type (e.g. "uimessages") * @param id the id of the item (e.g. an account, mailbox, or message id) * @return a Uri string */ private static String combinedUriString(String type, String id) { return "content://" + EmailContent.AUTHORITY + "/" + type + "/" + id; } private static final long COMBINED_ACCOUNT_ID = 0x10000000; /** * Generate an id for a combined mailbox of a given type * @param type the mailbox type for the combined mailbox * @return the id, as a String */ private static String combinedMailboxId(int type) { return Long.toString(Account.ACCOUNT_ID_COMBINED_VIEW + type); } private static String getVirtualMailboxIdString(long accountId, int type) { return Long.toString(getVirtualMailboxId(accountId, type)); } private static long getVirtualMailboxId(long accountId, int type) { return (accountId << 32) + type; } private static boolean isVirtualMailbox(long mailboxId) { return mailboxId >= 0x100000000L; } private static boolean isCombinedMailbox(long mailboxId) { return (mailboxId >> 32) == COMBINED_ACCOUNT_ID; } private static long getVirtualMailboxAccountId(long mailboxId) { return mailboxId >> 32; } private static String getVirtualMailboxAccountIdString(long mailboxId) { return Long.toString(mailboxId >> 32); } private static int getVirtualMailboxType(long mailboxId) { return (int)(mailboxId & 0xF); } private void addCombinedAccountRow(MatrixCursor mc) { long id = Account.getDefaultAccountId(getContext()); if (id == Account.NO_ACCOUNT) return; String idString = Long.toString(id); Object[] values = new Object[UIProvider.ACCOUNTS_PROJECTION.length]; values[UIProvider.ACCOUNT_ID_COLUMN] = 0; values[UIProvider.ACCOUNT_CAPABILITIES_COLUMN] = AccountCapabilities.UNDO | AccountCapabilities.SENDING_UNAVAILABLE; values[UIProvider.ACCOUNT_FOLDER_LIST_URI_COLUMN] = combinedUriString("uifolders", COMBINED_ACCOUNT_ID_STRING); values[UIProvider.ACCOUNT_NAME_COLUMN] = getContext().getString( R.string.mailbox_list_account_selector_combined_view); values[UIProvider.ACCOUNT_SAVE_DRAFT_URI_COLUMN] = combinedUriString("uisavedraft", idString); values[UIProvider.ACCOUNT_SEND_MESSAGE_URI_COLUMN] = combinedUriString("uisendmail", idString); values[UIProvider.ACCOUNT_UNDO_URI_COLUMN] = "'content://" + UIProvider.AUTHORITY + "/uiundo'"; values[UIProvider.ACCOUNT_URI_COLUMN] = combinedUriString("uiaccount", COMBINED_ACCOUNT_ID_STRING); values[UIProvider.ACCOUNT_MIME_TYPE_COLUMN] = EMAIL_APP_MIME_TYPE; values[UIProvider.ACCOUNT_SETTINGS_INTENT_URI_COLUMN] = getExternalUriString("settings", COMBINED_ACCOUNT_ID_STRING); values[UIProvider.ACCOUNT_COMPOSE_INTENT_URI_COLUMN] = getExternalUriStringEmail2("compose", Long.toString(id)); // TODO: Get these from default account? Preferences prefs = Preferences.getPreferences(getContext()); values[UIProvider.ACCOUNT_SETTINGS_AUTO_ADVANCE_COLUMN] = Integer.toString(UIProvider.AutoAdvance.NEWER); values[UIProvider.ACCOUNT_SETTINGS_MESSAGE_TEXT_SIZE_COLUMN] = Integer.toString(UIProvider.MessageTextSize.NORMAL); values[UIProvider.ACCOUNT_SETTINGS_SNAP_HEADERS_COLUMN] = Integer.toString(UIProvider.SnapHeaderValue.ALWAYS); //.add(UIProvider.SettingsColumns.SIGNATURE, AccountColumns.SIGNATURE) values[UIProvider.ACCOUNT_SETTINGS_REPLY_BEHAVIOR_COLUMN] = Integer.toString(UIProvider.DefaultReplyBehavior.REPLY); values[UIProvider.ACCOUNT_SETTINGS_HIDE_CHECKBOXES_COLUMN] = 0; values[UIProvider.ACCOUNT_SETTINGS_CONFIRM_DELETE_COLUMN] = prefs.getConfirmDelete() ? 1 : 0; values[UIProvider.ACCOUNT_SETTINGS_CONFIRM_ARCHIVE_COLUMN] = 0; values[UIProvider.ACCOUNT_SETTINGS_CONFIRM_SEND_COLUMN] = prefs.getConfirmSend() ? 1 : 0; values[UIProvider.ACCOUNT_SETTINGS_HIDE_CHECKBOXES_COLUMN] = prefs.getHideCheckboxes() ? 1 : 0; values[UIProvider.ACCOUNT_SETTINGS_DEFAULT_INBOX_COLUMN] = combinedUriString("uifolder", combinedMailboxId(Mailbox.TYPE_INBOX)); mc.addRow(values); } private Cursor getVirtualMailboxCursor(long mailboxId) { MatrixCursor mc = new MatrixCursor(UIProvider.FOLDERS_PROJECTION, 1); mc.addRow(getVirtualMailboxRow(getVirtualMailboxAccountId(mailboxId), getVirtualMailboxType(mailboxId))); return mc; } private Object[] getVirtualMailboxRow(long accountId, int mailboxType) { String idString = getVirtualMailboxIdString(accountId, mailboxType); Object[] values = new Object[UIProvider.FOLDERS_PROJECTION.length]; values[UIProvider.FOLDER_ID_COLUMN] = 0; values[UIProvider.FOLDER_URI_COLUMN] = combinedUriString("uifolder", idString); values[UIProvider.FOLDER_NAME_COLUMN] = getMailboxNameForType(mailboxType); values[UIProvider.FOLDER_HAS_CHILDREN_COLUMN] = 0; values[UIProvider.FOLDER_CAPABILITIES_COLUMN] = UIProvider.FolderCapabilities.IS_VIRTUAL; values[UIProvider.FOLDER_CONVERSATION_LIST_URI_COLUMN] = combinedUriString("uimessages", idString); values[UIProvider.FOLDER_ID_COLUMN] = 0; return values; } private Cursor uiAccounts(String[] uiProjection) { Context context = getContext(); SQLiteDatabase db = getDatabase(context); Cursor accountIdCursor = db.rawQuery("select _id from " + Account.TABLE_NAME, new String[0]); int numAccounts = accountIdCursor.getCount(); boolean combinedAccount = false; if (numAccounts > 1) { combinedAccount = true; numAccounts++; } final Bundle extras = new Bundle(); // Email always returns the accurate number of accounts extras.putInt(AccountCursorExtraKeys.ACCOUNTS_LOADED, 1); final MatrixCursor mc = new MatrixCursorWithExtra(uiProjection, accountIdCursor.getCount(), extras); Object[] values = new Object[uiProjection.length]; try { if (combinedAccount) { addCombinedAccountRow(mc); } while (accountIdCursor.moveToNext()) { String id = accountIdCursor.getString(0); Cursor accountCursor = db.rawQuery(genQueryAccount(uiProjection, id), new String[] {id}); if (accountCursor.moveToNext()) { for (int i = 0; i < uiProjection.length; i++) { values[i] = accountCursor.getString(i); } mc.addRow(values); } accountCursor.close(); } } finally { accountIdCursor.close(); } mc.setNotificationUri(context.getContentResolver(), UIPROVIDER_ACCOUNTS_NOTIFIER); return mc; } /** * Generate the "attachment list" SQLite query, given a projection from UnifiedEmail * * @param uiProjection as passed from UnifiedEmail * @return the SQLite query to be executed on the EmailProvider database */ private String genQueryAttachments(String[] uiProjection) { StringBuilder sb = genSelect(sAttachmentMap, uiProjection); sb.append(" FROM " + Attachment.TABLE_NAME + " WHERE " + AttachmentColumns.MESSAGE_KEY + " =? "); return sb.toString(); } /** * Generate the "single attachment" SQLite query, given a projection from UnifiedEmail * * @param uiProjection as passed from UnifiedEmail * @return the SQLite query to be executed on the EmailProvider database */ private String genQueryAttachment(String[] uiProjection) { StringBuilder sb = genSelect(sAttachmentMap, uiProjection); sb.append(" FROM " + Attachment.TABLE_NAME + " WHERE " + AttachmentColumns.ID + " =? "); return sb.toString(); } /** * Generate the "subfolder list" SQLite query, given a projection from UnifiedEmail * * @param uiProjection as passed from UnifiedEmail * @return the SQLite query to be executed on the EmailProvider database */ private String genQuerySubfolders(String[] uiProjection) { StringBuilder sb = genSelect(sFolderListMap, uiProjection); sb.append(" FROM " + Mailbox.TABLE_NAME + " WHERE " + MailboxColumns.PARENT_KEY + " =? ORDER BY "); sb.append(MAILBOX_ORDER_BY); return sb.toString(); } private static final String COMBINED_ACCOUNT_ID_STRING = Long.toString(COMBINED_ACCOUNT_ID); /** * Returns a cursor over all the folders for a specific URI which corresponds to a single * account. * @param uri * @param uiProjection * @return */ private Cursor uiFolders(Uri uri, String[] uiProjection) { Context context = getContext(); SQLiteDatabase db = getDatabase(context); String id = uri.getPathSegments().get(1); if (id.equals(COMBINED_ACCOUNT_ID_STRING)) { MatrixCursor mc = new MatrixCursor(UIProvider.FOLDERS_PROJECTION, 2); Object[] row = getVirtualMailboxRow(COMBINED_ACCOUNT_ID, Mailbox.TYPE_INBOX); int numUnread = EmailContent.count(context, Message.CONTENT_URI, MessageColumns.MAILBOX_KEY + " IN (SELECT " + MailboxColumns.ID + " FROM " + Mailbox.TABLE_NAME + " WHERE " + MailboxColumns.TYPE + "=" + Mailbox.TYPE_INBOX + ") AND " + MessageColumns.FLAG_READ + "=0", null); row[UIProvider.FOLDER_UNREAD_COUNT_COLUMN] = numUnread; mc.addRow(row); int numStarred = EmailContent.count(context, Message.CONTENT_URI, MessageColumns.FLAG_FAVORITE + "=1", null); if (numStarred > 0) { row = getVirtualMailboxRow(COMBINED_ACCOUNT_ID, Mailbox.TYPE_STARRED); row[UIProvider.FOLDER_UNREAD_COUNT_COLUMN] = numStarred; mc.addRow(row); } return mc; } else { Cursor c = db.rawQuery(genQueryAccountMailboxes(uiProjection), new String[] {id}); int numStarred = EmailContent.count(context, Message.CONTENT_URI, MessageColumns.ACCOUNT_KEY + "=? AND " + MessageColumns.FLAG_FAVORITE + "=1", new String[] {id}); if (numStarred == 0) { return c; } else { // Add starred virtual folder to the cursor // Show number of messages as unread count (for backward compatibility) MatrixCursor starCursor = new MatrixCursor(uiProjection, 1); Object[] row = getVirtualMailboxRow(Long.parseLong(id), Mailbox.TYPE_STARRED); row[UIProvider.FOLDER_UNREAD_COUNT_COLUMN] = numStarred; row[UIProvider.FOLDER_ICON_RES_ID_COLUMN] = R.drawable.ic_menu_star_holo_light; starCursor.addRow(row); Cursor[] cursors = new Cursor[] {starCursor, c}; return new MergeCursor(cursors); } } } /** * Returns an array of the default recent folders for a given URI which is unique for an * account. Some accounts might not have default recent folders, in which case an empty array * is returned. * @param id * @return */ private Uri[] defaultRecentFolders(final String id) { final SQLiteDatabase db = getDatabase(getContext()); if (id.equals(COMBINED_ACCOUNT_ID_STRING)) { // We don't have default recents for the combined view. return new Uri[0]; } // We search for the types we want, and find corresponding IDs. final String[] idAndType = { BaseColumns._ID, UIProvider.FolderColumns.TYPE }; // Sent, Drafts, and Starred are the default recents. final StringBuilder sb = genSelect(sFolderListMap, idAndType); sb.append(" FROM " + Mailbox.TABLE_NAME + " WHERE " + MailboxColumns.ACCOUNT_KEY + " = " + id + " AND " + MailboxColumns.TYPE + " IN (" + Mailbox.TYPE_SENT + ", " + Mailbox.TYPE_DRAFTS + ", " + Mailbox.TYPE_STARRED + ")"); LogUtils.d(TAG, "defaultRecentFolders: Query is %s", sb); final Cursor c = db.rawQuery(sb.toString(), null); if (c == null || c.getCount() <= 0 || !c.moveToFirst()) { return new Uri[0]; } // Read all the IDs of the mailboxes, and turn them into URIs. final Uri[] recentFolders = new Uri[c.getCount()]; int i = 0; do { final long folderId = c.getLong(0); recentFolders[i] = uiUri("uifolder", folderId); LogUtils.d(TAG, "Default recent folder: %d, with uri %s", folderId, recentFolders[i]); ++i; } while (c.moveToNext()); return recentFolders; } /** * Wrapper that handles the visibility feature (i.e. the conversation list is visible, so * any pending notifications for the corresponding mailbox should be canceled) */ static class VisibilityCursor extends CursorWrapper { private final long mMailboxId; private final Context mContext; public VisibilityCursor(Context context, Cursor cursor, long mailboxId) { super(cursor); mMailboxId = mailboxId; mContext = context; } @Override public Bundle respond(Bundle params) { final String setVisibilityKey = UIProvider.ConversationCursorCommand.COMMAND_KEY_SET_VISIBILITY; if (params.containsKey(setVisibilityKey)) { final boolean visible = params.getBoolean(setVisibilityKey); if (visible) { NotificationController.getInstance(mContext).cancelNewMessageNotification( mMailboxId); } } // Return success Bundle response = new Bundle(); response.putString(setVisibilityKey, UIProvider.ConversationCursorCommand.COMMAND_RESPONSE_OK); return response; } } /** * Handle UnifiedEmail queries here (dispatched from query()) * * @param match the UriMatcher match for the original uri passed in from UnifiedEmail * @param uri the original uri passed in from UnifiedEmail * @param uiProjection the projection passed in from UnifiedEmail * @return the result Cursor */ private Cursor uiQuery(int match, Uri uri, String[] uiProjection) { Context context = getContext(); ContentResolver resolver = context.getContentResolver(); SQLiteDatabase db = getDatabase(context); // Should we ever return null, or throw an exception?? Cursor c = null; String id = uri.getPathSegments().get(1); Uri notifyUri = null; switch(match) { case UI_ALL_FOLDERS: c = db.rawQuery(genQueryAccountAllMailboxes(uiProjection), new String[] {id}); break; case UI_RECENT_FOLDERS: c = db.rawQuery(genQueryRecentMailboxes(uiProjection), new String[] {id}); notifyUri = UIPROVIDER_RECENT_FOLDERS_NOTIFIER.buildUpon().appendPath(id).build(); break; case UI_SUBFOLDERS: c = db.rawQuery(genQuerySubfolders(uiProjection), new String[] {id}); break; case UI_MESSAGES: long mailboxId = Long.parseLong(id); if (isVirtualMailbox(mailboxId)) { c = getVirtualMailboxMessagesCursor(db, uiProjection, mailboxId); } else { c = db.rawQuery(genQueryMailboxMessages(uiProjection), new String[] {id}); } notifyUri = UIPROVIDER_CONVERSATION_NOTIFIER.buildUpon().appendPath(id).build(); c = new VisibilityCursor(context, c, mailboxId); break; case UI_MESSAGE: MessageQuery qq = genQueryViewMessage(uiProjection, id); String sql = qq.query; String attJson = qq.attachmentJson; // With attachments, we have another argument to bind if (attJson != null) { c = db.rawQuery(sql, new String[] {attJson, id}); } else { c = db.rawQuery(sql, new String[] {id}); } break; case UI_ATTACHMENTS: c = db.rawQuery(genQueryAttachments(uiProjection), new String[] {id}); notifyUri = UIPROVIDER_ATTACHMENTS_NOTIFIER.buildUpon().appendPath(id).build(); break; case UI_ATTACHMENT: c = db.rawQuery(genQueryAttachment(uiProjection), new String[] {id}); notifyUri = UIPROVIDER_ATTACHMENT_NOTIFIER.buildUpon().appendPath(id).build(); break; case UI_FOLDER: mailboxId = Long.parseLong(id); if (isVirtualMailbox(mailboxId)) { c = getVirtualMailboxCursor(mailboxId); } else { c = db.rawQuery(genQueryMailbox(uiProjection, id), new String[] {id}); notifyUri = UIPROVIDER_FOLDER_NOTIFIER.buildUpon().appendPath(id).build(); } break; case UI_ACCOUNT: if (id.equals(COMBINED_ACCOUNT_ID_STRING)) { MatrixCursor mc = new MatrixCursor(UIProvider.ACCOUNTS_PROJECTION, 1); addCombinedAccountRow(mc); c = mc; } else { c = db.rawQuery(genQueryAccount(uiProjection, id), new String[] {id}); } notifyUri = UIPROVIDER_ACCOUNT_NOTIFIER.buildUpon().appendPath(id).build(); break; case UI_CONVERSATION: c = db.rawQuery(genQueryConversation(uiProjection), new String[] {id}); break; } if (notifyUri != null) { c.setNotificationUri(resolver, notifyUri); } return c; } /** * Convert a UIProvider attachment to an EmailProvider attachment (for sending); we only need * a few of the fields * @param uiAtt the UIProvider attachment to convert * @return the EmailProvider attachment */ private Attachment convertUiAttachmentToAttachment( com.android.mail.providers.Attachment uiAtt) { Attachment att = new Attachment(); att.mContentUri = uiAtt.contentUri.toString(); att.mFileName = uiAtt.name; att.mMimeType = uiAtt.contentType; att.mSize = uiAtt.size; return att; } private String getMailboxNameForType(int mailboxType) { Context context = getContext(); int resId; switch (mailboxType) { case Mailbox.TYPE_INBOX: resId = R.string.mailbox_name_server_inbox; break; case Mailbox.TYPE_OUTBOX: resId = R.string.mailbox_name_server_outbox; break; case Mailbox.TYPE_DRAFTS: resId = R.string.mailbox_name_server_drafts; break; case Mailbox.TYPE_TRASH: resId = R.string.mailbox_name_server_trash; break; case Mailbox.TYPE_SENT: resId = R.string.mailbox_name_server_sent; break; case Mailbox.TYPE_JUNK: resId = R.string.mailbox_name_server_junk; break; case Mailbox.TYPE_STARRED: resId = R.string.widget_starred; break; default: throw new IllegalArgumentException("Illegal mailbox type"); } return context.getString(resId); } /** * Create a mailbox given the account and mailboxType. */ private Mailbox createMailbox(long accountId, int mailboxType) { Context context = getContext(); Mailbox box = Mailbox.newSystemMailbox(accountId, mailboxType, getMailboxNameForType(mailboxType)); // Make sure drafts and save will show up in recents... // If these already exist (from old Email app), they will have touch times switch (mailboxType) { case Mailbox.TYPE_DRAFTS: box.mLastTouchedTime = Mailbox.DRAFTS_DEFAULT_TOUCH_TIME; break; case Mailbox.TYPE_SENT: box.mLastTouchedTime = Mailbox.SENT_DEFAULT_TOUCH_TIME; break; } box.save(context); return box; } /** * Given an account name and a mailbox type, return that mailbox, creating it if necessary * @param accountName the account name to use * @param mailboxType the type of mailbox we're trying to find * @return the mailbox of the given type for the account in the uri, or null if not found */ private Mailbox getMailboxByAccountIdAndType(String accountId, int mailboxType) { long id = Long.parseLong(accountId); Mailbox mailbox = Mailbox.restoreMailboxOfType(getContext(), id, mailboxType); if (mailbox == null) { mailbox = createMailbox(id, mailboxType); } return mailbox; } private Message getMessageFromPathSegments(List pathSegments) { Message msg = null; if (pathSegments.size() > 2) { msg = Message.restoreMessageWithId(getContext(), Long.parseLong(pathSegments.get(2))); } if (msg == null) { msg = new Message(); } return msg; } /** * Given a mailbox and the content values for a message, create/save the message in the mailbox * @param mailbox the mailbox to use * @param values the content values that represent message fields * @return the uri of the newly created message */ private Uri uiSaveMessage(Message msg, Mailbox mailbox, ContentValues values) { Context context = getContext(); // Fill in the message Account account = Account.restoreAccountWithId(context, mailbox.mAccountKey); if (account == null) return null; msg.mFrom = account.mEmailAddress; msg.mTimeStamp = System.currentTimeMillis(); msg.mTo = values.getAsString(UIProvider.MessageColumns.TO); msg.mCc = values.getAsString(UIProvider.MessageColumns.CC); msg.mBcc = values.getAsString(UIProvider.MessageColumns.BCC); msg.mSubject = values.getAsString(UIProvider.MessageColumns.SUBJECT); msg.mText = values.getAsString(UIProvider.MessageColumns.BODY_TEXT); msg.mHtml = values.getAsString(UIProvider.MessageColumns.BODY_HTML); msg.mMailboxKey = mailbox.mId; msg.mAccountKey = mailbox.mAccountKey; msg.mDisplayName = msg.mTo; msg.mFlagLoaded = Message.FLAG_LOADED_COMPLETE; msg.mFlagRead = true; Integer quoteStartPos = values.getAsInteger(UIProvider.MessageColumns.QUOTE_START_POS); msg.mQuotedTextStartPos = quoteStartPos == null ? 0 : quoteStartPos; int flags = 0; int draftType = values.getAsInteger(UIProvider.MessageColumns.DRAFT_TYPE); switch(draftType) { case DraftType.FORWARD: flags |= Message.FLAG_TYPE_FORWARD; break; case DraftType.REPLY_ALL: flags |= Message.FLAG_TYPE_REPLY_ALL; // Fall through case DraftType.REPLY: flags |= Message.FLAG_TYPE_REPLY; break; case DraftType.COMPOSE: flags |= Message.FLAG_TYPE_ORIGINAL; break; } msg.mFlags = flags; int draftInfo = 0; if (values.containsKey(UIProvider.MessageColumns.QUOTE_START_POS)) { draftInfo = values.getAsInteger(UIProvider.MessageColumns.QUOTE_START_POS); if (values.getAsInteger(UIProvider.MessageColumns.APPEND_REF_MESSAGE_CONTENT) != 0) { draftInfo |= Message.DRAFT_INFO_APPEND_REF_MESSAGE; } } msg.mDraftInfo = draftInfo; String ref = values.getAsString(UIProvider.MessageColumns.REF_MESSAGE_ID); if (ref != null && msg.mQuotedTextStartPos > 0) { String refId = Uri.parse(ref).getLastPathSegment(); try { long sourceKey = Long.parseLong(refId); msg.mSourceKey = sourceKey; } catch (NumberFormatException e) { // This will be zero; the default } } // Get attachments from the ContentValues List uiAtts = com.android.mail.providers.Attachment.fromJSONArray( values.getAsString(UIProvider.MessageColumns.JOINED_ATTACHMENT_INFOS)); ArrayList atts = new ArrayList(); boolean hasUnloadedAttachments = false; for (com.android.mail.providers.Attachment uiAtt: uiAtts) { Uri attUri = uiAtt.uri; if (attUri != null && attUri.getAuthority().equals(EmailContent.AUTHORITY)) { // If it's one of ours, retrieve the attachment and add it to the list long attId = Long.parseLong(attUri.getLastPathSegment()); Attachment att = Attachment.restoreAttachmentWithId(context, attId); if (att != null) { // We must clone the attachment into a new one for this message; easiest to // use a parcel here Parcel p = Parcel.obtain(); att.writeToParcel(p, 0); p.setDataPosition(0); Attachment attClone = new Attachment(p); p.recycle(); // Clear the messageKey (this is going to be a new attachment) attClone.mMessageKey = 0; // If we're sending this, it's not loaded, and we're not smart forwarding // add the download flag, so that ADS will start up if (mailbox.mType == Mailbox.TYPE_OUTBOX && att.mContentUri == null && ((account.mFlags & Account.FLAGS_SUPPORTS_SMART_FORWARD) == 0)) { attClone.mFlags |= Attachment.FLAG_DOWNLOAD_FORWARD; hasUnloadedAttachments = true; } atts.add(attClone); } } else { // Convert external attachment to one of ours and add to the list atts.add(convertUiAttachmentToAttachment(uiAtt)); } } if (!atts.isEmpty()) { msg.mAttachments = atts; msg.mFlagAttachment = true; if (hasUnloadedAttachments) { Utility.showToast(context, R.string.message_view_attachment_background_load); } } // Save it or update it... if (!msg.isSaved()) { msg.save(context); } else { // This is tricky due to how messages/attachments are saved; rather than putz with // what's changed, we'll delete/re-add them ArrayList ops = new ArrayList(); // Delete all existing attachments ops.add(ContentProviderOperation.newDelete( ContentUris.withAppendedId(Attachment.MESSAGE_ID_URI, msg.mId)) .build()); // Delete the body ops.add(ContentProviderOperation.newDelete(Body.CONTENT_URI) .withSelection(Body.MESSAGE_KEY + "=?", new String[] {Long.toString(msg.mId)}) .build()); // Add the ops for the message, atts, and body msg.addSaveOps(ops); // Do it! try { applyBatch(ops); } catch (OperationApplicationException e) { } } if (mailbox.mType == Mailbox.TYPE_OUTBOX) { EmailServiceProxy service = EmailServiceUtils.getServiceForAccount(context, mServiceCallback, mailbox.mAccountKey); try { service.startSync(mailbox.mId, true); } catch (RemoteException e) { } long originalMsgId = msg.mSourceKey; if (originalMsgId != 0) { Message originalMsg = Message.restoreMessageWithId(context, originalMsgId); // If the original message exists, set its forwarded/replied to flags if (originalMsg != null) { ContentValues cv = new ContentValues(); flags = originalMsg.mFlags; switch(draftType) { case DraftType.FORWARD: flags |= Message.FLAG_FORWARDED; break; case DraftType.REPLY_ALL: case DraftType.REPLY: flags |= Message.FLAG_REPLIED_TO; break; } cv.put(Message.FLAGS, flags); context.getContentResolver().update(ContentUris.withAppendedId( Message.CONTENT_URI, originalMsgId), cv, null, null); } } } return uiUri("uimessage", msg.mId); } /** * Create and send the message via the account indicated in the uri * @param uri the incoming uri * @param values the content values that represent message fields * @return the uri of the created message */ private Uri uiSendMail(Uri uri, ContentValues values) { List pathSegments = uri.getPathSegments(); Mailbox mailbox = getMailboxByAccountIdAndType(pathSegments.get(1), Mailbox.TYPE_OUTBOX); if (mailbox == null) return null; Message msg = getMessageFromPathSegments(pathSegments); try { return uiSaveMessage(msg, mailbox, values); } finally { // Kick observers getContext().getContentResolver().notifyChange(Mailbox.CONTENT_URI, null); } } /** * Create a message and save it to the drafts folder of the account indicated in the uri * @param uri the incoming uri * @param values the content values that represent message fields * @return the uri of the created message */ private Uri uiSaveDraft(Uri uri, ContentValues values) { List pathSegments = uri.getPathSegments(); Mailbox mailbox = getMailboxByAccountIdAndType(pathSegments.get(1), Mailbox.TYPE_DRAFTS); if (mailbox == null) return null; Message msg = getMessageFromPathSegments(pathSegments); return uiSaveMessage(msg, mailbox, values); } private int uiUpdateDraft(Uri uri, ContentValues values) { Context context = getContext(); Message msg = Message.restoreMessageWithId(context, Long.parseLong(uri.getPathSegments().get(1))); if (msg == null) return 0; Mailbox mailbox = Mailbox.restoreMailboxWithId(context, msg.mMailboxKey); if (mailbox == null) return 0; uiSaveMessage(msg, mailbox, values); return 1; } private int uiSendDraft(Uri uri, ContentValues values) { Context context = getContext(); Message msg = Message.restoreMessageWithId(context, Long.parseLong(uri.getPathSegments().get(1))); if (msg == null) return 0; long mailboxId = Mailbox.findMailboxOfType(context, msg.mAccountKey, Mailbox.TYPE_OUTBOX); if (mailboxId == Mailbox.NO_MAILBOX) return 0; Mailbox mailbox = Mailbox.restoreMailboxWithId(context, mailboxId); if (mailbox == null) return 0; uiSaveMessage(msg, mailbox, values); // Kick observers context.getContentResolver().notifyChange(Mailbox.CONTENT_URI, null); return 1; } private void putIntegerLongOrBoolean(ContentValues values, String columnName, Object value) { if (value instanceof Integer) { Integer intValue = (Integer)value; values.put(columnName, intValue); } else if (value instanceof Boolean) { Boolean boolValue = (Boolean)value; values.put(columnName, boolValue ? 1 : 0); } else if (value instanceof Long) { Long longValue = (Long)value; values.put(columnName, longValue); } } /** * Update the timestamps for the folders specified and notifies on the recent folder URI. * @param folders * @return number of folders updated */ private int updateTimestamp(final Context context, String id, Uri[] folders){ int updated = 0; final long now = System.currentTimeMillis(); final ContentResolver resolver = context.getContentResolver(); final ContentValues touchValues = new ContentValues(); for (int i=0, size=folders.length; i < size; ++i) { touchValues.put(MailboxColumns.LAST_TOUCHED_TIME, now); LogUtils.d(TAG, "updateStamp: %s updated", folders[i]); updated += resolver.update(folders[i], touchValues, null, null); } final Uri toNotify = UIPROVIDER_RECENT_FOLDERS_NOTIFIER.buildUpon().appendPath(id).build(); LogUtils.d(TAG, "updateTimestamp: Notifying on %s", toNotify); resolver.notifyChange(toNotify, null); return updated; } /** * Updates the recent folders. The values to be updated are specified as ContentValues pairs * of (Folder URI, access timestamp). Returns nonzero if successful, always. * @param uri * @param values * @return nonzero value always. */ private int uiUpdateRecentFolders(Uri uri, ContentValues values) { final int numFolders = values.size(); final String id = uri.getPathSegments().get(1); final Uri[] folders = new Uri[numFolders]; final Context context = getContext(); final NotificationController controller = NotificationController.getInstance(context); int i = 0; for (final String uriString: values.keySet()) { folders[i] = Uri.parse(uriString); try { final String mailboxIdString = folders[i].getLastPathSegment(); final long mailboxId = Long.parseLong(mailboxIdString); controller.cancelNewMessageNotification(mailboxId); } catch (NumberFormatException e) { // Keep on going... } } return updateTimestamp(context, id, folders); } /** * Populates the recent folders according to the design. * @param uri * @return the number of recent folders were populated. */ private int uiPopulateRecentFolders(Uri uri) { final Context context = getContext(); final String id = uri.getLastPathSegment(); final Uri[] recentFolders = defaultRecentFolders(id); final int numFolders = recentFolders.length; if (numFolders <= 0) { return 0; } final int rowsUpdated = updateTimestamp(context, id, recentFolders); LogUtils.d(TAG, "uiPopulateRecentFolders: %d folders changed", rowsUpdated); return rowsUpdated; } private int uiUpdateAttachment(Uri uri, ContentValues uiValues) { Integer stateValue = uiValues.getAsInteger(UIProvider.AttachmentColumns.STATE); if (stateValue != null) { // This is a command from UIProvider long attachmentId = Long.parseLong(uri.getLastPathSegment()); Context context = getContext(); Attachment attachment = Attachment.restoreAttachmentWithId(context, attachmentId); if (attachment == null) { // Went away; ah, well... return 0; } ContentValues values = new ContentValues(); switch (stateValue.intValue()) { case UIProvider.AttachmentState.NOT_SAVED: // Set state, try to cancel request values.put(AttachmentColumns.UI_STATE, stateValue); values.put(AttachmentColumns.FLAGS, attachment.mFlags &= ~Attachment.FLAG_DOWNLOAD_USER_REQUEST); attachment.update(context, values); return 1; case UIProvider.AttachmentState.DOWNLOADING: // Set state and destination; request download values.put(AttachmentColumns.UI_STATE, stateValue); Integer destinationValue = uiValues.getAsInteger(UIProvider.AttachmentColumns.DESTINATION); values.put(AttachmentColumns.UI_DESTINATION, destinationValue == null ? 0 : destinationValue); values.put(AttachmentColumns.FLAGS, attachment.mFlags | Attachment.FLAG_DOWNLOAD_USER_REQUEST); attachment.update(context, values); return 1; case UIProvider.AttachmentState.SAVED: // If this is an inline attachment, notify message has changed if (!TextUtils.isEmpty(attachment.mContentId)) { notifyUI(UIPROVIDER_MESSAGE_NOTIFIER, attachment.mMessageKey); } return 1; } } return 0; } private int uiUpdateFolder(Uri uri, ContentValues uiValues) { Uri ourUri = convertToEmailProviderUri(uri, Mailbox.CONTENT_URI, true); if (ourUri == null) return 0; ContentValues ourValues = new ContentValues(); // This should only be called via update to "recent folders" for (String columnName: uiValues.keySet()) { if (columnName.equals(MailboxColumns.LAST_TOUCHED_TIME)) { ourValues.put(MailboxColumns.LAST_TOUCHED_TIME, uiValues.getAsLong(columnName)); } } return update(ourUri, ourValues, null, null); } private ContentValues convertUiMessageValues(Message message, ContentValues values) { ContentValues ourValues = new ContentValues(); for (String columnName: values.keySet()) { Object val = values.get(columnName); if (columnName.equals(UIProvider.ConversationColumns.STARRED)) { putIntegerLongOrBoolean(ourValues, MessageColumns.FLAG_FAVORITE, val); } else if (columnName.equals(UIProvider.ConversationColumns.READ)) { putIntegerLongOrBoolean(ourValues, MessageColumns.FLAG_READ, val); } else if (columnName.equals(MessageColumns.MAILBOX_KEY)) { putIntegerLongOrBoolean(ourValues, MessageColumns.MAILBOX_KEY, val); } else if (columnName.equals(UIProvider.ConversationColumns.FOLDER_LIST)) { // Convert from folder list uri to mailbox key Uri uri = Uri.parse((String)val); Long mailboxId = Long.parseLong(uri.getLastPathSegment()); putIntegerLongOrBoolean(ourValues, MessageColumns.MAILBOX_KEY, mailboxId); } else if (columnName.equals(UIProvider.ConversationColumns.RAW_FOLDERS)) { // Ignore; this is updated by the FOLDER_LIST update above. } else if (columnName.equals(UIProvider.MessageColumns.ALWAYS_SHOW_IMAGES)) { Address[] fromList = Address.unpack(message.mFrom); Preferences prefs = Preferences.getPreferences(getContext()); for (Address sender : fromList) { String email = sender.getAddress(); prefs.setSenderAsTrusted(email); } } else { throw new IllegalArgumentException("Can't update " + columnName + " in message"); } } return ourValues; } private Uri convertToEmailProviderUri(Uri uri, Uri newBaseUri, boolean asProvider) { String idString = uri.getLastPathSegment(); try { long id = Long.parseLong(idString); Uri ourUri = ContentUris.withAppendedId(newBaseUri, id); if (asProvider) { ourUri = ourUri.buildUpon().appendQueryParameter(IS_UIPROVIDER, "true").build(); } return ourUri; } catch (NumberFormatException e) { return null; } } private Message getMessageFromLastSegment(Uri uri) { long messageId = Long.parseLong(uri.getLastPathSegment()); return Message.restoreMessageWithId(getContext(), messageId); } /** * Add an undo operation for the current sequence; if the sequence is newer than what we've had, * clear out the undo list and start over * @param uri the uri we're working on * @param op the ContentProviderOperation to perform upon undo */ private void addToSequence(Uri uri, ContentProviderOperation op) { String sequenceString = uri.getQueryParameter(UIProvider.SEQUENCE_QUERY_PARAMETER); if (sequenceString != null) { int sequence = Integer.parseInt(sequenceString); if (sequence > mLastSequence) { // Reset sequence mLastSequenceOps.clear(); mLastSequence = sequence; } // TODO: Need something to indicate a change isn't ready (undoable) mLastSequenceOps.add(op); } } // TODO: This should depend on flags on the mailbox... private boolean uploadsToServer(Context context, Mailbox m) { if (m.mType == Mailbox.TYPE_DRAFTS || m.mType == Mailbox.TYPE_OUTBOX || m.mType == Mailbox.TYPE_SEARCH) { return false; } String protocol = Account.getProtocol(context, m.mAccountKey); EmailServiceInfo info = EmailServiceUtils.getServiceInfo(context, protocol); return (info != null && info.syncChanges); } private int uiUpdateMessage(Uri uri, ContentValues values) { Context context = getContext(); Message msg = getMessageFromLastSegment(uri); if (msg == null) return 0; Mailbox mailbox = Mailbox.restoreMailboxWithId(context, msg.mMailboxKey); if (mailbox == null) return 0; Uri ourBaseUri = uploadsToServer(context, mailbox) ? Message.SYNCED_CONTENT_URI : Message.CONTENT_URI; Uri ourUri = convertToEmailProviderUri(uri, ourBaseUri, true); if (ourUri == null) return 0; // Special case - meeting response if (values.containsKey(UIProvider.MessageOperations.RESPOND_COLUMN)) { EmailServiceProxy service = EmailServiceUtils.getServiceForAccount(context, mServiceCallback, mailbox.mAccountKey); try { service.sendMeetingResponse(msg.mId, values.getAsInteger(UIProvider.MessageOperations.RESPOND_COLUMN)); // Delete the message immediately uiDeleteMessage(uri); Utility.showToast(context, R.string.confirm_response); // Notify box has changed so the deletion is reflected in the UI notifyUIConversationMailbox(mailbox.mId); } catch (RemoteException e) { } return 1; } ContentValues undoValues = new ContentValues(); ContentValues ourValues = convertUiMessageValues(msg, values); for (String columnName: ourValues.keySet()) { if (columnName.equals(MessageColumns.MAILBOX_KEY)) { undoValues.put(MessageColumns.MAILBOX_KEY, msg.mMailboxKey); } else if (columnName.equals(MessageColumns.FLAG_READ)) { undoValues.put(MessageColumns.FLAG_READ, msg.mFlagRead); } else if (columnName.equals(MessageColumns.FLAG_FAVORITE)) { undoValues.put(MessageColumns.FLAG_FAVORITE, msg.mFlagFavorite); } } if (undoValues == null || undoValues.size() == 0) { return -1; } ContentProviderOperation op = ContentProviderOperation.newUpdate(convertToEmailProviderUri( uri, ourBaseUri, false)) .withValues(undoValues) .build(); addToSequence(uri, op); return update(ourUri, ourValues, null, null); } public static final String PICKER_UI_ACCOUNT = "picker_ui_account"; public static final String PICKER_MAILBOX_TYPE = "picker_mailbox_type"; public static final String PICKER_MESSAGE_ID = "picker_message_id"; private int uiDeleteMessage(Uri uri) { final Context context = getContext(); Message msg = getMessageFromLastSegment(uri); if (msg == null) return 0; Mailbox mailbox = Mailbox.restoreMailboxWithId(context, msg.mMailboxKey); if (mailbox == null) return 0; if (mailbox.mType == Mailbox.TYPE_TRASH || mailbox.mType == Mailbox.TYPE_DRAFTS) { // We actually delete these, including attachments AttachmentUtilities.deleteAllAttachmentFiles(context, msg.mAccountKey, msg.mId); notifyUI(UIPROVIDER_FOLDER_NOTIFIER, mailbox.mId); return context.getContentResolver().delete( ContentUris.withAppendedId(Message.SYNCED_CONTENT_URI, msg.mId), null, null); } Mailbox trashMailbox = Mailbox.restoreMailboxOfType(context, msg.mAccountKey, Mailbox.TYPE_TRASH); if (trashMailbox == null) { return 0; } ContentValues values = new ContentValues(); values.put(MessageColumns.MAILBOX_KEY, trashMailbox.mId); notifyUI(UIPROVIDER_FOLDER_NOTIFIER, mailbox.mId); return uiUpdateMessage(uri, values); } private int pickTrashFolder(Uri uri) { Context context = getContext(); Long acctId = Long.parseLong(uri.getLastPathSegment()); // For push imap, for example, we want the user to select the trash mailbox Cursor ac = query(uiUri("uiaccount", acctId), UIProvider.ACCOUNTS_PROJECTION, null, null, null); try { if (ac.moveToFirst()) { final com.android.mail.providers.Account uiAccount = new com.android.mail.providers.Account(ac); Intent intent = new Intent(context, FolderPickerActivity.class); intent.putExtra(PICKER_UI_ACCOUNT, uiAccount); intent.putExtra(PICKER_MAILBOX_TYPE, Mailbox.TYPE_TRASH); intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); context.startActivity(intent); return 1; } return 0; } finally { ac.close(); } } private Cursor uiUndo(String[] projection) { // First see if we have any operations saved // TODO: Make sure seq matches if (!mLastSequenceOps.isEmpty()) { try { // TODO Always use this projection? Or what's passed in? // Not sure if UI wants it, but I'm making a cursor of convo uri's MatrixCursor c = new MatrixCursor( new String[] {UIProvider.ConversationColumns.URI}, mLastSequenceOps.size()); for (ContentProviderOperation op: mLastSequenceOps) { c.addRow(new String[] {op.getUri().toString()}); } // Just apply the batch and we're done! applyBatch(mLastSequenceOps); // But clear the operations mLastSequenceOps.clear(); // Tell the UI there are changes ContentResolver resolver = getContext().getContentResolver(); resolver.notifyChange(UIPROVIDER_CONVERSATION_NOTIFIER, null); resolver.notifyChange(UIPROVIDER_FOLDER_NOTIFIER, null); return c; } catch (OperationApplicationException e) { } } return new MatrixCursor(projection, 0); } private void notifyUIConversation(Uri uri) { String id = uri.getLastPathSegment(); Message msg = Message.restoreMessageWithId(getContext(), Long.parseLong(id)); if (msg != null) { notifyUIConversationMailbox(msg.mMailboxKey); } } /** * Notify about the Mailbox id passed in * @param id the Mailbox id to be notified */ private void notifyUIConversationMailbox(long id) { notifyUI(UIPROVIDER_CONVERSATION_NOTIFIER, Long.toString(id)); Mailbox mailbox = Mailbox.restoreMailboxWithId(getContext(), id); // Notify combined inbox... if (mailbox.mType == Mailbox.TYPE_INBOX) { notifyUI(UIPROVIDER_CONVERSATION_NOTIFIER, EmailProvider.combinedMailboxId(Mailbox.TYPE_INBOX)); } notifyWidgets(id); } private void notifyUI(Uri uri, String id) { Uri notifyUri = uri.buildUpon().appendPath(id).build(); getContext().getContentResolver().notifyChange(notifyUri, null); } private void notifyUI(Uri uri, long id) { notifyUI(uri, Long.toString(id)); } /** * Support for services and service notifications */ private final IEmailServiceCallback.Stub mServiceCallback = new IEmailServiceCallback.Stub() { @Override public void syncMailboxListStatus(long accountId, int statusCode, int progress) throws RemoteException { } @Override public void syncMailboxStatus(long mailboxId, int statusCode, int progress) throws RemoteException { // We'll get callbacks here from the services, which we'll pass back to the UI Uri uri = ContentUris.withAppendedId(FOLDER_STATUS_URI, mailboxId); EmailProvider.this.getContext().getContentResolver().notifyChange(uri, null); } @Override public void loadAttachmentStatus(long messageId, long attachmentId, int statusCode, int progress) throws RemoteException { } @Override public void sendMessageStatus(long messageId, String subject, int statusCode, int progress) throws RemoteException { } @Override public void loadMessageStatus(long messageId, int statusCode, int progress) throws RemoteException { } }; private Cursor uiFolderRefresh(Uri uri) { Context context = getContext(); String idString = uri.getLastPathSegment(); long id = Long.parseLong(idString); Mailbox mailbox = Mailbox.restoreMailboxWithId(context, id); if (mailbox == null) return null; EmailServiceProxy service = EmailServiceUtils.getServiceForAccount(context, mServiceCallback, mailbox.mAccountKey); try { service.startSync(id, true); } catch (RemoteException e) { } return null; } //Number of additional messages to load when a user selects "Load more..." in POP/IMAP boxes public static final int VISIBLE_LIMIT_INCREMENT = 10; //Number of additional messages to load when a user selects "Load more..." in a search public static final int SEARCH_MORE_INCREMENT = 10; private Cursor uiFolderLoadMore(Uri uri) { Context context = getContext(); String idString = uri.getLastPathSegment(); long id = Long.parseLong(idString); Mailbox mailbox = Mailbox.restoreMailboxWithId(context, id); if (mailbox == null) return null; if (mailbox.mType == Mailbox.TYPE_SEARCH) { // Ask for 10 more messages mSearchParams.mOffset += SEARCH_MORE_INCREMENT; runSearchQuery(context, mailbox.mAccountKey, id); } else { ContentValues values = new ContentValues(); values.put(EmailContent.FIELD_COLUMN_NAME, MailboxColumns.VISIBLE_LIMIT); values.put(EmailContent.ADD_COLUMN_NAME, VISIBLE_LIMIT_INCREMENT); Uri mailboxUri = ContentUris.withAppendedId(Mailbox.ADD_TO_FIELD_URI, id); // Increase the limit context.getContentResolver().update(mailboxUri, values, null, null); // And order a refresh uiFolderRefresh(uri); } return null; } private static final String SEARCH_MAILBOX_SERVER_ID = "__search_mailbox__"; private SearchParams mSearchParams; /** * Returns the search mailbox for the specified account, creating one if necessary * @return the search mailbox for the passed in account */ private Mailbox getSearchMailbox(long accountId) { Context context = getContext(); Mailbox m = Mailbox.restoreMailboxOfType(context, accountId, Mailbox.TYPE_SEARCH); if (m == null) { m = new Mailbox(); m.mAccountKey = accountId; m.mServerId = SEARCH_MAILBOX_SERVER_ID; m.mFlagVisible = false; m.mDisplayName = SEARCH_MAILBOX_SERVER_ID; m.mSyncInterval = Mailbox.CHECK_INTERVAL_NEVER; m.mType = Mailbox.TYPE_SEARCH; m.mFlags = Mailbox.FLAG_HOLDS_MAIL; m.mParentKey = Mailbox.NO_MAILBOX; m.save(context); } return m; } private void runSearchQuery(final Context context, final long accountId, final long searchMailboxId) { // Start the search running in the background new Thread(new Runnable() { @Override public void run() { try { EmailServiceProxy service = EmailServiceUtils.getServiceForAccount(context, mServiceCallback, accountId); if (service != null) { try { // Save away the total count mSearchParams.mTotalCount = service.searchMessages(accountId, mSearchParams, searchMailboxId); //Log.d(TAG, "TotalCount to UI: " + mSearchParams.mTotalCount); notifyUI(UIPROVIDER_FOLDER_NOTIFIER, searchMailboxId); } catch (RemoteException e) { Log.e("searchMessages", "RemoteException", e); } } } finally { } }}).start(); } // TODO: Handle searching for more... private Cursor uiSearch(Uri uri, String[] projection) { final long accountId = Long.parseLong(uri.getLastPathSegment()); // TODO: Check the actual mailbox Mailbox inbox = Mailbox.restoreMailboxOfType(getContext(), accountId, Mailbox.TYPE_INBOX); if (inbox == null) return null; String filter = uri.getQueryParameter(UIProvider.SearchQueryParameters.QUERY); if (filter == null) { throw new IllegalArgumentException("No query parameter in search query"); } // Find/create our search mailbox Mailbox searchMailbox = getSearchMailbox(accountId); final long searchMailboxId = searchMailbox.mId; mSearchParams = new SearchParams(inbox.mId, filter, searchMailboxId); final Context context = getContext(); if (mSearchParams.mOffset == 0) { // Delete existing contents of search mailbox ContentResolver resolver = context.getContentResolver(); resolver.delete(Message.CONTENT_URI, Message.MAILBOX_KEY + "=" + searchMailboxId, null); ContentValues cv = new ContentValues(); // For now, use the actual query as the name of the mailbox cv.put(Mailbox.DISPLAY_NAME, mSearchParams.mFilter); resolver.update(ContentUris.withAppendedId(Mailbox.CONTENT_URI, searchMailboxId), cv, null, null); } // Start the search running in the background runSearchQuery(context, accountId, searchMailboxId); // This will look just like a "normal" folder return uiQuery(UI_FOLDER, ContentUris.withAppendedId(Mailbox.CONTENT_URI, searchMailbox.mId), projection); } private static final String MAILBOXES_FOR_ACCOUNT_SELECTION = MailboxColumns.ACCOUNT_KEY + "=?"; private static final String MAILBOXES_FOR_ACCOUNT_EXCEPT_ACCOUNT_MAILBOX_SELECTION = MAILBOXES_FOR_ACCOUNT_SELECTION + " AND " + MailboxColumns.TYPE + "!=" + Mailbox.TYPE_EAS_ACCOUNT_MAILBOX; private static final String MESSAGES_FOR_ACCOUNT_SELECTION = MessageColumns.ACCOUNT_KEY + "=?"; /** * Delete an account and clean it up */ private int uiDeleteAccount(Uri uri) { Context context = getContext(); long accountId = Long.parseLong(uri.getLastPathSegment()); try { // Get the account URI. final Account account = Account.restoreAccountWithId(context, accountId); if (account == null) { return 0; // Already deleted? } deleteAccountData(context, accountId); // Now delete the account itself uri = ContentUris.withAppendedId(Account.CONTENT_URI, accountId); context.getContentResolver().delete(uri, null, null); // Clean up AccountBackupRestore.backup(context); SecurityPolicy.getInstance(context).reducePolicies(); MailActivityEmail.setServicesEnabledSync(context); return 1; } catch (Exception e) { Log.w(Logging.LOG_TAG, "Exception while deleting account", e); } return 0; } private int uiDeleteAccountData(Uri uri) { Context context = getContext(); long accountId = Long.parseLong(uri.getLastPathSegment()); // Get the account URI. final Account account = Account.restoreAccountWithId(context, accountId); if (account == null) { return 0; // Already deleted? } deleteAccountData(context, accountId); return 1; } private void deleteAccountData(Context context, long accountId) { // Delete synced attachments AttachmentUtilities.deleteAllAccountAttachmentFiles(context, accountId); // Delete synced email, leaving only an empty inbox. We do this in two phases: // 1. Delete all non-inbox mailboxes (which will delete all of their messages) // 2. Delete all remaining messages (which will be the inbox messages) ContentResolver resolver = context.getContentResolver(); String[] accountIdArgs = new String[] { Long.toString(accountId) }; resolver.delete(Mailbox.CONTENT_URI, MAILBOXES_FOR_ACCOUNT_EXCEPT_ACCOUNT_MAILBOX_SELECTION, accountIdArgs); resolver.delete(Message.CONTENT_URI, MESSAGES_FOR_ACCOUNT_SELECTION, accountIdArgs); // Delete sync keys on remaining items ContentValues cv = new ContentValues(); cv.putNull(Account.SYNC_KEY); resolver.update(Account.CONTENT_URI, cv, Account.ID_SELECTION, accountIdArgs); cv.clear(); cv.putNull(Mailbox.SYNC_KEY); resolver.update(Mailbox.CONTENT_URI, cv, MAILBOXES_FOR_ACCOUNT_SELECTION, accountIdArgs); // Delete PIM data (contacts, calendar), stop syncs, etc. if applicable IEmailService service = EmailServiceUtils.getServiceForAccount(context, null, accountId); if (service != null) { try { service.deleteAccountPIMData(accountId); } catch (RemoteException e) { // Can't do anything about this } } } private int[] mSavedWidgetIds = new int[0]; private ArrayList mWidgetNotifyMailboxes = new ArrayList(); private AppWidgetManager mAppWidgetManager; private ComponentName mEmailComponent; private void notifyWidgets(long mailboxId) { Context context = getContext(); // Lazily initialize these if (mAppWidgetManager == null) { mAppWidgetManager = AppWidgetManager.getInstance(context); mEmailComponent = new ComponentName(context, WidgetProvider.PROVIDER_NAME); } // See if we have to populate our array of mailboxes used in widgets int[] widgetIds = mAppWidgetManager.getAppWidgetIds(mEmailComponent); if (!Arrays.equals(widgetIds, mSavedWidgetIds)) { mSavedWidgetIds = widgetIds; String[][] widgetInfos = BaseWidgetProvider.getWidgetInfo(context, widgetIds); // widgetInfo now has pairs of account uri/folder uri mWidgetNotifyMailboxes.clear(); for (String[] widgetInfo: widgetInfos) { try { if (widgetInfo == null) continue; long id = Long.parseLong(Uri.parse(widgetInfo[1]).getLastPathSegment()); if (!isCombinedMailbox(id)) { // For a regular mailbox, just add it to the list if (!mWidgetNotifyMailboxes.contains(id)) { mWidgetNotifyMailboxes.add(id); } } else { switch (getVirtualMailboxType(id)) { // We only handle the combined inbox in widgets case Mailbox.TYPE_INBOX: Cursor c = query(Mailbox.CONTENT_URI, Mailbox.ID_PROJECTION, MailboxColumns.TYPE + "=?", new String[] {Integer.toString(Mailbox.TYPE_INBOX)}, null); try { while (c.moveToNext()) { mWidgetNotifyMailboxes.add( c.getLong(Mailbox.ID_PROJECTION_COLUMN)); } } finally { c.close(); } break; } } } catch (NumberFormatException e) { // Move along } } } // If our mailbox needs to be notified, do so... if (mWidgetNotifyMailboxes.contains(mailboxId)) { Intent intent = new Intent(Utils.ACTION_NOTIFY_DATASET_CHANGED); intent.putExtra(Utils.EXTRA_FOLDER_URI, uiUri("uifolder", mailboxId)); intent.setType(EMAIL_APP_MIME_TYPE); context.sendBroadcast(intent); } } }