mirror of
https://github.com/moparisthebest/k-9
synced 2024-11-10 11:35:11 -05:00
5a8ddaa039
Avoids IllegalArgumentException in LocalStore.getMessage()
3151 lines
136 KiB
Java
3151 lines
136 KiB
Java
package com.fsck.k9.activity;
|
|
|
|
|
|
import java.io.File;
|
|
import java.io.Serializable;
|
|
import java.util.*;
|
|
import java.util.regex.Matcher;
|
|
import java.util.regex.Pattern;
|
|
|
|
import android.text.*;
|
|
import android.webkit.WebViewClient;
|
|
import com.fsck.k9.helper.HtmlConverter;
|
|
import com.fsck.k9.helper.StringUtils;
|
|
import com.fsck.k9.mail.*;
|
|
import com.fsck.k9.view.MessageWebView;
|
|
import org.apache.james.mime4j.codec.EncoderUtil;
|
|
import android.app.AlertDialog;
|
|
import android.app.Dialog;
|
|
import android.content.ContentResolver;
|
|
import android.content.Context;
|
|
import android.content.DialogInterface;
|
|
import android.content.Intent;
|
|
import android.content.pm.ActivityInfo;
|
|
import android.database.Cursor;
|
|
import android.net.Uri;
|
|
import android.os.AsyncTask;
|
|
import android.os.Bundle;
|
|
import android.os.Handler;
|
|
import android.os.Parcelable;
|
|
import android.provider.OpenableColumns;
|
|
import android.text.util.Rfc822Tokenizer;
|
|
import android.util.Log;
|
|
import android.util.TypedValue;
|
|
import android.view.Menu;
|
|
import android.view.MenuItem;
|
|
import android.view.View;
|
|
import android.view.View.OnClickListener;
|
|
import android.view.View.OnFocusChangeListener;
|
|
import android.view.Window;
|
|
import android.webkit.WebView;
|
|
import android.widget.AutoCompleteTextView.Validator;
|
|
import android.widget.Button;
|
|
import android.widget.CheckBox;
|
|
import android.widget.EditText;
|
|
import android.widget.ImageButton;
|
|
import android.widget.LinearLayout;
|
|
import android.widget.MultiAutoCompleteTextView;
|
|
import android.widget.TextView;
|
|
import android.widget.Toast;
|
|
|
|
import org.htmlcleaner.CleanerProperties;
|
|
import org.htmlcleaner.HtmlCleaner;
|
|
import org.htmlcleaner.SimpleHtmlSerializer;
|
|
import org.htmlcleaner.TagNode;
|
|
|
|
import com.fsck.k9.Account;
|
|
import com.fsck.k9.Account.QuoteStyle;
|
|
import com.fsck.k9.Account.MessageFormat;
|
|
import com.fsck.k9.EmailAddressAdapter;
|
|
import com.fsck.k9.EmailAddressValidator;
|
|
import com.fsck.k9.FontSizes;
|
|
import com.fsck.k9.Identity;
|
|
import com.fsck.k9.K9;
|
|
import com.fsck.k9.Preferences;
|
|
import com.fsck.k9.R;
|
|
import com.fsck.k9.controller.MessagingController;
|
|
import com.fsck.k9.controller.MessagingListener;
|
|
import com.fsck.k9.crypto.CryptoProvider;
|
|
import com.fsck.k9.crypto.PgpData;
|
|
import com.fsck.k9.helper.Contacts;
|
|
import com.fsck.k9.helper.Utility;
|
|
import com.fsck.k9.mail.Message.RecipientType;
|
|
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.MimeMultipart;
|
|
import com.fsck.k9.mail.internet.MimeUtility;
|
|
import com.fsck.k9.mail.internet.TextBody;
|
|
import com.fsck.k9.mail.store.LocalStore;
|
|
import com.fsck.k9.mail.store.LocalStore.LocalAttachmentBody;
|
|
|
|
public class MessageCompose extends K9Activity implements OnClickListener, OnFocusChangeListener {
|
|
private static final int DIALOG_SAVE_OR_DISCARD_DRAFT_MESSAGE = 1;
|
|
private static final int DIALOG_REFUSE_TO_SAVE_DRAFT_MARKED_ENCRYPTED = 2;
|
|
private static final int DIALOG_CONTINUE_WITHOUT_PUBLIC_KEY = 3;
|
|
|
|
private static final long INVALID_DRAFT_ID = MessagingController.INVALID_MESSAGE_ID;
|
|
|
|
private static final String ACTION_COMPOSE = "com.fsck.k9.intent.action.COMPOSE";
|
|
private static final String ACTION_REPLY = "com.fsck.k9.intent.action.REPLY";
|
|
private static final String ACTION_REPLY_ALL = "com.fsck.k9.intent.action.REPLY_ALL";
|
|
private static final String ACTION_FORWARD = "com.fsck.k9.intent.action.FORWARD";
|
|
private static final String ACTION_EDIT_DRAFT = "com.fsck.k9.intent.action.EDIT_DRAFT";
|
|
|
|
private static final String EXTRA_ACCOUNT = "account";
|
|
private static final String EXTRA_MESSAGE_BODY = "messageBody";
|
|
private static final String EXTRA_MESSAGE_REFERENCE = "message_reference";
|
|
|
|
private static final String STATE_KEY_ATTACHMENTS =
|
|
"com.fsck.k9.activity.MessageCompose.attachments";
|
|
private static final String STATE_KEY_CC_SHOWN =
|
|
"com.fsck.k9.activity.MessageCompose.ccShown";
|
|
private static final String STATE_KEY_BCC_SHOWN =
|
|
"com.fsck.k9.activity.MessageCompose.bccShown";
|
|
private static final String STATE_KEY_QUOTED_TEXT_MODE =
|
|
"com.fsck.k9.activity.MessageCompose.QuotedTextShown";
|
|
private static final String STATE_KEY_SOURCE_MESSAGE_PROCED =
|
|
"com.fsck.k9.activity.MessageCompose.stateKeySourceMessageProced";
|
|
private static final String STATE_KEY_DRAFT_ID = "com.fsck.k9.activity.MessageCompose.draftId";
|
|
private static final String STATE_KEY_HTML_QUOTE = "com.fsck.k9.activity.MessageCompose.HTMLQuote";
|
|
private static final String STATE_IDENTITY_CHANGED =
|
|
"com.fsck.k9.activity.MessageCompose.identityChanged";
|
|
private static final String STATE_IDENTITY =
|
|
"com.fsck.k9.activity.MessageCompose.identity";
|
|
private static final String STATE_PGP_DATA = "pgpData";
|
|
private static final String STATE_IN_REPLY_TO = "com.fsck.k9.activity.MessageCompose.inReplyTo";
|
|
private static final String STATE_REFERENCES = "com.fsck.k9.activity.MessageCompose.references";
|
|
private static final String STATE_KEY_MESSAGE_FORMAT = "com.fsck.k9.activity.MessageCompose.messageFormat";
|
|
private static final String STATE_KEY_READ_RECEIPT = "com.fsck.k9.activity.MessageCompose.messageReadReceipt";
|
|
private static final String STATE_KEY_DRAFT_NEEDS_SAVING = "com.fsck.k9.activity.MessageCompose.mDraftNeedsSaving";
|
|
|
|
private static final int MSG_PROGRESS_ON = 1;
|
|
private static final int MSG_PROGRESS_OFF = 2;
|
|
private static final int MSG_UPDATE_TITLE = 3;
|
|
private static final int MSG_SKIPPED_ATTACHMENTS = 4;
|
|
private static final int MSG_SAVED_DRAFT = 5;
|
|
private static final int MSG_DISCARDED_DRAFT = 6;
|
|
|
|
private static final int ACTIVITY_REQUEST_PICK_ATTACHMENT = 1;
|
|
private static final int ACTIVITY_CHOOSE_IDENTITY = 2;
|
|
private static final int ACTIVITY_CHOOSE_ACCOUNT = 3;
|
|
private static final int CONTACT_PICKER_TO = 4;
|
|
private static final int CONTACT_PICKER_CC = 5;
|
|
private static final int CONTACT_PICKER_BCC = 6;
|
|
|
|
|
|
/**
|
|
* Regular expression to remove the first localized "Re:" prefix in subjects.
|
|
*
|
|
* Currently:
|
|
* - "Aw:" (german: abbreviation for "Antwort")
|
|
*/
|
|
private static final Pattern PREFIX = Pattern.compile("^AW[:\\s]\\s*", Pattern.CASE_INSENSITIVE);
|
|
|
|
/**
|
|
* The account used for message composition.
|
|
*/
|
|
private Account mAccount;
|
|
|
|
|
|
private Contacts mContacts;
|
|
|
|
/**
|
|
* This identity's settings are used for message composition.
|
|
* Note: This has to be an identity of the account {@link #mAccount}.
|
|
*/
|
|
private Identity mIdentity;
|
|
|
|
private boolean mIdentityChanged = false;
|
|
private boolean mSignatureChanged = false;
|
|
|
|
/**
|
|
* Reference to the source message (in case of reply, forward, or edit
|
|
* draft actions).
|
|
*/
|
|
private MessageReference mMessageReference;
|
|
|
|
private Message mSourceMessage;
|
|
private String mSourceMessageBody;
|
|
|
|
/**
|
|
* Indicates that the source message has been processed at least once and should not
|
|
* be processed on any subsequent loads. This protects us from adding attachments that
|
|
* have already been added from the restore of the view state.
|
|
*/
|
|
private boolean mSourceMessageProcessed = false;
|
|
|
|
private enum QuotedTextMode {
|
|
NONE,
|
|
SHOW,
|
|
HIDE
|
|
};
|
|
|
|
private boolean mReadReceipt = false;
|
|
|
|
private QuotedTextMode mQuotedTextMode = QuotedTextMode.NONE;
|
|
|
|
private TextView mFromView;
|
|
private LinearLayout mCcWrapper;
|
|
private LinearLayout mBccWrapper;
|
|
private MultiAutoCompleteTextView mToView;
|
|
private MultiAutoCompleteTextView mCcView;
|
|
private MultiAutoCompleteTextView mBccView;
|
|
private EditText mSubjectView;
|
|
private EditText mSignatureView;
|
|
private EditText mMessageContentView;
|
|
private LinearLayout mAttachments;
|
|
private Button mQuotedTextShow;
|
|
private View mQuotedTextBar;
|
|
private ImageButton mQuotedTextEdit;
|
|
private ImageButton mQuotedTextDelete;
|
|
private EditText mQuotedText;
|
|
private MessageWebView mQuotedHTML;
|
|
private InsertableHtmlContent mQuotedHtmlContent; // Container for HTML reply as it's being built.
|
|
private View mEncryptLayout;
|
|
private CheckBox mCryptoSignatureCheckbox;
|
|
private CheckBox mEncryptCheckbox;
|
|
private TextView mCryptoSignatureUserId;
|
|
private TextView mCryptoSignatureUserIdRest;
|
|
|
|
private ImageButton mAddToFromContacts;
|
|
private ImageButton mAddCcFromContacts;
|
|
private ImageButton mAddBccFromContacts;
|
|
|
|
private PgpData mPgpData = null;
|
|
private boolean mAutoEncrypt = false;
|
|
private boolean mContinueWithoutPublicKey = false;
|
|
|
|
private String mReferences;
|
|
private String mInReplyTo;
|
|
|
|
private boolean mSourceProcessed = false;
|
|
private MessageFormat mMessageFormat;
|
|
private QuoteStyle mQuoteStyle;
|
|
|
|
private boolean mDraftNeedsSaving = false;
|
|
private boolean mPreventDraftSaving = false;
|
|
|
|
/**
|
|
* If this is {@code true} we don't save the message as a draft in {@link #onPause()}.
|
|
*/
|
|
private boolean mIgnoreOnPause = false;
|
|
|
|
/**
|
|
* The database ID of this message's draft. This is used when saving drafts so the message in
|
|
* the database is updated instead of being created anew. This property is INVALID_DRAFT_ID
|
|
* until the first save.
|
|
*/
|
|
private long mDraftId = INVALID_DRAFT_ID;
|
|
|
|
private Handler mHandler = new Handler() {
|
|
@Override
|
|
public void handleMessage(android.os.Message msg) {
|
|
switch (msg.what) {
|
|
case MSG_PROGRESS_ON:
|
|
setProgressBarIndeterminateVisibility(true);
|
|
break;
|
|
case MSG_PROGRESS_OFF:
|
|
setProgressBarIndeterminateVisibility(false);
|
|
break;
|
|
case MSG_UPDATE_TITLE:
|
|
updateTitle();
|
|
break;
|
|
case MSG_SKIPPED_ATTACHMENTS:
|
|
Toast.makeText(
|
|
MessageCompose.this,
|
|
getString(R.string.message_compose_attachments_skipped_toast),
|
|
Toast.LENGTH_LONG).show();
|
|
break;
|
|
case MSG_SAVED_DRAFT:
|
|
Toast.makeText(
|
|
MessageCompose.this,
|
|
getString(R.string.message_saved_toast),
|
|
Toast.LENGTH_LONG).show();
|
|
break;
|
|
case MSG_DISCARDED_DRAFT:
|
|
Toast.makeText(
|
|
MessageCompose.this,
|
|
getString(R.string.message_discarded_toast),
|
|
Toast.LENGTH_LONG).show();
|
|
break;
|
|
default:
|
|
super.handleMessage(msg);
|
|
break;
|
|
}
|
|
}
|
|
};
|
|
|
|
private Listener mListener = new Listener();
|
|
private EmailAddressAdapter mAddressAdapter;
|
|
private Validator mAddressValidator;
|
|
|
|
private FontSizes mFontSizes = K9.getFontSizes();
|
|
|
|
|
|
static class Attachment implements Serializable {
|
|
private static final long serialVersionUID = 3642382876618963734L;
|
|
public String name;
|
|
public String contentType;
|
|
public long size;
|
|
public Uri uri;
|
|
}
|
|
|
|
/**
|
|
* Compose a new message using the given account. If account is null the default account
|
|
* will be used.
|
|
* @param context
|
|
* @param account
|
|
*/
|
|
public static void actionCompose(Context context, Account account) {
|
|
String accountUuid = (account == null) ?
|
|
Preferences.getPreferences(context).getDefaultAccount().getUuid() :
|
|
account.getUuid();
|
|
|
|
Intent i = new Intent(context, MessageCompose.class);
|
|
i.putExtra(EXTRA_ACCOUNT, accountUuid);
|
|
i.setAction(ACTION_COMPOSE);
|
|
context.startActivity(i);
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
*/
|
|
public static void actionReply(
|
|
Context context,
|
|
Account account,
|
|
Message message,
|
|
boolean replyAll,
|
|
String messageBody) {
|
|
Intent i = new Intent(context, MessageCompose.class);
|
|
i.putExtra(EXTRA_MESSAGE_BODY, messageBody);
|
|
i.putExtra(EXTRA_MESSAGE_REFERENCE, message.makeMessageReference());
|
|
if (replyAll) {
|
|
i.setAction(ACTION_REPLY_ALL);
|
|
} else {
|
|
i.setAction(ACTION_REPLY);
|
|
}
|
|
context.startActivity(i);
|
|
}
|
|
|
|
/**
|
|
* Compose a new message as a forward of the given message.
|
|
* @param context
|
|
* @param account
|
|
* @param message
|
|
* @param messageBody optional, for decrypted messages, null if it should be grabbed from the given message
|
|
*/
|
|
public static void actionForward(
|
|
Context context,
|
|
Account account,
|
|
Message message,
|
|
String messageBody) {
|
|
Intent i = new Intent(context, MessageCompose.class);
|
|
i.putExtra(EXTRA_MESSAGE_BODY, messageBody);
|
|
i.putExtra(EXTRA_MESSAGE_REFERENCE, message.makeMessageReference());
|
|
i.setAction(ACTION_FORWARD);
|
|
context.startActivity(i);
|
|
}
|
|
|
|
/**
|
|
* Continue composition of the given message. This action modifies the way this Activity
|
|
* handles certain actions.
|
|
* Save will attempt to replace the message in the given folder with the updated version.
|
|
* Discard will delete the message from the given folder.
|
|
* @param context
|
|
* @param account
|
|
* @param message
|
|
*/
|
|
public static void actionEditDraft(Context context, Account account, Message message) {
|
|
Intent i = new Intent(context, MessageCompose.class);
|
|
i.putExtra(EXTRA_MESSAGE_REFERENCE, message.makeMessageReference());
|
|
i.setAction(ACTION_EDIT_DRAFT);
|
|
context.startActivity(i);
|
|
}
|
|
|
|
@Override
|
|
public void onCreate(Bundle savedInstanceState) {
|
|
super.onCreate(savedInstanceState);
|
|
requestWindowFeature(Window.FEATURE_INDETERMINATE_PROGRESS);
|
|
setContentView(R.layout.message_compose);
|
|
|
|
final Intent intent = getIntent();
|
|
|
|
mMessageReference = intent.getParcelableExtra(EXTRA_MESSAGE_REFERENCE);
|
|
mSourceMessageBody = intent.getStringExtra(EXTRA_MESSAGE_BODY);
|
|
|
|
if (K9.DEBUG && mSourceMessageBody != null)
|
|
Log.d(K9.LOG_TAG, "Composing message with explicitly specified message body.");
|
|
|
|
final String accountUuid = (mMessageReference != null) ?
|
|
mMessageReference.accountUuid :
|
|
intent.getStringExtra(EXTRA_ACCOUNT);
|
|
|
|
mAccount = Preferences.getPreferences(this).getAccount(accountUuid);
|
|
|
|
if (mAccount == null) {
|
|
mAccount = Preferences.getPreferences(this).getDefaultAccount();
|
|
}
|
|
|
|
if (mAccount == null) {
|
|
/*
|
|
* There are no accounts set up. This should not have happened. Prompt the
|
|
* user to set up an account as an acceptable bailout.
|
|
*/
|
|
startActivity(new Intent(this, Accounts.class));
|
|
mDraftNeedsSaving = false;
|
|
finish();
|
|
return;
|
|
}
|
|
|
|
mContacts = Contacts.getInstance(MessageCompose.this);
|
|
|
|
mAddressAdapter = EmailAddressAdapter.getInstance(this);
|
|
mAddressValidator = new EmailAddressValidator();
|
|
|
|
mFromView = (TextView) findViewById(R.id.from);
|
|
mToView = (MultiAutoCompleteTextView) findViewById(R.id.to);
|
|
mCcView = (MultiAutoCompleteTextView) findViewById(R.id.cc);
|
|
mBccView = (MultiAutoCompleteTextView) findViewById(R.id.bcc);
|
|
mSubjectView = (EditText) findViewById(R.id.subject);
|
|
mSubjectView.getInputExtras(true).putBoolean("allowEmoji", true);
|
|
|
|
mAddToFromContacts = (ImageButton) findViewById(R.id.add_to);
|
|
mAddCcFromContacts = (ImageButton) findViewById(R.id.add_cc);
|
|
mAddBccFromContacts = (ImageButton) findViewById(R.id.add_bcc);
|
|
mCcWrapper = (LinearLayout) findViewById(R.id.cc_wrapper);
|
|
mBccWrapper = (LinearLayout) findViewById(R.id.bcc_wrapper);
|
|
|
|
EditText upperSignature = (EditText)findViewById(R.id.upper_signature);
|
|
EditText lowerSignature = (EditText)findViewById(R.id.lower_signature);
|
|
|
|
mMessageContentView = (EditText)findViewById(R.id.message_content);
|
|
mMessageContentView.getInputExtras(true).putBoolean("allowEmoji", true);
|
|
mAttachments = (LinearLayout)findViewById(R.id.attachments);
|
|
mQuotedTextShow = (Button)findViewById(R.id.quoted_text_show);
|
|
mQuotedTextBar = findViewById(R.id.quoted_text_bar);
|
|
mQuotedTextEdit = (ImageButton)findViewById(R.id.quoted_text_edit);
|
|
mQuotedTextDelete = (ImageButton)findViewById(R.id.quoted_text_delete);
|
|
mQuotedText = (EditText)findViewById(R.id.quoted_text);
|
|
mQuotedText.getInputExtras(true).putBoolean("allowEmoji", true);
|
|
|
|
mQuotedHTML = (MessageWebView) findViewById(R.id.quoted_html);
|
|
mQuotedHTML.configure();
|
|
// Disable the ability to click links in the quoted HTML page. I think this is a nice feature, but if someone
|
|
// feels this should be a preference (or should go away all together), I'm ok with that too. -achen 20101130
|
|
mQuotedHTML.setWebViewClient(new WebViewClient() {
|
|
@Override
|
|
public boolean shouldOverrideUrlLoading(WebView view, String url) {
|
|
return true;
|
|
}
|
|
});
|
|
|
|
TextWatcher watcher = new TextWatcher() {
|
|
@Override
|
|
public void beforeTextChanged(CharSequence s, int start, int before, int after) {
|
|
/* do nothing */
|
|
}
|
|
|
|
@Override
|
|
public void onTextChanged(CharSequence s, int start, int before, int count) {
|
|
mDraftNeedsSaving = true;
|
|
}
|
|
|
|
@Override
|
|
public void afterTextChanged(android.text.Editable s) { /* do nothing */ }
|
|
};
|
|
|
|
// For watching changes to the To:, Cc:, and Bcc: fields for auto-encryption on a matching
|
|
// address.
|
|
TextWatcher recipientWatcher = new TextWatcher() {
|
|
@Override
|
|
public void beforeTextChanged(CharSequence s, int start, int before, int after) {
|
|
/* do nothing */
|
|
}
|
|
|
|
@Override
|
|
public void onTextChanged(CharSequence s, int start, int before, int count) {
|
|
mDraftNeedsSaving = true;
|
|
}
|
|
|
|
@Override
|
|
public void afterTextChanged(android.text.Editable s) {
|
|
final CryptoProvider crypto = mAccount.getCryptoProvider();
|
|
if (mAutoEncrypt && crypto.isAvailable(getApplicationContext())) {
|
|
for (Address address : getRecipientAddresses()) {
|
|
if (crypto.hasPublicKeyForEmail(getApplicationContext(),
|
|
address.getAddress())) {
|
|
mEncryptCheckbox.setChecked(true);
|
|
mContinueWithoutPublicKey = false;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
TextWatcher sigwatcher = new TextWatcher() {
|
|
@Override
|
|
public void beforeTextChanged(CharSequence s, int start, int before, int after) {
|
|
/* do nothing */
|
|
}
|
|
|
|
@Override
|
|
public void onTextChanged(CharSequence s, int start, int before, int count) {
|
|
mDraftNeedsSaving = true;
|
|
mSignatureChanged = true;
|
|
}
|
|
|
|
@Override
|
|
public void afterTextChanged(android.text.Editable s) { /* do nothing */ }
|
|
};
|
|
|
|
mToView.addTextChangedListener(recipientWatcher);
|
|
mCcView.addTextChangedListener(recipientWatcher);
|
|
mBccView.addTextChangedListener(recipientWatcher);
|
|
mSubjectView.addTextChangedListener(watcher);
|
|
|
|
mMessageContentView.addTextChangedListener(watcher);
|
|
mQuotedText.addTextChangedListener(watcher);
|
|
|
|
/* Yes, there really are poeple who ship versions of android without a contact picker */
|
|
if (mContacts.hasContactPicker()) {
|
|
mAddToFromContacts.setOnClickListener(new OnClickListener() {
|
|
@Override public void onClick(View v) {
|
|
doLaunchContactPicker(CONTACT_PICKER_TO);
|
|
}
|
|
});
|
|
mAddCcFromContacts.setOnClickListener(new OnClickListener() {
|
|
@Override public void onClick(View v) {
|
|
doLaunchContactPicker(CONTACT_PICKER_CC);
|
|
}
|
|
});
|
|
mAddBccFromContacts.setOnClickListener(new OnClickListener() {
|
|
@Override public void onClick(View v) {
|
|
doLaunchContactPicker(CONTACT_PICKER_BCC);
|
|
}
|
|
});
|
|
} else {
|
|
mAddToFromContacts.setVisibility(View.GONE);
|
|
mAddCcFromContacts.setVisibility(View.GONE);
|
|
mAddBccFromContacts.setVisibility(View.GONE);
|
|
}
|
|
/*
|
|
* We set this to invisible by default. Other methods will turn it back on if it's
|
|
* needed.
|
|
*/
|
|
|
|
showOrHideQuotedText(QuotedTextMode.NONE);
|
|
|
|
mQuotedTextShow.setOnClickListener(this);
|
|
mQuotedTextEdit.setOnClickListener(this);
|
|
mQuotedTextDelete.setOnClickListener(this);
|
|
|
|
mFromView.setVisibility(View.GONE);
|
|
|
|
mToView.setAdapter(mAddressAdapter);
|
|
mToView.setTokenizer(new Rfc822Tokenizer());
|
|
mToView.setValidator(mAddressValidator);
|
|
|
|
mCcView.setAdapter(mAddressAdapter);
|
|
mCcView.setTokenizer(new Rfc822Tokenizer());
|
|
mCcView.setValidator(mAddressValidator);
|
|
|
|
mBccView.setAdapter(mAddressAdapter);
|
|
mBccView.setTokenizer(new Rfc822Tokenizer());
|
|
mBccView.setValidator(mAddressValidator);
|
|
|
|
|
|
mSubjectView.setOnFocusChangeListener(this);
|
|
|
|
if (savedInstanceState != null) {
|
|
/*
|
|
* This data gets used in onCreate, so grab it here instead of onRestoreInstanceState
|
|
*/
|
|
mSourceMessageProcessed = savedInstanceState.getBoolean(STATE_KEY_SOURCE_MESSAGE_PROCED, false);
|
|
}
|
|
|
|
|
|
final String action = intent.getAction();
|
|
initFromIntent(intent);
|
|
|
|
if (mIdentity == null) {
|
|
mIdentity = mAccount.getIdentity(0);
|
|
}
|
|
|
|
if (mAccount.isSignatureBeforeQuotedText()) {
|
|
mSignatureView = upperSignature;
|
|
lowerSignature.setVisibility(View.GONE);
|
|
} else {
|
|
mSignatureView = lowerSignature;
|
|
upperSignature.setVisibility(View.GONE);
|
|
}
|
|
mSignatureView.addTextChangedListener(sigwatcher);
|
|
|
|
if (!mIdentity.getSignatureUse()) {
|
|
mSignatureView.setVisibility(View.GONE);
|
|
}
|
|
|
|
mMessageFormat = mAccount.getMessageFormat();
|
|
if (mMessageFormat == MessageFormat.AUTO) {
|
|
if (ACTION_COMPOSE.equals(action)) {
|
|
mMessageFormat = MessageFormat.TEXT;
|
|
} else if (mSourceMessageBody != null) {
|
|
// mSourceMessageBody is set to something when replying to and forwarding decrypted
|
|
// messages, so we set the format to plain text.
|
|
mMessageFormat = MessageFormat.TEXT;
|
|
}
|
|
}
|
|
|
|
mReadReceipt = mAccount.isMessageReadReceiptAlways();
|
|
mQuoteStyle = mAccount.getQuoteStyle();
|
|
|
|
if (!mSourceMessageProcessed) {
|
|
updateFrom();
|
|
updateSignature();
|
|
|
|
if (ACTION_REPLY.equals(action) ||
|
|
ACTION_REPLY_ALL.equals(action) ||
|
|
ACTION_FORWARD.equals(action) ||
|
|
ACTION_EDIT_DRAFT.equals(action)) {
|
|
/*
|
|
* If we need to load the message we add ourself as a message listener here
|
|
* so we can kick it off. Normally we add in onResume but we don't
|
|
* want to reload the message every time the activity is resumed.
|
|
* There is no harm in adding twice.
|
|
*/
|
|
MessagingController.getInstance(getApplication()).addListener(mListener);
|
|
|
|
final Account account = Preferences.getPreferences(this).getAccount(mMessageReference.accountUuid);
|
|
final String folderName = mMessageReference.folderName;
|
|
final String sourceMessageUid = mMessageReference.uid;
|
|
MessagingController.getInstance(getApplication()).loadMessageForView(account, folderName, sourceMessageUid, null);
|
|
}
|
|
|
|
if (!ACTION_EDIT_DRAFT.equals(action)) {
|
|
String bccAddress = mAccount.getAlwaysBcc();
|
|
if ((bccAddress != null) && !("".equals(bccAddress))) {
|
|
String[] bccAddresses = bccAddress.split(",");
|
|
for (String oneBccAddress : bccAddresses) {
|
|
addAddress(mBccView, new Address(oneBccAddress, ""));
|
|
}
|
|
}
|
|
}
|
|
|
|
/*
|
|
if (K9.DEBUG)
|
|
Log.d(K9.LOG_TAG, "action = " + action + ", account = " + mMessageReference.accountUuid + ", folder = " + mMessageReference.folderName + ", sourceMessageUid = " + mMessageReference.uid);
|
|
*/
|
|
|
|
updateTitle();
|
|
}
|
|
|
|
if (ACTION_REPLY.equals(action) ||
|
|
ACTION_REPLY_ALL.equals(action)) {
|
|
mMessageReference.flag = Flag.ANSWERED;
|
|
}
|
|
|
|
if (ACTION_REPLY.equals(action) ||
|
|
ACTION_REPLY_ALL.equals(action) ||
|
|
ACTION_EDIT_DRAFT.equals(action)) {
|
|
//change focus to message body.
|
|
mMessageContentView.requestFocus();
|
|
} else {
|
|
// Explicitly set focus to "To:" input field (see issue 2998)
|
|
mToView.requestFocus();
|
|
}
|
|
|
|
|
|
mEncryptLayout = findViewById(R.id.layout_encrypt);
|
|
mCryptoSignatureCheckbox = (CheckBox)findViewById(R.id.cb_crypto_signature);
|
|
mCryptoSignatureUserId = (TextView)findViewById(R.id.userId);
|
|
mCryptoSignatureUserIdRest = (TextView)findViewById(R.id.userIdRest);
|
|
mEncryptCheckbox = (CheckBox)findViewById(R.id.cb_encrypt);
|
|
if (mSourceMessageBody != null) {
|
|
// mSourceMessageBody is set to something when replying to and forwarding decrypted
|
|
// messages, so the sender probably wants the message to be encrypted.
|
|
mEncryptCheckbox.setChecked(true);
|
|
}
|
|
|
|
initializeCrypto();
|
|
final CryptoProvider crypto = mAccount.getCryptoProvider();
|
|
if (crypto.isAvailable(this)) {
|
|
mEncryptLayout.setVisibility(View.VISIBLE);
|
|
mCryptoSignatureCheckbox.setOnClickListener(new OnClickListener() {
|
|
@Override
|
|
public void onClick(View v) {
|
|
CheckBox checkBox = (CheckBox) v;
|
|
if (checkBox.isChecked()) {
|
|
mPreventDraftSaving = true;
|
|
if (!crypto.selectSecretKey(MessageCompose.this, mPgpData)) {
|
|
mPreventDraftSaving = false;
|
|
}
|
|
checkBox.setChecked(false);
|
|
} else {
|
|
mPgpData.setSignatureKeyId(0);
|
|
updateEncryptLayout();
|
|
}
|
|
}
|
|
});
|
|
|
|
if (mAccount.getCryptoAutoSignature()) {
|
|
long ids[] = crypto.getSecretKeyIdsFromEmail(this, mIdentity.getEmail());
|
|
if (ids != null && ids.length > 0) {
|
|
mPgpData.setSignatureKeyId(ids[0]);
|
|
mPgpData.setSignatureUserId(crypto.getUserId(this, ids[0]));
|
|
} else {
|
|
mPgpData.setSignatureKeyId(0);
|
|
mPgpData.setSignatureUserId(null);
|
|
}
|
|
}
|
|
updateEncryptLayout();
|
|
mAutoEncrypt = mAccount.isCryptoAutoEncrypt();
|
|
} else {
|
|
mEncryptLayout.setVisibility(View.GONE);
|
|
}
|
|
|
|
mDraftNeedsSaving = false;
|
|
|
|
// Set font size of input controls
|
|
int fontSize = mFontSizes.getMessageComposeInput();
|
|
mToView.setTextSize(TypedValue.COMPLEX_UNIT_SP, fontSize);
|
|
mCcView.setTextSize(TypedValue.COMPLEX_UNIT_SP, fontSize);
|
|
mBccView.setTextSize(TypedValue.COMPLEX_UNIT_SP, fontSize);
|
|
mSubjectView.setTextSize(TypedValue.COMPLEX_UNIT_SP, fontSize);
|
|
mMessageContentView.setTextSize(TypedValue.COMPLEX_UNIT_SP, fontSize);
|
|
mQuotedText.setTextSize(TypedValue.COMPLEX_UNIT_SP, fontSize);
|
|
mSignatureView.setTextSize(TypedValue.COMPLEX_UNIT_SP, fontSize);
|
|
}
|
|
|
|
/**
|
|
* Handle external intents that trigger the message compose activity.
|
|
*
|
|
* @param intent The (external) intent that started the activity.
|
|
*/
|
|
private void initFromIntent(final Intent intent) {
|
|
final String action = intent.getAction();
|
|
|
|
if (Intent.ACTION_VIEW.equals(action) || Intent.ACTION_SENDTO.equals(action)) {
|
|
/*
|
|
* Someone has clicked a mailto: link. The address is in the URI.
|
|
*/
|
|
if (intent.getData() != null) {
|
|
Uri uri = intent.getData();
|
|
if ("mailto".equals(uri.getScheme())) {
|
|
initializeFromMailto(uri);
|
|
}
|
|
}
|
|
|
|
/*
|
|
* Note: According to the documenation ACTION_VIEW and ACTION_SENDTO
|
|
* don't accept EXTRA_* parameters. Contrary to the AOSP Email application
|
|
* we don't accept those EXTRAs.
|
|
* Dear developer, if your application is using those EXTRAs you're doing
|
|
* it wrong! So go fix your program or get AOSP to change the documentation.
|
|
*/
|
|
}
|
|
else if (Intent.ACTION_SEND.equals(action) || Intent.ACTION_SEND_MULTIPLE.equals(action)) {
|
|
/*
|
|
* Note: Here we allow a slight deviation from the documentated behavior.
|
|
* EXTRA_TEXT is used as message body (if available) regardless of the MIME
|
|
* type of the intent. In addition one or multiple attachments can be added
|
|
* using EXTRA_STREAM.
|
|
*/
|
|
CharSequence text = intent.getCharSequenceExtra(Intent.EXTRA_TEXT);
|
|
if (text != null) {
|
|
mMessageContentView.setText(text);
|
|
}
|
|
|
|
String type = intent.getType();
|
|
if (Intent.ACTION_SEND.equals(action)) {
|
|
Uri stream = (Uri) intent.getParcelableExtra(Intent.EXTRA_STREAM);
|
|
if (stream != null) {
|
|
addAttachment(stream, type);
|
|
}
|
|
} else {
|
|
ArrayList<Parcelable> list = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM);
|
|
if (list != null) {
|
|
for (Parcelable parcelable : list) {
|
|
Uri stream = (Uri) parcelable;
|
|
if (stream != null) {
|
|
addAttachment(stream, type);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
String subject = intent.getStringExtra(Intent.EXTRA_SUBJECT);
|
|
if (subject != null) {
|
|
mSubjectView.setText(subject);
|
|
}
|
|
|
|
String[] extraEmail = intent.getStringArrayExtra(Intent.EXTRA_EMAIL);
|
|
String[] extraCc = intent.getStringArrayExtra(Intent.EXTRA_CC);
|
|
String[] extraBcc = intent.getStringArrayExtra(Intent.EXTRA_BCC);
|
|
|
|
if (extraEmail != null) {
|
|
setRecipients(mToView, Arrays.asList(extraEmail));
|
|
}
|
|
|
|
boolean ccOrBcc = false;
|
|
if (extraCc != null) {
|
|
ccOrBcc |= setRecipients(mCcView, Arrays.asList(extraCc));
|
|
}
|
|
|
|
if (extraBcc != null) {
|
|
ccOrBcc |= setRecipients(mBccView, Arrays.asList(extraBcc));
|
|
}
|
|
|
|
if (ccOrBcc) {
|
|
// Display CC and BCC text fields if CC or BCC recipients were set by the intent.
|
|
onAddCcBcc();
|
|
}
|
|
}
|
|
}
|
|
|
|
private boolean setRecipients(TextView view, List<String> recipients) {
|
|
boolean recipientAdded = false;
|
|
if (recipients != null) {
|
|
StringBuilder addressList = new StringBuilder();
|
|
for (String recipient : recipients) {
|
|
addressList.append(recipient);
|
|
addressList.append(", ");
|
|
recipientAdded = true;
|
|
}
|
|
view.setText(addressList);
|
|
}
|
|
|
|
return recipientAdded;
|
|
}
|
|
|
|
private void initializeCrypto() {
|
|
if (mPgpData != null) {
|
|
return;
|
|
}
|
|
mPgpData = new PgpData();
|
|
}
|
|
|
|
/**
|
|
* Fill the encrypt layout with the latest data about signature key and encryption keys.
|
|
*/
|
|
public void updateEncryptLayout() {
|
|
if (!mPgpData.hasSignatureKey()) {
|
|
mCryptoSignatureCheckbox.setText(R.string.btn_crypto_sign);
|
|
mCryptoSignatureCheckbox.setChecked(false);
|
|
mCryptoSignatureUserId.setVisibility(View.INVISIBLE);
|
|
mCryptoSignatureUserIdRest.setVisibility(View.INVISIBLE);
|
|
} else {
|
|
mMessageFormat = MessageFormat.TEXT;
|
|
// if a signature key is selected, then the checkbox itself has no text
|
|
mCryptoSignatureCheckbox.setText("");
|
|
mCryptoSignatureCheckbox.setChecked(true);
|
|
mCryptoSignatureUserId.setVisibility(View.VISIBLE);
|
|
mCryptoSignatureUserIdRest.setVisibility(View.VISIBLE);
|
|
mCryptoSignatureUserId.setText(R.string.unknown_crypto_signature_user_id);
|
|
mCryptoSignatureUserIdRest.setText("");
|
|
|
|
String userId = mPgpData.getSignatureUserId();
|
|
if (userId == null) {
|
|
userId = mAccount.getCryptoProvider().getUserId(this, mPgpData.getSignatureKeyId());
|
|
mPgpData.setSignatureUserId(userId);
|
|
}
|
|
|
|
if (userId != null) {
|
|
String chunks[] = mPgpData.getSignatureUserId().split(" <", 2);
|
|
mCryptoSignatureUserId.setText(chunks[0]);
|
|
if (chunks.length > 1) {
|
|
mCryptoSignatureUserIdRest.setText("<" + chunks[1]);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@Override
|
|
public void onResume() {
|
|
super.onResume();
|
|
mIgnoreOnPause = false;
|
|
MessagingController.getInstance(getApplication()).addListener(mListener);
|
|
}
|
|
|
|
@Override
|
|
public void onPause() {
|
|
super.onPause();
|
|
MessagingController.getInstance(getApplication()).removeListener(mListener);
|
|
// Save email as draft when activity is changed (go to home screen, call received) or screen locked
|
|
// don't do this if only changing orientations
|
|
if (!mIgnoreOnPause && (getChangingConfigurations() & ActivityInfo.CONFIG_ORIENTATION) == 0) {
|
|
saveIfNeeded();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The framework handles most of the fields, but we need to handle stuff that we
|
|
* dynamically show and hide:
|
|
* Attachment list,
|
|
* Cc field,
|
|
* Bcc field,
|
|
* Quoted text,
|
|
*/
|
|
@Override
|
|
protected void onSaveInstanceState(Bundle outState) {
|
|
super.onSaveInstanceState(outState);
|
|
ArrayList<Uri> attachments = new ArrayList<Uri>();
|
|
for (int i = 0, count = mAttachments.getChildCount(); i < count; i++) {
|
|
View view = mAttachments.getChildAt(i);
|
|
Attachment attachment = (Attachment) view.getTag();
|
|
attachments.add(attachment.uri);
|
|
}
|
|
outState.putParcelableArrayList(STATE_KEY_ATTACHMENTS, attachments);
|
|
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);
|
|
outState.putBoolean(STATE_KEY_SOURCE_MESSAGE_PROCED, mSourceMessageProcessed);
|
|
outState.putLong(STATE_KEY_DRAFT_ID, mDraftId);
|
|
outState.putSerializable(STATE_IDENTITY, mIdentity);
|
|
outState.putBoolean(STATE_IDENTITY_CHANGED, mIdentityChanged);
|
|
outState.putSerializable(STATE_PGP_DATA, mPgpData);
|
|
outState.putString(STATE_IN_REPLY_TO, mInReplyTo);
|
|
outState.putString(STATE_REFERENCES, mReferences);
|
|
outState.putSerializable(STATE_KEY_HTML_QUOTE, mQuotedHtmlContent);
|
|
outState.putSerializable(STATE_KEY_MESSAGE_FORMAT, mMessageFormat);
|
|
outState.putBoolean(STATE_KEY_READ_RECEIPT, mReadReceipt);
|
|
outState.putBoolean(STATE_KEY_DRAFT_NEEDS_SAVING, mDraftNeedsSaving);
|
|
}
|
|
|
|
@Override
|
|
protected void onRestoreInstanceState(Bundle savedInstanceState) {
|
|
super.onRestoreInstanceState(savedInstanceState);
|
|
ArrayList<Parcelable> attachments = savedInstanceState.getParcelableArrayList(STATE_KEY_ATTACHMENTS);
|
|
mAttachments.removeAllViews();
|
|
for (Parcelable p : attachments) {
|
|
Uri uri = (Uri) p;
|
|
addAttachment(uri);
|
|
}
|
|
|
|
mMessageFormat = (MessageFormat) savedInstanceState
|
|
.getSerializable(STATE_KEY_MESSAGE_FORMAT);
|
|
mReadReceipt = savedInstanceState
|
|
.getBoolean(STATE_KEY_READ_RECEIPT);
|
|
mCcWrapper.setVisibility(savedInstanceState.getBoolean(STATE_KEY_CC_SHOWN) ? View.VISIBLE
|
|
: View.GONE);
|
|
mBccWrapper.setVisibility(savedInstanceState
|
|
.getBoolean(STATE_KEY_BCC_SHOWN) ? View.VISIBLE : View.GONE);
|
|
showOrHideQuotedText((QuotedTextMode)savedInstanceState.getSerializable(STATE_KEY_QUOTED_TEXT_MODE));
|
|
if (mQuotedTextMode != QuotedTextMode.NONE && mMessageFormat == MessageFormat.HTML) {
|
|
mQuotedHtmlContent = (InsertableHtmlContent) savedInstanceState.getSerializable(STATE_KEY_HTML_QUOTE);
|
|
if (mQuotedHtmlContent != null && mQuotedHtmlContent.getQuotedContent() != null) {
|
|
mQuotedHTML.loadDataWithBaseURL("http://", mQuotedHtmlContent.getQuotedContent(), "text/html", "utf-8", null);
|
|
}
|
|
}
|
|
mDraftId = savedInstanceState.getLong(STATE_KEY_DRAFT_ID);
|
|
mIdentity = (Identity)savedInstanceState.getSerializable(STATE_IDENTITY);
|
|
mIdentityChanged = savedInstanceState.getBoolean(STATE_IDENTITY_CHANGED);
|
|
mPgpData = (PgpData) savedInstanceState.getSerializable(STATE_PGP_DATA);
|
|
mInReplyTo = savedInstanceState.getString(STATE_IN_REPLY_TO);
|
|
mReferences = savedInstanceState.getString(STATE_REFERENCES);
|
|
mDraftNeedsSaving = savedInstanceState.getBoolean(STATE_KEY_DRAFT_NEEDS_SAVING);
|
|
|
|
initializeCrypto();
|
|
updateFrom();
|
|
updateSignature();
|
|
updateEncryptLayout();
|
|
|
|
}
|
|
|
|
private void updateTitle() {
|
|
if (mSubjectView.getText().length() == 0) {
|
|
setTitle(R.string.compose_title);
|
|
} else {
|
|
setTitle(mSubjectView.getText().toString());
|
|
}
|
|
}
|
|
|
|
@Override
|
|
public void onFocusChange(View view, boolean focused) {
|
|
if (!focused) {
|
|
updateTitle();
|
|
}
|
|
}
|
|
|
|
private void addAddresses(MultiAutoCompleteTextView view, Address[] addresses) {
|
|
if (addresses == null) {
|
|
return;
|
|
}
|
|
for (Address address : addresses) {
|
|
addAddress(view, address);
|
|
}
|
|
}
|
|
|
|
private void addAddress(MultiAutoCompleteTextView view, Address address) {
|
|
view.append(address + ", ");
|
|
}
|
|
|
|
private Address[] getAddresses(MultiAutoCompleteTextView view) {
|
|
|
|
return Address.parseUnencoded(view.getText().toString().trim());
|
|
}
|
|
|
|
/*
|
|
* Returns an Address array of recipients this email will be sent to.
|
|
* @return Address array of recipients this email will be sent to.
|
|
*/
|
|
private Address[] getRecipientAddresses() {
|
|
String addresses = mToView.getText().toString() + mCcView.getText().toString()
|
|
+ mBccView.getText().toString();
|
|
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);
|
|
}
|
|
|
|
/**
|
|
* 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, MessageFormat messageFormat) {
|
|
// The length of the formatted version of the user-supplied text/reply
|
|
int composedMessageLength;
|
|
|
|
// The offset of the user-supplied text/reply in the final text body
|
|
int composedMessageOffset;
|
|
|
|
/*
|
|
* 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 = (mQuotedTextMode.equals(QuotedTextMode.SHOW) || isDraft);
|
|
|
|
// Reply after quote makes no sense for HEADER style replies
|
|
boolean replyAfterQuote = (mQuoteStyle == QuoteStyle.HEADER) ?
|
|
false : mAccount.isReplyAfterQuote();
|
|
|
|
boolean signatureBeforeQuotedText = mAccount.isSignatureBeforeQuotedText();
|
|
|
|
// Get the user-supplied text
|
|
String text = mMessageContentView.getText().toString();
|
|
|
|
// Handle HTML separate from the rest of the text content
|
|
if (messageFormat == MessageFormat.HTML) {
|
|
|
|
// Do we have to modify an existing message to include our reply?
|
|
if (includeQuotedText && mQuotedHtmlContent != null) {
|
|
if (K9.DEBUG) {
|
|
Log.d(K9.LOG_TAG, "insertable: " + mQuotedHtmlContent.toDebugString());
|
|
}
|
|
|
|
if (!isDraft) {
|
|
// Append signature to the reply
|
|
if (replyAfterQuote || signatureBeforeQuotedText) {
|
|
text = appendSignature(text);
|
|
}
|
|
}
|
|
|
|
// Convert the text to HTML
|
|
text = HtmlConverter.textToHtmlFragment(text);
|
|
|
|
/*
|
|
* Set the insertion location based upon our reply after quote setting.
|
|
* Additionally, add some extra separators between the composed message and quoted
|
|
* message depending on the quote location. We only add the extra separators when
|
|
* we're sending, that way when we load a draft, we don't have to know the length
|
|
* of the separators to remove them before editing.
|
|
*/
|
|
if (replyAfterQuote) {
|
|
mQuotedHtmlContent.setInsertionLocation(
|
|
InsertableHtmlContent.InsertionLocation.AFTER_QUOTE);
|
|
if (!isDraft) {
|
|
text = "<br clear=\"all\">" + text;
|
|
}
|
|
} else {
|
|
mQuotedHtmlContent.setInsertionLocation(
|
|
InsertableHtmlContent.InsertionLocation.BEFORE_QUOTE);
|
|
if (!isDraft) {
|
|
text += "<br><br>";
|
|
}
|
|
}
|
|
|
|
if (!isDraft) {
|
|
// Place signature immediately after the quoted text
|
|
if (!(replyAfterQuote || signatureBeforeQuotedText)) {
|
|
mQuotedHtmlContent.insertIntoQuotedFooter(getSignatureHtml());
|
|
}
|
|
}
|
|
|
|
mQuotedHtmlContent.setUserContent(text);
|
|
|
|
// Save length of the body and its offset. This is used when thawing drafts.
|
|
composedMessageLength = text.length();
|
|
composedMessageOffset = mQuotedHtmlContent.getInsertionPoint();
|
|
text = mQuotedHtmlContent.toString();
|
|
|
|
} else {
|
|
// There is no text to quote so simply append the signature if available
|
|
if (!isDraft) {
|
|
text = appendSignature(text);
|
|
}
|
|
|
|
// Convert the text to HTML
|
|
text = HtmlConverter.textToHtmlFragment(text);
|
|
|
|
//TODO: Wrap this in proper HTML tags
|
|
|
|
composedMessageLength = text.length();
|
|
composedMessageOffset = 0;
|
|
}
|
|
|
|
} else {
|
|
// Capture composed message length before we start attaching quoted parts and signatures.
|
|
composedMessageLength = text.length();
|
|
composedMessageOffset = 0;
|
|
|
|
if (!isDraft) {
|
|
// Append signature to the text/reply
|
|
if (replyAfterQuote || signatureBeforeQuotedText) {
|
|
text = appendSignature(text);
|
|
}
|
|
}
|
|
|
|
if (includeQuotedText) {
|
|
String quotedText = mQuotedText.getText().toString();
|
|
if (replyAfterQuote) {
|
|
composedMessageOffset = quotedText.length() + "\n".length();
|
|
text = quotedText + "\n" + text;
|
|
} else {
|
|
text += "\n\n" + quotedText.toString();
|
|
}
|
|
}
|
|
|
|
if (!isDraft) {
|
|
// Place signature immediately after the quoted text
|
|
if (!(replyAfterQuote || signatureBeforeQuotedText)) {
|
|
text = appendSignature(text);
|
|
}
|
|
}
|
|
}
|
|
|
|
TextBody body = new TextBody(text);
|
|
body.setComposedMessageLength(composedMessageLength);
|
|
body.setComposedMessageOffset(composedMessageOffset);
|
|
|
|
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());
|
|
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());
|
|
}
|
|
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 == MessageFormat.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, MessageFormat.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);
|
|
message.setBody(mp);
|
|
} else {
|
|
// If no attachments, our multipart/alternative part is the only one we need.
|
|
message.setBody(composedMimeMessage);
|
|
}
|
|
} else {
|
|
// Text-only message.
|
|
if (hasAttachments) {
|
|
MimeMultipart mp = new MimeMultipart();
|
|
mp.addBodyPart(new MimeBodyPart(body, "text/plain"));
|
|
addAttachmentsToMessage(mp);
|
|
message.setBody(mp);
|
|
} else {
|
|
// No attachments to include, just stick the text body in the message and call it good.
|
|
message.setBody(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));
|
|
}
|
|
|
|
return message;
|
|
}
|
|
|
|
/**
|
|
* 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 {
|
|
for (int i = 0, count = mAttachments.getChildCount(); i < count; i++) {
|
|
Attachment attachment = (Attachment) mAttachments.getChildAt(i).getTag();
|
|
|
|
MimeBodyPart bp = new MimeBodyPart(
|
|
new LocalStore.LocalAttachmentBody(attachment.uri, getApplication()));
|
|
|
|
/*
|
|
* 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;\n name=\"%s\"",
|
|
attachment.contentType,
|
|
EncoderUtil.encodeIfNecessary(attachment.name,
|
|
EncoderUtil.Usage.WORD_ENTITY, 7)));
|
|
|
|
bp.addHeader(MimeHeader.HEADER_CONTENT_TRANSFER_ENCODING, "base64");
|
|
|
|
/*
|
|
* 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(
|
|
"attachment;\n filename=\"%s\";\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;
|
|
}
|
|
|
|
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 (mSignatureChanged) {
|
|
uri.appendQueryParameter(IdentityField.SIGNATURE.value(), mSignatureView.getText().toString());
|
|
}
|
|
|
|
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 tokens = new StringTokenizer(identityString, ":", false);
|
|
|
|
// First item is the body length. We use this to separate the composed reply from the quoted text.
|
|
if (tokens.hasMoreTokens()) {
|
|
String bodyLengthS = Utility.base64Decode(tokens.nextToken());
|
|
try {
|
|
identity.put(IdentityField.LENGTH, Integer.valueOf(bodyLengthS).toString());
|
|
} catch (Exception e) {
|
|
Log.e(K9.LOG_TAG, "Unable to parse bodyLength '" + bodyLengthS + "'");
|
|
}
|
|
}
|
|
if (tokens.hasMoreTokens()) {
|
|
identity.put(IdentityField.SIGNATURE, Utility.base64Decode(tokens.nextToken()));
|
|
}
|
|
if (tokens.hasMoreTokens()) {
|
|
identity.put(IdentityField.NAME, Utility.base64Decode(tokens.nextToken()));
|
|
}
|
|
if (tokens.hasMoreTokens()) {
|
|
identity.put(IdentityField.EMAIL, Utility.base64Decode(tokens.nextToken()));
|
|
}
|
|
if (tokens.hasMoreTokens()) {
|
|
identity.put(IdentityField.QUOTED_TEXT_MODE, Utility.base64Decode(tokens.nextToken()));
|
|
}
|
|
}
|
|
|
|
return identity;
|
|
}
|
|
|
|
|
|
private String appendSignature(String originalText) {
|
|
String text = originalText;
|
|
if (mIdentity.getSignatureUse()) {
|
|
String signature = mSignatureView.getText().toString();
|
|
|
|
if (signature != null && !signature.contentEquals("")) {
|
|
text += "\n" + signature;
|
|
}
|
|
}
|
|
|
|
return text;
|
|
}
|
|
|
|
/**
|
|
* Get an HTML version of the signature in the #mSignatureView, if any.
|
|
* @return HTML version of signature.
|
|
*/
|
|
private String getSignatureHtml() {
|
|
String signature = "";
|
|
if (mIdentity.getSignatureUse()) {
|
|
signature = mSignatureView.getText().toString();
|
|
if(!StringUtils.isNullOrEmpty(signature)) {
|
|
signature = HtmlConverter.textToHtmlFragment("\n" + signature);
|
|
}
|
|
}
|
|
return signature;
|
|
}
|
|
|
|
private void sendMessage() {
|
|
new SendMessageTask().execute();
|
|
}
|
|
private void saveMessage() {
|
|
new SaveMessageTask().execute();
|
|
}
|
|
|
|
private void saveIfNeeded() {
|
|
if (!mDraftNeedsSaving || mPreventDraftSaving || mPgpData.hasEncryptionKeys() ||
|
|
mEncryptCheckbox.isChecked() || isDraftsFolderDisabled()) {
|
|
return;
|
|
}
|
|
|
|
mDraftNeedsSaving = false;
|
|
saveMessage();
|
|
}
|
|
|
|
public void onEncryptionKeySelectionDone() {
|
|
if (mPgpData.hasEncryptionKeys()) {
|
|
onSend();
|
|
} else {
|
|
Toast.makeText(this, R.string.send_aborted, Toast.LENGTH_SHORT).show();
|
|
}
|
|
}
|
|
|
|
public void onEncryptDone() {
|
|
if (mPgpData.getEncryptedData() != null) {
|
|
onSend();
|
|
} else {
|
|
Toast.makeText(this, R.string.send_aborted, Toast.LENGTH_SHORT).show();
|
|
}
|
|
}
|
|
|
|
private void onSend() {
|
|
if (getAddresses(mToView).length == 0 && getAddresses(mCcView).length == 0 && getAddresses(mBccView).length == 0) {
|
|
mToView.setError(getString(R.string.message_compose_error_no_recipients));
|
|
Toast.makeText(this, getString(R.string.message_compose_error_no_recipients), Toast.LENGTH_LONG).show();
|
|
return;
|
|
}
|
|
final CryptoProvider crypto = mAccount.getCryptoProvider();
|
|
if (mEncryptCheckbox.isChecked() && !mPgpData.hasEncryptionKeys()) {
|
|
mMessageFormat = MessageFormat.TEXT;
|
|
// key selection before encryption
|
|
StringBuilder emails = new StringBuilder();
|
|
for (Address address : getRecipientAddresses()) {
|
|
if (emails.length() != 0) {
|
|
emails.append(',');
|
|
}
|
|
emails.append(address.getAddress());
|
|
if (!mContinueWithoutPublicKey &&
|
|
!crypto.hasPublicKeyForEmail(this, address.getAddress())) {
|
|
showDialog(DIALOG_CONTINUE_WITHOUT_PUBLIC_KEY);
|
|
return;
|
|
}
|
|
}
|
|
if (emails.length() != 0) {
|
|
emails.append(',');
|
|
}
|
|
emails.append(mIdentity.getEmail());
|
|
|
|
mPreventDraftSaving = true;
|
|
if (!crypto.selectEncryptionKeys(MessageCompose.this, emails.toString(), mPgpData)) {
|
|
mPreventDraftSaving = false;
|
|
}
|
|
return;
|
|
}
|
|
if (mPgpData.hasEncryptionKeys() || mPgpData.hasSignatureKey()) {
|
|
if (mPgpData.getEncryptedData() == null) {
|
|
String text = buildText(false).getText();
|
|
mPreventDraftSaving = true;
|
|
if (!crypto.encrypt(this, text, mPgpData)) {
|
|
mPreventDraftSaving = false;
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
sendMessage();
|
|
|
|
if (mMessageReference != null && mMessageReference.flag != null) {
|
|
if (K9.DEBUG)
|
|
Log.d(K9.LOG_TAG, "Setting referenced message (" + mMessageReference.folderName + ", " + mMessageReference.uid + ") flag to " + mMessageReference.flag);
|
|
|
|
final Account account = Preferences.getPreferences(this).getAccount(mMessageReference.accountUuid);
|
|
final String folderName = mMessageReference.folderName;
|
|
final String sourceMessageUid = mMessageReference.uid;
|
|
MessagingController.getInstance(getApplication()).setFlag(account, folderName, new String[] {sourceMessageUid}, mMessageReference.flag, true);
|
|
}
|
|
|
|
mDraftNeedsSaving = false;
|
|
finish();
|
|
}
|
|
|
|
private void onDiscard() {
|
|
if (mDraftId != INVALID_DRAFT_ID) {
|
|
MessagingController.getInstance(getApplication()).deleteDraft(mAccount, mDraftId);
|
|
mDraftId = INVALID_DRAFT_ID;
|
|
}
|
|
mHandler.sendEmptyMessage(MSG_DISCARDED_DRAFT);
|
|
mDraftNeedsSaving = false;
|
|
finish();
|
|
}
|
|
|
|
private void onSave() {
|
|
saveIfNeeded();
|
|
finish();
|
|
}
|
|
|
|
private void onAddCcBcc() {
|
|
mCcWrapper.setVisibility(View.VISIBLE);
|
|
mBccWrapper.setVisibility(View.VISIBLE);
|
|
}
|
|
|
|
private void onReadReceipt() {
|
|
CharSequence txt;
|
|
if (mReadReceipt == false) {
|
|
txt = getString(R.string.read_receipt_enabled);
|
|
mReadReceipt = true;
|
|
} else {
|
|
txt = getString(R.string.read_receipt_disabled);
|
|
mReadReceipt = false;
|
|
}
|
|
Context context = getApplicationContext();
|
|
Toast toast = Toast.makeText(context, txt, Toast.LENGTH_SHORT);
|
|
toast.show();
|
|
}
|
|
/**
|
|
* Kick off a picker for whatever kind of MIME types we'll accept and let Android take over.
|
|
*/
|
|
private void onAddAttachment() {
|
|
if (K9.isGalleryBuggy()) {
|
|
if (K9.useGalleryBugWorkaround()) {
|
|
Toast.makeText(MessageCompose.this,
|
|
getString(R.string.message_compose_use_workaround),
|
|
Toast.LENGTH_LONG).show();
|
|
} else {
|
|
Toast.makeText(MessageCompose.this,
|
|
getString(R.string.message_compose_buggy_gallery),
|
|
Toast.LENGTH_LONG).show();
|
|
}
|
|
}
|
|
|
|
onAddAttachment2("*/*");
|
|
}
|
|
|
|
/**
|
|
* Kick off a picker for the specified MIME type and let Android take over.
|
|
*
|
|
* @param mime_type
|
|
* The MIME type we want our attachment to have.
|
|
*/
|
|
private void onAddAttachment2(final String mime_type) {
|
|
if (mAccount.getCryptoProvider().isAvailable(this)) {
|
|
Toast.makeText(this, R.string.attachment_encryption_unsupported, Toast.LENGTH_LONG).show();
|
|
}
|
|
Intent i = new Intent(Intent.ACTION_GET_CONTENT);
|
|
i.addCategory(Intent.CATEGORY_OPENABLE);
|
|
i.setType(mime_type);
|
|
mIgnoreOnPause = true;
|
|
startActivityForResult(Intent.createChooser(i, null), ACTIVITY_REQUEST_PICK_ATTACHMENT);
|
|
}
|
|
|
|
private void addAttachment(Uri uri) {
|
|
addAttachment(uri, null);
|
|
}
|
|
|
|
private void addAttachment(Uri uri, String contentType) {
|
|
long size = -1;
|
|
String name = null;
|
|
|
|
ContentResolver contentResolver = getContentResolver();
|
|
|
|
Cursor metadataCursor = contentResolver.query(
|
|
uri,
|
|
new String[] { OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE },
|
|
null,
|
|
null,
|
|
null);
|
|
|
|
if (metadataCursor != null) {
|
|
try {
|
|
if (metadataCursor.moveToFirst()) {
|
|
name = metadataCursor.getString(0);
|
|
size = metadataCursor.getInt(1);
|
|
}
|
|
} finally {
|
|
metadataCursor.close();
|
|
}
|
|
}
|
|
|
|
if (name == null) {
|
|
name = uri.getLastPathSegment();
|
|
}
|
|
|
|
String usableContentType = contentType;
|
|
if ((usableContentType == null) || (usableContentType.indexOf('*') != -1)) {
|
|
usableContentType = contentResolver.getType(uri);
|
|
}
|
|
if (usableContentType == null) {
|
|
usableContentType = MimeUtility.getMimeTypeByExtension(name);
|
|
}
|
|
|
|
if (size <= 0) {
|
|
String uriString = uri.toString();
|
|
if (uriString.startsWith("file://")) {
|
|
Log.v(K9.LOG_TAG, uriString.substring("file://".length()));
|
|
File f = new File(uriString.substring("file://".length()));
|
|
size = f.length();
|
|
} else {
|
|
Log.v(K9.LOG_TAG, "Not a file: " + uriString);
|
|
}
|
|
} else {
|
|
Log.v(K9.LOG_TAG, "old attachment.size: " + size);
|
|
}
|
|
Log.v(K9.LOG_TAG, "new attachment.size: " + size);
|
|
|
|
Attachment attachment = new Attachment();
|
|
attachment.uri = uri;
|
|
attachment.contentType = usableContentType;
|
|
attachment.name = name;
|
|
attachment.size = size;
|
|
|
|
View view = getLayoutInflater().inflate(R.layout.message_compose_attachment, mAttachments, false);
|
|
TextView nameView = (TextView)view.findViewById(R.id.attachment_name);
|
|
ImageButton delete = (ImageButton)view.findViewById(R.id.attachment_delete);
|
|
nameView.setText(attachment.name);
|
|
delete.setOnClickListener(this);
|
|
delete.setTag(view);
|
|
view.setTag(attachment);
|
|
mAttachments.addView(view);
|
|
}
|
|
|
|
@Override
|
|
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
|
|
// if a CryptoSystem activity is returning, then mPreventDraftSaving was set to true
|
|
mPreventDraftSaving = false;
|
|
|
|
if (mAccount.getCryptoProvider().onActivityResult(this, requestCode, resultCode, data, mPgpData)) {
|
|
return;
|
|
}
|
|
|
|
if (resultCode != RESULT_OK)
|
|
return;
|
|
if (data == null) {
|
|
return;
|
|
}
|
|
switch (requestCode) {
|
|
case ACTIVITY_REQUEST_PICK_ATTACHMENT:
|
|
addAttachment(data.getData());
|
|
mDraftNeedsSaving = true;
|
|
break;
|
|
case ACTIVITY_CHOOSE_IDENTITY:
|
|
onIdentityChosen(data);
|
|
break;
|
|
case ACTIVITY_CHOOSE_ACCOUNT:
|
|
onAccountChosen(data);
|
|
break;
|
|
case CONTACT_PICKER_TO:
|
|
case CONTACT_PICKER_CC:
|
|
case CONTACT_PICKER_BCC:
|
|
String email = mContacts.getEmailFromContactPicker(data);
|
|
if (email.length() == 0) {
|
|
Toast.makeText(this, getString(R.string.error_contact_address_not_found), Toast.LENGTH_LONG).show();
|
|
return;
|
|
}
|
|
if (requestCode == CONTACT_PICKER_TO) {
|
|
addAddress(mToView, new Address(email, ""));
|
|
} else if (requestCode == CONTACT_PICKER_CC) {
|
|
addAddress(mCcView, new Address(email, ""));
|
|
} else if (requestCode == CONTACT_PICKER_BCC) {
|
|
addAddress(mBccView, new Address(email, ""));
|
|
} else {
|
|
return;
|
|
}
|
|
|
|
|
|
|
|
break;
|
|
}
|
|
}
|
|
|
|
public void doLaunchContactPicker(int resultId) {
|
|
mIgnoreOnPause = true;
|
|
startActivityForResult(mContacts.contactPickerIntent(), resultId);
|
|
}
|
|
|
|
|
|
|
|
private void onAccountChosen(final Intent intent) {
|
|
final Bundle extras = intent.getExtras();
|
|
final String uuid = extras.getString(ChooseAccount.EXTRA_ACCOUNT);
|
|
final Identity identity = (Identity) extras.getSerializable(ChooseAccount.EXTRA_IDENTITY);
|
|
|
|
final Account account = Preferences.getPreferences(this).getAccount(uuid);
|
|
|
|
if (!mAccount.equals(account)) {
|
|
if (K9.DEBUG) {
|
|
Log.v(K9.LOG_TAG, "Switching account from " + mAccount + " to " + account);
|
|
}
|
|
|
|
// on draft edit, make sure we don't keep previous message UID
|
|
if (ACTION_EDIT_DRAFT.equals(getIntent().getAction())) {
|
|
mMessageReference = null;
|
|
}
|
|
|
|
// test whether there is something to save
|
|
if (mDraftNeedsSaving || (mDraftId != INVALID_DRAFT_ID)) {
|
|
final long previousDraftId = mDraftId;
|
|
final Account previousAccount = mAccount;
|
|
|
|
// make current message appear as new
|
|
mDraftId = INVALID_DRAFT_ID;
|
|
|
|
// actual account switch
|
|
mAccount = account;
|
|
|
|
if (K9.DEBUG) {
|
|
Log.v(K9.LOG_TAG, "Account switch, saving new draft in new account");
|
|
}
|
|
saveMessage();
|
|
|
|
if (previousDraftId != INVALID_DRAFT_ID) {
|
|
if (K9.DEBUG) {
|
|
Log.v(K9.LOG_TAG, "Account switch, deleting draft from previous account: "
|
|
+ previousDraftId);
|
|
}
|
|
MessagingController.getInstance(getApplication()).deleteDraft(previousAccount,
|
|
previousDraftId);
|
|
}
|
|
} else {
|
|
mAccount = account;
|
|
}
|
|
// not sure how to handle mFolder, mSourceMessage?
|
|
}
|
|
|
|
switchToIdentity(identity);
|
|
}
|
|
|
|
private void onIdentityChosen(Intent intent) {
|
|
Bundle bundle = intent.getExtras();
|
|
switchToIdentity((Identity) bundle.getSerializable(ChooseIdentity.EXTRA_IDENTITY));
|
|
}
|
|
|
|
private void switchToIdentity(Identity identity) {
|
|
mIdentity = identity;
|
|
mIdentityChanged = true;
|
|
mDraftNeedsSaving = true;
|
|
updateFrom();
|
|
updateSignature();
|
|
}
|
|
|
|
private void updateFrom() {
|
|
if (mIdentityChanged) {
|
|
mFromView.setVisibility(View.VISIBLE);
|
|
}
|
|
mFromView.setText(getString(R.string.message_view_from_format, mIdentity.getName(), mIdentity.getEmail()));
|
|
}
|
|
|
|
private void updateSignature() {
|
|
if (mIdentity.getSignatureUse()) {
|
|
mSignatureView.setText(mIdentity.getSignature());
|
|
mSignatureView.setVisibility(View.VISIBLE);
|
|
} else {
|
|
mSignatureView.setVisibility(View.GONE);
|
|
}
|
|
}
|
|
|
|
@Override
|
|
public void onClick(View view) {
|
|
switch (view.getId()) {
|
|
case R.id.attachment_delete:
|
|
/*
|
|
* The view is the delete button, and we have previously set the tag of
|
|
* the delete button to the view that owns it. We don't use parent because the
|
|
* view is very complex and could change in the future.
|
|
*/
|
|
mAttachments.removeView((View) view.getTag());
|
|
mDraftNeedsSaving = true;
|
|
break;
|
|
case R.id.quoted_text_show:
|
|
showOrHideQuotedText(QuotedTextMode.SHOW);
|
|
mDraftNeedsSaving = true;
|
|
break;
|
|
case R.id.quoted_text_delete:
|
|
showOrHideQuotedText(QuotedTextMode.HIDE);
|
|
mDraftNeedsSaving = true;
|
|
break;
|
|
case R.id.quoted_text_edit:
|
|
mMessageFormat = MessageFormat.TEXT;
|
|
if (mMessageReference != null) { // shouldn't happen...
|
|
// TODO - Should we check if mSourceMessageBody is already present and bypass the MessagingController call?
|
|
MessagingController.getInstance(getApplication()).addListener(mListener);
|
|
final Account account = Preferences.getPreferences(this).getAccount(mMessageReference.accountUuid);
|
|
final String folderName = mMessageReference.folderName;
|
|
final String sourceMessageUid = mMessageReference.uid;
|
|
MessagingController.getInstance(getApplication()).loadMessageForView(account, folderName, sourceMessageUid, null);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
/*
|
|
* Show or Hide the quoted text according to mQuotedTextMode.
|
|
*/
|
|
private void showOrHideQuotedText(QuotedTextMode mode) {
|
|
mQuotedTextMode = mode;
|
|
if (mQuotedTextMode == QuotedTextMode.NONE) {
|
|
mQuotedTextShow.setVisibility(View.GONE);
|
|
mQuotedTextBar.setVisibility(View.GONE);
|
|
|
|
mQuotedText.setVisibility(View.GONE);
|
|
mQuotedHTML.setVisibility(View.GONE);
|
|
mQuotedTextEdit.setVisibility(View.GONE);
|
|
} else if (mQuotedTextMode == QuotedTextMode.SHOW) {
|
|
mQuotedTextShow.setVisibility(View.GONE);
|
|
mQuotedTextBar.setVisibility(View.VISIBLE);
|
|
if (mMessageFormat == MessageFormat.HTML) {
|
|
mQuotedText.setVisibility(View.GONE);
|
|
mQuotedHTML.setVisibility(View.VISIBLE);
|
|
mQuotedTextEdit.setVisibility(View.VISIBLE);
|
|
} else {
|
|
mQuotedText.setVisibility(View.VISIBLE);
|
|
mQuotedHTML.setVisibility(View.GONE);
|
|
mQuotedTextEdit.setVisibility(View.GONE);
|
|
}
|
|
} else if (mQuotedTextMode == QuotedTextMode.HIDE) {
|
|
mQuotedTextShow.setVisibility(View.VISIBLE);
|
|
mQuotedTextBar.setVisibility(View.GONE);
|
|
|
|
mQuotedText.setVisibility(View.GONE);
|
|
mQuotedHTML.setVisibility(View.GONE);
|
|
mQuotedTextEdit.setVisibility(View.GONE);
|
|
}
|
|
}
|
|
|
|
@Override
|
|
public boolean onOptionsItemSelected(MenuItem item) {
|
|
switch (item.getItemId()) {
|
|
case R.id.send:
|
|
mPgpData.setEncryptionKeys(null);
|
|
onSend();
|
|
break;
|
|
case R.id.save:
|
|
if (mEncryptCheckbox.isChecked()) {
|
|
showDialog(DIALOG_REFUSE_TO_SAVE_DRAFT_MARKED_ENCRYPTED);
|
|
} else {
|
|
onSave();
|
|
}
|
|
break;
|
|
case R.id.discard:
|
|
onDiscard();
|
|
break;
|
|
case R.id.add_cc_bcc:
|
|
onAddCcBcc();
|
|
break;
|
|
case R.id.add_attachment:
|
|
onAddAttachment();
|
|
break;
|
|
case R.id.add_attachment_image:
|
|
onAddAttachment2("image/*");
|
|
break;
|
|
case R.id.add_attachment_video:
|
|
onAddAttachment2("video/*");
|
|
break;
|
|
case R.id.choose_identity:
|
|
onChooseIdentity();
|
|
break;
|
|
case R.id.read_receipt:
|
|
onReadReceipt();
|
|
default:
|
|
return super.onOptionsItemSelected(item);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
private void onChooseIdentity() {
|
|
// keep things simple: trigger account choice only if there are more
|
|
// than 1 account
|
|
mIgnoreOnPause = true;
|
|
if (Preferences.getPreferences(this).getAvailableAccounts().size() > 1) {
|
|
final Intent intent = new Intent(this, ChooseAccount.class);
|
|
intent.putExtra(ChooseAccount.EXTRA_ACCOUNT, mAccount.getUuid());
|
|
intent.putExtra(ChooseAccount.EXTRA_IDENTITY, mIdentity);
|
|
startActivityForResult(intent, ACTIVITY_CHOOSE_ACCOUNT);
|
|
} else if (mAccount.getIdentities().size() > 1) {
|
|
Intent intent = new Intent(this, ChooseIdentity.class);
|
|
intent.putExtra(ChooseIdentity.EXTRA_ACCOUNT, mAccount.getUuid());
|
|
startActivityForResult(intent, ACTIVITY_CHOOSE_IDENTITY);
|
|
} else {
|
|
Toast.makeText(this, getString(R.string.no_identities),
|
|
Toast.LENGTH_LONG).show();
|
|
}
|
|
}
|
|
|
|
@Override
|
|
public boolean onCreateOptionsMenu(Menu menu) {
|
|
super.onCreateOptionsMenu(menu);
|
|
getMenuInflater().inflate(R.menu.message_compose_option, menu);
|
|
|
|
// Disable the 'Save' menu option if Drafts folder is set to -NONE-
|
|
if (isDraftsFolderDisabled()) {
|
|
menu.findItem(R.id.save).setEnabled(false);
|
|
}
|
|
|
|
/*
|
|
* Show the menu items "Add attachment (Image)" and "Add attachment (Video)"
|
|
* if the work-around for the Gallery bug is enabled (see Issue 1186).
|
|
*/
|
|
int found = 0;
|
|
for (int i = menu.size() - 1; i >= 0; i--) {
|
|
MenuItem item = menu.getItem(i);
|
|
int id = item.getItemId();
|
|
if ((id == R.id.add_attachment_image) ||
|
|
(id == R.id.add_attachment_video)) {
|
|
item.setVisible(K9.useGalleryBugWorkaround());
|
|
found++;
|
|
}
|
|
|
|
// We found all the menu items we were looking for. So stop here.
|
|
if (found == 2) break;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
@Override
|
|
public void onBackPressed() {
|
|
if (mEncryptCheckbox.isChecked()) {
|
|
showDialog(DIALOG_REFUSE_TO_SAVE_DRAFT_MARKED_ENCRYPTED);
|
|
} else if (!mDraftNeedsSaving || isDraftsFolderDisabled()) {
|
|
Toast.makeText(MessageCompose.this, getString(R.string.message_discarded_toast), Toast.LENGTH_LONG).show();
|
|
super.onBackPressed();
|
|
} else {
|
|
showDialog(DIALOG_SAVE_OR_DISCARD_DRAFT_MESSAGE);
|
|
}
|
|
}
|
|
|
|
private boolean isDraftsFolderDisabled() {
|
|
return mAccount.getDraftsFolderName().equals(K9.FOLDER_NONE);
|
|
}
|
|
|
|
@Override
|
|
public Dialog onCreateDialog(int id) {
|
|
switch (id) {
|
|
case DIALOG_SAVE_OR_DISCARD_DRAFT_MESSAGE:
|
|
return new AlertDialog.Builder(this)
|
|
.setTitle(R.string.save_or_discard_draft_message_dlg_title)
|
|
.setMessage(R.string.save_or_discard_draft_message_instructions_fmt)
|
|
.setPositiveButton(R.string.save_draft_action, new DialogInterface.OnClickListener() {
|
|
@Override
|
|
public void onClick(DialogInterface dialog, int whichButton) {
|
|
dismissDialog(DIALOG_SAVE_OR_DISCARD_DRAFT_MESSAGE);
|
|
onSave();
|
|
}
|
|
})
|
|
.setNegativeButton(R.string.discard_action, new DialogInterface.OnClickListener() {
|
|
@Override
|
|
public void onClick(DialogInterface dialog, int whichButton) {
|
|
dismissDialog(DIALOG_SAVE_OR_DISCARD_DRAFT_MESSAGE);
|
|
onDiscard();
|
|
}
|
|
})
|
|
.create();
|
|
case DIALOG_REFUSE_TO_SAVE_DRAFT_MARKED_ENCRYPTED:
|
|
return new AlertDialog.Builder(this)
|
|
.setTitle(R.string.refuse_to_save_draft_marked_encrypted_dlg_title)
|
|
.setMessage(R.string.refuse_to_save_draft_marked_encrypted_instructions_fmt)
|
|
.setNeutralButton(R.string.okay_action, new DialogInterface.OnClickListener() {
|
|
@Override
|
|
public void onClick(DialogInterface dialog, int whichButton) {
|
|
dismissDialog(DIALOG_REFUSE_TO_SAVE_DRAFT_MARKED_ENCRYPTED);
|
|
}
|
|
})
|
|
.create();
|
|
case DIALOG_CONTINUE_WITHOUT_PUBLIC_KEY:
|
|
return new AlertDialog.Builder(this)
|
|
.setTitle(R.string.continue_without_public_key_dlg_title)
|
|
.setMessage(R.string.continue_without_public_key_instructions_fmt)
|
|
.setPositiveButton(R.string.continue_action, new DialogInterface.OnClickListener() {
|
|
@Override
|
|
public void onClick(DialogInterface dialog, int whichButton) {
|
|
dismissDialog(DIALOG_CONTINUE_WITHOUT_PUBLIC_KEY);
|
|
mContinueWithoutPublicKey = true;
|
|
onSend();
|
|
}
|
|
})
|
|
.setNegativeButton(R.string.back_action, new DialogInterface.OnClickListener() {
|
|
@Override
|
|
public void onClick(DialogInterface dialog, int whichButton) {
|
|
dismissDialog(DIALOG_CONTINUE_WITHOUT_PUBLIC_KEY);
|
|
mContinueWithoutPublicKey = false;
|
|
}
|
|
})
|
|
.create();
|
|
}
|
|
return super.onCreateDialog(id);
|
|
}
|
|
|
|
/**
|
|
* Add all attachments of an existing message as if they were added by hand.
|
|
*
|
|
* @param part
|
|
* The message part to check for being an attachment. This method will recurse if it's
|
|
* a multipart part.
|
|
* @param depth
|
|
* The recursion depth. Currently unused.
|
|
*
|
|
* @return {@code true} if all attachments were able to be attached, {@code false} otherwise.
|
|
*
|
|
* @throws MessagingException
|
|
* In case of an error
|
|
*/
|
|
private boolean loadAttachments(Part part, int depth) throws MessagingException {
|
|
if (part.getBody() instanceof Multipart) {
|
|
Multipart mp = (Multipart) part.getBody();
|
|
boolean ret = true;
|
|
for (int i = 0, count = mp.getCount(); i < count; i++) {
|
|
if (!loadAttachments(mp.getBodyPart(i), depth + 1)) {
|
|
ret = false;
|
|
}
|
|
}
|
|
return ret;
|
|
}
|
|
|
|
String contentType = MimeUtility.unfoldAndDecode(part.getContentType());
|
|
String name = MimeUtility.getHeaderParameter(contentType, "name");
|
|
if (name != null) {
|
|
Body body = part.getBody();
|
|
if (body != null && body instanceof LocalAttachmentBody) {
|
|
final Uri uri = ((LocalAttachmentBody) body).getContentUri();
|
|
mHandler.post(new Runnable() {
|
|
@Override
|
|
public void run() {
|
|
addAttachment(uri);
|
|
}
|
|
});
|
|
} else {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* 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 message Source message
|
|
*/
|
|
private void processSourceMessage(Message message) {
|
|
String action = getIntent().getAction();
|
|
try {
|
|
if (ACTION_REPLY.equals(action) || ACTION_REPLY_ALL.equals(action)) {
|
|
if (message.getSubject() != null) {
|
|
final String subject = PREFIX.matcher(message.getSubject()).replaceFirst("");
|
|
|
|
if (!subject.toLowerCase(Locale.US).startsWith("re:")) {
|
|
mSubjectView.setText("Re: " + subject);
|
|
} else {
|
|
mSubjectView.setText(subject);
|
|
}
|
|
} else {
|
|
mSubjectView.setText("");
|
|
}
|
|
|
|
/*
|
|
* If a reply-to was included with the message use that, otherwise use the from
|
|
* or sender address.
|
|
*/
|
|
Address[] replyToAddresses;
|
|
if (message.getReplyTo().length > 0) {
|
|
replyToAddresses = message.getReplyTo();
|
|
} else {
|
|
replyToAddresses = message.getFrom();
|
|
}
|
|
|
|
// if we're replying to a message we sent, we probably meant
|
|
// to reply to the recipient of that message
|
|
if (mAccount.isAnIdentity(replyToAddresses)) {
|
|
replyToAddresses = message.getRecipients(RecipientType.TO);
|
|
}
|
|
|
|
addAddresses(mToView, replyToAddresses);
|
|
|
|
|
|
|
|
if (message.getMessageId() != null && message.getMessageId().length() > 0) {
|
|
mInReplyTo = message.getMessageId();
|
|
|
|
if (message.getReferences() != null && message.getReferences().length > 0) {
|
|
StringBuilder buffy = new StringBuilder();
|
|
for (int i = 0; i < message.getReferences().length; i++)
|
|
buffy.append(message.getReferences()[i]);
|
|
|
|
mReferences = buffy.toString() + " " + mInReplyTo;
|
|
} else {
|
|
mReferences = mInReplyTo;
|
|
}
|
|
|
|
} else {
|
|
if (K9.DEBUG)
|
|
Log.d(K9.LOG_TAG, "could not get Message-ID.");
|
|
}
|
|
|
|
// Quote the message and setup the UI.
|
|
populateUIWithQuotedMessage(mAccount.isDefaultQuotedTextShown());
|
|
|
|
if (ACTION_REPLY_ALL.equals(action) || ACTION_REPLY.equals(action)) {
|
|
Identity useIdentity = null;
|
|
for (Address address : message.getRecipients(RecipientType.TO)) {
|
|
Identity identity = mAccount.findIdentity(address);
|
|
if (identity != null) {
|
|
useIdentity = identity;
|
|
break;
|
|
}
|
|
}
|
|
if (useIdentity == null) {
|
|
if (message.getRecipients(RecipientType.CC).length > 0) {
|
|
for (Address address : message.getRecipients(RecipientType.CC)) {
|
|
Identity identity = mAccount.findIdentity(address);
|
|
if (identity != null) {
|
|
useIdentity = identity;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (useIdentity != null) {
|
|
Identity defaultIdentity = mAccount.getIdentity(0);
|
|
if (useIdentity != defaultIdentity) {
|
|
switchToIdentity(useIdentity);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (ACTION_REPLY_ALL.equals(action)) {
|
|
for (Address address : message.getRecipients(RecipientType.TO)) {
|
|
if (!mAccount.isAnIdentity(address)) {
|
|
addAddress(mToView, address);
|
|
}
|
|
|
|
}
|
|
if (message.getRecipients(RecipientType.CC).length > 0) {
|
|
for (Address address : message.getRecipients(RecipientType.CC)) {
|
|
if (!mAccount.isAnIdentity(address) && !Utility.arrayContains(replyToAddresses, address)) {
|
|
addAddress(mCcView, address);
|
|
}
|
|
|
|
}
|
|
mCcWrapper.setVisibility(View.VISIBLE);
|
|
}
|
|
}
|
|
} else if (ACTION_FORWARD.equals(action)) {
|
|
String subject = message.getSubject();
|
|
if (subject != null && !subject.toLowerCase(Locale.US).startsWith("fwd:")) {
|
|
mSubjectView.setText("Fwd: " + subject);
|
|
} else {
|
|
mSubjectView.setText(subject);
|
|
}
|
|
mQuoteStyle = QuoteStyle.HEADER;
|
|
|
|
// Quote the message and setup the UI.
|
|
populateUIWithQuotedMessage(true);
|
|
|
|
if (!mSourceMessageProcessed) {
|
|
if (!loadAttachments(message, 0)) {
|
|
mHandler.sendEmptyMessage(MSG_SKIPPED_ATTACHMENTS);
|
|
}
|
|
}
|
|
} else if (ACTION_EDIT_DRAFT.equals(action)) {
|
|
String showQuotedTextMode = "NONE";
|
|
|
|
mDraftId = MessagingController.getInstance(getApplication()).getId(message);
|
|
mSubjectView.setText(message.getSubject());
|
|
addAddresses(mToView, message.getRecipients(RecipientType.TO));
|
|
if (message.getRecipients(RecipientType.CC).length > 0) {
|
|
addAddresses(mCcView, message.getRecipients(RecipientType.CC));
|
|
mCcWrapper.setVisibility(View.VISIBLE);
|
|
}
|
|
|
|
Address[] bccRecipients = message.getRecipients(RecipientType.BCC);
|
|
if (bccRecipients.length > 0) {
|
|
addAddresses(mBccView, bccRecipients);
|
|
String bccAddress = mAccount.getAlwaysBcc();
|
|
if (bccRecipients.length == 1 && bccAddress != null && bccAddress.equals(bccRecipients[0].toString())) {
|
|
// If the auto-bcc is the only entry in the BCC list, don't show the Bcc fields.
|
|
mBccWrapper.setVisibility(View.GONE);
|
|
} else {
|
|
mBccWrapper.setVisibility(View.VISIBLE);
|
|
}
|
|
}
|
|
|
|
// Read In-Reply-To header from draft
|
|
final String[] inReplyTo = message.getHeader("In-Reply-To");
|
|
if ((inReplyTo != null) && (inReplyTo.length >= 1)) {
|
|
mInReplyTo = inReplyTo[0];
|
|
}
|
|
|
|
// Read References header from draft
|
|
final String[] references = message.getHeader("References");
|
|
if ((references != null) && (references.length >= 1)) {
|
|
mReferences = references[0];
|
|
}
|
|
|
|
if (!mSourceMessageProcessed) {
|
|
loadAttachments(message, 0);
|
|
}
|
|
|
|
// Decode the identity header when loading a draft.
|
|
// 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]);
|
|
}
|
|
|
|
Identity newIdentity = new Identity();
|
|
if (k9identity.containsKey(IdentityField.SIGNATURE)) {
|
|
newIdentity.setSignatureUse(true);
|
|
newIdentity.setSignature(k9identity.get(IdentityField.SIGNATURE));
|
|
mSignatureChanged = true;
|
|
} else {
|
|
newIdentity.setSignatureUse(message.getFolder().getAccount().getSignatureUse());
|
|
newIdentity.setSignature(mIdentity.getSignature());
|
|
}
|
|
|
|
if (k9identity.containsKey(IdentityField.NAME)) {
|
|
newIdentity.setName(k9identity.get(IdentityField.NAME));
|
|
mIdentityChanged = true;
|
|
} else {
|
|
newIdentity.setName(mIdentity.getName());
|
|
}
|
|
|
|
if (k9identity.containsKey(IdentityField.EMAIL)) {
|
|
newIdentity.setEmail(k9identity.get(IdentityField.EMAIL));
|
|
mIdentityChanged = true;
|
|
} else {
|
|
newIdentity.setEmail(mIdentity.getEmail());
|
|
}
|
|
|
|
if (k9identity.containsKey(IdentityField.ORIGINAL_MESSAGE)) {
|
|
mMessageReference = null;
|
|
try {
|
|
mMessageReference = new MessageReference(k9identity.get(IdentityField.ORIGINAL_MESSAGE));
|
|
} catch (MessagingException e) {
|
|
Log.e(K9.LOG_TAG, "Could not decode message reference in identity.", e);
|
|
}
|
|
}
|
|
|
|
int cursorPosition = 0;
|
|
if (k9identity.containsKey(IdentityField.CURSOR_POSITION)) {
|
|
try {
|
|
cursorPosition = Integer.valueOf(k9identity.get(IdentityField.CURSOR_POSITION)).intValue();
|
|
} catch (Exception e) {
|
|
Log.e(K9.LOG_TAG, "Could not parse cursor position for MessageCompose; continuing.", e);
|
|
}
|
|
}
|
|
|
|
if (k9identity.containsKey(IdentityField.QUOTED_TEXT_MODE)) {
|
|
showQuotedTextMode = k9identity.get(IdentityField.QUOTED_TEXT_MODE);
|
|
}
|
|
|
|
mIdentity = newIdentity;
|
|
|
|
updateSignature();
|
|
updateFrom();
|
|
|
|
Integer bodyLength = k9identity.get(IdentityField.LENGTH) != null
|
|
? Integer.parseInt(k9identity.get(IdentityField.LENGTH))
|
|
: 0;
|
|
Integer bodyOffset = k9identity.get(IdentityField.OFFSET) != null
|
|
? Integer.parseInt(k9identity.get(IdentityField.OFFSET))
|
|
: 0;
|
|
Integer bodyFooterOffset = k9identity.get(IdentityField.FOOTER_OFFSET) != null
|
|
? Integer.parseInt(k9identity.get(IdentityField.FOOTER_OFFSET))
|
|
: null;
|
|
Integer bodyPlainLength = k9identity.get(IdentityField.PLAIN_LENGTH) != null
|
|
? Integer.parseInt(k9identity.get(IdentityField.PLAIN_LENGTH))
|
|
: null;
|
|
Integer bodyPlainOffset = k9identity.get(IdentityField.PLAIN_OFFSET) != null
|
|
? Integer.parseInt(k9identity.get(IdentityField.PLAIN_OFFSET))
|
|
: null;
|
|
mQuoteStyle = k9identity.get(IdentityField.QUOTE_STYLE) != null
|
|
? QuoteStyle.valueOf(k9identity.get(IdentityField.QUOTE_STYLE))
|
|
: mAccount.getQuoteStyle();
|
|
|
|
// 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 messageFormat = k9identity.get(IdentityField.MESSAGE_FORMAT);
|
|
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.
|
|
mMessageContentView.setText(getBodyTextFromMessage(message, MessageFormat.TEXT));
|
|
mMessageFormat = MessageFormat.TEXT;
|
|
showOrHideQuotedText(QuotedTextMode.valueOf(showQuotedTextMode));
|
|
return;
|
|
}
|
|
|
|
mMessageFormat = MessageFormat.valueOf(messageFormat);
|
|
|
|
if (mMessageFormat == MessageFormat.HTML) {
|
|
Part part = MimeUtility.findFirstPartByMimeType(message, "text/html");
|
|
if (part != null) { // Shouldn't happen if we were the one who saved it.
|
|
String text = MimeUtility.getTextFromPart(part);
|
|
if (K9.DEBUG) {
|
|
Log.d(K9.LOG_TAG, "Loading message with offset " + bodyOffset + ", length " + bodyLength + ". Text length is " + text.length() + ".");
|
|
}
|
|
|
|
// Grab our reply text.
|
|
String bodyText = text.substring(bodyOffset, bodyOffset + bodyLength);
|
|
mMessageContentView.setText(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) {
|
|
mQuotedHtmlContent = new InsertableHtmlContent();
|
|
mQuotedHtmlContent.setQuotedContent(quotedHTML);
|
|
// We don't know if bodyOffset refers to the header or to the footer
|
|
mQuotedHtmlContent.setHeaderInsertionPoint(bodyOffset);
|
|
if (bodyFooterOffset != null) {
|
|
mQuotedHtmlContent.setFooterInsertionPoint(bodyFooterOffset);
|
|
} else {
|
|
mQuotedHtmlContent.setFooterInsertionPoint(bodyOffset);
|
|
}
|
|
mQuotedHTML.loadDataWithBaseURL("http://", mQuotedHtmlContent.getQuotedContent(), "text/html", "utf-8", null);
|
|
}
|
|
}
|
|
if (bodyPlainOffset != null && bodyPlainLength != null) {
|
|
processSourceMessageText(message, bodyPlainOffset, bodyPlainLength, false);
|
|
}
|
|
} else if (mMessageFormat == MessageFormat.TEXT) {
|
|
processSourceMessageText(message, bodyOffset, bodyLength, true);
|
|
} else {
|
|
Log.e(K9.LOG_TAG, "Unhandled message format.");
|
|
}
|
|
|
|
// Set the cursor position if we have it.
|
|
try {
|
|
mMessageContentView.setSelection(cursorPosition);
|
|
} catch (Exception e) {
|
|
Log.e(K9.LOG_TAG, "Could not set cursor position in MessageCompose; ignoring.", e);
|
|
}
|
|
|
|
showOrHideQuotedText(QuotedTextMode.valueOf(showQuotedTextMode));
|
|
}
|
|
} catch (MessagingException me) {
|
|
/**
|
|
* Let the user continue composing their message even if we have a problem processing
|
|
* the source message. Log it as an error, though.
|
|
*/
|
|
Log.e(K9.LOG_TAG, "Error while processing source message: ", me);
|
|
} finally {
|
|
mSourceMessageProcessed = true;
|
|
mDraftNeedsSaving = false;
|
|
}
|
|
}
|
|
|
|
/*
|
|
* 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 message Source message
|
|
* @param bodyOffset Insertion point for reply.
|
|
* @param bodyLength Length of reply.
|
|
* @param viewMessageContent Update mMessageContentView or not.
|
|
* @throws MessagingException
|
|
*/
|
|
private void processSourceMessageText(Message message, Integer bodyOffset, Integer bodyLength,
|
|
boolean viewMessageContent) throws MessagingException {
|
|
Part textPart = MimeUtility.findFirstPartByMimeType(message, "text/plain");
|
|
if (textPart != null) {
|
|
String text = MimeUtility.getTextFromPart(textPart);
|
|
if (K9.DEBUG) {
|
|
Log.d(K9.LOG_TAG, "Loading message with offset " + bodyOffset + ", length " + bodyLength + ". Text length is " + text.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 != null && bodyLength + 1 < text.length()) { // + 1 to get rid of the newline we added when saving the draft
|
|
String bodyText = text.substring(bodyOffset, bodyOffset + bodyLength);
|
|
|
|
// Regenerate the quoted text without our user content in it nor added newlines.
|
|
StringBuilder quotedText = new StringBuilder();
|
|
if (bodyOffset == 0 && text.substring(bodyLength, bodyLength + 2).equals("\n\n")) {
|
|
// top-posting: ignore two newlines at start of quote
|
|
quotedText.append(text.substring(bodyLength + 2));
|
|
} else if (bodyOffset + bodyLength == text.length() &&
|
|
text.substring(bodyOffset - 1, bodyOffset).equals("\n")) {
|
|
// bottom-posting: ignore newline at end of quote
|
|
quotedText.append(text.substring(0, bodyOffset - 1));
|
|
} else {
|
|
quotedText.append(text.substring(0, bodyOffset)); // stuff before the reply
|
|
quotedText.append(text.substring(bodyOffset + bodyLength));
|
|
}
|
|
|
|
if (viewMessageContent) mMessageContentView.setText(bodyText);
|
|
mQuotedText.setText(quotedText.toString());
|
|
} else {
|
|
if (viewMessageContent) mMessageContentView.setText(text);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Regexes to check for signature.
|
|
private static final Pattern DASH_SIGNATURE_PLAIN = Pattern.compile("\r\n-- \r\n.*", Pattern.DOTALL);
|
|
private static final Pattern DASH_SIGNATURE_HTML = Pattern.compile("(<br( /)?>|\r?\n)-- <br( /)?>", Pattern.CASE_INSENSITIVE);
|
|
private static final Pattern BLOCKQUOTE_START = Pattern.compile("<blockquote", Pattern.CASE_INSENSITIVE);
|
|
private static final Pattern BLOCKQUOTE_END = Pattern.compile("</blockquote>", Pattern.CASE_INSENSITIVE);
|
|
|
|
/**
|
|
* Build and populate the UI with the quoted message.
|
|
*
|
|
* @param showQuotedText
|
|
* {@code true} if the quoted text should be shown, {@code false} otherwise.
|
|
*
|
|
* @throws MessagingException
|
|
*/
|
|
private void populateUIWithQuotedMessage(boolean showQuotedText) throws MessagingException {
|
|
if (mMessageFormat == MessageFormat.AUTO) {
|
|
mMessageFormat = MimeUtility.findFirstPartByMimeType(mSourceMessage, "text/html") == null
|
|
? MessageFormat.TEXT
|
|
: MessageFormat.HTML;
|
|
}
|
|
|
|
// TODO -- I am assuming that mSourceMessageBody will always be a text part. Is this a safe assumption?
|
|
|
|
// Handle the original message in the reply
|
|
// If we already have mSourceMessageBody, use that. It's pre-populated if we've got crypto going on.
|
|
String content = mSourceMessageBody != null
|
|
? mSourceMessageBody
|
|
: getBodyTextFromMessage(mSourceMessage, mMessageFormat);
|
|
if (mMessageFormat == MessageFormat.HTML) {
|
|
// Strip signature.
|
|
// closing tags such as </div>, </span>, </table>, </pre> will be cut off.
|
|
if (mAccount.isStripSignature() && (ACTION_REPLY_ALL.equals(getIntent().getAction()) ||
|
|
ACTION_REPLY.equals(getIntent().getAction()))) {
|
|
Matcher dashSignatureHtml = DASH_SIGNATURE_HTML.matcher(content);
|
|
if (dashSignatureHtml.find()) {
|
|
Matcher blockquoteStart = BLOCKQUOTE_START.matcher(content);
|
|
Matcher blockquoteEnd = BLOCKQUOTE_END.matcher(content);
|
|
List<Integer> start = new ArrayList<Integer>();
|
|
List<Integer> end = new ArrayList<Integer>();
|
|
|
|
while(blockquoteStart.find()) {
|
|
start.add(blockquoteStart.start());
|
|
}
|
|
while(blockquoteEnd.find()) {
|
|
end.add(blockquoteEnd.start());
|
|
}
|
|
if (start.size() != end.size()) {
|
|
Log.d(K9.LOG_TAG, "There are " + start.size() + " <blockquote> tags, but " +
|
|
end.size() + " </blockquote> tags. Refusing to strip.");
|
|
} else if (start.size() > 0) {
|
|
// Ignore quoted signatures in blockquotes.
|
|
dashSignatureHtml.region(0, start.get(0));
|
|
if (dashSignatureHtml.find()) {
|
|
// before first <blockquote>.
|
|
content = content.substring(0, dashSignatureHtml.start());
|
|
} else {
|
|
for (int i = 0; i < start.size() - 1; i++) {
|
|
// within blockquotes.
|
|
if (end.get(i) < start.get(i+1)) {
|
|
dashSignatureHtml.region(end.get(i), start.get(i+1));
|
|
if (dashSignatureHtml.find()) {
|
|
content = content.substring(0, dashSignatureHtml.start());
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
if (end.get(end.size() - 1) < content.length()) {
|
|
// after last </blockquote>.
|
|
dashSignatureHtml.region(end.get(end.size() - 1), content.length());
|
|
if (dashSignatureHtml.find()) {
|
|
content = content.substring(0, dashSignatureHtml.start());
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
// No blockquotes found.
|
|
content = content.substring(0, dashSignatureHtml.start());
|
|
}
|
|
}
|
|
|
|
// Fix the stripping off of closing tags if a signature was stripped,
|
|
// as well as clean up the HTML of the quoted message.
|
|
HtmlCleaner cleaner = new HtmlCleaner();
|
|
CleanerProperties properties = cleaner.getProperties();
|
|
|
|
// see http://htmlcleaner.sourceforge.net/parameters.php for descriptions
|
|
properties.setNamespacesAware(false);
|
|
properties.setAdvancedXmlEscape(false);
|
|
properties.setOmitXmlDeclaration(true);
|
|
properties.setOmitDoctypeDeclaration(false);
|
|
properties.setTranslateSpecialEntities(false);
|
|
properties.setRecognizeUnicodeChars(false);
|
|
|
|
TagNode node = cleaner.clean(content);
|
|
SimpleHtmlSerializer htmlSerialized = new SimpleHtmlSerializer(properties);
|
|
try {
|
|
content = htmlSerialized.getAsString(node, "UTF8");
|
|
} catch (java.io.IOException ioe) {
|
|
// Can't imagine this happening.
|
|
Log.e(K9.LOG_TAG, "Problem cleaning quoted message.", ioe);
|
|
}
|
|
}
|
|
// Add the HTML reply header to the top of the content.
|
|
mQuotedHtmlContent = quoteOriginalHtmlMessage(mSourceMessage, content, mQuoteStyle);
|
|
// Load the message with the reply header.
|
|
mQuotedHTML.loadDataWithBaseURL("http://", mQuotedHtmlContent.getQuotedContent(), "text/html", "utf-8", null);
|
|
mQuotedText.setText(quoteOriginalTextMessage(mSourceMessage,
|
|
getBodyTextFromMessage(mSourceMessage, MessageFormat.TEXT), mQuoteStyle));
|
|
|
|
} else if (mMessageFormat == MessageFormat.TEXT) {
|
|
if (mAccount.isStripSignature() && (ACTION_REPLY_ALL.equals(getIntent().getAction()) ||
|
|
ACTION_REPLY.equals(getIntent().getAction()))) {
|
|
if (DASH_SIGNATURE_PLAIN.matcher(content).find()) {
|
|
content = DASH_SIGNATURE_PLAIN.matcher(content).replaceFirst("\r\n");
|
|
}
|
|
}
|
|
mQuotedText.setText(quoteOriginalTextMessage(mSourceMessage, content, mQuoteStyle));
|
|
}
|
|
|
|
if (showQuotedText) {
|
|
showOrHideQuotedText(QuotedTextMode.SHOW);
|
|
} else {
|
|
showOrHideQuotedText(QuotedTextMode.HIDE);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fetch the body text from a message in the desired message format. This method handles
|
|
* conversions between formats (html to text and vice versa) if necessary.
|
|
* @param message Message to analyze for body part.
|
|
* @param format Desired format.
|
|
* @return Text in desired format.
|
|
* @throws MessagingException
|
|
*/
|
|
private String getBodyTextFromMessage(final Message message, final MessageFormat format) throws MessagingException {
|
|
Part part;
|
|
if (format == MessageFormat.HTML) {
|
|
// HTML takes precedence, then text.
|
|
part = MimeUtility.findFirstPartByMimeType(message, "text/html");
|
|
if (part != null) {
|
|
if (K9.DEBUG)
|
|
Log.d(K9.LOG_TAG, "getBodyTextFromMessage: HTML requested, HTML found.");
|
|
return MimeUtility.getTextFromPart(part);
|
|
}
|
|
|
|
part = MimeUtility.findFirstPartByMimeType(message, "text/plain");
|
|
if (part != null) {
|
|
if (K9.DEBUG)
|
|
Log.d(K9.LOG_TAG, "getBodyTextFromMessage: HTML requested, text found.");
|
|
return HtmlConverter.textToHtml(MimeUtility.getTextFromPart(part));
|
|
}
|
|
} else if (format == MessageFormat.TEXT) {
|
|
// Text takes precedence, then html.
|
|
part = MimeUtility.findFirstPartByMimeType(message, "text/plain");
|
|
if (part != null) {
|
|
if (K9.DEBUG)
|
|
Log.d(K9.LOG_TAG, "getBodyTextFromMessage: Text requested, text found.");
|
|
return MimeUtility.getTextFromPart(part);
|
|
}
|
|
|
|
part = MimeUtility.findFirstPartByMimeType(message, "text/html");
|
|
if (part != null) {
|
|
if (K9.DEBUG)
|
|
Log.d(K9.LOG_TAG, "getBodyTextFromMessage: Text requested, HTML found.");
|
|
return HtmlConverter.htmlToText(MimeUtility.getTextFromPart(part));
|
|
}
|
|
}
|
|
|
|
// If we had nothing interesting, return an empty string.
|
|
return "";
|
|
}
|
|
|
|
// Regular expressions to look for various HTML tags. This is no HTML::Parser, but hopefully it's good enough for
|
|
// our purposes.
|
|
private static final Pattern FIND_INSERTION_POINT_HTML = Pattern.compile("(?si:.*?(<html(?:>|\\s+[^>]*>)).*)");
|
|
private static final Pattern FIND_INSERTION_POINT_HEAD = Pattern.compile("(?si:.*?(<head(?:>|\\s+[^>]*>)).*)");
|
|
private static final Pattern FIND_INSERTION_POINT_BODY = Pattern.compile("(?si:.*?(<body(?:>|\\s+[^>]*>)).*)");
|
|
private static final Pattern FIND_INSERTION_POINT_HTML_END = Pattern.compile("(?si:.*(</html>).*?)");
|
|
private static final Pattern FIND_INSERTION_POINT_BODY_END = Pattern.compile("(?si:.*(</body>).*?)");
|
|
// The first group in a Matcher contains the first capture group. We capture the tag found in the above REs so that
|
|
// we can locate the *end* of that tag.
|
|
private static final int FIND_INSERTION_POINT_FIRST_GROUP = 1;
|
|
// HTML bits to insert as appropriate
|
|
// TODO is it safe to assume utf-8 here?
|
|
private static final String FIND_INSERTION_POINT_HTML_CONTENT = "<!DOCTYPE html PUBLIC \"-//W3C//DTD HTML 4.0 Transitional//EN\">\n<html>";
|
|
private static final String FIND_INSERTION_POINT_HTML_END_CONTENT = "</html>";
|
|
private static final String FIND_INSERTION_POINT_HEAD_CONTENT = "<head><meta content=\"text/html; charset=utf-8\" http-equiv=\"Content-Type\"></head>";
|
|
// Index of the start of the beginning of a String.
|
|
private static final int FIND_INSERTION_POINT_START_OF_STRING = 0;
|
|
/**
|
|
* <p>Find the start and end positions of the HTML in the string. This should be the very top
|
|
* and bottom of the displayable message. It returns a {@link InsertableHtmlContent}, which
|
|
* contains both the insertion points and potentially modified HTML. The modified HTML should be
|
|
* used in place of the HTML in the original message.</p>
|
|
*
|
|
* <p>This method loosely mimics the HTML forward/reply behavior of BlackBerry OS 4.5/BIS 2.5, which in turn mimics
|
|
* Outlook 2003 (as best I can tell).</p>
|
|
*
|
|
* @param content Content to examine for HTML insertion points
|
|
* @return Insertion points and HTML to use for insertion.
|
|
*/
|
|
private InsertableHtmlContent findInsertionPoints(final String content) {
|
|
InsertableHtmlContent insertable = new InsertableHtmlContent();
|
|
|
|
// If there is no content, don't bother doing any of the regex dancing.
|
|
if (content == null || content.equals("")) {
|
|
return insertable;
|
|
}
|
|
|
|
// Search for opening tags.
|
|
boolean hasHtmlTag = false;
|
|
boolean hasHeadTag = false;
|
|
boolean hasBodyTag = false;
|
|
// First see if we have an opening HTML tag. If we don't find one, we'll add one later.
|
|
Matcher htmlMatcher = FIND_INSERTION_POINT_HTML.matcher(content);
|
|
if (htmlMatcher.matches()) {
|
|
hasHtmlTag = true;
|
|
}
|
|
// Look for a HEAD tag. If we're missing a BODY tag, we'll use the close of the HEAD to start our content.
|
|
Matcher headMatcher = FIND_INSERTION_POINT_HEAD.matcher(content);
|
|
if (headMatcher.matches()) {
|
|
hasHeadTag = true;
|
|
}
|
|
// Look for a BODY tag. This is the ideal place for us to start our content.
|
|
Matcher bodyMatcher = FIND_INSERTION_POINT_BODY.matcher(content);
|
|
if (bodyMatcher.matches()) {
|
|
hasBodyTag = true;
|
|
}
|
|
|
|
if (K9.DEBUG)
|
|
Log.d(K9.LOG_TAG, "Open: hasHtmlTag:" + hasHtmlTag + " hasHeadTag:" + hasHeadTag + " hasBodyTag:" + hasBodyTag);
|
|
|
|
// Given our inspections, let's figure out where to start our content.
|
|
// This is the ideal case -- there's a BODY tag and we insert ourselves just after it.
|
|
if (hasBodyTag) {
|
|
insertable.setQuotedContent(new StringBuilder(content));
|
|
insertable.setHeaderInsertionPoint(bodyMatcher.end(FIND_INSERTION_POINT_FIRST_GROUP));
|
|
} else if (hasHeadTag) {
|
|
// Now search for a HEAD tag. We can insert after there.
|
|
|
|
// If BlackBerry sees a HEAD tag, it inserts right after that, so long as there is no BODY tag. It doesn't
|
|
// try to add BODY, either. Right or wrong, it seems to work fine.
|
|
insertable.setQuotedContent(new StringBuilder(content));
|
|
insertable.setHeaderInsertionPoint(headMatcher.end(FIND_INSERTION_POINT_FIRST_GROUP));
|
|
} else if (hasHtmlTag) {
|
|
// Lastly, check for an HTML tag.
|
|
// In this case, it will add a HEAD, but no BODY.
|
|
StringBuilder newContent = new StringBuilder(content);
|
|
// Insert the HEAD content just after the HTML tag.
|
|
newContent.insert(htmlMatcher.end(FIND_INSERTION_POINT_FIRST_GROUP), FIND_INSERTION_POINT_HEAD_CONTENT);
|
|
insertable.setQuotedContent(newContent);
|
|
// The new insertion point is the end of the HTML tag, plus the length of the HEAD content.
|
|
insertable.setHeaderInsertionPoint(htmlMatcher.end(FIND_INSERTION_POINT_FIRST_GROUP) + FIND_INSERTION_POINT_HEAD_CONTENT.length());
|
|
} else {
|
|
// If we have none of the above, we probably have a fragment of HTML. Yahoo! and Gmail both do this.
|
|
// Again, we add a HEAD, but not BODY.
|
|
StringBuilder newContent = new StringBuilder(content);
|
|
// Add the HTML and HEAD tags.
|
|
newContent.insert(FIND_INSERTION_POINT_START_OF_STRING, FIND_INSERTION_POINT_HEAD_CONTENT);
|
|
newContent.insert(FIND_INSERTION_POINT_START_OF_STRING, FIND_INSERTION_POINT_HTML_CONTENT);
|
|
// Append the </HTML> tag.
|
|
newContent.append(FIND_INSERTION_POINT_HTML_END_CONTENT);
|
|
insertable.setQuotedContent(newContent);
|
|
insertable.setHeaderInsertionPoint(FIND_INSERTION_POINT_HTML_CONTENT.length() + FIND_INSERTION_POINT_HEAD_CONTENT.length());
|
|
}
|
|
|
|
// Search for closing tags. We have to do this after we deal with opening tags since it may
|
|
// have modified the message.
|
|
boolean hasHtmlEndTag = false;
|
|
boolean hasBodyEndTag = false;
|
|
// First see if we have an opening HTML tag. If we don't find one, we'll add one later.
|
|
Matcher htmlEndMatcher = FIND_INSERTION_POINT_HTML_END.matcher(insertable.getQuotedContent());
|
|
if (htmlEndMatcher.matches()) {
|
|
hasHtmlEndTag = true;
|
|
}
|
|
// Look for a BODY tag. This is the ideal place for us to place our footer.
|
|
Matcher bodyEndMatcher = FIND_INSERTION_POINT_BODY_END.matcher(insertable.getQuotedContent());
|
|
if (bodyEndMatcher.matches()) {
|
|
hasBodyEndTag = true;
|
|
}
|
|
|
|
if (K9.DEBUG)
|
|
Log.d(K9.LOG_TAG, "Close: hasHtmlEndTag:" + hasHtmlEndTag + " hasBodyEndTag:" + hasBodyEndTag);
|
|
|
|
// Now figure out where to put our footer.
|
|
// This is the ideal case -- there's a BODY tag and we insert ourselves just before it.
|
|
if (hasBodyEndTag) {
|
|
insertable.setFooterInsertionPoint(bodyEndMatcher.start(FIND_INSERTION_POINT_FIRST_GROUP));
|
|
} else if (hasHtmlEndTag) {
|
|
// Check for an HTML tag. Add ourselves just before it.
|
|
insertable.setFooterInsertionPoint(htmlEndMatcher.start(FIND_INSERTION_POINT_FIRST_GROUP));
|
|
} else {
|
|
// If we have none of the above, we probably have a fragment of HTML.
|
|
// Set our footer insertion point as the end of the string.
|
|
insertable.setFooterInsertionPoint(insertable.getQuotedContent().length());
|
|
}
|
|
|
|
return insertable;
|
|
}
|
|
|
|
class Listener extends MessagingListener {
|
|
@Override
|
|
public void loadMessageForViewStarted(Account account, String folder, String uid) {
|
|
if ((mMessageReference == null) || !mMessageReference.uid.equals(uid)) {
|
|
return;
|
|
}
|
|
|
|
mHandler.sendEmptyMessage(MSG_PROGRESS_ON);
|
|
}
|
|
|
|
@Override
|
|
public void loadMessageForViewFinished(Account account, String folder, String uid, Message message) {
|
|
if ((mMessageReference == null) || !mMessageReference.uid.equals(uid)) {
|
|
return;
|
|
}
|
|
|
|
mHandler.sendEmptyMessage(MSG_PROGRESS_OFF);
|
|
}
|
|
|
|
@Override
|
|
public void loadMessageForViewBodyAvailable(Account account, String folder, String uid, final Message message) {
|
|
if ((mMessageReference == null) || !mMessageReference.uid.equals(uid)) {
|
|
return;
|
|
}
|
|
|
|
mSourceMessage = message;
|
|
runOnUiThread(new Runnable() {
|
|
@Override
|
|
public void run() {
|
|
// We check to see if we've previously processed the source message since this
|
|
// could be called when switching from HTML to text replies. If that happens, we
|
|
// only want to update the UI with quoted text (which picks the appropriate
|
|
// part).
|
|
if (mSourceProcessed) {
|
|
try {
|
|
populateUIWithQuotedMessage(true);
|
|
} catch (MessagingException e) {
|
|
// Hm, if we couldn't populate the UI after source reprocessing, let's just delete it?
|
|
showOrHideQuotedText(QuotedTextMode.HIDE);
|
|
Log.e(K9.LOG_TAG, "Could not re-process source message; deleting quoted text to be safe.", e);
|
|
}
|
|
} else {
|
|
processSourceMessage(message);
|
|
mSourceProcessed = true;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
@Override
|
|
public void loadMessageForViewFailed(Account account, String folder, String uid, Throwable t) {
|
|
if ((mMessageReference == null) || !mMessageReference.uid.equals(uid)) {
|
|
return;
|
|
}
|
|
mHandler.sendEmptyMessage(MSG_PROGRESS_OFF);
|
|
// TODO show network error
|
|
}
|
|
|
|
@Override
|
|
public void messageUidChanged(Account account, String folder, String oldUid, String newUid) {
|
|
// Track UID changes of the source message
|
|
if (mMessageReference != null) {
|
|
final Account sourceAccount = Preferences.getPreferences(MessageCompose.this).getAccount(mMessageReference.accountUuid);
|
|
final String sourceFolder = mMessageReference.folderName;
|
|
final String sourceMessageUid = mMessageReference.uid;
|
|
|
|
if (account.equals(sourceAccount) && (folder.equals(sourceFolder))) {
|
|
if (oldUid.equals(sourceMessageUid)) {
|
|
mMessageReference.uid = newUid;
|
|
}
|
|
if ((mSourceMessage != null) && (oldUid.equals(mSourceMessage.getUid()))) {
|
|
mSourceMessage.setUid(newUid);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* When we are launched with an intent that includes a mailto: URI, we can actually
|
|
* gather quite a few of our message fields from it.
|
|
*
|
|
* @param mailtoUri
|
|
* The mailto: URI we use to initialize the message fields.
|
|
*/
|
|
private void initializeFromMailto(Uri mailtoUri) {
|
|
String schemaSpecific = mailtoUri.getSchemeSpecificPart();
|
|
int end = schemaSpecific.indexOf('?');
|
|
if (end == -1) {
|
|
end = schemaSpecific.length();
|
|
}
|
|
|
|
// Extract the recipient's email address from the mailto URI if there's one.
|
|
String recipient = Uri.decode(schemaSpecific.substring(0, end));
|
|
|
|
/*
|
|
* mailto URIs are not hierarchical. So calling getQueryParameters()
|
|
* will throw an UnsupportedOperationException. We avoid this by
|
|
* creating a new hierarchical dummy Uri object with the query
|
|
* parameters of the original URI.
|
|
*/
|
|
Uri uri = Uri.parse("foo://bar?" + mailtoUri.getEncodedQuery());
|
|
|
|
// Read additional recipients from the "to" parameter.
|
|
List<String> to = uri.getQueryParameters("to");
|
|
if (recipient.length() != 0) {
|
|
to = new ArrayList<String>(to);
|
|
to.add(0, recipient);
|
|
}
|
|
setRecipients(mToView, to);
|
|
|
|
// Read carbon copy recipients from the "cc" parameter.
|
|
boolean ccOrBcc = setRecipients(mCcView, uri.getQueryParameters("cc"));
|
|
|
|
// Read blind carbon copy recipients from the "bcc" parameter.
|
|
ccOrBcc |= setRecipients(mBccView, uri.getQueryParameters("bcc"));
|
|
|
|
if (ccOrBcc) {
|
|
// Display CC and BCC text fields if CC or BCC recipients were set by the intent.
|
|
onAddCcBcc();
|
|
}
|
|
|
|
// Read subject from the "subject" parameter.
|
|
List<String> subject = uri.getQueryParameters("subject");
|
|
if (!subject.isEmpty()) {
|
|
mSubjectView.setText(subject.get(0));
|
|
}
|
|
|
|
// Read message body from the "body" parameter.
|
|
List<String> body = uri.getQueryParameters("body");
|
|
if (!body.isEmpty()) {
|
|
mMessageContentView.setText(body.get(0));
|
|
}
|
|
}
|
|
|
|
private class SendMessageTask extends AsyncTask<Void, Void, Void> {
|
|
@Override
|
|
protected Void doInBackground(Void... params) {
|
|
/*
|
|
* Create the message from all the data the user has entered.
|
|
*/
|
|
MimeMessage message;
|
|
try {
|
|
message = createMessage(false); // isDraft = true
|
|
} 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);
|
|
}
|
|
|
|
try {
|
|
mContacts.markAsContacted(message.getRecipients(RecipientType.TO));
|
|
mContacts.markAsContacted(message.getRecipients(RecipientType.CC));
|
|
mContacts.markAsContacted(message.getRecipients(RecipientType.BCC));
|
|
} catch (Exception e) {
|
|
Log.e(K9.LOG_TAG, "Failed to mark contact as contacted.", e);
|
|
}
|
|
|
|
MessagingController.getInstance(getApplication()).sendMessage(mAccount, message, null);
|
|
long draftId = mDraftId;
|
|
if (draftId != INVALID_DRAFT_ID) {
|
|
mDraftId = INVALID_DRAFT_ID;
|
|
MessagingController.getInstance(getApplication()).deleteDraft(mAccount, draftId);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private class SaveMessageTask extends AsyncTask<Void, Void, Void> {
|
|
@Override
|
|
protected Void doInBackground(Void... params) {
|
|
/*
|
|
* Create the message from all the data the user has entered.
|
|
*/
|
|
MimeMessage message;
|
|
try {
|
|
message = createMessage(true); // isDraft = true
|
|
} 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);
|
|
}
|
|
|
|
/*
|
|
* Save a draft
|
|
*/
|
|
if (ACTION_EDIT_DRAFT.equals(getIntent().getAction())) {
|
|
/*
|
|
* We're saving a previously saved draft, so update the new message's uid
|
|
* to the old message's uid.
|
|
*/
|
|
if (mMessageReference != null) {
|
|
message.setUid(mMessageReference.uid);
|
|
}
|
|
}
|
|
|
|
final MessagingController messagingController = MessagingController.getInstance(getApplication());
|
|
Message draftMessage = messagingController.saveDraft(mAccount, message, mDraftId);
|
|
mDraftId = messagingController.getId(draftMessage);
|
|
|
|
mHandler.sendEmptyMessage(MSG_SAVED_DRAFT);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private static final int REPLY_WRAP_LINE_WIDTH = 72;
|
|
private static final int QUOTE_BUFFER_LENGTH = 512; // amount of extra buffer to allocate to accommodate quoting headers or prefixes
|
|
|
|
/**
|
|
* Add quoting markup to a text message.
|
|
* @param originalMessage Metadata for message being quoted.
|
|
* @param messageBody Text of the message to be quoted.
|
|
* @param quoteStyle Style of quoting.
|
|
* @return Quoted text.
|
|
* @throws MessagingException
|
|
*/
|
|
private String quoteOriginalTextMessage(final Message originalMessage, final String messageBody, final QuoteStyle quoteStyle) throws MessagingException {
|
|
String body = messageBody == null ? "" : messageBody;
|
|
if (quoteStyle == QuoteStyle.PREFIX) {
|
|
StringBuilder quotedText = new StringBuilder(body.length() + QUOTE_BUFFER_LENGTH);
|
|
quotedText.append(String.format(
|
|
getString(R.string.message_compose_reply_header_fmt),
|
|
Address.toString(originalMessage.getFrom()))
|
|
);
|
|
|
|
final String prefix = mAccount.getQuotePrefix();
|
|
final String wrappedText = Utility.wrap(body, REPLY_WRAP_LINE_WIDTH - prefix.length());
|
|
|
|
// "$" and "\" in the quote prefix have to be escaped for
|
|
// the replaceAll() invocation.
|
|
final String escapedPrefix = prefix.replaceAll("(\\\\|\\$)", "\\\\$1");
|
|
quotedText.append(wrappedText.replaceAll("(?m)^", escapedPrefix));
|
|
|
|
return quotedText.toString().replaceAll("\\\r", "");
|
|
} else if (quoteStyle == QuoteStyle.HEADER) {
|
|
StringBuilder quotedText = new StringBuilder(body.length() + QUOTE_BUFFER_LENGTH);
|
|
quotedText.append("\n");
|
|
quotedText.append(getString(R.string.message_compose_quote_header_separator)).append("\n");
|
|
if (originalMessage.getFrom() != null && Address.toString(originalMessage.getFrom()).length() != 0) {
|
|
quotedText.append(getString(R.string.message_compose_quote_header_from)).append(" ").append(Address.toString(originalMessage.getFrom())).append("\n");
|
|
}
|
|
if (originalMessage.getSentDate() != null) {
|
|
quotedText.append(getString(R.string.message_compose_quote_header_send_date)).append(" ").append(originalMessage.getSentDate()).append("\n");
|
|
}
|
|
if (originalMessage.getRecipients(RecipientType.TO) != null && originalMessage.getRecipients(RecipientType.TO).length != 0) {
|
|
quotedText.append(getString(R.string.message_compose_quote_header_to)).append(" ").append(Address.toString(originalMessage.getRecipients(RecipientType.TO))).append("\n");
|
|
}
|
|
if (originalMessage.getRecipients(RecipientType.CC) != null && originalMessage.getRecipients(RecipientType.CC).length != 0) {
|
|
quotedText.append(getString(R.string.message_compose_quote_header_cc)).append(" ").append(Address.toString(originalMessage.getRecipients(RecipientType.CC))).append("\n");
|
|
}
|
|
if (originalMessage.getSubject() != null) {
|
|
quotedText.append(getString(R.string.message_compose_quote_header_subject)).append(" ").append(originalMessage.getSubject()).append("\n");
|
|
}
|
|
quotedText.append("\n");
|
|
|
|
quotedText.append(body);
|
|
|
|
return quotedText.toString();
|
|
} else {
|
|
// Shouldn't ever happen.
|
|
return body;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Add quoting markup to a HTML message.
|
|
* @param originalMessage Metadata for message being quoted.
|
|
* @param messageBody Text of the message to be quoted.
|
|
* @param quoteStyle Style of quoting.
|
|
* @return Modified insertable message.
|
|
* @throws MessagingException
|
|
*/
|
|
private InsertableHtmlContent quoteOriginalHtmlMessage(final Message originalMessage, final String messageBody, final QuoteStyle quoteStyle) throws MessagingException {
|
|
InsertableHtmlContent insertable = findInsertionPoints(messageBody);
|
|
|
|
if (quoteStyle == QuoteStyle.PREFIX) {
|
|
StringBuilder header = new StringBuilder(QUOTE_BUFFER_LENGTH);
|
|
header.append("<div class=\"gmail_quote\">");
|
|
// Remove all trailing newlines so that the quote starts immediately after the header. "Be like Gmail!"
|
|
header.append(HtmlConverter.textToHtmlFragment(String.format(
|
|
getString(R.string.message_compose_reply_header_fmt).replaceAll("\n$", ""),
|
|
Address.toString(originalMessage.getFrom()))
|
|
));
|
|
header.append("<blockquote class=\"gmail_quote\" " +
|
|
"style=\"margin: 0pt 0pt 0pt 0.8ex; border-left: 1px solid rgb(204, 204, 204); padding-left: 1ex;\">\n");
|
|
|
|
String footer = "</blockquote></div>";
|
|
|
|
insertable.insertIntoQuotedHeader(header.toString());
|
|
insertable.insertIntoQuotedFooter(footer);
|
|
} else if (quoteStyle == QuoteStyle.HEADER) {
|
|
|
|
StringBuilder header = new StringBuilder();
|
|
header.append("<div style='font-size:10.0pt;font-family:\"Tahoma\",\"sans-serif\";padding:3.0pt 0in 0in 0in'>\n");
|
|
header.append("<hr style='border:none;border-top:solid #B5C4DF 1.0pt'>\n"); // This gets converted into a horizontal line during html to text conversion.
|
|
if (mSourceMessage.getFrom() != null && Address.toString(mSourceMessage.getFrom()).length() != 0) {
|
|
header.append("<b>").append(getString(R.string.message_compose_quote_header_from)).append("</b> ").append(HtmlConverter.textToHtmlFragment(Address.toString(mSourceMessage.getFrom()))).append("<br>\n");
|
|
}
|
|
if (mSourceMessage.getSentDate() != null) {
|
|
header.append("<b>").append(getString(R.string.message_compose_quote_header_send_date)).append("</b> ").append(mSourceMessage.getSentDate()).append("<br>\n");
|
|
}
|
|
if (mSourceMessage.getRecipients(RecipientType.TO) != null && mSourceMessage.getRecipients(RecipientType.TO).length != 0) {
|
|
header.append("<b>").append(getString(R.string.message_compose_quote_header_to)).append("</b> ").append(HtmlConverter.textToHtmlFragment(Address.toString(mSourceMessage.getRecipients(RecipientType.TO)))).append("<br>\n");
|
|
}
|
|
if (mSourceMessage.getRecipients(RecipientType.CC) != null && mSourceMessage.getRecipients(RecipientType.CC).length != 0) {
|
|
header.append("<b>").append(getString(R.string.message_compose_quote_header_cc)).append("</b> ").append(HtmlConverter.textToHtmlFragment(Address.toString(mSourceMessage.getRecipients(RecipientType.CC)))).append("<br>\n");
|
|
}
|
|
if (mSourceMessage.getSubject() != null) {
|
|
header.append("<b>").append(getString(R.string.message_compose_quote_header_subject)).append("</b> ").append(HtmlConverter.textToHtmlFragment(mSourceMessage.getSubject())).append("<br>\n");
|
|
}
|
|
header.append("</div>\n");
|
|
header.append("<br>\n");
|
|
|
|
insertable.insertIntoQuotedHeader(header.toString());
|
|
}
|
|
|
|
return insertable;
|
|
}
|
|
}
|