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 * Helper function to handle a list of uris to attach. 1928 * @return true if anything has been attached. 1929 */ 1930 private boolean handleAttachmentUrisFromIntent(List<Uri> uris) { 1931 ArrayList<Attachment> attachments = Lists.newArrayList(); 1932 for (Uri uri : uris) { 1933 try { 1934 if (uri != null) { 1935 if (ContentResolver.SCHEME_FILE.equals(uri.getScheme())) { 1936 // We must not allow files from /data, even from our process. 1937 final File f = new File(uri.getPath()); 1938 final String filePath = f.getCanonicalPath(); 1939 if (filePath.startsWith(DATA_DIRECTORY_ROOT)) { 1940 showErrorToast(getString(R.string.attachment_permission_denied)); 1941 Analytics.getInstance().sendEvent(ANALYTICS_CATEGORY_ERRORS, 1942 "send_intent_attachment", "data_dir", 0); 1943 continue; 1944 } 1945 } else if (ContentResolver.SCHEME_CONTENT.equals(uri.getScheme())) { 1946 // disallow attachments from our own EmailProvider (b/27308057) 1947 if (getEmailProviderAuthority().equals(uri.getAuthority())) { 1948 showErrorToast(getString(R.string.attachment_permission_denied)); 1949 Analytics.getInstance().sendEvent(ANALYTICS_CATEGORY_ERRORS, 1950 "send_intent_attachment", "email_provider", 0); 1951 continue; 1952 } 1953 } 1954 1955 if (!handleSpecialAttachmentUri(uri)) { 1956 final Attachment a = mAttachmentsView.generateLocalAttachment(uri); 1957 attachments.add(a); 1958 1959 Analytics.getInstance().sendEvent("send_intent_attachment", 1960 Utils.normalizeMimeType(a.getContentType()), null, a.size); 1961 } 1962 } 1963 } catch (AttachmentFailureException e) { 1964 LogUtils.e(LOG_TAG, e, "Error adding attachment"); 1965 showAttachmentTooBigToast(e.getErrorRes()); 1966 } catch (IOException | SecurityException e) { 1967 LogUtils.e(LOG_TAG, e, "Error adding attachment"); 1968 showErrorToast(getString(R.string.attachment_permission_denied)); 1969 } 1970 } 1971 return addAttachments(attachments); 1972 } 1973 1974 protected void initQuotedText(CharSequence quotedText, boolean shouldQuoteText) { 1975 mQuotedTextView.setQuotedTextFromHtml(quotedText, shouldQuoteText); 1976 mShowQuotedText = true; 1977 } 1978 1979 private void initQuotedTextFromRefMessage(Message refMessage, int action) { 1980 if (mRefMessage != null && (action == REPLY || action == REPLY_ALL || action == FORWARD)) { 1981 mQuotedTextView.setQuotedText(action, refMessage, action != FORWARD); 1982 } 1983 } 1984 1985 private void updateHideOrShowCcBcc() { 1986 // Its possible there is a menu item OR a button. 1987 boolean ccVisible = mCcBccView.isCcVisible(); 1988 boolean bccVisible = mCcBccView.isBccVisible(); 1989 if (mCcBccButton != null) { 1990 if (!ccVisible || !bccVisible) { 1991 mCcBccButton.setVisibility(View.VISIBLE); 1992 } else { 1993 mCcBccButton.setVisibility(View.GONE); 1994 } 1995 } 1996 } 1997 1998 /** 1999 * Add attachment and update the compose area appropriately. 2000 */ 2001 private void addAttachmentAndUpdateView(Intent data) { 2002 if (data == null) { 2003 return; 2004 } 2005 2006 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { 2007 final ClipData clipData = data.getClipData(); 2008 if (clipData != null) { 2009 for (int i = 0, size = clipData.getItemCount(); i < size; i++) { 2010 addAttachmentAndUpdateView(clipData.getItemAt(i).getUri()); 2011 } 2012 return; 2013 } 2014 } 2015 2016 addAttachmentAndUpdateView(data.getData()); 2017 } 2018 2019 private void addAttachmentAndUpdateView(Uri contentUri) { 2020 if (contentUri == null) { 2021 return; 2022 } 2023 2024 if (handleSpecialAttachmentUri(contentUri)) { 2025 return; 2026 } 2027 2028 final boolean attached = handleAttachmentUrisFromIntent(Arrays.asList(contentUri)); 2029 if (attached) { 2030 mAttachmentsChanged = true; 2031 updateSaveUi(); 2032 } 2033 } 2034 2035 /** 2036 * Allow subclasses to implement custom handling of attachments. 2037 * 2038 * @param contentUri a passed-in URI from a pick intent 2039 * @return true iff handled 2040 */ 2041 protected boolean handleSpecialAttachmentUri(final Uri contentUri) { 2042 return false; 2043 } 2044 2045 private void addAttachmentAndUpdateView(Attachment attachment) { 2046 try { 2047 mAttachmentsView.addAttachment(mAccount, attachment); 2048 mAttachmentsChanged = true; 2049 updateSaveUi(); 2050 } catch (AttachmentFailureException e) { 2051 LogUtils.e(LOG_TAG, e, "Error adding attachment"); 2052 showAttachmentTooBigToast(e.getErrorRes()); 2053 } 2054 } 2055 2056 void initRecipientsFromRefMessage(Message refMessage, int action) { 2057 // Don't populate the address if this is a forward. 2058 if (action == ComposeActivity.FORWARD) { 2059 return; 2060 } 2061 initReplyRecipients(refMessage, action); 2062 } 2063 2064 // TODO: This should be private. This method shouldn't be used by ComposeActivityTests, as 2065 // it doesn't setup the state of the activity correctly 2066 @VisibleForTesting 2067 void initReplyRecipients(final Message refMessage, final int action) { 2068 String[] sentToAddresses = refMessage.getToAddressesUnescaped(); 2069 final Collection<String> toAddresses; 2070 final String[] fromAddresses = refMessage.getFromAddressesUnescaped(); 2071 final String fromAddress = fromAddresses.length > 0 ? fromAddresses[0] : null; 2072 final String[] replyToAddresses = getReplyToAddresses( 2073 refMessage.getReplyToAddressesUnescaped(), fromAddress); 2074 2075 // If this is a reply, the Cc list is empty. If this is a reply-all, the 2076 // Cc list is the union of the To and Cc recipients of the original 2077 // message, excluding the current user's email address and any addresses 2078 // already on the To list. 2079 if (action == ComposeActivity.REPLY) { 2080 toAddresses = initToRecipients(fromAddress, replyToAddresses, sentToAddresses); 2081 addToAddresses(toAddresses); 2082 } else if (action == ComposeActivity.REPLY_ALL) { 2083 final Set<String> ccAddresses = Sets.newHashSet(); 2084 toAddresses = initToRecipients(fromAddress, replyToAddresses, sentToAddresses); 2085 addToAddresses(toAddresses); 2086 addRecipients(ccAddresses, sentToAddresses); 2087 addRecipients(ccAddresses, refMessage.getCcAddressesUnescaped()); 2088 addCcAddresses(ccAddresses, toAddresses); 2089 } 2090 } 2091 2092 // If there is no reply to address, the reply to address is the sender. 2093 private static String[] getReplyToAddresses(String[] replyTo, String from) { 2094 boolean hasReplyTo = false; 2095 for (final String replyToAddress : replyTo) { 2096 if (!TextUtils.isEmpty(replyToAddress)) { 2097 hasReplyTo = true; 2098 } 2099 } 2100 return hasReplyTo ? replyTo : new String[] {from}; 2101 } 2102 2103 private void addToAddresses(Collection<String> addresses) { 2104 addAddressesToList(addresses, mTo); 2105 } 2106 2107 private void addCcAddresses(Collection<String> addresses, Collection<String> toAddresses) { 2108 addCcAddressesToList(tokenizeAddressList(addresses), 2109 toAddresses != null ? tokenizeAddressList(toAddresses) : null, mCc); 2110 } 2111 2112 private void addBccAddresses(Collection<String> addresses) { 2113 addAddressesToList(addresses, mBcc); 2114 } 2115 2116 @VisibleForTesting 2117 protected void addCcAddressesToList(List<Rfc822Token[]> addresses, 2118 List<Rfc822Token[]> compareToList, RecipientEditTextView list) { 2119 String address; 2120 2121 if (compareToList == null) { 2122 for (final Rfc822Token[] tokens : addresses) { 2123 for (final Rfc822Token token : tokens) { 2124 address = token.toString(); 2125 list.append(address + END_TOKEN); 2126 } 2127 } 2128 } else { 2129 HashSet<String> compareTo = convertToHashSet(compareToList); 2130 for (final Rfc822Token[] tokens : addresses) { 2131 for (final Rfc822Token token : tokens) { 2132 address = token.toString(); 2133 // Check if this is a duplicate: 2134 if (!compareTo.contains(token.getAddress())) { 2135 // Get the address here 2136 list.append(address + END_TOKEN); 2137 } 2138 } 2139 } 2140 } 2141 } 2142 2143 private static HashSet<String> convertToHashSet(final List<Rfc822Token[]> list) { 2144 final HashSet<String> hash = new HashSet<String>(); 2145 for (final Rfc822Token[] tokens : list) { 2146 for (final Rfc822Token token : tokens) { 2147 hash.add(token.getAddress()); 2148 } 2149 } 2150 return hash; 2151 } 2152 2153 protected List<Rfc822Token[]> tokenizeAddressList(Collection<String> addresses) { 2154 @VisibleForTesting 2155 List<Rfc822Token[]> tokenized = new ArrayList<Rfc822Token[]>(); 2156 2157 for (String address: addresses) { 2158 tokenized.add(Rfc822Tokenizer.tokenize(address)); 2159 } 2160 return tokenized; 2161 } 2162 2163 @VisibleForTesting 2164 void addAddressesToList(Collection<String> addresses, RecipientEditTextView list) { 2165 for (String address : addresses) { 2166 addAddressToList(address, list); 2167 } 2168 } 2169 2170 private static void addAddressToList(final String address, final RecipientEditTextView list) { 2171 if (address == null || list == null) 2172 return; 2173 2174 final Rfc822Token[] tokens = Rfc822Tokenizer.tokenize(address); 2175 2176 for (final Rfc822Token token : tokens) { 2177 list.append(token + END_TOKEN); 2178 } 2179 } 2180 2181 @VisibleForTesting 2182 protected Collection<String> initToRecipients(final String fullSenderAddress, 2183 final String[] replyToAddresses, final String[] inToAddresses) { 2184 // The To recipient is the reply-to address specified in the original 2185 // message, unless it is: 2186 // the current user OR a custom from of the current user, in which case 2187 // it's the To recipient list of the original message. 2188 // OR missing, in which case use the sender of the original message 2189 Set<String> toAddresses = Sets.newHashSet(); 2190 for (final String replyToAddress : replyToAddresses) { 2191 if (!TextUtils.isEmpty(replyToAddress) 2192 && !recipientMatchesThisAccount(replyToAddress)) { 2193 toAddresses.add(replyToAddress); 2194 } 2195 } 2196 if (toAddresses.size() == 0) { 2197 // In this case, the user is replying to a message in which their 2198 // current account or some of their custom from addresses are the only 2199 // recipients and they sent the original message. 2200 if (inToAddresses.length == 1 && recipientMatchesThisAccount(fullSenderAddress) 2201 && recipientMatchesThisAccount(inToAddresses[0])) { 2202 toAddresses.add(inToAddresses[0]); 2203 return toAddresses; 2204 } 2205 // This happens if the user replies to a message they originally 2206 // wrote. In this case, "reply" really means "re-send," so we 2207 // target the original recipients. This works as expected even 2208 // if the user sent the original message to themselves. 2209 for (String address : inToAddresses) { 2210 if (!recipientMatchesThisAccount(address)) { 2211 toAddresses.add(address); 2212 } 2213 } 2214 } 2215 return toAddresses; 2216 } 2217 2218 private void addRecipients(final Set<String> recipients, final String[] addresses) { 2219 for (final String email : addresses) { 2220 // Do not add this account, or any of its custom from addresses, to 2221 // the list of recipients. 2222 final String recipientAddress = Address.getEmailAddress(email).getAddress(); 2223 if (!recipientMatchesThisAccount(recipientAddress)) { 2224 recipients.add(email.replace("\"\"", "")); 2225 } 2226 } 2227 } 2228 2229 /** 2230 * A recipient matches this account if it has the same address as the 2231 * currently selected account OR one of the custom from addresses associated 2232 * with the currently selected account. 2233 * @param recipientAddress address we are comparing with the currently selected account 2234 */ 2235 protected boolean recipientMatchesThisAccount(String recipientAddress) { 2236 return ReplyFromAccount.matchesAccountOrCustomFrom(mAccount, recipientAddress, 2237 mAccount.getReplyFroms()); 2238 } 2239 2240 /** 2241 * Returns a formatted subject string with the appropriate prefix for the action type. 2242 * E.g., "FWD: " is prepended if action is {@link ComposeActivity#FORWARD}. 2243 */ 2244 public static String buildFormattedSubject(Resources res, String subject, int action) { 2245 final String prefix; 2246 final String correctedSubject; 2247 if (action == ComposeActivity.COMPOSE) { 2248 prefix = ""; 2249 } else if (action == ComposeActivity.FORWARD) { 2250 prefix = res.getString(R.string.forward_subject_label); 2251 } else { 2252 prefix = res.getString(R.string.reply_subject_label); 2253 } 2254 2255 if (TextUtils.isEmpty(subject)) { 2256 correctedSubject = prefix; 2257 } else { 2258 // Don't duplicate the prefix 2259 if (subject.toLowerCase().startsWith(prefix.toLowerCase())) { 2260 correctedSubject = subject; 2261 } else { 2262 correctedSubject = String.format( 2263 res.getString(R.string.formatted_subject), prefix, subject); 2264 } 2265 } 2266 2267 return correctedSubject; 2268 } 2269 2270 private void setSubject(Message refMessage, int action) { 2271 mSubject.setText(buildFormattedSubject(getResources(), refMessage.subject, action)); 2272 } 2273 2274 private void initRecipients() { 2275 setupRecipients(mTo); 2276 setupRecipients(mCc); 2277 setupRecipients(mBcc); 2278 } 2279 2280 private void setupRecipients(RecipientEditTextView view) { 2281 final DropdownChipLayouter layouter = getDropdownChipLayouter(); 2282 if (layouter != null) { 2283 view.setDropdownChipLayouter(layouter); 2284 } 2285 view.setAdapter(getRecipientAdapter()); 2286 view.setRecipientEntryItemClickedListener(this); 2287 if (mValidator == null) { 2288 final String accountName = mAccount.getEmailAddress(); 2289 int offset = accountName.indexOf("@") + 1; 2290 String account = accountName; 2291 if (offset > 0) { 2292 account = account.substring(offset); 2293 } 2294 mValidator = new Rfc822Validator(account); 2295 } 2296 view.setValidator(mValidator); 2297 } 2298 2299 /** 2300 * Derived classes should override if they wish to provide their own autocomplete behavior. 2301 */ 2302 public BaseRecipientAdapter getRecipientAdapter() { 2303 return new RecipientAdapter(this, mAccount); 2304 } 2305 2306 /** 2307 * Derived classes should override this to provide their own dropdown behavior. 2308 * If the result is null, the default {@link com.android.ex.chips.DropdownChipLayouter} 2309 * is used. 2310 */ 2311 public DropdownChipLayouter getDropdownChipLayouter() { 2312 return null; 2313 } 2314 2315 @Override 2316 public void onClick(View v) { 2317 final int id = v.getId(); 2318 if (id == R.id.add_cc_bcc) { 2319 // Verify that cc/ bcc aren't showing. 2320 // Animate in cc/bcc. 2321 showCcBccViews(); 2322 } 2323 } 2324 2325 @Override 2326 public void onFocusChange (View v, boolean hasFocus) { 2327 final int id = v.getId(); 2328 if (hasFocus && (id == R.id.subject || id == R.id.body)) { 2329 // Collapse cc/bcc iff both are empty 2330 final boolean showCcBccFields = !TextUtils.isEmpty(mCc.getText()) || 2331 !TextUtils.isEmpty(mBcc.getText()); 2332 mCcBccView.show(false /* animate */, showCcBccFields, showCcBccFields); 2333 mCcBccButton.setVisibility(showCcBccFields ? View.GONE : View.VISIBLE); 2334 2335 // On phones autoscroll down so that Cc aligns to the top if we are showing cc/bcc. 2336 if (getResources().getBoolean(R.bool.auto_scroll_cc) && showCcBccFields) { 2337 final int[] coords = new int[2]; 2338 mCc.getLocationOnScreen(coords); 2339 2340 // Subtract status bar and action bar height from y-coord. 2341 getWindow().getDecorView().getWindowVisibleDisplayFrame(mRect); 2342 final int deltaY = coords[1] - getSupportActionBar().getHeight() - mRect.top; 2343 2344 // Only scroll down 2345 if (deltaY > 0) { 2346 mScrollView.smoothScrollBy(0, deltaY); 2347 } 2348 } 2349 } 2350 } 2351 2352 @Override 2353 public boolean onCreateOptionsMenu(Menu menu) { 2354 final boolean superCreated = super.onCreateOptionsMenu(menu); 2355 // Don't render any menu items when there are no accounts. 2356 if (mAccounts == null || mAccounts.length == 0) { 2357 return superCreated; 2358 } 2359 MenuInflater inflater = getMenuInflater(); 2360 inflater.inflate(R.menu.compose_menu, menu); 2361 2362 /* 2363 * Start save in the correct enabled state. 2364 * 1) If a user launches compose from within gmail, save is disabled 2365 * until they add something, at which point, save is enabled, auto save 2366 * on exit; if the user empties everything, save is disabled, exiting does not 2367 * auto-save 2368 * 2) if a user replies/ reply all/ forwards from within gmail, save is 2369 * disabled until they change something, at which point, save is 2370 * enabled, auto save on exit; if the user empties everything, save is 2371 * disabled, exiting does not auto-save. 2372 * 3) If a user launches compose from another application and something 2373 * gets populated (attachments, recipients, body, subject, etc), save is 2374 * enabled, auto save on exit; if the user empties everything, save is 2375 * disabled, exiting does not auto-save 2376 */ 2377 mSave = menu.findItem(R.id.save); 2378 String action = getIntent() != null ? getIntent().getAction() : null; 2379 enableSave(mInnerSavedState != null ? 2380 mInnerSavedState.getBoolean(EXTRA_SAVE_ENABLED) 2381 : (Intent.ACTION_SEND.equals(action) 2382 || Intent.ACTION_SEND_MULTIPLE.equals(action) 2383 || Intent.ACTION_SENDTO.equals(action) 2384 || isDraftDirty())); 2385 2386 final MenuItem helpItem = menu.findItem(R.id.help_info_menu_item); 2387 final MenuItem sendFeedbackItem = menu.findItem(R.id.feedback_menu_item); 2388 final MenuItem attachFromServiceItem = menu.findItem(R.id.attach_from_service_stub1); 2389 if (helpItem != null) { 2390 helpItem.setVisible(mAccount != null 2391 && mAccount.supportsCapability(AccountCapabilities.HELP_CONTENT)); 2392 } 2393 if (sendFeedbackItem != null) { 2394 sendFeedbackItem.setVisible(mAccount != null 2395 && mAccount.supportsCapability(AccountCapabilities.SEND_FEEDBACK)); 2396 } 2397 if (attachFromServiceItem != null) { 2398 attachFromServiceItem.setVisible(shouldEnableAttachFromServiceMenu(mAccount)); 2399 } 2400 2401 // Show attach picture on pre-K devices. 2402 menu.findItem(R.id.add_photo_attachment).setVisible(!Utils.isRunningKitkatOrLater()); 2403 2404 return true; 2405 } 2406 2407 @Override 2408 public boolean onOptionsItemSelected(MenuItem item) { 2409 final int id = item.getItemId(); 2410 2411 Analytics.getInstance().sendMenuItemEvent(Analytics.EVENT_CATEGORY_MENU_ITEM, id, 2412 "compose", 0); 2413 2414 boolean handled = true; 2415 if (id == R.id.add_file_attachment) { 2416 doAttach(MIME_TYPE_ALL); 2417 } else if (id == R.id.add_photo_attachment) { 2418 doAttach(MIME_TYPE_PHOTO); 2419 } else if (id == R.id.save) { 2420 doSave(true); 2421 } else if (id == R.id.send) { 2422 doSend(); 2423 } else if (id == R.id.discard) { 2424 doDiscard(); 2425 } else if (id == R.id.settings) { 2426 Utils.showSettings(this, mAccount); 2427 } else if (id == android.R.id.home) { 2428 onAppUpPressed(); 2429 } else if (id == R.id.help_info_menu_item) { 2430 Utils.showHelp(this, mAccount, getString(R.string.compose_help_context)); 2431 } else { 2432 handled = false; 2433 } 2434 return handled || super.onOptionsItemSelected(item); 2435 } 2436 2437 @Override 2438 public void onBackPressed() { 2439 // If we are showing the wait fragment, just exit. 2440 if (getWaitFragment() != null) { 2441 finish(); 2442 } else { 2443 super.onBackPressed(); 2444 } 2445 } 2446 2447 /** 2448 * Carries out the "up" action in the action bar. 2449 */ 2450 private void onAppUpPressed() { 2451 if (mLaunchedFromEmail) { 2452 // If this was started from Gmail, simply treat app up as the system back button, so 2453 // that the last view is restored. 2454 onBackPressed(); 2455 return; 2456 } 2457 2458 // Fire the main activity to ensure it launches the "top" screen of mail. 2459 // Since the main Activity is singleTask, it should revive that task if it was already 2460 // started. 2461 final Intent mailIntent = Utils.createViewInboxIntent(mAccount); 2462 mailIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK | 2463 Intent.FLAG_ACTIVITY_TASK_ON_HOME); 2464 startActivity(mailIntent); 2465 finish(); 2466 } 2467 2468 private void doSend() { 2469 sendOrSaveWithSanityChecks(false, true, false, false); 2470 logSendOrSave(false /* save */); 2471 mPerformedSendOrDiscard = true; 2472 } 2473 2474 private void doSave(boolean showToast) { 2475 sendOrSaveWithSanityChecks(true, showToast, false, false); 2476 } 2477 2478 @Override 2479 public void onRecipientEntryItemClicked(int charactersTyped, int position) { 2480 // Send analytics of characters typed and position in dropdown selected. 2481 Analytics.getInstance().sendEvent( 2482 "suggest_click", Integer.toString(charactersTyped), Integer.toString(position), 0); 2483 } 2484 2485 @VisibleForTesting 2486 public interface SendOrSaveCallback { 2487 void initializeSendOrSave(); 2488 void notifyMessageIdAllocated(SendOrSaveMessage sendOrSaveMessage, Message message); 2489 long getMessageId(); 2490 void sendOrSaveFinished(SendOrSaveMessage message, boolean success); 2491 } 2492 2493 private void runSendOrSaveProviderCalls(SendOrSaveMessage sendOrSaveMessage, 2494 SendOrSaveCallback callback, ReplyFromAccount currReplyFromAccount, 2495 ReplyFromAccount originalReplyFromAccount) { 2496 long messageId = callback.getMessageId(); 2497 // If a previous draft has been saved, in an account that is different 2498 // than what the user wants to send from, remove the old draft, and treat this 2499 // as a new message 2500 if (originalReplyFromAccount != null 2501 && !currReplyFromAccount.account.uri.equals(originalReplyFromAccount.account.uri)) { 2502 if (messageId != UIProvider.INVALID_MESSAGE_ID) { 2503 ContentResolver resolver = getContentResolver(); 2504 ContentValues values = new ContentValues(); 2505 values.put(BaseColumns._ID, messageId); 2506 if (originalReplyFromAccount.account.expungeMessageUri != null) { 2507 new ContentProviderTask.UpdateTask() 2508 .run(resolver, originalReplyFromAccount.account.expungeMessageUri, 2509 values, null, null); 2510 } else { 2511 // TODO(mindyp) delete the conversation. 2512 } 2513 // reset messageId to 0, so a new message will be created 2514 messageId = UIProvider.INVALID_MESSAGE_ID; 2515 } 2516 } 2517 2518 final long messageIdToSave = messageId; 2519 sendOrSaveMessage(callback, messageIdToSave, sendOrSaveMessage, currReplyFromAccount); 2520 2521 if (!sendOrSaveMessage.mSave) { 2522 incrementRecipientsTimesContacted( 2523 (String) sendOrSaveMessage.mValues.get(UIProvider.MessageColumns.TO), 2524 (String) sendOrSaveMessage.mValues.get(UIProvider.MessageColumns.CC), 2525 (String) sendOrSaveMessage.mValues.get(UIProvider.MessageColumns.BCC)); 2526 } 2527 callback.sendOrSaveFinished(sendOrSaveMessage, true); 2528 } 2529 2530 private void incrementRecipientsTimesContacted( 2531 final String toAddresses, final String ccAddresses, final String bccAddresses) { 2532 final List<String> recipients = Lists.newArrayList(); 2533 addAddressesToRecipientList(recipients, toAddresses); 2534 addAddressesToRecipientList(recipients, ccAddresses); 2535 addAddressesToRecipientList(recipients, bccAddresses); 2536 incrementRecipientsTimesContacted(recipients); 2537 } 2538 2539 private void addAddressesToRecipientList( 2540 final List<String> recipients, final String addressString) { 2541 if (recipients == null) { 2542 throw new IllegalArgumentException("recipientList cannot be null"); 2543 } 2544 if (TextUtils.isEmpty(addressString)) { 2545 return; 2546 } 2547 final Rfc822Token[] tokens = Rfc822Tokenizer.tokenize(addressString); 2548 for (final Rfc822Token token : tokens) { 2549 recipients.add(token.getAddress()); 2550 } 2551 } 2552 2553 /** 2554 * Send or Save a message. 2555 */ 2556 private void sendOrSaveMessage(SendOrSaveCallback callback, final long messageIdToSave, 2557 final SendOrSaveMessage sendOrSaveMessage, final ReplyFromAccount selectedAccount) { 2558 final ContentResolver resolver = getContentResolver(); 2559 final boolean updateExistingMessage = messageIdToSave != UIProvider.INVALID_MESSAGE_ID; 2560 2561 final String accountMethod = sendOrSaveMessage.mSave ? 2562 UIProvider.AccountCallMethods.SAVE_MESSAGE : 2563 UIProvider.AccountCallMethods.SEND_MESSAGE; 2564 2565 try { 2566 if (updateExistingMessage) { 2567 sendOrSaveMessage.mValues.put(BaseColumns._ID, messageIdToSave); 2568 2569 callAccountSendSaveMethod(resolver, 2570 selectedAccount.account, accountMethod, sendOrSaveMessage); 2571 } else { 2572 Uri messageUri = null; 2573 final Bundle result = callAccountSendSaveMethod(resolver, 2574 selectedAccount.account, accountMethod, sendOrSaveMessage); 2575 if (result != null) { 2576 // If a non-null value was returned, then the provider handled the call 2577 // method 2578 messageUri = result.getParcelable(UIProvider.MessageColumns.URI); 2579 } 2580 if (sendOrSaveMessage.mSave && messageUri != null) { 2581 final Cursor messageCursor = resolver.query(messageUri, 2582 UIProvider.MESSAGE_PROJECTION, null, null, null); 2583 if (messageCursor != null) { 2584 try { 2585 if (messageCursor.moveToFirst()) { 2586 // Broadcast notification that a new message has 2587 // been allocated 2588 callback.notifyMessageIdAllocated(sendOrSaveMessage, 2589 new Message(messageCursor)); 2590 } 2591 } finally { 2592 messageCursor.close(); 2593 } 2594 } 2595 } 2596 } 2597 } finally { 2598 // Close any opened file descriptors 2599 closeOpenedAttachmentFds(sendOrSaveMessage); 2600 } 2601 } 2602 2603 private static void closeOpenedAttachmentFds(final SendOrSaveMessage sendOrSaveMessage) { 2604 final Bundle openedFds = sendOrSaveMessage.attachmentFds(); 2605 if (openedFds != null) { 2606 final Set<String> keys = openedFds.keySet(); 2607 for (final String key : keys) { 2608 final AssetFileDescriptor fd = openedFds.getParcelable(key); 2609 if (fd != null) { 2610 try { 2611 fd.close(); 2612 } catch (IOException e) { 2613 // Do nothing 2614 } 2615 } 2616 } 2617 } 2618 } 2619 2620 /** 2621 * Use the {@link ContentResolver#call} method to send or save the message. 2622 * 2623 * If this was successful, this method will return an non-null Bundle instance 2624 */ 2625 private static Bundle callAccountSendSaveMethod(final ContentResolver resolver, 2626 final Account account, final String method, 2627 final SendOrSaveMessage sendOrSaveMessage) { 2628 // Copy all of the values from the content values to the bundle 2629 final Bundle methodExtras = new Bundle(sendOrSaveMessage.mValues.size()); 2630 final Set<Entry<String, Object>> valueSet = sendOrSaveMessage.mValues.valueSet(); 2631 2632 for (Entry<String, Object> entry : valueSet) { 2633 final Object entryValue = entry.getValue(); 2634 final String key = entry.getKey(); 2635 if (entryValue instanceof String) { 2636 methodExtras.putString(key, (String)entryValue); 2637 } else if (entryValue instanceof Boolean) { 2638 methodExtras.putBoolean(key, (Boolean)entryValue); 2639 } else if (entryValue instanceof Integer) { 2640 methodExtras.putInt(key, (Integer)entryValue); 2641 } else if (entryValue instanceof Long) { 2642 methodExtras.putLong(key, (Long)entryValue); 2643 } else { 2644 LogUtils.wtf(LOG_TAG, "Unexpected object type: %s", 2645 entryValue.getClass().getName()); 2646 } 2647 } 2648 2649 // If the SendOrSaveMessage has some opened fds, add them to the bundle 2650 final Bundle fdMap = sendOrSaveMessage.attachmentFds(); 2651 if (fdMap != null) { 2652 methodExtras.putParcelable( 2653 UIProvider.SendOrSaveMethodParamKeys.OPENED_FD_MAP, fdMap); 2654 } 2655 2656 return resolver.call(account.uri, method, account.uri.toString(), methodExtras); 2657 } 2658 2659 /** 2660 * Reports recipients that have been contacted in order to improve auto-complete 2661 * suggestions. Default behavior updates usage statistics in ContactsProvider. 2662 * @param recipients addresses 2663 */ 2664 protected void incrementRecipientsTimesContacted(List<String> recipients) { 2665 final DataUsageStatUpdater statsUpdater = new DataUsageStatUpdater(this); 2666 statsUpdater.updateWithAddress(recipients); 2667 } 2668 2669 @VisibleForTesting 2670 public static class SendOrSaveMessage { 2671 final int mRequestId; 2672 final ContentValues mValues; 2673 final String mRefMessageId; 2674 @VisibleForTesting 2675 public final boolean mSave; 2676 private final Bundle mAttachmentFds; 2677 2678 public SendOrSaveMessage(Context context, int requestId, ContentValues values, 2679 String refMessageId, List<Attachment> attachments, Bundle optionalAttachmentFds, 2680 boolean save) { 2681 mRequestId = requestId; 2682 mValues = values; 2683 mRefMessageId = refMessageId; 2684 mSave = save; 2685 2686 // If the attachments are already open for us (pre-JB), then don't open them again 2687 if (optionalAttachmentFds != null) { 2688 mAttachmentFds = optionalAttachmentFds; 2689 } else { 2690 mAttachmentFds = initializeAttachmentFds(context, attachments); 2691 } 2692 } 2693 2694 Bundle attachmentFds() { 2695 return mAttachmentFds; 2696 } 2697 } 2698 2699 /** 2700 * Opens {@link ParcelFileDescriptor} for each of the attachments. This method must be 2701 * called before the ComposeActivity finishes. 2702 * Note: The caller is responsible for closing these file descriptors. 2703 */ 2704 private static Bundle initializeAttachmentFds(final Context context, 2705 final List<Attachment> attachments) { 2706 if (attachments == null || attachments.size() == 0) { 2707 return null; 2708 } 2709 2710 final Bundle result = new Bundle(attachments.size()); 2711 final ContentResolver resolver = context.getContentResolver(); 2712 2713 for (Attachment attachment : attachments) { 2714 if (attachment == null || Utils.isEmpty(attachment.contentUri)) { 2715 continue; 2716 } 2717 2718 AssetFileDescriptor fileDescriptor; 2719 try { 2720 if (attachment.virtualMimeType == null) { 2721 fileDescriptor = new AssetFileDescriptor( 2722 resolver.openFileDescriptor(attachment.contentUri, "r"), 0, 2723 AssetFileDescriptor.UNKNOWN_LENGTH); 2724 } else { 2725 fileDescriptor = resolver.openTypedAssetFileDescriptor( 2726 attachment.contentUri, attachment.virtualMimeType, null, null); 2727 } 2728 } catch (FileNotFoundException e) { 2729 LogUtils.e(LOG_TAG, e, "Exception attempting to open attachment"); 2730 fileDescriptor = null; 2731 } catch (SecurityException e) { 2732 // We have encountered a security exception when attempting to open the file 2733 // specified by the content uri. If the attachment has been cached, this 2734 // isn't a problem, as even through the original permission may have been 2735 // revoked, we have cached the file. This will happen when saving/sending 2736 // a previously saved draft. 2737 // TODO(markwei): Expose whether the attachment has been cached through the 2738 // attachment object. This would allow us to limit when the log is made, as 2739 // if the attachment has been cached, this really isn't an error 2740 LogUtils.e(LOG_TAG, e, "Security Exception attempting to open attachment"); 2741 // Just set the file descriptor to null, as the underlying provider needs 2742 // to handle the file descriptor not being set. 2743 fileDescriptor = null; 2744 } 2745 2746 if (fileDescriptor != null) { 2747 result.putParcelable(attachment.contentUri.toString(), fileDescriptor); 2748 } 2749 } 2750 2751 return result; 2752 } 2753 2754 /** 2755 * Get the to recipients. 2756 */ 2757 public String[] getToAddresses() { 2758 return getAddressesFromList(mTo); 2759 } 2760 2761 /** 2762 * Get the cc recipients. 2763 */ 2764 public String[] getCcAddresses() { 2765 return getAddressesFromList(mCc); 2766 } 2767 2768 /** 2769 * Get the bcc recipients. 2770 */ 2771 public String[] getBccAddresses() { 2772 return getAddressesFromList(mBcc); 2773 } 2774 2775 public String[] getAddressesFromList(RecipientEditTextView list) { 2776 if (list == null) { 2777 return new String[0]; 2778 } 2779 Rfc822Token[] tokens = Rfc822Tokenizer.tokenize(list.getText()); 2780 int count = tokens.length; 2781 String[] result = new String[count]; 2782 for (int i = 0; i < count; i++) { 2783 result[i] = tokens[i].toString(); 2784 } 2785 return result; 2786 } 2787 2788 /** 2789 * Check for invalid email addresses. 2790 * @param to String array of email addresses to check. 2791 * @param wrongEmailsOut Emails addresses that were invalid. 2792 */ 2793 public void checkInvalidEmails(final String[] to, final List<String> wrongEmailsOut) { 2794 if (mValidator == null) { 2795 return; 2796 } 2797 for (final String email : to) { 2798 if (!mValidator.isValid(email)) { 2799 wrongEmailsOut.add(email); 2800 } 2801 } 2802 } 2803 2804 public static class RecipientErrorDialogFragment extends DialogFragment { 2805 // Public no-args constructor needed for fragment re-instantiation 2806 public RecipientErrorDialogFragment() {} 2807 2808 public static RecipientErrorDialogFragment newInstance(final String message) { 2809 final RecipientErrorDialogFragment frag = new RecipientErrorDialogFragment(); 2810 final Bundle args = new Bundle(1); 2811 args.putString("message", message); 2812 frag.setArguments(args); 2813 return frag; 2814 } 2815 2816 @Override 2817 public Dialog onCreateDialog(Bundle savedInstanceState) { 2818 final String message = getArguments().getString("message"); 2819 return new AlertDialog.Builder(getActivity()) 2820 .setMessage(message) 2821 .setPositiveButton( 2822 R.string.ok, new Dialog.OnClickListener() { 2823 @Override 2824 public void onClick(DialogInterface dialog, int which) { 2825 ((ComposeActivity)getActivity()).finishRecipientErrorDialog(); 2826 } 2827 }).create(); 2828 } 2829 } 2830 2831 private void finishRecipientErrorDialog() { 2832 // after the user dismisses the recipient error 2833 // dialog we want to make sure to refocus the 2834 // recipient to field so they can fix the issue 2835 // easily 2836 if (mTo != null) { 2837 mTo.requestFocus(); 2838 } 2839 } 2840 2841 /** 2842 * Show an error because the user has entered an invalid recipient. 2843 */ 2844 private void showRecipientErrorDialog(final String message) { 2845 final DialogFragment frag = RecipientErrorDialogFragment.newInstance(message); 2846 frag.show(getFragmentManager(), "recipient error"); 2847 } 2848 2849 /** 2850 * Update the state of the UI based on whether or not the current draft 2851 * needs to be saved and the message is not empty. 2852 */ 2853 public void updateSaveUi() { 2854 if (mSave != null) { 2855 mSave.setEnabled((isDraftDirty() && !isBlank())); 2856 } 2857 } 2858 2859 /** 2860 * Returns true if the current draft is modified from the version we previously saved. 2861 */ 2862 private boolean isDraftDirty() { 2863 synchronized (mDraftLock) { 2864 // The message should only be saved if: 2865 // It hasn't been sent AND 2866 // Some text has been added to the message OR 2867 // an attachment has been added or removed 2868 // AND there is actually something in the draft to save. 2869 return (mTextChanged || mAttachmentsChanged || mReplyFromChanged) 2870 && !isBlank(); 2871 } 2872 } 2873 2874 /** 2875 * Returns whether the "Attach from Drive" menu item should be visible. 2876 */ 2877 protected boolean shouldEnableAttachFromServiceMenu(Account mAccount) { 2878 return false; 2879 } 2880 2881 /** 2882 * Check if all fields are blank. 2883 * @return boolean 2884 */ 2885 public boolean isBlank() { 2886 // Need to check for null since isBlank() can be called from onPause() 2887 // before findViews() is called 2888 if (mSubject == null || mBodyView == null || mTo == null || mCc == null || 2889 mAttachmentsView == null) { 2890 LogUtils.w(LOG_TAG, "null views in isBlank check"); 2891 return true; 2892 } 2893 return mSubject.getText().length() == 0 2894 && (mBodyView.getText().length() == 0 || getSignatureStartPosition(mSignature, 2895 mBodyView.getText().toString()) == 0) 2896 && mTo.length() == 0 2897 && mCc.length() == 0 && mBcc.length() == 0 2898 && mAttachmentsView.getAttachments().size() == 0; 2899 } 2900 2901 @VisibleForTesting 2902 protected int getSignatureStartPosition(String signature, String bodyText) { 2903 int startPos = -1; 2904 2905 if (TextUtils.isEmpty(signature) || TextUtils.isEmpty(bodyText)) { 2906 return startPos; 2907 } 2908 2909 int bodyLength = bodyText.length(); 2910 int signatureLength = signature.length(); 2911 String printableVersion = convertToPrintableSignature(signature); 2912 int printableLength = printableVersion.length(); 2913 2914 if (bodyLength >= printableLength 2915 && bodyText.substring(bodyLength - printableLength) 2916 .equals(printableVersion)) { 2917 startPos = bodyLength - printableLength; 2918 } else if (bodyLength >= signatureLength 2919 && bodyText.substring(bodyLength - signatureLength) 2920 .equals(signature)) { 2921 startPos = bodyLength - signatureLength; 2922 } 2923 return startPos; 2924 } 2925 2926 /** 2927 * Allows any changes made by the user to be ignored. Called when the user 2928 * decides to discard a draft. 2929 */ 2930 private void discardChanges() { 2931 mTextChanged = false; 2932 mAttachmentsChanged = false; 2933 mReplyFromChanged = false; 2934 } 2935 2936 /** 2937 * @param save True to save, false to send 2938 * @param showToast True to show a toast once the message is sent/saved 2939 */ 2940 protected void sendOrSaveWithSanityChecks(final boolean save, final boolean showToast, 2941 final boolean orientationChanged, final boolean autoSend) { 2942 if (mAccounts == null || mAccount == null) { 2943 Toast.makeText(this, R.string.send_failed, Toast.LENGTH_SHORT).show(); 2944 if (autoSend) { 2945 finish(); 2946 } 2947 return; 2948 } 2949 2950 final String[] to, cc, bcc; 2951 if (orientationChanged) { 2952 to = cc = bcc = new String[0]; 2953 } else { 2954 to = getToAddresses(); 2955 cc = getCcAddresses(); 2956 bcc = getBccAddresses(); 2957 } 2958 2959 final ArrayList<String> recipients = buildEmailAddressList(to); 2960 recipients.addAll(buildEmailAddressList(cc)); 2961 recipients.addAll(buildEmailAddressList(bcc)); 2962 2963 // Don't let the user send to nobody (but it's okay to save a message 2964 // with no recipients) 2965 if (!save && (to.length == 0 && cc.length == 0 && bcc.length == 0)) { 2966 showRecipientErrorDialog(getString(R.string.recipient_needed)); 2967 return; 2968 } 2969 2970 List<String> wrongEmails = new ArrayList<String>(); 2971 if (!save) { 2972 checkInvalidEmails(to, wrongEmails); 2973 checkInvalidEmails(cc, wrongEmails); 2974 checkInvalidEmails(bcc, wrongEmails); 2975 } 2976 2977 // Don't let the user send an email with invalid recipients 2978 if (wrongEmails.size() > 0) { 2979 String errorText = String.format(getString(R.string.invalid_recipient), 2980 wrongEmails.get(0)); 2981 showRecipientErrorDialog(errorText); 2982 return; 2983 } 2984 2985 if (!save) { 2986 if (autoSend) { 2987 // Skip all further checks during autosend. This flow is used by Android Wear 2988 // and Google Now. 2989 sendOrSave(save, showToast); 2990 return; 2991 } 2992 2993 // Show a warning before sending only if there are no attachments, body, or subject. 2994 if (mAttachmentsView.getAttachments().isEmpty() && showEmptyTextWarnings()) { 2995 boolean warnAboutEmptySubject = isSubjectEmpty(); 2996 boolean emptyBody = TextUtils.getTrimmedLength(mBodyView.getEditableText()) == 0; 2997 2998 // A warning about an empty body may not be warranted when 2999 // forwarding mails, since a common use case is to forward 3000 // quoted text and not append any more text. 3001 boolean warnAboutEmptyBody = emptyBody && (!mForward || isBodyEmpty()); 3002 3003 // When we bring up a dialog warning the user about a send, 3004 // assume that they accept sending the message. If they do not, 3005 // the dialog listener is required to enable sending again. 3006 if (warnAboutEmptySubject) { 3007 showSendConfirmDialog(R.string.confirm_send_message_with_no_subject, 3008 showToast, recipients); 3009 return; 3010 } 3011 3012 if (warnAboutEmptyBody) { 3013 showSendConfirmDialog(R.string.confirm_send_message_with_no_body, 3014 showToast, recipients); 3015 return; 3016 } 3017 } 3018 // Ask for confirmation to send. 3019 if (showSendConfirmation()) { 3020 showSendConfirmDialog(R.string.confirm_send_message, showToast, recipients); 3021 return; 3022 } 3023 } 3024 3025 performAdditionalSendOrSaveSanityChecks(save, showToast, recipients); 3026 } 3027 3028 /** 3029 * Returns a boolean indicating whether warnings should be shown for empty 3030 * subject and body fields 3031 * 3032 * @return True if a warning should be shown for empty text fields 3033 */ 3034 protected boolean showEmptyTextWarnings() { 3035 return mAttachmentsView.getAttachments().size() == 0; 3036 } 3037 3038 /** 3039 * Returns a boolean indicating whether the user should confirm each send 3040 * 3041 * @return True if a warning should be on each send 3042 */ 3043 protected boolean showSendConfirmation() { 3044 return mCachedSettings != null && mCachedSettings.confirmSend; 3045 } 3046 3047 public static class SendConfirmDialogFragment extends DialogFragment 3048 implements DialogInterface.OnClickListener { 3049 3050 private static final String MESSAGE_ID = "messageId"; 3051 private static final String SHOW_TOAST = "showToast"; 3052 private static final String RECIPIENTS = "recipients"; 3053 3054 private boolean mShowToast; 3055 3056 private ArrayList<String> mRecipients; 3057 3058 // Public no-args constructor needed for fragment re-instantiation 3059 public SendConfirmDialogFragment() {} 3060 3061 public static SendConfirmDialogFragment newInstance(final int messageId, 3062 final boolean showToast, final ArrayList<String> recipients) { 3063 final SendConfirmDialogFragment frag = new SendConfirmDialogFragment(); 3064 final Bundle args = new Bundle(3); 3065 args.putInt(MESSAGE_ID, messageId); 3066 args.putBoolean(SHOW_TOAST, showToast); 3067 args.putStringArrayList(RECIPIENTS, recipients); 3068 frag.setArguments(args); 3069 return frag; 3070 } 3071 3072 @Override 3073 public Dialog onCreateDialog(Bundle savedInstanceState) { 3074 final int messageId = getArguments().getInt(MESSAGE_ID); 3075 mShowToast = getArguments().getBoolean(SHOW_TOAST); 3076 mRecipients = getArguments().getStringArrayList(RECIPIENTS); 3077 3078 final int confirmTextId = (messageId == R.string.confirm_send_message) ? 3079 R.string.ok : R.string.send; 3080 3081 return new AlertDialog.Builder(getActivity()) 3082 .setMessage(messageId) 3083 .setPositiveButton(confirmTextId, this) 3084 .setNegativeButton(R.string.cancel, null) 3085 .create(); 3086 } 3087 3088 @Override 3089 public void onClick(DialogInterface dialog, int which) { 3090 if (which == DialogInterface.BUTTON_POSITIVE) { 3091 ((ComposeActivity) getActivity()).finishSendConfirmDialog(mShowToast, mRecipients); 3092 } 3093 } 3094 } 3095 3096 private void finishSendConfirmDialog( 3097 final boolean showToast, final ArrayList<String> recipients) { 3098 performAdditionalSendOrSaveSanityChecks(false /* save */, showToast, recipients); 3099 } 3100 3101 // The list of recipients are used by the additional sendOrSave checks. 3102 // However, the send confirm dialog may be shown before performing 3103 // the additional checks. As a result, we need to plumb the recipient 3104 // list through the send confirm dialog so that 3105 // performAdditionalSendOrSaveChecks can be performed properly. 3106 private void showSendConfirmDialog(final int messageId, 3107 final boolean showToast, final ArrayList<String> recipients) { 3108 final DialogFragment frag = SendConfirmDialogFragment.newInstance( 3109 messageId, showToast, recipients); 3110 frag.show(getFragmentManager(), "send confirm"); 3111 } 3112 3113 /** 3114 * Returns whether the ComposeArea believes there is any text in the body of 3115 * the composition. TODO: When ComposeArea controls the Body as well, add 3116 * that here. 3117 */ 3118 public boolean isBodyEmpty() { 3119 return !mQuotedTextView.isTextIncluded(); 3120 } 3121 3122 /** 3123 * Test to see if the subject is empty. 3124 * 3125 * @return boolean. 3126 */ 3127 // TODO: this will likely go away when composeArea.focus() is implemented 3128 // after all the widget control is moved over. 3129 public boolean isSubjectEmpty() { 3130 return TextUtils.getTrimmedLength(mSubject.getText()) == 0; 3131 } 3132 3133 @VisibleForTesting 3134 public String getSubject() { 3135 return mSubject.getText().toString(); 3136 } 3137 3138 private void sendOrSaveInternal(Context context, int requestId, 3139 ReplyFromAccount currReplyFromAccount, ReplyFromAccount originalReplyFromAccount, 3140 Message message, Message refMessage, CharSequence quotedText, 3141 SendOrSaveCallback callback, boolean save, int composeMode, ContentValues extraValues, 3142 Bundle optionalAttachmentFds) { 3143 final ContentValues values = new ContentValues(); 3144 3145 final String refMessageId = refMessage != null ? refMessage.uri.toString() : ""; 3146 3147 MessageModification.putToAddresses(values, message.getToAddresses()); 3148 MessageModification.putCcAddresses(values, message.getCcAddresses()); 3149 MessageModification.putBccAddresses(values, message.getBccAddresses()); 3150 MessageModification.putCustomFromAddress(values, message.getFrom()); 3151 3152 MessageModification.putSubject(values, message.subject); 3153 3154 // bodyHtml already have the composing spans removed. 3155 final String htmlBody = message.bodyHtml; 3156 final String textBody = message.bodyText; 3157 // fullbodyhtml/fullbodytext will contain the actual body plus the quoted text. 3158 String fullBodyHtml = htmlBody; 3159 String fullBodyText = textBody; 3160 String quotedString = null; 3161 final boolean hasQuotedText = !TextUtils.isEmpty(quotedText); 3162 if (hasQuotedText) { 3163 // The quoted text is HTML at this point. 3164 quotedString = quotedText.toString(); 3165 fullBodyHtml = htmlBody + quotedString; 3166 fullBodyText = textBody + Utils.convertHtmlToPlainText(quotedString); 3167 MessageModification.putForward(values, composeMode == ComposeActivity.FORWARD); 3168 MessageModification.putAppendRefMessageContent(values, true /* include quoted */); 3169 } 3170 3171 // Only take refMessage into account if either one of its html/text is not empty. 3172 int quotedTextPos = -1; 3173 if (refMessage != null && !(TextUtils.isEmpty(refMessage.bodyHtml) && 3174 TextUtils.isEmpty(refMessage.bodyText))) { 3175 // The code below might need to be revisited. The quoted text position is different 3176 // between text/html and text/plain parts and they should be stored seperately and 3177 // the right version should be used in the UI. text/html should have preference 3178 // if both exist. Issues like this made me file b/14256940 to make sure that we 3179 // properly handle the existing of both text/html and text/plain parts and to verify 3180 // that we are not making some assumptions that break if there is no text/html part. 3181 if (!TextUtils.isEmpty(refMessage.bodyHtml)) { 3182 MessageModification.putBodyHtml(values, fullBodyHtml); 3183 if (hasQuotedText) { 3184 quotedTextPos = htmlBody.length() + 3185 QuotedTextView.getQuotedTextOffset(quotedString); 3186 } 3187 } 3188 if (!TextUtils.isEmpty(refMessage.bodyText)) { 3189 MessageModification.putBody(values, fullBodyText); 3190 if (hasQuotedText && (quotedTextPos == -1)) { 3191 quotedTextPos = textBody.length(); 3192 } 3193 } 3194 if (quotedTextPos != -1) { 3195 // The quoted text pos is the text/html version first and the text/plan version 3196 // if there is no text/html part. The reason for this is because preference 3197 // is given to text/html in the compose window if it exists. In the future, we 3198 // should calculate the index for both since the user could choose to compose 3199 // explicitly in text/plain. 3200 MessageModification.putQuoteStartPos(values, quotedTextPos); 3201 } 3202 } else { 3203 MessageModification.putBodyHtml(values, fullBodyHtml); 3204 MessageModification.putBody(values, fullBodyText); 3205 } 3206 int draftType = getDraftType(composeMode); 3207 MessageModification.putDraftType(values, draftType); 3208 MessageModification.putAttachments(values, message.getAttachments()); 3209 if (!TextUtils.isEmpty(refMessageId)) { 3210 MessageModification.putRefMessageId(values, refMessageId); 3211 } 3212 if (extraValues != null) { 3213 values.putAll(extraValues); 3214 } 3215 3216 SendOrSaveMessage sendOrSaveMessage = new SendOrSaveMessage(context, requestId, 3217 values, refMessageId, message.getAttachments(), optionalAttachmentFds, save); 3218 runSendOrSaveProviderCalls(sendOrSaveMessage, callback, currReplyFromAccount, 3219 originalReplyFromAccount); 3220 3221 LogUtils.i(LOG_TAG, "[compose] SendOrSaveMessage [%s] posted (isSave: %s) - " + 3222 "bodyHtml length: %d, bodyText length: %d, quoted text pos: %d, attach count: %d", 3223 requestId, save, message.bodyHtml.length(), message.bodyText.length(), 3224 quotedTextPos, message.getAttachmentCount(true)); 3225 } 3226 3227 /** 3228 * Removes any composing spans from the specified string. This will create a new 3229 * SpannableString instance, as to not modify the behavior of the EditText view. 3230 */ 3231 private static SpannableString removeComposingSpans(Spanned body) { 3232 final SpannableString messageBody = new SpannableString(body); 3233 BaseInputConnection.removeComposingSpans(messageBody); 3234 3235 // Remove watcher spans while we're at it, so any off-UI thread manipulation of these 3236 // spans doesn't trigger unexpected side-effects. This copy is essentially 100% detached 3237 // from the EditText. 3238 // 3239 // (must remove SpanWatchers first to avoid triggering them as we remove other spans) 3240 removeSpansOfType(messageBody, SpanWatcher.class); 3241 removeSpansOfType(messageBody, TextWatcher.class); 3242 3243 return messageBody; 3244 } 3245 3246 private static void removeSpansOfType(SpannableString str, Class<?> cls) { 3247 for (Object span : str.getSpans(0, str.length(), cls)) { 3248 str.removeSpan(span); 3249 } 3250 } 3251 3252 private static int getDraftType(int mode) { 3253 int draftType = -1; 3254 switch (mode) { 3255 case ComposeActivity.COMPOSE: 3256 draftType = DraftType.COMPOSE; 3257 break; 3258 case ComposeActivity.REPLY: 3259 draftType = DraftType.REPLY; 3260 break; 3261 case ComposeActivity.REPLY_ALL: 3262 draftType = DraftType.REPLY_ALL; 3263 break; 3264 case ComposeActivity.FORWARD: 3265 draftType = DraftType.FORWARD; 3266 break; 3267 } 3268 return draftType; 3269 } 3270 3271 /** 3272 * Derived classes should override this step to perform additional checks before 3273 * send or save. The default implementation simply calls {@link #sendOrSave(boolean, boolean)}. 3274 */ 3275 protected void performAdditionalSendOrSaveSanityChecks( 3276 final boolean save, final boolean showToast, ArrayList<String> recipients) { 3277 sendOrSave(save, showToast); 3278 } 3279 3280 protected void sendOrSave(final boolean save, final boolean showToast) { 3281 // Check if user is a monkey. Monkeys can compose and hit send 3282 // button but are not allowed to send anything off the device. 3283 if (ActivityManager.isUserAMonkey()) { 3284 return; 3285 } 3286 3287 final SendOrSaveCallback callback = new SendOrSaveCallback() { 3288 @Override 3289 public void initializeSendOrSave() { 3290 final Intent i = new Intent(ComposeActivity.this, EmptyService.class); 3291 3292 // API 16+ allows for setClipData. For pre-16 we are going to open the fds 3293 // on the main thread. 3294 if (Utils.isRunningJellybeanOrLater()) { 3295 // Grant the READ permission for the attachments to the service so that 3296 // as long as the service stays alive we won't hit PermissionExceptions. 3297 final ClipDescription desc = new ClipDescription("attachment_uris", 3298 new String[]{ClipDescription.MIMETYPE_TEXT_URILIST}); 3299 ClipData clipData = null; 3300 for (Attachment a : mAttachmentsView.getAttachments()) { 3301 if (a != null && !Utils.isEmpty(a.contentUri)) { 3302 final ClipData.Item uriItem = new ClipData.Item(a.contentUri); 3303 if (clipData == null) { 3304 clipData = new ClipData(desc, uriItem); 3305 } else { 3306 clipData.addItem(uriItem); 3307 } 3308 } 3309 } 3310 i.setClipData(clipData); 3311 i.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); 3312 } 3313 3314 synchronized (PENDING_SEND_OR_SAVE_TASKS_NUM) { 3315 if (PENDING_SEND_OR_SAVE_TASKS_NUM.getAndAdd(1) == 0) { 3316 // Start service so we won't be killed if this app is 3317 // put in the background. 3318 startService(i); 3319 } 3320 } 3321 if (sTestSendOrSaveCallback != null) { 3322 sTestSendOrSaveCallback.initializeSendOrSave(); 3323 } 3324 } 3325 3326 @Override 3327 public void notifyMessageIdAllocated(SendOrSaveMessage sendOrSaveMessage, 3328 Message message) { 3329 synchronized (mDraftLock) { 3330 mDraftId = message.id; 3331 mDraft = message; 3332 if (sRequestMessageIdMap != null) { 3333 sRequestMessageIdMap.put(sendOrSaveMessage.mRequestId, mDraftId); 3334 } 3335 // Cache request message map, in case the process is killed 3336 saveRequestMap(); 3337 } 3338 if (sTestSendOrSaveCallback != null) { 3339 sTestSendOrSaveCallback.notifyMessageIdAllocated(sendOrSaveMessage, message); 3340 } 3341 } 3342 3343 @Override 3344 public long getMessageId() { 3345 synchronized (mDraftLock) { 3346 return mDraftId; 3347 } 3348 } 3349 3350 @Override 3351 public void sendOrSaveFinished(SendOrSaveMessage message, boolean success) { 3352 // Update the last sent from account. 3353 if (mAccount != null) { 3354 MailAppProvider.getInstance().setLastSentFromAccount(mAccount.uri.toString()); 3355 } 3356 if (success) { 3357 // Successfully sent or saved so reset change markers 3358 discardChanges(); 3359 } else { 3360 // A failure happened with saving/sending the draft 3361 // TODO(pwestbro): add a better string that should be used 3362 // when failing to send or save 3363 Toast.makeText(ComposeActivity.this, R.string.send_failed, Toast.LENGTH_SHORT) 3364 .show(); 3365 } 3366 3367 synchronized (PENDING_SEND_OR_SAVE_TASKS_NUM) { 3368 if (PENDING_SEND_OR_SAVE_TASKS_NUM.addAndGet(-1) == 0) { 3369 // Stop service so we can be killed. 3370 stopService(new Intent(ComposeActivity.this, EmptyService.class)); 3371 } 3372 } 3373 if (sTestSendOrSaveCallback != null) { 3374 sTestSendOrSaveCallback.sendOrSaveFinished(message, success); 3375 } 3376 } 3377 }; 3378 setAccount(mReplyFromAccount.account); 3379 3380 final Spanned body = removeComposingSpans(mBodyView.getText()); 3381 callback.initializeSendOrSave(); 3382 3383 // For pre-JB we need to open the fds on the main thread 3384 final Bundle attachmentFds; 3385 if (!Utils.isRunningJellybeanOrLater()) { 3386 attachmentFds = initializeAttachmentFds(this, mAttachmentsView.getAttachments()); 3387 } else { 3388 attachmentFds = null; 3389 } 3390 3391 // Generate a unique message id for this request 3392 mRequestId = sRandom.nextInt(); 3393 SEND_SAVE_TASK_HANDLER.post(new Runnable() { 3394 @Override 3395 public void run() { 3396 final Message msg = createMessage(mReplyFromAccount, mRefMessage, getMode(), body); 3397 sendOrSaveInternal(ComposeActivity.this, mRequestId, mReplyFromAccount, 3398 mDraftAccount, msg, mRefMessage, mQuotedTextView.getQuotedTextIfIncluded(), 3399 callback, save, mComposeMode, mExtraValues, attachmentFds); 3400 } 3401 }); 3402 3403 // Don't display the toast if the user is just changing the orientation, 3404 // but we still need to save the draft to the cursor because this is how we restore 3405 // the attachments when the configuration change completes. 3406 if (showToast && (getChangingConfigurations() & ActivityInfo.CONFIG_ORIENTATION) == 0) { 3407 Toast.makeText(this, save ? R.string.message_saved : R.string.sending_message, 3408 Toast.LENGTH_LONG).show(); 3409 } 3410 3411 // Need to update variables here because the send or save completes 3412 // asynchronously even though the toast shows right away. 3413 discardChanges(); 3414 updateSaveUi(); 3415 3416 // If we are sending, finish the activity 3417 if (!save) { 3418 finish(); 3419 } 3420 } 3421 3422 /** 3423 * Save the state of the request messageid map. This allows for the Gmail 3424 * process to be killed, but and still allow for ComposeActivity instances 3425 * to be recreated correctly. 3426 */ 3427 private void saveRequestMap() { 3428 // TODO: store the request map in user preferences. 3429 } 3430 3431 @SuppressLint("NewApi") 3432 private void doAttach(String type) { 3433 Intent i = new Intent(Intent.ACTION_GET_CONTENT); 3434 i.addFlags(Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET); 3435 i.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true); 3436 i.setType(type); 3437 mAddingAttachment = true; 3438 startActivityForResult(Intent.createChooser(i, getText(R.string.select_attachment_type)), 3439 RESULT_PICK_ATTACHMENT); 3440 } 3441 3442 private void showCcBccViews() { 3443 mCcBccView.show(true, true, true); 3444 if (mCcBccButton != null) { 3445 mCcBccButton.setVisibility(View.GONE); 3446 } 3447 } 3448 3449 private static String getActionString(int action) { 3450 final String msgType; 3451 switch (action) { 3452 case COMPOSE: 3453 msgType = "new_message"; 3454 break; 3455 case REPLY: 3456 msgType = "reply"; 3457 break; 3458 case REPLY_ALL: 3459 msgType = "reply_all"; 3460 break; 3461 case FORWARD: 3462 msgType = "forward"; 3463 break; 3464 default: 3465 msgType = "unknown"; 3466 break; 3467 } 3468 return msgType; 3469 } 3470 3471 private void logSendOrSave(boolean save) { 3472 if (!Analytics.isLoggable() || mAttachmentsView == null) { 3473 return; 3474 } 3475 3476 final String category = (save) ? "message_save" : "message_send"; 3477 final int attachmentCount = getAttachments().size(); 3478 final String msgType = getActionString(mComposeMode); 3479 final String label; 3480 final long value; 3481 if (mComposeMode == COMPOSE) { 3482 label = Integer.toString(attachmentCount); 3483 value = attachmentCount; 3484 } else { 3485 label = null; 3486 value = 0; 3487 } 3488 Analytics.getInstance().sendEvent(category, msgType, label, value); 3489 } 3490 3491 @Override 3492 public boolean onNavigationItemSelected(int position, long itemId) { 3493 int initialComposeMode = mComposeMode; 3494 if (position == ComposeActivity.REPLY) { 3495 mComposeMode = ComposeActivity.REPLY; 3496 } else if (position == ComposeActivity.REPLY_ALL) { 3497 mComposeMode = ComposeActivity.REPLY_ALL; 3498 } else if (position == ComposeActivity.FORWARD) { 3499 mComposeMode = ComposeActivity.FORWARD; 3500 } 3501 clearChangeListeners(); 3502 if (initialComposeMode != mComposeMode) { 3503 resetMessageForModeChange(); 3504 if (mRefMessage != null) { 3505 setFieldsFromRefMessage(mComposeMode); 3506 } 3507 boolean showCc = false; 3508 boolean showBcc = false; 3509 if (mDraft != null) { 3510 // Following desktop behavior, if the user has added a BCC 3511 // field to a draft, we show it regardless of compose mode. 3512 showBcc = !TextUtils.isEmpty(mDraft.getBcc()); 3513 // Use the draft to determine what to populate. 3514 // If the Bcc field is showing, show the Cc field whether it is populated or not. 3515 showCc = showBcc 3516 || (!TextUtils.isEmpty(mDraft.getCc()) && mComposeMode == REPLY_ALL); 3517 } 3518 if (mRefMessage != null) { 3519 showCc = !TextUtils.isEmpty(mCc.getText()); 3520 showBcc = !TextUtils.isEmpty(mBcc.getText()); 3521 } 3522 mCcBccView.show(false /* animate */, showCc, showBcc); 3523 } 3524 updateHideOrShowCcBcc(); 3525 initChangeListeners(); 3526 return true; 3527 } 3528 3529 @VisibleForTesting 3530 protected void resetMessageForModeChange() { 3531 // When switching between reply, reply all, forward, 3532 // follow the behavior of webview. 3533 // The contents of the following fields are cleared 3534 // so that they can be populated directly from the 3535 // ref message: 3536 // 1) Any recipient fields 3537 // 2) The subject 3538 mTo.setText(""); 3539 mCc.setText(""); 3540 mBcc.setText(""); 3541 // Any edits to the subject are replaced with the original subject. 3542 mSubject.setText(""); 3543 3544 // Any changes to the contents of the following fields are kept: 3545 // 1) Body 3546 // 2) Attachments 3547 // If the user made changes to attachments, keep their changes. 3548 if (!mAttachmentsChanged) { 3549 mAttachmentsView.deleteAllAttachments(); 3550 } 3551 } 3552 3553 private class ComposeModeAdapter extends ArrayAdapter<String> { 3554 3555 private Context mContext; 3556 private LayoutInflater mInflater; 3557 3558 public ComposeModeAdapter(Context context) { 3559 super(context, R.layout.compose_mode_item, R.id.mode, getResources() 3560 .getStringArray(R.array.compose_modes)); 3561 mContext = context; 3562 } 3563 3564 private LayoutInflater getInflater() { 3565 if (mInflater == null) { 3566 mInflater = LayoutInflater.from(mContext); 3567 } 3568 return mInflater; 3569 } 3570 3571 @Override 3572 public View getView(int position, View convertView, ViewGroup parent) { 3573 if (convertView == null) { 3574 convertView = getInflater().inflate(R.layout.compose_mode_display_item, null); 3575 } 3576 ((TextView) convertView.findViewById(R.id.mode)).setText(getItem(position)); 3577 return super.getView(position, convertView, parent); 3578 } 3579 } 3580 3581 @Override 3582 public void onRespondInline(String text) { 3583 appendToBody(text, false); 3584 mQuotedTextView.setUpperDividerVisible(false); 3585 mRespondedInline = true; 3586 if (!mBodyView.hasFocus()) { 3587 mBodyView.requestFocus(); 3588 } 3589 } 3590 3591 /** 3592 * Append text to the body of the message. If there is no existing body 3593 * text, just sets the body to text. 3594 * 3595 * @param text Text to append 3596 * @param withSignature True to append a signature. 3597 */ 3598 public void appendToBody(CharSequence text, boolean withSignature) { 3599 Editable bodyText = mBodyView.getEditableText(); 3600 if (bodyText != null && bodyText.length() > 0) { 3601 bodyText.append(text); 3602 } else { 3603 setBody(text, withSignature); 3604 } 3605 } 3606 3607 /** 3608 * Set the body of the message. 3609 * Please try to exclusively use this method instead of calling mBodyView.setText(..) directly. 3610 * 3611 * @param text text to set 3612 * @param withSignature True to append a signature. 3613 */ 3614 public void setBody(CharSequence text, boolean withSignature) { 3615 LogUtils.i(LOG_TAG, "Body populated, len: %d, sig: %b", text.length(), withSignature); 3616 mBodyView.setText(text); 3617 if (withSignature) { 3618 appendSignature(); 3619 } 3620 } 3621 3622 private void appendSignature() { 3623 final String newSignature = mCachedSettings != null ? mCachedSettings.signature : null; 3624 final int signaturePos = getSignatureStartPosition(mSignature, mBodyView.getText().toString()); 3625 if (!TextUtils.equals(newSignature, mSignature) || signaturePos < 0) { 3626 mSignature = newSignature; 3627 if (!TextUtils.isEmpty(mSignature)) { 3628 // Appending a signature does not count as changing text. 3629 mBodyView.removeTextChangedListener(this); 3630 mBodyView.append(convertToPrintableSignature(mSignature)); 3631 mBodyView.addTextChangedListener(this); 3632 } 3633 resetBodySelection(); 3634 } 3635 } 3636 3637 private String convertToPrintableSignature(String signature) { 3638 String signatureResource = getResources().getString(R.string.signature); 3639 if (signature == null) { 3640 signature = ""; 3641 } 3642 return String.format(signatureResource, signature); 3643 } 3644 3645 @Override 3646 public void onAccountChanged() { 3647 mReplyFromAccount = mFromSpinner.getCurrentAccount(); 3648 if (!mAccount.equals(mReplyFromAccount.account)) { 3649 // Clear a signature, if there was one. 3650 mBodyView.removeTextChangedListener(this); 3651 String oldSignature = mSignature; 3652 String bodyText = getBody().getText().toString(); 3653 if (!TextUtils.isEmpty(oldSignature)) { 3654 int pos = getSignatureStartPosition(oldSignature, bodyText); 3655 if (pos > -1) { 3656 setBody(bodyText.substring(0, pos), false); 3657 } 3658 } 3659 setAccount(mReplyFromAccount.account); 3660 mBodyView.addTextChangedListener(this); 3661 // TODO: handle discarding attachments when switching accounts. 3662 // Only enable save for this draft if there is any other content 3663 // in the message. 3664 if (!isBlank()) { 3665 enableSave(true); 3666 } 3667 mReplyFromChanged = true; 3668 initRecipients(); 3669 3670 invalidateOptionsMenu(); 3671 } 3672 } 3673 3674 public void enableSave(boolean enabled) { 3675 if (mSave != null) { 3676 mSave.setEnabled(enabled); 3677 } 3678 } 3679 3680 public static class DiscardConfirmDialogFragment extends DialogFragment { 3681 // Public no-args constructor needed for fragment re-instantiation 3682 public DiscardConfirmDialogFragment() {} 3683 3684 @Override 3685 public Dialog onCreateDialog(Bundle savedInstanceState) { 3686 return new AlertDialog.Builder(getActivity()) 3687 .setMessage(R.string.confirm_discard_text) 3688 .setPositiveButton(R.string.discard, 3689 new DialogInterface.OnClickListener() { 3690 @Override 3691 public void onClick(DialogInterface dialog, int which) { 3692 ((ComposeActivity)getActivity()).doDiscardWithoutConfirmation(); 3693 } 3694 }) 3695 .setNegativeButton(R.string.cancel, null) 3696 .create(); 3697 } 3698 } 3699 3700 private void doDiscard() { 3701 // Only need to ask for confirmation if the draft is in a dirty state. 3702 if (isDraftDirty()) { 3703 final DialogFragment frag = new DiscardConfirmDialogFragment(); 3704 frag.show(getFragmentManager(), "discard confirm"); 3705 } else { 3706 doDiscardWithoutConfirmation(); 3707 } 3708 } 3709 3710 /** 3711 * Effectively discard the current message. 3712 * 3713 * This method is either invoked from the menu or from the dialog 3714 * once the user has confirmed that they want to discard the message. 3715 */ 3716 private void doDiscardWithoutConfirmation() { 3717 synchronized (mDraftLock) { 3718 if (mDraftId != UIProvider.INVALID_MESSAGE_ID) { 3719 ContentValues values = new ContentValues(); 3720 values.put(BaseColumns._ID, mDraftId); 3721 if (!mAccount.expungeMessageUri.equals(Uri.EMPTY)) { 3722 getContentResolver().update(mAccount.expungeMessageUri, values, null, null); 3723 } else { 3724 getContentResolver().delete(mDraft.uri, null, null); 3725 } 3726 // This is not strictly necessary (since we should not try to 3727 // save the draft after calling this) but it ensures that if we 3728 // do save again for some reason we make a new draft rather than 3729 // trying to resave an expunged draft. 3730 mDraftId = UIProvider.INVALID_MESSAGE_ID; 3731 } 3732 } 3733 3734 // Display a toast to let the user know 3735 Toast.makeText(this, R.string.message_discarded, Toast.LENGTH_SHORT).show(); 3736 3737 // This prevents the draft from being saved in onPause(). 3738 discardChanges(); 3739 mPerformedSendOrDiscard = true; 3740 finish(); 3741 } 3742 3743 private void saveIfNeeded() { 3744 if (mAccount == null) { 3745 // We have not chosen an account yet so there's no way that we can save. This is ok, 3746 // though, since we are saving our state before AccountsActivity is activated. Thus, the 3747 // user has not interacted with us yet and there is no real state to save. 3748 return; 3749 } 3750 3751 if (isDraftDirty()) { 3752 doSave(!mAddingAttachment /* show toast */); 3753 } 3754 } 3755 3756 @Override 3757 public void onAttachmentDeleted() { 3758 mAttachmentsChanged = true; 3759 // If we are showing any attachments, make sure we have an upper 3760 // divider. 3761 mQuotedTextView.setUpperDividerVisible(mAttachmentsView.getAttachments().size() > 0); 3762 updateSaveUi(); 3763 } 3764 3765 @Override 3766 public void onAttachmentAdded() { 3767 mQuotedTextView.setUpperDividerVisible(mAttachmentsView.getAttachments().size() > 0); 3768 mAttachmentsView.focusLastAttachment(); 3769 } 3770 3771 /** 3772 * This is called any time one of our text fields changes. 3773 */ 3774 @Override 3775 public void afterTextChanged(Editable s) { 3776 mTextChanged = true; 3777 updateSaveUi(); 3778 } 3779 3780 @Override 3781 public void beforeTextChanged(CharSequence s, int start, int count, int after) { 3782 // Do nothing. 3783 } 3784 3785 @Override 3786 public void onTextChanged(CharSequence s, int start, int before, int count) { 3787 // Do nothing. 3788 } 3789 3790 3791 // There is a big difference between the text associated with an address changing 3792 // to add the display name or to format properly and a recipient being added or deleted. 3793 // Make sure we only notify of changes when a recipient has been added or deleted. 3794 private class RecipientTextWatcher implements TextWatcher { 3795 private HashMap<String, Integer> mContent = new HashMap<String, Integer>(); 3796 3797 private RecipientEditTextView mView; 3798 3799 private TextWatcher mListener; 3800 3801 public RecipientTextWatcher(RecipientEditTextView view, TextWatcher listener) { 3802 mView = view; 3803 mListener = listener; 3804 } 3805 3806 @Override 3807 public void afterTextChanged(Editable s) { 3808 if (hasChanged()) { 3809 mListener.afterTextChanged(s); 3810 } 3811 } 3812 3813 private boolean hasChanged() { 3814 final ArrayList<String> currRecips = buildEmailAddressList(getAddressesFromList(mView)); 3815 int totalCount = currRecips.size(); 3816 int totalPrevCount = 0; 3817 for (Entry<String, Integer> entry : mContent.entrySet()) { 3818 totalPrevCount += entry.getValue(); 3819 } 3820 if (totalCount != totalPrevCount) { 3821 return true; 3822 } 3823 3824 for (String recip : currRecips) { 3825 if (!mContent.containsKey(recip)) { 3826 return true; 3827 } else { 3828 int count = mContent.get(recip) - 1; 3829 if (count < 0) { 3830 return true; 3831 } else { 3832 mContent.put(recip, count); 3833 } 3834 } 3835 } 3836 return false; 3837 } 3838 3839 @Override 3840 public void beforeTextChanged(CharSequence s, int start, int count, int after) { 3841 final ArrayList<String> recips = buildEmailAddressList(getAddressesFromList(mView)); 3842 for (String recip : recips) { 3843 if (!mContent.containsKey(recip)) { 3844 mContent.put(recip, 1); 3845 } else { 3846 mContent.put(recip, (mContent.get(recip)) + 1); 3847 } 3848 } 3849 } 3850 3851 @Override 3852 public void onTextChanged(CharSequence s, int start, int before, int count) { 3853 // Do nothing. 3854 } 3855 } 3856 3857 /** 3858 * Returns a list of email addresses from the recipients. List only contains 3859 * email addresses strips additional info like the recipient's name. 3860 */ 3861 private static ArrayList<String> buildEmailAddressList(String[] recips) { 3862 // Tokenize them all and put them in the list. 3863 final ArrayList<String> recipAddresses = Lists.newArrayListWithCapacity(recips.length); 3864 for (int i = 0; i < recips.length; i++) { 3865 recipAddresses.add(Rfc822Tokenizer.tokenize(recips[i])[0].getAddress()); 3866 } 3867 return recipAddresses; 3868 } 3869 3870 public static void registerTestSendOrSaveCallback(SendOrSaveCallback testCallback) { 3871 if (sTestSendOrSaveCallback != null && testCallback != null) { 3872 throw new IllegalStateException("Attempting to register more than one test callback"); 3873 } 3874 sTestSendOrSaveCallback = testCallback; 3875 } 3876 3877 @VisibleForTesting 3878 protected ArrayList<Attachment> getAttachments() { 3879 return mAttachmentsView.getAttachments(); 3880 } 3881 3882 @Override 3883 public Loader<Cursor> onCreateLoader(int id, Bundle args) { 3884 switch (id) { 3885 case INIT_DRAFT_USING_REFERENCE_MESSAGE: 3886 return new CursorLoader(this, mRefMessageUri, UIProvider.MESSAGE_PROJECTION, null, 3887 null, null); 3888 case REFERENCE_MESSAGE_LOADER: 3889 return new CursorLoader(this, mRefMessageUri, UIProvider.MESSAGE_PROJECTION, null, 3890 null, null); 3891 case LOADER_ACCOUNT_CURSOR: 3892 return new CursorLoader(this, MailAppProvider.getAccountsUri(), 3893 UIProvider.ACCOUNTS_PROJECTION, null, null, null); 3894 } 3895 return null; 3896 } 3897 3898 @Override 3899 public void onLoadFinished(Loader<Cursor> loader, Cursor data) { 3900 int id = loader.getId(); 3901 switch (id) { 3902 case INIT_DRAFT_USING_REFERENCE_MESSAGE: 3903 if (data != null && data.moveToFirst()) { 3904 mRefMessage = new Message(data); 3905 Intent intent = getIntent(); 3906 initFromRefMessage(mComposeMode); 3907 finishSetup(mComposeMode, intent, null); 3908 if (mComposeMode != FORWARD) { 3909 String to = intent.getStringExtra(EXTRA_TO); 3910 if (!TextUtils.isEmpty(to)) { 3911 mRefMessage.setTo(null); 3912 mRefMessage.setFrom(null); 3913 clearChangeListeners(); 3914 mTo.append(to); 3915 initChangeListeners(); 3916 } 3917 } 3918 } else { 3919 finish(); 3920 } 3921 break; 3922 case REFERENCE_MESSAGE_LOADER: 3923 // Only populate mRefMessage and leave other fields untouched. 3924 if (data != null && data.moveToFirst()) { 3925 mRefMessage = new Message(data); 3926 } 3927 finishSetup(mComposeMode, getIntent(), mInnerSavedState); 3928 break; 3929 case LOADER_ACCOUNT_CURSOR: 3930 if (data != null && data.moveToFirst()) { 3931 // there are accounts now! 3932 Account account; 3933 final ArrayList<Account> accounts = new ArrayList<Account>(); 3934 final ArrayList<Account> initializedAccounts = new ArrayList<Account>(); 3935 do { 3936 account = Account.builder().buildFrom(data); 3937 if (account.isAccountReady()) { 3938 initializedAccounts.add(account); 3939 } 3940 accounts.add(account); 3941 } while (data.moveToNext()); 3942 if (initializedAccounts.size() > 0) { 3943 findViewById(R.id.wait).setVisibility(View.GONE); 3944 getLoaderManager().destroyLoader(LOADER_ACCOUNT_CURSOR); 3945 findViewById(R.id.compose).setVisibility(View.VISIBLE); 3946 mAccounts = initializedAccounts.toArray( 3947 new Account[initializedAccounts.size()]); 3948 3949 finishCreate(); 3950 invalidateOptionsMenu(); 3951 } else { 3952 // Show "waiting" 3953 account = accounts.size() > 0 ? accounts.get(0) : null; 3954 showWaitFragment(account); 3955 } 3956 } 3957 break; 3958 } 3959 } 3960 3961 private void showWaitFragment(Account account) { 3962 WaitFragment fragment = getWaitFragment(); 3963 if (fragment != null) { 3964 fragment.updateAccount(account); 3965 } else { 3966 findViewById(R.id.wait).setVisibility(View.VISIBLE); 3967 replaceFragment(WaitFragment.newInstance(account, false /* expectingMessages */), 3968 FragmentTransaction.TRANSIT_FRAGMENT_OPEN, TAG_WAIT); 3969 } 3970 } 3971 3972 private WaitFragment getWaitFragment() { 3973 return (WaitFragment) getFragmentManager().findFragmentByTag(TAG_WAIT); 3974 } 3975 3976 private int replaceFragment(Fragment fragment, int transition, String tag) { 3977 FragmentTransaction fragmentTransaction = getFragmentManager().beginTransaction(); 3978 fragmentTransaction.setTransition(transition); 3979 fragmentTransaction.replace(R.id.wait, fragment, tag); 3980 final int transactionId = fragmentTransaction.commitAllowingStateLoss(); 3981 return transactionId; 3982 } 3983 3984 @Override 3985 public void onLoaderReset(Loader<Cursor> arg0) { 3986 // Do nothing. 3987 } 3988 3989 /** 3990 * Background task to convert the message's html to Spanned. 3991 */ 3992 private class HtmlToSpannedTask extends AsyncTask<String, Void, Spanned> { 3993 3994 @Override 3995 protected Spanned doInBackground(String... input) { 3996 return HtmlUtils.htmlToSpan(input[0], mSpanConverterFactory); 3997 } 3998 3999 @Override 4000 protected void onPostExecute(Spanned spanned) { 4001 mBodyView.removeTextChangedListener(ComposeActivity.this); 4002 setBody(spanned, false); 4003 mTextChanged = false; 4004 mBodyView.addTextChangedListener(ComposeActivity.this); 4005 } 4006 } 4007 4008 @Override 4009 public void onSupportActionModeStarted(ActionMode mode) { 4010 super.onSupportActionModeStarted(mode); 4011 ViewUtils.setStatusBarColor(this, R.color.action_mode_statusbar_color); 4012 } 4013 4014 @Override 4015 public void onSupportActionModeFinished(ActionMode mode) { 4016 super.onSupportActionModeFinished(mode); 4017 ViewUtils.setStatusBarColor(this, R.color.primary_dark_color); 4018 } 4019 } 4020