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