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