ComposeActivity.java revision 30e2c24b056542f3b1b438aeb798305d1226d0c8
1/**
2 * Copyright (c) 2011, Google Inc.
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.mail.compose;
18
19import android.accounts.Account;
20import android.app.ActionBar;
21import android.app.ActionBar.OnNavigationListener;
22import android.app.Activity;
23import android.content.ContentResolver;
24import android.content.Context;
25import android.content.Intent;
26import android.database.Cursor;
27import android.database.sqlite.SQLiteException;
28import android.net.Uri;
29import android.os.Bundle;
30import android.os.ParcelFileDescriptor;
31import android.provider.OpenableColumns;
32import android.provider.Settings;
33import android.text.Editable;
34import android.text.TextUtils;
35import android.text.util.Rfc822Token;
36import android.text.util.Rfc822Tokenizer;
37import android.view.Gravity;
38import android.view.LayoutInflater;
39import android.view.Menu;
40import android.view.MenuInflater;
41import android.view.MenuItem;
42import android.view.View;
43import android.view.ViewGroup;
44import android.view.View.OnClickListener;
45import android.widget.AdapterView;
46import android.widget.AdapterView.OnItemSelectedListener;
47import android.widget.ArrayAdapter;
48import android.widget.Button;
49import android.widget.Spinner;
50import android.widget.TextView;
51import android.widget.Toast;
52
53import com.android.common.Rfc822Validator;
54import com.android.mail.compose.QuotedTextView.RespondInlineListener;
55import com.android.mail.providers.Address;
56import com.android.mail.providers.Attachment;
57import com.android.mail.providers.UIProvider;
58import com.android.mail.providers.protos.mock.MockAttachment;
59import com.android.mail.R;
60import com.android.mail.utils.AccountUtils;
61import com.android.mail.utils.LogUtils;
62import com.android.mail.utils.MimeType;
63import com.android.mail.utils.Utils;
64import com.android.ex.chips.RecipientEditTextView;
65import com.google.common.annotations.VisibleForTesting;
66import com.google.common.collect.Sets;
67
68import java.io.FileNotFoundException;
69import java.io.IOException;
70import java.text.DateFormat;
71import java.util.ArrayList;
72import java.util.Arrays;
73import java.util.Collection;
74import java.util.Collections;
75import java.util.Date;
76import java.util.HashSet;
77import java.util.List;
78import java.util.Set;
79
80public class ComposeActivity extends Activity implements OnClickListener, OnNavigationListener,
81        RespondInlineListener, OnItemSelectedListener {
82    // Identifiers for which type of composition this is
83    static final int COMPOSE = -1;  // also used for editing a draft
84    static final int REPLY = 0;
85    static final int REPLY_ALL = 1;
86    static final int FORWARD = 2;
87
88    // HTML tags used to quote reply content
89    // The following style must be in-sync with
90    // pinto.app.MessageUtil.QUOTE_STYLE and
91    // java/com/google/caribou/ui/pinto/modules/app/messageutil.js
92    // BEG_QUOTE_BIDI is also available there when we support BIDI
93    private static final String BLOCKQUOTE_BEGIN = "<blockquote class=\"quote\" style=\""
94            + "margin:0 0 0 .8ex;" + "border-left:1px #ccc solid;" + "padding-left:1ex\">";
95    private static final String BLOCKQUOTE_END = "</blockquote>";
96    // HTML tags used to quote replies & forwards
97    /* package for testing */static final String QUOTE_BEGIN = "<div class=\"quote\">";
98    private static final String QUOTE_END = "</div>";
99    // Separates the attribution headers (Subject, To, etc) from the body in
100    // quoted text.
101    /* package for testing */  static final String HEADER_SEPARATOR = "<br type='attribution'>";
102    private static final int HEADER_SEPARATOR_LENGTH = HEADER_SEPARATOR.length();
103
104    // Integer extra holding one of the above compose action
105    private static final String EXTRA_ACTION = "action";
106
107    /**
108     * Notifies the {@code Activity} that the caller is an Email
109     * {@code Activity}, so that the back behavior may be modified accordingly.
110     *
111     * @see #onAppUpPressed
112     */
113    private static final String EXTRA_FROM_EMAIL_TASK = "fromemail";
114
115    //  If this is a reply/forward then this extra will hold the original message uri
116    private static final String EXTRA_IN_REFERENCE_TO_MESSAGE_URI = "in-reference-to-uri";
117    private static final String END_TOKEN = ", ";
118    private static final String LOG_TAG = new LogUtils().getLogTag();
119    // Request numbers for activities we start
120    private static final int RESULT_PICK_ATTACHMENT = 1;
121    private static final int RESULT_CREATE_ACCOUNT = 2;
122
123    private RecipientEditTextView mTo;
124    private RecipientEditTextView mCc;
125    private RecipientEditTextView mBcc;
126    private Button mCcBccButton;
127    private CcBccView mCcBccView;
128    private AttachmentsView mAttachmentsView;
129    private String mAccount;
130    private Rfc822Validator mRecipientValidator;
131    private Uri mRefMessageUri;
132    private TextView mSubject;
133
134    private ActionBar mActionBar;
135    private ComposeModeAdapter mComposeModeAdapter;
136    private int mComposeMode = -1;
137    private boolean mForward;
138    private String mRecipient;
139    private boolean mAttachmentsChanged;
140    private QuotedTextView mQuotedTextView;
141    private TextView mBodyText;
142    private View mFromStatic;
143    private View mFromSpinner;
144    private Spinner mFrom;
145    private List<String[]> mReplyFromAccounts;
146    private boolean mAccountSpinnerReady;
147    private String[] mCurrentReplyFromAccount;
148    private boolean mMessageIsForwardOrReply;
149    private List<String> mAccounts;
150    private boolean mAddingAttachment;
151    private boolean mAttachmentAddedOrRemoved;
152
153    /**
154     * Can be called from a non-UI thread.
155     */
156    public static void editDraft(Context context, String account, long mLocalMessageId) {
157    }
158
159    /**
160     * Can be called from a non-UI thread.
161     */
162    public static void compose(Context launcher, String account) {
163        launch(launcher, account, null, COMPOSE);
164    }
165
166    /**
167     * Can be called from a non-UI thread.
168     */
169    public static void reply(Context launcher, String account, String uri) {
170        launch(launcher, account, uri, REPLY);
171    }
172
173    /**
174     * Can be called from a non-UI thread.
175     */
176    public static void replyAll(Context launcher, String account, String uri) {
177        launch(launcher, account, uri, REPLY_ALL);
178    }
179
180    /**
181     * Can be called from a non-UI thread.
182     */
183    public static void forward(Context launcher, String account, String uri) {
184        launch(launcher, account, uri, FORWARD);
185    }
186
187    private static void launch(Context launcher, String account, String uri, int action) {
188        Intent intent = new Intent(launcher, ComposeActivity.class);
189        intent.putExtra(EXTRA_FROM_EMAIL_TASK, true);
190        intent.putExtra(EXTRA_ACTION, action);
191        intent.putExtra(Utils.EXTRA_ACCOUNT, account);
192        intent.putExtra(EXTRA_IN_REFERENCE_TO_MESSAGE_URI, uri);
193        launcher.startActivity(intent);
194    }
195
196    @Override
197    public void onCreate(Bundle savedInstanceState) {
198        super.onCreate(savedInstanceState);
199        Intent intent = getIntent();
200        mAccount = intent.getStringExtra(Utils.EXTRA_ACCOUNT);
201        setContentView(R.layout.compose);
202        findViews();
203        int action = intent.getIntExtra(EXTRA_ACTION, COMPOSE);
204        if (action == REPLY || action == REPLY_ALL || action == FORWARD) {
205            mRefMessageUri = Uri.parse(intent.getStringExtra(EXTRA_IN_REFERENCE_TO_MESSAGE_URI));
206            initFromRefMessage(action, mAccount);
207        } else {
208            setQuotedTextVisibility(false);
209        }
210        initActionBar(action);
211        asyncInitFromSpinner();
212    }
213
214    @Override
215    protected void onResume() {
216        super.onResume();
217        // Update the from spinner as other accounts
218        // may now be available.
219        asyncInitFromSpinner();
220    }
221
222    private void asyncInitFromSpinner() {
223        Account[] result = AccountUtils.getSyncingAccounts(this, null, null, null);
224        mAccounts = AccountUtils
225                .mergeAccountLists(mAccounts, result, true /* prioritizeAccountList */);
226        createReplyFromCache();
227        initFromSpinner();
228    }
229
230    /**
231     * Create a cache of all accounts a user could send mail from
232     */
233    private void createReplyFromCache() {
234        // Check for replyFroms.
235        List<String> accounts = null;
236        mReplyFromAccounts = new ArrayList<String[]>();
237
238        if (mMessageIsForwardOrReply) {
239            accounts = Collections.singletonList(mAccount);
240        } else {
241            accounts = mAccounts;
242        }
243        for (String account : accounts) {
244            // First add the account. First position is account, second
245            // is display of account, 3rd position is the REAL account this
246            // is being sent from / synced to.
247            mReplyFromAccounts.add(new String[] {
248                    account, account, account, "false"
249            });
250        }
251    }
252
253    private void initFromSpinner() {
254        // If there are not yet any accounts in the cached synced accounts
255        // because this is the first time Gmail was opened, and it was opened directly
256        // to the compose activity,don't bother populating the reply from spinner yet.
257        if (mReplyFromAccounts == null || mReplyFromAccounts.size() == 0) {
258            mAccountSpinnerReady = false;
259            return;
260        }
261        FromAddressSpinnerAdapter adapter = new FromAddressSpinnerAdapter(this);
262        int currentAccountIndex = 0;
263        String replyFromAccount = mAccount;
264
265        boolean checkRealAccount = mRecipient == null || mAccount.equals(mRecipient);
266
267        currentAccountIndex = addAccountsToAdapter(adapter, checkRealAccount, replyFromAccount);
268
269        mFrom.setAdapter(adapter);
270        mFrom.setSelection(currentAccountIndex, false);
271        mFrom.setOnItemSelectedListener(this);
272        mCurrentReplyFromAccount = mReplyFromAccounts.get(currentAccountIndex);
273
274        hideOrShowFromSpinner();
275        mAccountSpinnerReady = true;
276        adapter.setSpinner(mFrom);
277    }
278
279    private void hideOrShowFromSpinner() {
280        // Determine whether the from account spinner or the static
281        // from text should be show
282        // When the spinner is shown, the static from text
283        // is hidden
284        showFromSpinner(mFrom.getCount() > 1);
285    }
286
287    private int addAccountsToAdapter(FromAddressSpinnerAdapter adapter, boolean checkRealAccount,
288            String replyFromAccount) {
289        int currentIndex = 0;
290        int currentAccountIndex = 0;
291        // Get the position of the current account
292        for (String[] account : mReplyFromAccounts) {
293            // Add the account to the Adapter
294            // The reason that we are not adding the Account array, but adding
295            // the names of each account, is because Account returns a string
296            // that we don't want to display on toString()
297            adapter.add(account);
298            // Compare to the account address, not the real account being
299            // sent from.
300            if (checkRealAccount) {
301                // Need to check the real account and the account address
302                // so that we can send from the correct address on the
303                // correct account when the same address may exist across
304                // multiple accounts.
305                if (account[FromAddressSpinnerAdapter.REAL_ACCOUNT].equals(mAccount)
306                        && account[FromAddressSpinnerAdapter.ACCOUNT_ADDRESS]
307                                .equals(replyFromAccount)) {
308                    currentAccountIndex = currentIndex;
309                }
310            } else {
311                // Just need to check the account address.
312                if (replyFromAccount.equals(
313                        account[FromAddressSpinnerAdapter.ACCOUNT_ADDRESS])) {
314                    currentAccountIndex = currentIndex;
315                }
316            }
317
318            currentIndex++;
319        }
320        return currentAccountIndex;
321    }
322
323    private void findViews() {
324        mCcBccButton = (Button) findViewById(R.id.add_cc_bcc);
325        if (mCcBccButton != null) {
326            mCcBccButton.setOnClickListener(this);
327        }
328        mCcBccView = (CcBccView) findViewById(R.id.cc_bcc_wrapper);
329        mAttachmentsView = (AttachmentsView)findViewById(R.id.attachments);
330        mTo = setupRecipients(R.id.to);
331        mCc = setupRecipients(R.id.cc);
332        mBcc = setupRecipients(R.id.bcc);
333        mSubject = (TextView) findViewById(R.id.subject);
334        mQuotedTextView = (QuotedTextView) findViewById(R.id.quoted_text_view);
335        mQuotedTextView.setRespondInlineListener(this);
336        mBodyText = (TextView) findViewById(R.id.body);
337        mFromStatic = findViewById(R.id.static_from_content);
338        mFromSpinner = findViewById(R.id.spinner_from_content);
339        mFrom = (Spinner) findViewById(R.id.from_picker);
340    }
341
342    /**
343     * Show the static from text view or the spinner
344     * @param showSpinner Whether the spinner should be shown
345     */
346    private void showFromSpinner(boolean showSpinner) {
347        // show/hide the static text
348        mFromStatic.setVisibility(
349                showSpinner ? View.GONE : View.VISIBLE);
350
351        // show/hide the spinner
352        mFromSpinner.setVisibility(
353                showSpinner ? View.VISIBLE : View.GONE);
354    }
355
356    private void setQuotedTextVisibility(boolean show) {
357        mQuotedTextView.setVisibility(show ? View.VISIBLE : View.GONE);
358    }
359
360    private void initActionBar(int action) {
361        mComposeMode = action;
362        mActionBar = getActionBar();
363        if (action == ComposeActivity.COMPOSE) {
364            mActionBar.setNavigationMode(ActionBar.NAVIGATION_MODE_STANDARD);
365            mActionBar.setTitle(R.string.compose);
366        } else {
367            mActionBar.setTitle(null);
368            if (mComposeModeAdapter == null) {
369                mComposeModeAdapter = new ComposeModeAdapter(this);
370            }
371            mActionBar.setNavigationMode(ActionBar.NAVIGATION_MODE_LIST);
372            mActionBar.setListNavigationCallbacks(mComposeModeAdapter, this);
373            switch (action) {
374                case ComposeActivity.REPLY:
375                    mActionBar.setSelectedNavigationItem(0);
376                    break;
377                case ComposeActivity.REPLY_ALL:
378                    mActionBar.setSelectedNavigationItem(1);
379                    break;
380                case ComposeActivity.FORWARD:
381                    mActionBar.setSelectedNavigationItem(2);
382                    break;
383            }
384        }
385    }
386
387    private void initFromRefMessage(int action, String recipientAddress) {
388        ContentResolver resolver = getContentResolver();
389        Cursor refMessage = resolver.query(mRefMessageUri, UIProvider.MESSAGE_PROJECTION, null,
390                null, null);
391        if (refMessage != null) {
392            try {
393                refMessage.moveToFirst();
394                setSubject(refMessage, action);
395                // Setup recipients
396                if (action == FORWARD) {
397                    mForward = true;
398                }
399                setQuotedTextVisibility(true);
400                initRecipientsFromRefMessageCursor(recipientAddress, refMessage, action);
401                initBodyFromRefMessage(refMessage, action);
402                if (action == ComposeActivity.FORWARD || mAttachmentsChanged) {
403                    updateAttachments(action, refMessage);
404                } else {
405                    // Clear the attachments.
406                    removeAllAttachments();
407                }
408                updateHideOrShowCcBcc();
409            } finally {
410                refMessage.close();
411            }
412        }
413    }
414
415    private void initBodyFromRefMessage(Cursor refMessage, int action) {
416        boolean forward = action == FORWARD;
417        DateFormat dateFormat = DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT);
418        Date date = new Date(refMessage.getLong(UIProvider.MESSAGE_DATE_RECEIVED_MS_COLUMN));
419        StringBuffer quotedText = new StringBuffer();
420
421        if (action == ComposeActivity.REPLY || action == ComposeActivity.REPLY_ALL) {
422            quotedText.append(QUOTE_BEGIN);
423            quotedText
424                    .append(String.format(
425                            getString(R.string.reply_attribution),
426                            dateFormat.format(date),
427                            Utils.cleanUpString(
428                                    refMessage.getString(UIProvider.MESSAGE_FROM_COLUMN), true)));
429            quotedText.append(HEADER_SEPARATOR);
430            quotedText.append(BLOCKQUOTE_BEGIN);
431            quotedText.append(refMessage.getString(UIProvider.MESSAGE_BODY_HTML));
432            quotedText.append(BLOCKQUOTE_END);
433            quotedText.append(QUOTE_END);
434        } else if (action == ComposeActivity.FORWARD) {
435            quotedText.append(QUOTE_BEGIN);
436            quotedText
437                    .append(String.format(getString(R.string.forward_attribution), Utils
438                            .cleanUpString(refMessage.getString(UIProvider.MESSAGE_FROM_COLUMN),
439                                    true /* remove empty quotes */), dateFormat.format(date), Utils
440                            .cleanUpString(refMessage.getString(UIProvider.MESSAGE_SUBJECT_COLUMN),
441                                    false /* don't remove empty quotes */), Utils.cleanUpString(
442                            refMessage.getString(UIProvider.MESSAGE_TO_COLUMN), true)));
443            String ccAddresses = refMessage.getString(UIProvider.MESSAGE_CC_COLUMN);
444            quotedText.append(String.format(getString(R.string.cc_attribution),
445                    Utils.cleanUpString(ccAddresses, true /* remove empty quotes */)));
446        }
447        quotedText.append(HEADER_SEPARATOR);
448        quotedText.append(refMessage.getString(UIProvider.MESSAGE_BODY_HTML));
449        quotedText.append(QUOTE_END);
450        setQuotedText(quotedText.toString(), !forward);
451    }
452
453    /**
454     * Fill the quoted text WebView. There is no point in having a "Show quoted
455     * text" checkbox in a forwarded message so make sure mForward is
456     * initialized properly before calling this method so we can hide it.
457     */
458    public void setQuotedText(CharSequence text, boolean allow) {
459        // There is no way to retrieve this string from the WebView once it's
460        // been loaded, so we need to store it here.
461        mQuotedTextView.setQuotedText(text);
462        mQuotedTextView.allowQuotedText(allow);
463        // If there is quoted text, we always allow respond inline, since this
464        // may be a forward.
465        mQuotedTextView.allowRespondInline(true);
466    }
467
468    private void updateHideOrShowCcBcc() {
469        // Its possible there is a menu item OR a button.
470        boolean ccVisible = !TextUtils.isEmpty(mCc.getText());
471        boolean bccVisible = !TextUtils.isEmpty(mBcc.getText());
472        if (ccVisible || bccVisible) {
473            mCcBccView.show(false, ccVisible, bccVisible);
474        }
475        if (mCcBccButton != null) {
476            if (!mCc.isShown() || !mBcc.isShown()) {
477                mCcBccButton.setVisibility(View.VISIBLE);
478                mCcBccButton.setText(getString(!mCc.isShown() ? R.string.add_cc_label
479                        : R.string.add_bcc_label));
480            } else {
481                mCcBccButton.setVisibility(View.GONE);
482            }
483        }
484    }
485
486    public void removeAllAttachments() {
487        mAttachmentsView.removeAllViews();
488    }
489
490    private void updateAttachments(int action, Cursor refMessage) {
491        // TODO: when we hook up attachments, make this work properly.
492    }
493
494    @Override
495    protected final void onActivityResult(int request, int result, Intent data) {
496        mAddingAttachment = false;
497        if (result != RESULT_OK) {
498            return;
499        }
500
501        if (request == RESULT_PICK_ATTACHMENT) {
502            addAttachmentAndUpdateView(data);
503        }
504    }
505    /**
506     * Add attachment and update the compose area appropriately.
507     * @param data
508     */
509    public void addAttachmentAndUpdateView(Intent data) {
510        Uri uri = data != null ? data.getData() : null;
511        if (uri != null && !TextUtils.isEmpty(uri.getPath())) {
512            mAttachmentsChanged = true;
513            String contentType = getContentResolver().getType(uri);
514            try {
515                addAttachment(uri, contentType, false /* doSave */);
516            } catch (AttachmentFailureException e) {
517                // A toast has already been shown to the user, no need to do anything.
518                LogUtils.e(LOG_TAG, e, "Error adding attachment");
519            }
520        } else {
521           showAttachmentTooBigToast();
522        }
523    }
524
525    @VisibleForTesting
526    protected int getSizeFromFile(Uri uri, ContentResolver contentResolver) {
527        int size = -1;
528        ParcelFileDescriptor file = null;
529        try {
530            file = contentResolver.openFileDescriptor(uri, "r");
531            size = (int) file.getStatSize();
532        } catch (FileNotFoundException e) {
533            LogUtils.w(LOG_TAG, "Error opening file to obtain size.");
534        } finally {
535            try {
536                if (file != null) {
537                    file.close();
538                }
539            } catch (IOException e) {
540                LogUtils.w(LOG_TAG, "Error closing file opened to obtain size.");
541            }
542        }
543        return size;
544    }
545
546    /**
547     * Adds an attachment
548     * @param uri the uri to attach
549     * @param contentType the type of the resource pointed to by the URI or null if the type is
550     *   unknown
551     * @param doSave whether the message should be saved
552     *
553     * @return int size of the attachment added.
554     * @throws AttachmentFailureException if an error occurs adding the attachment.
555     */
556    private int addAttachment(Uri uri, String contentType, boolean doSave)
557            throws AttachmentFailureException {
558        final ContentResolver contentResolver = getContentResolver();
559        if (contentType == null) contentType = "";
560
561        MockAttachment attachment = new MockAttachment();
562        // partId will be assigned by the engine.
563        attachment.name = null;
564        attachment.contentType = contentType;
565        attachment.size = 0;
566        attachment.simpleContentType = contentType;
567        attachment.origin = uri;
568        attachment.originExtras = uri.toString();
569
570        Cursor metadataCursor = null;
571        try {
572            metadataCursor = contentResolver.query(
573                    uri, new String[]{OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE},
574                    null, null, null);
575            if (metadataCursor != null) {
576                try {
577                    if (metadataCursor.moveToNext()) {
578                        attachment.name = metadataCursor.getString(0);
579                        attachment.size = metadataCursor.getInt(1);
580                    }
581                } finally {
582                    metadataCursor.close();
583                }
584            }
585        } catch (SQLiteException ex) {
586            // One of the two columns is probably missing, let's make one more attempt to get at
587            // least one.
588            // Note that the documentations in Intent#ACTION_OPENABLE and
589            // OpenableColumns seem to contradict each other about whether these columns are
590            // required, but it doesn't hurt to fail properly.
591
592            // Let's try to get DISPLAY_NAME
593            try {
594                metadataCursor =
595                        getOptionalColumn(contentResolver, uri, OpenableColumns.DISPLAY_NAME);
596                if (metadataCursor != null && metadataCursor.moveToNext()) {
597                    attachment.name = metadataCursor.getString(0);
598                }
599            } finally {
600                if (metadataCursor != null) metadataCursor.close();
601            }
602
603            // Let's try to get SIZE
604            try {
605                metadataCursor =
606                        getOptionalColumn(contentResolver, uri, OpenableColumns.SIZE);
607                if (metadataCursor != null && metadataCursor.moveToNext()) {
608                    attachment.size = metadataCursor.getInt(0);
609                } else {
610                    // Unable to get the size from the metadata cursor. Open the file and seek.
611                    attachment.size = getSizeFromFile(uri, contentResolver);
612                }
613            } finally {
614                if (metadataCursor != null) metadataCursor.close();
615            }
616        } catch (SecurityException e) {
617            // We received a security exception when attempting to add an
618            // attachment.  Warn the user.
619            // TODO(pwestbro): determine if we need more specific text in the toast.
620            Toast.makeText(this,
621                    R.string.generic_attachment_problem, Toast.LENGTH_LONG).show();
622            throw new AttachmentFailureException("Security Exception from attachment uri", e);
623        }
624
625        if (attachment.name == null) {
626            attachment.name = uri.getLastPathSegment();
627        }
628
629        int maxSize = UIProvider.getMailMaxAttachmentSize(mAccount);
630
631        // Error getting the size or the size was too big.
632        if (attachment.size == -1 || attachment.size > maxSize) {
633            showAttachmentTooBigToast();
634            throw new AttachmentFailureException("Attachment too large to attach");
635        } else if ((mAttachmentsView.getTotalAttachmentsSize()
636                + attachment.size) > maxSize) {
637            showAttachmentTooBigToast();
638            throw new AttachmentFailureException("Attachment too large to attach");
639        } else {
640            addAttachment(attachment);
641        }
642
643        return attachment.size;
644    }
645
646    /**
647     * @return a cursor to the requested column or null if an exception occurs while trying
648     * to query it.
649     */
650    private Cursor getOptionalColumn(ContentResolver contentResolver, Uri uri, String columnName) {
651        Cursor result = null;
652        try {
653            result = contentResolver.query(uri, new String[]{columnName}, null, null, null);
654        } catch (SQLiteException ex) {
655            // ignore, leave result null
656        }
657        return result;
658    }
659
660    /**
661     * Add attachment.
662     * @param attachment
663     */
664    public void addAttachment(Attachment attachment) {
665        mAttachmentsView.addAttachment(attachment);
666    }
667
668    /**
669     * When an attachment is too large to be added to a message, show a toast.
670     * This method also updates the position of the toast so that it is shown
671     * clearly above they keyboard if it happens to be open.
672     */
673    private void showAttachmentTooBigToast() {
674        Toast t = Toast.makeText(this, R.string.generic_attachment_problem, Toast.LENGTH_LONG);
675        t.setText(R.string.too_large_to_attach);
676        t.setGravity(Gravity.CENTER_HORIZONTAL, 0, getResources()
677                .getDimensionPixelSize(R.dimen.attachment_toast_yoffset));
678        t.show();
679    }
680
681    /**
682     * Class containing information about failures when adding attachments.
683     */
684    class AttachmentFailureException extends Exception {
685        private static final long serialVersionUID = 1L;
686
687        public AttachmentFailureException(String error) {
688            super(error);
689        }
690        public AttachmentFailureException(String detailMessage, Throwable throwable) {
691            super(detailMessage, throwable);
692        }
693    }
694
695    private void initRecipientsFromRefMessageCursor(String recipientAddress, Cursor refMessage,
696            int action) {
697        // Don't populate the address if this is a forward.
698        if (action == ComposeActivity.FORWARD) {
699            return;
700        }
701        initReplyRecipients(mAccount, refMessage, action);
702    }
703
704    private void initReplyRecipients(String account, Cursor refMessage, int action) {
705        // This is the email address of the current user, i.e. the one composing
706        // the reply.
707        final String accountEmail = Address.getEmailAddress(account).getAddress();
708        String fromAddress = refMessage.getString(UIProvider.MESSAGE_FROM_COLUMN);
709        String[] sentToAddresses = Utils.splitCommaSeparatedString(refMessage
710                .getString(UIProvider.MESSAGE_TO_COLUMN));
711        String[] replytoAddresses = Utils.splitCommaSeparatedString(refMessage
712                .getString(UIProvider.MESSAGE_REPLY_TO_COLUMN));
713        final Collection<String> toAddresses;
714
715        // If this is a reply, the Cc list is empty. If this is a reply-all, the
716        // Cc list is the union of the To and Cc recipients of the original
717        // message, excluding the current user's email address and any addresses
718        // already on the To list.
719        if (action == ComposeActivity.REPLY) {
720            toAddresses = initToRecipients(account, accountEmail, fromAddress,
721                    replytoAddresses, new String[0]);
722            addToAddresses(toAddresses);
723        } else if (action == ComposeActivity.REPLY_ALL) {
724            final Set<String> ccAddresses = Sets.newHashSet();
725            toAddresses = initToRecipients(account, accountEmail, fromAddress,
726                    replytoAddresses, new String[0]);
727            addRecipients(accountEmail, ccAddresses, sentToAddresses);
728            addRecipients(accountEmail, ccAddresses, Utils.splitCommaSeparatedString(refMessage
729                    .getString(UIProvider.MESSAGE_CC_COLUMN)));
730            addCcAddresses(ccAddresses, toAddresses);
731        }
732    }
733
734    private void addToAddresses(Collection<String> addresses) {
735        addAddressesToList(addresses, mTo);
736    }
737
738    private void addCcAddresses(Collection<String> addresses, Collection<String> toAddresses) {
739        addCcAddressesToList(tokenizeAddressList(addresses), tokenizeAddressList(toAddresses),
740                mCc);
741    }
742
743    @VisibleForTesting
744    protected void addCcAddressesToList(List<Rfc822Token[]> addresses,
745            List<Rfc822Token[]> compareToList, RecipientEditTextView list) {
746        String address;
747
748        HashSet<String> compareTo = convertToHashSet(compareToList);
749        for (Rfc822Token[] tokens : addresses) {
750            for (int i = 0; i < tokens.length; i++) {
751                address = tokens[i].toString();
752                // Check if this is a duplicate:
753                if (!compareTo.contains(tokens[i].getAddress())) {
754                    // Get the address here
755                    list.append(address + END_TOKEN);
756                }
757            }
758        }
759    }
760
761    private void addAddressesToList(List<Rfc822Token[]> addresses, RecipientEditTextView list) {
762        String address;
763        for (Rfc822Token[] tokens : addresses) {
764            for (int i = 0; i < tokens.length; i++) {
765                address = tokens[i].toString();
766                list.append(address + END_TOKEN);
767            }
768        }
769    }
770
771    private HashSet<String> convertToHashSet(List<Rfc822Token[]> list) {
772        HashSet<String> hash = new HashSet<String>();
773        for (Rfc822Token[] tokens : list) {
774            for (int i = 0; i < tokens.length; i++) {
775                hash.add(tokens[i].getAddress());
776            }
777        }
778        return hash;
779    }
780
781    private void addBccAddresses(Collection<String> addresses) {
782        addAddressesToList(addresses, mBcc);
783    }
784
785    protected List<Rfc822Token[]> tokenizeAddressList(Collection<String> addresses) {
786        @VisibleForTesting
787        List<Rfc822Token[]> tokenized = new ArrayList<Rfc822Token[]>();
788
789        for (String address: addresses) {
790            tokenized.add(Rfc822Tokenizer.tokenize(address));
791        }
792        return tokenized;
793    }
794
795    @VisibleForTesting
796    void addAddressesToList(Collection<String> addresses, RecipientEditTextView list) {
797        for (String address : addresses) {
798            addAddressToList(address, list);
799        }
800    }
801
802    private void addAddressToList(String address, RecipientEditTextView list) {
803        if (address == null || list == null)
804            return;
805
806        Rfc822Token[] tokens = Rfc822Tokenizer.tokenize(address);
807
808        for (int i = 0; i < tokens.length; i++) {
809            list.append(tokens[i] + END_TOKEN);
810        }
811    }
812
813    @VisibleForTesting
814    protected Collection<String> initToRecipients(String account, String accountEmail,
815            String senderAddress, String[] replyToAddresses, String[] inToAddresses) {
816        // The To recipient is the reply-to address specified in the original
817        // message, unless it is:
818        // the current user OR a custom from of the current user, in which case
819        // it's the To recipient list of the original message.
820        // OR missing, in which case use the sender of the original message
821        Set<String> toAddresses = Sets.newHashSet();
822        Address sender = Address.getEmailAddress(senderAddress);
823        if (sender != null && sender.getAddress().equalsIgnoreCase(account)) {
824            // The sender address is this account, so reply acts like reply all.
825            toAddresses.addAll(Arrays.asList(inToAddresses));
826        } else if (replyToAddresses != null && replyToAddresses.length != 0) {
827            toAddresses.addAll(Arrays.asList(replyToAddresses));
828        } else {
829            // Check to see if the sender address is one of the user's custom
830            // from addresses.
831            if (senderAddress != null && sender != null
832                    && !accountEmail.equalsIgnoreCase(sender.getAddress())) {
833                // Replying to the sender of the original message is the most
834                // common case.
835                toAddresses.add(senderAddress);
836            } else {
837                // This happens if the user replies to a message they originally
838                // wrote. In this case, "reply" really means "re-send," so we
839                // target the original recipients. This works as expected even
840                // if the user sent the original message to themselves.
841                toAddresses.addAll(Arrays.asList(inToAddresses));
842            }
843        }
844        return toAddresses;
845    }
846
847    private static void addRecipients(String account, Set<String> recipients, String[] addresses) {
848        for (String email : addresses) {
849            // Do not add this account, or any of the custom froms, to the list
850            // of recipients.
851            final String recipientAddress = Address.getEmailAddress(email).getAddress();
852            if (!account.equalsIgnoreCase(recipientAddress)) {
853                recipients.add(email.replace("\"\"", ""));
854            }
855        }
856    }
857
858    private void setSubject(Cursor refMessage, int action) {
859        String subject = refMessage.getString(UIProvider.MESSAGE_SUBJECT_COLUMN);
860        String prefix;
861        String correctedSubject = null;
862        if (action == ComposeActivity.COMPOSE) {
863            prefix = "";
864        } else if (action == ComposeActivity.FORWARD) {
865            prefix = getString(R.string.forward_subject_label);
866        } else {
867            prefix = getString(R.string.reply_subject_label);
868        }
869
870        // Don't duplicate the prefix
871        if (subject.toLowerCase().startsWith(prefix.toLowerCase())) {
872            correctedSubject = subject;
873        } else {
874            correctedSubject = String
875                    .format(getString(R.string.formatted_subject), prefix, subject);
876        }
877        mSubject.setText(correctedSubject);
878    }
879
880    private RecipientEditTextView setupRecipients(int id) {
881        RecipientEditTextView view = (RecipientEditTextView) findViewById(id);
882        view.setAdapter(new RecipientAdapter(this, mAccount));
883        view.setTokenizer(new Rfc822Tokenizer());
884        if (mRecipientValidator == null) {
885            int offset = mAccount.indexOf("@") + 1;
886            String account = mAccount;
887            if (offset > -1) {
888                account = account.substring(mAccount.indexOf("@") + 1);
889            }
890            mRecipientValidator = new Rfc822Validator(account);
891        }
892        view.setValidator(mRecipientValidator);
893        return view;
894    }
895
896    @Override
897    public void onClick(View v) {
898        int id = v.getId();
899        switch (id) {
900            case R.id.add_cc_bcc:
901                // Verify that cc/ bcc aren't showing.
902                // Animate in cc/bcc.
903                showCcBccViews();
904                break;
905        }
906    }
907
908    @Override
909    public boolean onCreateOptionsMenu(Menu menu) {
910        super.onCreateOptionsMenu(menu);
911        MenuInflater inflater = getMenuInflater();
912        inflater.inflate(R.menu.compose_menu, menu);
913        return true;
914    }
915
916    @Override
917    public boolean onPrepareOptionsMenu(Menu menu) {
918        MenuItem ccBcc = menu.findItem(R.id.add_cc_bcc);
919        if (ccBcc != null) {
920            // Its possible there is a menu item OR a button.
921            boolean ccFieldVisible = mCc.isShown();
922            boolean bccFieldVisible = mBcc.isShown();
923            if (!ccFieldVisible || !bccFieldVisible) {
924                ccBcc.setVisible(true);
925                ccBcc.setTitle(getString(!ccFieldVisible ? R.string.add_cc_label
926                        : R.string.add_bcc_label));
927            } else {
928                ccBcc.setVisible(false);
929            }
930        }
931        return true;
932    }
933
934    @Override
935    public boolean onOptionsItemSelected(MenuItem item) {
936        int id = item.getItemId();
937        boolean handled = false;
938        switch (id) {
939            case R.id.add_attachment:
940                doAttach();
941                break;
942            case R.id.add_cc_bcc:
943                showCcBccViews();
944                handled = true;
945                break;
946        }
947        return !handled ? super.onOptionsItemSelected(item) : handled;
948    }
949
950    public void doAttach() {
951        Intent i = new Intent(Intent.ACTION_GET_CONTENT);
952        i.addCategory(Intent.CATEGORY_OPENABLE);
953        if (Settings.System.getInt(
954                getContentResolver(), UIProvider.getAttachmentTypeSetting(), 0) != 0) {
955            i.setType("*/*");
956        } else {
957            i.setType("image/*");
958        }
959        mAddingAttachment = true;
960        startActivityForResult(Intent.createChooser(i,
961                getText(R.string.select_attachment_type)), RESULT_PICK_ATTACHMENT);
962    }
963
964    private void showCcBccViews() {
965        mCcBccView.show(true, true, true);
966        if (mCcBccButton != null) {
967            mCcBccButton.setVisibility(View.GONE);
968        }
969    }
970
971    @Override
972    public boolean onNavigationItemSelected(int position, long itemId) {
973        int initialComposeMode = mComposeMode;
974        if (position == ComposeActivity.REPLY) {
975            mComposeMode = ComposeActivity.REPLY;
976        } else if (position == ComposeActivity.REPLY_ALL) {
977            mComposeMode = ComposeActivity.REPLY_ALL;
978        } else if (position == ComposeActivity.FORWARD) {
979            mComposeMode = ComposeActivity.FORWARD;
980        }
981        if (initialComposeMode != mComposeMode) {
982            initFromRefMessage(mComposeMode, mAccount);
983        }
984        return true;
985    }
986
987    private class ComposeModeAdapter extends ArrayAdapter<String> {
988
989        private LayoutInflater mInflater;
990
991        public ComposeModeAdapter(Context context) {
992            super(context, R.layout.compose_mode_item, R.id.mode, getResources()
993                    .getStringArray(R.array.compose_modes));
994        }
995
996        private LayoutInflater getInflater() {
997            if (mInflater == null) {
998                mInflater = LayoutInflater.from(getContext());
999            }
1000            return mInflater;
1001        }
1002
1003        @Override
1004        public View getView(int position, View convertView, ViewGroup parent) {
1005            if (convertView == null) {
1006                convertView = getInflater().inflate(R.layout.compose_mode_display_item, null);
1007            }
1008            ((TextView) convertView.findViewById(R.id.mode)).setText(getItem(position));
1009            return super.getView(position, convertView, parent);
1010        }
1011    }
1012
1013    @Override
1014    public void onRespondInline(String text) {
1015        appendToBody(text, false);
1016    }
1017
1018    /**
1019     * Append text to the body of the message. If there is no existing body
1020     * text, just sets the body to text.
1021     *
1022     * @param text
1023     * @param withSignature True to append a signature.
1024     */
1025    public void appendToBody(CharSequence text, boolean withSignature) {
1026        Editable bodyText = mBodyText.getEditableText();
1027        if (bodyText != null && bodyText.length() > 0) {
1028            bodyText.append(text);
1029        } else {
1030            setBody(text, withSignature);
1031        }
1032    }
1033
1034    /**
1035     * Set the body of the message.
1036     * @param text
1037     * @param withSignature True to append a signature.
1038     */
1039    public void setBody(CharSequence text, boolean withSignature) {
1040        mBodyText.setText(text);
1041    }
1042
1043    @Override
1044    public void onItemSelected(AdapterView<?> parent, View view, int position, long id) {
1045        // TODO
1046    }
1047
1048    @Override
1049    public void onNothingSelected(AdapterView<?> parent) {
1050        // Do nothing.
1051    }
1052}