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