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