From cf9631d4810e57e5384d3af5542889ac46d3f815 Mon Sep 17 00:00:00 2001 From: cketti Date: Mon, 13 Feb 2012 23:11:59 +0100 Subject: [PATCH 1/5] Changed the way we decide what message parts to display --- .../k9/controller/MessagingController.java | 8 +- .../fsck/k9/mail/internet/MimeUtility.java | 890 +++++++++++++++++- src/com/fsck/k9/mail/store/LocalStore.java | 98 +- 3 files changed, 884 insertions(+), 112 deletions(-) diff --git a/src/com/fsck/k9/controller/MessagingController.java b/src/com/fsck/k9/controller/MessagingController.java index 1d536f91f..1b2b47a6b 100644 --- a/src/com/fsck/k9/controller/MessagingController.java +++ b/src/com/fsck/k9/controller/MessagingController.java @@ -1691,9 +1691,7 @@ public class MessagingController implements Runnable { * right now, attachments will be left for later. */ - ArrayList viewables = new ArrayList(); - ArrayList attachments = new ArrayList(); - MimeUtility.collectParts(message, viewables, attachments); + Set viewables = MimeUtility.collectTextParts(message); /* * Now download the parts we're interested in storing. @@ -2816,9 +2814,7 @@ public class MessagingController implements Runnable { try { LocalStore localStore = account.getLocalStore(); - ArrayList viewables = new ArrayList(); - ArrayList attachments = new ArrayList(); - MimeUtility.collectParts(message, viewables, attachments); + List attachments = MimeUtility.collectAttachments(message); for (Part attachment : attachments) { attachment.setBody(null); } diff --git a/src/com/fsck/k9/mail/internet/MimeUtility.java b/src/com/fsck/k9/mail/internet/MimeUtility.java index d8ad792b2..7d2755be9 100644 --- a/src/com/fsck/k9/mail/internet/MimeUtility.java +++ b/src/com/fsck/k9/mail/internet/MimeUtility.java @@ -1,9 +1,14 @@ package com.fsck.k9.mail.internet; +import android.content.Context; import android.util.Log; import com.fsck.k9.K9; +import com.fsck.k9.R; +import com.fsck.k9.helper.HtmlConverter; import com.fsck.k9.mail.*; +import com.fsck.k9.mail.Message.RecipientType; + import org.apache.commons.io.IOUtils; import org.apache.james.mime4j.codec.Base64InputStream; import org.apache.james.mime4j.codec.QuotedPrintableInputStream; @@ -12,7 +17,11 @@ import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.util.ArrayList; +import java.util.Date; +import java.util.HashSet; +import java.util.List; import java.util.Locale; +import java.util.Set; import java.util.regex.Pattern; import java.nio.charset.Charset; import java.nio.charset.IllegalCharsetNameException; @@ -23,6 +32,9 @@ public class MimeUtility { public static final String K9_SETTINGS_MIME_TYPE = "application/x-k9settings"; + private static final String TEXT_DIVIDER = + "------------------------------------------------------------------------"; + /* * http://www.w3schools.com/media/media_mimeref.asp * + @@ -1100,49 +1112,863 @@ public class MimeUtility { return tempBody; } + /** - * An unfortunately named method that makes decisions about a Part (usually a Message) - * as to which of it's children will be "viewable" and which will be attachments. - * The method recursively sorts the viewables and attachments into seperate - * lists for further processing. - * @param part - * @param viewables - * @param attachments - * @throws MessagingException + * Empty base class for the class hierarchy used by + * {@link MimeUtility#extractTextAndAttachments(Context, Message)}. + * + * @see Text + * @see Html + * @see MessageHeader + * @see Alternative */ - public static void collectParts(Part part, ArrayList viewables, - ArrayList attachments) throws MessagingException { - /* - * If the part is Multipart but not alternative it's either mixed or - * something we don't know about, which means we treat it as mixed - * per the spec. We just process it's pieces recursively. + static abstract class Viewable { /* empty */ } + + /** + * Class representing textual parts of a message that aren't marked as attachments. + * + * @see MimeUtility#isPartTextualBody(Part) + */ + static abstract class Textual extends Viewable { + private Part mPart; + + public Textual(Part part) { + mPart = part; + } + + public Part getPart() { + return mPart; + } + } + + /** + * Class representing a {@code text/plain} part of a message. + */ + static class Text extends Textual { + public Text(Part part) { + super(part); + } + } + + /** + * Class representing a {@code text/html} part of a message. + */ + static class Html extends Textual { + public Html(Part part) { + super(part); + } + } + + /** + * Class representing a {@code message/rfc822} part of a message. + * + *

+ * This is used to extract basic header information when the message contents are displayed + * inline. + *

+ */ + static class MessageHeader extends Viewable { + private Part mContainerPart; + private Message mMessage; + + public MessageHeader(Part containerPart, Message message) { + mContainerPart = containerPart; + mMessage = message; + } + + public Part getContainerPart() { + return mContainerPart; + } + + public Message getMessage() { + return mMessage; + } + } + + /** + * Class representing a {@code multipart/alternative} part of a message. + * + *

+ * Only relevant {@code text/plain} and {@code text/html} children are stored in this container + * class. + *

+ */ + static class Alternative extends Viewable { + private List mText; + private List mHtml; + + public Alternative(List text, List html) { + mText = text; + mHtml = html; + } + + public List getText() { + return mText; + } + + public List getHtml() { + return mHtml; + } + } + + /** + * Store viewable text of a message as plain text and HTML, and the parts considered + * attachments. + * + * @see MimeUtility#extractTextAndAttachments(Context, Message) + */ + public static class ViewableContainer { + /** + * The viewable text of the message in plain text. */ - if (part.getBody() instanceof Multipart) { - Multipart mp = (Multipart)part.getBody(); - for (int i = 0; i < mp.getCount(); i++) { - collectParts(mp.getBodyPart(i), viewables, attachments); + public final String text; + + /** + * The viewable text of the message in HTML. + */ + public final String html; + + /** + * The parts of the message considered attachments (everything not viewable). + */ + public final List attachments; + + ViewableContainer(String text, String html, List attachments) { + this.text = text; + this.html = html; + this.attachments = attachments; + } + } + + /** + * Collect attachment parts of a message. + * + * @param message + * The message to collect the attachment parts from. + * + * @return A list of parts regarded as attachments. + * + * @throws MessagingException + * In case of an error. + */ + public static List collectAttachments(Message message) + throws MessagingException { + try { + List attachments = new ArrayList(); + getViewables(message, attachments); + + return attachments; + } catch (Exception e) { + throw new MessagingException("Couldn't collect attachment parts", e); + } + } + + /** + * Collect the viewable textual parts of a message. + * + * @param message + * The message to extract the viewable parts from. + * + * @return A set of viewable parts of the message. + * + * @throws MessagingException + * In case of an error. + */ + public static Set collectTextParts(Message message) + throws MessagingException { + try { + List attachments = new ArrayList(); + + // Collect all viewable parts + List viewables = getViewables(message, attachments); + + // Extract the Part references + return getParts(viewables); + } catch (Exception e) { + throw new MessagingException("Couldn't extract viewable parts", e); + } + } + + /** + * Extract the viewable textual parts of a message and return the rest as attachments. + * + * @param context + * A {@link Context} instance that will be used to get localized strings. + * @param message + * The message to extract the text and attachments from. + * + * @return A {@link ViewableContainer} instance containing the textual parts of the message as + * plain text and HTML, and a list of message parts considered attachments. + * + * @throws MessagingException + * In case of an error. + */ + public static ViewableContainer extractTextAndAttachments(Context context, Message message) + throws MessagingException { + try { + List attachments = new ArrayList(); + + // Collect all viewable parts + List viewables = getViewables(message, attachments); + + /* + * Convert the tree of viewable parts into text and HTML + */ + + // Used to suppress the divider for the first viewable part + boolean hideDivider = true; + + StringBuilder text = new StringBuilder(); + StringBuilder html = new StringBuilder(); + for (Viewable viewable : viewables) { + if (viewable instanceof Textual) { + // This is either a text/plain or text/html part. Fill the variables 'text' and + // 'html', converting between plain text and HTML as necessary. + text.append(buildText(viewable, !hideDivider)); + html.append(buildHtml(viewable, !hideDivider)); + hideDivider = false; + } else if (viewable instanceof MessageHeader) { + MessageHeader header = (MessageHeader) viewable; + Part containerPart = header.getContainerPart(); + Message innerMessage = header.getMessage(); + + addTextDivider(text, containerPart, !hideDivider); + addMessageHeaderText(context, text, innerMessage); + + addHtmlDivider(html, containerPart, !hideDivider); + addMessageHeaderHtml(context, html, innerMessage); + + hideDivider = true; + } else if (viewable instanceof Alternative) { + // Handle multipart/alternative contents + Alternative alternative = (Alternative) viewable; + + /* + * We made sure at least one of text/plain or text/html is present when + * creating the Alternative object. If one part is not present we convert the + * other one to make sure 'text' and 'html' always contain the same text. + */ + List textAlternative = alternative.getText().isEmpty() ? + alternative.getHtml() : alternative.getText(); + List htmlAlternative = alternative.getHtml().isEmpty() ? + alternative.getText() : alternative.getHtml(); + + // Fill the 'text' variable + boolean divider = !hideDivider; + for (Viewable textViewable : textAlternative) { + text.append(buildText(textViewable, divider)); + divider = true; + } + + // Fill the 'html' variable + divider = !hideDivider; + for (Viewable htmlViewable : htmlAlternative) { + html.append(buildHtml(htmlViewable, divider)); + divider = true; + } + hideDivider = false; + } } + + return new ViewableContainer(text.toString(), html.toString(), attachments); + } catch (Exception e) { + throw new MessagingException("Couldn't extract viewable parts", e); } - /* - * If the part is an embedded message we just continue to process - * it, pulling any viewables or attachments into the running list. - */ - else if (part.getBody() instanceof Message) { - Message message = (Message)part.getBody(); - collectParts(message, viewables, attachments); - } - /* - * If the part is HTML and it got this far it's part of a mixed (et - * al) and should be rendered inline. - */ - else if (isPartTextualBody(part)) { - viewables.add(part); + } + + /** + * Traverse the MIME tree of a message an extract viewable parts. + * + * @param part + * The message part to start from. + * @param attachments + * A list that will receive the parts that are considered attachments. + * + * @return A list of {@link Viewable}s. + * + * @throws MessagingException + * In case of an error. + */ + public static List getViewables(Part part, List attachments) throws MessagingException { + List viewables = new ArrayList(); + + Body body = part.getBody(); + if (body instanceof Multipart) { + Multipart multipart = (Multipart) body; + if (part.getMimeType().equalsIgnoreCase("multipart/alternative")) { + /* + * For multipart/alternative parts we try to find a text/plain and a text/html + * child. Everything else we find is put into 'attachments'. + */ + List text = findTextPart(multipart, true); + + Set knownTextParts = getParts(text); + List html = findHtmlPart(multipart, knownTextParts, attachments, true); + + if (!text.isEmpty() || !html.isEmpty()) { + Alternative alternative = new Alternative(text, html); + viewables.add(alternative); + } + } else { + // For all other multipart parts we recurse to grab all viewable children. + int childCount = multipart.getCount(); + for (int i = 0; i < childCount; i++) { + Part bodyPart = multipart.getBodyPart(i); + viewables.addAll(getViewables(bodyPart, attachments)); + } + } + } else if (body instanceof Message && + !("attachment".equalsIgnoreCase(getContentDisposition(part)))) { + /* + * We only care about message/rfc822 parts whose Content-Disposition header has a value + * other than "attachment". + */ + Message message = (Message) body; + + // We add the Message object so we can extract the filename later. + viewables.add(new MessageHeader(part, message)); + + // Recurse to grab all viewable parts and attachments from that message. + viewables.addAll(getViewables(message, attachments)); + } else if (isPartTextualBody(part)) { + /* + * Save text/plain and text/html + */ + String mimeType = part.getMimeType(); + if (mimeType.equalsIgnoreCase("text/plain")) { + Text text = new Text(part); + viewables.add(text); + } else { + Html html = new Html(part); + viewables.add(html); + } } else { + // Everything else is treated as attachment. attachments.add(part); } + return viewables; } + /** + * Search the children of a {@link Multipart} for {@code text/plain} parts. + * + * @param multipart + * The {@code Multipart} to search through. + * @param directChild + * If {@code true}, this method will return after the first {@code text/plain} was + * found. + * + * @return A list of {@link Text} viewables. + * + * @throws MessagingException + * In case of an error. + */ + private static List findTextPart(Multipart multipart, boolean directChild) + throws MessagingException { + List viewables = new ArrayList(); + + int childCount = multipart.getCount(); + for (int i = 0; i < childCount; i++) { + Part part = multipart.getBodyPart(i); + Body body = part.getBody(); + if (body instanceof Multipart) { + Multipart innerMultipart = (Multipart) body; + + /* + * Recurse to find text parts. Since this is a multipart that is a child of a + * multipart/alternative we don't want to stop after the first text/plain part + * we find. This will allow to get all text parts for constructions like this: + * + * 1. multipart/alternative + * 1.1. multipart/mixed + * 1.1.1. text/plain + * 1.1.2. text/plain + * 1.2. text/html + */ + List textViewables = findTextPart(innerMultipart, false); + + if (!textViewables.isEmpty()) { + viewables.addAll(textViewables); + if (directChild) { + break; + } + } + } else if (isPartTextualBody(part) && part.getMimeType().equalsIgnoreCase("text/plain")) { + Text text = new Text(part); + viewables.add(text); + if (directChild) { + break; + } + } + } + + return viewables; + } + + /** + * Search the children of a {@link Multipart} for {@code text/html} parts. + * + *

+ * Every part that is not a {@code text/html} we want to display, we add to 'attachments'. + *

+ * + * @param multipart + * The {@code Multipart} to search through. + * @param knownTextParts + * A set of {@code text/plain} parts that shouldn't be added to 'attachments'. + * @param attachments + * A list that will receive the parts that are considered attachments. + * @param directChild + * If {@code true}, this method will add all {@code text/html} parts except the first + * found to 'attachments'. + * + * @return A list of {@link Text} viewables. + * + * @throws MessagingException + * In case of an error. + */ + private static List findHtmlPart(Multipart multipart, Set knownTextParts, + List attachments, boolean directChild) throws MessagingException { + List viewables = new ArrayList(); + + boolean partFound = false; + int childCount = multipart.getCount(); + for (int i = 0; i < childCount; i++) { + Part part = multipart.getBodyPart(i); + Body body = part.getBody(); + if (body instanceof Multipart) { + Multipart innerMultipart = (Multipart) body; + + if (directChild && partFound) { + // We already found our text/html part. Now we're only looking for attachments. + findAttachments(innerMultipart, knownTextParts, attachments); + } else { + /* + * Recurse to find HTML parts. Since this is a multipart that is a child of a + * multipart/alternative we don't want to stop after the first text/html part + * we find. This will allow to get all text parts for constructions like this: + * + * 1. multipart/alternative + * 1.1. text/plain + * 1.2. multipart/mixed + * 1.2.1. text/html + * 1.2.2. text/html + * 1.3. image/jpeg + */ + List htmlViewables = findHtmlPart(innerMultipart, knownTextParts, + attachments, false); + + if (!htmlViewables.isEmpty()) { + partFound = true; + viewables.addAll(htmlViewables); + } + } + } else if (!(directChild && partFound) && isPartTextualBody(part) && + part.getMimeType().equalsIgnoreCase("text/html")) { + Html html = new Html(part); + viewables.add(html); + partFound = true; + } else if (!knownTextParts.contains(part)) { + // Only add this part as attachment if it's not a viewable text/plain part found + // earlier. + attachments.add(part); + } + } + + return viewables; + } + + /** + * Build a set of message parts for fast lookups. + * + * @param viewables + * A list of {@link Viewable}s containing references to the message parts to include in + * the set. + * + * @return The set of viewable {@code Part}s. + * + * @see MimeUtility#findHtmlPart(Multipart, Set, List, boolean) + * @see MimeUtility#findAttachments(Multipart, Set, List) + */ + private static Set getParts(List viewables) { + Set parts = new HashSet(); + + for (Viewable viewable : viewables) { + if (viewable instanceof Textual) { + parts.add(((Textual) viewable).getPart()); + } else if (viewable instanceof Alternative) { + Alternative alternative = (Alternative) viewable; + parts.addAll(getParts(alternative.getText())); + parts.addAll(getParts(alternative.getHtml())); + } + } + + return parts; + } + + /** + * Traverse the MIME tree and add everything that's not a known text part to 'attachments'. + * + * @param multipart + * The {@link Multipart} to start from. + * @param knownTextParts + * A set of known text parts we don't want to end up in 'attachments'. + * @param attachments + * A list that will receive the parts that are considered attachments. + */ + private static void findAttachments(Multipart multipart, Set knownTextParts, + List attachments) { + int childCount = multipart.getCount(); + for (int i = 0; i < childCount; i++) { + Part part = multipart.getBodyPart(i); + Body body = part.getBody(); + if (body instanceof Multipart) { + Multipart innerMultipart = (Multipart) body; + findAttachments(innerMultipart, knownTextParts, attachments); + } else if (!knownTextParts.contains(part)) { + attachments.add(part); + } + } + } + + /** + * Extract important header values from a message to display inline (plain text version). + * + * @param context + * A {@link Context} instance that will be used to get localized strings. + * @param text + * The {@link StringBuilder} that will receive the (plain text) output. + * @param message + * The message to extract the header values from. + * + * @throws MessagingException + * In case of an error. + */ + private static void addMessageHeaderText(Context context, StringBuilder text, Message message) + throws MessagingException { + // From: + Address[] from = message.getFrom(); + if (from != null && from.length > 0) { + text.append(context.getString(R.string.message_compose_quote_header_from)); + text.append(' '); + text.append(Address.toString(from)); + text.append("\n"); + } + + // To: + Address[] to = message.getRecipients(RecipientType.TO); + if (to != null && to.length > 0) { + text.append(context.getString(R.string.message_compose_quote_header_to)); + text.append(' '); + text.append(Address.toString(to)); + text.append("\n"); + } + + // Cc: + Address[] cc = message.getRecipients(RecipientType.CC); + if (cc != null && cc.length > 0) { + text.append(context.getString(R.string.message_compose_quote_header_cc)); + text.append(' '); + text.append(Address.toString(cc)); + text.append("\n"); + } + + // Date: + Date date = message.getSentDate(); + if (date != null) { + text.append(context.getString(R.string.message_compose_quote_header_send_date)); + text.append(' '); + text.append(date.toString()); + text.append("\n"); + } + + // Subject: + String subject = message.getSubject(); + text.append(context.getString(R.string.message_compose_quote_header_subject)); + text.append(' '); + if (subject == null) { + text.append(context.getString(R.string.general_no_subject)); + } else { + text.append(subject); + } + text.append("\n\n"); + } + + /** + * Extract important header values from a message to display inline (HTML version). + * + * @param context + * A {@link Context} instance that will be used to get localized strings. + * @param html + * The {@link StringBuilder} that will receive the (HTML) output. + * @param message + * The message to extract the header values from. + * + * @throws MessagingException + * In case of an error. + */ + private static void addMessageHeaderHtml(Context context, StringBuilder html, Message message) + throws MessagingException { + + html.append(""); + + // From: + Address[] from = message.getFrom(); + if (from != null && from.length > 0) { + addTableRow(html, context.getString(R.string.message_compose_quote_header_from), + Address.toString(from)); + } + + // To: + Address[] to = message.getRecipients(RecipientType.TO); + if (to != null && to.length > 0) { + addTableRow(html, context.getString(R.string.message_compose_quote_header_to), + Address.toString(to)); + } + + // Cc: + Address[] cc = message.getRecipients(RecipientType.CC); + if (cc != null && cc.length > 0) { + addTableRow(html, context.getString(R.string.message_compose_quote_header_cc), + Address.toString(cc)); + } + + // Date: + Date date = message.getSentDate(); + if (date != null) { + addTableRow(html, context.getString(R.string.message_compose_quote_header_send_date), + date.toString()); + } + + // Subject: + String subject = message.getSubject(); + addTableRow(html, context.getString(R.string.message_compose_quote_header_subject), + (subject == null) ? context.getString(R.string.general_no_subject) : subject); + + html.append("
"); + } + + /** + * Output an HTML table two column row with some hardcoded style. + * + * @param html + * The {@link StringBuilder} that will receive the output. + * @param header + * The string to be put in the {@code TH} element. + * @param value + * The string to be put in the {@code TD} element. + */ + private static void addTableRow(StringBuilder html, String header, String value) { + html.append(""); + html.append(header); + html.append(""); + html.append(""); + html.append(value); + html.append(""); + } + + /** + * Use the contents of a {@link Viewable} to create the plain text to be displayed. + * + *

+ * This will use {@link HtmlConverter#htmlToText(String)} to convert HTML parts to plain text + * if necessary. + *

+ * + * @param viewable + * The viewable part to build the text from. + * @param prependDivider + * {@code true}, if the text divider should be inserted as first element. + * {@code false}, otherwise. + * + * @return The contents of the supplied viewable instance as plain text. + */ + private static StringBuilder buildText(Viewable viewable, boolean prependDivider) + { + StringBuilder text = new StringBuilder(); + if (viewable instanceof Textual) { + Part part = ((Textual)viewable).getPart(); + addTextDivider(text, part, prependDivider); + + String t = getTextFromPart(part); + if (t == null) { + t = ""; + } else if (viewable instanceof Html) { + t = HtmlConverter.htmlToText(t); + } + text.append(t); + } else if (viewable instanceof Alternative) { + // That's odd - an Alternative as child of an Alternative; go ahead and try to use the + // text/plain child; fall-back to the text/html part. + Alternative alternative = (Alternative) viewable; + + List textAlternative = alternative.getText().isEmpty() ? + alternative.getHtml() : alternative.getText(); + + boolean divider = prependDivider; + for (Viewable textViewable : textAlternative) { + text.append(buildText(textViewable, divider)); + divider = true; + } + } + + return text; + } + + /* + * Some constants that are used by addTextDivider() below. + */ + private static final int TEXT_DIVIDER_LENGTH = TEXT_DIVIDER.length(); + private static final String FILENAME_PREFIX = "----- "; + private static final int FILENAME_PREFIX_LENGTH = FILENAME_PREFIX.length(); + private static final String FILENAME_SUFFIX = " "; + private static final int FILENAME_SUFFIX_LENGTH = FILENAME_SUFFIX.length(); + + /** + * Add a plain text divider between two plain text message parts. + * + * @param text + * The {@link StringBuilder} to append the divider to. + * @param part + * The message part that will follow after the divider. This is used to extract the + * part's name. + * @param prependDivider + * {@code true}, if the divider should be appended. {@code false}, otherwise. + */ + private static void addTextDivider(StringBuilder text, Part part, boolean prependDivider) { + if (prependDivider) { + String filename = getPartName(part); + + text.append("\n\n"); + int len = filename.length(); + if (len > 0) { + if (len > TEXT_DIVIDER_LENGTH - FILENAME_PREFIX_LENGTH - FILENAME_SUFFIX_LENGTH) { + filename = filename.substring(0, TEXT_DIVIDER_LENGTH - FILENAME_PREFIX_LENGTH - + FILENAME_SUFFIX_LENGTH - 3) + "..."; + } + text.append(FILENAME_PREFIX); + text.append(filename); + text.append(TEXT_DIVIDER.substring(0, TEXT_DIVIDER_LENGTH - + FILENAME_PREFIX_LENGTH - filename.length() - FILENAME_SUFFIX_LENGTH)); + text.append(' '); + } else { + text.append(TEXT_DIVIDER); + } + text.append("\n\n"); + } + } + + /** + * Use the contents of a {@link Viewable} to create the HTML to be displayed. + * + *

+ * This will use {@link HtmlConverter#textToHtml(String)} to convert plain text parts to HTML + * if necessary. + *

+ * + * @param viewable + * The viewable part to build the HTML from. + * @param prependDivider + * {@code true}, if the HTML divider should be inserted as first element. + * {@code false}, otherwise. + * + * @return The contents of the supplied viewable instance as HTML. + */ + private static StringBuilder buildHtml(Viewable viewable, boolean prependDivider) + { + StringBuilder html = new StringBuilder(); + if (viewable instanceof Textual) { + Part part = ((Textual)viewable).getPart(); + addHtmlDivider(html, part, prependDivider); + + String t = getTextFromPart(part); + if (t == null) { + t = ""; + } else if (viewable instanceof Text) { + t = HtmlConverter.textToHtml(t); + } + html.append(t); + } else if (viewable instanceof Alternative) { + // That's odd - an Alternative as child of an Alternative; go ahead and try to use the + // text/html child; fall-back to the text/plain part. + Alternative alternative = (Alternative) viewable; + + List htmlAlternative = alternative.getHtml().isEmpty() ? + alternative.getText() : alternative.getHtml(); + + boolean divider = prependDivider; + for (Viewable htmlViewable : htmlAlternative) { + html.append(buildHtml(htmlViewable, divider)); + divider = true; + } + } + + return html; + } + + /** + * Add an HTML divider between two HTML message parts. + * + * @param html + * The {@link StringBuilder} to append the divider to. + * @param part + * The message part that will follow after the divider. This is used to extract the + * part's name. + * @param prependDivider + * {@code true}, if the divider should be appended. {@code false}, otherwise. + */ + private static void addHtmlDivider(StringBuilder html, Part part, boolean prependDivider) { + if (prependDivider) { + String filename = getPartName(part); + + html.append("

"); + html.append(filename); + html.append("

"); + } + } + + /** + * Get the name of the message part. + * + * @param part + * The part to get the name for. + * + * @return The (file)name of the part if available. An empty string, otherwise. + */ + private static String getPartName(Part part) { + try { + String disposition = part.getDisposition(); + if (disposition != null) { + String name = MimeUtility.getHeaderParameter(disposition, "filename"); + return (name == null) ? "" : name; + } + } + catch (MessagingException e) { /* ignore */ } + + return ""; + } + + /** + * Get the value of the {@code Content-Disposition} header. + * + * @param part + * The message part to read the header from. + * + * @return The value of the {@code Content-Disposition} header if available. {@code null}, + * otherwise. + */ + private static String getContentDisposition(Part part) { + try { + String disposition = part.getDisposition(); + if (disposition != null) { + return MimeUtility.getHeaderParameter(disposition, null); + } + } + catch (MessagingException e) { /* ignore */ } + + return null; + } public static Boolean isPartTextualBody(Part part) throws MessagingException { String disposition = part.getDisposition(); diff --git a/src/com/fsck/k9/mail/store/LocalStore.java b/src/com/fsck/k9/mail/store/LocalStore.java index 049f4222e..8f2fbb021 100644 --- a/src/com/fsck/k9/mail/store/LocalStore.java +++ b/src/com/fsck/k9/mail/store/LocalStore.java @@ -55,6 +55,7 @@ import com.fsck.k9.mail.internet.MimeHeader; import com.fsck.k9.mail.internet.MimeMessage; import com.fsck.k9.mail.internet.MimeMultipart; import com.fsck.k9.mail.internet.MimeUtility; +import com.fsck.k9.mail.internet.MimeUtility.ViewableContainer; import com.fsck.k9.mail.internet.TextBody; import com.fsck.k9.mail.store.LockableDatabase.DbCallback; import com.fsck.k9.mail.store.LockableDatabase.WrappedException; @@ -2099,45 +2100,14 @@ public class LocalStore extends Store implements Serializable { deleteAttachments(message.getUid()); } - ArrayList viewables = new ArrayList(); - ArrayList attachments = new ArrayList(); - MimeUtility.collectParts(message, viewables, attachments); + ViewableContainer container = + MimeUtility.extractTextAndAttachments(mApplication, message); - StringBuilder sbHtml = new StringBuilder(); - StringBuilder sbText = new StringBuilder(); - for (Part viewable : viewables) { - try { - String text = MimeUtility.getTextFromPart(viewable); + List attachments = container.attachments; + String text = container.text; + String html = container.html; - /* - * Small hack to make sure the string "null" doesn't end up - * in one of the StringBuilders. - */ - if (text == null) { - text = ""; - } - - /* - * Anything with MIME type text/html will be stored as such. Anything - * else will be stored as text/plain. - */ - if (viewable.getMimeType().equalsIgnoreCase("text/html")) { - sbHtml.append(text); - } else { - sbText.append(text); - } - } catch (Exception e) { - throw new MessagingException("Unable to get text for message part", e); - } - } - - String text = sbText.toString(); - String html = markupContent(text, sbHtml.toString()); String preview = calculateContentPreview(text); - // If we couldn't generate a reasonable preview from the text part, try doing it with the HTML part. - if (preview == null || preview.length() == 0) { - preview = calculateContentPreview(HtmlConverter.htmlToText(html)); - } try { ContentValues cv = new ContentValues(); @@ -2215,49 +2185,17 @@ public class LocalStore extends Store implements Serializable { @Override public Void doDbWork(final SQLiteDatabase db) throws WrappedException, UnavailableStorageException { try { - ArrayList viewables = new ArrayList(); - ArrayList attachments = new ArrayList(); - message.buildMimeRepresentation(); - MimeUtility.collectParts(message, viewables, attachments); + ViewableContainer container = + MimeUtility.extractTextAndAttachments(mApplication, message); - StringBuilder sbHtml = new StringBuilder(); - StringBuilder sbText = new StringBuilder(); - for (int i = 0, count = viewables.size(); i < count; i++) { - Part viewable = viewables.get(i); - try { - String text = MimeUtility.getTextFromPart(viewable); + List attachments = container.attachments; + String text = container.text; + String html = container.html; - /* - * Small hack to make sure the string "null" doesn't end up - * in one of the StringBuilders. - */ - if (text == null) { - text = ""; - } - - /* - * Anything with MIME type text/html will be stored as such. Anything - * else will be stored as text/plain. - */ - if (viewable.getMimeType().equalsIgnoreCase("text/html")) { - sbHtml.append(text); - } else { - sbText.append(text); - } - } catch (Exception e) { - throw new MessagingException("Unable to get text for message part", e); - } - } - - String text = sbText.toString(); - String html = markupContent(text, sbHtml.toString()); String preview = calculateContentPreview(text); - // If we couldn't generate a reasonable preview from the text part, try doing it with the HTML part. - if (preview == null || preview.length() == 0) { - preview = calculateContentPreview(HtmlConverter.htmlToText(html)); - } + try { db.execSQL("UPDATE messages SET " + "uid = ?, subject = ?, sender_list = ?, date = ?, flags = ?, " @@ -2391,6 +2329,18 @@ public class LocalStore extends Store implements Serializable { Body body = attachment.getBody(); if (body instanceof LocalAttachmentBody) { contentUri = ((LocalAttachmentBody) body).getContentUri(); + } else if (body instanceof Message) { + // It's a message, so use Message.writeTo() to output the + // message including all children. + Message message = (Message) body; + tempAttachmentFile = File.createTempFile("att", null, attachmentDirectory); + FileOutputStream out = new FileOutputStream(tempAttachmentFile); + try { + message.writeTo(out); + } finally { + out.close(); + } + size = (int) (tempAttachmentFile.length() & 0x7FFFFFFFL); } else { /* * If the attachment has a body we're expected to save it into the local store From 8ce78408c2b66bb09ae41c8a8cfdc220db64ddbc Mon Sep 17 00:00:00 2001 From: cketti Date: Fri, 17 Feb 2012 19:40:58 +0100 Subject: [PATCH 2/5] Fixed HTML generation in MimeUtility.extractTextAndAttachments() --- src/com/fsck/k9/activity/MessageCompose.java | 2 +- src/com/fsck/k9/helper/HtmlConverter.java | 72 +++++++++++++++---- .../fsck/k9/mail/internet/MimeUtility.java | 12 ++-- src/com/fsck/k9/mail/store/LocalStore.java | 13 +--- 4 files changed, 68 insertions(+), 31 deletions(-) diff --git a/src/com/fsck/k9/activity/MessageCompose.java b/src/com/fsck/k9/activity/MessageCompose.java index 8b85cfd95..927078cf2 100644 --- a/src/com/fsck/k9/activity/MessageCompose.java +++ b/src/com/fsck/k9/activity/MessageCompose.java @@ -2709,7 +2709,7 @@ public class MessageCompose extends K9Activity implements OnClickListener, OnFoc if (part != null) { if (K9.DEBUG) Log.d(K9.LOG_TAG, "getBodyTextFromMessage: HTML requested, text found."); - return HtmlConverter.textToHtml(MimeUtility.getTextFromPart(part)); + return HtmlConverter.textToHtml(MimeUtility.getTextFromPart(part), true); } } else if (format == MessageFormat.TEXT) { // Text takes precedence, then html. diff --git a/src/com/fsck/k9/helper/HtmlConverter.java b/src/com/fsck/k9/helper/HtmlConverter.java index 9cf3c38df..e0bf6b720 100644 --- a/src/com/fsck/k9/helper/HtmlConverter.java +++ b/src/com/fsck/k9/helper/HtmlConverter.java @@ -125,19 +125,41 @@ public class HtmlConverter { private static final int MAX_SMART_HTMLIFY_MESSAGE_LENGTH = 1024 * 256 ; + public static final String getHtmlHeader() { + return ""; + } + + public static final String getHtmlFooter() { + return ""; + } + /** - * Naively convert a text string into an HTML document. This method avoids using regular expressions on the entire - * message body to save memory. - * @param text Plain text string. + * Naively convert a text string into an HTML document. + * + *

+ * This method avoids using regular expressions on the entire message body to save memory. + *

+ * + * @param text + * Plain text string. + * @param useHtmlTag + * If {@code true} this method adds headers and footers to create a proper HTML + * document. + * * @return HTML string. */ - private static String simpleTextToHtml(String text) { + private static String simpleTextToHtml(String text, boolean useHtmlTag) { // Encode HTML entities to make sure we don't display something evil. text = TextUtils.htmlEncode(text); StringReader reader = new StringReader(text); StringBuilder buff = new StringBuilder(text.length() + TEXT_TO_HTML_EXTRA_BUFFER_LENGTH); - buff.append(""); + + if (useHtmlTag) { + buff.append(getHtmlHeader()); + } + + buff.append(htmlifyMessageHeader()); int c; try { @@ -159,25 +181,39 @@ public class HtmlConverter { Log.e(K9.LOG_TAG, "Could not read string to convert text to HTML:", e); } - buff.append(""); + buff.append(htmlifyMessageFooter()); + + if (useHtmlTag) { + buff.append(getHtmlFooter()); + } return buff.toString(); } /** - * Convert a text string into an HTML document. Attempts to do smart replacement for large - * documents to prevent OOM errors. This method adds headers and footers to create a proper HTML - * document. To convert to a fragment, use {@link #textToHtmlFragment(String)}. - * @param text Plain text string. + * Convert a text string into an HTML document. + * + *

+ * Attempts to do smart replacement for large documents to prevent OOM errors. This method + * optionally adds headers and footers to create a proper HTML document. To convert to a + * fragment, use {@link #textToHtmlFragment(String)}. + *

+ * + * @param text + * Plain text string. + * @param useHtmlTag + * If {@code true} this method adds headers and footers to create a proper HTML + * document. + * * @return HTML string. */ - public static String textToHtml(String text) { + public static String textToHtml(String text, boolean useHtmlTag) { // Our HTMLification code is somewhat memory intensive // and was causing lots of OOM errors on the market // if the message is big and plain text, just do // a trivial htmlification if (text.length() > MAX_SMART_HTMLIFY_MESSAGE_LENGTH) { - return simpleTextToHtml(text); + return simpleTextToHtml(text, useHtmlTag); } StringReader reader = new StringReader(text); StringBuilder buff = new StringBuilder(text.length() + TEXT_TO_HTML_EXTRA_BUFFER_LENGTH); @@ -221,11 +257,19 @@ public class HtmlConverter { text = text.replaceAll("(?m)(\r\n|\n|\r){4,}", "\n\n"); StringBuffer sb = new StringBuffer(text.length() + TEXT_TO_HTML_EXTRA_BUFFER_LENGTH); - sb.append(""); + + if (useHtmlTag) { + sb.append(getHtmlHeader()); + } + sb.append(htmlifyMessageHeader()); linkifyText(text, sb); sb.append(htmlifyMessageFooter()); - sb.append(""); + + if (useHtmlTag) { + sb.append(getHtmlFooter()); + } + text = sb.toString(); return text; diff --git a/src/com/fsck/k9/mail/internet/MimeUtility.java b/src/com/fsck/k9/mail/internet/MimeUtility.java index 7d2755be9..74e3ce974 100644 --- a/src/com/fsck/k9/mail/internet/MimeUtility.java +++ b/src/com/fsck/k9/mail/internet/MimeUtility.java @@ -1112,7 +1112,7 @@ public class MimeUtility { return tempBody; } - + /** * Empty base class for the class hierarchy used by * {@link MimeUtility#extractTextAndAttachments(Context, Message)}. @@ -1320,6 +1320,8 @@ public class MimeUtility { StringBuilder text = new StringBuilder(); StringBuilder html = new StringBuilder(); + html.append(HtmlConverter.getHtmlHeader()); + for (Viewable viewable : viewables) { if (viewable instanceof Textual) { // This is either a text/plain or text/html part. Fill the variables 'text' and @@ -1370,6 +1372,8 @@ public class MimeUtility { } } + html.append(HtmlConverter.getHtmlFooter()); + return new ViewableContainer(text.toString(), html.toString(), attachments); } catch (Exception e) { throw new MessagingException("Couldn't extract viewable parts", e); @@ -1863,8 +1867,8 @@ public class MimeUtility { * Use the contents of a {@link Viewable} to create the HTML to be displayed. * *

- * This will use {@link HtmlConverter#textToHtml(String)} to convert plain text parts to HTML - * if necessary. + * This will use {@link HtmlConverter#textToHtml(String, boolean)} to convert plain text parts + * to HTML if necessary. *

* * @param viewable @@ -1886,7 +1890,7 @@ public class MimeUtility { if (t == null) { t = ""; } else if (viewable instanceof Text) { - t = HtmlConverter.textToHtml(t); + t = HtmlConverter.textToHtml(t, false); } html.append(t); } else if (viewable instanceof Alternative) { diff --git a/src/com/fsck/k9/mail/store/LocalStore.java b/src/com/fsck/k9/mail/store/LocalStore.java index 8f2fbb021..e59eeeef4 100644 --- a/src/com/fsck/k9/mail/store/LocalStore.java +++ b/src/com/fsck/k9/mail/store/LocalStore.java @@ -2105,7 +2105,7 @@ public class LocalStore extends Store implements Serializable { List attachments = container.attachments; String text = container.text; - String html = container.html; + String html = HtmlConverter.convertEmoji2Img(container.html); String preview = calculateContentPreview(text); @@ -2754,17 +2754,6 @@ public class LocalStore extends Store implements Serializable { } - public String markupContent(String text, String html) { - if (text.length() > 0 && html.length() == 0) { - html = HtmlConverter.textToHtml(text); - } - - html = HtmlConverter.convertEmoji2Img(html); - - return html; - } - - @Override public boolean isInTopGroup() { return mInTopGroup; From b9803ece1942accf30ee7041670de492063f5432 Mon Sep 17 00:00:00 2001 From: cketti Date: Sat, 18 Feb 2012 00:04:09 +0100 Subject: [PATCH 3/5] Fixed divider before text part with filename --- src/com/fsck/k9/mail/internet/MimeUtility.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/com/fsck/k9/mail/internet/MimeUtility.java b/src/com/fsck/k9/mail/internet/MimeUtility.java index 74e3ce974..5fd3c047d 100644 --- a/src/com/fsck/k9/mail/internet/MimeUtility.java +++ b/src/com/fsck/k9/mail/internet/MimeUtility.java @@ -1853,9 +1853,9 @@ public class MimeUtility { } text.append(FILENAME_PREFIX); text.append(filename); + text.append(FILENAME_SUFFIX); text.append(TEXT_DIVIDER.substring(0, TEXT_DIVIDER_LENGTH - FILENAME_PREFIX_LENGTH - filename.length() - FILENAME_SUFFIX_LENGTH)); - text.append(' '); } else { text.append(TEXT_DIVIDER); } From 03d4cee14af8d8520a5fc6a3f124ae6aa85dab8f Mon Sep 17 00:00:00 2001 From: cketti Date: Sat, 18 Feb 2012 00:25:14 +0100 Subject: [PATCH 4/5] Added tests for MimeUtility.extractTextAndAttachments() --- .../fsck/k9/mail/internet/ViewablesTest.java | 188 ++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 tests/src/com/fsck/k9/mail/internet/ViewablesTest.java diff --git a/tests/src/com/fsck/k9/mail/internet/ViewablesTest.java b/tests/src/com/fsck/k9/mail/internet/ViewablesTest.java new file mode 100644 index 000000000..ded79c3d5 --- /dev/null +++ b/tests/src/com/fsck/k9/mail/internet/ViewablesTest.java @@ -0,0 +1,188 @@ +package com.fsck.k9.mail.internet; + +import java.util.Date; +import java.util.Locale; +import java.util.TimeZone; +import android.test.AndroidTestCase; +import com.fsck.k9.mail.Address; +import com.fsck.k9.mail.MessagingException; +import com.fsck.k9.mail.Message.RecipientType; +import com.fsck.k9.mail.internet.MimeUtility.ViewableContainer; + +public class ViewablesTest extends AndroidTestCase { + + public void testSimplePlainTextMessage() throws MessagingException { + String bodyText = "K-9 Mail rocks :>"; + + // Create text/plain body + TextBody body = new TextBody(bodyText); + + // Create message + MimeMessage message = new MimeMessage(); + message.setBody(body); + + // Extract text + ViewableContainer container = MimeUtility.extractTextAndAttachments(getContext(), message); + + String expectedText = bodyText; + String expectedHtml = + "" + + "
" +
+                "K-9 Mail rocks :>" +
+                "
" + + ""; + + assertEquals(expectedText, container.text); + assertEquals(expectedHtml, container.html); + } + + public void testSimpleHtmlMessage() throws MessagingException { + String bodyText = "K-9 Mail rocks :>"; + + // Create text/plain body + TextBody body = new TextBody(bodyText); + + // Create message + MimeMessage message = new MimeMessage(); + message.setHeader("Content-Type", "text/html"); + message.setBody(body); + + // Extract text + ViewableContainer container = MimeUtility.extractTextAndAttachments(getContext(), message); + + String expectedText = "K-9 Mail rocks :>"; + String expectedHtml = + "" + + bodyText + + ""; + + assertEquals(expectedText, container.text); + assertEquals(expectedHtml, container.html); + } + + public void testMultipartPlainTextMessage() throws MessagingException { + String bodyText1 = "text body 1"; + String bodyText2 = "text body 2"; + + // Create text/plain bodies + TextBody body1 = new TextBody(bodyText1); + TextBody body2 = new TextBody(bodyText2); + + // Create multipart/mixed part + MimeMultipart multipart = new MimeMultipart(); + MimeBodyPart bodyPart1 = new MimeBodyPart(body1, "text/plain"); + MimeBodyPart bodyPart2 = new MimeBodyPart(body2, "text/plain"); + multipart.addBodyPart(bodyPart1); + multipart.addBodyPart(bodyPart2); + + // Create message + MimeMessage message = new MimeMessage(); + message.setBody(multipart); + + // Extract text + ViewableContainer container = MimeUtility.extractTextAndAttachments(getContext(), message); + + String expectedText = + bodyText1 + "\n\n" + + "------------------------------------------------------------------------\n\n" + + bodyText2; + String expectedHtml = + "" + + "
" +
+                bodyText1 +
+                "
" + + "

" + + "
" +
+                bodyText2 +
+                "
" + + ""; + + + assertEquals(expectedText, container.text); + assertEquals(expectedHtml, container.html); + } + + public void testTextPlusRfc822Message() throws MessagingException { + Locale.setDefault(Locale.US); + TimeZone.setDefault(TimeZone.getTimeZone("UTC")); + + String bodyText = "Some text here"; + String innerBodyText = "Hey there. I'm inside a message/rfc822 (inline) attachment."; + + // Create text/plain body + TextBody textBody = new TextBody(bodyText); + + // Create inner text/plain body + TextBody innerBody = new TextBody(innerBodyText); + + // Create message/rfc822 body + MimeMessage innerMessage = new MimeMessage(); + innerMessage.addSentDate(new Date(112, 02, 17)); + innerMessage.setRecipients(RecipientType.TO, new Address[] { new Address("to@example.com") }); + innerMessage.setSubject("Subject"); + innerMessage.setFrom(new Address("from@example.com")); + innerMessage.setBody(innerBody); + + // Create multipart/mixed part + MimeMultipart multipart = new MimeMultipart(); + MimeBodyPart bodyPart1 = new MimeBodyPart(textBody, "text/plain"); + MimeBodyPart bodyPart2 = new MimeBodyPart(innerMessage, "message/rfc822"); + bodyPart2.setHeader("Content-Disposition", "inline; filename=\"message.eml\""); + multipart.addBodyPart(bodyPart1); + multipart.addBodyPart(bodyPart2); + + // Create message + MimeMessage message = new MimeMessage(); + message.setBody(multipart); + + // Extract text + ViewableContainer container = MimeUtility.extractTextAndAttachments(getContext(), message); + + String expectedText = + bodyText + + "\n\n" + + "----- message.eml ------------------------------------------------------" + + "\n\n" + + "From: from@example.com" + "\n" + + "To: to@example.com" + "\n" + + "Sent: Sat Mar 17 00:00:00 GMT+00:00 2012" + "\n" + + "Subject: Subject" + "\n" + + "\n" + + innerBodyText; + String expectedHtml = + "" + + "
" +
+                bodyText +
+                "
" + + "

message.eml

" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "
From:from@example.com
To:to@example.com
Sent:Sat Mar 17 00:00:00 GMT+00:00 2012
Subject:Subject
" + + "
" +
+                innerBodyText +
+                "
" + + ""; + + assertEquals(expectedText, container.text); + assertEquals(expectedHtml, container.html); + } +} From 4adfc51339066234af6d14218acffd02b379c0d5 Mon Sep 17 00:00:00 2001 From: cketti Date: Sat, 18 Feb 2012 00:44:24 +0100 Subject: [PATCH 5/5] Use HtmlConverter.convertEmoji2Img() in LocalFolder.updateMessage() --- src/com/fsck/k9/mail/store/LocalStore.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/com/fsck/k9/mail/store/LocalStore.java b/src/com/fsck/k9/mail/store/LocalStore.java index e59eeeef4..fe830ff7b 100644 --- a/src/com/fsck/k9/mail/store/LocalStore.java +++ b/src/com/fsck/k9/mail/store/LocalStore.java @@ -2192,7 +2192,7 @@ public class LocalStore extends Store implements Serializable { List attachments = container.attachments; String text = container.text; - String html = container.html; + String html = HtmlConverter.convertEmoji2Img(container.html); String preview = calculateContentPreview(text);