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