MessageViewFragmentBase.java revision 04795828d97727a4514487adc58f330aa950485e
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 android.app.Activity;
20import android.app.DownloadManager;
21import android.app.Fragment;
22import android.app.LoaderManager.LoaderCallbacks;
23import android.content.ActivityNotFoundException;
24import android.content.ContentResolver;
25import android.content.ContentUris;
26import android.content.Context;
27import android.content.Intent;
28import android.content.Loader;
29import android.content.pm.PackageManager;
30import android.content.res.Resources;
31import android.database.ContentObserver;
32import android.graphics.Bitmap;
33import android.graphics.BitmapFactory;
34import android.media.MediaScannerConnection;
35import android.net.Uri;
36import android.os.Bundle;
37import android.os.Environment;
38import android.os.Handler;
39import android.provider.ContactsContract;
40import android.provider.ContactsContract.QuickContact;
41import android.text.SpannableStringBuilder;
42import android.text.TextUtils;
43import android.text.format.DateUtils;
44import android.util.Log;
45import android.util.Patterns;
46import android.view.LayoutInflater;
47import android.view.View;
48import android.view.ViewGroup;
49import android.webkit.WebSettings;
50import android.webkit.WebView;
51import android.webkit.WebViewClient;
52import android.widget.Button;
53import android.widget.ImageView;
54import android.widget.LinearLayout;
55import android.widget.ProgressBar;
56import android.widget.TextView;
57
58import com.android.email.AttachmentInfo;
59import com.android.email.Controller;
60import com.android.email.ControllerResultUiThreadWrapper;
61import com.android.email.Email;
62import com.android.email.Preferences;
63import com.android.email.R;
64import com.android.email.Throttle;
65import com.android.email.mail.internet.EmailHtmlUtil;
66import com.android.email.service.AttachmentDownloadService;
67import com.android.emailcommon.Logging;
68import com.android.emailcommon.mail.Address;
69import com.android.emailcommon.mail.MessagingException;
70import com.android.emailcommon.provider.Account;
71import com.android.emailcommon.provider.EmailContent.Attachment;
72import com.android.emailcommon.provider.EmailContent.Body;
73import com.android.emailcommon.provider.EmailContent.Message;
74import com.android.emailcommon.provider.Mailbox;
75import com.android.emailcommon.utility.AttachmentUtilities;
76import com.android.emailcommon.utility.EmailAsyncTask;
77import com.android.emailcommon.utility.Utility;
78import com.google.common.collect.Maps;
79
80import org.apache.commons.io.IOUtils;
81
82import java.io.File;
83import java.io.FileOutputStream;
84import java.io.IOException;
85import java.io.InputStream;
86import java.io.OutputStream;
87import java.util.Formatter;
88import java.util.Map;
89import java.util.regex.Matcher;
90import java.util.regex.Pattern;
91
92// TODO Better handling of config changes.
93// - Retain the content; don't kick 3 async tasks every time
94
95/**
96 * Base class for {@link MessageViewFragment} and {@link MessageFileViewFragment}.
97 */
98public abstract class MessageViewFragmentBase extends Fragment implements View.OnClickListener {
99    private static final String BUNDLE_KEY_CURRENT_TAB = "MessageViewFragmentBase.currentTab";
100    private static final String BUNDLE_KEY_PICTURE_LOADED = "MessageViewFragmentBase.pictureLoaded";
101    private static final int PHOTO_LOADER_ID = 1;
102    protected Context mContext;
103
104    // Regex that matches start of img tag. '<(?i)img\s+'.
105    private static final Pattern IMG_TAG_START_REGEX = Pattern.compile("<(?i)img\\s+");
106    // Regex that matches Web URL protocol part as case insensitive.
107    private static final Pattern WEB_URL_PROTOCOL = Pattern.compile("(?i)http|https://");
108
109    private static int PREVIEW_ICON_WIDTH = 62;
110    private static int PREVIEW_ICON_HEIGHT = 62;
111
112    private TextView mSubjectView;
113    private TextView mFromNameView;
114    private TextView mFromAddressView;
115    private TextView mDateTimeView;
116    private TextView mAddressesView;
117    private WebView mMessageContentView;
118    private LinearLayout mAttachments;
119    private View mTabSection;
120    private ImageView mFromBadge;
121    private ImageView mSenderPresenceView;
122    private View mMainView;
123    private View mLoadingProgress;
124    private View mShowDetailsButton;
125
126    private TextView mMessageTab;
127    private TextView mAttachmentTab;
128    private TextView mInviteTab;
129    // It is not really a tab, but looks like one of them.
130    private TextView mShowPicturesTab;
131    private Button mAlwaysShowPicturesButton;
132
133    private View mAttachmentsScroll;
134    private View mInviteScroll;
135
136    private long mAccountId = Account.NO_ACCOUNT;
137    private long mMessageId = Message.NO_MESSAGE;
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 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 = 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 = UiUtilities.getView(view, R.id.show_message);
310        mAttachmentTab = UiUtilities.getView(view, R.id.show_attachments);
311        mShowPicturesTab = UiUtilities.getView(view, R.id.show_pictures);
312        mAlwaysShowPicturesButton = UiUtilities.getView(view, R.id.always_show_pictures_button);
313        // Invite is only used in MessageViewFragment, but visibility is controlled here.
314        mInviteTab = UiUtilities.getView(view, R.id.show_invite);
315
316        mMessageTab.setOnClickListener(this);
317        mAttachmentTab.setOnClickListener(this);
318        mShowPicturesTab.setOnClickListener(this);
319        mAlwaysShowPicturesButton.setOnClickListener(this);
320        mInviteTab.setOnClickListener(this);
321        mShowDetailsButton.setOnClickListener(this);
322
323        mAttachmentsScroll = UiUtilities.getView(view, R.id.attachments_scroll);
324        mInviteScroll = UiUtilities.getView(view, R.id.invite_scroll);
325
326        WebSettings webSettings = mMessageContentView.getSettings();
327        boolean supportMultiTouch = mContext.getPackageManager()
328                .hasSystemFeature(PackageManager.FEATURE_TOUCHSCREEN_MULTITOUCH);
329        webSettings.setDisplayZoomControls(!supportMultiTouch);
330        webSettings.setSupportZoom(true);
331        webSettings.setBuiltInZoomControls(true);
332        mMessageContentView.setWebViewClient(new CustomWebViewClient());
333        return view;
334    }
335
336    @Override
337    public void onActivityCreated(Bundle savedInstanceState) {
338        if (Logging.DEBUG_LIFECYCLE && Email.DEBUG) {
339            Log.d(Logging.LOG_TAG, this + " onActivityCreated");
340        }
341        super.onActivityCreated(savedInstanceState);
342        mController.addResultCallback(mControllerCallback);
343
344        resetView();
345        new LoadMessageTask(true).executeParallel();
346
347        UiUtilities.installFragment(this);
348    }
349
350    @Override
351    public void onStart() {
352        if (Logging.DEBUG_LIFECYCLE && Email.DEBUG) {
353            Log.d(Logging.LOG_TAG, this + " onStart");
354        }
355        super.onStart();
356    }
357
358    @Override
359    public void onResume() {
360        if (Logging.DEBUG_LIFECYCLE && Email.DEBUG) {
361            Log.d(Logging.LOG_TAG, this + " onResume");
362        }
363        super.onResume();
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        super.onPause();
377    }
378
379    @Override
380    public void onStop() {
381        if (Logging.DEBUG_LIFECYCLE && Email.DEBUG) {
382            Log.d(Logging.LOG_TAG, this + " onStop");
383        }
384        super.onStop();
385    }
386
387    @Override
388    public void onDestroyView() {
389        if (Logging.DEBUG_LIFECYCLE && Email.DEBUG) {
390            Log.d(Logging.LOG_TAG, this + " onDestroyView");
391        }
392        UiUtilities.uninstallFragment(this);
393        mController.removeResultCallback(mControllerCallback);
394        cancelAllTasks();
395        mMessageContentView.destroy();
396        mMessageContentView = null;
397
398        super.onDestroyView();
399    }
400
401    @Override
402    public void onDestroy() {
403        if (Logging.DEBUG_LIFECYCLE && Email.DEBUG) {
404            Log.d(Logging.LOG_TAG, this + " onDestroy");
405        }
406        super.onDestroy();
407    }
408
409    @Override
410    public void onDetach() {
411        if (Logging.DEBUG_LIFECYCLE && Email.DEBUG) {
412            Log.d(Logging.LOG_TAG, this + " onDetach");
413        }
414        super.onDetach();
415    }
416
417    @Override
418    public void onSaveInstanceState(Bundle outState) {
419        if (Logging.DEBUG_LIFECYCLE && Email.DEBUG) {
420            Log.d(Logging.LOG_TAG, this + " onSaveInstanceState");
421        }
422        super.onSaveInstanceState(outState);
423        outState.putInt(BUNDLE_KEY_CURRENT_TAB, mCurrentTab);
424        outState.putBoolean(BUNDLE_KEY_PICTURE_LOADED, (mTabFlags & TAB_FLAGS_PICTURE_LOADED) != 0);
425    }
426
427    private void restoreInstanceState(Bundle state) {
428        if (Logging.DEBUG_LIFECYCLE && Email.DEBUG) {
429            Log.d(Logging.LOG_TAG, this + " restoreInstanceState");
430        }
431        // At this point (in onCreate) no tabs are visible (because we don't know if the message has
432        // an attachment or invite before loading it).  We just remember the tab here.
433        // We'll make it current when the tab first becomes visible in updateTabs().
434        mRestoredTab = state.getInt(BUNDLE_KEY_CURRENT_TAB);
435        mRestoredPictureLoaded = state.getBoolean(BUNDLE_KEY_PICTURE_LOADED);
436    }
437
438    public void setCallback(Callback callback) {
439        mCallback = (callback == null) ? EmptyCallback.INSTANCE : callback;
440    }
441
442    private void cancelAllTasks() {
443        mMessageObserver.unregister();
444        mTaskTracker.cancellAllInterrupt();
445    }
446
447    protected final Controller getController() {
448        return mController;
449    }
450
451    protected final Callback getCallback() {
452        return mCallback;
453    }
454
455    protected final Message getMessage() {
456        return mMessage;
457    }
458
459    protected final boolean isMessageOpen() {
460        return mMessage != null;
461    }
462
463    /**
464     * Returns the account id of the current message, or -1 if unknown (message not open yet, or
465     * viewing an EML message).
466     */
467    public long getAccountId() {
468        return mAccountId;
469    }
470
471    /**
472     * Show/hide the content.  We hide all the content (except for the bottom buttons) when loading,
473     * to avoid flicker.
474     */
475    private void showContent(boolean showContent, boolean showProgressWhenHidden) {
476        makeVisible(mMainView, showContent);
477        makeVisible(mLoadingProgress, !showContent && showProgressWhenHidden);
478    }
479
480    protected void resetView() {
481        showContent(false, false);
482        updateTabs(0);
483        setCurrentTab(TAB_MESSAGE);
484        if (mMessageContentView != null) {
485            blockNetworkLoads(true);
486            mMessageContentView.scrollTo(0, 0);
487            mMessageContentView.clearView();
488
489            // Dynamic configuration of WebView
490            final WebSettings settings = mMessageContentView.getSettings();
491            settings.setLayoutAlgorithm(WebSettings.LayoutAlgorithm.NORMAL);
492            mMessageContentView.setInitialScale(getWebViewZoom());
493        }
494        mAttachmentsScroll.scrollTo(0, 0);
495        mInviteScroll.scrollTo(0, 0);
496        mAttachments.removeAllViews();
497        mAttachments.setVisibility(View.GONE);
498        initContactStatusViews();
499    }
500
501    /**
502     * Returns the zoom scale (in percent) which is a combination of the user setting
503     * (tiny, small, normal, large, huge) and the device density. The intention
504     * is for the text to be physically equal in size over different density
505     * screens.
506     */
507    private int getWebViewZoom() {
508        float density = mContext.getResources().getDisplayMetrics().density;
509        int zoom = Preferences.getPreferences(mContext).getTextZoom();
510        return (int) (ZOOM_SCALE_ARRAY[zoom] * density * 100);
511    }
512
513    private void initContactStatusViews() {
514        mContactStatusState = CONTACT_STATUS_STATE_UNLOADED;
515        mQuickContactLookupUri = null;
516        mSenderPresenceView.setImageResource(ContactStatusLoader.PRESENCE_UNKNOWN_RESOURCE_ID);
517        showDefaultQuickContactBadgeImage();
518    }
519
520    private void showDefaultQuickContactBadgeImage() {
521        mFromBadge.setImageResource(R.drawable.ic_contact_picture);
522    }
523
524    protected final void addTabFlags(int tabFlags) {
525        updateTabs(mTabFlags | tabFlags);
526    }
527
528    private final void clearTabFlags(int tabFlags) {
529        updateTabs(mTabFlags & ~tabFlags);
530    }
531
532    private void setAttachmentCount(int count) {
533        mAttachmentCount = count;
534        if (mAttachmentCount > 0) {
535            addTabFlags(TAB_FLAGS_HAS_ATTACHMENT);
536        } else {
537            clearTabFlags(TAB_FLAGS_HAS_ATTACHMENT);
538        }
539    }
540
541    private static void makeVisible(View v, boolean visible) {
542        final int visibility = visible ? View.VISIBLE : View.GONE;
543        if ((v != null) && (v.getVisibility() != visibility)) {
544            v.setVisibility(visibility);
545        }
546    }
547
548    private static boolean isVisible(View v) {
549        return (v != null) && (v.getVisibility() == View.VISIBLE);
550    }
551
552    /**
553     * Update the visual of the tabs.  (visibility, text, etc)
554     */
555    private void updateTabs(int tabFlags) {
556        mTabFlags = tabFlags;
557        boolean messageTabVisible = (tabFlags & (TAB_FLAGS_HAS_INVITE | TAB_FLAGS_HAS_ATTACHMENT))
558                != 0;
559        makeVisible(mMessageTab, messageTabVisible);
560        makeVisible(mInviteTab, (tabFlags & TAB_FLAGS_HAS_INVITE) != 0);
561        makeVisible(mAttachmentTab, (tabFlags & TAB_FLAGS_HAS_ATTACHMENT) != 0);
562
563        final boolean hasPictures = (tabFlags & TAB_FLAGS_HAS_PICTURES) != 0;
564        final boolean pictureLoaded = (tabFlags & TAB_FLAGS_PICTURE_LOADED) != 0;
565        makeVisible(mShowPicturesTab, hasPictures && !pictureLoaded);
566
567        mAttachmentTab.setText(mContext.getResources().getQuantityString(
568                R.plurals.message_view_show_attachments_action,
569                mAttachmentCount, mAttachmentCount));
570
571        // Hide the entire section if no tabs are visible.
572        makeVisible(mTabSection, isVisible(mMessageTab) || isVisible(mInviteTab)
573                || isVisible(mAttachmentTab) || isVisible(mShowPicturesTab)
574                || isVisible(UiUtilities.getView(getView(), R.id.always_show_pictures_container)));
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 showPicturesInHtml() {
886        boolean picturesAlreadyLoaded = (mTabFlags & TAB_FLAGS_PICTURE_LOADED) != 0;
887        if ((mMessageContentView != null) && !picturesAlreadyLoaded) {
888            blockNetworkLoads(false);
889            // TODO: why is this calling setMessageHtml just because the images can load now?
890            setMessageHtml(mHtmlTextWebView);
891
892            // Prompt the user to always show images from this sender.
893            makeVisible(UiUtilities.getView(getView(), R.id.always_show_pictures_container), true);
894
895            addTabFlags(TAB_FLAGS_PICTURE_LOADED);
896        }
897    }
898
899    private void onShowDetails() {
900        if (!isMessageOpen()) return;
901        String subject = mMessage.mSubject;
902        String date = formatDate(mMessage.mTimeStamp, true);
903
904        final String SEPARATOR = "\n";
905        String from = Address.toString(Address.unpack(mMessage.mFrom), SEPARATOR);
906        String to = Address.toString(Address.unpack(mMessage.mTo), SEPARATOR);
907        String cc = Address.toString(Address.unpack(mMessage.mCc), SEPARATOR);
908        String bcc = Address.toString(Address.unpack(mMessage.mBcc), SEPARATOR);
909        MessageViewMessageDetailsDialog dialog = MessageViewMessageDetailsDialog.newInstance(
910                getActivity(), subject, date, from, to, cc, bcc);
911        dialog.show(getActivity().getFragmentManager(), null);
912    }
913
914    @Override
915    public void onClick(View view) {
916        if (!isMessageOpen()) {
917            return; // Ignore.
918        }
919        switch (view.getId()) {
920            case R.id.from_name:
921            case R.id.from_address:
922            case R.id.badge:
923            case R.id.presence:
924                onClickSender();
925                break;
926            case R.id.load:
927                onLoadAttachment((MessageViewAttachmentInfo) view.getTag());
928                break;
929            case R.id.info:
930                onInfoAttachment((MessageViewAttachmentInfo) view.getTag());
931                break;
932            case R.id.save:
933                onSaveAttachment((MessageViewAttachmentInfo) view.getTag());
934                break;
935            case R.id.open:
936                onOpenAttachment((MessageViewAttachmentInfo) view.getTag());
937                break;
938            case R.id.cancel:
939                onCancelAttachment((MessageViewAttachmentInfo) view.getTag());
940                break;
941            case R.id.show_message:
942                setCurrentTab(TAB_MESSAGE);
943                break;
944            case R.id.show_invite:
945                setCurrentTab(TAB_INVITE);
946                break;
947            case R.id.show_attachments:
948                setCurrentTab(TAB_ATTACHMENT);
949                break;
950            case R.id.show_pictures:
951                showPicturesInHtml();
952                break;
953            case R.id.always_show_pictures_button:
954                setShowImagesForSender();
955                break;
956            case R.id.show_details:
957                onShowDetails();
958                break;
959        }
960    }
961
962    /**
963     * Start loading contact photo and presence.
964     */
965    private void queryContactStatus() {
966        if (!isMessageOpen()) return;
967        initContactStatusViews(); // Initialize the state, just in case.
968
969        // Find the sender email address, and start presence check.
970        Address sender = Address.unpackFirst(mMessage.mFrom);
971        if (sender != null) {
972            String email = sender.getAddress();
973            if (email != null) {
974                getLoaderManager().restartLoader(PHOTO_LOADER_ID,
975                        ContactStatusLoaderCallbacks.createArguments(email),
976                        new ContactStatusLoaderCallbacks(this));
977            }
978        }
979    }
980
981    /**
982     * Called by {@link LoadMessageTask} and {@link ReloadMessageTask} to load a message in a
983     * subclass specific way.
984     *
985     * NOTE This method is called on a worker thread!  Implementations must properly synchronize
986     * when accessing members.
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 == Mailbox.NO_MAILBOX) {
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            RecentMailboxManager.getInstance(mContext).touch(message.mMailboxKey);
1040        }
1041    }
1042
1043    /**
1044     * Kicked by {@link MessageObserver}.  Reload the message and update the views.
1045     */
1046    private class ReloadMessageTask extends EmailAsyncTask<Void, Void, Message> {
1047        public ReloadMessageTask() {
1048            super(mTaskTracker);
1049        }
1050
1051        @Override
1052        protected Message doInBackground(Void... params) {
1053            Activity activity = getActivity();
1054            if (activity == null) {
1055                return null;
1056            } else {
1057                return openMessageSync(activity);
1058            }
1059        }
1060
1061        @Override
1062        protected void onPostExecute(Message message) {
1063            if (message == null || message.mMailboxKey != mMessage.mMailboxKey) {
1064                // Message deleted or moved.
1065                mCallback.onMessageNotExists();
1066                return;
1067            }
1068            mMessage = message;
1069            updateHeaderView(mMessage);
1070        }
1071    }
1072
1073    /**
1074     * Called when a message is shown to the user.
1075     */
1076    protected void onMessageShown(long messageId, int mailboxType) {
1077        mCallback.onMessageShown();
1078    }
1079
1080    /**
1081     * Called when the message body is loaded.
1082     */
1083    protected void onPostLoadBody() {
1084    }
1085
1086    /**
1087     * Async task for loading a single message body outside of the UI thread
1088     */
1089    private class LoadBodyTask extends EmailAsyncTask<Void, Void, String[]> {
1090
1091        private final long mId;
1092        private boolean mErrorLoadingMessageBody;
1093        private final boolean mAutoShowPictures;
1094
1095        /**
1096         * Special constructor to cache some local info
1097         */
1098        public LoadBodyTask(long messageId, boolean autoShowPictures) {
1099            super(mTaskTracker);
1100            mId = messageId;
1101            mAutoShowPictures = autoShowPictures;
1102        }
1103
1104        @Override
1105        protected String[] doInBackground(Void... params) {
1106            try {
1107                String text = null;
1108                String html = Body.restoreBodyHtmlWithMessageId(mContext, mId);
1109                if (html == null) {
1110                    text = Body.restoreBodyTextWithMessageId(mContext, mId);
1111                }
1112                return new String[] { text, html };
1113            } catch (RuntimeException re) {
1114                // This catches SQLiteException as well as other RTE's we've seen from the
1115                // database calls, such as IllegalStateException
1116                Log.d(Logging.LOG_TAG, "Exception while loading message body", re);
1117                mErrorLoadingMessageBody = true;
1118                return null;
1119            }
1120        }
1121
1122        @Override
1123        protected void onPostExecute(String[] results) {
1124            if (results == null || isCancelled()) {
1125                if (mErrorLoadingMessageBody) {
1126                    Utility.showToast(getActivity(), R.string.error_loading_message_body);
1127                }
1128                resetView();
1129                return;
1130            }
1131            reloadUiFromBody(results[0], results[1], mAutoShowPictures);    // text, html
1132            onPostLoadBody();
1133        }
1134    }
1135
1136    /**
1137     * Async task for loading attachments
1138     *
1139     * Note:  This really should only be called when the message load is complete - or, we should
1140     * leave open a listener so the attachments can fill in as they are discovered.  In either case,
1141     * this implementation is incomplete, as it will fail to refresh properly if the message is
1142     * partially loaded at this time.
1143     */
1144    private class LoadAttachmentsTask extends EmailAsyncTask<Long, Void, Attachment[]> {
1145        public LoadAttachmentsTask() {
1146            super(mTaskTracker);
1147        }
1148
1149        @Override
1150        protected Attachment[] doInBackground(Long... messageIds) {
1151            return Attachment.restoreAttachmentsWithMessageId(mContext, messageIds[0]);
1152        }
1153
1154        @Override
1155        protected void onPostExecute(Attachment[] attachments) {
1156            try {
1157                if (isCancelled() || attachments == null) {
1158                    return;
1159                }
1160                boolean htmlChanged = false;
1161                int numDisplayedAttachments = 0;
1162                for (Attachment attachment : attachments) {
1163                    if (mHtmlTextRaw != null && attachment.mContentId != null
1164                            && attachment.mContentUri != null) {
1165                        // for html body, replace CID for inline images
1166                        // Regexp which matches ' src="cid:contentId"'.
1167                        String contentIdRe =
1168                            "\\s+(?i)src=\"cid(?-i):\\Q" + attachment.mContentId + "\\E\"";
1169                        String srcContentUri = " src=\"" + attachment.mContentUri + "\"";
1170                        mHtmlTextRaw = mHtmlTextRaw.replaceAll(contentIdRe, srcContentUri);
1171                        htmlChanged = true;
1172                    } else {
1173                        addAttachment(attachment);
1174                        numDisplayedAttachments++;
1175                    }
1176                }
1177                setAttachmentCount(numDisplayedAttachments);
1178                mHtmlTextWebView = mHtmlTextRaw;
1179                mHtmlTextRaw = null;
1180                if (htmlChanged) {
1181                    setMessageHtml(mHtmlTextWebView);
1182                }
1183            } finally {
1184                showContent(true, false);
1185            }
1186        }
1187    }
1188
1189    private static Bitmap getPreviewIcon(Context context, AttachmentInfo attachment) {
1190        try {
1191            return BitmapFactory.decodeStream(
1192                    context.getContentResolver().openInputStream(
1193                            AttachmentUtilities.getAttachmentThumbnailUri(
1194                                    attachment.mAccountKey, attachment.mId,
1195                                    PREVIEW_ICON_WIDTH,
1196                                    PREVIEW_ICON_HEIGHT)));
1197        } catch (Exception e) {
1198            Log.d(Logging.LOG_TAG, "Attachment preview failed with exception " + e.getMessage());
1199            return null;
1200        }
1201    }
1202
1203    /**
1204     * Subclass of AttachmentInfo which includes our views and buttons related to attachment
1205     * handling, as well as our determination of suitability for viewing (based on availability of
1206     * a viewer app) and saving (based upon the presence of external storage)
1207     */
1208    private static class MessageViewAttachmentInfo extends AttachmentInfo {
1209        private Button openButton;
1210        private Button saveButton;
1211        private Button loadButton;
1212        private Button infoButton;
1213        private Button cancelButton;
1214        private ImageView iconView;
1215
1216        private static final Map<AttachmentInfo, String> sSavedFileInfos = Maps.newHashMap();
1217
1218        // Don't touch it directly from the outer class.
1219        private final ProgressBar mProgressView;
1220        private boolean loaded;
1221
1222        private MessageViewAttachmentInfo(Context context, Attachment attachment,
1223                ProgressBar progressView) {
1224            super(context, attachment);
1225            mProgressView = progressView;
1226        }
1227
1228        /**
1229         * Create a new attachment info based upon an existing attachment info. Display
1230         * related fields (such as views and buttons) are copied from old to new.
1231         */
1232        private MessageViewAttachmentInfo(Context context, MessageViewAttachmentInfo oldInfo) {
1233            super(context, oldInfo);
1234            openButton = oldInfo.openButton;
1235            saveButton = oldInfo.saveButton;
1236            loadButton = oldInfo.loadButton;
1237            infoButton = oldInfo.infoButton;
1238            cancelButton = oldInfo.cancelButton;
1239            iconView = oldInfo.iconView;
1240            mProgressView = oldInfo.mProgressView;
1241            loaded = oldInfo.loaded;
1242        }
1243
1244        public void hideProgress() {
1245            // Don't use GONE, which'll break the layout.
1246            if (mProgressView.getVisibility() != View.INVISIBLE) {
1247                mProgressView.setVisibility(View.INVISIBLE);
1248            }
1249        }
1250
1251        public void showProgress(int progress) {
1252            if (mProgressView.getVisibility() != View.VISIBLE) {
1253                mProgressView.setVisibility(View.VISIBLE);
1254            }
1255            if (mProgressView.isIndeterminate()) {
1256                mProgressView.setIndeterminate(false);
1257            }
1258            mProgressView.setProgress(progress);
1259        }
1260
1261        public void showProgressIndeterminate() {
1262            if (mProgressView.getVisibility() != View.VISIBLE) {
1263                mProgressView.setVisibility(View.VISIBLE);
1264            }
1265            if (!mProgressView.isIndeterminate()) {
1266                mProgressView.setIndeterminate(true);
1267            }
1268        }
1269
1270        /**
1271         * Determines whether or not this attachment has a saved file in the external storage. That
1272         * is, the user has at some point clicked "save" for this attachment.
1273         *
1274         * Note: this is an approximation and uses an in-memory cache that can get wiped when the
1275         * process dies, and so is somewhat conservative. Additionally, the user can modify the file
1276         * after saving, and so the file may not be the same (though this is unlikely).
1277         */
1278        public boolean isFileSaved() {
1279            String path = getSavedPath();
1280            if (path == null) {
1281                return false;
1282            }
1283            boolean savedFileExists = new File(path).exists();
1284            if (!savedFileExists) {
1285                // Purge the cache entry.
1286                setSavedPath(null);
1287            }
1288            return savedFileExists;
1289        }
1290
1291        private void setSavedPath(String path) {
1292            if (path == null) {
1293                sSavedFileInfos.remove(this);
1294            } else {
1295                sSavedFileInfos.put(this, path);
1296            }
1297        }
1298
1299        /**
1300         * Returns an absolute file path for the given attachment if it has been saved. If one is
1301         * not found, {@code null} is returned.
1302         *
1303         * Clients are expected to validate that the file at the given path is still valid.
1304         */
1305        private String getSavedPath() {
1306            return sSavedFileInfos.get(this);
1307        }
1308
1309        @Override
1310        protected Uri getUriForIntent(Context context, long accountId) {
1311            // Prefer to act on the saved file for intents.
1312            String path = getSavedPath();
1313            return (path != null)
1314                    ? Uri.parse("file://" + getSavedPath())
1315                    : super.getUriForIntent(context, accountId);
1316        }
1317    }
1318
1319    /**
1320     * Updates all current attachments on the attachment tab.
1321     */
1322    private void updateAttachmentTab() {
1323        for (int i = 0, count = mAttachments.getChildCount(); i < count; i++) {
1324            View view = mAttachments.getChildAt(i);
1325            MessageViewAttachmentInfo oldInfo = (MessageViewAttachmentInfo)view.getTag();
1326            MessageViewAttachmentInfo newInfo =
1327                    new MessageViewAttachmentInfo(getActivity(), oldInfo);
1328            updateAttachmentButtons(newInfo);
1329            view.setTag(newInfo);
1330        }
1331    }
1332
1333    /**
1334     * Updates the attachment buttons. Adjusts the visibility of the buttons as well
1335     * as updating any tag information associated with the buttons.
1336     */
1337    private void updateAttachmentButtons(MessageViewAttachmentInfo attachmentInfo) {
1338        ImageView attachmentIcon = attachmentInfo.iconView;
1339        Button openButton = attachmentInfo.openButton;
1340        Button saveButton = attachmentInfo.saveButton;
1341        Button loadButton = attachmentInfo.loadButton;
1342        Button infoButton = attachmentInfo.infoButton;
1343        Button cancelButton = attachmentInfo.cancelButton;
1344
1345        if (!attachmentInfo.mAllowView) {
1346            openButton.setVisibility(View.GONE);
1347        }
1348        if (!attachmentInfo.mAllowSave) {
1349            saveButton.setVisibility(View.GONE);
1350        }
1351
1352        if (!attachmentInfo.mAllowView && !attachmentInfo.mAllowSave) {
1353            // This attachment may never be viewed or saved, so block everything
1354            attachmentInfo.hideProgress();
1355            openButton.setVisibility(View.GONE);
1356            saveButton.setVisibility(View.GONE);
1357            loadButton.setVisibility(View.GONE);
1358            cancelButton.setVisibility(View.GONE);
1359            infoButton.setVisibility(View.VISIBLE);
1360        } else if (attachmentInfo.loaded) {
1361            // If the attachment is loaded, show 100% progress
1362            // Note that for POP3 messages, the user will only see "Open" and "Save",
1363            // because the entire message is loaded before being shown.
1364            // Hide "Load" and "Info", show "View" and "Save"
1365            attachmentInfo.showProgress(100);
1366            if (attachmentInfo.mAllowSave) {
1367                saveButton.setVisibility(View.VISIBLE);
1368
1369                boolean isFileSaved = attachmentInfo.isFileSaved();
1370                saveButton.setEnabled(!isFileSaved);
1371                if (!isFileSaved) {
1372                    saveButton.setText(R.string.message_view_attachment_save_action);
1373                } else {
1374                    saveButton.setText(R.string.message_view_attachment_saved);
1375                }
1376            }
1377            if (attachmentInfo.mAllowView) {
1378                // Set the attachment action button text accordingly
1379                if (attachmentInfo.mContentType.startsWith("audio/") ||
1380                        attachmentInfo.mContentType.startsWith("video/")) {
1381                    openButton.setText(R.string.message_view_attachment_play_action);
1382                } else if (attachmentInfo.mAllowInstall) {
1383                    openButton.setText(R.string.message_view_attachment_install_action);
1384                } else {
1385                    openButton.setText(R.string.message_view_attachment_view_action);
1386                }
1387                openButton.setVisibility(View.VISIBLE);
1388            }
1389            if (attachmentInfo.mDenyFlags == AttachmentInfo.ALLOW) {
1390                infoButton.setVisibility(View.GONE);
1391            } else {
1392                infoButton.setVisibility(View.VISIBLE);
1393            }
1394            loadButton.setVisibility(View.GONE);
1395            cancelButton.setVisibility(View.GONE);
1396
1397            updatePreviewIcon(attachmentInfo);
1398        } else {
1399            // The attachment is not loaded, so present UI to start downloading it
1400
1401            // Show "Load"; hide "View", "Save" and "Info"
1402            saveButton.setVisibility(View.GONE);
1403            openButton.setVisibility(View.GONE);
1404            infoButton.setVisibility(View.GONE);
1405
1406            // If the attachment is queued, show the indeterminate progress bar.  From this point,.
1407            // any progress changes will cause this to be replaced by the normal progress bar
1408            if (AttachmentDownloadService.isAttachmentQueued(attachmentInfo.mId)) {
1409                attachmentInfo.showProgressIndeterminate();
1410                loadButton.setVisibility(View.GONE);
1411                cancelButton.setVisibility(View.VISIBLE);
1412            } else {
1413                loadButton.setVisibility(View.VISIBLE);
1414                cancelButton.setVisibility(View.GONE);
1415            }
1416        }
1417        openButton.setTag(attachmentInfo);
1418        saveButton.setTag(attachmentInfo);
1419        loadButton.setTag(attachmentInfo);
1420        infoButton.setTag(attachmentInfo);
1421        cancelButton.setTag(attachmentInfo);
1422    }
1423
1424    /**
1425     * Copy data from a cursor-refreshed attachment into the UI.  Called from UI thread.
1426     *
1427     * @param attachment A single attachment loaded from the provider
1428     */
1429    private void addAttachment(Attachment attachment) {
1430        LayoutInflater inflater = getActivity().getLayoutInflater();
1431        View view = inflater.inflate(R.layout.message_view_attachment, null);
1432
1433        TextView attachmentName = (TextView) UiUtilities.getView(view, R.id.attachment_name);
1434        TextView attachmentInfoView = (TextView) UiUtilities.getView(view, R.id.attachment_info);
1435        ImageView attachmentIcon = (ImageView) UiUtilities.getView(view, R.id.attachment_icon);
1436        Button openButton = (Button) UiUtilities.getView(view, R.id.open);
1437        Button saveButton = (Button) UiUtilities.getView(view, R.id.save);
1438        Button loadButton = (Button) UiUtilities.getView(view, R.id.load);
1439        Button infoButton = (Button) UiUtilities.getView(view, R.id.info);
1440        Button cancelButton = (Button) UiUtilities.getView(view, R.id.cancel);
1441        ProgressBar attachmentProgress = (ProgressBar) UiUtilities.getView(view, R.id.progress);
1442
1443        MessageViewAttachmentInfo attachmentInfo = new MessageViewAttachmentInfo(
1444                mContext, attachment, attachmentProgress);
1445
1446        // Check whether the attachment already exists
1447        if (Utility.attachmentExists(mContext, attachment)) {
1448            attachmentInfo.loaded = true;
1449        }
1450
1451        attachmentInfo.openButton = openButton;
1452        attachmentInfo.saveButton = saveButton;
1453        attachmentInfo.loadButton = loadButton;
1454        attachmentInfo.infoButton = infoButton;
1455        attachmentInfo.cancelButton = cancelButton;
1456        attachmentInfo.iconView = attachmentIcon;
1457
1458        updateAttachmentButtons(attachmentInfo);
1459
1460        view.setTag(attachmentInfo);
1461        openButton.setOnClickListener(this);
1462        saveButton.setOnClickListener(this);
1463        loadButton.setOnClickListener(this);
1464        infoButton.setOnClickListener(this);
1465        cancelButton.setOnClickListener(this);
1466
1467        attachmentName.setText(attachmentInfo.mName);
1468        attachmentInfoView.setText(UiUtilities.formatSize(mContext, attachmentInfo.mSize));
1469
1470        mAttachments.addView(view);
1471        mAttachments.setVisibility(View.VISIBLE);
1472    }
1473
1474    private MessageViewAttachmentInfo findAttachmentInfoFromView(long attachmentId) {
1475        for (int i = 0, count = mAttachments.getChildCount(); i < count; i++) {
1476            MessageViewAttachmentInfo attachmentInfo =
1477                    (MessageViewAttachmentInfo) mAttachments.getChildAt(i).getTag();
1478            if (attachmentInfo.mId == attachmentId) {
1479                return attachmentInfo;
1480            }
1481        }
1482        return null;
1483    }
1484
1485    /**
1486     * Reload the UI from a provider cursor.  {@link LoadMessageTask#onPostExecute} calls it.
1487     *
1488     * Update the header views, and start loading the body.
1489     *
1490     * @param message A copy of the message loaded from the database
1491     * @param okToFetch If true, and message is not fully loaded, it's OK to fetch from
1492     * the network.  Use false to prevent looping here.
1493     */
1494    protected void reloadUiFromMessage(Message message, boolean okToFetch) {
1495        mMessage = message;
1496        mAccountId = message.mAccountKey;
1497
1498        mMessageObserver.register(ContentUris.withAppendedId(Message.CONTENT_URI, mMessage.mId));
1499
1500        updateHeaderView(mMessage);
1501
1502        // Handle partially-loaded email, as follows:
1503        // 1. Check value of message.mFlagLoaded
1504        // 2. If != LOADED, ask controller to load it
1505        // 3. Controller callback (after loaded) should trigger LoadBodyTask & LoadAttachmentsTask
1506        // 4. Else start the loader tasks right away (message already loaded)
1507        if (okToFetch && message.mFlagLoaded != Message.FLAG_LOADED_COMPLETE) {
1508            mControllerCallback.getWrappee().setWaitForLoadMessageId(message.mId);
1509            mController.loadMessageForView(message.mId);
1510        } else {
1511            Address[] fromList = Address.unpack(mMessage.mFrom);
1512            boolean autoShowImages = false;
1513            for (Address sender : fromList) {
1514                String email = sender.getAddress();
1515                if (shouldShowImagesFor(email)) {
1516                    autoShowImages = true;
1517                    break;
1518                }
1519            }
1520            mControllerCallback.getWrappee().setWaitForLoadMessageId(Message.NO_MESSAGE);
1521            // Ask for body
1522            new LoadBodyTask(message.mId, autoShowImages).executeParallel();
1523        }
1524    }
1525
1526    protected void updateHeaderView(Message message) {
1527        mSubjectView.setText(message.mSubject);
1528        final Address from = Address.unpackFirst(message.mFrom);
1529
1530        // Set sender address/display name
1531        // Note we set " " for empty field, so TextView's won't get squashed.
1532        // Otherwise their height will be 0, which breaks the layout.
1533        if (from != null) {
1534            final String fromFriendly = from.toFriendly();
1535            final String fromAddress = from.getAddress();
1536            mFromNameView.setText(fromFriendly);
1537            mFromAddressView.setText(fromFriendly.equals(fromAddress) ? " " : fromAddress);
1538        } else {
1539            mFromNameView.setText(" ");
1540            mFromAddressView.setText(" ");
1541        }
1542        mDateTimeView.setText(DateUtils.getRelativeTimeSpanString(mContext, message.mTimeStamp)
1543                .toString());
1544
1545        // To/Cc/Bcc
1546        final Resources res = mContext.getResources();
1547        final SpannableStringBuilder ssb = new SpannableStringBuilder();
1548        final String friendlyTo = Address.toFriendly(Address.unpack(message.mTo));
1549        final String friendlyCc = Address.toFriendly(Address.unpack(message.mCc));
1550        final String friendlyBcc = Address.toFriendly(Address.unpack(message.mBcc));
1551
1552        if (!TextUtils.isEmpty(friendlyTo)) {
1553            Utility.appendBold(ssb, res.getString(R.string.message_view_to_label));
1554            ssb.append(" ");
1555            ssb.append(friendlyTo);
1556        }
1557        if (!TextUtils.isEmpty(friendlyCc)) {
1558            ssb.append("  ");
1559            Utility.appendBold(ssb, res.getString(R.string.message_view_cc_label));
1560            ssb.append(" ");
1561            ssb.append(friendlyCc);
1562        }
1563        if (!TextUtils.isEmpty(friendlyBcc)) {
1564            ssb.append("  ");
1565            Utility.appendBold(ssb, res.getString(R.string.message_view_bcc_label));
1566            ssb.append(" ");
1567            ssb.append(friendlyBcc);
1568        }
1569        mAddressesView.setText(ssb);
1570    }
1571
1572    /**
1573     * @return the given date/time in a human readable form.  The returned string always have
1574     *     month and day (and year if {@code withYear} is set), so is usually long.
1575     *     Use {@link DateUtils#getRelativeTimeSpanString} instead to save the screen real estate.
1576     */
1577    private String formatDate(long millis, boolean withYear) {
1578        StringBuilder sb = new StringBuilder();
1579        Formatter formatter = new Formatter(sb);
1580        DateUtils.formatDateRange(mContext, formatter, millis, millis,
1581                DateUtils.FORMAT_SHOW_DATE
1582                | DateUtils.FORMAT_ABBREV_ALL
1583                | DateUtils.FORMAT_SHOW_TIME
1584                | (withYear ? DateUtils.FORMAT_SHOW_YEAR : DateUtils.FORMAT_NO_YEAR));
1585        return sb.toString();
1586    }
1587
1588    /**
1589     * Reload the body from the provider cursor.  This must only be called from the UI thread.
1590     *
1591     * @param bodyText text part
1592     * @param bodyHtml html part
1593     *
1594     * TODO deal with html vs text and many other issues <- WHAT DOES IT MEAN??
1595     */
1596    private void reloadUiFromBody(String bodyText, String bodyHtml, boolean autoShowPictures) {
1597        String text = null;
1598        mHtmlTextRaw = null;
1599        boolean hasImages = false;
1600
1601        if (bodyHtml == null) {
1602            text = bodyText;
1603            /*
1604             * Convert the plain text to HTML
1605             */
1606            StringBuffer sb = new StringBuffer("<html><body>");
1607            if (text != null) {
1608                // Escape any inadvertent HTML in the text message
1609                text = EmailHtmlUtil.escapeCharacterToDisplay(text);
1610                // Find any embedded URL's and linkify
1611                Matcher m = Patterns.WEB_URL.matcher(text);
1612                while (m.find()) {
1613                    int start = m.start();
1614                    /*
1615                     * WEB_URL_PATTERN may match domain part of email address. To detect
1616                     * this false match, the character just before the matched string
1617                     * should not be '@'.
1618                     */
1619                    if (start == 0 || text.charAt(start - 1) != '@') {
1620                        String url = m.group();
1621                        Matcher proto = WEB_URL_PROTOCOL.matcher(url);
1622                        String link;
1623                        if (proto.find()) {
1624                            // This is work around to force URL protocol part be lower case,
1625                            // because WebView could follow only lower case protocol link.
1626                            link = proto.group().toLowerCase() + url.substring(proto.end());
1627                        } else {
1628                            // Patterns.WEB_URL matches URL without protocol part,
1629                            // so added default protocol to link.
1630                            link = "http://" + url;
1631                        }
1632                        String href = String.format("<a href=\"%s\">%s</a>", link, url);
1633                        m.appendReplacement(sb, href);
1634                    }
1635                    else {
1636                        m.appendReplacement(sb, "$0");
1637                    }
1638                }
1639                m.appendTail(sb);
1640            }
1641            sb.append("</body></html>");
1642            text = sb.toString();
1643        } else {
1644            text = bodyHtml;
1645            mHtmlTextRaw = bodyHtml;
1646            hasImages = IMG_TAG_START_REGEX.matcher(text).find();
1647        }
1648
1649        // TODO this is not really accurate.
1650        // - Images aren't the only network resources.  (e.g. CSS)
1651        // - If images are attached to the email and small enough, we download them at once,
1652        //   and won't need network access when they're shown.
1653        if (hasImages) {
1654            if (mRestoredPictureLoaded || autoShowPictures) {
1655                blockNetworkLoads(false);
1656                addTabFlags(TAB_FLAGS_PICTURE_LOADED); // Set for next onSaveInstanceState
1657
1658                // Make sure to reset the flag -- otherwise this will keep taking effect even after
1659                // moving to another message.
1660                mRestoredPictureLoaded = false;
1661            } else {
1662                addTabFlags(TAB_FLAGS_HAS_PICTURES);
1663            }
1664        }
1665        setMessageHtml(text);
1666
1667        // Ask for attachments after body
1668        new LoadAttachmentsTask().executeParallel(mMessage.mId);
1669
1670        mIsMessageLoadedForTest = true;
1671    }
1672
1673    /**
1674     * Overrides for WebView behaviors.
1675     */
1676    private class CustomWebViewClient extends WebViewClient {
1677        @Override
1678        public boolean shouldOverrideUrlLoading(WebView view, String url) {
1679            return mCallback.onUrlInMessageClicked(url);
1680        }
1681    }
1682
1683    private View findAttachmentView(long attachmentId) {
1684        for (int i = 0, count = mAttachments.getChildCount(); i < count; i++) {
1685            View view = mAttachments.getChildAt(i);
1686            MessageViewAttachmentInfo attachment = (MessageViewAttachmentInfo) view.getTag();
1687            if (attachment.mId == attachmentId) {
1688                return view;
1689            }
1690        }
1691        return null;
1692    }
1693
1694    private MessageViewAttachmentInfo findAttachmentInfo(long attachmentId) {
1695        View view = findAttachmentView(attachmentId);
1696        if (view != null) {
1697            return (MessageViewAttachmentInfo)view.getTag();
1698        }
1699        return null;
1700    }
1701
1702    /**
1703     * Controller results listener.  We wrap it with {@link ControllerResultUiThreadWrapper},
1704     * so all methods are called on the UI thread.
1705     */
1706    private class ControllerResults extends Controller.Result {
1707        private long mWaitForLoadMessageId;
1708
1709        public void setWaitForLoadMessageId(long messageId) {
1710            mWaitForLoadMessageId = messageId;
1711        }
1712
1713        @Override
1714        public void loadMessageForViewCallback(MessagingException result, long accountId,
1715                long messageId, int progress) {
1716            if (messageId != mWaitForLoadMessageId) {
1717                // We are not waiting for this message to load, so exit quickly
1718                return;
1719            }
1720            if (result == null) {
1721                switch (progress) {
1722                    case 0:
1723                        mCallback.onLoadMessageStarted();
1724                        // Loading from network -- show the progress icon.
1725                        showContent(false, true);
1726                        break;
1727                    case 100:
1728                        mWaitForLoadMessageId = -1;
1729                        mCallback.onLoadMessageFinished();
1730                        // reload UI and reload everything else too
1731                        // pass false to LoadMessageTask to prevent looping here
1732                        cancelAllTasks();
1733                        new LoadMessageTask(false).executeParallel();
1734                        break;
1735                    default:
1736                        // do nothing - we don't have a progress bar at this time
1737                        break;
1738                }
1739            } else {
1740                mWaitForLoadMessageId = Message.NO_MESSAGE;
1741                String error = mContext.getString(R.string.status_network_error);
1742                mCallback.onLoadMessageError(error);
1743                resetView();
1744            }
1745        }
1746
1747        @Override
1748        public void loadAttachmentCallback(MessagingException result, long accountId,
1749                long messageId, long attachmentId, int progress) {
1750            if (messageId == mMessageId) {
1751                if (result == null) {
1752                    showAttachmentProgress(attachmentId, progress);
1753                    switch (progress) {
1754                        case 100:
1755                            final MessageViewAttachmentInfo attachmentInfo =
1756                                    findAttachmentInfoFromView(attachmentId);
1757                            if (attachmentInfo != null) {
1758                                updatePreviewIcon(attachmentInfo);
1759                            }
1760                            doFinishLoadAttachment(attachmentId);
1761                            break;
1762                        default:
1763                            // do nothing - we don't have a progress bar at this time
1764                            break;
1765                    }
1766                } else {
1767                    MessageViewAttachmentInfo attachment = findAttachmentInfo(attachmentId);
1768                    if (attachment == null) {
1769                        // Called before LoadAttachmentsTask finishes.
1770                        // (Possible if you quickly close & re-open a message)
1771                        return;
1772                    }
1773                    attachment.cancelButton.setVisibility(View.GONE);
1774                    attachment.loadButton.setVisibility(View.VISIBLE);
1775                    attachment.hideProgress();
1776
1777                    final String error;
1778                    if (result.getCause() instanceof IOException) {
1779                        error = mContext.getString(R.string.status_network_error);
1780                    } else {
1781                        error = mContext.getString(
1782                                R.string.message_view_load_attachment_failed_toast,
1783                                attachment.mName);
1784                    }
1785                    mCallback.onLoadMessageError(error);
1786                }
1787            }
1788        }
1789
1790        private void showAttachmentProgress(long attachmentId, int progress) {
1791            MessageViewAttachmentInfo attachment = findAttachmentInfo(attachmentId);
1792            if (attachment != null) {
1793                if (progress == 0) {
1794                    attachment.cancelButton.setVisibility(View.GONE);
1795                }
1796                attachment.showProgress(progress);
1797            }
1798        }
1799    }
1800
1801    /**
1802     * Class to detect update on the current message (e.g. toggle star).  When it gets content
1803     * change notifications, it kicks {@link ReloadMessageTask}.
1804     */
1805    private class MessageObserver extends ContentObserver implements Runnable {
1806        private final Throttle mThrottle;
1807        private final ContentResolver mContentResolver;
1808
1809        private boolean mRegistered;
1810
1811        public MessageObserver(Handler handler, Context context) {
1812            super(handler);
1813            mContentResolver = context.getContentResolver();
1814            mThrottle = new Throttle("MessageObserver", this, handler);
1815        }
1816
1817        public void unregister() {
1818            if (!mRegistered) {
1819                return;
1820            }
1821            mThrottle.cancelScheduledCallback();
1822            mContentResolver.unregisterContentObserver(this);
1823            mRegistered = false;
1824        }
1825
1826        public void register(Uri notifyUri) {
1827            unregister();
1828            mContentResolver.registerContentObserver(notifyUri, true, this);
1829            mRegistered = true;
1830        }
1831
1832        @Override
1833        public boolean deliverSelfNotifications() {
1834            return true;
1835        }
1836
1837        @Override
1838        public void onChange(boolean selfChange) {
1839            mThrottle.onEvent();
1840        }
1841
1842        /** This method is delay-called by {@link Throttle} on the UI thread. */
1843        @Override
1844        public void run() {
1845            // This method is delay-called, so need to make sure if it's still registered.
1846            if (mRegistered) {
1847                new ReloadMessageTask().cancelPreviousAndExecuteParallel();
1848            }
1849        }
1850    }
1851
1852    private void updatePreviewIcon(MessageViewAttachmentInfo attachmentInfo) {
1853        new UpdatePreviewIconTask(attachmentInfo).executeParallel();
1854    }
1855
1856    private class UpdatePreviewIconTask extends EmailAsyncTask<Void, Void, Bitmap> {
1857        @SuppressWarnings("hiding")
1858        private final Context mContext;
1859        private final MessageViewAttachmentInfo mAttachmentInfo;
1860
1861        public UpdatePreviewIconTask(MessageViewAttachmentInfo attachmentInfo) {
1862            super(mTaskTracker);
1863            mContext = getActivity();
1864            mAttachmentInfo = attachmentInfo;
1865        }
1866
1867        @Override
1868        protected Bitmap doInBackground(Void... params) {
1869            return getPreviewIcon(mContext, mAttachmentInfo);
1870        }
1871
1872        @Override
1873        protected void onPostExecute(Bitmap result) {
1874            if (result == null) {
1875                return;
1876            }
1877            mAttachmentInfo.iconView.setImageBitmap(result);
1878        }
1879    }
1880
1881    private boolean shouldShowImagesFor(String senderEmail) {
1882        return Preferences.getPreferences(getActivity()).shouldShowImagesFor(senderEmail);
1883    }
1884
1885    private void setShowImagesForSender() {
1886        makeVisible(UiUtilities.getView(getView(), R.id.always_show_pictures_container), false);
1887
1888        // Force redraw of the container.
1889        updateTabs(mTabFlags);
1890
1891        Address[] fromList = Address.unpack(mMessage.mFrom);
1892        Preferences prefs = Preferences.getPreferences(getActivity());
1893        for (Address sender : fromList) {
1894            String email = sender.getAddress();
1895            prefs.setSenderAsTrusted(email);
1896        }
1897    }
1898
1899    public boolean isMessageLoadedForTest() {
1900        return mIsMessageLoadedForTest;
1901    }
1902
1903    public void clearIsMessageLoadedForTest() {
1904        mIsMessageLoadedForTest = true;
1905    }
1906}
1907