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