Handle viewing of attachments that are, themselves, emails

* Add intent filter for application/eml and message/rfc822 mime types,
  launching MessageView with a Uri
* Modify loadMessageTask to handle the Uri by parsing the attachment's
  input stream with Pop3Message.parse(), and then creating an
  EmailProvider message in a special Mailbox created to hold
  "attachment" messages
* Delete all "attachment" messages after the parent message is closed
* Add unit tests

Change-Id: I20276ee006b9f05b889f3c808d3dc407cde26d49
This commit is contained in:
Marc Blank 2010-05-04 15:33:08 -07:00
parent 8ade2fe010
commit 391ae25c43
11 changed files with 302 additions and 54 deletions

View File

@ -191,6 +191,12 @@
<activity
android:name=".activity.MessageView"
android:theme="@android:style/Theme.NoTitleBar" >
<intent-filter android:label="@string/app_name">
<action android:name="android.intent.action.VIEW" />
<data android:mimeType="application/eml" />
<data android:mimeType="message/rfc822" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
<activity
android:name=".activity.MessageCompose"

View File

@ -308,6 +308,8 @@
<string name="message_saved_toast">Message saved as draft.</string>
<!-- String that is displayed when the attachment could not be displayed. -->
<string name="message_view_display_attachment_toast">This attachment cannot be displayed.</string>
<!-- String that is displayed when a long message is being parsed. -->
<string name="message_view_parse_message_toast">Opening message\u2026</string>
<!-- Title of screen when setting up new email account -->
<string name="account_setup_basics_title">Set up email</string>

View File

