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