package com.fsck.k9.activity; import android.annotation.SuppressLint; import android.annotation.TargetApi; import android.app.AlertDialog; import android.app.PendingIntent; import android.app.AlertDialog.Builder; import android.app.Dialog; import android.content.ClipData; import android.content.Context; import android.content.DialogInterface; import android.content.Intent; import android.content.IntentSender.SendIntentException; import android.content.pm.ActivityInfo; import android.net.Uri; import android.os.AsyncTask; import android.os.Build; import android.os.Bundle; import android.os.Handler; import android.os.Parcelable; import android.support.v4.app.LoaderManager; import android.support.v4.content.Loader; import android.text.TextUtils; import android.text.TextWatcher; import android.text.util.Rfc822Tokenizer; import android.util.AttributeSet; import android.util.Log; import android.util.TypedValue; import android.view.LayoutInflater; import com.actionbarsherlock.view.Menu; import com.actionbarsherlock.view.MenuItem; import com.actionbarsherlock.view.Window; import android.view.ContextThemeWrapper; import android.view.View; import android.view.View.OnClickListener; import android.view.ViewGroup; import android.webkit.WebView; import android.webkit.WebViewClient; import android.widget.AutoCompleteTextView.Validator; import android.widget.BaseAdapter; import android.widget.Button; import android.widget.CheckBox; import android.widget.CompoundButton; import android.widget.CompoundButton.OnCheckedChangeListener; 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 com.fsck.k9.Account; import com.fsck.k9.Account.MessageFormat; import com.fsck.k9.Account.QuoteStyle; 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.activity.loader.AttachmentContentLoader; import com.fsck.k9.activity.loader.AttachmentInfoLoader; import com.fsck.k9.activity.misc.Attachment; import com.fsck.k9.controller.MessagingController; import com.fsck.k9.controller.MessagingListener; import com.fsck.k9.crypto.CryptoProvider; import com.fsck.k9.crypto.OpenPgpApiHelper; import com.fsck.k9.crypto.PgpData; import com.fsck.k9.fragment.ProgressDialogFragment; import com.fsck.k9.helper.ContactItem; import com.fsck.k9.helper.Contacts; import com.fsck.k9.helper.HtmlConverter; import com.fsck.k9.helper.IdentityHelper; import com.fsck.k9.helper.StringUtils; import com.fsck.k9.helper.Utility; import com.fsck.k9.mail.Address; import com.fsck.k9.mail.Body; import com.fsck.k9.mail.Flag; import com.fsck.k9.mail.Message; import com.fsck.k9.mail.Message.RecipientType; import com.fsck.k9.mail.MessagingException; import com.fsck.k9.mail.Multipart; import com.fsck.k9.mail.Part; 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.TextBodyBuilder; 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.LocalAttachmentBody; import com.fsck.k9.mail.store.LocalStore.TempFileBody; import com.fsck.k9.mail.store.LocalStore.TempFileMessageBody; import com.fsck.k9.view.MessageWebView; import org.apache.james.mime4j.codec.EncoderUtil; import org.apache.james.mime4j.util.MimeUtil; import org.htmlcleaner.CleanerProperties; import org.htmlcleaner.HtmlCleaner; import org.htmlcleaner.SimpleHtmlSerializer; import org.htmlcleaner.TagNode; import org.openintents.openpgp.OpenPgpError; import org.openintents.openpgp.util.OpenPgpApi; import org.openintents.openpgp.util.OpenPgpServiceConnection; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.InputStream; import java.io.UnsupportedEncodingException; import java.text.DateFormat; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.Date; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Set; import java.util.StringTokenizer; import java.util.regex.Matcher; import java.util.regex.Pattern; public class MessageCompose extends K9Activity implements OnClickListener, ProgressDialogFragment.CancelListener { 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 int DIALOG_CONFIRM_DISCARD_ON_BACK = 4; private static final int DIALOG_CHOOSE_IDENTITY = 5; 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_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 String STATE_KEY_FORCE_PLAIN_TEXT = "com.fsck.k9.activity.MessageCompose.forcePlainText"; private static final String STATE_KEY_QUOTED_TEXT_FORMAT = "com.fsck.k9.activity.MessageCompose.quotedTextFormat"; private static final String STATE_KEY_NUM_ATTACHMENTS_LOADING = "numAttachmentsLoading"; private static final String STATE_KEY_WAITING_FOR_ATTACHMENTS = "waitingForAttachments"; private static final String LOADER_ARG_ATTACHMENT = "attachment"; private static final String FRAGMENT_WAITING_FOR_ATTACHMENT = "waitingForAttachment"; private static final int MSG_PROGRESS_ON = 1; private static final int MSG_PROGRESS_OFF = 2; private static final int MSG_SKIPPED_ATTACHMENTS = 3; private static final int MSG_SAVED_DRAFT = 4; private static final int MSG_DISCARDED_DRAFT = 5; private static final int MSG_PERFORM_STALLED_ACTION = 6; private static final int ACTIVITY_REQUEST_PICK_ATTACHMENT = 1; private static final int CONTACT_PICKER_TO = 4; private static final int CONTACT_PICKER_CC = 5; private static final int CONTACT_PICKER_BCC = 6; private static final int CONTACT_PICKER_TO2 = 7; private static final int CONTACT_PICKER_CC2 = 8; private static final int CONTACT_PICKER_BCC2 = 9; private static final Account[] EMPTY_ACCOUNT_ARRAY = new Account[0]; private static final int REQUEST_CODE_SIGN_ENCRYPT = 12; /** * 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; /** * "Original" message body * *
* The contents of this string will be used instead of the body of a referenced message when
* replying to or forwarding a message.
* Right now this is only used when replying to a signed or encrypted message. It then contains
* the stripped/decrypted body of that message.
*
Note: * When this field is not {@code null} we assume that the message we are composing right now * should be encrypted. *
*/ 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 int mMaxLoaderId = 0; enum Action { COMPOSE, REPLY, REPLY_ALL, FORWARD, EDIT_DRAFT } /** * Contains the action we're currently performing (e.g. replying to a message) */ private Action mAction; private enum QuotedTextMode { NONE, SHOW, HIDE } private boolean mReadReceipt = false; private QuotedTextMode mQuotedTextMode = QuotedTextMode.NONE; /** * Contains the format of the quoted text (text vs. HTML). */ private SimpleMessageFormat mQuotedTextFormat; /** * When this it {@code true} the message format setting is ignored and we're always sending * a text/plain message. */ private boolean mForcePlainText = false; private Button mChooseIdentityButton; private LinearLayout mCcWrapper; private LinearLayout mBccWrapper; private MultiAutoCompleteTextView mToView; private MultiAutoCompleteTextView mCcView; private MultiAutoCompleteTextView mBccView; private EditText mSubjectView; private EolConvertingEditText mSignatureView; private EolConvertingEditText mMessageContentView; private LinearLayout mAttachments; private Button mQuotedTextShow; private View mQuotedTextBar; private ImageButton mQuotedTextEdit; private ImageButton mQuotedTextDelete; private EolConvertingEditText 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 mOpenPgpProvider; private OpenPgpServiceConnection mOpenPgpServiceConnection; private String mReferences; private String mInReplyTo; private Menu mMenu; private boolean mSourceProcessed = false; enum SimpleMessageFormat { TEXT, HTML } /** * The currently used message format. * ** Note: * Don't modify this field directly. Use {@link #updateMessageFormat()}. *
*/ private SimpleMessageFormat 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; /** * Number of attachments currently being fetched. */ private int mNumAttachmentsLoading = 0; private enum WaitingAction { NONE, SEND, SAVE } /** * Specifies what action to perform once attachments have been fetched. */ private WaitingAction mWaitingForAttachments = WaitingAction.NONE; private Handler mHandler = new Handler() { @Override public void handleMessage(android.os.Message msg) { switch (msg.what) { case MSG_PROGRESS_ON: setSupportProgressBarIndeterminateVisibility(true); break; case MSG_PROGRESS_OFF: setSupportProgressBarIndeterminateVisibility(false); 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; case MSG_PERFORM_STALLED_ACTION: performStalledAction(); break; default: super.handleMessage(msg); break; } } }; private Listener mListener = new Listener(); private EmailAddressAdapter mAddressAdapter; private Validator mAddressValidator; private FontSizes mFontSizes = K9.getFontSizes(); private ContextThemeWrapper mThemeContext; /** * 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); } /** * Get intent for composing 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 Intent getActionReplyIntent( 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); } return 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) { context.startActivity(getActionReplyIntent(context, account, message, replyAll, messageBody)); } /** * 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 message */ public static void actionEditDraft(Context context, MessageReference messageReference) { Intent i = new Intent(context, MessageCompose.class); i.putExtra(EXTRA_MESSAGE_REFERENCE, messageReference); i.setAction(ACTION_EDIT_DRAFT); context.startActivity(i); } /* * This is a workaround for an annoying ( temporarly? ) issue: * https://github.com/JakeWharton/ActionBarSherlock/issues/449 */ @Override protected void onPostCreate(Bundle savedInstanceState) { super.onPostCreate(savedInstanceState); setSupportProgressBarIndeterminateVisibility(false); } @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); if (UpgradeDatabases.actionUpgradeDatabases(this, getIntent())) { finish(); return; } requestWindowFeature(Window.FEATURE_INDETERMINATE_PROGRESS); if (K9.getK9ComposerThemeSetting() != K9.Theme.USE_GLOBAL) { // theme the whole content according to the theme (except the action bar) mThemeContext = new ContextThemeWrapper(this, K9.getK9ThemeResourceId(K9.getK9ComposerTheme())); View v = ((LayoutInflater) mThemeContext.getSystemService(Context.LAYOUT_INFLATER_SERVICE)). inflate(R.layout.message_compose, null); TypedValue outValue = new TypedValue(); // background color needs to be forced mThemeContext.getTheme().resolveAttribute(R.attr.messageViewHeaderBackgroundColor, outValue, true); v.setBackgroundColor(outValue.data); setContentView(v); } else { setContentView(R.layout.message_compose); mThemeContext = this; } 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 = new EmailAddressAdapter(mThemeContext); mAddressValidator = new EmailAddressValidator(); mChooseIdentityButton = (Button) findViewById(R.id.identity); mChooseIdentityButton.setOnClickListener(this); if (mAccount.getIdentities().size() == 1 && Preferences.getPreferences(this).getAvailableAccounts().size() == 1) { mChooseIdentityButton.setVisibility(View.GONE); } 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); if (mAccount.isAlwaysShowCcBcc()) { onAddCcBcc(); } EolConvertingEditText upperSignature = (EolConvertingEditText)findViewById(R.id.upper_signature); EolConvertingEditText lowerSignature = (EolConvertingEditText)findViewById(R.id.lower_signature); mMessageContentView = (EolConvertingEditText)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 = (EolConvertingEditText)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); 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); 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); } if (initFromIntent(intent)) { mAction = Action.COMPOSE; mDraftNeedsSaving = true; } else { String action = intent.getAction(); if (ACTION_COMPOSE.equals(action)) { mAction = Action.COMPOSE; } else if (ACTION_REPLY.equals(action)) { mAction = Action.REPLY; } else if (ACTION_REPLY_ALL.equals(action)) { mAction = Action.REPLY_ALL; } else if (ACTION_FORWARD.equals(action)) { mAction = Action.FORWARD; } else if (ACTION_EDIT_DRAFT.equals(action)) { mAction = Action.EDIT_DRAFT; } else { // This shouldn't happen Log.w(K9.LOG_TAG, "MessageCompose was started with an unsupported action"); mAction = Action.COMPOSE; } } 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); } mReadReceipt = mAccount.isMessageReadReceiptAlways(); mQuoteStyle = mAccount.getQuoteStyle(); updateFrom(); if (!mSourceMessageProcessed) { updateSignature(); if (mAction == Action.REPLY || mAction == Action.REPLY_ALL || mAction == Action.FORWARD || mAction == Action.EDIT_DRAFT) { /* * 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 (mAction != Action.EDIT_DRAFT) { addAddresses(mBccView, mAccount.getAlwaysBcc()); } } if (mAction == Action.REPLY || mAction == Action.REPLY_ALL) { mMessageReference.flag = Flag.ANSWERED; } if (mAction == Action.REPLY || mAction == Action.REPLY_ALL || mAction == Action.EDIT_DRAFT) { //change focus to message body. mMessageContentView.requestFocus(); } else { // Explicitly set focus to "To:" input field (see issue 2998) mToView.requestFocus(); } if (mAction == Action.FORWARD) { mMessageReference.flag = Flag.FORWARDED; } mEncryptLayout = findViewById(R.id.layout_encrypt); mCryptoSignatureCheckbox = (CheckBox)findViewById(R.id.cb_crypto_signature); mCryptoSignatureCheckbox.setOnCheckedChangeListener(new OnCheckedChangeListener() { @Override public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) { updateMessageFormat(); } }); mCryptoSignatureUserId = (TextView)findViewById(R.id.userId); mCryptoSignatureUserIdRest = (TextView)findViewById(R.id.userIdRest); mEncryptCheckbox = (CheckBox)findViewById(R.id.cb_encrypt); mEncryptCheckbox.setOnCheckedChangeListener(new OnCheckedChangeListener() { @Override public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) { updateMessageFormat(); } }); 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(); mOpenPgpProvider = mAccount.getOpenPgpProvider(); if (mOpenPgpProvider != null) { // New OpenPGP Provider API // bind to service mOpenPgpServiceConnection = new OpenPgpServiceConnection(this, mOpenPgpProvider); mOpenPgpServiceConnection.bindToService(); mEncryptLayout.setVisibility(View.VISIBLE); mCryptoSignatureCheckbox.setOnClickListener(new OnClickListener() { @Override public void onClick(View v) { CheckBox checkBox = (CheckBox) v; if (checkBox.isChecked()) { mPreventDraftSaving = true; } } }); if (mAccount.getCryptoAutoSignature()) { // TODO: currently disabled for new openpgp providers (see AccountSettings) } updateMessageFormat(); // TODO: currently disabled for new openpgp providers (see AccountSettings) mAutoEncrypt = false; //mAutoEncrypt = mAccount.isCryptoAutoEncrypt(); } else 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); } // Set font size of input controls int fontSize = mFontSizes.getMessageComposeInput(); mFontSizes.setViewTextSize(mToView, fontSize); mFontSizes.setViewTextSize(mCcView, fontSize); mFontSizes.setViewTextSize(mBccView, fontSize); mFontSizes.setViewTextSize(mSubjectView, fontSize); mFontSizes.setViewTextSize(mMessageContentView, fontSize); mFontSizes.setViewTextSize(mQuotedText, fontSize); mFontSizes.setViewTextSize(mSignatureView, fontSize); updateMessageFormat(); setTitle(); } @Override public void onDestroy() { super.onDestroy(); if (mOpenPgpServiceConnection != null) { mOpenPgpServiceConnection.unbindFromService(); } } /** * Handle external intents that trigger the message compose activity. * ** Supported external intents: *
* 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 {@code true} we build a message that will be saved as a draft (as opposed to * sent). * @param messageFormat * Specifies what type of message to build ({@code text/plain} vs. {@code text/html}). * * @return {@link TextBody} instance that contains the entered text and possibly the quoted * original message. */ private TextBody buildText(boolean isDraft, SimpleMessageFormat messageFormat) { String messageText = mMessageContentView.getCharacters(); TextBodyBuilder textBodyBuilder = new TextBodyBuilder(messageText); /* * Find out if we need to include the original message as quoted text. * * We include the quoted text in the body if the user didn't choose to * hide it. We always include the quoted text when we're saving a draft. * That's so the user is able to "un-hide" the quoted text if (s)he * opens a saved draft. */ boolean includeQuotedText = (isDraft || mQuotedTextMode == QuotedTextMode.SHOW); boolean isReplyAfterQuote = (mQuoteStyle == QuoteStyle.PREFIX && mAccount.isReplyAfterQuote()); textBodyBuilder.setIncludeQuotedText(false); if (includeQuotedText) { if (messageFormat == SimpleMessageFormat.HTML && mQuotedHtmlContent != null) { textBodyBuilder.setIncludeQuotedText(true); textBodyBuilder.setQuotedTextHtml(mQuotedHtmlContent); textBodyBuilder.setReplyAfterQuote(isReplyAfterQuote); } String quotedText = mQuotedText.getCharacters(); if (messageFormat == SimpleMessageFormat.TEXT && quotedText.length() > 0) { textBodyBuilder.setIncludeQuotedText(true); textBodyBuilder.setQuotedText(quotedText); textBodyBuilder.setReplyAfterQuote(isReplyAfterQuote); } } textBodyBuilder.setInsertSeparator(!isDraft); boolean useSignature = (!isDraft && mIdentity.getSignatureUse()); if (useSignature) { textBodyBuilder.setAppendSignature(true); textBodyBuilder.setSignature(mSignatureView.getCharacters()); textBodyBuilder.setSignatureBeforeQuotedText(mAccount.isSignatureBeforeQuotedText()); } else { textBodyBuilder.setAppendSignature(false); } TextBody body; if (messageFormat == SimpleMessageFormat.HTML) { body = textBodyBuilder.buildTextHtml(); } else { body = textBodyBuilder.buildTextPlain(); } return body; } /** * Build the final message to be sent (or saved). If there is another message quoted in this one, it will be baked * into the final message here. * @param isDraft Indicates if this message is a draft or not. Drafts do not have signatures * appended and have some extra metadata baked into their header for use during thawing. * @return Message to be sent. * @throws MessagingException */ private MimeMessage createMessage(boolean isDraft) throws MessagingException { MimeMessage message = new MimeMessage(); message.addSentDate(new Date()); 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 == SimpleMessageFormat.HTML) { // HTML message (with alternative text part) // This is the compiled MIME part for an HTML message. MimeMultipart composedMimeMessage = new MimeMultipart(); composedMimeMessage.setSubType("alternative"); // Let the receiver select either the text or the HTML part. composedMimeMessage.addBodyPart(new MimeBodyPart(body, "text/html")); bodyPlain = buildText(isDraft, SimpleMessageFormat.TEXT); composedMimeMessage.addBodyPart(new MimeBodyPart(bodyPlain, "text/plain")); if (hasAttachments) { // If we're HTML and have attachments, we have a MimeMultipart container to hold the // whole message (mp here), of which one part is a MimeMultipart container // (composedMimeMessage) with the user's composed messages, and subsequent parts for // the attachments. MimeMultipart mp = new MimeMultipart(); mp.addBodyPart(new MimeBodyPart(composedMimeMessage)); addAttachmentsToMessage(mp); message.setBody(mp); } else { // If no attachments, our multipart/alternative part is the only one we need. message.setBody(composedMimeMessage); } } else if (mMessageFormat == SimpleMessageFormat.TEXT) { // 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 { Body body; for (int i = 0, count = mAttachments.getChildCount(); i < count; i++) { Attachment attachment = (Attachment) mAttachments.getChildAt(i).getTag(); if (attachment.state != Attachment.LoadingState.COMPLETE) { continue; } String contentType = attachment.contentType; if (MimeUtil.isMessage(contentType)) { body = new TempFileMessageBody(attachment.filename); } else { body = new TempFileBody(attachment.filename); } MimeBodyPart bp = new MimeBodyPart(body); /* * Correctly encode the filename here. Otherwise the whole * header value (all parameters at once) will be encoded by * MimeHeader.writeTo(). */ bp.addHeader(MimeHeader.HEADER_CONTENT_TYPE, String.format("%s;\r\n name=\"%s\"", contentType, EncoderUtil.encodeIfNecessary(attachment.name, EncoderUtil.Usage.WORD_ENTITY, 7))); bp.setEncoding(MimeUtility.getEncodingforType(contentType)); /* * TODO: Oh the joys of MIME... * * From RFC 2183 (The Content-Disposition Header Field): * "Parameter values longer than 78 characters, or which * contain non-ASCII characters, MUST be encoded as specified * in [RFC 2184]." * * Example: * * Content-Type: application/x-stuff * title*1*=us-ascii'en'This%20is%20even%20more%20 * title*2*=%2A%2A%2Afun%2A%2A%2A%20 * title*3="isn't it!" */ bp.addHeader(MimeHeader.HEADER_CONTENT_DISPOSITION, String.format(Locale.US, "attachment;\r\n filename=\"%s\";\r\n size=%d", attachment.name, attachment.size)); mp.addBodyPart(bp); } } // FYI, there's nothing in the code that requires these variables to one letter. They're one // letter simply to save space. This name sucks. It's too similar to Account.Identity. private enum IdentityField { LENGTH("l"), OFFSET("o"), FOOTER_OFFSET("fo"), PLAIN_LENGTH("pl"), PLAIN_OFFSET("po"), MESSAGE_FORMAT("f"), MESSAGE_READ_RECEIPT("r"), SIGNATURE("s"), NAME("n"), EMAIL("e"), // TODO - store a reference to the message being replied so we can mark it at the time of send. ORIGINAL_MESSAGE("m"), CURSOR_POSITION("p"), // Where in the message your cursor was when you saved. QUOTED_TEXT_MODE("q"), QUOTE_STYLE("qs"); private final String value; IdentityField(String value) { this.value = value; } public String value() { return value; } /** * Get the list of IdentityFields that should be integer values. * ** These values are sanity checked for integer-ness during decoding. *
* * @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.", 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 { MessageFormat origMessageFormat = mAccount.getMessageFormat(); if (mForcePlainText || origMessageFormat == MessageFormat.TEXT) { // Use plain text for the quoted message mQuotedTextFormat = SimpleMessageFormat.TEXT; } else if (origMessageFormat == MessageFormat.AUTO) { // Figure out which message format to use for the quoted text by looking if the source // message contains a text/html part. If it does, we use that. mQuotedTextFormat = (MimeUtility.findFirstPartByMimeType(mSourceMessage, "text/html") == null) ? SimpleMessageFormat.TEXT : SimpleMessageFormat.HTML; } else { mQuotedTextFormat = SimpleMessageFormat.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, mQuotedTextFormat); if (mQuotedTextFormat == SimpleMessageFormat.HTML) { // Strip signature. // closing tags such as , , , will be cut off. if (mAccount.isStripSignature() && (mAction == Action.REPLY || mAction == Action.REPLY_ALL)) { Matcher dashSignatureHtml = DASH_SIGNATURE_HTML.matcher(content); if (dashSignatureHtml.find()) { Matcher blockquoteStart = BLOCKQUOTE_START.matcher(content); Matcher blockquoteEnd = BLOCKQUOTE_END.matcher(content); Liststart = new ArrayList (); List end = new ArrayList (); 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() + " tags, but " + end.size() + "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. 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. 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.setText(mQuotedHtmlContent.getQuotedContent()); // TODO: Also strip the signature from the text/plain part mQuotedText.setCharacters(quoteOriginalTextMessage(mSourceMessage, getBodyTextFromMessage(mSourceMessage, SimpleMessageFormat.TEXT), mQuoteStyle)); } else if (mQuotedTextFormat == SimpleMessageFormat.TEXT) { if (mAccount.isStripSignature() && (mAction == Action.REPLY || mAction == Action.REPLY_ALL)) { if (DASH_SIGNATURE_PLAIN.matcher(content).find()) { content = DASH_SIGNATURE_PLAIN.matcher(content).replaceFirst("\r\n"); } } mQuotedText.setCharacters(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 SimpleMessageFormat format) throws MessagingException { Part part; if (format == SimpleMessageFormat.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 == SimpleMessageFormat.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:.*?(|\\s+[^>]*>)).*)"); private static final Pattern FIND_INSERTION_POINT_HEAD = Pattern.compile("(?si:.*?(|\\s+[^>]*>)).*)"); private static final Pattern FIND_INSERTION_POINT_BODY = Pattern.compile("(?si:.*?(|\\s+[^>]*>)).*)"); private static final Pattern FIND_INSERTION_POINT_HTML_END = Pattern.compile("(?si:.*().*?)"); private static final Pattern FIND_INSERTION_POINT_BODY_END = Pattern.compile("(?si:.*().*?)"); // 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 = "\r\n"; private static final String FIND_INSERTION_POINT_HTML_END_CONTENT = ""; private static final String FIND_INSERTION_POINT_HEAD_CONTENT = ""; // Index of the start of the beginning of a String. private static final int FIND_INSERTION_POINT_START_OF_STRING = 0; /** *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.
* *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).
* * @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 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); } updateMessageFormat(); } 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. */ CaseInsensitiveParamWrapper uri = new CaseInsensitiveParamWrapper( Uri.parse("foo://bar?" + mailtoUri.getEncodedQuery())); // Read additional recipients from the "to" parameter. Listto = uri.getQueryParameters("to"); if (recipient.length() != 0) { to = new ArrayList (to); to.add(0, recipient); } addRecipients(mToView, to); // Read carbon copy recipients from the "cc" parameter. boolean ccOrBcc = addRecipients(mCcView, uri.getQueryParameters("cc")); // Read blind carbon copy recipients from the "bcc" parameter. ccOrBcc |= addRecipients(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 subject = uri.getQueryParameters("subject"); if (!subject.isEmpty()) { mSubjectView.setText(subject.get(0)); } // Read message body from the "body" parameter. List body = uri.getQueryParameters("body"); if (!body.isEmpty()) { mMessageContentView.setCharacters(body.get(0)); } } private static class CaseInsensitiveParamWrapper { private final Uri uri; private Set mParamNames; public CaseInsensitiveParamWrapper(Uri uri) { this.uri = uri; } public List getQueryParameters(String key) { final List params = new ArrayList (); for (String paramName : getQueryParameterNames()) { if (paramName.equalsIgnoreCase(key)) { params.addAll(uri.getQueryParameters(paramName)); } } return params; } @TargetApi(11) private Set getQueryParameterNames() { if (Build.VERSION.SDK_INT >= 11) { return uri.getQueryParameterNames(); } return getQueryParameterNamesPreSdk11(); } private Set getQueryParameterNamesPreSdk11() { if (mParamNames == null) { String query = uri.getQuery(); Set paramNames = new HashSet (); Collections.addAll(paramNames, query.split("(=[^&]*(&|$))|&")); mParamNames = paramNames; } return mParamNames; } } private class SendMessageTask extends AsyncTask { @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 { @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 (mAction == Action.EDIT_DRAFT) { /* * 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; String sentDate = getSentDateText(originalMessage); if (quoteStyle == QuoteStyle.PREFIX) { StringBuilder quotedText = new StringBuilder(body.length() + QUOTE_BUFFER_LENGTH); if (sentDate.length() != 0) { quotedText.append(String.format( getString(R.string.message_compose_reply_header_fmt_with_date) + "\r\n", sentDate, Address.toString(originalMessage.getFrom()))); } else { quotedText.append(String.format( getString(R.string.message_compose_reply_header_fmt) + "\r\n", 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("\r\n"); quotedText.append(getString(R.string.message_compose_quote_header_separator)).append("\r\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("\r\n"); } if (sentDate.length() != 0) { quotedText.append(getString(R.string.message_compose_quote_header_send_date)).append(" ").append(sentDate).append("\r\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("\r\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("\r\n"); } if (originalMessage.getSubject() != null) { quotedText.append(getString(R.string.message_compose_quote_header_subject)).append(" ").append(originalMessage.getSubject()).append("\r\n"); } quotedText.append("\r\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); String sentDate = getSentDateText(originalMessage); if (quoteStyle == QuoteStyle.PREFIX) { StringBuilder header = new StringBuilder(QUOTE_BUFFER_LENGTH); header.append(" "); if (sentDate.length() != 0) { header.append(HtmlConverter.textToHtmlFragment(String.format( getString(R.string.message_compose_reply_header_fmt_with_date), sentDate, Address.toString(originalMessage.getFrom())) )); } else { header.append(HtmlConverter.textToHtmlFragment(String.format( getString(R.string.message_compose_reply_header_fmt), Address.toString(originalMessage.getFrom())) )); } header.append(""; insertable.insertIntoQuotedHeader(header.toString()); insertable.insertIntoQuotedFooter(footer); } else if (quoteStyle == QuoteStyle.HEADER) { StringBuilder header = new StringBuilder(); header.append("\r\n"); String footer = "\r\n"); header.append("\r\n"); header.append("
\r\n"); // This gets converted into a horizontal line during html to text conversion. if (originalMessage.getFrom() != null && Address.toString(originalMessage.getFrom()).length() != 0) { header.append("").append(getString(R.string.message_compose_quote_header_from)).append(" ") .append(HtmlConverter.textToHtmlFragment(Address.toString(originalMessage.getFrom()))) .append("
\r\n"); } if (sentDate.length() != 0) { header.append("").append(getString(R.string.message_compose_quote_header_send_date)).append(" ") .append(sentDate) .append("
\r\n"); } if (originalMessage.getRecipients(RecipientType.TO) != null && originalMessage.getRecipients(RecipientType.TO).length != 0) { header.append("").append(getString(R.string.message_compose_quote_header_to)).append(" ") .append(HtmlConverter.textToHtmlFragment(Address.toString(originalMessage.getRecipients(RecipientType.TO)))) .append("
\r\n"); } if (originalMessage.getRecipients(RecipientType.CC) != null && originalMessage.getRecipients(RecipientType.CC).length != 0) { header.append("").append(getString(R.string.message_compose_quote_header_cc)).append(" ") .append(HtmlConverter.textToHtmlFragment(Address.toString(originalMessage.getRecipients(RecipientType.CC)))) .append("
\r\n"); } if (originalMessage.getSubject() != null) { header.append("").append(getString(R.string.message_compose_quote_header_subject)).append(" ") .append(HtmlConverter.textToHtmlFragment(originalMessage.getSubject())) .append("
\r\n"); } header.append("
\r\n"); insertable.insertIntoQuotedHeader(header.toString()); } return insertable; } /** * Used to store an {@link Identity} instance together with the {@link Account} it belongs to. * * @see IdentityAdapter */ static class IdentityContainer { public final Identity identity; public final Account account; IdentityContainer(Identity identity, Account account) { this.identity = identity; this.account = account; } } /** * Adapter for the Choose identity list view. * ** Account names are displayed as section headers, identities as selectable list items. *
*/ static class IdentityAdapter extends BaseAdapter { private LayoutInflater mLayoutInflater; private List