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