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