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