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