ComposeActivity.java revision 30e2c24b056542f3b1b438aeb798305d1226d0c8
1/** 2 * Copyright (c) 2011, Google Inc. 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.mail.compose; 18 19import android.accounts.Account; 20import android.app.ActionBar; 21import android.app.ActionBar.OnNavigationListener; 22import android.app.Activity; 23import android.content.ContentResolver; 24import android.content.Context; 25import android.content.Intent; 26import android.database.Cursor; 27import android.database.sqlite.SQLiteException; 28import android.net.Uri; 29import android.os.Bundle; 30import android.os.ParcelFileDescriptor; 31import android.provider.OpenableColumns; 32import android.provider.Settings; 33import android.text.Editable; 34import android.text.TextUtils; 35import android.text.util.Rfc822Token; 36import android.text.util.Rfc822Tokenizer; 37import android.view.Gravity; 38import android.view.LayoutInflater; 39import android.view.Menu; 40import android.view.MenuInflater; 41import android.view.MenuItem; 42import android.view.View; 43import android.view.ViewGroup; 44import android.view.View.OnClickListener; 45import android.widget.AdapterView; 46import android.widget.AdapterView.OnItemSelectedListener; 47import android.widget.ArrayAdapter; 48import android.widget.Button; 49import android.widget.Spinner; 50import android.widget.TextView; 51import android.widget.Toast; 52 53import com.android.common.Rfc822Validator; 54import com.android.mail.compose.QuotedTextView.RespondInlineListener; 55import com.android.mail.providers.Address; 56import com.android.mail.providers.Attachment; 57import com.android.mail.providers.UIProvider; 58import com.android.mail.providers.protos.mock.MockAttachment; 59import com.android.mail.R; 60import com.android.mail.utils.AccountUtils; 61import com.android.mail.utils.LogUtils; 62import com.android.mail.utils.MimeType; 63import com.android.mail.utils.Utils; 64import com.android.ex.chips.RecipientEditTextView; 65import com.google.common.annotations.VisibleForTesting; 66import com.google.common.collect.Sets; 67 68import java.io.FileNotFoundException; 69import java.io.IOException; 70import java.text.DateFormat; 71import java.util.ArrayList; 72import java.util.Arrays; 73import java.util.Collection; 74import java.util.Collections; 75import java.util.Date; 76import java.util.HashSet; 77import java.util.List; 78import java.util.Set; 79 80public class ComposeActivity extends Activity implements OnClickListener, OnNavigationListener, 81 RespondInlineListener, OnItemSelectedListener { 82 // Identifiers for which type of composition this is 83 static final int COMPOSE = -1; // also used for editing a draft 84 static final int REPLY = 0; 85 static final int REPLY_ALL = 1; 86 static final int FORWARD = 2; 87 88 // HTML tags used to quote reply content 89 // The following style must be in-sync with 90 // pinto.app.MessageUtil.QUOTE_STYLE and 91 // java/com/google/caribou/ui/pinto/modules/app/messageutil.js 92 // BEG_QUOTE_BIDI is also available there when we support BIDI 93 private static final String BLOCKQUOTE_BEGIN = "<blockquote class=\"quote\" style=\"" 94 + "margin:0 0 0 .8ex;" + "border-left:1px #ccc solid;" + "padding-left:1ex\">"; 95 private static final String BLOCKQUOTE_END = "</blockquote>"; 96 // HTML tags used to quote replies & forwards 97 /* package for testing */static final String QUOTE_BEGIN = "<div class=\"quote\">"; 98 private static final String QUOTE_END = "</div>"; 99 // Separates the attribution headers (Subject, To, etc) from the body in 100 // quoted text. 101 /* package for testing */ static final String HEADER_SEPARATOR = "<br type='attribution'>"; 102 private static final int HEADER_SEPARATOR_LENGTH = HEADER_SEPARATOR.length(); 103 104 // Integer extra holding one of the above compose action 105 private static final String EXTRA_ACTION = "action"; 106 107 /** 108 * Notifies the {@code Activity} that the caller is an Email 109 * {@code Activity}, so that the back behavior may be modified accordingly. 110 * 111 * @see #onAppUpPressed 112 */ 113 private static final String EXTRA_FROM_EMAIL_TASK = "fromemail"; 114 115 // If this is a reply/forward then this extra will hold the original message uri 116 private static final String EXTRA_IN_REFERENCE_TO_MESSAGE_URI = "in-reference-to-uri"; 117 private static final String END_TOKEN = ", "; 118 private static final String LOG_TAG = new LogUtils().getLogTag(); 119 // Request numbers for activities we start 120 private static final int RESULT_PICK_ATTACHMENT = 1; 121 private static final int RESULT_CREATE_ACCOUNT = 2; 122 123 private RecipientEditTextView mTo; 124 private RecipientEditTextView mCc; 125 private RecipientEditTextView mBcc; 126 private Button mCcBccButton; 127 private CcBccView mCcBccView; 128 private AttachmentsView mAttachmentsView; 129 private String mAccount; 130 private Rfc822Validator mRecipientValidator; 131 private Uri mRefMessageUri; 132 private TextView mSubject; 133 134 private ActionBar mActionBar; 135 private ComposeModeAdapter mComposeModeAdapter; 136 private int mComposeMode = -1; 137 private boolean mForward; 138 private String mRecipient; 139 private boolean mAttachmentsChanged; 140 private QuotedTextView mQuotedTextView; 141 private TextView mBodyText; 142 private View mFromStatic; 143 private View mFromSpinner; 144 private Spinner mFrom; 145 private List<String[]> mReplyFromAccounts; 146 private boolean mAccountSpinnerReady; 147 private String[] mCurrentReplyFromAccount; 148 private boolean mMessageIsForwardOrReply; 149 private List<String> mAccounts; 150 private boolean mAddingAttachment; 151 private boolean mAttachmentAddedOrRemoved; 152 153 /** 154 * Can be called from a non-UI thread. 155 */ 156 public static void editDraft(Context context, String account, long mLocalMessageId) { 157 } 158 159 /** 160 * Can be called from a non-UI thread. 161 */ 162 public static void compose(Context launcher, String account) { 163 launch(launcher, account, null, COMPOSE); 164 } 165 166 /** 167 * Can be called from a non-UI thread. 168 */ 169 public static void reply(Context launcher, String account, String uri) { 170 launch(launcher, account, uri, REPLY); 171 } 172 173 /** 174 * Can be called from a non-UI thread. 175 */ 176 public static void replyAll(Context launcher, String account, String uri) { 177 launch(launcher, account, uri, REPLY_ALL); 178 } 179 180 /** 181 * Can be called from a non-UI thread. 182 */ 183 public static void forward(Context launcher, String account, String uri) { 184 launch(launcher, account, uri, FORWARD); 185 } 186 187 private static void launch(Context launcher, String account, String uri, int action) { 188 Intent intent = new Intent(launcher, ComposeActivity.class); 189 intent.putExtra(EXTRA_FROM_EMAIL_TASK, true); 190 intent.putExtra(EXTRA_ACTION, action); 191 intent.putExtra(Utils.EXTRA_ACCOUNT, account); 192 intent.putExtra(EXTRA_IN_REFERENCE_TO_MESSAGE_URI, uri); 193 launcher.startActivity(intent); 194 } 195 196 @Override 197 public void onCreate(Bundle savedInstanceState) { 198 super.onCreate(savedInstanceState); 199 Intent intent = getIntent(); 200 mAccount = intent.getStringExtra(Utils.EXTRA_ACCOUNT); 201 setContentView(R.layout.compose); 202 findViews(); 203 int action = intent.getIntExtra(EXTRA_ACTION, COMPOSE); 204 if (action == REPLY || action == REPLY_ALL || action == FORWARD) { 205 mRefMessageUri = Uri.parse(intent.getStringExtra(EXTRA_IN_REFERENCE_TO_MESSAGE_URI)); 206 initFromRefMessage(action, mAccount); 207 } else { 208 setQuotedTextVisibility(false); 209 } 210 initActionBar(action); 211 asyncInitFromSpinner(); 212 } 213 214 @Override 215 protected void onResume() { 216 super.onResume(); 217 // Update the from spinner as other accounts 218 // may now be available. 219 asyncInitFromSpinner(); 220 } 221 222 private void asyncInitFromSpinner() { 223 Account[] result = AccountUtils.getSyncingAccounts(this, null, null, null); 224 mAccounts = AccountUtils 225 .mergeAccountLists(mAccounts, result, true /* prioritizeAccountList */); 226 createReplyFromCache(); 227 initFromSpinner(); 228 } 229 230 /** 231 * Create a cache of all accounts a user could send mail from 232 */ 233 private void createReplyFromCache() { 234 // Check for replyFroms. 235 List<String> accounts = null; 236 mReplyFromAccounts = new ArrayList<String[]>(); 237 238 if (mMessageIsForwardOrReply) { 239 accounts = Collections.singletonList(mAccount); 240 } else { 241 accounts = mAccounts; 242 } 243 for (String account : accounts) { 244 // First add the account. First position is account, second 245 // is display of account, 3rd position is the REAL account this 246 // is being sent from / synced to. 247 mReplyFromAccounts.add(new String[] { 248 account, account, account, "false" 249 }); 250 } 251 } 252 253 private void initFromSpinner() { 254 // If there are not yet any accounts in the cached synced accounts 255 // because this is the first time Gmail was opened, and it was opened directly 256 // to the compose activity,don't bother populating the reply from spinner yet. 257 if (mReplyFromAccounts == null || mReplyFromAccounts.size() == 0) { 258 mAccountSpinnerReady = false; 259 return; 260 } 261 FromAddressSpinnerAdapter adapter = new FromAddressSpinnerAdapter(this); 262 int currentAccountIndex = 0; 263 String replyFromAccount = mAccount; 264 265 boolean checkRealAccount = mRecipient == null || mAccount.equals(mRecipient); 266 267 currentAccountIndex = addAccountsToAdapter(adapter, checkRealAccount, replyFromAccount); 268 269 mFrom.setAdapter(adapter); 270 mFrom.setSelection(currentAccountIndex, false); 271 mFrom.setOnItemSelectedListener(this); 272 mCurrentReplyFromAccount = mReplyFromAccounts.get(currentAccountIndex); 273 274 hideOrShowFromSpinner(); 275 mAccountSpinnerReady = true; 276 adapter.setSpinner(mFrom); 277 } 278 279 private void hideOrShowFromSpinner() { 280 // Determine whether the from account spinner or the static 281 // from text should be show 282 // When the spinner is shown, the static from text 283 // is hidden 284 showFromSpinner(mFrom.getCount() > 1); 285 } 286 287 private int addAccountsToAdapter(FromAddressSpinnerAdapter adapter, boolean checkRealAccount, 288 String replyFromAccount) { 289 int currentIndex = 0; 290 int currentAccountIndex = 0; 291 // Get the position of the current account 292 for (String[] account : mReplyFromAccounts) { 293 // Add the account to the Adapter 294 // The reason that we are not adding the Account array, but adding 295 // the names of each account, is because Account returns a string 296 // that we don't want to display on toString() 297 adapter.add(account); 298 // Compare to the account address, not the real account being 299 // sent from. 300 if (checkRealAccount) { 301 // Need to check the real account and the account address 302 // so that we can send from the correct address on the 303 // correct account when the same address may exist across 304 // multiple accounts. 305 if (account[FromAddressSpinnerAdapter.REAL_ACCOUNT].equals(mAccount) 306 && account[FromAddressSpinnerAdapter.ACCOUNT_ADDRESS] 307 .equals(replyFromAccount)) { 308 currentAccountIndex = currentIndex; 309 } 310 } else { 311 // Just need to check the account address. 312 if (replyFromAccount.equals( 313 account[FromAddressSpinnerAdapter.ACCOUNT_ADDRESS])) { 314 currentAccountIndex = currentIndex; 315 } 316 } 317 318 currentIndex++; 319 } 320 return currentAccountIndex; 321 } 322 323 private void findViews() { 324 mCcBccButton = (Button) findViewById(R.id.add_cc_bcc); 325 if (mCcBccButton != null) { 326 mCcBccButton.setOnClickListener(this); 327 } 328 mCcBccView = (CcBccView) findViewById(R.id.cc_bcc_wrapper); 329 mAttachmentsView = (AttachmentsView)findViewById(R.id.attachments); 330 mTo = setupRecipients(R.id.to); 331 mCc = setupRecipients(R.id.cc); 332 mBcc = setupRecipients(R.id.bcc); 333 mSubject = (TextView) findViewById(R.id.subject); 334 mQuotedTextView = (QuotedTextView) findViewById(R.id.quoted_text_view); 335 mQuotedTextView.setRespondInlineListener(this); 336 mBodyText = (TextView) findViewById(R.id.body); 337 mFromStatic = findViewById(R.id.static_from_content); 338 mFromSpinner = findViewById(R.id.spinner_from_content); 339 mFrom = (Spinner) findViewById(R.id.from_picker); 340 } 341 342 /** 343 * Show the static from text view or the spinner 344 * @param showSpinner Whether the spinner should be shown 345 */ 346 private void showFromSpinner(boolean showSpinner) { 347 // show/hide the static text 348 mFromStatic.setVisibility( 349 showSpinner ? View.GONE : View.VISIBLE); 350 351 // show/hide the spinner 352 mFromSpinner.setVisibility( 353 showSpinner ? View.VISIBLE : View.GONE); 354 } 355 356 private void setQuotedTextVisibility(boolean show) { 357 mQuotedTextView.setVisibility(show ? View.VISIBLE : View.GONE); 358 } 359 360 private void initActionBar(int action) { 361 mComposeMode = action; 362 mActionBar = getActionBar(); 363 if (action == ComposeActivity.COMPOSE) { 364 mActionBar.setNavigationMode(ActionBar.NAVIGATION_MODE_STANDARD); 365 mActionBar.setTitle(R.string.compose); 366 } else { 367 mActionBar.setTitle(null); 368 if (mComposeModeAdapter == null) { 369 mComposeModeAdapter = new ComposeModeAdapter(this); 370 } 371 mActionBar.setNavigationMode(ActionBar.NAVIGATION_MODE_LIST); 372 mActionBar.setListNavigationCallbacks(mComposeModeAdapter, this); 373 switch (action) { 374 case ComposeActivity.REPLY: 375 mActionBar.setSelectedNavigationItem(0); 376 break; 377 case ComposeActivity.REPLY_ALL: 378 mActionBar.setSelectedNavigationItem(1); 379 break; 380 case ComposeActivity.FORWARD: 381 mActionBar.setSelectedNavigationItem(2); 382 break; 383 } 384 } 385 } 386 387 private void initFromRefMessage(int action, String recipientAddress) { 388 ContentResolver resolver = getContentResolver(); 389 Cursor refMessage = resolver.query(mRefMessageUri, UIProvider.MESSAGE_PROJECTION, null, 390 null, null); 391 if (refMessage != null) { 392 try { 393 refMessage.moveToFirst(); 394 setSubject(refMessage, action); 395 // Setup recipients 396 if (action == FORWARD) { 397 mForward = true; 398 } 399 setQuotedTextVisibility(true); 400 initRecipientsFromRefMessageCursor(recipientAddress, refMessage, action); 401 initBodyFromRefMessage(refMessage, action); 402 if (action == ComposeActivity.FORWARD || mAttachmentsChanged) { 403 updateAttachments(action, refMessage); 404 } else { 405 // Clear the attachments. 406 removeAllAttachments(); 407 } 408 updateHideOrShowCcBcc(); 409 } finally { 410 refMessage.close(); 411 } 412 } 413 } 414 415 private void initBodyFromRefMessage(Cursor refMessage, int action) { 416 boolean forward = action == FORWARD; 417 DateFormat dateFormat = DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT); 418 Date date = new Date(refMessage.getLong(UIProvider.MESSAGE_DATE_RECEIVED_MS_COLUMN)); 419 StringBuffer quotedText = new StringBuffer(); 420 421 if (action == ComposeActivity.REPLY || action == ComposeActivity.REPLY_ALL) { 422 quotedText.append(QUOTE_BEGIN); 423 quotedText 424 .append(String.format( 425 getString(R.string.reply_attribution), 426 dateFormat.format(date), 427 Utils.cleanUpString( 428 refMessage.getString(UIProvider.MESSAGE_FROM_COLUMN), true))); 429 quotedText.append(HEADER_SEPARATOR); 430 quotedText.append(BLOCKQUOTE_BEGIN); 431 quotedText.append(refMessage.getString(UIProvider.MESSAGE_BODY_HTML)); 432 quotedText.append(BLOCKQUOTE_END); 433 quotedText.append(QUOTE_END); 434 } else if (action == ComposeActivity.FORWARD) { 435 quotedText.append(QUOTE_BEGIN); 436 quotedText 437 .append(String.format(getString(R.string.forward_attribution), Utils 438 .cleanUpString(refMessage.getString(UIProvider.MESSAGE_FROM_COLUMN), 439 true /* remove empty quotes */), dateFormat.format(date), Utils 440 .cleanUpString(refMessage.getString(UIProvider.MESSAGE_SUBJECT_COLUMN), 441 false /* don't remove empty quotes */), Utils.cleanUpString( 442 refMessage.getString(UIProvider.MESSAGE_TO_COLUMN), true))); 443 String ccAddresses = refMessage.getString(UIProvider.MESSAGE_CC_COLUMN); 444 quotedText.append(String.format(getString(R.string.cc_attribution), 445 Utils.cleanUpString(ccAddresses, true /* remove empty quotes */))); 446 } 447 quotedText.append(HEADER_SEPARATOR); 448 quotedText.append(refMessage.getString(UIProvider.MESSAGE_BODY_HTML)); 449 quotedText.append(QUOTE_END); 450 setQuotedText(quotedText.toString(), !forward); 451 } 452 453 /** 454 * Fill the quoted text WebView. There is no point in having a "Show quoted 455 * text" checkbox in a forwarded message so make sure mForward is 456 * initialized properly before calling this method so we can hide it. 457 */ 458 public void setQuotedText(CharSequence text, boolean allow) { 459 // There is no way to retrieve this string from the WebView once it's 460 // been loaded, so we need to store it here. 461 mQuotedTextView.setQuotedText(text); 462 mQuotedTextView.allowQuotedText(allow); 463 // If there is quoted text, we always allow respond inline, since this 464 // may be a forward. 465 mQuotedTextView.allowRespondInline(true); 466 } 467 468 private void updateHideOrShowCcBcc() { 469 // Its possible there is a menu item OR a button. 470 boolean ccVisible = !TextUtils.isEmpty(mCc.getText()); 471 boolean bccVisible = !TextUtils.isEmpty(mBcc.getText()); 472 if (ccVisible || bccVisible) { 473 mCcBccView.show(false, ccVisible, bccVisible); 474 } 475 if (mCcBccButton != null) { 476 if (!mCc.isShown() || !mBcc.isShown()) { 477 mCcBccButton.setVisibility(View.VISIBLE); 478 mCcBccButton.setText(getString(!mCc.isShown() ? R.string.add_cc_label 479 : R.string.add_bcc_label)); 480 } else { 481 mCcBccButton.setVisibility(View.GONE); 482 } 483 } 484 } 485 486 public void removeAllAttachments() { 487 mAttachmentsView.removeAllViews(); 488 } 489 490 private void updateAttachments(int action, Cursor refMessage) { 491 // TODO: when we hook up attachments, make this work properly. 492 } 493 494 @Override 495 protected final void onActivityResult(int request, int result, Intent data) { 496 mAddingAttachment = false; 497 if (result != RESULT_OK) { 498 return; 499 } 500 501 if (request == RESULT_PICK_ATTACHMENT) { 502 addAttachmentAndUpdateView(data); 503 } 504 } 505 /** 506 * Add attachment and update the compose area appropriately. 507 * @param data 508 */ 509 public void addAttachmentAndUpdateView(Intent data) { 510 Uri uri = data != null ? data.getData() : null; 511 if (uri != null && !TextUtils.isEmpty(uri.getPath())) { 512 mAttachmentsChanged = true; 513 String contentType = getContentResolver().getType(uri); 514 try { 515 addAttachment(uri, contentType, false /* doSave */); 516 } catch (AttachmentFailureException e) { 517 // A toast has already been shown to the user, no need to do anything. 518 LogUtils.e(LOG_TAG, e, "Error adding attachment"); 519 } 520 } else { 521 showAttachmentTooBigToast(); 522 } 523 } 524 525 @VisibleForTesting 526 protected int getSizeFromFile(Uri uri, ContentResolver contentResolver) { 527 int size = -1; 528 ParcelFileDescriptor file = null; 529 try { 530 file = contentResolver.openFileDescriptor(uri, "r"); 531 size = (int) file.getStatSize(); 532 } catch (FileNotFoundException e) { 533 LogUtils.w(LOG_TAG, "Error opening file to obtain size."); 534 } finally { 535 try { 536 if (file != null) { 537 file.close(); 538 } 539 } catch (IOException e) { 540 LogUtils.w(LOG_TAG, "Error closing file opened to obtain size."); 541 } 542 } 543 return size; 544 } 545 546 /** 547 * Adds an attachment 548 * @param uri the uri to attach 549 * @param contentType the type of the resource pointed to by the URI or null if the type is 550 * unknown 551 * @param doSave whether the message should be saved 552 * 553 * @return int size of the attachment added. 554 * @throws AttachmentFailureException if an error occurs adding the attachment. 555 */ 556 private int addAttachment(Uri uri, String contentType, boolean doSave) 557 throws AttachmentFailureException { 558 final ContentResolver contentResolver = getContentResolver(); 559 if (contentType == null) contentType = ""; 560 561 MockAttachment attachment = new MockAttachment(); 562 // partId will be assigned by the engine. 563 attachment.name = null; 564 attachment.contentType = contentType; 565 attachment.size = 0; 566 attachment.simpleContentType = contentType; 567 attachment.origin = uri; 568 attachment.originExtras = uri.toString(); 569 570 Cursor metadataCursor = null; 571 try { 572 metadataCursor = contentResolver.query( 573 uri, new String[]{OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE}, 574 null, null, null); 575 if (metadataCursor != null) { 576 try { 577 if (metadataCursor.moveToNext()) { 578 attachment.name = metadataCursor.getString(0); 579 attachment.size = metadataCursor.getInt(1); 580 } 581 } finally { 582 metadataCursor.close(); 583 } 584 } 585 } catch (SQLiteException ex) { 586 // One of the two columns is probably missing, let's make one more attempt to get at 587 // least one. 588 // Note that the documentations in Intent#ACTION_OPENABLE and 589 // OpenableColumns seem to contradict each other about whether these columns are 590 // required, but it doesn't hurt to fail properly. 591 592 // Let's try to get DISPLAY_NAME 593 try { 594 metadataCursor = 595 getOptionalColumn(contentResolver, uri, OpenableColumns.DISPLAY_NAME); 596 if (metadataCursor != null && metadataCursor.moveToNext()) { 597 attachment.name = metadataCursor.getString(0); 598 } 599 } finally { 600 if (metadataCursor != null) metadataCursor.close(); 601 } 602 603 // Let's try to get SIZE 604 try { 605 metadataCursor = 606 getOptionalColumn(contentResolver, uri, OpenableColumns.SIZE); 607 if (metadataCursor != null && metadataCursor.moveToNext()) { 608 attachment.size = metadataCursor.getInt(0); 609 } else { 610 // Unable to get the size from the metadata cursor. Open the file and seek. 611 attachment.size = getSizeFromFile(uri, contentResolver); 612 } 613 } finally { 614 if (metadataCursor != null) metadataCursor.close(); 615 } 616 } catch (SecurityException e) { 617 // We received a security exception when attempting to add an 618 // attachment. Warn the user. 619 // TODO(pwestbro): determine if we need more specific text in the toast. 620 Toast.makeText(this, 621 R.string.generic_attachment_problem, Toast.LENGTH_LONG).show(); 622 throw new AttachmentFailureException("Security Exception from attachment uri", e); 623 } 624 625 if (attachment.name == null) { 626 attachment.name = uri.getLastPathSegment(); 627 } 628 629 int maxSize = UIProvider.getMailMaxAttachmentSize(mAccount); 630 631 // Error getting the size or the size was too big. 632 if (attachment.size == -1 || attachment.size > maxSize) { 633 showAttachmentTooBigToast(); 634 throw new AttachmentFailureException("Attachment too large to attach"); 635 } else if ((mAttachmentsView.getTotalAttachmentsSize() 636 + attachment.size) > maxSize) { 637 showAttachmentTooBigToast(); 638 throw new AttachmentFailureException("Attachment too large to attach"); 639 } else { 640 addAttachment(attachment); 641 } 642 643 return attachment.size; 644 } 645 646 /** 647 * @return a cursor to the requested column or null if an exception occurs while trying 648 * to query it. 649 */ 650 private Cursor getOptionalColumn(ContentResolver contentResolver, Uri uri, String columnName) { 651 Cursor result = null; 652 try { 653 result = contentResolver.query(uri, new String[]{columnName}, null, null, null); 654 } catch (SQLiteException ex) { 655 // ignore, leave result null 656 } 657 return result; 658 } 659 660 /** 661 * Add attachment. 662 * @param attachment 663 */ 664 public void addAttachment(Attachment attachment) { 665 mAttachmentsView.addAttachment(attachment); 666 } 667 668 /** 669 * When an attachment is too large to be added to a message, show a toast. 670 * This method also updates the position of the toast so that it is shown 671 * clearly above they keyboard if it happens to be open. 672 */ 673 private void showAttachmentTooBigToast() { 674 Toast t = Toast.makeText(this, R.string.generic_attachment_problem, Toast.LENGTH_LONG); 675 t.setText(R.string.too_large_to_attach); 676 t.setGravity(Gravity.CENTER_HORIZONTAL, 0, getResources() 677 .getDimensionPixelSize(R.dimen.attachment_toast_yoffset)); 678 t.show(); 679 } 680 681 /** 682 * Class containing information about failures when adding attachments. 683 */ 684 class AttachmentFailureException extends Exception { 685 private static final long serialVersionUID = 1L; 686 687 public AttachmentFailureException(String error) { 688 super(error); 689 } 690 public AttachmentFailureException(String detailMessage, Throwable throwable) { 691 super(detailMessage, throwable); 692 } 693 } 694 695 private void initRecipientsFromRefMessageCursor(String recipientAddress, Cursor refMessage, 696 int action) { 697 // Don't populate the address if this is a forward. 698 if (action == ComposeActivity.FORWARD) { 699 return; 700 } 701 initReplyRecipients(mAccount, refMessage, action); 702 } 703 704 private void initReplyRecipients(String account, Cursor refMessage, int action) { 705 // This is the email address of the current user, i.e. the one composing 706 // the reply. 707 final String accountEmail = Address.getEmailAddress(account).getAddress(); 708 String fromAddress = refMessage.getString(UIProvider.MESSAGE_FROM_COLUMN); 709 String[] sentToAddresses = Utils.splitCommaSeparatedString(refMessage 710 .getString(UIProvider.MESSAGE_TO_COLUMN)); 711 String[] replytoAddresses = Utils.splitCommaSeparatedString(refMessage 712 .getString(UIProvider.MESSAGE_REPLY_TO_COLUMN)); 713 final Collection<String> toAddresses; 714 715 // If this is a reply, the Cc list is empty. If this is a reply-all, the 716 // Cc list is the union of the To and Cc recipients of the original 717 // message, excluding the current user's email address and any addresses 718 // already on the To list. 719 if (action == ComposeActivity.REPLY) { 720 toAddresses = initToRecipients(account, accountEmail, fromAddress, 721 replytoAddresses, new String[0]); 722 addToAddresses(toAddresses); 723 } else if (action == ComposeActivity.REPLY_ALL) { 724 final Set<String> ccAddresses = Sets.newHashSet(); 725 toAddresses = initToRecipients(account, accountEmail, fromAddress, 726 replytoAddresses, new String[0]); 727 addRecipients(accountEmail, ccAddresses, sentToAddresses); 728 addRecipients(accountEmail, ccAddresses, Utils.splitCommaSeparatedString(refMessage 729 .getString(UIProvider.MESSAGE_CC_COLUMN))); 730 addCcAddresses(ccAddresses, toAddresses); 731 } 732 } 733 734 private void addToAddresses(Collection<String> addresses) { 735 addAddressesToList(addresses, mTo); 736 } 737 738 private void addCcAddresses(Collection<String> addresses, Collection<String> toAddresses) { 739 addCcAddressesToList(tokenizeAddressList(addresses), tokenizeAddressList(toAddresses), 740 mCc); 741 } 742 743 @VisibleForTesting 744 protected void addCcAddressesToList(List<Rfc822Token[]> addresses, 745 List<Rfc822Token[]> compareToList, RecipientEditTextView list) { 746 String address; 747 748 HashSet<String> compareTo = convertToHashSet(compareToList); 749 for (Rfc822Token[] tokens : addresses) { 750 for (int i = 0; i < tokens.length; i++) { 751 address = tokens[i].toString(); 752 // Check if this is a duplicate: 753 if (!compareTo.contains(tokens[i].getAddress())) { 754 // Get the address here 755 list.append(address + END_TOKEN); 756 } 757 } 758 } 759 } 760 761 private void addAddressesToList(List<Rfc822Token[]> addresses, RecipientEditTextView list) { 762 String address; 763 for (Rfc822Token[] tokens : addresses) { 764 for (int i = 0; i < tokens.length; i++) { 765 address = tokens[i].toString(); 766 list.append(address + END_TOKEN); 767 } 768 } 769 } 770 771 private HashSet<String> convertToHashSet(List<Rfc822Token[]> list) { 772 HashSet<String> hash = new HashSet<String>(); 773 for (Rfc822Token[] tokens : list) { 774 for (int i = 0; i < tokens.length; i++) { 775 hash.add(tokens[i].getAddress()); 776 } 777 } 778 return hash; 779 } 780 781 private void addBccAddresses(Collection<String> addresses) { 782 addAddressesToList(addresses, mBcc); 783 } 784 785 protected List<Rfc822Token[]> tokenizeAddressList(Collection<String> addresses) { 786 @VisibleForTesting 787 List<Rfc822Token[]> tokenized = new ArrayList<Rfc822Token[]>(); 788 789 for (String address: addresses) { 790 tokenized.add(Rfc822Tokenizer.tokenize(address)); 791 } 792 return tokenized; 793 } 794 795 @VisibleForTesting 796 void addAddressesToList(Collection<String> addresses, RecipientEditTextView list) { 797 for (String address : addresses) { 798 addAddressToList(address, list); 799 } 800 } 801 802 private void addAddressToList(String address, RecipientEditTextView list) { 803 if (address == null || list == null) 804 return; 805 806 Rfc822Token[] tokens = Rfc822Tokenizer.tokenize(address); 807 808 for (int i = 0; i < tokens.length; i++) { 809 list.append(tokens[i] + END_TOKEN); 810 } 811 } 812 813 @VisibleForTesting 814 protected Collection<String> initToRecipients(String account, String accountEmail, 815 String senderAddress, String[] replyToAddresses, String[] inToAddresses) { 816 // The To recipient is the reply-to address specified in the original 817 // message, unless it is: 818 // the current user OR a custom from of the current user, in which case 819 // it's the To recipient list of the original message. 820 // OR missing, in which case use the sender of the original message 821 Set<String> toAddresses = Sets.newHashSet(); 822 Address sender = Address.getEmailAddress(senderAddress); 823 if (sender != null && sender.getAddress().equalsIgnoreCase(account)) { 824 // The sender address is this account, so reply acts like reply all. 825 toAddresses.addAll(Arrays.asList(inToAddresses)); 826 } else if (replyToAddresses != null && replyToAddresses.length != 0) { 827 toAddresses.addAll(Arrays.asList(replyToAddresses)); 828 } else { 829 // Check to see if the sender address is one of the user's custom 830 // from addresses. 831 if (senderAddress != null && sender != null 832 && !accountEmail.equalsIgnoreCase(sender.getAddress())) { 833 // Replying to the sender of the original message is the most 834 // common case. 835 toAddresses.add(senderAddress); 836 } else { 837 // This happens if the user replies to a message they originally 838 // wrote. In this case, "reply" really means "re-send," so we 839 // target the original recipients. This works as expected even 840 // if the user sent the original message to themselves. 841 toAddresses.addAll(Arrays.asList(inToAddresses)); 842 } 843 } 844 return toAddresses; 845 } 846 847 private static void addRecipients(String account, Set<String> recipients, String[] addresses) { 848 for (String email : addresses) { 849 // Do not add this account, or any of the custom froms, to the list 850 // of recipients. 851 final String recipientAddress = Address.getEmailAddress(email).getAddress(); 852 if (!account.equalsIgnoreCase(recipientAddress)) { 853 recipients.add(email.replace("\"\"", "")); 854 } 855 } 856 } 857 858 private void setSubject(Cursor refMessage, int action) { 859 String subject = refMessage.getString(UIProvider.MESSAGE_SUBJECT_COLUMN); 860 String prefix; 861 String correctedSubject = null; 862 if (action == ComposeActivity.COMPOSE) { 863 prefix = ""; 864 } else if (action == ComposeActivity.FORWARD) { 865 prefix = getString(R.string.forward_subject_label); 866 } else { 867 prefix = getString(R.string.reply_subject_label); 868 } 869 870 // Don't duplicate the prefix 871 if (subject.toLowerCase().startsWith(prefix.toLowerCase())) { 872 correctedSubject = subject; 873 } else { 874 correctedSubject = String 875 .format(getString(R.string.formatted_subject), prefix, subject); 876 } 877 mSubject.setText(correctedSubject); 878 } 879 880 private RecipientEditTextView setupRecipients(int id) { 881 RecipientEditTextView view = (RecipientEditTextView) findViewById(id); 882 view.setAdapter(new RecipientAdapter(this, mAccount)); 883 view.setTokenizer(new Rfc822Tokenizer()); 884 if (mRecipientValidator == null) { 885 int offset = mAccount.indexOf("@") + 1; 886 String account = mAccount; 887 if (offset > -1) { 888 account = account.substring(mAccount.indexOf("@") + 1); 889 } 890 mRecipientValidator = new Rfc822Validator(account); 891 } 892 view.setValidator(mRecipientValidator); 893 return view; 894 } 895 896 @Override 897 public void onClick(View v) { 898 int id = v.getId(); 899 switch (id) { 900 case R.id.add_cc_bcc: 901 // Verify that cc/ bcc aren't showing. 902 // Animate in cc/bcc. 903 showCcBccViews(); 904 break; 905 } 906 } 907 908 @Override 909 public boolean onCreateOptionsMenu(Menu menu) { 910 super.onCreateOptionsMenu(menu); 911 MenuInflater inflater = getMenuInflater(); 912 inflater.inflate(R.menu.compose_menu, menu); 913 return true; 914 } 915 916 @Override 917 public boolean onPrepareOptionsMenu(Menu menu) { 918 MenuItem ccBcc = menu.findItem(R.id.add_cc_bcc); 919 if (ccBcc != null) { 920 // Its possible there is a menu item OR a button. 921 boolean ccFieldVisible = mCc.isShown(); 922 boolean bccFieldVisible = mBcc.isShown(); 923 if (!ccFieldVisible || !bccFieldVisible) { 924 ccBcc.setVisible(true); 925 ccBcc.setTitle(getString(!ccFieldVisible ? R.string.add_cc_label 926 : R.string.add_bcc_label)); 927 } else { 928 ccBcc.setVisible(false); 929 } 930 } 931 return true; 932 } 933 934 @Override 935 public boolean onOptionsItemSelected(MenuItem item) { 936 int id = item.getItemId(); 937 boolean handled = false; 938 switch (id) { 939 case R.id.add_attachment: 940 doAttach(); 941 break; 942 case R.id.add_cc_bcc: 943 showCcBccViews(); 944 handled = true; 945 break; 946 } 947 return !handled ? super.onOptionsItemSelected(item) : handled; 948 } 949 950 public void doAttach() { 951 Intent i = new Intent(Intent.ACTION_GET_CONTENT); 952 i.addCategory(Intent.CATEGORY_OPENABLE); 953 if (Settings.System.getInt( 954 getContentResolver(), UIProvider.getAttachmentTypeSetting(), 0) != 0) { 955 i.setType("*/*"); 956 } else { 957 i.setType("image/*"); 958 } 959 mAddingAttachment = true; 960 startActivityForResult(Intent.createChooser(i, 961 getText(R.string.select_attachment_type)), RESULT_PICK_ATTACHMENT); 962 } 963 964 private void showCcBccViews() { 965 mCcBccView.show(true, true, true); 966 if (mCcBccButton != null) { 967 mCcBccButton.setVisibility(View.GONE); 968 } 969 } 970 971 @Override 972 public boolean onNavigationItemSelected(int position, long itemId) { 973 int initialComposeMode = mComposeMode; 974 if (position == ComposeActivity.REPLY) { 975 mComposeMode = ComposeActivity.REPLY; 976 } else if (position == ComposeActivity.REPLY_ALL) { 977 mComposeMode = ComposeActivity.REPLY_ALL; 978 } else if (position == ComposeActivity.FORWARD) { 979 mComposeMode = ComposeActivity.FORWARD; 980 } 981 if (initialComposeMode != mComposeMode) { 982 initFromRefMessage(mComposeMode, mAccount); 983 } 984 return true; 985 } 986 987 private class ComposeModeAdapter extends ArrayAdapter<String> { 988 989 private LayoutInflater mInflater; 990 991 public ComposeModeAdapter(Context context) { 992 super(context, R.layout.compose_mode_item, R.id.mode, getResources() 993 .getStringArray(R.array.compose_modes)); 994 } 995 996 private LayoutInflater getInflater() { 997 if (mInflater == null) { 998 mInflater = LayoutInflater.from(getContext()); 999 } 1000 return mInflater; 1001 } 1002 1003 @Override 1004 public View getView(int position, View convertView, ViewGroup parent) { 1005 if (convertView == null) { 1006 convertView = getInflater().inflate(R.layout.compose_mode_display_item, null); 1007 } 1008 ((TextView) convertView.findViewById(R.id.mode)).setText(getItem(position)); 1009 return super.getView(position, convertView, parent); 1010 } 1011 } 1012 1013 @Override 1014 public void onRespondInline(String text) { 1015 appendToBody(text, false); 1016 } 1017 1018 /** 1019 * Append text to the body of the message. If there is no existing body 1020 * text, just sets the body to text. 1021 * 1022 * @param text 1023 * @param withSignature True to append a signature. 1024 */ 1025 public void appendToBody(CharSequence text, boolean withSignature) { 1026 Editable bodyText = mBodyText.getEditableText(); 1027 if (bodyText != null && bodyText.length() > 0) { 1028 bodyText.append(text); 1029 } else { 1030 setBody(text, withSignature); 1031 } 1032 } 1033 1034 /** 1035 * Set the body of the message. 1036 * @param text 1037 * @param withSignature True to append a signature. 1038 */ 1039 public void setBody(CharSequence text, boolean withSignature) { 1040 mBodyText.setText(text); 1041 } 1042 1043 @Override 1044 public void onItemSelected(AdapterView<?> parent, View view, int position, long id) { 1045 // TODO 1046 } 1047 1048 @Override 1049 public void onNothingSelected(AdapterView<?> parent) { 1050 // Do nothing. 1051 } 1052}