Move code to build messages outside of MessageCompose

This commit is contained in:
cketti 2015-03-02 23:32:25 +01:00
parent 5330fe5b27
commit bc284584d1
10 changed files with 813 additions and 459 deletions

View file

@ -16,7 +16,6 @@ import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.StringTokenizer;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@ -89,7 +88,6 @@ import com.fsck.k9.fragment.ProgressDialogFragment;
import com.fsck.k9.helper.ContactItem;
import com.fsck.k9.helper.Contacts;
import com.fsck.k9.helper.SimpleTextWatcher;
import com.fsck.k9.mail.filter.Base64;
import com.fsck.k9.helper.HtmlConverter;
import com.fsck.k9.helper.IdentityHelper;
import com.fsck.k9.helper.Utility;
@ -102,21 +100,19 @@ import com.fsck.k9.mail.MessagingException;
import com.fsck.k9.mail.Multipart;
import com.fsck.k9.mail.Part;
import com.fsck.k9.mail.internet.MessageExtractor;
import com.fsck.k9.mail.internet.MimeBodyPart;
import com.fsck.k9.mail.internet.MimeHeader;
import com.fsck.k9.mail.internet.MimeMessage;
import com.fsck.k9.mail.internet.MimeMessageHelper;
import com.fsck.k9.mail.internet.MimeMultipart;
import com.fsck.k9.mail.internet.MimeUtility;
import com.fsck.k9.mail.internet.TextBody;
import com.fsck.k9.mailstore.LocalMessage;
import com.fsck.k9.mailstore.TempFileBody;
import com.fsck.k9.mailstore.TempFileMessageBody;
import com.fsck.k9.message.IdentityField;
import com.fsck.k9.message.IdentityHeaderParser;
import com.fsck.k9.message.InsertableHtmlContent;
import com.fsck.k9.message.MessageBuilder;
import com.fsck.k9.message.QuotedTextMode;
import com.fsck.k9.message.SimpleMessageFormat;
import com.fsck.k9.ui.EolConvertingEditText;
import com.fsck.k9.view.MessageWebView;
import org.apache.james.mime4j.codec.EncoderUtil;
import org.apache.james.mime4j.util.MimeUtil;
import org.htmlcleaner.CleanerProperties;
import org.htmlcleaner.HtmlCleaner;
import org.htmlcleaner.SimpleHtmlSerializer;
@ -264,12 +260,6 @@ public class MessageCompose extends K9Activity implements OnClickListener,
*/
private Action mAction;
private enum QuotedTextMode {
NONE,
SHOW,
HIDE
}
private boolean mReadReceipt = false;
private QuotedTextMode mQuotedTextMode = QuotedTextMode.NONE;
@ -316,11 +306,6 @@ public class MessageCompose extends K9Activity implements OnClickListener,
private boolean mSourceProcessed = false;
enum SimpleMessageFormat {
TEXT,
HTML
}
/**
* The currently used message format.
*
@ -454,7 +439,6 @@ public class MessageCompose extends K9Activity implements OnClickListener,
* Compose a new message as a reply to the given message. If replyAll is true the function
* is reply all instead of simply reply.
* @param context
* @param account
* @param message
* @param replyAll
* @param messageBody optional, for decrypted messages, null if it should be grabbed from the given message
@ -1039,16 +1023,10 @@ public class MessageCompose extends K9Activity implements OnClickListener,
@Override
protected void onSaveInstanceState(Bundle outState) {
super.onSaveInstanceState(outState);
ArrayList<Attachment> attachments = new ArrayList<Attachment>();
for (int i = 0, count = mAttachments.getChildCount(); i < count; i++) {
View view = mAttachments.getChildAt(i);
Attachment attachment = (Attachment) view.getTag();
attachments.add(attachment);
}
outState.putInt(STATE_KEY_NUM_ATTACHMENTS_LOADING, mNumAttachmentsLoading);
outState.putString(STATE_KEY_WAITING_FOR_ATTACHMENTS, mWaitingForAttachments.name());
outState.putParcelableArrayList(STATE_KEY_ATTACHMENTS, attachments);
outState.putParcelableArrayList(STATE_KEY_ATTACHMENTS, createAttachmentList());
outState.putBoolean(STATE_KEY_CC_SHOWN, mCcWrapper.getVisibility() == View.VISIBLE);
outState.putBoolean(STATE_KEY_BCC_SHOWN, mBccWrapper.getVisibility() == View.VISIBLE);
outState.putSerializable(STATE_KEY_QUOTED_TEXT_MODE, mQuotedTextMode);
@ -1195,437 +1173,55 @@ public class MessageCompose extends K9Activity implements OnClickListener,
return Address.parseUnencoded(addresses.trim());
}
/*
* Build the Body that will contain the text of the message. We'll decide where to
* include it later. Draft messages are treated somewhat differently in that signatures are not
* appended and HTML separators between composed text and quoted text are not added.
* @param isDraft If we should build a message that will be saved as a draft (as opposed to sent).
*/
private TextBody buildText(boolean isDraft) {
return buildText(isDraft, mMessageFormat);
return createMessageBuilder(isDraft).buildText();
}
/**
* Build the {@link Body} that will contain the text of the message.
*
* <p>
* Draft messages are treated somewhat differently in that signatures are not appended and HTML
* separators between composed text and quoted text are not added.
* </p>
*
* @param isDraft
* If {@code true} we build a message that will be saved as a draft (as opposed to
* sent).
* @param messageFormat
* Specifies what type of message to build ({@code text/plain} vs. {@code text/html}).
*
* @return {@link TextBody} instance that contains the entered text and possibly the quoted
* original message.
*/
private TextBody buildText(boolean isDraft, SimpleMessageFormat messageFormat) {
String messageText = mMessageContentView.getCharacters();
TextBodyBuilder textBodyBuilder = new TextBodyBuilder(messageText);
/*
* Find out if we need to include the original message as quoted text.
*
* We include the quoted text in the body if the user didn't choose to
* hide it. We always include the quoted text when we're saving a draft.
* That's so the user is able to "un-hide" the quoted text if (s)he
* opens a saved draft.
*/
boolean includeQuotedText = (isDraft || mQuotedTextMode == QuotedTextMode.SHOW);
boolean isReplyAfterQuote = (mQuoteStyle == QuoteStyle.PREFIX && mAccount.isReplyAfterQuote());
textBodyBuilder.setIncludeQuotedText(false);
if (includeQuotedText) {
if (messageFormat == SimpleMessageFormat.HTML && mQuotedHtmlContent != null) {
textBodyBuilder.setIncludeQuotedText(true);
textBodyBuilder.setQuotedTextHtml(mQuotedHtmlContent);
textBodyBuilder.setReplyAfterQuote(isReplyAfterQuote);
}
String quotedText = mQuotedText.getCharacters();
if (messageFormat == SimpleMessageFormat.TEXT && quotedText.length() > 0) {
textBodyBuilder.setIncludeQuotedText(true);
textBodyBuilder.setQuotedText(quotedText);
textBodyBuilder.setReplyAfterQuote(isReplyAfterQuote);
}
}
textBodyBuilder.setInsertSeparator(!isDraft);
boolean useSignature = (!isDraft && mIdentity.getSignatureUse());
if (useSignature) {
textBodyBuilder.setAppendSignature(true);
textBodyBuilder.setSignature(mSignatureView.getCharacters());
textBodyBuilder.setSignatureBeforeQuotedText(mAccount.isSignatureBeforeQuotedText());
} else {
textBodyBuilder.setAppendSignature(false);
}
TextBody body;
if (messageFormat == SimpleMessageFormat.HTML) {
body = textBodyBuilder.buildTextHtml();
} else {
body = textBodyBuilder.buildTextPlain();
}
return body;
}
/**
* Build the final message to be sent (or saved). If there is another message quoted in this one, it will be baked
* into the final message here.
* @param isDraft Indicates if this message is a draft or not. Drafts do not have signatures
* appended and have some extra metadata baked into their header for use during thawing.
* @return Message to be sent.
* @throws MessagingException
*/
private MimeMessage createMessage(boolean isDraft) throws MessagingException {
MimeMessage message = new MimeMessage();
message.addSentDate(new Date(), K9.hideTimeZone());
Address from = new Address(mIdentity.getEmail(), mIdentity.getName());
message.setFrom(from);
message.setRecipients(RecipientType.TO, getAddresses(mToView));
message.setRecipients(RecipientType.CC, getAddresses(mCcView));
message.setRecipients(RecipientType.BCC, getAddresses(mBccView));
message.setSubject(mSubjectView.getText().toString());
if (mReadReceipt) {
message.setHeader("Disposition-Notification-To", from.toEncodedString());
message.setHeader("X-Confirm-Reading-To", from.toEncodedString());
message.setHeader("Return-Receipt-To", from.toEncodedString());
}
if (!K9.hideUserAgent()) {
message.setHeader("User-Agent", getString(R.string.message_header_mua));
}
final String replyTo = mIdentity.getReplyTo();
if (replyTo != null) {
message.setReplyTo(new Address[] { new Address(replyTo) });
}
if (mInReplyTo != null) {
message.setInReplyTo(mInReplyTo);
}
if (mReferences != null) {
message.setReferences(mReferences);
}
// Build the body.
// TODO FIXME - body can be either an HTML or Text part, depending on whether we're in
// HTML mode or not. Should probably fix this so we don't mix up html and text parts.
TextBody body = null;
if (mPgpData.getEncryptedData() != null) {
String text = mPgpData.getEncryptedData();
body = new TextBody(text);
} else {
body = buildText(isDraft);
}
// text/plain part when mMessageFormat == MessageFormat.HTML
TextBody bodyPlain = null;
final boolean hasAttachments = mAttachments.getChildCount() > 0;
if (mMessageFormat == SimpleMessageFormat.HTML) {
// HTML message (with alternative text part)
// This is the compiled MIME part for an HTML message.
MimeMultipart composedMimeMessage = new MimeMultipart();
composedMimeMessage.setSubType("alternative"); // Let the receiver select either the text or the HTML part.
composedMimeMessage.addBodyPart(new MimeBodyPart(body, "text/html"));
bodyPlain = buildText(isDraft, SimpleMessageFormat.TEXT);
composedMimeMessage.addBodyPart(new MimeBodyPart(bodyPlain, "text/plain"));
if (hasAttachments) {
// If we're HTML and have attachments, we have a MimeMultipart container to hold the
// whole message (mp here), of which one part is a MimeMultipart container
// (composedMimeMessage) with the user's composed messages, and subsequent parts for
// the attachments.
MimeMultipart mp = new MimeMultipart();
mp.addBodyPart(new MimeBodyPart(composedMimeMessage));
addAttachmentsToMessage(mp);
MimeMessageHelper.setBody(message, mp);
} else {
// If no attachments, our multipart/alternative part is the only one we need.
MimeMessageHelper.setBody(message, composedMimeMessage);
}
} else if (mMessageFormat == SimpleMessageFormat.TEXT) {
// Text-only message.
if (hasAttachments) {
MimeMultipart mp = new MimeMultipart();
mp.addBodyPart(new MimeBodyPart(body, "text/plain"));
addAttachmentsToMessage(mp);
MimeMessageHelper.setBody(message, mp);
} else {
// No attachments to include, just stick the text body in the message and call it good.
MimeMessageHelper.setBody(message, body);
}
}
// If this is a draft, add metadata for thawing.
if (isDraft) {
// Add the identity to the message.
message.addHeader(K9.IDENTITY_HEADER, buildIdentityHeader(body, bodyPlain));
}
message.generateMessageId();
return message;
private MimeMessage createDraftMessage() throws MessagingException {
return createMessageBuilder(true).build();
}
/**
* Add attachments as parts into a MimeMultipart container.
* @param mp MimeMultipart container in which to insert parts.
* @throws MessagingException
*/
private void addAttachmentsToMessage(final MimeMultipart mp) throws MessagingException {
Body body;
private MimeMessage createMessage() throws MessagingException {
return createMessageBuilder(false).build();
}
private MessageBuilder createMessageBuilder(boolean isDraft) {
return new MessageBuilder(getApplicationContext())
.setSubject(mSubjectView.getText().toString())
.setTo(getAddresses(mToView))
.setCc(getAddresses(mCcView))
.setBcc(getAddresses(mBccView))
.setInReplyTo(mInReplyTo)
.setReferences(mReferences)
.setRequestReadReceipt(mReadReceipt)
.setIdentity(mIdentity)
.setMessageFormat(mMessageFormat)
.setText(mMessageContentView.getCharacters())
.setPgpData(mPgpData)
.setAttachments(createAttachmentList())
.setSignature(mSignatureView.getCharacters())
.setQuoteStyle(mQuoteStyle)
.setQuotedTextMode(mQuotedTextMode)
.setQuotedText(mQuotedText.getCharacters())
.setQuotedHtmlContent(mQuotedHtmlContent)
.setReplyAfterQuote(mAccount.isReplyAfterQuote())
.setSignatureBeforeQuotedText(mAccount.isSignatureBeforeQuotedText())
.setIdentityChanged(mIdentityChanged)
.setSignatureChanged(mSignatureChanged)
.setCursorPosition(mMessageContentView.getSelectionStart())
.setMessageReference(mMessageReference)
.setDraft(isDraft);
}
private ArrayList<Attachment> createAttachmentList() {
ArrayList<Attachment> attachments = new ArrayList<Attachment>();
for (int i = 0, count = mAttachments.getChildCount(); i < count; i++) {
Attachment attachment = (Attachment) mAttachments.getChildAt(i).getTag();
if (attachment.state != Attachment.LoadingState.COMPLETE) {
continue;
}
String contentType = attachment.contentType;
if (MimeUtil.isMessage(contentType)) {
body = new TempFileMessageBody(attachment.filename);
} else {
body = new TempFileBody(attachment.filename);
}
MimeBodyPart bp = new MimeBodyPart(body);
/*
* Correctly encode the filename here. Otherwise the whole
* header value (all parameters at once) will be encoded by
* MimeHeader.writeTo().
*/
bp.addHeader(MimeHeader.HEADER_CONTENT_TYPE, String.format("%s;\r\n name=\"%s\"",
contentType,
EncoderUtil.encodeIfNecessary(attachment.name,
EncoderUtil.Usage.WORD_ENTITY, 7)));
bp.setEncoding(MimeUtility.getEncodingforType(contentType));
/*
* TODO: Oh the joys of MIME...
*
* From RFC 2183 (The Content-Disposition Header Field):
* "Parameter values longer than 78 characters, or which
* contain non-ASCII characters, MUST be encoded as specified
* in [RFC 2184]."
*
* Example:
*
* Content-Type: application/x-stuff
* title*1*=us-ascii'en'This%20is%20even%20more%20
* title*2*=%2A%2A%2Afun%2A%2A%2A%20
* title*3="isn't it!"
*/
bp.addHeader(MimeHeader.HEADER_CONTENT_DISPOSITION, String.format(Locale.US,
"attachment;\r\n filename=\"%s\";\r\n size=%d",
attachment.name, attachment.size));
mp.addBodyPart(bp);
}
}
// FYI, there's nothing in the code that requires these variables to one letter. They're one
// letter simply to save space. This name sucks. It's too similar to Account.Identity.
private enum IdentityField {
LENGTH("l"),
OFFSET("o"),
FOOTER_OFFSET("fo"),
PLAIN_LENGTH("pl"),
PLAIN_OFFSET("po"),
MESSAGE_FORMAT("f"),
MESSAGE_READ_RECEIPT("r"),
SIGNATURE("s"),
NAME("n"),
EMAIL("e"),
// TODO - store a reference to the message being replied so we can mark it at the time of send.
ORIGINAL_MESSAGE("m"),
CURSOR_POSITION("p"), // Where in the message your cursor was when you saved.
QUOTED_TEXT_MODE("q"),
QUOTE_STYLE("qs");
private final String value;
IdentityField(String value) {
this.value = value;
View view = mAttachments.getChildAt(i);
Attachment attachment = (Attachment) view.getTag();
attachments.add(attachment);
}
public String value() {
return value;
}
/**
* Get the list of IdentityFields that should be integer values.
*
* <p>
* These values are sanity checked for integer-ness during decoding.
* </p>
*
* @return The list of integer {@link IdentityField}s.
*/
public static IdentityField[] getIntegerFields() {
return new IdentityField[] { LENGTH, OFFSET, FOOTER_OFFSET, PLAIN_LENGTH, PLAIN_OFFSET };
}
}
// Version identifier for "new style" identity. ! is an impossible value in base64 encoding, so we
// use that to determine which version we're in.
private static final String IDENTITY_VERSION_1 = "!";
/**
* Build the identity header string. This string contains metadata about a draft message to be
* used upon loading a draft for composition. This should be generated at the time of saving a
* draft.<br>
* <br>
* This is a URL-encoded key/value pair string. The list of possible values are in {@link IdentityField}.
* @param body {@link TextBody} to analyze for body length and offset.
* @param bodyPlain {@link TextBody} to analyze for body length and offset. May be null.
* @return Identity string.
*/
private String buildIdentityHeader(final TextBody body, final TextBody bodyPlain) {
Uri.Builder uri = new Uri.Builder();
if (body.getComposedMessageLength() != null && body.getComposedMessageOffset() != null) {
// See if the message body length is already in the TextBody.
uri.appendQueryParameter(IdentityField.LENGTH.value(), body.getComposedMessageLength().toString());
uri.appendQueryParameter(IdentityField.OFFSET.value(), body.getComposedMessageOffset().toString());
} else {
// If not, calculate it now.
uri.appendQueryParameter(IdentityField.LENGTH.value(), Integer.toString(body.getText().length()));
uri.appendQueryParameter(IdentityField.OFFSET.value(), Integer.toString(0));
}
if (mQuotedHtmlContent != null) {
uri.appendQueryParameter(IdentityField.FOOTER_OFFSET.value(),
Integer.toString(mQuotedHtmlContent.getFooterInsertionPoint()));
}
if (bodyPlain != null) {
if (bodyPlain.getComposedMessageLength() != null && bodyPlain.getComposedMessageOffset() != null) {
// See if the message body length is already in the TextBody.
uri.appendQueryParameter(IdentityField.PLAIN_LENGTH.value(), bodyPlain.getComposedMessageLength().toString());
uri.appendQueryParameter(IdentityField.PLAIN_OFFSET.value(), bodyPlain.getComposedMessageOffset().toString());
} else {
// If not, calculate it now.
uri.appendQueryParameter(IdentityField.PLAIN_LENGTH.value(), Integer.toString(body.getText().length()));
uri.appendQueryParameter(IdentityField.PLAIN_OFFSET.value(), Integer.toString(0));
}
}
// Save the quote style (useful for forwards).
uri.appendQueryParameter(IdentityField.QUOTE_STYLE.value(), mQuoteStyle.name());
// Save the message format for this offset.
uri.appendQueryParameter(IdentityField.MESSAGE_FORMAT.value(), mMessageFormat.name());
// If we're not using the standard identity of signature, append it on to the identity blob.
if (mIdentity.getSignatureUse() && mSignatureChanged) {
uri.appendQueryParameter(IdentityField.SIGNATURE.value(), mSignatureView.getCharacters());
}
if (mIdentityChanged) {
uri.appendQueryParameter(IdentityField.NAME.value(), mIdentity.getName());
uri.appendQueryParameter(IdentityField.EMAIL.value(), mIdentity.getEmail());
}
if (mMessageReference != null) {
uri.appendQueryParameter(IdentityField.ORIGINAL_MESSAGE.value(), mMessageReference.toIdentityString());
}
uri.appendQueryParameter(IdentityField.CURSOR_POSITION.value(), Integer.toString(mMessageContentView.getSelectionStart()));
uri.appendQueryParameter(IdentityField.QUOTED_TEXT_MODE.value(), mQuotedTextMode.name());
String k9identity = IDENTITY_VERSION_1 + uri.build().getEncodedQuery();
if (K9.DEBUG) {
Log.d(K9.LOG_TAG, "Generated identity: " + k9identity);
}
return k9identity;
}
/**
* Parse an identity string. Handles both legacy and new (!) style identities.
*
* @param identityString
* The encoded identity string that was saved in a drafts header.
*
* @return A map containing the value for each {@link IdentityField} in the identity string.
*/
private Map<IdentityField, String> parseIdentityHeader(final String identityString) {
Map<IdentityField, String> identity = new HashMap<IdentityField, String>();
if (K9.DEBUG) {
Log.d(K9.LOG_TAG, "Decoding identity: " + identityString);
}
if (identityString == null || identityString.length() < 1) {
return identity;
}
// Check to see if this is a "next gen" identity.
if (identityString.charAt(0) == IDENTITY_VERSION_1.charAt(0) && identityString.length() > 2) {
Uri.Builder builder = new Uri.Builder();
builder.encodedQuery(identityString.substring(1)); // Need to cut off the ! at the beginning.
Uri uri = builder.build();
for (IdentityField key : IdentityField.values()) {
String value = uri.getQueryParameter(key.value());
if (value != null) {
identity.put(key, value);
}
}
if (K9.DEBUG) {
Log.d(K9.LOG_TAG, "Decoded identity: " + identity.toString());
}
// Sanity check our Integers so that recipients of this result don't have to.
for (IdentityField key : IdentityField.getIntegerFields()) {
if (identity.get(key) != null) {
try {
Integer.parseInt(identity.get(key));
} catch (NumberFormatException e) {
Log.e(K9.LOG_TAG, "Invalid " + key.name() + " field in identity: " + identity.get(key));
}
}
}
} else {
// Legacy identity
if (K9.DEBUG) {
Log.d(K9.LOG_TAG, "Got a saved legacy identity: " + identityString);
}
StringTokenizer tokenizer = new StringTokenizer(identityString, ":", false);
// First item is the body length. We use this to separate the composed reply from the quoted text.
if (tokenizer.hasMoreTokens()) {
String bodyLengthS = Base64.decode(tokenizer.nextToken());
try {
identity.put(IdentityField.LENGTH, Integer.valueOf(bodyLengthS).toString());
} catch (Exception e) {
Log.e(K9.LOG_TAG, "Unable to parse bodyLength '" + bodyLengthS + "'");
}
}
if (tokenizer.hasMoreTokens()) {
identity.put(IdentityField.SIGNATURE, Base64.decode(tokenizer.nextToken()));
}
if (tokenizer.hasMoreTokens()) {
identity.put(IdentityField.NAME, Base64.decode(tokenizer.nextToken()));
}
if (tokenizer.hasMoreTokens()) {
identity.put(IdentityField.EMAIL, Base64.decode(tokenizer.nextToken()));
}
if (tokenizer.hasMoreTokens()) {
identity.put(IdentityField.QUOTED_TEXT_MODE, Base64.decode(tokenizer.nextToken()));
}
}
return identity;
return attachments;
}
private void sendMessage() {
@ -2786,7 +2382,7 @@ public class MessageCompose extends K9Activity implements OnClickListener,
// See buildIdentityHeader(TextBody) for a detailed description of the composition of this blob.
Map<IdentityField, String> k9identity = new HashMap<IdentityField, String>();
if (message.getHeader(K9.IDENTITY_HEADER) != null && message.getHeader(K9.IDENTITY_HEADER).length > 0 && message.getHeader(K9.IDENTITY_HEADER)[0] != null) {
k9identity = parseIdentityHeader(message.getHeader(K9.IDENTITY_HEADER)[0]);
k9identity = IdentityHeaderParser.parse(message.getHeader(K9.IDENTITY_HEADER)[0]);
}
Identity newIdentity = new Identity();
@ -3499,7 +3095,7 @@ public class MessageCompose extends K9Activity implements OnClickListener,
*/
MimeMessage message;
try {
message = createMessage(false); // isDraft = true
message = createMessage();
} catch (MessagingException me) {
Log.e(K9.LOG_TAG, "Failed to create new message for send or save.", me);
throw new RuntimeException("Failed to create a new message for send or save.", me);
@ -3532,7 +3128,7 @@ public class MessageCompose extends K9Activity implements OnClickListener,
*/
MimeMessage message;
try {
message = createMessage(true); // isDraft = true
message = createDraftMessage();
} catch (MessagingException me) {
Log.e(K9.LOG_TAG, "Failed to create new message for send or save.", me);
throw new RuntimeException("Failed to create a new message for send or save.", me);

View file

@ -0,0 +1,49 @@
package com.fsck.k9.message;
// FYI, there's nothing in the code that requires these variables to one letter. They're one
// letter simply to save space. This name sucks. It's too similar to Account.Identity.
public enum IdentityField {
LENGTH("l"),
OFFSET("o"),
FOOTER_OFFSET("fo"),
PLAIN_LENGTH("pl"),
PLAIN_OFFSET("po"),
MESSAGE_FORMAT("f"),
MESSAGE_READ_RECEIPT("r"),
SIGNATURE("s"),
NAME("n"),
EMAIL("e"),
// TODO - store a reference to the message being replied so we can mark it at the time of send.
ORIGINAL_MESSAGE("m"),
CURSOR_POSITION("p"), // Where in the message your cursor was when you saved.
QUOTED_TEXT_MODE("q"),
QUOTE_STYLE("qs");
private final String value;
IdentityField(String value) {
this.value = value;
}
public String value() {
return value;
}
/**
* Get the list of IdentityFields that should be integer values.
*
* <p>
* These values are sanity checked for integer-ness during decoding.
* </p>
*
* @return The list of integer {@link IdentityField}s.
*/
public static IdentityField[] getIntegerFields() {
return new IdentityField[] { LENGTH, OFFSET, FOOTER_OFFSET, PLAIN_LENGTH, PLAIN_OFFSET };
}
// Version identifier for "new style" identity. ! is an impossible value in base64 encoding, so we
// use that to determine which version we're in.
static final String IDENTITY_VERSION_1 = "!";
}

View file

@ -0,0 +1,158 @@
package com.fsck.k9.message;
import android.net.Uri;
import android.util.Log;
import com.fsck.k9.Account.QuoteStyle;
import com.fsck.k9.Identity;
import com.fsck.k9.K9;
import com.fsck.k9.activity.MessageReference;
import com.fsck.k9.mail.internet.TextBody;
public class IdentityHeaderBuilder {
private InsertableHtmlContent quotedHtmlContent;
private QuoteStyle quoteStyle;
private SimpleMessageFormat messageFormat;
private Identity identity;
private boolean signatureChanged;
private String signature;
private boolean identityChanged;
private QuotedTextMode quotedTextMode;
private MessageReference messageReference;
private TextBody body;
private TextBody bodyPlain;
private int cursorPosition;
/**
* Build the identity header string. This string contains metadata about a draft message to be
* used upon loading a draft for composition. This should be generated at the time of saving a
* draft.<br>
* <br>
* This is a URL-encoded key/value pair string. The list of possible values are in {@link IdentityField}.
*
* @return Identity string.
*/
public String build() {
//FIXME: check arguments
Uri.Builder uri = new Uri.Builder();
if (body.getComposedMessageLength() != null && body.getComposedMessageOffset() != null) {
// See if the message body length is already in the TextBody.
uri.appendQueryParameter(IdentityField.LENGTH.value(), body.getComposedMessageLength().toString());
uri.appendQueryParameter(IdentityField.OFFSET.value(), body.getComposedMessageOffset().toString());
} else {
// If not, calculate it now.
uri.appendQueryParameter(IdentityField.LENGTH.value(), Integer.toString(body.getText().length()));
uri.appendQueryParameter(IdentityField.OFFSET.value(), Integer.toString(0));
}
if (quotedHtmlContent != null) {
uri.appendQueryParameter(IdentityField.FOOTER_OFFSET.value(),
Integer.toString(quotedHtmlContent.getFooterInsertionPoint()));
}
if (bodyPlain != null) {
if (bodyPlain.getComposedMessageLength() != null && bodyPlain.getComposedMessageOffset() != null) {
// See if the message body length is already in the TextBody.
uri.appendQueryParameter(IdentityField.PLAIN_LENGTH.value(), bodyPlain.getComposedMessageLength().toString());
uri.appendQueryParameter(IdentityField.PLAIN_OFFSET.value(), bodyPlain.getComposedMessageOffset().toString());
} else {
// If not, calculate it now.
uri.appendQueryParameter(IdentityField.PLAIN_LENGTH.value(), Integer.toString(body.getText().length()));
uri.appendQueryParameter(IdentityField.PLAIN_OFFSET.value(), Integer.toString(0));
}
}
// Save the quote style (useful for forwards).
uri.appendQueryParameter(IdentityField.QUOTE_STYLE.value(), quoteStyle.name());
// Save the message format for this offset.
uri.appendQueryParameter(IdentityField.MESSAGE_FORMAT.value(), messageFormat.name());
// If we're not using the standard identity of signature, append it on to the identity blob.
if (identity.getSignatureUse() && signatureChanged) {
uri.appendQueryParameter(IdentityField.SIGNATURE.value(), signature);
}
if (identityChanged) {
uri.appendQueryParameter(IdentityField.NAME.value(), identity.getName());
uri.appendQueryParameter(IdentityField.EMAIL.value(), identity.getEmail());
}
if (messageReference != null) {
uri.appendQueryParameter(IdentityField.ORIGINAL_MESSAGE.value(), messageReference.toIdentityString());
}
uri.appendQueryParameter(IdentityField.CURSOR_POSITION.value(), Integer.toString(cursorPosition));
uri.appendQueryParameter(IdentityField.QUOTED_TEXT_MODE.value(), quotedTextMode.name());
String k9identity = IdentityField.IDENTITY_VERSION_1 + uri.build().getEncodedQuery();
if (K9.DEBUG) {
Log.d(K9.LOG_TAG, "Generated identity: " + k9identity);
}
return k9identity;
}
public IdentityHeaderBuilder setQuotedHtmlContent(InsertableHtmlContent quotedHtmlContent) {
this.quotedHtmlContent = quotedHtmlContent;
return this;
}
public IdentityHeaderBuilder setQuoteStyle(QuoteStyle quoteStyle) {
this.quoteStyle = quoteStyle;
return this;
}
public IdentityHeaderBuilder setQuoteTextMode(QuotedTextMode quotedTextMode) {
this.quotedTextMode = quotedTextMode;
return this;
}
public IdentityHeaderBuilder setMessageFormat(SimpleMessageFormat messageFormat) {
this.messageFormat = messageFormat;
return this;
}
public IdentityHeaderBuilder setIdentity(Identity identity) {
this.identity = identity;
return this;
}
public IdentityHeaderBuilder setIdentityChanged(boolean identityChanged) {
this.identityChanged = identityChanged;
return this;
}
public IdentityHeaderBuilder setSignature(String signature) {
this.signature = signature;
return this;
}
public IdentityHeaderBuilder setSignatureChanged(boolean signatureChanged) {
this.signatureChanged = signatureChanged;
return this;
}
public IdentityHeaderBuilder setMessageReference(MessageReference messageReference) {
this.messageReference = messageReference;
return this;
}
public IdentityHeaderBuilder setBody(TextBody body) {
this.body = body;
return this;
}
public IdentityHeaderBuilder setBodyPlain(TextBody bodyPlain) {
this.bodyPlain = bodyPlain;
return this;
}
public IdentityHeaderBuilder setCursorPosition(int cursorPosition) {
this.cursorPosition = cursorPosition;
return this;
}
}

View file

@ -0,0 +1,94 @@
package com.fsck.k9.message;
import java.util.HashMap;
import java.util.Map;
import java.util.StringTokenizer;
import android.net.Uri;
import android.util.Log;
import com.fsck.k9.K9;
import com.fsck.k9.mail.filter.Base64;
public class IdentityHeaderParser {
/**
* Parse an identity string. Handles both legacy and new (!) style identities.
*
* @param identityString
* The encoded identity string that was saved in a drafts header.
*
* @return A map containing the value for each {@link IdentityField} in the identity string.
*/
public static Map<IdentityField, String> parse(final String identityString) {
Map<IdentityField, String> identity = new HashMap<IdentityField, String>();
if (K9.DEBUG) {
Log.d(K9.LOG_TAG, "Decoding identity: " + identityString);
}
if (identityString == null || identityString.length() < 1) {
return identity;
}
// Check to see if this is a "next gen" identity.
if (identityString.charAt(0) == IdentityField.IDENTITY_VERSION_1.charAt(0) && identityString.length() > 2) {
Uri.Builder builder = new Uri.Builder();
builder.encodedQuery(identityString.substring(1)); // Need to cut off the ! at the beginning.
Uri uri = builder.build();
for (IdentityField key : IdentityField.values()) {
String value = uri.getQueryParameter(key.value());
if (value != null) {
identity.put(key, value);
}
}
if (K9.DEBUG) {
Log.d(K9.LOG_TAG, "Decoded identity: " + identity.toString());
}
// Sanity check our Integers so that recipients of this result don't have to.
for (IdentityField key : IdentityField.getIntegerFields()) {
if (identity.get(key) != null) {
try {
Integer.parseInt(identity.get(key));
} catch (NumberFormatException e) {
Log.e(K9.LOG_TAG, "Invalid " + key.name() + " field in identity: " + identity.get(key));
}
}
}
} else {
// Legacy identity
if (K9.DEBUG) {
Log.d(K9.LOG_TAG, "Got a saved legacy identity: " + identityString);
}
StringTokenizer tokenizer = new StringTokenizer(identityString, ":", false);
// First item is the body length. We use this to separate the composed reply from the quoted text.
if (tokenizer.hasMoreTokens()) {
String bodyLengthS = Base64.decode(tokenizer.nextToken());
try {
identity.put(IdentityField.LENGTH, Integer.valueOf(bodyLengthS).toString());
} catch (Exception e) {
Log.e(K9.LOG_TAG, "Unable to parse bodyLength '" + bodyLengthS + "'");
}
}
if (tokenizer.hasMoreTokens()) {
identity.put(IdentityField.SIGNATURE, Base64.decode(tokenizer.nextToken()));
}
if (tokenizer.hasMoreTokens()) {
identity.put(IdentityField.NAME, Base64.decode(tokenizer.nextToken()));
}
if (tokenizer.hasMoreTokens()) {
identity.put(IdentityField.EMAIL, Base64.decode(tokenizer.nextToken()));
}
if (tokenizer.hasMoreTokens()) {
identity.put(IdentityField.QUOTED_TEXT_MODE, Base64.decode(tokenizer.nextToken()));
}
}
return identity;
}
}

View file

@ -1,4 +1,4 @@
package com.fsck.k9.activity;
package com.fsck.k9.message;
import java.io.Serializable;
@ -12,7 +12,7 @@ import java.io.Serializable;
*
* TODO: This container should also have a text part, along with its insertion point. Or maybe a generic InsertableContent and maintain one each for Html and Text?
*/
class InsertableHtmlContent implements Serializable {
public class InsertableHtmlContent implements Serializable {
private static final long serialVersionUID = 2397327034L;
// Default to a headerInsertionPoint at the beginning of the message.
private int headerInsertionPoint = 0;

View file

@ -0,0 +1,442 @@
package com.fsck.k9.message;
import java.util.Date;
import java.util.List;
import java.util.Locale;
import android.content.Context;
import com.fsck.k9.Account.QuoteStyle;
import com.fsck.k9.Identity;
import com.fsck.k9.K9;
import com.fsck.k9.R;
import com.fsck.k9.activity.MessageReference;
import com.fsck.k9.activity.misc.Attachment;
import com.fsck.k9.crypto.PgpData;
import com.fsck.k9.mail.Address;
import com.fsck.k9.mail.Body;
import com.fsck.k9.mail.Message.RecipientType;
import com.fsck.k9.mail.MessagingException;
import com.fsck.k9.mail.internet.MimeBodyPart;
import com.fsck.k9.mail.internet.MimeHeader;
import com.fsck.k9.mail.internet.MimeMessage;
import com.fsck.k9.mail.internet.MimeMessageHelper;
import com.fsck.k9.mail.internet.MimeMultipart;
import com.fsck.k9.mail.internet.MimeUtility;
import com.fsck.k9.mail.internet.TextBody;
import com.fsck.k9.mailstore.TempFileBody;
import com.fsck.k9.mailstore.TempFileMessageBody;
import org.apache.james.mime4j.codec.EncoderUtil;
import org.apache.james.mime4j.util.MimeUtil;
public class MessageBuilder {
private final Context context;
private String subject;
private Address[] to;
private Address[] cc;
private Address[] bcc;
private String inReplyTo;
private String references;
private boolean requestReadReceipt;
private Identity identity;
private SimpleMessageFormat messageFormat;
private String text;
private PgpData pgpData;
private List<Attachment> attachments;
private String signature;
private QuoteStyle quoteStyle;
private QuotedTextMode quotedTextMode;
private String quotedText;
private InsertableHtmlContent quotedHtmlContent;
private boolean isReplyAfterQuote;
private boolean isSignatureBeforeQuotedText;
private boolean identityChanged;
private boolean signatureChanged;
private int cursorPosition;
private MessageReference messageReference;
private boolean isDraft;
public MessageBuilder(Context context) {
this.context = context;
}
/**
* Build the final message to be sent (or saved). If there is another message quoted in this one, it will be baked
* into the final message here.
*/
public MimeMessage build() throws MessagingException {
//FIXME: check arguments
MimeMessage message = new MimeMessage();
message.addSentDate(new Date(), K9.hideTimeZone());
Address from = new Address(identity.getEmail(), identity.getName());
message.setFrom(from);
message.setRecipients(RecipientType.TO, to);
message.setRecipients(RecipientType.CC, cc);
message.setRecipients(RecipientType.BCC, bcc);
message.setSubject(subject);
if (requestReadReceipt) {
message.setHeader("Disposition-Notification-To", from.toEncodedString());
message.setHeader("X-Confirm-Reading-To", from.toEncodedString());
message.setHeader("Return-Receipt-To", from.toEncodedString());
}
if (!K9.hideUserAgent()) {
message.setHeader("User-Agent", context.getString(R.string.message_header_mua));
}
final String replyTo = identity.getReplyTo();
if (replyTo != null) {
message.setReplyTo(new Address[] { new Address(replyTo) });
}
if (inReplyTo != null) {
message.setInReplyTo(inReplyTo);
}
if (references != null) {
message.setReferences(references);
}
// Build the body.
// TODO FIXME - body can be either an HTML or Text part, depending on whether we're in
// HTML mode or not. Should probably fix this so we don't mix up html and text parts.
TextBody body;
if (pgpData.getEncryptedData() != null) {
String text = pgpData.getEncryptedData();
body = new TextBody(text);
} else {
body = buildText(isDraft);
}
// text/plain part when messageFormat == MessageFormat.HTML
TextBody bodyPlain = null;
final boolean hasAttachments = !attachments.isEmpty();
if (messageFormat == SimpleMessageFormat.HTML) {
// HTML message (with alternative text part)
// This is the compiled MIME part for an HTML message.
MimeMultipart composedMimeMessage = new MimeMultipart();
composedMimeMessage.setSubType("alternative"); // Let the receiver select either the text or the HTML part.
composedMimeMessage.addBodyPart(new MimeBodyPart(body, "text/html"));
bodyPlain = buildText(isDraft, SimpleMessageFormat.TEXT);
composedMimeMessage.addBodyPart(new MimeBodyPart(bodyPlain, "text/plain"));
if (hasAttachments) {
// If we're HTML and have attachments, we have a MimeMultipart container to hold the
// whole message (mp here), of which one part is a MimeMultipart container
// (composedMimeMessage) with the user's composed messages, and subsequent parts for
// the attachments.
MimeMultipart mp = new MimeMultipart();
mp.addBodyPart(new MimeBodyPart(composedMimeMessage));
addAttachmentsToMessage(mp);
MimeMessageHelper.setBody(message, mp);
} else {
// If no attachments, our multipart/alternative part is the only one we need.
MimeMessageHelper.setBody(message, composedMimeMessage);
}
} else if (messageFormat == SimpleMessageFormat.TEXT) {
// Text-only message.
if (hasAttachments) {
MimeMultipart mp = new MimeMultipart();
mp.addBodyPart(new MimeBodyPart(body, "text/plain"));
addAttachmentsToMessage(mp);
MimeMessageHelper.setBody(message, mp);
} else {
// No attachments to include, just stick the text body in the message and call it good.
MimeMessageHelper.setBody(message, body);
}
}
// If this is a draft, add metadata for thawing.
if (isDraft) {
// Add the identity to the message.
message.addHeader(K9.IDENTITY_HEADER, buildIdentityHeader(body, bodyPlain));
}
message.generateMessageId();
return message;
}
public TextBody buildText() {
return buildText(isDraft, messageFormat);
}
private String buildIdentityHeader(TextBody body, TextBody bodyPlain) {
return new IdentityHeaderBuilder()
.setCursorPosition(cursorPosition)
.setIdentity(identity)
.setIdentityChanged(identityChanged)
.setMessageFormat(messageFormat)
.setMessageReference(messageReference)
.setQuotedHtmlContent(quotedHtmlContent)
.setQuoteStyle(quoteStyle)
.setQuoteTextMode(quotedTextMode)
.setSignature(signature)
.setSignatureChanged(signatureChanged)
.setBody(body)
.setBodyPlain(bodyPlain)
.build();
}
/**
* Add attachments as parts into a MimeMultipart container.
* @param mp MimeMultipart container in which to insert parts.
* @throws MessagingException
*/
private void addAttachmentsToMessage(final MimeMultipart mp) throws MessagingException {
Body body;
for (Attachment attachment : attachments) {
if (attachment.state != Attachment.LoadingState.COMPLETE) {
continue;
}
String contentType = attachment.contentType;
if (MimeUtil.isMessage(contentType)) {
body = new TempFileMessageBody(attachment.filename);
} else {
body = new TempFileBody(attachment.filename);
}
MimeBodyPart bp = new MimeBodyPart(body);
/*
* Correctly encode the filename here. Otherwise the whole
* header value (all parameters at once) will be encoded by
* MimeHeader.writeTo().
*/
bp.addHeader(MimeHeader.HEADER_CONTENT_TYPE, String.format("%s;\r\n name=\"%s\"",
contentType,
EncoderUtil.encodeIfNecessary(attachment.name,
EncoderUtil.Usage.WORD_ENTITY, 7)));
bp.setEncoding(MimeUtility.getEncodingforType(contentType));
/*
* TODO: Oh the joys of MIME...
*
* From RFC 2183 (The Content-Disposition Header Field):
* "Parameter values longer than 78 characters, or which
* contain non-ASCII characters, MUST be encoded as specified
* in [RFC 2184]."
*
* Example:
*
* Content-Type: application/x-stuff
* title*1*=us-ascii'en'This%20is%20even%20more%20
* title*2*=%2A%2A%2Afun%2A%2A%2A%20
* title*3="isn't it!"
*/
bp.addHeader(MimeHeader.HEADER_CONTENT_DISPOSITION, String.format(Locale.US,
"attachment;\r\n filename=\"%s\";\r\n size=%d",
attachment.name, attachment.size));
mp.addBodyPart(bp);
}
}
/**
* Build the Body that will contain the text of the message. We'll decide where to
* include it later. Draft messages are treated somewhat differently in that signatures are not
* appended and HTML separators between composed text and quoted text are not added.
* @param isDraft If we should build a message that will be saved as a draft (as opposed to sent).
*/
private TextBody buildText(boolean isDraft) {
return buildText(isDraft, messageFormat);
}
/**
* Build the {@link Body} that will contain the text of the message.
*
* <p>
* Draft messages are treated somewhat differently in that signatures are not appended and HTML
* separators between composed text and quoted text are not added.
* </p>
*
* @param isDraft
* If {@code true} we build a message that will be saved as a draft (as opposed to
* sent).
* @param simpleMessageFormat
* Specifies what type of message to build ({@code text/plain} vs. {@code text/html}).
*
* @return {@link TextBody} instance that contains the entered text and possibly the quoted
* original message.
*/
private TextBody buildText(boolean isDraft, SimpleMessageFormat simpleMessageFormat) {
String messageText = text;
TextBodyBuilder textBodyBuilder = new TextBodyBuilder(messageText);
/*
* Find out if we need to include the original message as quoted text.
*
* We include the quoted text in the body if the user didn't choose to
* hide it. We always include the quoted text when we're saving a draft.
* That's so the user is able to "un-hide" the quoted text if (s)he
* opens a saved draft.
*/
boolean includeQuotedText = (isDraft || quotedTextMode == QuotedTextMode.SHOW);
boolean isReplyAfterQuote = (quoteStyle == QuoteStyle.PREFIX && this.isReplyAfterQuote);
textBodyBuilder.setIncludeQuotedText(false);
if (includeQuotedText) {
if (simpleMessageFormat == SimpleMessageFormat.HTML && quotedHtmlContent != null) {
textBodyBuilder.setIncludeQuotedText(true);
textBodyBuilder.setQuotedTextHtml(quotedHtmlContent);
textBodyBuilder.setReplyAfterQuote(isReplyAfterQuote);
}
if (simpleMessageFormat == SimpleMessageFormat.TEXT && quotedText.length() > 0) {
textBodyBuilder.setIncludeQuotedText(true);
textBodyBuilder.setQuotedText(quotedText);
textBodyBuilder.setReplyAfterQuote(isReplyAfterQuote);
}
}
textBodyBuilder.setInsertSeparator(!isDraft);
boolean useSignature = (!isDraft && identity.getSignatureUse());
if (useSignature) {
textBodyBuilder.setAppendSignature(true);
textBodyBuilder.setSignature(signature);
textBodyBuilder.setSignatureBeforeQuotedText(isSignatureBeforeQuotedText);
} else {
textBodyBuilder.setAppendSignature(false);
}
TextBody body;
if (simpleMessageFormat == SimpleMessageFormat.HTML) {
body = textBodyBuilder.buildTextHtml();
} else {
body = textBodyBuilder.buildTextPlain();
}
return body;
}
public MessageBuilder setSubject(String subject) {
this.subject = subject;
return this;
}
public MessageBuilder setTo(Address[] to) {
this.to = to;
return this;
}
public MessageBuilder setCc(Address[] cc) {
this.cc = cc;
return this;
}
public MessageBuilder setBcc(Address[] bcc) {
this.bcc = bcc;
return this;
}
public MessageBuilder setInReplyTo(String inReplyTo) {
this.inReplyTo = inReplyTo;
return this;
}
public MessageBuilder setReferences(String references) {
this.references = references;
return this;
}
public MessageBuilder setRequestReadReceipt(boolean requestReadReceipt) {
this.requestReadReceipt = requestReadReceipt;
return this;
}
public MessageBuilder setIdentity(Identity identity) {
this.identity = identity;
return this;
}
public MessageBuilder setMessageFormat(SimpleMessageFormat messageFormat) {
this.messageFormat = messageFormat;
return this;
}
public MessageBuilder setText(String text) {
this.text = text;
return this;
}
public MessageBuilder setPgpData(PgpData pgpData) {
this.pgpData = pgpData;
return this;
}
public MessageBuilder setAttachments(List<Attachment> attachments) {
this.attachments = attachments;
return this;
}
public MessageBuilder setSignature(String signature) {
this.signature = signature;
return this;
}
public MessageBuilder setQuoteStyle(QuoteStyle quoteStyle) {
this.quoteStyle = quoteStyle;
return this;
}
public MessageBuilder setQuotedTextMode(QuotedTextMode quotedTextMode) {
this.quotedTextMode = quotedTextMode;
return this;
}
public MessageBuilder setQuotedText(String quotedText) {
this.quotedText = quotedText;
return this;
}
public MessageBuilder setQuotedHtmlContent(InsertableHtmlContent quotedHtmlContent) {
this.quotedHtmlContent = quotedHtmlContent;
return this;
}
public MessageBuilder setReplyAfterQuote(boolean isReplyAfterQuote) {
this.isReplyAfterQuote = isReplyAfterQuote;
return this;
}
public MessageBuilder setSignatureBeforeQuotedText(boolean isSignatureBeforeQuotedText) {
this.isSignatureBeforeQuotedText = isSignatureBeforeQuotedText;
return this;
}
public MessageBuilder setIdentityChanged(boolean identityChanged) {
this.identityChanged = identityChanged;
return this;
}
public MessageBuilder setSignatureChanged(boolean signatureChanged) {
this.signatureChanged = signatureChanged;
return this;
}
public MessageBuilder setCursorPosition(int cursorPosition) {
this.cursorPosition = cursorPosition;
return this;
}
public MessageBuilder setMessageReference(MessageReference messageReference) {
this.messageReference = messageReference;
return this;
}
public MessageBuilder setDraft(boolean isDraft) {
this.isDraft = isDraft;
return this;
}
}

View file

@ -0,0 +1,8 @@
package com.fsck.k9.message;
public enum QuotedTextMode {
NONE,
SHOW,
HIDE
}

View file

@ -0,0 +1,7 @@
package com.fsck.k9.message;
public enum SimpleMessageFormat {
TEXT,
HTML
}

View file

@ -1,4 +1,4 @@
package com.fsck.k9.activity;
package com.fsck.k9.message;
import android.text.TextUtils;
import android.util.Log;

View file

@ -1,4 +1,4 @@
package com.fsck.k9.activity;
package com.fsck.k9.message;
import com.fsck.k9.Account.QuoteStyle;
import com.fsck.k9.mail.internet.TextBody;