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