From 8210da8b5023c6e9abe77cea6428c4c1339c0b02 Mon Sep 17 00:00:00 2001 From: Jorge Ruesga Date: Sun, 1 Mar 2015 22:02:40 +0100 Subject: [PATCH] email: suggested contacts This change adds support for suggested contacts (email addresses not in the contact provider and received via email). The implementation creates a new separate "extras" database (to avoid conflicts with future aosp changes). In the table SuggestedContacts are stored every email address present in every email inserted in the database. This allow to display this contacts in the RecipientEditTextView when compose an email. Suggested contacts are selected by account (only those ones received by that account). This features is opt-out by default, but it can be activated in general settings by choosing the suggested contact mode: * none: Not active * recents: Those received within the last 7 days * all: All the suggested contacts Change-Id: I156c3b1e2c4e4cff985a2183bc72b805bd596f3b Signed-off-by: Jorge Ruesga --- .../emailcommon/provider/EmailContent.java | 9 + .../provider/SuggestedContact.java | 48 +++++ .../com/android/email/provider/DBHelper.java | 48 +++++ .../android/email/provider/EmailProvider.java | 175 +++++++++++++++++- 4 files changed, 272 insertions(+), 8 deletions(-) create mode 100644 emailcommon/src/com/android/emailcommon/provider/SuggestedContact.java diff --git a/emailcommon/src/com/android/emailcommon/provider/EmailContent.java b/emailcommon/src/com/android/emailcommon/provider/EmailContent.java index 745a161e0..f1fcb0dcf 100755 --- a/emailcommon/src/com/android/emailcommon/provider/EmailContent.java +++ b/emailcommon/src/com/android/emailcommon/provider/EmailContent.java @@ -196,6 +196,7 @@ public abstract class EmailContent { MessageStateChange.init(); Body.initBody(); Attachment.initAttachment(); + SuggestedContact.initSuggestedContact(); } } @@ -1853,4 +1854,12 @@ public abstract class EmailContent { public static final String PROTOCOL_POLICIES_ENFORCED = "protocolPoliciesEnforced"; public static final String PROTOCOL_POLICIES_UNSUPPORTED = "protocolPoliciesUnsupported"; } + + public interface SuggestedContactColumns extends BaseColumns { + static final String ACCOUNT_KEY = "accountKey"; + static final String ADDRESS = "address"; + static final String NAME = "name"; + static final String DISPLAY_NAME = "display_name"; + static final String LAST_SEEN = "last_seen"; + } } diff --git a/emailcommon/src/com/android/emailcommon/provider/SuggestedContact.java b/emailcommon/src/com/android/emailcommon/provider/SuggestedContact.java new file mode 100644 index 000000000..ef46d9cfb --- /dev/null +++ b/emailcommon/src/com/android/emailcommon/provider/SuggestedContact.java @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2014 The CyanogenMod 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.emailcommon.provider; + +import android.net.Uri; +import android.provider.BaseColumns; + +import com.android.emailcommon.provider.EmailContent.SuggestedContactColumns; + +/** + * A suggested contact extracted from sent and received emails to be displayed when the user + * compose a message. Tied to a specific account. + */ +public abstract class SuggestedContact extends EmailContent + implements SuggestedContactColumns { + public static final String TABLE_NAME = "SuggestedContact"; + public static Uri CONTENT_URI; + public static Uri ACCOUNT_ID_URI; + + public static final String[] PROJECTION = new String[] { + SuggestedContact._ID, + SuggestedContact.ACCOUNT_KEY, + SuggestedContact.ADDRESS, + SuggestedContact.NAME, + SuggestedContact.DISPLAY_NAME, + SuggestedContact.LAST_SEEN, + }; + + public static void initSuggestedContact() { + CONTENT_URI = Uri.parse(EmailContent.CONTENT_URI + "/suggestedcontact"); + ACCOUNT_ID_URI = Uri.parse(EmailContent.CONTENT_URI + "/suggestedcontact/account"); + } +} \ No newline at end of file diff --git a/provider_src/com/android/email/provider/DBHelper.java b/provider_src/com/android/email/provider/DBHelper.java index f6a0f2de3..63262a5ec 100644 --- a/provider_src/com/android/email/provider/DBHelper.java +++ b/provider_src/com/android/email/provider/DBHelper.java @@ -48,6 +48,7 @@ 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.QuickResponseColumns; +import com.android.emailcommon.provider.EmailContent.SuggestedContactColumns; import com.android.emailcommon.provider.EmailContent.SyncColumns; import com.android.emailcommon.provider.HostAuth; import com.android.emailcommon.provider.Mailbox; @@ -56,6 +57,7 @@ import com.android.emailcommon.provider.MessageMove; import com.android.emailcommon.provider.MessageStateChange; import com.android.emailcommon.provider.Policy; import com.android.emailcommon.provider.QuickResponse; +import com.android.emailcommon.provider.SuggestedContact; import com.android.emailcommon.service.LegacyPolicySet; import com.android.emailcommon.service.SyncWindow; import com.android.mail.providers.UIProvider; @@ -198,6 +200,11 @@ public final class DBHelper { // Version 101: Move body contents to external files public static final int BODY_DATABASE_VERSION = 101; + // Any changes to the database format *must* include update-in-place code. + // Original version: 1 + // Version 1: Suggested contacts + public static final int EXTRAS_DATABASE_VERSION = 1; + /* * Internal helper method for index creation. * Example: @@ -686,6 +693,24 @@ public final class DBHelper { db.execSQL(createIndex(Body.TABLE_NAME, BodyColumns.MESSAGE_KEY)); } + private static void createSuggestedContactTable(SQLiteDatabase db) { + String s = " (" + SuggestedContactColumns._ID + " integer primary key autoincrement, " + + SuggestedContactColumns.ACCOUNT_KEY + " integer, " + + SuggestedContactColumns.ADDRESS + " text, " + + SuggestedContactColumns.NAME + " text, " + + SuggestedContactColumns.DISPLAY_NAME + " text, " + + SuggestedContactColumns.LAST_SEEN + " integer" + + ");"; + db.execSQL("create table " + SuggestedContact.TABLE_NAME + s); + + // Create a unique index for account-address + String indexDDL = "create unique index " + SuggestedContact.TABLE_NAME.toLowerCase() + + "_account_address" + " on " + SuggestedContact.TABLE_NAME + + " (" + SuggestedContactColumns.ACCOUNT_KEY + ", " + + SuggestedContactColumns.ADDRESS + ");"; + db.execSQL(indexDDL); + } + private static void upgradeBodyToVersion5(final SQLiteDatabase db) { try { db.execSQL("drop table " + Body.TABLE_NAME); @@ -824,6 +849,29 @@ public final class DBHelper { } } + protected static class ExtrasDatabaseHelper extends SQLiteOpenHelper { + final Context mContext; + + ExtrasDatabaseHelper(Context context, String name) { + super(context, name, null, EXTRAS_DATABASE_VERSION); + mContext = context; + } + + @Override + public void onCreate(SQLiteDatabase db) { + LogUtils.d(TAG, "Creating EmailProviderExtras database"); + createSuggestedContactTable(db); + } + + @Override + public void onUpgrade(final SQLiteDatabase db, final int oldVersion, final int newVersion) { + } + + @Override + public void onOpen(SQLiteDatabase db) { + } + } + /** Counts the number of messages in each mailbox, and updates the message count column. */ @VisibleForTesting static void recalculateMessageCount(SQLiteDatabase db) { diff --git a/provider_src/com/android/email/provider/EmailProvider.java b/provider_src/com/android/email/provider/EmailProvider.java index 4bd9d4d19..ee5fe860a 100644 --- a/provider_src/com/android/email/provider/EmailProvider.java +++ b/provider_src/com/android/email/provider/EmailProvider.java @@ -42,6 +42,7 @@ import android.database.CursorWrapper; import android.database.DatabaseUtils; import android.database.MatrixCursor; import android.database.MergeCursor; +import android.database.SQLException; import android.database.sqlite.SQLiteDatabase; import android.database.sqlite.SQLiteException; import android.database.sqlite.SQLiteStatement; @@ -100,6 +101,7 @@ import com.android.emailcommon.provider.MessageMove; import com.android.emailcommon.provider.MessageStateChange; import com.android.emailcommon.provider.Policy; import com.android.emailcommon.provider.QuickResponse; +import com.android.emailcommon.provider.SuggestedContact; import com.android.emailcommon.service.EmailServiceProxy; import com.android.emailcommon.service.EmailServiceStatus; import com.android.emailcommon.service.IEmailService; @@ -162,6 +164,7 @@ public class EmailProvider extends ContentProvider // exposed for testing public static final String DATABASE_NAME = "EmailProvider.db"; public static final String BODY_DATABASE_NAME = "EmailProviderBody.db"; + public static final String EXTRAS_DATABASE_NAME = "EmailProviderExtras.db"; // We don't back up to the backup database anymore, just keep this constant here so we can // delete the old backups and trigger a new backup to the account manager @@ -287,11 +290,15 @@ public class EmailProvider extends ContentProvider private static final int CREDENTIAL = CREDENTIAL_BASE; private static final int CREDENTIAL_ID = CREDENTIAL_BASE + 1; + private static final int SUGGESTED_CONTACT_BASE = 0xC000; + private static final int SUGGESTED_CONTACT= SUGGESTED_CONTACT_BASE; + private static final int SUGGESTED_CONTACT_ID = SUGGESTED_CONTACT_BASE + 1; + private static final int BASE_SHIFT = 12; // 12 bits to the base type: 0, 0x1000, 0x2000, etc. private static final SparseArray TABLE_NAMES; static { - SparseArray array = new SparseArray(11); + SparseArray array = new SparseArray(12); array.put(ACCOUNT_BASE >> BASE_SHIFT, Account.TABLE_NAME); array.put(MAILBOX_BASE >> BASE_SHIFT, Mailbox.TABLE_NAME); array.put(MESSAGE_BASE >> BASE_SHIFT, Message.TABLE_NAME); @@ -304,6 +311,7 @@ public class EmailProvider extends ContentProvider array.put(UI_BASE >> BASE_SHIFT, null); array.put(BODY_BASE >> BASE_SHIFT, Body.TABLE_NAME); array.put(CREDENTIAL_BASE >> BASE_SHIFT, Credential.TABLE_NAME); + array.put(SUGGESTED_CONTACT_BASE >> BASE_SHIFT, SuggestedContact.TABLE_NAME); TABLE_NAMES = array; } @@ -384,6 +392,7 @@ public class EmailProvider extends ContentProvider private SQLiteDatabase mDatabase; private SQLiteDatabase mBodyDatabase; + private SQLiteDatabase mExtrasDatabase; private Handler mDelayedSyncHandler; private final Set mDelayedSyncRequests = new HashSet(); @@ -494,6 +503,13 @@ public class EmailProvider extends ContentProvider String bodyFileName = mBodyDatabase.getPath(); mDatabase.execSQL("attach \"" + bodyFileName + "\" as BodyDatabase"); } + DBHelper.ExtrasDatabaseHelper extrasHelper = + new DBHelper.ExtrasDatabaseHelper(context, EXTRAS_DATABASE_NAME); + mExtrasDatabase = extrasHelper.getWritableDatabase(); + if (mExtrasDatabase != null) { + String extrasFileName = mExtrasDatabase.getPath(); + mDatabase.execSQL("attach \"" + extrasFileName + "\" as ExtrasDatabase"); + } // Restore accounts if the database is corrupted... restoreIfNeeded(context, mDatabase); @@ -575,6 +591,10 @@ public class EmailProvider extends ContentProvider mBodyDatabase.close(); mBodyDatabase = null; } + if (mExtrasDatabase != null) { + mExtrasDatabase.close(); + mExtrasDatabase = null; + } } // exposed for testing @@ -706,6 +726,7 @@ public class EmailProvider extends ContentProvider case POLICY_ID: case QUICK_RESPONSE_ID: case CREDENTIAL_ID: + case SUGGESTED_CONTACT_ID: id = uri.getPathSegments().get(1); if (match == SYNCED_MESSAGE_ID) { // For synced messages, first copy the old message to the deleted table and @@ -727,6 +748,11 @@ public class EmailProvider extends ContentProvider if (match == ACCOUNT_ID) { notifyUI(UIPROVIDER_ACCOUNT_NOTIFIER, id); notifyUI(UIPROVIDER_ALL_ACCOUNTS_NOTIFIER, null); + + // Delete account suggested contacts + mExtrasDatabase.delete(SuggestedContact.TABLE_NAME, + SuggestedContact.ACCOUNT_KEY + " = ?", new String[]{id}); + } else if (match == MAILBOX_ID) { notifyUIFolder(id, accountId); } else if (match == ATTACHMENT_ID) { @@ -750,7 +776,13 @@ public class EmailProvider extends ContentProvider case ACCOUNT: case HOSTAUTH: case POLICY: + case SUGGESTED_CONTACT: result = db.delete(tableName, selection, selectionArgs); + if (match == ACCOUNT) { + // TODO extract account deleted + // As a fallback clean all suggested contacts + mExtrasDatabase.delete(SuggestedContact.TABLE_NAME, null, null); + } break; case MESSAGE_MOVE: db.delete(MessageMove.TABLE_NAME, selection, selectionArgs); @@ -849,6 +881,10 @@ public class EmailProvider extends ContentProvider return "vnd.android.cursor.dir/email-hostauth"; case HOSTAUTH_ID: return "vnd.android.cursor.item/email-hostauth"; + case SUGGESTED_CONTACT: + return "vnd.android.cursor.item/email-suggested-contact"; + case SUGGESTED_CONTACT_ID: + return "vnd.android.cursor.dir/email-suggested-contact"; case ATTACHMENTS_CACHED_FILE_ACCESS: { SQLiteDatabase db = getDatabase(getContext()); Cursor c = db.query(Attachment.TABLE_NAME, MIME_TYPE_PROJECTION, @@ -887,7 +923,7 @@ public class EmailProvider extends ContentProvider private static Uri UIPROVIDER_RECENT_FOLDERS_NOTIFIER; @Override - public Uri insert(Uri uri, ContentValues values) { + public Uri insert(Uri uri, final ContentValues values) { Log.d(TAG, "Insert: " + uri); final int match = findMatch(uri, "insert"); final Context context = getContext(); @@ -935,6 +971,20 @@ public class EmailProvider extends ContentProvider case DELETED_MESSAGE: case MESSAGE: decodeEmailAddresses(values); + + // Update the suggested contacts of this email in the background + if (!MailPrefs.get(context).getSuggestedContactMode().equals( + MailPrefs.SuggestedContactsMode.NONE)) { + new Thread(new Runnable() { + @Override + public void run() { + if(match == MESSAGE) { + addOrUpdateSuggestedContactsFromHeaders(values); + } + } + }).start(); + } + case ATTACHMENT: case MAILBOX: case ACCOUNT: @@ -1233,14 +1283,19 @@ public class EmailProvider extends ContentProvider sURIMatcher.addURI(EmailContent.AUTHORITY, "pickSentFolder/#", ACCOUNT_PICK_SENT_FOLDER); sURIMatcher.addURI(EmailContent.AUTHORITY, "uipurgefolder/#", UI_PURGE_FOLDER); + + // Suggested Contact + sURIMatcher.addURI(EmailContent.AUTHORITY, "suggestedcontact", SUGGESTED_CONTACT); + sURIMatcher.addURI(EmailContent.AUTHORITY, "suggestedcontact/#", SUGGESTED_CONTACT_ID); } } /** - * 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. + * The idea here is that the three databases (EmailProvider.db, EmailProviderBody.db + * and EmailProviderExtras.db must always be in sync (i.e. there are three 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() { synchronized (sDatabaseLock) { @@ -1251,18 +1306,33 @@ public class EmailProvider extends ContentProvider if (mBodyDatabase != null) { mBodyDatabase = null; } + if (mExtrasDatabase != null) { + mExtrasDatabase = null; + } // Look for orphans, and delete as necessary; these must always be in sync final File databaseFile = getContext().getDatabasePath(DATABASE_NAME); final File bodyFile = getContext().getDatabasePath(BODY_DATABASE_NAME); + final File extrasFile = getContext().getDatabasePath(BODY_DATABASE_NAME); // TODO Make sure attachments are deleted - if (databaseFile.exists() && !bodyFile.exists()) { + boolean mainDbExists = databaseFile.exists(); + boolean bodyDbExists = bodyFile.exists(); + boolean extrasDbExists = extrasFile.exists(); + boolean extrasDbShouldExists = DBHelper.EXTRAS_DATABASE_VERSION <= 1; + if (mainDbExists && (!bodyDbExists || (!extrasDbExists && extrasDbShouldExists))) { LogUtils.w(TAG, "Deleting orphaned EmailProvider database..."); getContext().deleteDatabase(DATABASE_NAME); - } else if (bodyFile.exists() && !databaseFile.exists()) { + } + if (bodyDbExists && (!mainDbExists || (!extrasDbExists && extrasDbShouldExists))) { LogUtils.w(TAG, "Deleting orphaned EmailProviderBody database..."); getContext().deleteDatabase(BODY_DATABASE_NAME); } + if (extrasDbExists && (!mainDbExists || !bodyDbExists)) { + if (DBHelper.EXTRAS_DATABASE_VERSION > 1) { + LogUtils.w(TAG, "Deleting orphaned EmailProviderExtras database..."); + getContext().deleteDatabase(EXTRAS_DATABASE_NAME); + } + } } } @@ -1291,6 +1361,7 @@ public class EmailProvider extends ContentProvider case HOSTAUTH_ID: case CREDENTIAL_ID: case POLICY_ID: + case SUGGESTED_CONTACT_ID: return new MatrixCursorWithCachedColumns(projection, 0); } } @@ -1464,6 +1535,15 @@ public class EmailProvider extends ContentProvider id = uri.getPathSegments().get(2); c = uiQuickResponseAccount(projection, id); break; + case SUGGESTED_CONTACT: + c = db.query(tableName, projection, + selection, selectionArgs, null, null, sortOrder, limit); + break; + case SUGGESTED_CONTACT_ID: + id = uri.getPathSegments().get(1); + c = db.query(tableName, projection, whereWithId(id, selection), + selectionArgs, null, null, sortOrder, limit); + break; case ATTACHMENTS_CACHED_FILE_ACCESS: if (projection == null) { projection = @@ -6089,6 +6169,85 @@ public class EmailProvider extends ContentProvider } } + /** + * This method extract the address of a new email to insert in the database + * and extract and update he suggested contact table with this addresses. + */ + private void addOrUpdateSuggestedContactsFromHeaders(ContentValues values) { + List
suggestedContacts = new ArrayList<>(); + + Long accountId = values.getAsLong(MessageColumns.ACCOUNT_KEY); + if (accountId == null) { + // Ignore the entire content. We don't have enough information to + // update the suggested contact + return; + } + + if (values.containsKey(Message.MessageColumns.TO_LIST)) { + final String to = values.getAsString(Message.MessageColumns.TO_LIST); + suggestedContacts.addAll(Arrays.asList(Address.fromHeader(to))); + } + + if (values.containsKey(Message.MessageColumns.CC_LIST)) { + final String cc = values.getAsString(Message.MessageColumns.CC_LIST); + suggestedContacts.addAll(Arrays.asList(Address.fromHeader(cc))); + } + + if (values.containsKey(Message.MessageColumns.BCC_LIST)) { + final String bcc = values.getAsString(Message.MessageColumns.BCC_LIST); + suggestedContacts.addAll(Arrays.asList(Address.fromHeader(bcc))); + } + + if (values.containsKey(Message.MessageColumns.REPLY_TO_LIST)) { + final String replyTo = values.getAsString(Message.MessageColumns.REPLY_TO_LIST); + suggestedContacts.addAll(Arrays.asList(Address.fromHeader(replyTo))); + } + + // Update or insert every suggested contact + mExtrasDatabase.beginTransactionNonExclusive(); + try { + for (Address suggestedContact : suggestedContacts) { + addOrUpdateSuggestedContact(accountId, suggestedContact); + } + mExtrasDatabase.setTransactionSuccessful(); + } finally { + mExtrasDatabase.endTransaction(); + } + } + + private void addOrUpdateSuggestedContact(long accountId, Address address) { + try { + // Update first the suggested contact, and if not exists add a new row + if (address == null) { + return; + } + + // Update + String emailAddress = address.getAddress().toLowerCase(); + String where = SuggestedContact.ACCOUNT_KEY + " = ? and " + + SuggestedContact.ADDRESS + " = ?"; + String[] args = {String.valueOf(accountId), emailAddress}; + ContentValues values = new ContentValues(); + values.put(SuggestedContact.NAME, TextUtils.isEmpty(address.getPersonal()) + ? emailAddress : address.getPersonal()); + values.put(SuggestedContact.DISPLAY_NAME, address.toString()); + values.put(SuggestedContact.LAST_SEEN, System.currentTimeMillis()); + long affectedRecords = mExtrasDatabase.update( + SuggestedContact.TABLE_NAME, values, where, args); + + // Insert + if (affectedRecords == 0) { + values.put(SuggestedContact.ACCOUNT_KEY, accountId); + values.put(SuggestedContact.ADDRESS, emailAddress); + mExtrasDatabase.insertOrThrow(SuggestedContact.TABLE_NAME, null, values); + } + + } catch (SQLException ex) { + Log.w(TAG, "Failed to insert/update suggested contact address: " + + String.valueOf(address), ex); + } + } + /** Projection used for getting email address for an account. */ private static final String[] ACCOUNT_EMAIL_PROJECTION = { AccountColumns.EMAIL_ADDRESS };