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