MessageCompose.java revision 2f9cbdb37dc1d755318dbd5c2089741f6df94114
1/*
2 * Copyright (C) 2008 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17package com.android.email.activity;
18
19import com.android.email.Controller;
20import com.android.email.Email;
21import com.android.email.EmailAddressAdapter;
22import com.android.email.EmailAddressValidator;
23import com.android.email.R;
24import com.android.email.mail.internet.EmailHtmlUtil;
25import com.android.emailcommon.Logging;
26import com.android.emailcommon.internet.MimeUtility;
27import com.android.emailcommon.mail.Address;
28import com.android.emailcommon.provider.EmailContent;
29import com.android.emailcommon.provider.EmailContent.Account;
30import com.android.emailcommon.provider.EmailContent.Attachment;
31import com.android.emailcommon.provider.EmailContent.Body;
32import com.android.emailcommon.provider.EmailContent.BodyColumns;
33import com.android.emailcommon.provider.EmailContent.Message;
34import com.android.emailcommon.provider.EmailContent.MessageColumns;
35import com.android.emailcommon.utility.AttachmentUtilities;
36import com.android.emailcommon.utility.EmailAsyncTask;
37import com.android.emailcommon.utility.Utility;
38import com.google.common.annotations.VisibleForTesting;
39
40import android.app.ActionBar;
41import android.app.Activity;
42import android.app.ActivityManager;
43import android.content.ActivityNotFoundException;
44import android.content.ContentResolver;
45import android.content.ContentUris;
46import android.content.ContentValues;
47import android.content.Context;
48import android.content.Intent;
49import android.content.pm.ActivityInfo;
50import android.database.Cursor;
51import android.net.Uri;
52import android.os.Bundle;
53import android.os.Parcelable;
54import android.provider.OpenableColumns;
55import android.text.InputFilter;
56import android.text.SpannableStringBuilder;
57import android.text.Spanned;
58import android.text.TextUtils;
59import android.text.TextWatcher;
60import android.text.util.Rfc822Tokenizer;
61import android.util.Log;
62import android.view.Menu;
63import android.view.MenuItem;
64import android.view.View;
65import android.view.View.OnClickListener;
66import android.view.View.OnFocusChangeListener;
67import android.webkit.WebView;
68import android.widget.CheckBox;
69import android.widget.EditText;
70import android.widget.ImageButton;
71import android.widget.LinearLayout;
72import android.widget.MultiAutoCompleteTextView;
73import android.widget.TextView;
74import android.widget.Toast;
75
76import java.io.File;
77import java.io.UnsupportedEncodingException;
78import java.net.URLDecoder;
79import java.util.ArrayList;
80import java.util.List;
81import java.util.concurrent.ConcurrentHashMap;
82import java.util.concurrent.ExecutionException;
83
84
85/**
86 * Activity to compose a message.
87 *
88 * TODO Revive shortcuts command for removed menu options.
89 * C: add cc/bcc
90 * N: add attachment
91 */
92public class MessageCompose extends Activity implements OnClickListener, OnFocusChangeListener,
93        DeleteMessageConfirmationDialog.Callback {
94
95    private static final String ACTION_REPLY = "com.android.email.intent.action.REPLY";
96    private static final String ACTION_REPLY_ALL = "com.android.email.intent.action.REPLY_ALL";
97    private static final String ACTION_FORWARD = "com.android.email.intent.action.FORWARD";
98    private static final String ACTION_EDIT_DRAFT = "com.android.email.intent.action.EDIT_DRAFT";
99
100    private static final String EXTRA_ACCOUNT_ID = "account_id";
101    private static final String EXTRA_MESSAGE_ID = "message_id";
102    /** If the intent is sent from the email app itself, it should have this boolean extra. */
103    private static final String EXTRA_FROM_WITHIN_APP = "from_within_app";
104
105    private static final String STATE_KEY_CC_SHOWN =
106        "com.android.email.activity.MessageCompose.ccShown";
107    private static final String STATE_KEY_QUOTED_TEXT_SHOWN =
108        "com.android.email.activity.MessageCompose.quotedTextShown";
109    private static final String STATE_KEY_SOURCE_MESSAGE_PROCED =
110        "com.android.email.activity.MessageCompose.stateKeySourceMessageProced";
111    private static final String STATE_KEY_DRAFT_ID =
112        "com.android.email.activity.MessageCompose.draftId";
113    private static final String STATE_KEY_REQUEST_ID =
114        "com.android.email.activity.MessageCompose.requestId";
115
116    private static final int ACTIVITY_REQUEST_PICK_ATTACHMENT = 1;
117
118    private static final String[] ATTACHMENT_META_SIZE_PROJECTION = {
119        OpenableColumns.SIZE
120    };
121    private static final int ATTACHMENT_META_SIZE_COLUMN_SIZE = 0;
122
123    /**
124     * A registry of the active tasks used to save messages.
125     */
126    private static final ConcurrentHashMap<Long, SendOrSaveMessageTask> sActiveSaveTasks =
127            new ConcurrentHashMap<Long, SendOrSaveMessageTask>();
128
129    private static long sNextSaveTaskId = 1;
130
131    /**
132     * The ID of the latest save or send task requested by this Activity.
133     */
134    private long mLastSaveTaskId = -1;
135
136    private Account mAccount;
137
138    // mDraft has mId > 0 after the first draft save.
139    private Message mDraft = new Message();
140
141    // mSource is only set for REPLY, REPLY_ALL and FORWARD, and contains the source message.
142    private Message mSource;
143
144    // we use mAction instead of Intent.getAction() because sometimes we need to
145    // re-write the action to EDIT_DRAFT.
146    private String mAction;
147
148    /**
149     * Indicates that the source message has been processed at least once and should not
150     * be processed on any subsequent loads. This protects us from adding attachments that
151     * have already been added from the restore of the view state.
152     */
153    private boolean mSourceMessageProcessed = false;
154
155    private TextView mFromView;
156    private MultiAutoCompleteTextView mToView;
157    private MultiAutoCompleteTextView mCcView;
158    private MultiAutoCompleteTextView mBccView;
159    private View mCcBccContainer;
160    private EditText mSubjectView;
161    private EditText mMessageContentView;
162    private View mAttachmentContainer;
163    private LinearLayout mAttachments;
164    private View mQuotedTextBar;
165    private CheckBox mIncludeQuotedTextCheckBox;
166    private WebView mQuotedText;
167
168    private Controller mController;
169    private boolean mDraftNeedsSaving;
170    private boolean mMessageLoaded;
171    private final EmailAsyncTask.Tracker mTaskTracker = new EmailAsyncTask.Tracker();
172
173    private EmailAddressAdapter mAddressAdapterTo;
174    private EmailAddressAdapter mAddressAdapterCc;
175    private EmailAddressAdapter mAddressAdapterBcc;
176
177    private static Intent getBaseIntent(Context context) {
178        Intent i = new Intent(context, MessageCompose.class);
179        i.putExtra(EXTRA_FROM_WITHIN_APP, true);
180        return i;
181    }
182
183    /**
184     * Create an {@link Intent} that can start the message compose activity. If accountId -1,
185     * the default account will be used; otherwise, the specified account is used.
186     */
187    public static Intent getMessageComposeIntent(Context context, long accountId) {
188        Intent i = getBaseIntent(context);
189        i.putExtra(EXTRA_ACCOUNT_ID, accountId);
190        return i;
191    }
192
193    /**
194     * Compose a new message using the given account. If account is -1 the default account
195     * will be used.
196     * @param context
197     * @param accountId
198     */
199    public static void actionCompose(Context context, long accountId) {
200       try {
201           Intent i = getMessageComposeIntent(context, accountId);
202           context.startActivity(i);
203       } catch (ActivityNotFoundException anfe) {
204           // Swallow it - this is usually a race condition, especially under automated test.
205           // (The message composer might have been disabled)
206           Email.log(anfe.toString());
207       }
208    }
209
210    /**
211     * Compose a new message using a uri (mailto:) and a given account.  If account is -1 the
212     * default account will be used.
213     * @param context
214     * @param uriString
215     * @param accountId
216     * @return true if startActivity() succeeded
217     */
218    public static boolean actionCompose(Context context, String uriString, long accountId) {
219        try {
220            Intent i = getMessageComposeIntent(context, accountId);
221            i.setAction(Intent.ACTION_SEND);
222            i.setData(Uri.parse(uriString));
223            context.startActivity(i);
224            return true;
225        } catch (ActivityNotFoundException anfe) {
226            // Swallow it - this is usually a race condition, especially under automated test.
227            // (The message composer might have been disabled)
228            Email.log(anfe.toString());
229            return false;
230        }
231    }
232
233    /**
234     * Compose a new message as a reply to the given message. If replyAll is true the function
235     * is reply all instead of simply reply.
236     * @param context
237     * @param messageId
238     * @param replyAll
239     */
240    public static void actionReply(Context context, long messageId, boolean replyAll) {
241        startActivityWithMessage(context, replyAll ? ACTION_REPLY_ALL : ACTION_REPLY, messageId);
242    }
243
244    /**
245     * Compose a new message as a forward of the given message.
246     * @param context
247     * @param messageId
248     */
249    public static void actionForward(Context context, long messageId) {
250        startActivityWithMessage(context, ACTION_FORWARD, messageId);
251    }
252
253    /**
254     * Continue composition of the given message. This action modifies the way this Activity
255     * handles certain actions.
256     * Save will attempt to replace the message in the given folder with the updated version.
257     * Discard will delete the message from the given folder.
258     * @param context
259     * @param messageId the message id.
260     */
261    public static void actionEditDraft(Context context, long messageId) {
262        startActivityWithMessage(context, ACTION_EDIT_DRAFT, messageId);
263    }
264
265    private static void startActivityWithMessage(Context context, String action, long messageId) {
266        Intent i = getBaseIntent(context);
267        i.putExtra(EXTRA_MESSAGE_ID, messageId);
268        i.setAction(action);
269        context.startActivity(i);
270    }
271
272    private void setAccount(Intent intent) {
273        long accountId = intent.getLongExtra(EXTRA_ACCOUNT_ID, -1);
274        if (accountId == -1) {
275            accountId = Account.getDefaultAccountId(this);
276        }
277        if (accountId == -1) {
278            // There are no accounts set up. This should not have happened. Prompt the
279            // user to set up an account as an acceptable bailout.
280            AccountFolderList.actionShowAccounts(this);
281            finish();
282        } else {
283            setAccount(Account.restoreAccountWithId(this, accountId));
284        }
285    }
286
287    private void setAccount(Account account) {
288        if (account == null) {
289            throw new IllegalArgumentException();
290        }
291        mAccount = account;
292        mFromView.setText(account.mEmailAddress);
293        mAddressAdapterTo.setAccount(account);
294        mAddressAdapterCc.setAccount(account);
295        mAddressAdapterBcc.setAccount(account);
296    }
297
298    @Override
299    public void onCreate(Bundle savedInstanceState) {
300        super.onCreate(savedInstanceState);
301        ActivityHelper.debugSetWindowFlags(this);
302        setContentView(R.layout.message_compose);
303
304        mController = Controller.getInstance(getApplication());
305        initViews();
306
307        long draftId = Message.NOT_SAVED;
308        long existingSaveTaskId = -1;
309        if (savedInstanceState != null) {
310            // This data gets used in onCreate, so grab it here instead of onRestoreInstanceState
311            mSourceMessageProcessed =
312                savedInstanceState.getBoolean(STATE_KEY_SOURCE_MESSAGE_PROCED, false);
313            draftId = savedInstanceState.getLong(STATE_KEY_DRAFT_ID, Message.NOT_SAVED);
314            existingSaveTaskId = savedInstanceState.getLong(STATE_KEY_REQUEST_ID, -1);
315        }
316
317        // Show the back arrow on the action bar.
318        getActionBar().setDisplayOptions(
319                ActionBar.DISPLAY_HOME_AS_UP, ActionBar.DISPLAY_HOME_AS_UP);
320
321        Intent intent = getIntent();
322        mAction = intent.getAction();
323
324        if (draftId != Message.NOT_SAVED) {
325            // this means that we saved the draft earlier,
326            // so now we need to disregard the intent action and do
327            // EDIT_DRAFT instead.
328            mAction = ACTION_EDIT_DRAFT;
329            mDraft.mId = draftId;
330        }
331
332        // Handle the various intents that launch the message composer
333        if (Intent.ACTION_VIEW.equals(mAction)
334                || Intent.ACTION_SENDTO.equals(mAction)
335                || Intent.ACTION_SEND.equals(mAction)
336                || Intent.ACTION_SEND_MULTIPLE.equals(mAction)) {
337            // Use the fields found in the Intent to prefill as much of the message as possible
338            initFromIntent(intent);
339            setDraftNeedsSaving(true);
340            mMessageLoaded = true;
341            mSourceMessageProcessed = true;
342        } else {
343            // Otherwise, handle the internal cases (Message Composer invoked from within app)
344            long messageId = (draftId != Message.NOT_SAVED)
345                    ? draftId : intent.getLongExtra(EXTRA_MESSAGE_ID, -1);
346            SendOrSaveMessageTask saveTask = sActiveSaveTasks.get(existingSaveTaskId);
347            if ((messageId != Message.NOT_SAVED) || (saveTask != null)) {
348                new LoadMessageTask(messageId, saveTask).executeParallel();
349            } else {
350                setAccount(intent);
351                setInitialComposeText(null, getAccountSignature(mAccount));
352
353                // Since this is a new message, we don't need to call LoadMessageTask.
354                // But we DO need to set mMessageLoaded to indicate the message can be sent
355                mMessageLoaded = true;
356                mSourceMessageProcessed = true;
357            }
358        }
359
360        // Attach the text listeners late, since any population of data from Intent/saved instances
361        // are uninteresting and should be ignored.
362        initListeners();
363    }
364
365    @Override
366    protected void onRestoreInstanceState(Bundle savedInstanceState) {
367        super.onRestoreInstanceState(savedInstanceState);
368        if (savedInstanceState.getBoolean(STATE_KEY_CC_SHOWN)) {
369            showCcBccFields();
370        }
371        mQuotedTextBar.setVisibility(savedInstanceState.getBoolean(STATE_KEY_QUOTED_TEXT_SHOWN)
372                ? View.VISIBLE : View.GONE);
373        mQuotedText.setVisibility(savedInstanceState.getBoolean(STATE_KEY_QUOTED_TEXT_SHOWN)
374                ? View.VISIBLE : View.GONE);
375    }
376
377    // needed for unit tests
378    @Override
379    public void setIntent(Intent intent) {
380        super.setIntent(intent);
381        mAction = intent.getAction();
382    }
383
384    @Override
385    public void onResume() {
386        super.onResume();
387
388        // Exit immediately if the accounts list has changed (e.g. externally deleted)
389        if (Email.getNotifyUiAccountsChanged()) {
390            Welcome.actionStart(this);
391            finish();
392            return;
393        }
394    }
395
396    @Override
397    public void onPause() {
398        super.onPause();
399        saveIfNeeded();
400    }
401
402    /**
403     * We override onDestroy to make sure that the WebView gets explicitly destroyed.
404     * Otherwise it can leak native references.
405     */
406    @Override
407    public void onDestroy() {
408        super.onDestroy();
409        mQuotedText.destroy();
410        mQuotedText = null;
411
412        mTaskTracker.cancellAllInterrupt();
413
414        if (mAddressAdapterTo != null) {
415            mAddressAdapterTo.close();
416        }
417        if (mAddressAdapterCc != null) {
418            mAddressAdapterCc.close();
419        }
420        if (mAddressAdapterBcc != null) {
421            mAddressAdapterBcc.close();
422        }
423    }
424
425    /**
426     * The framework handles most of the fields, but we need to handle stuff that we
427     * dynamically show and hide:
428     * Cc field,
429     * Bcc field,
430     * Quoted text,
431     */
432    @Override
433    protected void onSaveInstanceState(Bundle outState) {
434        super.onSaveInstanceState(outState);
435
436        long draftId = mDraft.mId;
437        if (draftId != Message.NOT_SAVED) {
438            outState.putLong(STATE_KEY_DRAFT_ID, draftId);
439        }
440        outState.putBoolean(STATE_KEY_CC_SHOWN, mCcBccContainer.getVisibility() == View.VISIBLE);
441        outState.putBoolean(STATE_KEY_QUOTED_TEXT_SHOWN,
442                mQuotedTextBar.getVisibility() == View.VISIBLE);
443        outState.putBoolean(STATE_KEY_SOURCE_MESSAGE_PROCED, mSourceMessageProcessed);
444
445        // If there are any outstanding save requests, ensure that it's noted in case it hasn't
446        // finished by the time the activity is restored.
447        outState.putLong(STATE_KEY_REQUEST_ID, mLastSaveTaskId);
448    }
449
450    /**
451     * @return true if the activity was opened by the email app itself.
452     */
453    private boolean isOpenedFromWithinApp() {
454        Intent i = getIntent();
455        return (i != null && i.getBooleanExtra(EXTRA_FROM_WITHIN_APP, false));
456    }
457
458    private void setDraftNeedsSaving(boolean needsSaving) {
459        if (mDraftNeedsSaving != needsSaving) {
460            mDraftNeedsSaving = needsSaving;
461            invalidateOptionsMenu();
462        }
463    }
464
465    public void setFocusShifter(int fromViewId, final int targetViewId) {
466        View label = findViewById(fromViewId); // xlarge only
467        if (label != null) {
468            final View target = UiUtilities.getView(this, targetViewId);
469            label.setOnClickListener(new View.OnClickListener() {
470                @Override
471                public void onClick(View v) {
472                    target.requestFocus();
473                }
474            });
475        }
476    }
477
478    /**
479     * An {@link InputFilter} that implements special address cleanup rules.
480     * The first space key entry following an "@" symbol that is followed by any combination
481     * of letters and symbols, including one+ dots and zero commas, should insert an extra
482     * comma (followed by the space).
483     */
484    @VisibleForTesting
485    static final InputFilter RECIPIENT_FILTER = new InputFilter() {
486        @Override
487        public CharSequence filter(CharSequence source, int start, int end, Spanned dest,
488                int dstart, int dend) {
489
490            // Quick check - did they enter a single space?
491            if (end-start != 1 || source.charAt(start) != ' ') {
492                return null;
493            }
494
495            // determine if the characters before the new space fit the pattern
496            // follow backwards and see if we find a comma, dot, or @
497            int scanBack = dstart;
498            boolean dotFound = false;
499            while (scanBack > 0) {
500                char c = dest.charAt(--scanBack);
501                switch (c) {
502                    case '.':
503                        dotFound = true;    // one or more dots are req'd
504                        break;
505                    case ',':
506                        return null;
507                    case '@':
508                        if (!dotFound) {
509                            return null;
510                        }
511
512                        // we have found a comma-insert case.  now just do it
513                        // in the least expensive way we can.
514                        if (source instanceof Spanned) {
515                            SpannableStringBuilder sb = new SpannableStringBuilder(",");
516                            sb.append(source);
517                            return sb;
518                        } else {
519                            return ", ";
520                        }
521                    default:
522                        // just keep going
523                }
524            }
525
526            // no termination cases were found, so don't edit the input
527            return null;
528        }
529    };
530
531    private void initViews() {
532        mFromView = UiUtilities.getView(this, R.id.from);
533        mToView = UiUtilities.getView(this, R.id.to);
534        mCcView = UiUtilities.getView(this, R.id.cc);
535        mBccView = UiUtilities.getView(this, R.id.bcc);
536        mCcBccContainer = UiUtilities.getView(this, R.id.cc_bcc_container);
537        mSubjectView = UiUtilities.getView(this, R.id.subject);
538        mMessageContentView = UiUtilities.getView(this, R.id.message_content);
539        mAttachments = UiUtilities.getView(this, R.id.attachments);
540        mAttachmentContainer = UiUtilities.getView(this, R.id.attachment_container);
541        mQuotedTextBar = UiUtilities.getView(this, R.id.quoted_text_bar);
542        mIncludeQuotedTextCheckBox = UiUtilities.getView(this, R.id.include_quoted_text);
543        mQuotedText = UiUtilities.getView(this, R.id.quoted_text);
544
545        InputFilter[] recipientFilters = new InputFilter[] { RECIPIENT_FILTER };
546
547        // NOTE: assumes no other filters are set
548        mToView.setFilters(recipientFilters);
549        mCcView.setFilters(recipientFilters);
550        mBccView.setFilters(recipientFilters);
551
552        /*
553         * We set this to invisible by default. Other methods will turn it back on if it's
554         * needed.
555         */
556        mQuotedTextBar.setVisibility(View.GONE);
557        setIncludeQuotedText(false, false);
558
559        mIncludeQuotedTextCheckBox.setOnClickListener(this);
560
561        EmailAddressValidator addressValidator = new EmailAddressValidator();
562
563        setupAddressAdapters();
564        mToView.setAdapter(mAddressAdapterTo);
565        mToView.setTokenizer(new Rfc822Tokenizer());
566        mToView.setValidator(addressValidator);
567
568        mCcView.setAdapter(mAddressAdapterCc);
569        mCcView.setTokenizer(new Rfc822Tokenizer());
570        mCcView.setValidator(addressValidator);
571
572        mBccView.setAdapter(mAddressAdapterBcc);
573        mBccView.setTokenizer(new Rfc822Tokenizer());
574        mBccView.setValidator(addressValidator);
575
576        final View addCcBccView = UiUtilities.getView(this, R.id.add_cc_bcc);
577        addCcBccView.setOnClickListener(this);
578
579        final View addAttachmentView = UiUtilities.getView(this, R.id.add_attachment);
580        addAttachmentView.setOnClickListener(this);
581
582        setFocusShifter(R.id.to_label, R.id.to);
583        setFocusShifter(R.id.cc_label, R.id.cc);
584        setFocusShifter(R.id.bcc_label, R.id.bcc);
585        setFocusShifter(R.id.subject_label, R.id.subject);
586        setFocusShifter(R.id.tap_trap, R.id.message_content);
587
588        mMessageContentView.setOnFocusChangeListener(this);
589
590        updateAttachmentContainer();
591        mToView.requestFocus();
592    }
593
594    private void initListeners() {
595        final TextWatcher watcher = new TextWatcher() {
596            public void beforeTextChanged(CharSequence s, int start,
597                                          int before, int after) { }
598
599            public void onTextChanged(CharSequence s, int start,
600                                          int before, int count) {
601                setDraftNeedsSaving(true);
602            }
603
604            public void afterTextChanged(android.text.Editable s) { }
605        };
606
607        mToView.addTextChangedListener(watcher);
608        mCcView.addTextChangedListener(watcher);
609        mBccView.addTextChangedListener(watcher);
610        mSubjectView.addTextChangedListener(watcher);
611        mMessageContentView.addTextChangedListener(watcher);
612    }
613
614    /**
615     * Set up address auto-completion adapters.
616     */
617    private void setupAddressAdapters() {
618        mAddressAdapterTo = new EmailAddressAdapter(this);
619        mAddressAdapterCc = new EmailAddressAdapter(this);
620        mAddressAdapterBcc = new EmailAddressAdapter(this);
621    }
622
623    /**
624     * Asynchronously loads a message and the account information.
625     * This can be used to load a reference message (when replying) or when restoring a draft.
626     */
627    private class LoadMessageTask extends EmailAsyncTask<Void, Void, Object[]> {
628        /**
629         * The message ID to load, if available.
630         */
631        private long mMessageId;
632
633        /**
634         * A future-like reference to the save task which must complete prior to this load.
635         */
636        private final SendOrSaveMessageTask mSaveTask;
637
638        public LoadMessageTask(long messageId, SendOrSaveMessageTask saveTask) {
639            super(mTaskTracker);
640            mMessageId = messageId;
641            mSaveTask = saveTask;
642        }
643
644        private long getIdToLoad() throws InterruptedException, ExecutionException {
645            if (mMessageId == -1) {
646                mMessageId = mSaveTask.get();
647            }
648            return mMessageId;
649        }
650
651        @Override
652        protected Object[] doInBackground(Void... params) {
653            long messageId;
654            try {
655                messageId = getIdToLoad();
656            } catch (InterruptedException e) {
657                // Don't have a good message ID to load - bail.
658                Log.e(Logging.LOG_TAG,
659                        "Unable to load draft message since existing save task failed: " + e);
660                return null;
661            } catch (ExecutionException e) {
662                // Don't have a good message ID to load - bail.
663                Log.e(Logging.LOG_TAG,
664                        "Unable to load draft message since existing save task failed: " + e);
665                return new Object[] {null, null};
666            }
667            Message message = Message.restoreMessageWithId(MessageCompose.this, messageId);
668            if (message == null) {
669                return new Object[] {null, null};
670            }
671            long accountId = message.mAccountKey;
672            Account account = Account.restoreAccountWithId(MessageCompose.this, accountId);
673            try {
674                // Body body = Body.restoreBodyWithMessageId(MessageCompose.this, message.mId);
675                message.mHtml = Body.restoreBodyHtmlWithMessageId(MessageCompose.this, message.mId);
676                message.mText = Body.restoreBodyTextWithMessageId(MessageCompose.this, message.mId);
677                boolean isEditDraft = ACTION_EDIT_DRAFT.equals(mAction);
678                // the reply fields are only filled/used for Drafts.
679                if (isEditDraft) {
680                    message.mHtmlReply =
681                        Body.restoreReplyHtmlWithMessageId(MessageCompose.this, message.mId);
682                    message.mTextReply =
683                        Body.restoreReplyTextWithMessageId(MessageCompose.this, message.mId);
684                    message.mIntroText =
685                        Body.restoreIntroTextWithMessageId(MessageCompose.this, message.mId);
686                    message.mSourceKey = Body.restoreBodySourceKey(MessageCompose.this,
687                                                                   message.mId);
688                } else {
689                    message.mHtmlReply = null;
690                    message.mTextReply = null;
691                    message.mIntroText = null;
692                }
693            } catch (RuntimeException e) {
694                Log.d(Logging.LOG_TAG, "Exception while loading message body: " + e);
695                return new Object[] {null, null};
696            }
697            return new Object[] {message, account};
698        }
699
700        @Override
701        protected void onPostExecute(Object[] messageAndAccount) {
702            if (messageAndAccount == null) {
703                return;
704            }
705
706            final Message message = (Message) messageAndAccount[0];
707            final Account account = (Account) messageAndAccount[1];
708            if (message == null && account == null) {
709                // Something unexpected happened:
710                // the message or the body couldn't be loaded by SQLite.
711                // Bail out.
712                Utility.showToast(MessageCompose.this, R.string.error_loading_message_body);
713                finish();
714                return;
715            }
716
717            // Drafts and "forwards" need to include attachments from the original unless the
718            // account is marked as supporting smart forward
719            final boolean isEditDraft = ACTION_EDIT_DRAFT.equals(mAction);
720            final boolean isForward = ACTION_FORWARD.equals(mAction);
721            if (isEditDraft || isForward) {
722                if (isEditDraft) {
723                    mDraft = message;
724                } else {
725                    mSource = message;
726                }
727                new EmailAsyncTask<Long, Void, Attachment[]>(mTaskTracker) {
728                    @Override
729                    protected Attachment[] doInBackground(Long... messageIds) {
730                        return Attachment.restoreAttachmentsWithMessageId(MessageCompose.this,
731                                messageIds[0]);
732                    }
733                    @Override
734                    protected void onPostExecute(Attachment[] attachments) {
735                        if (attachments == null) {
736                            return;
737                        }
738                        final boolean supportsSmartForward =
739                            (account.mFlags & Account.FLAGS_SUPPORTS_SMART_FORWARD) != 0;
740
741                        for (Attachment attachment : attachments) {
742                            if (supportsSmartForward && isForward) {
743                                attachment.mFlags |= Attachment.FLAG_SMART_FORWARD;
744                            }
745                            // Note allowDelete is set in two cases:
746                            // 1. First time a message (w/ attachments) is forwarded,
747                            //    where action == ACTION_FORWARD
748                            // 2. 1 -> Save -> Reopen, where action == EDIT_DRAFT,
749                            //    but FLAG_SMART_FORWARD is already set at 1.
750                            // Even if the account supports smart-forward, attachments added
751                            // manually are still removable.
752                            final boolean allowDelete =
753                                    (attachment.mFlags & Attachment.FLAG_SMART_FORWARD) == 0;
754                            addAttachment(attachment, allowDelete);
755                        }
756                    }
757                }.executeParallel(message.mId);
758            } else if (ACTION_REPLY.equals(mAction) || ACTION_REPLY_ALL.equals(mAction)) {
759                mSource = message;
760            } else if (Email.LOGD) {
761                Email.log("Action " + mAction + " has unexpected EXTRA_MESSAGE_ID");
762            }
763
764            setAccount(account);
765            processSourceMessageGuarded(message, mAccount);
766            mMessageLoaded = true;
767        }
768    }
769
770    @Override
771    public void onFocusChange(View view, boolean focused) {
772        if (focused) {
773            switch (view.getId()) {
774                case R.id.message_content:
775                    // When focusing on the message content via tabbing to it, or other means of
776                    // auto focusing, move the cursor to the end of the body (before the signature).
777                    if (mMessageContentView.getSelectionStart() == 0
778                            && mMessageContentView.getSelectionEnd() == 0) {
779                        // There is no way to determine if the focus change was programmatic or due
780                        // to keyboard event, or if it was due to a tap/restore. Use a best-guess
781                        // by using the fact that auto-focus/keyboard tabs set the selection to 0.
782                        setMessageContentSelection(getAccountSignature(mAccount));
783                    }
784            }
785        }
786    }
787
788    private void addAddresses(MultiAutoCompleteTextView view, Address[] addresses) {
789        if (addresses == null) {
790            return;
791        }
792        for (Address address : addresses) {
793            addAddress(view, address.toString());
794        }
795    }
796
797    private void addAddresses(MultiAutoCompleteTextView view, String[] addresses) {
798        if (addresses == null) {
799            return;
800        }
801        for (String oneAddress : addresses) {
802            addAddress(view, oneAddress);
803        }
804    }
805
806    private void addAddress(MultiAutoCompleteTextView view, String address) {
807        view.append(address + ", ");
808    }
809
810    private String getPackedAddresses(TextView view) {
811        Address[] addresses = Address.parse(view.getText().toString().trim());
812        return Address.pack(addresses);
813    }
814
815    private Address[] getAddresses(TextView view) {
816        Address[] addresses = Address.parse(view.getText().toString().trim());
817        return addresses;
818    }
819
820    /*
821     * Computes a short string indicating the destination of the message based on To, Cc, Bcc.
822     * If only one address appears, returns the friendly form of that address.
823     * Otherwise returns the friendly form of the first address appended with "and N others".
824     */
825    private String makeDisplayName(String packedTo, String packedCc, String packedBcc) {
826        Address first = null;
827        int nRecipients = 0;
828        for (String packed: new String[] {packedTo, packedCc, packedBcc}) {
829            Address[] addresses = Address.unpack(packed);
830            nRecipients += addresses.length;
831            if (first == null && addresses.length > 0) {
832                first = addresses[0];
833            }
834        }
835        if (nRecipients == 0) {
836            return "";
837        }
838        String friendly = first.toFriendly();
839        if (nRecipients == 1) {
840            return friendly;
841        }
842        return this.getString(R.string.message_compose_display_name, friendly, nRecipients - 1);
843    }
844
845    private ContentValues getUpdateContentValues(Message message) {
846        ContentValues values = new ContentValues();
847        values.put(MessageColumns.TIMESTAMP, message.mTimeStamp);
848        values.put(MessageColumns.FROM_LIST, message.mFrom);
849        values.put(MessageColumns.TO_LIST, message.mTo);
850        values.put(MessageColumns.CC_LIST, message.mCc);
851        values.put(MessageColumns.BCC_LIST, message.mBcc);
852        values.put(MessageColumns.SUBJECT, message.mSubject);
853        values.put(MessageColumns.DISPLAY_NAME, message.mDisplayName);
854        values.put(MessageColumns.FLAG_READ, message.mFlagRead);
855        values.put(MessageColumns.FLAG_LOADED, message.mFlagLoaded);
856        values.put(MessageColumns.FLAG_ATTACHMENT, message.mFlagAttachment);
857        values.put(MessageColumns.FLAGS, message.mFlags);
858        return values;
859    }
860
861    /**
862     * Updates the given message using values from the compose UI.
863     *
864     * @param message The message to be updated.
865     * @param account the account (used to obtain From: address).
866     * @param hasAttachments true if it has one or more attachment.
867     * @param sending set true if the message is about to sent, in which case we perform final
868     *        clean up;
869     */
870    private void updateMessage(Message message, Account account, boolean hasAttachments,
871            boolean sending) {
872        if (message.mMessageId == null || message.mMessageId.length() == 0) {
873            message.mMessageId = Utility.generateMessageId();
874        }
875        message.mTimeStamp = System.currentTimeMillis();
876        message.mFrom = new Address(account.getEmailAddress(), account.getSenderName()).pack();
877        message.mTo = getPackedAddresses(mToView);
878        message.mCc = getPackedAddresses(mCcView);
879        message.mBcc = getPackedAddresses(mBccView);
880        message.mSubject = mSubjectView.getText().toString();
881        message.mText = mMessageContentView.getText().toString();
882        message.mAccountKey = account.mId;
883        message.mDisplayName = makeDisplayName(message.mTo, message.mCc, message.mBcc);
884        message.mFlagRead = true;
885        message.mFlagLoaded = Message.FLAG_LOADED_COMPLETE;
886        message.mFlagAttachment = hasAttachments;
887        // Use the Intent to set flags saying this message is a reply or a forward and save the
888        // unique id of the source message
889        if (mSource != null && mQuotedTextBar.getVisibility() == View.VISIBLE) {
890            // If the quote bar is visible; this must either be a reply or forward
891            message.mSourceKey = mSource.mId;
892            // Get the body of the source message here
893            message.mHtmlReply = mSource.mHtml;
894            message.mTextReply = mSource.mText;
895            String fromAsString = Address.unpackToString(mSource.mFrom);
896            if (ACTION_FORWARD.equals(mAction)) {
897                message.mFlags |= Message.FLAG_TYPE_FORWARD;
898                String subject = mSource.mSubject;
899                String to = Address.unpackToString(mSource.mTo);
900                String cc = Address.unpackToString(mSource.mCc);
901                message.mIntroText =
902                    getString(R.string.message_compose_fwd_header_fmt, subject, fromAsString,
903                            to != null ? to : "", cc != null ? cc : "");
904            } else {
905                message.mFlags |= Message.FLAG_TYPE_REPLY;
906                message.mIntroText =
907                    getString(R.string.message_compose_reply_header_fmt, fromAsString);
908            }
909        }
910
911        if (includeQuotedText()) {
912            message.mFlags &= ~Message.FLAG_NOT_INCLUDE_QUOTED_TEXT;
913        } else {
914            message.mFlags |= Message.FLAG_NOT_INCLUDE_QUOTED_TEXT;
915            if (sending) {
916                // If we are about to send a message, and not including the original message,
917                // clear the related field.
918                // We can't do this until the last minutes, so that the user can change their
919                // mind later and want to include it again.
920                mDraft.mIntroText = null;
921                mDraft.mTextReply = null;
922                mDraft.mHtmlReply = null;
923                mDraft.mSourceKey = 0;
924                mDraft.mFlags &= ~Message.FLAG_TYPE_MASK;
925            }
926        }
927    }
928
929    private Attachment[] getAttachmentsFromUI() {
930        int count = mAttachments.getChildCount();
931        Attachment[] attachments = new Attachment[count];
932        for (int i = 0; i < count; ++i) {
933            attachments[i] = (Attachment) mAttachments.getChildAt(i).getTag();
934        }
935        return attachments;
936    }
937
938    private class SendOrSaveMessageTask extends EmailAsyncTask<Void, Void, Long> {
939        private final boolean mSend;
940        private final long mTaskId;
941
942        /** A context that will survive even past activity destruction. */
943        private final Context mContext;
944
945        public SendOrSaveMessageTask(long taskId, boolean send) {
946            super(null /* DO NOT cancel in onDestroy */);
947            if (send && ActivityManager.isUserAMonkey()) {
948                Log.d(Logging.LOG_TAG, "Inhibiting send while monkey is in charge.");
949                send = false;
950            }
951            mTaskId = taskId;
952            mSend = send;
953            mContext = getApplicationContext();
954
955            sActiveSaveTasks.put(mTaskId, this);
956        }
957
958        @Override
959        protected Long doInBackground(Void... params) {
960            synchronized (mDraft) {
961                final Attachment[] attachments = getAttachmentsFromUI();
962                updateMessage(mDraft, mAccount, attachments.length > 0, mSend);
963                ContentResolver resolver = getContentResolver();
964                if (mDraft.isSaved()) {
965                    // Update the message
966                    Uri draftUri =
967                        ContentUris.withAppendedId(Message.SYNCED_CONTENT_URI, mDraft.mId);
968                    resolver.update(draftUri, getUpdateContentValues(mDraft), null, null);
969                    // Update the body
970                    ContentValues values = new ContentValues();
971                    values.put(BodyColumns.TEXT_CONTENT, mDraft.mText);
972                    values.put(BodyColumns.TEXT_REPLY, mDraft.mTextReply);
973                    values.put(BodyColumns.HTML_REPLY, mDraft.mHtmlReply);
974                    values.put(BodyColumns.INTRO_TEXT, mDraft.mIntroText);
975                    values.put(BodyColumns.SOURCE_MESSAGE_KEY, mDraft.mSourceKey);
976                    Body.updateBodyWithMessageId(MessageCompose.this, mDraft.mId, values);
977                } else {
978                    // mDraft.mId is set upon return of saveToMailbox()
979                    mController.saveToMailbox(mDraft, EmailContent.Mailbox.TYPE_DRAFTS);
980                }
981                // For any unloaded attachment, set the flag saying we need it loaded
982                boolean hasUnloadedAttachments = false;
983                for (Attachment attachment : attachments) {
984                    if (attachment.mContentUri == null &&
985                            ((attachment.mFlags & Attachment.FLAG_SMART_FORWARD) == 0)) {
986                        attachment.mFlags |= Attachment.FLAG_DOWNLOAD_FORWARD;
987                        hasUnloadedAttachments = true;
988                        if (Email.DEBUG) {
989                            Log.d(Logging.LOG_TAG,
990                                    "Requesting download of attachment #" + attachment.mId);
991                        }
992                    }
993                    // Make sure the UI version of the attachment has the now-correct id; we will
994                    // use the id again when coming back from picking new attachments
995                    if (!attachment.isSaved()) {
996                        // this attachment is new so save it to DB.
997                        attachment.mMessageKey = mDraft.mId;
998                        attachment.save(MessageCompose.this);
999                    } else if (attachment.mMessageKey != mDraft.mId) {
1000                        // We clone the attachment and save it again; otherwise, it will
1001                        // continue to point to the source message.  From this point forward,
1002                        // the attachments will be independent of the original message in the
1003                        // database; however, we still need the message on the server in order
1004                        // to retrieve unloaded attachments
1005                        attachment.mMessageKey = mDraft.mId;
1006                        ContentValues cv = attachment.toContentValues();
1007                        cv.put(Attachment.FLAGS, attachment.mFlags);
1008                        cv.put(Attachment.MESSAGE_KEY, mDraft.mId);
1009                        getContentResolver().insert(Attachment.CONTENT_URI, cv);
1010                    }
1011                }
1012
1013                if (mSend) {
1014                    // Let the user know if message sending might be delayed by background
1015                    // downlading of unloaded attachments
1016                    if (hasUnloadedAttachments) {
1017                        Utility.showToast(MessageCompose.this,
1018                                R.string.message_view_attachment_background_load);
1019                    }
1020                    mController.sendMessage(mDraft.mId, mDraft.mAccountKey);
1021                }
1022                return mDraft.mId;
1023            }
1024        }
1025
1026        @Override
1027        protected void onPostExecute(Long draftId) {
1028            // Note that send or save tasks are always completed, even if the activity
1029            // finishes earlier.
1030            sActiveSaveTasks.remove(mTaskId);
1031            // Don't display the toast if the user is just changing the orientation
1032            if (!mSend && (getChangingConfigurations() & ActivityInfo.CONFIG_ORIENTATION) == 0) {
1033                Toast.makeText(mContext, R.string.message_saved_toast, Toast.LENGTH_LONG).show();
1034            }
1035        }
1036    }
1037
1038    /**
1039     * Send or save a message:
1040     * - out of the UI thread
1041     * - write to Drafts
1042     * - if send, invoke Controller.sendMessage()
1043     * - when operation is complete, display toast
1044     */
1045    private void sendOrSaveMessage(boolean send) {
1046        if (!mMessageLoaded) {
1047            Log.w(Logging.LOG_TAG,
1048                    "Attempted to save draft message prior to the state being fully loaded");
1049            return;
1050        }
1051        synchronized (sActiveSaveTasks) {
1052            mLastSaveTaskId = sNextSaveTaskId++;
1053
1054            SendOrSaveMessageTask task = new SendOrSaveMessageTask(mLastSaveTaskId, send);
1055
1056            // Ensure the tasks are executed serially so that rapid scheduling doesn't result
1057            // in inconsistent data.
1058            task.executeSerial();
1059        }
1060   }
1061
1062    private void saveIfNeeded() {
1063        if (!mDraftNeedsSaving) {
1064            return;
1065        }
1066        setDraftNeedsSaving(false);
1067        sendOrSaveMessage(false);
1068    }
1069
1070    /**
1071     * Checks whether all the email addresses listed in TO, CC, BCC are valid.
1072     */
1073    @VisibleForTesting
1074    boolean isAddressAllValid() {
1075        for (TextView view : new TextView[]{mToView, mCcView, mBccView}) {
1076            String addresses = view.getText().toString().trim();
1077            if (!Address.isAllValid(addresses)) {
1078                view.setError(getString(R.string.message_compose_error_invalid_email));
1079                return false;
1080            }
1081        }
1082        return true;
1083    }
1084
1085    private void onSend() {
1086        if (!isAddressAllValid()) {
1087            Toast.makeText(this, getString(R.string.message_compose_error_invalid_email),
1088                           Toast.LENGTH_LONG).show();
1089        } else if (getAddresses(mToView).length == 0 &&
1090                getAddresses(mCcView).length == 0 &&
1091                getAddresses(mBccView).length == 0) {
1092            mToView.setError(getString(R.string.message_compose_error_no_recipients));
1093            Toast.makeText(this, getString(R.string.message_compose_error_no_recipients),
1094                    Toast.LENGTH_LONG).show();
1095        } else {
1096            sendOrSaveMessage(true);
1097            setDraftNeedsSaving(false);
1098            finish();
1099        }
1100    }
1101
1102    private void onDiscard() {
1103        DeleteMessageConfirmationDialog.newInstance(1, null).show(getFragmentManager(), "dialog");
1104    }
1105
1106    /**
1107     * Called when ok on the "discard draft" dialog is pressed.  Actually delete the draft.
1108     */
1109    @Override
1110    public void onDeleteMessageConfirmationDialogOkPressed() {
1111        if (mDraft.mId > 0) {
1112            // By the way, we can't pass the message ID from onDiscard() to here (using a
1113            // dialog argument or whatever), because you can rotate the screen when the dialog is
1114            // shown, and during rotation we save & restore the draft.  If it's the
1115            // first save, we give it an ID at this point for the first time (and last time).
1116            // Which means it's possible for a draft to not have an ID in onDiscard(),
1117            // but here.
1118            mController.deleteMessage(mDraft.mId, mDraft.mAccountKey);
1119        }
1120        Utility.showToast(MessageCompose.this, R.string.message_discarded_toast);
1121        setDraftNeedsSaving(false);
1122        finish();
1123    }
1124
1125    /**
1126     * Handles an explicit user-initiated action to save a draft.
1127     */
1128    private void onSave() {
1129        saveIfNeeded();
1130    }
1131
1132    private void showCcBccFieldsIfFilled() {
1133        if ((mCcView.length() > 0) || (mBccView.length() > 0)) {
1134            showCcBccFields();
1135        }
1136    }
1137
1138    private void showCcBccFields() {
1139        mCcBccContainer.setVisibility(View.VISIBLE);
1140        UiUtilities.setVisibilitySafe(this, R.id.add_cc_bcc, View.INVISIBLE);
1141    }
1142
1143    /**
1144     * Kick off a picker for whatever kind of MIME types we'll accept and let Android take over.
1145     */
1146    private void onAddAttachment() {
1147        Intent i = new Intent(Intent.ACTION_GET_CONTENT);
1148        i.addCategory(Intent.CATEGORY_OPENABLE);
1149        i.setType(AttachmentUtilities.ACCEPTABLE_ATTACHMENT_SEND_UI_TYPES[0]);
1150        startActivityForResult(
1151                Intent.createChooser(i, getString(R.string.choose_attachment_dialog_title)),
1152                ACTIVITY_REQUEST_PICK_ATTACHMENT);
1153    }
1154
1155    private Attachment loadAttachmentInfo(Uri uri) {
1156        long size = -1;
1157        ContentResolver contentResolver = getContentResolver();
1158
1159        // Load name & size independently, because not all providers support both
1160        final String name = Utility.getContentFileName(this, uri);
1161
1162        Cursor metadataCursor = contentResolver.query(uri, ATTACHMENT_META_SIZE_PROJECTION,
1163                null, null, null);
1164        if (metadataCursor != null) {
1165            try {
1166                if (metadataCursor.moveToFirst()) {
1167                    size = metadataCursor.getLong(ATTACHMENT_META_SIZE_COLUMN_SIZE);
1168                }
1169            } finally {
1170                metadataCursor.close();
1171            }
1172        }
1173
1174        // When the size is not provided, we need to determine it locally.
1175        if (size < 0) {
1176            // if the URI is a file: URI, ask file system for its size
1177            if ("file".equalsIgnoreCase(uri.getScheme())) {
1178                String path = uri.getPath();
1179                if (path != null) {
1180                    File file = new File(path);
1181                    size = file.length();  // Returns 0 for file not found
1182                }
1183            }
1184
1185            if (size <= 0) {
1186                // The size was not measurable;  This attachment is not safe to use.
1187                // Quick hack to force a relevant error into the UI
1188                // TODO: A proper announcement of the problem
1189                size = AttachmentUtilities.MAX_ATTACHMENT_UPLOAD_SIZE + 1;
1190            }
1191        }
1192
1193        Attachment attachment = new Attachment();
1194        attachment.mFileName = name;
1195        attachment.mContentUri = uri.toString();
1196        attachment.mSize = size;
1197        attachment.mMimeType = AttachmentUtilities.inferMimeTypeForUri(this, uri);
1198        return attachment;
1199    }
1200
1201    private void addAttachment(Attachment attachment, boolean allowDelete) {
1202        // Before attaching the attachment, make sure it meets any other pre-attach criteria
1203        if (attachment.mSize > AttachmentUtilities.MAX_ATTACHMENT_UPLOAD_SIZE) {
1204            Toast.makeText(this, R.string.message_compose_attachment_size, Toast.LENGTH_LONG)
1205                    .show();
1206            return;
1207        }
1208
1209        View view = getLayoutInflater().inflate(R.layout.message_compose_attachment,
1210                mAttachments, false);
1211        TextView nameView = (TextView) UiUtilities.getView(view, R.id.attachment_name);
1212        ImageButton delete = (ImageButton) UiUtilities.getView(view, R.id.attachment_delete);
1213        TextView sizeView = (TextView) UiUtilities.getView(view, R.id.attachment_size);
1214
1215        nameView.setText(attachment.mFileName);
1216        sizeView.setText(UiUtilities.formatSize(this, attachment.mSize));
1217        if (allowDelete) {
1218            delete.setOnClickListener(this);
1219            delete.setTag(view);
1220        } else {
1221            delete.setVisibility(View.INVISIBLE);
1222        }
1223        view.setTag(attachment);
1224        mAttachments.addView(view);
1225        updateAttachmentContainer();
1226    }
1227
1228    private void updateAttachmentContainer() {
1229        mAttachmentContainer.setVisibility(mAttachments.getChildCount() == 0
1230                ? View.GONE : View.VISIBLE);
1231    }
1232
1233    private void addAttachmentFromUri(Uri uri) {
1234        addAttachment(loadAttachmentInfo(uri), true);
1235    }
1236
1237    /**
1238     * Same as {@link #addAttachmentFromUri}, but does the mime-type check against
1239     * {@link AttachmentUtilities#ACCEPTABLE_ATTACHMENT_SEND_INTENT_TYPES}.
1240     */
1241    private void addAttachmentFromSendIntent(Uri uri) {
1242        final Attachment attachment = loadAttachmentInfo(uri);
1243        final String mimeType = attachment.mMimeType;
1244        if (!TextUtils.isEmpty(mimeType) && MimeUtility.mimeTypeMatches(mimeType,
1245                AttachmentUtilities.ACCEPTABLE_ATTACHMENT_SEND_INTENT_TYPES)) {
1246            addAttachment(attachment, true);
1247        }
1248    }
1249
1250    @Override
1251    protected void onActivityResult(int requestCode, int resultCode, Intent data) {
1252        if (data == null) {
1253            return;
1254        }
1255        addAttachmentFromUri(data.getData());
1256        setDraftNeedsSaving(true);
1257    }
1258
1259    private boolean includeQuotedText() {
1260        return mIncludeQuotedTextCheckBox.isChecked();
1261    }
1262
1263    public void onClick(View view) {
1264        if (handleCommand(view.getId())) {
1265            return;
1266        }
1267        switch (view.getId()) {
1268            case R.id.attachment_delete:
1269                onDeleteAttachment(view); // Needs a view; can't be a menu item
1270                break;
1271        }
1272    }
1273
1274    private void setIncludeQuotedText(boolean include, boolean updateNeedsSaving) {
1275        mIncludeQuotedTextCheckBox.setChecked(include);
1276        mQuotedText.setVisibility(mIncludeQuotedTextCheckBox.isChecked()
1277                ? View.VISIBLE : View.GONE);
1278        if (updateNeedsSaving) {
1279            setDraftNeedsSaving(true);
1280        }
1281    }
1282
1283    private void onDeleteAttachment(View delButtonView) {
1284        /*
1285         * The view is the delete button, and we have previously set the tag of
1286         * the delete button to the view that owns it. We don't use parent because the
1287         * view is very complex and could change in the future.
1288         */
1289        View attachmentView = (View) delButtonView.getTag();
1290        Attachment attachment = (Attachment) attachmentView.getTag();
1291        mAttachments.removeView(attachmentView);
1292        updateAttachmentContainer();
1293        if (attachment.mMessageKey == mDraft.mId && attachment.isSaved()) {
1294            final long attachmentId = attachment.mId;
1295            EmailAsyncTask.runAsyncParallel(new Runnable() {
1296                @Override
1297                public void run() {
1298                    mController.deleteAttachment(attachmentId);
1299                }
1300            });
1301        }
1302        setDraftNeedsSaving(true);
1303    }
1304
1305    @Override
1306    public boolean onOptionsItemSelected(MenuItem item) {
1307        if (handleCommand(item.getItemId())) {
1308            return true;
1309        }
1310        return super.onOptionsItemSelected(item);
1311    }
1312
1313    private boolean handleCommand(int viewId) {
1314        switch (viewId) {
1315        case android.R.id.home:
1316            onActionBarHomePressed();
1317            return true;
1318        case R.id.send:
1319            onSend();
1320            return true;
1321        case R.id.save:
1322            onSave();
1323            return true;
1324        case R.id.discard:
1325            onDiscard();
1326            return true;
1327        case R.id.include_quoted_text:
1328            // The checkbox is already toggled at this point.
1329            setIncludeQuotedText(mIncludeQuotedTextCheckBox.isChecked(), true);
1330            return true;
1331        case R.id.add_cc_bcc:
1332            showCcBccFields();
1333            return true;
1334        case R.id.add_attachment:
1335            onAddAttachment();
1336            return true;
1337        }
1338        return false;
1339    }
1340
1341    private void onActionBarHomePressed() {
1342        finish();
1343        if (isOpenedFromWithinApp()) {
1344            // If opend from within the app, we just close it.
1345        } else {
1346            // Otherwise, need to open the main screen.  Let Welcome do that.
1347            Welcome.actionStart(this);
1348        }
1349    }
1350
1351    @Override
1352    public boolean onCreateOptionsMenu(Menu menu) {
1353        super.onCreateOptionsMenu(menu);
1354        getMenuInflater().inflate(R.menu.message_compose_option, menu);
1355        return true;
1356    }
1357
1358    @Override
1359    public boolean onPrepareOptionsMenu(Menu menu) {
1360        menu.findItem(R.id.save).setEnabled(mDraftNeedsSaving);
1361        return true;
1362    }
1363
1364    /**
1365     * Set a message body and a signature when the Activity is launched.
1366     *
1367     * @param text the message body
1368     */
1369    @VisibleForTesting
1370    void setInitialComposeText(CharSequence text, String signature) {
1371        mMessageContentView.setText("");
1372        int textLength = 0;
1373        if (text != null) {
1374            mMessageContentView.append(text);
1375            textLength = text.length();
1376        }
1377        if (!TextUtils.isEmpty(signature)) {
1378            if (textLength == 0 || text.charAt(textLength - 1) != '\n') {
1379                mMessageContentView.append("\n");
1380            }
1381            mMessageContentView.append(signature);
1382
1383            // Reset cursor to right before the signature.
1384            mMessageContentView.setSelection(textLength);
1385        }
1386    }
1387
1388    /**
1389     * Fill all the widgets with the content found in the Intent Extra, if any.
1390     *
1391     * Note that we don't actually check the intent action  (typically VIEW, SENDTO, or SEND).
1392     * There is enough overlap in the definitions that it makes more sense to simply check for
1393     * all available data and use as much of it as possible.
1394     *
1395     * With one exception:  EXTRA_STREAM is defined as only valid for ACTION_SEND.
1396     *
1397     * @param intent the launch intent
1398     */
1399    @VisibleForTesting
1400    void initFromIntent(Intent intent) {
1401
1402        setAccount(intent);
1403
1404        // First, add values stored in top-level extras
1405        String[] extraStrings = intent.getStringArrayExtra(Intent.EXTRA_EMAIL);
1406        if (extraStrings != null) {
1407            addAddresses(mToView, extraStrings);
1408        }
1409        extraStrings = intent.getStringArrayExtra(Intent.EXTRA_CC);
1410        if (extraStrings != null) {
1411            addAddresses(mCcView, extraStrings);
1412        }
1413        extraStrings = intent.getStringArrayExtra(Intent.EXTRA_BCC);
1414        if (extraStrings != null) {
1415            addAddresses(mBccView, extraStrings);
1416        }
1417        String extraString = intent.getStringExtra(Intent.EXTRA_SUBJECT);
1418        if (extraString != null) {
1419            mSubjectView.setText(extraString);
1420        }
1421
1422        // Next, if we were invoked with a URI, try to interpret it
1423        // We'll take two courses here.  If it's mailto:, there is a specific set of rules
1424        // that define various optional fields.  However, for any other scheme, we'll simply
1425        // take the entire scheme-specific part and interpret it as a possible list of addresses.
1426        final Uri dataUri = intent.getData();
1427        if (dataUri != null) {
1428            if ("mailto".equals(dataUri.getScheme())) {
1429                initializeFromMailTo(dataUri.toString());
1430            } else {
1431                String toText = dataUri.getSchemeSpecificPart();
1432                if (toText != null) {
1433                    addAddresses(mToView, toText.split(","));
1434                }
1435            }
1436        }
1437
1438        // Next, fill in the plaintext (note, this will override mailto:?body=)
1439        CharSequence text = intent.getCharSequenceExtra(Intent.EXTRA_TEXT);
1440        setInitialComposeText(text, getAccountSignature(mAccount));
1441
1442        // Next, convert EXTRA_STREAM into an attachment
1443        if (Intent.ACTION_SEND.equals(mAction) && intent.hasExtra(Intent.EXTRA_STREAM)) {
1444            Uri uri = (Uri) intent.getParcelableExtra(Intent.EXTRA_STREAM);
1445            if (uri != null) {
1446                addAttachmentFromSendIntent(uri);
1447            }
1448        }
1449
1450        if (Intent.ACTION_SEND_MULTIPLE.equals(mAction)
1451                && intent.hasExtra(Intent.EXTRA_STREAM)) {
1452            ArrayList<Parcelable> list = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM);
1453            if (list != null) {
1454                for (Parcelable parcelable : list) {
1455                    Uri uri = (Uri) parcelable;
1456                    if (uri != null) {
1457                        addAttachmentFromSendIntent(uri);
1458                    }
1459                }
1460            }
1461        }
1462
1463        // Finally - expose fields that were filled in but are normally hidden, and set focus
1464        showCcBccFieldsIfFilled();
1465        setNewMessageFocus();
1466    }
1467
1468    /**
1469     * When we are launched with an intent that includes a mailto: URI, we can actually
1470     * gather quite a few of our message fields from it.
1471     *
1472     * @mailToString the href (which must start with "mailto:").
1473     */
1474    private void initializeFromMailTo(String mailToString) {
1475
1476        // Chop up everything between mailto: and ? to find recipients
1477        int index = mailToString.indexOf("?");
1478        int length = "mailto".length() + 1;
1479        String to;
1480        try {
1481            // Extract the recipient after mailto:
1482            if (index == -1) {
1483                to = decode(mailToString.substring(length));
1484            } else {
1485                to = decode(mailToString.substring(length, index));
1486            }
1487            addAddresses(mToView, to.split(" ,"));
1488        } catch (UnsupportedEncodingException e) {
1489            Log.e(Logging.LOG_TAG, e.getMessage() + " while decoding '" + mailToString + "'");
1490        }
1491
1492        // Extract the other parameters
1493
1494        // We need to disguise this string as a URI in order to parse it
1495        Uri uri = Uri.parse("foo://" + mailToString);
1496
1497        List<String> cc = uri.getQueryParameters("cc");
1498        addAddresses(mCcView, cc.toArray(new String[cc.size()]));
1499
1500        List<String> otherTo = uri.getQueryParameters("to");
1501        addAddresses(mCcView, otherTo.toArray(new String[otherTo.size()]));
1502
1503        List<String> bcc = uri.getQueryParameters("bcc");
1504        addAddresses(mBccView, bcc.toArray(new String[bcc.size()]));
1505
1506        List<String> subject = uri.getQueryParameters("subject");
1507        if (subject.size() > 0) {
1508            mSubjectView.setText(subject.get(0));
1509        }
1510
1511        List<String> body = uri.getQueryParameters("body");
1512        if (body.size() > 0) {
1513            setInitialComposeText(body.get(0), getAccountSignature(mAccount));
1514        }
1515    }
1516
1517    private String decode(String s) throws UnsupportedEncodingException {
1518        return URLDecoder.decode(s, "UTF-8");
1519    }
1520
1521    /**
1522     * Displays quoted text from the original email
1523     */
1524    private void displayQuotedText(String textBody, String htmlBody) {
1525        // Only use plain text if there is no HTML body
1526        boolean plainTextFlag = TextUtils.isEmpty(htmlBody);
1527        String text = plainTextFlag ? textBody : htmlBody;
1528        if (text != null) {
1529            text = plainTextFlag ? EmailHtmlUtil.escapeCharacterToDisplay(text) : text;
1530            // TODO: re-enable EmailHtmlUtil.resolveInlineImage() for HTML
1531            //    EmailHtmlUtil.resolveInlineImage(getContentResolver(), mAccount,
1532            //                                     text, message, 0);
1533            mQuotedTextBar.setVisibility(View.VISIBLE);
1534            if (mQuotedText != null) {
1535                mQuotedText.loadDataWithBaseURL("email://", text, "text/html", "utf-8", null);
1536            }
1537        }
1538    }
1539
1540    /**
1541     * Given a packed address String, the address of our sending account, a view, and a list of
1542     * addressees already added to other addressing views, adds unique addressees that don't
1543     * match our address to the passed in view
1544     */
1545    private boolean safeAddAddresses(String addrs, String ourAddress,
1546            MultiAutoCompleteTextView view, ArrayList<Address> addrList) {
1547        boolean added = false;
1548        for (Address address : Address.unpack(addrs)) {
1549            // Don't send to ourselves or already-included addresses
1550            if (!address.getAddress().equalsIgnoreCase(ourAddress) && !addrList.contains(address)) {
1551                addrList.add(address);
1552                addAddress(view, address.toString());
1553                added = true;
1554            }
1555        }
1556        return added;
1557    }
1558
1559    /**
1560     * Set up the to and cc views properly for the "reply" and "replyAll" cases.  What's important
1561     * is that we not 1) send to ourselves, and 2) duplicate addressees.
1562     * @param message the message we're replying to
1563     * @param account the account we're sending from
1564     * @param toView the "To" view
1565     * @param ccView the "Cc" view
1566     * @param replyAll whether this is a replyAll (vs a reply)
1567     */
1568    @VisibleForTesting
1569    void setupAddressViews(Message message, Account account,
1570            MultiAutoCompleteTextView toView, MultiAutoCompleteTextView ccView, boolean replyAll) {
1571        /*
1572         * If a reply-to was included with the message use that, otherwise use the from
1573         * or sender address.
1574         */
1575        Address[] replyToAddresses = Address.unpack(message.mReplyTo);
1576        if (replyToAddresses.length == 0) {
1577            replyToAddresses = Address.unpack(message.mFrom);
1578        }
1579        addAddresses(mToView, replyToAddresses);
1580
1581        if (replyAll) {
1582            // Keep a running list of addresses we're sending to
1583            ArrayList<Address> allAddresses = new ArrayList<Address>();
1584            String ourAddress = account.mEmailAddress;
1585
1586            for (Address address: replyToAddresses) {
1587                allAddresses.add(address);
1588            }
1589
1590            safeAddAddresses(message.mTo, ourAddress, mToView, allAddresses);
1591            safeAddAddresses(message.mCc, ourAddress, mCcView, allAddresses);
1592        }
1593        showCcBccFieldsIfFilled();
1594    }
1595
1596    void processSourceMessageGuarded(Message message, Account account) {
1597        // Make sure we only do this once (otherwise we'll duplicate addresses!)
1598        if (!mSourceMessageProcessed) {
1599            processSourceMessage(message, account);
1600            mSourceMessageProcessed = true;
1601        }
1602
1603        /* The quoted text is displayed in a WebView whose content is not automatically
1604         * saved/restored by onRestoreInstanceState(), so we need to *always* restore it here,
1605         * regardless of the value of mSourceMessageProcessed.
1606         * This only concerns EDIT_DRAFT because after a configuration change we're always
1607         * in EDIT_DRAFT.
1608         */
1609        if (ACTION_EDIT_DRAFT.equals(mAction)) {
1610            displayQuotedText(message.mTextReply, message.mHtmlReply);
1611            setIncludeQuotedText((mDraft.mFlags & Message.FLAG_NOT_INCLUDE_QUOTED_TEXT) == 0,
1612                    false);
1613        }
1614    }
1615
1616    /**
1617     * Pull out the parts of the now loaded source message and apply them to the new message
1618     * depending on the type of message being composed.
1619     * @param message
1620     */
1621    /* package */
1622    void processSourceMessage(Message message, Account account) {
1623        setDraftNeedsSaving(true);
1624        final String subject = message.mSubject;
1625        if (ACTION_REPLY.equals(mAction) || ACTION_REPLY_ALL.equals(mAction)) {
1626            setupAddressViews(message, account, mToView, mCcView,
1627                ACTION_REPLY_ALL.equals(mAction));
1628            if (subject != null && !subject.toLowerCase().startsWith("re:")) {
1629                mSubjectView.setText("Re: " + subject);
1630            } else {
1631                mSubjectView.setText(subject);
1632            }
1633            displayQuotedText(message.mText, message.mHtml);
1634            setIncludeQuotedText(true, false);
1635            setInitialComposeText(null, getAccountSignature(account));
1636        } else if (ACTION_FORWARD.equals(mAction)) {
1637            mSubjectView.setText(subject != null && !subject.toLowerCase().startsWith("fwd:") ?
1638                    "Fwd: " + subject : subject);
1639            displayQuotedText(message.mText, message.mHtml);
1640            setIncludeQuotedText(true, false);
1641            setInitialComposeText(null, getAccountSignature(account));
1642        } else if (ACTION_EDIT_DRAFT.equals(mAction)) {
1643            mSubjectView.setText(subject);
1644            addAddresses(mToView, Address.unpack(message.mTo));
1645            Address[] cc = Address.unpack(message.mCc);
1646            if (cc.length > 0) {
1647                addAddresses(mCcView, cc);
1648            }
1649            Address[] bcc = Address.unpack(message.mBcc);
1650            if (bcc.length > 0) {
1651                addAddresses(mBccView, bcc);
1652            }
1653
1654            mMessageContentView.setText(message.mText);
1655            // TODO: re-enable loadAttachments
1656            // loadAttachments(message, 0);
1657            setDraftNeedsSaving(false);
1658        }
1659        showCcBccFieldsIfFilled();
1660        setNewMessageFocus();
1661    }
1662
1663    /**
1664     * Set a cursor to the end of a body except a signature.
1665     */
1666    @VisibleForTesting
1667    void setMessageContentSelection(String signature) {
1668        int selection = mMessageContentView.length();
1669        if (!TextUtils.isEmpty(signature)) {
1670            int signatureLength = signature.length();
1671            int estimatedSelection = selection - signatureLength;
1672            if (estimatedSelection >= 0) {
1673                CharSequence text = mMessageContentView.getText();
1674                int i = 0;
1675                while (i < signatureLength
1676                       && text.charAt(estimatedSelection + i) == signature.charAt(i)) {
1677                    ++i;
1678                }
1679                if (i == signatureLength) {
1680                    selection = estimatedSelection;
1681                    while (selection > 0 && text.charAt(selection - 1) == '\n') {
1682                        --selection;
1683                    }
1684                }
1685            }
1686        }
1687        mMessageContentView.setSelection(selection, selection);
1688    }
1689
1690    /**
1691     * In order to accelerate typing, position the cursor in the first empty field,
1692     * or at the end of the body composition field if none are empty.  Typically, this will
1693     * play out as follows:
1694     *   Reply / Reply All - put cursor in the empty message body
1695     *   Forward - put cursor in the empty To field
1696     *   Edit Draft - put cursor in whatever field still needs entry
1697     */
1698    private void setNewMessageFocus() {
1699        if (mToView.length() == 0) {
1700            mToView.requestFocus();
1701        } else if (mSubjectView.length() == 0) {
1702            mSubjectView.requestFocus();
1703        } else {
1704            mMessageContentView.requestFocus();
1705        }
1706    }
1707
1708    /**
1709     * @return the signature for the specified account, if non-null. If the account specified is
1710     *     null or has no signature, {@code null} is returned.
1711     */
1712    private static String getAccountSignature(Account account) {
1713        return (account == null) ? null : account.mSignature;
1714    }
1715}
1716