package com.fsck.k9.ui.compose; import java.util.Map; import android.content.res.Resources; import android.os.Bundle; import timber.log.Timber; import com.fsck.k9.Account; import com.fsck.k9.Account.MessageFormat; import com.fsck.k9.Account.QuoteStyle; import com.fsck.k9.K9; import com.fsck.k9.activity.MessageCompose; import com.fsck.k9.activity.MessageCompose.Action; import com.fsck.k9.message.extractors.BodyTextExtractor; import com.fsck.k9.message.html.HtmlConverter; import com.fsck.k9.message.quote.HtmlQuoteCreator; import com.fsck.k9.message.quote.TextQuoteCreator; import com.fsck.k9.message.signature.HtmlSignatureRemover; import com.fsck.k9.mail.MessagingException; import com.fsck.k9.mail.Part; import com.fsck.k9.mail.internet.MessageExtractor; import com.fsck.k9.mail.internet.MimeUtility; import com.fsck.k9.mailstore.AttachmentResolver; import com.fsck.k9.mailstore.MessageViewInfo; import com.fsck.k9.message.IdentityField; import com.fsck.k9.message.quote.InsertableHtmlContent; import com.fsck.k9.message.MessageBuilder; import com.fsck.k9.message.QuotedTextMode; import com.fsck.k9.message.SimpleMessageFormat; import com.fsck.k9.message.signature.TextSignatureRemover; public class QuotedMessagePresenter { private static final String STATE_KEY_HTML_QUOTE = "state:htmlQuote"; private static final String STATE_KEY_QUOTED_TEXT_MODE = "state:quotedTextShown"; private static final String STATE_KEY_QUOTED_TEXT_FORMAT = "state:quotedTextFormat"; private static final String STATE_KEY_FORCE_PLAIN_TEXT = "state:forcePlainText"; private static final int UNKNOWN_LENGTH = 0; private final QuotedMessageMvpView view; private final MessageCompose messageCompose; private final Resources resources; private QuotedTextMode quotedTextMode; private QuoteStyle quoteStyle; private boolean forcePlainText; private SimpleMessageFormat quotedTextFormat; private InsertableHtmlContent quotedHtmlContent; private Account account; public QuotedMessagePresenter( MessageCompose messageCompose, QuotedMessageMvpView quotedMessageMvpView, Account account) { this.messageCompose = messageCompose; this.resources = messageCompose.getResources(); this.view = quotedMessageMvpView; onSwitchAccount(account); quotedTextMode = QuotedTextMode.NONE; quoteStyle = account.getQuoteStyle(); quotedMessageMvpView.setOnClickPresenter(this); } public void onSwitchAccount(Account account) { this.account = account; } public void showOrHideQuotedText(QuotedTextMode mode) { quotedTextMode = mode; view.showOrHideQuotedText(mode, quotedTextFormat); } /** * Build and populate the UI with the quoted message. * * @param showQuotedText * {@code true} if the quoted text should be shown, {@code false} otherwise. */ public void populateUIWithQuotedMessage(MessageViewInfo messageViewInfo, boolean showQuotedText, Action action) throws MessagingException { MessageFormat origMessageFormat = account.getMessageFormat(); if (forcePlainText || origMessageFormat == MessageFormat.TEXT) { // Use plain text for the quoted message quotedTextFormat = SimpleMessageFormat.TEXT; } else if (origMessageFormat == MessageFormat.AUTO) { // Figure out which message format to use for the quoted text by looking if the source // message contains a text/html part. If it does, we use that. quotedTextFormat = (MimeUtility.findFirstPartByMimeType(messageViewInfo.rootPart, "text/html") == null) ? SimpleMessageFormat.TEXT : SimpleMessageFormat.HTML; } else { quotedTextFormat = SimpleMessageFormat.HTML; } // Handle the original message in the reply // If we already have sourceMessageBody, use that. It's pre-populated if we've got crypto going on. String content = BodyTextExtractor.getBodyTextFromMessage(messageViewInfo.rootPart, quotedTextFormat); if (quotedTextFormat == SimpleMessageFormat.HTML) { // Strip signature. // closing tags such as </div>, </span>, </table>, </pre> will be cut off. if (account.isStripSignature() && (action == Action.REPLY || action == Action.REPLY_ALL)) { content = HtmlSignatureRemover.stripSignature(content); } // Add the HTML reply header to the top of the content. quotedHtmlContent = HtmlQuoteCreator.quoteOriginalHtmlMessage( resources, messageViewInfo.message, content, quoteStyle); // Load the message with the reply header. TODO replace with MessageViewInfo data view.setQuotedHtml(quotedHtmlContent.getQuotedContent(), AttachmentResolver.createFromPart(messageViewInfo.rootPart)); // TODO: Also strip the signature from the text/plain part view.setQuotedText(TextQuoteCreator.quoteOriginalTextMessage(resources, messageViewInfo.message, BodyTextExtractor.getBodyTextFromMessage(messageViewInfo.rootPart, SimpleMessageFormat.TEXT), quoteStyle, account.getQuotePrefix())); } else if (quotedTextFormat == SimpleMessageFormat.TEXT) { if (account.isStripSignature() && (action == Action.REPLY || action == Action.REPLY_ALL)) { content = TextSignatureRemover.stripSignature(content); } view.setQuotedText(TextQuoteCreator.quoteOriginalTextMessage( resources, messageViewInfo.message, content, quoteStyle, account.getQuotePrefix())); } if (showQuotedText) { showOrHideQuotedText(QuotedTextMode.SHOW); } else { showOrHideQuotedText(QuotedTextMode.HIDE); } } public void builderSetProperties(MessageBuilder builder) { builder.setQuoteStyle(quoteStyle) // TODO avoid using a getter from the view! .setQuotedText(view.getQuotedText()) .setQuotedTextMode(quotedTextMode) .setQuotedHtmlContent(quotedHtmlContent) .setReplyAfterQuote(account.isReplyAfterQuote()); } public void onSaveInstanceState(Bundle outState) { outState.putSerializable(STATE_KEY_QUOTED_TEXT_MODE, quotedTextMode); outState.putSerializable(STATE_KEY_HTML_QUOTE, quotedHtmlContent); outState.putSerializable(STATE_KEY_QUOTED_TEXT_FORMAT, quotedTextFormat); outState.putBoolean(STATE_KEY_FORCE_PLAIN_TEXT, forcePlainText); } public void onRestoreInstanceState(Bundle savedInstanceState) { quotedHtmlContent = (InsertableHtmlContent) savedInstanceState.getSerializable(STATE_KEY_HTML_QUOTE); if (quotedHtmlContent != null && quotedHtmlContent.getQuotedContent() != null) { // we don't have the part here, but inline-displayed images are cached by the webview view.setQuotedHtml(quotedHtmlContent.getQuotedContent(), null); } quotedTextFormat = (SimpleMessageFormat) savedInstanceState.getSerializable( STATE_KEY_QUOTED_TEXT_FORMAT); forcePlainText = savedInstanceState.getBoolean(STATE_KEY_FORCE_PLAIN_TEXT); showOrHideQuotedText( (QuotedTextMode) savedInstanceState.getSerializable(STATE_KEY_QUOTED_TEXT_MODE)); } public void processMessageToForward(MessageViewInfo messageViewInfo) throws MessagingException { quoteStyle = QuoteStyle.HEADER; populateUIWithQuotedMessage(messageViewInfo, true, Action.FORWARD); } public void initFromReplyToMessage(MessageViewInfo messageViewInfo, Action action) throws MessagingException { populateUIWithQuotedMessage(messageViewInfo, account.isDefaultQuotedTextShown(), action); } public void processDraftMessage(MessageViewInfo messageViewInfo, Map<IdentityField, String> k9identity) { quoteStyle = k9identity.get(IdentityField.QUOTE_STYLE) != null ? QuoteStyle.valueOf(k9identity.get(IdentityField.QUOTE_STYLE)) : account.getQuoteStyle(); int cursorPosition = 0; if (k9identity.containsKey(IdentityField.CURSOR_POSITION)) { try { cursorPosition = Integer.parseInt(k9identity.get(IdentityField.CURSOR_POSITION)); } catch (Exception e) { Timber.e(e, "Could not parse cursor position for MessageCompose; continuing."); } } String showQuotedTextMode; if (k9identity.containsKey(IdentityField.QUOTED_TEXT_MODE)) { showQuotedTextMode = k9identity.get(IdentityField.QUOTED_TEXT_MODE); } else { showQuotedTextMode = "NONE"; } int bodyLength = k9identity.get(IdentityField.LENGTH) != null ? Integer.valueOf(k9identity.get(IdentityField.LENGTH)) : UNKNOWN_LENGTH; int bodyOffset = k9identity.get(IdentityField.OFFSET) != null ? Integer.valueOf(k9identity.get(IdentityField.OFFSET)) : UNKNOWN_LENGTH; Integer bodyFooterOffset = k9identity.get(IdentityField.FOOTER_OFFSET) != null ? Integer.valueOf(k9identity.get(IdentityField.FOOTER_OFFSET)) : null; Integer bodyPlainLength = k9identity.get(IdentityField.PLAIN_LENGTH) != null ? Integer.valueOf(k9identity.get(IdentityField.PLAIN_LENGTH)) : null; Integer bodyPlainOffset = k9identity.get(IdentityField.PLAIN_OFFSET) != null ? Integer.valueOf(k9identity.get(IdentityField.PLAIN_OFFSET)) : null; QuotedTextMode quotedMode; try { quotedMode = QuotedTextMode.valueOf(showQuotedTextMode); } catch (Exception e) { quotedMode = QuotedTextMode.NONE; } // Always respect the user's current composition format preference, even if the // draft was saved in a different format. // TODO - The current implementation doesn't allow a user in HTML mode to edit a draft that wasn't saved with K9mail. String messageFormatString = k9identity.get(IdentityField.MESSAGE_FORMAT); MessageFormat messageFormat = null; if (messageFormatString != null) { try { messageFormat = MessageFormat.valueOf(messageFormatString); } catch (Exception e) { /* do nothing */ } } if (messageFormat == null) { // This message probably wasn't created by us. The exception is legacy // drafts created before the advent of HTML composition. In those cases, // we'll display the whole message (including the quoted part) in the // composition window. If that's the case, try and convert it to text to // match the behavior in text mode. view.setMessageContentCharacters( BodyTextExtractor.getBodyTextFromMessage(messageViewInfo.message, SimpleMessageFormat.TEXT)); forcePlainText = true; showOrHideQuotedText(quotedMode); return; } if (messageFormat == MessageFormat.HTML) { Part part = MimeUtility.findFirstPartByMimeType(messageViewInfo.message, "text/html"); if (part != null) { // Shouldn't happen if we were the one who saved it. quotedTextFormat = SimpleMessageFormat.HTML; String text = MessageExtractor.getTextFromPart(part); Timber.d("Loading message with offset %d, length %d. Text length is %d.", bodyOffset, bodyLength, text.length()); if (bodyOffset + bodyLength > text.length()) { // The draft was edited outside of K-9 Mail? Timber.d("The identity field from the draft contains an invalid LENGTH/OFFSET"); bodyOffset = 0; bodyLength = 0; } // Grab our reply text. String bodyText = text.substring(bodyOffset, bodyOffset + bodyLength); view.setMessageContentCharacters(HtmlConverter.htmlToText(bodyText)); // Regenerate the quoted html without our user content in it. StringBuilder quotedHTML = new StringBuilder(); quotedHTML.append(text.substring(0, bodyOffset)); // stuff before the reply quotedHTML.append(text.substring(bodyOffset + bodyLength)); if (quotedHTML.length() > 0) { quotedHtmlContent = new InsertableHtmlContent(); quotedHtmlContent.setQuotedContent(quotedHTML); // We don't know if bodyOffset refers to the header or to the footer quotedHtmlContent.setHeaderInsertionPoint(bodyOffset); if (bodyFooterOffset != null) { quotedHtmlContent.setFooterInsertionPoint(bodyFooterOffset); } else { quotedHtmlContent.setFooterInsertionPoint(bodyOffset); } // TODO replace with MessageViewInfo data view.setQuotedHtml(quotedHtmlContent.getQuotedContent(), AttachmentResolver.createFromPart(messageViewInfo.rootPart)); } } if (bodyPlainOffset != null && bodyPlainLength != null) { processSourceMessageText(messageViewInfo.rootPart, bodyPlainOffset, bodyPlainLength, false); } } else if (messageFormat == MessageFormat.TEXT) { quotedTextFormat = SimpleMessageFormat.TEXT; processSourceMessageText(messageViewInfo.rootPart, bodyOffset, bodyLength, true); } else { Timber.e("Unhandled message format."); } // Set the cursor position if we have it. try { view.setMessageContentCursorPosition(cursorPosition); } catch (Exception e) { Timber.e(e, "Could not set cursor position in MessageCompose; ignoring."); } showOrHideQuotedText(quotedMode); } /** * Pull out the parts of the now loaded source message and apply them to the new message * depending on the type of message being composed. * @param bodyOffset Insertion point for reply. * @param bodyLength Length of reply. * @param viewMessageContent Update mMessageContentView or not. */ private void processSourceMessageText( Part rootMessagePart, int bodyOffset, int bodyLength, boolean viewMessageContent) { Part textPart = MimeUtility.findFirstPartByMimeType(rootMessagePart, "text/plain"); if (textPart == null) { return; } String messageText = MessageExtractor.getTextFromPart(textPart); Timber.d("Loading message with offset %d, length %d. Text length is %d.", bodyOffset, bodyLength, messageText.length()); // If we had a body length (and it was valid), separate the composition from the quoted text // and put them in their respective places in the UI. if (bodyLength != UNKNOWN_LENGTH) { try { // Regenerate the quoted text without our user content in it nor added newlines. StringBuilder quotedText = new StringBuilder(); if (bodyOffset == UNKNOWN_LENGTH && messageText.substring(bodyLength, bodyLength + 4).equals("\r\n\r\n")) { // top-posting: ignore two newlines at start of quote quotedText.append(messageText.substring(bodyLength + 4)); } else if (bodyOffset + bodyLength == messageText.length() && messageText.substring(bodyOffset - 2, bodyOffset).equals("\r\n")) { // bottom-posting: ignore newline at end of quote quotedText.append(messageText.substring(0, bodyOffset - 2)); } else { quotedText.append(messageText.substring(0, bodyOffset)); // stuff before the reply quotedText.append(messageText.substring(bodyOffset + bodyLength)); } view.setQuotedText(quotedText.toString()); messageText = messageText.substring(bodyOffset, bodyOffset + bodyLength); } catch (IndexOutOfBoundsException e) { // Invalid bodyOffset or bodyLength. The draft was edited outside of K-9 Mail? Timber.d("The identity field from the draft contains an invalid bodyOffset/bodyLength"); } } if (viewMessageContent) { view.setMessageContentCharacters(messageText); } } void onClickShowQuotedText() { showOrHideQuotedText(QuotedTextMode.SHOW); messageCompose.updateMessageFormat(); messageCompose.saveDraftEventually(); } void onClickDeleteQuotedText() { showOrHideQuotedText(QuotedTextMode.HIDE); messageCompose.updateMessageFormat(); messageCompose.saveDraftEventually(); } void onClickEditQuotedText() { forcePlainText = true; messageCompose.loadQuotedTextForEdit(); } public boolean includeQuotedText() { return quotedTextMode == QuotedTextMode.SHOW; } public boolean isForcePlainText() { return forcePlainText; } public boolean isQuotedTextText() { return quotedTextFormat == SimpleMessageFormat.TEXT; } }