Home | History | Annotate | Download | only in activity
      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 
     17 package com.android.email.activity;
     18 
     19 import com.android.email.Controller;
     20 import com.android.email.Email;
     21 import com.android.email.R;
     22 import com.android.email.Utility;
     23 import com.android.email.mail.Address;
     24 import com.android.email.mail.MeetingInfo;
     25 import com.android.email.mail.MessagingException;
     26 import com.android.email.mail.PackedString;
     27 import com.android.email.mail.internet.EmailHtmlUtil;
     28 import com.android.email.mail.internet.MimeUtility;
     29 import com.android.email.provider.AttachmentProvider;
     30 import com.android.email.provider.EmailContent;
     31 import com.android.email.provider.EmailContent.Attachment;
     32 import com.android.email.provider.EmailContent.Body;
     33 import com.android.email.provider.EmailContent.BodyColumns;
     34 import com.android.email.provider.EmailContent.Message;
     35 import com.android.email.service.EmailServiceConstants;
     36 
     37 import org.apache.commons.io.IOUtils;
     38 
     39 import android.app.Activity;
     40 import android.app.ProgressDialog;
     41 import android.content.ActivityNotFoundException;
     42 import android.content.ContentResolver;
     43 import android.content.Context;
     44 import android.content.Intent;
     45 import android.database.ContentObserver;
     46 import android.database.Cursor;
     47 import android.graphics.Bitmap;
     48 import android.graphics.BitmapFactory;
     49 import android.graphics.drawable.Drawable;
     50 import android.media.MediaScannerConnection;
     51 import android.media.MediaScannerConnection.MediaScannerConnectionClient;
     52 import android.net.Uri;
     53 import android.os.AsyncTask;
     54 import android.os.Bundle;
     55 import android.os.Environment;
     56 import android.os.Handler;
     57 import android.provider.Browser;
     58 import android.provider.ContactsContract;
     59 import android.provider.ContactsContract.CommonDataKinds;
     60 import android.provider.ContactsContract.Contacts;
     61 import android.provider.ContactsContract.QuickContact;
     62 import android.provider.ContactsContract.StatusUpdates;
     63 import android.text.TextUtils;
     64 import android.util.Log;
     65 import android.util.Patterns;
     66 import android.view.LayoutInflater;
     67 import android.view.Menu;
     68 import android.view.MenuItem;
     69 import android.view.View;
     70 import android.view.View.OnClickListener;
     71 import android.webkit.WebView;
     72 import android.webkit.WebViewClient;
     73 import android.widget.Button;
     74 import android.widget.ImageView;
     75 import android.widget.LinearLayout;
     76 import android.widget.TextView;
     77 import android.widget.Toast;
     78 
     79 import java.io.File;
     80 import java.io.FileOutputStream;
     81 import java.io.IOException;
     82 import java.io.InputStream;
     83 import java.io.OutputStream;
     84 import java.util.Date;
     85 import java.util.regex.Matcher;
     86 import java.util.regex.Pattern;
     87 
     88 public 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