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