From 1d98989222f2d023ddb08a70d5abb850029f95dc Mon Sep 17 00:00:00 2001 From: Marc Blank Date: Tue, 8 Sep 2009 16:44:00 -0700 Subject: [PATCH] Implement SmartReply/SmartForward for EAS; fixes #2098779 * SmartForward and SmartReply are EAS commands that automatically include the original message and, if a forward, all original attachments, regardless of whether they've been downloaded to the device * Both commands improve battery life by sending less data; greatly so for SmartForward if there are attachments Change-Id: I12432cd5275a3b54e9a80d5cd59da437c4a086cc --- .../email/mail/transport/Rfc822Output.java | 17 +++-- .../email/mail/transport/SmtpSender.java | 2 +- .../android/exchange/AbstractSyncService.java | 37 +++++++++ .../android/exchange/EasOutboxService.java | 75 +++++++++++++++---- src/com/android/exchange/EasSyncService.java | 19 ++++- .../mail/transport/Rfc822OutputTests.java | 38 +++++++++- 6 files changed, 160 insertions(+), 28 deletions(-) diff --git a/src/com/android/email/mail/transport/Rfc822Output.java b/src/com/android/email/mail/transport/Rfc822Output.java index 6f0616441..f3503bab1 100644 --- a/src/com/android/email/mail/transport/Rfc822Output.java +++ b/src/com/android/email/mail/transport/Rfc822Output.java @@ -59,11 +59,15 @@ public class Rfc822Output { static final SimpleDateFormat mDateFormat = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss Z", Locale.US); - /*package*/ static String buildBodyText(Context context, Message message) { - int flags = message.mFlags; + /*package*/ static String buildBodyText(Context context, Message message, + boolean appendQuotedText) { Body body = Body.restoreBodyWithMessageId(context, message.mId); String text = body.mTextContent; + if (!appendQuotedText) { + return text; + } + String quotedText = body.mTextReply; if (quotedText != null) { // fix CR-LF line endings to LF-only needed by EditText. @@ -71,6 +75,7 @@ public class Rfc822Output { quotedText = matcher.replaceAll("\n"); } String fromAsString = Address.unpackToString(message.mFrom); + int flags = message.mFlags; if ((flags & Message.FLAG_TYPE_REPLY) != 0) { text += context.getString(R.string.message_compose_reply_header_fmt, fromAsString); if (quotedText != null) { @@ -97,12 +102,12 @@ public class Rfc822Output { * @param context system context for accessing the provider * @param messageId the message to write out * @param out the output stream to write the message to + * @param appendQuotedText whether or not to append quoted text if this is a reply/forward * - * TODO is there anything in the flags fields we need to look at? * TODO alternative parts (e.g. text+html) are not supported here. */ - public static void writeTo(Context context, long messageId, OutputStream out) - throws IOException, MessagingException { + public static void writeTo(Context context, long messageId, OutputStream out, + boolean appendQuotedText) throws IOException, MessagingException { Message message = Message.restoreMessageWithId(context, messageId); if (message == null) { // throw something? @@ -129,7 +134,7 @@ public class Rfc822Output { writeAddressHeader(writer, "Reply-To", message.mReplyTo); // Analyze message and determine if we have multiparts - String text = buildBodyText(context, message); + String text = buildBodyText(context, message, appendQuotedText); Uri uri = ContentUris.withAppendedId(Attachment.MESSAGE_ID_URI, messageId); Cursor attachmentsCursor = context.getContentResolver().query(uri, diff --git a/src/com/android/email/mail/transport/SmtpSender.java b/src/com/android/email/mail/transport/SmtpSender.java index 7643bf759..0407a64de 100644 --- a/src/com/android/email/mail/transport/SmtpSender.java +++ b/src/com/android/email/mail/transport/SmtpSender.java @@ -232,7 +232,7 @@ public class SmtpSender extends Sender { executeSimpleCommand("DATA"); // TODO byte stuffing Rfc822Output.writeTo(mContext, messageId, - new EOLConvertingOutputStream(mTransport.getOutputStream())); + new EOLConvertingOutputStream(mTransport.getOutputStream()), true); executeSimpleCommand("\r\n."); } catch (IOException ioe) { throw new MessagingException("Unable to send message", ioe); diff --git a/src/com/android/exchange/AbstractSyncService.java b/src/com/android/exchange/AbstractSyncService.java index 97cfb8767..425424dad 100644 --- a/src/com/android/exchange/AbstractSyncService.java +++ b/src/com/android/exchange/AbstractSyncService.java @@ -22,9 +22,13 @@ import com.android.email.provider.EmailContent.Account; import com.android.email.provider.EmailContent.Mailbox; import com.android.exchange.utility.FileLogger; +import android.content.ContentResolver; +import android.content.ContentUris; import android.content.Context; +import android.database.Cursor; import android.net.ConnectivityManager; import android.net.NetworkInfo; +import android.net.Uri; import android.net.NetworkInfo.DetailedState; import android.util.Log; @@ -325,4 +329,37 @@ public abstract class AbstractSyncService implements Runnable { } return null; } + + /** + * Convenience method wrapping calls to retrieve columns from a single row, via EmailProvider. + * The arguments are exactly the same as to contentResolver.query(). Results are returned in + * an array of Strings corresponding to the columns in the projection. + */ + protected String[] getRowColumns(Uri contentUri, String[] projection, String selection, + String[] selectionArgs) { + String[] values = new String[projection.length]; + ContentResolver cr = mContext.getContentResolver(); + Cursor c = cr.query(contentUri, projection, selection, selectionArgs, null); + try { + if (c.moveToFirst()) { + for (int i = 0; i < projection.length; i++) { + values[i] = c.getString(i); + } + } else { + return null; + } + } finally { + c.close(); + } + return values; + } + + /** + * Convenience method for retrieving columns from a particular row in EmailProvider. + * Passed in here are a base uri (e.g. Message.CONTENT_URI), the unique id of a row, and + * a projection. This method calls the previous one with the appropriate URI. + */ + protected String[] getRowColumns(Uri baseUri, long id, String ... projection) { + return getRowColumns(ContentUris.withAppendedId(baseUri, id), projection, null, null); + } } diff --git a/src/com/android/exchange/EasOutboxService.java b/src/com/android/exchange/EasOutboxService.java index 8f6903f57..bb0547b63 100644 --- a/src/com/android/exchange/EasOutboxService.java +++ b/src/com/android/exchange/EasOutboxService.java @@ -19,7 +19,10 @@ package com.android.exchange; import com.android.email.mail.MessagingException; import com.android.email.mail.transport.Rfc822Output; +import com.android.email.provider.EmailContent.Body; +import com.android.email.provider.EmailContent.BodyColumns; import com.android.email.provider.EmailContent.Mailbox; +import com.android.email.provider.EmailContent.MailboxColumns; import com.android.email.provider.EmailContent.Message; import com.android.email.provider.EmailContent.MessageColumns; import com.android.email.provider.EmailContent.SyncColumns; @@ -38,16 +41,19 @@ import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; +import java.net.URLEncoder; public class EasOutboxService extends EasSyncService { public static final int SEND_FAILED = 1; public static final String MAILBOX_KEY_AND_NOT_SEND_FAILED = MessageColumns.MAILBOX_KEY + "=? and " + SyncColumns.SERVER_ID + "!=" + SEND_FAILED; + public static final String[] BODY_SOURCE_PROJECTION = + new String[] {BodyColumns.SOURCE_MESSAGE_KEY}; + public static final String WHERE_MESSAGE_KEY = Body.MESSAGE_KEY + "=?"; public EasOutboxService(Context _context, Mailbox _mailbox) { super(_context, _mailbox); - mContext = _context; } private void sendCallback(long msgId, String subject, int status) { @@ -73,34 +79,70 @@ public class EasOutboxService extends EasSyncService { File tmpFile = File.createTempFile("eas_", "tmp", cacheDir); // Write the output to a temporary file try { + String[] cols = getRowColumns(Message.CONTENT_URI, msgId, MessageColumns.FLAGS, + MessageColumns.SUBJECT); + int flags = Integer.parseInt(cols[0]); + String subject = cols[1]; + + boolean reply = (flags & Message.FLAG_TYPE_REPLY) != 0; + boolean forward = (flags & Message.FLAG_TYPE_FORWARD) != 0; + // The reference message and mailbox are called item and collection in EAS + String itemId = null; + String collectionId = null; + if (reply || forward) { + // First, we need to get the id of the reply/forward message + cols = getRowColumns(Body.CONTENT_URI, BODY_SOURCE_PROJECTION, + WHERE_MESSAGE_KEY, new String[] {Long.toString(msgId)}); + if (cols != null) { + long refId = Long.parseLong(cols[0]); + // Then, we need the serverId and mailboxKey of the message + cols = getRowColumns(Message.CONTENT_URI, refId, SyncColumns.SERVER_ID, + MessageColumns.MAILBOX_KEY); + if (cols != null) { + itemId = cols[0]; + long boxId = Long.parseLong(cols[1]); + // Then, we need the serverId of the mailbox + cols = getRowColumns(Mailbox.CONTENT_URI, boxId, MailboxColumns.SERVER_ID); + if (cols != null) { + collectionId = cols[0]; + } + } + } + } + + boolean smartSend = itemId != null && collectionId != null; + + // Write the message in rfc822 format to the temporary file FileOutputStream fileStream = new FileOutputStream(tmpFile); - Rfc822Output.writeTo(mContext, msgId, fileStream); + Rfc822Output.writeTo(mContext, msgId, fileStream, !smartSend); fileStream.close(); - // Now, get an input stream to our new file and create an entity with it + + // Now, get an input stream to our temporary file and create an entity with it FileInputStream inputStream = new FileInputStream(tmpFile); InputStreamEntity inputEntity = new InputStreamEntity(inputStream, tmpFile.length()); - // Send the post to the server - HttpResponse resp = - sendHttpClientPost("SendMail&SaveInSent=T", inputEntity); + + // Create the appropriate command and POST it to the server + String cmd = "SendMail&SaveInSent=T"; + if (smartSend) { + cmd = reply ? "SmartReply" : "SmartForward"; + cmd += "&ItemId=" + URLEncoder.encode(itemId, "UTF-8") + "&CollectionId=" + + URLEncoder.encode(collectionId, "UTF-8") + "&SaveInSent=T"; + } + HttpResponse resp = sendHttpClientPost(cmd, inputEntity); + inputStream.close(); int code = resp.getStatusLine().getStatusCode(); if (code == HttpStatus.SC_OK) { userLog("Deleting message..."); - // Yes it would be marginally faster to get only the subject, but it would also - // be more code; note, we need the subject for the callback below, since the - // message gets deleted just below. This allows the UI to present the subject - // of the message in a Toast or other notification - Message msg = Message.restoreMessageWithId(mContext, msgId); - mContext.getContentResolver().delete(ContentUris.withAppendedId( - Message.CONTENT_URI, msgId), null, null); + mContentResolver.delete(ContentUris.withAppendedId(Message.CONTENT_URI, msgId), + null, null); result = EmailServiceStatus.SUCCESS; - sendCallback(-1, msg.mSubject, EmailServiceStatus.SUCCESS); + sendCallback(-1, subject, EmailServiceStatus.SUCCESS); } else { ContentValues cv = new ContentValues(); cv.put(SyncColumns.SERVER_ID, SEND_FAILED); Message.update(mContext, Message.CONTENT_URI, msgId, cv); - // TODO REMOTE_EXCEPTION is temporary; add better error codes result = EmailServiceStatus.REMOTE_EXCEPTION; if (isAuthError(code)) { result = EmailServiceStatus.LOGIN_FAILED; @@ -127,6 +169,7 @@ public class EasOutboxService extends EasSyncService { File cacheDir = mContext.getCacheDir(); try { + mDeviceId = SyncManager.getDeviceId(); Cursor c = mContext.getContentResolver().query(Message.CONTENT_URI, Message.ID_COLUMN_PROJECTION, MAILBOX_KEY_AND_NOT_SEND_FAILED, new String[] {Long.toString(mMailbox.mId)}, null); @@ -150,6 +193,8 @@ public class EasOutboxService extends EasSyncService { c.close(); } mExitStatus = EXIT_DONE; + } catch (IOException e) { + mExitStatus = EXIT_IO_ERROR; } catch (Exception e) { mExitStatus = EXIT_EXCEPTION; } finally { diff --git a/src/com/android/exchange/EasSyncService.java b/src/com/android/exchange/EasSyncService.java index 21deced47..7d4cf7e6c 100644 --- a/src/com/android/exchange/EasSyncService.java +++ b/src/com/android/exchange/EasSyncService.java @@ -123,7 +123,7 @@ public class EasSyncService extends AbstractSyncService { // Reasonable default String mProtocolVersion = "2.5"; public Double mProtocolVersionDouble; - private String mDeviceId = null; + protected String mDeviceId = null; private String mDeviceType = "Android"; private String mAuthString = null; private String mCmdString = null; @@ -417,9 +417,22 @@ public class EasSyncService extends AbstractSyncService { protected HttpResponse sendHttpClientPost(String cmd, HttpEntity entity, int timeout) throws IOException { HttpClient client = getHttpClient(timeout); - String us = makeUriString(cmd, null); + + // Split the mail sending commands + String extra = null; + boolean msg = false; + if (cmd.startsWith("SmartForward&") || cmd.startsWith("SmartReply&")) { + int cmdLength = cmd.length() - 1; + extra = cmd.substring(cmdLength); + cmd = cmd.substring(0, cmdLength); + msg = true; + } else if (cmd.startsWith("SendMail&")) { + msg = true; + } + + String us = makeUriString(cmd, extra); HttpPost method = new HttpPost(URI.create(us)); - if (cmd.startsWith("SendMail&")) { + if (msg) { method.setHeader("Content-Type", "message/rfc822"); } else { method.setHeader("Content-Type", "application/vnd.ms-sync.wbxml"); diff --git a/tests/src/com/android/email/mail/transport/Rfc822OutputTests.java b/tests/src/com/android/email/mail/transport/Rfc822OutputTests.java index 3512855be..1a24d4086 100644 --- a/tests/src/com/android/email/mail/transport/Rfc822OutputTests.java +++ b/tests/src/com/android/email/mail/transport/Rfc822OutputTests.java @@ -34,18 +34,25 @@ public class Rfc822OutputTests extends AndroidTestCase { private static final String RECIPIENT_BCC = "recipient-bcc@android.com"; private static final String SUBJECT = "This is the subject"; private static final String BODY = "This is the body. This is also the body."; + private static final String TEXT = "Here is some new text."; private static final String REPLY_BODY_SHORT = "\n\n" + SENDER + " wrote:\n\n"; private static final String REPLY_BODY = REPLY_BODY_SHORT + ">" + BODY; // TODO Create more tests here. Specifically, we should test to make sure that forward works // properly instead of just reply + // TODO Localize the following test, which will not work properly in other than English + // speaking locales! + /** * Test for buildBodyText(). * Compare with expected values. * Also test the situation where the message has no body. + * + * WARNING: This test is NOT localized, so it will fail if run on a device in a + * non-English speaking locale! */ - public void testBuildBodyText() { + public void testBuildBodyTextWithReply() { // Create the least necessary; sender, flags, and the body of the reply Message msg = new Message(); msg.mText = ""; @@ -54,14 +61,39 @@ public class Rfc822OutputTests extends AndroidTestCase { msg.mTextReply = BODY; msg.save(getContext()); - String body = Rfc822Output.buildBodyText(getContext(), msg); + String body = Rfc822Output.buildBodyText(getContext(), msg, true); assertEquals(REPLY_BODY, body); // Save a different message with no reply body (so we reset the id) msg.mId = -1; msg.mTextReply = null; msg.save(getContext()); - body = Rfc822Output.buildBodyText(getContext(), msg); + body = Rfc822Output.buildBodyText(getContext(), msg, true); assertEquals(REPLY_BODY_SHORT, body); } + + /** + * Test for buildBodyText(). + * Compare with expected values. + * Also test the situation where the message has no body. + */ + public void testBuildBodyTextWithoutReply() { + // Create the least necessary; sender, flags, and the body of the reply + Message msg = new Message(); + msg.mText = TEXT; + msg.mFrom = SENDER; + msg.mFlags = Message.FLAG_TYPE_REPLY; + msg.mTextReply = BODY; + msg.save(getContext()); + + String body = Rfc822Output.buildBodyText(getContext(), msg, false); + assertEquals(TEXT, body); + + // Save a different message with no reply body (so we reset the id) + msg.mId = -1; + msg.mTextReply = null; + msg.save(getContext()); + body = Rfc822Output.buildBodyText(getContext(), msg, false); + assertEquals(TEXT, body); + } }