MessageViewFragmentBase.java revision d1b7d43df66b073207e0988b2066aa6183afaffe
1/*
2 * Copyright (C) 2010 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.AttachmentInfo;
20import com.android.email.Controller;
21import com.android.email.ControllerResultUiThreadWrapper;
22import com.android.email.Email;
23import com.android.email.Preferences;
24import com.android.email.R;
25import com.android.email.Throttle;
26import com.android.email.mail.internet.EmailHtmlUtil;
27import com.android.email.service.AttachmentDownloadService;
28import com.android.emailcommon.Logging;
29import com.android.emailcommon.mail.Address;
30import com.android.emailcommon.mail.MessagingException;
31import com.android.emailcommon.provider.EmailContent.Attachment;
32import com.android.emailcommon.provider.EmailContent.Body;
33import com.android.emailcommon.provider.EmailContent.Message;
34import com.android.emailcommon.provider.Mailbox;
35import com.android.emailcommon.utility.AttachmentUtilities;
36import com.android.emailcommon.utility.EmailAsyncTask;
37import com.android.emailcommon.utility.Utility;
38import com.google.common.collect.Maps;
39
40import org.apache.commons.io.IOUtils;
41
42import android.app.Activity;
43import android.app.DownloadManager;
44import android.app.Fragment;
45import android.app.LoaderManager.LoaderCallbacks;
46import android.content.ActivityNotFoundException;
47import android.content.ContentResolver;
48import android.content.ContentUris;
49import android.content.Context;
50import android.content.Intent;
51import android.content.Loader;
52import android.content.pm.PackageManager;
53import android.content.res.Resources;
54import android.database.ContentObserver;
55import android.graphics.Bitmap;
56import android.graphics.BitmapFactory;
57import android.media.MediaScannerConnection;
58import android.net.Uri;
59import android.os.Bundle;
60import android.os.Environment;
61import android.os.Handler;
62import android.provider.ContactsContract;
63import android.provider.ContactsContract.QuickContact;
64import android.text.SpannableStringBuilder;
65import android.text.TextUtils;
66import android.text.format.DateUtils;
67import android.util.Log;
68import android.util.Patterns;
69import android.view.LayoutInflater;
70import android.view.View;
71import android.view.ViewGroup;
72import android.webkit.WebSettings;
73import android.webkit.WebView;
74import android.webkit.WebViewClient;
75import android.widget.Button;
76import android.widget.ImageView;
77import android.widget.LinearLayout;
78import android.widget.ProgressBar;
79import android.widget.TextView;
80
81import java.io.File;
82import java.io.FileOutputStream;
83import java.io.IOException;
84import java.io.InputStream;
85import java.io.OutputStream;
86import java.util.Formatter;
87import java.util.Map;
88import java.util.regex.Matcher;
89import java.util.regex.Pattern;
90
91// TODO Better handling of config changes.
92// - Retain the content; don't kick 3 async tasks every time
93
94/**
95 * Base class for {@link MessageViewFragment} and {@link MessageFileViewFragment}.
96 */
97public abstract class MessageViewFragmentBase extends Fragment implements View.OnClickListener {
98    private static final String BUNDLE_KEY_CURRENT_TAB = "MessageViewFragmentBase.currentTab";
99    private static final String BUNDLE_KEY_PICTURE_LOADED = "MessageViewFragmentBase.pictureLoaded";
100    private static final int PHOTO_LOADER_ID = 1;
101    protected Context mContext;
102
103    // Regex that matches start of img tag. '<(?i)img\s+'.
104    private static final Pattern IMG_TAG_START_REGEX = Pattern.compile("<(?i)img\\s+");
105    // Regex that matches Web URL protocol part as case insensitive.
106    private static final Pattern WEB_URL_PROTOCOL = Pattern.compile("(?i)http|https://");
107
108    private static int PREVIEW_ICON_WIDTH = 62;
109    private static int PREVIEW_ICON_HEIGHT = 62;
110
111    private TextView mSubjectView;
112    private TextView mFromNameView;
113    private TextView mFromAddressView;
114    private TextView mDateTimeView;
115    private TextView mAddressesView;
116    private WebView mMessageContentView;
117    private LinearLayout mAttachments;
118    private View mTabSection;
119    private ImageView mFromBadge;
120    private ImageView mSenderPresenceView;
121    private View mMainView;
122    private View mLoadingProgress;
123    private Button mShowDetailsButton;
124
125    private TextView mMessageTab;
126    private TextView mAttachmentTab;
127    private TextView mInviteTab;
128    // It is not really a tab, but looks like one of them.
129    private TextView mShowPicturesTab;
130
131    private View mAttachmentsScroll;
132    private View mInviteScroll;
133
134    private long mAccountId = -1;
135    private long mMessageId = -1;
136    private Message mMessage;
137
138    private Controller mController;
139    private ControllerResultUiThreadWrapper<ControllerResults> mControllerCallback;
140
141    // contains the HTML body. Is used by LoadAttachmentTask to display inline images.
142    // is null most of the time, is used transiently to pass info to LoadAttachementTask
143    private String mHtmlTextRaw;
144
145    // contains the HTML content as set in WebView.
146    private String mHtmlTextWebView;
147
148    private boolean mResumed;
149
150    private boolean mIsMessageLoadedForTest;
151
152    private MessageObserver mMessageObserver;
153
154    private static final int CONTACT_STATUS_STATE_UNLOADED = 0;
155    private static final int CONTACT_STATUS_STATE_UNLOADED_TRIGGERED = 1;
156    private static final int CONTACT_STATUS_STATE_LOADED = 2;
157
158    private int mContactStatusState;
159    private Uri mQuickContactLookupUri;
160
161    /** Flag for {@link #mTabFlags}: Message has attachment(s) */
162    protected static final int TAB_FLAGS_HAS_ATTACHMENT = 1;
163
164    /**
165     * Flag for {@link #mTabFlags}: Message contains invite.  This flag is only set by
166     * {@link MessageViewFragment}.
167     */
168    protected static final int TAB_FLAGS_HAS_INVITE = 2;
169
170    /** Flag for {@link #mTabFlags}: Message contains pictures */
171    protected static final int TAB_FLAGS_HAS_PICTURES = 4;
172
173    /** Flag for {@link #mTabFlags}: "Show pictures" has already been pressed */
174    protected static final int TAB_FLAGS_PICTURE_LOADED = 8;
175
176    /**
177     * Flags to control the tabs.
178     * @see #updateTabs(int)
179     */
180    private int mTabFlags;
181
182    /** # of attachments in the current message */
183    private int mAttachmentCount;
184
185    // Use (random) large values, to avoid confusion with TAB_FLAGS_*
186    protected static final int TAB_MESSAGE = 101;
187    protected static final int TAB_INVITE = 102;
188    protected static final int TAB_ATTACHMENT = 103;
189    private static final int TAB_NONE = 0;
190
191    /** Current tab */
192    private int mCurrentTab = TAB_NONE;
193    /**
194     * Tab that was selected in the previous activity instance.
195     * Used to restore the current tab after screen rotation.
196     */
197    private int mRestoredTab = TAB_NONE;
198
199    private boolean mRestoredPictureLoaded;
200
201    private final EmailAsyncTask.Tracker mTaskTracker = new EmailAsyncTask.Tracker();
202
203    /**
204     * Zoom scales for webview.  Values correspond to {@link Preferences#TEXT_ZOOM_TINY}..
205     * {@link Preferences#TEXT_ZOOM_HUGE}.
206     */
207    private static final float[] ZOOM_SCALE_ARRAY = new float[] {0.8f, 0.9f, 1.0f, 1.2f, 1.5f};
208
209    public interface Callback {
210        /** Called when a message is about to be shown. */
211        public void onMessageShown();
212
213        /** Called when the fragment is about to be destroyed. */
214        public void onMessageViewDestroyed();
215
216        /**
217         * Called when a link in a message is clicked.
218         *
219         * @param url link url that's clicked.
220         * @return true if handled, false otherwise.
221         */
222        public boolean onUrlInMessageClicked(String url);
223
224        /**
225         * Called when the message specified doesn't exist, or is deleted/moved.
226         */
227        public void onMessageNotExists();
228
229        /** Called when it starts loading a message. */
230        public void onLoadMessageStarted();
231
232        /** Called when it successfully finishes loading a message. */
233        public void onLoadMessageFinished();
234
235        /** Called when an error occurred during loading a message. */
236        public void onLoadMessageError(String errorMessage);
237    }
238
239    public static class EmptyCallback implements Callback {
240        public static final Callback INSTANCE = new EmptyCallback();
241        @Override public void onMessageShown() {}
242        @Override public void onMessageViewDestroyed() {}
243        @Override public void onLoadMessageError(String errorMessage) {}
244        @Override public void onLoadMessageFinished() {}
245        @Override public void onLoadMessageStarted() {}
246        @Override public void onMessageNotExists() {}
247        @Override
248        public boolean onUrlInMessageClicked(String url) {
249            return false;
250        }
251    }
252
253    private Callback mCallback = EmptyCallback.INSTANCE;
254
255    @Override
256    public void onAttach(Activity activity) {
257        if (Logging.DEBUG_LIFECYCLE && Email.DEBUG) {
258            Log.d(Logging.LOG_TAG, this + " onAttach");
259        }
260        super.onAttach(activity);
261    }
262
263    @Override
264    public void onCreate(Bundle savedInstanceState) {
265        if (Logging.DEBUG_LIFECYCLE && Email.DEBUG) {
266            Log.d(Logging.LOG_TAG, this + " onCreate");
267        }
268        super.onCreate(savedInstanceState);
269
270        mContext = getActivity().getApplicationContext();
271
272        mControllerCallback = new ControllerResultUiThreadWrapper<ControllerResults>(
273                new Handler(), new ControllerResults());
274
275        mController = Controller.getInstance(mContext);
276        mMessageObserver = new MessageObserver(new Handler(), mContext);
277
278        if (savedInstanceState != null) {
279            restoreInstanceState(savedInstanceState);
280        }
281    }
282
283    @Override
284    public View onCreateView(
285            LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
286        if (Logging.DEBUG_LIFECYCLE && Email.DEBUG) {
287            Log.d(Logging.LOG_TAG, this + " onCreateView");
288        }
289        final View view = inflater.inflate(R.layout.message_view_fragment, container, false);
290
291        mSubjectView = (TextView) UiUtilities.getView(view, R.id.subject);
292        mFromNameView = (TextView) UiUtilities.getView(view, R.id.from_name);
293        mFromAddressView = (TextView) UiUtilities.getView(view, R.id.from_address);
294        mAddressesView = (TextView) UiUtilities.getView(view, R.id.addresses);
295        mDateTimeView = (TextView) UiUtilities.getView(view, R.id.datetime);
296        mMessageContentView = (WebView) UiUtilities.getView(view, R.id.message_content);
297        mAttachments = (LinearLayout) UiUtilities.getView(view, R.id.attachments);
298        mTabSection = UiUtilities.getView(view, R.id.message_tabs_section);
299        mFromBadge = (ImageView) UiUtilities.getView(view, R.id.badge);
300        mSenderPresenceView = (ImageView) UiUtilities.getView(view, R.id.presence);
301        mMainView = UiUtilities.getView(view, R.id.main_panel);
302        mLoadingProgress = UiUtilities.getView(view, R.id.loading_progress);
303        mShowDetailsButton = (Button) UiUtilities.getView(view, R.id.show_details);
304
305        mFromNameView.setOnClickListener(this);
306        mFromAddressView.setOnClickListener(this);
307        mFromBadge.setOnClickListener(this);
308        mSenderPresenceView.setOnClickListener(this);
309
310        mMessageTab = (TextView) UiUtilities.getView(view, R.id.show_message);
311        mAttachmentTab = (TextView) UiUtilities.getView(view, R.id.show_attachments);
312        mShowPicturesTab = (TextView) UiUtilities.getView(view, R.id.show_pictures);
313        // Invite is only used in MessageViewFragment, but visibility is controlled here.
314        mInviteTab = (TextView) UiUtilities.getView(view, R.id.show_invite);
315
316        mMessageTab.setOnClickListener(this);
317        mAttachmentTab.setOnClickListener(this);
318        mShowPicturesTab.setOnClickListener(this);
319        mInviteTab.setOnClickListener(this);
320        mShowDetailsButton.setOnClickListener(this);
321
322        mAttachmentsScroll = UiUtilities.getView(view, R.id.attachments_scroll);
323        mInviteScroll = UiUtilities.getView(view, R.id.invite_scroll);
324
325        WebSettings webSettings = mMessageContentView.getSettings();
326        boolean supportMultiTouch = mContext.getPackageManager()
327                .hasSystemFeature(PackageManager.FEATURE_TOUCHSCREEN_MULTITOUCH);
328        webSettings.setDisplayZoomControls(!supportMultiTouch);
329        webSettings.setSupportZoom(true);
330        webSettings.setBuiltInZoomControls(true);
331        mMessageContentView.setWebViewClient(new CustomWebViewClient());
332        return view;
333    }
334
335    @Override
336    public void onActivityCreated(Bundle savedInstanceState) {
337        if (Logging.DEBUG_LIFECYCLE && Email.DEBUG) {
338            Log.d(Logging.LOG_TAG, this + " onActivityCreated");
339        }
340        super.onActivityCreated(savedInstanceState);
341        mController.addResultCallback(mControllerCallback);
342
343        resetView();
344        new LoadMessageTask(true).executeParallel();
345    }
346
347    @Override
348    public void onStart() {
349        if (Logging.DEBUG_LIFECYCLE && Email.DEBUG) {
350            Log.d(Logging.LOG_TAG, this + " onStart");
351        }
352        super.onStart();
353    }
354
355    @Override
356    public void onResume() {
357        if (Logging.DEBUG_LIFECYCLE && Email.DEBUG) {
358            Log.d(Logging.LOG_TAG, this + " onResume");
359        }
360        super.onResume();
361
362        mResumed = true;
363
364        // We might have comes back from other full-screen activities.  If so, we need to update
365        // the attachment tab as system settings may have been updated that affect which
366        // options are available to the user.
367        updateAttachmentTab();
368    }
369
370    @Override
371    public void onPause() {
372        if (Logging.DEBUG_LIFECYCLE && Email.DEBUG) {
373            Log.d(Logging.LOG_TAG, this + " onPause");
374        }
375        mResumed = false;
376        super.onPause();
377    }
378
379    @Override
380    public void onStop() {
381        if (Logging.DEBUG_LIFECYCLE && Email.DEBUG) {
382            Log.d(Logging.LOG_TAG, this + " onStop");
383        }
384        super.onStop();
385    }
386
387    @Override
388    public void onDestroyView() {
389        if (Logging.DEBUG_LIFECYCLE && Email.DEBUG) {
390            Log.d(Logging.LOG_TAG, this + " onDestroyView");
391        }
392        super.onDestroyView();
393    }
394
395    @Override
396    public void onDestroy() {
397        if (Logging.DEBUG_LIFECYCLE && Email.DEBUG) {
398            Log.d(Logging.LOG_TAG, this + " onDestroy");
399        }
400        mCallback.onMessageViewDestroyed();
401        mController.removeResultCallback(mControllerCallback);
402        cancelAllTasks();
403        mMessageContentView.destroy();
404        mMessageContentView = null;
405        super.onDestroy();
406    }
407
408    @Override
409    public void onDetach() {
410        if (Logging.DEBUG_LIFECYCLE && Email.DEBUG) {
411            Log.d(Logging.LOG_TAG, this + " onDetach");
412        }
413        super.onDetach();
414    }
415
416    @Override
417    public void onSaveInstanceState(Bundle outState) {
418        if (Logging.DEBUG_LIFECYCLE && Email.DEBUG) {
419            Log.d(Logging.LOG_TAG, this + " onSaveInstanceState");
420        }
421        super.onSaveInstanceState(outState);
422        outState.putInt(BUNDLE_KEY_CURRENT_TAB, mCurrentTab);
423        outState.putBoolean(BUNDLE_KEY_PICTURE_LOADED, (mTabFlags & TAB_FLAGS_PICTURE_LOADED) != 0);
424    }
425
426    private void restoreInstanceState(Bundle state) {
427        if (Logging.DEBUG_LIFECYCLE && Email.DEBUG) {
428            Log.d(Logging.LOG_TAG, this + " restoreInstanceState");
429        }
430        // At this point (in onCreate) no tabs are visible (because we don't know if the message has
431        // an attachment or invite before loading it).  We just remember the tab here.
432        // We'll make it current when the tab first becomes visible in updateTabs().
433        mRestoredTab = state.getInt(BUNDLE_KEY_CURRENT_TAB);
434        mRestoredPictureLoaded = state.getBoolean(BUNDLE_KEY_PICTURE_LOADED);
435    }
436
437    public void setCallback(Callback callback) {
438        mCallback = (callback == null) ? EmptyCallback.INSTANCE : callback;
439    }
440
441    private void cancelAllTasks() {
442        mMessageObserver.unregister();
443        mTaskTracker.cancellAllInterrupt();
444    }
445
446    protected final Controller getController() {
447        return mController;
448    }
449
450    protected final Callback getCallback() {
451        return mCallback;
452    }
453
454    protected final Message getMessage() {
455        return mMessage;
456    }
457
458    protected final boolean isMessageOpen() {
459        return mMessage != null;
460    }
461
462    /**
463     * Returns the account id of the current message, or -1 if unknown (message not open yet, or
464     * viewing an EML message).
465     */
466    public long getAccountId() {
467        return mAccountId;
468    }
469
470
471    /**
472     * Show/hide the content.  We hide all the content (except for the bottom buttons) when loading,
473     * to avoid flicker.
474     */
475    private void showContent(boolean showContent, boolean showProgressWhenHidden) {
476        makeVisible(mMainView, showContent);
477        makeVisible(mLoadingProgress, !showContent && showProgressWhenHidden);
478    }
479
480    protected void resetView() {
481        showContent(false, false);
482        updateTabs(0);
483        setCurrentTab(TAB_MESSAGE);
484        if (mMessageContentView != null) {
485            blockNetworkLoads(true);
486            mMessageContentView.scrollTo(0, 0);
487            mMessageContentView.clearView();
488
489            // Dynamic configuration of WebView
490            final WebSettings settings = mMessageContentView.getSettings();
491            settings.setLayoutAlgorithm(WebSettings.LayoutAlgorithm.NORMAL);
492            mMessageContentView.setInitialScale(getWebViewZoom());
493        }
494        mAttachmentsScroll.scrollTo(0, 0);
495        mInviteScroll.scrollTo(0, 0);
496        mAttachments.removeAllViews();
497        mAttachments.setVisibility(View.GONE);
498        initContactStatusViews();
499    }
500
501    /**
502     * Returns the zoom scale (in percent) which is a combination of the user setting
503     * (tiny, small, normal, large, huge) and the device density. The intention
504     * is for the text to be physically equal in size over different density
505     * screens.
506     */
507    private int getWebViewZoom() {
508        float density = mContext.getResources().getDisplayMetrics().density;
509        int zoom = Preferences.getPreferences(mContext).getTextZoom();
510        return (int) (ZOOM_SCALE_ARRAY[zoom] * density * 100);
511    }
512
513    private void initContactStatusViews() {
514        mContactStatusState = CONTACT_STATUS_STATE_UNLOADED;
515        mQuickContactLookupUri = null;
516        mSenderPresenceView.setImageResource(ContactStatusLoader.PRESENCE_UNKNOWN_RESOURCE_ID);
517        showDefaultQuickContactBadgeImage();
518    }
519
520    private void showDefaultQuickContactBadgeImage() {
521        mFromBadge.setImageResource(R.drawable.ic_contact_picture);
522    }
523
524    protected final void addTabFlags(int tabFlags) {
525        updateTabs(mTabFlags | tabFlags);
526    }
527
528    private final void clearTabFlags(int tabFlags) {
529        updateTabs(mTabFlags & ~tabFlags);
530    }
531
532    private void setAttachmentCount(int count) {
533        mAttachmentCount = count;
534        if (mAttachmentCount > 0) {
535            addTabFlags(TAB_FLAGS_HAS_ATTACHMENT);
536        } else {
537            clearTabFlags(TAB_FLAGS_HAS_ATTACHMENT);
538        }
539    }
540
541    private static void makeVisible(View v, boolean visible) {
542        final int visibility = visible ? View.VISIBLE : View.GONE;
543        if ((v != null) && (v.getVisibility() != visibility)) {
544            v.setVisibility(visibility);
545        }
546    }
547
548    private static boolean isVisible(View v) {
549        return (v != null) && (v.getVisibility() == View.VISIBLE);
550    }
551
552    /**
553     * Update the visual of the tabs.  (visibility, text, etc)
554     */
555    private void updateTabs(int tabFlags) {
556        mTabFlags = tabFlags;
557        boolean messageTabVisible = (tabFlags & (TAB_FLAGS_HAS_INVITE | TAB_FLAGS_HAS_ATTACHMENT))
558                != 0;
559        makeVisible(mMessageTab, messageTabVisible);
560        makeVisible(mInviteTab, (tabFlags & TAB_FLAGS_HAS_INVITE) != 0);
561        makeVisible(mAttachmentTab, (tabFlags & TAB_FLAGS_HAS_ATTACHMENT) != 0);
562
563        final boolean hasPictures = (tabFlags & TAB_FLAGS_HAS_PICTURES) != 0;
564        final boolean pictureLoaded = (tabFlags & TAB_FLAGS_PICTURE_LOADED) != 0;
565        makeVisible(mShowPicturesTab, hasPictures && !pictureLoaded);
566
567        mAttachmentTab.setText(mContext.getResources().getQuantityString(
568                R.plurals.message_view_show_attachments_action,
569                mAttachmentCount, mAttachmentCount));
570
571        // Hide the entire section if no tabs are visible.
572        makeVisible(mTabSection, isVisible(mMessageTab) || isVisible(mInviteTab)
573                || isVisible(mAttachmentTab) || isVisible(mShowPicturesTab));
574
575        // Restore previously selected tab after rotation
576        if (mRestoredTab != TAB_NONE && isVisible(getTabViewForFlag(mRestoredTab))) {
577            setCurrentTab(mRestoredTab);
578            mRestoredTab = TAB_NONE;
579        }
580    }
581
582    /**
583     * Set the current tab.
584     *
585     * @param tab any of {@link #TAB_MESSAGE}, {@link #TAB_ATTACHMENT} or {@link #TAB_INVITE}.
586     */
587    private void setCurrentTab(int tab) {
588        mCurrentTab = tab;
589
590        // Hide & unselect all tabs
591        makeVisible(getTabContentViewForFlag(TAB_MESSAGE), false);
592        makeVisible(getTabContentViewForFlag(TAB_ATTACHMENT), false);
593        makeVisible(getTabContentViewForFlag(TAB_INVITE), false);
594        getTabViewForFlag(TAB_MESSAGE).setSelected(false);
595        getTabViewForFlag(TAB_ATTACHMENT).setSelected(false);
596        getTabViewForFlag(TAB_INVITE).setSelected(false);
597
598        makeVisible(getTabContentViewForFlag(mCurrentTab), true);
599        getTabViewForFlag(mCurrentTab).setSelected(true);
600    }
601
602    private View getTabViewForFlag(int tabFlag) {
603        switch (tabFlag) {
604            case TAB_MESSAGE:
605                return mMessageTab;
606            case TAB_ATTACHMENT:
607                return mAttachmentTab;
608            case TAB_INVITE:
609                return mInviteTab;
610        }
611        throw new IllegalArgumentException();
612    }
613
614    private View getTabContentViewForFlag(int tabFlag) {
615        switch (tabFlag) {
616            case TAB_MESSAGE:
617                return mMessageContentView;
618            case TAB_ATTACHMENT:
619                return mAttachmentsScroll;
620            case TAB_INVITE:
621                return mInviteScroll;
622        }
623        throw new IllegalArgumentException();
624    }
625
626    private void blockNetworkLoads(boolean block) {
627        if (mMessageContentView != null) {
628            mMessageContentView.getSettings().setBlockNetworkLoads(block);
629        }
630    }
631
632    private void setMessageHtml(String html) {
633        if (html == null) {
634            html = "";
635        }
636        if (mMessageContentView != null) {
637            mMessageContentView.loadDataWithBaseURL("email://", html, "text/html", "utf-8", null);
638        }
639    }
640
641    /**
642     * Handle clicks on sender, which shows {@link QuickContact} or prompts to add
643     * the sender as a contact.
644     */
645    private void onClickSender() {
646        if (!isMessageOpen()) return;
647        final Address senderEmail = Address.unpackFirst(mMessage.mFrom);
648        if (senderEmail == null) return;
649
650        if (mContactStatusState == CONTACT_STATUS_STATE_UNLOADED) {
651            // Status not loaded yet.
652            mContactStatusState = CONTACT_STATUS_STATE_UNLOADED_TRIGGERED;
653            return;
654        }
655        if (mContactStatusState == CONTACT_STATUS_STATE_UNLOADED_TRIGGERED) {
656            return; // Already clicked, and waiting for the data.
657        }
658
659        if (mQuickContactLookupUri != null) {
660            QuickContact.showQuickContact(mContext, mFromBadge, mQuickContactLookupUri,
661                        QuickContact.MODE_LARGE, null);
662        } else {
663            // No matching contact, ask user to create one
664            final Uri mailUri = Uri.fromParts("mailto", senderEmail.getAddress(), null);
665            final Intent intent = new Intent(ContactsContract.Intents.SHOW_OR_CREATE_CONTACT,
666                    mailUri);
667
668            // Pass along full E-mail string for possible create dialog
669            intent.putExtra(ContactsContract.Intents.EXTRA_CREATE_DESCRIPTION,
670                    senderEmail.toString());
671
672            // Only provide personal name hint if we have one
673            final String senderPersonal = senderEmail.getPersonal();
674            if (!TextUtils.isEmpty(senderPersonal)) {
675                intent.putExtra(ContactsContract.Intents.Insert.NAME, senderPersonal);
676            }
677            intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET);
678
679            startActivity(intent);
680        }
681    }
682
683    private static class ContactStatusLoaderCallbacks
684            implements LoaderCallbacks<ContactStatusLoader.Result> {
685        private static final String BUNDLE_EMAIL_ADDRESS = "email";
686        private final MessageViewFragmentBase mFragment;
687
688        public ContactStatusLoaderCallbacks(MessageViewFragmentBase fragment) {
689            mFragment = fragment;
690        }
691
692        public static Bundle createArguments(String emailAddress) {
693            Bundle b = new Bundle();
694            b.putString(BUNDLE_EMAIL_ADDRESS, emailAddress);
695            return b;
696        }
697
698        @Override
699        public Loader<ContactStatusLoader.Result> onCreateLoader(int id, Bundle args) {
700            return new ContactStatusLoader(mFragment.mContext,
701                    args.getString(BUNDLE_EMAIL_ADDRESS));
702        }
703
704        @Override
705        public void onLoadFinished(Loader<ContactStatusLoader.Result> loader,
706                ContactStatusLoader.Result result) {
707            boolean triggered =
708                    (mFragment.mContactStatusState == CONTACT_STATUS_STATE_UNLOADED_TRIGGERED);
709            mFragment.mContactStatusState = CONTACT_STATUS_STATE_LOADED;
710            mFragment.mQuickContactLookupUri = result.mLookupUri;
711            mFragment.mSenderPresenceView.setImageResource(result.mPresenceResId);
712            if (result.mPhoto != null) { // photo will be null if unknown.
713                mFragment.mFromBadge.setImageBitmap(result.mPhoto);
714            }
715            if (triggered) {
716                mFragment.onClickSender();
717            }
718        }
719
720        @Override
721        public void onLoaderReset(Loader<ContactStatusLoader.Result> loader) {
722        }
723    }
724
725    private void onSaveAttachment(MessageViewAttachmentInfo info) {
726        if (!Utility.isExternalStorageMounted()) {
727            /*
728             * Abort early if there's no place to save the attachment. We don't want to spend
729             * the time downloading it and then abort.
730             */
731            Utility.showToast(getActivity(), R.string.message_view_status_attachment_not_saved);
732            return;
733        }
734
735        if (info.isFileSaved()) {
736            // Nothing to do - we have the file saved.
737            return;
738        }
739
740        File savedFile = performAttachmentSave(info);
741        if (savedFile != null) {
742            Utility.showToast(getActivity(), String.format(
743                    mContext.getString(R.string.message_view_status_attachment_saved),
744                    savedFile.getName()));
745        } else {
746            Utility.showToast(getActivity(), R.string.message_view_status_attachment_not_saved);
747        }
748    }
749
750    private File performAttachmentSave(MessageViewAttachmentInfo info) {
751        Attachment attachment = Attachment.restoreAttachmentWithId(mContext, info.mId);
752        Uri attachmentUri = AttachmentUtilities.getAttachmentUri(mAccountId, attachment.mId);
753
754        try {
755            File downloads = Environment.getExternalStoragePublicDirectory(
756                    Environment.DIRECTORY_DOWNLOADS);
757            downloads.mkdirs();
758            File file = Utility.createUniqueFile(downloads, attachment.mFileName);
759            Uri contentUri = AttachmentUtilities.resolveAttachmentIdToContentUri(
760                    mContext.getContentResolver(), attachmentUri);
761            InputStream in = mContext.getContentResolver().openInputStream(contentUri);
762            OutputStream out = new FileOutputStream(file);
763            IOUtils.copy(in, out);
764            out.flush();
765            out.close();
766            in.close();
767
768            String absolutePath = file.getAbsolutePath();
769
770            // Although the download manager can scan media files, scanning only happens after the
771            // user clicks on the item in the Downloads app. So, we run the attachment through
772            // the media scanner ourselves so it gets added to gallery / music immediately.
773            MediaScannerConnection.scanFile(mContext, new String[] {absolutePath},
774                    null, null);
775
776            DownloadManager dm =
777                    (DownloadManager) getActivity().getSystemService(Context.DOWNLOAD_SERVICE);
778            dm.addCompletedDownload(info.mName, info.mName,
779                    false /* do not use media scanner */,
780                    info.mContentType, absolutePath, info.mSize,
781                    true /* show notification */);
782
783            // Cache the stored file information.
784            info.setSavedPath(absolutePath);
785
786            // Update our buttons.
787            updateAttachmentButtons(info);
788
789            return file;
790
791        } catch (IOException ioe) {
792            // Ignore. Callers will handle it from the return code.
793        }
794
795        return null;
796    }
797
798    private void onOpenAttachment(MessageViewAttachmentInfo info) {
799        if (info.mAllowInstall) {
800            // The package installer is unable to install files from a content URI; it must be
801            // given a file path. Therefore, we need to save it first in order to proceed
802            if (!info.mAllowSave || !Utility.isExternalStorageMounted()) {
803                Utility.showToast(getActivity(), R.string.message_view_status_attachment_not_saved);
804                return;
805            }
806
807            if (!info.isFileSaved()) {
808                if (performAttachmentSave(info) == null) {
809                    // Saving failed for some reason - bail.
810                    Utility.showToast(
811                            getActivity(), R.string.message_view_status_attachment_not_saved);
812                    return;
813                }
814            }
815        }
816        try {
817            Intent intent = info.getAttachmentIntent(mContext, mAccountId);
818            startActivity(intent);
819        } catch (ActivityNotFoundException e) {
820            Utility.showToast(getActivity(), R.string.message_view_display_attachment_toast);
821        }
822    }
823
824    private void onInfoAttachment(final MessageViewAttachmentInfo attachment) {
825        AttachmentInfoDialog dialog =
826            AttachmentInfoDialog.newInstance(getActivity(), attachment.mDenyFlags);
827        dialog.show(getActivity().getFragmentManager(), null);
828    }
829
830    private void onLoadAttachment(final MessageViewAttachmentInfo attachment) {
831        attachment.loadButton.setVisibility(View.GONE);
832        // If there's nothing in the download queue, we'll probably start right away so wait a
833        // second before showing the cancel button
834        if (AttachmentDownloadService.getQueueSize() == 0) {
835            // Set to invisible; if the button is still in this state one second from now, we'll
836            // assume the download won't start right away, and we make the cancel button visible
837            attachment.cancelButton.setVisibility(View.GONE);
838            // Create the timed task that will change the button state
839            new EmailAsyncTask<Void, Void, Void>(mTaskTracker) {
840                @Override
841                protected Void doInBackground(Void... params) {
842                    try {
843                        Thread.sleep(1000L);
844                    } catch (InterruptedException e) { }
845                    return null;
846                }
847                @Override
848                protected void onPostExecute(Void result) {
849                    // If the timeout completes and the attachment has not loaded, show cancel
850                    if (!attachment.loaded) {
851                        attachment.cancelButton.setVisibility(View.VISIBLE);
852                    }
853                }
854            }.executeParallel();
855        } else {
856            attachment.cancelButton.setVisibility(View.VISIBLE);
857        }
858        attachment.showProgressIndeterminate();
859        mController.loadAttachment(attachment.mId, mMessageId, mAccountId);
860    }
861
862    private void onCancelAttachment(MessageViewAttachmentInfo attachment) {
863        // Don't change button states if we couldn't cancel the download
864        if (AttachmentDownloadService.cancelQueuedAttachment(attachment.mId)) {
865            attachment.loadButton.setVisibility(View.VISIBLE);
866            attachment.cancelButton.setVisibility(View.GONE);
867            attachment.hideProgress();
868        }
869    }
870
871    /**
872     * Called by ControllerResults. Show the "View" and "Save" buttons; hide "Load" and "Stop"
873     *
874     * @param attachmentId the attachment that was just downloaded
875     */
876    private void doFinishLoadAttachment(long attachmentId) {
877        MessageViewAttachmentInfo info = findAttachmentInfo(attachmentId);
878        if (info != null) {
879            info.loaded = true;
880            updateAttachmentButtons(info);
881        }
882    }
883
884    private void onShowPicturesInHtml() {
885        if (mMessageContentView != null) {
886            blockNetworkLoads(false);
887            setMessageHtml(mHtmlTextWebView);
888            addTabFlags(TAB_FLAGS_PICTURE_LOADED);
889        }
890    }
891
892    private void onShowDetails() {
893        if (!isMessageOpen()) return;
894        String subject = mMessage.mSubject;
895        String date = formatDate(mMessage.mTimeStamp, true);
896
897        final String SEPARATOR = "\n";
898        String from = Address.toString(Address.unpack(mMessage.mFrom), SEPARATOR);
899        String to = Address.toString(Address.unpack(mMessage.mTo), SEPARATOR);
900        String cc = Address.toString(Address.unpack(mMessage.mCc), SEPARATOR);
901        String bcc = Address.toString(Address.unpack(mMessage.mBcc), SEPARATOR);
902        MessageViewMessageDetailsDialog dialog = MessageViewMessageDetailsDialog.newInstance(
903                getActivity(), subject, date, from, to, cc, bcc);
904        dialog.show(getActivity().getFragmentManager(), null);
905    }
906
907    @Override
908    public void onClick(View view) {
909        if (!isMessageOpen()) {
910            return; // Ignore.
911        }
912        switch (view.getId()) {
913            case R.id.from_name:
914            case R.id.from_address:
915            case R.id.badge:
916            case R.id.presence:
917                onClickSender();
918                break;
919            case R.id.load:
920                onLoadAttachment((MessageViewAttachmentInfo) view.getTag());
921                break;
922            case R.id.info:
923                onInfoAttachment((MessageViewAttachmentInfo) view.getTag());
924                break;
925            case R.id.save:
926                onSaveAttachment((MessageViewAttachmentInfo) view.getTag());
927                break;
928            case R.id.open:
929                onOpenAttachment((MessageViewAttachmentInfo) view.getTag());
930                break;
931            case R.id.cancel:
932                onCancelAttachment((MessageViewAttachmentInfo) view.getTag());
933                break;
934            case R.id.show_message:
935                setCurrentTab(TAB_MESSAGE);
936                break;
937            case R.id.show_invite:
938                setCurrentTab(TAB_INVITE);
939                break;
940            case R.id.show_attachments:
941                setCurrentTab(TAB_ATTACHMENT);
942                break;
943            case R.id.show_pictures:
944                onShowPicturesInHtml();
945                break;
946            case R.id.show_details:
947                onShowDetails();
948                break;
949        }
950    }
951
952    /**
953     * Start loading contact photo and presence.
954     */
955    private void queryContactStatus() {
956        if (!isMessageOpen()) return;
957        initContactStatusViews(); // Initialize the state, just in case.
958
959        // Find the sender email address, and start presence check.
960        Address sender = Address.unpackFirst(mMessage.mFrom);
961        if (sender != null) {
962            String email = sender.getAddress();
963            if (email != null) {
964                getLoaderManager().restartLoader(PHOTO_LOADER_ID,
965                        ContactStatusLoaderCallbacks.createArguments(email),
966                        new ContactStatusLoaderCallbacks(this));
967            }
968        }
969    }
970
971    /**
972     * Called by {@link LoadMessageTask} and {@link ReloadMessageTask} to load a message in a
973     * subclass specific way.
974     *
975     * NOTE This method is called on a worker thread!  Implementations must properly synchronize
976     * when accessing members.
977     *
978     * @param activity the parent activity.  Subclass use it as a context, and to show a toast.
979     */
980    protected abstract Message openMessageSync(Activity activity);
981
982    /**
983     * Async task for loading a single message outside of the UI thread
984     */
985    private class LoadMessageTask extends EmailAsyncTask<Void, Void, Message> {
986
987        private final boolean mOkToFetch;
988        private int mMailboxType;
989
990        /**
991         * Special constructor to cache some local info
992         */
993        public LoadMessageTask(boolean okToFetch) {
994            super(mTaskTracker);
995            mOkToFetch = okToFetch;
996        }
997
998        @Override
999        protected Message doInBackground(Void... params) {
1000            Activity activity = getActivity();
1001            Message message = null;
1002            if (activity != null) {
1003                message = openMessageSync(activity);
1004            }
1005            if (message != null) {
1006                mMailboxType = Mailbox.getMailboxType(mContext, message.mMailboxKey);
1007                if (mMailboxType == -1) {
1008                    message = null; // mailbox removed??
1009                }
1010            }
1011            return message;
1012        }
1013
1014        @Override
1015        protected void onPostExecute(Message message) {
1016            if (isCancelled()) {
1017                return;
1018            }
1019            if (message == null) {
1020                resetView();
1021                mCallback.onMessageNotExists();
1022                return;
1023            }
1024            mMessageId = message.mId;
1025
1026            reloadUiFromMessage(message, mOkToFetch);
1027            queryContactStatus();
1028            onMessageShown(mMessageId, mMailboxType);
1029        }
1030    }
1031
1032    /**
1033     * Kicked by {@link MessageObserver}.  Reload the message and update the views.
1034     */
1035    private class ReloadMessageTask extends EmailAsyncTask<Void, Void, Message> {
1036        public ReloadMessageTask() {
1037            super(mTaskTracker);
1038        }
1039
1040        @Override
1041        protected Message doInBackground(Void... params) {
1042            Activity activity = getActivity();
1043            if (activity == null) {
1044                return null;
1045            } else {
1046                return openMessageSync(activity);
1047            }
1048        }
1049
1050        @Override
1051        protected void onPostExecute(Message message) {
1052            if (message == null || message.mMailboxKey != mMessage.mMailboxKey) {
1053                // Message deleted or moved.
1054                mCallback.onMessageNotExists();
1055                return;
1056            }
1057            mMessage = message;
1058            updateHeaderView(mMessage);
1059        }
1060    }
1061
1062    /**
1063     * Called when a message is shown to the user.
1064     */
1065    protected void onMessageShown(long messageId, int mailboxType) {
1066        mCallback.onMessageShown();
1067    }
1068
1069    /**
1070     * Called when the message body is loaded.
1071     */
1072    protected void onPostLoadBody() {
1073    }
1074
1075    /**
1076     * Async task for loading a single message body outside of the UI thread
1077     */
1078    private class LoadBodyTask extends EmailAsyncTask<Void, Void, String[]> {
1079
1080        private final long mId;
1081        private boolean mErrorLoadingMessageBody;
1082
1083        /**
1084         * Special constructor to cache some local info
1085         */
1086        public LoadBodyTask(long messageId) {
1087            super(mTaskTracker);
1088            mId = messageId;
1089        }
1090
1091        @Override
1092        protected String[] doInBackground(Void... params) {
1093            try {
1094                String text = null;
1095                String html = Body.restoreBodyHtmlWithMessageId(mContext, mId);
1096                if (html == null) {
1097                    text = Body.restoreBodyTextWithMessageId(mContext, mId);
1098                }
1099                return new String[] { text, html };
1100            } catch (RuntimeException re) {
1101                // This catches SQLiteException as well as other RTE's we've seen from the
1102                // database calls, such as IllegalStateException
1103                Log.d(Logging.LOG_TAG, "Exception while loading message body", re);
1104                mErrorLoadingMessageBody = true;
1105                return null;
1106            }
1107        }
1108
1109        @Override
1110        protected void onPostExecute(String[] results) {
1111            if (results == null || isCancelled()) {
1112                if (mErrorLoadingMessageBody) {
1113                    Utility.showToast(getActivity(), R.string.error_loading_message_body);
1114                }
1115                resetView();
1116                return;
1117            }
1118            reloadUiFromBody(results[0], results[1]);    // text, html
1119            onPostLoadBody();
1120        }
1121    }
1122
1123    /**
1124     * Async task for loading attachments
1125     *
1126     * Note:  This really should only be called when the message load is complete - or, we should
1127     * leave open a listener so the attachments can fill in as they are discovered.  In either case,
1128     * this implementation is incomplete, as it will fail to refresh properly if the message is
1129     * partially loaded at this time.
1130     */
1131    private class LoadAttachmentsTask extends EmailAsyncTask<Long, Void, Attachment[]> {
1132        public LoadAttachmentsTask() {
1133            super(mTaskTracker);
1134        }
1135
1136        @Override
1137        protected Attachment[] doInBackground(Long... messageIds) {
1138            return Attachment.restoreAttachmentsWithMessageId(mContext, messageIds[0]);
1139        }
1140
1141        @Override
1142        protected void onPostExecute(Attachment[] attachments) {
1143            try {
1144                if (isCancelled() || attachments == null) {
1145                    return;
1146                }
1147                boolean htmlChanged = false;
1148                int numDisplayedAttachments = 0;
1149                for (Attachment attachment : attachments) {
1150                    if (mHtmlTextRaw != null && attachment.mContentId != null
1151                            && attachment.mContentUri != null) {
1152                        // for html body, replace CID for inline images
1153                        // Regexp which matches ' src="cid:contentId"'.
1154                        String contentIdRe =
1155                            "\\s+(?i)src=\"cid(?-i):\\Q" + attachment.mContentId + "\\E\"";
1156                        String srcContentUri = " src=\"" + attachment.mContentUri + "\"";
1157                        mHtmlTextRaw = mHtmlTextRaw.replaceAll(contentIdRe, srcContentUri);
1158                        htmlChanged = true;
1159                    } else {
1160                        addAttachment(attachment);
1161                        numDisplayedAttachments++;
1162                    }
1163                }
1164                setAttachmentCount(numDisplayedAttachments);
1165                mHtmlTextWebView = mHtmlTextRaw;
1166                mHtmlTextRaw = null;
1167                if (htmlChanged) {
1168                    setMessageHtml(mHtmlTextWebView);
1169                }
1170            } finally {
1171                showContent(true, false);
1172            }
1173        }
1174    }
1175
1176    private static Bitmap getPreviewIcon(Context context, AttachmentInfo attachment) {
1177        try {
1178            return BitmapFactory.decodeStream(
1179                    context.getContentResolver().openInputStream(
1180                            AttachmentUtilities.getAttachmentThumbnailUri(
1181                                    attachment.mAccountKey, attachment.mId,
1182                                    PREVIEW_ICON_WIDTH,
1183                                    PREVIEW_ICON_HEIGHT)));
1184        } catch (Exception e) {
1185            Log.d(Logging.LOG_TAG, "Attachment preview failed with exception " + e.getMessage());
1186            return null;
1187        }
1188    }
1189
1190    /**
1191     * Subclass of AttachmentInfo which includes our views and buttons related to attachment
1192     * handling, as well as our determination of suitability for viewing (based on availability of
1193     * a viewer app) and saving (based upon the presence of external storage)
1194     */
1195    private static class MessageViewAttachmentInfo extends AttachmentInfo {
1196        private Button openButton;
1197        private Button saveButton;
1198        private Button loadButton;
1199        private Button infoButton;
1200        private Button cancelButton;
1201        private ImageView iconView;
1202
1203        private static final Map<AttachmentInfo, String> sSavedFileInfos = Maps.newHashMap();
1204
1205        // Don't touch it directly from the outer class.
1206        private final ProgressBar mProgressView;
1207        private boolean loaded;
1208
1209        private MessageViewAttachmentInfo(Context context, Attachment attachment,
1210                ProgressBar progressView) {
1211            super(context, attachment);
1212            mProgressView = progressView;
1213        }
1214
1215        /**
1216         * Create a new attachment info based upon an existing attachment info. Display
1217         * related fields (such as views and buttons) are copied from old to new.
1218         */
1219        private MessageViewAttachmentInfo(Context context, MessageViewAttachmentInfo oldInfo) {
1220            super(context, oldInfo);
1221            openButton = oldInfo.openButton;
1222            saveButton = oldInfo.saveButton;
1223            loadButton = oldInfo.loadButton;
1224            infoButton = oldInfo.infoButton;
1225            cancelButton = oldInfo.cancelButton;
1226            iconView = oldInfo.iconView;
1227            mProgressView = oldInfo.mProgressView;
1228            loaded = oldInfo.loaded;
1229        }
1230
1231        public void hideProgress() {
1232            // Don't use GONE, which'll break the layout.
1233            if (mProgressView.getVisibility() != View.INVISIBLE) {
1234                mProgressView.setVisibility(View.INVISIBLE);
1235            }
1236        }
1237
1238        public void showProgress(int progress) {
1239            if (mProgressView.getVisibility() != View.VISIBLE) {
1240                mProgressView.setVisibility(View.VISIBLE);
1241            }
1242            if (mProgressView.isIndeterminate()) {
1243                mProgressView.setIndeterminate(false);
1244            }
1245            mProgressView.setProgress(progress);
1246        }
1247
1248        public void showProgressIndeterminate() {
1249            if (mProgressView.getVisibility() != View.VISIBLE) {
1250                mProgressView.setVisibility(View.VISIBLE);
1251            }
1252            if (!mProgressView.isIndeterminate()) {
1253                mProgressView.setIndeterminate(true);
1254            }
1255        }
1256
1257        /**
1258         * Determines whether or not this attachment has a saved file in the external storage. That
1259         * is, the user has at some point clicked "save" for this attachment.
1260         *
1261         * Note: this is an approximation and uses an in-memory cache that can get wiped when the
1262         * process dies, and so is somewhat conservative. Additionally, the user can modify the file
1263         * after saving, and so the file may not be the same (though this is unlikely).
1264         */
1265        public boolean isFileSaved() {
1266            String path = getSavedPath();
1267            if (path == null) {
1268                return false;
1269            }
1270            boolean savedFileExists = new File(path).exists();
1271            if (!savedFileExists) {
1272                // Purge the cache entry.
1273                setSavedPath(null);
1274            }
1275            return savedFileExists;
1276        }
1277
1278        private void setSavedPath(String path) {
1279            if (path == null) {
1280                sSavedFileInfos.remove(this);
1281            } else {
1282                sSavedFileInfos.put(this, path);
1283            }
1284        }
1285
1286        /**
1287         * Returns an absolute file path for the given attachment if it has been saved. If one is
1288         * not found, {@code null} is returned.
1289         *
1290         * Clients are expected to validate that the file at the given path is still valid.
1291         */
1292        private String getSavedPath() {
1293            return sSavedFileInfos.get(this);
1294        }
1295
1296        @Override
1297        protected Uri getUriForIntent(Context context, long accountId) {
1298            // Prefer to act on the saved file for intents.
1299            String path = getSavedPath();
1300            return (path != null)
1301                    ? Uri.parse("file://" + getSavedPath())
1302                    : super.getUriForIntent(context, accountId);
1303        }
1304    }
1305
1306    /**
1307     * Updates all current attachments on the attachment tab.
1308     */
1309    private void updateAttachmentTab() {
1310        for (int i = 0, count = mAttachments.getChildCount(); i < count; i++) {
1311            View view = mAttachments.getChildAt(i);
1312            MessageViewAttachmentInfo oldInfo = (MessageViewAttachmentInfo)view.getTag();
1313            MessageViewAttachmentInfo newInfo =
1314                    new MessageViewAttachmentInfo(getActivity(), oldInfo);
1315            updateAttachmentButtons(newInfo);
1316            view.setTag(newInfo);
1317        }
1318    }
1319
1320    /**
1321     * Updates the attachment buttons. Adjusts the visibility of the buttons as well
1322     * as updating any tag information associated with the buttons.
1323     */
1324    private void updateAttachmentButtons(MessageViewAttachmentInfo attachmentInfo) {
1325        ImageView attachmentIcon = attachmentInfo.iconView;
1326        Button openButton = attachmentInfo.openButton;
1327        Button saveButton = attachmentInfo.saveButton;
1328        Button loadButton = attachmentInfo.loadButton;
1329        Button infoButton = attachmentInfo.infoButton;
1330        Button cancelButton = attachmentInfo.cancelButton;
1331
1332        if (!attachmentInfo.mAllowView) {
1333            openButton.setVisibility(View.GONE);
1334        }
1335        if (!attachmentInfo.mAllowSave) {
1336            saveButton.setVisibility(View.GONE);
1337        }
1338
1339        if (!attachmentInfo.mAllowView && !attachmentInfo.mAllowSave) {
1340            // This attachment may never be viewed or saved, so block everything
1341            attachmentInfo.hideProgress();
1342            openButton.setVisibility(View.GONE);
1343            saveButton.setVisibility(View.GONE);
1344            loadButton.setVisibility(View.GONE);
1345            cancelButton.setVisibility(View.GONE);
1346            infoButton.setVisibility(View.VISIBLE);
1347        } else if (attachmentInfo.loaded) {
1348            // If the attachment is loaded, show 100% progress
1349            // Note that for POP3 messages, the user will only see "Open" and "Save",
1350            // because the entire message is loaded before being shown.
1351            // Hide "Load" and "Info", show "View" and "Save"
1352            attachmentInfo.showProgress(100);
1353            if (attachmentInfo.mAllowSave) {
1354                saveButton.setVisibility(View.VISIBLE);
1355
1356                boolean isFileSaved = attachmentInfo.isFileSaved();
1357                saveButton.setEnabled(!isFileSaved);
1358                if (!isFileSaved) {
1359                    saveButton.setText(R.string.message_view_attachment_save_action);
1360                } else {
1361                    saveButton.setText(R.string.message_view_attachment_saved);
1362                }
1363            }
1364            if (attachmentInfo.mAllowView) {
1365                // Set the attachment action button text accordingly
1366                if (attachmentInfo.mContentType.startsWith("audio/") ||
1367                        attachmentInfo.mContentType.startsWith("video/")) {
1368                    openButton.setText(R.string.message_view_attachment_play_action);
1369                } else if (attachmentInfo.mAllowInstall) {
1370                    openButton.setText(R.string.message_view_attachment_install_action);
1371                } else {
1372                    openButton.setText(R.string.message_view_attachment_view_action);
1373                }
1374                openButton.setVisibility(View.VISIBLE);
1375            }
1376            if (attachmentInfo.mDenyFlags == AttachmentInfo.ALLOW) {
1377                infoButton.setVisibility(View.GONE);
1378            } else {
1379                infoButton.setVisibility(View.VISIBLE);
1380            }
1381            loadButton.setVisibility(View.GONE);
1382            cancelButton.setVisibility(View.GONE);
1383
1384            updatePreviewIcon(attachmentInfo);
1385        } else {
1386            // The attachment is not loaded, so present UI to start downloading it
1387
1388            // Show "Load"; hide "View", "Save" and "Info"
1389            saveButton.setVisibility(View.GONE);
1390            openButton.setVisibility(View.GONE);
1391            infoButton.setVisibility(View.GONE);
1392
1393            // If the attachment is queued, show the indeterminate progress bar.  From this point,.
1394            // any progress changes will cause this to be replaced by the normal progress bar
1395            if (AttachmentDownloadService.isAttachmentQueued(attachmentInfo.mId)) {
1396                attachmentInfo.showProgressIndeterminate();
1397                loadButton.setVisibility(View.GONE);
1398                cancelButton.setVisibility(View.VISIBLE);
1399            } else {
1400                loadButton.setVisibility(View.VISIBLE);
1401                cancelButton.setVisibility(View.GONE);
1402            }
1403        }
1404        openButton.setTag(attachmentInfo);
1405        saveButton.setTag(attachmentInfo);
1406        loadButton.setTag(attachmentInfo);
1407        infoButton.setTag(attachmentInfo);
1408        cancelButton.setTag(attachmentInfo);
1409    }
1410
1411    /**
1412     * Copy data from a cursor-refreshed attachment into the UI.  Called from UI thread.
1413     *
1414     * @param attachment A single attachment loaded from the provider
1415     */
1416    private void addAttachment(Attachment attachment) {
1417        LayoutInflater inflater = getActivity().getLayoutInflater();
1418        View view = inflater.inflate(R.layout.message_view_attachment, null);
1419
1420        TextView attachmentName = (TextView) UiUtilities.getView(view, R.id.attachment_name);
1421        TextView attachmentInfoView = (TextView) UiUtilities.getView(view, R.id.attachment_info);
1422        ImageView attachmentIcon = (ImageView) UiUtilities.getView(view, R.id.attachment_icon);
1423        Button openButton = (Button) UiUtilities.getView(view, R.id.open);
1424        Button saveButton = (Button) UiUtilities.getView(view, R.id.save);
1425        Button loadButton = (Button) UiUtilities.getView(view, R.id.load);
1426        Button infoButton = (Button) UiUtilities.getView(view, R.id.info);
1427        Button cancelButton = (Button) UiUtilities.getView(view, R.id.cancel);
1428        ProgressBar attachmentProgress = (ProgressBar) UiUtilities.getView(view, R.id.progress);
1429
1430        MessageViewAttachmentInfo attachmentInfo = new MessageViewAttachmentInfo(
1431                mContext, attachment, attachmentProgress);
1432
1433        // Check whether the attachment already exists
1434        if (Utility.attachmentExists(mContext, attachment)) {
1435            attachmentInfo.loaded = true;
1436        }
1437
1438        attachmentInfo.openButton = openButton;
1439        attachmentInfo.saveButton = saveButton;
1440        attachmentInfo.loadButton = loadButton;
1441        attachmentInfo.infoButton = infoButton;
1442        attachmentInfo.cancelButton = cancelButton;
1443        attachmentInfo.iconView = attachmentIcon;
1444
1445        updateAttachmentButtons(attachmentInfo);
1446
1447        view.setTag(attachmentInfo);
1448        openButton.setOnClickListener(this);
1449        saveButton.setOnClickListener(this);
1450        loadButton.setOnClickListener(this);
1451        infoButton.setOnClickListener(this);
1452        cancelButton.setOnClickListener(this);
1453
1454        attachmentName.setText(attachmentInfo.mName);
1455        attachmentInfoView.setText(UiUtilities.formatSize(mContext, attachmentInfo.mSize));
1456
1457        mAttachments.addView(view);
1458        mAttachments.setVisibility(View.VISIBLE);
1459    }
1460
1461    private MessageViewAttachmentInfo findAttachmentInfoFromView(long attachmentId) {
1462        for (int i = 0, count = mAttachments.getChildCount(); i < count; i++) {
1463            MessageViewAttachmentInfo attachmentInfo =
1464                    (MessageViewAttachmentInfo) mAttachments.getChildAt(i).getTag();
1465            if (attachmentInfo.mId == attachmentId) {
1466                return attachmentInfo;
1467            }
1468        }
1469        return null;
1470    }
1471
1472    /**
1473     * Reload the UI from a provider cursor.  {@link LoadMessageTask#onPostExecute} calls it.
1474     *
1475     * Update the header views, and start loading the body.
1476     *
1477     * @param message A copy of the message loaded from the database
1478     * @param okToFetch If true, and message is not fully loaded, it's OK to fetch from
1479     * the network.  Use false to prevent looping here.
1480     */
1481    protected void reloadUiFromMessage(Message message, boolean okToFetch) {
1482        mMessage = message;
1483        mAccountId = message.mAccountKey;
1484
1485        mMessageObserver.register(ContentUris.withAppendedId(Message.CONTENT_URI, mMessage.mId));
1486
1487        updateHeaderView(mMessage);
1488
1489        // Handle partially-loaded email, as follows:
1490        // 1. Check value of message.mFlagLoaded
1491        // 2. If != LOADED, ask controller to load it
1492        // 3. Controller callback (after loaded) should trigger LoadBodyTask & LoadAttachmentsTask
1493        // 4. Else start the loader tasks right away (message already loaded)
1494        if (okToFetch && message.mFlagLoaded != Message.FLAG_LOADED_COMPLETE) {
1495            mControllerCallback.getWrappee().setWaitForLoadMessageId(message.mId);
1496            mController.loadMessageForView(message.mId);
1497        } else {
1498            mControllerCallback.getWrappee().setWaitForLoadMessageId(-1);
1499            // Ask for body
1500            new LoadBodyTask(message.mId).executeParallel();
1501        }
1502    }
1503
1504    protected void updateHeaderView(Message message) {
1505        mSubjectView.setText(message.mSubject);
1506        final Address from = Address.unpackFirst(message.mFrom);
1507
1508        // Set sender address/display name
1509        // Note we set " " for empty field, so TextView's won't get squashed.
1510        // Otherwise their height will be 0, which breaks the layout.
1511        if (from != null) {
1512            final String fromFriendly = from.toFriendly();
1513            final String fromAddress = from.getAddress();
1514            mFromNameView.setText(fromFriendly);
1515            mFromAddressView.setText(fromFriendly.equals(fromAddress) ? " " : fromAddress);
1516        } else {
1517            mFromNameView.setText(" ");
1518            mFromAddressView.setText(" ");
1519        }
1520        mDateTimeView.setText(formatDate(message.mTimeStamp, false));
1521
1522        // To/Cc/Bcc
1523        final Resources res = mContext.getResources();
1524        final SpannableStringBuilder ssb = new SpannableStringBuilder();
1525        final String friendlyTo = Address.toFriendly(Address.unpack(message.mTo));
1526        final String friendlyCc = Address.toFriendly(Address.unpack(message.mCc));
1527        final String friendlyBcc = Address.toFriendly(Address.unpack(message.mBcc));
1528
1529        if (!TextUtils.isEmpty(friendlyTo)) {
1530            Utility.appendBold(ssb, res.getString(R.string.message_view_to_label));
1531            ssb.append(" ");
1532            ssb.append(friendlyTo);
1533        }
1534        if (!TextUtils.isEmpty(friendlyCc)) {
1535            ssb.append("  ");
1536            Utility.appendBold(ssb, res.getString(R.string.message_view_cc_label));
1537            ssb.append(" ");
1538            ssb.append(friendlyCc);
1539        }
1540        if (!TextUtils.isEmpty(friendlyBcc)) {
1541            ssb.append("  ");
1542            Utility.appendBold(ssb, res.getString(R.string.message_view_bcc_label));
1543            ssb.append(" ");
1544            ssb.append(friendlyBcc);
1545        }
1546        mAddressesView.setText(ssb);
1547    }
1548
1549    private String formatDate(long millis, boolean withYear) {
1550        StringBuilder sb = new StringBuilder();
1551        Formatter formatter = new Formatter(sb);
1552        DateUtils.formatDateRange(mContext, formatter, millis, millis,
1553                DateUtils.FORMAT_SHOW_DATE
1554                | DateUtils.FORMAT_ABBREV_ALL
1555                | DateUtils.FORMAT_SHOW_TIME
1556                | (withYear ? DateUtils.FORMAT_SHOW_YEAR : DateUtils.FORMAT_NO_YEAR));
1557        return sb.toString();
1558    }
1559
1560    /**
1561     * Reload the body from the provider cursor.  This must only be called from the UI thread.
1562     *
1563     * @param bodyText text part
1564     * @param bodyHtml html part
1565     *
1566     * TODO deal with html vs text and many other issues <- WHAT DOES IT MEAN??
1567     */
1568    private void reloadUiFromBody(String bodyText, String bodyHtml) {
1569        String text = null;
1570        mHtmlTextRaw = null;
1571        boolean hasImages = false;
1572
1573        if (bodyHtml == null) {
1574            text = bodyText;
1575            /*
1576             * Convert the plain text to HTML
1577             */
1578            StringBuffer sb = new StringBuffer("<html><body>");
1579            if (text != null) {
1580                // Escape any inadvertent HTML in the text message
1581                text = EmailHtmlUtil.escapeCharacterToDisplay(text);
1582                // Find any embedded URL's and linkify
1583                Matcher m = Patterns.WEB_URL.matcher(text);
1584                while (m.find()) {
1585                    int start = m.start();
1586                    /*
1587                     * WEB_URL_PATTERN may match domain part of email address. To detect
1588                     * this false match, the character just before the matched string
1589                     * should not be '@'.
1590                     */
1591                    if (start == 0 || text.charAt(start - 1) != '@') {
1592                        String url = m.group();
1593                        Matcher proto = WEB_URL_PROTOCOL.matcher(url);
1594                        String link;
1595                        if (proto.find()) {
1596                            // This is work around to force URL protocol part be lower case,
1597                            // because WebView could follow only lower case protocol link.
1598                            link = proto.group().toLowerCase() + url.substring(proto.end());
1599                        } else {
1600                            // Patterns.WEB_URL matches URL without protocol part,
1601                            // so added default protocol to link.
1602                            link = "http://" + url;
1603                        }
1604                        String href = String.format("<a href=\"%s\">%s</a>", link, url);
1605                        m.appendReplacement(sb, href);
1606                    }
1607                    else {
1608                        m.appendReplacement(sb, "$0");
1609                    }
1610                }
1611                m.appendTail(sb);
1612            }
1613            sb.append("</body></html>");
1614            text = sb.toString();
1615        } else {
1616            text = bodyHtml;
1617            mHtmlTextRaw = bodyHtml;
1618            hasImages = IMG_TAG_START_REGEX.matcher(text).find();
1619        }
1620
1621        // TODO this is not really accurate.
1622        // - Images aren't the only network resources.  (e.g. CSS)
1623        // - If images are attached to the email and small enough, we download them at once,
1624        //   and won't need network access when they're shown.
1625        if (hasImages) {
1626            if (mRestoredPictureLoaded) {
1627                blockNetworkLoads(false);
1628                addTabFlags(TAB_FLAGS_PICTURE_LOADED); // Set for next onSaveInstanceState
1629
1630                // Make sure to reset the flag -- otherwise this will keep taking effect even after
1631                // moving to another message.
1632                mRestoredPictureLoaded = false;
1633            } else {
1634                addTabFlags(TAB_FLAGS_HAS_PICTURES);
1635            }
1636        }
1637        setMessageHtml(text);
1638
1639        // Ask for attachments after body
1640        new LoadAttachmentsTask().executeParallel(mMessage.mId);
1641
1642        mIsMessageLoadedForTest = true;
1643    }
1644
1645    /**
1646     * Overrides for WebView behaviors.
1647     */
1648    private class CustomWebViewClient extends WebViewClient {
1649        @Override
1650        public boolean shouldOverrideUrlLoading(WebView view, String url) {
1651            return mCallback.onUrlInMessageClicked(url);
1652        }
1653    }
1654
1655    private View findAttachmentView(long attachmentId) {
1656        for (int i = 0, count = mAttachments.getChildCount(); i < count; i++) {
1657            View view = mAttachments.getChildAt(i);
1658            MessageViewAttachmentInfo attachment = (MessageViewAttachmentInfo) view.getTag();
1659            if (attachment.mId == attachmentId) {
1660                return view;
1661            }
1662        }
1663        return null;
1664    }
1665
1666    private MessageViewAttachmentInfo findAttachmentInfo(long attachmentId) {
1667        View view = findAttachmentView(attachmentId);
1668        if (view != null) {
1669            return (MessageViewAttachmentInfo)view.getTag();
1670        }
1671        return null;
1672    }
1673
1674    /**
1675     * Controller results listener.  We wrap it with {@link ControllerResultUiThreadWrapper},
1676     * so all methods are called on the UI thread.
1677     */
1678    private class ControllerResults extends Controller.Result {
1679        private long mWaitForLoadMessageId;
1680
1681        public void setWaitForLoadMessageId(long messageId) {
1682            mWaitForLoadMessageId = messageId;
1683        }
1684
1685        @Override
1686        public void loadMessageForViewCallback(MessagingException result, long accountId,
1687                long messageId, int progress) {
1688            if (messageId != mWaitForLoadMessageId) {
1689                // We are not waiting for this message to load, so exit quickly
1690                return;
1691            }
1692            if (result == null) {
1693                switch (progress) {
1694                    case 0:
1695                        mCallback.onLoadMessageStarted();
1696                        // Loading from network -- show the progress icon.
1697                        showContent(false, true);
1698                        break;
1699                    case 100:
1700                        mWaitForLoadMessageId = -1;
1701                        mCallback.onLoadMessageFinished();
1702                        // reload UI and reload everything else too
1703                        // pass false to LoadMessageTask to prevent looping here
1704                        cancelAllTasks();
1705                        new LoadMessageTask(false).executeParallel();
1706                        break;
1707                    default:
1708                        // do nothing - we don't have a progress bar at this time
1709                        break;
1710                }
1711            } else {
1712                mWaitForLoadMessageId = -1;
1713                String error = mContext.getString(R.string.status_network_error);
1714                mCallback.onLoadMessageError(error);
1715                resetView();
1716            }
1717        }
1718
1719        @Override
1720        public void loadAttachmentCallback(MessagingException result, long accountId,
1721                long messageId, long attachmentId, int progress) {
1722            if (messageId == mMessageId) {
1723                if (result == null) {
1724                    showAttachmentProgress(attachmentId, progress);
1725                    switch (progress) {
1726                        case 100:
1727                            final MessageViewAttachmentInfo attachmentInfo =
1728                                    findAttachmentInfoFromView(attachmentId);
1729                            if (attachmentInfo != null) {
1730                                updatePreviewIcon(attachmentInfo);
1731                            }
1732                            doFinishLoadAttachment(attachmentId);
1733                            break;
1734                        default:
1735                            // do nothing - we don't have a progress bar at this time
1736                            break;
1737                    }
1738                } else {
1739                    MessageViewAttachmentInfo attachment = findAttachmentInfo(attachmentId);
1740                    if (attachment == null) {
1741                        // Called before LoadAttachmentsTask finishes.
1742                        // (Possible if you quickly close & re-open a message)
1743                        return;
1744                    }
1745                    attachment.cancelButton.setVisibility(View.GONE);
1746                    attachment.loadButton.setVisibility(View.VISIBLE);
1747                    attachment.hideProgress();
1748
1749                    final String error;
1750                    if (result.getCause() instanceof IOException) {
1751                        error = mContext.getString(R.string.status_network_error);
1752                    } else {
1753                        error = mContext.getString(
1754                                R.string.message_view_load_attachment_failed_toast,
1755                                attachment.mName);
1756                    }
1757                    mCallback.onLoadMessageError(error);
1758                }
1759            }
1760        }
1761
1762        private void showAttachmentProgress(long attachmentId, int progress) {
1763            MessageViewAttachmentInfo attachment = findAttachmentInfo(attachmentId);
1764            if (attachment != null) {
1765                if (progress == 0) {
1766                    attachment.cancelButton.setVisibility(View.GONE);
1767                }
1768                attachment.showProgress(progress);
1769            }
1770        }
1771    }
1772
1773    /**
1774     * Class to detect update on the current message (e.g. toggle star).  When it gets content
1775     * change notifications, it kicks {@link ReloadMessageTask}.
1776     */
1777    private class MessageObserver extends ContentObserver implements Runnable {
1778        private final Throttle mThrottle;
1779        private final ContentResolver mContentResolver;
1780
1781        private boolean mRegistered;
1782
1783        public MessageObserver(Handler handler, Context context) {
1784            super(handler);
1785            mContentResolver = context.getContentResolver();
1786            mThrottle = new Throttle("MessageObserver", this, handler);
1787        }
1788
1789        public void unregister() {
1790            if (!mRegistered) {
1791                return;
1792            }
1793            mThrottle.cancelScheduledCallback();
1794            mContentResolver.unregisterContentObserver(this);
1795            mRegistered = false;
1796        }
1797
1798        public void register(Uri notifyUri) {
1799            unregister();
1800            mContentResolver.registerContentObserver(notifyUri, true, this);
1801            mRegistered = true;
1802        }
1803
1804        @Override
1805        public boolean deliverSelfNotifications() {
1806            return true;
1807        }
1808
1809        @Override
1810        public void onChange(boolean selfChange) {
1811            mThrottle.onEvent();
1812        }
1813
1814        /** This method is delay-called by {@link Throttle} on the UI thread. */
1815        @Override
1816        public void run() {
1817            // This method is delay-called, so need to make sure if it's still registered.
1818            if (mRegistered) {
1819                new ReloadMessageTask().cancelPreviousAndExecuteParallel();
1820            }
1821        }
1822    }
1823
1824    private void updatePreviewIcon(MessageViewAttachmentInfo attachmentInfo) {
1825        new UpdatePreviewIconTask(attachmentInfo).executeParallel();
1826    }
1827
1828    private class UpdatePreviewIconTask extends EmailAsyncTask<Void, Void, Bitmap> {
1829        @SuppressWarnings("hiding")
1830        private final Context mContext;
1831        private final MessageViewAttachmentInfo mAttachmentInfo;
1832
1833        public UpdatePreviewIconTask(MessageViewAttachmentInfo attachmentInfo) {
1834            super(mTaskTracker);
1835            mContext = getActivity();
1836            mAttachmentInfo = attachmentInfo;
1837        }
1838
1839        @Override
1840        protected Bitmap doInBackground(Void... params) {
1841            return getPreviewIcon(mContext, mAttachmentInfo);
1842        }
1843
1844        @Override
1845        protected void onPostExecute(Bitmap result) {
1846            if (result == null) {
1847                return;
1848            }
1849            mAttachmentInfo.iconView.setImageBitmap(result);
1850        }
1851    }
1852
1853    public boolean isMessageLoadedForTest() {
1854        return mIsMessageLoadedForTest;
1855    }
1856
1857    public void clearIsMessageLoadedForTest() {
1858        mIsMessageLoadedForTest = true;
1859    }
1860}
1861