MessageCompose.java revision ae72ec88ce0fce4d36aba95be3f4ff7b059577b7
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.EmailAddressAdapter; 22import com.android.email.EmailAddressValidator; 23import com.android.email.UiUtilities; 24import com.android.email.R; 25import com.android.email.mail.internet.EmailHtmlUtil; 26import com.android.emailcommon.Logging; 27import com.android.emailcommon.internet.MimeUtility; 28import com.android.emailcommon.mail.Address; 29import com.android.emailcommon.provider.EmailContent; 30import com.android.emailcommon.provider.EmailContent.Account; 31import com.android.emailcommon.provider.EmailContent.Attachment; 32import com.android.emailcommon.provider.EmailContent.Body; 33import com.android.emailcommon.provider.EmailContent.BodyColumns; 34import com.android.emailcommon.provider.EmailContent.Message; 35import com.android.emailcommon.provider.EmailContent.MessageColumns; 36import com.android.emailcommon.utility.AttachmentUtilities; 37import com.android.emailcommon.utility.EmailAsyncTask; 38import com.android.emailcommon.utility.Utility; 39 40import android.app.ActionBar; 41import android.app.Activity; 42import android.app.ActivityManager; 43import android.content.ActivityNotFoundException; 44import android.content.ContentResolver; 45import android.content.ContentUris; 46import android.content.ContentValues; 47import android.content.Context; 48import android.content.Intent; 49import android.content.pm.ActivityInfo; 50import android.database.Cursor; 51import android.net.Uri; 52import android.os.Bundle; 53import android.os.Parcelable; 54import android.provider.OpenableColumns; 55import android.text.InputFilter; 56import android.text.SpannableStringBuilder; 57import android.text.Spanned; 58import android.text.TextUtils; 59import android.text.TextWatcher; 60import android.text.util.Rfc822Tokenizer; 61import android.util.Log; 62import android.view.Menu; 63import android.view.MenuItem; 64import android.view.View; 65import android.view.View.OnClickListener; 66import android.view.View.OnFocusChangeListener; 67import android.webkit.WebView; 68import android.widget.CheckBox; 69import android.widget.EditText; 70import android.widget.ImageButton; 71import android.widget.LinearLayout; 72import android.widget.MultiAutoCompleteTextView; 73import android.widget.TextView; 74import android.widget.Toast; 75 76import java.io.File; 77import java.io.UnsupportedEncodingException; 78import java.net.URLDecoder; 79import java.util.ArrayList; 80import java.util.List; 81 82 83/** 84 * Activity to compose a message. 85 * 86 * TODO Revive shortcuts command for removed menu options. 87 * C: add cc/bcc 88 * N: add attachment 89 */ 90public class MessageCompose extends Activity implements OnClickListener, OnFocusChangeListener, 91 DeleteMessageConfirmationDialog.Callback { 92 private static final String ACTION_REPLY = "com.android.email.intent.action.REPLY"; 93 private static final String ACTION_REPLY_ALL = "com.android.email.intent.action.REPLY_ALL"; 94 private static final String ACTION_FORWARD = "com.android.email.intent.action.FORWARD"; 95 private static final String ACTION_EDIT_DRAFT = "com.android.email.intent.action.EDIT_DRAFT"; 96 97 private static final String EXTRA_ACCOUNT_ID = "account_id"; 98 private static final String EXTRA_MESSAGE_ID = "message_id"; 99 /** If the intent is sent from the email app itself, it should have this boolean extra. */ 100 private static final String EXTRA_FROM_WITHIN_APP = "from_within_app"; 101 102 private static final String STATE_KEY_CC_SHOWN = 103 "com.android.email.activity.MessageCompose.ccShown"; 104 private static final String STATE_KEY_QUOTED_TEXT_SHOWN = 105 "com.android.email.activity.MessageCompose.quotedTextShown"; 106 private static final String STATE_KEY_SOURCE_MESSAGE_PROCED = 107 "com.android.email.activity.MessageCompose.stateKeySourceMessageProced"; 108 private static final String STATE_KEY_DRAFT_ID = 109 "com.android.email.activity.MessageCompose.draftId"; 110 111 private static final int ACTIVITY_REQUEST_PICK_ATTACHMENT = 1; 112 113 private static final String[] ATTACHMENT_META_SIZE_PROJECTION = { 114 OpenableColumns.SIZE 115 }; 116 private static final int ATTACHMENT_META_SIZE_COLUMN_SIZE = 0; 117 118 // Is set while the draft is saved by a background thread. 119 // Is static in order to be shared between the two activity instances 120 // on orientation change. 121 private static boolean sSaveInProgress = false; 122 // lock and condition for sSaveInProgress 123 private static final Object sSaveInProgressCondition = new Object(); 124 125 private Account mAccount; 126 127 // mDraft has mId > 0 after the first draft save. 128 private Message mDraft = new Message(); 129 130 // mSource is only set for REPLY, REPLY_ALL and FORWARD, and contains the source message. 131 private Message mSource; 132 133 // we use mAction instead of Intent.getAction() because sometimes we need to 134 // re-write the action to EDIT_DRAFT. 135 private String mAction; 136 137 /** 138 * Indicates that the source message has been processed at least once and should not 139 * be processed on any subsequent loads. This protects us from adding attachments that 140 * have already been added from the restore of the view state. 141 */ 142 private boolean mSourceMessageProcessed = false; 143 144 private TextView mFromView; 145 private MultiAutoCompleteTextView mToView; 146 private MultiAutoCompleteTextView mCcView; 147 private MultiAutoCompleteTextView mBccView; 148 private View mCcBccContainer; 149 private EditText mSubjectView; 150 private EditText mMessageContentView; 151 private View mAttachmentContainer; 152 private LinearLayout mAttachments; 153 private View mQuotedTextBar; 154 private CheckBox mIncludeQuotedTextCheckBox; 155 private WebView mQuotedText; 156 157 private Controller mController; 158 private boolean mDraftNeedsSaving; 159 private boolean mMessageLoaded; 160 private final EmailAsyncTask.Tracker mTaskTracker = new EmailAsyncTask.Tracker(); 161 162 private EmailAddressAdapter mAddressAdapterTo; 163 private EmailAddressAdapter mAddressAdapterCc; 164 private EmailAddressAdapter mAddressAdapterBcc; 165 166 /** Whether the save command should be enabled. */ 167 private boolean mSaveEnabled; 168 169 private static Intent getBaseIntent(Context context) { 170 Intent i = new Intent(context, MessageCompose.class); 171 i.putExtra(EXTRA_FROM_WITHIN_APP, true); 172 return i; 173 } 174 175 /** 176 * Create an {@link Intent} that can start the message compose activity. If accountId -1, 177 * the default account will be used; otherwise, the specified account is used. 178 */ 179 public static Intent getMessageComposeIntent(Context context, long accountId) { 180 Intent i = getBaseIntent(context); 181 i.putExtra(EXTRA_ACCOUNT_ID, accountId); 182 return i; 183 } 184 185 /** 186 * Compose a new message using the given account. If account is -1 the default account 187 * will be used. 188 * @param context 189 * @param accountId 190 */ 191 public static void actionCompose(Context context, long accountId) { 192 try { 193 Intent i = getMessageComposeIntent(context, accountId); 194 context.startActivity(i); 195 } catch (ActivityNotFoundException anfe) { 196 // Swallow it - this is usually a race condition, especially under automated test. 197 // (The message composer might have been disabled) 198 Email.log(anfe.toString()); 199 } 200 } 201 202 /** 203 * Compose a new message using a uri (mailto:) and a given account. If account is -1 the 204 * default account will be used. 205 * @param context 206 * @param uriString 207 * @param accountId 208 * @return true if startActivity() succeeded 209 */ 210 public static boolean actionCompose(Context context, String uriString, long accountId) { 211 try { 212 Intent i = getMessageComposeIntent(context, accountId); 213 i.setAction(Intent.ACTION_SEND); 214 i.setData(Uri.parse(uriString)); 215 context.startActivity(i); 216 return true; 217 } catch (ActivityNotFoundException anfe) { 218 // Swallow it - this is usually a race condition, especially under automated test. 219 // (The message composer might have been disabled) 220 Email.log(anfe.toString()); 221 return false; 222 } 223 } 224 225 /** 226 * Compose a new message as a reply to the given message. If replyAll is true the function 227 * is reply all instead of simply reply. 228 * @param context 229 * @param messageId 230 * @param replyAll 231 */ 232 public static void actionReply(Context context, long messageId, boolean replyAll) { 233 startActivityWithMessage(context, replyAll ? ACTION_REPLY_ALL : ACTION_REPLY, messageId); 234 } 235 236 /** 237 * Compose a new message as a forward of the given message. 238 * @param context 239 * @param messageId 240 */ 241 public static void actionForward(Context context, long messageId) { 242 startActivityWithMessage(context, ACTION_FORWARD, messageId); 243 } 244 245 /** 246 * Continue composition of the given message. This action modifies the way this Activity 247 * handles certain actions. 248 * Save will attempt to replace the message in the given folder with the updated version. 249 * Discard will delete the message from the given folder. 250 * @param context 251 * @param messageId the message id. 252 */ 253 public static void actionEditDraft(Context context, long messageId) { 254 startActivityWithMessage(context, ACTION_EDIT_DRAFT, messageId); 255 } 256 257 private static void startActivityWithMessage(Context context, String action, long messageId) { 258 Intent i = getBaseIntent(context); 259 i.putExtra(EXTRA_MESSAGE_ID, messageId); 260 i.setAction(action); 261 context.startActivity(i); 262 } 263 264 private void setAccount(Intent intent) { 265 long accountId = intent.getLongExtra(EXTRA_ACCOUNT_ID, -1); 266 if (accountId == -1) { 267 accountId = Account.getDefaultAccountId(this); 268 } 269 if (accountId == -1) { 270 // There are no accounts set up. This should not have happened. Prompt the 271 // user to set up an account as an acceptable bailout. 272 AccountFolderList.actionShowAccounts(this); 273 finish(); 274 } else { 275 setAccount(Account.restoreAccountWithId(this, accountId)); 276 } 277 } 278 279 private void setAccount(Account account) { 280 if (account == null) { 281 throw new IllegalArgumentException(); 282 } 283 mAccount = account; 284 mFromView.setText(account.mEmailAddress); 285 mAddressAdapterTo.setAccount(account); 286 mAddressAdapterCc.setAccount(account); 287 mAddressAdapterBcc.setAccount(account); 288 } 289 290 @Override 291 public void onCreate(Bundle savedInstanceState) { 292 super.onCreate(savedInstanceState); 293 ActivityHelper.debugSetWindowFlags(this); 294 setContentView(R.layout.message_compose); 295 296 mController = Controller.getInstance(getApplication()); 297 initViews(); 298 setDraftNeedsSaving(false); 299 300 long draftId = -1; 301 if (savedInstanceState != null) { 302 // This data gets used in onCreate, so grab it here instead of onRestoreInstanceState 303 mSourceMessageProcessed = 304 savedInstanceState.getBoolean(STATE_KEY_SOURCE_MESSAGE_PROCED, false); 305 draftId = savedInstanceState.getLong(STATE_KEY_DRAFT_ID, -1); 306 } 307 308 // Show the back arrow on the action bar. 309 getActionBar().setDisplayOptions( 310 ActionBar.DISPLAY_HOME_AS_UP, ActionBar.DISPLAY_HOME_AS_UP); 311 312 Intent intent = getIntent(); 313 mAction = intent.getAction(); 314 315 if (draftId != -1) { 316 // this means that we saved the draft earlier, 317 // so now we need to disregard the intent action and do 318 // EDIT_DRAFT instead. 319 mAction = ACTION_EDIT_DRAFT; 320 mDraft.mId = draftId; 321 } 322 323 // Handle the various intents that launch the message composer 324 if (Intent.ACTION_VIEW.equals(mAction) 325 || Intent.ACTION_SENDTO.equals(mAction) 326 || Intent.ACTION_SEND.equals(mAction) 327 || Intent.ACTION_SEND_MULTIPLE.equals(mAction)) { 328 setAccount(intent); 329 // Use the fields found in the Intent to prefill as much of the message as possible 330 initFromIntent(intent); 331 setDraftNeedsSaving(true); 332 mMessageLoaded = true; 333 mSourceMessageProcessed = true; 334 } else { 335 // Otherwise, handle the internal cases (Message Composer invoked from within app) 336 long messageId = draftId != -1 ? draftId : intent.getLongExtra(EXTRA_MESSAGE_ID, -1); 337 if (messageId != -1) { 338 new LoadMessageTask(messageId).executeParallel(); 339 } else { 340 setAccount(intent); 341 // Since this is a new message, we don't need to call LoadMessageTask. 342 // But we DO need to set mMessageLoaded to indicate the message can be sent 343 mMessageLoaded = true; 344 mSourceMessageProcessed = true; 345 } 346 setInitialComposeText(null, (mAccount != null) ? mAccount.mSignature : null); 347 } 348 349 if (ACTION_REPLY.equals(mAction) || ACTION_REPLY_ALL.equals(mAction) || 350 ACTION_FORWARD.equals(mAction) || ACTION_EDIT_DRAFT.equals(mAction)) { 351 /* 352 * If we need to load the message we add ourself as a message listener here 353 * so we can kick it off. Normally we add in onResume but we don't 354 * want to reload the message every time the activity is resumed. 355 * There is no harm in adding twice. 356 */ 357 // TODO: signal the controller to load the message 358 } 359 } 360 361 // needed for unit tests 362 @Override 363 public void setIntent(Intent intent) { 364 super.setIntent(intent); 365 mAction = intent.getAction(); 366 } 367 368 @Override 369 public void onResume() { 370 super.onResume(); 371 372 // Exit immediately if the accounts list has changed (e.g. externally deleted) 373 if (Email.getNotifyUiAccountsChanged()) { 374 Welcome.actionStart(this); 375 finish(); 376 return; 377 } 378 } 379 380 @Override 381 public void onPause() { 382 super.onPause(); 383 saveIfNeeded(); 384 } 385 386 /** 387 * We override onDestroy to make sure that the WebView gets explicitly destroyed. 388 * Otherwise it can leak native references. 389 */ 390 @Override 391 public void onDestroy() { 392 super.onDestroy(); 393 mQuotedText.destroy(); 394 mQuotedText = null; 395 396 mTaskTracker.cancellAllInterrupt(); 397 398 if (mAddressAdapterTo != null) { 399 mAddressAdapterTo.close(); 400 } 401 if (mAddressAdapterCc != null) { 402 mAddressAdapterCc.close(); 403 } 404 if (mAddressAdapterBcc != null) { 405 mAddressAdapterBcc.close(); 406 } 407 } 408 409 /** 410 * The framework handles most of the fields, but we need to handle stuff that we 411 * dynamically show and hide: 412 * Cc field, 413 * Bcc field, 414 * Quoted text, 415 */ 416 @Override 417 protected void onSaveInstanceState(Bundle outState) { 418 super.onSaveInstanceState(outState); 419 long draftId = getOrCreateDraftId(); 420 if (draftId != -1) { 421 outState.putLong(STATE_KEY_DRAFT_ID, draftId); 422 } 423 outState.putBoolean(STATE_KEY_CC_SHOWN, mCcBccContainer.getVisibility() == View.VISIBLE); 424 outState.putBoolean(STATE_KEY_QUOTED_TEXT_SHOWN, 425 mQuotedTextBar.getVisibility() == View.VISIBLE); 426 outState.putBoolean(STATE_KEY_SOURCE_MESSAGE_PROCED, mSourceMessageProcessed); 427 } 428 429 @Override 430 protected void onRestoreInstanceState(Bundle savedInstanceState) { 431 super.onRestoreInstanceState(savedInstanceState); 432 if (savedInstanceState.getBoolean(STATE_KEY_CC_SHOWN)) { 433 showCcBccFields(); 434 } 435 mQuotedTextBar.setVisibility(savedInstanceState.getBoolean(STATE_KEY_QUOTED_TEXT_SHOWN) ? 436 View.VISIBLE : View.GONE); 437 mQuotedText.setVisibility(savedInstanceState.getBoolean(STATE_KEY_QUOTED_TEXT_SHOWN) ? 438 View.VISIBLE : View.GONE); 439 setDraftNeedsSaving(false); 440 } 441 442 /** 443 * @return true if the activity was opened by the email app itself. 444 */ 445 private boolean isOpenedFromWithinApp() { 446 Intent i = getIntent(); 447 return (i != null && i.getBooleanExtra(EXTRA_FROM_WITHIN_APP, false)); 448 } 449 450 private void setDraftNeedsSaving(boolean needsSaving) { 451 mDraftNeedsSaving = needsSaving; 452 mSaveEnabled = needsSaving; 453 invalidateOptionsMenu(); 454 } 455 456 public void setFocusShifter(int fromViewId, final int targetViewId) { 457 View label = findViewById(fromViewId); 458 // Labels don't exist on the phone UI, so null check. 459 if (label != null) { 460 label.setOnClickListener(new View.OnClickListener() { 461 @Override 462 public void onClick(View v) { 463 findViewById(targetViewId).requestFocus(); 464 } 465 }); 466 } 467 } 468 469 private void initViews() { 470 mFromView = (TextView)findViewById(R.id.from); 471 mToView = (MultiAutoCompleteTextView)findViewById(R.id.to); 472 mCcView = (MultiAutoCompleteTextView)findViewById(R.id.cc); 473 mBccView = (MultiAutoCompleteTextView)findViewById(R.id.bcc); 474 mCcBccContainer = findViewById(R.id.cc_bcc_container); 475 mSubjectView = (EditText)findViewById(R.id.subject); 476 mMessageContentView = (EditText)findViewById(R.id.message_content); 477 mAttachments = (LinearLayout)findViewById(R.id.attachments); 478 mAttachmentContainer = findViewById(R.id.attachment_container); 479 mQuotedTextBar = findViewById(R.id.quoted_text_bar); 480 mIncludeQuotedTextCheckBox = (CheckBox) findViewById(R.id.include_quoted_text); 481 mQuotedText = (WebView)findViewById(R.id.quoted_text); 482 483 TextWatcher watcher = new TextWatcher() { 484 public void beforeTextChanged(CharSequence s, int start, 485 int before, int after) { } 486 487 public void onTextChanged(CharSequence s, int start, 488 int before, int count) { 489 setDraftNeedsSaving(true); 490 } 491 492 public void afterTextChanged(android.text.Editable s) { } 493 }; 494 495 /** 496 * Implements special address cleanup rules: 497 * The first space key entry following an "@" symbol that is followed by any combination 498 * of letters and symbols, including one+ dots and zero commas, should insert an extra 499 * comma (followed by the space). 500 */ 501 InputFilter recipientFilter = new InputFilter() { 502 503 public CharSequence filter(CharSequence source, int start, int end, Spanned dest, 504 int dstart, int dend) { 505 506 // quick check - did they enter a single space? 507 if (end-start != 1 || source.charAt(start) != ' ') { 508 return null; 509 } 510 511 // determine if the characters before the new space fit the pattern 512 // follow backwards and see if we find a comma, dot, or @ 513 int scanBack = dstart; 514 boolean dotFound = false; 515 while (scanBack > 0) { 516 char c = dest.charAt(--scanBack); 517 switch (c) { 518 case '.': 519 dotFound = true; // one or more dots are req'd 520 break; 521 case ',': 522 return null; 523 case '@': 524 if (!dotFound) { 525 return null; 526 } 527 528 // we have found a comma-insert case. now just do it 529 // in the least expensive way we can. 530 if (source instanceof Spanned) { 531 SpannableStringBuilder sb = new SpannableStringBuilder(","); 532 sb.append(source); 533 return sb; 534 } else { 535 return ", "; 536 } 537 default: 538 // just keep going 539 } 540 } 541 542 // no termination cases were found, so don't edit the input 543 return null; 544 } 545 }; 546 InputFilter[] recipientFilters = new InputFilter[] { recipientFilter }; 547 548 mToView.addTextChangedListener(watcher); 549 mCcView.addTextChangedListener(watcher); 550 mBccView.addTextChangedListener(watcher); 551 mSubjectView.addTextChangedListener(watcher); 552 mMessageContentView.addTextChangedListener(watcher); 553 554 // NOTE: assumes no other filters are set 555 mToView.setFilters(recipientFilters); 556 mCcView.setFilters(recipientFilters); 557 mBccView.setFilters(recipientFilters); 558 559 /* 560 * We set this to invisible by default. Other methods will turn it back on if it's 561 * needed. 562 */ 563 mQuotedTextBar.setVisibility(View.GONE); 564 setIncludeQuotedText(false, false); 565 566 mIncludeQuotedTextCheckBox.setOnClickListener(this); 567 568 EmailAddressValidator addressValidator = new EmailAddressValidator(); 569 570 setupAddressAdapters(); 571 mToView.setAdapter(mAddressAdapterTo); 572 mToView.setTokenizer(new Rfc822Tokenizer()); 573 mToView.setValidator(addressValidator); 574 575 mCcView.setAdapter(mAddressAdapterCc); 576 mCcView.setTokenizer(new Rfc822Tokenizer()); 577 mCcView.setValidator(addressValidator); 578 579 mBccView.setAdapter(mAddressAdapterBcc); 580 mBccView.setTokenizer(new Rfc822Tokenizer()); 581 mBccView.setValidator(addressValidator); 582 583 findViewById(R.id.add_cc_bcc).setOnClickListener(this); 584 findViewById(R.id.add_attachment).setOnClickListener(this); 585 586 setFocusShifter(R.id.to_label, R.id.to); 587 setFocusShifter(R.id.cc_label, R.id.cc); 588 setFocusShifter(R.id.bcc_label, R.id.bcc); 589 setFocusShifter(R.id.subject_label, R.id.subject); 590 setFocusShifter(R.id.tap_trap, R.id.message_content); 591 592 mSubjectView.setOnFocusChangeListener(this); 593 mMessageContentView.setOnFocusChangeListener(this); 594 595 updateAttachmentContainer(); 596 mToView.requestFocus(); 597 } 598 599 /** 600 * Set up address auto-completion adapters. 601 */ 602 private void setupAddressAdapters() { 603 mAddressAdapterTo = new EmailAddressAdapter(this); 604 mAddressAdapterCc = new EmailAddressAdapter(this); 605 mAddressAdapterBcc = new EmailAddressAdapter(this); 606 } 607 608 private class LoadMessageTask extends EmailAsyncTask<Void, Void, Object[]> { 609 private final long mMessageId; 610 611 public LoadMessageTask(long messageId) { 612 super(mTaskTracker); 613 mMessageId = messageId; 614 } 615 616 @Override 617 protected Object[] doInBackground(Void... params) { 618 synchronized (sSaveInProgressCondition) { 619 while (sSaveInProgress) { 620 try { 621 sSaveInProgressCondition.wait(); 622 } catch (InterruptedException e) { 623 // ignore & retry loop 624 } 625 } 626 } 627 Message message = Message.restoreMessageWithId(MessageCompose.this, mMessageId); 628 if (message == null) { 629 return new Object[] {null, null}; 630 } 631 long accountId = message.mAccountKey; 632 Account account = Account.restoreAccountWithId(MessageCompose.this, accountId); 633 try { 634 // Body body = Body.restoreBodyWithMessageId(MessageCompose.this, message.mId); 635 message.mHtml = Body.restoreBodyHtmlWithMessageId(MessageCompose.this, message.mId); 636 message.mText = Body.restoreBodyTextWithMessageId(MessageCompose.this, message.mId); 637 boolean isEditDraft = ACTION_EDIT_DRAFT.equals(mAction); 638 // the reply fields are only filled/used for Drafts. 639 if (isEditDraft) { 640 message.mHtmlReply = 641 Body.restoreReplyHtmlWithMessageId(MessageCompose.this, message.mId); 642 message.mTextReply = 643 Body.restoreReplyTextWithMessageId(MessageCompose.this, message.mId); 644 message.mIntroText = 645 Body.restoreIntroTextWithMessageId(MessageCompose.this, message.mId); 646 message.mSourceKey = Body.restoreBodySourceKey(MessageCompose.this, 647 message.mId); 648 } else { 649 message.mHtmlReply = null; 650 message.mTextReply = null; 651 message.mIntroText = null; 652 } 653 } catch (RuntimeException e) { 654 Log.d(Logging.LOG_TAG, "Exception while loading message body: " + e); 655 return new Object[] {null, null}; 656 } 657 return new Object[]{message, account}; 658 } 659 660 @Override 661 protected void onPostExecute(Object[] messageAndAccount) { 662 if (messageAndAccount == null) { 663 return; 664 } 665 666 final Message message = (Message) messageAndAccount[0]; 667 final Account account = (Account) messageAndAccount[1]; 668 if (message == null && account == null) { 669 // Something unexpected happened: 670 // the message or the body couldn't be loaded by SQLite. 671 // Bail out. 672 Utility.showToast(MessageCompose.this, R.string.error_loading_message_body); 673 finish(); 674 return; 675 } 676 677 // Drafts and "forwards" need to include attachments from the original unless the 678 // account is marked as supporting smart forward 679 final boolean isEditDraft = ACTION_EDIT_DRAFT.equals(mAction); 680 final boolean isForward = ACTION_FORWARD.equals(mAction); 681 if (isEditDraft || isForward) { 682 if (isEditDraft) { 683 mDraft = message; 684 } else { 685 mSource = message; 686 } 687 new EmailAsyncTask<Long, Void, Attachment[]>(mTaskTracker) { 688 @Override 689 protected Attachment[] doInBackground(Long... messageIds) { 690 return Attachment.restoreAttachmentsWithMessageId(MessageCompose.this, 691 messageIds[0]); 692 } 693 @Override 694 protected void onPostExecute(Attachment[] attachments) { 695 if (attachments == null) { 696 return; 697 } 698 final boolean supportsSmartForward = 699 (account.mFlags & Account.FLAGS_SUPPORTS_SMART_FORWARD) != 0; 700 701 for (Attachment attachment : attachments) { 702 if (supportsSmartForward && isForward) { 703 attachment.mFlags |= Attachment.FLAG_SMART_FORWARD; 704 } 705 // Note allowDelete is set in two cases: 706 // 1. First time a message (w/ attachments) is forwarded, 707 // where action == ACTION_FORWARD 708 // 2. 1 -> Save -> Reopen, where action == EDIT_DRAFT, 709 // but FLAG_SMART_FORWARD is already set at 1. 710 // Even if the account supports smart-forward, attachments added 711 // manually are still removable. 712 final boolean allowDelete = 713 (attachment.mFlags & Attachment.FLAG_SMART_FORWARD) == 0; 714 addAttachment(attachment, allowDelete); 715 } 716 } 717 }.executeParallel(message.mId); 718 } else if (ACTION_REPLY.equals(mAction) || ACTION_REPLY_ALL.equals(mAction)) { 719 mSource = message; 720 } else if (Email.LOGD) { 721 Email.log("Action " + mAction + " has unexpected EXTRA_MESSAGE_ID"); 722 } 723 724 setAccount(account); 725 processSourceMessageGuarded(message, mAccount); 726 mMessageLoaded = true; 727 } 728 } 729 730 public void onFocusChange(View view, boolean focused) { 731 if (focused) { 732 switch (view.getId()) { 733 case R.id.message_content: 734 setMessageContentSelection((mAccount != null) ? mAccount.mSignature : null); 735 } 736 } 737 } 738 739 private void addAddresses(MultiAutoCompleteTextView view, Address[] addresses) { 740 if (addresses == null) { 741 return; 742 } 743 for (Address address : addresses) { 744 addAddress(view, address.toString()); 745 } 746 } 747 748 private void addAddresses(MultiAutoCompleteTextView view, String[] addresses) { 749 if (addresses == null) { 750 return; 751 } 752 for (String oneAddress : addresses) { 753 addAddress(view, oneAddress); 754 } 755 } 756 757 private void addAddress(MultiAutoCompleteTextView view, String address) { 758 view.append(address + ", "); 759 } 760 761 private String getPackedAddresses(TextView view) { 762 Address[] addresses = Address.parse(view.getText().toString().trim()); 763 return Address.pack(addresses); 764 } 765 766 private Address[] getAddresses(TextView view) { 767 Address[] addresses = Address.parse(view.getText().toString().trim()); 768 return addresses; 769 } 770 771 /* 772 * Computes a short string indicating the destination of the message based on To, Cc, Bcc. 773 * If only one address appears, returns the friendly form of that address. 774 * Otherwise returns the friendly form of the first address appended with "and N others". 775 */ 776 private String makeDisplayName(String packedTo, String packedCc, String packedBcc) { 777 Address first = null; 778 int nRecipients = 0; 779 for (String packed: new String[] {packedTo, packedCc, packedBcc}) { 780 Address[] addresses = Address.unpack(packed); 781 nRecipients += addresses.length; 782 if (first == null && addresses.length > 0) { 783 first = addresses[0]; 784 } 785 } 786 if (nRecipients == 0) { 787 return ""; 788 } 789 String friendly = first.toFriendly(); 790 if (nRecipients == 1) { 791 return friendly; 792 } 793 return this.getString(R.string.message_compose_display_name, friendly, nRecipients - 1); 794 } 795 796 private ContentValues getUpdateContentValues(Message message) { 797 ContentValues values = new ContentValues(); 798 values.put(MessageColumns.TIMESTAMP, message.mTimeStamp); 799 values.put(MessageColumns.FROM_LIST, message.mFrom); 800 values.put(MessageColumns.TO_LIST, message.mTo); 801 values.put(MessageColumns.CC_LIST, message.mCc); 802 values.put(MessageColumns.BCC_LIST, message.mBcc); 803 values.put(MessageColumns.SUBJECT, message.mSubject); 804 values.put(MessageColumns.DISPLAY_NAME, message.mDisplayName); 805 values.put(MessageColumns.FLAG_READ, message.mFlagRead); 806 values.put(MessageColumns.FLAG_LOADED, message.mFlagLoaded); 807 values.put(MessageColumns.FLAG_ATTACHMENT, message.mFlagAttachment); 808 values.put(MessageColumns.FLAGS, message.mFlags); 809 return values; 810 } 811 812 /** 813 * Updates the given message using values from the compose UI. 814 * 815 * @param message The message to be updated. 816 * @param account the account (used to obtain From: address). 817 * @param hasAttachments true if it has one or more attachment. 818 * @param sending set true if the message is about to sent, in which case we perform final 819 * clean up; 820 */ 821 private void updateMessage(Message message, Account account, boolean hasAttachments, 822 boolean sending) { 823 if (message.mMessageId == null || message.mMessageId.length() == 0) { 824 message.mMessageId = Utility.generateMessageId(); 825 } 826 message.mTimeStamp = System.currentTimeMillis(); 827 message.mFrom = new Address(account.getEmailAddress(), account.getSenderName()).pack(); 828 message.mTo = getPackedAddresses(mToView); 829 message.mCc = getPackedAddresses(mCcView); 830 message.mBcc = getPackedAddresses(mBccView); 831 message.mSubject = mSubjectView.getText().toString(); 832 message.mText = mMessageContentView.getText().toString(); 833 message.mAccountKey = account.mId; 834 message.mDisplayName = makeDisplayName(message.mTo, message.mCc, message.mBcc); 835 message.mFlagRead = true; 836 message.mFlagLoaded = Message.FLAG_LOADED_COMPLETE; 837 message.mFlagAttachment = hasAttachments; 838 // Use the Intent to set flags saying this message is a reply or a forward and save the 839 // unique id of the source message 840 if (mSource != null && mQuotedTextBar.getVisibility() == View.VISIBLE) { 841 // If the quote bar is visible; this must either be a reply or forward 842 message.mSourceKey = mSource.mId; 843 // Get the body of the source message here 844 message.mHtmlReply = mSource.mHtml; 845 message.mTextReply = mSource.mText; 846 String fromAsString = Address.unpackToString(mSource.mFrom); 847 if (ACTION_FORWARD.equals(mAction)) { 848 message.mFlags |= Message.FLAG_TYPE_FORWARD; 849 String subject = mSource.mSubject; 850 String to = Address.unpackToString(mSource.mTo); 851 String cc = Address.unpackToString(mSource.mCc); 852 message.mIntroText = 853 getString(R.string.message_compose_fwd_header_fmt, subject, fromAsString, 854 to != null ? to : "", cc != null ? cc : ""); 855 } else { 856 message.mFlags |= Message.FLAG_TYPE_REPLY; 857 message.mIntroText = 858 getString(R.string.message_compose_reply_header_fmt, fromAsString); 859 } 860 } 861 862 if (includeQuotedText()) { 863 message.mFlags &= ~Message.FLAG_NOT_INCLUDE_QUOTED_TEXT; 864 } else { 865 message.mFlags |= Message.FLAG_NOT_INCLUDE_QUOTED_TEXT; 866 if (sending) { 867 // If we are about to send a message, and not including the original message, 868 // clear the related field. 869 // We can't do this until the last minutes, so that the user can change their 870 // mind later and want to include it again. 871 mDraft.mIntroText = null; 872 mDraft.mTextReply = null; 873 mDraft.mHtmlReply = null; 874 mDraft.mSourceKey = 0; 875 mDraft.mFlags &= ~Message.FLAG_TYPE_MASK; 876 } 877 } 878 } 879 880 private Attachment[] getAttachmentsFromUI() { 881 int count = mAttachments.getChildCount(); 882 Attachment[] attachments = new Attachment[count]; 883 for (int i = 0; i < count; ++i) { 884 attachments[i] = (Attachment) mAttachments.getChildAt(i).getTag(); 885 } 886 return attachments; 887 } 888 889 /* This method does DB operations in UI thread because 890 the draftId is needed by onSaveInstanceState() which can't wait for it 891 to be saved in the background. 892 TODO: This will cause ANRs, so we need to find a better solution. 893 */ 894 private long getOrCreateDraftId() { 895 synchronized (mDraft) { 896 if (mDraft.mId > 0) { 897 return mDraft.mId; 898 } 899 // don't save draft if the source message did not load yet 900 if (!mMessageLoaded) { 901 return -1; 902 } 903 final Attachment[] attachments = getAttachmentsFromUI(); 904 updateMessage(mDraft, mAccount, attachments.length > 0, false); 905 mController.saveToMailbox(mDraft, EmailContent.Mailbox.TYPE_DRAFTS); 906 return mDraft.mId; 907 } 908 } 909 910 private class SendOrSaveMessageTask extends EmailAsyncTask<Void, Void, Void> { 911 private final boolean mSend; 912 913 public SendOrSaveMessageTask(boolean send) { 914 super(null /* DO NOT cancel in onDestroy */); 915 if (send && ActivityManager.isUserAMonkey()) { 916 Log.d(Logging.LOG_TAG, "Inhibiting send while monkey is in charge."); 917 send = false; 918 } 919 mSend = send; 920 } 921 922 @Override 923 protected Void doInBackground(Void... params) { 924 synchronized (mDraft) { 925 final Attachment[] attachments = getAttachmentsFromUI(); 926 updateMessage(mDraft, mAccount, attachments.length > 0, mSend); 927 ContentResolver resolver = getContentResolver(); 928 if (mDraft.isSaved()) { 929 // Update the message 930 Uri draftUri = 931 ContentUris.withAppendedId(Message.SYNCED_CONTENT_URI, mDraft.mId); 932 resolver.update(draftUri, getUpdateContentValues(mDraft), null, null); 933 // Update the body 934 ContentValues values = new ContentValues(); 935 values.put(BodyColumns.TEXT_CONTENT, mDraft.mText); 936 values.put(BodyColumns.TEXT_REPLY, mDraft.mTextReply); 937 values.put(BodyColumns.HTML_REPLY, mDraft.mHtmlReply); 938 values.put(BodyColumns.INTRO_TEXT, mDraft.mIntroText); 939 values.put(BodyColumns.SOURCE_MESSAGE_KEY, mDraft.mSourceKey); 940 Body.updateBodyWithMessageId(MessageCompose.this, mDraft.mId, values); 941 } else { 942 // mDraft.mId is set upon return of saveToMailbox() 943 mController.saveToMailbox(mDraft, EmailContent.Mailbox.TYPE_DRAFTS); 944 } 945 // For any unloaded attachment, set the flag saying we need it loaded 946 boolean hasUnloadedAttachments = false; 947 for (Attachment attachment : attachments) { 948 if (attachment.mContentUri == null && 949 ((attachment.mFlags & Attachment.FLAG_SMART_FORWARD) == 0)) { 950 attachment.mFlags |= Attachment.FLAG_DOWNLOAD_FORWARD; 951 hasUnloadedAttachments = true; 952 if (Email.DEBUG){ 953 Log.d("MessageCompose", 954 "Requesting download of attachment #" + attachment.mId); 955 } 956 } 957 // Make sure the UI version of the attachment has the now-correct id; we will 958 // use the id again when coming back from picking new attachments 959 if (!attachment.isSaved()) { 960 // this attachment is new so save it to DB. 961 attachment.mMessageKey = mDraft.mId; 962 attachment.save(MessageCompose.this); 963 } else if (attachment.mMessageKey != mDraft.mId) { 964 // We clone the attachment and save it again; otherwise, it will 965 // continue to point to the source message. From this point forward, 966 // the attachments will be independent of the original message in the 967 // database; however, we still need the message on the server in order 968 // to retrieve unloaded attachments 969 attachment.mMessageKey = mDraft.mId; 970 ContentValues cv = attachment.toContentValues(); 971 cv.put(Attachment.FLAGS, attachment.mFlags); 972 cv.put(Attachment.MESSAGE_KEY, mDraft.mId); 973 getContentResolver().insert(Attachment.CONTENT_URI, cv); 974 } 975 } 976 977 if (mSend) { 978 // Let the user know if message sending might be delayed by background 979 // downlading of unloaded attachments 980 if (hasUnloadedAttachments) { 981 Utility.showToast(MessageCompose.this, 982 R.string.message_view_attachment_background_load); 983 } 984 mController.sendMessage(mDraft.mId, mDraft.mAccountKey); 985 } 986 return null; 987 } 988 } 989 990 @Override 991 protected void onPostExecute(Void param) { 992 synchronized (sSaveInProgressCondition) { 993 sSaveInProgress = false; 994 sSaveInProgressCondition.notify(); 995 } 996 // Don't display the toast if the user is just changing the orientation 997 if (!mSend && (getChangingConfigurations() & ActivityInfo.CONFIG_ORIENTATION) == 0) { 998 Toast.makeText(MessageCompose.this, R.string.message_saved_toast, 999 Toast.LENGTH_LONG).show(); 1000 } 1001 } 1002 } 1003 1004 /** 1005 * Send or save a message: 1006 * - out of the UI thread 1007 * - write to Drafts 1008 * - if send, invoke Controller.sendMessage() 1009 * - when operation is complete, display toast 1010 */ 1011 private void sendOrSaveMessage(boolean send) { 1012 if (!mMessageLoaded) { 1013 // early save, before the message was loaded: do nothing 1014 return; 1015 } 1016 synchronized (sSaveInProgressCondition) { 1017 sSaveInProgress = true; 1018 } 1019 new SendOrSaveMessageTask(send).executeParallel(); 1020 } 1021 1022 private void saveIfNeeded() { 1023 if (!mDraftNeedsSaving) { 1024 return; 1025 } 1026 setDraftNeedsSaving(false); 1027 sendOrSaveMessage(false); 1028 } 1029 1030 /** 1031 * Checks whether all the email addresses listed in TO, CC, BCC are valid. 1032 */ 1033 /* package */ boolean isAddressAllValid() { 1034 for (TextView view : new TextView[]{mToView, mCcView, mBccView}) { 1035 String addresses = view.getText().toString().trim(); 1036 if (!Address.isAllValid(addresses)) { 1037 view.setError(getString(R.string.message_compose_error_invalid_email)); 1038 return false; 1039 } 1040 } 1041 return true; 1042 } 1043 1044 private void onSend() { 1045 if (!isAddressAllValid()) { 1046 Toast.makeText(this, getString(R.string.message_compose_error_invalid_email), 1047 Toast.LENGTH_LONG).show(); 1048 } else if (getAddresses(mToView).length == 0 && 1049 getAddresses(mCcView).length == 0 && 1050 getAddresses(mBccView).length == 0) { 1051 mToView.setError(getString(R.string.message_compose_error_no_recipients)); 1052 Toast.makeText(this, getString(R.string.message_compose_error_no_recipients), 1053 Toast.LENGTH_LONG).show(); 1054 } else { 1055 sendOrSaveMessage(true); 1056 setDraftNeedsSaving(false); 1057 finish(); 1058 } 1059 } 1060 1061 private void onDiscard() { 1062 DeleteMessageConfirmationDialog.newInstance(1, null).show(getFragmentManager(), "dialog"); 1063 } 1064 1065 /** 1066 * Called when ok on the "discard draft" dialog is pressed. Actually delete the draft. 1067 */ 1068 @Override 1069 public void onDeleteMessageConfirmationDialogOkPressed() { 1070 if (mDraft.mId > 0) { 1071 // By the way, we can't pass the message ID from onDiscard() to here (using a 1072 // dialog argument or whatever), because you can rotate the screen when the dialog is 1073 // shown, and during rotation we save & restore the draft. If it's the 1074 // first save, we give it an ID at this point for the first time (and last time). 1075 // Which means it's possible for a draft to not have an ID in onDiscard(), 1076 // but here. 1077 mController.deleteMessage(mDraft.mId, mDraft.mAccountKey); 1078 } 1079 Utility.showToast(MessageCompose.this, R.string.message_discarded_toast); 1080 setDraftNeedsSaving(false); 1081 finish(); 1082 } 1083 1084 private void onSave() { 1085 saveIfNeeded(); 1086 finish(); 1087 } 1088 1089 private void showCcBccFieldsIfFilled() { 1090 if ((mCcView.length() > 0) || (mBccView.length() > 0)) { 1091 showCcBccFields(); 1092 } 1093 } 1094 1095 private void showCcBccFields() { 1096 mCcBccContainer.setVisibility(View.VISIBLE); 1097 findViewById(R.id.add_cc_bcc).setVisibility(View.INVISIBLE); 1098 } 1099 1100 /** 1101 * Kick off a picker for whatever kind of MIME types we'll accept and let Android take over. 1102 */ 1103 private void onAddAttachment() { 1104 Intent i = new Intent(Intent.ACTION_GET_CONTENT); 1105 i.addCategory(Intent.CATEGORY_OPENABLE); 1106 i.setType(AttachmentUtilities.ACCEPTABLE_ATTACHMENT_SEND_UI_TYPES[0]); 1107 startActivityForResult( 1108 Intent.createChooser(i, getString(R.string.choose_attachment_dialog_title)), 1109 ACTIVITY_REQUEST_PICK_ATTACHMENT); 1110 } 1111 1112 private Attachment loadAttachmentInfo(Uri uri) { 1113 long size = -1; 1114 ContentResolver contentResolver = getContentResolver(); 1115 1116 // Load name & size independently, because not all providers support both 1117 final String name = Utility.getContentFileName(this, uri); 1118 1119 Cursor metadataCursor = contentResolver.query(uri, ATTACHMENT_META_SIZE_PROJECTION, 1120 null, null, null); 1121 if (metadataCursor != null) { 1122 try { 1123 if (metadataCursor.moveToFirst()) { 1124 size = metadataCursor.getLong(ATTACHMENT_META_SIZE_COLUMN_SIZE); 1125 } 1126 } finally { 1127 metadataCursor.close(); 1128 } 1129 } 1130 1131 // When the size is not provided, we need to determine it locally. 1132 if (size < 0) { 1133 // if the URI is a file: URI, ask file system for its size 1134 if ("file".equalsIgnoreCase(uri.getScheme())) { 1135 String path = uri.getPath(); 1136 if (path != null) { 1137 File file = new File(path); 1138 size = file.length(); // Returns 0 for file not found 1139 } 1140 } 1141 1142 if (size <= 0) { 1143 // The size was not measurable; This attachment is not safe to use. 1144 // Quick hack to force a relevant error into the UI 1145 // TODO: A proper announcement of the problem 1146 size = AttachmentUtilities.MAX_ATTACHMENT_UPLOAD_SIZE + 1; 1147 } 1148 } 1149 1150 Attachment attachment = new Attachment(); 1151 attachment.mFileName = name; 1152 attachment.mContentUri = uri.toString(); 1153 attachment.mSize = size; 1154 attachment.mMimeType = AttachmentUtilities.inferMimeTypeForUri(this, uri); 1155 return attachment; 1156 } 1157 1158 private void addAttachment(Attachment attachment, boolean allowDelete) { 1159 // Before attaching the attachment, make sure it meets any other pre-attach criteria 1160 if (attachment.mSize > AttachmentUtilities.MAX_ATTACHMENT_UPLOAD_SIZE) { 1161 Toast.makeText(this, R.string.message_compose_attachment_size, Toast.LENGTH_LONG) 1162 .show(); 1163 return; 1164 } 1165 1166 View view = getLayoutInflater().inflate(R.layout.message_compose_attachment, 1167 mAttachments, false); 1168 TextView nameView = (TextView)view.findViewById(R.id.attachment_name); 1169 ImageButton delete = (ImageButton)view.findViewById(R.id.attachment_delete); 1170 TextView sizeView = (TextView)view.findViewById(R.id.attachment_size); 1171 1172 nameView.setText(attachment.mFileName); 1173 sizeView.setText(UiUtilities.formatSize(this, attachment.mSize)); 1174 if (allowDelete) { 1175 delete.setOnClickListener(this); 1176 delete.setTag(view); 1177 } else { 1178 delete.setVisibility(View.INVISIBLE); 1179 } 1180 view.setTag(attachment); 1181 mAttachments.addView(view); 1182 updateAttachmentContainer(); 1183 } 1184 1185 private void updateAttachmentContainer() { 1186 mAttachmentContainer.setVisibility(mAttachments.getChildCount() == 0 1187 ? View.GONE : View.VISIBLE); 1188 } 1189 1190 private void addAttachmentFromUri(Uri uri) { 1191 addAttachment(loadAttachmentInfo(uri), true); 1192 } 1193 1194 /** 1195 * Same as {@link #addAttachmentFromUri}, but does the mime-type check against 1196 * {@link AttachmentUtilities#ACCEPTABLE_ATTACHMENT_SEND_INTENT_TYPES}. 1197 */ 1198 private void addAttachmentFromSendIntent(Uri uri) { 1199 final Attachment attachment = loadAttachmentInfo(uri); 1200 final String mimeType = attachment.mMimeType; 1201 if (!TextUtils.isEmpty(mimeType) && MimeUtility.mimeTypeMatches(mimeType, 1202 AttachmentUtilities.ACCEPTABLE_ATTACHMENT_SEND_INTENT_TYPES)) { 1203 addAttachment(attachment, true); 1204 } 1205 } 1206 1207 @Override 1208 protected void onActivityResult(int requestCode, int resultCode, Intent data) { 1209 if (data == null) { 1210 return; 1211 } 1212 addAttachmentFromUri(data.getData()); 1213 setDraftNeedsSaving(true); 1214 } 1215 1216 private boolean includeQuotedText() { 1217 return mIncludeQuotedTextCheckBox.isChecked(); 1218 } 1219 1220 public void onClick(View view) { 1221 if (handleCommand(view.getId())) { 1222 return; 1223 } 1224 switch (view.getId()) { 1225 case R.id.attachment_delete: 1226 onDeleteAttachment(view); // Needs a view; can't be a menu item 1227 break; 1228 } 1229 } 1230 1231 private void setIncludeQuotedText(boolean include, boolean updateNeedsSaving) { 1232 mIncludeQuotedTextCheckBox.setChecked(include); 1233 mQuotedText.setVisibility(mIncludeQuotedTextCheckBox.isChecked() 1234 ? View.VISIBLE : View.GONE); 1235 if (updateNeedsSaving) { 1236 setDraftNeedsSaving(true); 1237 } 1238 } 1239 1240 private void onDeleteAttachment(View delButtonView) { 1241 /* 1242 * The view is the delete button, and we have previously set the tag of 1243 * the delete button to the view that owns it. We don't use parent because the 1244 * view is very complex and could change in the future. 1245 */ 1246 View attachmentView = (View) delButtonView.getTag(); 1247 Attachment attachment = (Attachment) attachmentView.getTag(); 1248 mAttachments.removeView(attachmentView); 1249 updateAttachmentContainer(); 1250 if (attachment.mMessageKey == mDraft.mId && attachment.isSaved()) { 1251 final long attachmentId = attachment.mId; 1252 EmailAsyncTask.runAsyncParallel(new Runnable() { 1253 @Override 1254 public void run() { 1255 mController.deleteAttachment(attachmentId); 1256 } 1257 }); 1258 } 1259 setDraftNeedsSaving(true); 1260 } 1261 1262 @Override 1263 public boolean onOptionsItemSelected(MenuItem item) { 1264 if (handleCommand(item.getItemId())) { 1265 return true; 1266 } 1267 return super.onOptionsItemSelected(item); 1268 } 1269 1270 private boolean handleCommand(int viewId) { 1271 switch (viewId) { 1272 case android.R.id.home: 1273 onActionBarHomePressed(); 1274 return true; 1275 case R.id.send: 1276 onSend(); 1277 return true; 1278 case R.id.save: 1279 onSave(); 1280 return true; 1281 case R.id.discard: 1282 onDiscard(); 1283 return true; 1284 case R.id.include_quoted_text: 1285 // The checkbox is already toggled at this point. 1286 setIncludeQuotedText(mIncludeQuotedTextCheckBox.isChecked(), true); 1287 return true; 1288 case R.id.add_cc_bcc: 1289 showCcBccFields(); 1290 return true; 1291 case R.id.add_attachment: 1292 onAddAttachment(); 1293 return true; 1294 } 1295 return false; 1296 } 1297 1298 private void onActionBarHomePressed() { 1299 finish(); 1300 if (isOpenedFromWithinApp()) { 1301 // If opend from within the app, we just close it. 1302 } else { 1303 // Otherwise, need to open the main screen. Let Welcome do that. 1304 Welcome.actionStart(this); 1305 } 1306 } 1307 1308 @Override 1309 public boolean onCreateOptionsMenu(Menu menu) { 1310 super.onCreateOptionsMenu(menu); 1311 getMenuInflater().inflate(R.menu.message_compose_option, menu); 1312 return true; 1313 } 1314 1315 @Override 1316 public boolean onPrepareOptionsMenu(Menu menu) { 1317 menu.findItem(R.id.save).setEnabled(mSaveEnabled); 1318 return true; 1319 } 1320 1321 /** 1322 * Set a message body and a signature when the Activity is launched. 1323 * 1324 * @param text the message body 1325 */ 1326 /* package */ void setInitialComposeText(CharSequence text, String signature) { 1327 int textLength = 0; 1328 if (text != null) { 1329 mMessageContentView.append(text); 1330 textLength = text.length(); 1331 } 1332 if (!TextUtils.isEmpty(signature)) { 1333 if (textLength == 0 || text.charAt(textLength - 1) != '\n') { 1334 mMessageContentView.append("\n"); 1335 } 1336 mMessageContentView.append(signature); 1337 } 1338 } 1339 1340 /** 1341 * Fill all the widgets with the content found in the Intent Extra, if any. 1342 * 1343 * Note that we don't actually check the intent action (typically VIEW, SENDTO, or SEND). 1344 * There is enough overlap in the definitions that it makes more sense to simply check for 1345 * all available data and use as much of it as possible. 1346 * 1347 * With one exception: EXTRA_STREAM is defined as only valid for ACTION_SEND. 1348 * 1349 * @param intent the launch intent 1350 */ 1351 /* package */ void initFromIntent(Intent intent) { 1352 1353 // First, add values stored in top-level extras 1354 String[] extraStrings = intent.getStringArrayExtra(Intent.EXTRA_EMAIL); 1355 if (extraStrings != null) { 1356 addAddresses(mToView, extraStrings); 1357 } 1358 extraStrings = intent.getStringArrayExtra(Intent.EXTRA_CC); 1359 if (extraStrings != null) { 1360 addAddresses(mCcView, extraStrings); 1361 } 1362 extraStrings = intent.getStringArrayExtra(Intent.EXTRA_BCC); 1363 if (extraStrings != null) { 1364 addAddresses(mBccView, extraStrings); 1365 } 1366 String extraString = intent.getStringExtra(Intent.EXTRA_SUBJECT); 1367 if (extraString != null) { 1368 mSubjectView.setText(extraString); 1369 } 1370 1371 // Next, if we were invoked with a URI, try to interpret it 1372 // We'll take two courses here. If it's mailto:, there is a specific set of rules 1373 // that define various optional fields. However, for any other scheme, we'll simply 1374 // take the entire scheme-specific part and interpret it as a possible list of addresses. 1375 final Uri dataUri = intent.getData(); 1376 if (dataUri != null) { 1377 if ("mailto".equals(dataUri.getScheme())) { 1378 initializeFromMailTo(dataUri.toString()); 1379 } else { 1380 String toText = dataUri.getSchemeSpecificPart(); 1381 if (toText != null) { 1382 addAddresses(mToView, toText.split(",")); 1383 } 1384 } 1385 } 1386 1387 // Next, fill in the plaintext (note, this will override mailto:?body=) 1388 CharSequence text = intent.getCharSequenceExtra(Intent.EXTRA_TEXT); 1389 if (text != null) { 1390 setInitialComposeText(text, null); 1391 } 1392 1393 // Next, convert EXTRA_STREAM into an attachment 1394 if (Intent.ACTION_SEND.equals(mAction) && intent.hasExtra(Intent.EXTRA_STREAM)) { 1395 Uri uri = (Uri) intent.getParcelableExtra(Intent.EXTRA_STREAM); 1396 if (uri != null) { 1397 addAttachmentFromSendIntent(uri); 1398 } 1399 } 1400 1401 if (Intent.ACTION_SEND_MULTIPLE.equals(mAction) 1402 && intent.hasExtra(Intent.EXTRA_STREAM)) { 1403 ArrayList<Parcelable> list = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM); 1404 if (list != null) { 1405 for (Parcelable parcelable : list) { 1406 Uri uri = (Uri) parcelable; 1407 if (uri != null) { 1408 addAttachmentFromSendIntent(uri); 1409 } 1410 } 1411 } 1412 } 1413 1414 // Finally - expose fields that were filled in but are normally hidden, and set focus 1415 showCcBccFieldsIfFilled(); 1416 setNewMessageFocus(); 1417 setDraftNeedsSaving(false); 1418 } 1419 1420 /** 1421 * When we are launched with an intent that includes a mailto: URI, we can actually 1422 * gather quite a few of our message fields from it. 1423 * 1424 * @mailToString the href (which must start with "mailto:"). 1425 */ 1426 private void initializeFromMailTo(String mailToString) { 1427 1428 // Chop up everything between mailto: and ? to find recipients 1429 int index = mailToString.indexOf("?"); 1430 int length = "mailto".length() + 1; 1431 String to; 1432 try { 1433 // Extract the recipient after mailto: 1434 if (index == -1) { 1435 to = decode(mailToString.substring(length)); 1436 } else { 1437 to = decode(mailToString.substring(length, index)); 1438 } 1439 addAddresses(mToView, to.split(" ,")); 1440 } catch (UnsupportedEncodingException e) { 1441 Log.e(Logging.LOG_TAG, e.getMessage() + " while decoding '" + mailToString + "'"); 1442 } 1443 1444 // Extract the other parameters 1445 1446 // We need to disguise this string as a URI in order to parse it 1447 Uri uri = Uri.parse("foo://" + mailToString); 1448 1449 List<String> cc = uri.getQueryParameters("cc"); 1450 addAddresses(mCcView, cc.toArray(new String[cc.size()])); 1451 1452 List<String> otherTo = uri.getQueryParameters("to"); 1453 addAddresses(mCcView, otherTo.toArray(new String[otherTo.size()])); 1454 1455 List<String> bcc = uri.getQueryParameters("bcc"); 1456 addAddresses(mBccView, bcc.toArray(new String[bcc.size()])); 1457 1458 List<String> subject = uri.getQueryParameters("subject"); 1459 if (subject.size() > 0) { 1460 mSubjectView.setText(subject.get(0)); 1461 } 1462 1463 List<String> body = uri.getQueryParameters("body"); 1464 if (body.size() > 0) { 1465 setInitialComposeText(body.get(0), (mAccount != null) ? mAccount.mSignature : null); 1466 } 1467 } 1468 1469 private String decode(String s) throws UnsupportedEncodingException { 1470 return URLDecoder.decode(s, "UTF-8"); 1471 } 1472 1473 /** 1474 * Displays quoted text from the original email 1475 */ 1476 private void displayQuotedText(String textBody, String htmlBody) { 1477 // Only use plain text if there is no HTML body 1478 boolean plainTextFlag = TextUtils.isEmpty(htmlBody); 1479 String text = plainTextFlag ? textBody : htmlBody; 1480 if (text != null) { 1481 text = plainTextFlag ? EmailHtmlUtil.escapeCharacterToDisplay(text) : text; 1482 // TODO: re-enable EmailHtmlUtil.resolveInlineImage() for HTML 1483 // EmailHtmlUtil.resolveInlineImage(getContentResolver(), mAccount, 1484 // text, message, 0); 1485 mQuotedTextBar.setVisibility(View.VISIBLE); 1486 if (mQuotedText != null) { 1487 mQuotedText.loadDataWithBaseURL("email://", text, "text/html", "utf-8", null); 1488 } 1489 } 1490 } 1491 1492 /** 1493 * Given a packed address String, the address of our sending account, a view, and a list of 1494 * addressees already added to other addressing views, adds unique addressees that don't 1495 * match our address to the passed in view 1496 */ 1497 private boolean safeAddAddresses(String addrs, String ourAddress, 1498 MultiAutoCompleteTextView view, ArrayList<Address> addrList) { 1499 boolean added = false; 1500 for (Address address : Address.unpack(addrs)) { 1501 // Don't send to ourselves or already-included addresses 1502 if (!address.getAddress().equalsIgnoreCase(ourAddress) && !addrList.contains(address)) { 1503 addrList.add(address); 1504 addAddress(view, address.toString()); 1505 added = true; 1506 } 1507 } 1508 return added; 1509 } 1510 1511 /** 1512 * Set up the to and cc views properly for the "reply" and "replyAll" cases. What's important 1513 * is that we not 1) send to ourselves, and 2) duplicate addressees. 1514 * @param message the message we're replying to 1515 * @param account the account we're sending from 1516 * @param toView the "To" view 1517 * @param ccView the "Cc" view 1518 * @param replyAll whether this is a replyAll (vs a reply) 1519 */ 1520 /*package*/ void setupAddressViews(Message message, Account account, 1521 MultiAutoCompleteTextView toView, MultiAutoCompleteTextView ccView, boolean replyAll) { 1522 /* 1523 * If a reply-to was included with the message use that, otherwise use the from 1524 * or sender address. 1525 */ 1526 Address[] replyToAddresses = Address.unpack(message.mReplyTo); 1527 if (replyToAddresses.length == 0) { 1528 replyToAddresses = Address.unpack(message.mFrom); 1529 } 1530 addAddresses(mToView, replyToAddresses); 1531 1532 if (replyAll) { 1533 // Keep a running list of addresses we're sending to 1534 ArrayList<Address> allAddresses = new ArrayList<Address>(); 1535 String ourAddress = account.mEmailAddress; 1536 1537 for (Address address: replyToAddresses) { 1538 allAddresses.add(address); 1539 } 1540 1541 safeAddAddresses(message.mTo, ourAddress, mToView, allAddresses); 1542 safeAddAddresses(message.mCc, ourAddress, mCcView, allAddresses); 1543 } 1544 showCcBccFieldsIfFilled(); 1545 } 1546 1547 void processSourceMessageGuarded(Message message, Account account) { 1548 // Make sure we only do this once (otherwise we'll duplicate addresses!) 1549 if (!mSourceMessageProcessed) { 1550 processSourceMessage(message, account); 1551 mSourceMessageProcessed = true; 1552 } 1553 1554 /* The quoted text is displayed in a WebView whose content is not automatically 1555 * saved/restored by onRestoreInstanceState(), so we need to *always* restore it here, 1556 * regardless of the value of mSourceMessageProcessed. 1557 * This only concerns EDIT_DRAFT because after a configuration change we're always 1558 * in EDIT_DRAFT. 1559 */ 1560 if (ACTION_EDIT_DRAFT.equals(mAction)) { 1561 displayQuotedText(message.mTextReply, message.mHtmlReply); 1562 setIncludeQuotedText((mDraft.mFlags & Message.FLAG_NOT_INCLUDE_QUOTED_TEXT) == 0, 1563 false); 1564 } 1565 } 1566 1567 /** 1568 * Pull out the parts of the now loaded source message and apply them to the new message 1569 * depending on the type of message being composed. 1570 * @param message 1571 */ 1572 /* package */ 1573 void processSourceMessage(Message message, Account account) { 1574 setDraftNeedsSaving(true); 1575 final String subject = message.mSubject; 1576 if (ACTION_REPLY.equals(mAction) || ACTION_REPLY_ALL.equals(mAction)) { 1577 setupAddressViews(message, account, mToView, mCcView, 1578 ACTION_REPLY_ALL.equals(mAction)); 1579 if (subject != null && !subject.toLowerCase().startsWith("re:")) { 1580 mSubjectView.setText("Re: " + subject); 1581 } else { 1582 mSubjectView.setText(subject); 1583 } 1584 displayQuotedText(message.mText, message.mHtml); 1585 setIncludeQuotedText(true, false); 1586 setInitialComposeText(null, (account != null) ? account.mSignature : null); 1587 } else if (ACTION_FORWARD.equals(mAction)) { 1588 mSubjectView.setText(subject != null && !subject.toLowerCase().startsWith("fwd:") ? 1589 "Fwd: " + subject : subject); 1590 displayQuotedText(message.mText, message.mHtml); 1591 setIncludeQuotedText(true, false); 1592 setInitialComposeText(null, (account != null) ? account.mSignature : null); 1593 } else if (ACTION_EDIT_DRAFT.equals(mAction)) { 1594 mSubjectView.setText(subject); 1595 addAddresses(mToView, Address.unpack(message.mTo)); 1596 Address[] cc = Address.unpack(message.mCc); 1597 if (cc.length > 0) { 1598 addAddresses(mCcView, cc); 1599 } 1600 Address[] bcc = Address.unpack(message.mBcc); 1601 if (bcc.length > 0) { 1602 addAddresses(mBccView, bcc); 1603 } 1604 1605 mMessageContentView.setText(message.mText); 1606 // TODO: re-enable loadAttachments 1607 // loadAttachments(message, 0); 1608 setDraftNeedsSaving(false); 1609 } 1610 showCcBccFieldsIfFilled(); 1611 setNewMessageFocus(); 1612 } 1613 1614 /** 1615 * Set a cursor to the end of a body except a signature 1616 */ 1617 /* package */ void setMessageContentSelection(String signature) { 1618 // when selecting the message content, explicitly move IP to the end of the message, 1619 // so you can quickly resume typing into a draft 1620 int selection = mMessageContentView.length(); 1621 if (!TextUtils.isEmpty(signature)) { 1622 int signatureLength = signature.length(); 1623 int estimatedSelection = selection - signatureLength; 1624 if (estimatedSelection >= 0) { 1625 CharSequence text = mMessageContentView.getText(); 1626 int i = 0; 1627 while (i < signatureLength 1628 && text.charAt(estimatedSelection + i) == signature.charAt(i)) { 1629 ++i; 1630 } 1631 if (i == signatureLength) { 1632 selection = estimatedSelection; 1633 while (selection > 0 && text.charAt(selection - 1) == '\n') { 1634 --selection; 1635 } 1636 } 1637 } 1638 } 1639 mMessageContentView.setSelection(selection, selection); 1640 } 1641 1642 /** 1643 * In order to accelerate typing, position the cursor in the first empty field, 1644 * or at the end of the body composition field if none are empty. Typically, this will 1645 * play out as follows: 1646 * Reply / Reply All - put cursor in the empty message body 1647 * Forward - put cursor in the empty To field 1648 * Edit Draft - put cursor in whatever field still needs entry 1649 */ 1650 private void setNewMessageFocus() { 1651 if (mToView.length() == 0) { 1652 mToView.requestFocus(); 1653 } else if (mSubjectView.length() == 0) { 1654 mSubjectView.requestFocus(); 1655 } else { 1656 mMessageContentView.requestFocus(); 1657 setMessageContentSelection((mAccount != null) ? mAccount.mSignature : null); 1658 } 1659 } 1660} 1661