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