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