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.R;
22import com.android.email.Utility;
23import com.android.email.mail.Address;
24import com.android.email.mail.MeetingInfo;
25import com.android.email.mail.MessagingException;
26import com.android.email.mail.PackedString;
27import com.android.email.mail.internet.EmailHtmlUtil;
28import com.android.email.mail.internet.MimeUtility;
29import com.android.email.provider.AttachmentProvider;
30import com.android.email.provider.EmailContent;
31import com.android.email.provider.EmailContent.Attachment;
32import com.android.email.provider.EmailContent.Body;
33import com.android.email.provider.EmailContent.BodyColumns;
34import com.android.email.provider.EmailContent.Message;
35import com.android.email.service.EmailServiceConstants;
36
37import org.apache.commons.io.IOUtils;
38
39import android.app.Activity;
40import android.app.ProgressDialog;
41import android.content.ActivityNotFoundException;
42import android.content.ContentResolver;
43import android.content.Context;
44import android.content.Intent;
45import android.database.ContentObserver;
46import android.database.Cursor;
47import android.graphics.Bitmap;
48import android.graphics.BitmapFactory;
49import android.graphics.drawable.Drawable;
50import android.media.MediaScannerConnection;
51import android.media.MediaScannerConnection.MediaScannerConnectionClient;
52import android.net.Uri;
53import android.os.AsyncTask;
54import android.os.Bundle;
55import android.os.Environment;
56import android.os.Handler;
57import android.provider.Browser;
58import android.provider.ContactsContract;
59import android.provider.ContactsContract.CommonDataKinds;
60import android.provider.ContactsContract.Contacts;
61import android.provider.ContactsContract.QuickContact;
62import android.provider.ContactsContract.StatusUpdates;
63import android.text.TextUtils;
64import android.util.Log;
65import android.util.Patterns;
66import android.view.LayoutInflater;
67import android.view.Menu;
68import android.view.MenuItem;
69import android.view.View;
70import android.view.View.OnClickListener;
71import android.webkit.WebView;
72import android.webkit.WebViewClient;
73import android.widget.Button;
74import android.widget.ImageView;
75import android.widget.LinearLayout;
76import android.widget.TextView;
77import android.widget.Toast;
78
79import java.io.File;
80import java.io.FileOutputStream;
81import java.io.IOException;
82import java.io.InputStream;
83import java.io.OutputStream;
84import java.util.Date;
85import java.util.regex.Matcher;
86import java.util.regex.Pattern;
87
88public class MessageView extends Activity implements OnClickListener {
89    private static final String EXTRA_MESSAGE_ID = "com.android.email.MessageView_message_id";
90    private static final String EXTRA_MAILBOX_ID = "com.android.email.MessageView_mailbox_id";
91    /* package */ static final String EXTRA_DISABLE_REPLY = "com.android.email.MessageView_disable_reply";
92
93    // for saveInstanceState()
94    private static final String STATE_MESSAGE_ID = "messageId";
95
96    // Regex that matches start of img tag. '<(?i)img\s+'.
97    private static final Pattern IMG_TAG_START_REGEX = Pattern.compile("<(?i)img\\s+");
98    // Regex that matches Web URL protocol part as case insensitive.
99    private static final Pattern WEB_URL_PROTOCOL = Pattern.compile("(?i)http|https://");
100
101    // Support for LoadBodyTask
102    private static final String[] BODY_CONTENT_PROJECTION = new String[] {
103        Body.RECORD_ID, BodyColumns.MESSAGE_KEY,
104        BodyColumns.HTML_CONTENT, BodyColumns.TEXT_CONTENT
105    };
106
107    private static final String[] PRESENCE_STATUS_PROJECTION =
108        new String[] { Contacts.CONTACT_PRESENCE };
109
110    private static final int BODY_CONTENT_COLUMN_RECORD_ID = 0;
111    private static final int BODY_CONTENT_COLUMN_MESSAGE_KEY = 1;
112    private static final int BODY_CONTENT_COLUMN_HTML_CONTENT = 2;
113    private static final int BODY_CONTENT_COLUMN_TEXT_CONTENT = 3;
114
115    private TextView mSubjectView;
116    private TextView mFromView;
117    private TextView mDateView;
118    private TextView mTimeView;
119    private TextView mToView;
120    private TextView mCcView;
121    private View mCcContainerView;
122    private WebView mMessageContentView;
123    private LinearLayout mAttachments;
124    private ImageView mAttachmentIcon;
125    private ImageView mFavoriteIcon;
126    private View mShowPicturesSection;
127    private View mInviteSection;
128    private ImageView mSenderPresenceView;
129    private ProgressDialog mProgressDialog;
130    private View mScrollView;
131
132    // calendar meeting invite answers
133    private TextView mMeetingYes;
134    private TextView mMeetingMaybe;
135    private TextView mMeetingNo;
136    private int mPreviousMeetingResponse = -1;
137
138    private long mAccountId;
139    private long mMessageId;
140    private long mMailboxId;
141    private Message mMessage;
142    private long mWaitForLoadMessageId;
143
144    private LoadMessageTask mLoadMessageTask;
145    private LoadBodyTask mLoadBodyTask;
146    private LoadAttachmentsTask mLoadAttachmentsTask;
147    private PresenceCheckTask mPresenceCheckTask;
148
149    private long mLoadAttachmentId;         // the attachment being saved/viewed
150    private boolean mLoadAttachmentSave;    // if true, saving - if false, viewing
151    private String mLoadAttachmentName;     // the display name
152
153    private java.text.DateFormat mDateFormat;
154    private java.text.DateFormat mTimeFormat;
155
156    private Drawable mFavoriteIconOn;
157    private Drawable mFavoriteIconOff;
158
159    private MessageViewHandler mHandler;
160    private Controller mController;
161    private ControllerResults mControllerCallback;
162
163    private View mMoveToNewer;
164    private View mMoveToOlder;
165    private LoadMessageListTask mLoadMessageListTask;
166    private Cursor mMessageListCursor;
167    private ContentObserver mCursorObserver;
168
169    // contains the HTML body. Is used by LoadAttachmentTask to display inline images.
170    // is null most of the time, is used transiently to pass info to LoadAttachementTask
171    private String mHtmlTextRaw;
172
173    // contains the HTML content as set in WebView.
174    private String mHtmlTextWebView;
175
176    // this is true when reply & forward are disabled, such as messages in the trash
177    private boolean mDisableReplyAndForward;
178
179    private class MessageViewHandler extends Handler {
180        private static final int MSG_PROGRESS = 1;
181        private static final int MSG_ATTACHMENT_PROGRESS = 2;
182        private static final int MSG_LOAD_CONTENT_URI = 3;
183        private static final int MSG_SET_ATTACHMENTS_ENABLED = 4;
184        private static final int MSG_LOAD_BODY_ERROR = 5;
185        private static final int MSG_NETWORK_ERROR = 6;
186        private static final int MSG_FETCHING_ATTACHMENT = 10;
187        private static final int MSG_VIEW_ATTACHMENT_ERROR = 12;
188        private static final int MSG_UPDATE_ATTACHMENT_ICON = 18;
189        private static final int MSG_FINISH_LOAD_ATTACHMENT = 19;
190
191        @Override
192        public void handleMessage(android.os.Message msg) {
193            switch (msg.what) {
194                case MSG_PROGRESS:
195                    setProgressBarIndeterminateVisibility(msg.arg1 != 0);
196                    break;
197                case MSG_ATTACHMENT_PROGRESS:
198                    boolean progress = (msg.arg1 != 0);
199                    if (progress) {
200                        mProgressDialog.setMessage(
201                                getString(R.string.message_view_fetching_attachment_progress,
202                                        mLoadAttachmentName));
203                        mProgressDialog.show();
204                    } else {
205                        mProgressDialog.dismiss();
206                    }
207                    setProgressBarIndeterminateVisibility(progress);
208                    break;
209                case MSG_LOAD_CONTENT_URI:
210                    String uriString = (String) msg.obj;
211                    if (mMessageContentView != null) {
212                        mMessageContentView.loadUrl(uriString);
213                    }
214                    break;
215                case MSG_SET_ATTACHMENTS_ENABLED:
216                    for (int i = 0, count = mAttachments.getChildCount(); i < count; i++) {
217                        AttachmentInfo attachment =
218                            (AttachmentInfo) mAttachments.getChildAt(i).getTag();
219                        attachment.viewButton.setEnabled(msg.arg1 == 1);
220                        attachment.downloadButton.setEnabled(msg.arg1 == 1);
221                    }
222                    break;
223                case MSG_LOAD_BODY_ERROR:
224                    Toast.makeText(MessageView.this,
225                            R.string.error_loading_message_body, Toast.LENGTH_LONG).show();
226                    break;
227                case MSG_NETWORK_ERROR:
228                    Toast.makeText(MessageView.this,
229                            R.string.status_network_error, Toast.LENGTH_LONG).show();
230                    break;
231                case MSG_FETCHING_ATTACHMENT:
232                    Toast.makeText(MessageView.this,
233                            getString(R.string.message_view_fetching_attachment_toast),
234                            Toast.LENGTH_SHORT).show();
235                    break;
236                case MSG_VIEW_ATTACHMENT_ERROR:
237                    Toast.makeText(MessageView.this,
238                            getString(R.string.message_view_display_attachment_toast),
239                            Toast.LENGTH_SHORT).show();
240                    break;
241                case MSG_UPDATE_ATTACHMENT_ICON:
242                    ((AttachmentInfo) mAttachments.getChildAt(msg.arg1).getTag())
243                        .iconView.setImageBitmap((Bitmap) msg.obj);
244                    break;
245                case MSG_FINISH_LOAD_ATTACHMENT:
246                    long attachmentId = (Long)msg.obj;
247                    doFinishLoadAttachment(attachmentId);
248                    break;
249                default:
250                    super.handleMessage(msg);
251            }
252        }
253
254        public void attachmentProgress(boolean progress) {
255            android.os.Message msg = android.os.Message.obtain(this, MSG_ATTACHMENT_PROGRESS);
256            msg.arg1 = progress ? 1 : 0;
257            sendMessage(msg);
258        }
259
260        public void progress(boolean progress) {
261            android.os.Message msg = android.os.Message.obtain(this, MSG_PROGRESS);
262            msg.arg1 = progress ? 1 : 0;
263            sendMessage(msg);
264        }
265
266        public void loadContentUri(String uriString) {
267            android.os.Message msg = android.os.Message.obtain(this, MSG_LOAD_CONTENT_URI);
268            msg.obj = uriString;
269            sendMessage(msg);
270        }
271
272        public void setAttachmentsEnabled(boolean enabled) {
273            android.os.Message msg = android.os.Message.obtain(this, MSG_SET_ATTACHMENTS_ENABLED);
274            msg.arg1 = enabled ? 1 : 0;
275            sendMessage(msg);
276        }
277
278        public void loadBodyError() {
279            sendEmptyMessage(MSG_LOAD_BODY_ERROR);
280        }
281
282        public void networkError() {
283            sendEmptyMessage(MSG_NETWORK_ERROR);
284        }
285
286        public void fetchingAttachment() {
287            sendEmptyMessage(MSG_FETCHING_ATTACHMENT);
288        }
289
290        public void attachmentViewError() {
291            sendEmptyMessage(MSG_VIEW_ATTACHMENT_ERROR);
292        }
293
294        public void updateAttachmentIcon(int pos, Bitmap icon) {
295            android.os.Message msg = android.os.Message.obtain(this, MSG_UPDATE_ATTACHMENT_ICON);
296            msg.arg1 = pos;
297            msg.obj = icon;
298            sendMessage(msg);
299        }
300
301        public void finishLoadAttachment(long attachmentId) {
302            android.os.Message msg = android.os.Message.obtain(this, MSG_FINISH_LOAD_ATTACHMENT);
303            msg.obj = Long.valueOf(attachmentId);
304            sendMessage(msg);
305        }
306    }
307
308    /**
309     * Encapsulates known information about a single attachment.
310     */
311    private static class AttachmentInfo {
312        public String name;
313        public String contentType;
314        public long size;
315        public long attachmentId;
316        public Button viewButton;
317        public Button downloadButton;
318        public ImageView iconView;
319    }
320
321    /**
322     * View a specific message found in the Email provider.
323     * @param messageId the message to view.
324     * @param mailboxId identifies the sequence of messages used for newer/older navigation.
325     * @param disableReplyAndForward set if reply/forward do not make sense for this message
326     *        (e.g. messages in Trash).
327     */
328    public static void actionView(Context context, long messageId, long mailboxId,
329            boolean disableReplyAndForward) {
330        if (messageId < 0) {
331            throw new IllegalArgumentException("MessageView invalid messageId " + messageId);
332        }
333        Intent i = new Intent(context, MessageView.class);
334        i.putExtra(EXTRA_MESSAGE_ID, messageId);
335        i.putExtra(EXTRA_MAILBOX_ID, mailboxId);
336        i.putExtra(EXTRA_DISABLE_REPLY, disableReplyAndForward);
337        context.startActivity(i);
338    }
339
340    public static void actionView(Context context, long messageId, long mailboxId) {
341        actionView(context, messageId, mailboxId, false);
342    }
343
344    @Override
345    public void onCreate(Bundle icicle) {
346        super.onCreate(icicle);
347        setContentView(R.layout.message_view);
348
349        mHandler = new MessageViewHandler();
350        mControllerCallback = new ControllerResults();
351
352        mSubjectView = (TextView) findViewById(R.id.subject);
353        mFromView = (TextView) findViewById(R.id.from);
354        mToView = (TextView) findViewById(R.id.to);
355        mCcView = (TextView) findViewById(R.id.cc);
356        mCcContainerView = findViewById(R.id.cc_container);
357        mDateView = (TextView) findViewById(R.id.date);
358        mTimeView = (TextView) findViewById(R.id.time);
359        mMessageContentView = (WebView) findViewById(R.id.message_content);
360        mAttachments = (LinearLayout) findViewById(R.id.attachments);
361        mAttachmentIcon = (ImageView) findViewById(R.id.attachment);
362        mFavoriteIcon = (ImageView) findViewById(R.id.favorite);
363        mShowPicturesSection = findViewById(R.id.show_pictures_section);
364        mInviteSection = findViewById(R.id.invite_section);
365        mSenderPresenceView = (ImageView) findViewById(R.id.presence);
366        mMoveToNewer = findViewById(R.id.moveToNewer);
367        mMoveToOlder = findViewById(R.id.moveToOlder);
368        mScrollView = findViewById(R.id.scrollview);
369
370        mMoveToNewer.setOnClickListener(this);
371        mMoveToOlder.setOnClickListener(this);
372        mFromView.setOnClickListener(this);
373        mSenderPresenceView.setOnClickListener(this);
374        mFavoriteIcon.setOnClickListener(this);
375        findViewById(R.id.reply).setOnClickListener(this);
376        findViewById(R.id.reply_all).setOnClickListener(this);
377        findViewById(R.id.delete).setOnClickListener(this);
378        findViewById(R.id.show_pictures).setOnClickListener(this);
379
380        mMeetingYes = (TextView) findViewById(R.id.accept);
381        mMeetingMaybe = (TextView) findViewById(R.id.maybe);
382        mMeetingNo = (TextView) findViewById(R.id.decline);
383
384        mMeetingYes.setOnClickListener(this);
385        mMeetingMaybe.setOnClickListener(this);
386        mMeetingNo.setOnClickListener(this);
387        findViewById(R.id.invite_link).setOnClickListener(this);
388
389        mMessageContentView.setClickable(true);
390        mMessageContentView.setLongClickable(false);    // Conflicts with ScrollView, unfortunately
391        mMessageContentView.setVerticalScrollBarEnabled(false);
392        mMessageContentView.getSettings().setBlockNetworkLoads(true);
393        mMessageContentView.getSettings().setSupportZoom(false);
394        mMessageContentView.setWebViewClient(new CustomWebViewClient());
395
396        mProgressDialog = new ProgressDialog(this);
397        mProgressDialog.setIndeterminate(true);
398        mProgressDialog.setProgressStyle(ProgressDialog.STYLE_SPINNER);
399
400        mDateFormat = android.text.format.DateFormat.getDateFormat(this);   // short format
401        mTimeFormat = android.text.format.DateFormat.getTimeFormat(this);   // 12/24 date format
402
403        mFavoriteIconOn = getResources().getDrawable(R.drawable.btn_star_big_buttonless_on);
404        mFavoriteIconOff = getResources().getDrawable(R.drawable.btn_star_big_buttonless_off);
405
406        initFromIntent();
407        if (icicle != null) {
408            mMessageId = icicle.getLong(STATE_MESSAGE_ID, mMessageId);
409        }
410
411        mController = Controller.getInstance(getApplication());
412
413        // This observer is used to watch for external changes to the message list
414        mCursorObserver = new ContentObserver(mHandler){
415                @Override
416                public void onChange(boolean selfChange) {
417                    // get a new message list cursor, but only if we already had one
418                    // (otherwise it's "too soon" and other pathways will cause it to be loaded)
419                    if (mLoadMessageListTask == null && mMessageListCursor != null) {
420                        mLoadMessageListTask = new LoadMessageListTask(mMailboxId);
421                        mLoadMessageListTask.execute();
422                    }
423                }
424            };
425
426        messageChanged();
427    }
428
429    /* package */ void initFromIntent() {
430        Intent intent = getIntent();
431        mMessageId = intent.getLongExtra(EXTRA_MESSAGE_ID, -1);
432        mMailboxId = intent.getLongExtra(EXTRA_MAILBOX_ID, -1);
433        mDisableReplyAndForward = intent.getBooleanExtra(EXTRA_DISABLE_REPLY, false);
434        if (mDisableReplyAndForward) {
435            findViewById(R.id.reply).setEnabled(false);
436            findViewById(R.id.reply_all).setEnabled(false);
437        }
438    }
439
440    @Override
441    protected void onSaveInstanceState(Bundle state) {
442        super.onSaveInstanceState(state);
443        if (mMessageId != -1) {
444            state.putLong(STATE_MESSAGE_ID, mMessageId);
445        }
446    }
447
448    @Override
449    public void onResume() {
450        super.onResume();
451        mWaitForLoadMessageId = -1;
452        mController.addResultCallback(mControllerCallback);
453
454        // Exit immediately if the accounts list has changed (e.g. externally deleted)
455        if (Email.getNotifyUiAccountsChanged()) {
456            Welcome.actionStart(this);
457            finish();
458            return;
459        }
460
461        if (mMessage != null) {
462            startPresenceCheck();
463
464            // get a new message list cursor, but only if mailbox is set
465            // (otherwise it's "too soon" and other pathways will cause it to be loaded)
466            if (mLoadMessageListTask == null && mMailboxId != -1) {
467                mLoadMessageListTask = new LoadMessageListTask(mMailboxId);
468                mLoadMessageListTask.execute();
469            }
470        }
471    }
472
473    @Override
474    public void onPause() {
475        super.onPause();
476        mController.removeResultCallback(mControllerCallback);
477        closeMessageListCursor();
478    }
479
480    private void closeMessageListCursor() {
481        if (mMessageListCursor != null) {
482            mMessageListCursor.unregisterContentObserver(mCursorObserver);
483            mMessageListCursor.close();
484            mMessageListCursor = null;
485        }
486    }
487
488    private void cancelAllTasks() {
489        Utility.cancelTaskInterrupt(mLoadMessageTask);
490        mLoadMessageTask = null;
491        Utility.cancelTaskInterrupt(mLoadBodyTask);
492        mLoadBodyTask = null;
493        Utility.cancelTaskInterrupt(mLoadAttachmentsTask);
494        mLoadAttachmentsTask = null;
495        Utility.cancelTaskInterrupt(mLoadMessageListTask);
496        mLoadMessageListTask = null;
497        Utility.cancelTaskInterrupt(mPresenceCheckTask);
498        mPresenceCheckTask = null;
499    }
500
501    /**
502     * We override onDestroy to make sure that the WebView gets explicitly destroyed.
503     * Otherwise it can leak native references.
504     */
505    @Override
506    public void onDestroy() {
507        super.onDestroy();
508        cancelAllTasks();
509        // This is synchronized because the listener accesses mMessageContentView from its thread
510        synchronized (this) {
511            mMessageContentView.destroy();
512            mMessageContentView = null;
513        }
514        // the cursor was closed in onPause()
515    }
516
517    private void onDelete() {
518        if (mMessage != null) {
519            // the delete triggers mCursorObserver
520            // first move to older/newer before the actual delete
521            long messageIdToDelete = mMessageId;
522            boolean moved = moveToOlder() || moveToNewer();
523            mController.deleteMessage(messageIdToDelete, mMessage.mAccountKey);
524            Toast.makeText(this, getResources().getQuantityString(R.plurals.message_deleted_toast,
525                    1), Toast.LENGTH_SHORT).show();
526            if (!moved) {
527                // this generates a benign warning "Duplicate finish request" because
528                // repositionMessageListCursor() will fail to reposition and do its own finish()
529                finish();
530            }
531        }
532    }
533
534    /**
535     * Overrides for various WebView behaviors.
536     */
537    private class CustomWebViewClient extends WebViewClient {
538        /**
539         * This is intended to mirror the operation of the original
540         * (see android.webkit.CallbackProxy) with one addition of intent flags
541         * "FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET".  This improves behavior when sublaunching
542         * other apps via embedded URI's.
543         *
544         * We also use this hook to catch "mailto:" links and handle them locally.
545         */
546        @Override
547        public boolean shouldOverrideUrlLoading(WebView view, String url) {
548            // hijack mailto: uri's and handle locally
549            if (url != null && url.toLowerCase().startsWith("mailto:")) {
550                return MessageCompose.actionCompose(MessageView.this, url, mAccountId);
551            }
552
553            // Handle most uri's via intent launch
554            boolean result = false;
555            Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url));
556            intent.addCategory(Intent.CATEGORY_BROWSABLE);
557            intent.putExtra(Browser.EXTRA_APPLICATION_ID, getPackageName());
558            intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET);
559            try {
560                startActivity(intent);
561                result = true;
562            } catch (ActivityNotFoundException ex) {
563                // If no application can handle the URL, assume that the
564                // caller can handle it.
565            }
566            return result;
567        }
568    }
569
570    /**
571     * Handle clicks on sender, which shows {@link QuickContact} or prompts to add
572     * the sender as a contact.
573     */
574    private void onClickSender() {
575        // Bail early if message or sender not present
576        if (mMessage == null) return;
577
578        final Address senderEmail = Address.unpackFirst(mMessage.mFrom);
579        if (senderEmail == null) return;
580
581        // First perform lookup query to find existing contact
582        final ContentResolver resolver = getContentResolver();
583        final String address = senderEmail.getAddress();
584        final Uri dataUri = Uri.withAppendedPath(CommonDataKinds.Email.CONTENT_FILTER_URI,
585                Uri.encode(address));
586        final Uri lookupUri = ContactsContract.Data.getContactLookupUri(resolver, dataUri);
587
588        if (lookupUri != null) {
589            // Found matching contact, trigger QuickContact
590            QuickContact.showQuickContact(this, mSenderPresenceView, lookupUri,
591                    QuickContact.MODE_LARGE, null);
592        } else {
593            // No matching contact, ask user to create one
594            final Uri mailUri = Uri.fromParts("mailto", address, null);
595            final Intent intent = new Intent(ContactsContract.Intents.SHOW_OR_CREATE_CONTACT,
596                    mailUri);
597
598            // Pass along full E-mail string for possible create dialog
599            intent.putExtra(ContactsContract.Intents.EXTRA_CREATE_DESCRIPTION,
600                    senderEmail.toString());
601
602            // Only provide personal name hint if we have one
603            final String senderPersonal = senderEmail.getPersonal();
604            if (!TextUtils.isEmpty(senderPersonal)) {
605                intent.putExtra(ContactsContract.Intents.Insert.NAME, senderPersonal);
606            }
607            intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET);
608
609            startActivity(intent);
610        }
611    }
612
613    /**
614     * Toggle favorite status and write back to provider
615     */
616    private void onClickFavorite() {
617        if (mMessage != null) {
618            // Update UI
619            boolean newFavorite = ! mMessage.mFlagFavorite;
620            mFavoriteIcon.setImageDrawable(newFavorite ? mFavoriteIconOn : mFavoriteIconOff);
621
622            // Update provider
623            mMessage.mFlagFavorite = newFavorite;
624            mController.setMessageFavorite(mMessageId, newFavorite);
625        }
626    }
627
628    private void onReply() {
629        if (mMessage != null) {
630            MessageCompose.actionReply(this, mMessage.mId, false);
631            finish();
632        }
633    }
634
635    private void onReplyAll() {
636        if (mMessage != null) {
637            MessageCompose.actionReply(this, mMessage.mId, true);
638            finish();
639        }
640    }
641
642    private void onForward() {
643        if (mMessage != null) {
644            MessageCompose.actionForward(this, mMessage.mId);
645            finish();
646        }
647    }
648
649    private boolean moveToOlder() {
650        // Guard with !isLast() because Cursor.moveToNext() returns false even as it moves
651        // from last to after-last.
652        if (mMessageListCursor != null
653                && !mMessageListCursor.isLast()
654                && mMessageListCursor.moveToNext()) {
655            mMessageId = mMessageListCursor.getLong(0);
656            messageChanged();
657            return true;
658        }
659        return false;
660    }
661
662    private boolean moveToNewer() {
663        // Guard with !isFirst() because Cursor.moveToPrev() returns false even as it moves
664        // from first to before-first.
665        if (mMessageListCursor != null
666                && !mMessageListCursor.isFirst()
667                && mMessageListCursor.moveToPrevious()) {
668            mMessageId = mMessageListCursor.getLong(0);
669            messageChanged();
670            return true;
671        }
672        return false;
673    }
674
675    private void onMarkAsRead(boolean isRead) {
676        if (mMessage != null && mMessage.mFlagRead != isRead) {
677            mMessage.mFlagRead = isRead;
678            mController.setMessageRead(mMessageId, isRead);
679        }
680    }
681
682    /**
683     * Creates a unique file in the given directory by appending a hyphen
684     * and a number to the given filename.
685     * @param directory
686     * @param filename
687     * @return a new File object, or null if one could not be created
688     */
689    /* package */ static File createUniqueFile(File directory, String filename) {
690        File file = new File(directory, filename);
691        if (!file.exists()) {
692            return file;
693        }
694        // Get the extension of the file, if any.
695        int index = filename.lastIndexOf('.');
696        String format;
697        if (index != -1) {
698            String name = filename.substring(0, index);
699            String extension = filename.substring(index);
700            format = name + "-%d" + extension;
701        }
702        else {
703            format = filename + "-%d";
704        }
705        for (int i = 2; i < Integer.MAX_VALUE; i++) {
706            file = new File(directory, String.format(format, i));
707            if (!file.exists()) {
708                return file;
709            }
710        }
711        return null;
712    }
713
714    /**
715     * Send a service message indicating that a meeting invite button has been clicked.
716     */
717    private void onRespond(int response, int toastResId) {
718        // do not send twice in a row the same response
719        if (mPreviousMeetingResponse != response) {
720            mController.sendMeetingResponse(mMessageId, response, mControllerCallback);
721            mPreviousMeetingResponse = response;
722        }
723        Toast.makeText(this, toastResId, Toast.LENGTH_SHORT).show();
724        if (!moveToOlder()) {
725            finish(); // if this is the last message, move up to message-list.
726        }
727    }
728
729    private void onDownloadAttachment(AttachmentInfo attachment) {
730        if (!Environment.getExternalStorageState().equals(Environment.MEDIA_MOUNTED)) {
731            /*
732             * Abort early if there's no place to save the attachment. We don't want to spend
733             * the time downloading it and then abort.
734             */
735            Toast.makeText(this,
736                    getString(R.string.message_view_status_attachment_not_saved),
737                    Toast.LENGTH_SHORT).show();
738            return;
739        }
740
741        mLoadAttachmentId = attachment.attachmentId;
742        mLoadAttachmentSave = true;
743        mLoadAttachmentName = attachment.name;
744
745        mController.loadAttachment(attachment.attachmentId, mMessageId, mMessage.mMailboxKey,
746                mAccountId, mControllerCallback);
747    }
748
749    private void onViewAttachment(AttachmentInfo attachment) {
750        mLoadAttachmentId = attachment.attachmentId;
751        mLoadAttachmentSave = false;
752        mLoadAttachmentName = attachment.name;
753
754        mController.loadAttachment(attachment.attachmentId, mMessageId, mMessage.mMailboxKey,
755                mAccountId, mControllerCallback);
756    }
757
758    private void onShowPictures() {
759        if (mMessage != null) {
760            if (mMessageContentView != null) {
761                mMessageContentView.getSettings().setBlockNetworkLoads(false);
762                if (mHtmlTextWebView != null) {
763                    mMessageContentView.loadDataWithBaseURL("email://", mHtmlTextWebView,
764                                                            "text/html", "utf-8", null);
765                }
766            }
767            mShowPicturesSection.setVisibility(View.GONE);
768        }
769    }
770
771    public void onClick(View view) {
772        switch (view.getId()) {
773            case R.id.from:
774            case R.id.presence:
775                onClickSender();
776                break;
777            case R.id.favorite:
778                onClickFavorite();
779                break;
780            case R.id.reply:
781                onReply();
782                break;
783            case R.id.reply_all:
784                onReplyAll();
785                break;
786            case R.id.delete:
787                onDelete();
788                break;
789            case R.id.moveToOlder:
790                moveToOlder();
791                break;
792            case R.id.moveToNewer:
793                moveToNewer();
794                break;
795            case R.id.download:
796                onDownloadAttachment((AttachmentInfo) view.getTag());
797                break;
798            case R.id.view:
799                onViewAttachment((AttachmentInfo) view.getTag());
800                break;
801            case R.id.show_pictures:
802                onShowPictures();
803                break;
804            case R.id.accept:
805                onRespond(EmailServiceConstants.MEETING_REQUEST_ACCEPTED,
806                         R.string.message_view_invite_toast_yes);
807                break;
808            case R.id.maybe:
809                onRespond(EmailServiceConstants.MEETING_REQUEST_TENTATIVE,
810                         R.string.message_view_invite_toast_maybe);
811                break;
812            case R.id.decline:
813                onRespond(EmailServiceConstants.MEETING_REQUEST_DECLINED,
814                         R.string.message_view_invite_toast_no);
815                break;
816            case R.id.invite_link:
817                String startTime =
818                    new PackedString(mMessage.mMeetingInfo).get(MeetingInfo.MEETING_DTSTART);
819                if (startTime != null) {
820                    long epochTimeMillis = Utility.parseEmailDateTimeToMillis(startTime);
821                    Uri uri = Uri.parse("content://com.android.calendar/time/" + epochTimeMillis);
822                    Intent intent = new Intent(Intent.ACTION_VIEW);
823                    intent.setData(uri);
824                    intent.putExtra("VIEW", "DAY");
825                    intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET);
826                    startActivity(intent);
827                } else {
828                    Email.log("meetingInfo without DTSTART " + mMessage.mMeetingInfo);
829                }
830                break;
831        }
832    }
833
834   @Override
835    public boolean onOptionsItemSelected(MenuItem item) {
836       boolean handled = handleMenuItem(item.getItemId());
837       if (!handled) {
838           handled = super.onOptionsItemSelected(item);
839       }
840       return handled;
841   }
842
843   /**
844    * This is the core functionality of onOptionsItemSelected() but broken out and exposed
845    * for testing purposes (because it's annoying to mock a MenuItem).
846    *
847    * @param menuItemId id that was clicked
848    * @return true if handled here
849    */
850   /* package */ boolean handleMenuItem(int menuItemId) {
851       switch (menuItemId) {
852           case R.id.delete:
853               onDelete();
854               break;
855           case R.id.reply:
856               onReply();
857               break;
858           case R.id.reply_all:
859               onReplyAll();
860               break;
861           case R.id.forward:
862               onForward();
863               break;
864           case R.id.mark_as_unread:
865               onMarkAsRead(false);
866               finish();
867               break;
868           default:
869               return false;
870       }
871       return true;
872   }
873
874    @Override
875    public boolean onCreateOptionsMenu(Menu menu) {
876        super.onCreateOptionsMenu(menu);
877        getMenuInflater().inflate(R.menu.message_view_option, menu);
878        if (mDisableReplyAndForward) {
879            menu.findItem(R.id.forward).setEnabled(false);
880            menu.findItem(R.id.reply).setEnabled(false);
881            menu.findItem(R.id.reply_all).setEnabled(false);
882        }
883        return true;
884    }
885
886    /**
887     * Re-init everything needed for changing message.
888     */
889    private void messageChanged() {
890        if (Email.DEBUG) {
891            Email.log("MessageView: messageChanged to id=" + mMessageId);
892        }
893        cancelAllTasks();
894        setTitle("");
895        if (mMessageContentView != null) {
896            mMessageContentView.scrollTo(0, 0);
897            mMessageContentView.loadUrl("file:///android_asset/empty.html");
898        }
899        mScrollView.scrollTo(0, 0);
900        mAttachments.removeAllViews();
901        mAttachments.setVisibility(View.GONE);
902        mAttachmentIcon.setVisibility(View.GONE);
903
904        // Start an AsyncTask to make a new cursor and load the message
905        mLoadMessageTask = new LoadMessageTask(mMessageId, true);
906        mLoadMessageTask.execute();
907        updateNavigationArrows(mMessageListCursor);
908    }
909
910    /**
911     * Reposition the older/newer cursor.  Finish() the activity if we are no longer
912     * in the list.  Update the UI arrows as appropriate.
913     */
914    private void repositionMessageListCursor() {
915        if (Email.DEBUG) {
916            Email.log("MessageView: reposition to id=" + mMessageId);
917        }
918        // position the cursor on the current message
919        mMessageListCursor.moveToPosition(-1);
920        while (mMessageListCursor.moveToNext() && mMessageListCursor.getLong(0) != mMessageId) {
921        }
922        if (mMessageListCursor.isAfterLast()) {
923            // overshoot - get out now, the list is no longer valid
924            finish();
925        }
926        updateNavigationArrows(mMessageListCursor);
927    }
928
929    /**
930     * Update the arrows based on the current position of the older/newer cursor.
931     */
932    private void updateNavigationArrows(Cursor cursor) {
933        if (cursor != null) {
934            boolean hasNewer, hasOlder;
935            if (cursor.isAfterLast() || cursor.isBeforeFirst()) {
936                // The cursor not being on a message means that the current message was not found.
937                // While this should not happen, simply disable prev/next arrows in that case.
938                hasNewer = hasOlder = false;
939            } else {
940                hasNewer = !cursor.isFirst();
941                hasOlder = !cursor.isLast();
942            }
943            mMoveToNewer.setVisibility(hasNewer ? View.VISIBLE : View.INVISIBLE);
944            mMoveToOlder.setVisibility(hasOlder ? View.VISIBLE : View.INVISIBLE);
945        }
946    }
947
948    private Bitmap getPreviewIcon(AttachmentInfo attachment) {
949        try {
950            return BitmapFactory.decodeStream(
951                    getContentResolver().openInputStream(
952                            AttachmentProvider.getAttachmentThumbnailUri(
953                                    mAccountId, attachment.attachmentId,
954                                    62,
955                                    62)));
956        }
957        catch (Exception e) {
958            Log.d(Email.LOG_TAG, "Attachment preview failed with exception " + e.getMessage());
959            return null;
960        }
961    }
962
963    /*
964     * Formats the given size as a String in bytes, kB, MB or GB with a single digit
965     * of precision. Ex: 12,315,000 = 12.3 MB
966     */
967    public static String formatSize(float size) {
968        long kb = 1024;
969        long mb = (kb * 1024);
970        long gb  = (mb * 1024);
971        if (size < kb) {
972            return String.format("%d bytes", (int) size);
973        }
974        else if (size < mb) {
975            return String.format("%.1f kB", size / kb);
976        }
977        else if (size < gb) {
978            return String.format("%.1f MB", size / mb);
979        }
980        else {
981            return String.format("%.1f GB", size / gb);
982        }
983    }
984
985    private void updateAttachmentThumbnail(long attachmentId) {
986        for (int i = 0, count = mAttachments.getChildCount(); i < count; i++) {
987            AttachmentInfo attachment = (AttachmentInfo) mAttachments.getChildAt(i).getTag();
988            if (attachment.attachmentId == attachmentId) {
989                Bitmap previewIcon = getPreviewIcon(attachment);
990                if (previewIcon != null) {
991                    mHandler.updateAttachmentIcon(i, previewIcon);
992                }
993                return;
994            }
995        }
996    }
997
998    /**
999     * Copy data from a cursor-refreshed attachment into the UI.  Called from UI thread.
1000     *
1001     * @param attachment A single attachment loaded from the provider
1002     */
1003    private void addAttachment(Attachment attachment) {
1004
1005        AttachmentInfo attachmentInfo = new AttachmentInfo();
1006        attachmentInfo.size = attachment.mSize;
1007        attachmentInfo.contentType =
1008                AttachmentProvider.inferMimeType(attachment.mFileName, attachment.mMimeType);
1009        attachmentInfo.name = attachment.mFileName;
1010        attachmentInfo.attachmentId = attachment.mId;
1011
1012        LayoutInflater inflater = getLayoutInflater();
1013        View view = inflater.inflate(R.layout.message_view_attachment, null);
1014
1015        TextView attachmentName = (TextView)view.findViewById(R.id.attachment_name);
1016        TextView attachmentInfoView = (TextView)view.findViewById(R.id.attachment_info);
1017        ImageView attachmentIcon = (ImageView)view.findViewById(R.id.attachment_icon);
1018        Button attachmentView = (Button)view.findViewById(R.id.view);
1019        Button attachmentDownload = (Button)view.findViewById(R.id.download);
1020
1021        if ((!MimeUtility.mimeTypeMatches(attachmentInfo.contentType,
1022                Email.ACCEPTABLE_ATTACHMENT_VIEW_TYPES))
1023                || (MimeUtility.mimeTypeMatches(attachmentInfo.contentType,
1024                        Email.UNACCEPTABLE_ATTACHMENT_VIEW_TYPES))) {
1025            attachmentView.setVisibility(View.GONE);
1026        }
1027        if ((!MimeUtility.mimeTypeMatches(attachmentInfo.contentType,
1028                Email.ACCEPTABLE_ATTACHMENT_DOWNLOAD_TYPES))
1029                || (MimeUtility.mimeTypeMatches(attachmentInfo.contentType,
1030                        Email.UNACCEPTABLE_ATTACHMENT_DOWNLOAD_TYPES))) {
1031            attachmentDownload.setVisibility(View.GONE);
1032        }
1033
1034        if (attachmentInfo.size > Email.MAX_ATTACHMENT_DOWNLOAD_SIZE) {
1035            attachmentView.setVisibility(View.GONE);
1036            attachmentDownload.setVisibility(View.GONE);
1037        }
1038
1039        attachmentInfo.viewButton = attachmentView;
1040        attachmentInfo.downloadButton = attachmentDownload;
1041        attachmentInfo.iconView = attachmentIcon;
1042
1043        view.setTag(attachmentInfo);
1044        attachmentView.setOnClickListener(this);
1045        attachmentView.setTag(attachmentInfo);
1046        attachmentDownload.setOnClickListener(this);
1047        attachmentDownload.setTag(attachmentInfo);
1048
1049        attachmentName.setText(attachmentInfo.name);
1050        attachmentInfoView.setText(formatSize(attachmentInfo.size));
1051
1052        Bitmap previewIcon = getPreviewIcon(attachmentInfo);
1053        if (previewIcon != null) {
1054            attachmentIcon.setImageBitmap(previewIcon);
1055        }
1056
1057        mAttachments.addView(view);
1058        mAttachments.setVisibility(View.VISIBLE);
1059    }
1060
1061    private class PresenceCheckTask extends AsyncTask<String, Void, Integer> {
1062        @Override
1063        protected Integer doInBackground(String... emails) {
1064            Cursor cursor =
1065                    getContentResolver().query(ContactsContract.Data.CONTENT_URI,
1066                    PRESENCE_STATUS_PROJECTION, CommonDataKinds.Email.DATA + "=?", emails, null);
1067            if (cursor != null) {
1068                try {
1069                    if (cursor.moveToFirst()) {
1070                        int status = cursor.getInt(0);
1071                        int icon = StatusUpdates.getPresenceIconResourceId(status);
1072                        return icon;
1073                    }
1074                } finally {
1075                    cursor.close();
1076                }
1077            }
1078            return 0;
1079        }
1080
1081        @Override
1082        protected void onPostExecute(Integer icon) {
1083            if (icon == null) {
1084                return;
1085            }
1086            updateSenderPresence(icon);
1087        }
1088    }
1089
1090    /**
1091     * Launch a thread (because of cross-process DB lookup) to check presence of the sender of the
1092     * message.  When that thread completes, update the UI.
1093     *
1094     * This must only be called when mMessage is null (it will hide presence indications) or when
1095     * mMessage has already seen its headers loaded.
1096     *
1097     * Note:  This is just a polling operation.  A more advanced solution would be to keep the
1098     * cursor open and respond to presence status updates (in the form of content change
1099     * notifications).  However, because presence changes fairly slowly compared to the duration
1100     * of viewing a single message, a simple poll at message load (and onResume) should be
1101     * sufficient.
1102     */
1103    private void startPresenceCheck() {
1104        if (mMessage != null) {
1105            Address sender = Address.unpackFirst(mMessage.mFrom);
1106            if (sender != null) {
1107                String email = sender.getAddress();
1108                if (email != null) {
1109                    mPresenceCheckTask = new PresenceCheckTask();
1110                    mPresenceCheckTask.execute(email);
1111                    return;
1112                }
1113            }
1114        }
1115        updateSenderPresence(0);
1116    }
1117
1118    /**
1119     * Update the actual UI.  Must be called from main thread (or handler)
1120     * @param presenceIconId the presence of the sender, 0 for "unknown"
1121     */
1122    private void updateSenderPresence(int presenceIconId) {
1123        if (presenceIconId == 0) {
1124            // This is a placeholder used for "unknown" presence, including signed off,
1125            // no presence relationship.
1126            presenceIconId = R.drawable.presence_inactive;
1127        }
1128        mSenderPresenceView.setImageResource(presenceIconId);
1129    }
1130
1131
1132    /**
1133     * This task finds out the messageId for the previous and next message
1134     * in the order given by mailboxId as used in MessageList.
1135     *
1136     * It generates the same cursor as the one used in MessageList (but with an id-only projection),
1137     * scans through it until finds the current messageId, and takes the previous and next ids.
1138     */
1139    private class LoadMessageListTask extends AsyncTask<Void, Void, Cursor> {
1140        private long mLocalMailboxId;
1141
1142        public LoadMessageListTask(long mailboxId) {
1143            mLocalMailboxId = mailboxId;
1144        }
1145
1146        @Override
1147        protected Cursor doInBackground(Void... params) {
1148            String selection =
1149                Utility.buildMailboxIdSelection(getContentResolver(), mLocalMailboxId);
1150            Cursor c = getContentResolver().query(EmailContent.Message.CONTENT_URI,
1151                    EmailContent.ID_PROJECTION,
1152                    selection, null,
1153                    EmailContent.MessageColumns.TIMESTAMP + " DESC");
1154            if (isCancelled()) {
1155                c.close();
1156                c = null;
1157            }
1158            return c;
1159        }
1160
1161        @Override
1162        protected void onPostExecute(Cursor cursor) {
1163            // remove the reference to ourselves so another one can be launched
1164            MessageView.this.mLoadMessageListTask = null;
1165
1166            if (cursor == null || cursor.isClosed()) {
1167                return;
1168            }
1169            // replace the older cursor if there is one
1170            closeMessageListCursor();
1171            mMessageListCursor = cursor;
1172            mMessageListCursor.registerContentObserver(MessageView.this.mCursorObserver);
1173            repositionMessageListCursor();
1174        }
1175    }
1176
1177    /**
1178     * Async task for loading a single message outside of the UI thread
1179     * Note:  To support unit testing, a sentinel messageId of Long.MIN_VALUE prevents
1180     * loading the message but leaves the activity open.
1181     */
1182    private class LoadMessageTask extends AsyncTask<Void, Void, Message> {
1183
1184        private long mId;
1185        private boolean mOkToFetch;
1186
1187        /**
1188         * Special constructor to cache some local info
1189         */
1190        public LoadMessageTask(long messageId, boolean okToFetch) {
1191            mId = messageId;
1192            mOkToFetch = okToFetch;
1193        }
1194
1195        @Override
1196        protected Message doInBackground(Void... params) {
1197            if (mId == Long.MIN_VALUE)  {
1198                return null;
1199            }
1200            return Message.restoreMessageWithId(MessageView.this, mId);
1201        }
1202
1203        @Override
1204        protected void onPostExecute(Message message) {
1205            /* doInBackground() may return null result (due to restoreMessageWithId())
1206             * and in that situation we want to Activity.finish().
1207             *
1208             * OTOH we don't want to Activity.finish() for isCancelled() because this
1209             * would introduce a surprise side-effect to task cancellation: every task
1210             * cancelation would also result in finish().
1211             *
1212             * Right now LoadMesageTask is cancelled not only from onDestroy(),
1213             * and it would be a bug to also finish() the activity in that situation.
1214             */
1215            if (isCancelled()) {
1216                return;
1217            }
1218            if (message == null) {
1219                if (mId != Long.MIN_VALUE) {
1220                    finish();
1221                }
1222                return;
1223            }
1224            reloadUiFromMessage(message, mOkToFetch);
1225            startPresenceCheck();
1226        }
1227    }
1228
1229    /**
1230     * Async task for loading a single message body outside of the UI thread
1231     */
1232    private class LoadBodyTask extends AsyncTask<Void, Void, String[]> {
1233
1234        private long mId;
1235
1236        /**
1237         * Special constructor to cache some local info
1238         */
1239        public LoadBodyTask(long messageId) {
1240            mId = messageId;
1241        }
1242
1243        @Override
1244        protected String[] doInBackground(Void... params) {
1245            try {
1246                String text = null;
1247                String html = Body.restoreBodyHtmlWithMessageId(MessageView.this, mId);
1248                if (html == null) {
1249                    text = Body.restoreBodyTextWithMessageId(MessageView.this, mId);
1250                }
1251                return new String[] { text, html };
1252            } catch (RuntimeException re) {
1253                // This catches SQLiteException as well as other RTE's we've seen from the
1254                // database calls, such as IllegalStateException
1255                Log.d(Email.LOG_TAG, "Exception while loading message body: " + re.toString());
1256                mHandler.loadBodyError();
1257                return new String[] { null, null };
1258            }
1259        }
1260
1261        @Override
1262        protected void onPostExecute(String[] results) {
1263            if (results == null) {
1264                return;
1265            }
1266            reloadUiFromBody(results[0], results[1]);    // text, html
1267            onMarkAsRead(true);
1268        }
1269    }
1270
1271    /**
1272     * Async task for loading attachments
1273     *
1274     * Note:  This really should only be called when the message load is complete - or, we should
1275     * leave open a listener so the attachments can fill in as they are discovered.  In either case,
1276     * this implementation is incomplete, as it will fail to refresh properly if the message is
1277     * partially loaded at this time.
1278     */
1279    private class LoadAttachmentsTask extends AsyncTask<Long, Void, Attachment[]> {
1280        @Override
1281        protected Attachment[] doInBackground(Long... messageIds) {
1282            return Attachment.restoreAttachmentsWithMessageId(MessageView.this, messageIds[0]);
1283        }
1284
1285        @Override
1286        protected void onPostExecute(Attachment[] attachments) {
1287            if (attachments == null) {
1288                return;
1289            }
1290            boolean htmlChanged = false;
1291            for (Attachment attachment : attachments) {
1292                if (mHtmlTextRaw != null && attachment.mContentId != null
1293                        && attachment.mContentUri != null) {
1294                    // for html body, replace CID for inline images
1295                    // Regexp which matches ' src="cid:contentId"'.
1296                    String contentIdRe =
1297                        "\\s+(?i)src=\"cid(?-i):\\Q" + attachment.mContentId + "\\E\"";
1298                    String srcContentUri = " src=\"" + attachment.mContentUri + "\"";
1299                    mHtmlTextRaw = mHtmlTextRaw.replaceAll(contentIdRe, srcContentUri);
1300                    htmlChanged = true;
1301                } else {
1302                    addAttachment(attachment);
1303                }
1304            }
1305            mHtmlTextWebView = mHtmlTextRaw;
1306            mHtmlTextRaw = null;
1307            if (htmlChanged && mMessageContentView != null) {
1308                mMessageContentView.loadDataWithBaseURL("email://", mHtmlTextWebView,
1309                                                        "text/html", "utf-8", null);
1310            }
1311        }
1312    }
1313
1314    /**
1315     * Reload the UI from a provider cursor.  This must only be called from the UI thread.
1316     *
1317     * @param message A copy of the message loaded from the database
1318     * @param okToFetch If true, and message is not fully loaded, it's OK to fetch from
1319     * the network.  Use false to prevent looping here.
1320     *
1321     * TODO: trigger presence check
1322     */
1323    private void reloadUiFromMessage(Message message, boolean okToFetch) {
1324        mMessage = message;
1325        mAccountId = message.mAccountKey;
1326        if (mMailboxId == -1) {
1327            mMailboxId = message.mMailboxKey;
1328        }
1329        // only start LoadMessageListTask here if it's the first time
1330        if (mMessageListCursor == null) {
1331            mLoadMessageListTask = new LoadMessageListTask(mMailboxId);
1332            mLoadMessageListTask.execute();
1333        }
1334
1335        mSubjectView.setText(message.mSubject);
1336        mFromView.setText(Address.toFriendly(Address.unpack(message.mFrom)));
1337        Date date = new Date(message.mTimeStamp);
1338        mTimeView.setText(mTimeFormat.format(date));
1339        mDateView.setText(Utility.isDateToday(date) ? null : mDateFormat.format(date));
1340        mToView.setText(Address.toFriendly(Address.unpack(message.mTo)));
1341        String friendlyCc = Address.toFriendly(Address.unpack(message.mCc));
1342        mCcView.setText(friendlyCc);
1343        mCcContainerView.setVisibility((friendlyCc != null) ? View.VISIBLE : View.GONE);
1344        mAttachmentIcon.setVisibility(message.mAttachments != null ? View.VISIBLE : View.GONE);
1345        mFavoriteIcon.setImageDrawable(message.mFlagFavorite ? mFavoriteIconOn : mFavoriteIconOff);
1346        // Show the message invite section if we're an incoming meeting invitation only
1347        mInviteSection.setVisibility((message.mFlags & Message.FLAG_INCOMING_MEETING_INVITE) != 0 ?
1348                View.VISIBLE : View.GONE);
1349
1350        // Handle partially-loaded email, as follows:
1351        // 1. Check value of message.mFlagLoaded
1352        // 2. If != LOADED, ask controller to load it
1353        // 3. Controller callback (after loaded) should trigger LoadBodyTask & LoadAttachmentsTask
1354        // 4. Else start the loader tasks right away (message already loaded)
1355        if (okToFetch && message.mFlagLoaded != Message.FLAG_LOADED_COMPLETE) {
1356            mWaitForLoadMessageId = message.mId;
1357            mController.loadMessageForView(message.mId, mControllerCallback);
1358        } else {
1359            mWaitForLoadMessageId = -1;
1360            // Ask for body
1361            mLoadBodyTask = new LoadBodyTask(message.mId);
1362            mLoadBodyTask.execute();
1363        }
1364    }
1365
1366    /**
1367     * Reload the body from the provider cursor.  This must only be called from the UI thread.
1368     *
1369     * @param bodyText text part
1370     * @param bodyHtml html part
1371     *
1372     * TODO deal with html vs text and many other issues
1373     */
1374    private void reloadUiFromBody(String bodyText, String bodyHtml) {
1375        String text = null;
1376        mHtmlTextRaw = null;
1377        boolean hasImages = false;
1378
1379        if (bodyHtml == null) {
1380            text = bodyText;
1381            /*
1382             * Convert the plain text to HTML
1383             */
1384            StringBuffer sb = new StringBuffer("<html><body>");
1385            if (text != null) {
1386                // Escape any inadvertent HTML in the text message
1387                text = EmailHtmlUtil.escapeCharacterToDisplay(text);
1388                // Find any embedded URL's and linkify
1389                Matcher m = Patterns.WEB_URL.matcher(text);
1390                while (m.find()) {
1391                    int start = m.start();
1392                    /*
1393                     * WEB_URL_PATTERN may match domain part of email address. To detect
1394                     * this false match, the character just before the matched string
1395                     * should not be '@'.
1396                     */
1397                    if (start == 0 || text.charAt(start - 1) != '@') {
1398                        String url = m.group();
1399                        Matcher proto = WEB_URL_PROTOCOL.matcher(url);
1400                        String link;
1401                        if (proto.find()) {
1402                            // This is work around to force URL protocol part be lower case,
1403                            // because WebView could follow only lower case protocol link.
1404                            link = proto.group().toLowerCase() + url.substring(proto.end());
1405                        } else {
1406                            // Patterns.WEB_URL matches URL without protocol part,
1407                            // so added default protocol to link.
1408                            link = "http://" + url;
1409                        }
1410                        String href = String.format("<a href=\"%s\">%s</a>", link, url);
1411                        m.appendReplacement(sb, href);
1412                    }
1413                    else {
1414                        m.appendReplacement(sb, "$0");
1415                    }
1416                }
1417                m.appendTail(sb);
1418            }
1419            sb.append("</body></html>");
1420            text = sb.toString();
1421        } else {
1422            text = bodyHtml;
1423            mHtmlTextRaw = bodyHtml;
1424            hasImages = IMG_TAG_START_REGEX.matcher(text).find();
1425        }
1426
1427        mShowPicturesSection.setVisibility(hasImages ? View.VISIBLE : View.GONE);
1428        if (mMessageContentView != null) {
1429            mMessageContentView.loadDataWithBaseURL("email://", text, "text/html", "utf-8", null);
1430        }
1431
1432        // Ask for attachments after body
1433        mLoadAttachmentsTask = new LoadAttachmentsTask();
1434        mLoadAttachmentsTask.execute(mMessage.mId);
1435    }
1436
1437    /**
1438     * Controller results listener.  This completely replaces MessagingListener
1439     */
1440    private class ControllerResults implements Controller.Result {
1441
1442        public void loadMessageForViewCallback(MessagingException result, long messageId,
1443                int progress) {
1444            if (messageId != MessageView.this.mMessageId
1445                    || messageId != MessageView.this.mWaitForLoadMessageId) {
1446                // We are not waiting for this message to load, so exit quickly
1447                return;
1448            }
1449            if (result == null) {
1450                switch (progress) {
1451                    case 0:
1452                        mHandler.progress(true);
1453                        mHandler.loadContentUri("file:///android_asset/loading.html");
1454                        break;
1455                    case 100:
1456                        mWaitForLoadMessageId = -1;
1457                        mHandler.progress(false);
1458                        // reload UI and reload everything else too
1459                        // pass false to LoadMessageTask to prevent looping here
1460                        cancelAllTasks();
1461                        mLoadMessageTask = new LoadMessageTask(mMessageId, false);
1462                        mLoadMessageTask.execute();
1463                        break;
1464                    default:
1465                        // do nothing - we don't have a progress bar at this time
1466                        break;
1467                }
1468            } else {
1469                mWaitForLoadMessageId = -1;
1470                mHandler.progress(false);
1471                mHandler.networkError();
1472                mHandler.loadContentUri("file:///android_asset/empty.html");
1473            }
1474        }
1475
1476        public void loadAttachmentCallback(MessagingException result, long messageId,
1477                long attachmentId, int progress) {
1478            if (messageId == MessageView.this.mMessageId) {
1479                if (result == null) {
1480                    switch (progress) {
1481                        case 0:
1482                            mHandler.setAttachmentsEnabled(false);
1483                            mHandler.attachmentProgress(true);
1484                            mHandler.fetchingAttachment();
1485                            break;
1486                        case 100:
1487                            mHandler.setAttachmentsEnabled(true);
1488                            mHandler.attachmentProgress(false);
1489                            updateAttachmentThumbnail(attachmentId);
1490                            mHandler.finishLoadAttachment(attachmentId);
1491                            break;
1492                        default:
1493                            // do nothing - we don't have a progress bar at this time
1494                            break;
1495                    }
1496                } else {
1497                    mHandler.setAttachmentsEnabled(true);
1498                    mHandler.attachmentProgress(false);
1499                    mHandler.networkError();
1500                }
1501            }
1502        }
1503
1504        public void updateMailboxCallback(MessagingException result, long accountId,
1505                long mailboxId, int progress, int numNewMessages) {
1506            if (result != null || progress == 100) {
1507                Email.updateMailboxRefreshTime(mailboxId);
1508            }
1509        }
1510
1511        public void updateMailboxListCallback(MessagingException result, long accountId,
1512                int progress) {
1513        }
1514
1515        public void serviceCheckMailCallback(MessagingException result, long accountId,
1516                long mailboxId, int progress, long tag) {
1517        }
1518
1519        public void sendMailCallback(MessagingException result, long accountId, long messageId,
1520                int progress) {
1521        }
1522    }
1523
1524
1525//        @Override
1526//        public void loadMessageForViewBodyAvailable(Account account, String folder,
1527//                String uid, com.android.email.mail.Message message) {
1528//             MessageView.this.mOldMessage = message;
1529//             try {
1530//                 Part part = MimeUtility.findFirstPartByMimeType(mOldMessage, "text/html");
1531//                 if (part == null) {
1532//                     part = MimeUtility.findFirstPartByMimeType(mOldMessage, "text/plain");
1533//                 }
1534//                 if (part != null) {
1535//                     String text = MimeUtility.getTextFromPart(part);
1536//                     if (part.getMimeType().equalsIgnoreCase("text/html")) {
1537//                         text = EmailHtmlUtil.resolveInlineImage(
1538//                                 getContentResolver(), mAccount.mId, text, mOldMessage, 0);
1539//                     } else {
1540//                         // And also escape special character, such as "<>&",
1541//                         // to HTML escape sequence.
1542//                         text = EmailHtmlUtil.escapeCharacterToDisplay(text);
1543
1544//                         /*
1545//                          * Linkify the plain text and convert it to HTML by replacing
1546//                          * \r?\n with <br> and adding a html/body wrapper.
1547//                          */
1548//                         StringBuffer sb = new StringBuffer("<html><body>");
1549//                         if (text != null) {
1550//                             Matcher m = Patterns.WEB_URL.matcher(text);
1551//                             while (m.find()) {
1552//                                 int start = m.start();
1553//                                 /*
1554//                                  * WEB_URL_PATTERN may match domain part of email address. To detect
1555//                                  * this false match, the character just before the matched string
1556//                                  * should not be '@'.
1557//                                  */
1558//                                 if (start == 0 || text.charAt(start - 1) != '@') {
1559//                                     String url = m.group();
1560//                                     Matcher proto = WEB_URL_PROTOCOL.matcher(url);
1561//                                     String link;
1562//                                     if (proto.find()) {
1563//                                         // Work around to force URL protocol part be lower case,
1564//                                         // since WebView could follow only lower case protocol link.
1565//                                         link = proto.group().toLowerCase()
1566//                                             + url.substring(proto.end());
1567//                                     } else {
1568//                                         // Patterns.WEB_URL matches URL without protocol part,
1569//                                         // so added default protocol to link.
1570//                                         link = "http://" + url;
1571//                                     }
1572//                                     String href = String.format("<a href=\"%s\">%s</a>", link, url);
1573//                                     m.appendReplacement(sb, href);
1574//                                 }
1575//                                 else {
1576//                                     m.appendReplacement(sb, "$0");
1577//                                 }
1578//                             }
1579//                             m.appendTail(sb);
1580//                         }
1581//                         sb.append("</body></html>");
1582//                         text = sb.toString();
1583//                     }
1584
1585//                     /*
1586//                      * TODO consider how to get background images and a million other things
1587//                      * that HTML allows.
1588//                      */
1589//                     // Check if text contains img tag.
1590//                     if (IMG_TAG_START_REGEX.matcher(text).find()) {
1591//                         mHandler.showShowPictures(true);
1592//                     }
1593
1594//                     loadMessageContentText(text);
1595//                 }
1596//                 else {
1597//                     loadMessageContentUrl("file:///android_asset/empty.html");
1598//                 }
1599// //                renderAttachments(mOldMessage, 0);
1600//             }
1601//             catch (Exception e) {
1602//                 if (Email.LOGD) {
1603//                     Log.v(Email.LOG_TAG, "loadMessageForViewBodyAvailable", e);
1604//                 }
1605//             }
1606//        }
1607
1608    /**
1609     * Back in the UI thread, handle the final steps of downloading an attachment (view or save).
1610     *
1611     * @param attachmentId the attachment that was just downloaded
1612     */
1613    private void doFinishLoadAttachment(long attachmentId) {
1614        // If the result does't line up, just skip it - we handle one at a time.
1615        if (attachmentId != mLoadAttachmentId) {
1616            return;
1617        }
1618        Attachment attachment =
1619            Attachment.restoreAttachmentWithId(MessageView.this, attachmentId);
1620        Uri attachmentUri = AttachmentProvider.getAttachmentUri(mAccountId, attachment.mId);
1621        Uri contentUri =
1622            AttachmentProvider.resolveAttachmentIdToContentUri(getContentResolver(), attachmentUri);
1623
1624        if (mLoadAttachmentSave) {
1625            try {
1626                File file = createUniqueFile(Environment.getExternalStorageDirectory(),
1627                        attachment.mFileName);
1628                InputStream in = getContentResolver().openInputStream(contentUri);
1629                OutputStream out = new FileOutputStream(file);
1630                IOUtils.copy(in, out);
1631                out.flush();
1632                out.close();
1633                in.close();
1634
1635                Toast.makeText(MessageView.this, String.format(
1636                        getString(R.string.message_view_status_attachment_saved), file.getName()),
1637                        Toast.LENGTH_LONG).show();
1638
1639                new MediaScannerNotifier(this, file, mHandler);
1640            } catch (IOException ioe) {
1641                Toast.makeText(MessageView.this,
1642                        getString(R.string.message_view_status_attachment_not_saved),
1643                        Toast.LENGTH_LONG).show();
1644            }
1645        } else {
1646            try {
1647                Intent intent = new Intent(Intent.ACTION_VIEW);
1648                intent.setData(contentUri);
1649                intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION
1650                                | Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET);
1651                startActivity(intent);
1652            } catch (ActivityNotFoundException e) {
1653                mHandler.attachmentViewError();
1654                // TODO: Add a proper warning message (and lots of upstream cleanup to prevent
1655                // it from happening) in the next release.
1656            }
1657        }
1658    }
1659
1660    /**
1661     * This notifier is created after an attachment completes downloaded.  It attaches to the
1662     * media scanner and waits to handle the completion of the scan.  At that point it tries
1663     * to start an ACTION_VIEW activity for the attachment.
1664    */
1665    private static class MediaScannerNotifier implements MediaScannerConnectionClient {
1666        private Context mContext;
1667        private MediaScannerConnection mConnection;
1668        private File mFile;
1669        private MessageViewHandler mHandler;
1670
1671        public MediaScannerNotifier(Context context, File file, MessageViewHandler handler) {
1672            mContext = context;
1673            mFile = file;
1674            mHandler = handler;
1675            mConnection = new MediaScannerConnection(context, this);
1676            mConnection.connect();
1677        }
1678
1679        public void onMediaScannerConnected() {
1680            mConnection.scanFile(mFile.getAbsolutePath(), null);
1681        }
1682
1683        public void onScanCompleted(String path, Uri uri) {
1684            try {
1685                if (uri != null) {
1686                    Intent intent = new Intent(Intent.ACTION_VIEW);
1687                    intent.setData(uri);
1688                    mContext.startActivity(intent);
1689                }
1690            } catch (ActivityNotFoundException e) {
1691                mHandler.attachmentViewError();
1692                // TODO: Add a proper warning message (and lots of upstream cleanup to prevent
1693                // it from happening) in the next release.
1694            } finally {
1695                mConnection.disconnect();
1696                mContext = null;
1697                mHandler = null;
1698            }
1699        }
1700    }
1701}
1702