| /* |
| * Copyright (C) 2008 The Android Open Source Project |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| |
| package com.android.email.activity; |
| |
| import com.android.email.Controller; |
| import com.android.email.Email; |
| import com.android.email.EmailAddressAdapter; |
| import com.android.email.EmailAddressValidator; |
| import com.android.email.R; |
| import com.android.email.Utility; |
| import com.android.email.mail.Address; |
| import com.android.email.mail.MessagingException; |
| import com.android.email.mail.internet.EmailHtmlUtil; |
| import com.android.email.mail.internet.MimeUtility; |
| import com.android.email.provider.EmailContent; |
| import com.android.email.provider.EmailContent.Account; |
| import com.android.email.provider.EmailContent.Attachment; |
| import com.android.email.provider.EmailContent.Body; |
| import com.android.email.provider.EmailContent.BodyColumns; |
| import com.android.email.provider.EmailContent.Message; |
| import com.android.email.provider.EmailContent.MessageColumns; |
| import com.android.exchange.provider.GalEmailAddressAdapter; |
| |
| import android.app.Activity; |
| import android.content.ActivityNotFoundException; |
| import android.content.ContentResolver; |
| import android.content.ContentUris; |
| import android.content.ContentValues; |
| import android.content.Context; |
| 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.InputFilter; |
| import android.text.SpannableStringBuilder; |
| import android.text.Spanned; |
| import android.text.TextUtils; |
| import android.text.TextWatcher; |
| import android.text.util.Rfc822Tokenizer; |
| import android.util.Log; |
| import android.view.Menu; |
| import android.view.MenuItem; |
| import android.view.View; |
| import android.view.Window; |
| import android.view.View.OnClickListener; |
| import android.view.View.OnFocusChangeListener; |
| import android.webkit.WebView; |
| import android.widget.Button; |
| 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 java.io.UnsupportedEncodingException; |
| import java.net.URLDecoder; |
| import java.util.ArrayList; |
| import java.util.List; |
| |
| |
| public class MessageCompose extends Activity implements OnClickListener, OnFocusChangeListener { |
| private static final String ACTION_REPLY = "com.android.email.intent.action.REPLY"; |
| private static final String ACTION_REPLY_ALL = "com.android.email.intent.action.REPLY_ALL"; |
| private static final String ACTION_FORWARD = "com.android.email.intent.action.FORWARD"; |
| private static final String ACTION_EDIT_DRAFT = "com.android.email.intent.action.EDIT_DRAFT"; |
| |
| private static final String EXTRA_ACCOUNT_ID = "account_id"; |
| private static final String EXTRA_MESSAGE_ID = "message_id"; |
| private static final String STATE_KEY_CC_SHOWN = |
| "com.android.email.activity.MessageCompose.ccShown"; |
| private static final String STATE_KEY_BCC_SHOWN = |
| "com.android.email.activity.MessageCompose.bccShown"; |
| private static final String STATE_KEY_QUOTED_TEXT_SHOWN = |
| "com.android.email.activity.MessageCompose.quotedTextShown"; |
| private static final String STATE_KEY_SOURCE_MESSAGE_PROCED = |
| "com.android.email.activity.MessageCompose.stateKeySourceMessageProced"; |
| private static final String STATE_KEY_DRAFT_ID = |
| "com.android.email.activity.MessageCompose.draftId"; |
| |
| private static final int MSG_UPDATE_TITLE = 3; |
| private static final int MSG_SKIPPED_ATTACHMENTS = 4; |
| private static final int MSG_DISCARDED_DRAFT = 6; |
| |
| private static final int ACTIVITY_REQUEST_PICK_ATTACHMENT = 1; |
| |
| private static final String[] ATTACHMENT_META_COLUMNS = { |
| OpenableColumns.DISPLAY_NAME, |
| OpenableColumns.SIZE |
| }; |
| |
| // Is set while the draft is saved by a background thread. |
| // Is static in order to be shared between the two activity instances |
| // on orientation change. |
| private static boolean sSaveInProgress = false; |
| // lock and condition for sSaveInProgress |
| private static final Object sSaveInProgressCondition = new Object(); |
| |
| private Account mAccount; |
| |
| // mDraft has mId > 0 after the first draft save. |
| private Message mDraft = new Message(); |
| |
| // mSource is only set for REPLY, REPLY_ALL and FORWARD, and contains the source message. |
| private Message mSource; |
| |
| // we use mAction instead of Intent.getAction() because sometimes we need to |
| // re-write the action to EDIT_DRAFT. |
| private String mAction; |
| |
| /** |
| * 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 MultiAutoCompleteTextView mToView; |
| private MultiAutoCompleteTextView mCcView; |
| private MultiAutoCompleteTextView mBccView; |
| private EditText mSubjectView; |
| private EditText mMessageContentView; |
| private Button mSendButton; |
| private Button mDiscardButton; |
| private Button mSaveButton; |
| private LinearLayout mAttachments; |
| private View mQuotedTextBar; |
| private ImageButton mQuotedTextDelete; |
| private WebView mQuotedText; |
| private TextView mLeftTitle; |
| private TextView mRightTitle; |
| |
| private Controller mController; |
| private Listener mListener; |
| private boolean mDraftNeedsSaving; |
| private boolean mMessageLoaded; |
| private AsyncTask mLoadAttachmentsTask; |
| private AsyncTask mSaveMessageTask; |
| private AsyncTask mLoadMessageTask; |
| |
| private EmailAddressAdapter mAddressAdapterTo; |
| private EmailAddressAdapter mAddressAdapterCc; |
| private EmailAddressAdapter mAddressAdapterBcc; |
| |
| private Handler mHandler = new Handler() { |
| @Override |
| public void handleMessage(android.os.Message msg) { |
| switch (msg.what) { |
| 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; |
| default: |
| super.handleMessage(msg); |
| break; |
| } |
| } |
| }; |
| |
| /** |
| * Compose a new message using the given account. If account is -1 the default account |
| * will be used. |
| * @param context |
| * @param accountId |
| */ |
| public static void actionCompose(Context context, long accountId) { |
| try { |
| Intent i = new Intent(context, MessageCompose.class); |
| i.putExtra(EXTRA_ACCOUNT_ID, accountId); |
| context.startActivity(i); |
| } catch (ActivityNotFoundException anfe) { |
| // Swallow it - this is usually a race condition, especially under automated test. |
| // (The message composer might have been disabled) |
| Email.log(anfe.toString()); |
| } |
| } |
| |
| /** |
| * Compose a new message using a uri (mailto:) and a given account. If account is -1 the |
| * default account will be used. |
| * @param context |
| * @param uriString |
| * @param accountId |
| * @return true if startActivity() succeeded |
| */ |
| public static boolean actionCompose(Context context, String uriString, long accountId) { |
| try { |
| Intent i = new Intent(context, MessageCompose.class); |
| i.setAction(Intent.ACTION_SEND); |
| i.setData(Uri.parse(uriString)); |
| i.putExtra(EXTRA_ACCOUNT_ID, accountId); |
| context.startActivity(i); |
| return true; |
| } catch (ActivityNotFoundException anfe) { |
| // Swallow it - this is usually a race condition, especially under automated test. |
| // (The message composer might have been disabled) |
| Email.log(anfe.toString()); |
| return false; |
| } |
| } |
| |
| /** |
| * 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 messageId |
| * @param replyAll |
| */ |
| public static void actionReply(Context context, long messageId, boolean replyAll) { |
| startActivityWithMessage(context, replyAll ? ACTION_REPLY_ALL : ACTION_REPLY, messageId); |
| } |
| |
| /** |
| * Compose a new message as a forward of the given message. |
| * @param context |
| * @param messageId |
| */ |
| public static void actionForward(Context context, long messageId) { |
| startActivityWithMessage(context, ACTION_FORWARD, messageId); |
| } |
| |
| /** |
| * 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 messageId the message id. |
| */ |
| public static void actionEditDraft(Context context, long messageId) { |
| startActivityWithMessage(context, ACTION_EDIT_DRAFT, messageId); |
| } |
| |
| private static void startActivityWithMessage(Context context, String action, long messageId) { |
| Intent i = new Intent(context, MessageCompose.class); |
| i.putExtra(EXTRA_MESSAGE_ID, messageId); |
| i.setAction(action); |
| context.startActivity(i); |
| } |
| |
| private void setAccount(Intent intent) { |
| long accountId = intent.getLongExtra(EXTRA_ACCOUNT_ID, -1); |
| if (accountId == -1) { |
| accountId = Account.getDefaultAccountId(this); |
| } |
| if (accountId == -1) { |
| // There are no accounts set up. This should not have happened. Prompt the |
| // user to set up an account as an acceptable bailout. |
| AccountFolderList.actionShowAccounts(this); |
| finish(); |
| } else { |
| setAccount(Account.restoreAccountWithId(this, accountId)); |
| } |
| } |
| |
| private void setAccount(Account account) { |
| mAccount = account; |
| if (account != null) { |
| mRightTitle.setText(account.mDisplayName); |
| mAddressAdapterTo.setAccount(account); |
| mAddressAdapterCc.setAccount(account); |
| mAddressAdapterBcc.setAccount(account); |
| } |
| } |
| |
| @Override |
| public void onCreate(Bundle savedInstanceState) { |
| super.onCreate(savedInstanceState); |
| requestWindowFeature(Window.FEATURE_CUSTOM_TITLE); |
| setContentView(R.layout.message_compose); |
| getWindow().setFeatureInt(Window.FEATURE_CUSTOM_TITLE, R.layout.list_title); |
| |
| mController = Controller.getInstance(getApplication()); |
| mListener = new Listener(); |
| initViews(); |
| setDraftNeedsSaving(false); |
| |
| long draftId = -1; |
| 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); |
| draftId = savedInstanceState.getLong(STATE_KEY_DRAFT_ID, -1); |
| } |
| |
| Intent intent = getIntent(); |
| mAction = intent.getAction(); |
| |
| if (draftId != -1) { |
| // this means that we saved the draft earlier, |
| // so now we need to disregard the intent action and do |
| // EDIT_DRAFT instead. |
| mAction = ACTION_EDIT_DRAFT; |
| mDraft.mId = draftId; |
| } |
| |
| // Handle the various intents that launch the message composer |
| if (Intent.ACTION_VIEW.equals(mAction) |
| || Intent.ACTION_SENDTO.equals(mAction) |
| || Intent.ACTION_SEND.equals(mAction) |
| || Intent.ACTION_SEND_MULTIPLE.equals(mAction)) { |
| setAccount(intent); |
| // Use the fields found in the Intent to prefill as much of the message as possible |
| initFromIntent(intent); |
| setDraftNeedsSaving(true); |
| mMessageLoaded = true; |
| mSourceMessageProcessed = true; |
| } else { |
| // Otherwise, handle the internal cases (Message Composer invoked from within app) |
| long messageId = draftId != -1 ? draftId : intent.getLongExtra(EXTRA_MESSAGE_ID, -1); |
| if (messageId != -1) { |
| mLoadMessageTask = new LoadMessageTask().execute(messageId); |
| } else { |
| setAccount(intent); |
| // Since this is a new message, we don't need to call LoadMessageTask. |
| // But we DO need to set mMessageLoaded to indicate the message can be sent |
| mMessageLoaded = true; |
| mSourceMessageProcessed = true; |
| } |
| setInitialComposeText(null, (mAccount != null) ? mAccount.mSignature : null); |
| } |
| |
| if (ACTION_REPLY.equals(mAction) || ACTION_REPLY_ALL.equals(mAction) || |
| ACTION_FORWARD.equals(mAction) || ACTION_EDIT_DRAFT.equals(mAction)) { |
| /* |
| * 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. |
| */ |
| // TODO: signal the controller to load the message |
| } |
| updateTitle(); |
| } |
| |
| // needed for unit tests |
| @Override |
| public void setIntent(Intent intent) { |
| super.setIntent(intent); |
| mAction = intent.getAction(); |
| } |
| |
| @Override |
| public void onResume() { |
| super.onResume(); |
| mController.addResultCallback(mListener); |
| |
| // Exit immediately if the accounts list has changed (e.g. externally deleted) |
| if (Email.getNotifyUiAccountsChanged()) { |
| Welcome.actionStart(this); |
| finish(); |
| return; |
| } |
| } |
| |
| @Override |
| public void onPause() { |
| super.onPause(); |
| saveIfNeeded(); |
| mController.removeResultCallback(mListener); |
| } |
| |
| /** |
| * We override onDestroy to make sure that the WebView gets explicitly destroyed. |
| * Otherwise it can leak native references. |
| */ |
| @Override |
| public void onDestroy() { |
| super.onDestroy(); |
| mQuotedText.destroy(); |
| mQuotedText = null; |
| |
| Utility.cancelTaskInterrupt(mLoadAttachmentsTask); |
| mLoadAttachmentsTask = null; |
| Utility.cancelTaskInterrupt(mLoadMessageTask); |
| mLoadMessageTask = null; |
| // don't cancel mSaveMessageTask, let it do its job to the end. |
| mSaveMessageTask = null; |
| |
| if (mAddressAdapterTo != null) { |
| mAddressAdapterTo.changeCursor(null); |
| } |
| if (mAddressAdapterCc != null) { |
| mAddressAdapterCc.changeCursor(null); |
| } |
| if (mAddressAdapterBcc != null) { |
| mAddressAdapterBcc.changeCursor(null); |
| } |
| } |
| |
| /** |
| * The framework handles most of the fields, but we need to handle stuff that we |
| * dynamically show and hide: |
| * Cc field, |
| * Bcc field, |
| * Quoted text, |
| */ |
| @Override |
| protected void onSaveInstanceState(Bundle outState) { |
| super.onSaveInstanceState(outState); |
| long draftId = getOrCreateDraftId(); |
| if (draftId != -1) { |
| outState.putLong(STATE_KEY_DRAFT_ID, draftId); |
| } |
| outState.putBoolean(STATE_KEY_CC_SHOWN, mCcView.getVisibility() == View.VISIBLE); |
| outState.putBoolean(STATE_KEY_BCC_SHOWN, mBccView.getVisibility() == View.VISIBLE); |
| outState.putBoolean(STATE_KEY_QUOTED_TEXT_SHOWN, |
| mQuotedTextBar.getVisibility() == View.VISIBLE); |
| outState.putBoolean(STATE_KEY_SOURCE_MESSAGE_PROCED, mSourceMessageProcessed); |
| } |
| |
| @Override |
| protected void onRestoreInstanceState(Bundle savedInstanceState) { |
| super.onRestoreInstanceState(savedInstanceState); |
| mCcView.setVisibility(savedInstanceState.getBoolean(STATE_KEY_CC_SHOWN) ? |
| View.VISIBLE : View.GONE); |
| mBccView.setVisibility(savedInstanceState.getBoolean(STATE_KEY_BCC_SHOWN) ? |
| View.VISIBLE : View.GONE); |
| mQuotedTextBar.setVisibility(savedInstanceState.getBoolean(STATE_KEY_QUOTED_TEXT_SHOWN) ? |
| View.VISIBLE : View.GONE); |
| mQuotedText.setVisibility(savedInstanceState.getBoolean(STATE_KEY_QUOTED_TEXT_SHOWN) ? |
| View.VISIBLE : View.GONE); |
| setDraftNeedsSaving(false); |
| } |
| |
| private void setDraftNeedsSaving(boolean needsSaving) { |
| mDraftNeedsSaving = needsSaving; |
| mSaveButton.setEnabled(needsSaving); |
| } |
| |
| private void initViews() { |
| mToView = (MultiAutoCompleteTextView)findViewById(R.id.to); |
| mCcView = (MultiAutoCompleteTextView)findViewById(R.id.cc); |
| mBccView = (MultiAutoCompleteTextView)findViewById(R.id.bcc); |
| mSubjectView = (EditText)findViewById(R.id.subject); |
| mMessageContentView = (EditText)findViewById(R.id.message_content); |
| mSendButton = (Button)findViewById(R.id.send); |
| mDiscardButton = (Button)findViewById(R.id.discard); |
| mSaveButton = (Button)findViewById(R.id.save); |
| mAttachments = (LinearLayout)findViewById(R.id.attachments); |
| mQuotedTextBar = findViewById(R.id.quoted_text_bar); |
| mQuotedTextDelete = (ImageButton)findViewById(R.id.quoted_text_delete); |
| mQuotedText = (WebView)findViewById(R.id.quoted_text); |
| mLeftTitle = (TextView)findViewById(R.id.title_left_text); |
| mRightTitle = (TextView)findViewById(R.id.title_right_text); |
| |
| TextWatcher watcher = new TextWatcher() { |
| public void beforeTextChanged(CharSequence s, int start, |
| int before, int after) { } |
| |
| public void onTextChanged(CharSequence s, int start, |
| int before, int count) { |
| setDraftNeedsSaving(true); |
| } |
| |
| public void afterTextChanged(android.text.Editable s) { } |
| }; |
| |
| /** |
| * Implements special address cleanup rules: |
| * The first space key entry following an "@" symbol that is followed by any combination |
| * of letters and symbols, including one+ dots and zero commas, should insert an extra |
| * comma (followed by the space). |
| */ |
| InputFilter recipientFilter = new InputFilter() { |
| |
| public CharSequence filter(CharSequence source, int start, int end, Spanned dest, |
| int dstart, int dend) { |
| |
| // quick check - did they enter a single space? |
| if (end-start != 1 || source.charAt(start) != ' ') { |
| return null; |
| } |
| |
| // determine if the characters before the new space fit the pattern |
| // follow backwards and see if we find a comma, dot, or @ |
| int scanBack = dstart; |
| boolean dotFound = false; |
| while (scanBack > 0) { |
| char c = dest.charAt(--scanBack); |
| switch (c) { |
| case '.': |
| dotFound = true; // one or more dots are req'd |
| break; |
| case ',': |
| return null; |
| case '@': |
| if (!dotFound) { |
| return null; |
| } |
| |
| // we have found a comma-insert case. now just do it |
| // in the least expensive way we can. |
| if (source instanceof Spanned) { |
| SpannableStringBuilder sb = new SpannableStringBuilder(","); |
| sb.append(source); |
| return sb; |
| } else { |
| return ", "; |
| } |
| default: |
| // just keep going |
| } |
| } |
| |
| // no termination cases were found, so don't edit the input |
| return null; |
| } |
| }; |
| InputFilter[] recipientFilters = new InputFilter[] { recipientFilter }; |
| |
| mToView.addTextChangedListener(watcher); |
| mCcView.addTextChangedListener(watcher); |
| mBccView.addTextChangedListener(watcher); |
| mSubjectView.addTextChangedListener(watcher); |
| mMessageContentView.addTextChangedListener(watcher); |
| |
| // NOTE: assumes no other filters are set |
| mToView.setFilters(recipientFilters); |
| mCcView.setFilters(recipientFilters); |
| mBccView.setFilters(recipientFilters); |
| |
| /* |
| * We set this to invisible by default. Other methods will turn it back on if it's |
| * needed. |
| */ |
| mQuotedTextBar.setVisibility(View.GONE); |
| mQuotedText.setVisibility(View.GONE); |
| |
| mQuotedTextDelete.setOnClickListener(this); |
| |
| EmailAddressValidator addressValidator = new EmailAddressValidator(); |
| |
| setupAddressAdapters(); |
| mToView.setAdapter(mAddressAdapterTo); |
| mToView.setTokenizer(new Rfc822Tokenizer()); |
| mToView.setValidator(addressValidator); |
| |
| mCcView.setAdapter(mAddressAdapterCc); |
| mCcView.setTokenizer(new Rfc822Tokenizer()); |
| mCcView.setValidator(addressValidator); |
| |
| mBccView.setAdapter(mAddressAdapterBcc); |
| mBccView.setTokenizer(new Rfc822Tokenizer()); |
| mBccView.setValidator(addressValidator); |
| |
| mSendButton.setOnClickListener(this); |
| mDiscardButton.setOnClickListener(this); |
| mSaveButton.setOnClickListener(this); |
| |
| mSubjectView.setOnFocusChangeListener(this); |
| mMessageContentView.setOnFocusChangeListener(this); |
| } |
| |
| /** |
| * Set up address auto-completion adapters. |
| */ |
| @SuppressWarnings("all") |
| private void setupAddressAdapters() { |
| /* EXCHANGE-REMOVE-SECTION-START */ |
| if (true) { |
| mAddressAdapterTo = new GalEmailAddressAdapter(this); |
| mAddressAdapterCc = new GalEmailAddressAdapter(this); |
| mAddressAdapterBcc = new GalEmailAddressAdapter(this); |
| } else { |
| /* EXCHANGE-REMOVE-SECTION-END */ |
| mAddressAdapterTo = new EmailAddressAdapter(this); |
| mAddressAdapterCc = new EmailAddressAdapter(this); |
| mAddressAdapterBcc = new EmailAddressAdapter(this); |
| /* EXCHANGE-REMOVE-SECTION-START */ |
| } |
| /* EXCHANGE-REMOVE-SECTION-END */ |
| } |
| |
| // TODO: is there any way to unify this with MessageView.LoadMessageTask? |
| private class LoadMessageTask extends AsyncTask<Long, Void, Object[]> { |
| @Override |
| protected Object[] doInBackground(Long... messageIds) { |
| synchronized (sSaveInProgressCondition) { |
| while (sSaveInProgress) { |
| try { |
| sSaveInProgressCondition.wait(); |
| } catch (InterruptedException e) { |
| // ignore & retry loop |
| } |
| } |
| } |
| Message message = Message.restoreMessageWithId(MessageCompose.this, messageIds[0]); |
| if (message == null) { |
| return new Object[] {null, null}; |
| } |
| long accountId = message.mAccountKey; |
| Account account = Account.restoreAccountWithId(MessageCompose.this, accountId); |
| try { |
| // Body body = Body.restoreBodyWithMessageId(MessageCompose.this, message.mId); |
| message.mHtml = Body.restoreBodyHtmlWithMessageId(MessageCompose.this, message.mId); |
| message.mText = Body.restoreBodyTextWithMessageId(MessageCompose.this, message.mId); |
| boolean isEditDraft = ACTION_EDIT_DRAFT.equals(mAction); |
| // the reply fields are only filled/used for Drafts. |
| if (isEditDraft) { |
| message.mHtmlReply = |
| Body.restoreReplyHtmlWithMessageId(MessageCompose.this, message.mId); |
| message.mTextReply = |
| Body.restoreReplyTextWithMessageId(MessageCompose.this, message.mId); |
| message.mIntroText = |
| Body.restoreIntroTextWithMessageId(MessageCompose.this, message.mId); |
| message.mSourceKey = Body.restoreBodySourceKey(MessageCompose.this, |
| message.mId); |
| } else { |
| message.mHtmlReply = null; |
| message.mTextReply = null; |
| message.mIntroText = null; |
| } |
| } catch (RuntimeException e) { |
| Log.d(Email.LOG_TAG, "Exception while loading message body: " + e); |
| return new Object[] {null, null}; |
| } |
| return new Object[]{message, account}; |
| } |
| |
| @Override |
| protected void onPostExecute(Object[] messageAndAccount) { |
| if (messageAndAccount == null) { |
| return; |
| } |
| |
| final Message message = (Message) messageAndAccount[0]; |
| final Account account = (Account) messageAndAccount[1]; |
| if (message == null && account == null) { |
| // Something unexpected happened: |
| // the message or the body couldn't be loaded by SQLite. |
| // Bail out. |
| Toast.makeText(MessageCompose.this, R.string.error_loading_message_body, |
| Toast.LENGTH_LONG).show(); |
| finish(); |
| return; |
| } |
| |
| if (ACTION_EDIT_DRAFT.equals(mAction)) { |
| mDraft = message; |
| mLoadAttachmentsTask = new AsyncTask<Long, Void, Attachment[]>() { |
| @Override |
| protected Attachment[] doInBackground(Long... messageIds) { |
| return Attachment.restoreAttachmentsWithMessageId(MessageCompose.this, |
| messageIds[0]); |
| } |
| @Override |
| protected void onPostExecute(Attachment[] attachments) { |
| if (attachments == null) { |
| return; |
| } |
| for (Attachment attachment : attachments) { |
| addAttachment(attachment); |
| } |
| } |
| }.execute(message.mId); |
| } else if (ACTION_REPLY.equals(mAction) |
| || ACTION_REPLY_ALL.equals(mAction) |
| || ACTION_FORWARD.equals(mAction)) { |
| mSource = message; |
| } else if (Email.LOGD) { |
| Email.log("Action " + mAction + " has unexpected EXTRA_MESSAGE_ID"); |
| } |
| |
| setAccount(account); |
| processSourceMessageGuarded(message, mAccount); |
| mMessageLoaded = true; |
| } |
| } |
| |
| private void updateTitle() { |
| if (mSubjectView.getText().length() == 0) { |
| mLeftTitle.setText(R.string.compose_title); |
| } else { |
| mLeftTitle.setText(mSubjectView.getText().toString()); |
| } |
| } |
| |
| public void onFocusChange(View view, boolean focused) { |
| if (!focused) { |
| updateTitle(); |
| } else { |
| switch (view.getId()) { |
| case R.id.message_content: |
| setMessageContentSelection((mAccount != null) ? mAccount.mSignature : null); |
| } |
| } |
| } |
| |
| private void addAddresses(MultiAutoCompleteTextView view, Address[] addresses) { |
| if (addresses == null) { |
| return; |
| } |
| for (Address address : addresses) { |
| addAddress(view, address.toString()); |
| } |
| } |
| |
| private void addAddresses(MultiAutoCompleteTextView view, String[] addresses) { |
| if (addresses == null) { |
| return; |
| } |
| for (String oneAddress : addresses) { |
| addAddress(view, oneAddress); |
| } |
| } |
| |
| private void addAddress(MultiAutoCompleteTextView view, String address) { |
| view.append(address + ", "); |
| } |
| |
| private String getPackedAddresses(TextView view) { |
| Address[] addresses = Address.parse(view.getText().toString().trim()); |
| return Address.pack(addresses); |
| } |
| |
| private Address[] getAddresses(TextView view) { |
| Address[] addresses = Address.parse(view.getText().toString().trim()); |
| return addresses; |
| } |
| |
| /* |
| * Computes a short string indicating the destination of the message based on To, Cc, Bcc. |
| * If only one address appears, returns the friendly form of that address. |
| * Otherwise returns the friendly form of the first address appended with "and N others". |
| */ |
| private String makeDisplayName(String packedTo, String packedCc, String packedBcc) { |
| Address first = null; |
| int nRecipients = 0; |
| for (String packed: new String[] {packedTo, packedCc, packedBcc}) { |
| Address[] addresses = Address.unpack(packed); |
| nRecipients += addresses.length; |
| if (first == null && addresses.length > 0) { |
| first = addresses[0]; |
| } |
| } |
| if (nRecipients == 0) { |
| return ""; |
| } |
| String friendly = first.toFriendly(); |
| if (nRecipients == 1) { |
| return friendly; |
| } |
| return this.getString(R.string.message_compose_display_name, friendly, nRecipients - 1); |
| } |
| |
| private ContentValues getUpdateContentValues(Message message) { |
| ContentValues values = new ContentValues(); |
| values.put(MessageColumns.TIMESTAMP, message.mTimeStamp); |
| values.put(MessageColumns.FROM_LIST, message.mFrom); |
| values.put(MessageColumns.TO_LIST, message.mTo); |
| values.put(MessageColumns.CC_LIST, message.mCc); |
| values.put(MessageColumns.BCC_LIST, message.mBcc); |
| values.put(MessageColumns.SUBJECT, message.mSubject); |
| values.put(MessageColumns.DISPLAY_NAME, message.mDisplayName); |
| values.put(MessageColumns.FLAG_READ, message.mFlagRead); |
| values.put(MessageColumns.FLAG_LOADED, message.mFlagLoaded); |
| values.put(MessageColumns.FLAG_ATTACHMENT, message.mFlagAttachment); |
| values.put(MessageColumns.FLAGS, message.mFlags); |
| return values; |
| } |
| |
| /** |
| * @param message The message to be updated. |
| * @param account the account (used to obtain From: address). |
| * @param bodyText the body text. |
| */ |
| private void updateMessage(Message message, Account account, boolean hasAttachments) { |
| if (message.mMessageId == null || message.mMessageId.length() == 0) { |
| message.mMessageId = Utility.generateMessageId(); |
| } |
| message.mTimeStamp = System.currentTimeMillis(); |
| message.mFrom = new Address(account.getEmailAddress(), account.getSenderName()).pack(); |
| message.mTo = getPackedAddresses(mToView); |
| message.mCc = getPackedAddresses(mCcView); |
| message.mBcc = getPackedAddresses(mBccView); |
| message.mSubject = mSubjectView.getText().toString(); |
| message.mText = mMessageContentView.getText().toString(); |
| message.mAccountKey = account.mId; |
| message.mDisplayName = makeDisplayName(message.mTo, message.mCc, message.mBcc); |
| message.mFlagRead = true; |
| message.mFlagLoaded = Message.FLAG_LOADED_COMPLETE; |
| message.mFlagAttachment = hasAttachments; |
| // Use the Intent to set flags saying this message is a reply or a forward and save the |
| // unique id of the source message |
| if (mSource != null && mQuotedTextBar.getVisibility() == View.VISIBLE) { |
| if (ACTION_REPLY.equals(mAction) || ACTION_REPLY_ALL.equals(mAction) |
| || ACTION_FORWARD.equals(mAction)) { |
| message.mSourceKey = mSource.mId; |
| // Get the body of the source message here |
| message.mHtmlReply = mSource.mHtml; |
| message.mTextReply = mSource.mText; |
| } |
| |
| String fromAsString = Address.unpackToString(mSource.mFrom); |
| if (ACTION_FORWARD.equals(mAction)) { |
| message.mFlags |= Message.FLAG_TYPE_FORWARD; |
| String subject = mSource.mSubject; |
| String to = Address.unpackToString(mSource.mTo); |
| String cc = Address.unpackToString(mSource.mCc); |
| message.mIntroText = |
| getString(R.string.message_compose_fwd_header_fmt, subject, fromAsString, |
| to != null ? to : "", cc != null ? cc : ""); |
| } else { |
| message.mFlags |= Message.FLAG_TYPE_REPLY; |
| message.mIntroText = |
| getString(R.string.message_compose_reply_header_fmt, fromAsString); |
| } |
| } |
| } |
| |
| private Attachment[] getAttachmentsFromUI() { |
| int count = mAttachments.getChildCount(); |
| Attachment[] attachments = new Attachment[count]; |
| for (int i = 0; i < count; ++i) { |
| attachments[i] = (Attachment) mAttachments.getChildAt(i).getTag(); |
| } |
| return attachments; |
| } |
| |
| /* This method does DB operations in UI thread because |
| the draftId is needed by onSaveInstanceState() which can't wait for it |
| to be saved in the background. |
| TODO: This will cause ANRs, so we need to find a better solution. |
| */ |
| private long getOrCreateDraftId() { |
| synchronized (mDraft) { |
| if (mDraft.mId > 0) { |
| return mDraft.mId; |
| } |
| // don't save draft if the source message did not load yet |
| if (!mMessageLoaded) { |
| return -1; |
| } |
| final Attachment[] attachments = getAttachmentsFromUI(); |
| updateMessage(mDraft, mAccount, attachments.length > 0); |
| mController.saveToMailbox(mDraft, EmailContent.Mailbox.TYPE_DRAFTS); |
| return mDraft.mId; |
| } |
| } |
| |
| /** |
| * Send or save a message: |
| * - out of the UI thread |
| * - write to Drafts |
| * - if send, invoke Controller.sendMessage() |
| * - when operation is complete, display toast |
| */ |
| private void sendOrSaveMessage(final boolean send) { |
| final Attachment[] attachments = getAttachmentsFromUI(); |
| if (!mMessageLoaded) { |
| // early save, before the message was loaded: do nothing |
| return; |
| } |
| updateMessage(mDraft, mAccount, attachments.length > 0); |
| |
| synchronized (sSaveInProgressCondition) { |
| sSaveInProgress = true; |
| } |
| |
| mSaveMessageTask = new AsyncTask<Void, Void, Void>() { |
| @Override |
| protected Void doInBackground(Void... params) { |
| synchronized (mDraft) { |
| if (mDraft.isSaved()) { |
| // Update the message |
| Uri draftUri = |
| ContentUris.withAppendedId(mDraft.SYNCED_CONTENT_URI, mDraft.mId); |
| getContentResolver().update(draftUri, getUpdateContentValues(mDraft), |
| null, null); |
| // Update the body |
| ContentValues values = new ContentValues(); |
| values.put(BodyColumns.TEXT_CONTENT, mDraft.mText); |
| values.put(BodyColumns.TEXT_REPLY, mDraft.mTextReply); |
| values.put(BodyColumns.HTML_REPLY, mDraft.mHtmlReply); |
| values.put(BodyColumns.INTRO_TEXT, mDraft.mIntroText); |
| values.put(BodyColumns.SOURCE_MESSAGE_KEY, mDraft.mSourceKey); |
| Body.updateBodyWithMessageId(MessageCompose.this, mDraft.mId, values); |
| } else { |
| // mDraft.mId is set upon return of saveToMailbox() |
| mController.saveToMailbox(mDraft, EmailContent.Mailbox.TYPE_DRAFTS); |
| } |
| for (Attachment attachment : attachments) { |
| if (!attachment.isSaved()) { |
| // this attachment is new so save it to DB. |
| attachment.mMessageKey = mDraft.mId; |
| attachment.save(MessageCompose.this); |
| } |
| } |
| |
| if (send) { |
| mController.sendMessage(mDraft.mId, mDraft.mAccountKey); |
| } |
| return null; |
| } |
| } |
| |
| @Override |
| protected void onPostExecute(Void dummy) { |
| synchronized (sSaveInProgressCondition) { |
| sSaveInProgress = false; |
| sSaveInProgressCondition.notify(); |
| } |
| if (isCancelled()) { |
| return; |
| } |
| // Don't display the toast if the user is just changing the orientation |
| if (!send && (getChangingConfigurations() & ActivityInfo.CONFIG_ORIENTATION) == 0) { |
| Toast.makeText(MessageCompose.this, R.string.message_saved_toast, |
| Toast.LENGTH_LONG).show(); |
| } |
| } |
| }.execute(); |
| } |
| |
| private void saveIfNeeded() { |
| if (!mDraftNeedsSaving) { |
| return; |
| } |
| setDraftNeedsSaving(false); |
| sendOrSaveMessage(false); |
| } |
| |
| /** |
| * Checks whether all the email addresses listed in TO, CC, BCC are valid. |
| */ |
| /* package */ boolean isAddressAllValid() { |
| for (TextView view : new TextView[]{mToView, mCcView, mBccView}) { |
| String addresses = view.getText().toString().trim(); |
| if (!Address.isAllValid(addresses)) { |
| view.setError(getString(R.string.message_compose_error_invalid_email)); |
| return false; |
| } |
| } |
| return true; |
| } |
| |
| private void onSend() { |
| if (!isAddressAllValid()) { |
| Toast.makeText(this, getString(R.string.message_compose_error_invalid_email), |
| Toast.LENGTH_LONG).show(); |
| } else 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(); |
| } else { |
| sendOrSaveMessage(true); |
| setDraftNeedsSaving(false); |
| finish(); |
| } |
| } |
| |
| private void onDiscard() { |
| if (mDraft.mId > 0) { |
| mController.deleteMessage(mDraft.mId, mDraft.mAccountKey); |
| } |
| Toast.makeText(this, getString(R.string.message_discarded_toast), Toast.LENGTH_LONG).show(); |
| setDraftNeedsSaving(false); |
| finish(); |
| } |
| |
| private void onSave() { |
| saveIfNeeded(); |
| finish(); |
| } |
| |
| private void onAddCcBcc() { |
| mCcView.setVisibility(View.VISIBLE); |
| mBccView.setVisibility(View.VISIBLE); |
| } |
| |
| /** |
| * Kick off a picker for whatever kind of MIME types we'll accept and let Android take over. |
| */ |
| private void onAddAttachment() { |
| Intent i = new Intent(Intent.ACTION_GET_CONTENT); |
| i.addCategory(Intent.CATEGORY_OPENABLE); |
| i.setType(Email.ACCEPTABLE_ATTACHMENT_SEND_UI_TYPES[0]); |
| startActivityForResult( |
| Intent.createChooser(i, getString(R.string.choose_attachment_dialog_title)), |
| ACTIVITY_REQUEST_PICK_ATTACHMENT); |
| } |
| |
| private Attachment loadAttachmentInfo(Uri uri) { |
| int size = -1; |
| String name = null; |
| ContentResolver contentResolver = getContentResolver(); |
| Cursor metadataCursor = contentResolver.query(uri, |
| ATTACHMENT_META_COLUMNS, 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 contentType = contentResolver.getType(uri); |
| if (contentType == null) { |
| contentType = ""; |
| } |
| |
| Attachment attachment = new Attachment(); |
| attachment.mFileName = name; |
| attachment.mContentUri = uri.toString(); |
| attachment.mSize = size; |
| attachment.mMimeType = contentType; |
| return attachment; |
| } |
| |
| private void addAttachment(Attachment attachment) { |
| // Before attaching the attachment, make sure it meets any other pre-attach criteria |
| if (attachment.mSize > Email.MAX_ATTACHMENT_UPLOAD_SIZE) { |
| Toast.makeText(this, R.string.message_compose_attachment_size, Toast.LENGTH_LONG) |
| .show(); |
| return; |
| } |
| |
| 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.mFileName); |
| delete.setOnClickListener(this); |
| delete.setTag(view); |
| view.setTag(attachment); |
| mAttachments.addView(view); |
| } |
| |
| private void addAttachment(Uri uri) { |
| addAttachment(loadAttachmentInfo(uri)); |
| } |
| |
| @Override |
| protected void onActivityResult(int requestCode, int resultCode, Intent data) { |
| if (data == null) { |
| return; |
| } |
| addAttachment(data.getData()); |
| setDraftNeedsSaving(true); |
| } |
| |
| public void onClick(View view) { |
| switch (view.getId()) { |
| case R.id.send: |
| onSend(); |
| break; |
| case R.id.save: |
| onSave(); |
| break; |
| case R.id.discard: |
| onDiscard(); |
| break; |
| case R.id.attachment_delete: |
| onDeleteAttachment(view); |
| break; |
| case R.id.quoted_text_delete: |
| mQuotedTextBar.setVisibility(View.GONE); |
| mQuotedText.setVisibility(View.GONE); |
| mDraft.mIntroText = null; |
| mDraft.mTextReply = null; |
| mDraft.mHtmlReply = null; |
| mDraft.mSourceKey = 0; |
| setDraftNeedsSaving(true); |
| break; |
| } |
| } |
| |
| private void onDeleteAttachment(View delButtonView) { |
| /* |
| * 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. |
| */ |
| View attachmentView = (View) delButtonView.getTag(); |
| Attachment attachment = (Attachment) attachmentView.getTag(); |
| mAttachments.removeView(attachmentView); |
| if (attachment.isSaved()) { |
| // The following async task for deleting attachments: |
| // - can be started multiple times in parallel (to delete multiple attachments). |
| // - need not be interrupted on activity exit, instead should run to completion. |
| new AsyncTask<Long, Void, Void>() { |
| @Override |
| protected Void doInBackground(Long... attachmentIds) { |
| mController.deleteAttachment(attachmentIds[0]); |
| return null; |
| } |
| }.execute(attachment.mId); |
| } |
| setDraftNeedsSaving(true); |
| } |
| |
| @Override |
| public boolean onOptionsItemSelected(MenuItem item) { |
| switch (item.getItemId()) { |
| case R.id.send: |
| onSend(); |
| break; |
| case R.id.save: |
| onSave(); |
| break; |
| case R.id.discard: |
| onDiscard(); |
| break; |
| case R.id.add_cc_bcc: |
| onAddCcBcc(); |
| break; |
| case R.id.add_attachment: |
| onAddAttachment(); |
| break; |
| default: |
| return super.onOptionsItemSelected(item); |
| } |
| return true; |
| } |
| |
| @Override |
| public boolean onCreateOptionsMenu(Menu menu) { |
| super.onCreateOptionsMenu(menu); |
| getMenuInflater().inflate(R.menu.message_compose_option, menu); |
| return true; |
| } |
| |
| /** |
| * Returns true if all attachments were able to be attached, otherwise returns false. |
| */ |
| // 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; |
| // } else { |
| // 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() { |
| // public void run() { |
| // addAttachment(uri); |
| // } |
| // }); |
| // } |
| // else { |
| // return false; |
| // } |
| // } |
| // return true; |
| // } |
| // } |
| |
| /** |
| * Set a message body and a signature when the Activity is launched. |
| * |
| * @param text the message body |
| */ |
| /* package */ void setInitialComposeText(CharSequence text, String signature) { |
| int textLength = 0; |
| if (text != null) { |
| mMessageContentView.append(text); |
| textLength = text.length(); |
| } |
| if (!TextUtils.isEmpty(signature)) { |
| if (textLength == 0 || text.charAt(textLength - 1) != '\n') { |
| mMessageContentView.append("\n"); |
| } |
| mMessageContentView.append(signature); |
| } |
| } |
| |
| /** |
| * Fill all the widgets with the content found in the Intent Extra, if any. |
| * |
| * Note that we don't actually check the intent action (typically VIEW, SENDTO, or SEND). |
| * There is enough overlap in the definitions that it makes more sense to simply check for |
| * all available data and use as much of it as possible. |
| * |
| * With one exception: EXTRA_STREAM is defined as only valid for ACTION_SEND. |
| * |
| * @param intent the launch intent |
| */ |
| /* package */ void initFromIntent(Intent intent) { |
| |
| // First, add values stored in top-level extras |
| |
| String[] extraStrings = intent.getStringArrayExtra(Intent.EXTRA_EMAIL); |
| if (extraStrings != null) { |
| addAddresses(mToView, extraStrings); |
| } |
| extraStrings = intent.getStringArrayExtra(Intent.EXTRA_CC); |
| if (extraStrings != null) { |
| addAddresses(mCcView, extraStrings); |
| } |
| extraStrings = intent.getStringArrayExtra(Intent.EXTRA_BCC); |
| if (extraStrings != null) { |
| addAddresses(mBccView, extraStrings); |
| } |
| String extraString = intent.getStringExtra(Intent.EXTRA_SUBJECT); |
| if (extraString != null) { |
| mSubjectView.setText(extraString); |
| } |
| |
| // Next, if we were invoked with a URI, try to interpret it |
| // We'll take two courses here. If it's mailto:, there is a specific set of rules |
| // that define various optional fields. However, for any other scheme, we'll simply |
| // take the entire scheme-specific part and interpret it as a possible list of addresses. |
| |
| final Uri dataUri = intent.getData(); |
| if (dataUri != null) { |
| if ("mailto".equals(dataUri.getScheme())) { |
| initializeFromMailTo(dataUri.toString()); |
| } else { |
| String toText = dataUri.getSchemeSpecificPart(); |
| if (toText != null) { |
| addAddresses(mToView, toText.split(",")); |
| } |
| } |
| } |
| |
| // Next, fill in the plaintext (note, this will override mailto:?body=) |
| |
| CharSequence text = intent.getCharSequenceExtra(Intent.EXTRA_TEXT); |
| if (text != null) { |
| setInitialComposeText(text, null); |
| } |
| |
| // Next, convert EXTRA_STREAM into an attachment |
| |
| if (Intent.ACTION_SEND.equals(mAction) && intent.hasExtra(Intent.EXTRA_STREAM)) { |
| String type = intent.getType(); |
| Uri stream = (Uri) intent.getParcelableExtra(Intent.EXTRA_STREAM); |
| if (stream != null && type != null) { |
| if (MimeUtility.mimeTypeMatches(type, |
| Email.ACCEPTABLE_ATTACHMENT_SEND_INTENT_TYPES)) { |
| addAttachment(stream); |
| } |
| } |
| } |
| |
| if (Intent.ACTION_SEND_MULTIPLE.equals(mAction) |
| && intent.hasExtra(Intent.EXTRA_STREAM)) { |
| ArrayList<Parcelable> list = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM); |
| if (list != null) { |
| for (Parcelable parcelable : list) { |
| Uri uri = (Uri) parcelable; |
| if (uri != null) { |
| Attachment attachment = loadAttachmentInfo(uri); |
| if (MimeUtility.mimeTypeMatches(attachment.mMimeType, |
| Email.ACCEPTABLE_ATTACHMENT_SEND_INTENT_TYPES)) { |
| addAttachment(attachment); |
| } |
| } |
| } |
| } |
| } |
| |
| // Finally - expose fields that were filled in but are normally hidden, and set focus |
| |
| if (mCcView.length() > 0) { |
| mCcView.setVisibility(View.VISIBLE); |
| } |
| if (mBccView.length() > 0) { |
| mBccView.setVisibility(View.VISIBLE); |
| } |
| setNewMessageFocus(); |
| setDraftNeedsSaving(false); |
| } |
| |
| /** |
| * 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. |
| * |
| * @mailToString the href (which must start with "mailto:"). |
| */ |
| private void initializeFromMailTo(String mailToString) { |
| |
| // Chop up everything between mailto: and ? to find recipients |
| int index = mailToString.indexOf("?"); |
| int length = "mailto".length() + 1; |
| String to; |
| try { |
| // Extract the recipient after mailto: |
| if (index == -1) { |
| to = decode(mailToString.substring(length)); |
| } else { |
| to = decode(mailToString.substring(length, index)); |
| } |
| addAddresses(mToView, to.split(" ,")); |
| } catch (UnsupportedEncodingException e) { |
| Log.e(Email.LOG_TAG, e.getMessage() + " while decoding '" + mailToString + "'"); |
| } |
| |
| // Extract the other parameters |
| |
| // We need to disguise this string as a URI in order to parse it |
| Uri uri = Uri.parse("foo://" + mailToString); |
| |
| List<String> cc = uri.getQueryParameters("cc"); |
| addAddresses(mCcView, cc.toArray(new String[cc.size()])); |
| |
| List<String> otherTo = uri.getQueryParameters("to"); |
| addAddresses(mCcView, otherTo.toArray(new String[otherTo.size()])); |
| |
| List<String> bcc = uri.getQueryParameters("bcc"); |
| addAddresses(mBccView, bcc.toArray(new String[bcc.size()])); |
| |
| List<String> subject = uri.getQueryParameters("subject"); |
| if (subject.size() > 0) { |
| mSubjectView.setText(subject.get(0)); |
| } |
| |
| List<String> body = uri.getQueryParameters("body"); |
| if (body.size() > 0) { |
| setInitialComposeText(body.get(0), (mAccount != null) ? mAccount.mSignature : null); |
| } |
| } |
| |
| private String decode(String s) throws UnsupportedEncodingException { |
| return URLDecoder.decode(s, "UTF-8"); |
| } |
| |
| // used by processSourceMessage() |
| private void displayQuotedText(String textBody, String htmlBody) { |
| /* Use plain-text body if available, otherwise use HTML body. |
| * This matches the desired behavior for IMAP/POP where we only send plain-text, |
| * and for EAS which sends HTML and has no plain-text body. |
| */ |
| boolean plainTextFlag = textBody != null; |
| String text = plainTextFlag ? textBody : htmlBody; |
| if (text != null) { |
| text = plainTextFlag ? EmailHtmlUtil.escapeCharacterToDisplay(text) : text; |
| // TODO: re-enable EmailHtmlUtil.resolveInlineImage() for HTML |
| // EmailHtmlUtil.resolveInlineImage(getContentResolver(), mAccount, |
| // text, message, 0); |
| mQuotedTextBar.setVisibility(View.VISIBLE); |
| if (mQuotedText != null) { |
| mQuotedText.setVisibility(View.VISIBLE); |
| mQuotedText.loadDataWithBaseURL("email://", text, "text/html", "utf-8", null); |
| } |
| } |
| } |
| |
| /** |
| * Given a packed address String, the address of our sending account, a view, and a list of |
| * addressees already added to other addressing views, adds unique addressees that don't |
| * match our address to the passed in view |
| */ |
| private boolean safeAddAddresses(String addrs, String ourAddress, |
| MultiAutoCompleteTextView view, ArrayList<Address> addrList) { |
| boolean added = false; |
| for (Address address : Address.unpack(addrs)) { |
| // Don't send to ourselves or already-included addresses |
| if (!address.getAddress().equalsIgnoreCase(ourAddress) && !addrList.contains(address)) { |
| addrList.add(address); |
| addAddress(view, address.toString()); |
| added = true; |
| } |
| } |
| return added; |
| } |
| |
| /** |
| * Set up the to and cc views properly for the "reply" and "replyAll" cases. What's important |
| * is that we not 1) send to ourselves, and 2) duplicate addressees. |
| * @param message the message we're replying to |
| * @param account the account we're sending from |
| * @param toView the "To" view |
| * @param ccView the "Cc" view |
| * @param replyAll whether this is a replyAll (vs a reply) |
| */ |
| /*package*/ void setupAddressViews(Message message, Account account, |
| MultiAutoCompleteTextView toView, MultiAutoCompleteTextView ccView, boolean replyAll) { |
| /* |
| * If a reply-to was included with the message use that, otherwise use the from |
| * or sender address. |
| */ |
| Address[] replyToAddresses = Address.unpack(message.mReplyTo); |
| if (replyToAddresses.length == 0) { |
| replyToAddresses = Address.unpack(message.mFrom); |
| } |
| addAddresses(mToView, replyToAddresses); |
| |
| if (replyAll) { |
| // Keep a running list of addresses we're sending to |
| ArrayList<Address> allAddresses = new ArrayList<Address>(); |
| String ourAddress = account.mEmailAddress; |
| |
| for (Address address: replyToAddresses) { |
| allAddresses.add(address); |
| } |
| |
| safeAddAddresses(message.mTo, ourAddress, mToView, allAddresses); |
| if (safeAddAddresses(message.mCc, ourAddress, mCcView, allAddresses)) { |
| mCcView.setVisibility(View.VISIBLE); |
| } |
| } |
| } |
| |
| void processSourceMessageGuarded(Message message, Account account) { |
| // Make sure we only do this once (otherwise we'll duplicate addresses!) |
| if (!mSourceMessageProcessed) { |
| processSourceMessage(message, account); |
| mSourceMessageProcessed = true; |
| } |
| |
| /* The quoted text is displayed in a WebView whose content is not automatically |
| * saved/restored by onRestoreInstanceState(), so we need to *always* restore it here, |
| * regardless of the value of mSourceMessageProcessed. |
| * This only concerns EDIT_DRAFT because after a configuration change we're always |
| * in EDIT_DRAFT. |
| */ |
| if (ACTION_EDIT_DRAFT.equals(mAction)) { |
| displayQuotedText(message.mTextReply, message.mHtmlReply); |
| } |
| } |
| |
| /** |
| * 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 |
| */ |
| /* package */ |
| void processSourceMessage(Message message, Account account) { |
| setDraftNeedsSaving(true); |
| final String subject = message.mSubject; |
| if (ACTION_REPLY.equals(mAction) || ACTION_REPLY_ALL.equals(mAction)) { |
| setupAddressViews(message, account, mToView, mCcView, |
| ACTION_REPLY_ALL.equals(mAction)); |
| if (subject != null && !subject.toLowerCase().startsWith("re:")) { |
| mSubjectView.setText("Re: " + subject); |
| } else { |
| mSubjectView.setText(subject); |
| } |
| displayQuotedText(message.mText, message.mHtml); |
| } else if (ACTION_FORWARD.equals(mAction)) { |
| mSubjectView.setText(subject != null && !subject.toLowerCase().startsWith("fwd:") ? |
| "Fwd: " + subject : subject); |
| displayQuotedText(message.mText, message.mHtml); |
| // TODO: re-enable loadAttachments below |
| // if (!loadAttachments(message, 0)) { |
| // mHandler.sendEmptyMessage(MSG_SKIPPED_ATTACHMENTS); |
| // } |
| } else if (ACTION_EDIT_DRAFT.equals(mAction)) { |
| mSubjectView.setText(subject); |
| addAddresses(mToView, Address.unpack(message.mTo)); |
| Address[] cc = Address.unpack(message.mCc); |
| if (cc.length > 0) { |
| addAddresses(mCcView, cc); |
| mCcView.setVisibility(View.VISIBLE); |
| } |
| Address[] bcc = Address.unpack(message.mBcc); |
| if (bcc.length > 0) { |
| addAddresses(mBccView, bcc); |
| mBccView.setVisibility(View.VISIBLE); |
| } |
| |
| mMessageContentView.setText(message.mText); |
| // TODO: re-enable loadAttachments |
| // loadAttachments(message, 0); |
| setDraftNeedsSaving(false); |
| } |
| setNewMessageFocus(); |
| } |
| |
| /** |
| * Set a cursor to the end of a body except a signature |
| */ |
| /* package */ void setMessageContentSelection(String signature) { |
| // when selecting the message content, explicitly move IP to the end of the message, |
| // so you can quickly resume typing into a draft |
| int selection = mMessageContentView.length(); |
| if (!TextUtils.isEmpty(signature)) { |
| int signatureLength = signature.length(); |
| int estimatedSelection = selection - signatureLength; |
| if (estimatedSelection >= 0) { |
| CharSequence text = mMessageContentView.getText(); |
| int i = 0; |
| while (i < signatureLength |
| && text.charAt(estimatedSelection + i) == signature.charAt(i)) { |
| ++i; |
| } |
| if (i == signatureLength) { |
| selection = estimatedSelection; |
| while (selection > 0 && text.charAt(selection - 1) == '\n') { |
| --selection; |
| } |
| } |
| } |
| } |
| mMessageContentView.setSelection(selection, selection); |
| } |
| |
| /** |
| * In order to accelerate typing, position the cursor in the first empty field, |
| * or at the end of the body composition field if none are empty. Typically, this will |
| * play out as follows: |
| * Reply / Reply All - put cursor in the empty message body |
| * Forward - put cursor in the empty To field |
| * Edit Draft - put cursor in whatever field still needs entry |
| */ |
| private void setNewMessageFocus() { |
| if (mToView.length() == 0) { |
| mToView.requestFocus(); |
| } else if (mSubjectView.length() == 0) { |
| mSubjectView.requestFocus(); |
| } else { |
| mMessageContentView.requestFocus(); |
| setMessageContentSelection((mAccount != null) ? mAccount.mSignature : null); |
| } |
| } |
| |
| private class Listener implements Controller.Result { |
| public void updateMailboxListCallback(MessagingException result, long accountId, |
| int progress) { |
| } |
| |
| public void updateMailboxCallback(MessagingException result, long accountId, |
| long mailboxId, int progress, int numNewMessages) { |
| if (result != null || progress == 100) { |
| Email.updateMailboxRefreshTime(mailboxId); |
| } |
| } |
| |
| public void loadMessageForViewCallback(MessagingException result, long messageId, |
| int progress) { |
| } |
| |
| public void loadAttachmentCallback(MessagingException result, long messageId, |
| long attachmentId, int progress) { |
| } |
| |
| public void serviceCheckMailCallback(MessagingException result, long accountId, |
| long mailboxId, int progress, long tag) { |
| } |
| |
| public void sendMailCallback(MessagingException result, long accountId, long messageId, |
| int progress) { |
| } |
| } |
| } |