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