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