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