MessageCompose.java revision f2dded3a2fba83dd3f0d14cce6abe467a4ab66eb
1/*
2 * Copyright (C) 2008 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17package com.android.email.activity;
18
19import com.android.email.Controller;
20import com.android.email.Email;
21import com.android.email.EmailAddressAdapter;
22import com.android.email.EmailAddressValidator;
23import com.android.email.R;
24import com.android.email.mail.Address;
25import com.android.email.mail.MessagingException;
26import com.android.email.mail.internet.EmailHtmlUtil;
27import com.android.email.mail.internet.MimeUtility;
28import com.android.email.provider.EmailContent;
29import com.android.email.provider.EmailContent.Account;
30import com.android.email.provider.EmailContent.Attachment;
31import com.android.email.provider.EmailContent.Body;
32import com.android.email.provider.EmailContent.BodyColumns;
33import com.android.email.provider.EmailContent.Message;
34import com.android.email.provider.EmailContent.MessageColumns;
35
36import android.app.Activity;
37import android.content.ActivityNotFoundException;
38import android.content.ContentResolver;
39import android.content.ContentValues;
40import android.content.Context;
41import android.content.Intent;
42import android.content.pm.ActivityInfo;
43import android.database.Cursor;
44import android.net.Uri;
45import android.os.AsyncTask;
46import android.os.Bundle;
47import android.os.Handler;
48import android.os.Parcelable;
49import android.provider.OpenableColumns;
50import android.text.InputFilter;
51import android.text.SpannableStringBuilder;
52import android.text.Spanned;
53import android.text.TextWatcher;
54import android.text.util.Rfc822Tokenizer;
55import android.util.Log;
56import android.view.Menu;
57import android.view.MenuItem;
58import android.view.View;
59import android.view.Window;
60import android.view.View.OnClickListener;
61import android.view.View.OnFocusChangeListener;
62import android.webkit.WebView;
63import android.widget.Button;
64import android.widget.EditText;
65import android.widget.ImageButton;
66import android.widget.LinearLayout;
67import android.widget.MultiAutoCompleteTextView;
68import android.widget.TextView;
69import android.widget.Toast;
70
71import java.io.UnsupportedEncodingException;
72import java.net.URLDecoder;
73import java.util.ArrayList;
74import java.util.List;
75
76
77public class MessageCompose extends Activity implements OnClickListener, OnFocusChangeListener {
78    private static final String ACTION_REPLY = "com.android.email.intent.action.REPLY";
79    private static final String ACTION_REPLY_ALL = "com.android.email.intent.action.REPLY_ALL";
80    private static final String ACTION_FORWARD = "com.android.email.intent.action.FORWARD";
81    private static final String ACTION_EDIT_DRAFT = "com.android.email.intent.action.EDIT_DRAFT";
82
83    private static final String EXTRA_ACCOUNT_ID = "account_id";
84    private static final String EXTRA_MESSAGE_ID = "message_id";
85    private static final String STATE_KEY_CC_SHOWN =
86        "com.android.email.activity.MessageCompose.ccShown";
87    private static final String STATE_KEY_BCC_SHOWN =
88        "com.android.email.activity.MessageCompose.bccShown";
89    private static final String STATE_KEY_QUOTED_TEXT_SHOWN =
90        "com.android.email.activity.MessageCompose.quotedTextShown";
91    private static final String STATE_KEY_SOURCE_MESSAGE_PROCED =
92        "com.android.email.activity.MessageCompose.stateKeySourceMessageProced";
93
94    private static final int MSG_PROGRESS_ON = 1;
95    private static final int MSG_PROGRESS_OFF = 2;
96    private static final int MSG_UPDATE_TITLE = 3;
97    private static final int MSG_SKIPPED_ATTACHMENTS = 4;
98    private static final int MSG_DISCARDED_DRAFT = 6;
99
100    private static final int ACTIVITY_REQUEST_PICK_ATTACHMENT = 1;
101
102    private static final String[] ATTACHMENT_META_COLUMNS = {
103        OpenableColumns.DISPLAY_NAME,
104        OpenableColumns.SIZE
105    };
106
107    private Account mAccount;
108
109    // mDraft is null until the first save, afterwards it contains the last saved version.
110    private Message mDraft;
111
112    // mSource is only set for REPLY, REPLY_ALL and FORWARD, and contains the source message.
113    private Message mSource;
114
115    /**
116     * Indicates that the source message has been processed at least once and should not
117     * be processed on any subsequent loads. This protects us from adding attachments that
118     * have already been added from the restore of the view state.
119     */
120    private boolean mSourceMessageProcessed = false;
121
122    private MultiAutoCompleteTextView mToView;
123    private MultiAutoCompleteTextView mCcView;
124    private MultiAutoCompleteTextView mBccView;
125    private EditText mSubjectView;
126    private EditText mMessageContentView;
127    private Button mSendButton;
128    private Button mDiscardButton;
129    private Button mSaveButton;
130    private LinearLayout mAttachments;
131    private View mQuotedTextBar;
132    private ImageButton mQuotedTextDelete;
133    private WebView mQuotedText;
134
135    private Controller mController;
136    private Listener mListener = new Listener();
137    private boolean mDraftNeedsSaving;
138    private AsyncTask mLoadAttachmentsTask;
139    private AsyncTask mSaveMessageTask;
140
141    private Handler mHandler = new Handler() {
142        @Override
143        public void handleMessage(android.os.Message msg) {
144            switch (msg.what) {
145                case MSG_PROGRESS_ON:
146                    setProgressBarIndeterminateVisibility(true);
147                    break;
148                case MSG_PROGRESS_OFF:
149                    setProgressBarIndeterminateVisibility(false);
150                    break;
151                case MSG_UPDATE_TITLE:
152                    updateTitle();
153                    break;
154                case MSG_SKIPPED_ATTACHMENTS:
155                    Toast.makeText(
156                            MessageCompose.this,
157                            getString(R.string.message_compose_attachments_skipped_toast),
158                            Toast.LENGTH_LONG).show();
159                    break;
160                default:
161                    super.handleMessage(msg);
162                    break;
163            }
164        }
165    };
166
167    /**
168     * Compose a new message using the given account. If account is -1 the default account
169     * will be used.
170     * @param context
171     * @param account
172     */
173    public static void actionCompose(Context context, long accountId) {
174       try {
175           Intent i = new Intent(context, MessageCompose.class);
176           i.putExtra(EXTRA_ACCOUNT_ID, accountId);
177           context.startActivity(i);
178       } catch (ActivityNotFoundException anfe) {
179           // Swallow it - this is usually a race condition, especially under automated test.
180           // (The message composer might have been disabled)
181           Email.log(anfe.toString());
182       }
183    }
184
185    /**
186     * Compose a new message as a reply to the given message. If replyAll is true the function
187     * is reply all instead of simply reply.
188     * @param context
189     * @param messageId
190     * @param replyAll
191     */
192    public static void actionReply(Context context, long messageId, boolean replyAll) {
193        startActivityWithMessage(context, replyAll ? ACTION_REPLY_ALL : ACTION_REPLY, messageId);
194    }
195
196    /**
197     * Compose a new message as a forward of the given message.
198     * @param context
199     * @param messageId
200     */
201    public static void actionForward(Context context, long messageId) {
202        startActivityWithMessage(context, ACTION_FORWARD, messageId);
203    }
204
205    /**
206     * Continue composition of the given message. This action modifies the way this Activity
207     * handles certain actions.
208     * Save will attempt to replace the message in the given folder with the updated version.
209     * Discard will delete the message from the given folder.
210     * @param context
211     * @param messageId the message id.
212     */
213    public static void actionEditDraft(Context context, long messageId) {
214        startActivityWithMessage(context, ACTION_EDIT_DRAFT, messageId);
215    }
216
217    private static void startActivityWithMessage(Context context, String action, long messageId) {
218        Intent i = new Intent(context, MessageCompose.class);
219        i.putExtra(EXTRA_MESSAGE_ID, messageId);
220        i.setAction(action);
221        context.startActivity(i);
222    }
223
224    private void setAccount(Intent intent) {
225        long accountId = intent.getLongExtra(EXTRA_ACCOUNT_ID, -1);
226        if (accountId == -1) {
227            accountId = Account.getDefaultAccountId(this);
228        }
229        if (accountId == -1) {
230            // There are no accounts set up. This should not have happened. Prompt the
231            // user to set up an account as an acceptable bailout.
232            AccountFolderList.actionShowAccounts(this);
233            finish();
234        } else {
235            mAccount = Account.restoreAccountWithId(this, accountId);
236        }
237    }
238
239    @Override
240    public void onCreate(Bundle savedInstanceState) {
241        super.onCreate(savedInstanceState);
242        requestWindowFeature(Window.FEATURE_INDETERMINATE_PROGRESS);
243        setContentView(R.layout.message_compose);
244        mController = Controller.getInstance(getApplication());
245        initViews();
246
247        if (savedInstanceState != null) {
248            /*
249             * This data gets used in onCreate, so grab it here instead of onRestoreIntstanceState
250             */
251            mSourceMessageProcessed =
252                savedInstanceState.getBoolean(STATE_KEY_SOURCE_MESSAGE_PROCED, false);
253        }
254
255        Intent intent = getIntent();
256        final String action = intent.getAction();
257
258        // Handle the various intents that launch the message composer
259        if (Intent.ACTION_VIEW.equals(action)
260                || Intent.ACTION_SENDTO.equals(action)
261                || Intent.ACTION_SEND.equals(action)
262                || Intent.ACTION_SEND_MULTIPLE.equals(action)) {
263            setAccount(intent);
264            // Use the fields found in the Intent to prefill as much of the message as possible
265            initFromIntent(intent);
266        } else {
267            // Otherwise, handle the internal cases (Message Composer invoked from within app)
268            long messageId = intent.getLongExtra(EXTRA_MESSAGE_ID, -1);
269            if (messageId != -1) {
270                new LoadMessageTask().execute(messageId);
271            } else {
272                setAccount(intent);
273            }
274            if (ACTION_EDIT_DRAFT.equals(action) && messageId != -1) {
275                mLoadAttachmentsTask = new AsyncTask<Long, Void, Attachment[]>() {
276                    @Override
277                    protected Attachment[] doInBackground(Long... messageIds) {
278                        return Attachment.restoreAttachmentsWithMessageId(MessageCompose.this,
279                                messageIds[0]);
280                    }
281                    @Override
282                    protected void onPostExecute(Attachment[] attachments) {
283                        for (Attachment attachment : attachments) {
284                            addAttachment(attachment);
285                        }
286                    }
287                }.execute(messageId);
288            }
289        }
290
291        if (ACTION_REPLY.equals(action) || ACTION_REPLY_ALL.equals(action) ||
292                ACTION_FORWARD.equals(action) || ACTION_EDIT_DRAFT.equals(action)) {
293            /*
294             * If we need to load the message we add ourself as a message listener here
295             * so we can kick it off. Normally we add in onResume but we don't
296             * want to reload the message every time the activity is resumed.
297             * There is no harm in adding twice.
298             */
299            // TODO: signal the controller to load the message
300        }
301        updateTitle();
302    }
303
304    @Override
305    public void onResume() {
306        super.onResume();
307        mController.addResultCallback(mListener);
308    }
309
310    @Override
311    public void onPause() {
312        super.onPause();
313        saveIfNeeded();
314        mController.removeResultCallback(mListener);
315    }
316
317    private static void cancelTask(AsyncTask<?, ?, ?> task) {
318        if (task != null && task.getStatus() != AsyncTask.Status.FINISHED) {
319            task.cancel(true);
320        }
321    }
322
323    /**
324     * We override onDestroy to make sure that the WebView gets explicitly destroyed.
325     * Otherwise it can leak native references.
326     */
327    @Override
328    public void onDestroy() {
329        super.onDestroy();
330        mQuotedText.destroy();
331        mQuotedText = null;
332        cancelTask(mLoadAttachmentsTask);
333        mLoadAttachmentsTask = null;
334        // don't cancel mSaveMessageTask, let it do its job to the end.
335        // cancelTask(mSaveMessageTask);
336    }
337
338    /**
339     * The framework handles most of the fields, but we need to handle stuff that we
340     * dynamically show and hide:
341     * Cc field,
342     * Bcc field,
343     * Quoted text,
344     */
345    @Override
346    protected void onSaveInstanceState(Bundle outState) {
347        super.onSaveInstanceState(outState);
348        saveIfNeeded();
349        outState.putBoolean(STATE_KEY_CC_SHOWN, mCcView.getVisibility() == View.VISIBLE);
350        outState.putBoolean(STATE_KEY_BCC_SHOWN, mBccView.getVisibility() == View.VISIBLE);
351        outState.putBoolean(STATE_KEY_QUOTED_TEXT_SHOWN,
352                mQuotedTextBar.getVisibility() == View.VISIBLE);
353        outState.putBoolean(STATE_KEY_SOURCE_MESSAGE_PROCED, mSourceMessageProcessed);
354    }
355
356    @Override
357    protected void onRestoreInstanceState(Bundle savedInstanceState) {
358        super.onRestoreInstanceState(savedInstanceState);
359        mCcView.setVisibility(savedInstanceState.getBoolean(STATE_KEY_CC_SHOWN) ?
360                View.VISIBLE : View.GONE);
361        mBccView.setVisibility(savedInstanceState.getBoolean(STATE_KEY_BCC_SHOWN) ?
362                View.VISIBLE : View.GONE);
363        mQuotedTextBar.setVisibility(savedInstanceState.getBoolean(STATE_KEY_QUOTED_TEXT_SHOWN) ?
364                View.VISIBLE : View.GONE);
365        mQuotedText.setVisibility(savedInstanceState.getBoolean(STATE_KEY_QUOTED_TEXT_SHOWN) ?
366                View.VISIBLE : View.GONE);
367        mDraftNeedsSaving = false;
368    }
369
370    private void initViews() {
371        mToView = (MultiAutoCompleteTextView)findViewById(R.id.to);
372        mCcView = (MultiAutoCompleteTextView)findViewById(R.id.cc);
373        mBccView = (MultiAutoCompleteTextView)findViewById(R.id.bcc);
374        mSubjectView = (EditText)findViewById(R.id.subject);
375        mMessageContentView = (EditText)findViewById(R.id.message_content);
376        mSendButton = (Button)findViewById(R.id.send);
377        mDiscardButton = (Button)findViewById(R.id.discard);
378        mSaveButton = (Button)findViewById(R.id.save);
379        mAttachments = (LinearLayout)findViewById(R.id.attachments);
380        mQuotedTextBar = findViewById(R.id.quoted_text_bar);
381        mQuotedTextDelete = (ImageButton)findViewById(R.id.quoted_text_delete);
382        mQuotedText = (WebView)findViewById(R.id.quoted_text);
383
384        TextWatcher watcher = new TextWatcher() {
385            public void beforeTextChanged(CharSequence s, int start,
386                                          int before, int after) { }
387
388            public void onTextChanged(CharSequence s, int start,
389                                          int before, int count) {
390                mDraftNeedsSaving = true;
391            }
392
393            public void afterTextChanged(android.text.Editable s) { }
394        };
395
396        /**
397         * Implements special address cleanup rules:
398         * The first space key entry following an "@" symbol that is followed by any combination
399         * of letters and symbols, including one+ dots and zero commas, should insert an extra
400         * comma (followed by the space).
401         */
402        InputFilter recipientFilter = new InputFilter() {
403
404            public CharSequence filter(CharSequence source, int start, int end, Spanned dest,
405                    int dstart, int dend) {
406
407                // quick check - did they enter a single space?
408                if (end-start != 1 || source.charAt(start) != ' ') {
409                    return null;
410                }
411
412                // determine if the characters before the new space fit the pattern
413                // follow backwards and see if we find a comma, dot, or @
414                int scanBack = dstart;
415                boolean dotFound = false;
416                while (scanBack > 0) {
417                    char c = dest.charAt(--scanBack);
418                    switch (c) {
419                        case '.':
420                            dotFound = true;    // one or more dots are req'd
421                            break;
422                        case ',':
423                            return null;
424                        case '@':
425                            if (!dotFound) {
426                                return null;
427                            }
428
429                            // we have found a comma-insert case.  now just do it
430                            // in the least expensive way we can.
431                            if (source instanceof Spanned) {
432                                SpannableStringBuilder sb = new SpannableStringBuilder(",");
433                                sb.append(source);
434                                return sb;
435                            } else {
436                                return ", ";
437                            }
438                        default:
439                            // just keep going
440                    }
441                }
442
443                // no termination cases were found, so don't edit the input
444                return null;
445            }
446        };
447        InputFilter[] recipientFilters = new InputFilter[] { recipientFilter };
448
449        mToView.addTextChangedListener(watcher);
450        mCcView.addTextChangedListener(watcher);
451        mBccView.addTextChangedListener(watcher);
452        mSubjectView.addTextChangedListener(watcher);
453        mMessageContentView.addTextChangedListener(watcher);
454
455        // NOTE: assumes no other filters are set
456        mToView.setFilters(recipientFilters);
457        mCcView.setFilters(recipientFilters);
458        mBccView.setFilters(recipientFilters);
459
460        /*
461         * We set this to invisible by default. Other methods will turn it back on if it's
462         * needed.
463         */
464        mQuotedTextBar.setVisibility(View.GONE);
465        mQuotedText.setVisibility(View.GONE);
466
467        mQuotedTextDelete.setOnClickListener(this);
468
469        EmailAddressAdapter addressAdapter = new EmailAddressAdapter(this);
470        EmailAddressValidator addressValidator = new EmailAddressValidator();
471
472        mToView.setAdapter(addressAdapter);
473        mToView.setTokenizer(new Rfc822Tokenizer());
474        mToView.setValidator(addressValidator);
475
476        mCcView.setAdapter(addressAdapter);
477        mCcView.setTokenizer(new Rfc822Tokenizer());
478        mCcView.setValidator(addressValidator);
479
480        mBccView.setAdapter(addressAdapter);
481        mBccView.setTokenizer(new Rfc822Tokenizer());
482        mBccView.setValidator(addressValidator);
483
484        mSendButton.setOnClickListener(this);
485        mDiscardButton.setOnClickListener(this);
486        mSaveButton.setOnClickListener(this);
487
488        mSubjectView.setOnFocusChangeListener(this);
489    }
490
491    // TODO: is there any way to unify this with MessageView.LoadMessageTask?
492    private class LoadMessageTask extends AsyncTask<Long, Void, Object[]> {
493        @Override
494        protected Object[] doInBackground(Long... messageIds) {
495            Message message = Message.restoreMessageWithId(MessageCompose.this, messageIds[0]);
496            long accountId = message.mAccountKey;
497            Account account = Account.restoreAccountWithId(MessageCompose.this, accountId);
498            Body body = Body.restoreBodyWithMessageId(MessageCompose.this, message.mId);
499            message.mHtml = body.mHtmlContent;
500            message.mText = body.mTextContent;
501            return new Object[]{message, account};
502        }
503
504        @Override
505        protected void onPostExecute(Object[] messageAndAccount) {
506            final Message message = (Message) messageAndAccount[0];
507            final Account account = (Account) messageAndAccount[1];
508            final String action = getIntent().getAction();
509            if (ACTION_EDIT_DRAFT.equals(action)) {
510                mDraft = message;
511            } else if (ACTION_REPLY.equals(action)
512                       || ACTION_REPLY_ALL.equals(action)
513                       || ACTION_FORWARD.equals(action)) {
514                mSource = message;
515            } else if (Email.LOGD) {
516                Email.log("Action " + action + " has unexpected EXTRA_MESSAGE_ID");
517            }
518
519            mAccount = account;
520            processSourceMessage(message, mAccount);
521        }
522    }
523
524    private void updateTitle() {
525        if (mSubjectView.getText().length() == 0) {
526            setTitle(R.string.compose_title);
527        } else {
528            setTitle(mSubjectView.getText().toString());
529        }
530    }
531
532    public void onFocusChange(View view, boolean focused) {
533        if (!focused) {
534            updateTitle();
535        }
536    }
537
538    private void addAddresses(MultiAutoCompleteTextView view, Address[] addresses) {
539        if (addresses == null) {
540            return;
541        }
542        for (Address address : addresses) {
543            addAddress(view, address.toString());
544        }
545    }
546
547    private void addAddresses(MultiAutoCompleteTextView view, String[] addresses) {
548        if (addresses == null) {
549            return;
550        }
551        for (String oneAddress : addresses) {
552            addAddress(view, oneAddress);
553        }
554    }
555
556    private void addAddress(MultiAutoCompleteTextView view, String address) {
557        view.append(address + ", ");
558    }
559
560    private String getPackedAddresses(TextView view) {
561        Address[] addresses = Address.parse(view.getText().toString().trim());
562        return Address.pack(addresses);
563    }
564
565    private Address[] getAddresses(TextView view) {
566        Address[] addresses = Address.parse(view.getText().toString().trim());
567        return addresses;
568    }
569
570    private ContentValues getUpdateContentValues(Message message) {
571        ContentValues values = new ContentValues();
572        values.put(MessageColumns.TIMESTAMP, message.mTimeStamp);
573        values.put(MessageColumns.FROM_LIST, message.mFrom);
574        values.put(MessageColumns.TO_LIST, message.mTo);
575        values.put(MessageColumns.CC_LIST, message.mCc);
576        values.put(MessageColumns.BCC_LIST, message.mBcc);
577        values.put(MessageColumns.SUBJECT, message.mSubject);
578        values.put(MessageColumns.DISPLAY_NAME, message.mDisplayName);
579        values.put(MessageColumns.FLAG_LOADED, message.mFlagLoaded);
580        values.put(MessageColumns.FLAG_ATTACHMENT, message.mFlagAttachment);
581        values.put(MessageColumns.FLAGS, message.mFlags);
582        return values;
583    }
584
585    /*
586     * Computes a short string indicating the destination of the message based on To, Cc, Bcc.
587     * If only one address appears, returns the friendly form of that address.
588     * Otherwise returns the friendly form of the first address appended with "and N others".
589     */
590    private String makeDisplayName(String packedTo, String packedCc, String packedBcc) {
591        Address first = null;
592        int nRecipients = 0;
593        for (String packed: new String[] {packedTo, packedCc, packedBcc}) {
594            Address[] addresses = Address.unpack(packed);
595            nRecipients += addresses.length;
596            if (first == null && addresses.length > 0) {
597                first = addresses[0];
598            }
599        }
600        if (nRecipients == 0) {
601            return "";
602        }
603        String friendly = first.toFriendly();
604        if (nRecipients == 1) {
605            return friendly;
606        }
607        return this.getString(R.string.message_compose_display_name, friendly, nRecipients - 1);
608    }
609
610    /**
611     * @param message The message to be updated.
612     * @param account the account (used to obtain From: address).
613     * @param bodyText the body text.
614     */
615    private void updateMessage(Message message, Account account, boolean hasAttachments) {
616        message.mTimeStamp = System.currentTimeMillis();
617        message.mFrom = new Address(account.getEmailAddress(), account.getSenderName()).pack();
618        message.mTo = getPackedAddresses(mToView);
619        message.mCc = getPackedAddresses(mCcView);
620        message.mBcc = getPackedAddresses(mBccView);
621        message.mSubject = mSubjectView.getText().toString();
622        message.mText = mMessageContentView.getText().toString();
623        message.mAccountKey = account.mId;
624        message.mDisplayName = makeDisplayName(message.mTo, message.mCc, message.mBcc);
625        message.mFlagLoaded = Message.FLAG_LOADED_COMPLETE;
626        message.mFlagAttachment = hasAttachments;
627        String action = getIntent().getAction();
628        // Use the Intent to set flags saying this message is a reply or a forward and save the
629        // unique id of the source message
630        if (mSource != null && mQuotedTextBar.getVisibility() == View.VISIBLE) {
631            if (ACTION_REPLY.equals(action) || ACTION_REPLY_ALL.equals(action)
632                    || ACTION_FORWARD.equals(action)) {
633                message.mSourceKey = mSource.mId;
634                // Get the body of the source message here
635                // Note that the following commented line will be useful when we use HTML in replies
636                //message.mHtmlReply = mSource.mHtml;
637                message.mTextReply = mSource.mText;
638            }
639            if (ACTION_FORWARD.equals(action)) {
640                message.mFlags |= Message.FLAG_TYPE_FORWARD;
641            } else {
642                message.mFlags |= Message.FLAG_TYPE_REPLY;
643            }
644        }
645    }
646
647    private Attachment[] getAttachmentsFromUI() {
648        int count = mAttachments.getChildCount();
649        Attachment[] attachments = new Attachment[count];
650        for (int i = 0; i < count; ++i) {
651            attachments[i] = (Attachment) mAttachments.getChildAt(i).getTag();
652        }
653        return attachments;
654    }
655
656    /**
657     * Send or save a message:
658     * - out of the UI thread
659     * - write to Drafts
660     * - if send, invoke Controller.sendMessage()
661     * - when operation is complete, display toast
662     */
663    private void sendOrSaveMessage(final boolean send) {
664        if (mDraft == null) {
665            mDraft = new Message();
666        }
667        final Attachment[] attachments = getAttachmentsFromUI();
668        updateMessage(mDraft, mAccount, attachments.length > 0);
669
670        mSaveMessageTask = new AsyncTask<Void, Void, Void>() {
671            @Override
672            protected Void doInBackground(Void... params) {
673                synchronized (mDraft) {
674                    if (mDraft.isSaved()) {
675                        mDraft.update(MessageCompose.this, getUpdateContentValues(mDraft));
676                        ContentValues values = new ContentValues();
677                        values.put(BodyColumns.TEXT_CONTENT, mDraft.mText);
678                        values.put(BodyColumns.TEXT_REPLY, mDraft.mTextReply);
679                        values.put(BodyColumns.HTML_REPLY, mDraft.mHtmlReply);
680                        Body.updateBodyWithMessageId(MessageCompose.this, mDraft.mId, values);
681                    } else {
682                        // mDraft.mId is set upon return of saveToMailbox()
683                        mController.saveToMailbox(mDraft, EmailContent.Mailbox.TYPE_DRAFTS);
684                    }
685
686                    // TODO: remove from DB the attachments that were removed from UI
687                    for (Attachment attachment : attachments) {
688                        if (!attachment.isSaved()) {
689                            // this attachment is new so save it to DB.
690                            attachment.mMessageKey = mDraft.mId;
691                            attachment.save(MessageCompose.this);
692                        }
693                    }
694
695                    if (send) {
696                        mController.sendMessage(mDraft.mId, mDraft.mAccountKey);
697                    }
698                    return null;
699                }
700            }
701
702            @Override
703            protected void onPostExecute(Void dummy) {
704                // Don't display the toast if the user is just changing the orientation
705                if (!send && (getChangingConfigurations() & ActivityInfo.CONFIG_ORIENTATION) == 0) {
706                    Toast.makeText(MessageCompose.this, R.string.message_saved_toast,
707                            Toast.LENGTH_LONG).show();
708                }
709            }
710        }.execute();
711    }
712
713    private void saveIfNeeded() {
714        if (!mDraftNeedsSaving) {
715            return;
716        }
717        mDraftNeedsSaving = false;
718        sendOrSaveMessage(false);
719    }
720
721    /**
722     * Checks whether all the email addresses listed in TO, CC, BCC are valid.
723     */
724    /* package */ boolean isAddressAllValid() {
725        for (TextView view : new TextView[]{mToView, mCcView, mBccView}) {
726            String addresses = view.getText().toString().trim();
727            if (!Address.isAllValid(addresses)) {
728                view.setError(getString(R.string.message_compose_error_invalid_email));
729                return false;
730            }
731        }
732        return true;
733    }
734
735    private void onSend() {
736        if (!isAddressAllValid()) {
737            Toast.makeText(this, getString(R.string.message_compose_error_invalid_email),
738                           Toast.LENGTH_LONG).show();
739        } else if (getAddresses(mToView).length == 0 &&
740                getAddresses(mCcView).length == 0 &&
741                getAddresses(mBccView).length == 0) {
742            mToView.setError(getString(R.string.message_compose_error_no_recipients));
743            Toast.makeText(this, getString(R.string.message_compose_error_no_recipients),
744                    Toast.LENGTH_LONG).show();
745        } else {
746            sendOrSaveMessage(true);
747            mDraftNeedsSaving = false;
748            finish();
749        }
750    }
751
752    private void onDiscard() {
753        if (mDraft != null) {
754            mController.deleteMessage(mDraft.mId, mDraft.mAccountKey);
755        }
756        Toast.makeText(this, getString(R.string.message_discarded_toast), Toast.LENGTH_LONG).show();
757        mDraftNeedsSaving = false;
758        finish();
759    }
760
761    private void onSave() {
762        saveIfNeeded();
763        finish();
764    }
765
766    private void onAddCcBcc() {
767        mCcView.setVisibility(View.VISIBLE);
768        mBccView.setVisibility(View.VISIBLE);
769    }
770
771    /**
772     * Kick off a picker for whatever kind of MIME types we'll accept and let Android take over.
773     */
774    private void onAddAttachment() {
775        Intent i = new Intent(Intent.ACTION_GET_CONTENT);
776        i.addCategory(Intent.CATEGORY_OPENABLE);
777        i.setType(Email.ACCEPTABLE_ATTACHMENT_SEND_TYPES[0]);
778        startActivityForResult(
779                Intent.createChooser(i, getString(R.string.choose_attachment_dialog_title)),
780                ACTIVITY_REQUEST_PICK_ATTACHMENT);
781    }
782
783    private Attachment loadAttachmentInfo(Uri uri) {
784        int size = -1;
785        String name = null;
786        ContentResolver contentResolver = getContentResolver();
787        Cursor metadataCursor = contentResolver.query(uri,
788                ATTACHMENT_META_COLUMNS, null, null, null);
789        if (metadataCursor != null) {
790            try {
791                if (metadataCursor.moveToFirst()) {
792                    name = metadataCursor.getString(0);
793                    size = metadataCursor.getInt(1);
794                }
795            } finally {
796                metadataCursor.close();
797            }
798        }
799        if (name == null) {
800            name = uri.getLastPathSegment();
801        }
802
803        String contentType = contentResolver.getType(uri);
804        if (contentType == null) {
805            contentType = "";
806        }
807
808        Attachment attachment = new Attachment();
809        attachment.mFileName = name;
810        attachment.mContentUri = uri.toString();
811        attachment.mSize = size;
812        attachment.mMimeType = contentType;
813        return attachment;
814    }
815
816    private void addAttachment(Attachment attachment) {
817        // Before attaching the attachment, make sure it meets any other pre-attach criteria
818        if (attachment.mSize > Email.MAX_ATTACHMENT_UPLOAD_SIZE) {
819            Toast.makeText(this, R.string.message_compose_attachment_size, Toast.LENGTH_LONG)
820                    .show();
821            return;
822        }
823
824        View view = getLayoutInflater().inflate(R.layout.message_compose_attachment,
825                mAttachments, false);
826        TextView nameView = (TextView)view.findViewById(R.id.attachment_name);
827        ImageButton delete = (ImageButton)view.findViewById(R.id.attachment_delete);
828        nameView.setText(attachment.mFileName);
829        delete.setOnClickListener(this);
830        delete.setTag(view);
831        view.setTag(attachment);
832        mAttachments.addView(view);
833    }
834
835    private void addAttachment(Uri uri) {
836        addAttachment(loadAttachmentInfo(uri));
837    }
838
839    @Override
840    protected void onActivityResult(int requestCode, int resultCode, Intent data) {
841        if (data == null) {
842            return;
843        }
844        addAttachment(data.getData());
845        mDraftNeedsSaving = true;
846    }
847
848    public void onClick(View view) {
849        switch (view.getId()) {
850            case R.id.send:
851                onSend();
852                break;
853            case R.id.save:
854                onSave();
855                break;
856            case R.id.discard:
857                onDiscard();
858                break;
859            case R.id.attachment_delete:
860                /*
861                 * The view is the delete button, and we have previously set the tag of
862                 * the delete button to the view that owns it. We don't use parent because the
863                 * view is very complex and could change in the future.
864                 */
865                mAttachments.removeView((View) view.getTag());
866                mDraftNeedsSaving = true;
867                break;
868            case R.id.quoted_text_delete:
869                mQuotedTextBar.setVisibility(View.GONE);
870                mQuotedText.setVisibility(View.GONE);
871                mDraftNeedsSaving = true;
872                break;
873        }
874    }
875
876    @Override
877    public boolean onOptionsItemSelected(MenuItem item) {
878        switch (item.getItemId()) {
879            case R.id.send:
880                onSend();
881                break;
882            case R.id.save:
883                onSave();
884                break;
885            case R.id.discard:
886                onDiscard();
887                break;
888            case R.id.add_cc_bcc:
889                onAddCcBcc();
890                break;
891            case R.id.add_attachment:
892                onAddAttachment();
893                break;
894            default:
895                return super.onOptionsItemSelected(item);
896        }
897        return true;
898    }
899
900    @Override
901    public boolean onCreateOptionsMenu(Menu menu) {
902        super.onCreateOptionsMenu(menu);
903        getMenuInflater().inflate(R.menu.message_compose_option, menu);
904        return true;
905    }
906
907    /**
908     * Returns true if all attachments were able to be attached, otherwise returns false.
909     */
910//     private boolean loadAttachments(Part part, int depth) throws MessagingException {
911//         if (part.getBody() instanceof Multipart) {
912//             Multipart mp = (Multipart) part.getBody();
913//             boolean ret = true;
914//             for (int i = 0, count = mp.getCount(); i < count; i++) {
915//                 if (!loadAttachments(mp.getBodyPart(i), depth + 1)) {
916//                     ret = false;
917//                 }
918//             }
919//             return ret;
920//         } else {
921//             String contentType = MimeUtility.unfoldAndDecode(part.getContentType());
922//             String name = MimeUtility.getHeaderParameter(contentType, "name");
923//             if (name != null) {
924//                 Body body = part.getBody();
925//                 if (body != null && body instanceof LocalAttachmentBody) {
926//                     final Uri uri = ((LocalAttachmentBody) body).getContentUri();
927//                     mHandler.post(new Runnable() {
928//                         public void run() {
929//                             addAttachment(uri);
930//                         }
931//                     });
932//                 }
933//                 else {
934//                     return false;
935//                 }
936//             }
937//             return true;
938//         }
939//     }
940
941    /**
942     * Fill all the widgets with the content found in the Intent Extra, if any.
943     *
944     * Note that we don't actually check the intent action  (typically VIEW, SENDTO, or SEND).
945     * There is enough overlap in the definitions that it makes more sense to simply check for
946     * all available data and use as much of it as possible.
947     *
948     * With one exception:  EXTRA_STREAM is defined as only valid for ACTION_SEND.
949     *
950     * @param intent the launch intent
951     */
952    /* package */ void initFromIntent(Intent intent) {
953
954        // First, add values stored in top-level extras
955
956        String[] extraStrings = intent.getStringArrayExtra(Intent.EXTRA_EMAIL);
957        if (extraStrings != null) {
958            addAddresses(mToView, extraStrings);
959        }
960        extraStrings = intent.getStringArrayExtra(Intent.EXTRA_CC);
961        if (extraStrings != null) {
962            addAddresses(mCcView, extraStrings);
963        }
964        extraStrings = intent.getStringArrayExtra(Intent.EXTRA_BCC);
965        if (extraStrings != null) {
966            addAddresses(mBccView, extraStrings);
967        }
968        String extraString = intent.getStringExtra(Intent.EXTRA_SUBJECT);
969        if (extraString != null) {
970            mSubjectView.setText(extraString);
971        }
972
973        // Next, if we were invoked with a URI, try to interpret it
974        // We'll take two courses here.  If it's mailto:, there is a specific set of rules
975        // that define various optional fields.  However, for any other scheme, we'll simply
976        // take the entire scheme-specific part and interpret it as a possible list of addresses.
977
978        final Uri dataUri = intent.getData();
979        if (dataUri != null) {
980            if ("mailto".equals(dataUri.getScheme())) {
981                initializeFromMailTo(dataUri.toString());
982            } else {
983                String toText = dataUri.getSchemeSpecificPart();
984                if (toText != null) {
985                    addAddresses(mToView, toText.split(","));
986                }
987            }
988        }
989
990        // Next, fill in the plaintext (note, this will override mailto:?body=)
991
992        CharSequence text = intent.getCharSequenceExtra(Intent.EXTRA_TEXT);
993        if (text != null) {
994            mMessageContentView.setText(text);
995        }
996
997        // Next, convert EXTRA_STREAM into an attachment
998
999        if (Intent.ACTION_SEND.equals(intent.getAction()) && intent.hasExtra(Intent.EXTRA_STREAM)) {
1000            String type = intent.getType();
1001            Uri stream = (Uri) intent.getParcelableExtra(Intent.EXTRA_STREAM);
1002            if (stream != null && type != null) {
1003                if (MimeUtility.mimeTypeMatches(type, Email.ACCEPTABLE_ATTACHMENT_SEND_TYPES)) {
1004                    addAttachment(stream);
1005                }
1006            }
1007        }
1008
1009        if (Intent.ACTION_SEND_MULTIPLE.equals(intent.getAction())
1010                && intent.hasExtra(Intent.EXTRA_STREAM)) {
1011            ArrayList<Parcelable> list = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM);
1012            if (list != null) {
1013                for (Parcelable parcelable : list) {
1014                    Uri uri = (Uri) parcelable;
1015                    if (uri != null) {
1016                        Attachment attachment = loadAttachmentInfo(uri);
1017                        if (MimeUtility.mimeTypeMatches(attachment.mMimeType,
1018                                                        Email.ACCEPTABLE_ATTACHMENT_SEND_TYPES)) {
1019                            addAttachment(attachment);
1020                        }
1021                    }
1022                }
1023            }
1024        }
1025
1026        // Finally - expose fields that were filled in but are normally hidden, and set focus
1027
1028        if (mCcView.length() > 0) {
1029            mCcView.setVisibility(View.VISIBLE);
1030        }
1031        if (mBccView.length() > 0) {
1032            mBccView.setVisibility(View.VISIBLE);
1033        }
1034        setNewMessageFocus();
1035        mDraftNeedsSaving = false;
1036    }
1037
1038    /**
1039     * When we are launched with an intent that includes a mailto: URI, we can actually
1040     * gather quite a few of our message fields from it.
1041     *
1042     * @mailToString the href (which must start with "mailto:").
1043     */
1044    private void initializeFromMailTo(String mailToString) {
1045
1046        // Chop up everything between mailto: and ? to find recipients
1047        int index = mailToString.indexOf("?");
1048        int length = "mailto".length() + 1;
1049        String to;
1050        try {
1051            // Extract the recipient after mailto:
1052            if (index == -1) {
1053                to = decode(mailToString.substring(length));
1054            } else {
1055                to = decode(mailToString.substring(length, index));
1056            }
1057            addAddresses(mToView, to.split(" ,"));
1058        } catch (UnsupportedEncodingException e) {
1059            Log.e(Email.LOG_TAG, e.getMessage() + " while decoding '" + mailToString + "'");
1060        }
1061
1062        // Extract the other parameters
1063
1064        // We need to disguise this string as a URI in order to parse it
1065        Uri uri = Uri.parse("foo://" + mailToString);
1066
1067        List<String> cc = uri.getQueryParameters("cc");
1068        addAddresses(mCcView, cc.toArray(new String[cc.size()]));
1069
1070        List<String> otherTo = uri.getQueryParameters("to");
1071        addAddresses(mCcView, otherTo.toArray(new String[otherTo.size()]));
1072
1073        List<String> bcc = uri.getQueryParameters("bcc");
1074        addAddresses(mBccView, bcc.toArray(new String[bcc.size()]));
1075
1076        List<String> subject = uri.getQueryParameters("subject");
1077        if (subject.size() > 0) {
1078            mSubjectView.setText(subject.get(0));
1079        }
1080
1081        List<String> body = uri.getQueryParameters("body");
1082        if (body.size() > 0) {
1083            mMessageContentView.setText(body.get(0));
1084        }
1085    }
1086
1087    private String decode(String s) throws UnsupportedEncodingException {
1088        return URLDecoder.decode(s, "UTF-8");
1089    }
1090
1091    // used by processSourceMessage()
1092    private void displayQuotedText(Message message) {
1093        boolean plainTextFlag = message.mHtml == null;
1094        String text = plainTextFlag ? message.mText : message.mHtml;
1095        if (text != null) {
1096            text = plainTextFlag ? EmailHtmlUtil.escapeCharacterToDisplay(text) : text;
1097            // TODO: re-enable EmailHtmlUtil.resolveInlineImage() for HTML
1098            //    EmailHtmlUtil.resolveInlineImage(getContentResolver(), mAccount,
1099            //                                     text, message, 0);
1100            mQuotedTextBar.setVisibility(View.VISIBLE);
1101            mQuotedText.setVisibility(View.VISIBLE);
1102            mQuotedText.loadDataWithBaseURL("email://", text, "text/html",
1103                                            "utf-8", null);
1104        }
1105    }
1106
1107    /**
1108     * Given a packed address String, the address of our sending account, a view, and a list of
1109     * addressees already added to other addressing views, adds unique addressees that don't
1110     * match our address to the passed in view
1111     */
1112    private boolean safeAddAddresses(String addrs, String ourAddress,
1113            MultiAutoCompleteTextView view, ArrayList<Address> addrList) {
1114        boolean added = false;
1115        for (Address address : Address.unpack(addrs)) {
1116            // Don't send to ourselves or already-included addresses
1117            if (!address.getAddress().equalsIgnoreCase(ourAddress) && !addrList.contains(address)) {
1118                addrList.add(address);
1119                addAddress(view, address.toString());
1120                added = true;
1121            }
1122        }
1123        return added;
1124    }
1125
1126    /**
1127     * Set up the to and cc views properly for the "reply" and "replyAll" cases.  What's important
1128     * is that we not 1) send to ourselves, and 2) duplicate addressees.
1129     * @param message the message we're replying to
1130     * @param account the account we're sending from
1131     * @param toView the "To" view
1132     * @param ccView the "Cc" view
1133     * @param replyAll whether this is a replyAll (vs a reply)
1134     */
1135    /*package*/ void setupAddressViews(Message message, Account account,
1136            MultiAutoCompleteTextView toView, MultiAutoCompleteTextView ccView, boolean replyAll) {
1137        /*
1138         * If a reply-to was included with the message use that, otherwise use the from
1139         * or sender address.
1140         */
1141        Address[] replyToAddresses = Address.unpack(message.mReplyTo);
1142        if (replyToAddresses.length == 0) {
1143            replyToAddresses = Address.unpack(message.mFrom);
1144        }
1145        addAddresses(mToView, replyToAddresses);
1146
1147        if (replyAll) {
1148            // Keep a running list of addresses we're sending to
1149            ArrayList<Address> allAddresses = new ArrayList<Address>();
1150            String ourAddress = account.mEmailAddress;
1151
1152            for (Address address: replyToAddresses) {
1153                allAddresses.add(address);
1154            }
1155
1156            safeAddAddresses(message.mTo, ourAddress, mToView, allAddresses);
1157            if (safeAddAddresses(message.mCc, ourAddress, mCcView, allAddresses)) {
1158                mCcView.setVisibility(View.VISIBLE);
1159            }
1160        }
1161    }
1162
1163    /**
1164     * Pull out the parts of the now loaded source message and apply them to the new message
1165     * depending on the type of message being composed.
1166     * @param message
1167     */
1168    /* package */
1169    void processSourceMessage(Message message, Account account) {
1170        final String action = getIntent().getAction();
1171        mDraftNeedsSaving = true;
1172        final String subject = message.mSubject;
1173        if (ACTION_REPLY.equals(action) || ACTION_REPLY_ALL.equals(action)) {
1174            setupAddressViews(message, account, mToView, mCcView, ACTION_REPLY_ALL.equals(action));
1175
1176            if (subject != null && !subject.toLowerCase().startsWith("re:")) {
1177                mSubjectView.setText("Re: " + subject);
1178            } else {
1179                mSubjectView.setText(subject);
1180            }
1181
1182            displayQuotedText(message);
1183        } else if (ACTION_FORWARD.equals(action)) {
1184            mSubjectView.setText(subject != null && !subject.toLowerCase().startsWith("fwd:") ?
1185                                 "Fwd: " + subject : subject);
1186            displayQuotedText(message);
1187            if (!mSourceMessageProcessed) {
1188                // TODO: re-enable loadAttachments below
1189//                 if (!loadAttachments(message, 0)) {
1190//                     mHandler.sendEmptyMessage(MSG_SKIPPED_ATTACHMENTS);
1191//                 }
1192            }
1193        } else if (ACTION_EDIT_DRAFT.equals(action)) {
1194            mSubjectView.setText(subject);
1195            addAddresses(mToView, Address.unpack(message.mTo));
1196            Address[] cc = Address.unpack(message.mCc);
1197            if (cc.length > 0) {
1198                addAddresses(mCcView, cc);
1199                mCcView.setVisibility(View.VISIBLE);
1200            }
1201            Address[] bcc = Address.unpack(message.mBcc);
1202            if (bcc.length > 0) {
1203                addAddresses(mBccView, bcc);
1204                mBccView.setVisibility(View.VISIBLE);
1205            }
1206
1207            // TODO: why not the same text handling as in displayQuotedText() ?
1208            mMessageContentView.setText(message.mText);
1209
1210            if (!mSourceMessageProcessed) {
1211                // TODO: re-enable loadAttachments
1212                // loadAttachments(message, 0);
1213            }
1214            mDraftNeedsSaving = false;
1215        }
1216        setNewMessageFocus();
1217        mSourceMessageProcessed = true;
1218    }
1219
1220    /**
1221     * In order to accelerate typing, position the cursor in the first empty field,
1222     * or at the end of the body composition field if none are empty.  Typically, this will
1223     * play out as follows:
1224     *   Reply / Reply All - put cursor in the empty message body
1225     *   Forward - put cursor in the empty To field
1226     *   Edit Draft - put cursor in whatever field still needs entry
1227     */
1228    private void setNewMessageFocus() {
1229        if (mToView.length() == 0) {
1230            mToView.requestFocus();
1231        } else if (mSubjectView.length() == 0) {
1232            mSubjectView.requestFocus();
1233        } else {
1234            mMessageContentView.requestFocus();
1235            // when selecting the message content, explicitly move IP to the end, so you can
1236            // quickly resume typing into a draft
1237            int selection = mMessageContentView.length();
1238            mMessageContentView.setSelection(selection, selection);
1239        }
1240    }
1241
1242    class Listener implements Controller.Result {
1243        public void updateMailboxListCallback(MessagingException result, long accountId,
1244                int progress) {
1245        }
1246
1247        public void updateMailboxCallback(MessagingException result, long accountId,
1248                long mailboxId, int progress, int numNewMessages) {
1249        }
1250
1251        public void loadMessageForViewCallback(MessagingException result, long messageId,
1252                int progress) {
1253        }
1254
1255        public void loadAttachmentCallback(MessagingException result, long messageId,
1256                long attachmentId, int progress) {
1257        }
1258
1259        public void serviceCheckMailCallback(MessagingException result, long accountId,
1260                long mailboxId, int progress, long tag) {
1261        }
1262
1263        public void sendMailCallback(MessagingException result, long accountId, long messageId,
1264                int progress) {
1265        }
1266    }
1267
1268//     class Listener extends MessagingListener {
1269//         @Override
1270//         public void loadMessageForViewStarted(Account account, String folder,
1271//                 String uid) {
1272//             mHandler.sendEmptyMessage(MSG_PROGRESS_ON);
1273//         }
1274
1275//         @Override
1276//         public void loadMessageForViewFinished(Account account, String folder,
1277//                 String uid, Message message) {
1278//             mHandler.sendEmptyMessage(MSG_PROGRESS_OFF);
1279//         }
1280
1281//         @Override
1282//         public void loadMessageForViewBodyAvailable(Account account, String folder,
1283//                 String uid, final Message message) {
1284//            // TODO: convert uid to EmailContent.Message and re-do what's below
1285//             mSourceMessage = message;
1286//             runOnUiThread(new Runnable() {
1287//                 public void run() {
1288//                     processSourceMessage(message);
1289//                 }
1290//             });
1291//         }
1292
1293//         @Override
1294//         public void loadMessageForViewFailed(Account account, String folder, String uid,
1295//                 final String message) {
1296//             mHandler.sendEmptyMessage(MSG_PROGRESS_OFF);
1297//             // TODO show network error
1298//         }
1299//     }
1300}
1301