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