From d8bce7e73155dca734f45502e52c0039de4c9663 Mon Sep 17 00:00:00 2001 From: Todd Kennedy Date: Thu, 3 Mar 2011 15:26:42 -0800 Subject: [PATCH] DO NOT MERGE Add original HTML message to forward/reply When replying or fowarding an HTML message, we now send both plain text and HTML bodies as a multi-part mime message. We take special care to ensure the message bodies are in their own multi-part block and do not interfere with any additional attachments to the message. bug 3060920 Backport-Of: I2fc3cb4e1f65bcc28486a62731b44b0ee0a99719 Change-Id: I89ec2795b55ceb7472a8ee3db2dc8f50cf537d9c --- .../emailcommon/internet/Rfc822Output.java | 174 ++++++++++++++---- proguard.flags | 5 + .../email/activity/MessageCompose.java | 31 ++-- .../internet/Rfc822OutputTests.java | 19 ++ 4 files changed, 173 insertions(+), 56 deletions(-) diff --git a/emailcommon/src/com/android/emailcommon/internet/Rfc822Output.java b/emailcommon/src/com/android/emailcommon/internet/Rfc822Output.java index 7382d30b2..6aa70d334 100644 --- a/emailcommon/src/com/android/emailcommon/internet/Rfc822Output.java +++ b/emailcommon/src/com/android/emailcommon/internet/Rfc822Output.java @@ -28,6 +28,7 @@ import android.content.ContentUris; import android.content.Context; import android.database.Cursor; import android.net.Uri; +import android.text.Html; import android.util.Base64; import android.util.Base64OutputStream; @@ -61,15 +62,64 @@ public class Rfc822Output { private static final String WHERE_NOT_SMART_FORWARD = "(" + Attachment.FLAGS + "&" + Attachment.FLAG_SMART_FORWARD + ")=0"; + /** A less-than-perfect pattern to pull out content */ + private static final String BODY_PATTERN = "(?:<\\s*body[^>]*>)(.*)(?:<\\s*/\\s*body\\s*>)"; + /** Index of the plain text version of the message body */ + private final static int TEXT_BODY_IDX = 0; + /** Index of the HTML version of the message body */ + private final static int HTML_BODY_IDX = 1; + + /** + * Returns just the content between the tags. This is not perfect and breaks + * with malformed HTML or if there happens to be special characters in the attributes of + * the tag (e.g. a '>' in a java script block). + */ + /*package*/ static String getHtmlBody(String html) { + Pattern bodyPattern = + Pattern.compile(BODY_PATTERN,Pattern.CASE_INSENSITIVE | Pattern.DOTALL); + Matcher match = bodyPattern.matcher(html); + if (match.find()) { + return match.group(1); // Found body; return + } else { + return html; // Body not found; return the full HTML and hope for the best + } + } + + /** + * Returns an HTML encoded message alternate + */ + private static String getHtmlAlternate(Body body) { + if (body.mHtmlReply == null) { + return null; + } + StringBuffer altMessage = new StringBuffer(); + altMessage.append(body.mTextContent.replaceAll("\\r?\\n", "
")); + if (body.mIntroText != null) { + altMessage.append(body.mIntroText.replaceAll("\\r?\\n", "
")); + } + String htmlBody = getHtmlBody(body.mHtmlReply); + altMessage.append(htmlBody); + return altMessage.toString(); + } + + /** + * Gets the plain text version of the message body. + */ /*package*/ static String buildBodyText(Context context, Message message, boolean useSmartReply) { Body body = Body.restoreBodyWithMessageId(context, message.mId); - if (body == null) { - return null; - } + return buildBodyText(body, message.mFlags, useSmartReply)[TEXT_BODY_IDX]; + } + /** + * Gets both the plain text and HTML versions of the message body. + */ + private static String[] buildBodyText(Body body, int flags, boolean useSmartReply) { + String[] messageBody = new String[] { null, null }; + if (body == null) { + return messageBody; + } String text = body.mTextContent; - int flags = message.mFlags; boolean isReply = (flags & Message.FLAG_TYPE_REPLY) != 0; boolean isForward = (flags & Message.FLAG_TYPE_FORWARD) != 0; // For all forwards/replies, we add the intro text @@ -83,26 +133,31 @@ public class Rfc822Output { if (isForward) { text += "\n"; } - return text; - } - - String quotedText = body.mTextReply; - if (quotedText != null) { - // fix CR-LF line endings to LF-only needed by EditText. - Matcher matcher = PATTERN_ENDLINE_CRLF.matcher(quotedText); - quotedText = matcher.replaceAll("\n"); - } - if (isReply) { - if (quotedText != null) { - Matcher matcher = PATTERN_START_OF_LINE.matcher(quotedText); - text += matcher.replaceAll(">"); + } else { + String quotedText = body.mTextReply; + // If there is no plain-text body, use de-tagified HTML as the text body + if (quotedText == null && body.mHtmlReply != null) { + quotedText = Html.fromHtml(body.mHtmlReply).toString(); } - } else if (isForward) { if (quotedText != null) { - text += quotedText; + // fix CR-LF line endings to LF-only needed by EditText. + Matcher matcher = PATTERN_ENDLINE_CRLF.matcher(quotedText); + quotedText = matcher.replaceAll("\n"); + } + if (isReply) { + if (quotedText != null) { + Matcher matcher = PATTERN_START_OF_LINE.matcher(quotedText); + text += matcher.replaceAll(">"); + } + } else if (isForward) { + if (quotedText != null) { + text += quotedText; + } } } - return text; + messageBody[TEXT_BODY_IDX] = text; + messageBody[HTML_BODY_IDX] = getHtmlAlternate(body); + return messageBody; } /** @@ -112,9 +167,13 @@ 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 +<<<<<<< HEAD:emailcommon/src/com/android/emailcommon/internet/Rfc822Output.java * @param useSmartReply whether or not quoted text is appended to a reply/forward * * TODO alternative parts (e.g. text+html) are not supported here. +======= + * @param useSmartReply whether or not to append quoted text if this is a reply/forward +>>>>>>> 5912e7c... Attach original HTML message on forward/reply:src/com/android/emailcommon/internet/Rfc822Output.java */ public static void writeTo(Context context, long messageId, OutputStream out, boolean useSmartReply, boolean sendBcc) throws IOException, MessagingException { @@ -149,7 +208,8 @@ public class Rfc822Output { writeHeader(writer, "MIME-Version", "1.0"); // Analyze message and determine if we have multiparts - String text = buildBodyText(context, message, useSmartReply); + Body body = Body.restoreBodyWithMessageId(context, message.mId); + String[] bodyText = buildBodyText(body, message.mFlags, useSmartReply); Uri uri = ContentUris.withAppendedId(Attachment.MESSAGE_ID_URI, messageId); Cursor attachmentsCursor = context.getContentResolver().query(uri, @@ -163,11 +223,7 @@ public class Rfc822Output { // Simplified case for no multipart - just emit text and be done. if (!multipart) { - if (text != null) { - writeTextWithHeaders(writer, stream, text); - } else { - writer.write("\r\n"); // a truly empty message - } + writeTextWithHeaders(writer, stream, bodyText); } else { // continue with multipart headers, then into multipart body multipartBoundary = "--_com.android.email_" + System.nanoTime(); @@ -189,9 +245,9 @@ public class Rfc822Output { writer.write("\r\n"); // first multipart element is the body - if (text != null) { + if (bodyText[TEXT_BODY_IDX] != null) { writeBoundary(writer, multipartBoundary, false); - writeTextWithHeaders(writer, stream, text); + writeTextWithHeaders(writer, stream, bodyText); } // Write out the attachments until we run out @@ -230,7 +286,9 @@ public class Rfc822Output { + "\n filename=\"" + attachment.mFileName + "\";" + "\n size=" + Long.toString(attachment.mSize)); } - writeHeader(writer, "Content-ID", attachment.mContentId); + if (attachment.mContentId != null) { + writeHeader(writer, "Content-ID", attachment.mContentId); + } writer.append("\r\n"); // Set up input stream and write it out via base64 @@ -335,7 +393,9 @@ public class Rfc822Output { } /** - * Write text (either as main body or inside a multipart), preceded by appropriate headers. + * Write the body text. If only one version of the body is specified (either plain text + * or HTML), the text is written directly. Otherwise, the plain text and HTML bodies + * are both written with the appropriate headers. * * Note this always uses base64, even when not required. Slightly less efficient for * US-ASCII text, but handles all formats even when non-ascii chars are involved. A small @@ -343,15 +403,53 @@ public class Rfc822Output { * * @param writer the output writer * @param out the output stream inside the writer (used for byte[] access) - * @param text The original text of the message + * @param bodyText Plain text and HTML versions of the original text of the message */ - private static void writeTextWithHeaders(Writer writer, OutputStream out, String text) + private static void writeTextWithHeaders(Writer writer, OutputStream out, String[] bodyText) throws IOException { - writeHeader(writer, "Content-Type", "text/plain; charset=utf-8"); - writeHeader(writer, "Content-Transfer-Encoding", "base64"); - writer.write("\r\n"); - byte[] bytes = text.getBytes("UTF-8"); - writer.flush(); - out.write(Base64.encode(bytes, Base64.CRLF)); + String text = bodyText[TEXT_BODY_IDX]; + String html = bodyText[HTML_BODY_IDX]; + + if (text == null) { + writer.write("\r\n"); // a truly empty message + } else { + String multipartBoundary = null; + boolean multipart = html != null; + + // Simplified case for no multipart - just emit text and be done. + if (multipart) { + // continue with multipart headers, then into multipart body + multipartBoundary = "--_com.android.email_" + System.nanoTime(); + + writeHeader(writer, "Content-Type", + "multipart/alternative; boundary=\"" + multipartBoundary + "\""); + // Finish headers and prepare for body section(s) + writer.write("\r\n"); + writeBoundary(writer, multipartBoundary, false); + } + + // first multipart element is the body + writeHeader(writer, "Content-Type", "text/plain; charset=utf-8"); + writeHeader(writer, "Content-Transfer-Encoding", "base64"); + writer.write("\r\n"); + byte[] textBytes = text.getBytes("UTF-8"); + writer.flush(); + out.write(Base64.encode(textBytes, Base64.CRLF)); + + if (multipart) { + // next multipart section + writeBoundary(writer, multipartBoundary, false); + + writeHeader(writer, "Content-Type", "text/html; charset=utf-8"); + writeHeader(writer, "Content-Transfer-Encoding", "base64"); + writer.write("\r\n"); + byte[] htmlBytes = html.getBytes("UTF-8"); + writer.flush(); + out.write(Base64.encode(htmlBytes, Base64.CRLF)); + + // end of multipart section + writeBoundary(writer, multipartBoundary, true); + } + } } } diff --git a/proguard.flags b/proguard.flags index f6355d7c0..494c26c46 100644 --- a/proguard.flags +++ b/proguard.flags @@ -46,6 +46,11 @@ *** setProviderContext(android.content.Context); } +-keepclasseswithmembers class com.android.emailcommon.internet.Rfc822Output { + *** getHtmlBody(java.lang.String); + *** buildBodyText(android.content.Context, com.android.emailcommon.provider.EmailContent$Message, boolean); +} + -keepclasseswithmembers class com.android.emailcommon.mail.Address { (java.lang.String); (java.lang.String,java.lang.String); diff --git a/src/com/android/email/activity/MessageCompose.java b/src/com/android/email/activity/MessageCompose.java index 2ca634c80..4b279a182 100644 --- a/src/com/android/email/activity/MessageCompose.java +++ b/src/com/android/email/activity/MessageCompose.java @@ -141,7 +141,6 @@ public class MessageCompose extends Activity implements OnClickListener, OnFocus */ private boolean mSourceMessageProcessed = false; - private ActionBar mActionBar; private TextView mFromView; private MultiAutoCompleteTextView mToView; private MultiAutoCompleteTextView mCcView; @@ -472,7 +471,6 @@ public class MessageCompose extends Activity implements OnClickListener, OnFocus } private void initViews() { - mActionBar = getActionBar(); mFromView = (TextView)findViewById(R.id.from); mToView = (MultiAutoCompleteTextView)findViewById(R.id.to); mCcView = (MultiAutoCompleteTextView)findViewById(R.id.cc); @@ -481,7 +479,7 @@ public class MessageCompose extends Activity implements OnClickListener, OnFocus mSubjectView = (EditText)findViewById(R.id.subject); mMessageContentView = (EditText)findViewById(R.id.message_content); mAttachments = (LinearLayout)findViewById(R.id.attachments); - mAttachmentContainer = (LinearLayout)findViewById(R.id.attachment_container); + mAttachmentContainer = findViewById(R.id.attachment_container); mQuotedTextBar = findViewById(R.id.quoted_text_bar); mIncludeQuotedTextCheckBox = (CheckBox) findViewById(R.id.include_quoted_text); mQuotedText = (WebView)findViewById(R.id.quoted_text); @@ -814,6 +812,8 @@ public class MessageCompose extends Activity implements OnClickListener, OnFocus } /** + * Updates the given message using values from the compose UI. + * * @param message The message to be updated. * @param account the account (used to obtain From: address). * @param hasAttachments true if it has one or more attachment. @@ -840,14 +840,11 @@ public class MessageCompose extends Activity implements OnClickListener, OnFocus // Use the Intent to set flags saying this message is a reply or a forward and save the // unique id of the source message if (mSource != null && mQuotedTextBar.getVisibility() == View.VISIBLE) { - if (ACTION_REPLY.equals(mAction) || ACTION_REPLY_ALL.equals(mAction) - || ACTION_FORWARD.equals(mAction)) { - message.mSourceKey = mSource.mId; - // Get the body of the source message here - message.mHtmlReply = mSource.mHtml; - message.mTextReply = mSource.mText; - } - + // If the quote bar is visible; this must either be a reply or forward + message.mSourceKey = mSource.mId; + // Get the body of the source message here + message.mHtmlReply = mSource.mHtml; + message.mTextReply = mSource.mText; String fromAsString = Address.unpackToString(mSource.mFrom); if (ACTION_FORWARD.equals(mAction)) { message.mFlags |= Message.FLAG_TYPE_FORWARD; @@ -949,7 +946,6 @@ public class MessageCompose extends Activity implements OnClickListener, OnFocus // For any unloaded attachment, set the flag saying we need it loaded boolean hasUnloadedAttachments = false; for (Attachment attachment : attachments) { - if (attachment.mContentUri == null && ((attachment.mFlags & Attachment.FLAG_SMART_FORWARD) != 0)) { attachment.mFlags |= Attachment.FLAG_DOWNLOAD_FORWARD; @@ -1481,13 +1477,12 @@ public class MessageCompose extends Activity implements OnClickListener, OnFocus return URLDecoder.decode(s, "UTF-8"); } - // used by processSourceMessage() + /** + * Displays quoted text from the original email + */ private void displayQuotedText(String textBody, String htmlBody) { - /* Use plain-text body if available, otherwise use HTML body. - * This matches the desired behavior for IMAP/POP where we only send plain-text, - * and for EAS which sends HTML and has no plain-text body. - */ - boolean plainTextFlag = textBody != null; + // Only use plain text if there is no HTML body + boolean plainTextFlag = htmlBody == null; String text = plainTextFlag ? textBody : htmlBody; if (text != null) { text = plainTextFlag ? EmailHtmlUtil.escapeCharacterToDisplay(text) : text; diff --git a/tests/src/com/android/emailcommon/internet/Rfc822OutputTests.java b/tests/src/com/android/emailcommon/internet/Rfc822OutputTests.java index 1d9253f95..dc5999292 100644 --- a/tests/src/com/android/emailcommon/internet/Rfc822OutputTests.java +++ b/tests/src/com/android/emailcommon/internet/Rfc822OutputTests.java @@ -269,6 +269,25 @@ public class Rfc822OutputTests extends ProviderTestCase2 { assertNotNull(header.getField("content-disposition")); } + private static String BODY_TEST1 = "MyTitle" + + "test1"; + private static String BODY_RESULT1 = "test1"; + private static String BODY_TEST2 = "test2
test2"; + private static String BODY_RESULT2 = "test2
test2"; + private static String BODY_TEST3 = "test3"; + private static String BODY_RESULT3 = "test3"; + + public void testGetHtmlBody() { + String actual; + + actual = Rfc822Output.getHtmlBody(BODY_TEST1); + assertEquals(BODY_RESULT1, actual); + actual = Rfc822Output.getHtmlBody(BODY_TEST2); + assertEquals(BODY_RESULT2, actual); + actual = Rfc822Output.getHtmlBody(BODY_TEST3); + assertEquals(BODY_RESULT3, actual); + } + /** * Confirm that the constructed message includes "MIME-VERSION: 1.0" */