MessageCompose.java revision ce2c05f50b0859b3a1306ef0a4e2bcd4cabb553f
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 android.app.ActionBar;
20import android.app.ActionBar.OnNavigationListener;
21import android.app.Activity;
22import android.app.ActivityManager;
23import android.app.FragmentTransaction;
24import android.content.ActivityNotFoundException;
25import android.content.ContentResolver;
26import android.content.ContentUris;
27import android.content.ContentValues;
28import android.content.Context;
29import android.content.Intent;
30import android.database.Cursor;
31import android.net.Uri;
32import android.os.Bundle;
33import android.os.Parcelable;
34import android.provider.OpenableColumns;
35import android.text.InputFilter;
36import android.text.SpannableStringBuilder;
37import android.text.Spanned;
38import android.text.TextUtils;
39import android.text.TextWatcher;
40import android.text.util.Rfc822Tokenizer;
41import android.util.Log;
42import android.view.Menu;
43import android.view.MenuItem;
44import android.view.View;
45import android.view.View.OnClickListener;
46import android.view.View.OnFocusChangeListener;
47import android.view.ViewGroup;
48import android.webkit.WebView;
49import android.widget.ArrayAdapter;
50import android.widget.CheckBox;
51import android.widget.EditText;
52import android.widget.ImageView;
53import android.widget.MultiAutoCompleteTextView;
54import android.widget.TextView;
55import android.widget.Toast;
56
57import com.android.common.contacts.DataUsageStatUpdater;
58import com.android.email.Controller;
59import com.android.email.Email;
60import com.android.email.EmailAddressAdapter;
61import com.android.email.EmailAddressValidator;
62import com.android.email.R;
63import com.android.email.RecipientAdapter;
64import com.android.email.activity.setup.AccountSettings;
65import com.android.email.mail.internet.EmailHtmlUtil;
66import com.android.emailcommon.Logging;
67import com.android.emailcommon.internet.MimeUtility;
68import com.android.emailcommon.mail.Address;
69import com.android.emailcommon.provider.Account;
70import com.android.emailcommon.provider.EmailContent;
71import com.android.emailcommon.provider.EmailContent.Attachment;
72import com.android.emailcommon.provider.EmailContent.Body;
73import com.android.emailcommon.provider.EmailContent.BodyColumns;
74import com.android.emailcommon.provider.EmailContent.Message;
75import com.android.emailcommon.provider.EmailContent.MessageColumns;
76import com.android.emailcommon.provider.EmailContent.QuickResponseColumns;
77import com.android.emailcommon.provider.Mailbox;
78import com.android.emailcommon.provider.QuickResponse;
79import com.android.emailcommon.utility.AttachmentUtilities;
80import com.android.emailcommon.utility.EmailAsyncTask;
81import com.android.emailcommon.utility.Utility;
82import com.android.ex.chips.AccountSpecifier;
83import com.android.ex.chips.ChipsUtil;
84import com.android.ex.chips.RecipientEditTextView;
85import com.google.common.annotations.VisibleForTesting;
86import com.google.common.base.Objects;
87import com.google.common.collect.Lists;
88
89import java.io.File;
90import java.io.UnsupportedEncodingException;
91import java.net.URLDecoder;
92import java.util.ArrayList;
93import java.util.HashMap;
94import java.util.HashSet;
95import java.util.List;
96import java.util.concurrent.ConcurrentHashMap;
97import java.util.concurrent.ExecutionException;
98
99
100/**
101 * Activity to compose a message.
102 *
103 * TODO Revive shortcuts command for removed menu options.
104 * C: add cc/bcc
105 * N: add attachment
106 */
107public class MessageCompose extends Activity implements OnClickListener, OnFocusChangeListener,
108        DeleteMessageConfirmationDialog.Callback, InsertQuickResponseDialog.Callback {
109
110    private static final String ACTION_REPLY = "com.android.email.intent.action.REPLY";
111    private static final String ACTION_REPLY_ALL = "com.android.email.intent.action.REPLY_ALL";
112    private static final String ACTION_FORWARD = "com.android.email.intent.action.FORWARD";
113    private static final String ACTION_EDIT_DRAFT = "com.android.email.intent.action.EDIT_DRAFT";
114
115    private static final String EXTRA_ACCOUNT_ID = "account_id";
116    private static final String EXTRA_MESSAGE_ID = "message_id";
117    /** If the intent is sent from the email app itself, it should have this boolean extra. */
118    public static final String EXTRA_FROM_WITHIN_APP = "from_within_app";
119    /** If the intent is sent from thw widget. */
120    public static final String EXTRA_FROM_WIDGET = "from_widget";
121
122    private static final String STATE_KEY_CC_SHOWN =
123        "com.android.email.activity.MessageCompose.ccShown";
124    private static final String STATE_KEY_QUOTED_TEXT_SHOWN =
125        "com.android.email.activity.MessageCompose.quotedTextShown";
126    private static final String STATE_KEY_DRAFT_ID =
127        "com.android.email.activity.MessageCompose.draftId";
128    private static final String STATE_KEY_LAST_SAVE_TASK_ID =
129        "com.android.email.activity.MessageCompose.requestId";
130    private static final String STATE_KEY_ACTION =
131        "com.android.email.activity.MessageCompose.action";
132
133    private static final int ACTIVITY_REQUEST_PICK_ATTACHMENT = 1;
134
135    private static final String[] ATTACHMENT_META_SIZE_PROJECTION = {
136        OpenableColumns.SIZE
137    };
138    private static final int ATTACHMENT_META_SIZE_COLUMN_SIZE = 0;
139
140    /**
141     * A registry of the active tasks used to save messages.
142     */
143    private static final ConcurrentHashMap<Long, SendOrSaveMessageTask> sActiveSaveTasks =
144            new ConcurrentHashMap<Long, SendOrSaveMessageTask>();
145
146    private static long sNextSaveTaskId = 1;
147
148    /**
149     * The ID of the latest save or send task requested by this Activity.
150     */
151    private long mLastSaveTaskId = -1;
152
153    private Account mAccount;
154
155    /**
156     * The contents of the current message being edited. This is not always in sync with what's
157     * on the UI. {@link #updateMessage(Message, Account, boolean, boolean)} must be called to sync
158     * the UI values into this object.
159     */
160    private Message mDraft = new Message();
161
162    /**
163     * A collection of attachments the user is currently wanting to attach to this message.
164     */
165    private final ArrayList<Attachment> mAttachments = new ArrayList<Attachment>();
166
167    /**
168     * The source message for a reply, reply all, or forward. This is asynchronously loaded.
169     */
170    private Message mSource;
171
172    /**
173     * The attachments associated with the source attachments. Usually included in a forward.
174     */
175    private ArrayList<Attachment> mSourceAttachments = new ArrayList<Attachment>();
176
177    /**
178     * The action being handled by this activity. This is initially populated from the
179     * {@link Intent}, but can switch between reply/reply all/forward where appropriate.
180     * This value is nullable (a null value indicating a regular "compose").
181     */
182    private String mAction;
183
184    private TextView mFromView;
185    private MultiAutoCompleteTextView mToView;
186    private MultiAutoCompleteTextView mCcView;
187    private MultiAutoCompleteTextView mBccView;
188    private View mCcBccContainer;
189    private EditText mSubjectView;
190    private EditText mMessageContentView;
191    private View mAttachmentContainer;
192    private ViewGroup mAttachmentContentView;
193    private View mQuotedTextArea;
194    private CheckBox mIncludeQuotedTextCheckBox;
195    private WebView mQuotedText;
196    private ActionSpinnerAdapter mActionSpinnerAdapter;
197
198    private Controller mController;
199    private boolean mDraftNeedsSaving;
200    private boolean mMessageLoaded;
201    private boolean mInitiallyEmpty;
202    private boolean mPickingAttachment = false;
203    private Boolean mQuickResponsesAvailable = true;
204    private final EmailAsyncTask.Tracker mTaskTracker = new EmailAsyncTask.Tracker();
205
206    private AccountSpecifier mAddressAdapterTo;
207    private AccountSpecifier mAddressAdapterCc;
208    private AccountSpecifier mAddressAdapterBcc;
209
210    /**
211     * Watches the to, cc, bcc, subject, and message body fields.
212     */
213    private final TextWatcher mWatcher = new TextWatcher() {
214        @Override
215        public void beforeTextChanged(CharSequence s, int start,
216                                      int before, int after) { }
217
218        @Override
219        public void onTextChanged(CharSequence s, int start,
220                                      int before, int count) {
221            setMessageChanged(true);
222        }
223
224        @Override
225        public void afterTextChanged(android.text.Editable s) { }
226    };
227
228    private static Intent getBaseIntent(Context context) {
229        return new Intent(context, MessageCompose.class);
230    }
231
232    /**
233     * Create an {@link Intent} that can start the message compose activity. If accountId -1,
234     * the default account will be used; otherwise, the specified account is used.
235     */
236    public static Intent getMessageComposeIntent(Context context, long accountId) {
237        Intent i = getBaseIntent(context);
238        i.putExtra(EXTRA_ACCOUNT_ID, accountId);
239        return i;
240    }
241
242    /**
243     * Creates an {@link Intent} that can start the message compose activity from the main Email
244     * activity. This should not be used for Intents to be fired from outside of the main Email
245     * activity, such as from widgets, as the behavior of the compose screen differs subtly from
246     * those cases.
247     */
248    private static Intent getMainAppIntent(Context context, long accountId) {
249        Intent result = getMessageComposeIntent(context, accountId);
250        result.putExtra(EXTRA_FROM_WITHIN_APP, true);
251        return result;
252    }
253
254    /**
255     * Compose a new message using the given account. If account is {@link Account#NO_ACCOUNT}
256     * the default account will be used.
257     * This should only be called from the main Email application.
258     * @param context
259     * @param accountId
260     */
261    public static void actionCompose(Context context, long accountId) {
262       try {
263           Intent i = getMainAppIntent(context, accountId);
264           context.startActivity(i);
265       } catch (ActivityNotFoundException anfe) {
266           // Swallow it - this is usually a race condition, especially under automated test.
267           // (The message composer might have been disabled)
268           Email.log(anfe.toString());
269       }
270    }
271
272    /**
273     * Compose a new message using a uri (mailto:) and a given account.  If account is -1 the
274     * default account will be used.
275     * This should only be called from the main Email application.
276     * @param context
277     * @param uriString
278     * @param accountId
279     * @return true if startActivity() succeeded
280     */
281    public static boolean actionCompose(Context context, String uriString, long accountId) {
282        try {
283            Intent i = getMainAppIntent(context, accountId);
284            i.setAction(Intent.ACTION_SEND);
285            i.setData(Uri.parse(uriString));
286            context.startActivity(i);
287            return true;
288        } catch (ActivityNotFoundException anfe) {
289            // Swallow it - this is usually a race condition, especially under automated test.
290            // (The message composer might have been disabled)
291            Email.log(anfe.toString());
292            return false;
293        }
294    }
295
296    /**
297     * Compose a new message as a reply to the given message. If replyAll is true the function
298     * is reply all instead of simply reply.
299     * @param context
300     * @param messageId
301     * @param replyAll
302     */
303    public static void actionReply(Context context, long messageId, boolean replyAll) {
304        startActivityWithMessage(context, replyAll ? ACTION_REPLY_ALL : ACTION_REPLY, messageId);
305    }
306
307    /**
308     * Compose a new message as a forward of the given message.
309     * @param context
310     * @param messageId
311     */
312    public static void actionForward(Context context, long messageId) {
313        startActivityWithMessage(context, ACTION_FORWARD, messageId);
314    }
315
316    /**
317     * Continue composition of the given message. This action modifies the way this Activity
318     * handles certain actions.
319     * Save will attempt to replace the message in the given folder with the updated version.
320     * Discard will delete the message from the given folder.
321     * @param context
322     * @param messageId the message id.
323     */
324    public static void actionEditDraft(Context context, long messageId) {
325        startActivityWithMessage(context, ACTION_EDIT_DRAFT, messageId);
326    }
327
328    /**
329     * Starts a compose activity with a message as a reference message (e.g. for reply or forward).
330     */
331    private static void startActivityWithMessage(Context context, String action, long messageId) {
332        Intent i = getBaseIntent(context);
333        i.putExtra(EXTRA_MESSAGE_ID, messageId);
334        i.setAction(action);
335        context.startActivity(i);
336    }
337
338    private void setAccount(Intent intent) {
339        long accountId = intent.getLongExtra(EXTRA_ACCOUNT_ID, -1);
340        if (accountId == Account.NO_ACCOUNT) {
341            accountId = Account.getDefaultAccountId(this);
342        }
343        if (accountId == Account.NO_ACCOUNT) {
344            // There are no accounts set up. This should not have happened. Prompt the
345            // user to set up an account as an acceptable bailout.
346            Welcome.actionStart(this);
347            finish();
348        } else {
349            setAccount(Account.restoreAccountWithId(this, accountId));
350        }
351    }
352
353    private void setAccount(Account account) {
354        if (account == null) {
355            throw new IllegalArgumentException();
356        }
357        mAccount = account;
358        mFromView.setText(account.mEmailAddress);
359        mAddressAdapterTo
360                .setAccount(new android.accounts.Account(account.mEmailAddress, "unknown"));
361        mAddressAdapterCc
362                .setAccount(new android.accounts.Account(account.mEmailAddress, "unknown"));
363        mAddressAdapterBcc
364                .setAccount(new android.accounts.Account(account.mEmailAddress, "unknown"));
365
366        new QuickResponseChecker(mTaskTracker).executeParallel((Void) null);
367    }
368
369    @Override
370    public void onCreate(Bundle savedInstanceState) {
371        super.onCreate(savedInstanceState);
372        ActivityHelper.debugSetWindowFlags(this);
373        setContentView(R.layout.message_compose);
374
375        mController = Controller.getInstance(getApplication());
376        initViews();
377
378        // Show the back arrow on the action bar.
379        getActionBar().setDisplayOptions(
380                ActionBar.DISPLAY_HOME_AS_UP, ActionBar.DISPLAY_HOME_AS_UP);
381
382        if (savedInstanceState != null) {
383            long draftId = savedInstanceState.getLong(STATE_KEY_DRAFT_ID, Message.NOT_SAVED);
384            long existingSaveTaskId = savedInstanceState.getLong(STATE_KEY_LAST_SAVE_TASK_ID, -1);
385            setAction(savedInstanceState.getString(STATE_KEY_ACTION));
386            SendOrSaveMessageTask existingSaveTask = sActiveSaveTasks.get(existingSaveTaskId);
387
388            if ((draftId != Message.NOT_SAVED) || (existingSaveTask != null)) {
389                // Restoring state and there was an existing message saved or in the process of
390                // being saved.
391                resumeDraft(draftId, existingSaveTask, false /* don't restore views */);
392            } else {
393                // Restoring state but there was nothing saved - probably means the user rotated
394                // the device immediately - just use the Intent.
395                resolveIntent(getIntent());
396            }
397        } else {
398            Intent intent = getIntent();
399            setAction(intent.getAction());
400            resolveIntent(intent);
401        }
402    }
403
404    private void resolveIntent(Intent intent) {
405        if (Intent.ACTION_VIEW.equals(mAction)
406                || Intent.ACTION_SENDTO.equals(mAction)
407                || Intent.ACTION_SEND.equals(mAction)
408                || Intent.ACTION_SEND_MULTIPLE.equals(mAction)) {
409            initFromIntent(intent);
410            setMessageChanged(true);
411            setMessageLoaded(true);
412        } else if (ACTION_REPLY.equals(mAction)
413                || ACTION_REPLY_ALL.equals(mAction)
414                || ACTION_FORWARD.equals(mAction)) {
415            long sourceMessageId = getIntent().getLongExtra(EXTRA_MESSAGE_ID, Message.NOT_SAVED);
416            loadSourceMessage(sourceMessageId, true);
417
418        } else if (ACTION_EDIT_DRAFT.equals(mAction)) {
419            // Assert getIntent.hasExtra(EXTRA_MESSAGE_ID)
420            long draftId = getIntent().getLongExtra(EXTRA_MESSAGE_ID, Message.NOT_SAVED);
421            resumeDraft(draftId, null, true /* restore views */);
422
423        } else {
424            // Normal compose flow for a new message.
425            setAccount(intent);
426            setInitialComposeText(null, getAccountSignature(mAccount));
427            setMessageLoaded(true);
428        }
429    }
430
431    @Override
432    protected void onRestoreInstanceState(Bundle savedInstanceState) {
433        // Temporarily disable onTextChanged listeners while restoring the fields
434        removeListeners();
435        super.onRestoreInstanceState(savedInstanceState);
436        if (savedInstanceState.getBoolean(STATE_KEY_CC_SHOWN)) {
437            showCcBccFields();
438        }
439        mQuotedTextArea.setVisibility(savedInstanceState.getBoolean(STATE_KEY_QUOTED_TEXT_SHOWN)
440                ? View.VISIBLE : View.GONE);
441        mQuotedText.setVisibility(savedInstanceState.getBoolean(STATE_KEY_QUOTED_TEXT_SHOWN)
442                ? View.VISIBLE : View.GONE);
443        addListeners();
444    }
445
446    // needed for unit tests
447    @Override
448    public void setIntent(Intent intent) {
449        super.setIntent(intent);
450        setAction(intent.getAction());
451    }
452
453    private void setQuickResponsesAvailable(boolean quickResponsesAvailable) {
454        if (mQuickResponsesAvailable != quickResponsesAvailable) {
455            mQuickResponsesAvailable = quickResponsesAvailable;
456            invalidateOptionsMenu();
457        }
458    }
459
460    /**
461     * Given an accountId and context, finds if the database has any QuickResponse
462     * entries and returns the result to the Callback.
463     */
464    private class QuickResponseChecker extends EmailAsyncTask<Void, Void, Boolean> {
465        public QuickResponseChecker(EmailAsyncTask.Tracker tracker) {
466            super(tracker);
467        }
468
469        @Override
470        protected Boolean doInBackground(Void... params) {
471            return EmailContent.count(MessageCompose.this, QuickResponse.CONTENT_URI,
472                    QuickResponseColumns.ACCOUNT_KEY + "=?",
473                    new String[] {Long.toString(mAccount.mId)}) > 0;
474        }
475
476        @Override
477        protected void onSuccess(Boolean quickResponsesAvailable) {
478            setQuickResponsesAvailable(quickResponsesAvailable);
479        }
480    }
481
482    @Override
483    public void onResume() {
484        super.onResume();
485
486        // Exit immediately if the accounts list has changed (e.g. externally deleted)
487        if (Email.getNotifyUiAccountsChanged()) {
488            Welcome.actionStart(this);
489            finish();
490            return;
491        }
492
493        // If activity paused and quick responses are removed/added, possibly update options menu
494        if (mAccount != null) {
495            new QuickResponseChecker(mTaskTracker).executeParallel((Void) null);
496        }
497    }
498
499    @Override
500    public void onPause() {
501        super.onPause();
502        saveIfNeeded();
503    }
504
505    /**
506     * We override onDestroy to make sure that the WebView gets explicitly destroyed.
507     * Otherwise it can leak native references.
508     */
509    @Override
510    public void onDestroy() {
511        super.onDestroy();
512        mQuotedText.destroy();
513        mQuotedText = null;
514
515        mTaskTracker.cancellAllInterrupt();
516
517        if (mAddressAdapterTo != null && mAddressAdapterTo instanceof EmailAddressAdapter) {
518            ((EmailAddressAdapter) mAddressAdapterTo).close();
519        }
520        if (mAddressAdapterCc != null && mAddressAdapterCc instanceof EmailAddressAdapter) {
521            ((EmailAddressAdapter) mAddressAdapterCc).close();
522        }
523        if (mAddressAdapterBcc != null && mAddressAdapterBcc instanceof EmailAddressAdapter) {
524            ((EmailAddressAdapter) mAddressAdapterBcc).close();
525        }
526    }
527
528    /**
529     * The framework handles most of the fields, but we need to handle stuff that we
530     * dynamically show and hide:
531     * Cc field,
532     * Bcc field,
533     * Quoted text,
534     */
535    @Override
536    protected void onSaveInstanceState(Bundle outState) {
537        super.onSaveInstanceState(outState);
538
539        long draftId = mDraft.mId;
540        if (draftId != Message.NOT_SAVED) {
541            outState.putLong(STATE_KEY_DRAFT_ID, draftId);
542        }
543        outState.putBoolean(STATE_KEY_CC_SHOWN, mCcBccContainer.getVisibility() == View.VISIBLE);
544        outState.putBoolean(STATE_KEY_QUOTED_TEXT_SHOWN,
545                mQuotedTextArea.getVisibility() == View.VISIBLE);
546        outState.putString(STATE_KEY_ACTION, mAction);
547
548        // If there are any outstanding save requests, ensure that it's noted in case it hasn't
549        // finished by the time the activity is restored.
550        outState.putLong(STATE_KEY_LAST_SAVE_TASK_ID, mLastSaveTaskId);
551    }
552
553    @Override
554    public void onBackPressed() {
555        onBack(true /* systemKey */);
556    }
557
558    /**
559     * Whether or not the current message being edited has a source message (i.e. is a reply,
560     * or forward) that is loaded.
561     */
562    private boolean hasSourceMessage() {
563        return mSource != null;
564    }
565
566    /**
567     * @return true if the activity was opened by the email app itself.
568     */
569    private boolean isOpenedFromWithinApp() {
570        Intent i = getIntent();
571        return (i != null && i.getBooleanExtra(EXTRA_FROM_WITHIN_APP, false));
572    }
573
574    private boolean isOpenedFromWidget() {
575        Intent i = getIntent();
576        return (i != null && i.getBooleanExtra(EXTRA_FROM_WIDGET, false));
577    }
578
579    /**
580     * Sets message as loaded and then initializes the TextWatchers.
581     * @param isLoaded - value to which to set mMessageLoaded
582     */
583    private void setMessageLoaded(boolean isLoaded) {
584        if (mMessageLoaded != isLoaded) {
585            mMessageLoaded = isLoaded;
586            addListeners();
587            mInitiallyEmpty = areViewsEmpty();
588        }
589    }
590
591    private void setMessageChanged(boolean messageChanged) {
592        boolean needsSaving = messageChanged && !(mInitiallyEmpty && areViewsEmpty());
593
594        if (mDraftNeedsSaving != needsSaving) {
595            mDraftNeedsSaving = needsSaving;
596            invalidateOptionsMenu();
597        }
598    }
599
600    /**
601     * @return whether or not all text fields are empty (i.e. the entire compose message is empty)
602     */
603    private boolean areViewsEmpty() {
604        return (mToView.length() == 0)
605                && (mCcView.length() == 0)
606                && (mBccView.length() == 0)
607                && (mSubjectView.length() == 0)
608                && isBodyEmpty()
609                && mAttachments.isEmpty();
610    }
611
612    private boolean isBodyEmpty() {
613        return (mMessageContentView.length() == 0)
614                || mMessageContentView.getText()
615                        .toString().equals("\n" + getAccountSignature(mAccount));
616    }
617
618    public void setFocusShifter(int fromViewId, final int targetViewId) {
619        View label = findViewById(fromViewId); // xlarge only
620        if (label != null) {
621            final View target = UiUtilities.getView(this, targetViewId);
622            label.setOnClickListener(new View.OnClickListener() {
623                @Override
624                public void onClick(View v) {
625                    target.requestFocus();
626                }
627            });
628        }
629    }
630
631    /**
632     * An {@link InputFilter} that implements special address cleanup rules.
633     * The first space key entry following an "@" symbol that is followed by any combination
634     * of letters and symbols, including one+ dots and zero commas, should insert an extra
635     * comma (followed by the space).
636     */
637    @VisibleForTesting
638    static final InputFilter RECIPIENT_FILTER = new InputFilter() {
639        @Override
640        public CharSequence filter(CharSequence source, int start, int end, Spanned dest,
641                int dstart, int dend) {
642
643            // Quick check - did they enter a single space?
644            if (end-start != 1 || source.charAt(start) != ' ') {
645                return null;
646            }
647
648            // determine if the characters before the new space fit the pattern
649            // follow backwards and see if we find a comma, dot, or @
650            int scanBack = dstart;
651            boolean dotFound = false;
652            while (scanBack > 0) {
653                char c = dest.charAt(--scanBack);
654                switch (c) {
655                    case '.':
656                        dotFound = true;    // one or more dots are req'd
657                        break;
658                    case ',':
659                        return null;
660                    case '@':
661                        if (!dotFound) {
662                            return null;
663                        }
664
665                        // we have found a comma-insert case.  now just do it
666                        // in the least expensive way we can.
667                        if (source instanceof Spanned) {
668                            SpannableStringBuilder sb = new SpannableStringBuilder(",");
669                            sb.append(source);
670                            return sb;
671                        } else {
672                            return ", ";
673                        }
674                    default:
675                        // just keep going
676                }
677            }
678
679            // no termination cases were found, so don't edit the input
680            return null;
681        }
682    };
683
684    private void initViews() {
685        ViewGroup toParent = UiUtilities.getViewOrNull(this, R.id.to_content);
686        if (toParent != null) {
687            mToView = (MultiAutoCompleteTextView) toParent.findViewById(R.id.to);
688            ViewGroup ccParent, bccParent;
689            ccParent = (ViewGroup) findViewById(R.id.cc_content);
690            mCcView = (MultiAutoCompleteTextView) ccParent.findViewById(R.id.cc);
691            bccParent = (ViewGroup) findViewById(R.id.bcc_content);
692            mBccView = (MultiAutoCompleteTextView) bccParent.findViewById(R.id.bcc);
693        } else {
694            mToView = UiUtilities.getView(this, R.id.to);
695            mCcView = UiUtilities.getView(this, R.id.cc);
696            mBccView = UiUtilities.getView(this, R.id.bcc);
697        }
698
699        mFromView = UiUtilities.getView(this, R.id.from);
700        mCcBccContainer = UiUtilities.getView(this, R.id.cc_bcc_wrapper);
701        mSubjectView = UiUtilities.getView(this, R.id.subject);
702        mMessageContentView = UiUtilities.getView(this, R.id.body_text);
703        mAttachmentContentView = UiUtilities.getView(this, R.id.attachments);
704        mAttachmentContainer = UiUtilities.getView(this, R.id.attachment_container);
705        mQuotedTextArea = UiUtilities.getView(this, R.id.quoted_text_area);
706        mIncludeQuotedTextCheckBox = UiUtilities.getView(this, R.id.include_quoted_text);
707        mQuotedText = UiUtilities.getView(this, R.id.quoted_text);
708
709        InputFilter[] recipientFilters = new InputFilter[] { RECIPIENT_FILTER };
710
711        // NOTE: assumes no other filters are set
712        mToView.setFilters(recipientFilters);
713        mCcView.setFilters(recipientFilters);
714        mBccView.setFilters(recipientFilters);
715
716        /*
717         * We set this to invisible by default. Other methods will turn it back on if it's
718         * needed.
719         */
720        mQuotedTextArea.setVisibility(View.GONE);
721        setIncludeQuotedText(false, false);
722
723        mIncludeQuotedTextCheckBox.setOnClickListener(this);
724
725        EmailAddressValidator addressValidator = new EmailAddressValidator();
726
727        setupAddressAdapters();
728        mToView.setTokenizer(new Rfc822Tokenizer());
729        mToView.setValidator(addressValidator);
730
731        mCcView.setTokenizer(new Rfc822Tokenizer());
732        mCcView.setValidator(addressValidator);
733
734        mBccView.setTokenizer(new Rfc822Tokenizer());
735        mBccView.setValidator(addressValidator);
736
737        final View addCcBccView = UiUtilities.getViewOrNull(this, R.id.add_cc_bcc);
738        if (addCcBccView != null) {
739            // Tablet view.
740            addCcBccView.setOnClickListener(this);
741        }
742
743        final View addAttachmentView = UiUtilities.getViewOrNull(this, R.id.add_attachment);
744        if (addAttachmentView != null) {
745            // Tablet view.
746            addAttachmentView.setOnClickListener(this);
747        }
748
749        setFocusShifter(R.id.to_label, R.id.to);
750        setFocusShifter(R.id.cc_label, R.id.cc);
751        setFocusShifter(R.id.bcc_label, R.id.bcc);
752        setFocusShifter(R.id.composearea_tap_trap_bottom, R.id.body_text);
753
754        mMessageContentView.setOnFocusChangeListener(this);
755
756        updateAttachmentContainer();
757        mToView.requestFocus();
758    }
759
760    /**
761     * Initializes listeners. Should only be called once initializing of views is complete to
762     * avoid unnecessary draft saving.
763     */
764    private void addListeners() {
765        mToView.addTextChangedListener(mWatcher);
766        mCcView.addTextChangedListener(mWatcher);
767        mBccView.addTextChangedListener(mWatcher);
768        mSubjectView.addTextChangedListener(mWatcher);
769        mMessageContentView.addTextChangedListener(mWatcher);
770    }
771
772    /**
773     * Removes listeners from the user-editable fields. Can be used to temporarily disable them
774     * while resetting fields (such as when changing from reply to reply all) to avoid
775     * unnecessary saving.
776     */
777    private void removeListeners() {
778        mToView.removeTextChangedListener(mWatcher);
779        mCcView.removeTextChangedListener(mWatcher);
780        mBccView.removeTextChangedListener(mWatcher);
781        mSubjectView.removeTextChangedListener(mWatcher);
782        mMessageContentView.removeTextChangedListener(mWatcher);
783    }
784
785    /**
786     * Set up address auto-completion adapters.
787     */
788    private void setupAddressAdapters() {
789        boolean supportsChips = ChipsUtil.supportsChipsUi();
790
791        if (supportsChips && mToView instanceof RecipientEditTextView) {
792            mAddressAdapterTo = new RecipientAdapter(this, (RecipientEditTextView) mToView);
793            mToView.setAdapter((RecipientAdapter) mAddressAdapterTo);
794        } else {
795            mAddressAdapterTo = new EmailAddressAdapter(this);
796            mToView.setAdapter((EmailAddressAdapter) mAddressAdapterTo);
797        }
798        if (supportsChips && mCcView instanceof RecipientEditTextView) {
799            mAddressAdapterCc = new RecipientAdapter(this, (RecipientEditTextView) mCcView);
800            mCcView.setAdapter((RecipientAdapter) mAddressAdapterCc);
801        } else {
802            mAddressAdapterCc = new EmailAddressAdapter(this);
803            mCcView.setAdapter((EmailAddressAdapter) mAddressAdapterCc);
804        }
805        if (supportsChips && mBccView instanceof RecipientEditTextView) {
806            mAddressAdapterBcc = new RecipientAdapter(this, (RecipientEditTextView) mBccView);
807            mBccView.setAdapter((RecipientAdapter) mAddressAdapterBcc);
808        } else {
809            mAddressAdapterBcc = new EmailAddressAdapter(this);
810            mBccView.setAdapter((EmailAddressAdapter) mAddressAdapterBcc);
811        }
812    }
813
814    /**
815     * Asynchronously loads a draft message for editing.
816     * This may or may not restore the view contents, depending on whether or not callers want,
817     * since in the case of screen rotation, those are restored automatically.
818     */
819    private void resumeDraft(
820            long draftId,
821            SendOrSaveMessageTask existingSaveTask,
822            final boolean restoreViews) {
823        // Note - this can be Message.NOT_SAVED if there is an existing save task in progress
824        // for the draft we need to load.
825        mDraft.mId = draftId;
826
827        new LoadMessageTask(draftId, existingSaveTask, new OnMessageLoadHandler() {
828            @Override
829            public void onMessageLoaded(Message message, Body body) {
830                message.mHtml = body.mHtmlContent;
831                message.mText = body.mTextContent;
832                message.mHtmlReply = body.mHtmlReply;
833                message.mTextReply = body.mTextReply;
834                message.mIntroText = body.mIntroText;
835                message.mSourceKey = body.mSourceKey;
836
837                mDraft = message;
838                processDraftMessage(message, restoreViews);
839
840                // Load attachments related to the draft.
841                loadAttachments(message.mId, mAccount, new AttachmentLoadedCallback() {
842                    @Override
843                    public void onAttachmentLoaded(Attachment[] attachments) {
844                        for (Attachment attachment: attachments) {
845                            addAttachment(attachment);
846                        }
847                    }
848                });
849
850                // If we're resuming an edit of a reply, reply-all, or forward, re-load the
851                // source message if available so that we get more information.
852                if (message.mSourceKey != Message.NOT_SAVED) {
853                    loadSourceMessage(message.mSourceKey, false /* restore views */);
854                }
855            }
856
857            @Override
858            public void onLoadFailed() {
859                Utility.showToast(MessageCompose.this, R.string.error_loading_message_body);
860                finish();
861            }
862        }).executeSerial((Void[]) null);
863    }
864
865    @VisibleForTesting
866    void processDraftMessage(Message message, boolean restoreViews) {
867        if (restoreViews) {
868            mSubjectView.setText(message.mSubject);
869            addAddresses(mToView, Address.unpack(message.mTo));
870            Address[] cc = Address.unpack(message.mCc);
871            if (cc.length > 0) {
872                addAddresses(mCcView, cc);
873            }
874            Address[] bcc = Address.unpack(message.mBcc);
875            if (bcc.length > 0) {
876                addAddresses(mBccView, bcc);
877            }
878
879            mMessageContentView.setText(message.mText);
880
881            showCcBccFieldsIfFilled();
882            setNewMessageFocus();
883        }
884        setMessageChanged(false);
885
886        // The quoted text must always be restored.
887        displayQuotedText(message.mTextReply, message.mHtmlReply);
888        setIncludeQuotedText(
889                (mDraft.mFlags & Message.FLAG_NOT_INCLUDE_QUOTED_TEXT) == 0, false);
890    }
891
892    /**
893     * Asynchronously loads a source message (to be replied or forwarded in this current view),
894     * populating text fields and quoted text fields when the load finishes, if requested.
895     */
896    private void loadSourceMessage(long sourceMessageId, final boolean restoreViews) {
897        new LoadMessageTask(sourceMessageId, null, new OnMessageLoadHandler() {
898            @Override
899            public void onMessageLoaded(Message message, Body body) {
900                message.mHtml = body.mHtmlContent;
901                message.mText = body.mTextContent;
902                message.mHtmlReply = null;
903                message.mTextReply = null;
904                message.mIntroText = null;
905                mSource = message;
906                mSourceAttachments = new ArrayList<Attachment>();
907
908                if (restoreViews) {
909                    processSourceMessage(mSource, mAccount);
910                    setInitialComposeText(null, getAccountSignature(mAccount));
911                }
912
913                loadAttachments(message.mId, mAccount, new AttachmentLoadedCallback() {
914                    @Override
915                    public void onAttachmentLoaded(Attachment[] attachments) {
916                        final boolean supportsSmartForward =
917                            (mAccount.mFlags & Account.FLAGS_SUPPORTS_SMART_FORWARD) != 0;
918
919                        // Process the attachments to have the appropriate smart forward flags.
920                        for (Attachment attachment : attachments) {
921                            if (supportsSmartForward) {
922                                attachment.mFlags |= Attachment.FLAG_SMART_FORWARD;
923                            }
924                            mSourceAttachments.add(attachment);
925                        }
926                        if (isForward() && restoreViews) {
927                            if (processSourceMessageAttachments(
928                                    mAttachments, mSourceAttachments, true)) {
929                                updateAttachmentUi();
930                                setMessageChanged(true);
931                            }
932                        }
933                    }
934                });
935
936                if (mAction.equals(ACTION_EDIT_DRAFT)) {
937                    // Resuming a draft may in fact be resuming a reply/reply all/forward.
938                    // Use a best guess and infer the action here.
939                    String inferredAction = inferAction();
940                    if (inferredAction != null) {
941                        setAction(inferredAction);
942                        // No need to update the action selector as switching actions should do it.
943                        return;
944                    }
945                }
946
947                updateActionSelector();
948            }
949
950            @Override
951            public void onLoadFailed() {
952                // The loading of the source message is only really required if it is needed
953                // immediately to restore the view contents. In the case of resuming draft, it
954                // is only needed to gather additional information.
955                if (restoreViews) {
956                    Utility.showToast(MessageCompose.this, R.string.error_loading_message_body);
957                    finish();
958                }
959            }
960        }).executeSerial((Void[]) null);
961    }
962
963    /**
964     * Infers whether or not the current state of the message best reflects either a reply,
965     * reply-all, or forward.
966     */
967    @VisibleForTesting
968    String inferAction() {
969        String subject = mSubjectView.getText().toString();
970        if (subject == null) {
971            return null;
972        }
973        if (subject.toLowerCase().startsWith("fwd:")) {
974            return ACTION_FORWARD;
975        } else if (subject.toLowerCase().startsWith("re:")) {
976            int numRecipients = getAddresses(mToView).length
977                    + getAddresses(mCcView).length
978                    + getAddresses(mBccView).length;
979            if (numRecipients > 1) {
980                return ACTION_REPLY_ALL;
981            } else {
982                return ACTION_REPLY;
983            }
984        } else {
985            // Unsure.
986            return null;
987        }
988    }
989
990    private interface OnMessageLoadHandler {
991        /**
992         * Handles a load to a message (e.g. a draft message or a source message).
993         */
994        void onMessageLoaded(Message message, Body body);
995
996        /**
997         * Handles a failure to load a message.
998         */
999        void onLoadFailed();
1000    }
1001
1002    /**
1003     * Asynchronously loads a message and the account information.
1004     * This can be used to load a reference message (when replying) or when restoring a draft.
1005     */
1006    private class LoadMessageTask extends EmailAsyncTask<Void, Void, Object[]> {
1007        /**
1008         * The message ID to load, if available.
1009         */
1010        private long mMessageId;
1011
1012        /**
1013         * A future-like reference to the save task which must complete prior to this load.
1014         */
1015        private final SendOrSaveMessageTask mSaveTask;
1016
1017        /**
1018         * A callback to pass the results of the load to.
1019         */
1020        private final OnMessageLoadHandler mCallback;
1021
1022        public LoadMessageTask(
1023                long messageId, SendOrSaveMessageTask saveTask, OnMessageLoadHandler callback) {
1024            super(mTaskTracker);
1025            mMessageId = messageId;
1026            mSaveTask = saveTask;
1027            mCallback = callback;
1028        }
1029
1030        private long getIdToLoad() throws InterruptedException, ExecutionException {
1031            if (mMessageId == -1) {
1032                mMessageId = mSaveTask.get();
1033            }
1034            return mMessageId;
1035        }
1036
1037        @Override
1038        protected Object[] doInBackground(Void... params) {
1039            long messageId;
1040            try {
1041                messageId = getIdToLoad();
1042            } catch (InterruptedException e) {
1043                // Don't have a good message ID to load - bail.
1044                Log.e(Logging.LOG_TAG,
1045                        "Unable to load draft message since existing save task failed: " + e);
1046                return null;
1047            } catch (ExecutionException e) {
1048                // Don't have a good message ID to load - bail.
1049                Log.e(Logging.LOG_TAG,
1050                        "Unable to load draft message since existing save task failed: " + e);
1051                return null;
1052            }
1053            Message message = Message.restoreMessageWithId(MessageCompose.this, messageId);
1054            if (message == null) {
1055                return null;
1056            }
1057            long accountId = message.mAccountKey;
1058            Account account = Account.restoreAccountWithId(MessageCompose.this, accountId);
1059            Body body;
1060            try {
1061                body = Body.restoreBodyWithMessageId(MessageCompose.this, message.mId);
1062            } catch (RuntimeException e) {
1063                Log.d(Logging.LOG_TAG, "Exception while loading message body: " + e);
1064                return null;
1065            }
1066            return new Object[] {message, body, account};
1067        }
1068
1069        @Override
1070        protected void onSuccess(Object[] results) {
1071            if ((results == null) || (results.length != 3)) {
1072                mCallback.onLoadFailed();
1073                return;
1074            }
1075
1076            final Message message = (Message) results[0];
1077            final Body body = (Body) results[1];
1078            final Account account = (Account) results[2];
1079            if ((message == null) || (body == null) || (account == null)) {
1080                mCallback.onLoadFailed();
1081                return;
1082            }
1083
1084            setAccount(account);
1085            mCallback.onMessageLoaded(message, body);
1086            setMessageLoaded(true);
1087        }
1088    }
1089
1090    private interface AttachmentLoadedCallback {
1091        /**
1092         * Handles completion of the loading of a set of attachments.
1093         * Callback will always happen on the main thread.
1094         */
1095        void onAttachmentLoaded(Attachment[] attachment);
1096    }
1097
1098    private void loadAttachments(
1099            final long messageId,
1100            final Account account,
1101            final AttachmentLoadedCallback callback) {
1102        new EmailAsyncTask<Void, Void, Attachment[]>(mTaskTracker) {
1103            @Override
1104            protected Attachment[] doInBackground(Void... params) {
1105                return Attachment.restoreAttachmentsWithMessageId(MessageCompose.this, messageId);
1106            }
1107
1108            @Override
1109            protected void onSuccess(Attachment[] attachments) {
1110                if (attachments == null) {
1111                    attachments = new Attachment[0];
1112                }
1113                callback.onAttachmentLoaded(attachments);
1114            }
1115        }.executeSerial((Void[]) null);
1116    }
1117
1118    @Override
1119    public void onFocusChange(View view, boolean focused) {
1120        if (focused) {
1121            switch (view.getId()) {
1122                case R.id.body_text:
1123                    // When focusing on the message content via tabbing to it, or other means of
1124                    // auto focusing, move the cursor to the end of the body (before the signature).
1125                    if (mMessageContentView.getSelectionStart() == 0
1126                            && mMessageContentView.getSelectionEnd() == 0) {
1127                        // There is no way to determine if the focus change was programmatic or due
1128                        // to keyboard event, or if it was due to a tap/restore. Use a best-guess
1129                        // by using the fact that auto-focus/keyboard tabs set the selection to 0.
1130                        setMessageContentSelection(getAccountSignature(mAccount));
1131                    }
1132            }
1133        }
1134    }
1135
1136    private static void addAddresses(MultiAutoCompleteTextView view, Address[] addresses) {
1137        if (addresses == null) {
1138            return;
1139        }
1140        for (Address address : addresses) {
1141            addAddress(view, address.toString());
1142        }
1143    }
1144
1145    private static void addAddresses(MultiAutoCompleteTextView view, String[] addresses) {
1146        if (addresses == null) {
1147            return;
1148        }
1149        for (String oneAddress : addresses) {
1150            addAddress(view, oneAddress);
1151        }
1152    }
1153
1154    private static void addAddresses(MultiAutoCompleteTextView view, String addresses) {
1155        if (addresses == null) {
1156            return;
1157        }
1158        Address[] unpackedAddresses = Address.unpack(addresses);
1159        for (Address address : unpackedAddresses) {
1160            addAddress(view, address.toString());
1161        }
1162    }
1163
1164    private static void addAddress(MultiAutoCompleteTextView view, String address) {
1165        view.append(address + ", ");
1166    }
1167
1168    private static String getPackedAddresses(TextView view) {
1169        Address[] addresses = Address.parse(view.getText().toString().trim());
1170        return Address.pack(addresses);
1171    }
1172
1173    private static Address[] getAddresses(TextView view) {
1174        Address[] addresses = Address.parse(view.getText().toString().trim());
1175        return addresses;
1176    }
1177
1178    /*
1179     * Computes a short string indicating the destination of the message based on To, Cc, Bcc.
1180     * If only one address appears, returns the friendly form of that address.
1181     * Otherwise returns the friendly form of the first address appended with "and N others".
1182     */
1183    private String makeDisplayName(String packedTo, String packedCc, String packedBcc) {
1184        Address first = null;
1185        int nRecipients = 0;
1186        for (String packed: new String[] {packedTo, packedCc, packedBcc}) {
1187            Address[] addresses = Address.unpack(packed);
1188            nRecipients += addresses.length;
1189            if (first == null && addresses.length > 0) {
1190                first = addresses[0];
1191            }
1192        }
1193        if (nRecipients == 0) {
1194            return "";
1195        }
1196        String friendly = first.toFriendly();
1197        if (nRecipients == 1) {
1198            return friendly;
1199        }
1200        return this.getString(R.string.message_compose_display_name, friendly, nRecipients - 1);
1201    }
1202
1203    private ContentValues getUpdateContentValues(Message message) {
1204        ContentValues values = new ContentValues();
1205        values.put(MessageColumns.TIMESTAMP, message.mTimeStamp);
1206        values.put(MessageColumns.FROM_LIST, message.mFrom);
1207        values.put(MessageColumns.TO_LIST, message.mTo);
1208        values.put(MessageColumns.CC_LIST, message.mCc);
1209        values.put(MessageColumns.BCC_LIST, message.mBcc);
1210        values.put(MessageColumns.SUBJECT, message.mSubject);
1211        values.put(MessageColumns.DISPLAY_NAME, message.mDisplayName);
1212        values.put(MessageColumns.FLAG_READ, message.mFlagRead);
1213        values.put(MessageColumns.FLAG_LOADED, message.mFlagLoaded);
1214        values.put(MessageColumns.FLAG_ATTACHMENT, message.mFlagAttachment);
1215        values.put(MessageColumns.FLAGS, message.mFlags);
1216        return values;
1217    }
1218
1219    /**
1220     * Updates the given message using values from the compose UI.
1221     *
1222     * @param message The message to be updated.
1223     * @param account the account (used to obtain From: address).
1224     * @param hasAttachments true if it has one or more attachment.
1225     * @param sending set true if the message is about to sent, in which case we perform final
1226     *        clean up;
1227     */
1228    private void updateMessage(Message message, Account account, boolean hasAttachments,
1229            boolean sending) {
1230        if (message.mMessageId == null || message.mMessageId.length() == 0) {
1231            message.mMessageId = Utility.generateMessageId();
1232        }
1233        message.mTimeStamp = System.currentTimeMillis();
1234        message.mFrom = new Address(account.getEmailAddress(), account.getSenderName()).pack();
1235        message.mTo = getPackedAddresses(mToView);
1236        message.mCc = getPackedAddresses(mCcView);
1237        message.mBcc = getPackedAddresses(mBccView);
1238        message.mSubject = mSubjectView.getText().toString();
1239        message.mText = mMessageContentView.getText().toString();
1240        message.mAccountKey = account.mId;
1241        message.mDisplayName = makeDisplayName(message.mTo, message.mCc, message.mBcc);
1242        message.mFlagRead = true;
1243        message.mFlagLoaded = Message.FLAG_LOADED_COMPLETE;
1244        message.mFlagAttachment = hasAttachments;
1245        // Use the Intent to set flags saying this message is a reply or a forward and save the
1246        // unique id of the source message
1247        if (mSource != null && mQuotedTextArea.getVisibility() == View.VISIBLE) {
1248            message.mSourceKey = mSource.mId;
1249            // If the quote bar is visible; this must either be a reply or forward
1250            // Get the body of the source message here
1251            message.mHtmlReply = mSource.mHtml;
1252            message.mTextReply = mSource.mText;
1253            String fromAsString = Address.unpackToString(mSource.mFrom);
1254            if (isForward()) {
1255                message.mFlags |= Message.FLAG_TYPE_FORWARD;
1256                String subject = mSource.mSubject;
1257                String to = Address.unpackToString(mSource.mTo);
1258                String cc = Address.unpackToString(mSource.mCc);
1259                message.mIntroText =
1260                    getString(R.string.message_compose_fwd_header_fmt, subject, fromAsString,
1261                            to != null ? to : "", cc != null ? cc : "");
1262            } else {
1263                message.mFlags |= Message.FLAG_TYPE_REPLY;
1264                message.mIntroText =
1265                    getString(R.string.message_compose_reply_header_fmt, fromAsString);
1266            }
1267        }
1268
1269        if (includeQuotedText()) {
1270            message.mFlags &= ~Message.FLAG_NOT_INCLUDE_QUOTED_TEXT;
1271        } else {
1272            message.mFlags |= Message.FLAG_NOT_INCLUDE_QUOTED_TEXT;
1273            if (sending) {
1274                // If we are about to send a message, and not including the original message,
1275                // clear the related field.
1276                // We can't do this until the last minutes, so that the user can change their
1277                // mind later and want to include it again.
1278                mDraft.mIntroText = null;
1279                mDraft.mTextReply = null;
1280                mDraft.mHtmlReply = null;
1281
1282                // Note that mSourceKey is not cleared out as this is still considered a
1283                // reply/forward.
1284            }
1285        }
1286    }
1287
1288    private class SendOrSaveMessageTask extends EmailAsyncTask<Void, Void, Long> {
1289        private final boolean mSend;
1290        private final long mTaskId;
1291
1292        /** A context that will survive even past activity destruction. */
1293        private final Context mContext;
1294
1295        public SendOrSaveMessageTask(long taskId, boolean send) {
1296            super(null /* DO NOT cancel in onDestroy */);
1297            if (send && ActivityManager.isUserAMonkey()) {
1298                Log.d(Logging.LOG_TAG, "Inhibiting send while monkey is in charge.");
1299                send = false;
1300            }
1301            mTaskId = taskId;
1302            mSend = send;
1303            mContext = getApplicationContext();
1304
1305            sActiveSaveTasks.put(mTaskId, this);
1306        }
1307
1308        @Override
1309        protected Long doInBackground(Void... params) {
1310            synchronized (mDraft) {
1311                updateMessage(mDraft, mAccount, mAttachments.size() > 0, mSend);
1312                ContentResolver resolver = getContentResolver();
1313                if (mDraft.isSaved()) {
1314                    // Update the message
1315                    Uri draftUri =
1316                        ContentUris.withAppendedId(Message.SYNCED_CONTENT_URI, mDraft.mId);
1317                    resolver.update(draftUri, getUpdateContentValues(mDraft), null, null);
1318                    // Update the body
1319                    ContentValues values = new ContentValues();
1320                    values.put(BodyColumns.TEXT_CONTENT, mDraft.mText);
1321                    values.put(BodyColumns.TEXT_REPLY, mDraft.mTextReply);
1322                    values.put(BodyColumns.HTML_REPLY, mDraft.mHtmlReply);
1323                    values.put(BodyColumns.INTRO_TEXT, mDraft.mIntroText);
1324                    values.put(BodyColumns.SOURCE_MESSAGE_KEY, mDraft.mSourceKey);
1325                    Body.updateBodyWithMessageId(MessageCompose.this, mDraft.mId, values);
1326                } else {
1327                    // mDraft.mId is set upon return of saveToMailbox()
1328                    mController.saveToMailbox(mDraft, Mailbox.TYPE_DRAFTS);
1329                }
1330                // For any unloaded attachment, set the flag saying we need it loaded
1331                boolean hasUnloadedAttachments = false;
1332                for (Attachment attachment : mAttachments) {
1333                    if (attachment.mContentUri == null &&
1334                            ((attachment.mFlags & Attachment.FLAG_SMART_FORWARD) == 0)) {
1335                        attachment.mFlags |= Attachment.FLAG_DOWNLOAD_FORWARD;
1336                        hasUnloadedAttachments = true;
1337                        if (Email.DEBUG) {
1338                            Log.d(Logging.LOG_TAG,
1339                                    "Requesting download of attachment #" + attachment.mId);
1340                        }
1341                    }
1342                    // Make sure the UI version of the attachment has the now-correct id; we will
1343                    // use the id again when coming back from picking new attachments
1344                    if (!attachment.isSaved()) {
1345                        // this attachment is new so save it to DB.
1346                        attachment.mMessageKey = mDraft.mId;
1347                        attachment.save(MessageCompose.this);
1348                    } else if (attachment.mMessageKey != mDraft.mId) {
1349                        // We clone the attachment and save it again; otherwise, it will
1350                        // continue to point to the source message.  From this point forward,
1351                        // the attachments will be independent of the original message in the
1352                        // database; however, we still need the message on the server in order
1353                        // to retrieve unloaded attachments
1354                        attachment.mMessageKey = mDraft.mId;
1355                        ContentValues cv = attachment.toContentValues();
1356                        cv.put(Attachment.FLAGS, attachment.mFlags);
1357                        cv.put(Attachment.MESSAGE_KEY, mDraft.mId);
1358                        getContentResolver().insert(Attachment.CONTENT_URI, cv);
1359                    }
1360                }
1361
1362                if (mSend) {
1363                    // Let the user know if message sending might be delayed by background
1364                    // downlading of unloaded attachments
1365                    if (hasUnloadedAttachments) {
1366                        Utility.showToast(MessageCompose.this,
1367                                R.string.message_view_attachment_background_load);
1368                    }
1369                    mController.sendMessage(mDraft);
1370
1371                    ArrayList<CharSequence> addressTexts = new ArrayList<CharSequence>();
1372                    addressTexts.add(mToView.getText());
1373                    addressTexts.add(mCcView.getText());
1374                    addressTexts.add(mBccView.getText());
1375                    DataUsageStatUpdater updater = new DataUsageStatUpdater(mContext);
1376                    updater.updateWithRfc822Address(addressTexts);
1377                }
1378                return mDraft.mId;
1379            }
1380        }
1381
1382        private boolean shouldShowSaveToast() {
1383            // Don't show the toast when rotating, or when opening an Activity on top of this one.
1384            return !isChangingConfigurations() && !mPickingAttachment;
1385        }
1386
1387        @Override
1388        protected void onSuccess(Long draftId) {
1389            // Note that send or save tasks are always completed, even if the activity
1390            // finishes earlier.
1391            sActiveSaveTasks.remove(mTaskId);
1392            // Don't display the toast if the user is just changing the orientation
1393            if (!mSend && shouldShowSaveToast()) {
1394                Toast.makeText(mContext, R.string.message_saved_toast, Toast.LENGTH_LONG).show();
1395            }
1396        }
1397    }
1398
1399    /**
1400     * Send or save a message:
1401     * - out of the UI thread
1402     * - write to Drafts
1403     * - if send, invoke Controller.sendMessage()
1404     * - when operation is complete, display toast
1405     */
1406    private void sendOrSaveMessage(boolean send) {
1407        if (!mMessageLoaded) {
1408            Log.w(Logging.LOG_TAG,
1409                    "Attempted to save draft message prior to the state being fully loaded");
1410            return;
1411        }
1412        synchronized (sActiveSaveTasks) {
1413            mLastSaveTaskId = sNextSaveTaskId++;
1414
1415            SendOrSaveMessageTask task = new SendOrSaveMessageTask(mLastSaveTaskId, send);
1416
1417            // Ensure the tasks are executed serially so that rapid scheduling doesn't result
1418            // in inconsistent data.
1419            task.executeSerial();
1420        }
1421   }
1422
1423    private void saveIfNeeded() {
1424        if (!mDraftNeedsSaving) {
1425            return;
1426        }
1427        setMessageChanged(false);
1428        sendOrSaveMessage(false);
1429    }
1430
1431    /**
1432     * Checks whether all the email addresses listed in TO, CC, BCC are valid.
1433     */
1434    @VisibleForTesting
1435    boolean isAddressAllValid() {
1436        boolean supportsChips = ChipsUtil.supportsChipsUi();
1437        for (TextView view : new TextView[]{mToView, mCcView, mBccView}) {
1438            String addresses = view.getText().toString().trim();
1439            if (!Address.isAllValid(addresses)) {
1440                // Don't show an error message if we're using chips as the chips have
1441                // their own error state.
1442                if (!supportsChips || !(view instanceof RecipientEditTextView)) {
1443                    view.setError(getString(R.string.message_compose_error_invalid_email));
1444                }
1445                return false;
1446            }
1447        }
1448        return true;
1449    }
1450
1451    private void onSend() {
1452        if (!isAddressAllValid()) {
1453            Toast.makeText(this, getString(R.string.message_compose_error_invalid_email),
1454                           Toast.LENGTH_LONG).show();
1455        } else if (getAddresses(mToView).length == 0 &&
1456                getAddresses(mCcView).length == 0 &&
1457                getAddresses(mBccView).length == 0) {
1458            mToView.setError(getString(R.string.message_compose_error_no_recipients));
1459            Toast.makeText(this, getString(R.string.message_compose_error_no_recipients),
1460                    Toast.LENGTH_LONG).show();
1461        } else {
1462            sendOrSaveMessage(true);
1463            setMessageChanged(false);
1464            finish();
1465        }
1466    }
1467
1468    private void showQuickResponseDialog() {
1469        if (mAccount == null) {
1470            // Load not finished, bail.
1471            return;
1472        }
1473        InsertQuickResponseDialog.newInstance(null, mAccount)
1474                .show(getFragmentManager(), null);
1475    }
1476
1477    /**
1478     * Inserts the selected QuickResponse into the message body at the current cursor position.
1479     */
1480    @Override
1481    public void onQuickResponseSelected(CharSequence text) {
1482        int start = mMessageContentView.getSelectionStart();
1483        int end = mMessageContentView.getSelectionEnd();
1484        mMessageContentView.getEditableText().replace(start, end, text);
1485    }
1486
1487    private void onDiscard() {
1488        DeleteMessageConfirmationDialog.newInstance(1, null).show(getFragmentManager(), "dialog");
1489    }
1490
1491    /**
1492     * Called when ok on the "discard draft" dialog is pressed.  Actually delete the draft.
1493     */
1494    @Override
1495    public void onDeleteMessageConfirmationDialogOkPressed() {
1496        if (mDraft.mId > 0) {
1497            // By the way, we can't pass the message ID from onDiscard() to here (using a
1498            // dialog argument or whatever), because you can rotate the screen when the dialog is
1499            // shown, and during rotation we save & restore the draft.  If it's the
1500            // first save, we give it an ID at this point for the first time (and last time).
1501            // Which means it's possible for a draft to not have an ID in onDiscard(),
1502            // but here.
1503            mController.deleteMessage(mDraft.mId);
1504        }
1505        Utility.showToast(MessageCompose.this, R.string.message_discarded_toast);
1506        setMessageChanged(false);
1507        finish();
1508    }
1509
1510    /**
1511     * Handles an explicit user-initiated action to save a draft.
1512     */
1513    private void onSave() {
1514        saveIfNeeded();
1515    }
1516
1517    private void showCcBccFieldsIfFilled() {
1518        if ((mCcView.length() > 0) || (mBccView.length() > 0)) {
1519            showCcBccFields();
1520        }
1521    }
1522
1523    private void showCcBccFields() {
1524        if (mCcBccContainer.getVisibility() != View.VISIBLE) {
1525            mCcBccContainer.setVisibility(View.VISIBLE);
1526            mCcView.requestFocus();
1527            UiUtilities.setVisibilitySafe(this, R.id.add_cc_bcc, View.INVISIBLE);
1528            invalidateOptionsMenu();
1529        }
1530    }
1531
1532    /**
1533     * Kick off a picker for whatever kind of MIME types we'll accept and let Android take over.
1534     */
1535    private void onAddAttachment() {
1536        Intent i = new Intent(Intent.ACTION_GET_CONTENT);
1537        i.addCategory(Intent.CATEGORY_OPENABLE);
1538        i.addFlags(Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET);
1539        i.setType(AttachmentUtilities.ACCEPTABLE_ATTACHMENT_SEND_UI_TYPES[0]);
1540        mPickingAttachment = true;
1541        startActivityForResult(
1542                Intent.createChooser(i, getString(R.string.choose_attachment_dialog_title)),
1543                ACTIVITY_REQUEST_PICK_ATTACHMENT);
1544    }
1545
1546    private Attachment loadAttachmentInfo(Uri uri) {
1547        long size = -1;
1548        ContentResolver contentResolver = getContentResolver();
1549
1550        // Load name & size independently, because not all providers support both
1551        final String name = Utility.getContentFileName(this, uri);
1552
1553        Cursor metadataCursor = contentResolver.query(uri, ATTACHMENT_META_SIZE_PROJECTION,
1554                null, null, null);
1555        if (metadataCursor != null) {
1556            try {
1557                if (metadataCursor.moveToFirst()) {
1558                    size = metadataCursor.getLong(ATTACHMENT_META_SIZE_COLUMN_SIZE);
1559                }
1560            } finally {
1561                metadataCursor.close();
1562            }
1563        }
1564
1565        // When the size is not provided, we need to determine it locally.
1566        if (size < 0) {
1567            // if the URI is a file: URI, ask file system for its size
1568            if ("file".equalsIgnoreCase(uri.getScheme())) {
1569                String path = uri.getPath();
1570                if (path != null) {
1571                    File file = new File(path);
1572                    size = file.length();  // Returns 0 for file not found
1573                }
1574            }
1575
1576            if (size <= 0) {
1577                // The size was not measurable;  This attachment is not safe to use.
1578                // Quick hack to force a relevant error into the UI
1579                // TODO: A proper announcement of the problem
1580                size = AttachmentUtilities.MAX_ATTACHMENT_UPLOAD_SIZE + 1;
1581            }
1582        }
1583
1584        Attachment attachment = new Attachment();
1585        attachment.mFileName = name;
1586        attachment.mContentUri = uri.toString();
1587        attachment.mSize = size;
1588        attachment.mMimeType = AttachmentUtilities.inferMimeTypeForUri(this, uri);
1589        return attachment;
1590    }
1591
1592    private void addAttachment(Attachment attachment) {
1593        // Before attaching the attachment, make sure it meets any other pre-attach criteria
1594        if (attachment.mSize > AttachmentUtilities.MAX_ATTACHMENT_UPLOAD_SIZE) {
1595            Toast.makeText(this, R.string.message_compose_attachment_size, Toast.LENGTH_LONG)
1596                    .show();
1597            return;
1598        }
1599
1600        mAttachments.add(attachment);
1601        updateAttachmentUi();
1602    }
1603
1604    private void updateAttachmentUi() {
1605        mAttachmentContentView.removeAllViews();
1606
1607        for (Attachment attachment : mAttachments) {
1608            // Note: allowDelete is set in two cases:
1609            // 1. First time a message (w/ attachments) is forwarded,
1610            //    where action == ACTION_FORWARD
1611            // 2. 1 -> Save -> Reopen
1612            //    but FLAG_SMART_FORWARD is already set at 1.
1613            // Even if the account supports smart-forward, attachments added
1614            // manually are still removable.
1615            final boolean allowDelete = (attachment.mFlags & Attachment.FLAG_SMART_FORWARD) == 0;
1616
1617            View view = getLayoutInflater().inflate(R.layout.attachment, mAttachmentContentView,
1618                    false);
1619            TextView nameView = UiUtilities.getView(view, R.id.attachment_name);
1620            ImageView delete = UiUtilities.getView(view, R.id.remove_attachment);
1621            TextView sizeView = UiUtilities.getView(view, R.id.attachment_size);
1622
1623            nameView.setText(attachment.mFileName);
1624            if (attachment.mSize > 0) {
1625                sizeView.setText(UiUtilities.formatSize(this, attachment.mSize));
1626            } else {
1627                sizeView.setVisibility(View.GONE);
1628            }
1629            if (allowDelete) {
1630                delete.setOnClickListener(this);
1631                delete.setTag(view);
1632            } else {
1633                delete.setVisibility(View.INVISIBLE);
1634            }
1635            view.setTag(attachment);
1636            mAttachmentContentView.addView(view);
1637        }
1638        updateAttachmentContainer();
1639    }
1640
1641    private void updateAttachmentContainer() {
1642        mAttachmentContainer.setVisibility(mAttachmentContentView.getChildCount() == 0
1643                ? View.GONE : View.VISIBLE);
1644    }
1645
1646    private void addAttachmentFromUri(Uri uri) {
1647        addAttachment(loadAttachmentInfo(uri));
1648    }
1649
1650    /**
1651     * Same as {@link #addAttachmentFromUri}, but does the mime-type check against
1652     * {@link AttachmentUtilities#ACCEPTABLE_ATTACHMENT_SEND_INTENT_TYPES}.
1653     */
1654    private void addAttachmentFromSendIntent(Uri uri) {
1655        final Attachment attachment = loadAttachmentInfo(uri);
1656        final String mimeType = attachment.mMimeType;
1657        if (!TextUtils.isEmpty(mimeType) && MimeUtility.mimeTypeMatches(mimeType,
1658                AttachmentUtilities.ACCEPTABLE_ATTACHMENT_SEND_INTENT_TYPES)) {
1659            addAttachment(attachment);
1660        }
1661    }
1662
1663    @Override
1664    protected void onActivityResult(int requestCode, int resultCode, Intent data) {
1665        mPickingAttachment = false;
1666        if (data == null) {
1667            return;
1668        }
1669        addAttachmentFromUri(data.getData());
1670        setMessageChanged(true);
1671    }
1672
1673    private boolean includeQuotedText() {
1674        return mIncludeQuotedTextCheckBox.isChecked();
1675    }
1676
1677    public void onClick(View view) {
1678        if (handleCommand(view.getId())) {
1679            return;
1680        }
1681        switch (view.getId()) {
1682            case R.id.remove_attachment:
1683                onDeleteAttachmentIconClicked(view);
1684                break;
1685        }
1686    }
1687
1688    private void setIncludeQuotedText(boolean include, boolean updateNeedsSaving) {
1689        mIncludeQuotedTextCheckBox.setChecked(include);
1690        mQuotedText.setVisibility(mIncludeQuotedTextCheckBox.isChecked()
1691                ? View.VISIBLE : View.GONE);
1692        if (updateNeedsSaving) {
1693            setMessageChanged(true);
1694        }
1695    }
1696
1697    private void onDeleteAttachmentIconClicked(View delButtonView) {
1698        View attachmentView = (View) delButtonView.getTag();
1699        Attachment attachment = (Attachment) attachmentView.getTag();
1700        deleteAttachment(mAttachments, attachment);
1701        updateAttachmentUi();
1702        setMessageChanged(true);
1703    }
1704
1705    /**
1706     * Removes an attachment from the current message.
1707     * If the attachment has previous been saved in the db (i.e. this is a draft message which
1708     * has previously been saved), then the draft is deleted from the db.
1709     *
1710     * This does not update the UI to remove the attachment view.
1711     * @param attachments the list of attachments to delete from. Injected for tests.
1712     * @param attachment the attachment to delete
1713     */
1714    private void deleteAttachment(List<Attachment> attachments, Attachment attachment) {
1715        attachments.remove(attachment);
1716        if ((attachment.mMessageKey == mDraft.mId) && attachment.isSaved()) {
1717            final long attachmentId = attachment.mId;
1718            EmailAsyncTask.runAsyncParallel(new Runnable() {
1719                @Override
1720                public void run() {
1721                    mController.deleteAttachment(attachmentId);
1722                }
1723            });
1724        }
1725    }
1726
1727    @Override
1728    public boolean onOptionsItemSelected(MenuItem item) {
1729        if (handleCommand(item.getItemId())) {
1730            return true;
1731        }
1732        return super.onOptionsItemSelected(item);
1733    }
1734
1735    private boolean handleCommand(int viewId) {
1736        switch (viewId) {
1737        case android.R.id.home:
1738            onBack(false /* systemKey */);
1739            return true;
1740        case R.id.send:
1741            onSend();
1742            return true;
1743        case R.id.save:
1744            onSave();
1745            return true;
1746        case R.id.show_quick_text_list_dialog:
1747            showQuickResponseDialog();
1748            return true;
1749        case R.id.discard:
1750            onDiscard();
1751            return true;
1752        case R.id.include_quoted_text:
1753            // The checkbox is already toggled at this point.
1754            setIncludeQuotedText(mIncludeQuotedTextCheckBox.isChecked(), true);
1755            return true;
1756        case R.id.add_cc_bcc:
1757            showCcBccFields();
1758            return true;
1759        case R.id.add_attachment:
1760            onAddAttachment();
1761            return true;
1762        case R.id.settings:
1763            AccountSettings.actionSettings(this, mAccount.mId);
1764            return true;
1765        }
1766        return false;
1767    }
1768
1769    /**
1770     * Handle a tap to the system back key, or the "app up" button in the action bar.
1771     * @param systemKey whether or not the system key was pressed
1772     */
1773    private void onBack(boolean systemKey) {
1774        finish();
1775        if (isOpenedFromWithinApp()) {
1776            // If opened from within the app, we just close it.
1777            return;
1778        }
1779
1780        if ((isOpenedFromWidget() || !systemKey) && (mAccount != null)) {
1781            // Otherwise, need to open the main screen for the appropriate account.
1782            // Note that mAccount should always be set by the time the action bar is set up.
1783            startActivity(Welcome.createOpenAccountInboxIntent(this, mAccount.mId));
1784        }
1785    }
1786
1787    private void setAction(String action) {
1788        if (Objects.equal(action, mAction)) {
1789            return;
1790        }
1791
1792        mAction = action;
1793        onActionChanged();
1794    }
1795
1796    /**
1797     * Handles changing from reply/reply all/forward states. Note: this activity cannot transition
1798     * from a standard compose state to any of the other three states.
1799     */
1800    private void onActionChanged() {
1801        if (!hasSourceMessage()) {
1802            return;
1803        }
1804        // Temporarily remove listeners so that changing action does not invalidate and save message
1805        removeListeners();
1806
1807        processSourceMessage(mSource, mAccount);
1808
1809        // Note that the attachments might not be loaded yet, but this will safely noop
1810        // if that's the case, and the attachments will be processed when they load.
1811        if (processSourceMessageAttachments(mAttachments, mSourceAttachments, isForward())) {
1812            updateAttachmentUi();
1813            setMessageChanged(true);
1814        }
1815
1816        updateActionSelector();
1817        addListeners();
1818    }
1819
1820    /**
1821     * Updates UI components that allows the user to switch between reply/reply all/forward.
1822     */
1823    private void updateActionSelector() {
1824        ActionBar actionBar = getActionBar();
1825        // Spinner based mode switching.
1826        if (mActionSpinnerAdapter == null) {
1827            mActionSpinnerAdapter = new ActionSpinnerAdapter(this);
1828            actionBar.setListNavigationCallbacks(mActionSpinnerAdapter, ACTION_SPINNER_LISTENER);
1829        }
1830        actionBar.setNavigationMode(ActionBar.NAVIGATION_MODE_LIST);
1831        actionBar.setSelectedNavigationItem(ActionSpinnerAdapter.getActionPosition(mAction));
1832        actionBar.setDisplayShowTitleEnabled(false);
1833    }
1834
1835    private final OnNavigationListener ACTION_SPINNER_LISTENER = new OnNavigationListener() {
1836        @Override
1837        public boolean onNavigationItemSelected(int itemPosition, long itemId) {
1838            setAction(ActionSpinnerAdapter.getAction(itemPosition));
1839            return true;
1840        }
1841    };
1842
1843    private static class ActionSpinnerAdapter extends ArrayAdapter<String> {
1844        public ActionSpinnerAdapter(final Context context) {
1845            super(context,
1846                    android.R.layout.simple_spinner_dropdown_item,
1847                    android.R.id.text1,
1848                    Lists.newArrayList(ACTION_REPLY, ACTION_REPLY_ALL, ACTION_FORWARD));
1849        }
1850
1851        @Override
1852        public View getDropDownView(int position, View convertView, ViewGroup parent) {
1853            View result = super.getDropDownView(position, convertView, parent);
1854            ((TextView) result.findViewById(android.R.id.text1)).setText(getDisplayValue(position));
1855            return result;
1856        }
1857
1858        @Override
1859        public View getView(int position, View convertView, ViewGroup parent) {
1860            View result = super.getView(position, convertView, parent);
1861            ((TextView) result.findViewById(android.R.id.text1)).setText(getDisplayValue(position));
1862            return result;
1863        }
1864
1865        private String getDisplayValue(int position) {
1866            switch (position) {
1867                case 0:
1868                    return getContext().getString(R.string.reply_action);
1869                case 1:
1870                    return getContext().getString(R.string.reply_all_action);
1871                case 2:
1872                    return getContext().getString(R.string.forward_action);
1873                default:
1874                    throw new IllegalArgumentException("Invalid action type for spinner");
1875            }
1876        }
1877
1878        public static String getAction(int position) {
1879            switch (position) {
1880                case 0:
1881                    return ACTION_REPLY;
1882                case 1:
1883                    return ACTION_REPLY_ALL;
1884                case 2:
1885                    return ACTION_FORWARD;
1886                default:
1887                    throw new IllegalArgumentException("Invalid action type for spinner");
1888            }
1889        }
1890
1891        public static int getActionPosition(String action) {
1892            if (ACTION_REPLY.equals(action)) {
1893                return 0;
1894            } else if (ACTION_REPLY_ALL.equals(action)) {
1895                return 1;
1896            } else if (ACTION_FORWARD.equals(action)) {
1897                return 2;
1898            }
1899            throw new IllegalArgumentException("Invalid action type for spinner");
1900        }
1901
1902    }
1903
1904    @Override
1905    public boolean onCreateOptionsMenu(Menu menu) {
1906        super.onCreateOptionsMenu(menu);
1907        getMenuInflater().inflate(R.menu.message_compose_option, menu);
1908        return true;
1909    }
1910
1911    @Override
1912    public boolean onPrepareOptionsMenu(Menu menu) {
1913        menu.findItem(R.id.save).setEnabled(mDraftNeedsSaving);
1914        MenuItem addCcBcc = menu.findItem(R.id.add_cc_bcc);
1915        if (addCcBcc != null) {
1916            // Only available on phones.
1917            addCcBcc.setVisible(
1918                    (mCcBccContainer == null) || (mCcBccContainer.getVisibility() != View.VISIBLE));
1919        }
1920        MenuItem insertQuickResponse = menu.findItem(R.id.show_quick_text_list_dialog);
1921        insertQuickResponse.setVisible(mQuickResponsesAvailable);
1922        insertQuickResponse.setEnabled(mQuickResponsesAvailable);
1923        return true;
1924    }
1925
1926    /**
1927     * Set a message body and a signature when the Activity is launched.
1928     *
1929     * @param text the message body
1930     */
1931    @VisibleForTesting
1932    void setInitialComposeText(CharSequence text, String signature) {
1933        mMessageContentView.setText("");
1934        int textLength = 0;
1935        if (text != null) {
1936            mMessageContentView.append(text);
1937            textLength = text.length();
1938        }
1939        if (!TextUtils.isEmpty(signature)) {
1940            if (textLength == 0 || text.charAt(textLength - 1) != '\n') {
1941                mMessageContentView.append("\n");
1942            }
1943            mMessageContentView.append(signature);
1944
1945            // Reset cursor to right before the signature.
1946            mMessageContentView.setSelection(textLength);
1947        }
1948    }
1949
1950    /**
1951     * Fill all the widgets with the content found in the Intent Extra, if any.
1952     *
1953     * Note that we don't actually check the intent action  (typically VIEW, SENDTO, or SEND).
1954     * There is enough overlap in the definitions that it makes more sense to simply check for
1955     * all available data and use as much of it as possible.
1956     *
1957     * With one exception:  EXTRA_STREAM is defined as only valid for ACTION_SEND.
1958     *
1959     * @param intent the launch intent
1960     */
1961    @VisibleForTesting
1962    void initFromIntent(Intent intent) {
1963
1964        setAccount(intent);
1965
1966        // First, add values stored in top-level extras
1967        String[] extraStrings = intent.getStringArrayExtra(Intent.EXTRA_EMAIL);
1968        if (extraStrings != null) {
1969            addAddresses(mToView, extraStrings);
1970        }
1971        extraStrings = intent.getStringArrayExtra(Intent.EXTRA_CC);
1972        if (extraStrings != null) {
1973            addAddresses(mCcView, extraStrings);
1974        }
1975        extraStrings = intent.getStringArrayExtra(Intent.EXTRA_BCC);
1976        if (extraStrings != null) {
1977            addAddresses(mBccView, extraStrings);
1978        }
1979        String extraString = intent.getStringExtra(Intent.EXTRA_SUBJECT);
1980        if (extraString != null) {
1981            mSubjectView.setText(extraString);
1982        }
1983
1984        // Next, if we were invoked with a URI, try to interpret it
1985        // We'll take two courses here.  If it's mailto:, there is a specific set of rules
1986        // that define various optional fields.  However, for any other scheme, we'll simply
1987        // take the entire scheme-specific part and interpret it as a possible list of addresses.
1988        final Uri dataUri = intent.getData();
1989        if (dataUri != null) {
1990            if ("mailto".equals(dataUri.getScheme())) {
1991                initializeFromMailTo(dataUri.toString());
1992            } else {
1993                String toText = dataUri.getSchemeSpecificPart();
1994                if (toText != null) {
1995                    addAddresses(mToView, toText.split(","));
1996                }
1997            }
1998        }
1999
2000        // Next, fill in the plaintext (note, this will override mailto:?body=)
2001        CharSequence text = intent.getCharSequenceExtra(Intent.EXTRA_TEXT);
2002        setInitialComposeText(text, getAccountSignature(mAccount));
2003
2004        // Next, convert EXTRA_STREAM into an attachment
2005        if (Intent.ACTION_SEND.equals(mAction) && intent.hasExtra(Intent.EXTRA_STREAM)) {
2006            Uri uri = (Uri) intent.getParcelableExtra(Intent.EXTRA_STREAM);
2007            if (uri != null) {
2008                addAttachmentFromSendIntent(uri);
2009            }
2010        }
2011
2012        if (Intent.ACTION_SEND_MULTIPLE.equals(mAction)
2013                && intent.hasExtra(Intent.EXTRA_STREAM)) {
2014            ArrayList<Parcelable> list = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM);
2015            if (list != null) {
2016                for (Parcelable parcelable : list) {
2017                    Uri uri = (Uri) parcelable;
2018                    if (uri != null) {
2019                        addAttachmentFromSendIntent(uri);
2020                    }
2021                }
2022            }
2023        }
2024
2025        // Finally - expose fields that were filled in but are normally hidden, and set focus
2026        showCcBccFieldsIfFilled();
2027        setNewMessageFocus();
2028    }
2029
2030    /**
2031     * When we are launched with an intent that includes a mailto: URI, we can actually
2032     * gather quite a few of our message fields from it.
2033     *
2034     * @param mailToString the href (which must start with "mailto:").
2035     */
2036    private void initializeFromMailTo(String mailToString) {
2037
2038        // Chop up everything between mailto: and ? to find recipients
2039        int index = mailToString.indexOf("?");
2040        int length = "mailto".length() + 1;
2041        String to;
2042        try {
2043            // Extract the recipient after mailto:
2044            if (index == -1) {
2045                to = decode(mailToString.substring(length));
2046            } else {
2047                to = decode(mailToString.substring(length, index));
2048            }
2049            addAddresses(mToView, to.split(" ,"));
2050        } catch (UnsupportedEncodingException e) {
2051            Log.e(Logging.LOG_TAG, e.getMessage() + " while decoding '" + mailToString + "'");
2052        }
2053
2054        // Extract the other parameters
2055
2056        // We need to disguise this string as a URI in order to parse it
2057        Uri uri = Uri.parse("foo://" + mailToString);
2058
2059        List<String> cc = uri.getQueryParameters("cc");
2060        addAddresses(mCcView, cc.toArray(new String[cc.size()]));
2061
2062        List<String> otherTo = uri.getQueryParameters("to");
2063        addAddresses(mCcView, otherTo.toArray(new String[otherTo.size()]));
2064
2065        List<String> bcc = uri.getQueryParameters("bcc");
2066        addAddresses(mBccView, bcc.toArray(new String[bcc.size()]));
2067
2068        List<String> subject = uri.getQueryParameters("subject");
2069        if (subject.size() > 0) {
2070            mSubjectView.setText(subject.get(0));
2071        }
2072
2073        List<String> body = uri.getQueryParameters("body");
2074        if (body.size() > 0) {
2075            setInitialComposeText(body.get(0), getAccountSignature(mAccount));
2076        }
2077    }
2078
2079    private String decode(String s) throws UnsupportedEncodingException {
2080        return URLDecoder.decode(s, "UTF-8");
2081    }
2082
2083    /**
2084     * Displays quoted text from the original email
2085     */
2086    private void displayQuotedText(String textBody, String htmlBody) {
2087        // Only use plain text if there is no HTML body
2088        boolean plainTextFlag = TextUtils.isEmpty(htmlBody);
2089        String text = plainTextFlag ? textBody : htmlBody;
2090        if (text != null) {
2091            text = plainTextFlag ? EmailHtmlUtil.escapeCharacterToDisplay(text) : text;
2092            // TODO: re-enable EmailHtmlUtil.resolveInlineImage() for HTML
2093            //    EmailHtmlUtil.resolveInlineImage(getContentResolver(), mAccount,
2094            //                                     text, message, 0);
2095            mQuotedTextArea.setVisibility(View.VISIBLE);
2096            if (mQuotedText != null) {
2097                mQuotedText.loadDataWithBaseURL("email://", text, "text/html", "utf-8", null);
2098            }
2099        }
2100    }
2101
2102    /**
2103     * Given a packed address String, the address of our sending account, a view, and a list of
2104     * addressees already added to other addressing views, adds unique addressees that don't
2105     * match our address to the passed in view
2106     */
2107    private static boolean safeAddAddresses(String addrs, String ourAddress,
2108            MultiAutoCompleteTextView view, ArrayList<Address> addrList) {
2109        boolean added = false;
2110        for (Address address : Address.unpack(addrs)) {
2111            // Don't send to ourselves or already-included addresses
2112            if (!address.getAddress().equalsIgnoreCase(ourAddress) && !addrList.contains(address)) {
2113                addrList.add(address);
2114                addAddress(view, address.toString());
2115                added = true;
2116            }
2117        }
2118        return added;
2119    }
2120
2121    /**
2122     * Set up the to and cc views properly for the "reply" and "replyAll" cases.  What's important
2123     * is that we not 1) send to ourselves, and 2) duplicate addressees.
2124     * @param message the message we're replying to
2125     * @param account the account we're sending from
2126     * @param replyAll whether this is a replyAll (vs a reply)
2127     */
2128    @VisibleForTesting
2129    void setupAddressViews(Message message, Account account, boolean replyAll) {
2130        // Start clean.
2131        clearAddressViews();
2132
2133        // If Reply-to: addresses are included, use those; otherwise, use the From: address.
2134        Address[] replyToAddresses = Address.unpack(message.mReplyTo);
2135        if (replyToAddresses.length == 0) {
2136            replyToAddresses = Address.unpack(message.mFrom);
2137        }
2138
2139        // Check if ourAddress is one of the replyToAddresses to decide how to populate To: field
2140        String ourAddress = account.mEmailAddress;
2141        boolean containsOurAddress = false;
2142        for (Address address : replyToAddresses) {
2143            if (ourAddress.equalsIgnoreCase(address.getAddress())) {
2144                containsOurAddress = true;
2145                break;
2146            }
2147        }
2148
2149        if (containsOurAddress) {
2150            addAddresses(mToView, message.mTo);
2151        } else {
2152            addAddresses(mToView, replyToAddresses);
2153        }
2154
2155        if (replyAll) {
2156            // Keep a running list of addresses we're sending to
2157            ArrayList<Address> allAddresses = new ArrayList<Address>();
2158            for (Address address: replyToAddresses) {
2159                allAddresses.add(address);
2160            }
2161
2162            if (!containsOurAddress) {
2163                safeAddAddresses(message.mTo, ourAddress, mCcView, allAddresses);
2164            }
2165
2166            safeAddAddresses(message.mCc, ourAddress, mCcView, allAddresses);
2167        }
2168        showCcBccFieldsIfFilled();
2169    }
2170
2171    private void clearAddressViews() {
2172        mToView.setText("");
2173        mCcView.setText("");
2174        mBccView.setText("");
2175    }
2176
2177    /**
2178     * Pull out the parts of the now loaded source message and apply them to the new message
2179     * depending on the type of message being composed.
2180     */
2181    @VisibleForTesting
2182    void processSourceMessage(Message message, Account account) {
2183        String subject = message.mSubject;
2184        if (subject == null) {
2185            subject = "";
2186        }
2187        if (ACTION_REPLY.equals(mAction) || ACTION_REPLY_ALL.equals(mAction)) {
2188            setupAddressViews(message, account, ACTION_REPLY_ALL.equals(mAction));
2189            if (!subject.toLowerCase().startsWith("re:")) {
2190                mSubjectView.setText("Re: " + subject);
2191            } else {
2192                mSubjectView.setText(subject);
2193            }
2194            displayQuotedText(message.mText, message.mHtml);
2195            setIncludeQuotedText(true, false);
2196        } else if (ACTION_FORWARD.equals(mAction)) {
2197            clearAddressViews();
2198            mSubjectView.setText(!subject.toLowerCase().startsWith("fwd:")
2199                    ? "Fwd: " + subject : subject);
2200            displayQuotedText(message.mText, message.mHtml);
2201            setIncludeQuotedText(true, false);
2202        } else {
2203            Log.w(Logging.LOG_TAG, "Unexpected action for a call to processSourceMessage "
2204                    + mAction);
2205        }
2206        showCcBccFieldsIfFilled();
2207        setNewMessageFocus();
2208    }
2209
2210    /**
2211     * Processes the source attachments and ensures they're either included or excluded from
2212     * a list of active attachments. This can be used to add attachments for a forwarded message, or
2213     * to remove them if going from a "Forward" to a "Reply"
2214     * Uniqueness is based on filename.
2215     *
2216     * @param current the list of active attachments on the current message. Injected for tests.
2217     * @param sourceAttachments the list of attachments related with the source message. Injected
2218     *     for tests.
2219     * @param include whether or not the sourceMessages should be included or excluded from the
2220     *     current list of active attachments
2221     * @return whether or not the current attachments were modified
2222     */
2223    @VisibleForTesting
2224    boolean processSourceMessageAttachments(
2225            List<Attachment> current, List<Attachment> sourceAttachments, boolean include) {
2226
2227        // Build a map of filename to the active attachments.
2228        HashMap<String, Attachment> currentNames = new HashMap<String, Attachment>();
2229        for (Attachment attachment : current) {
2230            currentNames.put(attachment.mFileName, attachment);
2231        }
2232
2233        boolean dirty = false;
2234        if (include) {
2235            // Needs to make sure it's in the list.
2236            for (Attachment attachment : sourceAttachments) {
2237                if (!currentNames.containsKey(attachment.mFileName)) {
2238                    current.add(attachment);
2239                    dirty = true;
2240                }
2241            }
2242        } else {
2243            // Need to remove the source attachments.
2244            HashSet<String> sourceNames = new HashSet<String>();
2245            for (Attachment attachment : sourceAttachments) {
2246                if (currentNames.containsKey(attachment.mFileName)) {
2247                    deleteAttachment(current, currentNames.get(attachment.mFileName));
2248                    dirty = true;
2249                }
2250            }
2251        }
2252
2253        return dirty;
2254    }
2255
2256    /**
2257     * Set a cursor to the end of a body except a signature.
2258     */
2259    @VisibleForTesting
2260    void setMessageContentSelection(String signature) {
2261        int selection = mMessageContentView.length();
2262        if (!TextUtils.isEmpty(signature)) {
2263            int signatureLength = signature.length();
2264            int estimatedSelection = selection - signatureLength;
2265            if (estimatedSelection >= 0) {
2266                CharSequence text = mMessageContentView.getText();
2267                int i = 0;
2268                while (i < signatureLength
2269                       && text.charAt(estimatedSelection + i) == signature.charAt(i)) {
2270                    ++i;
2271                }
2272                if (i == signatureLength) {
2273                    selection = estimatedSelection;
2274                    while (selection > 0 && text.charAt(selection - 1) == '\n') {
2275                        --selection;
2276                    }
2277                }
2278            }
2279        }
2280        mMessageContentView.setSelection(selection, selection);
2281    }
2282
2283    /**
2284     * In order to accelerate typing, position the cursor in the first empty field,
2285     * or at the end of the body composition field if none are empty.  Typically, this will
2286     * play out as follows:
2287     *   Reply / Reply All - put cursor in the empty message body
2288     *   Forward - put cursor in the empty To field
2289     *   Edit Draft - put cursor in whatever field still needs entry
2290     */
2291    private void setNewMessageFocus() {
2292        if (mToView.length() == 0) {
2293            mToView.requestFocus();
2294        } else if (mSubjectView.length() == 0) {
2295            mSubjectView.requestFocus();
2296        } else {
2297            mMessageContentView.requestFocus();
2298        }
2299    }
2300
2301    private boolean isForward() {
2302        return ACTION_FORWARD.equals(mAction);
2303    }
2304
2305    /**
2306     * @return the signature for the specified account, if non-null. If the account specified is
2307     *     null or has no signature, {@code null} is returned.
2308     */
2309    private static String getAccountSignature(Account account) {
2310        return (account == null) ? null : account.mSignature;
2311    }
2312}
2313