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