@ -19,6 +19,7 @@ package com.android.email;
import com.android.email.mail.AuthenticationFailedException;
import com.android.email.mail.MessagingException;
import com.android.email.mail.Store;
import com.android.email.mail.store.Pop3Store.Pop3Message;
import com.android.email.provider.AttachmentProvider;
import com.android.email.provider.EmailContent;
import com.android.email.provider.EmailContent.Account;
@ -41,6 +42,9 @@ import android.os.RemoteException;
import android.util.Log;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.util.HashSet;
import java.util.concurrent.ConcurrentHashMap;
@ -59,6 +63,17 @@ public class Controller {
private final ServiceCallback mServiceCallback = new ServiceCallback();
private final HashSet<Result> mListeners = new HashSet<Result>();
// Note that 0 is a syntactically valid account key; however there can never be an account
// with id = 0, so attempts to restore the account will return null. Null values are
// handled properly within the code, so this won't cause any issues.
private static final long ATTACHMENT_MAILBOX_ACCOUNT_KEY = 0;
/*package*/ static final String ATTACHMENT_MAILBOX_SERVER_ID = "__attachment_mailbox__";
/*package*/ static final String ATTACHMENT_MESSAGE_UID_PREFIX = "__attachment_message__";
private static final String WHERE_TYPE_ATTACHMENT =
MailboxColumns.TYPE + "=" + Mailbox.TYPE_ATTACHMENT;
private static final String WHERE_MAILBOX_KEY = MessageColumns.MAILBOX_KEY + "=?";
private static String[] MESSAGEID_TO_ACCOUNTID_PROJECTION = new String[] {
EmailContent.RECORD_ID,
EmailContent.MessageColumns.ACCOUNT_KEY
@ -83,7 +98,7 @@ public class Controller {
protected Controller(Context _context) {
mContext = _context.getApplicationContext();
mProviderContext = _context;
mLegacyController = MessagingController.getInstance(mContext);
mLegacyController = MessagingController.getInstance(mProviderContext);
mLegacyController.addListener(mLegacyListener);
}
@ -137,6 +152,89 @@ public class Controller {
}
}
/**
* Delete all Messages that live in the attachment mailbox
*/
public void deleteAttachmentMessages() {
// Note: There should only be one attachment mailbox at present
ContentResolver resolver = mProviderContext.getContentResolver();
Cursor c = null;
try {
c = resolver.query(Mailbox.CONTENT_URI, EmailContent.ID_PROJECTION,
WHERE_TYPE_ATTACHMENT, null, null);
while (c.moveToNext()) {
long mailboxId = c.getLong(EmailContent.ID_PROJECTION_COLUMN);
// Must delete attachments BEFORE messages
AttachmentProvider.deleteAllMailboxAttachmentFiles(mProviderContext, 0, mailboxId);
resolver.delete(Message.CONTENT_URI, WHERE_MAILBOX_KEY,
new String[] {Long.toString(mailboxId)});
}
} finally {
if (c != null) {
c.close();
}
}
}
/**
* Returns the attachment Mailbox (where we store eml attachment Emails), creating one
* if necessary
* @return the account's temporary Mailbox
*/
public Mailbox getAttachmentMailbox() {
Cursor c = mProviderContext.getContentResolver().query(Mailbox.CONTENT_URI,
Mailbox.CONTENT_PROJECTION, WHERE_TYPE_ATTACHMENT, null, null);
try {
if (c.moveToFirst()) {
return new Mailbox().restore(c);
}
} finally {
c.close();
}
Mailbox m = new Mailbox();
m.mAccountKey = ATTACHMENT_MAILBOX_ACCOUNT_KEY;
m.mServerId = ATTACHMENT_MAILBOX_SERVER_ID;
m.mFlagVisible = false;
m.mDisplayName = ATTACHMENT_MAILBOX_SERVER_ID;
m.mSyncInterval = Mailbox.CHECK_INTERVAL_NEVER;
m.mType = Mailbox.TYPE_ATTACHMENT;
m.save(mProviderContext);
return m;
}
/**
* Create a Message from the Uri and store it in the attachment mailbox
* @param uri the uri containing message content
* @return the Message or null
*/
public Message loadMessageFromUri(Uri uri) {
Mailbox mailbox = getAttachmentMailbox();
if (mailbox == null) return null;
try {
InputStream is = mProviderContext.getContentResolver().openInputStream(uri);
try {
// First, create a Pop3Message from the attachment and then parse it
Pop3Message pop3Message = new Pop3Message(
ATTACHMENT_MESSAGE_UID_PREFIX + System.currentTimeMillis(), null);
pop3Message.parse(is);
// Now, pull out the header fields
Message msg = new Message();
LegacyConversions.updateMessageFields(msg, pop3Message, 0, mailbox.mId);
// Commit the message to the local store
msg.save(mProviderContext);
// Setup the rest of the message and mark it completely loaded
mLegacyController.copyOneMessageToProvider(pop3Message, msg,
Message.FLAG_LOADED_COMPLETE, mProviderContext);
// Restore the complete message and return it
return Message.restoreMessageWithId(mProviderContext, msg.mId);
} catch (MessagingException e) {
} catch (IOException e) {
}
} catch (FileNotFoundException e) {
}
return null;
}
/**
* Enable/disable logging for external sync services
*

View File

@ -435,11 +435,11 @@ public class MessagingController implements Runnable {
}
}
private void saveOrUpdate(EmailContent content) {
private void saveOrUpdate(EmailContent content, Context context) {
if (content.isSaved()) {
content.update(mContext, content.toContentValues());
content.update(context, content.toContentValues());
} else {
content.save(mContext);
content.save(context);
}
}
@ -619,7 +619,7 @@ public class MessagingController implements Runnable {
LegacyConversions.updateMessageFields(localMessage,
message, account.mId, folder.mId);
// Commit the message to the local store
saveOrUpdate(localMessage);
saveOrUpdate(localMessage, mContext);
// Track the "new" ness of the downloaded message
if (!message.isSet(Flag.SEEN)) {
newMessages.add(message);
@ -916,7 +916,7 @@ public class MessagingController implements Runnable {
/**
* Copy one downloaded message (which may have partially-loaded sections)
* into a provider message
* into a newly created EmailProvider Message, given the account and mailbox
*
* @param message the remote message we've just downloaded
* @param account the account it will be stored into
@ -924,47 +924,59 @@ public class MessagingController implements Runnable {
* @param loadStatus when complete, the message will be marked with this status (e.g.
* EmailContent.Message.LOADED)
*/
private void copyOneMessageToProvider(Message message, EmailContent.Account account,
public void copyOneMessageToProvider(Message message, EmailContent.Account account,
EmailContent.Mailbox folder, int loadStatus) {
EmailContent.Message localMessage = null;
Cursor c = null;
try {
EmailContent.Message localMessage = null;
Cursor c = null;
try {
c = mContext.getContentResolver().query(
EmailContent.Message.CONTENT_URI,
EmailContent.Message.CONTENT_PROJECTION,
EmailContent.MessageColumns.ACCOUNT_KEY + "=?" +
" AND " + MessageColumns.MAILBOX_KEY + "=?" +
" AND " + SyncColumns.SERVER_ID + "=?",
new String[] {
String.valueOf(account.mId),
String.valueOf(folder.mId),
String.valueOf(message.getUid())
},
null);
if (c.moveToNext()) {
localMessage = EmailContent.getContent(c, EmailContent.Message.class);
}
} finally {
if (c != null) {
c.close();
}
c = mContext.getContentResolver().query(
EmailContent.Message.CONTENT_URI,
EmailContent.Message.CONTENT_PROJECTION,
EmailContent.MessageColumns.ACCOUNT_KEY + "=?" +
" AND " + MessageColumns.MAILBOX_KEY + "=?" +
" AND " + SyncColumns.SERVER_ID + "=?",
new String[] {
String.valueOf(account.mId),
String.valueOf(folder.mId),
String.valueOf(message.getUid())
},
null);
if (c.moveToNext()) {
localMessage = EmailContent.getContent(c, EmailContent.Message.class);
localMessage.mMailboxKey = folder.mId;
localMessage.mAccountKey = account.mId;
copyOneMessageToProvider(message, localMessage, loadStatus, mContext);
}
if (localMessage == null) {
Log.d(Email.LOG_TAG, "Could not retrieve message from db, UUID="
+ message.getUid());
return;
} finally {
if (c != null) {
c.close();
}
}
}
EmailContent.Body body = EmailContent.Body.restoreBodyWithMessageId(mContext,
/**
* Copy one downloaded message (which may have partially-loaded sections)
* into an already-created EmailProvider Message
*
* @param message the remote message we've just downloaded
* @param localMessage the EmailProvider Message, already created
* @param loadStatus when complete, the message will be marked with this status (e.g.
* EmailContent.Message.LOADED)
* @param context the context to be used for EmailProvider
*/
public void copyOneMessageToProvider(Message message, EmailContent.Message localMessage,
int loadStatus, Context context) {
try {
EmailContent.Body body = EmailContent.Body.restoreBodyWithMessageId(context,
localMessage.mId);
if (body == null) {
body = new EmailContent.Body();
}
try {
// Copy the fields that are available into the message object
LegacyConversions.updateMessageFields(localMessage, message, account.mId,
folder.mId);
LegacyConversions.updateMessageFields(localMessage, message,
localMessage.mAccountKey, localMessage.mMailboxKey);
// Now process body parts & attachments
ArrayList<Part> viewables = new ArrayList<Part>();
@ -974,11 +986,11 @@ public class MessagingController implements Runnable {
LegacyConversions.updateBodyFields(body, localMessage, viewables);
// Commit the message & body to the local store immediately
saveOrUpdate(localMessage);
saveOrUpdate(body);
saveOrUpdate(localMessage, context);
saveOrUpdate(body, context);
// process (and save) attachments
LegacyConversions.updateAttachments(mContext, localMessage,
LegacyConversions.updateAttachments(context, localMessage,
attachments, false);
// One last update of message with two updated flags
@ -989,7 +1001,7 @@ public class MessagingController implements Runnable {
cv.put(EmailContent.MessageColumns.FLAG_LOADED, localMessage.mFlagLoaded);
Uri uri = ContentUris.withAppendedId(EmailContent.Message.CONTENT_URI,
localMessage.mId);
mContext.getContentResolver().update(uri, cv, null, null);
context.getContentResolver().update(uri, cv, null, null);
} catch (MessagingException me) {
Log.e(Email.LOG_TAG, "Error while copying downloaded message." + me);

View File

@ -17,9 +17,9 @@
package com.android.email.activity;
import com.android.email.Controller;
import com.android.email.ControllerResultUiThreadWrapper;
import com.android.email.Email;
import com.android.email.R;
import com.android.email.ControllerResultUiThreadWrapper;
import com.android.email.Utility;
import com.android.email.mail.Address;
import com.android.email.mail.MeetingInfo;
@ -89,7 +89,10 @@ import java.util.regex.Pattern;
public class MessageView extends Activity implements OnClickListener {
private static final String EXTRA_MESSAGE_ID = "com.android.email.MessageView_message_id";
private static final String EXTRA_MAILBOX_ID = "com.android.email.MessageView_mailbox_id";
/* package */ static final String EXTRA_DISABLE_REPLY = "com.android.email.MessageView_disable_reply";
private static final String EXTRA_ORIGINAL_MAILBOX_ID =
"com.android.email.MessageView_original_mailbox_id";
/* package */ static final String EXTRA_DISABLE_REPLY =
"com.android.email.MessageView_disable_reply";
// for saveInstanceState()
private static final String STATE_MESSAGE_ID = "messageId";
@ -141,6 +144,8 @@ public class MessageView extends Activity implements OnClickListener {
private long mMailboxId;
private Message mMessage;
private long mWaitForLoadMessageId;
// Set to true for messages that are attachments
private boolean mAttachmentMessageFlag = false;
private LoadMessageTask mLoadMessageTask;
private LoadBodyTask mLoadBodyTask;
@ -384,6 +389,10 @@ public class MessageView extends Activity implements OnClickListener {
mMessageContentView.destroy();
mMessageContentView = null;
// the cursor was closed in onPause()
// If we're leaving a non-attachment message, delete any/all attachment messages
if (!mAttachmentMessageFlag) {
mController.deleteAttachmentMessages();
}
}
private void onDelete() {
@ -875,7 +884,6 @@ public class MessageView extends Activity implements OnClickListener {
* @param attachment A single attachment loaded from the provider
*/
private void addAttachment(Attachment attachment) {
AttachmentInfo attachmentInfo = new AttachmentInfo();
attachmentInfo.size = attachment.mSize;
attachmentInfo.contentType =
@ -1056,18 +1064,41 @@ public class MessageView extends Activity implements OnClickListener {
private long mId;
private boolean mOkToFetch;
private Uri mIntentUri;
/**
* Special constructor to cache some local info
*/
public LoadMessageTask(long messageId, boolean okToFetch) {
mIntentUri = getIntent().getData();
mId = messageId;
mOkToFetch = okToFetch;
mAttachmentMessageFlag = (mIntentUri != null);
}
/**
* There will either be a Uri in the Intent (i.e. whose content is the Message to be
* loaded), or mId will be holding the id of the Message as stored in the provider.
* If we're loading via Uri, the Controller does the actual message parsing and storage,
* and we setup the message id and mailbox id based on the result; forward and reply are
* disabled for messages loaded via Uri
*/
@Override
protected Message doInBackground(Void... params) {
if (mId == Long.MIN_VALUE) {
// If we have a URI, then we were opened via an intent filter (e.g. an attachment that
// has a mime type that we can handle (e.g. message/rfc822).
if (mIntentUri != null) {
final Activity activity = MessageView.this;
// Put up a toast; this can take a little while...
Utility.showToast(activity, R.string.message_view_parse_message_toast);
Message msg = mController.loadMessageFromUri(mIntentUri);
if (msg == null) {
// Indicate that the attachment couldn't be loaded
Utility.showToast(activity, R.string.message_view_display_attachment_toast);
return null;
}
return msg;
} else if (mId == Long.MIN_VALUE) {
return null;
}
return Message.restoreMessageWithId(MessageView.this, mId);
@ -1094,6 +1125,9 @@ public class MessageView extends Activity implements OnClickListener {
}
return;
}
mMessageId = message.mId;
mMailboxId = message.mMailboxKey;
mDisableReplyAndForward = mIntentUri != null;
reloadUiFromMessage(message, mOkToFetch);
startPresenceCheck();
}

View File

@ -423,7 +423,7 @@ public class MimeMessage extends Message {
return;
}
if (mExtendedHeader == null) {
mExtendedHeader = new MimeHeader();
mExtendedHeader = new MimeHeader();
}
mExtendedHeader.setHeader(name, END_OF_LINE.matcher(value).replaceAll(""));
}
@ -433,7 +433,7 @@ public class MimeMessage extends Message {
*
* @param name Extended header name
* @return header value - null if header does not exist
* @throws MessagingException
* @throws MessagingException
*/
public String getExtendedHeader(String name) throws MessagingException {
if (mExtendedHeader == null) {

View File

@ -935,7 +935,7 @@ public class Pop3Store extends Store {
}
}
class Pop3Message extends MimeMessage {
public static class Pop3Message extends MimeMessage {
public Pop3Message(String uid, Pop3Folder folder) throws MessagingException {
mUid = uid;
mFolder = folder;
@ -947,7 +947,7 @@ public class Pop3Store extends Store {
}
@Override
protected void parse(InputStream in) throws IOException, MessagingException {
public void parse(InputStream in) throws IOException, MessagingException {
super.parse(in);
}

View File

@ -2003,6 +2003,10 @@ public abstract class EmailContent {
public static final int TYPE_TASKS = 0x43;
public static final int TYPE_EAS_ACCOUNT_MAILBOX = 0x44;
public static final int TYPE_NOT_SYNCABLE = 0x100;
// A mailbox that holds Messages that are attachments
public static final int TYPE_ATTACHMENT = 0x101;
// Bit field flags
public static final int FLAG_HAS_CHILDREN = 1<<0;
public static final int FLAG_CHILDREN_VISIBLE = 1<<1;

View File

@ -1737,7 +1737,6 @@ public class SyncManager extends Service implements Runnable {
public void onCreate() {
synchronized (sSyncLock) {
alwaysLog("!!! EAS SyncManager, onCreate");
// If we're in the process of shutting down, try again in 5 seconds
if (sStop) {
return;
}
@ -2187,15 +2186,24 @@ public class SyncManager extends Service implements Runnable {
serviceRequest(mailboxId, 5*SECONDS, reason);
}
/**
* Return a boolean indicating whether the mailbox can be synced
* @param m the mailbox
* @return whether or not the mailbox can be synced
*/
static /*package*/ boolean isSyncable(Mailbox m) {
if (m == null || m.mType == Mailbox.TYPE_DRAFTS || m.mType == Mailbox.TYPE_OUTBOX ||
m.mType >= Mailbox.TYPE_NOT_SYNCABLE) {
return false;
}
return true;
}
static public void serviceRequest(long mailboxId, long ms, int reason) {
SyncManager syncManager = INSTANCE;
if (syncManager == null) return;
Mailbox m = Mailbox.restoreMailboxWithId(syncManager, mailboxId);
// Never allow manual start of Drafts or Outbox via serviceRequest
if (m == null || m.mType == Mailbox.TYPE_DRAFTS || m.mType == Mailbox.TYPE_OUTBOX) {
log("Ignoring serviceRequest for drafts/outbox/null mailbox");
return;
}
if (!isSyncable(m)) return;
try {
AbstractSyncService service = syncManager.mServiceMap.get(mailboxId);
if (service != null) {

View File

@ -16,16 +16,23 @@
package com.android.email;
import com.android.email.mail.MessagingException;
import com.android.email.mail.transport.Rfc822Output;
import com.android.email.provider.EmailContent;
import com.android.email.provider.EmailProvider;
import com.android.email.provider.ProviderTestUtils;
import com.android.email.provider.EmailContent.Account;
import com.android.email.provider.EmailContent.Body;
import com.android.email.provider.EmailContent.Mailbox;
import com.android.email.provider.EmailContent.Message;
import android.content.Context;
import android.net.Uri;
import android.test.ProviderTestCase2;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.util.Locale;
/**
@ -282,6 +289,63 @@ public class ControllerProviderOpsTests extends ProviderTestCase2<EmailProvider>
assertFalse(message1get.mFlagFavorite);
}
public void testGetAndDeleteAttachmentMailbox() {
Controller ct = new TestController(mProviderContext, mContext);
Mailbox box = ct.getAttachmentMailbox();
assertNotNull(box);
Mailbox anotherBox = ct.getAttachmentMailbox();
assertNotNull(anotherBox);
// We should always get back the same Mailbox row
assertEquals(box.mId, anotherBox.mId);
// Add two messages to this mailbox
ProviderTestUtils.setupMessage("message1", 0, box.mId, false, true,
mProviderContext);
ProviderTestUtils.setupMessage("message2", 0, box.mId, false, true,
mProviderContext);
// Make sure we can find them where they are expected
assertEquals(2, EmailContent.count(mProviderContext, Message.CONTENT_URI,
Message.MAILBOX_KEY + "=?", new String[] {Long.toString(box.mId)}));
// Delete them
ct.deleteAttachmentMessages();
// Make sure they're gone
assertEquals(0, EmailContent.count(mProviderContext, Message.CONTENT_URI,
Message.MAILBOX_KEY + "=?", new String[] {Long.toString(box.mId)}));
}
public void testLoadMessageFromUri() throws IOException, MessagingException {
// Create a simple message
Message msg = new Message();
String text = "This is some text";
msg.mText = text;
String sender = "sender@host.com";
msg.mFrom = sender;
// Save this away
msg.save(mProviderContext);
// Write out the message in rfc822 format
File outputFile = File.createTempFile("message", "tmp", mContext.getFilesDir());
assertNotNull(outputFile);
FileOutputStream outputStream = new FileOutputStream(outputFile);
Rfc822Output.writeTo(mProviderContext, msg.mId, outputStream, false, false);
outputStream.close();
// Load the message via Controller and a Uri
Controller ct = new TestController(mProviderContext, mContext);
Message loadedMsg = ct.loadMessageFromUri(Uri.fromFile(outputFile));
// Check server id, mailbox key, account key, and from
assertNotNull(loadedMsg);
assertTrue(loadedMsg.mServerId.startsWith(Controller.ATTACHMENT_MESSAGE_UID_PREFIX));
Mailbox box = ct.getAttachmentMailbox();
assertNotNull(box);
assertEquals(box.mId, loadedMsg.mMailboxKey);
assertEquals(0, loadedMsg.mAccountKey);
assertEquals(loadedMsg.mFrom, sender);
// Check body text
String loadedMsgText = Body.restoreBodyTextWithMessageId(mProviderContext, loadedMsg.mId);
assertEquals(text, loadedMsgText);
}
/**
* TODO: releasing associated data (e.g. attachments, embedded images)
*/

View File

@ -211,4 +211,24 @@ public class SyncManagerAccountTests extends AccountTestCase {
assertEquals(0, errorMap.keySet().size());
}
public void testIsSyncable() {
Context context = mMockContext;
Account acct1 = ProviderTestUtils.setupAccount("acct1", true, context);
Mailbox box1 = ProviderTestUtils.setupMailbox("box1", acct1.mId, true, context,
Mailbox.TYPE_DRAFTS);
Mailbox box2 = ProviderTestUtils.setupMailbox("box2", acct1.mId, true, context,
Mailbox.TYPE_OUTBOX);
Mailbox box3 = ProviderTestUtils.setupMailbox("box2", acct1.mId, true, context,
Mailbox.TYPE_ATTACHMENT);
Mailbox box4 = ProviderTestUtils.setupMailbox("box2", acct1.mId, true, context,
Mailbox.TYPE_NOT_SYNCABLE + 64);
Mailbox box5 = ProviderTestUtils.setupMailbox("box2", acct1.mId, true, context,
Mailbox.TYPE_MAIL);
assertFalse(SyncManager.isSyncable(null));
assertFalse(SyncManager.isSyncable(box1));
assertFalse(SyncManager.isSyncable(box2));
assertFalse(SyncManager.isSyncable(box3));
assertFalse(SyncManager.isSyncable(box4));
assertTrue(SyncManager.isSyncable(box5));
}
}