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