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