1/* 2 * Copyright (C) 2008 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.Email; 21import com.android.email.R; 22import com.android.email.Utility; 23import com.android.email.mail.Address; 24import com.android.email.mail.MeetingInfo; 25import com.android.email.mail.MessagingException; 26import com.android.email.mail.PackedString; 27import com.android.email.mail.internet.EmailHtmlUtil; 28import com.android.email.mail.internet.MimeUtility; 29import com.android.email.provider.AttachmentProvider; 30import com.android.email.provider.EmailContent; 31import com.android.email.provider.EmailContent.Attachment; 32import com.android.email.provider.EmailContent.Body; 33import com.android.email.provider.EmailContent.BodyColumns; 34import com.android.email.provider.EmailContent.Message; 35import com.android.email.service.EmailServiceConstants; 36 37import org.apache.commons.io.IOUtils; 38 39import android.app.Activity; 40import android.app.ProgressDialog; 41import android.content.ActivityNotFoundException; 42import android.content.ContentResolver; 43import android.content.Context; 44import android.content.Intent; 45import android.database.ContentObserver; 46import android.database.Cursor; 47import android.graphics.Bitmap; 48import android.graphics.BitmapFactory; 49import android.graphics.drawable.Drawable; 50import android.media.MediaScannerConnection; 51import android.media.MediaScannerConnection.MediaScannerConnectionClient; 52import android.net.Uri; 53import android.os.AsyncTask; 54import android.os.Bundle; 55import android.os.Environment; 56import android.os.Handler; 57import android.provider.Browser; 58import android.provider.ContactsContract; 59import android.provider.ContactsContract.CommonDataKinds; 60import android.provider.ContactsContract.Contacts; 61import android.provider.ContactsContract.QuickContact; 62import android.provider.ContactsContract.StatusUpdates; 63import android.text.TextUtils; 64import android.util.Log; 65import android.util.Patterns; 66import android.view.LayoutInflater; 67import android.view.Menu; 68import android.view.MenuItem; 69import android.view.View; 70import android.view.View.OnClickListener; 71import android.webkit.WebView; 72import android.webkit.WebViewClient; 73import android.widget.Button; 74import android.widget.ImageView; 75import android.widget.LinearLayout; 76import android.widget.TextView; 77import android.widget.Toast; 78 79import java.io.File; 80import java.io.FileOutputStream; 81import java.io.IOException; 82import java.io.InputStream; 83import java.io.OutputStream; 84import java.util.Date; 85import java.util.regex.Matcher; 86import java.util.regex.Pattern; 87 88public class MessageView extends Activity implements OnClickListener { 89 private static final String EXTRA_MESSAGE_ID = "com.android.email.MessageView_message_id"; 90 private static final String EXTRA_MAILBOX_ID = "com.android.email.MessageView_mailbox_id"; 91 /* package */ static final String EXTRA_DISABLE_REPLY = "com.android.email.MessageView_disable_reply"; 92 93 // for saveInstanceState() 94 private static final String STATE_MESSAGE_ID = "messageId"; 95 96 // Regex that matches start of img tag. '<(?i)img\s+'. 97 private static final Pattern IMG_TAG_START_REGEX = Pattern.compile("<(?i)img\\s+"); 98 // Regex that matches Web URL protocol part as case insensitive. 99 private static final Pattern WEB_URL_PROTOCOL = Pattern.compile("(?i)http|https://"); 100 101 // Support for LoadBodyTask 102 private static final String[] BODY_CONTENT_PROJECTION = new String[] { 103 Body.RECORD_ID, BodyColumns.MESSAGE_KEY, 104 BodyColumns.HTML_CONTENT, BodyColumns.TEXT_CONTENT 105 }; 106 107 private static final String[] PRESENCE_STATUS_PROJECTION = 108 new String[] { Contacts.CONTACT_PRESENCE }; 109 110 private static final int BODY_CONTENT_COLUMN_RECORD_ID = 0; 111 private static final int BODY_CONTENT_COLUMN_MESSAGE_KEY = 1; 112 private static final int BODY_CONTENT_COLUMN_HTML_CONTENT = 2; 113 private static final int BODY_CONTENT_COLUMN_TEXT_CONTENT = 3; 114 115 private TextView mSubjectView; 116 private TextView mFromView; 117 private TextView mDateView; 118 private TextView mTimeView; 119 private TextView mToView; 120 private TextView mCcView; 121 private View mCcContainerView; 122 private WebView mMessageContentView; 123 private LinearLayout mAttachments; 124 private ImageView mAttachmentIcon; 125 private ImageView mFavoriteIcon; 126 private View mShowPicturesSection; 127 private View mInviteSection; 128 private ImageView mSenderPresenceView; 129 private ProgressDialog mProgressDialog; 130 private View mScrollView; 131 132 // calendar meeting invite answers 133 private TextView mMeetingYes; 134 private TextView mMeetingMaybe; 135 private TextView mMeetingNo; 136 private int mPreviousMeetingResponse = -1; 137 138 private long mAccountId; 139 private long mMessageId; 140 private long mMailboxId; 141 private Message mMessage; 142 private long mWaitForLoadMessageId; 143 144 private LoadMessageTask mLoadMessageTask; 145 private LoadBodyTask mLoadBodyTask; 146 private LoadAttachmentsTask mLoadAttachmentsTask; 147 private PresenceCheckTask mPresenceCheckTask; 148 149 private long mLoadAttachmentId; // the attachment being saved/viewed 150 private boolean mLoadAttachmentSave; // if true, saving - if false, viewing 151 private String mLoadAttachmentName; // the display name 152 153 private java.text.DateFormat mDateFormat; 154 private java.text.DateFormat mTimeFormat; 155 156 private Drawable mFavoriteIconOn; 157 private Drawable mFavoriteIconOff; 158 159 private MessageViewHandler mHandler; 160 private Controller mController; 161 private ControllerResults mControllerCallback; 162 163 private View mMoveToNewer; 164 private View mMoveToOlder; 165 private LoadMessageListTask mLoadMessageListTask; 166 private Cursor mMessageListCursor; 167 private ContentObserver mCursorObserver; 168 169 // contains the HTML body. Is used by LoadAttachmentTask to display inline images. 170 // is null most of the time, is used transiently to pass info to LoadAttachementTask 171 private String mHtmlTextRaw; 172 173 // contains the HTML content as set in WebView. 174 private String mHtmlTextWebView; 175 176 // this is true when reply & forward are disabled, such as messages in the trash 177 private boolean mDisableReplyAndForward; 178 179 private class MessageViewHandler extends Handler { 180 private static final int MSG_PROGRESS = 1; 181 private static final int MSG_ATTACHMENT_PROGRESS = 2; 182 private static final int MSG_LOAD_CONTENT_URI = 3; 183 private static final int MSG_SET_ATTACHMENTS_ENABLED = 4; 184 private static final int MSG_LOAD_BODY_ERROR = 5; 185 private static final int MSG_NETWORK_ERROR = 6; 186 private static final int MSG_FETCHING_ATTACHMENT = 10; 187 private static final int MSG_VIEW_ATTACHMENT_ERROR = 12; 188 private static final int MSG_UPDATE_ATTACHMENT_ICON = 18; 189 private static final int MSG_FINISH_LOAD_ATTACHMENT = 19; 190 191 @Override 192 public void handleMessage(android.os.Message msg) { 193 switch (msg.what) { 194 case MSG_PROGRESS: 195 setProgressBarIndeterminateVisibility(msg.arg1 != 0); 196 break; 197 case MSG_ATTACHMENT_PROGRESS: 198 boolean progress = (msg.arg1 != 0); 199 if (progress) { 200 mProgressDialog.setMessage( 201 getString(R.string.message_view_fetching_attachment_progress, 202 mLoadAttachmentName)); 203 mProgressDialog.show(); 204 } else { 205 mProgressDialog.dismiss(); 206 } 207 setProgressBarIndeterminateVisibility(progress); 208 break; 209 case MSG_LOAD_CONTENT_URI: 210 String uriString = (String) msg.obj; 211 if (mMessageContentView != null) { 212 mMessageContentView.loadUrl(uriString); 213 } 214 break; 215 case MSG_SET_ATTACHMENTS_ENABLED: 216 for (int i = 0, count = mAttachments.getChildCount(); i < count; i++) { 217 AttachmentInfo attachment = 218 (AttachmentInfo) mAttachments.getChildAt(i).getTag(); 219 attachment.viewButton.setEnabled(msg.arg1 == 1); 220 attachment.downloadButton.setEnabled(msg.arg1 == 1); 221 } 222 break; 223 case MSG_LOAD_BODY_ERROR: 224 Toast.makeText(MessageView.this, 225 R.string.error_loading_message_body, Toast.LENGTH_LONG).show(); 226 break; 227 case MSG_NETWORK_ERROR: 228 Toast.makeText(MessageView.this, 229 R.string.status_network_error, Toast.LENGTH_LONG).show(); 230 break; 231 case MSG_FETCHING_ATTACHMENT: 232 Toast.makeText(MessageView.this, 233 getString(R.string.message_view_fetching_attachment_toast), 234 Toast.LENGTH_SHORT).show(); 235 break; 236 case MSG_VIEW_ATTACHMENT_ERROR: 237 Toast.makeText(MessageView.this, 238 getString(R.string.message_view_display_attachment_toast), 239 Toast.LENGTH_SHORT).show(); 240 break; 241 case MSG_UPDATE_ATTACHMENT_ICON: 242 ((AttachmentInfo) mAttachments.getChildAt(msg.arg1).getTag()) 243 .iconView.setImageBitmap((Bitmap) msg.obj); 244 break; 245 case MSG_FINISH_LOAD_ATTACHMENT: 246 long attachmentId = (Long)msg.obj; 247 doFinishLoadAttachment(attachmentId); 248 break; 249 default: 250 super.handleMessage(msg); 251 } 252 } 253 254 public void attachmentProgress(boolean progress) { 255 android.os.Message msg = android.os.Message.obtain(this, MSG_ATTACHMENT_PROGRESS); 256 msg.arg1 = progress ? 1 : 0; 257 sendMessage(msg); 258 } 259 260 public void progress(boolean progress) { 261 android.os.Message msg = android.os.Message.obtain(this, MSG_PROGRESS); 262 msg.arg1 = progress ? 1 : 0; 263 sendMessage(msg); 264 } 265 266 public void loadContentUri(String uriString) { 267 android.os.Message msg = android.os.Message.obtain(this, MSG_LOAD_CONTENT_URI); 268 msg.obj = uriString; 269 sendMessage(msg); 270 } 271 272 public void setAttachmentsEnabled(boolean enabled) { 273 android.os.Message msg = android.os.Message.obtain(this, MSG_SET_ATTACHMENTS_ENABLED); 274 msg.arg1 = enabled ? 1 : 0; 275 sendMessage(msg); 276 } 277 278 public void loadBodyError() { 279 sendEmptyMessage(MSG_LOAD_BODY_ERROR); 280 } 281 282 public void networkError() { 283 sendEmptyMessage(MSG_NETWORK_ERROR); 284 } 285 286 public void fetchingAttachment() { 287 sendEmptyMessage(MSG_FETCHING_ATTACHMENT); 288 } 289 290 public void attachmentViewError() { 291 sendEmptyMessage(MSG_VIEW_ATTACHMENT_ERROR); 292 } 293 294 public void updateAttachmentIcon(int pos, Bitmap icon) { 295 android.os.Message msg = android.os.Message.obtain(this, MSG_UPDATE_ATTACHMENT_ICON); 296 msg.arg1 = pos; 297 msg.obj = icon; 298 sendMessage(msg); 299 } 300 301 public void finishLoadAttachment(long attachmentId) { 302 android.os.Message msg = android.os.Message.obtain(this, MSG_FINISH_LOAD_ATTACHMENT); 303 msg.obj = Long.valueOf(attachmentId); 304 sendMessage(msg); 305 } 306 } 307 308 /** 309 * Encapsulates known information about a single attachment. 310 */ 311 private static class AttachmentInfo { 312 public String name; 313 public String contentType; 314 public long size; 315 public long attachmentId; 316 public Button viewButton; 317 public Button downloadButton; 318 public ImageView iconView; 319 } 320 321 /** 322 * View a specific message found in the Email provider. 323 * @param messageId the message to view. 324 * @param mailboxId identifies the sequence of messages used for newer/older navigation. 325 * @param disableReplyAndForward set if reply/forward do not make sense for this message 326 * (e.g. messages in Trash). 327 */ 328 public static void actionView(Context context, long messageId, long mailboxId, 329 boolean disableReplyAndForward) { 330 if (messageId < 0) { 331 throw new IllegalArgumentException("MessageView invalid messageId " + messageId); 332 } 333 Intent i = new Intent(context, MessageView.class); 334 i.putExtra(EXTRA_MESSAGE_ID, messageId); 335 i.putExtra(EXTRA_MAILBOX_ID, mailboxId); 336 i.putExtra(EXTRA_DISABLE_REPLY, disableReplyAndForward); 337 context.startActivity(i); 338 } 339 340 public static void actionView(Context context, long messageId, long mailboxId) { 341 actionView(context, messageId, mailboxId, false); 342 } 343 344 @Override 345 public void onCreate(Bundle icicle) { 346 super.onCreate(icicle); 347 setContentView(R.layout.message_view); 348 349 mHandler = new MessageViewHandler(); 350 mControllerCallback = new ControllerResults(); 351 352 mSubjectView = (TextView) findViewById(R.id.subject); 353 mFromView = (TextView) findViewById(R.id.from); 354 mToView = (TextView) findViewById(R.id.to); 355 mCcView = (TextView) findViewById(R.id.cc); 356 mCcContainerView = findViewById(R.id.cc_container); 357 mDateView = (TextView) findViewById(R.id.date); 358 mTimeView = (TextView) findViewById(R.id.time); 359 mMessageContentView = (WebView) findViewById(R.id.message_content); 360 mAttachments = (LinearLayout) findViewById(R.id.attachments); 361 mAttachmentIcon = (ImageView) findViewById(R.id.attachment); 362 mFavoriteIcon = (ImageView) findViewById(R.id.favorite); 363 mShowPicturesSection = findViewById(R.id.show_pictures_section); 364 mInviteSection = findViewById(R.id.invite_section); 365 mSenderPresenceView = (ImageView) findViewById(R.id.presence); 366 mMoveToNewer = findViewById(R.id.moveToNewer); 367 mMoveToOlder = findViewById(R.id.moveToOlder); 368 mScrollView = findViewById(R.id.scrollview); 369 370 mMoveToNewer.setOnClickListener(this); 371 mMoveToOlder.setOnClickListener(this); 372 mFromView.setOnClickListener(this); 373 mSenderPresenceView.setOnClickListener(this); 374 mFavoriteIcon.setOnClickListener(this); 375 findViewById(R.id.reply).setOnClickListener(this); 376 findViewById(R.id.reply_all).setOnClickListener(this); 377 findViewById(R.id.delete).setOnClickListener(this); 378 findViewById(R.id.show_pictures).setOnClickListener(this); 379 380 mMeetingYes = (TextView) findViewById(R.id.accept); 381 mMeetingMaybe = (TextView) findViewById(R.id.maybe); 382 mMeetingNo = (TextView) findViewById(R.id.decline); 383 384 mMeetingYes.setOnClickListener(this); 385 mMeetingMaybe.setOnClickListener(this); 386 mMeetingNo.setOnClickListener(this); 387 findViewById(R.id.invite_link).setOnClickListener(this); 388 389 mMessageContentView.setClickable(true); 390 mMessageContentView.setLongClickable(false); // Conflicts with ScrollView, unfortunately 391 mMessageContentView.setVerticalScrollBarEnabled(false); 392 mMessageContentView.getSettings().setBlockNetworkLoads(true); 393 mMessageContentView.getSettings().setSupportZoom(false); 394 mMessageContentView.setWebViewClient(new CustomWebViewClient()); 395 396 mProgressDialog = new ProgressDialog(this); 397 mProgressDialog.setIndeterminate(true); 398 mProgressDialog.setProgressStyle(ProgressDialog.STYLE_SPINNER); 399 400 mDateFormat = android.text.format.DateFormat.getDateFormat(this); // short format 401 mTimeFormat = android.text.format.DateFormat.getTimeFormat(this); // 12/24 date format 402 403 mFavoriteIconOn = getResources().getDrawable(R.drawable.btn_star_big_buttonless_on); 404 mFavoriteIconOff = getResources().getDrawable(R.drawable.btn_star_big_buttonless_off); 405 406 initFromIntent(); 407 if (icicle != null) { 408 mMessageId = icicle.getLong(STATE_MESSAGE_ID, mMessageId); 409 } 410 411 mController = Controller.getInstance(getApplication()); 412 413 // This observer is used to watch for external changes to the message list 414 mCursorObserver = new ContentObserver(mHandler){ 415 @Override 416 public void onChange(boolean selfChange) { 417 // get a new message list cursor, but only if we already had one 418 // (otherwise it's "too soon" and other pathways will cause it to be loaded) 419 if (mLoadMessageListTask == null && mMessageListCursor != null) { 420 mLoadMessageListTask = new LoadMessageListTask(mMailboxId); 421 mLoadMessageListTask.execute(); 422 } 423 } 424 }; 425 426 messageChanged(); 427 } 428 429 /* package */ void initFromIntent() { 430 Intent intent = getIntent(); 431 mMessageId = intent.getLongExtra(EXTRA_MESSAGE_ID, -1); 432 mMailboxId = intent.getLongExtra(EXTRA_MAILBOX_ID, -1); 433 mDisableReplyAndForward = intent.getBooleanExtra(EXTRA_DISABLE_REPLY, false); 434 if (mDisableReplyAndForward) { 435 findViewById(R.id.reply).setEnabled(false); 436 findViewById(R.id.reply_all).setEnabled(false); 437 } 438 } 439 440 @Override 441 protected void onSaveInstanceState(Bundle state) { 442 super.onSaveInstanceState(state); 443 if (mMessageId != -1) { 444 state.putLong(STATE_MESSAGE_ID, mMessageId); 445 } 446 } 447 448 @Override 449 public void onResume() { 450 super.onResume(); 451 mWaitForLoadMessageId = -1; 452 mController.addResultCallback(mControllerCallback); 453 454 // Exit immediately if the accounts list has changed (e.g. externally deleted) 455 if (Email.getNotifyUiAccountsChanged()) { 456 Welcome.actionStart(this); 457 finish(); 458 return; 459 } 460 461 if (mMessage != null) { 462 startPresenceCheck(); 463 464 // get a new message list cursor, but only if mailbox is set 465 // (otherwise it's "too soon" and other pathways will cause it to be loaded) 466 if (mLoadMessageListTask == null && mMailboxId != -1) { 467 mLoadMessageListTask = new LoadMessageListTask(mMailboxId); 468 mLoadMessageListTask.execute(); 469 } 470 } 471 } 472 473 @Override 474 public void onPause() { 475 super.onPause(); 476 mController.removeResultCallback(mControllerCallback); 477 closeMessageListCursor(); 478 } 479 480 private void closeMessageListCursor() { 481 if (mMessageListCursor != null) { 482 mMessageListCursor.unregisterContentObserver(mCursorObserver); 483 mMessageListCursor.close(); 484 mMessageListCursor = null; 485 } 486 } 487 488 private void cancelAllTasks() { 489 Utility.cancelTaskInterrupt(mLoadMessageTask); 490 mLoadMessageTask = null; 491 Utility.cancelTaskInterrupt(mLoadBodyTask); 492 mLoadBodyTask = null; 493 Utility.cancelTaskInterrupt(mLoadAttachmentsTask); 494 mLoadAttachmentsTask = null; 495 Utility.cancelTaskInterrupt(mLoadMessageListTask); 496 mLoadMessageListTask = null; 497 Utility.cancelTaskInterrupt(mPresenceCheckTask); 498 mPresenceCheckTask = null; 499 } 500 501 /** 502 * We override onDestroy to make sure that the WebView gets explicitly destroyed. 503 * Otherwise it can leak native references. 504 */ 505 @Override 506 public void onDestroy() { 507 super.onDestroy(); 508 cancelAllTasks(); 509 // This is synchronized because the listener accesses mMessageContentView from its thread 510 synchronized (this) { 511 mMessageContentView.destroy(); 512 mMessageContentView = null; 513 } 514 // the cursor was closed in onPause() 515 } 516 517 private void onDelete() { 518 if (mMessage != null) { 519 // the delete triggers mCursorObserver 520 // first move to older/newer before the actual delete 521 long messageIdToDelete = mMessageId; 522 boolean moved = moveToOlder() || moveToNewer(); 523 mController.deleteMessage(messageIdToDelete, mMessage.mAccountKey); 524 Toast.makeText(this, getResources().getQuantityString(R.plurals.message_deleted_toast, 525 1), Toast.LENGTH_SHORT).show(); 526 if (!moved) { 527 // this generates a benign warning "Duplicate finish request" because 528 // repositionMessageListCursor() will fail to reposition and do its own finish() 529 finish(); 530 } 531 } 532 } 533 534 /** 535 * Overrides for various WebView behaviors. 536 */ 537 private class CustomWebViewClient extends WebViewClient { 538 /** 539 * This is intended to mirror the operation of the original 540 * (see android.webkit.CallbackProxy) with one addition of intent flags 541 * "FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET". This improves behavior when sublaunching 542 * other apps via embedded URI's. 543 * 544 * We also use this hook to catch "mailto:" links and handle them locally. 545 */ 546 @Override 547 public boolean shouldOverrideUrlLoading(WebView view, String url) { 548 // hijack mailto: uri's and handle locally 549 if (url != null && url.toLowerCase().startsWith("mailto:")) { 550 return MessageCompose.actionCompose(MessageView.this, url, mAccountId); 551 } 552 553 // Handle most uri's via intent launch 554 boolean result = false; 555 Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url)); 556 intent.addCategory(Intent.CATEGORY_BROWSABLE); 557 intent.putExtra(Browser.EXTRA_APPLICATION_ID, getPackageName()); 558 intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET); 559 try { 560 startActivity(intent); 561 result = true; 562 } catch (ActivityNotFoundException ex) { 563 // If no application can handle the URL, assume that the 564 // caller can handle it. 565 } 566 return result; 567 } 568 } 569 570 /** 571 * Handle clicks on sender, which shows {@link QuickContact} or prompts to add 572 * the sender as a contact. 573 */ 574 private void onClickSender() { 575 // Bail early if message or sender not present 576 if (mMessage == null) return; 577 578 final Address senderEmail = Address.unpackFirst(mMessage.mFrom); 579 if (senderEmail == null) return; 580 581 // First perform lookup query to find existing contact 582 final ContentResolver resolver = getContentResolver(); 583 final String address = senderEmail.getAddress(); 584 final Uri dataUri = Uri.withAppendedPath(CommonDataKinds.Email.CONTENT_FILTER_URI, 585 Uri.encode(address)); 586 final Uri lookupUri = ContactsContract.Data.getContactLookupUri(resolver, dataUri); 587 588 if (lookupUri != null) { 589 // Found matching contact, trigger QuickContact 590 QuickContact.showQuickContact(this, mSenderPresenceView, lookupUri, 591 QuickContact.MODE_LARGE, null); 592 } else { 593 // No matching contact, ask user to create one 594 final Uri mailUri = Uri.fromParts("mailto", address, null); 595 final Intent intent = new Intent(ContactsContract.Intents.SHOW_OR_CREATE_CONTACT, 596 mailUri); 597 598 // Pass along full E-mail string for possible create dialog 599 intent.putExtra(ContactsContract.Intents.EXTRA_CREATE_DESCRIPTION, 600 senderEmail.toString()); 601 602 // Only provide personal name hint if we have one 603 final String senderPersonal = senderEmail.getPersonal(); 604 if (!TextUtils.isEmpty(senderPersonal)) { 605 intent.putExtra(ContactsContract.Intents.Insert.NAME, senderPersonal); 606 } 607 intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET); 608 609 startActivity(intent); 610 } 611 } 612 613 /** 614 * Toggle favorite status and write back to provider 615 */ 616 private void onClickFavorite() { 617 if (mMessage != null) { 618 // Update UI 619 boolean newFavorite = ! mMessage.mFlagFavorite; 620 mFavoriteIcon.setImageDrawable(newFavorite ? mFavoriteIconOn : mFavoriteIconOff); 621 622 // Update provider 623 mMessage.mFlagFavorite = newFavorite; 624 mController.setMessageFavorite(mMessageId, newFavorite); 625 } 626 } 627 628 private void onReply() { 629 if (mMessage != null) { 630 MessageCompose.actionReply(this, mMessage.mId, false); 631 finish(); 632 } 633 } 634 635 private void onReplyAll() { 636 if (mMessage != null) { 637 MessageCompose.actionReply(this, mMessage.mId, true); 638 finish(); 639 } 640 } 641 642 private void onForward() { 643 if (mMessage != null) { 644 MessageCompose.actionForward(this, mMessage.mId); 645 finish(); 646 } 647 } 648 649 private boolean moveToOlder() { 650 // Guard with !isLast() because Cursor.moveToNext() returns false even as it moves 651 // from last to after-last. 652 if (mMessageListCursor != null 653 && !mMessageListCursor.isLast() 654 && mMessageListCursor.moveToNext()) { 655 mMessageId = mMessageListCursor.getLong(0); 656 messageChanged(); 657 return true; 658 } 659 return false; 660 } 661 662 private boolean moveToNewer() { 663 // Guard with !isFirst() because Cursor.moveToPrev() returns false even as it moves 664 // from first to before-first. 665 if (mMessageListCursor != null 666 && !mMessageListCursor.isFirst() 667 && mMessageListCursor.moveToPrevious()) { 668 mMessageId = mMessageListCursor.getLong(0); 669 messageChanged(); 670 return true; 671 } 672 return false; 673 } 674 675 private void onMarkAsRead(boolean isRead) { 676 if (mMessage != null && mMessage.mFlagRead != isRead) { 677 mMessage.mFlagRead = isRead; 678 mController.setMessageRead(mMessageId, isRead); 679 } 680 } 681 682 /** 683 * Creates a unique file in the given directory by appending a hyphen 684 * and a number to the given filename. 685 * @param directory 686 * @param filename 687 * @return a new File object, or null if one could not be created 688 */ 689 /* package */ static File createUniqueFile(File directory, String filename) { 690 File file = new File(directory, filename); 691 if (!file.exists()) { 692 return file; 693 } 694 // Get the extension of the file, if any. 695 int index = filename.lastIndexOf('.'); 696 String format; 697 if (index != -1) { 698 String name = filename.substring(0, index); 699 String extension = filename.substring(index); 700 format = name + "-%d" + extension; 701 } 702 else { 703 format = filename + "-%d"; 704 } 705 for (int i = 2; i < Integer.MAX_VALUE; i++) { 706 file = new File(directory, String.format(format, i)); 707 if (!file.exists()) { 708 return file; 709 } 710 } 711 return null; 712 } 713 714 /** 715 * Send a service message indicating that a meeting invite button has been clicked. 716 */ 717 private void onRespond(int response, int toastResId) { 718 // do not send twice in a row the same response 719 if (mPreviousMeetingResponse != response) { 720 mController.sendMeetingResponse(mMessageId, response, mControllerCallback); 721 mPreviousMeetingResponse = response; 722 } 723 Toast.makeText(this, toastResId, Toast.LENGTH_SHORT).show(); 724 if (!moveToOlder()) { 725 finish(); // if this is the last message, move up to message-list. 726 } 727 } 728 729 private void onDownloadAttachment(AttachmentInfo attachment) { 730 if (!Environment.getExternalStorageState().equals(Environment.MEDIA_MOUNTED)) { 731 /* 732 * Abort early if there's no place to save the attachment. We don't want to spend 733 * the time downloading it and then abort. 734 */ 735 Toast.makeText(this, 736 getString(R.string.message_view_status_attachment_not_saved), 737 Toast.LENGTH_SHORT).show(); 738 return; 739 } 740 741 mLoadAttachmentId = attachment.attachmentId; 742 mLoadAttachmentSave = true; 743 mLoadAttachmentName = attachment.name; 744 745 mController.loadAttachment(attachment.attachmentId, mMessageId, mMessage.mMailboxKey, 746 mAccountId, mControllerCallback); 747 } 748 749 private void onViewAttachment(AttachmentInfo attachment) { 750 mLoadAttachmentId = attachment.attachmentId; 751 mLoadAttachmentSave = false; 752 mLoadAttachmentName = attachment.name; 753 754 mController.loadAttachment(attachment.attachmentId, mMessageId, mMessage.mMailboxKey, 755 mAccountId, mControllerCallback); 756 } 757 758 private void onShowPictures() { 759 if (mMessage != null) { 760 if (mMessageContentView != null) { 761 mMessageContentView.getSettings().setBlockNetworkLoads(false); 762 if (mHtmlTextWebView != null) { 763 mMessageContentView.loadDataWithBaseURL("email://", mHtmlTextWebView, 764 "text/html", "utf-8", null); 765 } 766 } 767 mShowPicturesSection.setVisibility(View.GONE); 768 } 769 } 770 771 public void onClick(View view) { 772 switch (view.getId()) { 773 case R.id.from: 774 case R.id.presence: 775 onClickSender(); 776 break; 777 case R.id.favorite: 778 onClickFavorite(); 779 break; 780 case R.id.reply: 781 onReply(); 782 break; 783 case R.id.reply_all: 784 onReplyAll(); 785 break; 786 case R.id.delete: 787 onDelete(); 788 break; 789 case R.id.moveToOlder: 790 moveToOlder(); 791 break; 792 case R.id.moveToNewer: 793 moveToNewer(); 794 break; 795 case R.id.download: 796 onDownloadAttachment((AttachmentInfo) view.getTag()); 797 break; 798 case R.id.view: 799 onViewAttachment((AttachmentInfo) view.getTag()); 800 break; 801 case R.id.show_pictures: 802 onShowPictures(); 803 break; 804 case R.id.accept: 805 onRespond(EmailServiceConstants.MEETING_REQUEST_ACCEPTED, 806 R.string.message_view_invite_toast_yes); 807 break; 808 case R.id.maybe: 809 onRespond(EmailServiceConstants.MEETING_REQUEST_TENTATIVE, 810 R.string.message_view_invite_toast_maybe); 811 break; 812 case R.id.decline: 813 onRespond(EmailServiceConstants.MEETING_REQUEST_DECLINED, 814 R.string.message_view_invite_toast_no); 815 break; 816 case R.id.invite_link: 817 String startTime = 818 new PackedString(mMessage.mMeetingInfo).get(MeetingInfo.MEETING_DTSTART); 819 if (startTime != null) { 820 long epochTimeMillis = Utility.parseEmailDateTimeToMillis(startTime); 821 Uri uri = Uri.parse("content://com.android.calendar/time/" + epochTimeMillis); 822 Intent intent = new Intent(Intent.ACTION_VIEW); 823 intent.setData(uri); 824 intent.putExtra("VIEW", "DAY"); 825 intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET); 826 startActivity(intent); 827 } else { 828 Email.log("meetingInfo without DTSTART " + mMessage.mMeetingInfo); 829 } 830 break; 831 } 832 } 833 834 @Override 835 public boolean onOptionsItemSelected(MenuItem item) { 836 boolean handled = handleMenuItem(item.getItemId()); 837 if (!handled) { 838 handled = super.onOptionsItemSelected(item); 839 } 840 return handled; 841 } 842 843 /** 844 * This is the core functionality of onOptionsItemSelected() but broken out and exposed 845 * for testing purposes (because it's annoying to mock a MenuItem). 846 * 847 * @param menuItemId id that was clicked 848 * @return true if handled here 849 */ 850 /* package */ boolean handleMenuItem(int menuItemId) { 851 switch (menuItemId) { 852 case R.id.delete: 853 onDelete(); 854 break; 855 case R.id.reply: 856 onReply(); 857 break; 858 case R.id.reply_all: 859 onReplyAll(); 860 break; 861 case R.id.forward: 862 onForward(); 863 break; 864 case R.id.mark_as_unread: 865 onMarkAsRead(false); 866 finish(); 867 break; 868 default: 869 return false; 870 } 871 return true; 872 } 873 874 @Override 875 public boolean onCreateOptionsMenu(Menu menu) { 876 super.onCreateOptionsMenu(menu); 877 getMenuInflater().inflate(R.menu.message_view_option, menu); 878 if (mDisableReplyAndForward) { 879 menu.findItem(R.id.forward).setEnabled(false); 880 menu.findItem(R.id.reply).setEnabled(false); 881 menu.findItem(R.id.reply_all).setEnabled(false); 882 } 883 return true; 884 } 885 886 /** 887 * Re-init everything needed for changing message. 888 */ 889 private void messageChanged() { 890 if (Email.DEBUG) { 891 Email.log("MessageView: messageChanged to id=" + mMessageId); 892 } 893 cancelAllTasks(); 894 setTitle(""); 895 if (mMessageContentView != null) { 896 mMessageContentView.scrollTo(0, 0); 897 mMessageContentView.loadUrl("file:///android_asset/empty.html"); 898 } 899 mScrollView.scrollTo(0, 0); 900 mAttachments.removeAllViews(); 901 mAttachments.setVisibility(View.GONE); 902 mAttachmentIcon.setVisibility(View.GONE); 903 904 // Start an AsyncTask to make a new cursor and load the message 905 mLoadMessageTask = new LoadMessageTask(mMessageId, true); 906 mLoadMessageTask.execute(); 907 updateNavigationArrows(mMessageListCursor); 908 } 909 910 /** 911 * Reposition the older/newer cursor. Finish() the activity if we are no longer 912 * in the list. Update the UI arrows as appropriate. 913 */ 914 private void repositionMessageListCursor() { 915 if (Email.DEBUG) { 916 Email.log("MessageView: reposition to id=" + mMessageId); 917 } 918 // position the cursor on the current message 919 mMessageListCursor.moveToPosition(-1); 920 while (mMessageListCursor.moveToNext() && mMessageListCursor.getLong(0) != mMessageId) { 921 } 922 if (mMessageListCursor.isAfterLast()) { 923 // overshoot - get out now, the list is no longer valid 924 finish(); 925 } 926 updateNavigationArrows(mMessageListCursor); 927 } 928 929 /** 930 * Update the arrows based on the current position of the older/newer cursor. 931 */ 932 private void updateNavigationArrows(Cursor cursor) { 933 if (cursor != null) { 934 boolean hasNewer, hasOlder; 935 if (cursor.isAfterLast() || cursor.isBeforeFirst()) { 936 // The cursor not being on a message means that the current message was not found. 937 // While this should not happen, simply disable prev/next arrows in that case. 938 hasNewer = hasOlder = false; 939 } else { 940 hasNewer = !cursor.isFirst(); 941 hasOlder = !cursor.isLast(); 942 } 943 mMoveToNewer.setVisibility(hasNewer ? View.VISIBLE : View.INVISIBLE); 944 mMoveToOlder.setVisibility(hasOlder ? View.VISIBLE : View.INVISIBLE); 945 } 946 } 947 948 private Bitmap getPreviewIcon(AttachmentInfo attachment) { 949 try { 950 return BitmapFactory.decodeStream( 951 getContentResolver().openInputStream( 952 AttachmentProvider.getAttachmentThumbnailUri( 953 mAccountId, attachment.attachmentId, 954 62, 955 62))); 956 } 957 catch (Exception e) { 958 Log.d(Email.LOG_TAG, "Attachment preview failed with exception " + e.getMessage()); 959 return null; 960 } 961 } 962 963 /* 964 * Formats the given size as a String in bytes, kB, MB or GB with a single digit 965 * of precision. Ex: 12,315,000 = 12.3 MB 966 */ 967 public static String formatSize(float size) { 968 long kb = 1024; 969 long mb = (kb * 1024); 970 long gb = (mb * 1024); 971 if (size < kb) { 972 return String.format("%d bytes", (int) size); 973 } 974 else if (size < mb) { 975 return String.format("%.1f kB", size / kb); 976 } 977 else if (size < gb) { 978 return String.format("%.1f MB", size / mb); 979 } 980 else { 981 return String.format("%.1f GB", size / gb); 982 } 983 } 984 985 private void updateAttachmentThumbnail(long attachmentId) { 986 for (int i = 0, count = mAttachments.getChildCount(); i < count; i++) { 987 AttachmentInfo attachment = (AttachmentInfo) mAttachments.getChildAt(i).getTag(); 988 if (attachment.attachmentId == attachmentId) { 989 Bitmap previewIcon = getPreviewIcon(attachment); 990 if (previewIcon != null) { 991 mHandler.updateAttachmentIcon(i, previewIcon); 992 } 993 return; 994 } 995 } 996 } 997 998 /** 999 * Copy data from a cursor-refreshed attachment into the UI. Called from UI thread. 1000 * 1001 * @param attachment A single attachment loaded from the provider 1002 */ 1003 private void addAttachment(Attachment attachment) { 1004 1005 AttachmentInfo attachmentInfo = new AttachmentInfo(); 1006 attachmentInfo.size = attachment.mSize; 1007 attachmentInfo.contentType = 1008 AttachmentProvider.inferMimeType(attachment.mFileName, attachment.mMimeType); 1009 attachmentInfo.name = attachment.mFileName; 1010 attachmentInfo.attachmentId = attachment.mId; 1011 1012 LayoutInflater inflater = getLayoutInflater(); 1013 View view = inflater.inflate(R.layout.message_view_attachment, null); 1014 1015 TextView attachmentName = (TextView)view.findViewById(R.id.attachment_name); 1016 TextView attachmentInfoView = (TextView)view.findViewById(R.id.attachment_info); 1017 ImageView attachmentIcon = (ImageView)view.findViewById(R.id.attachment_icon); 1018 Button attachmentView = (Button)view.findViewById(R.id.view); 1019 Button attachmentDownload = (Button)view.findViewById(R.id.download); 1020 1021 if ((!MimeUtility.mimeTypeMatches(attachmentInfo.contentType, 1022 Email.ACCEPTABLE_ATTACHMENT_VIEW_TYPES)) 1023 || (MimeUtility.mimeTypeMatches(attachmentInfo.contentType, 1024 Email.UNACCEPTABLE_ATTACHMENT_VIEW_TYPES))) { 1025 attachmentView.setVisibility(View.GONE); 1026 } 1027 if ((!MimeUtility.mimeTypeMatches(attachmentInfo.contentType, 1028 Email.ACCEPTABLE_ATTACHMENT_DOWNLOAD_TYPES)) 1029 || (MimeUtility.mimeTypeMatches(attachmentInfo.contentType, 1030 Email.UNACCEPTABLE_ATTACHMENT_DOWNLOAD_TYPES))) { 1031 attachmentDownload.setVisibility(View.GONE); 1032 } 1033 1034 if (attachmentInfo.size > Email.MAX_ATTACHMENT_DOWNLOAD_SIZE) { 1035 attachmentView.setVisibility(View.GONE); 1036 attachmentDownload.setVisibility(View.GONE); 1037 } 1038 1039 attachmentInfo.viewButton = attachmentView; 1040 attachmentInfo.downloadButton = attachmentDownload; 1041 attachmentInfo.iconView = attachmentIcon; 1042 1043 view.setTag(attachmentInfo); 1044 attachmentView.setOnClickListener(this); 1045 attachmentView.setTag(attachmentInfo); 1046 attachmentDownload.setOnClickListener(this); 1047 attachmentDownload.setTag(attachmentInfo); 1048 1049 attachmentName.setText(attachmentInfo.name); 1050 attachmentInfoView.setText(formatSize(attachmentInfo.size)); 1051 1052 Bitmap previewIcon = getPreviewIcon(attachmentInfo); 1053 if (previewIcon != null) { 1054 attachmentIcon.setImageBitmap(previewIcon); 1055 } 1056 1057 mAttachments.addView(view); 1058 mAttachments.setVisibility(View.VISIBLE); 1059 } 1060 1061 private class PresenceCheckTask extends AsyncTask<String, Void, Integer> { 1062 @Override 1063 protected Integer doInBackground(String... emails) { 1064 Cursor cursor = 1065 getContentResolver().query(ContactsContract.Data.CONTENT_URI, 1066 PRESENCE_STATUS_PROJECTION, CommonDataKinds.Email.DATA + "=?", emails, null); 1067 if (cursor != null) { 1068 try { 1069 if (cursor.moveToFirst()) { 1070 int status = cursor.getInt(0); 1071 int icon = StatusUpdates.getPresenceIconResourceId(status); 1072 return icon; 1073 } 1074 } finally { 1075 cursor.close(); 1076 } 1077 } 1078 return 0; 1079 } 1080 1081 @Override 1082 protected void onPostExecute(Integer icon) { 1083 if (icon == null) { 1084 return; 1085 } 1086 updateSenderPresence(icon); 1087 } 1088 } 1089 1090 /** 1091 * Launch a thread (because of cross-process DB lookup) to check presence of the sender of the 1092 * message. When that thread completes, update the UI. 1093 * 1094 * This must only be called when mMessage is null (it will hide presence indications) or when 1095 * mMessage has already seen its headers loaded. 1096 * 1097 * Note: This is just a polling operation. A more advanced solution would be to keep the 1098 * cursor open and respond to presence status updates (in the form of content change 1099 * notifications). However, because presence changes fairly slowly compared to the duration 1100 * of viewing a single message, a simple poll at message load (and onResume) should be 1101 * sufficient. 1102 */ 1103 private void startPresenceCheck() { 1104 if (mMessage != null) { 1105 Address sender = Address.unpackFirst(mMessage.mFrom); 1106 if (sender != null) { 1107 String email = sender.getAddress(); 1108 if (email != null) { 1109 mPresenceCheckTask = new PresenceCheckTask(); 1110 mPresenceCheckTask.execute(email); 1111 return; 1112 } 1113 } 1114 } 1115 updateSenderPresence(0); 1116 } 1117 1118 /** 1119 * Update the actual UI. Must be called from main thread (or handler) 1120 * @param presenceIconId the presence of the sender, 0 for "unknown" 1121 */ 1122 private void updateSenderPresence(int presenceIconId) { 1123 if (presenceIconId == 0) { 1124 // This is a placeholder used for "unknown" presence, including signed off, 1125 // no presence relationship. 1126 presenceIconId = R.drawable.presence_inactive; 1127 } 1128 mSenderPresenceView.setImageResource(presenceIconId); 1129 } 1130 1131 1132 /** 1133 * This task finds out the messageId for the previous and next message 1134 * in the order given by mailboxId as used in MessageList. 1135 * 1136 * It generates the same cursor as the one used in MessageList (but with an id-only projection), 1137 * scans through it until finds the current messageId, and takes the previous and next ids. 1138 */ 1139 private class LoadMessageListTask extends AsyncTask<Void, Void, Cursor> { 1140 private long mLocalMailboxId; 1141 1142 public LoadMessageListTask(long mailboxId) { 1143 mLocalMailboxId = mailboxId; 1144 } 1145 1146 @Override 1147 protected Cursor doInBackground(Void... params) { 1148 String selection = 1149 Utility.buildMailboxIdSelection(getContentResolver(), mLocalMailboxId); 1150 Cursor c = getContentResolver().query(EmailContent.Message.CONTENT_URI, 1151 EmailContent.ID_PROJECTION, 1152 selection, null, 1153 EmailContent.MessageColumns.TIMESTAMP + " DESC"); 1154 if (isCancelled()) { 1155 c.close(); 1156 c = null; 1157 } 1158 return c; 1159 } 1160 1161 @Override 1162 protected void onPostExecute(Cursor cursor) { 1163 // remove the reference to ourselves so another one can be launched 1164 MessageView.this.mLoadMessageListTask = null; 1165 1166 if (cursor == null || cursor.isClosed()) { 1167 return; 1168 } 1169 // replace the older cursor if there is one 1170 closeMessageListCursor(); 1171 mMessageListCursor = cursor; 1172 mMessageListCursor.registerContentObserver(MessageView.this.mCursorObserver); 1173 repositionMessageListCursor(); 1174 } 1175 } 1176 1177 /** 1178 * Async task for loading a single message outside of the UI thread 1179 * Note: To support unit testing, a sentinel messageId of Long.MIN_VALUE prevents 1180 * loading the message but leaves the activity open. 1181 */ 1182 private class LoadMessageTask extends AsyncTask<Void, Void, Message> { 1183 1184 private long mId; 1185 private boolean mOkToFetch; 1186 1187 /** 1188 * Special constructor to cache some local info 1189 */ 1190 public LoadMessageTask(long messageId, boolean okToFetch) { 1191 mId = messageId; 1192 mOkToFetch = okToFetch; 1193 } 1194 1195 @Override 1196 protected Message doInBackground(Void... params) { 1197 if (mId == Long.MIN_VALUE) { 1198 return null; 1199 } 1200 return Message.restoreMessageWithId(MessageView.this, mId); 1201 } 1202 1203 @Override 1204 protected void onPostExecute(Message message) { 1205 /* doInBackground() may return null result (due to restoreMessageWithId()) 1206 * and in that situation we want to Activity.finish(). 1207 * 1208 * OTOH we don't want to Activity.finish() for isCancelled() because this 1209 * would introduce a surprise side-effect to task cancellation: every task 1210 * cancelation would also result in finish(). 1211 * 1212 * Right now LoadMesageTask is cancelled not only from onDestroy(), 1213 * and it would be a bug to also finish() the activity in that situation. 1214 */ 1215 if (isCancelled()) { 1216 return; 1217 } 1218 if (message == null) { 1219 if (mId != Long.MIN_VALUE) { 1220 finish(); 1221 } 1222 return; 1223 } 1224 reloadUiFromMessage(message, mOkToFetch); 1225 startPresenceCheck(); 1226 } 1227 } 1228 1229 /** 1230 * Async task for loading a single message body outside of the UI thread 1231 */ 1232 private class LoadBodyTask extends AsyncTask<Void, Void, String[]> { 1233 1234 private long mId; 1235 1236 /** 1237 * Special constructor to cache some local info 1238 */ 1239 public LoadBodyTask(long messageId) { 1240 mId = messageId; 1241 } 1242 1243 @Override 1244 protected String[] doInBackground(Void... params) { 1245 try { 1246 String text = null; 1247 String html = Body.restoreBodyHtmlWithMessageId(MessageView.this, mId); 1248 if (html == null) { 1249 text = Body.restoreBodyTextWithMessageId(MessageView.this, mId); 1250 } 1251 return new String[] { text, html }; 1252 } catch (RuntimeException re) { 1253 // This catches SQLiteException as well as other RTE's we've seen from the 1254 // database calls, such as IllegalStateException 1255 Log.d(Email.LOG_TAG, "Exception while loading message body: " + re.toString()); 1256 mHandler.loadBodyError(); 1257 return new String[] { null, null }; 1258 } 1259 } 1260 1261 @Override 1262 protected void onPostExecute(String[] results) { 1263 if (results == null) { 1264 return; 1265 } 1266 reloadUiFromBody(results[0], results[1]); // text, html 1267 onMarkAsRead(true); 1268 } 1269 } 1270 1271 /** 1272 * Async task for loading attachments 1273 * 1274 * Note: This really should only be called when the message load is complete - or, we should 1275 * leave open a listener so the attachments can fill in as they are discovered. In either case, 1276 * this implementation is incomplete, as it will fail to refresh properly if the message is 1277 * partially loaded at this time. 1278 */ 1279 private class LoadAttachmentsTask extends AsyncTask<Long, Void, Attachment[]> { 1280 @Override 1281 protected Attachment[] doInBackground(Long... messageIds) { 1282 return Attachment.restoreAttachmentsWithMessageId(MessageView.this, messageIds[0]); 1283 } 1284 1285 @Override 1286 protected void onPostExecute(Attachment[] attachments) { 1287 if (attachments == null) { 1288 return; 1289 } 1290 boolean htmlChanged = false; 1291 for (Attachment attachment : attachments) { 1292 if (mHtmlTextRaw != null && attachment.mContentId != null 1293 && attachment.mContentUri != null) { 1294 // for html body, replace CID for inline images 1295 // Regexp which matches ' src="cid:contentId"'. 1296 String contentIdRe = 1297 "\\s+(?i)src=\"cid(?-i):\\Q" + attachment.mContentId + "\\E\""; 1298 String srcContentUri = " src=\"" + attachment.mContentUri + "\""; 1299 mHtmlTextRaw = mHtmlTextRaw.replaceAll(contentIdRe, srcContentUri); 1300 htmlChanged = true; 1301 } else { 1302 addAttachment(attachment); 1303 } 1304 } 1305 mHtmlTextWebView = mHtmlTextRaw; 1306 mHtmlTextRaw = null; 1307 if (htmlChanged && mMessageContentView != null) { 1308 mMessageContentView.loadDataWithBaseURL("email://", mHtmlTextWebView, 1309 "text/html", "utf-8", null); 1310 } 1311 } 1312 } 1313 1314 /** 1315 * Reload the UI from a provider cursor. This must only be called from the UI thread. 1316 * 1317 * @param message A copy of the message loaded from the database 1318 * @param okToFetch If true, and message is not fully loaded, it's OK to fetch from 1319 * the network. Use false to prevent looping here. 1320 * 1321 * TODO: trigger presence check 1322 */ 1323 private void reloadUiFromMessage(Message message, boolean okToFetch) { 1324 mMessage = message; 1325 mAccountId = message.mAccountKey; 1326 if (mMailboxId == -1) { 1327 mMailboxId = message.mMailboxKey; 1328 } 1329 // only start LoadMessageListTask here if it's the first time 1330 if (mMessageListCursor == null) { 1331 mLoadMessageListTask = new LoadMessageListTask(mMailboxId); 1332 mLoadMessageListTask.execute(); 1333 } 1334 1335 mSubjectView.setText(message.mSubject); 1336 mFromView.setText(Address.toFriendly(Address.unpack(message.mFrom))); 1337 Date date = new Date(message.mTimeStamp); 1338 mTimeView.setText(mTimeFormat.format(date)); 1339 mDateView.setText(Utility.isDateToday(date) ? null : mDateFormat.format(date)); 1340 mToView.setText(Address.toFriendly(Address.unpack(message.mTo))); 1341 String friendlyCc = Address.toFriendly(Address.unpack(message.mCc)); 1342 mCcView.setText(friendlyCc); 1343 mCcContainerView.setVisibility((friendlyCc != null) ? View.VISIBLE : View.GONE); 1344 mAttachmentIcon.setVisibility(message.mAttachments != null ? View.VISIBLE : View.GONE); 1345 mFavoriteIcon.setImageDrawable(message.mFlagFavorite ? mFavoriteIconOn : mFavoriteIconOff); 1346 // Show the message invite section if we're an incoming meeting invitation only 1347 mInviteSection.setVisibility((message.mFlags & Message.FLAG_INCOMING_MEETING_INVITE) != 0 ? 1348 View.VISIBLE : View.GONE); 1349 1350 // Handle partially-loaded email, as follows: 1351 // 1. Check value of message.mFlagLoaded 1352 // 2. If != LOADED, ask controller to load it 1353 // 3. Controller callback (after loaded) should trigger LoadBodyTask & LoadAttachmentsTask 1354 // 4. Else start the loader tasks right away (message already loaded) 1355 if (okToFetch && message.mFlagLoaded != Message.FLAG_LOADED_COMPLETE) { 1356 mWaitForLoadMessageId = message.mId; 1357 mController.loadMessageForView(message.mId, mControllerCallback); 1358 } else { 1359 mWaitForLoadMessageId = -1; 1360 // Ask for body 1361 mLoadBodyTask = new LoadBodyTask(message.mId); 1362 mLoadBodyTask.execute(); 1363 } 1364 } 1365 1366 /** 1367 * Reload the body from the provider cursor. This must only be called from the UI thread. 1368 * 1369 * @param bodyText text part 1370 * @param bodyHtml html part 1371 * 1372 * TODO deal with html vs text and many other issues 1373 */ 1374 private void reloadUiFromBody(String bodyText, String bodyHtml) { 1375 String text = null; 1376 mHtmlTextRaw = null; 1377 boolean hasImages = false; 1378 1379 if (bodyHtml == null) { 1380 text = bodyText; 1381 /* 1382 * Convert the plain text to HTML 1383 */ 1384 StringBuffer sb = new StringBuffer("<html><body>"); 1385 if (text != null) { 1386 // Escape any inadvertent HTML in the text message 1387 text = EmailHtmlUtil.escapeCharacterToDisplay(text); 1388 // Find any embedded URL's and linkify 1389 Matcher m = Patterns.WEB_URL.matcher(text); 1390 while (m.find()) { 1391 int start = m.start(); 1392 /* 1393 * WEB_URL_PATTERN may match domain part of email address. To detect 1394 * this false match, the character just before the matched string 1395 * should not be '@'. 1396 */ 1397 if (start == 0 || text.charAt(start - 1) != '@') { 1398 String url = m.group(); 1399 Matcher proto = WEB_URL_PROTOCOL.matcher(url); 1400 String link; 1401 if (proto.find()) { 1402 // This is work around to force URL protocol part be lower case, 1403 // because WebView could follow only lower case protocol link. 1404 link = proto.group().toLowerCase() + url.substring(proto.end()); 1405 } else { 1406 // Patterns.WEB_URL matches URL without protocol part, 1407 // so added default protocol to link. 1408 link = "http://" + url; 1409 } 1410 String href = String.format("<a href=\"%s\">%s</a>", link, url); 1411 m.appendReplacement(sb, href); 1412 } 1413 else { 1414 m.appendReplacement(sb, "$0"); 1415 } 1416 } 1417 m.appendTail(sb); 1418 } 1419 sb.append("</body></html>"); 1420 text = sb.toString(); 1421 } else { 1422 text = bodyHtml; 1423 mHtmlTextRaw = bodyHtml; 1424 hasImages = IMG_TAG_START_REGEX.matcher(text).find(); 1425 } 1426 1427 mShowPicturesSection.setVisibility(hasImages ? View.VISIBLE : View.GONE); 1428 if (mMessageContentView != null) { 1429 mMessageContentView.loadDataWithBaseURL("email://", text, "text/html", "utf-8", null); 1430 } 1431 1432 // Ask for attachments after body 1433 mLoadAttachmentsTask = new LoadAttachmentsTask(); 1434 mLoadAttachmentsTask.execute(mMessage.mId); 1435 } 1436 1437 /** 1438 * Controller results listener. This completely replaces MessagingListener 1439 */ 1440 private class ControllerResults implements Controller.Result { 1441 1442 public void loadMessageForViewCallback(MessagingException result, long messageId, 1443 int progress) { 1444 if (messageId != MessageView.this.mMessageId 1445 || messageId != MessageView.this.mWaitForLoadMessageId) { 1446 // We are not waiting for this message to load, so exit quickly 1447 return; 1448 } 1449 if (result == null) { 1450 switch (progress) { 1451 case 0: 1452 mHandler.progress(true); 1453 mHandler.loadContentUri("file:///android_asset/loading.html"); 1454 break; 1455 case 100: 1456 mWaitForLoadMessageId = -1; 1457 mHandler.progress(false); 1458 // reload UI and reload everything else too 1459 // pass false to LoadMessageTask to prevent looping here 1460 cancelAllTasks(); 1461 mLoadMessageTask = new LoadMessageTask(mMessageId, false); 1462 mLoadMessageTask.execute(); 1463 break; 1464 default: 1465 // do nothing - we don't have a progress bar at this time 1466 break; 1467 } 1468 } else { 1469 mWaitForLoadMessageId = -1; 1470 mHandler.progress(false); 1471 mHandler.networkError(); 1472 mHandler.loadContentUri("file:///android_asset/empty.html"); 1473 } 1474 } 1475 1476 public void loadAttachmentCallback(MessagingException result, long messageId, 1477 long attachmentId, int progress) { 1478 if (messageId == MessageView.this.mMessageId) { 1479 if (result == null) { 1480 switch (progress) { 1481 case 0: 1482 mHandler.setAttachmentsEnabled(false); 1483 mHandler.attachmentProgress(true); 1484 mHandler.fetchingAttachment(); 1485 break; 1486 case 100: 1487 mHandler.setAttachmentsEnabled(true); 1488 mHandler.attachmentProgress(false); 1489 updateAttachmentThumbnail(attachmentId); 1490 mHandler.finishLoadAttachment(attachmentId); 1491 break; 1492 default: 1493 // do nothing - we don't have a progress bar at this time 1494 break; 1495 } 1496 } else { 1497 mHandler.setAttachmentsEnabled(true); 1498 mHandler.attachmentProgress(false); 1499 mHandler.networkError(); 1500 } 1501 } 1502 } 1503 1504 public void updateMailboxCallback(MessagingException result, long accountId, 1505 long mailboxId, int progress, int numNewMessages) { 1506 if (result != null || progress == 100) { 1507 Email.updateMailboxRefreshTime(mailboxId); 1508 } 1509 } 1510 1511 public void updateMailboxListCallback(MessagingException result, long accountId, 1512 int progress) { 1513 } 1514 1515 public void serviceCheckMailCallback(MessagingException result, long accountId, 1516 long mailboxId, int progress, long tag) { 1517 } 1518 1519 public void sendMailCallback(MessagingException result, long accountId, long messageId, 1520 int progress) { 1521 } 1522 } 1523 1524 1525// @Override 1526// public void loadMessageForViewBodyAvailable(Account account, String folder, 1527// String uid, com.android.email.mail.Message message) { 1528// MessageView.this.mOldMessage = message; 1529// try { 1530// Part part = MimeUtility.findFirstPartByMimeType(mOldMessage, "text/html"); 1531// if (part == null) { 1532// part = MimeUtility.findFirstPartByMimeType(mOldMessage, "text/plain"); 1533// } 1534// if (part != null) { 1535// String text = MimeUtility.getTextFromPart(part); 1536// if (part.getMimeType().equalsIgnoreCase("text/html")) { 1537// text = EmailHtmlUtil.resolveInlineImage( 1538// getContentResolver(), mAccount.mId, text, mOldMessage, 0); 1539// } else { 1540// // And also escape special character, such as "<>&", 1541// // to HTML escape sequence. 1542// text = EmailHtmlUtil.escapeCharacterToDisplay(text); 1543 1544// /* 1545// * Linkify the plain text and convert it to HTML by replacing 1546// * \r?\n with <br> and adding a html/body wrapper. 1547// */ 1548// StringBuffer sb = new StringBuffer("<html><body>"); 1549// if (text != null) { 1550// Matcher m = Patterns.WEB_URL.matcher(text); 1551// while (m.find()) { 1552// int start = m.start(); 1553// /* 1554// * WEB_URL_PATTERN may match domain part of email address. To detect 1555// * this false match, the character just before the matched string 1556// * should not be '@'. 1557// */ 1558// if (start == 0 || text.charAt(start - 1) != '@') { 1559// String url = m.group(); 1560// Matcher proto = WEB_URL_PROTOCOL.matcher(url); 1561// String link; 1562// if (proto.find()) { 1563// // Work around to force URL protocol part be lower case, 1564// // since WebView could follow only lower case protocol link. 1565// link = proto.group().toLowerCase() 1566// + url.substring(proto.end()); 1567// } else { 1568// // Patterns.WEB_URL matches URL without protocol part, 1569// // so added default protocol to link. 1570// link = "http://" + url; 1571// } 1572// String href = String.format("<a href=\"%s\">%s</a>", link, url); 1573// m.appendReplacement(sb, href); 1574// } 1575// else { 1576// m.appendReplacement(sb, "$0"); 1577// } 1578// } 1579// m.appendTail(sb); 1580// } 1581// sb.append("</body></html>"); 1582// text = sb.toString(); 1583// } 1584 1585// /* 1586// * TODO consider how to get background images and a million other things 1587// * that HTML allows. 1588// */ 1589// // Check if text contains img tag. 1590// if (IMG_TAG_START_REGEX.matcher(text).find()) { 1591// mHandler.showShowPictures(true); 1592// } 1593 1594// loadMessageContentText(text); 1595// } 1596// else { 1597// loadMessageContentUrl("file:///android_asset/empty.html"); 1598// } 1599// // renderAttachments(mOldMessage, 0); 1600// } 1601// catch (Exception e) { 1602// if (Email.LOGD) { 1603// Log.v(Email.LOG_TAG, "loadMessageForViewBodyAvailable", e); 1604// } 1605// } 1606// } 1607 1608 /** 1609 * Back in the UI thread, handle the final steps of downloading an attachment (view or save). 1610 * 1611 * @param attachmentId the attachment that was just downloaded 1612 */ 1613 private void doFinishLoadAttachment(long attachmentId) { 1614 // If the result does't line up, just skip it - we handle one at a time. 1615 if (attachmentId != mLoadAttachmentId) { 1616 return; 1617 } 1618 Attachment attachment = 1619 Attachment.restoreAttachmentWithId(MessageView.this, attachmentId); 1620 Uri attachmentUri = AttachmentProvider.getAttachmentUri(mAccountId, attachment.mId); 1621 Uri contentUri = 1622 AttachmentProvider.resolveAttachmentIdToContentUri(getContentResolver(), attachmentUri); 1623 1624 if (mLoadAttachmentSave) { 1625 try { 1626 File file = createUniqueFile(Environment.getExternalStorageDirectory(), 1627 attachment.mFileName); 1628 InputStream in = getContentResolver().openInputStream(contentUri); 1629 OutputStream out = new FileOutputStream(file); 1630 IOUtils.copy(in, out); 1631 out.flush(); 1632 out.close(); 1633 in.close(); 1634 1635 Toast.makeText(MessageView.this, String.format( 1636 getString(R.string.message_view_status_attachment_saved), file.getName()), 1637 Toast.LENGTH_LONG).show(); 1638 1639 new MediaScannerNotifier(this, file, mHandler); 1640 } catch (IOException ioe) { 1641 Toast.makeText(MessageView.this, 1642 getString(R.string.message_view_status_attachment_not_saved), 1643 Toast.LENGTH_LONG).show(); 1644 } 1645 } else { 1646 try { 1647 Intent intent = new Intent(Intent.ACTION_VIEW); 1648 intent.setData(contentUri); 1649 intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION 1650 | Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET); 1651 startActivity(intent); 1652 } catch (ActivityNotFoundException e) { 1653 mHandler.attachmentViewError(); 1654 // TODO: Add a proper warning message (and lots of upstream cleanup to prevent 1655 // it from happening) in the next release. 1656 } 1657 } 1658 } 1659 1660 /** 1661 * This notifier is created after an attachment completes downloaded. It attaches to the 1662 * media scanner and waits to handle the completion of the scan. At that point it tries 1663 * to start an ACTION_VIEW activity for the attachment. 1664 */ 1665 private static class MediaScannerNotifier implements MediaScannerConnectionClient { 1666 private Context mContext; 1667 private MediaScannerConnection mConnection; 1668 private File mFile; 1669 private MessageViewHandler mHandler; 1670 1671 public MediaScannerNotifier(Context context, File file, MessageViewHandler handler) { 1672 mContext = context; 1673 mFile = file; 1674 mHandler = handler; 1675 mConnection = new MediaScannerConnection(context, this); 1676 mConnection.connect(); 1677 } 1678 1679 public void onMediaScannerConnected() { 1680 mConnection.scanFile(mFile.getAbsolutePath(), null); 1681 } 1682 1683 public void onScanCompleted(String path, Uri uri) { 1684 try { 1685 if (uri != null) { 1686 Intent intent = new Intent(Intent.ACTION_VIEW); 1687 intent.setData(uri); 1688 mContext.startActivity(intent); 1689 } 1690 } catch (ActivityNotFoundException e) { 1691 mHandler.attachmentViewError(); 1692 // TODO: Add a proper warning message (and lots of upstream cleanup to prevent 1693 // it from happening) in the next release. 1694 } finally { 1695 mConnection.disconnect(); 1696 mContext = null; 1697 mHandler = null; 1698 } 1699 } 1700 } 1701} 1702