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