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