ComposeActivity.java revision 2a3cc13b4473136c6ebfb0a257f81c66c2d3e8b3
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.annotation.SuppressLint; 20import android.app.ActionBar; 21import android.app.ActionBar.OnNavigationListener; 22import android.app.Activity; 23import android.app.ActivityManager; 24import android.app.AlertDialog; 25import android.app.Dialog; 26import android.app.DialogFragment; 27import android.app.Fragment; 28import android.app.FragmentTransaction; 29import android.app.LoaderManager; 30import android.content.ClipData; 31import android.content.ContentResolver; 32import android.content.ContentValues; 33import android.content.Context; 34import android.content.CursorLoader; 35import android.content.DialogInterface; 36import android.content.Intent; 37import android.content.Loader; 38import android.content.pm.ActivityInfo; 39import android.content.res.Resources; 40import android.database.Cursor; 41import android.net.Uri; 42import android.os.AsyncTask; 43import android.os.Build; 44import android.os.Bundle; 45import android.os.Handler; 46import android.os.HandlerThread; 47import android.os.ParcelFileDescriptor; 48import android.os.Parcelable; 49import android.provider.BaseColumns; 50import android.text.Editable; 51import android.text.Html; 52import android.text.SpannableString; 53import android.text.Spanned; 54import android.text.TextUtils; 55import android.text.TextWatcher; 56import android.text.util.Rfc822Token; 57import android.text.util.Rfc822Tokenizer; 58import android.view.Gravity; 59import android.view.KeyEvent; 60import android.view.LayoutInflater; 61import android.view.Menu; 62import android.view.MenuInflater; 63import android.view.MenuItem; 64import android.view.View; 65import android.view.View.OnClickListener; 66import android.view.ViewGroup; 67import android.view.inputmethod.BaseInputConnection; 68import android.view.inputmethod.EditorInfo; 69import android.widget.ArrayAdapter; 70import android.widget.Button; 71import android.widget.EditText; 72import android.widget.TextView; 73import android.widget.Toast; 74 75import com.android.common.Rfc822Validator; 76import com.android.common.contacts.DataUsageStatUpdater; 77import com.android.emailcommon.mail.Address; 78import com.android.ex.chips.RecipientEditTextView; 79import com.android.mail.MailIntentService; 80import com.android.mail.R; 81import com.android.mail.analytics.Analytics; 82import com.android.mail.browse.MessageHeaderView; 83import com.android.mail.compose.AttachmentsView.AttachmentAddedOrDeletedListener; 84import com.android.mail.compose.AttachmentsView.AttachmentFailureException; 85import com.android.mail.compose.FromAddressSpinner.OnAccountChangedListener; 86import com.android.mail.compose.QuotedTextView.RespondInlineListener; 87import com.android.mail.providers.Account; 88import com.android.mail.providers.Attachment; 89import com.android.mail.providers.Folder; 90import com.android.mail.providers.MailAppProvider; 91import com.android.mail.providers.Message; 92import com.android.mail.providers.MessageModification; 93import com.android.mail.providers.ReplyFromAccount; 94import com.android.mail.providers.Settings; 95import com.android.mail.providers.UIProvider; 96import com.android.mail.providers.UIProvider.AccountCapabilities; 97import com.android.mail.providers.UIProvider.DraftType; 98import com.android.mail.ui.AttachmentTile.AttachmentPreview; 99import com.android.mail.ui.MailActivity; 100import com.android.mail.ui.WaitFragment; 101import com.android.mail.utils.AccountUtils; 102import com.android.mail.utils.AttachmentUtils; 103import com.android.mail.utils.ContentProviderTask; 104import com.android.mail.utils.LogTag; 105import com.android.mail.utils.LogUtils; 106import com.android.mail.utils.NotificationActionUtils; 107import com.android.mail.utils.Utils; 108import com.google.common.annotations.VisibleForTesting; 109import com.google.common.collect.Lists; 110import com.google.common.collect.Sets; 111 112import java.io.FileNotFoundException; 113import java.io.IOException; 114import java.io.UnsupportedEncodingException; 115import java.net.URLDecoder; 116import java.util.ArrayList; 117import java.util.Arrays; 118import java.util.Collection; 119import java.util.HashMap; 120import java.util.HashSet; 121import java.util.List; 122import java.util.Map.Entry; 123import java.util.Set; 124import java.util.concurrent.ConcurrentHashMap; 125 126public class ComposeActivity extends Activity implements OnClickListener, OnNavigationListener, 127 RespondInlineListener, TextWatcher, 128 AttachmentAddedOrDeletedListener, OnAccountChangedListener, 129 LoaderManager.LoaderCallbacks<Cursor>, TextView.OnEditorActionListener { 130 /** 131 * An {@link Intent} action that launches {@link ComposeActivity}, but is handled as if the 132 * {@link Activity} were launched with no special action. 133 */ 134 private static final String ACTION_LAUNCH_COMPOSE = 135 "com.android.mail.intent.action.LAUNCH_COMPOSE"; 136 137 // Identifiers for which type of composition this is 138 public static final int COMPOSE = -1; 139 public static final int REPLY = 0; 140 public static final int REPLY_ALL = 1; 141 public static final int FORWARD = 2; 142 public static final int EDIT_DRAFT = 3; 143 144 // Integer extra holding one of the above compose action 145 protected static final String EXTRA_ACTION = "action"; 146 147 private static final String EXTRA_SHOW_CC = "showCc"; 148 private static final String EXTRA_SHOW_BCC = "showBcc"; 149 private static final String EXTRA_RESPONDED_INLINE = "respondedInline"; 150 private static final String EXTRA_SAVE_ENABLED = "saveEnabled"; 151 152 private static final String UTF8_ENCODING_NAME = "UTF-8"; 153 154 private static final String MAIL_TO = "mailto"; 155 156 private static final String EXTRA_SUBJECT = "subject"; 157 158 private static final String EXTRA_BODY = "body"; 159 160 /** 161 * Expected to be html formatted text. 162 */ 163 private static final String EXTRA_QUOTED_TEXT = "quotedText"; 164 165 protected static final String EXTRA_FROM_ACCOUNT_STRING = "fromAccountString"; 166 167 private static final String EXTRA_ATTACHMENT_PREVIEWS = "attachmentPreviews"; 168 169 // Extra that we can get passed from other activities 170 @VisibleForTesting 171 protected static final String EXTRA_TO = "to"; 172 private static final String EXTRA_CC = "cc"; 173 private static final String EXTRA_BCC = "bcc"; 174 175 /** 176 * An optional extra containing a {@link ContentValues} of values to be added to 177 * {@link SendOrSaveMessage#mValues}. 178 */ 179 public static final String EXTRA_VALUES = "extra-values"; 180 181 // List of all the fields 182 static final String[] ALL_EXTRAS = { EXTRA_SUBJECT, EXTRA_BODY, EXTRA_TO, EXTRA_CC, EXTRA_BCC, 183 EXTRA_QUOTED_TEXT }; 184 185 private static final String WEAR_EXTRA = "com.google.android.wearable.extras"; 186 187 private static SendOrSaveCallback sTestSendOrSaveCallback = null; 188 // Map containing information about requests to create new messages, and the id of the 189 // messages that were the result of those requests. 190 // 191 // This map is used when the activity that initiated the save a of a new message, is killed 192 // before the save has completed (and when we know the id of the newly created message). When 193 // a save is completed, the service that is running in the background, will update the map 194 // 195 // When a new ComposeActivity instance is created, it will attempt to use the information in 196 // the previously instantiated map. If ComposeActivity.onCreate() is called, with a bundle 197 // (restoring data from a previous instance), and the map hasn't been created, we will attempt 198 // to populate the map with data stored in shared preferences. 199 // FIXME: values in this map are never read. 200 private static ConcurrentHashMap<Integer, Long> sRequestMessageIdMap = null; 201 /** 202 * Notifies the {@code Activity} that the caller is an Email 203 * {@code Activity}, so that the back behavior may be modified accordingly. 204 * 205 * @see #onAppUpPressed 206 */ 207 public static final String EXTRA_FROM_EMAIL_TASK = "fromemail"; 208 209 public static final String EXTRA_ATTACHMENTS = "attachments"; 210 211 /** If set, we will clear notifications for this folder. */ 212 public static final String EXTRA_NOTIFICATION_FOLDER = "extra-notification-folder"; 213 214 // If this is a reply/forward then this extra will hold the original message 215 private static final String EXTRA_IN_REFERENCE_TO_MESSAGE = "in-reference-to-message"; 216 // If this is a reply/forward then this extra will hold a uri we must query 217 // to get the original message. 218 protected static final String EXTRA_IN_REFERENCE_TO_MESSAGE_URI = "in-reference-to-message-uri"; 219 // If this is an action to edit an existing draft message, this extra will hold the 220 // draft message 221 private static final String ORIGINAL_DRAFT_MESSAGE = "original-draft-message"; 222 private static final String END_TOKEN = ", "; 223 private static final String LOG_TAG = LogTag.getLogTag(); 224 // Request numbers for activities we start 225 private static final int RESULT_PICK_ATTACHMENT = 1; 226 private static final int RESULT_CREATE_ACCOUNT = 2; 227 // TODO(mindyp) set mime-type for auto send? 228 public static final String AUTO_SEND_ACTION = "com.android.mail.action.AUTO_SEND"; 229 230 private static final String EXTRA_SELECTED_REPLY_FROM_ACCOUNT = "replyFromAccount"; 231 private static final String EXTRA_REQUEST_ID = "requestId"; 232 private static final String EXTRA_FOCUS_SELECTION_START = "focusSelectionStart"; 233 private static final String EXTRA_FOCUS_SELECTION_END = "focusSelectionEnd"; 234 private static final String EXTRA_MESSAGE = "extraMessage"; 235 private static final int REFERENCE_MESSAGE_LOADER = 0; 236 private static final int LOADER_ACCOUNT_CURSOR = 1; 237 private static final int INIT_DRAFT_USING_REFERENCE_MESSAGE = 2; 238 private static final String EXTRA_SELECTED_ACCOUNT = "selectedAccount"; 239 private static final String TAG_WAIT = "wait-fragment"; 240 private static final String MIME_TYPE_ALL = "*/*"; 241 private static final String MIME_TYPE_PHOTO = "image/*"; 242 243 private static final String KEY_INNER_SAVED_STATE = "compose_state"; 244 245 /** 246 * A single thread for running tasks in the background. 247 */ 248 private Handler mSendSaveTaskHandler = null; 249 private RecipientEditTextView mTo; 250 private RecipientEditTextView mCc; 251 private RecipientEditTextView mBcc; 252 private Button mCcBccButton; 253 private CcBccView mCcBccView; 254 private AttachmentsView mAttachmentsView; 255 protected Account mAccount; 256 protected ReplyFromAccount mReplyFromAccount; 257 private Settings mCachedSettings; 258 private Rfc822Validator mValidator; 259 private TextView mSubject; 260 261 private ComposeModeAdapter mComposeModeAdapter; 262 protected int mComposeMode = -1; 263 private boolean mForward; 264 private QuotedTextView mQuotedTextView; 265 protected EditText mBodyView; 266 private View mFromStatic; 267 private TextView mFromStaticText; 268 private View mFromSpinnerWrapper; 269 @VisibleForTesting 270 protected FromAddressSpinner mFromSpinner; 271 private boolean mAddingAttachment; 272 private boolean mAttachmentsChanged; 273 private boolean mTextChanged; 274 private boolean mReplyFromChanged; 275 private MenuItem mSave; 276 @VisibleForTesting 277 protected Message mRefMessage; 278 private long mDraftId = UIProvider.INVALID_MESSAGE_ID; 279 private Message mDraft; 280 private ReplyFromAccount mDraftAccount; 281 private final Object mDraftLock = new Object(); 282 283 /** 284 * Boolean indicating whether ComposeActivity was launched from a Gmail controlled view. 285 */ 286 private boolean mLaunchedFromEmail = false; 287 private RecipientTextWatcher mToListener; 288 private RecipientTextWatcher mCcListener; 289 private RecipientTextWatcher mBccListener; 290 private Uri mRefMessageUri; 291 private boolean mShowQuotedText = false; 292 protected Bundle mInnerSavedState; 293 private ContentValues mExtraValues = null; 294 295 // Array of the outstanding send or save tasks. Access is synchronized 296 // with the object itself 297 /* package for testing */ 298 @VisibleForTesting 299 public final ArrayList<SendOrSaveTask> mActiveTasks = Lists.newArrayList(); 300 // FIXME: this variable is never read. related to sRequestMessageIdMap. 301 private int mRequestId; 302 private String mSignature; 303 private Account[] mAccounts; 304 private boolean mRespondedInline; 305 private boolean mPerformedSendOrDiscard = false; 306 307 /** 308 * Can be called from a non-UI thread. 309 */ 310 public static void editDraft(Context launcher, Account account, Message message) { 311 launch(launcher, account, message, EDIT_DRAFT, null, null, null, null, 312 null /* extraValues */); 313 } 314 315 /** 316 * Can be called from a non-UI thread. 317 */ 318 public static void compose(Context launcher, Account account) { 319 launch(launcher, account, null, COMPOSE, null, null, null, null, null /* extraValues */); 320 } 321 322 /** 323 * Can be called from a non-UI thread. 324 */ 325 public static void composeToAddress(Context launcher, Account account, String toAddress) { 326 launch(launcher, account, null, COMPOSE, toAddress, null, null, null, 327 null /* extraValues */); 328 } 329 330 /** 331 * Can be called from a non-UI thread. 332 */ 333 public static void composeWithExtraValues(Context launcher, Account account, 334 String subject, final ContentValues extraValues) { 335 launch(launcher, account, null, COMPOSE, null, null, null, subject, extraValues); 336 } 337 338 /** 339 * Can be called from a non-UI thread. 340 */ 341 public static Intent createReplyIntent(final Context launcher, final Account account, 342 final Uri messageUri, final boolean isReplyAll) { 343 return createActionIntent(launcher, account, messageUri, isReplyAll ? REPLY_ALL : REPLY); 344 } 345 346 /** 347 * Can be called from a non-UI thread. 348 */ 349 public static Intent createForwardIntent(final Context launcher, final Account account, 350 final Uri messageUri) { 351 return createActionIntent(launcher, account, messageUri, FORWARD); 352 } 353 354 private static Intent createActionIntent(final Context context, final Account account, 355 final Uri messageUri, final int action) { 356 final Intent intent = new Intent(ACTION_LAUNCH_COMPOSE); 357 intent.setPackage(context.getPackageName()); 358 359 updateActionIntent(account, messageUri, action, intent); 360 361 return intent; 362 } 363 364 @VisibleForTesting 365 static Intent updateActionIntent(Account account, Uri messageUri, int action, Intent intent) { 366 intent.putExtra(EXTRA_FROM_EMAIL_TASK, true); 367 intent.putExtra(EXTRA_ACTION, action); 368 intent.putExtra(Utils.EXTRA_ACCOUNT, account); 369 intent.putExtra(EXTRA_IN_REFERENCE_TO_MESSAGE_URI, messageUri); 370 371 return intent; 372 } 373 374 /** 375 * Can be called from a non-UI thread. 376 */ 377 public static void reply(Context launcher, Account account, Message message) { 378 launch(launcher, account, message, REPLY, null, null, null, null, null /* extraValues */); 379 } 380 381 /** 382 * Can be called from a non-UI thread. 383 */ 384 public static void replyAll(Context launcher, Account account, Message message) { 385 launch(launcher, account, message, REPLY_ALL, null, null, null, null, 386 null /* extraValues */); 387 } 388 389 /** 390 * Can be called from a non-UI thread. 391 */ 392 public static void forward(Context launcher, Account account, Message message) { 393 launch(launcher, account, message, FORWARD, null, null, null, null, null /* extraValues */); 394 } 395 396 public static void reportRenderingFeedback(Context launcher, Account account, Message message, 397 String body) { 398 launch(launcher, account, message, FORWARD, 399 "android-gmail-readability@google.com", body, null, null, null /* extraValues */); 400 } 401 402 private static void launch(Context context, Account account, Message message, int action, 403 String toAddress, String body, String quotedText, String subject, 404 final ContentValues extraValues) { 405 Intent intent = new Intent(ACTION_LAUNCH_COMPOSE); 406 intent.setPackage(context.getPackageName()); 407 intent.putExtra(EXTRA_FROM_EMAIL_TASK, true); 408 intent.putExtra(EXTRA_ACTION, action); 409 intent.putExtra(Utils.EXTRA_ACCOUNT, account); 410 if (action == EDIT_DRAFT) { 411 intent.putExtra(ORIGINAL_DRAFT_MESSAGE, message); 412 } else { 413 intent.putExtra(EXTRA_IN_REFERENCE_TO_MESSAGE, message); 414 } 415 if (toAddress != null) { 416 intent.putExtra(EXTRA_TO, toAddress); 417 } 418 if (body != null) { 419 intent.putExtra(EXTRA_BODY, body); 420 } 421 if (quotedText != null) { 422 intent.putExtra(EXTRA_QUOTED_TEXT, quotedText); 423 } 424 if (subject != null) { 425 intent.putExtra(EXTRA_SUBJECT, subject); 426 } 427 if (extraValues != null) { 428 LogUtils.d(LOG_TAG, "Launching with extraValues: %s", extraValues.toString()); 429 intent.putExtra(EXTRA_VALUES, extraValues); 430 } 431 context.startActivity(intent); 432 } 433 434 public static void composeMailto(Context context, Account account, Uri mailto) { 435 final Intent intent = new Intent(Intent.ACTION_VIEW, mailto); 436 intent.setPackage(context.getPackageName()); 437 intent.putExtra(EXTRA_FROM_EMAIL_TASK, true); 438 intent.putExtra(Utils.EXTRA_ACCOUNT, account); 439 context.startActivity(intent); 440 } 441 442 @Override 443 protected void onCreate(Bundle savedInstanceState) { 444 super.onCreate(savedInstanceState); 445 setContentView(R.layout.compose); 446 mInnerSavedState = (savedInstanceState != null) ? 447 savedInstanceState.getBundle(KEY_INNER_SAVED_STATE) : null; 448 checkValidAccounts(); 449 } 450 451 private void finishCreate() { 452 final Bundle savedState = mInnerSavedState; 453 findViews(); 454 final Intent intent = getIntent(); 455 final Message message; 456 final ArrayList<AttachmentPreview> previews; 457 mShowQuotedText = false; 458 final CharSequence quotedText; 459 int action; 460 // Check for any of the possibly supplied accounts.; 461 final Account account; 462 if (hadSavedInstanceStateMessage(savedState)) { 463 action = savedState.getInt(EXTRA_ACTION, COMPOSE); 464 account = savedState.getParcelable(Utils.EXTRA_ACCOUNT); 465 message = savedState.getParcelable(EXTRA_MESSAGE); 466 467 previews = savedState.getParcelableArrayList(EXTRA_ATTACHMENT_PREVIEWS); 468 mRefMessage = savedState.getParcelable(EXTRA_IN_REFERENCE_TO_MESSAGE); 469 quotedText = savedState.getCharSequence(EXTRA_QUOTED_TEXT); 470 471 mExtraValues = savedState.getParcelable(EXTRA_VALUES); 472 } else { 473 account = obtainAccount(intent); 474 action = intent.getIntExtra(EXTRA_ACTION, COMPOSE); 475 // Initialize the message from the message in the intent 476 message = intent.getParcelableExtra(ORIGINAL_DRAFT_MESSAGE); 477 previews = intent.getParcelableArrayListExtra(EXTRA_ATTACHMENT_PREVIEWS); 478 mRefMessage = intent.getParcelableExtra(EXTRA_IN_REFERENCE_TO_MESSAGE); 479 mRefMessageUri = intent.getParcelableExtra(EXTRA_IN_REFERENCE_TO_MESSAGE_URI); 480 quotedText = null; 481 482 if (Analytics.isLoggable()) { 483 if (intent.getBooleanExtra(Utils.EXTRA_FROM_NOTIFICATION, false)) { 484 Analytics.getInstance().sendEvent( 485 "notification_action", "compose", getActionString(action), 0); 486 } 487 } 488 } 489 mAttachmentsView.setAttachmentPreviews(previews); 490 491 setAccount(account); 492 if (mAccount == null) { 493 return; 494 } 495 496 initRecipients(); 497 498 // Clear the notification and mark the conversation as seen, if necessary 499 final Folder notificationFolder = 500 intent.getParcelableExtra(EXTRA_NOTIFICATION_FOLDER); 501 if (notificationFolder != null) { 502 final Intent clearNotifIntent = 503 new Intent(MailIntentService.ACTION_CLEAR_NEW_MAIL_NOTIFICATIONS); 504 clearNotifIntent.setPackage(getPackageName()); 505 clearNotifIntent.putExtra(Utils.EXTRA_ACCOUNT, account); 506 clearNotifIntent.putExtra(Utils.EXTRA_FOLDER, notificationFolder); 507 508 startService(clearNotifIntent); 509 } 510 511 if (intent.getBooleanExtra(EXTRA_FROM_EMAIL_TASK, false)) { 512 mLaunchedFromEmail = true; 513 } else if (Intent.ACTION_SEND.equals(intent.getAction())) { 514 final Uri dataUri = intent.getData(); 515 if (dataUri != null) { 516 final String dataScheme = intent.getData().getScheme(); 517 final String accountScheme = mAccount.composeIntentUri.getScheme(); 518 mLaunchedFromEmail = TextUtils.equals(dataScheme, accountScheme); 519 } 520 } 521 522 if (mRefMessageUri != null) { 523 mShowQuotedText = true; 524 mComposeMode = action; 525 526 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { 527 // TODO: Use the wrapper api to get the clip data. 528 ClipData clipData = intent.getClipData(); 529 if (clipData != null 530 && WEAR_EXTRA.equals(clipData.getDescription().getLabel())) { 531 Bundle extras = clipData.getItemAt(0).getIntent().getExtras(); 532 if (extras != null) { 533 String wearReply = 534 extras.getString(NotificationActionUtils.WEAR_REPLY_INPUT); 535 createWearReplyTask(this, mRefMessageUri, UIProvider.MESSAGE_PROJECTION, 536 mComposeMode, wearReply).execute(); 537 finish(); 538 return; 539 } 540 } 541 } 542 543 getLoaderManager().initLoader(INIT_DRAFT_USING_REFERENCE_MESSAGE, null, this); 544 return; 545 } else if (message != null && action != EDIT_DRAFT) { 546 initFromDraftMessage(message); 547 initQuotedTextFromRefMessage(mRefMessage, action); 548 showCcBcc(savedState); 549 mShowQuotedText = message.appendRefMessageContent; 550 // if we should be showing quoted text but mRefMessage is null 551 // and we have some quotedText, display that 552 if (mShowQuotedText && mRefMessage == null) { 553 if (quotedText != null) { 554 initQuotedText(quotedText, false /* shouldQuoteText */); 555 } else if (mExtraValues != null) { 556 initExtraValues(mExtraValues); 557 return; 558 } 559 } 560 } else if (action == EDIT_DRAFT) { 561 if (message == null) { 562 throw new IllegalStateException("Message must not be null to edit draft"); 563 } 564 initFromDraftMessage(message); 565 final boolean showBcc = !TextUtils.isEmpty(message.getBcc()); 566 final boolean showCc = showBcc || !TextUtils.isEmpty(message.getCc()); 567 mCcBccView.show(false, showCc, showBcc); 568 // Update the action to the draft type of the previous draft 569 switch (message.draftType) { 570 case UIProvider.DraftType.REPLY: 571 action = REPLY; 572 break; 573 case UIProvider.DraftType.REPLY_ALL: 574 action = REPLY_ALL; 575 break; 576 case UIProvider.DraftType.FORWARD: 577 action = FORWARD; 578 break; 579 case UIProvider.DraftType.COMPOSE: 580 default: 581 action = COMPOSE; 582 break; 583 } 584 LogUtils.d(LOG_TAG, "Previous draft had action type: %d", action); 585 586 mShowQuotedText = message.appendRefMessageContent; 587 if (message.refMessageUri != null) { 588 // If we're editing an existing draft that was in reference to an existing message, 589 // still need to load that original message since we might need to refer to the 590 // original sender and recipients if user switches "reply <-> reply-all". 591 mRefMessageUri = message.refMessageUri; 592 mComposeMode = action; 593 getLoaderManager().initLoader(REFERENCE_MESSAGE_LOADER, null, this); 594 return; 595 } 596 } else if ((action == REPLY || action == REPLY_ALL || action == FORWARD)) { 597 if (mRefMessage != null) { 598 initFromRefMessage(action); 599 mShowQuotedText = true; 600 } 601 } else { 602 if (initFromExtras(intent)) { 603 return; 604 } 605 } 606 607 mComposeMode = action; 608 finishSetup(action, intent, savedState); 609 } 610 611 private static AsyncTask<Void, Void, Message> createWearReplyTask( 612 final ComposeActivity composeActivity, 613 final Uri refMessageUri, final String[] projection, final int action, 614 final String wearReply) { 615 return new AsyncTask<Void, Void, Message>() { 616 private Intent mEmptyServiceIntent = new Intent(composeActivity, EmptyService.class); 617 618 @Override 619 protected void onPreExecute() { 620 // Start service so we won't be killed if this app is put in the background. 621 composeActivity.startService(mEmptyServiceIntent); 622 } 623 624 @Override 625 protected Message doInBackground(Void... params) { 626 Cursor cursor = composeActivity.getContentResolver() 627 .query(refMessageUri, projection, null, null, null, null); 628 if (cursor != null) { 629 try { 630 cursor.moveToFirst(); 631 return new Message(cursor); 632 } finally { 633 cursor.close(); 634 } 635 } 636 return null; 637 } 638 639 @Override 640 protected void onPostExecute(Message message) { 641 composeActivity.stopService(mEmptyServiceIntent); 642 643 composeActivity.mRefMessage = message; 644 composeActivity.initFromRefMessage(action); 645 composeActivity.setBody(wearReply, false); 646 composeActivity.finishSetup(action, composeActivity.getIntent(), null); 647 composeActivity.sendOrSaveWithSanityChecks(false /* save */, true /* show toast */, 648 false /* orientationChanged */, true /* autoSend */); 649 } 650 }; 651 } 652 653 private void checkValidAccounts() { 654 final Account[] allAccounts = AccountUtils.getAccounts(this); 655 if (allAccounts == null || allAccounts.length == 0) { 656 final Intent noAccountIntent = MailAppProvider.getNoAccountIntent(this); 657 if (noAccountIntent != null) { 658 mAccounts = null; 659 startActivityForResult(noAccountIntent, RESULT_CREATE_ACCOUNT); 660 } 661 } else { 662 // If none of the accounts are syncing, setup a watcher. 663 boolean anySyncing = false; 664 for (Account a : allAccounts) { 665 if (a.isAccountReady()) { 666 anySyncing = true; 667 break; 668 } 669 } 670 if (!anySyncing) { 671 // There are accounts, but none are sync'd, which is just like having no accounts. 672 mAccounts = null; 673 getLoaderManager().initLoader(LOADER_ACCOUNT_CURSOR, null, this); 674 return; 675 } 676 mAccounts = AccountUtils.getSyncingAccounts(this); 677 finishCreate(); 678 } 679 } 680 681 private Account obtainAccount(Intent intent) { 682 Account account = null; 683 Object accountExtra = null; 684 if (intent != null && intent.getExtras() != null) { 685 accountExtra = intent.getExtras().get(Utils.EXTRA_ACCOUNT); 686 if (accountExtra instanceof Account) { 687 return (Account) accountExtra; 688 } else if (accountExtra instanceof String) { 689 // This is the Account attached to the widget compose intent. 690 account = Account.newInstance((String) accountExtra); 691 if (account != null) { 692 return account; 693 } 694 } 695 accountExtra = intent.hasExtra(Utils.EXTRA_ACCOUNT) ? 696 intent.getStringExtra(Utils.EXTRA_ACCOUNT) : 697 intent.getStringExtra(EXTRA_SELECTED_ACCOUNT); 698 } 699 700 MailAppProvider provider = MailAppProvider.getInstance(); 701 String lastAccountUri = provider.getLastSentFromAccount(); 702 if (TextUtils.isEmpty(lastAccountUri)) { 703 lastAccountUri = provider.getLastViewedAccount(); 704 } 705 if (!TextUtils.isEmpty(lastAccountUri)) { 706 accountExtra = Uri.parse(lastAccountUri); 707 } 708 709 if (mAccounts != null && mAccounts.length > 0) { 710 if (accountExtra instanceof String && !TextUtils.isEmpty((String) accountExtra)) { 711 // For backwards compatibility, we need to check account 712 // names. 713 for (Account a : mAccounts) { 714 if (a.getEmailAddress().equals(accountExtra)) { 715 account = a; 716 } 717 } 718 } else if (accountExtra instanceof Uri) { 719 // The uri of the last viewed account is what is stored in 720 // the current code base. 721 for (Account a : mAccounts) { 722 if (a.uri.equals(accountExtra)) { 723 account = a; 724 } 725 } 726 } 727 if (account == null) { 728 account = mAccounts[0]; 729 } 730 } 731 return account; 732 } 733 734 protected void finishSetup(int action, Intent intent, Bundle savedInstanceState) { 735 setFocus(action); 736 // Don't bother with the intent if we have procured a message from the 737 // intent already. 738 if (!hadSavedInstanceStateMessage(savedInstanceState)) { 739 initAttachmentsFromIntent(intent); 740 } 741 initActionBar(); 742 initFromSpinner(savedInstanceState != null ? savedInstanceState : intent.getExtras(), 743 action); 744 745 // If this is a draft message, the draft account is whatever account was 746 // used to open the draft message in Compose. 747 if (mDraft != null) { 748 mDraftAccount = mReplyFromAccount; 749 } 750 751 initChangeListeners(); 752 updateHideOrShowCcBcc(); 753 updateHideOrShowQuotedText(mShowQuotedText); 754 755 mRespondedInline = mInnerSavedState != null && 756 mInnerSavedState.getBoolean(EXTRA_RESPONDED_INLINE); 757 if (mRespondedInline) { 758 mQuotedTextView.setVisibility(View.GONE); 759 } 760 } 761 762 private static boolean hadSavedInstanceStateMessage(final Bundle savedInstanceState) { 763 return savedInstanceState != null && savedInstanceState.containsKey(EXTRA_MESSAGE); 764 } 765 766 private void updateHideOrShowQuotedText(boolean showQuotedText) { 767 mQuotedTextView.updateCheckedState(showQuotedText); 768 mQuotedTextView.setUpperDividerVisible(mAttachmentsView.getAttachments().size() > 0); 769 } 770 771 private void setFocus(int action) { 772 if (action == EDIT_DRAFT) { 773 int type = mDraft.draftType; 774 switch (type) { 775 case UIProvider.DraftType.COMPOSE: 776 case UIProvider.DraftType.FORWARD: 777 action = COMPOSE; 778 break; 779 case UIProvider.DraftType.REPLY: 780 case UIProvider.DraftType.REPLY_ALL: 781 default: 782 action = REPLY; 783 break; 784 } 785 } 786 switch (action) { 787 case FORWARD: 788 case COMPOSE: 789 if (TextUtils.isEmpty(mTo.getText())) { 790 mTo.requestFocus(); 791 break; 792 } 793 //$FALL-THROUGH$ 794 case REPLY: 795 case REPLY_ALL: 796 default: 797 focusBody(); 798 break; 799 } 800 } 801 802 /** 803 * Focus the body of the message. 804 */ 805 private void focusBody() { 806 mBodyView.requestFocus(); 807 resetBodySelection(); 808 } 809 810 private void resetBodySelection() { 811 int length = mBodyView.getText().length(); 812 int signatureStartPos = getSignatureStartPosition( 813 mSignature, mBodyView.getText().toString()); 814 if (signatureStartPos > -1) { 815 // In case the user deleted the newlines... 816 mBodyView.setSelection(signatureStartPos); 817 } else if (length >= 0) { 818 // Move cursor to the end. 819 mBodyView.setSelection(length); 820 } 821 } 822 823 @Override 824 protected void onStart() { 825 super.onStart(); 826 827 Analytics.getInstance().activityStart(this); 828 } 829 830 @Override 831 protected void onStop() { 832 super.onStop(); 833 834 Analytics.getInstance().activityStop(this); 835 } 836 837 @Override 838 protected void onResume() { 839 super.onResume(); 840 // Update the from spinner as other accounts 841 // may now be available. 842 if (mFromSpinner != null && mAccount != null) { 843 mFromSpinner.initialize(mComposeMode, mAccount, mAccounts, mRefMessage); 844 } 845 } 846 847 @Override 848 protected void onPause() { 849 super.onPause(); 850 851 // When the user exits the compose view, see if this draft needs saving. 852 // Don't save unnecessary drafts if we are only changing the orientation. 853 if (!isChangingConfigurations()) { 854 saveIfNeeded(); 855 856 if (isFinishing() && !mPerformedSendOrDiscard && !isBlank()) { 857 // log saving upon backing out of activity. (we avoid logging every sendOrSave() 858 // because that method can be invoked many times in a single compose session.) 859 logSendOrSave(true /* save */); 860 } 861 } 862 } 863 864 @Override 865 protected final void onActivityResult(int request, int result, Intent data) { 866 if (request == RESULT_PICK_ATTACHMENT && result == RESULT_OK) { 867 addAttachmentAndUpdateView(data); 868 mAddingAttachment = false; 869 } else if (request == RESULT_CREATE_ACCOUNT) { 870 // We were waiting for the user to create an account 871 if (result != RESULT_OK) { 872 finish(); 873 } else { 874 // Watch for accounts to show up! 875 // restart the loader to get the updated list of accounts 876 getLoaderManager().initLoader(LOADER_ACCOUNT_CURSOR, null, this); 877 showWaitFragment(null); 878 } 879 } 880 } 881 882 @Override 883 protected final void onRestoreInstanceState(Bundle savedInstanceState) { 884 final boolean hasAccounts = mAccounts != null && mAccounts.length > 0; 885 if (hasAccounts) { 886 clearChangeListeners(); 887 } 888 super.onRestoreInstanceState(savedInstanceState); 889 if (mInnerSavedState != null) { 890 if (mInnerSavedState.containsKey(EXTRA_FOCUS_SELECTION_START)) { 891 int selectionStart = mInnerSavedState.getInt(EXTRA_FOCUS_SELECTION_START); 892 int selectionEnd = mInnerSavedState.getInt(EXTRA_FOCUS_SELECTION_END); 893 // There should be a focus and it should be an EditText since we 894 // only save these extras if these conditions are true. 895 EditText focusEditText = (EditText) getCurrentFocus(); 896 final int length = focusEditText.getText().length(); 897 if (selectionStart < length && selectionEnd < length) { 898 focusEditText.setSelection(selectionStart, selectionEnd); 899 } 900 } 901 } 902 if (hasAccounts) { 903 initChangeListeners(); 904 } 905 } 906 907 @Override 908 protected final void onSaveInstanceState(Bundle state) { 909 super.onSaveInstanceState(state); 910 final Bundle inner = new Bundle(); 911 saveState(inner); 912 state.putBundle(KEY_INNER_SAVED_STATE, inner); 913 } 914 915 private void saveState(Bundle state) { 916 // We have no accounts so there is nothing to compose, and therefore, nothing to save. 917 if (mAccounts == null || mAccounts.length == 0) { 918 return; 919 } 920 // The framework is happy to save and restore the selection but only if it also saves and 921 // restores the contents of the edit text. That's a lot of text to put in a bundle so we do 922 // this manually. 923 View focus = getCurrentFocus(); 924 if (focus != null && focus instanceof EditText) { 925 EditText focusEditText = (EditText) focus; 926 state.putInt(EXTRA_FOCUS_SELECTION_START, focusEditText.getSelectionStart()); 927 state.putInt(EXTRA_FOCUS_SELECTION_END, focusEditText.getSelectionEnd()); 928 } 929 930 final List<ReplyFromAccount> replyFromAccounts = mFromSpinner.getReplyFromAccounts(); 931 final int selectedPos = mFromSpinner.getSelectedItemPosition(); 932 final ReplyFromAccount selectedReplyFromAccount = (replyFromAccounts != null 933 && replyFromAccounts.size() > 0 && replyFromAccounts.size() > selectedPos) ? 934 replyFromAccounts.get(selectedPos) : null; 935 if (selectedReplyFromAccount != null) { 936 state.putString(EXTRA_SELECTED_REPLY_FROM_ACCOUNT, selectedReplyFromAccount.serialize() 937 .toString()); 938 state.putParcelable(Utils.EXTRA_ACCOUNT, selectedReplyFromAccount.account); 939 } else { 940 state.putParcelable(Utils.EXTRA_ACCOUNT, mAccount); 941 } 942 943 if (mDraftId == UIProvider.INVALID_MESSAGE_ID && mRequestId !=0) { 944 // We don't have a draft id, and we have a request id, 945 // save the request id. 946 state.putInt(EXTRA_REQUEST_ID, mRequestId); 947 } 948 949 // We want to restore the current mode after a pause 950 // or rotation. 951 int mode = getMode(); 952 state.putInt(EXTRA_ACTION, mode); 953 954 final Message message = createMessage(selectedReplyFromAccount, mRefMessage, mode); 955 if (mDraft != null) { 956 message.id = mDraft.id; 957 message.serverId = mDraft.serverId; 958 message.uri = mDraft.uri; 959 } 960 state.putParcelable(EXTRA_MESSAGE, message); 961 962 if (mRefMessage != null) { 963 state.putParcelable(EXTRA_IN_REFERENCE_TO_MESSAGE, mRefMessage); 964 } else if (message.appendRefMessageContent) { 965 // If we have no ref message but should be appending 966 // ref message content, we have orphaned quoted text. Save it. 967 state.putCharSequence(EXTRA_QUOTED_TEXT, mQuotedTextView.getQuotedTextIfIncluded()); 968 } 969 state.putBoolean(EXTRA_SHOW_CC, mCcBccView.isCcVisible()); 970 state.putBoolean(EXTRA_SHOW_BCC, mCcBccView.isBccVisible()); 971 state.putBoolean(EXTRA_RESPONDED_INLINE, mRespondedInline); 972 state.putBoolean(EXTRA_SAVE_ENABLED, mSave != null && mSave.isEnabled()); 973 state.putParcelableArrayList( 974 EXTRA_ATTACHMENT_PREVIEWS, mAttachmentsView.getAttachmentPreviews()); 975 976 state.putParcelable(EXTRA_VALUES, mExtraValues); 977 } 978 979 private int getMode() { 980 int mode = ComposeActivity.COMPOSE; 981 ActionBar actionBar = getActionBar(); 982 if (actionBar != null 983 && actionBar.getNavigationMode() == ActionBar.NAVIGATION_MODE_LIST) { 984 mode = actionBar.getSelectedNavigationIndex(); 985 } 986 return mode; 987 } 988 989 private Message createMessage(ReplyFromAccount selectedReplyFromAccount, Message refMessage, 990 int mode) { 991 Message message = new Message(); 992 message.id = UIProvider.INVALID_MESSAGE_ID; 993 message.serverId = null; 994 message.uri = null; 995 message.conversationUri = null; 996 message.subject = mSubject.getText().toString(); 997 message.snippet = null; 998 message.setTo(formatSenders(mTo.getText().toString())); 999 message.setCc(formatSenders(mCc.getText().toString())); 1000 message.setBcc(formatSenders(mBcc.getText().toString())); 1001 message.setReplyTo(null); 1002 message.dateReceivedMs = 0; 1003 message.bodyHtml = Html.toHtml(removeComposingSpans(mBodyView.getText())); 1004 message.bodyText = mBodyView.getText().toString(); 1005 message.embedsExternalResources = false; 1006 message.refMessageUri = mRefMessage != null ? mRefMessage.uri : null; 1007 message.appendRefMessageContent = mQuotedTextView.getQuotedTextIfIncluded() != null; 1008 ArrayList<Attachment> attachments = mAttachmentsView.getAttachments(); 1009 message.hasAttachments = attachments != null && attachments.size() > 0; 1010 message.attachmentListUri = null; 1011 message.messageFlags = 0; 1012 message.alwaysShowImages = false; 1013 message.attachmentsJson = Attachment.toJSONArray(attachments); 1014 CharSequence quotedText = mQuotedTextView.getQuotedText(); 1015 message.quotedTextOffset = -1; // Just a default value. 1016 if (refMessage != null && !TextUtils.isEmpty(quotedText)) { 1017 if (!TextUtils.isEmpty(refMessage.bodyHtml)) { 1018 // We want the index to point to just the quoted text and not the 1019 // "On December 25, 2014..." part of it. 1020 message.quotedTextOffset = 1021 QuotedTextView.getQuotedTextOffset(quotedText.toString()); 1022 } else if (!TextUtils.isEmpty(refMessage.bodyText)) { 1023 // We want to point to the entire quoted text. 1024 message.quotedTextOffset = QuotedTextView.findQuotedTextIndex(quotedText); 1025 } 1026 } 1027 message.accountUri = null; 1028 final String email = selectedReplyFromAccount != null ? selectedReplyFromAccount.address 1029 : mAccount != null ? mAccount.getEmailAddress() : null; 1030 final String senderName = selectedReplyFromAccount != null ? selectedReplyFromAccount.name 1031 : mAccount != null ? mAccount.getSenderName() : null; 1032 final Address address = new Address(email, senderName); 1033 message.setFrom(address.toHeader()); 1034 message.draftType = getDraftType(mode); 1035 return message; 1036 } 1037 1038 private static String formatSenders(final String string) { 1039 if (!TextUtils.isEmpty(string) && string.charAt(string.length() - 1) == ',') { 1040 return string.substring(0, string.length() - 1); 1041 } 1042 return string; 1043 } 1044 1045 @VisibleForTesting 1046 void setAccount(Account account) { 1047 if (account == null) { 1048 return; 1049 } 1050 if (!account.equals(mAccount)) { 1051 mAccount = account; 1052 mCachedSettings = mAccount.settings; 1053 appendSignature(); 1054 } 1055 if (mAccount != null) { 1056 MailActivity.setNfcMessage(mAccount.getEmailAddress()); 1057 } 1058 } 1059 1060 private void initFromSpinner(Bundle bundle, int action) { 1061 if (action == EDIT_DRAFT && mDraft.draftType == UIProvider.DraftType.COMPOSE) { 1062 action = COMPOSE; 1063 } 1064 mFromSpinner.initialize(action, mAccount, mAccounts, mRefMessage); 1065 1066 if (bundle != null) { 1067 if (bundle.containsKey(EXTRA_SELECTED_REPLY_FROM_ACCOUNT)) { 1068 mReplyFromAccount = ReplyFromAccount.deserialize(mAccount, 1069 bundle.getString(EXTRA_SELECTED_REPLY_FROM_ACCOUNT)); 1070 } else if (bundle.containsKey(EXTRA_FROM_ACCOUNT_STRING)) { 1071 final String accountString = bundle.getString(EXTRA_FROM_ACCOUNT_STRING); 1072 mReplyFromAccount = mFromSpinner.getMatchingReplyFromAccount(accountString); 1073 } 1074 } 1075 if (mReplyFromAccount == null) { 1076 if (mDraft != null) { 1077 mReplyFromAccount = getReplyFromAccountFromDraft(mAccount, mDraft); 1078 } else if (mRefMessage != null) { 1079 mReplyFromAccount = getReplyFromAccountForReply(mAccount, mRefMessage); 1080 } 1081 } 1082 if (mReplyFromAccount == null) { 1083 mReplyFromAccount = getDefaultReplyFromAccount(mAccount); 1084 } 1085 1086 mFromSpinner.setCurrentAccount(mReplyFromAccount); 1087 1088 if (mFromSpinner.getCount() > 1) { 1089 // If there is only 1 account, just show that account. 1090 // Otherwise, give the user the ability to choose which account to 1091 // send mail from / save drafts to. 1092 mFromStatic.setVisibility(View.GONE); 1093 mFromStaticText.setText(mReplyFromAccount.address); 1094 mFromSpinnerWrapper.setVisibility(View.VISIBLE); 1095 } else { 1096 mFromStatic.setVisibility(View.VISIBLE); 1097 mFromStaticText.setText(mReplyFromAccount.address); 1098 mFromSpinnerWrapper.setVisibility(View.GONE); 1099 } 1100 } 1101 1102 private ReplyFromAccount getReplyFromAccountForReply(Account account, Message refMessage) { 1103 if (refMessage.accountUri != null) { 1104 // This must be from combined inbox. 1105 List<ReplyFromAccount> replyFromAccounts = mFromSpinner.getReplyFromAccounts(); 1106 for (ReplyFromAccount from : replyFromAccounts) { 1107 if (from.account.uri.equals(refMessage.accountUri)) { 1108 return from; 1109 } 1110 } 1111 return null; 1112 } else { 1113 return getReplyFromAccount(account, refMessage); 1114 } 1115 } 1116 1117 /** 1118 * Given an account and the message we're replying to, 1119 * return who the message should be sent from. 1120 * @param account Account in which the message arrived. 1121 * @param refMessage Message to analyze for account selection 1122 * @return the address from which to reply. 1123 */ 1124 public ReplyFromAccount getReplyFromAccount(Account account, Message refMessage) { 1125 // First see if we are supposed to use the default address or 1126 // the address it was sentTo. 1127 if (mCachedSettings.forceReplyFromDefault) { 1128 return getDefaultReplyFromAccount(account); 1129 } else { 1130 // If we aren't explicitly told which account to look for, look at 1131 // all the message recipients and find one that matches 1132 // a custom from or account. 1133 List<String> allRecipients = new ArrayList<String>(); 1134 allRecipients.addAll(Arrays.asList(refMessage.getToAddressesUnescaped())); 1135 allRecipients.addAll(Arrays.asList(refMessage.getCcAddressesUnescaped())); 1136 return getMatchingRecipient(account, allRecipients); 1137 } 1138 } 1139 1140 /** 1141 * Compare all the recipients of an email to the current account and all 1142 * custom addresses associated with that account. Return the match if there 1143 * is one, or the default account if there isn't. 1144 */ 1145 protected ReplyFromAccount getMatchingRecipient(Account account, List<String> sentTo) { 1146 // Tokenize the list and place in a hashmap. 1147 ReplyFromAccount matchingReplyFrom = null; 1148 Rfc822Token[] tokens; 1149 HashSet<String> recipientsMap = new HashSet<String>(); 1150 for (String address : sentTo) { 1151 tokens = Rfc822Tokenizer.tokenize(address); 1152 for (final Rfc822Token token : tokens) { 1153 recipientsMap.add(token.getAddress()); 1154 } 1155 } 1156 1157 int matchingAddressCount = 0; 1158 List<ReplyFromAccount> customFroms; 1159 customFroms = account.getReplyFroms(); 1160 if (customFroms != null) { 1161 for (ReplyFromAccount entry : customFroms) { 1162 if (recipientsMap.contains(entry.address)) { 1163 matchingReplyFrom = entry; 1164 matchingAddressCount++; 1165 } 1166 } 1167 } 1168 if (matchingAddressCount > 1) { 1169 matchingReplyFrom = getDefaultReplyFromAccount(account); 1170 } 1171 return matchingReplyFrom; 1172 } 1173 1174 private static ReplyFromAccount getDefaultReplyFromAccount(final Account account) { 1175 for (final ReplyFromAccount from : account.getReplyFroms()) { 1176 if (from.isDefault) { 1177 return from; 1178 } 1179 } 1180 return new ReplyFromAccount(account, account.uri, account.getEmailAddress(), 1181 account.getSenderName(), account.getEmailAddress(), true, false); 1182 } 1183 1184 private ReplyFromAccount getReplyFromAccountFromDraft(final Account account, 1185 final Message msg) { 1186 final Address[] draftFroms = Address.parse(msg.getFrom()); 1187 final String sender = draftFroms.length > 0 ? draftFroms[0].getAddress() : ""; 1188 ReplyFromAccount replyFromAccount = null; 1189 List<ReplyFromAccount> replyFromAccounts = mFromSpinner.getReplyFromAccounts(); 1190 if (TextUtils.equals(account.getEmailAddress(), sender)) { 1191 replyFromAccount = getDefaultReplyFromAccount(account); 1192 } else { 1193 for (ReplyFromAccount fromAccount : replyFromAccounts) { 1194 if (TextUtils.equals(fromAccount.address, sender)) { 1195 replyFromAccount = fromAccount; 1196 break; 1197 } 1198 } 1199 } 1200 return replyFromAccount; 1201 } 1202 1203 private void findViews() { 1204 findViewById(R.id.compose).setVisibility(View.VISIBLE); 1205 mCcBccButton = (Button) findViewById(R.id.add_cc_bcc); 1206 if (mCcBccButton != null) { 1207 mCcBccButton.setOnClickListener(this); 1208 } 1209 mCcBccView = (CcBccView) findViewById(R.id.cc_bcc_wrapper); 1210 mAttachmentsView = (AttachmentsView)findViewById(R.id.attachments); 1211 final View addAttachmentsButton = findViewById(R.id.add_attachment); 1212 if (addAttachmentsButton != null) { 1213 addAttachmentsButton.setOnClickListener(this); 1214 } 1215 mTo = (RecipientEditTextView) findViewById(R.id.to); 1216 mTo.setTokenizer(new Rfc822Tokenizer()); 1217 mCc = (RecipientEditTextView) findViewById(R.id.cc); 1218 mCc.setTokenizer(new Rfc822Tokenizer()); 1219 mBcc = (RecipientEditTextView) findViewById(R.id.bcc); 1220 mBcc.setTokenizer(new Rfc822Tokenizer()); 1221 // TODO: add special chips text change watchers before adding 1222 // this as a text changed watcher to the to, cc, bcc fields. 1223 mSubject = (TextView) findViewById(R.id.subject); 1224 mSubject.setOnEditorActionListener(this); 1225 mQuotedTextView = (QuotedTextView) findViewById(R.id.quoted_text_view); 1226 mQuotedTextView.setRespondInlineListener(this); 1227 mBodyView = (EditText) findViewById(R.id.body); 1228 mFromStatic = findViewById(R.id.static_from_content); 1229 mFromStaticText = (TextView) findViewById(R.id.from_account_name); 1230 mFromSpinnerWrapper = findViewById(R.id.spinner_from_content); 1231 mFromSpinner = (FromAddressSpinner) findViewById(R.id.from_picker); 1232 } 1233 1234 @Override 1235 public boolean onEditorAction(TextView view, int action, KeyEvent keyEvent) { 1236 if (action == EditorInfo.IME_ACTION_DONE) { 1237 focusBody(); 1238 return true; 1239 } 1240 return false; 1241 } 1242 1243 protected TextView getBody() { 1244 return mBodyView; 1245 } 1246 1247 @VisibleForTesting 1248 public String getBodyHtml() { 1249 return Html.toHtml(removeComposingSpans(mBodyView.getText())); 1250 } 1251 1252 @VisibleForTesting 1253 public Account getFromAccount() { 1254 return mReplyFromAccount != null && mReplyFromAccount.account != null ? 1255 mReplyFromAccount.account : mAccount; 1256 } 1257 1258 private void clearChangeListeners() { 1259 mSubject.removeTextChangedListener(this); 1260 mBodyView.removeTextChangedListener(this); 1261 mTo.removeTextChangedListener(mToListener); 1262 mCc.removeTextChangedListener(mCcListener); 1263 mBcc.removeTextChangedListener(mBccListener); 1264 mFromSpinner.setOnAccountChangedListener(null); 1265 mAttachmentsView.setAttachmentChangesListener(null); 1266 } 1267 1268 // Now that the message has been initialized from any existing draft or 1269 // ref message data, set up listeners for any changes that occur to the 1270 // message. 1271 private void initChangeListeners() { 1272 // Make sure we only add text changed listeners once! 1273 clearChangeListeners(); 1274 mSubject.addTextChangedListener(this); 1275 mBodyView.addTextChangedListener(this); 1276 if (mToListener == null) { 1277 mToListener = new RecipientTextWatcher(mTo, this); 1278 } 1279 mTo.addTextChangedListener(mToListener); 1280 if (mCcListener == null) { 1281 mCcListener = new RecipientTextWatcher(mCc, this); 1282 } 1283 mCc.addTextChangedListener(mCcListener); 1284 if (mBccListener == null) { 1285 mBccListener = new RecipientTextWatcher(mBcc, this); 1286 } 1287 mBcc.addTextChangedListener(mBccListener); 1288 mFromSpinner.setOnAccountChangedListener(this); 1289 mAttachmentsView.setAttachmentChangesListener(this); 1290 } 1291 1292 private void initActionBar() { 1293 LogUtils.d(LOG_TAG, "initializing action bar in ComposeActivity"); 1294 ActionBar actionBar = getActionBar(); 1295 if (actionBar == null) { 1296 return; 1297 } 1298 if (mComposeMode == ComposeActivity.COMPOSE) { 1299 actionBar.setNavigationMode(ActionBar.NAVIGATION_MODE_STANDARD); 1300 actionBar.setTitle(R.string.compose); 1301 } else { 1302 actionBar.setTitle(null); 1303 if (mComposeModeAdapter == null) { 1304 mComposeModeAdapter = new ComposeModeAdapter(this); 1305 } 1306 actionBar.setNavigationMode(ActionBar.NAVIGATION_MODE_LIST); 1307 actionBar.setListNavigationCallbacks(mComposeModeAdapter, this); 1308 switch (mComposeMode) { 1309 case ComposeActivity.REPLY: 1310 actionBar.setSelectedNavigationItem(0); 1311 break; 1312 case ComposeActivity.REPLY_ALL: 1313 actionBar.setSelectedNavigationItem(1); 1314 break; 1315 case ComposeActivity.FORWARD: 1316 actionBar.setSelectedNavigationItem(2); 1317 break; 1318 } 1319 } 1320 actionBar.setDisplayOptions(ActionBar.DISPLAY_HOME_AS_UP | ActionBar.DISPLAY_SHOW_HOME, 1321 ActionBar.DISPLAY_HOME_AS_UP | ActionBar.DISPLAY_SHOW_HOME); 1322 actionBar.setHomeButtonEnabled(true); 1323 } 1324 1325 private void initFromRefMessage(int action) { 1326 setFieldsFromRefMessage(action); 1327 1328 // Check if To: address and email body needs to be prefilled based on extras. 1329 // This is used for reporting rendering feedback. 1330 if (MessageHeaderView.ENABLE_REPORT_RENDERING_PROBLEM) { 1331 Intent intent = getIntent(); 1332 if (intent.getExtras() != null) { 1333 String toAddresses = intent.getStringExtra(EXTRA_TO); 1334 if (toAddresses != null) { 1335 addToAddresses(Arrays.asList(TextUtils.split(toAddresses, ","))); 1336 } 1337 String body = intent.getStringExtra(EXTRA_BODY); 1338 if (body != null) { 1339 setBody(body, false /* withSignature */); 1340 } 1341 } 1342 } 1343 1344 if (mRefMessage != null) { 1345 // CC field only gets populated when doing REPLY_ALL. 1346 // BCC never gets auto-populated, unless the user is editing 1347 // a draft with one. 1348 if (!TextUtils.isEmpty(mCc.getText()) && action == REPLY_ALL) { 1349 mCcBccView.show(false, true, false); 1350 } 1351 } 1352 updateHideOrShowCcBcc(); 1353 } 1354 1355 private void setFieldsFromRefMessage(int action) { 1356 setSubject(mRefMessage, action); 1357 // Setup recipients 1358 if (action == FORWARD) { 1359 mForward = true; 1360 } 1361 initRecipientsFromRefMessage(mRefMessage, action); 1362 initQuotedTextFromRefMessage(mRefMessage, action); 1363 if (action == ComposeActivity.FORWARD || mAttachmentsChanged) { 1364 initAttachments(mRefMessage); 1365 } 1366 } 1367 1368 private void initFromDraftMessage(Message message) { 1369 LogUtils.d(LOG_TAG, "Intializing draft from previous draft message: %s", message); 1370 1371 mDraft = message; 1372 mDraftId = message.id; 1373 mSubject.setText(message.subject); 1374 mForward = message.draftType == UIProvider.DraftType.FORWARD; 1375 final List<String> toAddresses = Arrays.asList(message.getToAddressesUnescaped()); 1376 addToAddresses(toAddresses); 1377 addCcAddresses(Arrays.asList(message.getCcAddressesUnescaped()), toAddresses); 1378 addBccAddresses(Arrays.asList(message.getBccAddressesUnescaped())); 1379 if (message.hasAttachments) { 1380 List<Attachment> attachments = message.getAttachments(); 1381 for (Attachment a : attachments) { 1382 addAttachmentAndUpdateView(a); 1383 } 1384 } 1385 int quotedTextIndex = message.appendRefMessageContent ? message.quotedTextOffset : -1; 1386 // Set the body 1387 CharSequence quotedText = null; 1388 if (!TextUtils.isEmpty(message.bodyHtml)) { 1389 CharSequence htmlText = ""; 1390 if (quotedTextIndex > -1) { 1391 // Find the offset in the html text of the actual quoted text and strip it out. 1392 // Note that the actual quotedTextOffset in the message has not changed as 1393 // this different offset is used only for display purposes. They point to different 1394 // parts of the original message. Please see the comments in QuoteTextView 1395 // to see the differences. 1396 quotedTextIndex = QuotedTextView.findQuotedTextIndex(message.bodyHtml); 1397 if (quotedTextIndex > -1) { 1398 htmlText = Utils.convertHtmlToPlainText(message.bodyHtml.substring(0, 1399 quotedTextIndex)); 1400 quotedText = message.bodyHtml.subSequence(quotedTextIndex, 1401 message.bodyHtml.length()); 1402 } 1403 } else { 1404 htmlText = Utils.convertHtmlToPlainText(message.bodyHtml); 1405 } 1406 mBodyView.setText(htmlText); 1407 } else { 1408 final String body = message.bodyText; 1409 final CharSequence bodyText; 1410 if (TextUtils.isEmpty(body)) { 1411 bodyText = ""; 1412 quotedText = null; 1413 } else { 1414 if (quotedTextIndex > body.length()) { 1415 // Sanity check to guarantee that we will not over index the String. 1416 // If this happens there is a bigger problem. This should never happen hence 1417 // the wtf logging. 1418 quotedTextIndex = -1; 1419 LogUtils.wtf(LOG_TAG, "quotedTextIndex (%d) > body.length() (%d)", 1420 quotedTextIndex, body.length()); 1421 } 1422 bodyText = quotedTextIndex > -1 ? body.substring(0, quotedTextIndex) : body; 1423 if (quotedTextIndex > -1) { 1424 quotedText = body.substring(quotedTextIndex); 1425 } 1426 } 1427 mBodyView.setText(bodyText); 1428 } 1429 if (quotedTextIndex > -1 && quotedText != null) { 1430 mQuotedTextView.setQuotedTextFromDraft(quotedText, mForward); 1431 } 1432 } 1433 1434 /** 1435 * Fill all the widgets with the content found in the Intent Extra, if any. 1436 * Also apply the same style to all widgets. Note: if initFromExtras is 1437 * called as a result of switching between reply, reply all, and forward per 1438 * the latest revision of Gmail, and the user has already made changes to 1439 * attachments on a previous incarnation of the message (as a reply, reply 1440 * all, or forward), the original attachments from the message will not be 1441 * re-instantiated. The user's changes will be respected. This follows the 1442 * web gmail interaction. 1443 * @return {@code true} if the activity should not call {@link #finishSetup}. 1444 */ 1445 public boolean initFromExtras(Intent intent) { 1446 // If we were invoked with a SENDTO intent, the value 1447 // should take precedence 1448 final Uri dataUri = intent.getData(); 1449 if (dataUri != null) { 1450 if (MAIL_TO.equals(dataUri.getScheme())) { 1451 initFromMailTo(dataUri.toString()); 1452 } else { 1453 if (!mAccount.composeIntentUri.equals(dataUri)) { 1454 String toText = dataUri.getSchemeSpecificPart(); 1455 if (toText != null) { 1456 mTo.setText(""); 1457 addToAddresses(Arrays.asList(TextUtils.split(toText, ","))); 1458 } 1459 } 1460 } 1461 } 1462 1463 String[] extraStrings = intent.getStringArrayExtra(Intent.EXTRA_EMAIL); 1464 if (extraStrings != null) { 1465 addToAddresses(Arrays.asList(extraStrings)); 1466 } 1467 extraStrings = intent.getStringArrayExtra(Intent.EXTRA_CC); 1468 if (extraStrings != null) { 1469 addCcAddresses(Arrays.asList(extraStrings), null); 1470 } 1471 extraStrings = intent.getStringArrayExtra(Intent.EXTRA_BCC); 1472 if (extraStrings != null) { 1473 addBccAddresses(Arrays.asList(extraStrings)); 1474 } 1475 1476 String extraString = intent.getStringExtra(Intent.EXTRA_SUBJECT); 1477 if (extraString != null) { 1478 mSubject.setText(extraString); 1479 } 1480 1481 for (String extra : ALL_EXTRAS) { 1482 if (intent.hasExtra(extra)) { 1483 String value = intent.getStringExtra(extra); 1484 if (EXTRA_TO.equals(extra)) { 1485 addToAddresses(Arrays.asList(TextUtils.split(value, ","))); 1486 } else if (EXTRA_CC.equals(extra)) { 1487 addCcAddresses(Arrays.asList(TextUtils.split(value, ",")), null); 1488 } else if (EXTRA_BCC.equals(extra)) { 1489 addBccAddresses(Arrays.asList(TextUtils.split(value, ","))); 1490 } else if (EXTRA_SUBJECT.equals(extra)) { 1491 mSubject.setText(value); 1492 } else if (EXTRA_BODY.equals(extra)) { 1493 setBody(value, true /* with signature */); 1494 } else if (EXTRA_QUOTED_TEXT.equals(extra)) { 1495 initQuotedText(value, true /* shouldQuoteText */); 1496 } 1497 } 1498 } 1499 1500 Bundle extras = intent.getExtras(); 1501 if (extras != null) { 1502 CharSequence text = extras.getCharSequence(Intent.EXTRA_TEXT); 1503 if (text != null) { 1504 setBody(text, true /* with signature */); 1505 } 1506 1507 // TODO - support EXTRA_HTML_TEXT 1508 } 1509 1510 mExtraValues = intent.getParcelableExtra(EXTRA_VALUES); 1511 if (mExtraValues != null) { 1512 LogUtils.d(LOG_TAG, "Launched with extra values: %s", mExtraValues.toString()); 1513 initExtraValues(mExtraValues); 1514 return true; 1515 } 1516 1517 return false; 1518 } 1519 1520 protected void initExtraValues(ContentValues extraValues) { 1521 // DO NOTHING - Gmail will override 1522 } 1523 1524 1525 @VisibleForTesting 1526 protected String decodeEmailInUri(String s) throws UnsupportedEncodingException { 1527 // TODO: handle the case where there are spaces in the display name as 1528 // well as the email such as "Guy with spaces <guy+with+spaces@gmail.com>" 1529 // as they could be encoded ambiguously. 1530 // Since URLDecode.decode changes + into ' ', and + is a valid 1531 // email character, we need to find/ replace these ourselves before 1532 // decoding. 1533 try { 1534 return URLDecoder.decode(replacePlus(s), UTF8_ENCODING_NAME); 1535 } catch (IllegalArgumentException e) { 1536 if (LogUtils.isLoggable(LOG_TAG, LogUtils.VERBOSE)) { 1537 LogUtils.e(LOG_TAG, "%s while decoding '%s'", e.getMessage(), s); 1538 } else { 1539 LogUtils.e(LOG_TAG, e, "Exception while decoding mailto address"); 1540 } 1541 return null; 1542 } 1543 } 1544 1545 /** 1546 * Replaces all occurrences of '+' with "%2B", to prevent URLDecode.decode from 1547 * changing '+' into ' ' 1548 * 1549 * @param toReplace Input string 1550 * @return The string with all "+" characters replaced with "%2B" 1551 */ 1552 private static String replacePlus(String toReplace) { 1553 return toReplace.replace("+", "%2B"); 1554 } 1555 1556 /** 1557 * Replaces all occurrences of '%' with "%25", to prevent URLDecode.decode from 1558 * crashing on decoded '%' symbols 1559 * 1560 * @param toReplace Input string 1561 * @return The string with all "%" characters replaced with "%25" 1562 */ 1563 private static String replacePercent(String toReplace) { 1564 return toReplace.replace("%", "%25"); 1565 } 1566 1567 /** 1568 * Helper function to encapsulate encoding/decoding string from Uri.getQueryParameters 1569 * @param content Input string 1570 * @return The string that's properly escaped to be shown in mail subject/content 1571 */ 1572 private static String decodeContentFromQueryParam(String content) { 1573 try { 1574 return URLDecoder.decode(replacePlus(replacePercent(content)), UTF8_ENCODING_NAME); 1575 } catch (UnsupportedEncodingException e) { 1576 LogUtils.e(LOG_TAG, "%s while decoding '%s'", e.getMessage(), content); 1577 return ""; // Default to empty string so setText/setBody has same behavior as before. 1578 } 1579 } 1580 1581 /** 1582 * Initialize the compose view from a String representing a mailTo uri. 1583 * @param mailToString The uri as a string. 1584 */ 1585 public void initFromMailTo(String mailToString) { 1586 // We need to disguise this string as a URI in order to parse it 1587 // TODO: Remove this hack when http://b/issue?id=1445295 gets fixed 1588 Uri uri = Uri.parse("foo://" + mailToString); 1589 int index = mailToString.indexOf("?"); 1590 int length = "mailto".length() + 1; 1591 String to; 1592 try { 1593 // Extract the recipient after mailto: 1594 if (index == -1) { 1595 to = decodeEmailInUri(mailToString.substring(length)); 1596 } else { 1597 to = decodeEmailInUri(mailToString.substring(length, index)); 1598 } 1599 if (!TextUtils.isEmpty(to)) { 1600 addToAddresses(Arrays.asList(TextUtils.split(to, ","))); 1601 } 1602 } catch (UnsupportedEncodingException e) { 1603 if (LogUtils.isLoggable(LOG_TAG, LogUtils.VERBOSE)) { 1604 LogUtils.e(LOG_TAG, "%s while decoding '%s'", e.getMessage(), mailToString); 1605 } else { 1606 LogUtils.e(LOG_TAG, e, "Exception while decoding mailto address"); 1607 } 1608 } 1609 1610 List<String> cc = uri.getQueryParameters("cc"); 1611 addCcAddresses(Arrays.asList(cc.toArray(new String[cc.size()])), null); 1612 1613 List<String> otherTo = uri.getQueryParameters("to"); 1614 addToAddresses(Arrays.asList(otherTo.toArray(new String[otherTo.size()]))); 1615 1616 List<String> bcc = uri.getQueryParameters("bcc"); 1617 addBccAddresses(Arrays.asList(bcc.toArray(new String[bcc.size()]))); 1618 1619 // NOTE: Uri.getQueryParameters already decodes % encoded characters 1620 List<String> subject = uri.getQueryParameters("subject"); 1621 if (subject.size() > 0) { 1622 mSubject.setText(decodeContentFromQueryParam(subject.get(0))); 1623 } 1624 1625 List<String> body = uri.getQueryParameters("body"); 1626 if (body.size() > 0) { 1627 setBody(decodeContentFromQueryParam(body.get(0)), true /* with signature */); 1628 } 1629 } 1630 1631 @VisibleForTesting 1632 protected void initAttachments(Message refMessage) { 1633 addAttachments(refMessage.getAttachments()); 1634 } 1635 1636 public long addAttachments(List<Attachment> attachments) { 1637 long size = 0; 1638 AttachmentFailureException error = null; 1639 for (Attachment a : attachments) { 1640 try { 1641 size += mAttachmentsView.addAttachment(mAccount, a); 1642 } catch (AttachmentFailureException e) { 1643 error = e; 1644 } 1645 } 1646 if (error != null) { 1647 LogUtils.e(LOG_TAG, error, "Error adding attachment"); 1648 if (attachments.size() > 1) { 1649 showAttachmentTooBigToast(R.string.too_large_to_attach_multiple); 1650 } else { 1651 showAttachmentTooBigToast(error.getErrorRes()); 1652 } 1653 } 1654 return size; 1655 } 1656 1657 /** 1658 * When an attachment is too large to be added to a message, show a toast. 1659 * This method also updates the position of the toast so that it is shown 1660 * clearly above they keyboard if it happens to be open. 1661 */ 1662 private void showAttachmentTooBigToast(int errorRes) { 1663 String maxSize = AttachmentUtils.convertToHumanReadableSize( 1664 getApplicationContext(), mAccount.settings.getMaxAttachmentSize()); 1665 showErrorToast(getString(errorRes, maxSize)); 1666 } 1667 1668 private void showErrorToast(String message) { 1669 Toast t = Toast.makeText(this, message, Toast.LENGTH_LONG); 1670 t.setText(message); 1671 t.setGravity(Gravity.CENTER_HORIZONTAL, 0, 1672 getResources().getDimensionPixelSize(R.dimen.attachment_toast_yoffset)); 1673 t.show(); 1674 } 1675 1676 private void initAttachmentsFromIntent(Intent intent) { 1677 Bundle extras = intent.getExtras(); 1678 if (extras == null) { 1679 extras = Bundle.EMPTY; 1680 } 1681 final String action = intent.getAction(); 1682 if (!mAttachmentsChanged) { 1683 long totalSize = 0; 1684 if (extras.containsKey(EXTRA_ATTACHMENTS)) { 1685 String[] uris = (String[]) extras.getSerializable(EXTRA_ATTACHMENTS); 1686 for (String uriString : uris) { 1687 final Uri uri = Uri.parse(uriString); 1688 long size = 0; 1689 try { 1690 final Attachment a = mAttachmentsView.generateLocalAttachment(uri); 1691 size = mAttachmentsView.addAttachment(mAccount, a); 1692 1693 Analytics.getInstance().sendEvent("send_intent_attachment", 1694 Utils.normalizeMimeType(a.getContentType()), null, size); 1695 1696 } catch (AttachmentFailureException e) { 1697 LogUtils.e(LOG_TAG, e, "Error adding attachment"); 1698 showAttachmentTooBigToast(e.getErrorRes()); 1699 } 1700 totalSize += size; 1701 } 1702 } 1703 if (extras.containsKey(Intent.EXTRA_STREAM)) { 1704 if (Intent.ACTION_SEND_MULTIPLE.equals(action)) { 1705 ArrayList<Parcelable> uris = extras 1706 .getParcelableArrayList(Intent.EXTRA_STREAM); 1707 ArrayList<Attachment> attachments = new ArrayList<Attachment>(); 1708 for (Parcelable uri : uris) { 1709 try { 1710 final Attachment a = mAttachmentsView.generateLocalAttachment( 1711 (Uri) uri); 1712 attachments.add(a); 1713 1714 Analytics.getInstance().sendEvent("send_intent_attachment", 1715 Utils.normalizeMimeType(a.getContentType()), null, a.size); 1716 1717 } catch (AttachmentFailureException e) { 1718 LogUtils.e(LOG_TAG, e, "Error adding attachment"); 1719 String maxSize = AttachmentUtils.convertToHumanReadableSize( 1720 getApplicationContext(), 1721 mAccount.settings.getMaxAttachmentSize()); 1722 showErrorToast(getString 1723 (R.string.generic_attachment_problem, maxSize)); 1724 } 1725 } 1726 totalSize += addAttachments(attachments); 1727 } else { 1728 final Uri uri = extras.getParcelable(Intent.EXTRA_STREAM); 1729 long size = 0; 1730 try { 1731 final Attachment a = mAttachmentsView.generateLocalAttachment(uri); 1732 size = mAttachmentsView.addAttachment(mAccount, a); 1733 1734 Analytics.getInstance().sendEvent("send_intent_attachment", 1735 Utils.normalizeMimeType(a.getContentType()), null, size); 1736 1737 } catch (AttachmentFailureException e) { 1738 LogUtils.e(LOG_TAG, e, "Error adding attachment"); 1739 showAttachmentTooBigToast(e.getErrorRes()); 1740 } 1741 totalSize += size; 1742 } 1743 } 1744 1745 if (totalSize > 0) { 1746 mAttachmentsChanged = true; 1747 updateSaveUi(); 1748 1749 Analytics.getInstance().sendEvent("send_intent_with_attachments", 1750 Integer.toString(getAttachments().size()), null, totalSize); 1751 } 1752 } 1753 } 1754 1755 protected void initQuotedText(CharSequence quotedText, boolean shouldQuoteText) { 1756 mQuotedTextView.setQuotedTextFromHtml(quotedText, shouldQuoteText); 1757 mShowQuotedText = true; 1758 } 1759 1760 private void initQuotedTextFromRefMessage(Message refMessage, int action) { 1761 if (mRefMessage != null && (action == REPLY || action == REPLY_ALL || action == FORWARD)) { 1762 mQuotedTextView.setQuotedText(action, refMessage, action != FORWARD); 1763 } 1764 } 1765 1766 private void updateHideOrShowCcBcc() { 1767 // Its possible there is a menu item OR a button. 1768 boolean ccVisible = mCcBccView.isCcVisible(); 1769 boolean bccVisible = mCcBccView.isBccVisible(); 1770 if (mCcBccButton != null) { 1771 if (!ccVisible || !bccVisible) { 1772 mCcBccButton.setVisibility(View.VISIBLE); 1773 mCcBccButton.setText(getString(!ccVisible ? R.string.add_cc_label 1774 : R.string.add_bcc_label)); 1775 } else { 1776 mCcBccButton.setVisibility(View.INVISIBLE); 1777 } 1778 } 1779 } 1780 1781 private void showCcBcc(Bundle state) { 1782 if (state != null && state.containsKey(EXTRA_SHOW_CC)) { 1783 boolean showCc = state.getBoolean(EXTRA_SHOW_CC); 1784 boolean showBcc = state.getBoolean(EXTRA_SHOW_BCC); 1785 if (showCc || showBcc) { 1786 mCcBccView.show(false, showCc, showBcc); 1787 } 1788 } 1789 } 1790 1791 /** 1792 * Add attachment and update the compose area appropriately. 1793 */ 1794 private void addAttachmentAndUpdateView(Intent data) { 1795 if (data == null) { 1796 return; 1797 } 1798 1799 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { 1800 final ClipData clipData = data.getClipData(); 1801 if (clipData != null) { 1802 for (int i = 0, size = clipData.getItemCount(); i < size; i++) { 1803 addAttachmentAndUpdateView(clipData.getItemAt(i).getUri()); 1804 } 1805 return; 1806 } 1807 } 1808 1809 addAttachmentAndUpdateView(data.getData()); 1810 } 1811 1812 private void addAttachmentAndUpdateView(Uri contentUri) { 1813 if (contentUri == null) { 1814 return; 1815 } 1816 try { 1817 addAttachmentAndUpdateView(mAttachmentsView.generateLocalAttachment(contentUri)); 1818 } catch (AttachmentFailureException e) { 1819 LogUtils.e(LOG_TAG, e, "Error adding attachment"); 1820 showErrorToast(getResources().getString( 1821 e.getErrorRes(), 1822 AttachmentUtils.convertToHumanReadableSize( 1823 getApplicationContext(), mAccount.settings.getMaxAttachmentSize()))); 1824 } 1825 } 1826 1827 private void addAttachmentAndUpdateView(Attachment attachment) { 1828 try { 1829 long size = mAttachmentsView.addAttachment(mAccount, attachment); 1830 if (size > 0) { 1831 mAttachmentsChanged = true; 1832 updateSaveUi(); 1833 } 1834 } catch (AttachmentFailureException e) { 1835 LogUtils.e(LOG_TAG, e, "Error adding attachment"); 1836 showAttachmentTooBigToast(e.getErrorRes()); 1837 } 1838 } 1839 1840 void initRecipientsFromRefMessage(Message refMessage, int action) { 1841 // Don't populate the address if this is a forward. 1842 if (action == ComposeActivity.FORWARD) { 1843 return; 1844 } 1845 initReplyRecipients(refMessage, action); 1846 } 1847 1848 // TODO: This should be private. This method shouldn't be used by ComposeActivityTests, as 1849 // it doesn't setup the state of the activity correctly 1850 @VisibleForTesting 1851 void initReplyRecipients(final Message refMessage, final int action) { 1852 String[] sentToAddresses = refMessage.getToAddressesUnescaped(); 1853 final Collection<String> toAddresses; 1854 final String[] fromAddresses = refMessage.getFromAddressesUnescaped(); 1855 final String fromAddress = fromAddresses.length > 0 ? fromAddresses[0] : null; 1856 final String[] replyToAddresses = getReplyToAddresses( 1857 refMessage.getReplyToAddressesUnescaped(), fromAddress); 1858 1859 // If this is a reply, the Cc list is empty. If this is a reply-all, the 1860 // Cc list is the union of the To and Cc recipients of the original 1861 // message, excluding the current user's email address and any addresses 1862 // already on the To list. 1863 if (action == ComposeActivity.REPLY) { 1864 toAddresses = initToRecipients(fromAddress, replyToAddresses, sentToAddresses); 1865 addToAddresses(toAddresses); 1866 } else if (action == ComposeActivity.REPLY_ALL) { 1867 final Set<String> ccAddresses = Sets.newHashSet(); 1868 toAddresses = initToRecipients(fromAddress, replyToAddresses, sentToAddresses); 1869 addToAddresses(toAddresses); 1870 addRecipients(ccAddresses, sentToAddresses); 1871 addRecipients(ccAddresses, refMessage.getCcAddressesUnescaped()); 1872 addCcAddresses(ccAddresses, toAddresses); 1873 } 1874 } 1875 1876 // If there is no reply to address, the reply to address is the sender. 1877 private static String[] getReplyToAddresses(String[] replyTo, String from) { 1878 boolean hasReplyTo = false; 1879 for (final String replyToAddress : replyTo) { 1880 if (!TextUtils.isEmpty(replyToAddress)) { 1881 hasReplyTo = true; 1882 } 1883 } 1884 return hasReplyTo ? replyTo : new String[] {from}; 1885 } 1886 1887 private void addToAddresses(Collection<String> addresses) { 1888 addAddressesToList(addresses, mTo); 1889 } 1890 1891 private void addCcAddresses(Collection<String> addresses, Collection<String> toAddresses) { 1892 addCcAddressesToList(tokenizeAddressList(addresses), 1893 toAddresses != null ? tokenizeAddressList(toAddresses) : null, mCc); 1894 } 1895 1896 private void addBccAddresses(Collection<String> addresses) { 1897 addAddressesToList(addresses, mBcc); 1898 } 1899 1900 @VisibleForTesting 1901 protected void addCcAddressesToList(List<Rfc822Token[]> addresses, 1902 List<Rfc822Token[]> compareToList, RecipientEditTextView list) { 1903 String address; 1904 1905 if (compareToList == null) { 1906 for (final Rfc822Token[] tokens : addresses) { 1907 for (final Rfc822Token token : tokens) { 1908 address = token.toString(); 1909 list.append(address + END_TOKEN); 1910 } 1911 } 1912 } else { 1913 HashSet<String> compareTo = convertToHashSet(compareToList); 1914 for (final Rfc822Token[] tokens : addresses) { 1915 for (final Rfc822Token token : tokens) { 1916 address = token.toString(); 1917 // Check if this is a duplicate: 1918 if (!compareTo.contains(token.getAddress())) { 1919 // Get the address here 1920 list.append(address + END_TOKEN); 1921 } 1922 } 1923 } 1924 } 1925 } 1926 1927 private static HashSet<String> convertToHashSet(final List<Rfc822Token[]> list) { 1928 final HashSet<String> hash = new HashSet<String>(); 1929 for (final Rfc822Token[] tokens : list) { 1930 for (final Rfc822Token token : tokens) { 1931 hash.add(token.getAddress()); 1932 } 1933 } 1934 return hash; 1935 } 1936 1937 protected List<Rfc822Token[]> tokenizeAddressList(Collection<String> addresses) { 1938 @VisibleForTesting 1939 List<Rfc822Token[]> tokenized = new ArrayList<Rfc822Token[]>(); 1940 1941 for (String address: addresses) { 1942 tokenized.add(Rfc822Tokenizer.tokenize(address)); 1943 } 1944 return tokenized; 1945 } 1946 1947 @VisibleForTesting 1948 void addAddressesToList(Collection<String> addresses, RecipientEditTextView list) { 1949 for (String address : addresses) { 1950 addAddressToList(address, list); 1951 } 1952 } 1953 1954 private static void addAddressToList(final String address, final RecipientEditTextView list) { 1955 if (address == null || list == null) 1956 return; 1957 1958 final Rfc822Token[] tokens = Rfc822Tokenizer.tokenize(address); 1959 1960 for (final Rfc822Token token : tokens) { 1961 list.append(token + END_TOKEN); 1962 } 1963 } 1964 1965 @VisibleForTesting 1966 protected Collection<String> initToRecipients(final String fullSenderAddress, 1967 final String[] replyToAddresses, final String[] inToAddresses) { 1968 // The To recipient is the reply-to address specified in the original 1969 // message, unless it is: 1970 // the current user OR a custom from of the current user, in which case 1971 // it's the To recipient list of the original message. 1972 // OR missing, in which case use the sender of the original message 1973 Set<String> toAddresses = Sets.newHashSet(); 1974 for (final String replyToAddress : replyToAddresses) { 1975 if (!TextUtils.isEmpty(replyToAddress) 1976 && !recipientMatchesThisAccount(replyToAddress)) { 1977 toAddresses.add(replyToAddress); 1978 } 1979 } 1980 if (toAddresses.size() == 0) { 1981 // In this case, the user is replying to a message in which their 1982 // current account or some of their custom from addresses are the only 1983 // recipients and they sent the original message. 1984 if (inToAddresses.length == 1 && recipientMatchesThisAccount(fullSenderAddress) 1985 && recipientMatchesThisAccount(inToAddresses[0])) { 1986 toAddresses.add(inToAddresses[0]); 1987 return toAddresses; 1988 } 1989 // This happens if the user replies to a message they originally 1990 // wrote. In this case, "reply" really means "re-send," so we 1991 // target the original recipients. This works as expected even 1992 // if the user sent the original message to themselves. 1993 for (String address : inToAddresses) { 1994 if (!recipientMatchesThisAccount(address)) { 1995 toAddresses.add(address); 1996 } 1997 } 1998 } 1999 return toAddresses; 2000 } 2001 2002 private void addRecipients(final Set<String> recipients, final String[] addresses) { 2003 for (final String email : addresses) { 2004 // Do not add this account, or any of its custom from addresses, to 2005 // the list of recipients. 2006 final String recipientAddress = Address.getEmailAddress(email).getAddress(); 2007 if (!recipientMatchesThisAccount(recipientAddress)) { 2008 recipients.add(email.replace("\"\"", "")); 2009 } 2010 } 2011 } 2012 2013 /** 2014 * A recipient matches this account if it has the same address as the 2015 * currently selected account OR one of the custom from addresses associated 2016 * with the currently selected account. 2017 * @param recipientAddress address we are comparing with the currently selected account 2018 */ 2019 protected boolean recipientMatchesThisAccount(String recipientAddress) { 2020 return ReplyFromAccount.matchesAccountOrCustomFrom(mAccount, recipientAddress, 2021 mAccount.getReplyFroms()); 2022 } 2023 2024 /** 2025 * Returns a formatted subject string with the appropriate prefix for the action type. 2026 * E.g., "FWD: " is prepended if action is {@link ComposeActivity#FORWARD}. 2027 */ 2028 public static String buildFormattedSubject(final Resources res, final String subject, 2029 final int action) { 2030 final String prefix; 2031 final String correctedSubject; 2032 if (action == ComposeActivity.COMPOSE) { 2033 prefix = ""; 2034 } else if (action == ComposeActivity.FORWARD) { 2035 prefix = res.getString(R.string.forward_subject_label); 2036 } else { 2037 prefix = res.getString(R.string.reply_subject_label); 2038 } 2039 2040 // Don't duplicate the prefix 2041 if (!TextUtils.isEmpty(subject) 2042 && subject.toLowerCase().startsWith(prefix.toLowerCase())) { 2043 correctedSubject = subject; 2044 } else { 2045 final String subjectOrNoSubject = TextUtils.isEmpty(subject) ? 2046 res.getString(R.string.no_subject) : 2047 subject; 2048 2049 correctedSubject = 2050 res.getString(R.string.formatted_subject, prefix, subjectOrNoSubject); 2051 } 2052 2053 return correctedSubject; 2054 } 2055 2056 private void setSubject(Message refMessage, int action) { 2057 mSubject.setText(buildFormattedSubject(getResources(), refMessage.subject, action)); 2058 } 2059 2060 private void initRecipients() { 2061 setupRecipients(mTo); 2062 setupRecipients(mCc); 2063 setupRecipients(mBcc); 2064 } 2065 2066 private void setupRecipients(RecipientEditTextView view) { 2067 view.setAdapter(new RecipientAdapter(this, mAccount)); 2068 if (mValidator == null) { 2069 final String accountName = mAccount.getEmailAddress(); 2070 int offset = accountName.indexOf("@") + 1; 2071 String account = accountName; 2072 if (offset > 0) { 2073 account = account.substring(offset); 2074 } 2075 mValidator = new Rfc822Validator(account); 2076 } 2077 view.setValidator(mValidator); 2078 } 2079 2080 @Override 2081 public void onClick(View v) { 2082 final int id = v.getId(); 2083 if (id == R.id.add_cc_bcc) { 2084 // Verify that cc/ bcc aren't showing. 2085 // Animate in cc/bcc. 2086 showCcBccViews(); 2087 } else if (id == R.id.add_attachment) { 2088 doAttach(Utils.isRunningKitkatOrLater() ? MIME_TYPE_ALL : MIME_TYPE_PHOTO); 2089 } 2090 } 2091 2092 @Override 2093 public boolean onCreateOptionsMenu(Menu menu) { 2094 final boolean superCreated = super.onCreateOptionsMenu(menu); 2095 // Don't render any menu items when there are no accounts. 2096 if (mAccounts == null || mAccounts.length == 0) { 2097 return superCreated; 2098 } 2099 MenuInflater inflater = getMenuInflater(); 2100 inflater.inflate(R.menu.compose_menu, menu); 2101 2102 /* 2103 * Start save in the correct enabled state. 2104 * 1) If a user launches compose from within gmail, save is disabled 2105 * until they add something, at which point, save is enabled, auto save 2106 * on exit; if the user empties everything, save is disabled, exiting does not 2107 * auto-save 2108 * 2) if a user replies/ reply all/ forwards from within gmail, save is 2109 * disabled until they change something, at which point, save is 2110 * enabled, auto save on exit; if the user empties everything, save is 2111 * disabled, exiting does not auto-save. 2112 * 3) If a user launches compose from another application and something 2113 * gets populated (attachments, recipients, body, subject, etc), save is 2114 * enabled, auto save on exit; if the user empties everything, save is 2115 * disabled, exiting does not auto-save 2116 */ 2117 mSave = menu.findItem(R.id.save); 2118 String action = getIntent() != null ? getIntent().getAction() : null; 2119 enableSave(mInnerSavedState != null ? 2120 mInnerSavedState.getBoolean(EXTRA_SAVE_ENABLED) 2121 : (Intent.ACTION_SEND.equals(action) 2122 || Intent.ACTION_SEND_MULTIPLE.equals(action) 2123 || Intent.ACTION_SENDTO.equals(action) 2124 || shouldSave())); 2125 2126 MenuItem helpItem = menu.findItem(R.id.help_info_menu_item); 2127 MenuItem sendFeedbackItem = menu.findItem(R.id.feedback_menu_item); 2128 if (helpItem != null) { 2129 helpItem.setVisible(mAccount != null 2130 && mAccount.supportsCapability(AccountCapabilities.HELP_CONTENT)); 2131 } 2132 if (sendFeedbackItem != null) { 2133 sendFeedbackItem.setVisible(mAccount != null 2134 && mAccount.supportsCapability(AccountCapabilities.SEND_FEEDBACK)); 2135 } 2136 2137 // Show attach picture on pre-K devices. 2138 menu.findItem(R.id.add_photo_attachment).setVisible(!Utils.isRunningKitkatOrLater()); 2139 2140 return true; 2141 } 2142 2143 @Override 2144 public boolean onPrepareOptionsMenu(Menu menu) { 2145 MenuItem ccBcc = menu.findItem(R.id.add_cc_bcc); 2146 if (ccBcc != null && mCc != null) { 2147 // Its possible there is a menu item OR a button. 2148 boolean ccFieldVisible = mCc.isShown(); 2149 boolean bccFieldVisible = mBcc.isShown(); 2150 if (!ccFieldVisible || !bccFieldVisible) { 2151 ccBcc.setVisible(true); 2152 ccBcc.setTitle(getString(!ccFieldVisible ? R.string.add_cc_label 2153 : R.string.add_bcc_label)); 2154 } else { 2155 ccBcc.setVisible(false); 2156 } 2157 } 2158 return true; 2159 } 2160 2161 @Override 2162 public boolean onOptionsItemSelected(MenuItem item) { 2163 final int id = item.getItemId(); 2164 2165 Analytics.getInstance().sendMenuItemEvent(Analytics.EVENT_CATEGORY_MENU_ITEM, id, 2166 "compose", 0); 2167 2168 boolean handled = true; 2169 if (id == R.id.add_file_attachment) { 2170 doAttach(MIME_TYPE_ALL); 2171 } else if (id == R.id.add_photo_attachment) { 2172 doAttach(MIME_TYPE_PHOTO); 2173 } else if (id == R.id.add_cc_bcc) { 2174 showCcBccViews(); 2175 } else if (id == R.id.save) { 2176 doSave(true); 2177 } else if (id == R.id.send) { 2178 doSend(); 2179 } else if (id == R.id.discard) { 2180 doDiscard(); 2181 } else if (id == R.id.settings) { 2182 Utils.showSettings(this, mAccount); 2183 } else if (id == android.R.id.home) { 2184 onAppUpPressed(); 2185 } else if (id == R.id.help_info_menu_item) { 2186 Utils.showHelp(this, mAccount, getString(R.string.compose_help_context)); 2187 } else if (id == R.id.feedback_menu_item) { 2188 Utils.sendFeedback(this, mAccount, false); 2189 } else { 2190 handled = false; 2191 } 2192 return handled || super.onOptionsItemSelected(item); 2193 } 2194 2195 @Override 2196 public void onBackPressed() { 2197 // If we are showing the wait fragment, just exit. 2198 if (getWaitFragment() != null) { 2199 finish(); 2200 } else { 2201 super.onBackPressed(); 2202 } 2203 } 2204 2205 /** 2206 * Carries out the "up" action in the action bar. 2207 */ 2208 private void onAppUpPressed() { 2209 if (mLaunchedFromEmail) { 2210 // If this was started from Gmail, simply treat app up as the system back button, so 2211 // that the last view is restored. 2212 onBackPressed(); 2213 return; 2214 } 2215 2216 // Fire the main activity to ensure it launches the "top" screen of mail. 2217 // Since the main Activity is singleTask, it should revive that task if it was already 2218 // started. 2219 final Intent mailIntent = Utils.createViewInboxIntent(mAccount); 2220 mailIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK | 2221 Intent.FLAG_ACTIVITY_TASK_ON_HOME); 2222 startActivity(mailIntent); 2223 finish(); 2224 } 2225 2226 private void doSend() { 2227 sendOrSaveWithSanityChecks(false, true, false, false); 2228 logSendOrSave(false /* save */); 2229 mPerformedSendOrDiscard = true; 2230 } 2231 2232 private void doSave(boolean showToast) { 2233 sendOrSaveWithSanityChecks(true, showToast, false, false); 2234 } 2235 2236 @VisibleForTesting 2237 public interface SendOrSaveCallback { 2238 public void initializeSendOrSave(SendOrSaveTask sendOrSaveTask); 2239 public void notifyMessageIdAllocated(SendOrSaveMessage sendOrSaveMessage, Message message); 2240 public Message getMessage(); 2241 public void sendOrSaveFinished(SendOrSaveTask sendOrSaveTask, boolean success); 2242 } 2243 2244 @VisibleForTesting 2245 public static class SendOrSaveTask implements Runnable { 2246 private final Context mContext; 2247 @VisibleForTesting 2248 public final SendOrSaveCallback mSendOrSaveCallback; 2249 @VisibleForTesting 2250 public final SendOrSaveMessage mSendOrSaveMessage; 2251 private ReplyFromAccount mExistingDraftAccount; 2252 2253 public SendOrSaveTask(Context context, SendOrSaveMessage message, 2254 SendOrSaveCallback callback, ReplyFromAccount draftAccount) { 2255 mContext = context; 2256 mSendOrSaveCallback = callback; 2257 mSendOrSaveMessage = message; 2258 mExistingDraftAccount = draftAccount; 2259 } 2260 2261 @Override 2262 public void run() { 2263 final SendOrSaveMessage sendOrSaveMessage = mSendOrSaveMessage; 2264 2265 final ReplyFromAccount selectedAccount = sendOrSaveMessage.mAccount; 2266 Message message = mSendOrSaveCallback.getMessage(); 2267 long messageId = message != null ? message.id : UIProvider.INVALID_MESSAGE_ID; 2268 // If a previous draft has been saved, in an account that is different 2269 // than what the user wants to send from, remove the old draft, and treat this 2270 // as a new message 2271 if (mExistingDraftAccount != null 2272 && !selectedAccount.account.uri.equals(mExistingDraftAccount.account.uri)) { 2273 if (messageId != UIProvider.INVALID_MESSAGE_ID) { 2274 ContentResolver resolver = mContext.getContentResolver(); 2275 ContentValues values = new ContentValues(); 2276 values.put(BaseColumns._ID, messageId); 2277 if (mExistingDraftAccount.account.expungeMessageUri != null) { 2278 new ContentProviderTask.UpdateTask() 2279 .run(resolver, mExistingDraftAccount.account.expungeMessageUri, 2280 values, null, null); 2281 } else { 2282 // TODO(mindyp) delete the conversation. 2283 } 2284 // reset messageId to 0, so a new message will be created 2285 messageId = UIProvider.INVALID_MESSAGE_ID; 2286 } 2287 } 2288 2289 final long messageIdToSave = messageId; 2290 sendOrSaveMessage(messageIdToSave, sendOrSaveMessage, selectedAccount); 2291 2292 if (!sendOrSaveMessage.mSave) { 2293 incrementRecipientsTimesContacted(mContext, 2294 (String) sendOrSaveMessage.mValues.get(UIProvider.MessageColumns.TO)); 2295 incrementRecipientsTimesContacted(mContext, 2296 (String) sendOrSaveMessage.mValues.get(UIProvider.MessageColumns.CC)); 2297 incrementRecipientsTimesContacted(mContext, 2298 (String) sendOrSaveMessage.mValues.get(UIProvider.MessageColumns.BCC)); 2299 } 2300 mSendOrSaveCallback.sendOrSaveFinished(SendOrSaveTask.this, true); 2301 } 2302 2303 private static void incrementRecipientsTimesContacted(final Context context, 2304 final String addressString) { 2305 if (TextUtils.isEmpty(addressString)) { 2306 return; 2307 } 2308 final Rfc822Token[] tokens = Rfc822Tokenizer.tokenize(addressString); 2309 final ArrayList<String> recipients = new ArrayList<String>(tokens.length); 2310 for (final Rfc822Token token : tokens) { 2311 recipients.add(token.getAddress()); 2312 } 2313 final DataUsageStatUpdater statsUpdater = new DataUsageStatUpdater(context); 2314 statsUpdater.updateWithAddress(recipients); 2315 } 2316 2317 /** 2318 * Send or Save a message. 2319 */ 2320 private void sendOrSaveMessage(final long messageIdToSave, 2321 final SendOrSaveMessage sendOrSaveMessage, final ReplyFromAccount selectedAccount) { 2322 final ContentResolver resolver = mContext.getContentResolver(); 2323 final boolean updateExistingMessage = messageIdToSave != UIProvider.INVALID_MESSAGE_ID; 2324 2325 final String accountMethod = sendOrSaveMessage.mSave ? 2326 UIProvider.AccountCallMethods.SAVE_MESSAGE : 2327 UIProvider.AccountCallMethods.SEND_MESSAGE; 2328 2329 try { 2330 if (updateExistingMessage) { 2331 sendOrSaveMessage.mValues.put(BaseColumns._ID, messageIdToSave); 2332 2333 callAccountSendSaveMethod(resolver, 2334 selectedAccount.account, accountMethod, sendOrSaveMessage); 2335 } else { 2336 Uri messageUri = null; 2337 final Bundle result = callAccountSendSaveMethod(resolver, 2338 selectedAccount.account, accountMethod, sendOrSaveMessage); 2339 if (result != null) { 2340 // If a non-null value was returned, then the provider handled the call 2341 // method 2342 messageUri = result.getParcelable(UIProvider.MessageColumns.URI); 2343 } 2344 if (sendOrSaveMessage.mSave && messageUri != null) { 2345 final Cursor messageCursor = resolver.query(messageUri, 2346 UIProvider.MESSAGE_PROJECTION, null, null, null); 2347 if (messageCursor != null) { 2348 try { 2349 if (messageCursor.moveToFirst()) { 2350 // Broadcast notification that a new message has 2351 // been allocated 2352 mSendOrSaveCallback.notifyMessageIdAllocated(sendOrSaveMessage, 2353 new Message(messageCursor)); 2354 } 2355 } finally { 2356 messageCursor.close(); 2357 } 2358 } 2359 } 2360 } 2361 } finally { 2362 // Close any opened file descriptors 2363 closeOpenedAttachmentFds(sendOrSaveMessage); 2364 } 2365 } 2366 2367 private static void closeOpenedAttachmentFds(final SendOrSaveMessage sendOrSaveMessage) { 2368 final Bundle openedFds = sendOrSaveMessage.attachmentFds(); 2369 if (openedFds != null) { 2370 final Set<String> keys = openedFds.keySet(); 2371 for (final String key : keys) { 2372 final ParcelFileDescriptor fd = openedFds.getParcelable(key); 2373 if (fd != null) { 2374 try { 2375 fd.close(); 2376 } catch (IOException e) { 2377 // Do nothing 2378 } 2379 } 2380 } 2381 } 2382 } 2383 2384 /** 2385 * Use the {@link ContentResolver#call} method to send or save the message. 2386 * 2387 * If this was successful, this method will return an non-null Bundle instance 2388 */ 2389 private static Bundle callAccountSendSaveMethod(final ContentResolver resolver, 2390 final Account account, final String method, 2391 final SendOrSaveMessage sendOrSaveMessage) { 2392 // Copy all of the values from the content values to the bundle 2393 final Bundle methodExtras = new Bundle(sendOrSaveMessage.mValues.size()); 2394 final Set<Entry<String, Object>> valueSet = sendOrSaveMessage.mValues.valueSet(); 2395 2396 for (Entry<String, Object> entry : valueSet) { 2397 final Object entryValue = entry.getValue(); 2398 final String key = entry.getKey(); 2399 if (entryValue instanceof String) { 2400 methodExtras.putString(key, (String)entryValue); 2401 } else if (entryValue instanceof Boolean) { 2402 methodExtras.putBoolean(key, (Boolean)entryValue); 2403 } else if (entryValue instanceof Integer) { 2404 methodExtras.putInt(key, (Integer)entryValue); 2405 } else if (entryValue instanceof Long) { 2406 methodExtras.putLong(key, (Long)entryValue); 2407 } else { 2408 LogUtils.wtf(LOG_TAG, "Unexpected object type: %s", 2409 entryValue.getClass().getName()); 2410 } 2411 } 2412 2413 // If the SendOrSaveMessage has some opened fds, add them to the bundle 2414 final Bundle fdMap = sendOrSaveMessage.attachmentFds(); 2415 if (fdMap != null) { 2416 methodExtras.putParcelable( 2417 UIProvider.SendOrSaveMethodParamKeys.OPENED_FD_MAP, fdMap); 2418 } 2419 2420 return resolver.call(account.uri, method, account.uri.toString(), methodExtras); 2421 } 2422 } 2423 2424 @VisibleForTesting 2425 public static class SendOrSaveMessage { 2426 final ReplyFromAccount mAccount; 2427 final ContentValues mValues; 2428 final String mRefMessageId; 2429 @VisibleForTesting 2430 public final boolean mSave; 2431 final int mRequestId; 2432 private final Bundle mAttachmentFds; 2433 2434 public SendOrSaveMessage(Context context, ReplyFromAccount account, ContentValues values, 2435 String refMessageId, List<Attachment> attachments, boolean save) { 2436 mAccount = account; 2437 mValues = values; 2438 mRefMessageId = refMessageId; 2439 mSave = save; 2440 mRequestId = mValues.hashCode() ^ hashCode(); 2441 2442 mAttachmentFds = initializeAttachmentFds(context, attachments); 2443 } 2444 2445 int requestId() { 2446 return mRequestId; 2447 } 2448 2449 Bundle attachmentFds() { 2450 return mAttachmentFds; 2451 } 2452 2453 /** 2454 * Opens {@link ParcelFileDescriptor} for each of the attachments. This method must be 2455 * called before the ComposeActivity finishes. 2456 * Note: The caller is responsible for closing these file descriptors. 2457 */ 2458 private static Bundle initializeAttachmentFds(final Context context, 2459 final List<Attachment> attachments) { 2460 if (attachments == null || attachments.size() == 0) { 2461 return null; 2462 } 2463 2464 final Bundle result = new Bundle(attachments.size()); 2465 final ContentResolver resolver = context.getContentResolver(); 2466 2467 for (Attachment attachment : attachments) { 2468 if (attachment == null || Utils.isEmpty(attachment.contentUri)) { 2469 continue; 2470 } 2471 2472 ParcelFileDescriptor fileDescriptor; 2473 try { 2474 fileDescriptor = resolver.openFileDescriptor(attachment.contentUri, "r"); 2475 } catch (FileNotFoundException e) { 2476 LogUtils.e(LOG_TAG, e, "Exception attempting to open attachment"); 2477 fileDescriptor = null; 2478 } catch (SecurityException e) { 2479 // We have encountered a security exception when attempting to open the file 2480 // specified by the content uri. If the attachment has been cached, this 2481 // isn't a problem, as even through the original permission may have been 2482 // revoked, we have cached the file. This will happen when saving/sending 2483 // a previously saved draft. 2484 // TODO(markwei): Expose whether the attachment has been cached through the 2485 // attachment object. This would allow us to limit when the log is made, as 2486 // if the attachment has been cached, this really isn't an error 2487 LogUtils.e(LOG_TAG, e, "Security Exception attempting to open attachment"); 2488 // Just set the file descriptor to null, as the underlying provider needs 2489 // to handle the file descriptor not being set. 2490 fileDescriptor = null; 2491 } 2492 2493 if (fileDescriptor != null) { 2494 result.putParcelable(attachment.contentUri.toString(), fileDescriptor); 2495 } 2496 } 2497 2498 return result; 2499 } 2500 } 2501 2502 /** 2503 * Get the to recipients. 2504 */ 2505 public String[] getToAddresses() { 2506 return getAddressesFromList(mTo); 2507 } 2508 2509 /** 2510 * Get the cc recipients. 2511 */ 2512 public String[] getCcAddresses() { 2513 return getAddressesFromList(mCc); 2514 } 2515 2516 /** 2517 * Get the bcc recipients. 2518 */ 2519 public String[] getBccAddresses() { 2520 return getAddressesFromList(mBcc); 2521 } 2522 2523 public String[] getAddressesFromList(RecipientEditTextView list) { 2524 if (list == null) { 2525 return new String[0]; 2526 } 2527 Rfc822Token[] tokens = Rfc822Tokenizer.tokenize(list.getText()); 2528 int count = tokens.length; 2529 String[] result = new String[count]; 2530 for (int i = 0; i < count; i++) { 2531 result[i] = tokens[i].toString(); 2532 } 2533 return result; 2534 } 2535 2536 /** 2537 * Check for invalid email addresses. 2538 * @param to String array of email addresses to check. 2539 * @param wrongEmailsOut Emails addresses that were invalid. 2540 */ 2541 public void checkInvalidEmails(final String[] to, final List<String> wrongEmailsOut) { 2542 if (mValidator == null) { 2543 return; 2544 } 2545 for (final String email : to) { 2546 if (!mValidator.isValid(email)) { 2547 wrongEmailsOut.add(email); 2548 } 2549 } 2550 } 2551 2552 public static class RecipientErrorDialogFragment extends DialogFragment { 2553 // Public no-args constructor needed for fragment re-instantiation 2554 public RecipientErrorDialogFragment() {} 2555 2556 public static RecipientErrorDialogFragment newInstance(final String message) { 2557 final RecipientErrorDialogFragment frag = new RecipientErrorDialogFragment(); 2558 final Bundle args = new Bundle(1); 2559 args.putString("message", message); 2560 frag.setArguments(args); 2561 return frag; 2562 } 2563 2564 @Override 2565 public Dialog onCreateDialog(Bundle savedInstanceState) { 2566 final String message = getArguments().getString("message"); 2567 return new AlertDialog.Builder(getActivity()) 2568 .setMessage(message) 2569 .setPositiveButton( 2570 R.string.ok, new Dialog.OnClickListener() { 2571 @Override 2572 public void onClick(DialogInterface dialog, int which) { 2573 ((ComposeActivity)getActivity()).finishRecipientErrorDialog(); 2574 } 2575 }).create(); 2576 } 2577 } 2578 2579 private void finishRecipientErrorDialog() { 2580 // after the user dismisses the recipient error 2581 // dialog we want to make sure to refocus the 2582 // recipient to field so they can fix the issue 2583 // easily 2584 if (mTo != null) { 2585 mTo.requestFocus(); 2586 } 2587 } 2588 2589 /** 2590 * Show an error because the user has entered an invalid recipient. 2591 */ 2592 private void showRecipientErrorDialog(final String message) { 2593 final DialogFragment frag = RecipientErrorDialogFragment.newInstance(message); 2594 frag.show(getFragmentManager(), "recipient error"); 2595 } 2596 2597 /** 2598 * Update the state of the UI based on whether or not the current draft 2599 * needs to be saved and the message is not empty. 2600 */ 2601 public void updateSaveUi() { 2602 if (mSave != null) { 2603 mSave.setEnabled((shouldSave() && !isBlank())); 2604 } 2605 } 2606 2607 /** 2608 * Returns true if we need to save the current draft. 2609 */ 2610 private boolean shouldSave() { 2611 synchronized (mDraftLock) { 2612 // The message should only be saved if: 2613 // It hasn't been sent AND 2614 // Some text has been added to the message OR 2615 // an attachment has been added or removed 2616 // AND there is actually something in the draft to save. 2617 return (mTextChanged || mAttachmentsChanged || mReplyFromChanged) 2618 && !isBlank(); 2619 } 2620 } 2621 2622 /** 2623 * Check if all fields are blank. 2624 * @return boolean 2625 */ 2626 public boolean isBlank() { 2627 // Need to check for null since isBlank() can be called from onPause() 2628 // before findViews() is called 2629 if (mSubject == null || mBodyView == null || mTo == null || mCc == null || 2630 mAttachmentsView == null) { 2631 LogUtils.w(LOG_TAG, "null views in isBlank check"); 2632 return true; 2633 } 2634 return mSubject.getText().length() == 0 2635 && (mBodyView.getText().length() == 0 || getSignatureStartPosition(mSignature, 2636 mBodyView.getText().toString()) == 0) 2637 && mTo.length() == 0 2638 && mCc.length() == 0 && mBcc.length() == 0 2639 && mAttachmentsView.getAttachments().size() == 0; 2640 } 2641 2642 @VisibleForTesting 2643 protected int getSignatureStartPosition(String signature, String bodyText) { 2644 int startPos = -1; 2645 2646 if (TextUtils.isEmpty(signature) || TextUtils.isEmpty(bodyText)) { 2647 return startPos; 2648 } 2649 2650 int bodyLength = bodyText.length(); 2651 int signatureLength = signature.length(); 2652 String printableVersion = convertToPrintableSignature(signature); 2653 int printableLength = printableVersion.length(); 2654 2655 if (bodyLength >= printableLength 2656 && bodyText.substring(bodyLength - printableLength) 2657 .equals(printableVersion)) { 2658 startPos = bodyLength - printableLength; 2659 } else if (bodyLength >= signatureLength 2660 && bodyText.substring(bodyLength - signatureLength) 2661 .equals(signature)) { 2662 startPos = bodyLength - signatureLength; 2663 } 2664 return startPos; 2665 } 2666 2667 /** 2668 * Allows any changes made by the user to be ignored. Called when the user 2669 * decides to discard a draft. 2670 */ 2671 private void discardChanges() { 2672 mTextChanged = false; 2673 mAttachmentsChanged = false; 2674 mReplyFromChanged = false; 2675 } 2676 2677 /** 2678 * @param save True to save, false to send 2679 * @param showToast True to show a toast once the message is sent/saved 2680 * @return Whether the send or save succeeded. 2681 */ 2682 protected boolean sendOrSaveWithSanityChecks(final boolean save, final boolean showToast, 2683 final boolean orientationChanged, final boolean autoSend) { 2684 if (mAccounts == null || mAccount == null) { 2685 Toast.makeText(this, R.string.send_failed, Toast.LENGTH_SHORT).show(); 2686 if (autoSend) { 2687 finish(); 2688 } 2689 return false; 2690 } 2691 2692 final String[] to, cc, bcc; 2693 if (orientationChanged) { 2694 to = cc = bcc = new String[0]; 2695 } else { 2696 to = getToAddresses(); 2697 cc = getCcAddresses(); 2698 bcc = getBccAddresses(); 2699 } 2700 2701 // Don't let the user send to nobody (but it's okay to save a message 2702 // with no recipients) 2703 if (!save && (to.length == 0 && cc.length == 0 && bcc.length == 0)) { 2704 showRecipientErrorDialog(getString(R.string.recipient_needed)); 2705 return false; 2706 } 2707 2708 List<String> wrongEmails = new ArrayList<String>(); 2709 if (!save) { 2710 checkInvalidEmails(to, wrongEmails); 2711 checkInvalidEmails(cc, wrongEmails); 2712 checkInvalidEmails(bcc, wrongEmails); 2713 } 2714 2715 // Don't let the user send an email with invalid recipients 2716 if (wrongEmails.size() > 0) { 2717 String errorText = String.format(getString(R.string.invalid_recipient), 2718 wrongEmails.get(0)); 2719 showRecipientErrorDialog(errorText); 2720 return false; 2721 } 2722 2723 // Show a warning before sending only if there are no attachments. 2724 if (!save) { 2725 if (mAttachmentsView.getAttachments().isEmpty() && showEmptyTextWarnings()) { 2726 boolean warnAboutEmptySubject = isSubjectEmpty(); 2727 boolean emptyBody = TextUtils.getTrimmedLength(mBodyView.getEditableText()) == 0; 2728 2729 // A warning about an empty body may not be warranted when 2730 // forwarding mails, since a common use case is to forward 2731 // quoted text and not append any more text. 2732 boolean warnAboutEmptyBody = emptyBody && (!mForward || isBodyEmpty()); 2733 2734 // When we bring up a dialog warning the user about a send, 2735 // assume that they accept sending the message. If they do not, 2736 // the dialog listener is required to enable sending again. 2737 if (warnAboutEmptySubject) { 2738 showSendConfirmDialog(R.string.confirm_send_message_with_no_subject, 2739 showToast); 2740 return true; 2741 } 2742 2743 if (warnAboutEmptyBody) { 2744 showSendConfirmDialog(R.string.confirm_send_message_with_no_body, 2745 showToast); 2746 return true; 2747 } 2748 } 2749 // Ask for confirmation to send (if always required) 2750 if (showSendConfirmation()) { 2751 showSendConfirmDialog(R.string.confirm_send_message, showToast); 2752 return true; 2753 } 2754 } 2755 2756 sendOrSave(save, showToast); 2757 return true; 2758 } 2759 2760 /** 2761 * Returns a boolean indicating whether warnings should be shown for empty 2762 * subject and body fields 2763 * 2764 * @return True if a warning should be shown for empty text fields 2765 */ 2766 protected boolean showEmptyTextWarnings() { 2767 return mAttachmentsView.getAttachments().size() == 0; 2768 } 2769 2770 /** 2771 * Returns a boolean indicating whether the user should confirm each send 2772 * 2773 * @return True if a warning should be on each send 2774 */ 2775 protected boolean showSendConfirmation() { 2776 return mCachedSettings != null && mCachedSettings.confirmSend; 2777 } 2778 2779 public static class SendConfirmDialogFragment extends DialogFragment 2780 implements DialogInterface.OnClickListener { 2781 2782 private boolean mShowToast; 2783 2784 // Public no-args constructor needed for fragment re-instantiation 2785 public SendConfirmDialogFragment() {} 2786 2787 public static SendConfirmDialogFragment newInstance(final int messageId, 2788 final boolean showToast) { 2789 final SendConfirmDialogFragment frag = new SendConfirmDialogFragment(); 2790 final Bundle args = new Bundle(3); 2791 args.putInt("messageId", messageId); 2792 args.putBoolean("showToast", showToast); 2793 frag.setArguments(args); 2794 return frag; 2795 } 2796 2797 @Override 2798 public Dialog onCreateDialog(Bundle savedInstanceState) { 2799 final int messageId = getArguments().getInt("messageId"); 2800 mShowToast = getArguments().getBoolean("showToast"); 2801 2802 final int confirmTextId = (messageId == R.string.confirm_send_message) ? 2803 R.string.ok : R.string.send; 2804 2805 return new AlertDialog.Builder(getActivity()) 2806 .setMessage(messageId) 2807 .setPositiveButton(confirmTextId, this) 2808 .setNegativeButton(R.string.cancel, null) 2809 .create(); 2810 } 2811 2812 @Override 2813 public void onClick(DialogInterface dialog, int which) { 2814 if (which == DialogInterface.BUTTON_POSITIVE) { 2815 ((ComposeActivity) getActivity()).finishSendConfirmDialog(mShowToast); 2816 } 2817 } 2818 } 2819 2820 private void finishSendConfirmDialog(final boolean showToast) { 2821 sendOrSave(false /* save */, showToast); 2822 } 2823 2824 private void showSendConfirmDialog(final int messageId, 2825 final boolean showToast) { 2826 final DialogFragment frag = SendConfirmDialogFragment.newInstance(messageId, 2827 showToast); 2828 frag.show(getFragmentManager(), "send confirm"); 2829 } 2830 2831 /** 2832 * Returns whether the ComposeArea believes there is any text in the body of 2833 * the composition. TODO: When ComposeArea controls the Body as well, add 2834 * that here. 2835 */ 2836 public boolean isBodyEmpty() { 2837 return !mQuotedTextView.isTextIncluded(); 2838 } 2839 2840 /** 2841 * Test to see if the subject is empty. 2842 * 2843 * @return boolean. 2844 */ 2845 // TODO: this will likely go away when composeArea.focus() is implemented 2846 // after all the widget control is moved over. 2847 public boolean isSubjectEmpty() { 2848 return TextUtils.getTrimmedLength(mSubject.getText()) == 0; 2849 } 2850 2851 @VisibleForTesting 2852 public String getSubject() { 2853 return mSubject.getText().toString(); 2854 } 2855 2856 /* package */ 2857 static int sendOrSaveInternal(Context context, ReplyFromAccount replyFromAccount, 2858 Message message, final Message refMessage, Spanned body, final CharSequence quotedText, 2859 SendOrSaveCallback callback, Handler handler, boolean save, int composeMode, 2860 ReplyFromAccount draftAccount, final ContentValues extraValues) { 2861 final ContentValues values = new ContentValues(); 2862 2863 final String refMessageId = refMessage != null ? refMessage.uri.toString() : ""; 2864 2865 MessageModification.putToAddresses(values, message.getToAddresses()); 2866 MessageModification.putCcAddresses(values, message.getCcAddresses()); 2867 MessageModification.putBccAddresses(values, message.getBccAddresses()); 2868 MessageModification.putCustomFromAddress(values, message.getFrom()); 2869 2870 MessageModification.putSubject(values, message.subject); 2871 2872 // Make sure to remove only the composing spans from the Spannable before saving. 2873 final String htmlBody = Html.toHtml(removeComposingSpans(body)); 2874 final String textBody = Utils.convertHtmlToPlainText(htmlBody); 2875 // fullbody will contain the actual body plus the quoted text. 2876 final String fullBody; 2877 final String quotedString; 2878 final boolean hasQuotedText = !TextUtils.isEmpty(quotedText); 2879 if (hasQuotedText) { 2880 // The quoted text is HTML at this point. 2881 quotedString = quotedText.toString(); 2882 fullBody = htmlBody + quotedString; 2883 MessageModification.putForward(values, composeMode == ComposeActivity.FORWARD); 2884 MessageModification.putAppendRefMessageContent(values, true /* include quoted */); 2885 } else { 2886 fullBody = htmlBody; 2887 quotedString = null; 2888 } 2889 if (refMessage != null) { 2890 // The code below might need to be revisited. The quoted text position is different 2891 // between text/html and text/plain parts and they should be stored seperately and 2892 // the right version should be used in the UI. text/html should have preference 2893 // if both exist. Issues like this made me file b/14256940 to make sure that we 2894 // properly handle the existing of both text/html and text/plain parts and to verify 2895 // that we are not making some assumptions that break if there is no text/html part. 2896 int quotedTextPos = -1; 2897 if (!TextUtils.isEmpty(refMessage.bodyHtml)) { 2898 MessageModification.putBodyHtml(values, fullBody.toString()); 2899 if (hasQuotedText) { 2900 quotedTextPos = htmlBody.length() + 2901 QuotedTextView.getQuotedTextOffset(quotedString); 2902 } 2903 } 2904 if (!TextUtils.isEmpty(refMessage.bodyText)) { 2905 MessageModification.putBody(values, 2906 Utils.convertHtmlToPlainText(fullBody.toString())); 2907 if (hasQuotedText && (quotedTextPos == -1)) { 2908 quotedTextPos = textBody.length(); 2909 } 2910 } 2911 if (quotedTextPos != -1) { 2912 // The quoted text pos is the text/html version first and the text/plan version 2913 // if there is no text/html part. The reason for this is because preference 2914 // is given to text/html in the compose window if it exists. In the future, we 2915 // should calculate the index for both since the user could choose to compose 2916 // explicitly in text/plain. 2917 MessageModification.putQuoteStartPos(values, quotedTextPos); 2918 } 2919 } else { 2920 MessageModification.putBodyHtml(values, fullBody.toString()); 2921 MessageModification.putBody(values, Utils.convertHtmlToPlainText(fullBody.toString())); 2922 } 2923 int draftType = getDraftType(composeMode); 2924 MessageModification.putDraftType(values, draftType); 2925 MessageModification.putAttachments(values, message.getAttachments()); 2926 if (!TextUtils.isEmpty(refMessageId)) { 2927 MessageModification.putRefMessageId(values, refMessageId); 2928 } 2929 if (extraValues != null) { 2930 values.putAll(extraValues); 2931 } 2932 SendOrSaveMessage sendOrSaveMessage = new SendOrSaveMessage(context, replyFromAccount, 2933 values, refMessageId, message.getAttachments(), save); 2934 SendOrSaveTask sendOrSaveTask = new SendOrSaveTask(context, sendOrSaveMessage, callback, 2935 draftAccount); 2936 2937 callback.initializeSendOrSave(sendOrSaveTask); 2938 // Do the send/save action on the specified handler to avoid possible 2939 // ANRs 2940 handler.post(sendOrSaveTask); 2941 2942 return sendOrSaveMessage.requestId(); 2943 } 2944 2945 /** 2946 * Removes any composing spans from the specified string. This will create a new 2947 * SpannableString instance, as to not modify the behavior of the EditText view. 2948 */ 2949 private static SpannableString removeComposingSpans(Spanned body) { 2950 final SpannableString messageBody = new SpannableString(body); 2951 BaseInputConnection.removeComposingSpans(messageBody); 2952 return messageBody; 2953 } 2954 2955 private static int getDraftType(int mode) { 2956 int draftType = -1; 2957 switch (mode) { 2958 case ComposeActivity.COMPOSE: 2959 draftType = DraftType.COMPOSE; 2960 break; 2961 case ComposeActivity.REPLY: 2962 draftType = DraftType.REPLY; 2963 break; 2964 case ComposeActivity.REPLY_ALL: 2965 draftType = DraftType.REPLY_ALL; 2966 break; 2967 case ComposeActivity.FORWARD: 2968 draftType = DraftType.FORWARD; 2969 break; 2970 } 2971 return draftType; 2972 } 2973 2974 private void sendOrSave(final boolean save, final boolean showToast) { 2975 // Check if user is a monkey. Monkeys can compose and hit send 2976 // button but are not allowed to send anything off the device. 2977 if (ActivityManager.isUserAMonkey()) { 2978 return; 2979 } 2980 2981 final Spanned body = mBodyView.getEditableText(); 2982 2983 SendOrSaveCallback callback = new SendOrSaveCallback() { 2984 // FIXME: unused 2985 private int mRestoredRequestId; 2986 2987 @Override 2988 public void initializeSendOrSave(SendOrSaveTask sendOrSaveTask) { 2989 synchronized (mActiveTasks) { 2990 int numTasks = mActiveTasks.size(); 2991 if (numTasks == 0) { 2992 // Start service so we won't be killed if this app is 2993 // put in the background. 2994 startService(new Intent(ComposeActivity.this, EmptyService.class)); 2995 } 2996 2997 mActiveTasks.add(sendOrSaveTask); 2998 } 2999 if (sTestSendOrSaveCallback != null) { 3000 sTestSendOrSaveCallback.initializeSendOrSave(sendOrSaveTask); 3001 } 3002 } 3003 3004 @Override 3005 public void notifyMessageIdAllocated(SendOrSaveMessage sendOrSaveMessage, 3006 Message message) { 3007 synchronized (mDraftLock) { 3008 mDraftAccount = sendOrSaveMessage.mAccount; 3009 mDraftId = message.id; 3010 mDraft = message; 3011 if (sRequestMessageIdMap != null) { 3012 sRequestMessageIdMap.put(sendOrSaveMessage.requestId(), mDraftId); 3013 } 3014 // Cache request message map, in case the process is killed 3015 saveRequestMap(); 3016 } 3017 if (sTestSendOrSaveCallback != null) { 3018 sTestSendOrSaveCallback.notifyMessageIdAllocated(sendOrSaveMessage, message); 3019 } 3020 } 3021 3022 @Override 3023 public Message getMessage() { 3024 synchronized (mDraftLock) { 3025 return mDraft; 3026 } 3027 } 3028 3029 @Override 3030 public void sendOrSaveFinished(SendOrSaveTask task, boolean success) { 3031 // Update the last sent from account. 3032 if (mAccount != null) { 3033 MailAppProvider.getInstance().setLastSentFromAccount(mAccount.uri.toString()); 3034 } 3035 if (success) { 3036 // Successfully sent or saved so reset change markers 3037 discardChanges(); 3038 } else { 3039 // A failure happened with saving/sending the draft 3040 // TODO(pwestbro): add a better string that should be used 3041 // when failing to send or save 3042 Toast.makeText(ComposeActivity.this, R.string.send_failed, Toast.LENGTH_SHORT) 3043 .show(); 3044 } 3045 3046 int numTasks; 3047 synchronized (mActiveTasks) { 3048 // Remove the task from the list of active tasks 3049 mActiveTasks.remove(task); 3050 numTasks = mActiveTasks.size(); 3051 } 3052 3053 if (numTasks == 0) { 3054 // Stop service so we can be killed. 3055 stopService(new Intent(ComposeActivity.this, EmptyService.class)); 3056 } 3057 if (sTestSendOrSaveCallback != null) { 3058 sTestSendOrSaveCallback.sendOrSaveFinished(task, success); 3059 } 3060 } 3061 }; 3062 3063 setAccount(mReplyFromAccount.account); 3064 3065 if (mSendSaveTaskHandler == null) { 3066 HandlerThread handlerThread = new HandlerThread("Send Message Task Thread"); 3067 handlerThread.start(); 3068 3069 mSendSaveTaskHandler = new Handler(handlerThread.getLooper()); 3070 } 3071 3072 Message msg = createMessage(mReplyFromAccount, mRefMessage, getMode()); 3073 mRequestId = sendOrSaveInternal(this, mReplyFromAccount, msg, mRefMessage, body, 3074 mQuotedTextView.getQuotedTextIfIncluded(), callback, 3075 mSendSaveTaskHandler, save, mComposeMode, mDraftAccount, mExtraValues); 3076 3077 // Don't display the toast if the user is just changing the orientation, 3078 // but we still need to save the draft to the cursor because this is how we restore 3079 // the attachments when the configuration change completes. 3080 if (showToast && (getChangingConfigurations() & ActivityInfo.CONFIG_ORIENTATION) == 0) { 3081 Toast.makeText(this, save ? R.string.message_saved : R.string.sending_message, 3082 Toast.LENGTH_LONG).show(); 3083 } 3084 3085 // Need to update variables here because the send or save completes 3086 // asynchronously even though the toast shows right away. 3087 discardChanges(); 3088 updateSaveUi(); 3089 3090 // If we are sending, finish the activity 3091 if (!save) { 3092 finish(); 3093 } 3094 } 3095 3096 /** 3097 * Save the state of the request messageid map. This allows for the Gmail 3098 * process to be killed, but and still allow for ComposeActivity instances 3099 * to be recreated correctly. 3100 */ 3101 private void saveRequestMap() { 3102 // TODO: store the request map in user preferences. 3103 } 3104 3105 @SuppressLint("NewApi") 3106 private void doAttach(String type) { 3107 Intent i = new Intent(Intent.ACTION_GET_CONTENT); 3108 i.addCategory(Intent.CATEGORY_OPENABLE); 3109 i.addFlags(Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET); 3110 i.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true); 3111 i.setType(type); 3112 mAddingAttachment = true; 3113 startActivityForResult(Intent.createChooser(i, getText(R.string.select_attachment_type)), 3114 RESULT_PICK_ATTACHMENT); 3115 } 3116 3117 private void showCcBccViews() { 3118 mCcBccView.show(true, true, true); 3119 if (mCcBccButton != null) { 3120 mCcBccButton.setVisibility(View.INVISIBLE); 3121 } 3122 } 3123 3124 private static String getActionString(int action) { 3125 final String msgType; 3126 switch (action) { 3127 case COMPOSE: 3128 msgType = "new_message"; 3129 break; 3130 case REPLY: 3131 msgType = "reply"; 3132 break; 3133 case REPLY_ALL: 3134 msgType = "reply_all"; 3135 break; 3136 case FORWARD: 3137 msgType = "forward"; 3138 break; 3139 default: 3140 msgType = "unknown"; 3141 break; 3142 } 3143 return msgType; 3144 } 3145 3146 private void logSendOrSave(boolean save) { 3147 if (!Analytics.isLoggable() || mAttachmentsView == null) { 3148 return; 3149 } 3150 3151 final String category = (save) ? "message_save" : "message_send"; 3152 final int attachmentCount = getAttachments().size(); 3153 final String msgType = getActionString(mComposeMode); 3154 final String label; 3155 final long value; 3156 if (mComposeMode == COMPOSE) { 3157 label = Integer.toString(attachmentCount); 3158 value = attachmentCount; 3159 } else { 3160 label = null; 3161 value = 0; 3162 } 3163 Analytics.getInstance().sendEvent(category, msgType, label, value); 3164 } 3165 3166 @Override 3167 public boolean onNavigationItemSelected(int position, long itemId) { 3168 int initialComposeMode = mComposeMode; 3169 if (position == ComposeActivity.REPLY) { 3170 mComposeMode = ComposeActivity.REPLY; 3171 } else if (position == ComposeActivity.REPLY_ALL) { 3172 mComposeMode = ComposeActivity.REPLY_ALL; 3173 } else if (position == ComposeActivity.FORWARD) { 3174 mComposeMode = ComposeActivity.FORWARD; 3175 } 3176 clearChangeListeners(); 3177 if (initialComposeMode != mComposeMode) { 3178 resetMessageForModeChange(); 3179 if (mRefMessage != null) { 3180 setFieldsFromRefMessage(mComposeMode); 3181 } 3182 boolean showCc = false; 3183 boolean showBcc = false; 3184 if (mDraft != null) { 3185 // Following desktop behavior, if the user has added a BCC 3186 // field to a draft, we show it regardless of compose mode. 3187 showBcc = !TextUtils.isEmpty(mDraft.getBcc()); 3188 // Use the draft to determine what to populate. 3189 // If the Bcc field is showing, show the Cc field whether it is populated or not. 3190 showCc = showBcc 3191 || (!TextUtils.isEmpty(mDraft.getCc()) && mComposeMode == REPLY_ALL); 3192 } 3193 if (mRefMessage != null) { 3194 showCc = !TextUtils.isEmpty(mCc.getText()); 3195 showBcc = !TextUtils.isEmpty(mBcc.getText()); 3196 } 3197 mCcBccView.show(false, showCc, showBcc); 3198 } 3199 updateHideOrShowCcBcc(); 3200 initChangeListeners(); 3201 return true; 3202 } 3203 3204 @VisibleForTesting 3205 protected void resetMessageForModeChange() { 3206 // When switching between reply, reply all, forward, 3207 // follow the behavior of webview. 3208 // The contents of the following fields are cleared 3209 // so that they can be populated directly from the 3210 // ref message: 3211 // 1) Any recipient fields 3212 // 2) The subject 3213 mTo.setText(""); 3214 mCc.setText(""); 3215 mBcc.setText(""); 3216 // Any edits to the subject are replaced with the original subject. 3217 mSubject.setText(""); 3218 3219 // Any changes to the contents of the following fields are kept: 3220 // 1) Body 3221 // 2) Attachments 3222 // If the user made changes to attachments, keep their changes. 3223 if (!mAttachmentsChanged) { 3224 mAttachmentsView.deleteAllAttachments(); 3225 } 3226 } 3227 3228 private class ComposeModeAdapter extends ArrayAdapter<String> { 3229 3230 private LayoutInflater mInflater; 3231 3232 public ComposeModeAdapter(Context context) { 3233 super(context, R.layout.compose_mode_item, R.id.mode, getResources() 3234 .getStringArray(R.array.compose_modes)); 3235 } 3236 3237 private LayoutInflater getInflater() { 3238 if (mInflater == null) { 3239 mInflater = LayoutInflater.from(getContext()); 3240 } 3241 return mInflater; 3242 } 3243 3244 @Override 3245 public View getView(int position, View convertView, ViewGroup parent) { 3246 if (convertView == null) { 3247 convertView = getInflater().inflate(R.layout.compose_mode_display_item, null); 3248 } 3249 ((TextView) convertView.findViewById(R.id.mode)).setText(getItem(position)); 3250 return super.getView(position, convertView, parent); 3251 } 3252 } 3253 3254 @Override 3255 public void onRespondInline(String text) { 3256 appendToBody(text, false); 3257 mQuotedTextView.setUpperDividerVisible(false); 3258 mRespondedInline = true; 3259 if (!mBodyView.hasFocus()) { 3260 mBodyView.requestFocus(); 3261 } 3262 } 3263 3264 /** 3265 * Append text to the body of the message. If there is no existing body 3266 * text, just sets the body to text. 3267 * 3268 * @param text Text to append 3269 * @param withSignature True to append a signature. 3270 */ 3271 public void appendToBody(CharSequence text, boolean withSignature) { 3272 Editable bodyText = mBodyView.getEditableText(); 3273 if (bodyText != null && bodyText.length() > 0) { 3274 bodyText.append(text); 3275 } else { 3276 setBody(text, withSignature); 3277 } 3278 } 3279 3280 /** 3281 * Set the body of the message. 3282 * 3283 * @param text text to set 3284 * @param withSignature True to append a signature. 3285 */ 3286 public void setBody(CharSequence text, boolean withSignature) { 3287 mBodyView.setText(text); 3288 if (withSignature) { 3289 appendSignature(); 3290 } 3291 } 3292 3293 private void appendSignature() { 3294 final String newSignature = mCachedSettings != null ? mCachedSettings.signature : null; 3295 final int signaturePos = getSignatureStartPosition(mSignature, mBodyView.getText().toString()); 3296 if (!TextUtils.equals(newSignature, mSignature) || signaturePos < 0) { 3297 mSignature = newSignature; 3298 if (!TextUtils.isEmpty(mSignature)) { 3299 // Appending a signature does not count as changing text. 3300 mBodyView.removeTextChangedListener(this); 3301 mBodyView.append(convertToPrintableSignature(mSignature)); 3302 mBodyView.addTextChangedListener(this); 3303 } 3304 resetBodySelection(); 3305 } 3306 } 3307 3308 private String convertToPrintableSignature(String signature) { 3309 String signatureResource = getResources().getString(R.string.signature); 3310 if (signature == null) { 3311 signature = ""; 3312 } 3313 return String.format(signatureResource, signature); 3314 } 3315 3316 @Override 3317 public void onAccountChanged() { 3318 mReplyFromAccount = mFromSpinner.getCurrentAccount(); 3319 if (!mAccount.equals(mReplyFromAccount.account)) { 3320 // Clear a signature, if there was one. 3321 mBodyView.removeTextChangedListener(this); 3322 String oldSignature = mSignature; 3323 String bodyText = getBody().getText().toString(); 3324 if (!TextUtils.isEmpty(oldSignature)) { 3325 int pos = getSignatureStartPosition(oldSignature, bodyText); 3326 if (pos > -1) { 3327 mBodyView.setText(bodyText.substring(0, pos)); 3328 } 3329 } 3330 setAccount(mReplyFromAccount.account); 3331 mBodyView.addTextChangedListener(this); 3332 // TODO: handle discarding attachments when switching accounts. 3333 // Only enable save for this draft if there is any other content 3334 // in the message. 3335 if (!isBlank()) { 3336 enableSave(true); 3337 } 3338 mReplyFromChanged = true; 3339 initRecipients(); 3340 } 3341 } 3342 3343 public void enableSave(boolean enabled) { 3344 if (mSave != null) { 3345 mSave.setEnabled(enabled); 3346 } 3347 } 3348 3349 public static class DiscardConfirmDialogFragment extends DialogFragment { 3350 // Public no-args constructor needed for fragment re-instantiation 3351 public DiscardConfirmDialogFragment() {} 3352 3353 @Override 3354 public Dialog onCreateDialog(Bundle savedInstanceState) { 3355 return new AlertDialog.Builder(getActivity()) 3356 .setMessage(R.string.confirm_discard_text) 3357 .setPositiveButton(R.string.discard, 3358 new DialogInterface.OnClickListener() { 3359 @Override 3360 public void onClick(DialogInterface dialog, int which) { 3361 ((ComposeActivity)getActivity()).doDiscardWithoutConfirmation(); 3362 } 3363 }) 3364 .setNegativeButton(R.string.cancel, null) 3365 .create(); 3366 } 3367 } 3368 3369 private void doDiscard() { 3370 final DialogFragment frag = new DiscardConfirmDialogFragment(); 3371 frag.show(getFragmentManager(), "discard confirm"); 3372 } 3373 /** 3374 * Effectively discard the current message. 3375 * 3376 * This method is either invoked from the menu or from the dialog 3377 * once the user has confirmed that they want to discard the message. 3378 */ 3379 private void doDiscardWithoutConfirmation() { 3380 synchronized (mDraftLock) { 3381 if (mDraftId != UIProvider.INVALID_MESSAGE_ID) { 3382 ContentValues values = new ContentValues(); 3383 values.put(BaseColumns._ID, mDraftId); 3384 if (!mAccount.expungeMessageUri.equals(Uri.EMPTY)) { 3385 getContentResolver().update(mAccount.expungeMessageUri, values, null, null); 3386 } else { 3387 getContentResolver().delete(mDraft.uri, null, null); 3388 } 3389 // This is not strictly necessary (since we should not try to 3390 // save the draft after calling this) but it ensures that if we 3391 // do save again for some reason we make a new draft rather than 3392 // trying to resave an expunged draft. 3393 mDraftId = UIProvider.INVALID_MESSAGE_ID; 3394 } 3395 } 3396 3397 // Display a toast to let the user know 3398 Toast.makeText(this, R.string.message_discarded, Toast.LENGTH_SHORT).show(); 3399 3400 // This prevents the draft from being saved in onPause(). 3401 discardChanges(); 3402 mPerformedSendOrDiscard = true; 3403 finish(); 3404 } 3405 3406 private void saveIfNeeded() { 3407 if (mAccount == null) { 3408 // We have not chosen an account yet so there's no way that we can save. This is ok, 3409 // though, since we are saving our state before AccountsActivity is activated. Thus, the 3410 // user has not interacted with us yet and there is no real state to save. 3411 return; 3412 } 3413 3414 if (shouldSave()) { 3415 doSave(!mAddingAttachment /* show toast */); 3416 } 3417 } 3418 3419 @Override 3420 public void onAttachmentDeleted() { 3421 mAttachmentsChanged = true; 3422 // If we are showing any attachments, make sure we have an upper 3423 // divider. 3424 mQuotedTextView.setUpperDividerVisible(mAttachmentsView.getAttachments().size() > 0); 3425 updateSaveUi(); 3426 } 3427 3428 @Override 3429 public void onAttachmentAdded() { 3430 mQuotedTextView.setUpperDividerVisible(mAttachmentsView.getAttachments().size() > 0); 3431 mAttachmentsView.focusLastAttachment(); 3432 } 3433 3434 /** 3435 * This is called any time one of our text fields changes. 3436 */ 3437 @Override 3438 public void afterTextChanged(Editable s) { 3439 mTextChanged = true; 3440 updateSaveUi(); 3441 } 3442 3443 @Override 3444 public void beforeTextChanged(CharSequence s, int start, int count, int after) { 3445 // Do nothing. 3446 } 3447 3448 @Override 3449 public void onTextChanged(CharSequence s, int start, int before, int count) { 3450 // Do nothing. 3451 } 3452 3453 3454 // There is a big difference between the text associated with an address changing 3455 // to add the display name or to format properly and a recipient being added or deleted. 3456 // Make sure we only notify of changes when a recipient has been added or deleted. 3457 private class RecipientTextWatcher implements TextWatcher { 3458 private HashMap<String, Integer> mContent = new HashMap<String, Integer>(); 3459 3460 private RecipientEditTextView mView; 3461 3462 private TextWatcher mListener; 3463 3464 public RecipientTextWatcher(RecipientEditTextView view, TextWatcher listener) { 3465 mView = view; 3466 mListener = listener; 3467 } 3468 3469 @Override 3470 public void afterTextChanged(Editable s) { 3471 if (hasChanged()) { 3472 mListener.afterTextChanged(s); 3473 } 3474 } 3475 3476 private boolean hasChanged() { 3477 String[] currRecips = tokenizeRecips(getAddressesFromList(mView)); 3478 int totalCount = currRecips.length; 3479 int totalPrevCount = 0; 3480 for (Entry<String, Integer> entry : mContent.entrySet()) { 3481 totalPrevCount += entry.getValue(); 3482 } 3483 if (totalCount != totalPrevCount) { 3484 return true; 3485 } 3486 3487 for (String recip : currRecips) { 3488 if (!mContent.containsKey(recip)) { 3489 return true; 3490 } else { 3491 int count = mContent.get(recip) - 1; 3492 if (count < 0) { 3493 return true; 3494 } else { 3495 mContent.put(recip, count); 3496 } 3497 } 3498 } 3499 return false; 3500 } 3501 3502 private String[] tokenizeRecips(String[] recips) { 3503 // Tokenize them all and put them in the list. 3504 String[] recipAddresses = new String[recips.length]; 3505 for (int i = 0; i < recips.length; i++) { 3506 recipAddresses[i] = Rfc822Tokenizer.tokenize(recips[i])[0].getAddress(); 3507 } 3508 return recipAddresses; 3509 } 3510 3511 @Override 3512 public void beforeTextChanged(CharSequence s, int start, int count, int after) { 3513 String[] recips = tokenizeRecips(getAddressesFromList(mView)); 3514 for (String recip : recips) { 3515 if (!mContent.containsKey(recip)) { 3516 mContent.put(recip, 1); 3517 } else { 3518 mContent.put(recip, (mContent.get(recip)) + 1); 3519 } 3520 } 3521 } 3522 3523 @Override 3524 public void onTextChanged(CharSequence s, int start, int before, int count) { 3525 // Do nothing. 3526 } 3527 } 3528 3529 public static void registerTestSendOrSaveCallback(SendOrSaveCallback testCallback) { 3530 if (sTestSendOrSaveCallback != null && testCallback != null) { 3531 throw new IllegalStateException("Attempting to register more than one test callback"); 3532 } 3533 sTestSendOrSaveCallback = testCallback; 3534 } 3535 3536 @VisibleForTesting 3537 protected ArrayList<Attachment> getAttachments() { 3538 return mAttachmentsView.getAttachments(); 3539 } 3540 3541 @Override 3542 public Loader<Cursor> onCreateLoader(int id, Bundle args) { 3543 switch (id) { 3544 case INIT_DRAFT_USING_REFERENCE_MESSAGE: 3545 return new CursorLoader(this, mRefMessageUri, UIProvider.MESSAGE_PROJECTION, null, 3546 null, null); 3547 case REFERENCE_MESSAGE_LOADER: 3548 return new CursorLoader(this, mRefMessageUri, UIProvider.MESSAGE_PROJECTION, null, 3549 null, null); 3550 case LOADER_ACCOUNT_CURSOR: 3551 return new CursorLoader(this, MailAppProvider.getAccountsUri(), 3552 UIProvider.ACCOUNTS_PROJECTION, null, null, null); 3553 } 3554 return null; 3555 } 3556 3557 @Override 3558 public void onLoadFinished(Loader<Cursor> loader, Cursor data) { 3559 int id = loader.getId(); 3560 switch (id) { 3561 case INIT_DRAFT_USING_REFERENCE_MESSAGE: 3562 if (data != null && data.moveToFirst()) { 3563 mRefMessage = new Message(data); 3564 Intent intent = getIntent(); 3565 initFromRefMessage(mComposeMode); 3566 finishSetup(mComposeMode, intent, null); 3567 if (mComposeMode != FORWARD) { 3568 String to = intent.getStringExtra(EXTRA_TO); 3569 if (!TextUtils.isEmpty(to)) { 3570 mRefMessage.setTo(null); 3571 mRefMessage.setFrom(null); 3572 clearChangeListeners(); 3573 mTo.append(to); 3574 initChangeListeners(); 3575 } 3576 } 3577 } else { 3578 finish(); 3579 } 3580 break; 3581 case REFERENCE_MESSAGE_LOADER: 3582 // Only populate mRefMessage and leave other fields untouched. 3583 if (data != null && data.moveToFirst()) { 3584 mRefMessage = new Message(data); 3585 } 3586 finishSetup(mComposeMode, getIntent(), mInnerSavedState); 3587 break; 3588 case LOADER_ACCOUNT_CURSOR: 3589 if (data != null && data.moveToFirst()) { 3590 // there are accounts now! 3591 Account account; 3592 final ArrayList<Account> accounts = new ArrayList<Account>(); 3593 final ArrayList<Account> initializedAccounts = new ArrayList<Account>(); 3594 do { 3595 account = new Account(data); 3596 if (account.isAccountReady()) { 3597 initializedAccounts.add(account); 3598 } 3599 accounts.add(account); 3600 } while (data.moveToNext()); 3601 if (initializedAccounts.size() > 0) { 3602 findViewById(R.id.wait).setVisibility(View.GONE); 3603 getLoaderManager().destroyLoader(LOADER_ACCOUNT_CURSOR); 3604 findViewById(R.id.compose).setVisibility(View.VISIBLE); 3605 mAccounts = initializedAccounts.toArray( 3606 new Account[initializedAccounts.size()]); 3607 3608 finishCreate(); 3609 invalidateOptionsMenu(); 3610 } else { 3611 // Show "waiting" 3612 account = accounts.size() > 0 ? accounts.get(0) : null; 3613 showWaitFragment(account); 3614 } 3615 } 3616 break; 3617 } 3618 } 3619 3620 private void showWaitFragment(Account account) { 3621 WaitFragment fragment = getWaitFragment(); 3622 if (fragment != null) { 3623 fragment.updateAccount(account); 3624 } else { 3625 findViewById(R.id.wait).setVisibility(View.VISIBLE); 3626 replaceFragment(WaitFragment.newInstance(account, false /* expectingMessages */), 3627 FragmentTransaction.TRANSIT_FRAGMENT_OPEN, TAG_WAIT); 3628 } 3629 } 3630 3631 private WaitFragment getWaitFragment() { 3632 return (WaitFragment) getFragmentManager().findFragmentByTag(TAG_WAIT); 3633 } 3634 3635 private int replaceFragment(Fragment fragment, int transition, String tag) { 3636 FragmentTransaction fragmentTransaction = getFragmentManager().beginTransaction(); 3637 fragmentTransaction.setTransition(transition); 3638 fragmentTransaction.replace(R.id.wait, fragment, tag); 3639 final int transactionId = fragmentTransaction.commitAllowingStateLoss(); 3640 return transactionId; 3641 } 3642 3643 @Override 3644 public void onLoaderReset(Loader<Cursor> arg0) { 3645 // Do nothing. 3646 } 3647} 3648