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 <jorge@ruesga.com>
This commit is contained in:
Jorge Ruesga 2015-03-01 22:02:40 +01:00 committed by Steve Kondik
parent fb2b538c54
commit 8210da8b50
4 changed files with 272 additions and 8 deletions

View File

@ -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";
}
}

View File

@ -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");
}
}

View File

@ -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) {

View File

@ -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<String> TABLE_NAMES;
static {
SparseArray<String> array = new SparseArray<String>(11);
SparseArray<String> array = new SparseArray<String>(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<SyncRequestMessage> mDelayedSyncRequests = new HashSet<SyncRequestMessage>();
@ -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<Address> 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 };