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