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